Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions .cursor/skills/report-with-demo/SKILL.md
Original file line number Diff line number Diff line change
@@ -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 <feature request>`.

## 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/<feature-slug>.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.
38 changes: 38 additions & 0 deletions .cursor/skills/report-with-demo/contract-template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# <Feature> 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:
1 change: 1 addition & 0 deletions apps/backend/app/api/routes/staff.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions apps/backend/app/schemas/staff_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 13 additions & 1 deletion apps/backend/app/services/staff_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions apps/frontend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@

# testing
/coverage
/test-results/
/playwright-report/
/e2e-artifacts/

# next.js
/.next/
Expand Down
Loading
Loading