feat(questionnaire): integrate revised intake and follow-up monitoring
CI / ruff · pytest (pull_request) Successful in 55s
CI / ruff · pytest (pull_request) Successful in 55s
Add gated TSQ, Russian C-SSRS Recent, durable responses and notifications, diaries, schedules and audited clinical review. Preserve manual assessment when the Russian follow-up source is unavailable. Refs #52
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
"""Audited questionnaire review for the synthetic prototype.
|
||||
|
||||
Like the existing API, doctor_id is supplied by the caller; production must put
|
||||
these routes behind authenticated staff identity. No clinical data cross into identity.
|
||||
"""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.core import clock
|
||||
from app.db import repo
|
||||
from app.db.audit import audit
|
||||
from app.db.intake import QuestionnaireRecord
|
||||
from app.db.session import get_clinical_session
|
||||
from app.services import questionnaire as service
|
||||
|
||||
router = APIRouter(tags=["questionnaire-review"])
|
||||
|
||||
|
||||
class ReviewIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
||||
doctor_id: str = Field(min_length=1)
|
||||
assessment_performed: Literal[True]
|
||||
decision: Literal["resume", "keep_hold"]
|
||||
reference: str = Field(min_length=1, max_length=200, pattern=r"^[A-Za-z0-9_./:#-]+$")
|
||||
|
||||
|
||||
async def require_doctor(session, token, doctor_id):
|
||||
profile = await repo.get_profile(session, token)
|
||||
if profile is None or profile.doctor_id != doctor_id:
|
||||
raise HTTPException(404, "patient not found")
|
||||
|
||||
|
||||
@router.get("/patients/{token}/questionnaires")
|
||||
async def history(token: str, doctor_id: str):
|
||||
async with get_clinical_session() as session:
|
||||
await require_doctor(session, token, doctor_id)
|
||||
rows = await service.records(session, token)
|
||||
await audit(session, actor=doctor_id, action="view_questionnaires", token=token, ts=clock.now())
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"kind": r.kind,
|
||||
"status": r.status,
|
||||
"version": r.version,
|
||||
"policy_version": r.policy_version,
|
||||
"started_at": r.started_at,
|
||||
"completed_at": r.completed_at,
|
||||
"answers": r.answers,
|
||||
"details": r.details,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("/patients/{token}/questionnaires/{record_id}/review")
|
||||
async def review(token: str, record_id: str, body: ReviewIn):
|
||||
now = clock.now()
|
||||
async with get_clinical_session() as session:
|
||||
await service.lock_patient(session, token)
|
||||
await require_doctor(session, token, body.doctor_id)
|
||||
state = await repo.get_dialog_state(session, token)
|
||||
row = await session.get(QuestionnaireRecord, record_id)
|
||||
if row is None or row.token != token:
|
||||
raise HTTPException(404, "record not found")
|
||||
if not state or state.data.get("consent") is not True:
|
||||
raise HTTPException(409, "consent required")
|
||||
if row.status not in ("review_required", "interrupted"):
|
||||
raise HTTPException(409, "record does not require review")
|
||||
if body.decision == "resume" and (state.data.get("crisis") or {}).get("open"):
|
||||
raise HTTPException(409, "close the acute episode after assessment before resuming routine work")
|
||||
detail = {
|
||||
"doctor_id": body.doctor_id,
|
||||
"decision": body.decision,
|
||||
"reference": body.reference,
|
||||
"at": now.isoformat(),
|
||||
}
|
||||
row.details = {**row.details, "clinician_review": detail}
|
||||
if body.decision == "resume":
|
||||
row.status = "reviewed"
|
||||
remaining = [
|
||||
r
|
||||
for r in await service.records(session, token)
|
||||
if r.id != row.id and r.status in ("review_required", "interrupted")
|
||||
]
|
||||
if not remaining:
|
||||
state.data = {**state.data, "intake_review_required": False, "questionnaire_record": None}
|
||||
await audit(
|
||||
session,
|
||||
actor=body.doctor_id,
|
||||
action="review_questionnaire",
|
||||
token=token,
|
||||
details={"record_id": row.id, "decision": body.decision, "reference": body.reference},
|
||||
ts=now,
|
||||
)
|
||||
return {"id": row.id, "status": row.status, "review": detail}
|
||||
@@ -7,6 +7,7 @@ Lane B replaces bodies in #12 without changing paths or schemas (those are the f
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from app.api import fixtures
|
||||
from app.api.questionnaire import router as questionnaire_router
|
||||
from app.api.schemas import (
|
||||
AckIn,
|
||||
AlertOut,
|
||||
@@ -26,6 +27,7 @@ from app.dialog.contracts import get_engine
|
||||
from app.integrations.base import MemoryOutbox, integrations
|
||||
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
router.include_router(questionnaire_router)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ doctor dashboard
|
||||
|
||||
Reference in New Issue
Block a user