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
15 changes: 6 additions & 9 deletions apps/api/alembic/versions/0001_initial_auth_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,12 @@ def upgrade() -> None:
),
sa.Column("name_encrypted", sa.LargeBinary(), nullable=False),
sa.Column("birth_year", sa.Integer(), nullable=False),
sa.Column(
"is_minor",
sa.Boolean(),
sa.Computed(
"(EXTRACT(YEAR FROM CURRENT_DATE)::int - birth_year) < 14",
persisted=True,
),
nullable=False,
),
# BUG-065 fix: plain column, not `GENERATED ALWAYS AS ... STORED` —
# PG16 rejects CURRENT_DATE (volatile) inside a generated expression,
# so a fresh `alembic upgrade head` never completed past this table.
# The application now sets the value explicitly at INSERT time
# (`api/v1/auth.py::_is_minor`, `models/patient_profile.py`).
sa.Column("is_minor", sa.Boolean(), nullable=False),
sa.Column("gender", sa.Text()),
sa.Column("phone_encrypted", sa.LargeBinary()),
sa.Column("region", sa.Text()),
Expand Down
77 changes: 77 additions & 0 deletions apps/api/alembic/versions/0013_f1f3_backend_integration_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""F1~F3 backend-api integration schema catch-up (PR #79 canonical merge).

`fix/backend-api-integration`'s model-source changes (Phase 1 ADR-046 #2
round-trip, BUG-063/064 role+status widening) were authored on a branch that
forked before Master's alembic chain reached `0011`/`0012` and never carried
its own migrations for these columns/constraints — this migration is the
catch-up so a fresh `alembic upgrade head` matches `src/models/session.py`
exactly (closes the same class of source/DB divergence BUG-065 fixed for
`patient_profiles.is_minor`).

Adds:
- `sessions.session_state` (JSONB, nullable) — ADR-046 #2 WS-reconnect
round-trip carrier for the prior turn's `ChatResponse.session_state`.
- `sessions.clinical_escalation_required` (bool NOT NULL default false) —
ADR-044 4th backstop field, queryable independent of `session_state`.
- `risk_events.status` widened `varchar(16)` -> `varchar(32)` + CHECK
extended with `'pending_reclassify'` (BUG-063, M-1 conservative fallback
when the safety classifier is unavailable).
- `messages` CHECK `ck_messages_role` extended with `'assistant'` (BUG-064
additive step; storage keeps writing `'ai'`, `services/chat.py::respond`'s
outbound seam-map is the interim `'ai'`->`'assistant'` translation).

Revision ID: 0013
Revises: 0012
Create Date: 2026-07-25
"""

from collections.abc import Sequence

from alembic import op

revision: str = "0013"
down_revision: str | None = "0012"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
op.execute("ALTER TABLE sessions ADD COLUMN IF NOT EXISTS session_state JSONB")
op.execute(
"ALTER TABLE sessions ADD COLUMN IF NOT EXISTS clinical_escalation_required "
"BOOLEAN NOT NULL DEFAULT false"
)

op.execute("ALTER TABLE risk_events ALTER COLUMN status TYPE VARCHAR(32)")
op.execute("ALTER TABLE risk_events DROP CONSTRAINT IF EXISTS ck_risk_events_status")
op.execute(
"ALTER TABLE risk_events ADD CONSTRAINT ck_risk_events_status "
"CHECK (status IN ('detected','acknowledged','resolved','dismissed',"
"'pending_reclassify'))"
)

op.execute("ALTER TABLE messages DROP CONSTRAINT IF EXISTS ck_messages_role")
op.execute(
"ALTER TABLE messages ADD CONSTRAINT ck_messages_role "
"CHECK (role IN ('user','ai','system','assistant'))"
)


def downgrade() -> None:
op.execute("ALTER TABLE messages DROP CONSTRAINT IF EXISTS ck_messages_role")
op.execute(
"ALTER TABLE messages ADD CONSTRAINT ck_messages_role "
"CHECK (role IN ('user','ai','system'))"
)

op.execute("ALTER TABLE risk_events DROP CONSTRAINT IF EXISTS ck_risk_events_status")
op.execute(
"ALTER TABLE risk_events ADD CONSTRAINT ck_risk_events_status "
"CHECK (status IN ('detected','acknowledged','resolved','dismissed'))"
)
op.execute("ALTER TABLE risk_events ALTER COLUMN status TYPE VARCHAR(16)")

op.execute(
"ALTER TABLE sessions DROP COLUMN IF EXISTS clinical_escalation_required"
)
op.execute("ALTER TABLE sessions DROP COLUMN IF EXISTS session_state")
7 changes: 7 additions & 0 deletions apps/api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,10 @@ known-first-party = ["src"]
pythonpath = ["."]
testpaths = ["tests"]
asyncio_mode = "auto"
# pytest-asyncio 1.4.0 defaults to a function-scoped event loop per test; our
# session-scoped `engine` fixture (asyncpg pool) must share one loop with the
# tests that use it, or asyncpg raises "Task got Future attached to a
# different loop" (BUG-057 root cause 2). Session-scope both fixtures and
# tests so there is exactly one loop for the whole suite.
asyncio_default_fixture_loop_scope = "session"
asyncio_default_test_loop_scope = "session"
63 changes: 62 additions & 1 deletion apps/api/scripts/seed_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,66 @@ def _message_aad(session_id: uuid.UUID, message_id: uuid.UUID) -> bytes:
return f"messages.content:{session_id}:{message_id}".encode()


def _is_minor(birth_year: int, settings: Settings) -> bool:
"""BUG-065 fix: `is_minor` is no longer a DB-computed GENERATED column
(see `models/patient_profile.py`) — this demo seed sets it explicitly,
mirroring `api/v1/auth.py::_is_minor` (kept local here rather than
imported to avoid a `scripts/` -> `api/v1/` import across module
boundaries the rest of this file doesn't otherwise take)."""
return (datetime.now(tz=UTC).year - birth_year) < settings.minor_age_cutoff


def _narrative_to_report_response(n: dict) -> dict:
"""BUG-066 fix: `HandoffReport.content` must now match the realigned
`contracts.handoff.HandoffResponse` shape (`report_markdown`-primary)
instead of the pre-fix invented `chief_complaint`/`present_illness`/...
fields ai-server never actually produced — this demo seed data predates
that fix. Folds the old narrative dict into one Markdown report so the
clinician dashboard demo still renders real-looking content."""
saa = n.get("sleep_appetite_activity", {})
lines = [
f"## 주호소\n{n.get('chief_complaint', '')}",
f"## 현병력\n{n.get('present_illness', '')}",
]
if n.get("symptoms"):
lines.append("## 주요 증상\n" + ", ".join(n["symptoms"]))
if n.get("onset"):
lines.append(f"## 시작 시점\n{n['onset']}")
if n.get("recent_changes"):
lines.append(f"## 최근 변화\n{n['recent_changes']}")
if n.get("triggers"):
lines.append("## 유발 요인\n" + ", ".join(n["triggers"]))
saa_text = " · ".join(
filter(
None,
[
f"수면: {saa.get('sleep')}" if saa.get("sleep") else None,
f"식욕: {saa.get('appetite')}" if saa.get("appetite") else None,
f"활동: {saa.get('activity')}" if saa.get("activity") else None,
],
)
)
if saa_text:
lines.append(f"## 수면 / 식욕 / 활동\n{saa_text}")
if n.get("psych_history"):
lines.append(f"## 과거 정신건강 이력\n{n['psych_history']}")
if n.get("medications"):
lines.append(f"## 복용약\n{n['medications']}")
if n.get("clinician_attention"):
lines.append("## 의료진 확인 필요\n" + ", ".join(n["clinician_attention"]))

return {
"report_markdown": "\n\n".join(lines),
"report_json": None,
"report_pdf_base64": None,
"trend_plot_base64": None,
"evidence_packets": [],
"missing_slots": [],
"risk_level": "none",
"requires_human_review": False,
}


def _severity_phq9(score: int) -> str:
return (
"minimal" if score <= 4 else "mild" if score <= 9 else "moderate"
Expand Down Expand Up @@ -233,6 +293,7 @@ async def _make_persona(db, settings: Settings, p: dict) -> None:
p["name"], aad=_profile_aad(user.id, "name"), settings=settings
),
birth_year=p["birth_year"],
is_minor=_is_minor(p["birth_year"], settings),
gender=p["gender"],
phone_encrypted=encrypt_str(
p["phone"], aad=_profile_aad(user.id, "phone"), settings=settings
Expand Down Expand Up @@ -313,7 +374,7 @@ async def _make_persona(db, settings: Settings, p: dict) -> None:
HandoffReport(
session_id=sess.id,
status="ready",
content=p["narrative"],
content=_narrative_to_report_response(p["narrative"]),
generated_at=datetime.now(UTC) - timedelta(hours=1),
# v3 §6-B — 시드 리포트는 이미 전달된 상태로 둔다(의료진 대시보드 노출).
delivered_at=datetime.now(UTC) - timedelta(minutes=50),
Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/api/v1/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,12 @@ async def register(
payload.name, aad=_profile_aad(user.id, "name"), settings=settings
),
birth_year=payload.birth_year,
# BUG-065 fix: `is_minor` is no longer a DB-computed GENERATED
# column (see `models/patient_profile.py`/`alembic/0001` docstrings)
# — set explicitly here via the SAME `_is_minor()` helper
# `_check_guardian` above already used, so there is exactly one
# source of truth for this policy.
is_minor=_is_minor(payload.birth_year, settings),
gender=payload.gender,
phone_encrypted=encrypt_str(
payload.phone, aad=_profile_aad(user.id, "phone"), settings=settings
Expand Down
Loading
Loading