diff --git a/apps/backend/app/api/routes/auth.py b/apps/backend/app/api/routes/auth.py index 83e1b55..f90be90 100644 --- a/apps/backend/app/api/routes/auth.py +++ b/apps/backend/app/api/routes/auth.py @@ -6,6 +6,8 @@ from app.api.deps.auth import get_session from app.schemas.auth import AuthBootstrapResponse, AuthTokenResponse, StaffLineLoginRequest from app.services.auth import AuthService, AuthTokenService, LineIdentityProvider +from app.services.auth.line_provider import LineTokenVerifyError +from app.services.auth.line_verify_http import line_verify_http_error from app.services.auth.service import AuthFlowPermissionError @@ -53,6 +55,8 @@ async def login_staff_or_admin(request: Request, payload: StaffLineLoginRequest) session, line_id_token=payload.line_id_token, ) + except LineTokenVerifyError as exc: + raise line_verify_http_error(exc) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except AuthFlowPermissionError as exc: @@ -86,6 +90,8 @@ async def auth_bootstrap(request: Request, payload: StaffLineLoginRequest) -> Au session, line_id_token=payload.line_id_token, ) + except LineTokenVerifyError as exc: + raise line_verify_http_error(exc) from exc except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc diff --git a/apps/backend/app/api/routes/identity.py b/apps/backend/app/api/routes/identity.py index bf854e3..fc1c317 100644 --- a/apps/backend/app/api/routes/identity.py +++ b/apps/backend/app/api/routes/identity.py @@ -11,6 +11,8 @@ ) from app.schemas.identity import BindIdentityRequest, IdentityBindResponse, IdentityStatusRequest, IdentityStatusResponse from app.services.auth import LineIdentityProvider +from app.services.auth.line_provider import LineTokenVerifyError +from app.services.auth.line_verify_http import line_verify_http_error from app.services.admin_user_management import ( create_or_replace_healthcare_permission_request, get_latest_healthcare_permission_request_status, @@ -45,8 +47,8 @@ async def bind_patient_identity(request: Request, payload: BindIdentityRequest) line_provider = _build_line_provider(request) try: profile = line_provider.verify_id_token(line_id_token=payload.line_id_token) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc + except LineTokenVerifyError as exc: + raise line_verify_http_error(exc) from exc status, patient_id, can_upload = bind_identity( session, line_user_id=profile.line_user_id, @@ -70,8 +72,8 @@ async def patient_identity_status( line_provider = _build_line_provider(request) try: profile = line_provider.verify_id_token(line_id_token=payload.line_id_token) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc + except LineTokenVerifyError as exc: + raise line_verify_http_error(exc) from exc status, patient_id, can_upload = get_identity_status(session, line_user_id=profile.line_user_id) return IdentityStatusResponse(status=status, patient_id=patient_id, can_upload=can_upload) finally: @@ -88,8 +90,8 @@ async def create_healthcare_access_request( line_provider = _build_line_provider(request) try: profile = line_provider.verify_id_token(line_id_token=payload.line_id_token) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc + except LineTokenVerifyError as exc: + raise line_verify_http_error(exc) from exc access_request = create_or_replace_healthcare_permission_request( session, line_user_id=profile.line_user_id, @@ -114,8 +116,8 @@ async def get_healthcare_access_request_status( line_provider = _build_line_provider(request) try: profile = line_provider.verify_id_token(line_id_token=payload.line_id_token) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc + except LineTokenVerifyError as exc: + raise line_verify_http_error(exc) from exc access_request = get_latest_healthcare_permission_request_status(session, line_user_id=profile.line_user_id) if access_request is None: return HealthcarePermissionRequestStatusResponse( diff --git a/apps/backend/app/api/routes/staff.py b/apps/backend/app/api/routes/staff.py index 0abf63a..f7acd6f 100644 --- a/apps/backend/app/api/routes/staff.py +++ b/apps/backend/app/api/routes/staff.py @@ -50,6 +50,9 @@ StaffPatientUploadsResponse, StaffUploadQueueItem, StaffUploadQueueResponse, + StaffTodayAttentionPatientItem, + StaffTodayAttentionResponse, + StaffTodayAttentionRiskHighlight, StaffUploadRecord, StaffHistoryOverviewCalendarItem, StaffHistoryOverviewCalendarResponse, @@ -76,6 +79,7 @@ list_patient_upload_records_page, list_pending_bindings, list_staff_patients, + list_today_attention_patients, list_upload_queue, preview_delete_inactive_patients, update_pending_binding_status, @@ -495,6 +499,71 @@ async def get_staff_upload_queue( session.close() +@router.get("/v1/staff/uploads/today-attention", response_model=StaffTodayAttentionResponse) +async def get_staff_today_attention( + request: Request, + local_date: date | None = Query(default=None), + credentials=Depends(bearer_scheme), +) -> StaffTodayAttentionResponse: + principal = require_staff_or_admin(get_current_principal(request, credentials)) + session = get_session(request) + try: + accessible_patient_ids = _get_accessible_patient_ids( + session, + role=principal.role, + identity_id=principal.identity_id, + ) + today, total_uploads, rows = list_today_attention_patients( + session, + accessible_patient_ids=accessible_patient_ids, + local_date=local_date, + ) + suspected_patients = sum(1 for row in rows if row.tier == "suspected") + elevated_patients = sum(1 for row in rows if row.tier == "elevated") + other_patients = sum(1 for row in rows if row.tier == "other") + return StaffTodayAttentionResponse( + date=today.isoformat(), + total_uploads=total_uploads, + suspected_patients=suspected_patients, + elevated_patients=elevated_patients, + other_patients=other_patients, + items=[ + StaffTodayAttentionPatientItem( + patient_id=row.patient.id, + case_number=row.patient.case_number, + full_name=row.patient.full_name, + tier=row.tier, + representative_upload_id=row.representative_upload_id, + sort_upload_at=row.sort_upload_at, + has_annotation=row.has_annotation, + picture_url=row.picture_url, + day_upload_count=row.day_upload_count, + preview_upload_ids=row.preview_upload_ids, + risk_highlight=( + StaffTodayAttentionRiskHighlight( + upload_id=row.risk_highlight.upload_id, + screening_result=row.risk_highlight.screening_result, + probability=row.risk_highlight.probability, + threshold=row.risk_highlight.threshold, + symptom_pain=row.risk_highlight.symptom_pain, + symptom_discharge=row.risk_highlight.symptom_discharge, + symptom_pus=row.risk_highlight.symptom_pus, + symptom_cloudy_dialysate=row.risk_highlight.symptom_cloudy_dialysate, + has_high_risk_symptoms=row.risk_highlight.has_high_risk_symptoms, + symptom_aware_priority=row.risk_highlight.symptom_aware_priority, + created_at=row.risk_highlight.created_at, + ) + if row.risk_highlight is not None + else None + ), + ) + for row in rows + ], + ) + finally: + session.close() + + @router.get("/v1/staff/uploads/history-overview/days", response_model=StaffHistoryOverviewDaysResponse) async def get_staff_history_overview_days( request: Request, @@ -680,10 +749,13 @@ async def get_staff_history_overview_calendar( items=[ StaffHistoryOverviewCalendarItem( local_date=item.local_date.isoformat(), + upload_count=item.upload_count, + uploaded_users=item.uploaded_users, risky_patient_count=item.risky_patient_count, has_infection_risk=item.has_infection_risk, symptom_elevated_patient_count=item.symptom_elevated_patient_count, has_symptom_elevated_risk=item.has_symptom_elevated_risk, + unhandled_patient_count=item.unhandled_patient_count, ) for item in rows ], diff --git a/apps/backend/app/schemas/staff_dashboard.py b/apps/backend/app/schemas/staff_dashboard.py index 9ff1782..7b4ed1a 100644 --- a/apps/backend/app/schemas/staff_dashboard.py +++ b/apps/backend/app/schemas/staff_dashboard.py @@ -101,6 +101,43 @@ class StaffUploadQueueResponse(BaseModel): items: list[StaffUploadQueueItem] +class StaffTodayAttentionRiskHighlight(BaseModel): + upload_id: int + screening_result: str + probability: float | None + threshold: float | None + symptom_pain: bool + symptom_discharge: bool + symptom_pus: bool + symptom_cloudy_dialysate: bool + has_high_risk_symptoms: bool + symptom_aware_priority: Literal["normal", "suspected"] + created_at: datetime + + +class StaffTodayAttentionPatientItem(BaseModel): + patient_id: int + case_number: str + full_name: str | None + tier: Literal["suspected", "elevated", "other"] + representative_upload_id: int + sort_upload_at: datetime + has_annotation: bool + picture_url: str | None = None + day_upload_count: int = 0 + preview_upload_ids: list[int] = Field(default_factory=list) + risk_highlight: StaffTodayAttentionRiskHighlight | None = None + + +class StaffTodayAttentionResponse(BaseModel): + date: str + total_uploads: int + suspected_patients: int + elevated_patients: int + other_patients: int + items: list[StaffTodayAttentionPatientItem] + + class StaffHistoryOverviewDayItem(BaseModel): local_date: str upload_count: int @@ -182,10 +219,13 @@ class StaffHistoryOverviewResponse(BaseModel): class StaffHistoryOverviewCalendarItem(BaseModel): local_date: str + upload_count: int = 0 + uploaded_users: int = 0 risky_patient_count: int has_infection_risk: bool symptom_elevated_patient_count: int has_symptom_elevated_risk: bool + unhandled_patient_count: int = 0 class StaffHistoryOverviewCalendarResponse(BaseModel): diff --git a/apps/backend/app/services/attention_triage.py b/apps/backend/app/services/attention_triage.py new file mode 100644 index 0000000..57f059d --- /dev/null +++ b/apps/backend/app/services/attention_triage.py @@ -0,0 +1,44 @@ +"""Shared attention triage helpers for calendar unhandled counts and today-attention. + +Representative selection for risk patients: earliest upload in suspected tier, else elevated. +Unhandled = risk patient whose representative has no annotation. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Literal, Mapping, NamedTuple, Sequence + +from app.services.taipei_dates import normalize_datetime + +AttentionTier = Literal["suspected", "elevated", "other"] + + +class TriageUploadRef(NamedTuple): + upload_id: int + created_at: datetime + tier: AttentionTier + has_annotation: bool + + +def select_risk_representative(refs: Sequence[TriageUploadRef]) -> TriageUploadRef | None: + """Return earliest upload in suspected tier, else elevated; None if neither.""" + if not refs: + return None + has_suspected = any(ref.tier == "suspected" for ref in refs) + has_elevated = any(ref.tier == "elevated" for ref in refs) + if not has_suspected and not has_elevated: + return None + target_tier: AttentionTier = "suspected" if has_suspected else "elevated" + candidates = [ref for ref in refs if ref.tier == target_tier] + return min(candidates, key=lambda ref: (normalize_datetime(ref.created_at), ref.upload_id)) + + +def count_unhandled_patients(groups: Mapping[int, Sequence[TriageUploadRef]]) -> int: + """Count patients with a risk representative that has no annotation.""" + unhandled = 0 + for refs in groups.values(): + representative = select_risk_representative(refs) + if representative is not None and not representative.has_annotation: + unhandled += 1 + return unhandled diff --git a/apps/backend/app/services/auth/line_provider.py b/apps/backend/app/services/auth/line_provider.py index 8a965e1..1e41d4f 100644 --- a/apps/backend/app/services/auth/line_provider.py +++ b/apps/backend/app/services/auth/line_provider.py @@ -1,10 +1,35 @@ from __future__ import annotations +import logging +import time from dataclasses import dataclass from typing import Any import requests +logger = logging.getLogger(__name__) + +LINE_VERIFY_UNAVAILABLE = "LINE_VERIFY_UNAVAILABLE" +LINE_TOKEN_EXPIRED = "LINE_TOKEN_EXPIRED" +LINE_TOKEN_INVALID = "LINE_TOKEN_INVALID" +LINE_VERIFY_MISCONFIGURED = "LINE_VERIFY_MISCONFIGURED" + +MSG_VERIFY_UNAVAILABLE = "無法連上 LINE,請稍後再試。" +MSG_TOKEN_EXPIRED = "LINE 登入已過期,請重新開啟。" +MSG_TOKEN_INVALID = "LINE 登入失敗,請重新開啟。" +MSG_VERIFY_MISCONFIGURED = "系統異常,請聯絡護理師。" + +LINE_VERIFY_MAX_ATTEMPTS = 3 +LINE_VERIFY_RETRY_BACKOFF_SECONDS = (0.3, 0.6) +LINE_VERIFY_RETRYABLE_STATUS_CODES = frozenset({408, 429}) + + +class LineTokenVerifyError(ValueError): + def __init__(self, code: str, message: str) -> None: + self.code = code + self.message = message + super().__init__(message) + @dataclass(frozen=True) class LineIdentityProfile: @@ -33,16 +58,11 @@ def verify_id_token(self, *, line_id_token: str) -> LineIdentityProfile: return self._verify_line_id_token(line_id_token=line_id_token) def _verify_stub_token(self, *, line_id_token: str) -> LineIdentityProfile: - # Test-only / host-local mode; enables deterministic verify without LINE network. if not line_id_token.startswith("stub:"): - raise ValueError( - "Invalid LINE id token (stub mode): expected stub:. " - "Frontend must leave NEXT_PUBLIC_LIFF_ID unset and use " - "apps/frontend/.env.local.example (see docs/ops/local-dev-without-line.md)." - ) + raise LineTokenVerifyError(LINE_TOKEN_INVALID, MSG_TOKEN_INVALID) line_user_id = line_id_token.replace("stub:", "", 1).strip() if not line_user_id: - raise ValueError("Invalid LINE id token subject") + raise LineTokenVerifyError(LINE_TOKEN_INVALID, MSG_TOKEN_INVALID) return LineIdentityProfile( line_user_id=line_user_id, display_name=None, @@ -51,41 +71,73 @@ def _verify_stub_token(self, *, line_id_token: str) -> LineIdentityProfile: def _verify_line_id_token(self, *, line_id_token: str) -> LineIdentityProfile: if line_id_token.startswith("stub:"): - raise ValueError( - "Received stub: token but LINE_VERIFY_MODE is not stub. " - "For host-local verification set LINE_VERIFY_MODE=stub " - "(copy apps/backend/.env.local.example → .env; see docs/ops/local-dev-without-line.md)." - ) + raise LineTokenVerifyError(LINE_VERIFY_MISCONFIGURED, MSG_VERIFY_MISCONFIGURED) if not self._channel_id: - raise ValueError("LINE_CHANNEL_ID is required for LINE token verification") + raise LineTokenVerifyError(LINE_VERIFY_MISCONFIGURED, MSG_VERIFY_MISCONFIGURED) payload = {"id_token": line_id_token, "client_id": self._channel_id} - try: - response = requests.post(self._verify_endpoint, data=payload, timeout=self._timeout_seconds) - except requests.RequestException as exc: - raise ValueError("Failed to verify LINE id token") from exc + last_request_error: requests.RequestException | None = None - if response.status_code != 200: - verify_error = "" + for attempt in range(1, LINE_VERIFY_MAX_ATTEMPTS + 1): try: - body_json: Any = response.json() - error_code = str(body_json.get("error", "")).strip() - error_description = str(body_json.get("error_description", "")).strip() - if error_code or error_description: - verify_error = f"{error_code} {error_description}".strip() - except ValueError: - verify_error = response.text[:200].strip() - detail = f" (LINE verify {response.status_code}: {verify_error})" if verify_error else f" (LINE verify {response.status_code})" - raise ValueError(f"Invalid LINE id token{detail}") - - body: Any = response.json() - line_user_id = str(body.get("sub", "")).strip() - if not line_user_id: - raise ValueError("LINE verify response missing subject") - display_name = body.get("name") - picture_url = body.get("picture") - return LineIdentityProfile( - line_user_id=line_user_id, - display_name=str(display_name).strip() if isinstance(display_name, str) and display_name.strip() else None, - picture_url=str(picture_url).strip() if isinstance(picture_url, str) and picture_url.strip() else None, - ) + response = requests.post( + self._verify_endpoint, + data=payload, + timeout=self._timeout_seconds, + ) + except requests.RequestException as exc: + last_request_error = exc + logger.warning( + "LINE verify request failed (attempt %s/%s): %s", + attempt, + LINE_VERIFY_MAX_ATTEMPTS, + type(exc).__name__, + ) + if attempt < LINE_VERIFY_MAX_ATTEMPTS: + time.sleep(LINE_VERIFY_RETRY_BACKOFF_SECONDS[attempt - 1]) + continue + raise LineTokenVerifyError(LINE_VERIFY_UNAVAILABLE, MSG_VERIFY_UNAVAILABLE) from exc + + if response.status_code >= 500 or response.status_code in LINE_VERIFY_RETRYABLE_STATUS_CODES: + logger.warning( + "LINE verify returned %s (attempt %s/%s)", + response.status_code, + attempt, + LINE_VERIFY_MAX_ATTEMPTS, + ) + if attempt < LINE_VERIFY_MAX_ATTEMPTS: + time.sleep(LINE_VERIFY_RETRY_BACKOFF_SECONDS[attempt - 1]) + continue + raise LineTokenVerifyError(LINE_VERIFY_UNAVAILABLE, MSG_VERIFY_UNAVAILABLE) + + if response.status_code != 200: + verify_error = self._extract_verify_error(response) + if "expired" in verify_error.lower(): + raise LineTokenVerifyError(LINE_TOKEN_EXPIRED, MSG_TOKEN_EXPIRED) + raise LineTokenVerifyError(LINE_TOKEN_INVALID, MSG_TOKEN_INVALID) + + body: Any = response.json() + line_user_id = str(body.get("sub", "")).strip() + if not line_user_id: + raise LineTokenVerifyError(LINE_VERIFY_MISCONFIGURED, MSG_VERIFY_MISCONFIGURED) + display_name = body.get("name") + picture_url = body.get("picture") + return LineIdentityProfile( + line_user_id=line_user_id, + display_name=str(display_name).strip() if isinstance(display_name, str) and display_name.strip() else None, + picture_url=str(picture_url).strip() if isinstance(picture_url, str) and picture_url.strip() else None, + ) + + raise LineTokenVerifyError(LINE_VERIFY_UNAVAILABLE, MSG_VERIFY_UNAVAILABLE) from last_request_error + + @staticmethod + def _extract_verify_error(response: requests.Response) -> str: + try: + body_json: Any = response.json() + error_code = str(body_json.get("error", "")).strip() + error_description = str(body_json.get("error_description", "")).strip() + if error_code or error_description: + return f"{error_code} {error_description}".strip() + except ValueError: + pass + return response.text[:200].strip() diff --git a/apps/backend/app/services/auth/line_verify_http.py b/apps/backend/app/services/auth/line_verify_http.py new file mode 100644 index 0000000..f01f6cb --- /dev/null +++ b/apps/backend/app/services/auth/line_verify_http.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from fastapi import HTTPException + +from app.services.auth.line_provider import LineTokenVerifyError + + +def line_verify_http_error(exc: LineTokenVerifyError) -> HTTPException: + return HTTPException(status_code=400, detail={"code": exc.code, "message": exc.message}) diff --git a/apps/backend/app/services/staff_dashboard.py b/apps/backend/app/services/staff_dashboard.py index d44d15d..22d99fd 100644 --- a/apps/backend/app/services/staff_dashboard.py +++ b/apps/backend/app/services/staff_dashboard.py @@ -10,8 +10,9 @@ from sqlalchemy.orm import Session, aliased from app.db.models import AIResult, Annotation, LiffIdentity, Notification, Patient, PendingBinding, StaffPatientAssignment, Upload -from app.services.symptoms import calendar_risk_tier -from app.services.taipei_dates import TAIPEI_TIMEZONE, resolve_taipei_day_bounds, to_taipei_date +from app.services.attention_triage import TriageUploadRef, select_risk_representative +from app.services.symptoms import calendar_risk_tier, has_high_risk_symptoms, symptom_aware_priority +from app.services.taipei_dates import TAIPEI_TIMEZONE, resolve_taipei_day_bounds, resolve_taipei_day_bounds_for_date, to_taipei_date from app.services.upload_history import summarize_patient_upload_history @@ -993,6 +994,248 @@ def _summarize_tiered_uploads( ) +@dataclass +class TodayAttentionRiskHighlight: + upload_id: int + screening_result: str + probability: float | None + threshold: float | None + symptom_pain: bool + symptom_discharge: bool + symptom_pus: bool + symptom_cloudy_dialysate: bool + has_high_risk_symptoms: bool + symptom_aware_priority: Literal["normal", "suspected"] + created_at: datetime + + +@dataclass +class TodayAttentionPatientRow: + patient: Patient + tier: Literal["suspected", "elevated", "other"] + representative_upload_id: int + sort_upload_at: datetime + has_annotation: bool + picture_url: str | None + day_upload_count: int + preview_upload_ids: list[int] + risk_highlight: TodayAttentionRiskHighlight | None + + +_TIER_SORT_RANK: dict[str, int] = {"suspected": 0, "elevated": 1, "other": 2} + + +def _attention_risk_rank( + *, + screening_result: str, + annotation_label: str | None, + symptom_pain: bool, + symptom_pus: bool, + symptom_cloudy_dialysate: bool, +) -> int: + """Lower rank = higher priority. Matches history-overview risk sort.""" + if annotation_label == "confirmed_infection": + return 0 + if annotation_label == "suspected": + return 1 + if annotation_label == "rejected": + return 4 + if annotation_label == "normal": + return 3 + if screening_result == "suspected": + return 1 + tier = calendar_risk_tier( + screening_result=screening_result, + annotation_label=annotation_label, + symptom_pain=symptom_pain, + symptom_pus=symptom_pus, + symptom_cloudy_dialysate=symptom_cloudy_dialysate, + ) + if tier == "elevated": + return 2 + if screening_result == "normal": + return 3 + return 4 + + +def _load_patient_picture_urls(session: Session, *, patient_ids: set[int]) -> dict[int, str | None]: + if not patient_ids: + return {} + rows = session.execute( + select(LiffIdentity) + .where(LiffIdentity.patient_id.in_(patient_ids)) + .order_by(LiffIdentity.patient_id.asc(), LiffIdentity.id.asc()) + ).scalars() + result: dict[int, str | None] = {} + for row in rows: + if row.patient_id is None or row.patient_id in result: + continue + result[row.patient_id] = row.picture_url + return result + + +def _pick_risk_highlight( + patient_uploads: list[tuple[Upload, AIResult, str]], + *, + latest_annotation_by_upload: dict[int, Annotation], +) -> TodayAttentionRiskHighlight: + def sort_key(item: tuple[Upload, AIResult, str]) -> tuple[int, float, float, int]: + upload, ai_result, _ = item + annotation = latest_annotation_by_upload.get(upload.id) + rank = _attention_risk_rank( + screening_result=ai_result.screening_result, + annotation_label=annotation.label if annotation else None, + symptom_pain=upload.symptom_pain, + symptom_pus=upload.symptom_pus, + symptom_cloudy_dialysate=upload.symptom_cloudy_dialysate, + ) + # Lowest rank first; higher probability first; earlier created_at first. + prob = ai_result.probability if ai_result.probability is not None else -1.0 + return (rank, -prob, upload.created_at.timestamp(), upload.id) + + upload, ai_result, _ = min(patient_uploads, key=sort_key) + return TodayAttentionRiskHighlight( + upload_id=upload.id, + screening_result=ai_result.screening_result, + probability=ai_result.probability, + threshold=ai_result.threshold, + symptom_pain=upload.symptom_pain, + symptom_discharge=upload.symptom_discharge, + symptom_pus=upload.symptom_pus, + symptom_cloudy_dialysate=upload.symptom_cloudy_dialysate, + has_high_risk_symptoms=has_high_risk_symptoms( + symptom_pain=upload.symptom_pain, + symptom_pus=upload.symptom_pus, + symptom_cloudy_dialysate=upload.symptom_cloudy_dialysate, + ), + symptom_aware_priority=symptom_aware_priority( + ai_result.screening_result, + symptom_pain=upload.symptom_pain, + symptom_pus=upload.symptom_pus, + symptom_cloudy_dialysate=upload.symptom_cloudy_dialysate, + ), + created_at=upload.created_at, + ) + + +def list_today_attention_patients( + session: Session, + *, + accessible_patient_ids: set[int] | None = None, + local_date: date | None = None, +) -> tuple[date, int, list[TodayAttentionPatientRow]]: + """Uploaders for a Taipei day, partitioned into suspected / elevated / other with triage sort.""" + if local_date is None: + today, today_start, tomorrow_start = resolve_taipei_day_bounds() + else: + today, today_start, tomorrow_start = resolve_taipei_day_bounds_for_date(local_date) + if accessible_patient_ids is not None and not accessible_patient_ids: + return today, 0, [] + + base_query: Select = ( + select(Upload, AIResult, Patient) + .join(AIResult, AIResult.upload_id == Upload.id) + .join(Patient, Patient.id == Upload.patient_id) + .where( + Upload.created_at >= today_start, + Upload.created_at < tomorrow_start, + AIResult.screening_result != "rejected", + Patient.is_active.is_(True), + ) + ) + if accessible_patient_ids is not None: + base_query = base_query.where(Patient.id.in_(accessible_patient_ids)) + rows = session.execute(base_query).all() + if not rows: + return today, 0, [] + + upload_ids = {upload.id for upload, _, _ in rows} + latest_annotation_by_upload = _load_latest_annotation_by_upload_ids(session, upload_ids=upload_ids) + picture_by_patient = _load_patient_picture_urls( + session, + patient_ids={patient.id for _, _, patient in rows}, + ) + + uploads_by_patient: dict[int, list[tuple[Upload, AIResult, str]]] = defaultdict(list) + patients_by_id: dict[int, Patient] = {} + for upload, ai_result, patient in rows: + tier = _tier_for_upload( + upload=upload, + screening_result=ai_result.screening_result, + annotation=latest_annotation_by_upload.get(upload.id), + ) + uploads_by_patient[patient.id].append((upload, ai_result, tier)) + patients_by_id[patient.id] = patient + + attention_rows: list[TodayAttentionPatientRow] = [] + for patient_id, patient_uploads in uploads_by_patient.items(): + day_upload_count = len(patient_uploads) + preview_upload_ids: list[int] = [] + risk_highlight: TodayAttentionRiskHighlight | None = None + + triage_refs = [ + TriageUploadRef( + upload_id=upload.id, + created_at=upload.created_at, + tier="other" if tier not in {"suspected", "elevated"} else tier, # type: ignore[arg-type] + has_annotation=upload.id in latest_annotation_by_upload, + ) + for upload, _, tier in patient_uploads + ] + risk_representative = select_risk_representative(triage_refs) + + if risk_representative is not None: + patient_tier: Literal["suspected", "elevated", "other"] = risk_representative.tier # type: ignore[assignment] + representative = next( + upload for upload, _, _ in patient_uploads if upload.id == risk_representative.upload_id + ) + sort_upload_at = representative.created_at + risk_highlight = _pick_risk_highlight( + patient_uploads, + latest_annotation_by_upload=latest_annotation_by_upload, + ) + has_annotation = risk_representative.has_annotation + else: + patient_tier = "other" + representative = max( + (upload for upload, _, _ in patient_uploads), + key=lambda upload: (upload.created_at, upload.id), + ) + # Other tier still sorts by earliest today upload (longer wait first within tier). + sort_upload_at = min(upload.created_at for upload, _, _ in patient_uploads) + ordered = sorted( + (upload for upload, _, _ in patient_uploads), + key=lambda upload: (upload.created_at, upload.id), + ) + # ≤4: return all; >4: first 3 for FE "3 + n" display. + 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 + + attention_rows.append( + TodayAttentionPatientRow( + patient=patients_by_id[patient_id], + tier=patient_tier, + representative_upload_id=representative.id, + sort_upload_at=sort_upload_at, + has_annotation=has_annotation, + picture_url=picture_by_patient.get(patient_id), + day_upload_count=day_upload_count, + preview_upload_ids=preview_upload_ids, + risk_highlight=risk_highlight, + ) + ) + + attention_rows.sort( + key=lambda row: ( + _TIER_SORT_RANK[row.tier], + row.sort_upload_at, + row.patient.id, + ) + ) + return today, len(rows), attention_rows + + def get_today_suspected_summary( session: Session, *, diff --git a/apps/backend/app/services/staff_history_overview.py b/apps/backend/app/services/staff_history_overview.py index 74511ca..54a32c1 100644 --- a/apps/backend/app/services/staff_history_overview.py +++ b/apps/backend/app/services/staff_history_overview.py @@ -8,6 +8,7 @@ from sqlalchemy.orm import Session from app.db.models import AIResult, Annotation, LiffIdentity, Patient, Upload +from app.services.attention_triage import TriageUploadRef, count_unhandled_patients from app.services.staff_dashboard import calculate_age from app.services.symptoms import CalendarRiskTier, calendar_risk_tier, counts_toward_suspected_rate from app.services.taipei_dates import normalize_datetime, to_taipei_date @@ -25,6 +26,7 @@ class HistoryOverviewDaySummary: has_infection_risk: bool symptom_elevated_patient_count: int has_symptom_elevated_risk: bool + unhandled_patient_count: int @dataclass(frozen=True) @@ -89,10 +91,13 @@ class HistoryOverviewData: @dataclass(frozen=True) class HistoryOverviewCalendarItem: local_date: date + upload_count: int + uploaded_users: int risky_patient_count: int has_infection_risk: bool symptom_elevated_patient_count: int has_symptom_elevated_risk: bool + unhandled_patient_count: int @dataclass(frozen=True) @@ -217,6 +222,24 @@ def _day_patient_risk_sets(day_rows: list[_RawUploadRow]) -> tuple[set[int], set return suspected_patient_ids, elevated_patient_ids, rate_patient_ids +def _count_unhandled_for_day(day_rows: list[_RawUploadRow]) -> int: + """Count patients whose attention tier is suspected/elevated and representative upload is unannotated.""" + by_patient: dict[int, list[TriageUploadRef]] = defaultdict(list) + for row in day_rows: + if row.screening_result == "rejected": + continue + raw_tier = _tier_for_row(row) + by_patient[row.patient_id].append( + TriageUploadRef( + upload_id=row.upload_id, + created_at=row.created_at, + tier="other" if raw_tier == "none" else raw_tier, # type: ignore[arg-type] + has_annotation=row.annotation_label is not None, + ) + ) + return count_unhandled_patients(by_patient) + + def _raw_rows(session: Session, *, accessible_patient_ids: set[int] | None = None) -> list[_RawUploadRow]: base_query: Select = ( select(Upload, AIResult, Patient) @@ -297,6 +320,7 @@ def list_history_overview_days( has_infection_risk=suspected_infected_users > 0, symptom_elevated_patient_count=symptom_elevated_users, has_symptom_elevated_risk=symptom_elevated_users > 0, + unhandled_patient_count=_count_unhandled_for_day(day_rows), ) ) return result @@ -452,10 +476,13 @@ def get_history_overview_calendar_month( return [ HistoryOverviewCalendarItem( local_date=item.local_date, + upload_count=item.upload_count, + uploaded_users=item.uploaded_users, risky_patient_count=item.risky_patient_count, has_infection_risk=item.has_infection_risk, symptom_elevated_patient_count=item.symptom_elevated_patient_count, has_symptom_elevated_risk=item.has_symptom_elevated_risk, + unhandled_patient_count=item.unhandled_patient_count, ) for item in days if item.local_date.year == year and item.local_date.month == month diff --git a/apps/backend/app/services/taipei_dates.py b/apps/backend/app/services/taipei_dates.py index 788bc57..b53de50 100644 --- a/apps/backend/app/services/taipei_dates.py +++ b/apps/backend/app/services/taipei_dates.py @@ -15,9 +15,13 @@ def to_taipei_date(raw_dt: datetime) -> date: return normalize_datetime(raw_dt).astimezone(TAIPEI_TIMEZONE).date() -def resolve_taipei_day_bounds(reference_dt: datetime | None = None) -> tuple[date, datetime, datetime]: - resolved_reference = reference_dt if reference_dt is not None else datetime.now(tz=timezone.utc) - local_day = normalize_datetime(resolved_reference).astimezone(TAIPEI_TIMEZONE).date() +def resolve_taipei_day_bounds_for_date(local_day: date) -> tuple[date, datetime, datetime]: local_start = datetime.combine(local_day, time.min, tzinfo=TAIPEI_TIMEZONE) local_end = local_start + timedelta(days=1) return local_day, local_start.astimezone(timezone.utc), local_end.astimezone(timezone.utc) + + +def resolve_taipei_day_bounds(reference_dt: datetime | None = None) -> tuple[date, datetime, datetime]: + resolved_reference = reference_dt if reference_dt is not None else datetime.now(tz=timezone.utc) + local_day = normalize_datetime(resolved_reference).astimezone(TAIPEI_TIMEZONE).date() + return resolve_taipei_day_bounds_for_date(local_day) diff --git a/apps/backend/sql/manual/seed_dashboard_demo.py b/apps/backend/sql/manual/seed_dashboard_demo.py new file mode 100644 index 0000000..7bd51be --- /dev/null +++ b/apps/backend/sql/manual/seed_dashboard_demo.py @@ -0,0 +1,381 @@ +#!/usr/bin/env python3 +"""Seed ~30 days of dashboard demo uploads for local UI review. + +Creates P-DEV-DASH-* patients with mixed suspected / elevated / other tiers, +staff assignments (U_DEV_STAFF), and optional staff annotations. Dates anchor to +Taipei today so the admin week calendar always shows a realistic month of activity. + +Run after personas (and optionally fake patients): + + npm run seed:dev-personas + npm run seed:dashboard-demo + +Requires DATABASE_URL (loads apps/backend/.env when python-dotenv is installed). +""" + +from __future__ import annotations + +import os +import random +import sys +from dataclasses import dataclass +from datetime import date, datetime, timedelta, timezone +from pathlib import Path +from zoneinfo import ZoneInfo + +_BACKEND_ROOT = Path(__file__).resolve().parents[2] +if str(_BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(_BACKEND_ROOT)) + +try: + from dotenv import load_dotenv +except ImportError: + load_dotenv = None # type: ignore[misc, assignment] + +from sqlalchemy import delete, inspect, select, text +from sqlalchemy.orm import Session + +from app.db.migrations import upgrade_database +from app.db.models import AIResult, Annotation, LiffIdentity, Notification, Patient, StaffPatientAssignment, Upload +from app.db.session import create_engine_from_url, create_session_factory + +TZ = ZoneInfo("Asia/Taipei") +CASE_PREFIX = "P-DEV-DASH-" +LINE_PREFIX = "U_DEV_DASH_" +LOOKBACK_DAYS = 30 +SHOWCASE_MIN_UPLOADERS = 11 # e.g. 8/6 UI review: calendar "uploaded users" > 10 +RNG = random.Random(20260806) + + +@dataclass(frozen=True) +class DemoPatientSpec: + suffix: str + full_name: str + gender: str + birth_date: str + picture_url: str | None + + +_PATIENTS: tuple[DemoPatientSpec, ...] = ( + DemoPatientSpec("001", "陳志明", "male", "1968-04-12", "https://i.pravatar.cc/150?u=dash001"), + DemoPatientSpec("002", "林美華", "female", "1972-09-03", "https://i.pravatar.cc/150?u=dash002"), + DemoPatientSpec("003", "王淑芬", "female", "1960-11-28", "https://i.pravatar.cc/150?u=dash003"), + DemoPatientSpec("004", "張文雄", "male", "1955-07-19", "https://i.pravatar.cc/150?u=dash004"), + DemoPatientSpec("005", "黃雅婷", "female", "1988-02-14", "https://i.pravatar.cc/150?u=dash005"), + DemoPatientSpec("006", "李俊傑", "male", "1991-06-08", "https://i.pravatar.cc/150?u=dash006"), + DemoPatientSpec("007", "吳佳玲", "female", "1979-12-22", "https://i.pravatar.cc/150?u=dash007"), + DemoPatientSpec("008", "蔡明德", "male", "1963-03-30", None), + DemoPatientSpec("009", "許麗娟", "female", "1974-08-17", "https://i.pravatar.cc/150?u=dash009"), + DemoPatientSpec("010", "楊志豪", "male", "1982-01-05", "https://i.pravatar.cc/150?u=dash010"), + DemoPatientSpec("011", "鄭心怡", "female", "1995-10-11", "https://i.pravatar.cc/150?u=dash011"), + DemoPatientSpec("012", "謝承恩", "male", "1970-05-25", "https://i.pravatar.cc/150?u=dash012"), +) + + +def _taipei_today() -> date: + return datetime.now(tz=TZ).date() + + +def _parse_local_dt(day: date, hour: int, minute: int) -> datetime: + local = datetime.combine(day, datetime.min.time().replace(hour=hour, minute=minute), tzinfo=TZ) + return local.astimezone(timezone.utc) + + +def _resolve_staff_identity_id(session: Session) -> int | None: + for line_user_id in ("U_DEV_ADMIN", "U_DEV_STAFF", "U_DEV_DUAL"): + row = session.scalar( + select(LiffIdentity.id).where( + LiffIdentity.line_user_id == line_user_id, + LiffIdentity.role.in_(("staff", "admin")), + LiffIdentity.is_active.is_(True), + ) + ) + if row is not None: + return int(row) + return None + + +def _clear_demo(session: Session) -> None: + case_numbers = [f"{CASE_PREFIX}{spec.suffix}" for spec in _PATIENTS] + line_user_ids = [f"{LINE_PREFIX}{spec.suffix}" for spec in _PATIENTS] + patient_ids = list(session.scalars(select(Patient.id).where(Patient.case_number.in_(case_numbers))).all()) + if patient_ids: + upload_ids = select(Upload.id).where(Upload.patient_id.in_(patient_ids)) + session.execute(delete(Annotation).where(Annotation.upload_id.in_(upload_ids))) + session.execute(delete(Notification).where(Notification.patient_id.in_(patient_ids))) + session.execute(delete(AIResult).where(AIResult.upload_id.in_(upload_ids))) + session.execute(delete(Upload).where(Upload.patient_id.in_(patient_ids))) + session.execute(delete(StaffPatientAssignment).where(StaffPatientAssignment.patient_id.in_(patient_ids))) + session.execute(delete(LiffIdentity).where(LiffIdentity.patient_id.in_(patient_ids))) + session.execute(delete(Patient).where(Patient.id.in_(patient_ids))) + session.execute(delete(LiffIdentity).where(LiffIdentity.line_user_id.in_(line_user_ids))) + + +def _add_annotation( + session: Session, + *, + patient_id: int, + upload_id: int, + reviewer_identity_id: int, + label: str, +) -> None: + bind = session.get_bind() + column_names = {column["name"] for column in inspect(bind).get_columns("annotations")} + if "staff_user_id" in column_names: + session.execute( + text( + """ + INSERT INTO annotations ( + patient_id, upload_id, reviewer_identity_id, staff_user_id, label, comment, patient_read_at + ) VALUES ( + :patient_id, :upload_id, :reviewer_identity_id, :staff_user_id, :label, :comment, NULL + ) + """ + ), + { + "patient_id": patient_id, + "upload_id": upload_id, + "reviewer_identity_id": reviewer_identity_id, + "staff_user_id": reviewer_identity_id, + "label": label, + "comment": "dashboard demo", + }, + ) + return + session.add( + Annotation( + patient_id=patient_id, + upload_id=upload_id, + reviewer_identity_id=reviewer_identity_id, + label=label, + comment="dashboard demo", + ) + ) + + +def _upload_profile(day_offset: int, patient_index: int, upload_index: int) -> tuple[str, dict[str, bool], float, bool]: + """Return screening_result, symptoms, probability, should_annotate.""" + roll = RNG.random() + # Today / yesterday: richer risk mix for UI review. + if day_offset >= -1: + if roll < 0.28: + return ("suspected", {}, 0.82 + RNG.random() * 0.12, roll < 0.12) + if roll < 0.48: + symptoms = RNG.choice( + ( + {"symptom_pain": True}, + {"symptom_pus": True}, + {"symptom_cloudy_dialysate": True}, + {"symptom_pain": True, "symptom_pus": True}, + ) + ) + return ("normal", symptoms, 0.12 + RNG.random() * 0.15, roll < 0.22) + return ("normal", {}, 0.05 + RNG.random() * 0.2, False) + + if day_offset >= -7 and roll < 0.18: + return ("suspected", {}, 0.75 + RNG.random() * 0.2, roll < 0.35) + if roll < 0.12: + return ("suspected", {}, 0.7 + RNG.random() * 0.25, RNG.random() < 0.4) + if roll < 0.22: + return ( + "normal", + RNG.choice(({"symptom_pain": True}, {"symptom_pus": True}, {"symptom_cloudy_dialysate": True})), + 0.1 + RNG.random() * 0.2, + RNG.random() < 0.3, + ) + return ("normal", {}, 0.05 + RNG.random() * 0.25, False) + + +def _is_showcase_day(local_day: date, today: date) -> bool: + """Days that should show a busy patient pool (today + Aug 6 when in window).""" + if local_day == today: + return True + return local_day.month == 8 and local_day.day == 6 + + +def _seed_demo(session: Session, *, staff_identity_id: int | None) -> list[str]: + patients_by_suffix: dict[str, Patient] = {} + for spec in _PATIENTS: + patient = Patient( + case_number=f"{CASE_PREFIX}{spec.suffix}", + birth_date=spec.birth_date, + full_name=spec.full_name, + gender=spec.gender, + is_active=True, + ) + session.add(patient) + session.flush() + session.add( + LiffIdentity( + line_user_id=f"{LINE_PREFIX}{spec.suffix}", + display_name=spec.full_name, + picture_url=spec.picture_url, + patient_id=patient.id, + role="patient", + is_active=True, + ) + ) + if staff_identity_id is not None: + session.add( + StaffPatientAssignment( + staff_identity_id=staff_identity_id, + patient_id=patient.id, + ) + ) + patients_by_suffix[spec.suffix] = patient + + today = _taipei_today() + active_days: list[str] = [] + + for day_offset in range(-LOOKBACK_DAYS, 1): + local_day = today + timedelta(days=day_offset) + weekday = local_day.weekday() + showcase = _is_showcase_day(local_day, today) + # Skip some days; weekends slightly quieter. Never skip showcase days. + skip_chance = 0.35 if weekday >= 5 else 0.22 + if not showcase and day_offset not in (-1, -3, -7, -14, -21) and RNG.random() < skip_chance: + continue + + active_days.append(local_day.isoformat()) + + if showcase: + chosen_suffixes = [spec.suffix for spec in _PATIENTS] + upload_target = max(SHOWCASE_MIN_UPLOADERS + 2, RNG.randint(14, 20)) + else: + upload_target = RNG.randint(5, 14) if day_offset >= -7 else RNG.randint(3, 10) + if weekday >= 5: + upload_target = max(2, upload_target - 2) + chosen_suffixes = RNG.sample( + [spec.suffix for spec in _PATIENTS], + k=min(len(_PATIENTS), RNG.randint(3, min(9, upload_target))), + ) + + upload_count = 0 + patient_cycle = 0 + # Showcase: every patient uploads at least once before extras. + if showcase: + for suffix in chosen_suffixes: + patient = patients_by_suffix[suffix] + patient_index = int(suffix) + screening, symptoms, probability, annotate = _upload_profile(day_offset, patient_index, upload_count) + hour = 7 + (upload_count * 2 + patient_index) % 14 + minute = (upload_count * 11 + patient_index * 7) % 60 + upload = Upload( + patient_id=patient.id, + object_key=f"patients/dashboard-demo/{patient.id}/{local_day.isoformat()}-{upload_count}.jpg", + content_type="image/jpeg", + created_at=_parse_local_dt(local_day, hour, minute), + symptom_pain=bool(symptoms.get("symptom_pain")), + symptom_discharge=bool(symptoms.get("symptom_discharge")), + symptom_pus=bool(symptoms.get("symptom_pus")), + symptom_cloudy_dialysate=bool(symptoms.get("symptom_cloudy_dialysate")), + ) + session.add(upload) + session.flush() + session.add( + AIResult( + upload_id=upload.id, + screening_result=screening, + probability=probability, + threshold=0.5, + predicted_class="class_4" if screening == "suspected" else "class_1", + model_version="dashboard-demo-v1", + ) + ) + if annotate and staff_identity_id is not None and screening in ("suspected", "normal"): + label = "suspected" if screening == "suspected" else "normal" + _add_annotation( + session, + patient_id=patient.id, + upload_id=upload.id, + reviewer_identity_id=staff_identity_id, + label=label, + ) + upload_count += 1 + + while upload_count < upload_target: + suffix = chosen_suffixes[patient_cycle % len(chosen_suffixes)] + patient_cycle += 1 + patient = patients_by_suffix[suffix] + patient_index = int(suffix) + screening, symptoms, probability, annotate = _upload_profile(day_offset, patient_index, upload_count) + hour = 7 + (upload_count * 2 + patient_index) % 14 + minute = (upload_count * 11 + patient_index * 7) % 60 + + upload = Upload( + patient_id=patient.id, + object_key=f"patients/dashboard-demo/{patient.id}/{local_day.isoformat()}-{upload_count}.jpg", + content_type="image/jpeg", + created_at=_parse_local_dt(local_day, hour, minute), + symptom_pain=bool(symptoms.get("symptom_pain")), + symptom_discharge=bool(symptoms.get("symptom_discharge")), + symptom_pus=bool(symptoms.get("symptom_pus")), + symptom_cloudy_dialysate=bool(symptoms.get("symptom_cloudy_dialysate")), + ) + session.add(upload) + session.flush() + session.add( + AIResult( + upload_id=upload.id, + screening_result=screening, + probability=probability, + threshold=0.5, + predicted_class="class_4" if screening == "suspected" else "class_1", + model_version="dashboard-demo-v1", + ) + ) + if annotate and staff_identity_id is not None and screening in ("suspected", "normal"): + label = "suspected" if screening == "suspected" else "normal" + _add_annotation( + session, + patient_id=patient.id, + upload_id=upload.id, + reviewer_identity_id=staff_identity_id, + label=label, + ) + upload_count += 1 + + return active_days + + +def main() -> int: + if load_dotenv: + load_dotenv(_BACKEND_ROOT / ".env") + + database_url = os.getenv("PDCARE_DATABASE_URL") or os.getenv("DATABASE_URL") + if not database_url: + print( + "Neither PDCARE_DATABASE_URL nor DATABASE_URL is set. " + "Set one of them or put it in apps/backend/.env", + file=sys.stderr, + ) + return 1 + + print(f"Using database URL: {database_url}") + engine = create_engine_from_url(database_url) + upgrade_database(str(engine.url)) + session_factory = create_session_factory(engine) + + with session_factory() as session: + try: + staff_identity_id = _resolve_staff_identity_id(session) + if staff_identity_id is None: + print("Warning: no U_DEV_STAFF/U_DEV_ADMIN identity — run seed:dev-personas first.", file=sys.stderr) + _clear_demo(session) + active_days = _seed_demo(session, staff_identity_id=staff_identity_id) + session.commit() + except Exception: + session.rollback() + raise + + today = _taipei_today() + print(f"\nDashboard demo seeded ({len(_PATIENTS)} patients, {len(active_days)} active days).") + print(f"Taipei today: {today.isoformat()}") + print("Login as U_DEV_ADMIN or U_DEV_STAFF → /admin") + print("Use arrow keys / week nav / calendar icon on the week strip.") + print(f"Sample active range: {active_days[0]} … {active_days[-1]}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/backend/tests/test_attention_triage.py b/apps/backend/tests/test_attention_triage.py new file mode 100644 index 0000000..57f86cc --- /dev/null +++ b/apps/backend/tests/test_attention_triage.py @@ -0,0 +1,54 @@ +from datetime import datetime, timezone + +from app.services.attention_triage import TriageUploadRef, count_unhandled_patients, select_risk_representative + + +def _ref( + upload_id: int, + *, + tier: str, + has_annotation: bool = False, + minute: int = 0, +) -> TriageUploadRef: + return TriageUploadRef( + upload_id=upload_id, + created_at=datetime(2026, 8, 6, 10, minute, tzinfo=timezone.utc), + tier=tier, # type: ignore[arg-type] + has_annotation=has_annotation, + ) + + +def test_select_risk_representative_prefers_suspected_over_elevated() -> None: + refs = [ + _ref(1, tier="elevated", minute=1), + _ref(2, tier="suspected", minute=5), + _ref(3, tier="other", minute=0), + ] + representative = select_risk_representative(refs) + assert representative is not None + assert representative.upload_id == 2 + assert representative.tier == "suspected" + + +def test_select_risk_representative_picks_earliest_in_target_tier() -> None: + refs = [ + _ref(10, tier="suspected", minute=20), + _ref(11, tier="suspected", minute=5), + _ref(12, tier="elevated", minute=1), + ] + representative = select_risk_representative(refs) + assert representative is not None + assert representative.upload_id == 11 + + +def test_select_risk_representative_none_for_other_only() -> None: + assert select_risk_representative([_ref(1, tier="other")]) is None + + +def test_count_unhandled_patients_ignores_annotated_representative() -> None: + groups = { + 1: [_ref(1, tier="suspected", has_annotation=True)], + 2: [_ref(2, tier="elevated", has_annotation=False)], + 3: [_ref(3, tier="other")], + } + assert count_unhandled_patients(groups) == 1 diff --git a/apps/backend/tests/test_identity_api.py b/apps/backend/tests/test_identity_api.py index 65baf65..c4f08c0 100644 --- a/apps/backend/tests/test_identity_api.py +++ b/apps/backend/tests/test_identity_api.py @@ -4,6 +4,7 @@ from pathlib import Path from types import SimpleNamespace +import pytest from fastapi.testclient import TestClient from app.config import Settings @@ -194,3 +195,25 @@ def test_bind_status_rejects_invalid_line_token(tmp_path: Path) -> None: with TestClient(app) as client: response = client.post("/v1/identity/bind/status", json={"line_id_token": "not-a-stub-token"}) assert response.status_code == 400 + payload = response.json() + assert payload["detail"]["code"] == "LINE_TOKEN_INVALID" + assert payload["detail"]["message"] == "LINE 登入失敗,請重新開啟。" + + +def test_bind_status_returns_structured_line_verify_unavailable(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from app.services.auth.line_provider import LineIdentityProvider, LineTokenVerifyError + + settings = make_settings(tmp_path / "verify-unavailable.db") + app = create_app(settings=settings, loaded_model=SimpleNamespace(device="cpu")) + + def fake_verify(_self: LineIdentityProvider, *, line_id_token: str) -> None: + raise LineTokenVerifyError("LINE_VERIFY_UNAVAILABLE", "無法連上 LINE,請稍後再試。") + + monkeypatch.setattr(LineIdentityProvider, "verify_id_token", fake_verify) + + with TestClient(app) as client: + response = client.post("/v1/identity/bind/status", json={"line_id_token": "stub:U_LINE_X"}) + assert response.status_code == 400 + payload = response.json() + assert payload["detail"]["code"] == "LINE_VERIFY_UNAVAILABLE" + assert payload["detail"]["message"] == "無法連上 LINE,請稍後再試。" diff --git a/apps/backend/tests/test_staff_dashboard_api.py b/apps/backend/tests/test_staff_dashboard_api.py index 57d969e..692084a 100644 --- a/apps/backend/tests/test_staff_dashboard_api.py +++ b/apps/backend/tests/test_staff_dashboard_api.py @@ -1401,6 +1401,10 @@ def test_staff_history_overview_endpoints_return_expected_shape(tmp_path: Path) assert day_0529_calendar["has_infection_risk"] is True assert day_0529_calendar["symptom_elevated_patient_count"] == 0 assert day_0529_calendar["has_symptom_elevated_risk"] is False + assert day_0529_calendar["upload_count"] >= 1 + assert day_0529_calendar["uploaded_users"] >= 1 + assert "unhandled_patient_count" in day_0529_calendar + assert isinstance(day_0529_calendar["unhandled_patient_count"], int) def test_staff_history_overview_uses_linked_admin_identity_profile(tmp_path: Path) -> None: @@ -1642,4 +1646,8 @@ def test_staff_history_overview_counts_symptom_elevated_separately(tmp_path: Pat assert calendar_day["has_infection_risk"] is True assert calendar_day["symptom_elevated_patient_count"] == 1 assert calendar_day["has_symptom_elevated_risk"] is True + assert calendar_day["upload_count"] == 6 + assert calendar_day["uploaded_users"] == 5 + # elevated + suspected + both are risk-tier without annotation on representative. + assert calendar_day["unhandled_patient_count"] == 3 diff --git a/apps/backend/tests/test_staff_today_attention_api.py b/apps/backend/tests/test_staff_today_attention_api.py new file mode 100644 index 0000000..55b63f0 --- /dev/null +++ b/apps/backend/tests/test_staff_today_attention_api.py @@ -0,0 +1,336 @@ +from __future__ import annotations +# pyright: reportMissingImports=false + +from datetime import datetime, timedelta, timezone +from pathlib import Path +from types import SimpleNamespace + +from fastapi.testclient import TestClient + +from app.config import Settings +from app.db.models import AIResult, Annotation, LiffIdentity, Patient, StaffPatientAssignment, Upload +from app.main import create_app +from tests.db_test_utils import migrated_sqlite_database_url + + +def make_settings(db_path: Path) -> Settings: + return Settings( + app_name="test-staff-today-attention-api", + app_env="test", + model_url="https://example.com/model.pt", + model_path=Path("/tmp/model.pt"), + model_cache_dir=Path("/tmp"), + model_timeout_seconds=5.0, + device="cpu", + model_backbone="mobilenet_v3_large", + model_arch="baseline", + transfer_dropout=0.4, + threshold=0.5, + image_size=384, + infection_class_index=4, + class_names=("class_0", "class_1", "class_2", "class_3", "class_4"), + max_upload_mb=10, + log_level="INFO", + accepted_content_types=("image/jpeg", "image/png"), + cors_allowed_origins=("http://localhost:3000",), + cors_allowed_origin_regex=r"^https?://(?:\d{1,3}\.){3}\d{1,3}:3000$", + workers=1, + eval_hflip_tta=False, + database_url=migrated_sqlite_database_url(db_path), + s3_endpoint_url="http://localhost:8333", + s3_region="us-east-1", + s3_access_key="seaweed-access", + s3_secret_key="seaweed-secret", + s3_bucket_name="pd-care-private", + image_access_token_secret="test-secret", + image_access_token_ttl_seconds=300, + auth_token_secret="test-auth-secret", + auth_token_ttl_seconds=3600, + line_verify_mode="stub", + ) + + +def _taipei_today_start_utc() -> datetime: + taipei_tz = timezone(timedelta(hours=8)) + taipei_today = datetime.now(tz=timezone.utc).astimezone(taipei_tz).date() + return datetime.combine(taipei_today, datetime.min.time(), tzinfo=taipei_tz).astimezone(timezone.utc) + + +def _seed_staff(client: TestClient, *, line_user_id: str = "U_STAFF", role: str = "staff") -> int: + session_factory = client.app.state.db_session_factory + with session_factory() as session: + staff_identity = LiffIdentity( + line_user_id=line_user_id, + display_name="Staff", + picture_url=None, + patient_id=None, + role=role, + ) + session.add(staff_identity) + session.commit() + session.refresh(staff_identity) + return staff_identity.id + + +def _login_staff_token(client: TestClient, line_user_id: str = "U_STAFF") -> str: + response = client.post("/v1/auth/login", json={"line_id_token": f"stub:{line_user_id}"}) + assert response.status_code == 200 + return response.json()["access_token"] + + +def _assign_staff_patient(client: TestClient, *, staff_identity_id: int, patient_id: int) -> None: + session_factory = client.app.state.db_session_factory + with session_factory() as session: + session.add(StaffPatientAssignment(staff_identity_id=staff_identity_id, patient_id=patient_id)) + session.commit() + + +def _seed_today_patient( + client: TestClient, + *, + case_number: str, + line_user_id: str, + uploads: list[tuple[timedelta, str, dict[str, bool] | None]], +) -> tuple[int, list[int]]: + day_start = _taipei_today_start_utc() + session_factory = client.app.state.db_session_factory + with session_factory() as session: + patient = Patient( + case_number=case_number, + birth_date="1985-01-01", + full_name=case_number, + is_active=True, + ) + session.add(patient) + session.flush() + session.add( + LiffIdentity( + line_user_id=line_user_id, + display_name=case_number, + picture_url=None, + patient_id=patient.id, + role="patient", + ) + ) + upload_ids: list[int] = [] + for index, (offset, result, symptoms) in enumerate(uploads, start=1): + upload = Upload( + patient_id=patient.id, + object_key=f"patients/{patient.id}/uploads/{index}.jpg", + content_type="image/jpeg", + created_at=day_start + offset, + symptom_pain=bool(symptoms and symptoms.get("pain")), + symptom_pus=bool(symptoms and symptoms.get("pus")), + symptom_cloudy_dialysate=bool(symptoms and symptoms.get("cloudy")), + ) + session.add(upload) + session.flush() + session.add(AIResult(upload_id=upload.id, screening_result=result, probability=0.8, threshold=0.5)) + upload_ids.append(upload.id) + session.commit() + return patient.id, upload_ids + + +def test_today_attention_partitions_sorts_and_marks_annotation(tmp_path: Path) -> None: + settings = make_settings(tmp_path / "today-attention-partition.db") + app = create_app(settings=settings, loaded_model=SimpleNamespace(device="cpu")) + with TestClient(app) as client: + staff_identity_id = _seed_staff(client) + suspected_later_id, _ = _seed_today_patient( + client, + case_number="P-SUS-LATE", + line_user_id="U_SUS_LATE", + uploads=[(timedelta(hours=4), "suspected", None)], + ) + suspected_earlier_id, suspected_upload_ids = _seed_today_patient( + client, + case_number="P-SUS-EARLY", + line_user_id="U_SUS_EARLY", + uploads=[(timedelta(hours=1), "suspected", None)], + ) + elevated_id, elevated_upload_ids = _seed_today_patient( + client, + case_number="P-ELEV", + line_user_id="U_ELEV", + uploads=[(timedelta(hours=2), "normal", {"pain": True})], + ) + other_id, other_upload_ids = _seed_today_patient( + client, + case_number="P-OTHER", + line_user_id="U_OTHER", + uploads=[ + (timedelta(hours=1), "normal", None), + (timedelta(hours=5), "normal", None), + ], + ) + _assign_staff_patient(client, staff_identity_id=staff_identity_id, patient_id=suspected_later_id) + _assign_staff_patient(client, staff_identity_id=staff_identity_id, patient_id=suspected_earlier_id) + _assign_staff_patient(client, staff_identity_id=staff_identity_id, patient_id=elevated_id) + _assign_staff_patient(client, staff_identity_id=staff_identity_id, patient_id=other_id) + + session_factory = client.app.state.db_session_factory + with session_factory() as session: + session.add( + Annotation( + patient_id=suspected_earlier_id, + upload_id=suspected_upload_ids[0], + reviewer_identity_id=staff_identity_id, + label="suspected", + comment=None, + ) + ) + session.commit() + + token = _login_staff_token(client) + response = client.get( + "/v1/staff/uploads/today-attention", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["total_uploads"] == 5 + assert payload["suspected_patients"] == 2 + assert payload["elevated_patients"] == 1 + assert payload["other_patients"] == 1 + + tiers = [item["tier"] for item in payload["items"]] + assert tiers == ["suspected", "suspected", "elevated", "other"] + assert payload["items"][0]["patient_id"] == suspected_earlier_id + assert payload["items"][0]["has_annotation"] is True + assert payload["items"][1]["patient_id"] == suspected_later_id + assert payload["items"][1]["has_annotation"] is False + assert payload["items"][2]["patient_id"] == elevated_id + assert payload["items"][2]["representative_upload_id"] == elevated_upload_ids[0] + # Other tier uses latest upload as representative, earliest for sort. + assert payload["items"][3]["patient_id"] == other_id + assert payload["items"][3]["representative_upload_id"] == other_upload_ids[1] + assert payload["items"][3]["day_upload_count"] == 2 + assert payload["items"][3]["preview_upload_ids"] == other_upload_ids + assert payload["items"][3]["risk_highlight"] is None + assert payload["items"][0]["risk_highlight"] is not None + assert payload["items"][0]["risk_highlight"]["upload_id"] == suspected_upload_ids[0] + assert payload["items"][0]["day_upload_count"] == 1 + + +def test_today_attention_local_date_filters_day(tmp_path: Path) -> None: + settings = make_settings(tmp_path / "today-attention-local-date.db") + app = create_app(settings=settings, loaded_model=SimpleNamespace(device="cpu")) + with TestClient(app) as client: + staff_identity_id = _seed_staff(client) + today_patient_id, _ = _seed_today_patient( + client, + case_number="P-TODAY", + line_user_id="U_TODAY", + uploads=[(timedelta(hours=2), "suspected", None)], + ) + past_day_start = _taipei_today_start_utc() - timedelta(days=2) + session_factory = client.app.state.db_session_factory + with session_factory() as session: + patient = Patient( + case_number="P-PAST", + birth_date="1985-01-01", + full_name="P-PAST", + is_active=True, + ) + session.add(patient) + session.flush() + session.add( + LiffIdentity( + line_user_id="U_PAST", + display_name="P-PAST", + picture_url="https://example.com/past.png", + patient_id=patient.id, + role="patient", + ) + ) + upload = Upload( + patient_id=patient.id, + object_key=f"patients/{patient.id}/uploads/1.jpg", + content_type="image/jpeg", + created_at=past_day_start + timedelta(hours=3), + ) + session.add(upload) + session.flush() + session.add(AIResult(upload_id=upload.id, screening_result="suspected", probability=0.9, threshold=0.5)) + past_patient_id = patient.id + past_upload_id = upload.id + session.commit() + + _assign_staff_patient(client, staff_identity_id=staff_identity_id, patient_id=today_patient_id) + _assign_staff_patient(client, staff_identity_id=staff_identity_id, patient_id=past_patient_id) + + token = _login_staff_token(client) + past_date = (past_day_start.astimezone(timezone(timedelta(hours=8))).date()).isoformat() + response = client.get( + "/v1/staff/uploads/today-attention", + params={"local_date": past_date}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["date"] == past_date + assert payload["total_uploads"] == 1 + assert payload["suspected_patients"] == 1 + assert [item["patient_id"] for item in payload["items"]] == [past_patient_id] + assert payload["items"][0]["picture_url"] == "https://example.com/past.png" + assert payload["items"][0]["risk_highlight"]["upload_id"] == past_upload_id + + empty_response = client.get( + "/v1/staff/uploads/today-attention", + params={"local_date": "2020-01-01"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert empty_response.status_code == 200 + empty_payload = empty_response.json() + assert empty_payload["date"] == "2020-01-01" + assert empty_payload["total_uploads"] == 0 + assert empty_payload["items"] == [] + + +def test_today_attention_respects_staff_assignment_scope(tmp_path: Path) -> None: + settings = make_settings(tmp_path / "today-attention-scope.db") + app = create_app(settings=settings, loaded_model=SimpleNamespace(device="cpu")) + with TestClient(app) as client: + staff_identity_id = _seed_staff(client) + assigned_id, _ = _seed_today_patient( + client, + case_number="P-ASSIGNED", + line_user_id="U_ASSIGNED", + uploads=[(timedelta(hours=3), "suspected", None)], + ) + _seed_today_patient( + client, + case_number="P-UNASSIGNED", + line_user_id="U_UNASSIGNED", + uploads=[(timedelta(hours=2), "suspected", None)], + ) + _assign_staff_patient(client, staff_identity_id=staff_identity_id, patient_id=assigned_id) + + token = _login_staff_token(client) + response = client.get( + "/v1/staff/uploads/today-attention", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["suspected_patients"] == 1 + assert [item["patient_id"] for item in payload["items"]] == [assigned_id] + + +def test_today_attention_empty_day(tmp_path: Path) -> None: + settings = make_settings(tmp_path / "today-attention-empty.db") + app = create_app(settings=settings, loaded_model=SimpleNamespace(device="cpu")) + with TestClient(app) as client: + _seed_staff(client) + token = _login_staff_token(client) + response = client.get( + "/v1/staff/uploads/today-attention", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["total_uploads"] == 0 + assert payload["suspected_patients"] == 0 + assert payload["elevated_patients"] == 0 + assert payload["other_patients"] == 0 + assert payload["items"] == [] diff --git a/apps/backend/tests/test_taipei_dates.py b/apps/backend/tests/test_taipei_dates.py index 148d23f..3d292e2 100644 --- a/apps/backend/tests/test_taipei_dates.py +++ b/apps/backend/tests/test_taipei_dates.py @@ -2,7 +2,7 @@ from datetime import date, datetime, timezone -from app.services.taipei_dates import resolve_taipei_day_bounds, to_taipei_date +from app.services.taipei_dates import resolve_taipei_day_bounds, resolve_taipei_day_bounds_for_date, to_taipei_date def test_to_taipei_date_maps_utc_boundary_to_next_local_day() -> None: @@ -17,3 +17,10 @@ def test_resolve_taipei_day_bounds_uses_reference_datetime() -> None: assert local_day == date(2026, 7, 1) assert local_start == datetime(2026, 6, 30, 16, 0, tzinfo=timezone.utc) assert local_end == datetime(2026, 7, 1, 16, 0, tzinfo=timezone.utc) + + +def test_resolve_taipei_day_bounds_for_date() -> None: + local_day, local_start, local_end = resolve_taipei_day_bounds_for_date(date(2026, 7, 20)) + assert local_day == date(2026, 7, 20) + assert local_start == datetime(2026, 7, 19, 16, 0, tzinfo=timezone.utc) + assert local_end == datetime(2026, 7, 20, 16, 0, tzinfo=timezone.utc) diff --git a/apps/frontend/app/admin/_components/dashboard-day-calendar.tsx b/apps/frontend/app/admin/_components/dashboard-day-calendar.tsx new file mode 100644 index 0000000..9f43900 --- /dev/null +++ b/apps/frontend/app/admin/_components/dashboard-day-calendar.tsx @@ -0,0 +1,322 @@ +"use client"; + +import { CalendarDays, ChevronLeft, ChevronRight, Image as ImageIcon, TriangleAlert, Users } from "lucide-react"; +import { useCallback, useEffect, useId, useMemo, useRef, type ReactNode } from "react"; + +import { cn } from "@/lib/utils"; +import { + buildTaipeiWeekRow, + formatTaipeiWeekRangeLabel, + getWeekStartDateKey, + parseTaipeiDateKey, + shiftTaipeiDateKey, +} from "@/lib/utils/upload-calendar"; + +export type DayCalendarMetrics = { + uploadCount: number; + uploadedUsers: number; + riskyPatients: number; + unhandledPatients: number; +}; + +type DashboardDayCalendarProps = { + selectedDate: string; + weekStartDateKey: string; + metricsByDate: Record; + availableDates: Set | string[]; + loading?: boolean; + onSelectDate: (dateKey: string) => void; + onWeekChange: (weekStartDateKey: string) => void; +}; + +function MetricChip({ + icon, + value, + selected, + risk, +}: { + icon: ReactNode; + value: number; + selected: boolean; + risk?: boolean; +}) { + const muted = risk ? value <= 0 : false; + return ( +
+ + {icon} + + {value} +
+ ); +} + +const WEEKDAY_LABELS = ["日", "一", "二", "三", "四", "五", "六"] as const; + +function formatTaipeiDateLabel(dateKey: string): string { + const { year, month, day } = parseTaipeiDateKey(dateKey); + const weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay(); + return `${year} 年 ${month} 月 ${day} 日(${WEEKDAY_LABELS[weekday]})`; +} + +function DayCellButton({ + cell, + metricsByDate, + available, + selected, + onSelectDate, + className, +}: { + cell: { dateKey: string; dayOfMonth: number }; + metricsByDate: Record; + available: Set; + selected: boolean; + onSelectDate: (dateKey: string) => void; + className?: string; +}) { + const metrics = metricsByDate[cell.dateKey]; + const isAvailable = available.has(cell.dateKey); + const unhandled = metrics?.unhandledPatients ?? 0; + const uploadCount = metrics?.uploadCount ?? 0; + const uploadedUsers = metrics?.uploadedUsers ?? 0; + const riskyPatients = metrics?.riskyPatients ?? 0; + + return ( + + ); +} + +export function DashboardDayCalendar({ + selectedDate, + weekStartDateKey, + metricsByDate, + availableDates, + loading, + onSelectDate, + onWeekChange, +}: DashboardDayCalendarProps) { + const available = useMemo( + () => (availableDates instanceof Set ? availableDates : new Set(availableDates)), + [availableDates] + ); + const weekCells = buildTaipeiWeekRow(weekStartDateKey); + const weekTitle = formatTaipeiWeekRangeLabel(weekStartDateKey); + const mobileTitle = formatTaipeiDateLabel(selectedDate); + const selectedCell = useMemo(() => { + const { day } = parseTaipeiDateKey(selectedDate); + return { dateKey: selectedDate, dayOfMonth: day }; + }, [selectedDate]); + const datePickerId = useId(); + const dateInputRef = useRef(null); + const panelRef = useRef(null); + + const moveSelectedByDays = useCallback( + (offsetDays: number) => { + let candidate = selectedDate; + for (let step = 0; step < 366; step += 1) { + candidate = shiftTaipeiDateKey(candidate, offsetDays); + if (available.has(candidate)) { + onSelectDate(candidate); + const weekStart = getWeekStartDateKey(candidate); + if (weekStart !== weekStartDateKey) { + onWeekChange(weekStart); + } + return; + } + } + }, + [available, onSelectDate, onWeekChange, selectedDate, weekStartDateKey] + ); + + useEffect(() => { + const panel = panelRef.current; + if (!panel) { + return; + } + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "ArrowLeft") { + event.preventDefault(); + moveSelectedByDays(-1); + } else if (event.key === "ArrowRight") { + event.preventDefault(); + moveSelectedByDays(1); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + onWeekChange(shiftTaipeiDateKey(weekStartDateKey, -7)); + } else if (event.key === "ArrowDown") { + event.preventDefault(); + onWeekChange(shiftTaipeiDateKey(weekStartDateKey, 7)); + } + }; + panel.addEventListener("keydown", onKeyDown); + return () => { + panel.removeEventListener("keydown", onKeyDown); + }; + }, [moveSelectedByDays, onWeekChange, weekStartDateKey]); + + const openDatePicker = () => { + const input = dateInputRef.current; + if (!input) { + return; + } + if (typeof input.showPicker === "function") { + input.showPicker(); + } else { + input.focus(); + input.click(); + } + }; + + return ( +
+
+ + +
+ +
+ {mobileTitle} + {weekTitle} + {loading ? 載入中… : null} +
+ { + const next = event.target.value; + if (!next) { + return; + } + try { + parseTaipeiDateKey(next); + } catch { + return; + } + onSelectDate(next); + onWeekChange(getWeekStartDateKey(next)); + }} + /> +
+ + +
+ +

+ 方向鍵:← → 切換日期 · ↑ ↓ 切換週 · 點日曆圖示選日期 +

+ +
+ +
+ +
+ {WEEKDAY_LABELS.map((label) => ( +
{label}
+ ))} +
+ +
+ {weekCells.map((cell) => ( + + ))} +
+
+ ); +} diff --git a/apps/frontend/app/admin/_components/history-upload-annotation-modal.tsx b/apps/frontend/app/admin/_components/history-upload-annotation-modal.tsx new file mode 100644 index 0000000..b1ecb04 --- /dev/null +++ b/apps/frontend/app/admin/_components/history-upload-annotation-modal.tsx @@ -0,0 +1,146 @@ +"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 { historyUploadRiskLabel, type HistoryUploadDraftVerdict } from "./history-upload-review-helpers"; + +type HistoryUploadAnnotationModalProps = { + upload: StaffHistoryOverviewUploadItem; + imageUrl: string | null; + draft: HistoryUploadDraftVerdict; + saving: boolean; + onDraftChange: (next: HistoryUploadDraftVerdict) => void; + onSave: () => void; + onClose: () => void; +}; + +export function HistoryUploadAnnotationModal({ + upload, + imageUrl, + draft, + saving, + onDraftChange, + onSave, + onClose, +}: HistoryUploadAnnotationModalProps) { + return ( +
+
+
+
+

{upload.patient_full_name ?? "未命名病患"}

+

{upload.case_number}

+
+ +
+
+
+ {imageUrl ? ( + {`history-preview-${upload.upload_id}`} + ) : ( +
載入影像中...
+ )} +
+
+
+
+
年齡
+
{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 ?? "-"}
+
+
+
+ + 開啟病患完整頁 + +
+ +