Spaces:
Running
Running
Commit ·
da09932
1
Parent(s): cc678b9
Deploy CP-UX-12/13 landing: hero draft proof, privacy and Vietnamese-capability evidence
Browse files- docs/TEST_MATRIX.md +2 -0
- docs/decisions/0015-soap-note-measurement-gate.md +2 -1
- docs/decisions/0017-in-house-testing-scope.md +40 -0
- docs/product/ai-scribe.md +4 -3
- docs/product/carepath-suite.md +9 -4
- docs/product/live-interpreter.md +3 -0
- docs/qa-evidence/README.md +20 -0
- docs/stories/CP-UX-09-interpreter-public-block.md +7 -2
- docs/stories/CP-UX-10-scribe-story-landing.md +69 -0
- docs/stories/CP-UX-11-problem-led-scribe-onboarding.md +68 -0
- docs/stories/CP-UX-12-landing-professional-polish.md +70 -0
- docs/stories/CP-UX-13-privacy-capability-visibility.md +63 -0
- docs/stories/epics/E09-restructure/CP-RES-010-soap-measurement/design.md +9 -7
- docs/stories/epics/E09-restructure/CP-RES-010-soap-measurement/execplan.md +11 -9
- docs/stories/epics/E09-restructure/CP-RES-010-soap-measurement/overview.md +8 -7
- docs/stories/epics/E09-restructure/CP-RES-010-soap-measurement/validation.md +6 -4
- docs/stories/epics/E09-restructure/CP-RES-012-owner-deployment-clinical-handoff/design.md +16 -13
- docs/stories/epics/E09-restructure/CP-RES-012-owner-deployment-clinical-handoff/execplan.md +12 -10
- docs/stories/epics/E09-restructure/CP-RES-012-owner-deployment-clinical-handoff/overview.md +7 -8
- docs/stories/epics/E09-restructure/CP-RES-012-owner-deployment-clinical-handoff/validation.md +13 -5
- docs/ux-redesign-carepath.md +425 -2
- scribe/frontend/index.html +2 -2
- scribe/frontend/src/App.test.tsx +14 -46
- scribe/frontend/src/App.tsx +6 -12
- scribe/frontend/src/LandingPage.test.tsx +72 -68
- scribe/frontend/src/LandingPage.tsx +279 -208
- scribe/frontend/src/LeadForm.test.tsx +25 -0
- scribe/frontend/src/LeadForm.tsx +23 -19
- scribe/frontend/src/content/strings.ts +302 -25
- scribe/frontend/src/scribe/ScribeShowcase.test.tsx +12 -32
- scribe/frontend/src/scribe/ScribeShowcase.tsx +61 -80
- scribe/frontend/src/scribe/ScribeTool.test.tsx +11 -28
- scribe/frontend/src/scribe/ScribeTool.tsx +12 -41
- scribe/frontend/src/styles.css +398 -493
- scribe/frontend/tests/demo-flow.spec.ts +102 -159
- scribe/training/scripts/validate_soap_ratings.py +3 -5
- scribe/training/soap_eval/README.md +16 -12
- scribe/training/tests/test_soap_ratings.py +5 -5
docs/TEST_MATRIX.md
CHANGED
|
@@ -11,6 +11,8 @@ after the named evidence is actually run and recorded.
|
|
| 11 |
| 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) |
|
| 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) |
|
| 13 |
| 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) |
|
|
|
|
|
|
|
| 14 |
|
| 15 |
Proof labels mean the relevant layer has evidence for the baseline; they do not
|
| 16 |
claim every historical behavior was retested. Story packets define the exact
|
|
|
|
| 11 |
| 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) |
|
| 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) |
|
| 13 |
| 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) |
|
| 14 |
+
| CP-UX-10 | Public landing leads with Ghi chép bệnh án AI; Interpreter is visibly unavailable | yes | yes | yes | yes | implemented | lint; 46 unit; 5 deploy-env; build + NFC; 5 browser tests at 320–1440px; combined app 3 passed; Lighthouse 100/100/100 (2026-07-14) |
|
| 15 |
+
| CP-UX-11 | Vietnamese-only landing starts with the doctor's documentation burden and teaches the Scribe workflow before upload | yes | yes | yes | yes | implemented | lint; 45 unit; 5 deploy-env; build + NFC; 8 browser tests at 320–1440px with Axe/light/dark/reduced-motion; combined app 3 passed; Lighthouse 100/100/100 (2026-07-15) |
|
| 16 |
|
| 17 |
Proof labels mean the relevant layer has evidence for the baseline; they do not
|
| 18 |
claim every historical behavior was retested. Story packets define the exact
|
docs/decisions/0015-soap-note-measurement-gate.md
CHANGED
|
@@ -4,7 +4,8 @@ Date: 2026-07-13
|
|
| 4 |
|
| 5 |
## Status
|
| 6 |
|
| 7 |
-
|
|
|
|
| 8 |
|
| 9 |
## Context
|
| 10 |
|
|
|
|
| 4 |
|
| 5 |
## Status
|
| 6 |
|
| 7 |
+
Superseded by [0017 In-House Testing Scope](0017-in-house-testing-scope.md)
|
| 8 |
+
on 2026-07-13.
|
| 9 |
|
| 10 |
## Context
|
| 11 |
|
docs/decisions/0017-in-house-testing-scope.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 0017 In-House Testing Scope
|
| 2 |
+
|
| 3 |
+
Date: 2026-07-13
|
| 4 |
+
|
| 5 |
+
## Status
|
| 6 |
+
|
| 7 |
+
Accepted
|
| 8 |
+
|
| 9 |
+
## Context
|
| 10 |
+
|
| 11 |
+
The restructure and public deployment work are complete. The remaining
|
| 12 |
+
50-note clinician-rating target in decision 0015 would require an external
|
| 13 |
+
clinical-evidence program that is outside the current in-house testing scope.
|
| 14 |
+
|
| 15 |
+
## Decision
|
| 16 |
+
|
| 17 |
+
Remove the 50-note clinician-rating target from the restructure completion
|
| 18 |
+
criteria and from the SOAP review utility. In-house review is optional and
|
| 19 |
+
informational: it may inspect synthetic or already-approved de-identified
|
| 20 |
+
material with the existing score-only rubric, but it cannot authorize a SOAP
|
| 21 |
+
fine-tuning, provider, or safety-policy change.
|
| 22 |
+
|
| 23 |
+
No audio, transcript, patient identifier, or note text may enter this
|
| 24 |
+
repository. A future clinical-evidence program or model decision requires a
|
| 25 |
+
separate owner decision.
|
| 26 |
+
|
| 27 |
+
## Consequences
|
| 28 |
+
|
| 29 |
+
- The restructure can complete without external clinical-rating evidence.
|
| 30 |
+
- The rubric remains available for bounded in-house quality checks, without a
|
| 31 |
+
minimum sample-size goal or readiness result.
|
| 32 |
+
- CarePath makes no clinical-quality or model-readiness claim from in-house
|
| 33 |
+
testing.
|
| 34 |
+
|
| 35 |
+
## Alternatives Considered
|
| 36 |
+
|
| 37 |
+
1. Keep the 50-note gate: rejected because it conflicts with the current
|
| 38 |
+
owner-directed in-house scope.
|
| 39 |
+
2. Delete the rubric and validator: rejected because they remain useful for
|
| 40 |
+
privacy-bounded internal QA.
|
docs/product/ai-scribe.md
CHANGED
|
@@ -23,9 +23,10 @@ provides the workflow from `/ghi-chep-lam-sang/`.
|
|
| 23 |
- The committed text-only frozen baseline report is verified in CI. Before an
|
| 24 |
adapter can export, its frozen-fixture drug-name recall and dosage
|
| 25 |
number/unit preservation must not regress against the raw-ASR baseline.
|
| 26 |
-
-
|
| 27 |
-
|
| 28 |
-
|
|
|
|
| 29 |
|
| 30 |
## Proof
|
| 31 |
|
|
|
|
| 23 |
- The committed text-only frozen baseline report is verified in CI. Before an
|
| 24 |
adapter can export, its frozen-fixture drug-name recall and dosage
|
| 25 |
number/unit preservation must not regress against the raw-ASR baseline.
|
| 26 |
+
- In-house SOAP review may use only synthetic or already-approved
|
| 27 |
+
de-identified material. It is informational only and does not authorize a
|
| 28 |
+
SOAP fine-tuning, provider, or safety-policy change. The optional rating
|
| 29 |
+
schema stores no note text or audio here.
|
| 30 |
|
| 31 |
## Proof
|
| 32 |
|
docs/product/carepath-suite.md
CHANGED
|
@@ -7,12 +7,17 @@ has two distinct workflows; the user must never need to know the English terms
|
|
| 7 |
| Workflow | Purpose | Primary action |
|
| 8 |
| --- | --- | --- |
|
| 9 |
| Ghi chép bệnh án AI | AI hears a consultation and prepares structured clinical notes. | Bắt đầu ghi chép |
|
| 10 |
-
| 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. |
|
| 11 |
|
| 12 |
-
The
|
| 13 |
-
Every clinical screen must say what
|
| 14 |
-
what to do next, and its relevant limit
|
|
|
|
| 15 |
|
| 16 |
CarePath remains clinician-controlled: Scribe drafts require review, and the
|
| 17 |
Interpreter translates only. The detailed module contracts are
|
| 18 |
`ai-scribe.md` and `live-interpreter.md`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
| Workflow | Purpose | Primary action |
|
| 8 |
| --- | --- | --- |
|
| 9 |
| Ghi chép bệnh án AI | AI hears a consultation and prepares structured clinical notes. | Bắt đầu ghi chép |
|
| 10 |
+
| 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. | Đang phát triển — chưa thể truy cập |
|
| 11 |
|
| 12 |
+
The current public landing leads with the Scribe job to be done and keeps the
|
| 13 |
+
Interpreter visible as unavailable. Every clinical screen must say what
|
| 14 |
+
workflow is active, what it helps with, what to do next, and its relevant limit
|
| 15 |
+
or risk.
|
| 16 |
|
| 17 |
CarePath remains clinician-controlled: Scribe drafts require review, and the
|
| 18 |
Interpreter translates only. The detailed module contracts are
|
| 19 |
`ai-scribe.md` and `live-interpreter.md`.
|
| 20 |
+
|
| 21 |
+
The public web experience currently exposes Scribe as its only active product
|
| 22 |
+
journey. Interpreter remains visible as a non-interactive development status;
|
| 23 |
+
its browser routes stay closed until a separate release decision reopens them.
|
docs/product/live-interpreter.md
CHANGED
|
@@ -18,6 +18,9 @@ Scribe is the only public product. `/phien-dich-y-khoa/*` and `/console/*`
|
|
| 18 |
return 404; this does not change the Interpreter API, WebSocket, or safety
|
| 19 |
invariants used for internal development.
|
| 20 |
|
|
|
|
|
|
|
|
|
|
| 21 |
## Safety Invariants
|
| 22 |
|
| 23 |
- No microphone capture before recorded consent.
|
|
|
|
| 18 |
return 404; this does not change the Interpreter API, WebSocket, or safety
|
| 19 |
invariants used for internal development.
|
| 20 |
|
| 21 |
+
The Interpreter is on hold for public release. Restrict changes to internal
|
| 22 |
+
bug fixes and safety maintenance until the owner reopens its release work.
|
| 23 |
+
|
| 24 |
## Safety Invariants
|
| 25 |
|
| 26 |
- No microphone capture before recorded consent.
|
docs/qa-evidence/README.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Visual QA Evidence
|
| 2 |
+
|
| 3 |
+
This directory contains versioned visual proof for CarePath’s public site and
|
| 4 |
+
interpreter experience. These images are documentation, not application assets.
|
| 5 |
+
|
| 6 |
+
## Coverage
|
| 7 |
+
|
| 8 |
+
- `scribe/frontend/` — landing and clinical-note workflow at desktop and mobile viewports.
|
| 9 |
+
- `interpreter/` — consent, quiz, device check, first-run, blocked-risk, and
|
| 10 |
+
forced-colors states across desktop and mobile viewports.
|
| 11 |
+
|
| 12 |
+
## Retention
|
| 13 |
+
|
| 14 |
+
- Keep the complete evidence set in Git; it supports visual, accessibility, and
|
| 15 |
+
responsive-regression review.
|
| 16 |
+
- Refresh a scenario only after its corresponding browser or visual QA changes.
|
| 17 |
+
- Name new captures by surface, state, and viewport, matching the existing
|
| 18 |
+
convention (for example, `consent-checked-390x844.png`).
|
| 19 |
+
- Do not import images from this directory into runtime code or production
|
| 20 |
+
bundles.
|
docs/stories/CP-UX-09-interpreter-public-block.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
|
| 3 |
## Status
|
| 4 |
|
| 5 |
-
|
| 6 |
|
| 7 |
## Lane
|
| 8 |
|
|
@@ -47,4 +47,9 @@ non-runtime binary QA assets.
|
|
| 47 |
|
| 48 |
## Evidence
|
| 49 |
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
## Status
|
| 4 |
|
| 5 |
+
implemented
|
| 6 |
|
| 7 |
## Lane
|
| 8 |
|
|
|
|
| 47 |
|
| 48 |
## Evidence
|
| 49 |
|
| 50 |
+
2026-07-13: combined-app route proof passed (3 tests); Scribe frontend unit
|
| 51 |
+
(45), build, and browser (7) checks passed; Ruff passed. Space commit
|
| 52 |
+
`cc678b9` is Running. Live HTTP checks returned 404 for
|
| 53 |
+
`/phien-dich-y-khoa/`, its asset path, and `/console/`; `/`,
|
| 54 |
+
`/ghi-chep-lam-sang/`, `/api/v1/health`, `/api/health`, and the Vercel public
|
| 55 |
+
site each returned 200.
|
docs/stories/CP-UX-10-scribe-story-landing.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CP-UX-10 Scribe-Led Public Landing Story
|
| 2 |
+
|
| 3 |
+
## Status
|
| 4 |
+
|
| 5 |
+
implemented
|
| 6 |
+
|
| 7 |
+
## Lane
|
| 8 |
+
|
| 9 |
+
normal
|
| 10 |
+
|
| 11 |
+
## Product Contract
|
| 12 |
+
|
| 13 |
+
The public landing tells a Vietnamese-first story about reducing
|
| 14 |
+
post-consultation documentation work. Ghi chép bệnh án AI is the only active
|
| 15 |
+
journey. Phiên dịch khám bệnh trực tiếp is visibly in development and has no
|
| 16 |
+
public action.
|
| 17 |
+
|
| 18 |
+
## Relevant Product Docs
|
| 19 |
+
|
| 20 |
+
- `docs/product/carepath-suite.md`
|
| 21 |
+
- `docs/product/ai-scribe.md`
|
| 22 |
+
- `docs/product/live-interpreter.md`
|
| 23 |
+
- `docs/ux-redesign-carepath.md`
|
| 24 |
+
|
| 25 |
+
## Acceptance Criteria
|
| 26 |
+
|
| 27 |
+
- The Scribe hero, workflow, review limit, and canonical CTA are clear in
|
| 28 |
+
Vietnamese without requiring knowledge of the term Scribe.
|
| 29 |
+
- Interpreter status is prominent, non-interactive, and does not weaken its
|
| 30 |
+
existing 404 public-route contract.
|
| 31 |
+
- The Scribe pilot form cannot select or submit Interpreter interest.
|
| 32 |
+
- Mobile, keyboard, reduced-motion, diacritics, Axe, and Lighthouse gates pass.
|
| 33 |
+
- No backend, API, audio, consent, WebSocket, TTS, or risk behavior changes.
|
| 34 |
+
|
| 35 |
+
## Design Notes
|
| 36 |
+
|
| 37 |
+
- Editorial split hero with a 72rem title width and interface-led product visual.
|
| 38 |
+
- Gapless 12-by-2 bento: `7×2 + 5×1 + 5×1 = 24` cells.
|
| 39 |
+
- Geist remains self-hosted. GSAP supplies desktop pinning and image scale/fade;
|
| 40 |
+
reduced-motion and mobile layouts remain static.
|
| 41 |
+
- No photography, fake testimonials, unsupported statistics, or remote assets.
|
| 42 |
+
|
| 43 |
+
## Validation
|
| 44 |
+
|
| 45 |
+
| Layer | Expected proof |
|
| 46 |
+
| --- | --- |
|
| 47 |
+
| Unit | Landing copy, non-interactive status, Scribe CTA, Scribe-only form |
|
| 48 |
+
| E2E | Responsive layout, keyboard, Axe, reduced motion, Scribe route |
|
| 49 |
+
| Platform | Build, diacritics, Lighthouse, Vercel production and live routes |
|
| 50 |
+
| Regression | Combined-app Scribe 200 and Interpreter 404 route tests |
|
| 51 |
+
|
| 52 |
+
## Harness Delta
|
| 53 |
+
|
| 54 |
+
Add this story to the local Harness, record the real proof results, and finish
|
| 55 |
+
with a trace. No new Harness capability is required.
|
| 56 |
+
|
| 57 |
+
## Evidence
|
| 58 |
+
|
| 59 |
+
- Frontend lint passed; Vitest passed 46 tests across 10 files.
|
| 60 |
+
- Deploy-environment validation passed 5 tests; the production build and
|
| 61 |
+
Vietnamese NFC/diacritics gate passed.
|
| 62 |
+
- Playwright passed 5 flows across 320, 360, 390, 768, and 1440px, including
|
| 63 |
+
hero line limits, overflow, keyboard access, Axe, reduced motion, and the
|
| 64 |
+
canonical Scribe route.
|
| 65 |
+
- The combined FastAPI route proof passed 3 tests under the repository Python
|
| 66 |
+
3.12 environment, including Scribe availability and Interpreter 404s.
|
| 67 |
+
- Lighthouse scored 100 performance, 100 accessibility, and 100 best practices.
|
| 68 |
+
- No interactive browser was connected for separate desktop/phone visual
|
| 69 |
+
capture, so that manual visual gate remains explicitly unverified.
|
docs/stories/CP-UX-11-problem-led-scribe-onboarding.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CP-UX-11 Problem-Led Scribe Onboarding
|
| 2 |
+
|
| 3 |
+
## Status
|
| 4 |
+
|
| 5 |
+
implemented
|
| 6 |
+
|
| 7 |
+
## Lane
|
| 8 |
+
|
| 9 |
+
normal
|
| 10 |
+
|
| 11 |
+
## Product contract
|
| 12 |
+
|
| 13 |
+
The public site is Vietnamese-only and starts with the documentation burden a
|
| 14 |
+
doctor recognizes. It teaches the available Ghi chép bệnh án AI workflow with a
|
| 15 |
+
small self-calculator and a safe guided sample before asking the visitor to
|
| 16 |
+
upload audio or contact the pilot team.
|
| 17 |
+
|
| 18 |
+
Phiên dịch khám bệnh trực tiếp remains a non-interactive development status.
|
| 19 |
+
|
| 20 |
+
## Scope
|
| 21 |
+
|
| 22 |
+
- Compact the public landing page and remove the VI/EN control.
|
| 23 |
+
- Add a four-question, browser-only documentation-time calculator.
|
| 24 |
+
- Reuse the existing Scribe showcase for a guided sample that never invents an
|
| 25 |
+
assessment or plan.
|
| 26 |
+
- Put upload instructions and the upload control on the same Scribe tool screen.
|
| 27 |
+
- Keep the pilot form Scribe-only and secondary to the working product action.
|
| 28 |
+
|
| 29 |
+
No backend, API, audio processing, consent, retention, Interpreter, WebSocket,
|
| 30 |
+
TTS, or clinical safety behavior changes.
|
| 31 |
+
|
| 32 |
+
## Acceptance criteria
|
| 33 |
+
|
| 34 |
+
- The hero says `Dành thời gian cho người bệnh, không phải cho việc gõ bệnh án.`
|
| 35 |
+
and its first action opens the guided sample.
|
| 36 |
+
- The site contains no public English-language toggle or English-first product
|
| 37 |
+
label.
|
| 38 |
+
- A visitor can calculate current daily documentation time without a CarePath
|
| 39 |
+
savings claim or network request.
|
| 40 |
+
- The sample explains conversation, clarified transcript, structured draft, and
|
| 41 |
+
clinician review; Assessment and Plan remain for the doctor to complete.
|
| 42 |
+
- The real Scribe route shows instructions and the audio upload control together
|
| 43 |
+
without an extra continue gate.
|
| 44 |
+
- Interpreter status remains visible, non-interactive, and its public routes
|
| 45 |
+
remain 404.
|
| 46 |
+
- Mobile, keyboard, reduced-motion, diacritics, Axe, build, Lighthouse, and
|
| 47 |
+
combined-app checks pass.
|
| 48 |
+
|
| 49 |
+
## Validation
|
| 50 |
+
|
| 51 |
+
```powershell
|
| 52 |
+
cd scribe\frontend
|
| 53 |
+
npm.cmd run lint
|
| 54 |
+
npm.cmd test
|
| 55 |
+
npm.cmd run test:deploy-env
|
| 56 |
+
npm.cmd run build
|
| 57 |
+
npm.cmd run e2e
|
| 58 |
+
npm.cmd run lighthouse
|
| 59 |
+
cd ..\..
|
| 60 |
+
python -m pytest scribe/tests/test_combined_app.py
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
## Proof
|
| 64 |
+
|
| 65 |
+
- unit: 45 passed
|
| 66 |
+
- integration: combined app 3 passed
|
| 67 |
+
- e2e: 8 passed at 320, 360, 390, 768, and 1440px; phone and desktop screenshots captured
|
| 68 |
+
- platform: lint, deploy-env 5 passed, build/NFC passed, Lighthouse 100/100/100
|
docs/stories/CP-UX-12-landing-professional-polish.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CP-UX-12 Professional Landing Polish
|
| 2 |
+
|
| 3 |
+
## Status
|
| 4 |
+
|
| 5 |
+
implemented
|
| 6 |
+
|
| 7 |
+
## Lane
|
| 8 |
+
|
| 9 |
+
normal
|
| 10 |
+
|
| 11 |
+
## Product contract
|
| 12 |
+
|
| 13 |
+
The public landing keeps the CP-UX-11 problem-led structure and copy contract,
|
| 14 |
+
and presents it with professional polish: the hero shows a conversation-to-draft
|
| 15 |
+
proof of the product, the Interpreter development status is visible but quiet,
|
| 16 |
+
the page has one dark closing moment instead of three, and the footer stays
|
| 17 |
+
readable at phone widths.
|
| 18 |
+
|
| 19 |
+
## Scope
|
| 20 |
+
|
| 21 |
+
- Replace the hero daily-flow table with a static conversation-to-draft proof
|
| 22 |
+
panel reusing the sanitized sample; Subjective and Objective rows only.
|
| 23 |
+
- Keep both hero actions on one row at desktop widths.
|
| 24 |
+
- Restyle the Interpreter status as a bordered notice; same element, same
|
| 25 |
+
strings, still non-interactive.
|
| 26 |
+
- One shared section-padding rhythm; trust section light; start section is the
|
| 27 |
+
only dark section before the footer.
|
| 28 |
+
- Root-cause the footer responsive bug (late `.site-footer` override) and stack
|
| 29 |
+
the footer at phone widths.
|
| 30 |
+
|
| 31 |
+
No route, API, form, showcase-interaction, Scribe-tool, consent, audio,
|
| 32 |
+
Interpreter, or safety behavior changes. No new dependency.
|
| 33 |
+
|
| 34 |
+
## Acceptance criteria
|
| 35 |
+
|
| 36 |
+
- Hero shows the proof panel labeled as a cleaned sample; `Chờ bác sĩ nhập
|
| 37 |
+
hoặc xác nhận.` still never renders before the doctor opens the draft step.
|
| 38 |
+
- Both hero actions render on one row at 1024 px and wider.
|
| 39 |
+
- Interpreter status keeps exact strings, zero links or buttons, and loses the
|
| 40 |
+
dark band treatment.
|
| 41 |
+
- Exactly one dark section between the evidence grid and the footer.
|
| 42 |
+
- Footer stacks into readable rows at 320, 360, and 390 px.
|
| 43 |
+
- Hero under 800 px at 1440×900; H1 line limits hold; no horizontal overflow
|
| 44 |
+
from 320 to 1440 px.
|
| 45 |
+
- Lint, unit, deploy-env, build with diacritics gate, Playwright, Lighthouse,
|
| 46 |
+
and combined-app checks pass.
|
| 47 |
+
|
| 48 |
+
## Validation
|
| 49 |
+
|
| 50 |
+
```powershell
|
| 51 |
+
cd scribe\frontend
|
| 52 |
+
npm.cmd run lint
|
| 53 |
+
npm.cmd test
|
| 54 |
+
npm.cmd run test:deploy-env
|
| 55 |
+
npm.cmd run build
|
| 56 |
+
npm.cmd run e2e
|
| 57 |
+
npm.cmd run lighthouse
|
| 58 |
+
cd ..\..
|
| 59 |
+
python -m pytest scribe/tests/test_combined_app.py
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
## Evidence
|
| 63 |
+
|
| 64 |
+
2026-07-15: lint passed; 46 unit tests passed; 5 deploy-env tests passed;
|
| 65 |
+
production build and NFC diacritics gate passed; 8 Playwright tests passed
|
| 66 |
+
(keyboard, responsive 320–1440, axe light and dark, touch menu, reduced
|
| 67 |
+
motion); combined-app 3 passed; Lighthouse 100/100/100 with CLS 0.
|
| 68 |
+
Desktop and mobile screenshots verified: hero proof panel above the fold,
|
| 69 |
+
single-row hero actions, quiet Interpreter notice, one dark closing section,
|
| 70 |
+
stacked phone footer.
|
docs/stories/CP-UX-13-privacy-capability-visibility.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CP-UX-13 Privacy and Vietnamese-Capability Visibility
|
| 2 |
+
|
| 3 |
+
## Status
|
| 4 |
+
|
| 5 |
+
implemented
|
| 6 |
+
|
| 7 |
+
## Lane
|
| 8 |
+
|
| 9 |
+
normal
|
| 10 |
+
|
| 11 |
+
Privacy hard-gate note: this story restates verified, already-shipped privacy
|
| 12 |
+
claims more prominently. It changes no retention, consent, microphone, or API
|
| 13 |
+
behavior. Every claim is grounded in `scribe/carepath/main.py` (request-scoped
|
| 14 |
+
`TemporaryDirectory` processing) and `docs/product/ai-scribe.md` (temporary
|
| 15 |
+
working storage only; consultation audio is never training data).
|
| 16 |
+
|
| 17 |
+
## Product contract
|
| 18 |
+
|
| 19 |
+
The landing makes its trust facts visible instead of buried: the hero states
|
| 20 |
+
that audio is not retained, and the trust section shows two evidence panels,
|
| 21 |
+
Vietnamese capability and audio privacy, under one heading that keeps the
|
| 22 |
+
clinician-review requirement and the no-advice boundary. Review is framed as
|
| 23 |
+
fast and targeted because corrections and gaps are marked, never as optional.
|
| 24 |
+
|
| 25 |
+
## Scope
|
| 26 |
+
|
| 27 |
+
- Extend the hero small print with the audio-retention fact.
|
| 28 |
+
- Replace the four-bullet trust list with capability and privacy panels,
|
| 29 |
+
absorbing all previous safety statements.
|
| 30 |
+
- No accuracy figure, no savings promise, no suggestion to skip review.
|
| 31 |
+
- No route, API, backend, form, or Scribe-tool changes. No new dependency.
|
| 32 |
+
|
| 33 |
+
## Acceptance criteria
|
| 34 |
+
|
| 35 |
+
- Audio-retention fact visible in the hero without scrolling.
|
| 36 |
+
- Trust section shows both panels with all six verified claims plus the
|
| 37 |
+
review requirement and no-advice boundary.
|
| 38 |
+
- No prior safety statement is lost from the page.
|
| 39 |
+
- CP-UX-12 gates still pass: lint, unit, deploy-env, build with diacritics,
|
| 40 |
+
Playwright with axe light and dark, Lighthouse, combined-app tests.
|
| 41 |
+
|
| 42 |
+
## Validation
|
| 43 |
+
|
| 44 |
+
```powershell
|
| 45 |
+
cd scribe\frontend
|
| 46 |
+
npm.cmd run lint
|
| 47 |
+
npm.cmd test
|
| 48 |
+
npm.cmd run test:deploy-env
|
| 49 |
+
npm.cmd run build
|
| 50 |
+
npm.cmd run e2e
|
| 51 |
+
npm.cmd run lighthouse
|
| 52 |
+
cd ..\..
|
| 53 |
+
python -m pytest scribe/tests/test_combined_app.py
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
## Evidence
|
| 57 |
+
|
| 58 |
+
2026-07-15: lint passed; 47 unit tests passed; 5 deploy-env tests passed;
|
| 59 |
+
production build and NFC diacritics gate passed; 8 Playwright tests passed
|
| 60 |
+
(axe light and dark, responsive 320–1440, reduced motion); combined-app 3
|
| 61 |
+
passed; Lighthouse 100/100/100 with CLS 0. Screenshots verified the two
|
| 62 |
+
trust panels, extended hero small print, and intact review and no-advice
|
| 63 |
+
copy at desktop and mobile widths.
|
docs/stories/epics/E09-restructure/CP-RES-010-soap-measurement/design.md
CHANGED
|
@@ -8,10 +8,10 @@ contains no clinical source material.
|
|
| 8 |
|
| 9 |
## Application Flow
|
| 10 |
|
| 11 |
-
|
| 12 |
-
environment and runs `validate_soap_ratings.py` locally. The summary
|
| 13 |
-
the number of unique notes, mean scores, serious hallucinations,
|
| 14 |
-
dispositions
|
| 15 |
|
| 16 |
## Data Model
|
| 17 |
|
|
@@ -25,8 +25,10 @@ The CLI prints a JSON summary only; it never writes, logs, or uploads ratings.
|
|
| 25 |
|
| 26 |
## Alternatives Considered
|
| 27 |
|
| 28 |
-
1. Use synthetic
|
|
|
|
| 29 |
2. Store full notes and audio beside the code.
|
| 30 |
-
3.
|
| 31 |
|
| 32 |
-
|
|
|
|
|
|
| 8 |
|
| 9 |
## Application Flow
|
| 10 |
|
| 11 |
+
An in-house reviewer exports anonymous score-only rows from an approved local
|
| 12 |
+
review environment and runs `validate_soap_ratings.py` locally. The summary
|
| 13 |
+
reports the number of unique notes, mean scores, serious hallucinations, and
|
| 14 |
+
unsafe dispositions. It has no readiness result or decision threshold.
|
| 15 |
|
| 16 |
## Data Model
|
| 17 |
|
|
|
|
| 25 |
|
| 26 |
## Alternatives Considered
|
| 27 |
|
| 28 |
+
1. Use synthetic or already-approved de-identified material for in-house
|
| 29 |
+
testing.
|
| 30 |
2. Store full notes and audio beside the code.
|
| 31 |
+
3. Change the model based on in-house measurements alone.
|
| 32 |
|
| 33 |
+
The first option is accepted for bounded in-house testing. The latter options
|
| 34 |
+
are rejected because they would create privacy or safety risk.
|
docs/stories/epics/E09-restructure/CP-RES-010-soap-measurement/execplan.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
|
| 3 |
## Goal
|
| 4 |
|
| 5 |
-
|
| 6 |
|
| 7 |
## Scope
|
| 8 |
|
|
@@ -10,32 +10,34 @@ In scope:
|
|
| 10 |
|
| 11 |
- Rubric and blank score-only schema.
|
| 12 |
- Standard-library validation and summary.
|
| 13 |
-
- Product contract that
|
| 14 |
|
| 15 |
Out of scope:
|
| 16 |
|
| 17 |
-
- Clinical-data collection
|
| 18 |
|
| 19 |
## Risk Classification
|
| 20 |
|
| 21 |
Risk flags:
|
| 22 |
|
| 23 |
-
- Clinical data and model-quality decision policy.
|
| 24 |
|
| 25 |
Hard gates:
|
| 26 |
|
| 27 |
- No source material enters the repository.
|
| 28 |
-
-
|
| 29 |
-
|
|
|
|
| 30 |
|
| 31 |
## Work Phases
|
| 32 |
|
| 33 |
1. Define the minimum score-only schema and rubric.
|
| 34 |
-
2. Validate bounded scores and
|
| 35 |
-
3. Document
|
| 36 |
4. Prove the validator with synthetic in-memory test rows only.
|
| 37 |
|
| 38 |
## Stop Conditions
|
| 39 |
|
| 40 |
Pause for owner direction if any request would add patient data, audio,
|
| 41 |
-
transcripts, or note text to this repository
|
|
|
|
|
|
| 2 |
|
| 3 |
## Goal
|
| 4 |
|
| 5 |
+
Provide an optional, privacy-bounded in-house SOAP quality review tool.
|
| 6 |
|
| 7 |
## Scope
|
| 8 |
|
|
|
|
| 10 |
|
| 11 |
- Rubric and blank score-only schema.
|
| 12 |
- Standard-library validation and summary.
|
| 13 |
+
- Product contract that keeps in-house results informational.
|
| 14 |
|
| 15 |
Out of scope:
|
| 16 |
|
| 17 |
+
- Clinical-data collection or storage in this repository, or model changes.
|
| 18 |
|
| 19 |
## Risk Classification
|
| 20 |
|
| 21 |
Risk flags:
|
| 22 |
|
| 23 |
+
- Clinical data, privacy, and model-quality decision policy.
|
| 24 |
|
| 25 |
Hard gates:
|
| 26 |
|
| 27 |
- No source material enters the repository.
|
| 28 |
+
- The summary has no minimum sample-size or model-readiness result.
|
| 29 |
+
- No model, provider, or safety-policy change follows from in-house results
|
| 30 |
+
without a separate owner decision.
|
| 31 |
|
| 32 |
## Work Phases
|
| 33 |
|
| 34 |
1. Define the minimum score-only schema and rubric.
|
| 35 |
+
2. Validate bounded scores and summarize anonymous review metadata.
|
| 36 |
+
3. Document the in-house scope and stop conditions.
|
| 37 |
4. Prove the validator with synthetic in-memory test rows only.
|
| 38 |
|
| 39 |
## Stop Conditions
|
| 40 |
|
| 41 |
Pause for owner direction if any request would add patient data, audio,
|
| 42 |
+
transcripts, or note text to this repository, or use in-house results to
|
| 43 |
+
change a model, provider, or safety policy.
|
docs/stories/epics/E09-restructure/CP-RES-010-soap-measurement/overview.md
CHANGED
|
@@ -2,18 +2,18 @@
|
|
| 2 |
|
| 3 |
## Current Behavior
|
| 4 |
|
| 5 |
-
The Scribe produces a clinician-reviewed SOAP draft
|
| 6 |
-
|
| 7 |
|
| 8 |
## Target Behavior
|
| 9 |
|
| 10 |
-
The repository provides
|
| 11 |
-
|
| 12 |
-
|
| 13 |
|
| 14 |
## Affected Users
|
| 15 |
|
| 16 |
-
-
|
| 17 |
|
| 18 |
## Affected Product Docs
|
| 19 |
|
|
@@ -21,5 +21,6 @@ environment before making a tuning decision.
|
|
| 21 |
|
| 22 |
## Non-Goals
|
| 23 |
|
| 24 |
-
- Collect
|
|
|
|
| 25 |
- Train, fine-tune, or change the SOAP serving provider.
|
|
|
|
| 2 |
|
| 3 |
## Current Behavior
|
| 4 |
|
| 5 |
+
The Scribe produces a clinician-reviewed SOAP draft. The repository now has a
|
| 6 |
+
bounded review tool, but current work is limited to in-house testing.
|
| 7 |
|
| 8 |
## Target Behavior
|
| 9 |
|
| 10 |
+
The repository provides an optional score-only rubric, blank schema, and
|
| 11 |
+
validator for informational in-house review. It has no minimum sample-size
|
| 12 |
+
target and cannot authorize a model change.
|
| 13 |
|
| 14 |
## Affected Users
|
| 15 |
|
| 16 |
+
- In-house reviewers.
|
| 17 |
|
| 18 |
## Affected Product Docs
|
| 19 |
|
|
|
|
| 21 |
|
| 22 |
## Non-Goals
|
| 23 |
|
| 24 |
+
- Collect source audio, patient data, generated note text, or ratings in this
|
| 25 |
+
repository.
|
| 26 |
- Train, fine-tune, or change the SOAP serving provider.
|
docs/stories/epics/E09-restructure/CP-RES-010-soap-measurement/validation.md
CHANGED
|
@@ -9,7 +9,7 @@ is created or persisted.
|
|
| 9 |
|
| 10 |
| Layer | Cases |
|
| 11 |
| --- | --- |
|
| 12 |
-
| Unit |
|
| 13 |
| Integration | Existing CPU-only training test job discovers the validator test |
|
| 14 |
| E2E | Not applicable; no product surface changes |
|
| 15 |
| Platform | No external provider required |
|
|
@@ -23,6 +23,8 @@ python -m ruff check scribe/training/scripts/validate_soap_ratings.py scribe/tra
|
|
| 23 |
|
| 24 |
## Acceptance Evidence
|
| 25 |
|
| 26 |
-
2026-07-13:
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
| Layer | Cases |
|
| 11 |
| --- | --- |
|
| 12 |
+
| Unit | Quality summary, serious-hallucination summary, invalid-score rejection |
|
| 13 |
| Integration | Existing CPU-only training test job discovers the validator test |
|
| 14 |
| E2E | Not applicable; no product surface changes |
|
| 15 |
| Platform | No external provider required |
|
|
|
|
| 23 |
|
| 24 |
## Acceptance Evidence
|
| 25 |
|
| 26 |
+
2026-07-13: `python -m pytest scribe/training/tests` passed with `54 passed,
|
| 27 |
+
1 skipped`; the review-validator focused test passed with `4 passed`, and
|
| 28 |
+
Ruff passed. No data collection, model training, or serving behavior change
|
| 29 |
+
occurred. The tool is limited to optional in-house testing and makes no model
|
| 30 |
+
decision claim.
|
docs/stories/epics/E09-restructure/CP-RES-012-owner-deployment-clinical-handoff/design.md
CHANGED
|
@@ -2,26 +2,27 @@
|
|
| 2 |
|
| 3 |
## Domain Model
|
| 4 |
|
| 5 |
-
No
|
| 6 |
-
|
|
|
|
| 7 |
|
| 8 |
## Application Flow
|
| 9 |
|
| 10 |
The deployment owner configures Vercel exactly as `docs/deploy.md` specifies,
|
| 11 |
-
then validates the live site and Space health endpoints.
|
| 12 |
-
|
| 13 |
-
|
| 14 |
|
| 15 |
## Interface Contract
|
| 16 |
|
| 17 |
The existing Vercel environment validation and Space health endpoints are the
|
| 18 |
-
deployment contract. The score-only SOAP
|
| 19 |
-
|
| 20 |
|
| 21 |
## Data Model
|
| 22 |
|
| 23 |
-
No clinical material belongs in this repository.
|
| 24 |
-
|
| 25 |
|
| 26 |
## UI / Platform Impact
|
| 27 |
|
|
@@ -29,10 +30,12 @@ No local UI change. The Vercel Root Directory must point to `scribe/frontend`.
|
|
| 29 |
|
| 30 |
## Observability
|
| 31 |
|
| 32 |
-
Capture live deployment status
|
| 33 |
-
repository's source tree.
|
| 34 |
|
| 35 |
## Alternatives Considered
|
| 36 |
|
| 37 |
-
1.
|
| 38 |
-
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
## Domain Model
|
| 4 |
|
| 5 |
+
No runtime change is required. The product contract documents that optional
|
| 6 |
+
in-house review is informational only; deployment credentials and health-data
|
| 7 |
+
consent remain owner-controlled trust boundaries.
|
| 8 |
|
| 9 |
## Application Flow
|
| 10 |
|
| 11 |
The deployment owner configures Vercel exactly as `docs/deploy.md` specifies,
|
| 12 |
+
then validates the live site and Space health endpoints. Optional in-house
|
| 13 |
+
review may use synthetic or already-approved de-identified material, but it
|
| 14 |
+
does not authorize any SOAP model decision.
|
| 15 |
|
| 16 |
## Interface Contract
|
| 17 |
|
| 18 |
The existing Vercel environment validation and Space health endpoints are the
|
| 19 |
+
deployment contract. The score-only SOAP review schema and validator provide
|
| 20 |
+
in-house quality signals only.
|
| 21 |
|
| 22 |
## Data Model
|
| 23 |
|
| 24 |
+
No clinical material belongs in this repository. Optional review metadata may
|
| 25 |
+
be processed only outside the source tree using the existing validator.
|
| 26 |
|
| 27 |
## UI / Platform Impact
|
| 28 |
|
|
|
|
| 30 |
|
| 31 |
## Observability
|
| 32 |
|
| 33 |
+
Capture live deployment status. Optional in-house review summaries stay outside
|
| 34 |
+
this repository's source tree.
|
| 35 |
|
| 36 |
## Alternatives Considered
|
| 37 |
|
| 38 |
+
1. Keep the external clinical-rating gate: rejected because it conflicts with
|
| 39 |
+
the owner-directed in-house testing scope.
|
| 40 |
+
2. Simulate live deployment: rejected because it would not prove the external
|
| 41 |
+
environment.
|
docs/stories/epics/E09-restructure/CP-RES-012-owner-deployment-clinical-handoff/execplan.md
CHANGED
|
@@ -2,8 +2,8 @@
|
|
| 2 |
|
| 3 |
## Goal
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
|
| 8 |
## Scope
|
| 9 |
|
|
@@ -12,32 +12,34 @@ In scope:
|
|
| 12 |
- Owner configures Vercel Root Directory as `scribe/frontend` and supplies the
|
| 13 |
documented deployment environment variables.
|
| 14 |
- Owner rebuilds the Docker Space and verifies the documented live routes.
|
| 15 |
-
-
|
|
|
|
| 16 |
|
| 17 |
Out of scope:
|
| 18 |
|
| 19 |
- Repository-side deployment automation or clinical-data collection.
|
| 20 |
-
- Any model training or SOAP provider change
|
| 21 |
|
| 22 |
## Risk Classification
|
| 23 |
|
| 24 |
Risk flags:
|
| 25 |
|
| 26 |
-
- Deployment, credentials, clinical data, privacy.
|
| 27 |
|
| 28 |
Hard gates:
|
| 29 |
|
| 30 |
- No source audio, transcript, identifier, or note text enters the repository.
|
| 31 |
-
- No SOAP model
|
|
|
|
| 32 |
|
| 33 |
## Work Phases
|
| 34 |
|
| 35 |
1. Owner completes the Vercel and Space configuration in `docs/deploy.md`.
|
| 36 |
2. Owner records live route and health-endpoint evidence.
|
| 37 |
-
3.
|
| 38 |
-
4.
|
| 39 |
|
| 40 |
## Stop Conditions
|
| 41 |
|
| 42 |
-
|
| 43 |
-
|
|
|
|
| 2 |
|
| 3 |
## Goal
|
| 4 |
|
| 5 |
+
Record the completed owner deployment work and replace the external
|
| 6 |
+
clinician-rating completion gate with an in-house testing scope.
|
| 7 |
|
| 8 |
## Scope
|
| 9 |
|
|
|
|
| 12 |
- Owner configures Vercel Root Directory as `scribe/frontend` and supplies the
|
| 13 |
documented deployment environment variables.
|
| 14 |
- Owner rebuilds the Docker Space and verifies the documented live routes.
|
| 15 |
+
- Product and plan records state that optional in-house SOAP review is
|
| 16 |
+
informational only.
|
| 17 |
|
| 18 |
Out of scope:
|
| 19 |
|
| 20 |
- Repository-side deployment automation or clinical-data collection.
|
| 21 |
+
- Any model training or SOAP provider change from in-house review results.
|
| 22 |
|
| 23 |
## Risk Classification
|
| 24 |
|
| 25 |
Risk flags:
|
| 26 |
|
| 27 |
+
- Deployment, credentials, clinical data, privacy, model-decision policy.
|
| 28 |
|
| 29 |
Hard gates:
|
| 30 |
|
| 31 |
- No source audio, transcript, identifier, or note text enters the repository.
|
| 32 |
+
- No SOAP model, provider, or safety-policy change from in-house review results
|
| 33 |
+
without a separate owner decision.
|
| 34 |
|
| 35 |
## Work Phases
|
| 36 |
|
| 37 |
1. Owner completes the Vercel and Space configuration in `docs/deploy.md`.
|
| 38 |
2. Owner records live route and health-endpoint evidence.
|
| 39 |
+
3. Record the deliberate in-house-only scope and its safety limits.
|
| 40 |
+
4. Verify the policy, plan, optional review utility, and Harness evidence.
|
| 41 |
|
| 42 |
## Stop Conditions
|
| 43 |
|
| 44 |
+
Pause for owner direction if a request would collect clinical data or make a
|
| 45 |
+
model, provider, or safety-policy change from in-house review results.
|
docs/stories/epics/E09-restructure/CP-RES-012-owner-deployment-clinical-handoff/overview.md
CHANGED
|
@@ -2,19 +2,18 @@
|
|
| 2 |
|
| 3 |
## Current Behavior
|
| 4 |
|
| 5 |
-
The repository restructure and local product matrix are complete.
|
| 6 |
-
|
| 7 |
-
settings and collecting legally approved clinical evidence.
|
| 8 |
|
| 9 |
## Target Behavior
|
| 10 |
|
| 11 |
-
The owner
|
| 12 |
-
|
| 13 |
|
| 14 |
## Affected Users
|
| 15 |
|
| 16 |
- Deployment owner.
|
| 17 |
-
-
|
| 18 |
|
| 19 |
## Affected Product Docs
|
| 20 |
|
|
@@ -23,5 +22,5 @@ de-identified clinician-rating process before calling the whole plan complete.
|
|
| 23 |
|
| 24 |
## Non-Goals
|
| 25 |
|
| 26 |
-
- Collect audio, transcripts, patient data, or
|
| 27 |
-
- Change
|
|
|
|
| 2 |
|
| 3 |
## Current Behavior
|
| 4 |
|
| 5 |
+
The repository restructure and local product matrix are complete. The owner
|
| 6 |
+
has completed the deployment configuration and live-route verification.
|
|
|
|
| 7 |
|
| 8 |
## Target Behavior
|
| 9 |
|
| 10 |
+
The owner records the current in-house testing scope. No external
|
| 11 |
+
clinician-rating target blocks completion of the restructure.
|
| 12 |
|
| 13 |
## Affected Users
|
| 14 |
|
| 15 |
- Deployment owner.
|
| 16 |
+
- In-house reviewers.
|
| 17 |
|
| 18 |
## Affected Product Docs
|
| 19 |
|
|
|
|
| 22 |
|
| 23 |
## Non-Goals
|
| 24 |
|
| 25 |
+
- Collect audio, transcripts, patient data, or review data in this repo.
|
| 26 |
+
- Change a model, provider, or safety policy from in-house review results.
|
docs/stories/epics/E09-restructure/CP-RES-012-owner-deployment-clinical-handoff/validation.md
CHANGED
|
@@ -13,7 +13,7 @@ not establish a live deployment or authorize clinical data processing.
|
|
| 13 |
| Integration | Term-artifact drift, training baseline, mock Scribe smoke |
|
| 14 |
| E2E | Both Vite frontends' Playwright suites |
|
| 15 |
| Platform | Owner verifies Vercel and Space routes after configuration |
|
| 16 |
-
| Clinical |
|
| 17 |
|
| 18 |
## Commands
|
| 19 |
|
|
@@ -58,7 +58,15 @@ The rebuilt Space is Running at
|
|
| 58 |
Interpreter product link returns to the canonical Vercel site with language
|
| 59 |
preserved.
|
| 60 |
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
| Integration | Term-artifact drift, training baseline, mock Scribe smoke |
|
| 14 |
| E2E | Both Vite frontends' Playwright suites |
|
| 15 |
| Platform | Owner verifies Vercel and Space routes after configuration |
|
| 16 |
+
| Clinical | Not required for the current in-house-only scope |
|
| 17 |
|
| 18 |
## Commands
|
| 19 |
|
|
|
|
| 58 |
Interpreter product link returns to the canonical Vercel site with language
|
| 59 |
preserved.
|
| 60 |
|
| 61 |
+
2026-07-13 public availability update: Space commit `cc678b9` is Running.
|
| 62 |
+
`/phien-dich-y-khoa/`, its asset path, and `/console/` now return HTTP 404;
|
| 63 |
+
the Space root, `/ghi-chep-lam-sang/`, `/api/v1/health`, and `/api/health`
|
| 64 |
+
continue to return HTTP 200. The public Scribe site on Vercel also returns 200.
|
| 65 |
+
|
| 66 |
+
2026-07-13 in-house scope evidence: owner direction removed the external
|
| 67 |
+
50-note clinician-rating target. Decision 0017, the product contract, the
|
| 68 |
+
restructure plan, and the optional review utility now make in-house testing
|
| 69 |
+
informational only. No clinical material is collected or stored in this
|
| 70 |
+
repository, and no model decision is claimed. The focused review-validator
|
| 71 |
+
test passed with `4 passed`; the full training slice passed with `54 passed,
|
| 72 |
+
1 skipped`; and Ruff passed.
|
docs/ux-redesign-carepath.md
CHANGED
|
@@ -1,12 +1,434 @@
|
|
| 1 |
# CarePath Vietnamese-first UX implementation stories
|
| 2 |
|
| 3 |
-
**Status:**
|
| 4 |
|
| 5 |
**Objective:** make CarePath Vietnamese-first and make its two products unmistakably different:
|
| 6 |
|
| 7 |
1. **CarePath Trợ lý ghi chép lâm sàng** — creates a clinician-reviewed draft from uploaded Vietnamese visit audio.
|
| 8 |
2. **CarePath Phiên dịch y khoa Việt–Anh** — supports turn-by-turn conversation and blocks risky output until clinician confirmation.
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
## Backlog rules
|
| 11 |
|
| 12 |
- Implement one story per commit or pull request.
|
|
@@ -34,7 +456,8 @@ that URL can still open the unfinished workflow.
|
|
| 34 |
2. Every browser request to `/phien-dich-y-khoa/*` and `/console/*` returns
|
| 35 |
HTTP 404 instead of the Interpreter frontend.
|
| 36 |
3. Interpreter APIs, WebSockets, backend safety behavior, and automated tests
|
| 37 |
-
remain available for internal
|
|
|
|
| 38 |
|
| 39 |
### Affected routes, pages, and components
|
| 40 |
|
|
|
|
| 1 |
# CarePath Vietnamese-first UX implementation stories
|
| 2 |
|
| 3 |
+
**Status:** active UX implementation contract and backlog.
|
| 4 |
|
| 5 |
**Objective:** make CarePath Vietnamese-first and make its two products unmistakably different:
|
| 6 |
|
| 7 |
1. **CarePath Trợ lý ghi chép lâm sàng** — creates a clinician-reviewed draft from uploaded Vietnamese visit audio.
|
| 8 |
2. **CarePath Phiên dịch y khoa Việt–Anh** — supports turn-by-turn conversation and blocks risky output until clinician confirmation.
|
| 9 |
|
| 10 |
+
## Current priority update: Scribe-led public landing
|
| 11 |
+
|
| 12 |
+
### Current UX problem
|
| 13 |
+
|
| 14 |
+
The public landing technically labels the Interpreter as being in development,
|
| 15 |
+
but still presents it as a peer product choice and sends its card to the pilot
|
| 16 |
+
form. That affordance competes with the only available web workflow and leaves
|
| 17 |
+
the Scribe value story fragmented across unrelated product and safety sections.
|
| 18 |
+
|
| 19 |
+
### Proposed flow
|
| 20 |
+
|
| 21 |
+
1. Show a prominent, non-interactive status rail stating that **Phiên dịch khám
|
| 22 |
+
bệnh trực tiếp** is in development and cannot currently be accessed on the
|
| 23 |
+
web.
|
| 24 |
+
2. Lead with the documentation burden after a consultation, then show how an
|
| 25 |
+
approved audio upload becomes a clinician-reviewed structured draft.
|
| 26 |
+
3. Keep **Bắt đầu ghi chép** as the only product-launch action.
|
| 27 |
+
4. Keep the pilot form as a secondary Scribe-only contact path.
|
| 28 |
+
|
| 29 |
+
### Affected routes, pages, and components
|
| 30 |
+
|
| 31 |
+
- `/` in `scribe/frontend/src/LandingPage.tsx`
|
| 32 |
+
- Landing copy and metadata in `scribe/frontend/src/content/strings.ts`
|
| 33 |
+
- Landing presentation and reduced-motion behavior in `scribe/frontend/src/styles.css`
|
| 34 |
+
- The reusable pilot form's optional product-interest field
|
| 35 |
+
- `/ghi-chep-lam-sang/` remains unchanged
|
| 36 |
+
|
| 37 |
+
### Vietnamese-first copy
|
| 38 |
+
|
| 39 |
+
- Interpreter status: `Đang phát triển — hiện chưa thể truy cập trên web.`
|
| 40 |
+
- Hero: `Tập trung vào người bệnh. Để CarePath soạn bản nháp sau buổi khám.`
|
| 41 |
+
- Hero detail: `Tải lên tệp âm thanh buổi khám đã được phép sử dụng. CarePath
|
| 42 |
+
chép lời, chuẩn hóa thuật ngữ và tạo bản nháp bệnh án có cấu trúc để bác sĩ
|
| 43 |
+
kiểm tra.`
|
| 44 |
+
- Primary action: `Bắt đầu ghi chép`
|
| 45 |
+
- Secondary action: `Xem cách hoạt động`
|
| 46 |
+
- Problem chapter: `Một buổi khám không nên biến thành hai ca làm việc.`
|
| 47 |
+
- Safety limit: `Bản nháp chỉ được dùng sau khi bác sĩ kiểm tra.`
|
| 48 |
+
- Final action: `Bắt đầu từ một tệp âm thanh đã được phép sử dụng.`
|
| 49 |
+
|
| 50 |
+
### Implementation story: CP-UX-10
|
| 51 |
+
|
| 52 |
+
- Replace the two-product decision accordion with a Scribe-led AIDA narrative.
|
| 53 |
+
- Use the existing Geist font, CarePath mark, sanitized Scribe sample, and
|
| 54 |
+
existing form; add only GSAP and its React adapter for the approved motion.
|
| 55 |
+
- Add a gapless 12-column problem bento, native horizontal workflow accordion,
|
| 56 |
+
decorative process marquee, and desktop-only pinned workflow visuals.
|
| 57 |
+
- Lock the landing pilot form to Scribe and hide its product-interest selector.
|
| 58 |
+
- Remove landing-only Interpreter evidence and obsolete selector rules.
|
| 59 |
+
|
| 60 |
+
### Dependencies
|
| 61 |
+
|
| 62 |
+
- CP-UX-09 keeps the Interpreter browser routes blocked.
|
| 63 |
+
- CP-BASE-003 supplies the existing public-site test and deployment gates.
|
| 64 |
+
- The Vercel project continues to build from `scribe/frontend` on `main`.
|
| 65 |
+
|
| 66 |
+
### Acceptance criteria
|
| 67 |
+
|
| 68 |
+
- Scribe is the only active product journey and its CTA opens
|
| 69 |
+
`/ghi-chep-lam-sang/`.
|
| 70 |
+
- Interpreter status is visible near the top of the page and contains no link
|
| 71 |
+
or button.
|
| 72 |
+
- The page explains the post-consultation documentation problem, the Scribe
|
| 73 |
+
workflow, and mandatory clinician review without unsupported claims.
|
| 74 |
+
- Vietnamese and English layouts work at 320, 360, 390, 768, and 1440 pixels
|
| 75 |
+
without horizontal overflow.
|
| 76 |
+
- Keyboard navigation, reduced motion, diacritics, and serious Axe checks pass.
|
| 77 |
+
- Existing Scribe functionality works and Interpreter browser routes remain 404.
|
| 78 |
+
|
| 79 |
+
### Validation commands
|
| 80 |
+
|
| 81 |
+
```powershell
|
| 82 |
+
cd scribe\frontend
|
| 83 |
+
npm.cmd run lint
|
| 84 |
+
npm.cmd test
|
| 85 |
+
npm.cmd run test:deploy-env
|
| 86 |
+
npm.cmd run build
|
| 87 |
+
npm.cmd run e2e
|
| 88 |
+
npm.cmd run lighthouse
|
| 89 |
+
cd ..\..
|
| 90 |
+
python -m pytest scribe/tests/test_combined_app.py
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
### Risks and fallback behavior
|
| 94 |
+
|
| 95 |
+
- GSAP runs only on desktop when reduced motion is not requested; without it,
|
| 96 |
+
all content remains in normal document flow.
|
| 97 |
+
- The marquee is decorative and stops for reduced motion. Its meaning is also
|
| 98 |
+
available as static text.
|
| 99 |
+
- No Interpreter route, API, WebSocket, consent, microphone, TTS, or risk logic
|
| 100 |
+
changes. If the status presentation fails, it remains plain static text and
|
| 101 |
+
never becomes a launch action.
|
| 102 |
+
|
| 103 |
+
## Current priority update: problem-led Vietnamese Scribe onboarding
|
| 104 |
+
|
| 105 |
+
### Current UX problem
|
| 106 |
+
|
| 107 |
+
The Scribe-led landing explains the product before it earns attention from the
|
| 108 |
+
doctor's daily problem. Large decorative panels, 8–14rem section gaps, a 31rem
|
| 109 |
+
hero visual, and a 29rem accordion create long stretches with little actionable
|
| 110 |
+
information. The working Scribe route then adds another obstacle: visitors must
|
| 111 |
+
read a separate pre-start screen and click continue before they can even see the
|
| 112 |
+
upload control. The VI/EN switch also adds a choice that is not needed for this
|
| 113 |
+
Vietnamese-first release.
|
| 114 |
+
|
| 115 |
+
### Proposed flow
|
| 116 |
+
|
| 117 |
+
1. Keep the non-interactive Interpreter development status near the top.
|
| 118 |
+
2. Lead with `Dành thời gian cho người bệnh, không phải cho việc gõ bệnh án.`
|
| 119 |
+
3. Let doctors recognize their day and calculate their current documentation
|
| 120 |
+
time from four short inputs. Show current burden only, never a promised
|
| 121 |
+
CarePath saving.
|
| 122 |
+
4. Reuse one guided sample to show conversation → clarified transcript →
|
| 123 |
+
structured draft → doctor review.
|
| 124 |
+
5. Show evidence in three honest levels: the dated Vietnamese workload norm,
|
| 125 |
+
international ambient-scribe evidence, and what CarePath still needs to
|
| 126 |
+
prove locally.
|
| 127 |
+
6. Put trust limits before the action, then offer the working Scribe tool first
|
| 128 |
+
and the organization pilot form as a secondary disclosure.
|
| 129 |
+
7. On the Scribe route, show brief instructions and the upload control together.
|
| 130 |
+
|
| 131 |
+
### Affected routes, pages, and components
|
| 132 |
+
|
| 133 |
+
- `/` in `scribe/frontend/src/LandingPage.tsx`
|
| 134 |
+
- `/ghi-chep-lam-sang/` in `scribe/frontend/src/scribe/ScribeTool.tsx`
|
| 135 |
+
- `scribe/frontend/src/scribe/ScribeShowcase.tsx`
|
| 136 |
+
- Vietnamese landing, sample, metadata, and Scribe-tool copy
|
| 137 |
+
- Focused unit, Playwright, stylesheet, package, and Harness proof files
|
| 138 |
+
|
| 139 |
+
### Vietnamese-first copy
|
| 140 |
+
|
| 141 |
+
- Hero: `Dành thời gian cho người bệnh, không phải cho việc gõ bệnh án.`
|
| 142 |
+
- Hero detail: `CarePath chuẩn bị ghi chú lâm sàng từ cuộc trao đổi để bác sĩ
|
| 143 |
+
kiểm tra và xác nhận.`
|
| 144 |
+
- Primary action: `Trải nghiệm ca khám mẫu`
|
| 145 |
+
- Secondary action: `Tính thời gian ghi chép`
|
| 146 |
+
- Calculator result: current daily documentation time only, plus a clearly
|
| 147 |
+
labelled five-day equivalent.
|
| 148 |
+
- Sample limit: `Nhận định` and `Kế hoạch` display
|
| 149 |
+
`Chờ bác sĩ nhập hoặc xác nhận.`
|
| 150 |
+
- Tool helper: `Xem ca khám mẫu` links back to `/#demo`.
|
| 151 |
+
|
| 152 |
+
### Implementation story: CP-UX-11
|
| 153 |
+
|
| 154 |
+
- Delete the decorative marquee, bento, horizontal accordion, pinned gallery,
|
| 155 |
+
language toggle, and GSAP dependencies.
|
| 156 |
+
- Use semantic HTML, the existing Geist font, existing logo, existing lead form,
|
| 157 |
+
and the existing Scribe showcase; add no dependency or remote asset.
|
| 158 |
+
- Add the calculator as local React state with the transparent formula
|
| 159 |
+
`số bệnh nhân × phút ghi chép mỗi bệnh nhân`.
|
| 160 |
+
- Keep English copy data that other frozen demo code may still consume, but the
|
| 161 |
+
public App always renders Vietnamese and exposes no language control.
|
| 162 |
+
- Remove the Scribe tool's `readyToRecord` gate without changing validation,
|
| 163 |
+
upload, progress, error, API, or result behavior.
|
| 164 |
+
|
| 165 |
+
### Dependencies
|
| 166 |
+
|
| 167 |
+
- CP-UX-10 supplies the current Scribe-only public route and Scribe-only form.
|
| 168 |
+
- CP-UX-09 keeps both Interpreter browser paths at 404.
|
| 169 |
+
- CP-BASE-003 supplies deployment, accessibility, and responsive test gates.
|
| 170 |
+
|
| 171 |
+
### Acceptance criteria
|
| 172 |
+
|
| 173 |
+
- The site starts with the doctor's documentation problem, not a definition of
|
| 174 |
+
AI Medical Scribe.
|
| 175 |
+
- There is no public EN toggle; all visible onboarding and tool copy is
|
| 176 |
+
Vietnamese.
|
| 177 |
+
- The calculator works without storage, analytics, network requests, or a
|
| 178 |
+
CarePath productivity prediction.
|
| 179 |
+
- The guided sample teaches the workflow and leaves diagnosis and treatment
|
| 180 |
+
decisions to the doctor.
|
| 181 |
+
- The upload control is visible on first entry to the Scribe tool.
|
| 182 |
+
- The landing has no horizontal overflow at 320, 360, 390, 768, and 1440px and
|
| 183 |
+
no oversized empty instructional panel.
|
| 184 |
+
- Keyboard, Axe, reduced-motion, dark/light, diacritics, Scribe-route, lead,
|
| 185 |
+
Lighthouse, and Interpreter-404 checks pass.
|
| 186 |
+
|
| 187 |
+
### Validation commands
|
| 188 |
+
|
| 189 |
+
```powershell
|
| 190 |
+
cd scribe\frontend
|
| 191 |
+
npm.cmd run lint
|
| 192 |
+
npm.cmd test
|
| 193 |
+
npm.cmd run test:deploy-env
|
| 194 |
+
npm.cmd run build
|
| 195 |
+
npm.cmd run e2e
|
| 196 |
+
npm.cmd run lighthouse
|
| 197 |
+
cd ..\..
|
| 198 |
+
python -m pytest scribe/tests/test_combined_app.py
|
| 199 |
+
```
|
| 200 |
+
|
| 201 |
+
### Risks and fallback behavior
|
| 202 |
+
|
| 203 |
+
- The calculator is an estimate based only on answers entered in the browser;
|
| 204 |
+
if JavaScript is unavailable, the surrounding problem and workflow copy still
|
| 205 |
+
explains the value without a numeric claim.
|
| 206 |
+
- The workload source is explicitly dated and must not be presented as a current
|
| 207 |
+
national productivity measurement. International studies are labelled as
|
| 208 |
+
external evidence, not CarePath outcomes.
|
| 209 |
+
- The sample uses sanitized fixed content and leaves Assessment and Plan blank
|
| 210 |
+
for clinician input. It never becomes medical advice.
|
| 211 |
+
- No Interpreter route, API, WebSocket, audio, consent, retention, or safety
|
| 212 |
+
behavior changes.
|
| 213 |
+
|
| 214 |
+
## Current priority update: professional landing polish
|
| 215 |
+
|
| 216 |
+
### Current UX problem
|
| 217 |
+
|
| 218 |
+
The problem-led landing (CP-UX-11) has the right content in the right order,
|
| 219 |
+
but its presentation undercuts onboarding:
|
| 220 |
+
|
| 221 |
+
1. The hero states the problem yet never shows the product. The draft payoff
|
| 222 |
+
sits behind the third tab of the guided sample, so a scanning doctor sees
|
| 223 |
+
no evidence of what CarePath actually produces.
|
| 224 |
+
2. The two hero actions wrap into a vertical stack at desktop widths and read
|
| 225 |
+
as a rendering fault rather than a hierarchy.
|
| 226 |
+
3. The Interpreter development notice is a full-width dark band directly under
|
| 227 |
+
the navigation; the most dominant element above the fold announces a
|
| 228 |
+
product that cannot be used.
|
| 229 |
+
4. The final third of the page is three consecutive dark slabs (trust, start,
|
| 230 |
+
footer) that merge into one block and bury the trust checklist.
|
| 231 |
+
5. A late `.site-footer` column override defeats the responsive footer rules,
|
| 232 |
+
so footer text squeezes into unreadable columns at phone widths.
|
| 233 |
+
6. Section paddings differ per chapter, leaving long empty stretches between
|
| 234 |
+
the calculator, sample, and evidence sections on desktop.
|
| 235 |
+
|
| 236 |
+
### Proposed flow
|
| 237 |
+
|
| 238 |
+
Section order, routes, copy contract, and all interactive behavior stay as
|
| 239 |
+
CP-UX-11 delivered them. Only presentation changes:
|
| 240 |
+
|
| 241 |
+
1. Render the Interpreter status as a quiet bordered notice under the
|
| 242 |
+
navigation. Same element, same strings, still non-interactive.
|
| 243 |
+
2. Split the hero: problem headline and actions on the left, and a compact
|
| 244 |
+
conversation-to-draft proof panel on the right that reuses the sanitized
|
| 245 |
+
sample. Subjective and Objective rows only; Assessment and Plan never
|
| 246 |
+
appear before the doctor reaches the guided sample. Actions sit on one row
|
| 247 |
+
on desktop.
|
| 248 |
+
3. Keep calculator, guided sample, and evidence sections unchanged in
|
| 249 |
+
behavior; align them to one shared section-padding token.
|
| 250 |
+
4. Restyle the trust section as a light panel so the closing start section is
|
| 251 |
+
the single dark moment before the footer.
|
| 252 |
+
5. Fix the footer override ordering and stack it into readable rows at phone
|
| 253 |
+
widths.
|
| 254 |
+
|
| 255 |
+
### Affected routes, pages, and components
|
| 256 |
+
|
| 257 |
+
- `/` in `scribe/frontend/src/LandingPage.tsx` (hero proof panel replaces the
|
| 258 |
+
abstract daily-flow table)
|
| 259 |
+
- `scribe/frontend/src/styles.css` (status notice, hero grid, section rhythm,
|
| 260 |
+
trust section, footer)
|
| 261 |
+
- `scribe/frontend/src/LandingPage.test.tsx` (hero proof assertions)
|
| 262 |
+
- No route, API, form, showcase-interaction, or Scribe-tool changes
|
| 263 |
+
|
| 264 |
+
### Vietnamese-first copy (new strings only)
|
| 265 |
+
|
| 266 |
+
- Proof panel title: `Từ cuộc trao đổi đến bản nháp`
|
| 267 |
+
- Conversation label: `Trong ghi âm`
|
| 268 |
+
- Reused sample line: `Tôi bị đau tức ngực thoáng qua từ sáng, hơi khó thở.`
|
| 269 |
+
- Draft label: `Bản nháp cho bác sĩ kiểm tra`
|
| 270 |
+
- Draft rows: existing `Triệu chứng chủ quan` and `Thông tin khách quan`
|
| 271 |
+
sample content; the Objective row reads
|
| 272 |
+
`Chưa có kết quả cận lâm sàng trong nội dung ghi âm.`
|
| 273 |
+
- Caption: `Ca khám mẫu đã làm sạch. Nhận định và kế hoạch luôn thuộc về bác sĩ.`
|
| 274 |
+
|
| 275 |
+
### Implementation story: CP-UX-12
|
| 276 |
+
|
| 277 |
+
- Replace the hero `daily-flow` table with the conversation-to-draft proof
|
| 278 |
+
panel; delete the orphaned `daily-flow` styles.
|
| 279 |
+
- De-emphasize the Interpreter status into a bordered notice.
|
| 280 |
+
- One shared section padding; trust section light; footer stacking fixed at
|
| 281 |
+
its root (remove the post-media-query `.site-footer` override).
|
| 282 |
+
- No new dependency, motion, image, or remote asset. Semantic HTML only.
|
| 283 |
+
|
| 284 |
+
### Dependencies
|
| 285 |
+
|
| 286 |
+
- CP-UX-11 supplies the problem-led structure, calculator, and guided sample.
|
| 287 |
+
- CP-UX-09 keeps Interpreter browser routes blocked.
|
| 288 |
+
- CP-BASE-003 supplies the site test and deployment gates.
|
| 289 |
+
|
| 290 |
+
### Acceptance criteria
|
| 291 |
+
|
| 292 |
+
- The hero shows a conversation-to-draft glimpse labeled as a cleaned sample;
|
| 293 |
+
`Chờ bác sĩ nhập hoặc xác nhận.` still never renders before the doctor opens
|
| 294 |
+
the draft step (existing e2e assertion stays green).
|
| 295 |
+
- Both hero actions render on one row at 1024 px and wider.
|
| 296 |
+
- The Interpreter status keeps its exact strings and zero links or buttons,
|
| 297 |
+
and no longer uses the dark band treatment.
|
| 298 |
+
- Exactly one dark section (the start section) renders between the evidence
|
| 299 |
+
grid and the footer.
|
| 300 |
+
- The footer stacks into readable rows at 320, 360, and 390 px.
|
| 301 |
+
- Hero height stays under 800 px at 1440×900; H1 line limits hold; no
|
| 302 |
+
horizontal overflow from 320 to 1440 px.
|
| 303 |
+
- Lint, unit, deploy-env, build with diacritics gate, Playwright, Lighthouse,
|
| 304 |
+
and combined-app checks pass.
|
| 305 |
+
|
| 306 |
+
### Validation commands
|
| 307 |
+
|
| 308 |
+
```powershell
|
| 309 |
+
cd scribe\frontend
|
| 310 |
+
npm.cmd run lint
|
| 311 |
+
npm.cmd test
|
| 312 |
+
npm.cmd run test:deploy-env
|
| 313 |
+
npm.cmd run build
|
| 314 |
+
npm.cmd run e2e
|
| 315 |
+
npm.cmd run lighthouse
|
| 316 |
+
cd ..\..
|
| 317 |
+
python -m pytest scribe/tests/test_combined_app.py
|
| 318 |
+
```
|
| 319 |
+
|
| 320 |
+
### Risks and fallback behavior
|
| 321 |
+
|
| 322 |
+
- Presentation-only: no route, API, consent, microphone, retention, or safety
|
| 323 |
+
copy changes. The pilot form and showcase interactions are untouched.
|
| 324 |
+
- The proof panel is static text from the existing sanitized sample; if it
|
| 325 |
+
threatens the hero height budget, the conversation line drops first and the
|
| 326 |
+
panel degrades to a draft-only card.
|
| 327 |
+
- All new strings are NFC-normalized Vietnamese; the build diacritics gate is
|
| 328 |
+
the enforcement.
|
| 329 |
+
|
| 330 |
+
## Current priority update: privacy and Vietnamese-capability visibility
|
| 331 |
+
|
| 332 |
+
### Current UX problem
|
| 333 |
+
|
| 334 |
+
The landing states its strongest trust facts too quietly. The audio-retention
|
| 335 |
+
claim is one bullet in the closing checklist, the no-training claim from the
|
| 336 |
+
product contract never appears, and nothing explains why the system gets
|
| 337 |
+
clinical Vietnamese right. The page also frames review only as an obligation;
|
| 338 |
+
it never shows that marked corrections and listed gaps make that review fast
|
| 339 |
+
and targeted rather than a full re-read.
|
| 340 |
+
|
| 341 |
+
### Proposed flow
|
| 342 |
+
|
| 343 |
+
1. Extend the hero small print with the audio-retention fact so privacy is
|
| 344 |
+
visible before the first action.
|
| 345 |
+
2. Rework the trust section into two evidence panels under one heading:
|
| 346 |
+
- Vietnamese capability: Vietnamese speech recognition with diacritics
|
| 347 |
+
preserved, term normalization against the curated Vietnamese medical
|
| 348 |
+
lexicon including unaccented and shorthand variants, and visible marks
|
| 349 |
+
for corrections and missing information so review is fast and targeted.
|
| 350 |
+
- Audio privacy: request-scoped temporary processing deleted after the
|
| 351 |
+
request, consultation audio never used as training data, and no
|
| 352 |
+
automatic microphone capture.
|
| 353 |
+
3. Keep the review requirement and the no-advice boundary in the section
|
| 354 |
+
subline. Never claim review is unnecessary and never state an accuracy
|
| 355 |
+
figure; CarePath has no measured Vietnam deployment data.
|
| 356 |
+
|
| 357 |
+
### Affected routes, pages, and components
|
| 358 |
+
|
| 359 |
+
- `/` in `scribe/frontend/src/LandingPage.tsx` (hero small print, trust
|
| 360 |
+
section)
|
| 361 |
+
- `scribe/frontend/src/styles.css` (trust panel grid)
|
| 362 |
+
- `scribe/frontend/src/LandingPage.test.tsx` (trust content assertions)
|
| 363 |
+
- No route, API, backend, form, or Scribe-tool changes
|
| 364 |
+
|
| 365 |
+
### Vietnamese-first copy (new strings)
|
| 366 |
+
|
| 367 |
+
- Hero small print: `Dùng tệp âm thanh đã được phép sử dụng. Âm thanh không
|
| 368 |
+
bị giữ lại sau xử lý; bản nháp không tự đi vào hồ sơ.`
|
| 369 |
+
- Trust heading: `Nhận đúng tiếng Việt. Không giữ lại âm thanh.`
|
| 370 |
+
- Trust subline: `CarePath hỗ trợ ghi chép, không tự tạo chẩn đoán, tư vấn
|
| 371 |
+
hay đơn thuốc. Bác sĩ kiểm tra bản nháp trước khi sử dụng.`
|
| 372 |
+
- Capability panel `Nhận đúng tiếng Việt lâm sàng`:
|
| 373 |
+
- `Mô hình nhận dạng giọng nói tiếng Việt, giữ nguyên dấu và chính tả.`
|
| 374 |
+
- `Thuật ngữ y khoa được đối chiếu với bộ từ điển tiếng Việt tuyển chọn,
|
| 375 |
+
nhận cả cách nói tắt và thiếu dấu.`
|
| 376 |
+
- `Lời chép gốc, chỗ đã chỉnh và phần còn thiếu đều hiển thị rõ, nên bác
|
| 377 |
+
sĩ kiểm tra nhanh và đúng trọng tâm.`
|
| 378 |
+
- Privacy panel `Âm thanh của buổi khám không bị giữ lại`:
|
| 379 |
+
- `Tệp chỉ được xử lý tạm thời trong phạm vi yêu cầu và bị xóa ngay khi
|
| 380 |
+
xử lý xong.`
|
| 381 |
+
- `Âm thanh buổi khám không bao giờ được dùng để huấn luyện mô hình.`
|
| 382 |
+
- `Màn hình không tự bắt đầu micro; chỉ dùng tệp cơ sở chủ động tải lên.`
|
| 383 |
+
|
| 384 |
+
### Implementation story: CP-UX-13
|
| 385 |
+
|
| 386 |
+
- Replace the four-bullet trust list with the two labeled panels; absorb the
|
| 387 |
+
existing bullets so no safety statement is lost.
|
| 388 |
+
- Every claim is verified against the backend and product contract:
|
| 389 |
+
request-scoped `TemporaryDirectory` processing in `scribe/carepath/main.py`,
|
| 390 |
+
the no-training rule in `docs/product/ai-scribe.md`, the Gipformer
|
| 391 |
+
Vietnamese ASR provider, and the curated lexicon with unaccented aliases.
|
| 392 |
+
- No behavior, retention, consent, or API change of any kind.
|
| 393 |
+
|
| 394 |
+
### Dependencies
|
| 395 |
+
|
| 396 |
+
- CP-UX-12 supplies the current landing presentation.
|
| 397 |
+
- CP-NOTES-02 supplies the approved request-scoped processing phrasing.
|
| 398 |
+
- `docs/product/ai-scribe.md` is the source for privacy claims.
|
| 399 |
+
|
| 400 |
+
### Acceptance criteria
|
| 401 |
+
|
| 402 |
+
- The audio-retention fact is visible in the hero without scrolling.
|
| 403 |
+
- The trust section shows the capability and privacy panels with all six
|
| 404 |
+
claims, and keeps the review requirement and no-advice boundary visible.
|
| 405 |
+
- No accuracy percentage, no savings promise, and no suggestion that review
|
| 406 |
+
can be skipped appears anywhere on the page.
|
| 407 |
+
- All prior CP-UX-12 acceptance criteria still hold, including e2e, axe,
|
| 408 |
+
diacritics, and Lighthouse gates.
|
| 409 |
+
|
| 410 |
+
### Validation commands
|
| 411 |
+
|
| 412 |
+
```powershell
|
| 413 |
+
cd scribe\frontend
|
| 414 |
+
npm.cmd run lint
|
| 415 |
+
npm.cmd test
|
| 416 |
+
npm.cmd run test:deploy-env
|
| 417 |
+
npm.cmd run build
|
| 418 |
+
npm.cmd run e2e
|
| 419 |
+
npm.cmd run lighthouse
|
| 420 |
+
cd ..\..
|
| 421 |
+
python -m pytest scribe/tests/test_combined_app.py
|
| 422 |
+
```
|
| 423 |
+
|
| 424 |
+
### Risks and fallback behavior
|
| 425 |
+
|
| 426 |
+
- Presentation and copy only. If any claim cannot be verified in code or
|
| 427 |
+
contract, it is dropped rather than softened; the section degrades to the
|
| 428 |
+
previous checklist.
|
| 429 |
+
- The capability panel must never grow into an accuracy claim. Adding any
|
| 430 |
+
measured-quality statement requires pilot data and an owner decision first.
|
| 431 |
+
|
| 432 |
## Backlog rules
|
| 433 |
|
| 434 |
- Implement one story per commit or pull request.
|
|
|
|
| 456 |
2. Every browser request to `/phien-dich-y-khoa/*` and `/console/*` returns
|
| 457 |
HTTP 404 instead of the Interpreter frontend.
|
| 458 |
3. Interpreter APIs, WebSockets, backend safety behavior, and automated tests
|
| 459 |
+
remain available only for frozen internal maintenance; no public page links
|
| 460 |
+
to them.
|
| 461 |
|
| 462 |
### Affected routes, pages, and components
|
| 463 |
|
scribe/frontend/index.html
CHANGED
|
@@ -5,11 +5,11 @@
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<meta
|
| 7 |
name="description"
|
| 8 |
-
content="CarePath
|
| 9 |
/>
|
| 10 |
<meta name="theme-color" content="#102a2e" />
|
| 11 |
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 12 |
-
<title>CarePath |
|
| 13 |
</head>
|
| 14 |
<body>
|
| 15 |
<div id="root"></div>
|
|
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<meta
|
| 7 |
name="description"
|
| 8 |
+
content="CarePath chuẩn bị bản nháp ghi chú lâm sàng từ tệp âm thanh để bác sĩ kiểm tra. Phiên dịch trực tiếp đang phát triển và chưa mở trên web."
|
| 9 |
/>
|
| 10 |
<meta name="theme-color" content="#102a2e" />
|
| 11 |
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
| 12 |
+
<title>CarePath | Bớt gõ bệnh án, thêm thời gian cho người bệnh</title>
|
| 13 |
</head>
|
| 14 |
<body>
|
| 15 |
<div id="root"></div>
|
scribe/frontend/src/App.test.tsx
CHANGED
|
@@ -1,67 +1,33 @@
|
|
| 1 |
-
import {
|
| 2 |
import { beforeEach, describe, expect, it, vi } from "vitest";
|
| 3 |
import App from "./App";
|
| 4 |
|
| 5 |
-
describe("
|
| 6 |
beforeEach(() => {
|
| 7 |
localStorage.clear();
|
| 8 |
window.history.replaceState(null, "", "/");
|
| 9 |
document.documentElement.lang = "vi";
|
| 10 |
-
document.title = "CarePath
|
| 11 |
});
|
| 12 |
|
| 13 |
-
it("
|
|
|
|
| 14 |
render(<App />);
|
| 15 |
|
| 16 |
expect(document.documentElement.lang).toBe("vi");
|
| 17 |
expect(document.title).toBe(
|
| 18 |
-
"CarePath |
|
| 19 |
);
|
| 20 |
-
fireEvent.click(screen.getByRole("button", { name: "EN" }));
|
| 21 |
-
|
| 22 |
-
expect(document.documentElement.lang).toBe("en");
|
| 23 |
-
expect(document.title).toBe(
|
| 24 |
-
"CarePath | Clinical note drafting and live medical interpretation",
|
| 25 |
-
);
|
| 26 |
-
expect(localStorage.getItem("carepath-demo-language")).toBe("en");
|
| 27 |
-
expect(
|
| 28 |
-
screen.getByRole("link", {
|
| 29 |
-
name: "CarePath Interpreter: Get Interpreter updates",
|
| 30 |
-
}),
|
| 31 |
-
).toBeInTheDocument();
|
| 32 |
-
expect(
|
| 33 |
-
screen.getByRole("heading", {
|
| 34 |
-
name: "Choose the right product for the right point in care.",
|
| 35 |
-
}),
|
| 36 |
-
).toBeInTheDocument();
|
| 37 |
-
expect(
|
| 38 |
-
screen.getByRole("heading", {
|
| 39 |
-
name: "One shared oversight layer. Two product-specific safety systems.",
|
| 40 |
-
}),
|
| 41 |
-
).toBeInTheDocument();
|
| 42 |
expect(
|
| 43 |
screen.getByRole("heading", {
|
| 44 |
-
name: "
|
| 45 |
}),
|
| 46 |
).toBeInTheDocument();
|
|
|
|
|
|
|
| 47 |
expect(
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
}),
|
| 51 |
-
).toBeInTheDocument();
|
| 52 |
-
expect(
|
| 53 |
-
screen.getByText("AI assistance, clinician in control."),
|
| 54 |
-
).toBeInTheDocument();
|
| 55 |
-
});
|
| 56 |
-
|
| 57 |
-
it("ignores an invalid stored preference", () => {
|
| 58 |
-
localStorage.setItem("carepath-demo-language", "fr");
|
| 59 |
-
render(<App />);
|
| 60 |
-
|
| 61 |
-
expect(document.documentElement.lang).toBe("vi");
|
| 62 |
-
expect(
|
| 63 |
-
screen.getByRole("button", { name: "VI" }),
|
| 64 |
-
).toHaveAttribute("aria-pressed", "true");
|
| 65 |
});
|
| 66 |
|
| 67 |
it("replaces the legacy #/scribe route with the canonical clinical-note path", async () => {
|
|
@@ -110,7 +76,9 @@ describe("language preference", () => {
|
|
| 110 |
window.dispatchEvent(new PopStateEvent("popstate"));
|
| 111 |
|
| 112 |
expect(
|
| 113 |
-
await screen.findByRole("heading", {
|
|
|
|
|
|
|
| 114 |
).toBeInTheDocument();
|
| 115 |
vi.unstubAllGlobals();
|
| 116 |
});
|
|
|
|
| 1 |
+
import { render, screen } from "@testing-library/react";
|
| 2 |
import { beforeEach, describe, expect, it, vi } from "vitest";
|
| 3 |
import App from "./App";
|
| 4 |
|
| 5 |
+
describe("Vietnamese-only public app", () => {
|
| 6 |
beforeEach(() => {
|
| 7 |
localStorage.clear();
|
| 8 |
window.history.replaceState(null, "", "/");
|
| 9 |
document.documentElement.lang = "vi";
|
| 10 |
+
document.title = "CarePath";
|
| 11 |
});
|
| 12 |
|
| 13 |
+
it("always renders Vietnamese and exposes no language switch", () => {
|
| 14 |
+
localStorage.setItem("carepath-demo-language", "en");
|
| 15 |
render(<App />);
|
| 16 |
|
| 17 |
expect(document.documentElement.lang).toBe("vi");
|
| 18 |
expect(document.title).toBe(
|
| 19 |
+
"CarePath | Bớt gõ bệnh án, thêm thời gian cho người bệnh",
|
| 20 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
expect(
|
| 22 |
screen.getByRole("heading", {
|
| 23 |
+
name: "Dành thời gian cho người bệnh, không phải cho việc gõ bệnh án.",
|
| 24 |
}),
|
| 25 |
).toBeInTheDocument();
|
| 26 |
+
expect(screen.queryByRole("button", { name: "VI" })).not.toBeInTheDocument();
|
| 27 |
+
expect(screen.queryByRole("button", { name: "EN" })).not.toBeInTheDocument();
|
| 28 |
expect(
|
| 29 |
+
document.querySelector('a[href*="phien-dich-y-khoa"], a[href*="console"]'),
|
| 30 |
+
).toBeNull();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
});
|
| 32 |
|
| 33 |
it("replaces the legacy #/scribe route with the canonical clinical-note path", async () => {
|
|
|
|
| 76 |
window.dispatchEvent(new PopStateEvent("popstate"));
|
| 77 |
|
| 78 |
expect(
|
| 79 |
+
await screen.findByRole("heading", {
|
| 80 |
+
name: "Dành thời gian cho người bệnh, không phải cho việc gõ bệnh án.",
|
| 81 |
+
}),
|
| 82 |
).toBeInTheDocument();
|
| 83 |
vi.unstubAllGlobals();
|
| 84 |
});
|
scribe/frontend/src/App.tsx
CHANGED
|
@@ -2,26 +2,20 @@ import { useEffect, useRef, useState } from "react";
|
|
| 2 |
import LandingPage from "./LandingPage";
|
| 3 |
import ScribeTool from "./scribe/ScribeTool";
|
| 4 |
import { copyFor } from "./content/strings";
|
| 5 |
-
import type { Language } from "./demo/types";
|
| 6 |
|
| 7 |
const CLINICAL_NOTES_PATH = "/ghi-chep-lam-sang/";
|
| 8 |
|
| 9 |
export default function App() {
|
| 10 |
-
const [language, setLanguage] = useState<Language>(() => {
|
| 11 |
-
const saved = localStorage.getItem("carepath-demo-language");
|
| 12 |
-
return saved === "en" ? "en" : "vi";
|
| 13 |
-
});
|
| 14 |
const [pathname, setPathname] = useState(() => window.location.pathname);
|
| 15 |
const wasScribeTool = useRef(false);
|
| 16 |
|
| 17 |
useEffect(() => {
|
| 18 |
-
document.documentElement.lang =
|
| 19 |
-
document.title = copyFor(
|
| 20 |
document
|
| 21 |
.querySelector('meta[name="description"]')
|
| 22 |
-
?.setAttribute("content", copyFor(
|
| 23 |
-
|
| 24 |
-
}, [language]);
|
| 25 |
|
| 26 |
useEffect(() => {
|
| 27 |
if (window.location.hash === "#/scribe" || pathname === "/ghi-chep-lam-sang") {
|
|
@@ -63,8 +57,8 @@ export default function App() {
|
|
| 63 |
}, [isScribeTool]);
|
| 64 |
|
| 65 |
return isScribeTool ? (
|
| 66 |
-
<ScribeTool backHref="/"
|
| 67 |
) : (
|
| 68 |
-
<LandingPage
|
| 69 |
);
|
| 70 |
}
|
|
|
|
| 2 |
import LandingPage from "./LandingPage";
|
| 3 |
import ScribeTool from "./scribe/ScribeTool";
|
| 4 |
import { copyFor } from "./content/strings";
|
|
|
|
| 5 |
|
| 6 |
const CLINICAL_NOTES_PATH = "/ghi-chep-lam-sang/";
|
| 7 |
|
| 8 |
export default function App() {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
const [pathname, setPathname] = useState(() => window.location.pathname);
|
| 10 |
const wasScribeTool = useRef(false);
|
| 11 |
|
| 12 |
useEffect(() => {
|
| 13 |
+
document.documentElement.lang = "vi";
|
| 14 |
+
document.title = copyFor("vi").metadata.title;
|
| 15 |
document
|
| 16 |
.querySelector('meta[name="description"]')
|
| 17 |
+
?.setAttribute("content", copyFor("vi").metadata.description);
|
| 18 |
+
}, []);
|
|
|
|
| 19 |
|
| 20 |
useEffect(() => {
|
| 21 |
if (window.location.hash === "#/scribe" || pathname === "/ghi-chep-lam-sang") {
|
|
|
|
| 57 |
}, [isScribeTool]);
|
| 58 |
|
| 59 |
return isScribeTool ? (
|
| 60 |
+
<ScribeTool backHref="/" />
|
| 61 |
) : (
|
| 62 |
+
<LandingPage />
|
| 63 |
);
|
| 64 |
}
|
scribe/frontend/src/LandingPage.test.tsx
CHANGED
|
@@ -1,100 +1,104 @@
|
|
| 1 |
-
import { render, screen } from "@testing-library/react";
|
| 2 |
import { describe, expect, it } from "vitest";
|
| 3 |
-
import LandingPage from "./LandingPage";
|
| 4 |
-
import { copyFor, type ProductKey } from "./content/strings";
|
| 5 |
-
|
| 6 |
-
function expectProductCopy(language: "vi" | "en") {
|
| 7 |
-
const copy = copyFor(language);
|
| 8 |
-
for (const key of ["interpreter", "scribe"] satisfies ProductKey[]) {
|
| 9 |
-
const product = copy.products[key];
|
| 10 |
-
expect(screen.getAllByText(product.name).length).toBeGreaterThan(0);
|
| 11 |
-
expect(screen.getByText(product.body)).toBeInTheDocument();
|
| 12 |
-
expect(screen.getByText(product.audience)).toBeInTheDocument();
|
| 13 |
-
expect(screen.getByText(product.input)).toBeInTheDocument();
|
| 14 |
-
expect(screen.getByText(product.output)).toBeInTheDocument();
|
| 15 |
-
expect(screen.getByText(product.status)).toBeInTheDocument();
|
| 16 |
-
expect(screen.getByText(product.helper)).toBeInTheDocument();
|
| 17 |
-
expect(screen.getByText(product.timing)).toBeInTheDocument();
|
| 18 |
-
expect(screen.getByText(product.chooserSafety)).toBeInTheDocument();
|
| 19 |
-
expect(screen.getByRole("link", {
|
| 20 |
-
name: `${product.name}: ${product.cta.open}`,
|
| 21 |
-
})).toBeInTheDocument();
|
| 22 |
-
}
|
| 23 |
-
}
|
| 24 |
|
| 25 |
describe("LandingPage", () => {
|
| 26 |
-
it("
|
| 27 |
-
render(<LandingPage
|
| 28 |
|
| 29 |
expect(
|
| 30 |
screen.getByRole("heading", {
|
| 31 |
level: 1,
|
| 32 |
-
name: "
|
| 33 |
}),
|
| 34 |
).toBeInTheDocument();
|
| 35 |
expect(
|
| 36 |
-
screen.getByRole("
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
).toBeInTheDocument();
|
| 40 |
-
expect(screen.getByRole("heading", { name: "Phiên dịch khám bệnh trực tiếp" })).toBeInTheDocument();
|
| 41 |
-
expect(screen.getByRole("heading", { name: "Ghi chép bệnh án AI" })).toBeInTheDocument();
|
| 42 |
-
expect(screen.getByRole("heading", { name: "Bác sĩ kiểm tra trước khi dùng" })).toBeInTheDocument();
|
| 43 |
-
expect(screen.getByRole("heading", { name: "Độ không chắc chắn luôn hiển thị" })).toBeInTheDocument();
|
| 44 |
-
expect(screen.getByRole("heading", { name: "Không thay thế chẩn đoán hoặc tư vấn" })).toBeInTheDocument();
|
| 45 |
expect(
|
| 46 |
-
screen.
|
| 47 |
).toBeInTheDocument();
|
| 48 |
-
expect(screen.queryByText("
|
| 49 |
-
|
| 50 |
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
});
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
|
|
|
| 65 |
});
|
| 66 |
|
| 67 |
-
it("
|
| 68 |
-
render(<LandingPage
|
| 69 |
|
| 70 |
-
expect(screen.
|
| 71 |
-
name: "Choose the right product for the right point in care.",
|
| 72 |
-
})).toBeInTheDocument();
|
| 73 |
expect(
|
| 74 |
screen.getByRole("heading", {
|
| 75 |
-
name: "
|
| 76 |
}),
|
| 77 |
).toBeInTheDocument();
|
| 78 |
expect(
|
| 79 |
-
screen.
|
| 80 |
-
name: "Choose the product that fits your workflow.",
|
| 81 |
-
}),
|
| 82 |
).toBeInTheDocument();
|
| 83 |
expect(
|
| 84 |
-
screen.
|
| 85 |
-
).
|
|
|
|
|
|
|
|
|
|
| 86 |
expect(
|
| 87 |
-
screen.
|
| 88 |
-
).
|
| 89 |
-
expect(screen.
|
| 90 |
-
expectProductCopy("en");
|
| 91 |
});
|
| 92 |
|
| 93 |
-
it("keeps the pilot form
|
| 94 |
-
render(<LandingPage
|
| 95 |
|
|
|
|
| 96 |
expect(screen.getByRole("textbox", { name: "Cơ sở y tế" })).toHaveValue("");
|
| 97 |
-
expect(screen.
|
| 98 |
-
expect(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
});
|
| 100 |
});
|
|
|
|
| 1 |
+
import { fireEvent, render, screen } from "@testing-library/react";
|
| 2 |
import { describe, expect, it } from "vitest";
|
| 3 |
+
import LandingPage, { formatMinutes } from "./LandingPage";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
describe("LandingPage", () => {
|
| 6 |
+
it("leads with the doctor's problem and keeps Interpreter unavailable", () => {
|
| 7 |
+
render(<LandingPage />);
|
| 8 |
|
| 9 |
expect(
|
| 10 |
screen.getByRole("heading", {
|
| 11 |
level: 1,
|
| 12 |
+
name: "Dành thời gian cho người bệnh, không phải cho việc gõ bệnh án.",
|
| 13 |
}),
|
| 14 |
).toBeInTheDocument();
|
| 15 |
expect(
|
| 16 |
+
screen.getByRole("link", { name: "Trải nghiệm ca khám mẫu" }),
|
| 17 |
+
).toHaveAttribute("href", "#demo");
|
| 18 |
+
expect(
|
| 19 |
+
screen.getByRole("link", { name: "Tính thời gian ghi chép" }),
|
| 20 |
+
).toHaveAttribute("href", "#calculator");
|
| 21 |
+
expect(screen.queryByRole("button", { name: "EN" })).not.toBeInTheDocument();
|
| 22 |
+
|
| 23 |
+
const status = document.querySelector<HTMLElement>("[data-interpreter-status]");
|
| 24 |
+
expect(status).toHaveTextContent("Phiên dịch khám bệnh trực tiếp");
|
| 25 |
+
expect(status).toHaveTextContent("Đang phát triển — hiện chưa thể truy cập trên web.");
|
| 26 |
+
expect(status?.querySelector("a, button")).toBeNull();
|
| 27 |
+
expect(
|
| 28 |
+
document.querySelector('a[href*="phien-dich-y-khoa"], a[href*="console"]'),
|
| 29 |
+
).toBeNull();
|
| 30 |
+
});
|
| 31 |
+
|
| 32 |
+
it("shows the conversation-to-draft proof without inventing assessment or plan", () => {
|
| 33 |
+
render(<LandingPage />);
|
| 34 |
+
|
| 35 |
+
expect(screen.getByText("Từ cuộc trao đổi đến bản nháp")).toBeInTheDocument();
|
| 36 |
+
expect(
|
| 37 |
+
screen.getByText("Chưa có kết quả cận lâm sàng trong nội dung ghi âm."),
|
| 38 |
).toBeInTheDocument();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
expect(
|
| 40 |
+
screen.getByText(/Nhận định và kế hoạch luôn thuộc về bác sĩ/),
|
| 41 |
).toBeInTheDocument();
|
| 42 |
+
expect(screen.queryByText("Chờ bác sĩ nhập hoặc xác nhận.")).not.toBeInTheDocument();
|
| 43 |
+
});
|
| 44 |
|
| 45 |
+
it("calculates current documentation time without claiming CarePath savings", () => {
|
| 46 |
+
render(<LandingPage />);
|
| 47 |
+
|
| 48 |
+
fireEvent.change(screen.getByRole("spinbutton", { name: "Số người bệnh mỗi ngày" }), {
|
| 49 |
+
target: { value: "28" },
|
| 50 |
});
|
| 51 |
+
fireEvent.change(
|
| 52 |
+
screen.getByRole("spinbutton", { name: "Phút ghi chép cho mỗi người bệnh" }),
|
| 53 |
+
{ target: { value: "5" } },
|
| 54 |
+
);
|
| 55 |
+
|
| 56 |
+
expect(screen.getByText("2 giờ 20 phút mỗi ngày")).toBeInTheDocument();
|
| 57 |
+
expect(screen.getByText("11 giờ 40 phút cho 5 ngày làm việc")).toBeInTheDocument();
|
| 58 |
+
expect(screen.getByText(/Công thức: 28 người bệnh × 5 phút/)).toBeInTheDocument();
|
| 59 |
+
expect(screen.queryByText(/CarePath tiết kiệm/i)).not.toBeInTheDocument();
|
| 60 |
});
|
| 61 |
|
| 62 |
+
it("surfaces verified privacy and Vietnamese-capability claims with review intact", () => {
|
| 63 |
+
render(<LandingPage />);
|
| 64 |
|
| 65 |
+
expect(screen.getByText(/Âm thanh không bị giữ lại sau xử lý/)).toBeInTheDocument();
|
|
|
|
|
|
|
| 66 |
expect(
|
| 67 |
screen.getByRole("heading", {
|
| 68 |
+
name: "Nhận đúng tiếng Việt. Không giữ lại âm thanh.",
|
| 69 |
}),
|
| 70 |
).toBeInTheDocument();
|
| 71 |
expect(
|
| 72 |
+
screen.getByText(/Tệp chỉ được xử lý tạm thời trong phạm vi yêu cầu/),
|
|
|
|
|
|
|
| 73 |
).toBeInTheDocument();
|
| 74 |
expect(
|
| 75 |
+
screen.getByText("Âm thanh buổi khám không bao giờ được dùng để huấn luyện mô hình."),
|
| 76 |
+
).toBeInTheDocument();
|
| 77 |
+
expect(
|
| 78 |
+
screen.getByText(/bác sĩ kiểm tra nhanh và đúng trọng tâm/),
|
| 79 |
+
).toBeInTheDocument();
|
| 80 |
expect(
|
| 81 |
+
screen.getByText(/không tự tạo chẩn đoán, tư vấn hay đơn thuốc/),
|
| 82 |
+
).toBeInTheDocument();
|
| 83 |
+
expect(screen.getByText(/Bác sĩ kiểm tra bản nháp trước khi sử dụng/)).toBeInTheDocument();
|
|
|
|
| 84 |
});
|
| 85 |
|
| 86 |
+
it("keeps the pilot form scoped to Scribe interest", () => {
|
| 87 |
+
render(<LandingPage />);
|
| 88 |
|
| 89 |
+
fireEvent.click(screen.getByText("Dành cho cơ sở muốn thí điểm CarePath"));
|
| 90 |
expect(screen.getByRole("textbox", { name: "Cơ sở y tế" })).toHaveValue("");
|
| 91 |
+
expect(screen.queryByRole("combobox", { name: "Chức năng quan tâm" })).not.toBeInTheDocument();
|
| 92 |
+
expect(
|
| 93 |
+
screen.getByText(/Biểu mẫu này chỉ dành cho Ghi chép bệnh án AI/),
|
| 94 |
+
).toBeInTheDocument();
|
| 95 |
+
});
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
describe("formatMinutes", () => {
|
| 99 |
+
it("formats whole hours and remaining minutes", () => {
|
| 100 |
+
expect(formatMinutes(140)).toBe("2 giờ 20 phút");
|
| 101 |
+
expect(formatMinutes(120)).toBe("2 giờ");
|
| 102 |
+
expect(formatMinutes(45)).toBe("45 phút");
|
| 103 |
});
|
| 104 |
});
|
scribe/frontend/src/LandingPage.tsx
CHANGED
|
@@ -1,58 +1,53 @@
|
|
| 1 |
import { useRef, useState } from "react";
|
| 2 |
import logoUrl from "./assets/carepath.svg";
|
| 3 |
-
import { copyFor
|
| 4 |
-
import {
|
| 5 |
-
import type { Language } from "./demo/types";
|
| 6 |
import LeadForm from "./LeadForm";
|
| 7 |
-
import { leadContact, zaloHref
|
| 8 |
-
import
|
| 9 |
|
| 10 |
-
|
| 11 |
-
language: Language;
|
| 12 |
-
onLanguageChange?: (language: Language) => void;
|
| 13 |
-
}
|
| 14 |
|
| 15 |
-
|
| 16 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
export default function LandingPage({
|
| 19 |
-
|
| 20 |
-
onLanguageChange,
|
| 21 |
-
}: LandingPageProps) {
|
| 22 |
const menuRef = useRef<HTMLDetailsElement>(null);
|
| 23 |
-
const
|
| 24 |
-
const [
|
| 25 |
-
const
|
| 26 |
-
const
|
|
|
|
| 27 |
|
| 28 |
const navLinks = (
|
| 29 |
<>
|
| 30 |
-
<a href="#
|
| 31 |
-
<a href="#
|
| 32 |
-
<a href="#
|
| 33 |
-
<a href="#
|
| 34 |
</>
|
| 35 |
);
|
| 36 |
|
| 37 |
return (
|
| 38 |
<div className="site-shell">
|
| 39 |
-
<nav
|
| 40 |
-
className="site-nav"
|
| 41 |
-
aria-label={language === "vi" ? "Điều hướng chính" : "Main navigation"}
|
| 42 |
-
>
|
| 43 |
<a className="site-nav__brand" href="#top" aria-label="CarePath">
|
| 44 |
<img src={logoUrl} alt="" />
|
| 45 |
<span>CarePath</span>
|
| 46 |
</a>
|
| 47 |
<div className="site-nav__links">{navLinks}</div>
|
| 48 |
<details className="site-nav__menu" ref={menuRef}>
|
| 49 |
-
<summary>
|
| 50 |
<div
|
| 51 |
onClick={(event) => {
|
| 52 |
-
if (
|
| 53 |
-
menuRef.current &&
|
| 54 |
-
(event.target as HTMLElement).closest("a")
|
| 55 |
-
) {
|
| 56 |
menuRef.current.open = false;
|
| 57 |
}
|
| 58 |
}}
|
|
@@ -60,198 +55,275 @@ export default function LandingPage({
|
|
| 60 |
{navLinks}
|
| 61 |
</div>
|
| 62 |
</details>
|
| 63 |
-
<
|
| 64 |
-
<div className="language-toggle" aria-label={copy.language.label}>
|
| 65 |
-
<button
|
| 66 |
-
aria-pressed={language === "vi"}
|
| 67 |
-
onClick={() => onLanguageChange?.("vi")}
|
| 68 |
-
type="button"
|
| 69 |
-
>
|
| 70 |
-
{copy.language.vi}
|
| 71 |
-
</button>
|
| 72 |
-
<button
|
| 73 |
-
aria-pressed={language === "en"}
|
| 74 |
-
onClick={() => onLanguageChange?.("en")}
|
| 75 |
-
type="button"
|
| 76 |
-
>
|
| 77 |
-
{copy.language.en}
|
| 78 |
-
</button>
|
| 79 |
-
</div>
|
| 80 |
-
</div>
|
| 81 |
</nav>
|
| 82 |
|
| 83 |
-
<
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
<div>
|
| 110 |
-
<dt>{copy.gateway.useWhen}</dt>
|
| 111 |
-
<dd>{product.audience}</dd>
|
| 112 |
-
</div>
|
| 113 |
-
<div>
|
| 114 |
-
<dt>{copy.gateway.input}</dt>
|
| 115 |
-
<dd>{product.input}</dd>
|
| 116 |
-
</div>
|
| 117 |
-
<div>
|
| 118 |
-
<dt>{copy.gateway.output}</dt>
|
| 119 |
-
<dd>{product.output}</dd>
|
| 120 |
-
</div>
|
| 121 |
-
</dl>
|
| 122 |
-
<p className="product-accordion__safety">{product.chooserSafety}</p>
|
| 123 |
-
<strong className="product-accordion__cta">
|
| 124 |
-
<span aria-hidden="true">{key === "interpreter" ? "↔" : "☷"}</span>
|
| 125 |
-
{product.cta.open}
|
| 126 |
-
</strong>
|
| 127 |
</a>
|
| 128 |
-
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
</div>
|
| 131 |
</section>
|
| 132 |
|
| 133 |
-
<section className="
|
| 134 |
-
|
| 135 |
-
<
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
</section>
|
| 141 |
|
| 142 |
-
<section
|
| 143 |
-
className="
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
<h2 id="safety-title">{copy.safety.title}</h2>
|
| 150 |
-
<p>{copy.safety.body}</p>
|
| 151 |
</header>
|
| 152 |
-
<
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
<
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
</div>
|
| 180 |
</section>
|
| 181 |
|
| 182 |
-
<section className="
|
| 183 |
-
<header
|
| 184 |
-
<h2 id="
|
| 185 |
-
<p>
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
</
|
| 189 |
</header>
|
| 190 |
-
<div className="
|
| 191 |
-
|
| 192 |
-
<
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
<
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
{item.kind === "scribe" && (
|
| 217 |
-
<dl>
|
| 218 |
-
{soapDraft.slice(0, 2).map((row) => (
|
| 219 |
-
<div key={row.key}>
|
| 220 |
-
<dt>{copy.scribe.soapLabels[row.key]}</dt>
|
| 221 |
-
<dd lang="vi">{row.text}</dd>
|
| 222 |
-
</div>
|
| 223 |
-
))}
|
| 224 |
-
</dl>
|
| 225 |
-
)}
|
| 226 |
-
{item.kind === "research" && (
|
| 227 |
-
<p className="evidence-capture__source">docs/research.md</p>
|
| 228 |
-
)}
|
| 229 |
-
<strong>{item.detail}</strong>
|
| 230 |
-
</div>
|
| 231 |
-
</div>
|
| 232 |
-
</article>
|
| 233 |
-
))}
|
| 234 |
</div>
|
| 235 |
</section>
|
| 236 |
|
| 237 |
-
<section className="
|
| 238 |
-
<div className="
|
| 239 |
-
<h2>
|
| 240 |
-
<p>
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
</
|
| 244 |
-
<
|
|
|
|
|
|
|
| 245 |
</div>
|
| 246 |
-
<
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
</section>
|
| 256 |
</main>
|
| 257 |
|
|
@@ -260,7 +332,6 @@ export default function LandingPage({
|
|
| 260 |
<img src={logoUrl} alt="" />
|
| 261 |
<strong>{copy.footer.promise}</strong>
|
| 262 |
</div>
|
| 263 |
-
<p>{copy.footer.posture}</p>
|
| 264 |
<p>{copy.footer.honesty}</p>
|
| 265 |
<div>
|
| 266 |
<a href={`mailto:${leadContact.email}`}>{copy.footer.contact}</a>
|
|
|
|
| 1 |
import { useRef, useState } from "react";
|
| 2 |
import logoUrl from "./assets/carepath.svg";
|
| 3 |
+
import { copyFor } from "./content/strings";
|
| 4 |
+
import { scenarios } from "./demo/scenarios";
|
|
|
|
| 5 |
import LeadForm from "./LeadForm";
|
| 6 |
+
import { leadContact, zaloHref } from "./leads";
|
| 7 |
+
import ScribeShowcase from "./scribe/ScribeShowcase";
|
| 8 |
|
| 9 |
+
const scribeHref = "/ghi-chep-lam-sang/";
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
+
export function formatMinutes(total: number): string {
|
| 12 |
+
const rounded = Math.max(0, Math.round(total));
|
| 13 |
+
const hours = Math.floor(rounded / 60);
|
| 14 |
+
const minutes = rounded % 60;
|
| 15 |
+
if (hours === 0) return `${minutes} phút`;
|
| 16 |
+
if (minutes === 0) return `${hours} giờ`;
|
| 17 |
+
return `${hours} giờ ${minutes} phút`;
|
| 18 |
+
}
|
| 19 |
|
| 20 |
+
export default function LandingPage() {
|
| 21 |
+
const copy = copyFor("vi");
|
|
|
|
|
|
|
| 22 |
const menuRef = useRef<HTMLDetailsElement>(null);
|
| 23 |
+
const [patients, setPatients] = useState(28);
|
| 24 |
+
const [minutesPerPatient, setMinutesPerPatient] = useState(5);
|
| 25 |
+
const [afterHours, setAfterHours] = useState("sometimes");
|
| 26 |
+
const [workplace, setWorkplace] = useState("clinic");
|
| 27 |
+
const dailyMinutes = patients * minutesPerPatient;
|
| 28 |
|
| 29 |
const navLinks = (
|
| 30 |
<>
|
| 31 |
+
<a href="#calculator">Thời gian ghi chép</a>
|
| 32 |
+
<a href="#demo">Ca khám mẫu</a>
|
| 33 |
+
<a href="#evidence">Bằng chứng</a>
|
| 34 |
+
<a href="#start">Bắt đầu</a>
|
| 35 |
</>
|
| 36 |
);
|
| 37 |
|
| 38 |
return (
|
| 39 |
<div className="site-shell">
|
| 40 |
+
<nav className="site-nav" aria-label="Điều hướng chính">
|
|
|
|
|
|
|
|
|
|
| 41 |
<a className="site-nav__brand" href="#top" aria-label="CarePath">
|
| 42 |
<img src={logoUrl} alt="" />
|
| 43 |
<span>CarePath</span>
|
| 44 |
</a>
|
| 45 |
<div className="site-nav__links">{navLinks}</div>
|
| 46 |
<details className="site-nav__menu" ref={menuRef}>
|
| 47 |
+
<summary>Mở điều hướng</summary>
|
| 48 |
<div
|
| 49 |
onClick={(event) => {
|
| 50 |
+
if (menuRef.current && (event.target as HTMLElement).closest("a")) {
|
|
|
|
|
|
|
|
|
|
| 51 |
menuRef.current.open = false;
|
| 52 |
}
|
| 53 |
}}
|
|
|
|
| 55 |
{navLinks}
|
| 56 |
</div>
|
| 57 |
</details>
|
| 58 |
+
<a className="nav-cta" href={scribeHref}>Bắt đầu ghi chép</a>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
</nav>
|
| 60 |
|
| 61 |
+
<aside
|
| 62 |
+
className="interpreter-status"
|
| 63 |
+
aria-labelledby="interpreter-status-title"
|
| 64 |
+
data-interpreter-status
|
| 65 |
+
>
|
| 66 |
+
<strong id="interpreter-status-title">Phiên dịch khám bệnh trực tiếp</strong>
|
| 67 |
+
<span>Đang phát triển — hiện chưa thể truy cập trên web.</span>
|
| 68 |
+
</aside>
|
| 69 |
+
|
| 70 |
+
<main className="landing-main" id="top" tabIndex={-1}>
|
| 71 |
+
<section className="onboarding-hero" aria-labelledby="onboarding-title">
|
| 72 |
+
<h1 id="onboarding-title">
|
| 73 |
+
Dành thời gian cho người bệnh, không phải cho việc gõ bệnh án.
|
| 74 |
+
</h1>
|
| 75 |
+
<div className="onboarding-hero__body">
|
| 76 |
+
<div className="onboarding-hero__copy">
|
| 77 |
+
<p>
|
| 78 |
+
CarePath chuẩn bị ghi chú lâm sàng từ cuộc trao đổi để bác sĩ
|
| 79 |
+
kiểm tra và xác nhận.
|
| 80 |
+
</p>
|
| 81 |
+
<div className="onboarding-hero__actions">
|
| 82 |
+
<a className="button-link button-link--primary" href="#demo">
|
| 83 |
+
Trải nghiệm ca khám mẫu
|
| 84 |
+
</a>
|
| 85 |
+
<a className="button-link button-link--secondary" href="#calculator">
|
| 86 |
+
Tính thời gian ghi chép
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
</a>
|
| 88 |
+
</div>
|
| 89 |
+
<small>
|
| 90 |
+
Dùng tệp âm thanh đã được phép sử dụng. Âm thanh không bị giữ
|
| 91 |
+
lại sau xử lý; bản nháp không tự đi vào hồ sơ.
|
| 92 |
+
</small>
|
| 93 |
+
</div>
|
| 94 |
+
|
| 95 |
+
<div className="hero-proof" aria-label="Từ cuộc trao đổi đến bản nháp">
|
| 96 |
+
<p className="hero-proof__title">Từ cuộc trao đổi đến bản nháp</p>
|
| 97 |
+
<div className="hero-proof__stage">
|
| 98 |
+
<span>Trong ghi âm</span>
|
| 99 |
+
<p>
|
| 100 |
+
<strong>Bệnh nhân:</strong> Tôi bị đau tức ngực thoáng qua từ
|
| 101 |
+
sáng, hơi khó thở.
|
| 102 |
+
</p>
|
| 103 |
+
</div>
|
| 104 |
+
<div className="hero-proof__stage hero-proof__stage--draft">
|
| 105 |
+
<span>Bản nháp cho bác sĩ kiểm tra</span>
|
| 106 |
+
<dl>
|
| 107 |
+
<div>
|
| 108 |
+
<dt>Triệu chứng chủ quan</dt>
|
| 109 |
+
<dd>Đau tức ngực thoáng qua từ sáng, khó thở nhẹ. Chưa dùng thuốc.</dd>
|
| 110 |
+
</div>
|
| 111 |
+
<div>
|
| 112 |
+
<dt>Thông tin khách quan</dt>
|
| 113 |
+
<dd>Chưa có kết quả cận lâm sàng trong nội dung ghi âm.</dd>
|
| 114 |
+
</div>
|
| 115 |
+
</dl>
|
| 116 |
+
</div>
|
| 117 |
+
<p className="hero-proof__caption">
|
| 118 |
+
Ca khám mẫu đã làm sạch. Nhận định và kế hoạch luôn thuộc về bác sĩ.
|
| 119 |
+
</p>
|
| 120 |
+
</div>
|
| 121 |
</div>
|
| 122 |
</section>
|
| 123 |
|
| 124 |
+
<section className="burden-section" id="calculator" aria-labelledby="burden-title">
|
| 125 |
+
<header className="compact-heading">
|
| 126 |
+
<h2 id="burden-title">Một buổi khám không nên biến thành hai ca làm việc.</h2>
|
| 127 |
+
<p>
|
| 128 |
+
Hãy bắt đầu bằng thời gian bác sĩ đang dành cho ghi chép, không
|
| 129 |
+
phải bằng một lời hứa tiết kiệm chưa được kiểm chứng.
|
| 130 |
+
</p>
|
| 131 |
+
</header>
|
| 132 |
+
|
| 133 |
+
<div className="burden-layout">
|
| 134 |
+
<form className="burden-calculator" onSubmit={(event) => event.preventDefault()}>
|
| 135 |
+
<h3>Ước tính gánh nặng hiện tại</h3>
|
| 136 |
+
<p>Con số chỉ dựa trên câu trả lời của bạn và không được gửi đi.</p>
|
| 137 |
+
<div className="calculator-fields">
|
| 138 |
+
<label>
|
| 139 |
+
Số người bệnh mỗi ngày
|
| 140 |
+
<input
|
| 141 |
+
max="200"
|
| 142 |
+
min="0"
|
| 143 |
+
onChange={(event) => setPatients(Math.min(200, Math.max(0, Number(event.target.value))))}
|
| 144 |
+
type="number"
|
| 145 |
+
value={patients}
|
| 146 |
+
/>
|
| 147 |
+
</label>
|
| 148 |
+
<label>
|
| 149 |
+
Phút ghi chép cho mỗi người bệnh
|
| 150 |
+
<input
|
| 151 |
+
max="120"
|
| 152 |
+
min="0"
|
| 153 |
+
onChange={(event) => setMinutesPerPatient(Math.min(120, Math.max(0, Number(event.target.value))))}
|
| 154 |
+
type="number"
|
| 155 |
+
value={minutesPerPatient}
|
| 156 |
+
/>
|
| 157 |
+
</label>
|
| 158 |
+
<label>
|
| 159 |
+
Bạn thường hoàn tất bệnh án sau giờ làm?
|
| 160 |
+
<select onChange={(event) => setAfterHours(event.target.value)} value={afterHours}>
|
| 161 |
+
<option value="never">Hiếm khi</option>
|
| 162 |
+
<option value="sometimes">Một vài ngày trong tuần</option>
|
| 163 |
+
<option value="often">Gần như mỗi ngày</option>
|
| 164 |
+
</select>
|
| 165 |
+
</label>
|
| 166 |
+
<label>
|
| 167 |
+
Nơi làm việc chính
|
| 168 |
+
<select onChange={(event) => setWorkplace(event.target.value)} value={workplace}>
|
| 169 |
+
<option value="clinic">Phòng khám</option>
|
| 170 |
+
<option value="hospital">Bệnh viện</option>
|
| 171 |
+
<option value="other">Cơ sở khác</option>
|
| 172 |
+
</select>
|
| 173 |
+
</label>
|
| 174 |
+
</div>
|
| 175 |
+
<output className="calculator-result" aria-live="polite">
|
| 176 |
+
<span>Thời gian ghi chép ước tính hiện tại</span>
|
| 177 |
+
<strong>{formatMinutes(dailyMinutes)} mỗi ngày</strong>
|
| 178 |
+
<small>{formatMinutes(dailyMinutes * 5)} cho 5 ngày làm việc</small>
|
| 179 |
+
</output>
|
| 180 |
+
<p className="calculator-formula">
|
| 181 |
+
Công thức: {patients} người bệnh × {minutesPerPatient} phút. Tần
|
| 182 |
+
suất ngoài giờ và nơi làm việc giúp bạn tự đối chiếu bối cảnh,
|
| 183 |
+
không làm thay đổi phép tính.
|
| 184 |
+
</p>
|
| 185 |
+
</form>
|
| 186 |
+
|
| 187 |
+
<div className="workflow-comparison">
|
| 188 |
+
<article>
|
| 189 |
+
<h3>Khi chưa có bản nháp</h3>
|
| 190 |
+
<p>Nhớ lại cuộc trao đổi, gõ lại từng mục, rồi tự tìm phần còn thiếu.</p>
|
| 191 |
+
</article>
|
| 192 |
+
<article>
|
| 193 |
+
<h3>Khi dùng CarePath</h3>
|
| 194 |
+
<p>
|
| 195 |
+
Tải tệp đã được phép sử dụng, đọc lại lời chép và bắt đầu từ
|
| 196 |
+
một bản nháp có cấu trúc.
|
| 197 |
+
</p>
|
| 198 |
+
</article>
|
| 199 |
+
<p>Bác sĩ vẫn là người sửa, bổ sung và quyết định bản cuối.</p>
|
| 200 |
+
</div>
|
| 201 |
+
</div>
|
| 202 |
</section>
|
| 203 |
|
| 204 |
+
<section className="demo-section" id="demo" aria-labelledby="demo-title">
|
| 205 |
+
<header className="compact-heading compact-heading--wide">
|
| 206 |
+
<h2 id="demo-title">Thử một ca khám mẫu trước khi tải tệp của bạn.</h2>
|
| 207 |
+
<p>
|
| 208 |
+
Đi qua bốn bước ngắn để thấy nội dung nào đến từ cuộc trao đổi,
|
| 209 |
+
nội dung nào được làm rõ và phần nào bác sĩ phải tự xác nhận.
|
| 210 |
+
</p>
|
|
|
|
|
|
|
| 211 |
</header>
|
| 212 |
+
<ScribeShowcase />
|
| 213 |
+
</section>
|
| 214 |
+
|
| 215 |
+
<section className="evidence-section" id="evidence" aria-labelledby="evidence-title">
|
| 216 |
+
<header className="compact-heading compact-heading--wide">
|
| 217 |
+
<h2 id="evidence-title">Bằng chứng được đặt đúng giới hạn.</h2>
|
| 218 |
+
<p>
|
| 219 |
+
Nhu cầu là có thật. Kết quả quốc tế đáng để học hỏi. Hiệu quả của
|
| 220 |
+
CarePath tại Việt Nam vẫn cần được đo trong thí điểm.
|
| 221 |
+
</p>
|
| 222 |
+
</header>
|
| 223 |
+
<div className="evidence-grid">
|
| 224 |
+
<article>
|
| 225 |
+
<h3>Bối cảnh Việt Nam</h3>
|
| 226 |
+
<p>
|
| 227 |
+
Một định mức ban hành năm 2015 từng đặt 33–45 lượt khám mỗi ngày
|
| 228 |
+
tùy hạng bệnh viện, tương đương khoảng 11–15 phút cho mỗi lượt
|
| 229 |
+
trong 8 giờ. Đây là mốc lịch sử, không phải số đo hiện tại.
|
| 230 |
+
</p>
|
| 231 |
+
<a href="https://baohiemxahoi.gov.vn/tintuc/Pages/linh-vuc-bao-hiem-xa-hoi.aspx?CateID=0&ItemID=8449">
|
| 232 |
+
Xem nguồn Bảo hiểm xã hội Việt Nam
|
| 233 |
+
</a>
|
| 234 |
+
</article>
|
| 235 |
+
<article>
|
| 236 |
+
<h3>Kinh nghiệm quốc tế</h3>
|
| 237 |
+
<p>
|
| 238 |
+
Các nghiên cứu quan sát tại Singapore và Hoa Kỳ ghi nhận ít thời
|
| 239 |
+
gian ghi chép hơn, nhiều giao tiếp bằng mắt hơn hoặc giảm kiệt
|
| 240 |
+
sức sau khi dùng công cụ ghi chép môi trường. Đây không phải kết
|
| 241 |
+
quả của CarePath.
|
| 242 |
+
</p>
|
| 243 |
+
<div className="evidence-links">
|
| 244 |
+
<a href="https://medinform.jmir.org/2026/1/e85580">Nghiên cứu JMIR 2026</a>
|
| 245 |
+
<a href="https://jamanetwork.com/journals/jamanetworkopen/fullarticle/2839542">
|
| 246 |
+
Nghiên cứu JAMA Network Open
|
| 247 |
+
</a>
|
| 248 |
+
</div>
|
| 249 |
+
</article>
|
| 250 |
+
<article>
|
| 251 |
+
<h3>Điều CarePath còn phải chứng minh</h3>
|
| 252 |
+
<p>
|
| 253 |
+
Chưa có dữ liệu triển khai CarePath tại Việt Nam. Thí điểm cần đo
|
| 254 |
+
thời gian hoàn tất bệnh án, lỗi bác sĩ phải sửa và mức chấp nhận
|
| 255 |
+
của người dùng trước khi đưa ra tuyên bố hiệu quả.
|
| 256 |
+
</p>
|
| 257 |
+
</article>
|
| 258 |
</div>
|
| 259 |
</section>
|
| 260 |
|
| 261 |
+
<section className="trust-section" aria-labelledby="trust-title">
|
| 262 |
+
<header>
|
| 263 |
+
<h2 id="trust-title">Nhận đúng tiếng Việt. Không giữ lại âm thanh.</h2>
|
| 264 |
+
<p>
|
| 265 |
+
CarePath hỗ trợ ghi chép, không tự tạo chẩn đoán, tư vấn hay đơn
|
| 266 |
+
thuốc. Bác sĩ kiểm tra bản nháp trước khi sử dụng.
|
| 267 |
+
</p>
|
| 268 |
</header>
|
| 269 |
+
<div className="trust-grid">
|
| 270 |
+
<article>
|
| 271 |
+
<h3>Nhận đúng tiếng Việt lâm sàng</h3>
|
| 272 |
+
<ul>
|
| 273 |
+
<li>Mô hình nhận dạng giọng nói tiếng Việt, giữ nguyên dấu và chính tả.</li>
|
| 274 |
+
<li>
|
| 275 |
+
Thuật ngữ y khoa được đối chiếu với bộ từ điển tiếng Việt
|
| 276 |
+
tuyển chọn, nhận cả cách nói tắt và thiếu dấu.
|
| 277 |
+
</li>
|
| 278 |
+
<li>
|
| 279 |
+
Lời chép gốc, chỗ đã chỉnh và phần còn thiếu đều hiển thị rõ,
|
| 280 |
+
nên bác sĩ kiểm tra nhanh và đúng trọng tâm.
|
| 281 |
+
</li>
|
| 282 |
+
</ul>
|
| 283 |
+
</article>
|
| 284 |
+
<article>
|
| 285 |
+
<h3>Âm thanh của buổi khám không bị giữ lại</h3>
|
| 286 |
+
<ul>
|
| 287 |
+
<li>
|
| 288 |
+
Tệp chỉ được xử lý tạm thời trong phạm vi yêu cầu và bị xóa
|
| 289 |
+
ngay khi xử lý xong.
|
| 290 |
+
</li>
|
| 291 |
+
<li>Âm thanh buổi khám không bao giờ được dùng để huấn luyện mô hình.</li>
|
| 292 |
+
<li>Màn hình không tự bắt đầu micro; chỉ dùng tệp cơ sở chủ động tải lên.</li>
|
| 293 |
+
</ul>
|
| 294 |
+
</article>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
</div>
|
| 296 |
</section>
|
| 297 |
|
| 298 |
+
<section className="start-section" id="start" aria-labelledby="start-title">
|
| 299 |
+
<div className="start-section__copy">
|
| 300 |
+
<h2 id="start-title">Bắt đầu từ một tệp âm thanh đã được phép sử dụng.</h2>
|
| 301 |
+
<p>
|
| 302 |
+
Công cụ sẽ chỉ cho bạn từng bước tải tệp, tạo bản nháp và kiểm tra
|
| 303 |
+
trước khi sử dụng.
|
| 304 |
+
</p>
|
| 305 |
+
<a className="button-link button-link--light" href={scribeHref}>
|
| 306 |
+
Bắt đầu ghi chép
|
| 307 |
+
</a>
|
| 308 |
</div>
|
| 309 |
+
<details className="pilot-disclosure">
|
| 310 |
+
<summary>Dành cho cơ sở muốn thí điểm CarePath</summary>
|
| 311 |
+
<div>
|
| 312 |
+
<p>
|
| 313 |
+
Để lại bối cảnh làm việc nếu cơ sở muốn cùng xác định cách thử
|
| 314 |
+
nghiệm phù hợp. Biểu mẫu này chỉ dành cho Ghi chép bệnh án AI.
|
| 315 |
+
</p>
|
| 316 |
+
<LeadForm
|
| 317 |
+
clinic=""
|
| 318 |
+
specialty=""
|
| 319 |
+
scenario={scenarios[0]}
|
| 320 |
+
transcript=""
|
| 321 |
+
language="vi"
|
| 322 |
+
interest="scribe"
|
| 323 |
+
hideInterestField
|
| 324 |
+
/>
|
| 325 |
+
</div>
|
| 326 |
+
</details>
|
| 327 |
</section>
|
| 328 |
</main>
|
| 329 |
|
|
|
|
| 332 |
<img src={logoUrl} alt="" />
|
| 333 |
<strong>{copy.footer.promise}</strong>
|
| 334 |
</div>
|
|
|
|
| 335 |
<p>{copy.footer.honesty}</p>
|
| 336 |
<div>
|
| 337 |
<a href={`mailto:${leadContact.email}`}>{copy.footer.contact}</a>
|
scribe/frontend/src/LeadForm.test.tsx
CHANGED
|
@@ -182,6 +182,31 @@ describe("LeadForm", () => {
|
|
| 182 |
expect(onInterestChange).toHaveBeenCalledWith("both");
|
| 183 |
});
|
| 184 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
it("includes the simulation context only after explicit opt-in", async () => {
|
| 186 |
const fetcher = vi.fn(async () => new Response(null, { status: 204 })) as unknown as typeof fetch;
|
| 187 |
render(
|
|
|
|
| 182 |
expect(onInterestChange).toHaveBeenCalledWith("both");
|
| 183 |
});
|
| 184 |
|
| 185 |
+
it("submits a fixed Scribe interest when the landing selector is hidden", async () => {
|
| 186 |
+
const fetcher = vi.fn(async () => new Response(null, { status: 204 })) as unknown as typeof fetch;
|
| 187 |
+
render(
|
| 188 |
+
<LeadForm
|
| 189 |
+
clinic="Phòng khám Minh Tâm"
|
| 190 |
+
endpoint="https://leads.example.test/pilot"
|
| 191 |
+
fetcher={fetcher}
|
| 192 |
+
hideInterestField
|
| 193 |
+
interest="scribe"
|
| 194 |
+
language="vi"
|
| 195 |
+
scenario={scenarios[0]}
|
| 196 |
+
specialty="Nội tổng quát"
|
| 197 |
+
transcript=""
|
| 198 |
+
/>,
|
| 199 |
+
);
|
| 200 |
+
|
| 201 |
+
expect(screen.queryByRole("combobox", { name: "Chức năng quan tâm" })).not.toBeInTheDocument();
|
| 202 |
+
fillRequiredFields();
|
| 203 |
+
fireEvent.click(screen.getByRole("button", { name: "Gửi yêu cầu thí điểm" }));
|
| 204 |
+
|
| 205 |
+
await waitFor(() => expect(fetcher).toHaveBeenCalledOnce());
|
| 206 |
+
const [, request] = (fetcher as ReturnType<typeof vi.fn>).mock.calls[0];
|
| 207 |
+
expect(JSON.parse(String(request.body)).interest).toBe("scribe");
|
| 208 |
+
});
|
| 209 |
+
|
| 210 |
it("includes the simulation context only after explicit opt-in", async () => {
|
| 211 |
const fetcher = vi.fn(async () => new Response(null, { status: 204 })) as unknown as typeof fetch;
|
| 212 |
render(
|
scribe/frontend/src/LeadForm.tsx
CHANGED
|
@@ -22,6 +22,7 @@ interface LeadFormProps {
|
|
| 22 |
onMailto?: (url: string) => void;
|
| 23 |
interest?: ProductInterest;
|
| 24 |
onInterestChange?: (interest: ProductInterest) => void;
|
|
|
|
| 25 |
}
|
| 26 |
|
| 27 |
export default function LeadForm({
|
|
@@ -36,6 +37,7 @@ export default function LeadForm({
|
|
| 36 |
onMailto,
|
| 37 |
interest: controlledInterest,
|
| 38 |
onInterestChange,
|
|
|
|
| 39 |
}: LeadFormProps) {
|
| 40 |
const labels = copyFor(language).form;
|
| 41 |
const [localInterest, setLocalInterest] = useState<ProductInterest>("both");
|
|
@@ -161,25 +163,27 @@ export default function LeadForm({
|
|
| 161 |
{labels.includeDemoContext}
|
| 162 |
</label>
|
| 163 |
)}
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
|
|
|
|
|
|
| 183 |
<label>
|
| 184 |
{labels.role}
|
| 185 |
<input
|
|
|
|
| 22 |
onMailto?: (url: string) => void;
|
| 23 |
interest?: ProductInterest;
|
| 24 |
onInterestChange?: (interest: ProductInterest) => void;
|
| 25 |
+
hideInterestField?: boolean;
|
| 26 |
}
|
| 27 |
|
| 28 |
export default function LeadForm({
|
|
|
|
| 37 |
onMailto,
|
| 38 |
interest: controlledInterest,
|
| 39 |
onInterestChange,
|
| 40 |
+
hideInterestField = false,
|
| 41 |
}: LeadFormProps) {
|
| 42 |
const labels = copyFor(language).form;
|
| 43 |
const [localInterest, setLocalInterest] = useState<ProductInterest>("both");
|
|
|
|
| 163 |
{labels.includeDemoContext}
|
| 164 |
</label>
|
| 165 |
)}
|
| 166 |
+
{!hideInterestField && (
|
| 167 |
+
<label>
|
| 168 |
+
{labels.interest}
|
| 169 |
+
<select
|
| 170 |
+
value={interest}
|
| 171 |
+
onChange={(event) => {
|
| 172 |
+
const nextInterest = event.target.value as ProductInterest;
|
| 173 |
+
if (controlledInterest === undefined) {
|
| 174 |
+
setLocalInterest(nextInterest);
|
| 175 |
+
}
|
| 176 |
+
onInterestChange?.(nextInterest);
|
| 177 |
+
}}
|
| 178 |
+
>
|
| 179 |
+
<option value="interpreter">
|
| 180 |
+
{labels.interestOptions.interpreter}
|
| 181 |
+
</option>
|
| 182 |
+
<option value="scribe">{labels.interestOptions.scribe}</option>
|
| 183 |
+
<option value="both">{labels.interestOptions.both}</option>
|
| 184 |
+
</select>
|
| 185 |
+
</label>
|
| 186 |
+
)}
|
| 187 |
<label>
|
| 188 |
{labels.role}
|
| 189 |
<input
|
scribe/frontend/src/content/strings.ts
CHANGED
|
@@ -42,6 +42,68 @@ interface SafetyCardCopy {
|
|
| 42 |
items: SafetyItem[];
|
| 43 |
}
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
interface PageCopy {
|
| 46 |
metadata: {
|
| 47 |
title: string;
|
|
@@ -53,12 +115,13 @@ interface PageCopy {
|
|
| 53 |
en: string;
|
| 54 |
};
|
| 55 |
nav: {
|
| 56 |
-
|
| 57 |
-
|
| 58 |
safety: string;
|
| 59 |
pilot: string;
|
| 60 |
menu: string;
|
| 61 |
};
|
|
|
|
| 62 |
demo: {
|
| 63 |
brand: string;
|
| 64 |
consoleCta: string;
|
|
@@ -148,7 +211,7 @@ interface PageCopy {
|
|
| 148 |
title: string;
|
| 149 |
helper: string;
|
| 150 |
intro: string;
|
| 151 |
-
preStartSteps: [string, string, string
|
| 152 |
preStartReminder: string;
|
| 153 |
uploadNotice: string;
|
| 154 |
continue: string;
|
|
@@ -221,8 +284,8 @@ interface PageCopy {
|
|
| 221 |
export const strings: Record<Language, PageCopy> = {
|
| 222 |
vi: {
|
| 223 |
metadata: {
|
| 224 |
-
title: "CarePath |
|
| 225 |
-
description: "CarePath
|
| 226 |
},
|
| 227 |
language: {
|
| 228 |
label: "Ngôn ngữ",
|
|
@@ -230,12 +293,120 @@ export const strings: Record<Language, PageCopy> = {
|
|
| 230 |
en: "EN",
|
| 231 |
},
|
| 232 |
nav: {
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
safety: "
|
| 236 |
-
pilot: "
|
| 237 |
menu: "Mở điều hướng",
|
| 238 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
demo: {
|
| 240 |
brand: "CarePath Phiên dịch",
|
| 241 |
consoleCta: "Mở công cụ Phiên dịch",
|
|
@@ -438,10 +609,9 @@ export const strings: Record<Language, PageCopy> = {
|
|
| 438 |
helper: "Tạo bản nháp y khoa",
|
| 439 |
intro: "AI dùng phiên âm tự động để tạo bản nháp y khoa theo bốn mục SOAP; bác sĩ cần kiểm tra trước khi sử dụng.",
|
| 440 |
preStartSteps: [
|
| 441 |
-
"
|
| 442 |
-
"
|
| 443 |
-
"
|
| 444 |
-
"B��c sĩ kiểm tra, chỉnh sửa; bản nháp không tự vào hồ sơ",
|
| 445 |
],
|
| 446 |
preStartReminder: "AI chỉ hỗ trợ tạo bản nháp. Bác sĩ cần kiểm tra lại nội dung trước khi sử dụng.",
|
| 447 |
uploadNotice: "Cơ sở chủ động chọn và tải tệp ghi âm lên để tạo bản nháp. Tệp chỉ được xử lý tạm thời trong phạm vi yêu cầu này; màn hình không tự bắt đầu micro.",
|
|
@@ -616,14 +786,14 @@ export const strings: Record<Language, PageCopy> = {
|
|
| 616 |
footer: {
|
| 617 |
promise: "AI hỗ trợ, bác sĩ giữ quyền quyết định.",
|
| 618 |
posture: "Thiết kế theo hướng giảm thiểu dữ liệu, nhận biết yêu cầu của Nghị định 13/PDP và định vị phù hợp với nguyên tắc tiếp cận ngôn ngữ của §1557. Đây không phải tuyên bố chứng nhận pháp lý.",
|
| 619 |
-
honesty: "
|
| 620 |
contact: "Liên hệ chương trình thí điểm",
|
| 621 |
},
|
| 622 |
},
|
| 623 |
en: {
|
| 624 |
metadata: {
|
| 625 |
-
title: "CarePath |
|
| 626 |
-
description: "CarePath
|
| 627 |
},
|
| 628 |
language: {
|
| 629 |
label: "Language",
|
|
@@ -631,12 +801,120 @@ export const strings: Record<Language, PageCopy> = {
|
|
| 631 |
en: "EN",
|
| 632 |
},
|
| 633 |
nav: {
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
safety: "
|
| 637 |
-
pilot: "
|
| 638 |
menu: "Open navigation",
|
| 639 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 640 |
demo: {
|
| 641 |
brand: "CarePath Interpreter",
|
| 642 |
consoleCta: "Open the Interpreter tool",
|
|
@@ -839,10 +1117,9 @@ export const strings: Record<Language, PageCopy> = {
|
|
| 839 |
helper: "AI Scribe",
|
| 840 |
intro: "Upload a Vietnamese consultation recording. The system uses automated transcription, corrects medical terms, and creates a four-part SOAP draft for clinician review.",
|
| 841 |
preStartSteps: [
|
| 842 |
-
"
|
| 843 |
-
"
|
| 844 |
-
"
|
| 845 |
-
"The clinician checks and edits; the draft never enters the record automatically",
|
| 846 |
],
|
| 847 |
preStartReminder: "AI assists with drafting only. The clinician must review the draft before use.",
|
| 848 |
uploadNotice: "The clinic deliberately selects and uploads the recording file to create a draft. The file is processed temporarily for this request only; this screen does not start the microphone.",
|
|
@@ -1017,7 +1294,7 @@ export const strings: Record<Language, PageCopy> = {
|
|
| 1017 |
footer: {
|
| 1018 |
promise: "AI assistance, clinician in control.",
|
| 1019 |
posture: "Designed around data minimization, awareness of Decree 13/PDP obligations, and §1557-aligned language-access positioning. This is not a legal certification claim.",
|
| 1020 |
-
honesty: "
|
| 1021 |
contact: "Contact the pilot program",
|
| 1022 |
},
|
| 1023 |
},
|
|
|
|
| 42 |
items: SafetyItem[];
|
| 43 |
}
|
| 44 |
|
| 45 |
+
interface LandingCopy {
|
| 46 |
+
status: { title: string; state: string; body: string };
|
| 47 |
+
hero: {
|
| 48 |
+
title: string;
|
| 49 |
+
body: string;
|
| 50 |
+
primary: string;
|
| 51 |
+
secondary: string;
|
| 52 |
+
visual: {
|
| 53 |
+
sourceLabel: string;
|
| 54 |
+
sourceText: string;
|
| 55 |
+
transcriptLabel: string;
|
| 56 |
+
transcriptText: string;
|
| 57 |
+
draftLabel: string;
|
| 58 |
+
draftText: string;
|
| 59 |
+
};
|
| 60 |
+
};
|
| 61 |
+
process: { label: string; items: string[] };
|
| 62 |
+
problem: {
|
| 63 |
+
title: string;
|
| 64 |
+
body: string;
|
| 65 |
+
duringTitle: string;
|
| 66 |
+
duringBody: string;
|
| 67 |
+
afterTitle: string;
|
| 68 |
+
afterBody: string;
|
| 69 |
+
workflowTitle: string;
|
| 70 |
+
workflowBody: string;
|
| 71 |
+
reviewTitle: string;
|
| 72 |
+
reviewBody: string;
|
| 73 |
+
};
|
| 74 |
+
workflow: {
|
| 75 |
+
title: string;
|
| 76 |
+
body: string;
|
| 77 |
+
stages: Array<{ title: string; body: string }>;
|
| 78 |
+
};
|
| 79 |
+
story: {
|
| 80 |
+
titleStart: string;
|
| 81 |
+
titleEnd: string;
|
| 82 |
+
body: string;
|
| 83 |
+
rawTitle: string;
|
| 84 |
+
rawText: string;
|
| 85 |
+
rawDetail: string;
|
| 86 |
+
correctedTitle: string;
|
| 87 |
+
correctedText: string;
|
| 88 |
+
correctedDetail: string;
|
| 89 |
+
draftTitle: string;
|
| 90 |
+
draftDetail: string;
|
| 91 |
+
};
|
| 92 |
+
safety: {
|
| 93 |
+
title: string;
|
| 94 |
+
body: string;
|
| 95 |
+
items: Array<{ title: string; body: string }>;
|
| 96 |
+
};
|
| 97 |
+
action: {
|
| 98 |
+
title: string;
|
| 99 |
+
body: string;
|
| 100 |
+
primary: string;
|
| 101 |
+
pilotTitle: string;
|
| 102 |
+
pilotBody: string;
|
| 103 |
+
pilotNote: string;
|
| 104 |
+
};
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
interface PageCopy {
|
| 108 |
metadata: {
|
| 109 |
title: string;
|
|
|
|
| 115 |
en: string;
|
| 116 |
};
|
| 117 |
nav: {
|
| 118 |
+
need: string;
|
| 119 |
+
workflow: string;
|
| 120 |
safety: string;
|
| 121 |
pilot: string;
|
| 122 |
menu: string;
|
| 123 |
};
|
| 124 |
+
landing: LandingCopy;
|
| 125 |
demo: {
|
| 126 |
brand: string;
|
| 127 |
consoleCta: string;
|
|
|
|
| 211 |
title: string;
|
| 212 |
helper: string;
|
| 213 |
intro: string;
|
| 214 |
+
preStartSteps: [string, string, string];
|
| 215 |
preStartReminder: string;
|
| 216 |
uploadNotice: string;
|
| 217 |
continue: string;
|
|
|
|
| 284 |
export const strings: Record<Language, PageCopy> = {
|
| 285 |
vi: {
|
| 286 |
metadata: {
|
| 287 |
+
title: "CarePath | Bớt gõ bệnh án, thêm thời gian cho người bệnh",
|
| 288 |
+
description: "CarePath chuẩn bị bản nháp ghi chú lâm sàng từ tệp âm thanh để bác sĩ kiểm tra. Phiên dịch trực tiếp đang phát triển và chưa mở trên web.",
|
| 289 |
},
|
| 290 |
language: {
|
| 291 |
label: "Ngôn ngữ",
|
|
|
|
| 293 |
en: "EN",
|
| 294 |
},
|
| 295 |
nav: {
|
| 296 |
+
need: "Vì sao cần",
|
| 297 |
+
workflow: "Cách hoạt động",
|
| 298 |
+
safety: "Bác sĩ kiểm tra",
|
| 299 |
+
pilot: "Dùng thử",
|
| 300 |
menu: "Mở điều hướng",
|
| 301 |
},
|
| 302 |
+
landing: {
|
| 303 |
+
status: {
|
| 304 |
+
title: "Phiên dịch khám bệnh trực tiếp",
|
| 305 |
+
state: "Đang phát triển — hiện chưa thể truy cập trên web.",
|
| 306 |
+
body: "CarePath đang hoàn thiện các luồng an toàn cần thiết trước khi mở lại chức năng này.",
|
| 307 |
+
},
|
| 308 |
+
hero: {
|
| 309 |
+
title: "Tập trung vào người bệnh. Để CarePath soạn bản nháp sau buổi khám.",
|
| 310 |
+
body: "Tải lên tệp âm thanh buổi khám đã được phép sử dụng. CarePath chép lời, chuẩn hóa thuật ngữ và tạo bản nháp bệnh án có cấu trúc để bác sĩ kiểm tra.",
|
| 311 |
+
primary: "Bắt đầu ghi chép",
|
| 312 |
+
secondary: "Xem cách hoạt động",
|
| 313 |
+
visual: {
|
| 314 |
+
sourceLabel: "Trong buổi khám",
|
| 315 |
+
sourceText: "Bệnh nhân đau đầu ba ngày, buồn nôn nhẹ, không sốt.",
|
| 316 |
+
transcriptLabel: "Lời chép được làm rõ",
|
| 317 |
+
transcriptText: "Đau đầu 3 ngày · buồn nôn nhẹ · không sốt",
|
| 318 |
+
draftLabel: "Bản nháp có cấu trúc",
|
| 319 |
+
draftText: "Triệu chứng và thông tin phủ định được đặt đúng mục để bác sĩ kiểm tra.",
|
| 320 |
+
},
|
| 321 |
+
},
|
| 322 |
+
process: {
|
| 323 |
+
label: "Tệp âm thanh, chép lời, chuẩn hóa thuật ngữ, bản nháp SOAP, bác sĩ kiểm tra",
|
| 324 |
+
items: [
|
| 325 |
+
"Tệp âm thanh",
|
| 326 |
+
"Chép lời tiếng Việt",
|
| 327 |
+
"Chuẩn hóa thuật ngữ",
|
| 328 |
+
"Bản nháp SOAP",
|
| 329 |
+
"Bác sĩ kiểm tra",
|
| 330 |
+
],
|
| 331 |
+
},
|
| 332 |
+
problem: {
|
| 333 |
+
title: "Một buổi khám không nên biến thành hai ca làm việc.",
|
| 334 |
+
body: "Trong phòng khám, bác sĩ cần lắng nghe, đặt câu hỏi và đưa ra quyết định. Sau đó vẫn còn phần sắp xếp nội dung thành một ghi chú có cấu trúc.",
|
| 335 |
+
duringTitle: "Trong buổi khám",
|
| 336 |
+
duringBody: "Tập trung vào câu chuyện, triệu chứng và điều người bệnh đang cần.",
|
| 337 |
+
afterTitle: "Sau buổi khám",
|
| 338 |
+
afterBody: "Nhớ lại nội dung, gõ từng mục và kiểm tra xem thông tin quan trọng có bị bỏ sót hay không.",
|
| 339 |
+
workflowTitle: "Không bắt đầu từ trang trắng",
|
| 340 |
+
workflowBody: "CarePath biến lời đã nói thành một bản nháp có cấu trúc để bác sĩ bắt đầu từ việc kiểm tra, không phải chép lại.",
|
| 341 |
+
reviewTitle: "Bác sĩ quyết định bản cuối",
|
| 342 |
+
reviewBody: "CarePath không tự hoàn tất hồ sơ và không thay thế nhận định lâm sàng.",
|
| 343 |
+
},
|
| 344 |
+
workflow: {
|
| 345 |
+
title: "Một luồng rõ ràng từ tệp âm thanh đến bản nháp.",
|
| 346 |
+
body: "Mỗi bước đều giữ cho bác sĩ biết dữ liệu đến từ đâu và cần kiểm tra điều gì trước khi sử dụng.",
|
| 347 |
+
stages: [
|
| 348 |
+
{
|
| 349 |
+
title: "Tệp âm thanh đã được phép sử dụng",
|
| 350 |
+
body: "Cơ sở y tế chủ động chọn và tải lên tệp âm thanh của buổi khám.",
|
| 351 |
+
},
|
| 352 |
+
{
|
| 353 |
+
title: "Chép lời tiếng Việt",
|
| 354 |
+
body: "Nội dung đã nói được chuyển thành văn bản để bác sĩ có thể đọc lại.",
|
| 355 |
+
},
|
| 356 |
+
{
|
| 357 |
+
title: "Chuẩn hóa thuật ngữ",
|
| 358 |
+
body: "Tên thuốc và thuật ngữ y khoa được làm rõ nhưng vẫn phải được bác sĩ đối chiếu.",
|
| 359 |
+
},
|
| 360 |
+
{
|
| 361 |
+
title: "Tạo bản nháp có cấu trúc",
|
| 362 |
+
body: "Thông tin được sắp xếp thành các mục bệnh án, kể cả phần còn thiếu cần chú ý.",
|
| 363 |
+
},
|
| 364 |
+
{
|
| 365 |
+
title: "Bác sĩ kiểm tra",
|
| 366 |
+
body: "Chỉ bác sĩ mới quyết định nội dung nào được sửa, bổ sung hoặc sử dụng.",
|
| 367 |
+
},
|
| 368 |
+
],
|
| 369 |
+
},
|
| 370 |
+
story: {
|
| 371 |
+
titleStart: "Từ lời đã nói",
|
| 372 |
+
titleEnd: "đến bản nháp bệnh án có cấu trúc.",
|
| 373 |
+
body: "Ba lớp thông tin được trình bày riêng để bác sĩ có thể đối chiếu nguồn, phần đã làm rõ và bản nháp cuối.",
|
| 374 |
+
rawTitle: "Lời chép ban đầu",
|
| 375 |
+
rawText: "Bệnh nhân đau đầu ba ngày, buồn nôn nhẹ, không sốt.",
|
| 376 |
+
rawDetail: "Giữ lại nội dung nguồn để bác sĩ đối chiếu.",
|
| 377 |
+
correctedTitle: "Thuật ngữ được làm rõ",
|
| 378 |
+
correctedText: "Đau đầu 3 ngày · buồn nôn nhẹ · không sốt",
|
| 379 |
+
correctedDetail: "Số, thuật ngữ và thông tin phủ định vẫn cần được kiểm tra.",
|
| 380 |
+
draftTitle: "Bản nháp SOAP",
|
| 381 |
+
draftDetail: "Thông tin được sắp xếp, không tự biến thành hồ sơ hoàn tất.",
|
| 382 |
+
},
|
| 383 |
+
safety: {
|
| 384 |
+
title: "Bản nháp chỉ được dùng sau khi bác sĩ kiểm tra.",
|
| 385 |
+
body: "CarePath hỗ trợ công việc ghi chép. Quyền quyết định lâm sàng và trách nhiệm duyệt nội dung luôn thuộc về bác sĩ.",
|
| 386 |
+
items: [
|
| 387 |
+
{
|
| 388 |
+
title: "Luôn hiển thị nội dung nguồn",
|
| 389 |
+
body: "Bác sĩ có thể so sánh lời chép, phần đã làm rõ và bản nháp.",
|
| 390 |
+
},
|
| 391 |
+
{
|
| 392 |
+
title: "Đánh dấu phần còn thiếu",
|
| 393 |
+
body: "Thông tin chưa có không được tự điền thành một kết luận chắc chắn.",
|
| 394 |
+
},
|
| 395 |
+
{
|
| 396 |
+
title: "Không tự đưa vào hồ sơ",
|
| 397 |
+
body: "Bản nháp cần được kiểm tra và xử lý theo quy trình của cơ sở y tế.",
|
| 398 |
+
},
|
| 399 |
+
],
|
| 400 |
+
},
|
| 401 |
+
action: {
|
| 402 |
+
title: "Bắt đầu từ một tệp âm thanh đã được phép sử dụng.",
|
| 403 |
+
body: "Thử luồng ghi chép hiện có hoặc để lại thông tin nếu cơ sở của bạn muốn trao đổi về chương trình thí điểm.",
|
| 404 |
+
primary: "Bắt đầu ghi chép",
|
| 405 |
+
pilotTitle: "Trao đổi về chương trình thí điểm",
|
| 406 |
+
pilotBody: "Cho CarePath biết bối cảnh của cơ sở để cùng xác định cách thử nghiệm phù hợp.",
|
| 407 |
+
pilotNote: "Biểu mẫu này chỉ dành cho Ghi chép bệnh án AI.",
|
| 408 |
+
},
|
| 409 |
+
},
|
| 410 |
demo: {
|
| 411 |
brand: "CarePath Phiên dịch",
|
| 412 |
consoleCta: "Mở công cụ Phiên dịch",
|
|
|
|
| 609 |
helper: "Tạo bản nháp y khoa",
|
| 610 |
intro: "AI dùng phiên âm tự động để tạo bản nháp y khoa theo bốn mục SOAP; bác sĩ cần kiểm tra trước khi sử dụng.",
|
| 611 |
preStartSteps: [
|
| 612 |
+
"Chọn tệp âm thanh buổi khám đã được phép sử dụng",
|
| 613 |
+
"Thêm bối cảnh ngắn nếu cần rồi tạo bản nháp",
|
| 614 |
+
"Đối chiếu lời chép, sửa và xác nhận trước khi sử dụng",
|
|
|
|
| 615 |
],
|
| 616 |
preStartReminder: "AI chỉ hỗ trợ tạo bản nháp. Bác sĩ cần kiểm tra lại nội dung trước khi sử dụng.",
|
| 617 |
uploadNotice: "Cơ sở chủ động chọn và tải tệp ghi âm lên để tạo bản nháp. Tệp chỉ được xử lý tạm thời trong phạm vi yêu cầu này; màn hình không tự bắt đầu micro.",
|
|
|
|
| 786 |
footer: {
|
| 787 |
promise: "AI hỗ trợ, bác sĩ giữ quyền quyết định.",
|
| 788 |
posture: "Thiết kế theo hướng giảm thiểu dữ liệu, nhận biết yêu cầu của Nghị định 13/PDP và định vị phù hợp với nguyên tắc tiếp cận ngôn ngữ của §1557. Đây không phải tuyên bố chứng nhận pháp lý.",
|
| 789 |
+
honesty: "Ghi chép bệnh án AI tạo bản nháp cần bác sĩ kiểm tra. Phiên dịch khám bệnh trực tiếp đang phát triển và chưa mở trên web.",
|
| 790 |
contact: "Liên hệ chương trình thí điểm",
|
| 791 |
},
|
| 792 |
},
|
| 793 |
en: {
|
| 794 |
metadata: {
|
| 795 |
+
title: "CarePath | AI clinical documentation for Vietnamese doctors",
|
| 796 |
+
description: "CarePath prepares a structured clinical-note draft from permitted consultation audio for clinician review. Live interpretation remains in development and is not open on the web.",
|
| 797 |
},
|
| 798 |
language: {
|
| 799 |
label: "Language",
|
|
|
|
| 801 |
en: "EN",
|
| 802 |
},
|
| 803 |
nav: {
|
| 804 |
+
need: "Why it matters",
|
| 805 |
+
workflow: "How it works",
|
| 806 |
+
safety: "Clinician review",
|
| 807 |
+
pilot: "Try it",
|
| 808 |
menu: "Open navigation",
|
| 809 |
},
|
| 810 |
+
landing: {
|
| 811 |
+
status: {
|
| 812 |
+
title: "Live medical interpretation",
|
| 813 |
+
state: "In development — not currently accessible on the web.",
|
| 814 |
+
body: "CarePath is completing the required safety workflows before this product reopens.",
|
| 815 |
+
},
|
| 816 |
+
hero: {
|
| 817 |
+
title: "Focus on the patient. Let CarePath prepare the draft after the visit.",
|
| 818 |
+
body: "Upload permitted consultation audio. CarePath transcribes it, normalizes medical terms, and prepares a structured clinical-note draft for clinician review.",
|
| 819 |
+
primary: "Start documenting",
|
| 820 |
+
secondary: "See how it works",
|
| 821 |
+
visual: {
|
| 822 |
+
sourceLabel: "During the visit",
|
| 823 |
+
sourceText: "Bệnh nhân đau đầu ba ngày, buồn nôn nhẹ, không sốt.",
|
| 824 |
+
transcriptLabel: "Clarified transcript",
|
| 825 |
+
transcriptText: "Headache for 3 days · mild nausea · no fever",
|
| 826 |
+
draftLabel: "Structured draft",
|
| 827 |
+
draftText: "Symptoms and negative findings are placed in the right sections for clinician review.",
|
| 828 |
+
},
|
| 829 |
+
},
|
| 830 |
+
process: {
|
| 831 |
+
label: "Audio file, transcription, term normalization, SOAP draft, clinician review",
|
| 832 |
+
items: [
|
| 833 |
+
"Audio file",
|
| 834 |
+
"Vietnamese transcript",
|
| 835 |
+
"Term normalization",
|
| 836 |
+
"SOAP draft",
|
| 837 |
+
"Clinician review",
|
| 838 |
+
],
|
| 839 |
+
},
|
| 840 |
+
problem: {
|
| 841 |
+
title: "One consultation should not become two shifts of work.",
|
| 842 |
+
body: "During the visit, clinicians need to listen, ask questions, and make decisions. Afterwards, the conversation still has to become a structured note.",
|
| 843 |
+
duringTitle: "During the visit",
|
| 844 |
+
duringBody: "Stay with the patient's story, symptoms, and immediate needs.",
|
| 845 |
+
afterTitle: "After the visit",
|
| 846 |
+
afterBody: "Recall the conversation, enter each section, and check whether important details were missed.",
|
| 847 |
+
workflowTitle: "Do not start from a blank page",
|
| 848 |
+
workflowBody: "CarePath turns spoken content into a structured draft so review begins with evidence instead of retyping.",
|
| 849 |
+
reviewTitle: "The clinician decides the final note",
|
| 850 |
+
reviewBody: "CarePath does not complete the record independently or replace clinical judgment.",
|
| 851 |
+
},
|
| 852 |
+
workflow: {
|
| 853 |
+
title: "A clear path from permitted audio to a reviewable draft.",
|
| 854 |
+
body: "Each step keeps the source visible and makes clear what the clinician must verify before use.",
|
| 855 |
+
stages: [
|
| 856 |
+
{
|
| 857 |
+
title: "Permitted consultation audio",
|
| 858 |
+
body: "The healthcare organization chooses and uploads the consultation audio file.",
|
| 859 |
+
},
|
| 860 |
+
{
|
| 861 |
+
title: "Vietnamese transcription",
|
| 862 |
+
body: "Spoken content becomes text that the clinician can read and compare.",
|
| 863 |
+
},
|
| 864 |
+
{
|
| 865 |
+
title: "Medical term normalization",
|
| 866 |
+
body: "Drug names and medical terms are clarified but still require clinician verification.",
|
| 867 |
+
},
|
| 868 |
+
{
|
| 869 |
+
title: "Structured draft",
|
| 870 |
+
body: "Information is organized into clinical-note sections, including missing details that need attention.",
|
| 871 |
+
},
|
| 872 |
+
{
|
| 873 |
+
title: "Clinician review",
|
| 874 |
+
body: "Only the clinician decides what to edit, add, or use.",
|
| 875 |
+
},
|
| 876 |
+
],
|
| 877 |
+
},
|
| 878 |
+
story: {
|
| 879 |
+
titleStart: "From spoken detail",
|
| 880 |
+
titleEnd: "to a structured clinical-note draft.",
|
| 881 |
+
body: "Source, clarified content, and the resulting draft remain separate so the clinician can compare each layer.",
|
| 882 |
+
rawTitle: "Initial transcript",
|
| 883 |
+
rawText: "Bệnh nhân đau đầu ba ngày, buồn nôn nhẹ, không sốt.",
|
| 884 |
+
rawDetail: "The source remains visible for comparison.",
|
| 885 |
+
correctedTitle: "Clarified terminology",
|
| 886 |
+
correctedText: "Headache for 3 days · mild nausea · no fever",
|
| 887 |
+
correctedDetail: "Numbers, medical terms, and negative findings still require review.",
|
| 888 |
+
draftTitle: "SOAP draft",
|
| 889 |
+
draftDetail: "Information is organized without becoming a completed medical record.",
|
| 890 |
+
},
|
| 891 |
+
safety: {
|
| 892 |
+
title: "The draft is used only after clinician review.",
|
| 893 |
+
body: "CarePath supports documentation work. Clinical decisions and responsibility for the final content remain with the clinician.",
|
| 894 |
+
items: [
|
| 895 |
+
{
|
| 896 |
+
title: "Source content stays visible",
|
| 897 |
+
body: "The clinician can compare the transcript, clarified content, and draft.",
|
| 898 |
+
},
|
| 899 |
+
{
|
| 900 |
+
title: "Missing information is marked",
|
| 901 |
+
body: "Absent details are not silently turned into confident conclusions.",
|
| 902 |
+
},
|
| 903 |
+
{
|
| 904 |
+
title: "No automatic record entry",
|
| 905 |
+
body: "The draft must follow the healthcare organization's review process.",
|
| 906 |
+
},
|
| 907 |
+
],
|
| 908 |
+
},
|
| 909 |
+
action: {
|
| 910 |
+
title: "Start with an audio file you are permitted to use.",
|
| 911 |
+
body: "Try the available documentation workflow or leave your details to discuss a pilot for your organization.",
|
| 912 |
+
primary: "Start documenting",
|
| 913 |
+
pilotTitle: "Discuss a pilot",
|
| 914 |
+
pilotBody: "Tell CarePath about your organization so we can identify an appropriate way to test the workflow.",
|
| 915 |
+
pilotNote: "This form is only for AI clinical documentation.",
|
| 916 |
+
},
|
| 917 |
+
},
|
| 918 |
demo: {
|
| 919 |
brand: "CarePath Interpreter",
|
| 920 |
consoleCta: "Open the Interpreter tool",
|
|
|
|
| 1117 |
helper: "AI Scribe",
|
| 1118 |
intro: "Upload a Vietnamese consultation recording. The system uses automated transcription, corrects medical terms, and creates a four-part SOAP draft for clinician review.",
|
| 1119 |
preStartSteps: [
|
| 1120 |
+
"Choose an approved consultation audio file",
|
| 1121 |
+
"Add brief context if needed and create the draft",
|
| 1122 |
+
"Review the transcript, edit, and confirm before use",
|
|
|
|
| 1123 |
],
|
| 1124 |
preStartReminder: "AI assists with drafting only. The clinician must review the draft before use.",
|
| 1125 |
uploadNotice: "The clinic deliberately selects and uploads the recording file to create a draft. The file is processed temporarily for this request only; this screen does not start the microphone.",
|
|
|
|
| 1294 |
footer: {
|
| 1295 |
promise: "AI assistance, clinician in control.",
|
| 1296 |
posture: "Designed around data minimization, awareness of Decree 13/PDP obligations, and §1557-aligned language-access positioning. This is not a legal certification claim.",
|
| 1297 |
+
honesty: "AI clinical documentation produces a draft for clinician review. Live medical interpretation remains in development and is not open on the web.",
|
| 1298 |
contact: "Contact the pilot program",
|
| 1299 |
},
|
| 1300 |
},
|
scribe/frontend/src/scribe/ScribeShowcase.test.tsx
CHANGED
|
@@ -3,42 +3,22 @@ import { describe, expect, it } from "vitest";
|
|
| 3 |
import ScribeShowcase from "./ScribeShowcase";
|
| 4 |
|
| 5 |
describe("ScribeShowcase", () => {
|
| 6 |
-
it("
|
| 7 |
-
render(<ScribeShowcase
|
| 8 |
|
| 9 |
-
expect(screen.getByText(
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
"Gạch chân: đoạn hệ thống nghe sai trong bản phiên âm tự động.",
|
| 13 |
-
),
|
| 14 |
-
).toBeInTheDocument();
|
| 15 |
-
|
| 16 |
-
fireEvent.click(
|
| 17 |
-
screen.getByRole("button", { name: "2. Sau hiệu chỉnh thuật ngữ" }),
|
| 18 |
-
);
|
| 19 |
-
expect(screen.getByText("amlodipin")).toBeInTheDocument();
|
| 20 |
-
expect(screen.getByText("160/90 mmHg")).toBeInTheDocument();
|
| 21 |
|
| 22 |
-
fireEvent.click(
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
}),
|
| 26 |
-
);
|
| 27 |
-
expect(screen.getByText("Chờ bác sĩ duyệt")).toBeInTheDocument();
|
| 28 |
expect(screen.getByText("Kế hoạch")).toBeInTheDocument();
|
| 29 |
-
expect(
|
| 30 |
-
screen.getByText("Tăng huyết áp chưa kiểm soát."),
|
| 31 |
-
).toBeInTheDocument();
|
| 32 |
-
});
|
| 33 |
-
|
| 34 |
-
it("localizes the frame while keeping the Vietnamese sample", () => {
|
| 35 |
-
render(<ScribeShowcase language="en" />);
|
| 36 |
|
|
|
|
|
|
|
| 37 |
expect(
|
| 38 |
-
screen.
|
| 39 |
-
).
|
| 40 |
-
fireEvent.click(screen.getByRole("button", { name: "3. SOAP draft" }));
|
| 41 |
-
expect(screen.getByText("Awaiting clinician review")).toBeInTheDocument();
|
| 42 |
-
expect(screen.getByText("Subjective")).toBeInTheDocument();
|
| 43 |
});
|
| 44 |
});
|
|
|
|
| 3 |
import ScribeShowcase from "./ScribeShowcase";
|
| 4 |
|
| 5 |
describe("ScribeShowcase", () => {
|
| 6 |
+
it("teaches the sample flow without inventing assessment or plan", () => {
|
| 7 |
+
render(<ScribeShowcase />);
|
| 8 |
|
| 9 |
+
expect(screen.getByText(/Tôi bị đau tức ngực thoáng qua/)).toBeInTheDocument();
|
| 10 |
+
fireEvent.click(screen.getByRole("button", { name: "2. Nội dung được làm rõ" }));
|
| 11 |
+
expect(screen.getByText("Chưa có kết quả điện tâm đồ")).toBeInTheDocument();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
+
fireEvent.click(screen.getByRole("button", { name: "3. Bản nháp có cấu trúc" }));
|
| 14 |
+
expect(screen.getAllByText("Chờ bác sĩ nhập hoặc xác nhận.")).toHaveLength(2);
|
| 15 |
+
expect(screen.getByText("Nhận định")).toBeInTheDocument();
|
|
|
|
|
|
|
|
|
|
| 16 |
expect(screen.getByText("Kế hoạch")).toBeInTheDocument();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
+
fireEvent.click(screen.getByRole("button", { name: "4. Bác sĩ kiểm tra" }));
|
| 19 |
+
expect(screen.getByText("Trước khi sử dụng bản nháp")).toBeInTheDocument();
|
| 20 |
expect(
|
| 21 |
+
screen.getByRole("link", { name: "Mở công cụ và tải tệp âm thanh" }),
|
| 22 |
+
).toHaveAttribute("href", "/ghi-chep-lam-sang/");
|
|
|
|
|
|
|
|
|
|
| 23 |
});
|
| 24 |
});
|
scribe/frontend/src/scribe/ScribeShowcase.tsx
CHANGED
|
@@ -1,116 +1,97 @@
|
|
| 1 |
import { useState } from "react";
|
| 2 |
-
import { copyFor } from "../content/strings";
|
| 3 |
-
import type { Language } from "../demo/types";
|
| 4 |
|
| 5 |
-
type Step = "
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
}
|
| 11 |
-
|
| 12 |
-
// Sanitized sample dictation for the simulation. Every SOAP line in this
|
| 13 |
-
// fixed example is traceable to the sample content below.
|
| 14 |
-
const rawSegments: Segment[] = [
|
| 15 |
-
{ text: "bệnh nhân nữ 54 tuổi tiền sử tăng huyết áp đang dùng " },
|
| 16 |
-
{ text: "am lô đi pin", mark: "miss" },
|
| 17 |
-
{ text: " " },
|
| 18 |
-
{ text: "5 mi li gam", mark: "miss" },
|
| 19 |
-
{ text: " mỗi sáng hôm nay đau đầu chóng mặt huyết áp tự đo tại nhà " },
|
| 20 |
-
{ text: "một sáu mươi trên chín mươi", mark: "miss" },
|
| 21 |
-
{
|
| 22 |
-
text: " không đau ngực không khó thở chẩn đoán tăng huyết áp chưa kiểm soát kế hoạch đo lại huyết áp tại phòng khám theo dõi huyết áp mỗi sáng tái khám sau một tuần",
|
| 23 |
-
},
|
| 24 |
];
|
| 25 |
|
| 26 |
-
const
|
| 27 |
-
{ text: "Bệnh nhân nữ 54 tuổi, tiền sử tăng huyết áp, đang dùng " },
|
| 28 |
-
{ text: "amlodipin", mark: "fix" },
|
| 29 |
-
{ text: " " },
|
| 30 |
-
{ text: "5 mg", mark: "fix" },
|
| 31 |
-
{ text: " mỗi sáng. Hôm nay đau đầu, chóng mặt; huyết áp tự đo tại nhà " },
|
| 32 |
-
{ text: "160/90 mmHg", mark: "fix" },
|
| 33 |
{
|
| 34 |
-
|
|
|
|
|
|
|
| 35 |
},
|
| 36 |
-
];
|
| 37 |
-
|
| 38 |
-
export const soapDraft: Array<{ key: "s" | "o" | "a" | "p"; text: string }> = [
|
| 39 |
-
{ key: "s", text: "Đau đầu, chóng mặt từ sáng nay. Không đau ngực, không khó thở." },
|
| 40 |
{
|
| 41 |
key: "o",
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
{ key: "a", text: "Tăng huyết áp chưa kiểm soát." },
|
| 45 |
-
{
|
| 46 |
-
key: "p",
|
| 47 |
-
text: "Đo lại huyết áp tại phòng khám. Theo dõi huyết áp tại nhà mỗi sáng; tái khám sau một tuần.",
|
| 48 |
},
|
|
|
|
|
|
|
| 49 |
];
|
| 50 |
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
<p className="scribe-doc__text" lang="vi">
|
| 54 |
-
{segments.map((segment, index) =>
|
| 55 |
-
segment.mark ? (
|
| 56 |
-
<mark
|
| 57 |
-
className={`scribe-mark scribe-mark--${segment.mark}`}
|
| 58 |
-
key={`${segment.text}-${index}`}
|
| 59 |
-
>
|
| 60 |
-
{segment.text}
|
| 61 |
-
</mark>
|
| 62 |
-
) : (
|
| 63 |
-
segment.text
|
| 64 |
-
),
|
| 65 |
-
)}
|
| 66 |
-
</p>
|
| 67 |
-
);
|
| 68 |
-
}
|
| 69 |
-
|
| 70 |
-
export default function ScribeShowcase({ language }: { language: Language }) {
|
| 71 |
-
const copy = copyFor(language).scribe;
|
| 72 |
-
const [step, setStep] = useState<Step>("raw");
|
| 73 |
-
const steps: Step[] = ["raw", "corrected", "soap"];
|
| 74 |
|
| 75 |
return (
|
| 76 |
-
<div className="scribe-panel">
|
| 77 |
<div className="scribe-panel__header">
|
| 78 |
-
<p className="demo__brand">
|
| 79 |
-
<p className="demo__disclosure">
|
| 80 |
</div>
|
| 81 |
-
<div className="scribe-tabs">
|
| 82 |
-
{steps.map((
|
| 83 |
<button
|
| 84 |
-
aria-
|
|
|
|
| 85 |
className="scribe-tab"
|
| 86 |
-
key={
|
| 87 |
-
onClick={() => setStep(
|
| 88 |
type="button"
|
| 89 |
>
|
| 90 |
-
{
|
| 91 |
</button>
|
| 92 |
))}
|
| 93 |
</div>
|
| 94 |
-
<div className="scribe-doc">
|
| 95 |
-
{step === "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
<>
|
| 97 |
-
<p className="scribe-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
<dl className="soap-note">
|
| 99 |
{soapDraft.map((row) => (
|
| 100 |
<div key={row.key}>
|
| 101 |
-
<dt>
|
| 102 |
-
<span aria-hidden="true">{row.key.toUpperCase()}</span>
|
| 103 |
-
{copy.soapLabels[row.key]}
|
| 104 |
-
</dt>
|
| 105 |
<dd lang="vi">{row.text}</dd>
|
| 106 |
</div>
|
| 107 |
))}
|
| 108 |
</dl>
|
|
|
|
| 109 |
</>
|
| 110 |
-
) : (
|
| 111 |
-
<SampleText segments={step === "raw" ? rawSegments : correctedSegments} />
|
| 112 |
)}
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
</div>
|
| 115 |
</div>
|
| 116 |
);
|
|
|
|
| 1 |
import { useState } from "react";
|
|
|
|
|
|
|
| 2 |
|
| 3 |
+
type Step = "conversation" | "clarified" | "draft" | "review";
|
| 4 |
|
| 5 |
+
const steps: Array<{ key: Step; label: string }> = [
|
| 6 |
+
{ key: "conversation", label: "1. Cuộc trao đổi" },
|
| 7 |
+
{ key: "clarified", label: "2. Nội dung được làm rõ" },
|
| 8 |
+
{ key: "draft", label: "3. Bản nháp có cấu trúc" },
|
| 9 |
+
{ key: "review", label: "4. Bác sĩ kiểm tra" },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
];
|
| 11 |
|
| 12 |
+
export const soapDraft: Array<{ key: "s" | "o" | "a" | "p"; label: string; text: string }> = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
{
|
| 14 |
+
key: "s",
|
| 15 |
+
label: "Triệu chứng chủ quan",
|
| 16 |
+
text: "Đau tức ngực thoáng qua từ sáng, khó thở nhẹ. Chưa dùng thuốc.",
|
| 17 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
{
|
| 19 |
key: "o",
|
| 20 |
+
label: "Thông tin khách quan",
|
| 21 |
+
text: "Chưa có kết quả khám hoặc cận lâm sàng trong nội dung ghi âm.",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
},
|
| 23 |
+
{ key: "a", label: "Nhận định", text: "Chờ bác sĩ nhập hoặc xác nhận." },
|
| 24 |
+
{ key: "p", label: "Kế hoạch", text: "Chờ bác sĩ nhập hoặc xác nhận." },
|
| 25 |
];
|
| 26 |
|
| 27 |
+
export default function ScribeShowcase() {
|
| 28 |
+
const [step, setStep] = useState<Step>("conversation");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
return (
|
| 31 |
+
<div className="scribe-panel guided-sample">
|
| 32 |
<div className="scribe-panel__header">
|
| 33 |
+
<p className="demo__brand">Ca khám mẫu đã làm sạch</p>
|
| 34 |
+
<p className="demo__disclosure">Mô phỏng — không phải bệnh án thật</p>
|
| 35 |
</div>
|
| 36 |
+
<div className="scribe-tabs" aria-label="Các bước của ca khám mẫu">
|
| 37 |
+
{steps.map(({ key, label }) => (
|
| 38 |
<button
|
| 39 |
+
aria-controls="guided-sample-panel"
|
| 40 |
+
aria-pressed={step === key}
|
| 41 |
className="scribe-tab"
|
| 42 |
+
key={key}
|
| 43 |
+
onClick={() => setStep(key)}
|
| 44 |
type="button"
|
| 45 |
>
|
| 46 |
+
{label}
|
| 47 |
</button>
|
| 48 |
))}
|
| 49 |
</div>
|
| 50 |
+
<div className="scribe-doc" id="guided-sample-panel">
|
| 51 |
+
{step === "conversation" && (
|
| 52 |
+
<>
|
| 53 |
+
<p className="sample-speaker"><strong>Bệnh nhân:</strong> Tôi bị đau tức ngực thoáng qua từ sáng, hơi khó thở. Tôi chưa dùng thuốc.</p>
|
| 54 |
+
<p className="sample-speaker"><strong>Bác sĩ:</strong> Anh đã có kết quả điện tâm đồ hoặc xét nghiệm nào chưa?</p>
|
| 55 |
+
<p className="sample-speaker"><strong>Bệnh nhân:</strong> Chưa có.</p>
|
| 56 |
+
<p className="scribe-caption">CarePath chỉ bắt đầu từ điều đã được nói trong cuộc trao đổi.</p>
|
| 57 |
+
</>
|
| 58 |
+
)}
|
| 59 |
+
{step === "clarified" && (
|
| 60 |
<>
|
| 61 |
+
<p className="scribe-doc__text" lang="vi">
|
| 62 |
+
Bệnh nhân đau tức ngực thoáng qua từ sáng, khó thở nhẹ. Chưa dùng
|
| 63 |
+
thuốc. <mark className="scribe-mark scribe-mark--fix">Chưa có kết quả điện tâm đồ</mark> hoặc xét nghiệm trong nội dung ghi âm.
|
| 64 |
+
</p>
|
| 65 |
+
<p className="scribe-caption">Thông tin phủ định và thuật ngữ được làm rõ nhưng vẫn cần bác sĩ đối chiếu với âm thanh.</p>
|
| 66 |
+
</>
|
| 67 |
+
)}
|
| 68 |
+
{step === "draft" && (
|
| 69 |
+
<>
|
| 70 |
+
<p className="scribe-status">Bản nháp — cần bác sĩ kiểm tra</p>
|
| 71 |
<dl className="soap-note">
|
| 72 |
{soapDraft.map((row) => (
|
| 73 |
<div key={row.key}>
|
| 74 |
+
<dt><span aria-hidden="true">{row.key.toUpperCase()}</span>{row.label}</dt>
|
|
|
|
|
|
|
|
|
|
| 75 |
<dd lang="vi">{row.text}</dd>
|
| 76 |
</div>
|
| 77 |
))}
|
| 78 |
</dl>
|
| 79 |
+
<p className="scribe-caption">CarePath không tự điền nhận định hay kế hoạch khi cuộc trao đổi chưa cung cấp.</p>
|
| 80 |
</>
|
|
|
|
|
|
|
| 81 |
)}
|
| 82 |
+
{step === "review" && (
|
| 83 |
+
<div className="review-checklist">
|
| 84 |
+
<h3>Trước khi sử dụng bản nháp</h3>
|
| 85 |
+
<ul>
|
| 86 |
+
<li>Đối chiếu triệu chứng, thời điểm và thông tin phủ định với âm thanh.</li>
|
| 87 |
+
<li>Kiểm tra phần còn thiếu hoặc chưa chắc chắn.</li>
|
| 88 |
+
<li>Tự nhập hoặc xác nhận Nhận định và Kế hoạch.</li>
|
| 89 |
+
</ul>
|
| 90 |
+
<a className="button-link button-link--primary" href="/ghi-chep-lam-sang/">
|
| 91 |
+
Mở công cụ và tải tệp âm thanh
|
| 92 |
+
</a>
|
| 93 |
+
</div>
|
| 94 |
+
)}
|
| 95 |
</div>
|
| 96 |
</div>
|
| 97 |
);
|
scribe/frontend/src/scribe/ScribeTool.test.tsx
CHANGED
|
@@ -51,7 +51,7 @@ describe("ScribeTool", () => {
|
|
| 51 |
.mockResolvedValueOnce(soapResponse());
|
| 52 |
vi.stubGlobal("fetch", fetchMock);
|
| 53 |
|
| 54 |
-
render(<ScribeTool
|
| 55 |
await screen.findByText("Chế độ demo");
|
| 56 |
expect(
|
| 57 |
screen.getAllByRole("link", { name: "Tất cả chức năng" }),
|
|
@@ -62,23 +62,10 @@ describe("ScribeTool", () => {
|
|
| 62 |
expect(
|
| 63 |
screen.getByRole("heading", { name: "Ghi chép bệnh án AI — bản nháp" }),
|
| 64 |
).toBeInTheDocument();
|
| 65 |
-
expect(
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
).toBeInTheDocument();
|
| 70 |
-
expect(screen.getByText("Tải tệp ghi âm buổi khám lên")).toBeInTheDocument();
|
| 71 |
-
expect(
|
| 72 |
-
screen.getByText(
|
| 73 |
-
"AI tạo bản nháp y khoa theo bốn mục SOAP",
|
| 74 |
-
),
|
| 75 |
-
).toBeInTheDocument();
|
| 76 |
-
expect(
|
| 77 |
-
screen.getByText(
|
| 78 |
-
"Bác sĩ kiểm tra, chỉnh sửa; bản nháp không tự vào hồ sơ",
|
| 79 |
-
),
|
| 80 |
-
).toBeInTheDocument();
|
| 81 |
-
fireEvent.click(screen.getByRole("button", { name: "Tiếp tục tạo bản nháp" }));
|
| 82 |
|
| 83 |
const submit = screen.getByRole("button", { name: "Tạo bản nháp SOAP" });
|
| 84 |
expect(submit).toBeDisabled();
|
|
@@ -126,8 +113,7 @@ describe("ScribeTool", () => {
|
|
| 126 |
});
|
| 127 |
vi.stubGlobal("fetch", fetchMock);
|
| 128 |
|
| 129 |
-
render(<ScribeTool
|
| 130 |
-
fireEvent.click(screen.getByRole("button", { name: "Tiếp tục tạo bản nháp" }));
|
| 131 |
selectWav();
|
| 132 |
fireEvent.click(screen.getByRole("button", { name: "Tạo bản nháp SOAP" }));
|
| 133 |
|
|
@@ -155,8 +141,7 @@ describe("ScribeTool", () => {
|
|
| 155 |
}),
|
| 156 |
);
|
| 157 |
|
| 158 |
-
render(<ScribeTool
|
| 159 |
-
fireEvent.click(screen.getByRole("button", { name: "Tiếp tục tạo bản nháp" }));
|
| 160 |
selectWav();
|
| 161 |
fireEvent.click(screen.getByRole("button", { name: "Tạo bản nháp SOAP" }));
|
| 162 |
|
|
@@ -173,8 +158,7 @@ describe("ScribeTool", () => {
|
|
| 173 |
.mockRejectedValueOnce(new TypeError("Failed to fetch")),
|
| 174 |
);
|
| 175 |
|
| 176 |
-
render(<ScribeTool
|
| 177 |
-
fireEvent.click(screen.getByRole("button", { name: "Tiếp tục tạo bản nháp" }));
|
| 178 |
selectWav();
|
| 179 |
fireEvent.click(screen.getByRole("button", { name: "Tạo bản nháp SOAP" }));
|
| 180 |
|
|
@@ -185,8 +169,8 @@ describe("ScribeTool", () => {
|
|
| 185 |
|
| 186 |
it("marks the backend unreachable when health fails", async () => {
|
| 187 |
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline")));
|
| 188 |
-
render(<ScribeTool
|
| 189 |
-
expect(await screen.findByText("
|
| 190 |
});
|
| 191 |
});
|
| 192 |
|
|
@@ -225,8 +209,7 @@ describe("classifyFailure", () => {
|
|
| 225 |
}),
|
| 226 |
);
|
| 227 |
|
| 228 |
-
render(<ScribeTool
|
| 229 |
-
fireEvent.click(screen.getByRole("button", { name: "Tiếp tục tạo bản nháp" }));
|
| 230 |
selectWav();
|
| 231 |
fireEvent.click(screen.getByRole("button", { name: "Tạo bản nháp SOAP" }));
|
| 232 |
|
|
|
|
| 51 |
.mockResolvedValueOnce(soapResponse());
|
| 52 |
vi.stubGlobal("fetch", fetchMock);
|
| 53 |
|
| 54 |
+
render(<ScribeTool />);
|
| 55 |
await screen.findByText("Chế độ demo");
|
| 56 |
expect(
|
| 57 |
screen.getAllByRole("link", { name: "Tất cả chức năng" }),
|
|
|
|
| 62 |
expect(
|
| 63 |
screen.getByRole("heading", { name: "Ghi chép bệnh án AI — bản nháp" }),
|
| 64 |
).toBeInTheDocument();
|
| 65 |
+
expect(screen.getByText("Ba bước để tạo bản nháp")).toBeInTheDocument();
|
| 66 |
+
expect(screen.getByText("Chọn tệp âm thanh buổi khám đã được phép sử dụng")).toBeInTheDocument();
|
| 67 |
+
expect(screen.getByRole("link", { name: "Xem ca khám mẫu trước" })).toHaveAttribute("href", "/#demo");
|
| 68 |
+
expect(screen.queryByRole("button", { name: "Tiếp tục tạo bản nháp" })).not.toBeInTheDocument();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
const submit = screen.getByRole("button", { name: "Tạo bản nháp SOAP" });
|
| 71 |
expect(submit).toBeDisabled();
|
|
|
|
| 113 |
});
|
| 114 |
vi.stubGlobal("fetch", fetchMock);
|
| 115 |
|
| 116 |
+
render(<ScribeTool />);
|
|
|
|
| 117 |
selectWav();
|
| 118 |
fireEvent.click(screen.getByRole("button", { name: "Tạo bản nháp SOAP" }));
|
| 119 |
|
|
|
|
| 141 |
}),
|
| 142 |
);
|
| 143 |
|
| 144 |
+
render(<ScribeTool />);
|
|
|
|
| 145 |
selectWav();
|
| 146 |
fireEvent.click(screen.getByRole("button", { name: "Tạo bản nháp SOAP" }));
|
| 147 |
|
|
|
|
| 158 |
.mockRejectedValueOnce(new TypeError("Failed to fetch")),
|
| 159 |
);
|
| 160 |
|
| 161 |
+
render(<ScribeTool />);
|
|
|
|
| 162 |
selectWav();
|
| 163 |
fireEvent.click(screen.getByRole("button", { name: "Tạo bản nháp SOAP" }));
|
| 164 |
|
|
|
|
| 169 |
|
| 170 |
it("marks the backend unreachable when health fails", async () => {
|
| 171 |
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline")));
|
| 172 |
+
render(<ScribeTool />);
|
| 173 |
+
expect(await screen.findByText("Mất kết nối máy chủ")).toBeInTheDocument();
|
| 174 |
});
|
| 175 |
});
|
| 176 |
|
|
|
|
| 209 |
}),
|
| 210 |
);
|
| 211 |
|
| 212 |
+
render(<ScribeTool />);
|
|
|
|
| 213 |
selectWav();
|
| 214 |
fireEvent.click(screen.getByRole("button", { name: "Tạo bản nháp SOAP" }));
|
| 215 |
|
scribe/frontend/src/scribe/ScribeTool.tsx
CHANGED
|
@@ -9,7 +9,6 @@ import {
|
|
| 9 |
} from "react";
|
| 10 |
import logoUrl from "../assets/carepath.svg";
|
| 11 |
import { copyFor } from "../content/strings";
|
| 12 |
-
import type { Language } from "../demo/types";
|
| 13 |
|
| 14 |
// Same-origin by default: the combined API serves this site and /api/v1/*.
|
| 15 |
const API_BASE: string = import.meta.env.VITE_API_BASE ?? "";
|
|
@@ -109,19 +108,14 @@ function formatBytes(size: number): string {
|
|
| 109 |
|
| 110 |
interface ScribeToolProps {
|
| 111 |
backHref?: string;
|
| 112 |
-
language: Language;
|
| 113 |
-
onLanguageChange?: (language: Language) => void;
|
| 114 |
}
|
| 115 |
|
| 116 |
export default function ScribeTool({
|
| 117 |
backHref = "#top",
|
| 118 |
-
language,
|
| 119 |
-
onLanguageChange,
|
| 120 |
}: ScribeToolProps) {
|
| 121 |
-
const copy = copyFor(
|
| 122 |
const labels = copy.scribeTool;
|
| 123 |
const [state, setState] = useState<ToolState>("idle");
|
| 124 |
-
const [readyToRecord, setReadyToRecord] = useState(false);
|
| 125 |
const [health, setHealth] = useState<HealthState>("checking");
|
| 126 |
const [file, setFile] = useState<File | null>(null);
|
| 127 |
const [context, setContext] = useState("");
|
|
@@ -253,7 +247,7 @@ export default function ScribeTool({
|
|
| 253 |
|
| 254 |
return (
|
| 255 |
<div className="site-shell">
|
| 256 |
-
<nav className="site-nav" aria-label=
|
| 257 |
<a className="site-nav__brand" href={backHref} aria-label={labels.back}>
|
| 258 |
<img src={logoUrl} alt="" />
|
| 259 |
<span className="product-breadcrumb">
|
|
@@ -264,26 +258,8 @@ export default function ScribeTool({
|
|
| 264 |
</a>
|
| 265 |
<div className="site-nav__actions">
|
| 266 |
<span className="product-shell-status">
|
| 267 |
-
|
| 268 |
-
? "Công cụ thí điểm · Cần bác sĩ duyệt"
|
| 269 |
-
: "Pilot tool · Clinician review required"}
|
| 270 |
</span>
|
| 271 |
-
<div className="language-toggle" aria-label={copy.language.label}>
|
| 272 |
-
<button
|
| 273 |
-
aria-pressed={language === "vi"}
|
| 274 |
-
onClick={() => onLanguageChange?.("vi")}
|
| 275 |
-
type="button"
|
| 276 |
-
>
|
| 277 |
-
{copy.language.vi}
|
| 278 |
-
</button>
|
| 279 |
-
<button
|
| 280 |
-
aria-pressed={language === "en"}
|
| 281 |
-
onClick={() => onLanguageChange?.("en")}
|
| 282 |
-
type="button"
|
| 283 |
-
>
|
| 284 |
-
{copy.language.en}
|
| 285 |
-
</button>
|
| 286 |
-
</div>
|
| 287 |
<a className="nav-cta" href={backHref}>{labels.back}</a>
|
| 288 |
</div>
|
| 289 |
</nav>
|
|
@@ -299,26 +275,21 @@ export default function ScribeTool({
|
|
| 299 |
</p>
|
| 300 |
</header>
|
| 301 |
|
| 302 |
-
{state === "idle" &&
|
| 303 |
-
<
|
| 304 |
-
<
|
|
|
|
|
|
|
| 305 |
{labels.preStartSteps.map((step, index) => (
|
| 306 |
<li key={step}>
|
| 307 |
<span aria-hidden="true">{index + 1}</span>
|
| 308 |
{step}
|
| 309 |
</li>
|
| 310 |
))}
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
<
|
| 315 |
-
{labels.continue}
|
| 316 |
-
</button>
|
| 317 |
-
</section>
|
| 318 |
-
)}
|
| 319 |
-
|
| 320 |
-
{state === "idle" && readyToRecord && (
|
| 321 |
-
<form className="scribe-panel tool-panel" onSubmit={submit}>
|
| 322 |
<label
|
| 323 |
className={dragging ? "dropzone is-drag" : "dropzone"}
|
| 324 |
onDragEnter={(event) => {
|
|
|
|
| 9 |
} from "react";
|
| 10 |
import logoUrl from "../assets/carepath.svg";
|
| 11 |
import { copyFor } from "../content/strings";
|
|
|
|
| 12 |
|
| 13 |
// Same-origin by default: the combined API serves this site and /api/v1/*.
|
| 14 |
const API_BASE: string = import.meta.env.VITE_API_BASE ?? "";
|
|
|
|
| 108 |
|
| 109 |
interface ScribeToolProps {
|
| 110 |
backHref?: string;
|
|
|
|
|
|
|
| 111 |
}
|
| 112 |
|
| 113 |
export default function ScribeTool({
|
| 114 |
backHref = "#top",
|
|
|
|
|
|
|
| 115 |
}: ScribeToolProps) {
|
| 116 |
+
const copy = copyFor("vi");
|
| 117 |
const labels = copy.scribeTool;
|
| 118 |
const [state, setState] = useState<ToolState>("idle");
|
|
|
|
| 119 |
const [health, setHealth] = useState<HealthState>("checking");
|
| 120 |
const [file, setFile] = useState<File | null>(null);
|
| 121 |
const [context, setContext] = useState("");
|
|
|
|
| 247 |
|
| 248 |
return (
|
| 249 |
<div className="site-shell">
|
| 250 |
+
<nav className="site-nav" aria-label="Điều hướng chính">
|
| 251 |
<a className="site-nav__brand" href={backHref} aria-label={labels.back}>
|
| 252 |
<img src={logoUrl} alt="" />
|
| 253 |
<span className="product-breadcrumb">
|
|
|
|
| 258 |
</a>
|
| 259 |
<div className="site-nav__actions">
|
| 260 |
<span className="product-shell-status">
|
| 261 |
+
Công cụ thí điểm · Cần bác sĩ duyệt
|
|
|
|
|
|
|
| 262 |
</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
<a className="nav-cta" href={backHref}>{labels.back}</a>
|
| 264 |
</div>
|
| 265 |
</nav>
|
|
|
|
| 275 |
</p>
|
| 276 |
</header>
|
| 277 |
|
| 278 |
+
{state === "idle" && (
|
| 279 |
+
<form className="scribe-panel tool-panel" onSubmit={submit}>
|
| 280 |
+
<div className="tool-start-guide">
|
| 281 |
+
<h2>Ba bước để tạo bản nháp</h2>
|
| 282 |
+
<ol>
|
| 283 |
{labels.preStartSteps.map((step, index) => (
|
| 284 |
<li key={step}>
|
| 285 |
<span aria-hidden="true">{index + 1}</span>
|
| 286 |
{step}
|
| 287 |
</li>
|
| 288 |
))}
|
| 289 |
+
</ol>
|
| 290 |
+
<p>{labels.uploadNotice}</p>
|
| 291 |
+
<a href="/#demo">Xem ca khám mẫu trước</a>
|
| 292 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
<label
|
| 294 |
className={dragging ? "dropzone is-drag" : "dropzone"}
|
| 295 |
onDragEnter={(event) => {
|
scribe/frontend/src/styles.css
CHANGED
|
@@ -115,7 +115,6 @@ textarea {
|
|
| 115 |
.button:focus-visible,
|
| 116 |
.button-link:focus-visible,
|
| 117 |
.scenario:focus-visible,
|
| 118 |
-
.language-toggle button:focus-visible,
|
| 119 |
.nav-cta:focus-visible,
|
| 120 |
a:focus-visible,
|
| 121 |
input:focus-visible,
|
|
@@ -183,30 +182,6 @@ textarea:focus-visible {
|
|
| 183 |
gap: 0.7rem;
|
| 184 |
}
|
| 185 |
|
| 186 |
-
.language-toggle {
|
| 187 |
-
display: flex;
|
| 188 |
-
overflow: hidden;
|
| 189 |
-
border: 1px solid var(--line-strong);
|
| 190 |
-
border-radius: 999px;
|
| 191 |
-
}
|
| 192 |
-
|
| 193 |
-
.language-toggle button {
|
| 194 |
-
min-width: 2.4rem;
|
| 195 |
-
min-height: 2.4rem;
|
| 196 |
-
padding: 0;
|
| 197 |
-
border: 0;
|
| 198 |
-
background: transparent;
|
| 199 |
-
color: var(--text);
|
| 200 |
-
cursor: pointer;
|
| 201 |
-
font-size: 0.7rem;
|
| 202 |
-
font-weight: 700;
|
| 203 |
-
}
|
| 204 |
-
|
| 205 |
-
.language-toggle button[aria-pressed="true"] {
|
| 206 |
-
background: var(--text);
|
| 207 |
-
color: var(--bg);
|
| 208 |
-
}
|
| 209 |
-
|
| 210 |
.nav-cta {
|
| 211 |
padding: 0.6rem 1rem;
|
| 212 |
border-radius: var(--r-control);
|
|
@@ -1295,7 +1270,7 @@ mark.scribe-mark {
|
|
| 1295 |
|
| 1296 |
.site-footer {
|
| 1297 |
display: grid;
|
| 1298 |
-
grid-template-columns: 1fr 1.5fr
|
| 1299 |
gap: clamp(1.5rem, 4vw, 4rem);
|
| 1300 |
align-items: start;
|
| 1301 |
margin-top: var(--section-pad);
|
|
@@ -1460,7 +1435,7 @@ mark.scribe-mark {
|
|
| 1460 |
|
| 1461 |
}
|
| 1462 |
|
| 1463 |
-
/* ----------
|
| 1464 |
|
| 1465 |
.site-nav__menu {
|
| 1466 |
display: none;
|
|
@@ -1492,614 +1467,540 @@ mark.scribe-mark {
|
|
| 1492 |
white-space: nowrap;
|
| 1493 |
}
|
| 1494 |
|
| 1495 |
-
.
|
| 1496 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1497 |
width: min(100% - 3rem, var(--container));
|
| 1498 |
-
min-height: min(50rem, calc(100svh - 4rem));
|
| 1499 |
margin: 0 auto;
|
| 1500 |
-
|
| 1501 |
}
|
| 1502 |
|
| 1503 |
-
.
|
| 1504 |
-
|
|
|
|
|
|
|
|
|
|
| 1505 |
max-width: 72rem;
|
| 1506 |
margin: 0;
|
| 1507 |
-
font-size: clamp(
|
| 1508 |
font-weight: 700;
|
| 1509 |
letter-spacing: -0.065em;
|
| 1510 |
line-height: 0.98;
|
| 1511 |
text-wrap: balance;
|
| 1512 |
}
|
| 1513 |
|
| 1514 |
-
.
|
| 1515 |
display: grid;
|
| 1516 |
-
grid-template-columns: minmax(
|
| 1517 |
-
gap: clamp(
|
| 1518 |
-
align-items:
|
| 1519 |
-
margin-top: clamp(
|
| 1520 |
}
|
| 1521 |
|
| 1522 |
-
.hero__copy {
|
| 1523 |
-
max-width:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1524 |
}
|
| 1525 |
|
| 1526 |
-
.
|
| 1527 |
-
|
| 1528 |
-
|
|
|
|
| 1529 |
color: var(--muted);
|
| 1530 |
-
font-size:
|
| 1531 |
-
line-height: 1.
|
| 1532 |
}
|
| 1533 |
|
| 1534 |
-
.hero__actions {
|
| 1535 |
display: flex;
|
| 1536 |
flex-wrap: wrap;
|
| 1537 |
gap: 0.75rem;
|
| 1538 |
-
margin-top: 1.
|
| 1539 |
-
}
|
| 1540 |
-
|
| 1541 |
-
.button-link {
|
| 1542 |
-
transition:
|
| 1543 |
-
transform 220ms ease,
|
| 1544 |
-
background 220ms ease,
|
| 1545 |
-
border-color 220ms ease;
|
| 1546 |
}
|
| 1547 |
|
| 1548 |
-
.
|
| 1549 |
-
|
|
|
|
|
|
|
| 1550 |
}
|
| 1551 |
|
| 1552 |
-
.
|
| 1553 |
-
|
| 1554 |
-
|
| 1555 |
-
|
|
|
|
|
|
|
|
|
|
| 1556 |
}
|
| 1557 |
|
| 1558 |
-
.
|
| 1559 |
-
|
| 1560 |
-
background: color-mix(in srgb, var(--teal), black 16%);
|
| 1561 |
}
|
| 1562 |
|
| 1563 |
-
.
|
| 1564 |
-
|
| 1565 |
-
|
| 1566 |
-
color:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1567 |
}
|
| 1568 |
|
| 1569 |
-
.
|
| 1570 |
-
|
| 1571 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1572 |
}
|
| 1573 |
|
| 1574 |
-
.
|
| 1575 |
-
|
| 1576 |
-
min-height: 18rem;
|
| 1577 |
-
border: 1px solid var(--line);
|
| 1578 |
-
background:
|
| 1579 |
-
linear-gradient(135deg, color-mix(in srgb, var(--teal) 18%, transparent), transparent 54%),
|
| 1580 |
-
linear-gradient(315deg, color-mix(in srgb, var(--amber) 18%, transparent), transparent 56%),
|
| 1581 |
-
var(--surface);
|
| 1582 |
-
box-shadow: var(--shadow);
|
| 1583 |
}
|
| 1584 |
|
| 1585 |
-
.
|
| 1586 |
-
|
| 1587 |
-
inset: 12% 18%;
|
| 1588 |
-
border: 1px solid var(--line);
|
| 1589 |
-
border-radius: 50%;
|
| 1590 |
-
content: "";
|
| 1591 |
-
transform: rotate(-12deg);
|
| 1592 |
}
|
| 1593 |
|
| 1594 |
-
.
|
| 1595 |
-
position: absolute;
|
| 1596 |
display: grid;
|
| 1597 |
-
|
| 1598 |
-
|
| 1599 |
-
align-items: center;
|
| 1600 |
-
width: min(82%, 28rem);
|
| 1601 |
-
padding: 1rem 1.1rem;
|
| 1602 |
-
border: 1px solid var(--line-strong);
|
| 1603 |
-
background: var(--surface);
|
| 1604 |
-
box-shadow: 0 1rem 2.5rem rgb(16 42 46 / 0.1);
|
| 1605 |
}
|
| 1606 |
|
| 1607 |
-
.
|
| 1608 |
-
|
| 1609 |
-
font-
|
|
|
|
| 1610 |
}
|
| 1611 |
|
| 1612 |
-
.
|
| 1613 |
-
|
|
|
|
|
|
|
| 1614 |
}
|
| 1615 |
|
| 1616 |
-
.
|
| 1617 |
-
|
| 1618 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1619 |
}
|
| 1620 |
|
| 1621 |
-
.
|
| 1622 |
-
|
| 1623 |
-
|
| 1624 |
-
|
| 1625 |
-
transform: rotate(-3deg);
|
| 1626 |
}
|
| 1627 |
|
| 1628 |
-
.
|
| 1629 |
-
|
| 1630 |
-
bottom: 20%;
|
| 1631 |
-
color: var(--amber-text);
|
| 1632 |
-
transform: rotate(2.5deg);
|
| 1633 |
}
|
| 1634 |
|
| 1635 |
-
.
|
| 1636 |
-
max-width:
|
| 1637 |
}
|
| 1638 |
|
| 1639 |
-
.
|
| 1640 |
-
max-width:
|
| 1641 |
}
|
| 1642 |
|
| 1643 |
-
.
|
| 1644 |
-
.
|
|
|
|
| 1645 |
margin: 0;
|
| 1646 |
-
font-size: clamp(2rem, 4.
|
| 1647 |
-
font-weight:
|
| 1648 |
-
letter-spacing: -0.
|
| 1649 |
-
line-height: 1.
|
| 1650 |
text-wrap: balance;
|
| 1651 |
}
|
| 1652 |
|
| 1653 |
-
.
|
|
|
|
|
|
|
| 1654 |
max-width: 46rem;
|
| 1655 |
-
margin: 1.
|
| 1656 |
color: var(--muted);
|
| 1657 |
-
line-height: 1.
|
| 1658 |
-
}
|
| 1659 |
-
|
| 1660 |
-
.product-gateway {
|
| 1661 |
-
width: min(100% - 3rem, var(--container));
|
| 1662 |
-
margin: 0 auto;
|
| 1663 |
-
padding: clamp(3rem, 7vw, 6rem) 0;
|
| 1664 |
-
scroll-margin-top: 5rem;
|
| 1665 |
}
|
| 1666 |
|
| 1667 |
-
.
|
| 1668 |
display: grid;
|
| 1669 |
-
grid-template-columns: minmax(0, 1.
|
| 1670 |
-
|
|
|
|
| 1671 |
border: 1px solid var(--line);
|
| 1672 |
background: var(--line);
|
| 1673 |
-
gap: 1px;
|
| 1674 |
-
overflow: hidden;
|
| 1675 |
}
|
| 1676 |
|
| 1677 |
-
.
|
| 1678 |
-
|
| 1679 |
-
grid-template-rows: auto auto auto 1fr auto;
|
| 1680 |
-
gap: 1rem;
|
| 1681 |
-
min-width: 0;
|
| 1682 |
padding: clamp(1.5rem, 3.5vw, 3rem);
|
| 1683 |
-
|
| 1684 |
-
text-decoration: none;
|
| 1685 |
-
}
|
| 1686 |
-
|
| 1687 |
-
.product-accordion__panel--interpreter {
|
| 1688 |
-
background:
|
| 1689 |
-
linear-gradient(160deg, color-mix(in srgb, var(--teal) 9%, transparent), transparent 58%),
|
| 1690 |
-
var(--surface);
|
| 1691 |
-
}
|
| 1692 |
-
|
| 1693 |
-
.product-accordion__panel--scribe {
|
| 1694 |
-
background:
|
| 1695 |
-
linear-gradient(160deg, color-mix(in srgb, var(--amber) 20%, transparent), transparent 58%),
|
| 1696 |
-
var(--surface);
|
| 1697 |
-
}
|
| 1698 |
-
|
| 1699 |
-
.product-accordion__panel--scribe .product-accordion__heading h2 {
|
| 1700 |
-
font-size: clamp(2rem, 4vw, 3.5rem);
|
| 1701 |
-
}
|
| 1702 |
-
|
| 1703 |
-
.product-accordion__panel--scribe .product-accordion__status {
|
| 1704 |
-
color: var(--amber-text);
|
| 1705 |
-
}
|
| 1706 |
-
|
| 1707 |
-
.product-accordion__heading {
|
| 1708 |
-
display: grid;
|
| 1709 |
-
gap: 0.65rem;
|
| 1710 |
}
|
| 1711 |
|
| 1712 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1713 |
margin: 0;
|
| 1714 |
-
font-size:
|
| 1715 |
-
letter-spacing: -0.04em;
|
| 1716 |
-
line-height: 1;
|
| 1717 |
}
|
| 1718 |
|
| 1719 |
-
.
|
| 1720 |
-
.
|
| 1721 |
-
max-width: 24rem;
|
| 1722 |
color: var(--muted);
|
| 1723 |
-
font-size: 0.
|
| 1724 |
-
font-weight: 600;
|
| 1725 |
-
}
|
| 1726 |
-
|
| 1727 |
-
.product-accordion__status {
|
| 1728 |
-
color: var(--accent-ink);
|
| 1729 |
-
}
|
| 1730 |
-
|
| 1731 |
-
.product-accordion__panel > p {
|
| 1732 |
-
max-width: 34rem;
|
| 1733 |
-
margin: 0;
|
| 1734 |
-
color: var(--muted);
|
| 1735 |
-
line-height: 1.65;
|
| 1736 |
}
|
| 1737 |
|
| 1738 |
-
.
|
| 1739 |
display: grid;
|
|
|
|
| 1740 |
gap: 1rem;
|
| 1741 |
-
margin:
|
| 1742 |
}
|
| 1743 |
|
| 1744 |
-
.
|
| 1745 |
display: grid;
|
| 1746 |
-
|
| 1747 |
-
|
| 1748 |
-
|
| 1749 |
-
|
|
|
|
| 1750 |
}
|
| 1751 |
|
| 1752 |
-
.
|
| 1753 |
-
|
| 1754 |
-
|
| 1755 |
-
|
| 1756 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1757 |
}
|
| 1758 |
|
| 1759 |
-
.
|
| 1760 |
-
|
| 1761 |
-
|
| 1762 |
-
|
|
|
|
|
|
|
|
|
|
| 1763 |
}
|
| 1764 |
|
| 1765 |
-
.
|
| 1766 |
-
|
| 1767 |
-
|
| 1768 |
-
|
| 1769 |
-
align-self: end;
|
| 1770 |
-
font-size: 0.88rem;
|
| 1771 |
}
|
| 1772 |
|
| 1773 |
-
.
|
| 1774 |
-
|
| 1775 |
-
|
| 1776 |
-
|
| 1777 |
}
|
| 1778 |
|
| 1779 |
-
.
|
| 1780 |
-
|
|
|
|
|
|
|
| 1781 |
}
|
| 1782 |
|
| 1783 |
-
.
|
| 1784 |
display: grid;
|
| 1785 |
-
|
| 1786 |
-
|
| 1787 |
-
margin: 0 auto;
|
| 1788 |
-
border: 1px solid var(--line);
|
| 1789 |
-
background: var(--line);
|
| 1790 |
-
gap: 1px;
|
| 1791 |
}
|
| 1792 |
|
| 1793 |
-
.
|
| 1794 |
-
padding:
|
| 1795 |
-
|
| 1796 |
}
|
| 1797 |
|
| 1798 |
-
.
|
| 1799 |
-
|
| 1800 |
-
font-size: 1rem;
|
| 1801 |
}
|
| 1802 |
|
| 1803 |
-
.
|
|
|
|
| 1804 |
margin: 0.65rem 0 0;
|
| 1805 |
color: var(--muted);
|
| 1806 |
-
font-size: 0.
|
| 1807 |
-
line-height: 1.
|
| 1808 |
}
|
| 1809 |
|
| 1810 |
-
.
|
| 1811 |
-
|
| 1812 |
-
|
| 1813 |
-
|
| 1814 |
-
scroll-margin-top: 5rem;
|
| 1815 |
-
background:
|
| 1816 |
-
radial-gradient(circle at 12% 8%, rgb(102 216 204 / 0.16), transparent 32rem),
|
| 1817 |
-
var(--deep);
|
| 1818 |
-
color: var(--on-deep);
|
| 1819 |
}
|
| 1820 |
|
| 1821 |
-
.
|
| 1822 |
-
|
| 1823 |
}
|
| 1824 |
|
| 1825 |
-
.
|
| 1826 |
-
|
| 1827 |
-
grid-auto-flow: dense;
|
| 1828 |
-
grid-template-columns: repeat(12, minmax(0, 1fr));
|
| 1829 |
-
grid-template-rows: repeat(2, minmax(0, auto));
|
| 1830 |
-
gap: 1px;
|
| 1831 |
-
margin-top: clamp(3rem, 6vw, 5rem);
|
| 1832 |
-
border: 1px solid rgb(244 241 232 / 0.18);
|
| 1833 |
-
background: rgb(244 241 232 / 0.18);
|
| 1834 |
-
isolation: isolate;
|
| 1835 |
-
overflow: visible;
|
| 1836 |
}
|
| 1837 |
|
| 1838 |
-
.
|
| 1839 |
-
|
| 1840 |
-
|
| 1841 |
-
|
| 1842 |
-
|
| 1843 |
-
|
| 1844 |
-
|
| 1845 |
}
|
| 1846 |
|
| 1847 |
-
.
|
| 1848 |
-
|
| 1849 |
-
|
|
|
|
|
|
|
|
|
|
| 1850 |
}
|
| 1851 |
|
| 1852 |
-
.
|
| 1853 |
-
|
| 1854 |
-
grid-column: span 6;
|
| 1855 |
-
grid-template-columns: 1fr;
|
| 1856 |
}
|
| 1857 |
|
| 1858 |
-
.
|
| 1859 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1860 |
}
|
| 1861 |
|
| 1862 |
-
.
|
| 1863 |
-
|
| 1864 |
-
|
| 1865 |
-
letter-spacing: -0.04em;
|
| 1866 |
-
line-height: 1.05;
|
| 1867 |
}
|
| 1868 |
|
| 1869 |
-
.
|
| 1870 |
margin: 0.9rem 0 0;
|
| 1871 |
-
color: var(--
|
| 1872 |
-
font-size: 0.
|
| 1873 |
-
line-height: 1.
|
| 1874 |
}
|
| 1875 |
|
| 1876 |
-
.
|
| 1877 |
-
display:
|
| 1878 |
-
|
| 1879 |
-
|
| 1880 |
-
|
| 1881 |
-
|
| 1882 |
-
|
| 1883 |
}
|
| 1884 |
|
| 1885 |
-
.
|
| 1886 |
display: grid;
|
| 1887 |
-
|
| 1888 |
-
gap: 0.45rem;
|
| 1889 |
-
padding-top: 0.9rem;
|
| 1890 |
-
border-top: 1px solid rgb(244 241 232 / 0.2);
|
| 1891 |
}
|
| 1892 |
|
| 1893 |
-
.
|
| 1894 |
-
|
| 1895 |
}
|
| 1896 |
|
| 1897 |
-
.
|
| 1898 |
-
|
| 1899 |
-
|
| 1900 |
-
line-height: 1.55;
|
| 1901 |
-
}
|
| 1902 |
-
|
| 1903 |
-
.evidence {
|
| 1904 |
-
display: grid;
|
| 1905 |
-
grid-template-columns: minmax(16rem, 0.55fr) minmax(0, 1fr);
|
| 1906 |
-
gap: clamp(3rem, 8vw, 8rem);
|
| 1907 |
-
width: min(100% - 3rem, var(--container));
|
| 1908 |
margin: 0 auto;
|
| 1909 |
-
padding: clamp(
|
| 1910 |
-
}
|
| 1911 |
-
|
| 1912 |
-
.evidence .section-intro h2 {
|
| 1913 |
-
font-size: clamp(1.85rem, 2.2vw, 2.5rem);
|
| 1914 |
-
}
|
| 1915 |
-
|
| 1916 |
-
.evidence .section-intro a {
|
| 1917 |
-
display: inline-block;
|
| 1918 |
-
margin-top: 1rem;
|
| 1919 |
-
color: var(--text);
|
| 1920 |
-
font-size: 0.84rem;
|
| 1921 |
-
font-weight: 650;
|
| 1922 |
-
text-underline-offset: 0.25rem;
|
| 1923 |
}
|
| 1924 |
|
| 1925 |
-
.
|
| 1926 |
-
display: grid;
|
| 1927 |
-
gap: 1rem;
|
| 1928 |
-
min-width: 0;
|
| 1929 |
-
}
|
| 1930 |
-
|
| 1931 |
-
.evidence-proof {
|
| 1932 |
-
display: grid;
|
| 1933 |
-
grid-template-columns: minmax(13rem, 0.65fr) minmax(0, 1fr);
|
| 1934 |
border: 1px solid var(--line);
|
| 1935 |
background: var(--surface);
|
| 1936 |
-
overflow: hidden;
|
| 1937 |
}
|
| 1938 |
|
| 1939 |
-
.
|
| 1940 |
-
|
| 1941 |
-
align-content: start;
|
| 1942 |
-
padding: clamp(1.5rem, 3.5vw, 3rem);
|
| 1943 |
}
|
| 1944 |
|
| 1945 |
-
.
|
| 1946 |
-
|
| 1947 |
-
|
| 1948 |
-
|
| 1949 |
-
|
|
|
|
|
|
|
| 1950 |
}
|
| 1951 |
|
| 1952 |
-
.
|
| 1953 |
-
|
| 1954 |
-
|
| 1955 |
}
|
| 1956 |
|
| 1957 |
-
.
|
| 1958 |
-
|
| 1959 |
-
|
| 1960 |
-
|
| 1961 |
-
|
| 1962 |
}
|
| 1963 |
|
| 1964 |
-
.
|
| 1965 |
-
margin: 1.2rem 0 0;
|
| 1966 |
color: var(--muted);
|
| 1967 |
font-size: 0.88rem;
|
| 1968 |
-
line-height: 1.
|
| 1969 |
}
|
| 1970 |
|
| 1971 |
-
.
|
| 1972 |
display: grid;
|
| 1973 |
-
|
| 1974 |
-
|
| 1975 |
-
|
| 1976 |
-
|
| 1977 |
-
|
|
|
|
| 1978 |
}
|
| 1979 |
|
| 1980 |
-
.
|
| 1981 |
-
|
| 1982 |
-
background:
|
| 1983 |
-
radial-gradient(circle at 80% 15%, color-mix(in srgb, var(--amber) 20%, transparent), transparent 16rem),
|
| 1984 |
-
var(--tint-section);
|
| 1985 |
}
|
| 1986 |
|
| 1987 |
-
.
|
| 1988 |
-
|
| 1989 |
-
border: 1px solid var(--line-strong);
|
| 1990 |
background: var(--surface);
|
|
|
|
| 1991 |
}
|
| 1992 |
|
| 1993 |
-
.
|
| 1994 |
-
|
| 1995 |
-
|
| 1996 |
-
|
| 1997 |
-
padding: 0.8rem 1rem;
|
| 1998 |
-
border-bottom: 0;
|
| 1999 |
-
color: var(--muted);
|
| 2000 |
-
font-size: 0.7rem;
|
| 2001 |
-
font-weight: 650;
|
| 2002 |
-
}
|
| 2003 |
-
|
| 2004 |
-
.evidence-capture__content {
|
| 2005 |
-
display: grid;
|
| 2006 |
-
gap: 1rem;
|
| 2007 |
-
min-height: 17rem;
|
| 2008 |
-
align-content: center;
|
| 2009 |
-
padding: clamp(1.25rem, 3vw, 2.5rem);
|
| 2010 |
}
|
| 2011 |
|
| 2012 |
-
.
|
| 2013 |
-
|
| 2014 |
-
padding: 0.85rem 1rem;
|
| 2015 |
-
border: 1px solid var(--line);
|
| 2016 |
-
background: var(--tint-section);
|
| 2017 |
}
|
| 2018 |
|
| 2019 |
-
.
|
| 2020 |
-
|
| 2021 |
-
|
| 2022 |
font-size: 0.86rem;
|
| 2023 |
-
line-height: 1.5;
|
| 2024 |
}
|
| 2025 |
|
| 2026 |
-
.
|
| 2027 |
-
|
| 2028 |
-
|
| 2029 |
-
|
| 2030 |
-
|
| 2031 |
-
.product-accordion__safety {
|
| 2032 |
-
padding-top: 0.9rem;
|
| 2033 |
-
border-top: 1px solid var(--line);
|
| 2034 |
-
color: var(--muted);
|
| 2035 |
}
|
| 2036 |
|
| 2037 |
-
.
|
| 2038 |
-
|
| 2039 |
-
|
| 2040 |
-
color: var(--muted);
|
| 2041 |
-
font-size: 0.7rem;
|
| 2042 |
-
font-weight: 650;
|
| 2043 |
-
line-height: 1.5;
|
| 2044 |
}
|
| 2045 |
|
| 2046 |
-
.
|
| 2047 |
-
|
| 2048 |
-
|
| 2049 |
-
font-size: 0.78rem;
|
| 2050 |
-
line-height: 1.55;
|
| 2051 |
}
|
| 2052 |
|
| 2053 |
-
.
|
| 2054 |
display: grid;
|
|
|
|
| 2055 |
gap: 0.75rem;
|
| 2056 |
-
margin: 0;
|
|
|
|
|
|
|
| 2057 |
}
|
| 2058 |
|
| 2059 |
-
.
|
| 2060 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2061 |
}
|
| 2062 |
|
| 2063 |
-
.
|
| 2064 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2065 |
}
|
| 2066 |
|
| 2067 |
-
.
|
| 2068 |
-
|
| 2069 |
-
|
| 2070 |
-
|
| 2071 |
-
font-size: 0.8rem;
|
| 2072 |
line-height: 1.55;
|
| 2073 |
}
|
| 2074 |
|
| 2075 |
-
.
|
| 2076 |
-
|
| 2077 |
-
|
| 2078 |
-
|
| 2079 |
-
|
| 2080 |
-
|
| 2081 |
-
outline: 3px solid color-mix(in srgb, var(--teal), white 30%);
|
| 2082 |
-
outline-offset: 3px;
|
| 2083 |
-
}
|
| 2084 |
-
|
| 2085 |
-
.pilot {
|
| 2086 |
-
padding-block: clamp(8rem, 14vw, 12rem);
|
| 2087 |
-
}
|
| 2088 |
-
|
| 2089 |
-
.lead-form select {
|
| 2090 |
-
width: 100%;
|
| 2091 |
-
min-height: 2.75rem;
|
| 2092 |
-
padding: 0.65rem 0.75rem;
|
| 2093 |
-
border: 1px solid var(--line-strong);
|
| 2094 |
-
border-radius: var(--r-control);
|
| 2095 |
-
background: var(--input-bg);
|
| 2096 |
-
color: var(--text);
|
| 2097 |
}
|
| 2098 |
|
| 2099 |
@media (max-width: 1023px) {
|
| 2100 |
.site-nav__menu {
|
| 2101 |
display: block;
|
| 2102 |
-
order: 2;
|
| 2103 |
}
|
| 2104 |
|
| 2105 |
.site-nav__menu summary {
|
|
@@ -2142,28 +2043,19 @@ mark.scribe-mark {
|
|
| 2142 |
border-bottom: 0;
|
| 2143 |
}
|
| 2144 |
|
| 2145 |
-
.
|
| 2146 |
-
|
| 2147 |
-
|
| 2148 |
-
|
| 2149 |
-
.evidence {
|
| 2150 |
grid-template-columns: 1fr;
|
| 2151 |
}
|
| 2152 |
|
| 2153 |
-
.evidence
|
| 2154 |
-
|
| 2155 |
}
|
| 2156 |
}
|
| 2157 |
|
| 2158 |
@media (max-width: 760px) {
|
| 2159 |
-
.site-nav {
|
| 2160 |
-
gap: 0.6rem;
|
| 2161 |
-
}
|
| 2162 |
-
|
| 2163 |
-
.site-nav__menu > div {
|
| 2164 |
-
right: 1rem;
|
| 2165 |
-
}
|
| 2166 |
-
|
| 2167 |
.site-nav__brand > span:not(.product-breadcrumb) {
|
| 2168 |
display: none;
|
| 2169 |
}
|
|
@@ -2173,10 +2065,7 @@ mark.scribe-mark {
|
|
| 2173 |
display: flex;
|
| 2174 |
}
|
| 2175 |
|
| 2176 |
-
.product-breadcrumb strong
|
| 2177 |
-
display: none;
|
| 2178 |
-
}
|
| 2179 |
-
|
| 2180 |
.product-shell-status {
|
| 2181 |
display: none;
|
| 2182 |
}
|
|
@@ -2200,54 +2089,70 @@ mark.scribe-mark {
|
|
| 2200 |
transform: translate(-50%, -50%);
|
| 2201 |
}
|
| 2202 |
|
| 2203 |
-
.
|
| 2204 |
-
|
| 2205 |
-
width: min(100% - 1.5rem, var(--container));
|
| 2206 |
}
|
| 2207 |
|
| 2208 |
-
.
|
| 2209 |
-
|
| 2210 |
}
|
| 2211 |
|
| 2212 |
-
.
|
| 2213 |
-
|
|
|
|
|
|
|
| 2214 |
width: min(100% - 1.5rem, var(--container));
|
| 2215 |
}
|
| 2216 |
|
| 2217 |
-
.
|
| 2218 |
-
|
| 2219 |
}
|
| 2220 |
|
| 2221 |
-
.
|
| 2222 |
-
|
| 2223 |
-
|
|
|
|
| 2224 |
}
|
| 2225 |
|
| 2226 |
-
.
|
| 2227 |
-
|
| 2228 |
-
|
| 2229 |
}
|
| 2230 |
|
| 2231 |
-
.
|
| 2232 |
-
|
| 2233 |
-
.safety-bento__card--scribe {
|
| 2234 |
-
grid-column: span 1;
|
| 2235 |
}
|
| 2236 |
|
| 2237 |
-
.
|
| 2238 |
-
.safety-bento__card--shared {
|
| 2239 |
grid-template-columns: 1fr;
|
|
|
|
| 2240 |
}
|
| 2241 |
|
| 2242 |
-
.
|
| 2243 |
-
|
|
|
|
|
|
|
| 2244 |
}
|
| 2245 |
|
| 2246 |
-
.
|
| 2247 |
-
|
| 2248 |
}
|
| 2249 |
|
| 2250 |
-
.
|
| 2251 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2252 |
}
|
| 2253 |
}
|
|
|
|
| 115 |
.button:focus-visible,
|
| 116 |
.button-link:focus-visible,
|
| 117 |
.scenario:focus-visible,
|
|
|
|
| 118 |
.nav-cta:focus-visible,
|
| 119 |
a:focus-visible,
|
| 120 |
input:focus-visible,
|
|
|
|
| 182 |
gap: 0.7rem;
|
| 183 |
}
|
| 184 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
.nav-cta {
|
| 186 |
padding: 0.6rem 1rem;
|
| 187 |
border-radius: var(--r-control);
|
|
|
|
| 1270 |
|
| 1271 |
.site-footer {
|
| 1272 |
display: grid;
|
| 1273 |
+
grid-template-columns: 1fr 1.5fr auto;
|
| 1274 |
gap: clamp(1.5rem, 4vw, 4rem);
|
| 1275 |
align-items: start;
|
| 1276 |
margin-top: var(--section-pad);
|
|
|
|
| 1435 |
|
| 1436 |
}
|
| 1437 |
|
| 1438 |
+
/* ---------- CP-UX-11 compact Vietnamese onboarding ---------- */
|
| 1439 |
|
| 1440 |
.site-nav__menu {
|
| 1441 |
display: none;
|
|
|
|
| 1467 |
white-space: nowrap;
|
| 1468 |
}
|
| 1469 |
|
| 1470 |
+
.landing-main {
|
| 1471 |
+
overflow-x: clip;
|
| 1472 |
+
width: 100%;
|
| 1473 |
+
max-width: 100%;
|
| 1474 |
+
}
|
| 1475 |
+
|
| 1476 |
+
.interpreter-status {
|
| 1477 |
+
display: flex;
|
| 1478 |
+
flex-wrap: wrap;
|
| 1479 |
+
gap: 0.35rem 1rem;
|
| 1480 |
+
align-items: baseline;
|
| 1481 |
+
width: min(100% - 3rem, var(--container));
|
| 1482 |
+
margin: 1.25rem auto 0;
|
| 1483 |
+
padding: 0.75rem 1.1rem;
|
| 1484 |
+
border: 1px solid var(--line);
|
| 1485 |
+
background: var(--surface);
|
| 1486 |
+
}
|
| 1487 |
+
|
| 1488 |
+
.interpreter-status strong {
|
| 1489 |
+
font-size: 0.8rem;
|
| 1490 |
+
}
|
| 1491 |
+
|
| 1492 |
+
.interpreter-status span {
|
| 1493 |
+
color: var(--muted);
|
| 1494 |
+
font-size: 0.8rem;
|
| 1495 |
+
font-weight: 500;
|
| 1496 |
+
}
|
| 1497 |
+
|
| 1498 |
+
.button-link--primary {
|
| 1499 |
+
border-color: var(--deep);
|
| 1500 |
+
background: var(--deep);
|
| 1501 |
+
color: var(--on-deep);
|
| 1502 |
+
}
|
| 1503 |
+
|
| 1504 |
+
.button-link--primary:hover {
|
| 1505 |
+
background: color-mix(in srgb, var(--deep), black 16%);
|
| 1506 |
+
}
|
| 1507 |
+
|
| 1508 |
+
.button-link--light {
|
| 1509 |
+
margin-top: 1.6rem;
|
| 1510 |
+
border-color: var(--on-deep);
|
| 1511 |
+
background: var(--on-deep);
|
| 1512 |
+
color: var(--deep);
|
| 1513 |
+
}
|
| 1514 |
+
|
| 1515 |
+
.onboarding-hero,
|
| 1516 |
+
.burden-section,
|
| 1517 |
+
.demo-section,
|
| 1518 |
+
.evidence-section {
|
| 1519 |
width: min(100% - 3rem, var(--container));
|
|
|
|
| 1520 |
margin: 0 auto;
|
| 1521 |
+
scroll-margin-top: 5rem;
|
| 1522 |
}
|
| 1523 |
|
| 1524 |
+
.onboarding-hero {
|
| 1525 |
+
padding: clamp(4rem, 7vw, 6rem) 0 clamp(4rem, 7vw, 6rem);
|
| 1526 |
+
}
|
| 1527 |
+
|
| 1528 |
+
.onboarding-hero > h1 {
|
| 1529 |
max-width: 72rem;
|
| 1530 |
margin: 0;
|
| 1531 |
+
font-size: clamp(2.7rem, 5.4vw, 4.9rem);
|
| 1532 |
font-weight: 700;
|
| 1533 |
letter-spacing: -0.065em;
|
| 1534 |
line-height: 0.98;
|
| 1535 |
text-wrap: balance;
|
| 1536 |
}
|
| 1537 |
|
| 1538 |
+
.onboarding-hero__body {
|
| 1539 |
display: grid;
|
| 1540 |
+
grid-template-columns: minmax(20rem, 1fr) minmax(24rem, 1fr);
|
| 1541 |
+
gap: clamp(2.5rem, 5vw, 5rem);
|
| 1542 |
+
align-items: start;
|
| 1543 |
+
margin-top: clamp(2.25rem, 4.5vw, 3.75rem);
|
| 1544 |
}
|
| 1545 |
|
| 1546 |
+
.onboarding-hero__copy > p {
|
| 1547 |
+
max-width: 34rem;
|
| 1548 |
+
margin: 0;
|
| 1549 |
+
color: var(--muted);
|
| 1550 |
+
font-size: clamp(1.02rem, 1.6vw, 1.22rem);
|
| 1551 |
+
line-height: 1.65;
|
| 1552 |
}
|
| 1553 |
|
| 1554 |
+
.onboarding-hero__copy > small {
|
| 1555 |
+
display: block;
|
| 1556 |
+
max-width: 34rem;
|
| 1557 |
+
margin-top: 1.1rem;
|
| 1558 |
color: var(--muted);
|
| 1559 |
+
font-size: 0.78rem;
|
| 1560 |
+
line-height: 1.55;
|
| 1561 |
}
|
| 1562 |
|
| 1563 |
+
.onboarding-hero__actions {
|
| 1564 |
display: flex;
|
| 1565 |
flex-wrap: wrap;
|
| 1566 |
gap: 0.75rem;
|
| 1567 |
+
margin-top: 1.7rem;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1568 |
}
|
| 1569 |
|
| 1570 |
+
.hero-proof {
|
| 1571 |
+
border: 1px solid var(--line);
|
| 1572 |
+
background: var(--surface);
|
| 1573 |
+
box-shadow: var(--shadow);
|
| 1574 |
}
|
| 1575 |
|
| 1576 |
+
.hero-proof__title {
|
| 1577 |
+
margin: 0;
|
| 1578 |
+
padding: 0.8rem 1.1rem;
|
| 1579 |
+
border-bottom: 1px solid var(--line);
|
| 1580 |
+
color: var(--accent-ink);
|
| 1581 |
+
font-size: 0.75rem;
|
| 1582 |
+
font-weight: 700;
|
| 1583 |
}
|
| 1584 |
|
| 1585 |
+
.hero-proof__stage {
|
| 1586 |
+
padding: 1rem 1.1rem;
|
|
|
|
| 1587 |
}
|
| 1588 |
|
| 1589 |
+
.hero-proof__stage > span {
|
| 1590 |
+
display: block;
|
| 1591 |
+
margin-bottom: 0.5rem;
|
| 1592 |
+
color: var(--muted);
|
| 1593 |
+
font-size: 0.72rem;
|
| 1594 |
+
font-weight: 650;
|
| 1595 |
+
letter-spacing: 0.04em;
|
| 1596 |
+
text-transform: uppercase;
|
| 1597 |
}
|
| 1598 |
|
| 1599 |
+
.hero-proof__stage > p {
|
| 1600 |
+
margin: 0;
|
| 1601 |
+
padding: 0.7rem 0.85rem;
|
| 1602 |
+
border-left: 3px solid var(--teal);
|
| 1603 |
+
background: var(--surface-2);
|
| 1604 |
+
font-size: 0.88rem;
|
| 1605 |
+
line-height: 1.6;
|
| 1606 |
}
|
| 1607 |
|
| 1608 |
+
.hero-proof__stage--draft {
|
| 1609 |
+
border-top: 1px solid var(--line);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1610 |
}
|
| 1611 |
|
| 1612 |
+
.hero-proof__stage--draft > span {
|
| 1613 |
+
color: var(--accent-ink);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1614 |
}
|
| 1615 |
|
| 1616 |
+
.hero-proof__stage dl {
|
|
|
|
| 1617 |
display: grid;
|
| 1618 |
+
gap: 0.75rem;
|
| 1619 |
+
margin: 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1620 |
}
|
| 1621 |
|
| 1622 |
+
.hero-proof__stage dt {
|
| 1623 |
+
font-size: 0.72rem;
|
| 1624 |
+
font-weight: 700;
|
| 1625 |
+
color: var(--muted);
|
| 1626 |
}
|
| 1627 |
|
| 1628 |
+
.hero-proof__stage dd {
|
| 1629 |
+
margin: 0.15rem 0 0;
|
| 1630 |
+
font-size: 0.88rem;
|
| 1631 |
+
line-height: 1.55;
|
| 1632 |
}
|
| 1633 |
|
| 1634 |
+
.hero-proof__caption {
|
| 1635 |
+
margin: 0;
|
| 1636 |
+
padding: 0.8rem 1.1rem;
|
| 1637 |
+
border-top: 1px solid var(--line);
|
| 1638 |
+
color: var(--muted);
|
| 1639 |
+
font-size: 0.75rem;
|
| 1640 |
+
line-height: 1.55;
|
| 1641 |
}
|
| 1642 |
|
| 1643 |
+
.burden-section,
|
| 1644 |
+
.demo-section,
|
| 1645 |
+
.evidence-section {
|
| 1646 |
+
padding: clamp(3.5rem, 6vw, 5.5rem) 0;
|
|
|
|
| 1647 |
}
|
| 1648 |
|
| 1649 |
+
.burden-section {
|
| 1650 |
+
border-top: 1px solid var(--line);
|
|
|
|
|
|
|
|
|
|
| 1651 |
}
|
| 1652 |
|
| 1653 |
+
.compact-heading {
|
| 1654 |
+
max-width: 50rem;
|
| 1655 |
}
|
| 1656 |
|
| 1657 |
+
.compact-heading--wide {
|
| 1658 |
+
max-width: 61rem;
|
| 1659 |
}
|
| 1660 |
|
| 1661 |
+
.compact-heading h2,
|
| 1662 |
+
.trust-section h2,
|
| 1663 |
+
.start-section h2 {
|
| 1664 |
margin: 0;
|
| 1665 |
+
font-size: clamp(2rem, 4.2vw, 3.9rem);
|
| 1666 |
+
font-weight: 680;
|
| 1667 |
+
letter-spacing: -0.055em;
|
| 1668 |
+
line-height: 1.04;
|
| 1669 |
text-wrap: balance;
|
| 1670 |
}
|
| 1671 |
|
| 1672 |
+
.compact-heading > p,
|
| 1673 |
+
.trust-section > header > p,
|
| 1674 |
+
.start-section__copy > p {
|
| 1675 |
max-width: 46rem;
|
| 1676 |
+
margin: 1.1rem 0 0;
|
| 1677 |
color: var(--muted);
|
| 1678 |
+
line-height: 1.7;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1679 |
}
|
| 1680 |
|
| 1681 |
+
.burden-layout {
|
| 1682 |
display: grid;
|
| 1683 |
+
grid-template-columns: minmax(0, 1.15fr) minmax(18rem, 0.85fr);
|
| 1684 |
+
gap: 1px;
|
| 1685 |
+
margin-top: 2.5rem;
|
| 1686 |
border: 1px solid var(--line);
|
| 1687 |
background: var(--line);
|
|
|
|
|
|
|
| 1688 |
}
|
| 1689 |
|
| 1690 |
+
.burden-calculator,
|
| 1691 |
+
.workflow-comparison {
|
|
|
|
|
|
|
|
|
|
| 1692 |
padding: clamp(1.5rem, 3.5vw, 3rem);
|
| 1693 |
+
background: var(--surface);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1694 |
}
|
| 1695 |
|
| 1696 |
+
.burden-calculator h3,
|
| 1697 |
+
.workflow-comparison h3,
|
| 1698 |
+
.evidence-grid h3,
|
| 1699 |
+
.trust-grid h3,
|
| 1700 |
+
.review-checklist h3 {
|
| 1701 |
margin: 0;
|
| 1702 |
+
font-size: 1.05rem;
|
|
|
|
|
|
|
| 1703 |
}
|
| 1704 |
|
| 1705 |
+
.burden-calculator > p {
|
| 1706 |
+
margin: 0.55rem 0 0;
|
|
|
|
| 1707 |
color: var(--muted);
|
| 1708 |
+
font-size: 0.86rem;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1709 |
}
|
| 1710 |
|
| 1711 |
+
.calculator-fields {
|
| 1712 |
display: grid;
|
| 1713 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 1714 |
gap: 1rem;
|
| 1715 |
+
margin-top: 1.5rem;
|
| 1716 |
}
|
| 1717 |
|
| 1718 |
+
.calculator-fields label {
|
| 1719 |
display: grid;
|
| 1720 |
+
gap: 0.45rem;
|
| 1721 |
+
min-width: 0;
|
| 1722 |
+
color: var(--muted);
|
| 1723 |
+
font-size: 0.78rem;
|
| 1724 |
+
font-weight: 650;
|
| 1725 |
}
|
| 1726 |
|
| 1727 |
+
.calculator-fields input,
|
| 1728 |
+
.calculator-fields select {
|
| 1729 |
+
width: 100%;
|
| 1730 |
+
min-height: 2.9rem;
|
| 1731 |
+
padding: 0.65rem 0.75rem;
|
| 1732 |
+
border: 1px solid var(--line-strong);
|
| 1733 |
+
border-radius: var(--r-control);
|
| 1734 |
+
background: var(--input-bg);
|
| 1735 |
+
color: var(--text);
|
| 1736 |
}
|
| 1737 |
|
| 1738 |
+
.calculator-result {
|
| 1739 |
+
display: grid;
|
| 1740 |
+
gap: 0.3rem;
|
| 1741 |
+
margin-top: 1.5rem;
|
| 1742 |
+
padding: 1.25rem;
|
| 1743 |
+
background: var(--deep);
|
| 1744 |
+
color: var(--on-deep);
|
| 1745 |
}
|
| 1746 |
|
| 1747 |
+
.calculator-result span,
|
| 1748 |
+
.calculator-result small {
|
| 1749 |
+
color: var(--on-deep-muted);
|
| 1750 |
+
font-size: 0.76rem;
|
|
|
|
|
|
|
| 1751 |
}
|
| 1752 |
|
| 1753 |
+
.calculator-result strong {
|
| 1754 |
+
font-size: clamp(1.55rem, 3vw, 2.35rem);
|
| 1755 |
+
letter-spacing: -0.04em;
|
| 1756 |
+
line-height: 1.1;
|
| 1757 |
}
|
| 1758 |
|
| 1759 |
+
.burden-calculator .calculator-formula {
|
| 1760 |
+
margin-top: 0.9rem;
|
| 1761 |
+
font-size: 0.75rem;
|
| 1762 |
+
line-height: 1.55;
|
| 1763 |
}
|
| 1764 |
|
| 1765 |
+
.workflow-comparison {
|
| 1766 |
display: grid;
|
| 1767 |
+
align-content: start;
|
| 1768 |
+
gap: 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1769 |
}
|
| 1770 |
|
| 1771 |
+
.workflow-comparison article {
|
| 1772 |
+
padding: 1.4rem 0;
|
| 1773 |
+
border-bottom: 1px solid var(--line);
|
| 1774 |
}
|
| 1775 |
|
| 1776 |
+
.workflow-comparison article:first-child {
|
| 1777 |
+
padding-top: 0;
|
|
|
|
| 1778 |
}
|
| 1779 |
|
| 1780 |
+
.workflow-comparison article p,
|
| 1781 |
+
.workflow-comparison > p {
|
| 1782 |
margin: 0.65rem 0 0;
|
| 1783 |
color: var(--muted);
|
| 1784 |
+
font-size: 0.9rem;
|
| 1785 |
+
line-height: 1.65;
|
| 1786 |
}
|
| 1787 |
|
| 1788 |
+
.workflow-comparison > p {
|
| 1789 |
+
padding-top: 1.4rem;
|
| 1790 |
+
color: var(--accent-ink);
|
| 1791 |
+
font-weight: 650;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1792 |
}
|
| 1793 |
|
| 1794 |
+
.guided-sample {
|
| 1795 |
+
margin-top: 2.5rem;
|
| 1796 |
}
|
| 1797 |
|
| 1798 |
+
.guided-sample .scribe-tabs {
|
| 1799 |
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1800 |
}
|
| 1801 |
|
| 1802 |
+
.sample-speaker {
|
| 1803 |
+
max-width: 50rem;
|
| 1804 |
+
margin: 0;
|
| 1805 |
+
padding: 0.9rem 1rem;
|
| 1806 |
+
border-left: 3px solid var(--teal);
|
| 1807 |
+
background: var(--surface);
|
| 1808 |
+
line-height: 1.65;
|
| 1809 |
}
|
| 1810 |
|
| 1811 |
+
.review-checklist ul {
|
| 1812 |
+
display: grid;
|
| 1813 |
+
gap: 0.65rem;
|
| 1814 |
+
max-width: 48rem;
|
| 1815 |
+
margin: 1rem 0 1.4rem;
|
| 1816 |
+
padding-left: 1.25rem;
|
| 1817 |
}
|
| 1818 |
|
| 1819 |
+
.evidence-section {
|
| 1820 |
+
border-top: 1px solid var(--line);
|
|
|
|
|
|
|
| 1821 |
}
|
| 1822 |
|
| 1823 |
+
.evidence-grid {
|
| 1824 |
+
display: grid;
|
| 1825 |
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
| 1826 |
+
gap: 1px;
|
| 1827 |
+
margin-top: 2.5rem;
|
| 1828 |
+
border: 1px solid var(--line);
|
| 1829 |
+
background: var(--line);
|
| 1830 |
}
|
| 1831 |
|
| 1832 |
+
.evidence-grid article {
|
| 1833 |
+
padding: clamp(1.4rem, 3vw, 2.4rem);
|
| 1834 |
+
background: var(--surface);
|
|
|
|
|
|
|
| 1835 |
}
|
| 1836 |
|
| 1837 |
+
.evidence-grid p {
|
| 1838 |
margin: 0.9rem 0 0;
|
| 1839 |
+
color: var(--muted);
|
| 1840 |
+
font-size: 0.86rem;
|
| 1841 |
+
line-height: 1.7;
|
| 1842 |
}
|
| 1843 |
|
| 1844 |
+
.evidence-grid a {
|
| 1845 |
+
display: inline-block;
|
| 1846 |
+
margin-top: 1rem;
|
| 1847 |
+
color: var(--accent-ink);
|
| 1848 |
+
font-size: 0.8rem;
|
| 1849 |
+
font-weight: 700;
|
| 1850 |
+
text-underline-offset: 0.24rem;
|
| 1851 |
}
|
| 1852 |
|
| 1853 |
+
.evidence-links {
|
| 1854 |
display: grid;
|
| 1855 |
+
justify-items: start;
|
|
|
|
|
|
|
|
|
|
| 1856 |
}
|
| 1857 |
|
| 1858 |
+
.evidence-links a + a {
|
| 1859 |
+
margin-top: 0.45rem;
|
| 1860 |
}
|
| 1861 |
|
| 1862 |
+
.trust-section,
|
| 1863 |
+
.start-section {
|
| 1864 |
+
width: min(100% - 2rem, calc(var(--container) + 3rem));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1865 |
margin: 0 auto;
|
| 1866 |
+
padding: clamp(2.5rem, 6vw, 5rem);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1867 |
}
|
| 1868 |
|
| 1869 |
+
.trust-section {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1870 |
border: 1px solid var(--line);
|
| 1871 |
background: var(--surface);
|
|
|
|
| 1872 |
}
|
| 1873 |
|
| 1874 |
+
.trust-section > header {
|
| 1875 |
+
max-width: 50rem;
|
|
|
|
|
|
|
| 1876 |
}
|
| 1877 |
|
| 1878 |
+
.trust-grid {
|
| 1879 |
+
display: grid;
|
| 1880 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 1881 |
+
gap: 1px;
|
| 1882 |
+
margin-top: 2rem;
|
| 1883 |
+
border: 1px solid var(--line);
|
| 1884 |
+
background: var(--line);
|
| 1885 |
}
|
| 1886 |
|
| 1887 |
+
.trust-grid article {
|
| 1888 |
+
padding: clamp(1.4rem, 3vw, 2rem);
|
| 1889 |
+
background: var(--surface-2);
|
| 1890 |
}
|
| 1891 |
|
| 1892 |
+
.trust-grid ul {
|
| 1893 |
+
display: grid;
|
| 1894 |
+
gap: 0.75rem;
|
| 1895 |
+
margin: 1rem 0 0;
|
| 1896 |
+
padding-left: 1.1rem;
|
| 1897 |
}
|
| 1898 |
|
| 1899 |
+
.trust-grid li {
|
|
|
|
| 1900 |
color: var(--muted);
|
| 1901 |
font-size: 0.88rem;
|
| 1902 |
+
line-height: 1.6;
|
| 1903 |
}
|
| 1904 |
|
| 1905 |
+
.start-section {
|
| 1906 |
display: grid;
|
| 1907 |
+
grid-template-columns: minmax(18rem, 0.8fr) minmax(0, 1.2fr);
|
| 1908 |
+
gap: clamp(2.5rem, 6vw, 6rem);
|
| 1909 |
+
margin-top: 1.25rem;
|
| 1910 |
+
scroll-margin-top: 5rem;
|
| 1911 |
+
background: var(--deep);
|
| 1912 |
+
color: var(--on-deep);
|
| 1913 |
}
|
| 1914 |
|
| 1915 |
+
.start-section__copy > p {
|
| 1916 |
+
color: var(--on-deep-muted);
|
|
|
|
|
|
|
|
|
|
| 1917 |
}
|
| 1918 |
|
| 1919 |
+
.pilot-disclosure {
|
| 1920 |
+
align-self: start;
|
|
|
|
| 1921 |
background: var(--surface);
|
| 1922 |
+
color: var(--text);
|
| 1923 |
}
|
| 1924 |
|
| 1925 |
+
.pilot-disclosure > summary {
|
| 1926 |
+
padding: 1.2rem 1.35rem;
|
| 1927 |
+
cursor: pointer;
|
| 1928 |
+
font-weight: 700;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1929 |
}
|
| 1930 |
|
| 1931 |
+
.pilot-disclosure > div {
|
| 1932 |
+
padding: 0 1.35rem 1.35rem;
|
|
|
|
|
|
|
|
|
|
| 1933 |
}
|
| 1934 |
|
| 1935 |
+
.pilot-disclosure > div > p {
|
| 1936 |
+
margin: 0 0 1rem;
|
| 1937 |
+
color: var(--muted);
|
| 1938 |
font-size: 0.86rem;
|
|
|
|
| 1939 |
}
|
| 1940 |
|
| 1941 |
+
.pilot-disclosure .lead-form {
|
| 1942 |
+
padding: 0;
|
| 1943 |
+
border: 0;
|
| 1944 |
+
box-shadow: none;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1945 |
}
|
| 1946 |
|
| 1947 |
+
.tool-start-guide {
|
| 1948 |
+
padding-bottom: 1.2rem;
|
| 1949 |
+
border-bottom: 1px solid var(--line);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1950 |
}
|
| 1951 |
|
| 1952 |
+
.tool-start-guide h2 {
|
| 1953 |
+
margin: 0;
|
| 1954 |
+
font-size: 1rem;
|
|
|
|
|
|
|
| 1955 |
}
|
| 1956 |
|
| 1957 |
+
.tool-start-guide ol {
|
| 1958 |
display: grid;
|
| 1959 |
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
| 1960 |
gap: 0.75rem;
|
| 1961 |
+
margin: 1rem 0 0;
|
| 1962 |
+
padding: 0;
|
| 1963 |
+
list-style: none;
|
| 1964 |
}
|
| 1965 |
|
| 1966 |
+
.tool-start-guide li {
|
| 1967 |
+
display: grid;
|
| 1968 |
+
grid-template-columns: 1.6rem minmax(0, 1fr);
|
| 1969 |
+
gap: 0.55rem;
|
| 1970 |
+
align-items: start;
|
| 1971 |
+
font-size: 0.78rem;
|
| 1972 |
+
line-height: 1.5;
|
| 1973 |
}
|
| 1974 |
|
| 1975 |
+
.tool-start-guide li > span {
|
| 1976 |
+
display: grid;
|
| 1977 |
+
width: 1.6rem;
|
| 1978 |
+
height: 1.6rem;
|
| 1979 |
+
place-items: center;
|
| 1980 |
+
border-radius: 50%;
|
| 1981 |
+
background: var(--text);
|
| 1982 |
+
color: var(--bg);
|
| 1983 |
+
font-weight: 700;
|
| 1984 |
}
|
| 1985 |
|
| 1986 |
+
.tool-start-guide p {
|
| 1987 |
+
margin: 1rem 0 0;
|
| 1988 |
+
color: var(--muted);
|
| 1989 |
+
font-size: 0.78rem;
|
|
|
|
| 1990 |
line-height: 1.55;
|
| 1991 |
}
|
| 1992 |
|
| 1993 |
+
.tool-start-guide > a {
|
| 1994 |
+
display: inline-block;
|
| 1995 |
+
margin-top: 0.7rem;
|
| 1996 |
+
color: var(--accent-ink);
|
| 1997 |
+
font-size: 0.8rem;
|
| 1998 |
+
font-weight: 700;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1999 |
}
|
| 2000 |
|
| 2001 |
@media (max-width: 1023px) {
|
| 2002 |
.site-nav__menu {
|
| 2003 |
display: block;
|
|
|
|
| 2004 |
}
|
| 2005 |
|
| 2006 |
.site-nav__menu summary {
|
|
|
|
| 2043 |
border-bottom: 0;
|
| 2044 |
}
|
| 2045 |
|
| 2046 |
+
.onboarding-hero__body,
|
| 2047 |
+
.burden-layout,
|
| 2048 |
+
.trust-grid,
|
| 2049 |
+
.start-section {
|
|
|
|
| 2050 |
grid-template-columns: 1fr;
|
| 2051 |
}
|
| 2052 |
|
| 2053 |
+
.evidence-grid {
|
| 2054 |
+
grid-template-columns: 1fr;
|
| 2055 |
}
|
| 2056 |
}
|
| 2057 |
|
| 2058 |
@media (max-width: 760px) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2059 |
.site-nav__brand > span:not(.product-breadcrumb) {
|
| 2060 |
display: none;
|
| 2061 |
}
|
|
|
|
| 2065 |
display: flex;
|
| 2066 |
}
|
| 2067 |
|
| 2068 |
+
.product-breadcrumb strong,
|
|
|
|
|
|
|
|
|
|
| 2069 |
.product-shell-status {
|
| 2070 |
display: none;
|
| 2071 |
}
|
|
|
|
| 2089 |
transform: translate(-50%, -50%);
|
| 2090 |
}
|
| 2091 |
|
| 2092 |
+
.site-nav__menu > div {
|
| 2093 |
+
right: 1rem;
|
|
|
|
| 2094 |
}
|
| 2095 |
|
| 2096 |
+
.interpreter-status {
|
| 2097 |
+
width: min(100% - 1.5rem, var(--container));
|
| 2098 |
}
|
| 2099 |
|
| 2100 |
+
.onboarding-hero,
|
| 2101 |
+
.burden-section,
|
| 2102 |
+
.demo-section,
|
| 2103 |
+
.evidence-section {
|
| 2104 |
width: min(100% - 1.5rem, var(--container));
|
| 2105 |
}
|
| 2106 |
|
| 2107 |
+
.onboarding-hero {
|
| 2108 |
+
padding-block: 3rem 3.5rem;
|
| 2109 |
}
|
| 2110 |
|
| 2111 |
+
.onboarding-hero > h1 {
|
| 2112 |
+
font-size: clamp(1.65rem, 8.2vw, 3.15rem);
|
| 2113 |
+
letter-spacing: -0.055em;
|
| 2114 |
+
line-height: 1;
|
| 2115 |
}
|
| 2116 |
|
| 2117 |
+
.onboarding-hero__body {
|
| 2118 |
+
gap: 2rem;
|
| 2119 |
+
margin-top: 2rem;
|
| 2120 |
}
|
| 2121 |
|
| 2122 |
+
.calculator-fields {
|
| 2123 |
+
grid-template-columns: 1fr;
|
|
|
|
|
|
|
| 2124 |
}
|
| 2125 |
|
| 2126 |
+
.site-footer {
|
|
|
|
| 2127 |
grid-template-columns: 1fr;
|
| 2128 |
+
gap: 1.5rem;
|
| 2129 |
}
|
| 2130 |
|
| 2131 |
+
.burden-section,
|
| 2132 |
+
.demo-section,
|
| 2133 |
+
.evidence-section {
|
| 2134 |
+
padding-block: 3.5rem;
|
| 2135 |
}
|
| 2136 |
|
| 2137 |
+
.guided-sample .scribe-tabs {
|
| 2138 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 2139 |
}
|
| 2140 |
|
| 2141 |
+
.guided-sample .scribe-tab:nth-child(2) {
|
| 2142 |
+
border-right: 0;
|
| 2143 |
+
}
|
| 2144 |
+
|
| 2145 |
+
.guided-sample .scribe-tab:nth-child(3) {
|
| 2146 |
+
border-bottom: 0;
|
| 2147 |
+
}
|
| 2148 |
+
|
| 2149 |
+
.trust-section,
|
| 2150 |
+
.start-section {
|
| 2151 |
+
width: calc(100% - 1rem);
|
| 2152 |
+
padding: 2rem 1.25rem;
|
| 2153 |
+
}
|
| 2154 |
+
|
| 2155 |
+
.tool-start-guide ol {
|
| 2156 |
+
grid-template-columns: 1fr;
|
| 2157 |
}
|
| 2158 |
}
|
scribe/frontend/tests/demo-flow.spec.ts
CHANGED
|
@@ -2,10 +2,8 @@ import AxeBuilder from "@axe-core/playwright";
|
|
| 2 |
import { expect, test, type Locator, type Page } from "@playwright/test";
|
| 3 |
|
| 4 |
async function tabTo(page: Page, target: Locator) {
|
| 5 |
-
for (let attempt = 0; attempt <
|
| 6 |
-
if (await target.evaluate((element) => element === document.activeElement))
|
| 7 |
-
return;
|
| 8 |
-
}
|
| 9 |
await page.keyboard.press("Tab");
|
| 10 |
}
|
| 11 |
throw new Error("Keyboard focus did not reach the expected control.");
|
|
@@ -15,73 +13,46 @@ async function expectNoSeriousAxeViolations(page: Page) {
|
|
| 15 |
const results = await new AxeBuilder({ page })
|
| 16 |
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
| 17 |
.analyze();
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
| 22 |
}
|
| 23 |
|
| 24 |
-
test("keyboard-only visitor
|
| 25 |
-
|
| 26 |
-
}) => {
|
| 27 |
await page.route("**/api/leads", async (route) => {
|
|
|
|
| 28 |
await route.fulfill({ status: 204, body: "" });
|
| 29 |
});
|
| 30 |
await page.goto("/");
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
const name = page.getByRole("textbox", { name: "Họ và tên" });
|
| 33 |
await tabTo(page, name);
|
| 34 |
await page.keyboard.type("Nguyen Minh Anh");
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
await
|
| 38 |
-
await page.
|
| 39 |
-
|
| 40 |
-
const clinic = page.getByRole("textbox", { name: "Cơ sở y tế" });
|
| 41 |
-
await tabTo(page, clinic);
|
| 42 |
-
await page.keyboard.type("Phong kham Minh Anh");
|
| 43 |
-
|
| 44 |
-
const specialty = page.getByRole("textbox", { name: "Chuyên khoa" });
|
| 45 |
-
await tabTo(page, specialty);
|
| 46 |
-
await page.keyboard.type("Noi tong quat");
|
| 47 |
-
|
| 48 |
-
const contact = page.getByRole("textbox", { name: "Email hoặc Zalo" });
|
| 49 |
-
await tabTo(page, contact);
|
| 50 |
-
await page.keyboard.type("minhanh@example.com");
|
| 51 |
-
|
| 52 |
-
const submit = page.getByRole("button", {
|
| 53 |
-
name: "Gửi yêu cầu thí điểm",
|
| 54 |
-
});
|
| 55 |
-
await tabTo(page, submit);
|
| 56 |
-
await page.keyboard.press("Enter");
|
| 57 |
|
| 58 |
await expect(page.getByText("Đã gửi yêu cầu thí điểm.")).toBeVisible();
|
|
|
|
|
|
|
| 59 |
});
|
| 60 |
|
| 61 |
-
test("
|
| 62 |
-
page,
|
| 63 |
-
}) => {
|
| 64 |
-
await page.goto("/");
|
| 65 |
-
await expectNoSeriousAxeViolations(page);
|
| 66 |
-
});
|
| 67 |
-
|
| 68 |
-
test("safety cards stay in the document flow while scrolling", async ({ page }) => {
|
| 69 |
-
await page.setViewportSize({ width: 1440, height: 900 });
|
| 70 |
-
await page.goto("/");
|
| 71 |
-
|
| 72 |
-
const cards = page.locator("[data-safety-card]");
|
| 73 |
-
await cards.first().scrollIntoViewIfNeeded();
|
| 74 |
-
await page.mouse.wheel(0, 500);
|
| 75 |
-
|
| 76 |
-
await expect(cards).toHaveCount(3);
|
| 77 |
-
for (const card of await cards.all()) {
|
| 78 |
-
await expect(card).toHaveCSS("transform", "none");
|
| 79 |
-
}
|
| 80 |
-
});
|
| 81 |
-
|
| 82 |
-
test("both product choices remain discoverable across release viewports", async ({
|
| 83 |
-
page,
|
| 84 |
-
}) => {
|
| 85 |
const viewports = [
|
| 86 |
{ width: 320, height: 800 },
|
| 87 |
{ width: 360, height: 800 },
|
|
@@ -94,97 +65,77 @@ test("both product choices remain discoverable across release viewports", async
|
|
| 94 |
await page.setViewportSize(viewport);
|
| 95 |
await page.goto("/");
|
| 96 |
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
}
|
| 114 |
|
| 115 |
-
|
| 116 |
-
await
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
await page.locator("main#top").focus();
|
| 122 |
-
await page.keyboard.press("Tab");
|
| 123 |
-
await expect(page.locator(".product-accordion__panel--scribe")).toBeFocused();
|
| 124 |
-
await page.keyboard.press("Tab");
|
| 125 |
-
await expect(page.locator(".product-accordion__panel--interpreter")).toBeFocused();
|
| 126 |
-
|
| 127 |
-
if (viewport.width <= 1023) {
|
| 128 |
-
const menu = page.locator(".site-nav__menu");
|
| 129 |
-
const summary = menu.locator("summary");
|
| 130 |
-
if (viewport.width === 390) {
|
| 131 |
-
await summary.focus();
|
| 132 |
-
await page.keyboard.press("Enter");
|
| 133 |
-
await expect(menu).toHaveAttribute("open", "");
|
| 134 |
-
await expect(menu.getByRole("link", { name: "Phiên dịch khám bệnh trực tiếp" })).toBeVisible();
|
| 135 |
-
await expect(menu.getByRole("link", { name: "Ghi chép bệnh án AI" })).toBeVisible();
|
| 136 |
-
await page.keyboard.press("Tab");
|
| 137 |
-
await expect(
|
| 138 |
-
menu.getByRole("link", { name: "Phiên dịch khám bệnh trực tiếp" }),
|
| 139 |
-
).toBeFocused();
|
| 140 |
-
await page.keyboard.press("Enter");
|
| 141 |
-
await expect(menu).not.toHaveAttribute("open", "");
|
| 142 |
-
} else {
|
| 143 |
-
await summary.click();
|
| 144 |
-
await expect(menu.getByRole("link", { name: "Phiên dịch khám bệnh trực tiếp" })).toBeVisible();
|
| 145 |
-
await expect(menu.getByRole("link", { name: "Ghi chép bệnh án AI" })).toBeVisible();
|
| 146 |
-
}
|
| 147 |
}
|
| 148 |
-
|
| 149 |
}
|
|
|
|
| 150 |
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
await
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
await
|
| 160 |
-
await expect(page.
|
| 161 |
-
|
| 162 |
-
await page.
|
| 163 |
-
await expect(
|
| 164 |
-
page.getByRole("combobox", { name: "Chức năng quan tâm" }),
|
| 165 |
-
).toHaveValue("scribe");
|
| 166 |
-
await page.getByRole("combobox", { name: "Chức năng quan tâm" }).selectOption("interpreter");
|
| 167 |
-
await expect(
|
| 168 |
-
page.getByRole("combobox", { name: "Chức năng quan tâm" }),
|
| 169 |
-
).toHaveValue("interpreter");
|
| 170 |
});
|
| 171 |
|
| 172 |
-
test("clinical notes
|
| 173 |
-
let healthRequested = false;
|
| 174 |
await page.route("https://carepath-e2e.example/api/v1/health", async (route) => {
|
| 175 |
-
healthRequested = true;
|
| 176 |
await route.fulfill({ status: 200, json: { asr_ready: true, llm_ready: true } });
|
| 177 |
});
|
|
|
|
| 178 |
await page.goto("/ghi-chep-lam-sang/");
|
| 179 |
await expect(page.getByText("Hệ thống sẵn sàng")).toBeVisible();
|
| 180 |
-
expect(
|
|
|
|
|
|
|
| 181 |
await expectNoSeriousAxeViolations(page);
|
| 182 |
-
await page.
|
|
|
|
|
|
|
|
|
|
| 183 |
|
| 184 |
-
await
|
| 185 |
-
await expect(
|
| 186 |
-
|
| 187 |
-
).toBeVisible();
|
| 188 |
});
|
| 189 |
|
| 190 |
test.describe("touch navigation", () => {
|
|
@@ -200,11 +151,9 @@ test.describe("touch navigation", () => {
|
|
| 200 |
expect(panel).not.toBeNull();
|
| 201 |
expect(panel?.x ?? -1).toBeGreaterThanOrEqual(0);
|
| 202 |
expect((panel?.x ?? 0) + (panel?.width ?? 0)).toBeLessThanOrEqual(width);
|
| 203 |
-
await
|
| 204 |
-
if (width === 390) await expectNoSeriousAxeViolations(page);
|
| 205 |
-
await menu.getByRole("link", { name: "An toàn" }).tap();
|
| 206 |
await expect(menu).not.toHaveAttribute("open", "");
|
| 207 |
-
await expect(page).toHaveURL(/#
|
| 208 |
}
|
| 209 |
});
|
| 210 |
});
|
|
@@ -212,27 +161,21 @@ test.describe("touch navigation", () => {
|
|
| 212 |
test.describe("reduced motion", () => {
|
| 213 |
test.use({ reducedMotion: "reduce" });
|
| 214 |
|
| 215 |
-
test("keeps
|
| 216 |
-
await page.emulateMedia({ reducedMotion: "reduce" });
|
| 217 |
await page.goto("/");
|
| 218 |
-
expect(
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
),
|
| 222 |
-
).toBe(true);
|
| 223 |
-
const productPanels = page.locator(".product-accordion__panel");
|
| 224 |
-
await expect(productPanels).toHaveCount(2);
|
| 225 |
-
for (let index = 0; index < 2; index += 1) {
|
| 226 |
-
await expect(productPanels.nth(index)).toBeVisible();
|
| 227 |
-
await expect(productPanels.nth(index).locator("dl")).toBeVisible();
|
| 228 |
-
}
|
| 229 |
-
expect(
|
| 230 |
-
await page.locator(".product-accordion").evaluate(
|
| 231 |
-
(element) => getComputedStyle(element).display,
|
| 232 |
-
),
|
| 233 |
-
).toBe("grid");
|
| 234 |
-
|
| 235 |
-
await expect(page.locator(".product-story")).toHaveCount(0);
|
| 236 |
-
await expect(page.locator(".marquee")).toHaveCount(0);
|
| 237 |
});
|
| 238 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
import { expect, test, type Locator, type Page } from "@playwright/test";
|
| 3 |
|
| 4 |
async function tabTo(page: Page, target: Locator) {
|
| 5 |
+
for (let attempt = 0; attempt < 120; attempt += 1) {
|
| 6 |
+
if (await target.evaluate((element) => element === document.activeElement)) return;
|
|
|
|
|
|
|
| 7 |
await page.keyboard.press("Tab");
|
| 8 |
}
|
| 9 |
throw new Error("Keyboard focus did not reach the expected control.");
|
|
|
|
| 13 |
const results = await new AxeBuilder({ page })
|
| 14 |
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
|
| 15 |
.analyze();
|
| 16 |
+
expect(
|
| 17 |
+
results.violations.filter((violation) =>
|
| 18 |
+
["serious", "critical"].includes(violation.impact ?? ""),
|
| 19 |
+
),
|
| 20 |
+
).toEqual([]);
|
| 21 |
}
|
| 22 |
|
| 23 |
+
test("keyboard-only visitor can inspect the sample and submit Scribe pilot interest", async ({ page }) => {
|
| 24 |
+
let payload: Record<string, unknown> | undefined;
|
|
|
|
| 25 |
await page.route("**/api/leads", async (route) => {
|
| 26 |
+
payload = route.request().postDataJSON() as Record<string, unknown>;
|
| 27 |
await route.fulfill({ status: 204, body: "" });
|
| 28 |
});
|
| 29 |
await page.goto("/");
|
| 30 |
|
| 31 |
+
const firstSampleStep = page.getByRole("button", { name: "1. Cuộc trao đổi" });
|
| 32 |
+
await tabTo(page, firstSampleStep);
|
| 33 |
+
await page.keyboard.press("Tab");
|
| 34 |
+
await page.keyboard.press("Enter");
|
| 35 |
+
await expect(page.getByText("Chưa có kết quả điện tâm đồ")).toBeVisible();
|
| 36 |
+
|
| 37 |
+
const pilot = page.getByText("Dành cho cơ sở muốn thí điểm CarePath");
|
| 38 |
+
await tabTo(page, pilot);
|
| 39 |
+
await page.keyboard.press("Enter");
|
| 40 |
+
|
| 41 |
const name = page.getByRole("textbox", { name: "Họ và tên" });
|
| 42 |
await tabTo(page, name);
|
| 43 |
await page.keyboard.type("Nguyen Minh Anh");
|
| 44 |
+
await page.getByRole("textbox", { name: "Vai trò" }).fill("Giam doc phong kham");
|
| 45 |
+
await page.getByRole("textbox", { name: "Cơ sở y tế" }).fill("Phong kham Minh Anh");
|
| 46 |
+
await page.getByRole("textbox", { name: "Chuyên khoa" }).fill("Noi tong quat");
|
| 47 |
+
await page.getByRole("textbox", { name: "Email hoặc Zalo" }).fill("minhanh@example.com");
|
| 48 |
+
await page.getByRole("button", { name: "Gửi yêu cầu thí điểm" }).click();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
await expect(page.getByText("Đã gửi yêu cầu thí điểm.")).toBeVisible();
|
| 51 |
+
expect(payload?.interest).toBe("scribe");
|
| 52 |
+
await expect(page.getByRole("combobox", { name: "Chức năng quan tâm" })).toHaveCount(0);
|
| 53 |
});
|
| 54 |
|
| 55 |
+
test("Vietnamese onboarding is compact, responsive, and keeps Interpreter unavailable", async ({ page }) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
const viewports = [
|
| 57 |
{ width: 320, height: 800 },
|
| 58 |
{ width: 360, height: 800 },
|
|
|
|
| 65 |
await page.setViewportSize(viewport);
|
| 66 |
await page.goto("/");
|
| 67 |
|
| 68 |
+
const title = page.getByRole("heading", {
|
| 69 |
+
level: 1,
|
| 70 |
+
name: "Dành thời gian cho người bệnh, không phải cho việc gõ bệnh án.",
|
| 71 |
+
});
|
| 72 |
+
await expect(title).toBeVisible();
|
| 73 |
+
const lineCount = await title.evaluate((element) => {
|
| 74 |
+
const style = getComputedStyle(element);
|
| 75 |
+
return Math.round(element.getBoundingClientRect().height / Number.parseFloat(style.lineHeight));
|
| 76 |
+
});
|
| 77 |
+
expect(lineCount).toBeLessThanOrEqual(viewport.width >= 1024 ? 2 : 3);
|
| 78 |
+
|
| 79 |
+
const status = page.locator("[data-interpreter-status]");
|
| 80 |
+
await expect(status).toContainText("Phiên dịch khám bệnh trực tiếp");
|
| 81 |
+
await expect(status).toContainText("Đang phát triển — hiện chưa thể truy cập trên web.");
|
| 82 |
+
await expect(status.locator("a, button")).toHaveCount(0);
|
| 83 |
+
await expect(page.getByRole("button", { name: "EN" })).toHaveCount(0);
|
| 84 |
+
await expect(page.locator('a[href*="phien-dich-y-khoa"], a[href*="console"]')).toHaveCount(0);
|
| 85 |
+
|
| 86 |
+
await expect(page.getByRole("link", { name: "Trải nghiệm ca khám mẫu" })).toHaveAttribute("href", "#demo");
|
| 87 |
+
await expect(page.getByText("2 giờ 20 phút mỗi ngày")).toBeVisible();
|
| 88 |
+
await expect(page.getByText("Chờ bác sĩ nhập hoặc xác nhận.")).toHaveCount(0);
|
| 89 |
+
|
| 90 |
+
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0);
|
| 91 |
+
if (viewport.width === 1440) {
|
| 92 |
+
const heroHeight = await page.locator(".onboarding-hero").evaluate((element) => element.getBoundingClientRect().height);
|
| 93 |
+
expect(heroHeight).toBeLessThan(800);
|
| 94 |
}
|
| 95 |
|
| 96 |
+
if (viewport.width === 390 || viewport.width === 1440) {
|
| 97 |
+
await expectNoSeriousAxeViolations(page);
|
| 98 |
+
await page.screenshot({
|
| 99 |
+
fullPage: true,
|
| 100 |
+
path: `../../.codex/qa-evidence/cp-ux-11-${viewport.width}.png`,
|
| 101 |
+
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
}
|
|
|
|
| 103 |
}
|
| 104 |
+
});
|
| 105 |
|
| 106 |
+
test("calculator updates transparently and the guided sample keeps decisions with the doctor", async ({ page }) => {
|
| 107 |
+
await page.goto("/#calculator");
|
| 108 |
+
await page.getByRole("spinbutton", { name: "Số người bệnh mỗi ngày" }).fill("28");
|
| 109 |
+
await page.getByRole("spinbutton", { name: "Phút ghi chép cho mỗi người bệnh" }).fill("5");
|
| 110 |
+
await expect(page.getByText("2 giờ 20 phút mỗi ngày")).toBeVisible();
|
| 111 |
+
await expect(page.getByText("11 giờ 40 phút cho 5 ngày làm việc")).toBeVisible();
|
| 112 |
+
await expect(page.getByText(/Công thức: 28 người bệnh × 5 phút/)).toBeVisible();
|
| 113 |
+
|
| 114 |
+
await page.getByRole("button", { name: "3. Bản nháp có cấu trúc" }).click();
|
| 115 |
+
await expect(page.getByText("Chờ bác sĩ nhập hoặc xác nhận.")).toHaveCount(2);
|
| 116 |
+
await page.getByRole("button", { name: "4. Bác sĩ kiểm tra" }).click();
|
| 117 |
+
await expect(page.getByText("Tự nhập hoặc xác nhận Nhận định và Kế hoạch.")).toBeVisible();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
});
|
| 119 |
|
| 120 |
+
test("clinical notes expose guidance and upload together, then return to the sample", async ({ page }) => {
|
|
|
|
| 121 |
await page.route("https://carepath-e2e.example/api/v1/health", async (route) => {
|
|
|
|
| 122 |
await route.fulfill({ status: 200, json: { asr_ready: true, llm_ready: true } });
|
| 123 |
});
|
| 124 |
+
await page.setViewportSize({ width: 390, height: 844 });
|
| 125 |
await page.goto("/ghi-chep-lam-sang/");
|
| 126 |
await expect(page.getByText("Hệ thống sẵn sàng")).toBeVisible();
|
| 127 |
+
await expect(page.getByText("Ba bước để tạo bản nháp")).toBeVisible();
|
| 128 |
+
await expect(page.getByText("Kéo thả tệp âm thanh vào đây, hoặc bấm để chọn")).toBeVisible();
|
| 129 |
+
await expect(page.getByRole("button", { name: "Tiếp tục tạo bản nháp" })).toHaveCount(0);
|
| 130 |
await expectNoSeriousAxeViolations(page);
|
| 131 |
+
await page.screenshot({
|
| 132 |
+
fullPage: true,
|
| 133 |
+
path: "../../.codex/qa-evidence/cp-ux-11-tool-390.png",
|
| 134 |
+
});
|
| 135 |
|
| 136 |
+
await page.getByRole("link", { name: "Xem ca khám mẫu trước" }).click();
|
| 137 |
+
await expect(page).toHaveURL(/\/#demo$/);
|
| 138 |
+
await expect(page.getByRole("heading", { name: "Thử một ca khám mẫu trước khi tải tệp của bạn." })).toBeVisible();
|
|
|
|
| 139 |
});
|
| 140 |
|
| 141 |
test.describe("touch navigation", () => {
|
|
|
|
| 151 |
expect(panel).not.toBeNull();
|
| 152 |
expect(panel?.x ?? -1).toBeGreaterThanOrEqual(0);
|
| 153 |
expect((panel?.x ?? 0) + (panel?.width ?? 0)).toBeLessThanOrEqual(width);
|
| 154 |
+
await menu.getByRole("link", { name: "Ca khám mẫu" }).tap();
|
|
|
|
|
|
|
| 155 |
await expect(menu).not.toHaveAttribute("open", "");
|
| 156 |
+
await expect(page).toHaveURL(/#demo$/);
|
| 157 |
}
|
| 158 |
});
|
| 159 |
});
|
|
|
|
| 161 |
test.describe("reduced motion", () => {
|
| 162 |
test.use({ reducedMotion: "reduce" });
|
| 163 |
|
| 164 |
+
test("keeps content static without marquee or pinning", async ({ page }) => {
|
|
|
|
| 165 |
await page.goto("/");
|
| 166 |
+
await expect(page.locator(".process-marquee, .scribe-story")).toHaveCount(0);
|
| 167 |
+
await expect(page.locator(".onboarding-hero")).toHaveCSS("transform", "none");
|
| 168 |
+
expect(await page.evaluate(() => document.getAnimations().filter((animation) => animation.playState === "running").length)).toBe(0);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
});
|
| 170 |
});
|
| 171 |
+
|
| 172 |
+
for (const colorScheme of ["light", "dark"] as const) {
|
| 173 |
+
test(`keeps the main action readable in ${colorScheme} mode`, async ({ browser }) => {
|
| 174 |
+
const context = await browser.newContext({ colorScheme });
|
| 175 |
+
const page = await context.newPage();
|
| 176 |
+
await page.goto("http://127.0.0.1:4173/");
|
| 177 |
+
await expect(page.getByRole("link", { name: "Trải nghiệm ca khám mẫu" })).toBeVisible();
|
| 178 |
+
await expectNoSeriousAxeViolations(page);
|
| 179 |
+
await context.close();
|
| 180 |
+
});
|
| 181 |
+
}
|
scribe/training/scripts/validate_soap_ratings.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
@@ -21,7 +21,7 @@ SCORE_LIMITS = {"completeness": (1, 5), "hallucination": (0, 3), "terminology":
|
|
| 21 |
STATUSES = {"accepted", "needs_correction", "unsafe"}
|
| 22 |
|
| 23 |
|
| 24 |
-
def summarize_ratings(rows: list[dict[str, str]]
|
| 25 |
if not rows:
|
| 26 |
raise ValueError("rating export has no rows")
|
| 27 |
notes: set[str] = set()
|
|
@@ -59,17 +59,15 @@ def summarize_ratings(rows: list[dict[str, str]], min_notes: int = 50) -> dict[s
|
|
| 59 |
"mean_terminology": round(totals["terminology"] / count, 2),
|
| 60 |
"serious_hallucinations": serious_hallucinations,
|
| 61 |
"unsafe_notes": unsafe,
|
| 62 |
-
"ready_for_decision": len(notes) >= min_notes,
|
| 63 |
}
|
| 64 |
|
| 65 |
|
| 66 |
def main() -> None:
|
| 67 |
parser = argparse.ArgumentParser(description=__doc__)
|
| 68 |
parser.add_argument("--input", required=True, type=Path)
|
| 69 |
-
parser.add_argument("--min-notes", type=int, default=50)
|
| 70 |
args = parser.parse_args()
|
| 71 |
with args.input.open(encoding="utf-8", newline="") as stream:
|
| 72 |
-
summary = summarize_ratings(list(csv.DictReader(stream))
|
| 73 |
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
| 74 |
|
| 75 |
|
|
|
|
| 1 |
+
"""Summarize an in-house SOAP review export without reading note content."""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 21 |
STATUSES = {"accepted", "needs_correction", "unsafe"}
|
| 22 |
|
| 23 |
|
| 24 |
+
def summarize_ratings(rows: list[dict[str, str]]) -> dict[str, object]:
|
| 25 |
if not rows:
|
| 26 |
raise ValueError("rating export has no rows")
|
| 27 |
notes: set[str] = set()
|
|
|
|
| 59 |
"mean_terminology": round(totals["terminology"] / count, 2),
|
| 60 |
"serious_hallucinations": serious_hallucinations,
|
| 61 |
"unsafe_notes": unsafe,
|
|
|
|
| 62 |
}
|
| 63 |
|
| 64 |
|
| 65 |
def main() -> None:
|
| 66 |
parser = argparse.ArgumentParser(description=__doc__)
|
| 67 |
parser.add_argument("--input", required=True, type=Path)
|
|
|
|
| 68 |
args = parser.parse_args()
|
| 69 |
with args.input.open(encoding="utf-8", newline="") as stream:
|
| 70 |
+
summary = summarize_ratings(list(csv.DictReader(stream)))
|
| 71 |
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
| 72 |
|
| 73 |
|
scribe/training/soap_eval/README.md
CHANGED
|
@@ -1,14 +1,17 @@
|
|
| 1 |
-
# SOAP Note
|
| 2 |
|
| 3 |
This Scribe-only quality track contains only the anonymous rating schema. Do not put audio,
|
| 4 |
transcripts, patient identifiers, or SOAP note text in this repository.
|
| 5 |
|
| 6 |
-
##
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
## Rubric
|
| 14 |
|
|
@@ -22,9 +25,9 @@ Rate each generated note using an anonymous `note_id` only.
|
|
| 22 |
| `status` | `accepted`, `needs_correction`, `unsafe` | Clinician's final disposition. |
|
| 23 |
|
| 24 |
`clinician_id` is an approved pseudonymous reviewer identifier. `reviewed_at`
|
| 25 |
-
uses ISO 8601 date/time.
|
| 26 |
-
|
| 27 |
-
|
| 28 |
|
| 29 |
## Run
|
| 30 |
|
|
@@ -34,6 +37,7 @@ Export ratings without clinical source text to an approved local path, then run:
|
|
| 34 |
python scribe/training/scripts/validate_soap_ratings.py --input C:\approved\soap-ratings.csv
|
| 35 |
```
|
| 36 |
|
| 37 |
-
Review the resulting summary with the
|
| 38 |
-
of 2 or 3, or any `unsafe` disposition, requires documented
|
| 39 |
-
|
|
|
|
|
|
| 1 |
+
# SOAP Note In-House Review Protocol
|
| 2 |
|
| 3 |
This Scribe-only quality track contains only the anonymous rating schema. Do not put audio,
|
| 4 |
transcripts, patient identifiers, or SOAP note text in this repository.
|
| 5 |
|
| 6 |
+
## Current scope
|
| 7 |
|
| 8 |
+
This tool supports informational in-house testing only. Use synthetic or
|
| 9 |
+
already-approved de-identified material outside the repository. Do not put
|
| 10 |
+
source audio, transcripts, patient identifiers, or SOAP note text here.
|
| 11 |
+
|
| 12 |
+
The summary does not create a release target or authorize fine-tuning,
|
| 13 |
+
provider, or safety-policy changes. Those changes require a separate owner
|
| 14 |
+
decision.
|
| 15 |
|
| 16 |
## Rubric
|
| 17 |
|
|
|
|
| 25 |
| `status` | `accepted`, `needs_correction`, `unsafe` | Clinician's final disposition. |
|
| 26 |
|
| 27 |
`clinician_id` is an approved pseudonymous reviewer identifier. `reviewed_at`
|
| 28 |
+
uses ISO 8601 date/time. There is no minimum sample-size target. The validator
|
| 29 |
+
reports quality signals only; it never reports readiness or approval to change
|
| 30 |
+
the model.
|
| 31 |
|
| 32 |
## Run
|
| 33 |
|
|
|
|
| 37 |
python scribe/training/scripts/validate_soap_ratings.py --input C:\approved\soap-ratings.csv
|
| 38 |
```
|
| 39 |
|
| 40 |
+
Review the resulting summary with the designated in-house reviewer. Any
|
| 41 |
+
hallucination score of 2 or 3, or any `unsafe` disposition, requires documented
|
| 42 |
+
investigation and retention of the current model behavior unless a separate
|
| 43 |
+
owner decision authorizes a change.
|
scribe/training/tests/test_soap_ratings.py
CHANGED
|
@@ -25,18 +25,18 @@ def _row(note_id: str, **overrides: str) -> dict[str, str]:
|
|
| 25 |
|
| 26 |
|
| 27 |
class SoapRatingsTests(unittest.TestCase):
|
| 28 |
-
def
|
| 29 |
-
rows = [_row(
|
| 30 |
summary = summarize_ratings(rows)
|
| 31 |
-
self.
|
| 32 |
-
self.assertEqual(summary["unique_notes"],
|
|
|
|
| 33 |
|
| 34 |
def test_summary_reports_serious_hallucinations(self) -> None:
|
| 35 |
rows = [_row("note-1", hallucination="2", status="unsafe")]
|
| 36 |
summary = summarize_ratings(rows)
|
| 37 |
self.assertEqual(summary["serious_hallucinations"], 1)
|
| 38 |
self.assertEqual(summary["unsafe_notes"], 1)
|
| 39 |
-
self.assertFalse(summary["ready_for_decision"])
|
| 40 |
|
| 41 |
def test_rejects_out_of_range_score(self) -> None:
|
| 42 |
with self.assertRaisesRegex(ValueError, "hallucination"):
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
class SoapRatingsTests(unittest.TestCase):
|
| 28 |
+
def test_summary_reports_unique_notes_without_a_readiness_gate(self) -> None:
|
| 29 |
+
rows = [_row("note-1"), _row("note-1"), _row("note-2")]
|
| 30 |
summary = summarize_ratings(rows)
|
| 31 |
+
self.assertEqual(summary["rating_rows"], 3)
|
| 32 |
+
self.assertEqual(summary["unique_notes"], 2)
|
| 33 |
+
self.assertNotIn("ready_for_decision", summary)
|
| 34 |
|
| 35 |
def test_summary_reports_serious_hallucinations(self) -> None:
|
| 36 |
rows = [_row("note-1", hallucination="2", status="unsafe")]
|
| 37 |
summary = summarize_ratings(rows)
|
| 38 |
self.assertEqual(summary["serious_hallucinations"], 1)
|
| 39 |
self.assertEqual(summary["unsafe_notes"], 1)
|
|
|
|
| 40 |
|
| 41 |
def test_rejects_out_of_range_score(self) -> None:
|
| 42 |
with self.assertRaisesRegex(ValueError, "hallucination"):
|