diff --git a/.cursor/skills/report-with-demo/SKILL.md b/.cursor/skills/report-with-demo/SKILL.md new file mode 100644 index 0000000..e49a7b1 --- /dev/null +++ b/.cursor/skills/report-with-demo/SKILL.md @@ -0,0 +1,101 @@ +--- +name: report-with-demo +description: Defines a durable E2E acceptance contract before implementing a browser feature, then runs a deterministic Playwright scenario and records demo video, trace, and failure evidence. Use only when explicitly invoked as /report-with-demo. +disable-model-invocation: true +--- + +# Report With Demo + +Use this skill only when the user explicitly invokes `/report-with-demo `. + +## Purpose + +Turn a browser-facing feature request into: + +1. a durable E2E acceptance contract written before feature implementation; and +2. reproducible evidence (video, trace, and diagnostics) generated from a deterministic Playwright scenario. + +## Hard Rules + +- Never use production data, real patient identities, real LINE accounts, or secrets. +- Never record or test against production hosts. +- Never widen scope beyond the requested feature and written acceptance contract. +- Never auto-commit, auto-push, or auto-deploy. +- Never run an unbounded retry loop. The maximum is **at most three** repair attempts. + +## Required Workflow + +### 1) Inspect and constrain before coding + +Read the relevant frontend routes, backend endpoints, auth mode, existing tests, and available development personas. + +If there is a material acceptance ambiguity, ask one focused question before implementation. + +### 2) Write the acceptance contract first + +Create and save: + +`docs/e2e-contracts/.md` + +Use `.cursor/skills/report-with-demo/contract-template.md`. + +The contract must include: + +- outcome and explicit non-goals; +- environment and safety constraints; +- deterministic persona + seed/reset setup; +- Given/When/Then scenario steps; +- visible assertion per important step; +- required demo evidence outputs; +- known limitations. + +### 3) Implement only contract-defined scope + +Implement the requested feature according to the contract. + +Add semantic selectors or `data-testid` only where needed for stable E2E interaction. Do not use fragile CSS selectors, layout classes, `nth-child`, or text concatenation as primary locators. + +### 4) Write deterministic Playwright scenario + +Create or update a feature-specific Playwright test that: + +- uses development personas and deterministic setup; +- mirrors the contract steps exactly; +- asserts every contract-visible outcome; +- records video and trace through Playwright config. + +Browser MCP can be used for exploration or smoke verification, but it is not completion evidence because it does not produce the required demo video artifact. + +### 5) Execute, diagnose, and bounded retry + +Run the scenario and classify failures before changing code: + +| Failure class | Action | +| --- | --- | +| Contract assertion fails | Repair implementation or contract-derived setup and rerun. | +| Test harness or fixture failure | Repair deterministic harness/setup and rerun. | +| Environment/service unavailable | Retry only if transient recovery is plausible; otherwise report blocker. | +| Ambiguous product behavior | Stop and ask the user. | + +Run at most three diagnose/fix/rerun attempts. + +A video file alone is not success. Success requires scenario assertions to pass. + +### 6) Report and wait for feedback + +On success, report: + +- acceptance contract path; +- changed files summary; +- executed E2E command and pass result; +- video, trace, and screenshot paths; +- limitations/deviations. + +Then explicitly **wait for user feedback**. + +On failure after max attempts, report: + +- failed acceptance step; +- attempt history and diagnosis; +- artifact paths; +- smallest concrete blocker or decision needed from user. diff --git a/.cursor/skills/report-with-demo/contract-template.md b/.cursor/skills/report-with-demo/contract-template.md new file mode 100644 index 0000000..5c4db2e --- /dev/null +++ b/.cursor/skills/report-with-demo/contract-template.md @@ -0,0 +1,38 @@ +# E2E acceptance contract + +## Outcome + +- Primary user: +- Target outcome: +- Business reason: + +## Non-goals + +- Out of scope item 1: +- Out of scope item 2: + +## Environment and safety + +- Persona: +- Seed/reset command or fixture: +- Browser viewport: +- Prohibited data/services: +- Notes about auth mode: + +## Scenario + +| Step | Given / When / Then | Visible assertion | +| --- | --- | --- | +| 1 | Given ... When ... Then ... | Assert ... | +| 2 | Given ... When ... Then ... | Assert ... | + +## Demo evidence + +- Video: +- Trace: +- Failure screenshots: +- Test command: + +## Known limitations + +- Limitation 1: diff --git a/apps/backend/app/api/routes/staff.py b/apps/backend/app/api/routes/staff.py index 14384f4..d125a3e 100644 --- a/apps/backend/app/api/routes/staff.py +++ b/apps/backend/app/api/routes/staff.py @@ -133,6 +133,7 @@ def _serialize_today_attention( representative_upload_id=row.representative_upload_id, sort_upload_at=row.sort_upload_at, has_annotation=row.has_annotation, + annotation_label=row.annotation_label, picture_url=row.picture_url, day_upload_count=row.day_upload_count, preview_upload_ids=row.preview_upload_ids, diff --git a/apps/backend/app/schemas/staff_dashboard.py b/apps/backend/app/schemas/staff_dashboard.py index 84aa082..476cdd2 100644 --- a/apps/backend/app/schemas/staff_dashboard.py +++ b/apps/backend/app/schemas/staff_dashboard.py @@ -123,6 +123,7 @@ class StaffTodayAttentionPatientItem(BaseModel): representative_upload_id: int sort_upload_at: datetime has_annotation: bool + annotation_label: Literal["normal", "suspected", "confirmed_infection", "rejected"] | None = None picture_url: str | None = None day_upload_count: int = 0 preview_upload_ids: list[int] = Field(default_factory=list) diff --git a/apps/backend/app/services/staff_dashboard.py b/apps/backend/app/services/staff_dashboard.py index 3d54dc2..8c8515c 100644 --- a/apps/backend/app/services/staff_dashboard.py +++ b/apps/backend/app/services/staff_dashboard.py @@ -3,7 +3,7 @@ from collections import defaultdict from dataclasses import dataclass from datetime import date, datetime, time, timedelta, timezone -from typing import Literal +from typing import Literal, cast from sqlalchemy.exc import IntegrityError from sqlalchemy import Select, and_, case, delete, func, select @@ -1021,6 +1021,7 @@ class TodayAttentionPatientRow: representative_upload_id: int sort_upload_at: datetime has_annotation: bool + annotation_label: Literal["normal", "suspected", "confirmed_infection", "rejected"] | None picture_url: str | None day_upload_count: int preview_upload_ids: list[int] @@ -1199,6 +1200,11 @@ def list_today_attention_patients( latest_annotation_by_upload=latest_annotation_by_upload, ) has_annotation = risk_representative.has_annotation + annotation_label = ( + cast(Literal["normal", "suspected", "confirmed_infection", "rejected"], latest_annotation_by_upload.get(representative.id).label) + if representative.id in latest_annotation_by_upload + else None + ) else: patient_tier = "other" representative = max( @@ -1215,6 +1221,11 @@ def list_today_attention_patients( limit = day_upload_count if day_upload_count <= 4 else 3 preview_upload_ids = [upload.id for upload in ordered[:limit]] has_annotation = representative.id in latest_annotation_by_upload + annotation_label = ( + cast(Literal["normal", "suspected", "confirmed_infection", "rejected"], latest_annotation_by_upload.get(representative.id).label) + if representative.id in latest_annotation_by_upload + else None + ) attention_rows.append( TodayAttentionPatientRow( @@ -1223,6 +1234,7 @@ def list_today_attention_patients( representative_upload_id=representative.id, sort_upload_at=sort_upload_at, has_annotation=has_annotation, + annotation_label=annotation_label, picture_url=picture_by_patient.get(patient_id), day_upload_count=day_upload_count, preview_upload_ids=preview_upload_ids, diff --git a/apps/backend/migrations/versions/20260809_06_fix_legacy_annotations_schema.py b/apps/backend/migrations/versions/20260809_06_fix_legacy_annotations_schema.py new file mode 100644 index 0000000..7efff04 --- /dev/null +++ b/apps/backend/migrations/versions/20260809_06_fix_legacy_annotations_schema.py @@ -0,0 +1,104 @@ +"""fix legacy annotations schema that still requires staff_user_id + +Revision ID: 20260809_06 +Revises: 20260717_05 +Create Date: 2026-08-09 13:55:00 +""" + +# allow-destructive-migration: legacy local SQLite compatibility repair + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + +# revision identifiers, used by Alembic. +revision = "20260809_06" +down_revision = "20260717_05" +branch_labels = None +depends_on = None + + +def _column_names(table_name: str) -> set[str]: + bind = op.get_bind() + inspector = inspect(bind) + try: + return {column["name"] for column in inspector.get_columns(table_name)} + except Exception: + return set() + + +def upgrade() -> None: + # Legacy local SQLite snapshots may keep annotations.staff_user_id (NOT NULL), + # which breaks current write path that uses reviewer_identity_id. + table_names = set(inspect(op.get_bind()).get_table_names()) + if "annotations" not in table_names: + return + columns = _column_names("annotations") + if "staff_user_id" not in columns: + return + + bind = op.get_bind() + + unresolved_rows = bind.execute( + sa.text( + """ + SELECT COUNT(*) + FROM annotations + WHERE reviewer_identity_id IS NULL + AND NOT EXISTS ( + SELECT 1 + FROM liff_identities + WHERE liff_identities.id = annotations.staff_user_id + ) + """ + ) + ).scalar() + if unresolved_rows and int(unresolved_rows) > 0: + raise RuntimeError( + "Cannot migrate annotations rows with NULL reviewer_identity_id " + "and unmatched staff_user_id in liff_identities." + ) + + op.create_table( + "annotations_v2", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("patient_id", sa.Integer(), sa.ForeignKey("patients.id", ondelete="CASCADE"), nullable=False), + sa.Column("upload_id", sa.Integer(), sa.ForeignKey("uploads.id", ondelete="CASCADE"), nullable=False), + sa.Column("reviewer_identity_id", sa.Integer(), sa.ForeignKey("liff_identities.id", ondelete="CASCADE"), nullable=False), + sa.Column("label", sa.String(length=64), nullable=False), + sa.Column("comment", sa.Text(), nullable=True), + sa.Column("patient_read_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + + op.execute( + sa.text( + """ + INSERT INTO annotations_v2 ( + id, patient_id, upload_id, reviewer_identity_id, label, comment, patient_read_at, created_at + ) + SELECT + id, + patient_id, + upload_id, + COALESCE(reviewer_identity_id, staff_user_id), + label, + comment, + patient_read_at, + created_at + FROM annotations + """ + ) + ) + + op.drop_table("annotations") + op.rename_table("annotations_v2", "annotations") + op.create_index("ix_annotations_patient_id", "annotations", ["patient_id"]) + op.create_index("ix_annotations_upload_id", "annotations", ["upload_id"]) + + +def downgrade() -> None: + # Non-destructive policy: no automatic rollback for compatibility migration. + pass diff --git a/apps/frontend/.gitignore b/apps/frontend/.gitignore index b721bff..401416b 100644 --- a/apps/frontend/.gitignore +++ b/apps/frontend/.gitignore @@ -12,6 +12,9 @@ # testing /coverage +/test-results/ +/playwright-report/ +/e2e-artifacts/ # next.js /.next/ diff --git a/apps/frontend/app/admin/_components/history-upload-annotation-modal.tsx b/apps/frontend/app/admin/_components/history-upload-annotation-modal.tsx index b1ecb04..69dc712 100644 --- a/apps/frontend/app/admin/_components/history-upload-annotation-modal.tsx +++ b/apps/frontend/app/admin/_components/history-upload-annotation-modal.tsx @@ -1,12 +1,23 @@ "use client"; import Image from "next/image"; -import Link from "next/link"; import { X } from "lucide-react"; import type { StaffAnnotationItem, StaffHistoryOverviewUploadItem } from "@/lib/api/staff"; +import { + STAFF_ANNOTATION_LABEL_OPTIONS_WITH_UNMARKED, + STAFF_REVIEW_COPY, + STAFF_REVIEW_FIELD_LABELS, + annotationBadgeClass, + annotationLabelTextOrUnmarked, + screeningResultBadgeClass, + screeningResultText, + symptomAwarePriorityBadgeClass, + symptomAwarePriorityText, +} from "@/lib/i18n/staff-review-label-mapping"; +import { activeSymptomLabels, symptomsFromApiFields } from "@/lib/symptoms"; -import { historyUploadRiskLabel, type HistoryUploadDraftVerdict } from "./history-upload-review-helpers"; +import { type HistoryUploadDraftVerdict } from "./history-upload-review-helpers"; type HistoryUploadAnnotationModalProps = { upload: StaffHistoryOverviewUploadItem; @@ -27,6 +38,14 @@ export function HistoryUploadAnnotationModal({ onSave, onClose, }: HistoryUploadAnnotationModalProps) { + const symptomFlags = symptomsFromApiFields({ + symptom_pain: upload.symptom_pain, + symptom_discharge: upload.symptom_discharge, + symptom_pus: upload.symptom_pus, + symptom_cloudy_dialysate: upload.symptom_cloudy_dialysate, + }); + const symptomLabels = activeSymptomLabels(symptomFlags); + return (
@@ -45,96 +64,138 @@ export function HistoryUploadAnnotationModal({
-
- {imageUrl ? ( - {`history-preview-${upload.upload_id}`} - ) : ( -
載入影像中...
- )} +
+
+ {imageUrl ? ( + {`history-preview-${upload.upload_id}`} + ) : ( +
{STAFF_REVIEW_COPY.loadingImage}
+ )} +
-
-
-
-
年齡
-
{upload.age ?? "-"}
-
-
-
上傳時間
-
{new Date(upload.created_at).toLocaleString("zh-TW")}
-
-
-
臨床風險
-
{historyUploadRiskLabel(upload)}
-
-
-
影像判讀
-
{upload.screening_result}
-
-
-
症狀綜合
-
{upload.symptom_aware_priority}
-
-
-
機率
-
- {upload.probability !== null ? `${(upload.probability * 100).toFixed(1)}%` : "-"} -
-
-
-
Threshold
-
- {upload.threshold !== null ? upload.threshold.toFixed(2) : "-"} -
-
-
-
Model
-
{upload.model_version ?? "-"}
+ +
+
+
+
+
{STAFF_REVIEW_FIELD_LABELS.uploadedAt}
+
{new Date(upload.created_at).toLocaleString("zh-TW")}
+
+
+
{STAFF_REVIEW_FIELD_LABELS.symptomRisk}
+
+ {upload.has_high_risk_symptoms ? STAFF_REVIEW_COPY.highRisk : STAFF_REVIEW_COPY.regularRisk} +
+
+
+
+

{STAFF_REVIEW_FIELD_LABELS.symptomReported}

+
+ {symptomLabels.length > 0 ? ( + symptomLabels.map((label) => ( + + {label} + + )) + ) : ( + + {STAFF_REVIEW_COPY.noSymptomsReported} + + )} +
-
-
- - 開啟病患完整頁 - -
- -