사례개념화 워크시트 저장 추가

This commit is contained in:
Yun Chan 2026-06-27 19:27:34 +09:00
parent 7f4a2d90c7
commit bd389a97cc
15 changed files with 614 additions and 42 deletions

View file

@ -204,10 +204,16 @@ class ReviewWorksheetSection(BaseModel):
class ReviewCaseWorksheet(BaseModel):
status: Literal["empty", "draft_from_transcript"] = "empty"
status: Literal["empty", "draft_from_transcript", "saved_by_learner"] = "empty"
generatedBy: str = "rule-based transcript extractor"
sections: list[ReviewWorksheetSection] = Field(default_factory=list)
limitations: list[str] = Field(default_factory=list)
savedAt: Optional[str] = None
class ReviewCaseWorksheetSaveRequest(BaseModel):
sections: list[ReviewWorksheetSection] = Field(default_factory=list)
limitations: list[str] = Field(default_factory=list)
class SessionReviewResponse(BaseModel):
@ -851,6 +857,16 @@ def _case_worksheet_from_turns(turns: list[ReviewTurn]) -> ReviewCaseWorksheet:
)
def _saved_case_worksheet_from_payload(payload: dict[str, object] | None) -> ReviewCaseWorksheet | None:
if not payload:
return None
try:
worksheet = ReviewCaseWorksheet.model_validate(payload)
except Exception:
return None
return worksheet.model_copy(update={"status": "saved_by_learner"})
def _evaluation_payload(record: dict[str, object] | None) -> dict[str, object]:
if not record:
return {}
@ -1105,7 +1121,7 @@ def _session_detail(
SessionDetailTurn(
turn_seq=turn.turn_seq,
speaker="learner" if turn.speaker == "counselor" else "client",
stage=turn.stage,
stage=_stage_label(turn.stage),
text=turn.text_masked,
created_at=_iso(turn.created_at) or "",
)
@ -1249,7 +1265,7 @@ async def get_session_review(
client_initial = client_name[:1] or ""
reached_phase = _stage_label(sess.state.stage)
stage_labels = [turn.stage for turn in visible_turns] or [reached_phase]
stage_labels = [_stage_label(turn.stage) for turn in visible_turns] or [reached_phase]
axis = ["0:00"]
if duration_seconds > 0:
axis.append(_offset_label(duration_seconds))
@ -1335,6 +1351,13 @@ async def get_session_review(
if evaluation_record and not hidden_turns and not evaluation_durable:
summary += " 현재 평가는 런타임 캐시에서 복원되었습니다."
generated_worksheet = _case_worksheet_from_turns(turns)
saved_worksheet_payload, _ = await session_persistence.load_case_worksheet(
session_id,
principal,
)
case_worksheet = _saved_case_worksheet_from_payload(saved_worksheet_payload) or generated_worksheet
return SessionReviewResponse(
session_id=session_id,
client=ReviewClient(
@ -1359,7 +1382,7 @@ async def get_session_review(
rubric=rubric,
goodMoments=good_moments,
growthPoints=growth_points,
caseWorksheet=_case_worksheet_from_turns(turns),
caseWorksheet=case_worksheet,
nextLine=next_line,
clientFeedback=client_feedback,
audioUrl=None,
@ -1369,6 +1392,39 @@ async def get_session_review(
)
@router.put("/{session_id}/review/worksheet", response_model=ReviewCaseWorksheet)
async def save_session_review_worksheet(
session_id: str,
body: ReviewCaseWorksheetSaveRequest,
principal: CurrentPrincipal,
) -> ReviewCaseWorksheet:
"""Persist the learner's edited case formulation worksheet for this session."""
_ensure_learner(principal)
await _load_session_or_404(
session_id,
principal,
allow_ended=True,
include_turn_evaluation=False,
)
worksheet = ReviewCaseWorksheet(
status="saved_by_learner",
generatedBy="learner-edited worksheet",
sections=body.sections,
limitations=body.limitations,
)
ok = await session_persistence.save_case_worksheet(
session_id=session_id,
learner_id=principal.user_id,
payload=worksheet.model_dump(mode="json"),
)
if not ok:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail="case worksheet persistence unavailable",
)
return worksheet
@router.post("/{session_id}/turn", response_model=TurnResponse)
async def submit_turn(
session_id: str,

View file

@ -18,6 +18,7 @@ from .services.persona import PersonaCard
from .store import DEFAULT_TURN_VISIBLE_TO, InProcSession, TurnRecord
_EVALUATION_CACHE: dict[str, dict[str, Any]] = {}
_CASE_WORKSHEET_CACHE: dict[str, dict[str, Any]] = {}
_SESSION_AUDIT_ROLES = {"teacher", "admin"}
_APPROPRIATENESS_SCORE = {
"warn": 1.0,
@ -741,6 +742,66 @@ async def ensure_review_tables() -> None:
)
"""
)
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS app.case_worksheet (
session_id UUID PRIMARY KEY REFERENCES app.sessions(id) ON DELETE CASCADE,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""
)
await conn.execute(
"""
ALTER TABLE app.case_worksheet ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_case_worksheet_select ON app.case_worksheet;
DROP POLICY IF EXISTS p_case_worksheet_insert ON app.case_worksheet;
DROP POLICY IF EXISTS p_case_worksheet_update ON app.case_worksheet;
DROP POLICY IF EXISTS p_case_worksheet_delete ON app.case_worksheet;
CREATE POLICY p_case_worksheet_select
ON app.case_worksheet FOR SELECT USING (
app.current_role_name() IN ('admin','instructor')
OR EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.case_worksheet.session_id
AND s.learner_id = app.current_uid()
)
);
CREATE POLICY p_case_worksheet_insert
ON app.case_worksheet FOR INSERT WITH CHECK (
EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.case_worksheet.session_id
AND s.learner_id = app.current_uid()
)
);
CREATE POLICY p_case_worksheet_update
ON app.case_worksheet FOR UPDATE USING (
EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.case_worksheet.session_id
AND s.learner_id = app.current_uid()
)
) WITH CHECK (
EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.case_worksheet.session_id
AND s.learner_id = app.current_uid()
)
);
CREATE POLICY p_case_worksheet_delete
ON app.case_worksheet FOR DELETE USING (
EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.case_worksheet.session_id
AND s.learner_id = app.current_uid()
)
)
"""
)
except Exception:
return
@ -836,6 +897,65 @@ async def load_session_evaluation(
return _EVALUATION_CACHE.get(session_id), False
async def save_case_worksheet(
*,
session_id: str,
learner_id: str,
payload: dict[str, Any],
) -> bool:
if runtime_fallback_allowed():
_CASE_WORKSHEET_CACHE[session_id] = payload
try:
get_pool()
async with acquire(role="learner", user_id=learner_id) as conn:
await conn.execute(
"""
INSERT INTO app.case_worksheet (session_id, payload, created_at, updated_at)
VALUES ($1::uuid, $2::jsonb, now(), now())
ON CONFLICT (session_id) DO UPDATE SET
payload = EXCLUDED.payload,
updated_at = now()
""",
session_id,
payload,
)
return True
except Exception:
require_runtime_fallback_allowed("case worksheet save")
return False
async def load_case_worksheet(
session_id: str,
principal: Principal,
) -> tuple[dict[str, Any] | None, bool]:
try:
get_pool()
async with acquire(
role=principal.role.value,
user_id=principal.user_id,
cohort_ids=principal.cohort_ids,
) as conn:
row = await conn.fetchrow(
"""
SELECT payload, updated_at
FROM app.case_worksheet
WHERE session_id = $1::uuid
""",
session_id,
)
if row is None:
return (
_CASE_WORKSHEET_CACHE.get(session_id) if runtime_fallback_allowed() else None
), False
payload = dict(row["payload"] or {})
payload.setdefault("savedAt", _iso_dt(row["updated_at"]))
return payload, True
except Exception:
require_runtime_fallback_allowed("case worksheet load")
return _CASE_WORKSHEET_CACHE.get(session_id), False
def _session_from_rows(row, state_row, turn_rows: Iterable) -> InProcSession | None:
card = _card_from_joined_session_row(row)
if card is None:

View file

@ -677,6 +677,107 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(risk.evidence, [])
self.assertIn("명시 근거", risk.emptyReason or "")
async def test_review_prefers_saved_case_formulation_worksheet(self) -> None:
principal = _principal()
sess = _session(principal)
sess.turns.append(
TurnRecord(
turn_seq=1,
speaker="client",
stage=sess.state.stage.value,
text="자동 초안 대신 저장본을 확인합니다.",
text_masked="자동 초안 대신 저장본을 확인합니다.",
created_at=sess.created_at + 1,
)
)
saved_payload = {
"status": "saved_by_learner",
"generatedBy": "learner-edited worksheet",
"sections": [
{
"key": "exploration_11",
"title": "탐색 11항목",
"items": [
{
"key": "presenting_complaint",
"label": "주호소",
"value": "학습자가 저장한 주호소",
"evidence": [],
"confidence": "medium",
"emptyReason": None,
}
],
}
],
"limitations": ["학습자 저장본"],
"savedAt": "2026-06-27T10:00:00+00:00",
}
with patch.object(
sessions.session_persistence,
"load_case_worksheet",
AsyncMock(return_value=(saved_payload, True)),
):
response = await sessions.get_session_review(sess.session_id, principal)
worksheet = response.caseWorksheet
self.assertEqual(worksheet.status, "saved_by_learner")
self.assertEqual(worksheet.generatedBy, "learner-edited worksheet")
self.assertEqual(worksheet.sections[0].items[0].value, "학습자가 저장한 주호소")
self.assertEqual(worksheet.limitations, ["학습자 저장본"])
self.assertEqual(worksheet.savedAt, "2026-06-27T10:00:00+00:00")
async def test_learner_can_save_case_formulation_worksheet(self) -> None:
principal = _principal()
sess = _session(principal)
request = sessions.ReviewCaseWorksheetSaveRequest(
sections=[
sessions.ReviewWorksheetSection(
key="exploration_11",
title="탐색 11항목",
items=[
sessions.ReviewWorksheetItem(
key="presenting_complaint",
label="주호소",
value="수정한 주호소",
confidence="medium",
)
],
)
],
limitations=["임상 루브릭 전"],
)
with patch.object(
sessions,
"_load_session_or_404",
AsyncMock(return_value=sess),
) as load_session, patch.object(
sessions.session_persistence,
"save_case_worksheet",
AsyncMock(return_value=True),
) as save_worksheet:
response = await sessions.save_session_review_worksheet(
sess.session_id,
request,
principal,
)
load_session.assert_awaited_once_with(
sess.session_id,
principal,
allow_ended=True,
include_turn_evaluation=False,
)
save_worksheet.assert_awaited_once()
save_kwargs = save_worksheet.await_args.kwargs
self.assertEqual(save_kwargs["session_id"], sess.session_id)
self.assertEqual(save_kwargs["learner_id"], principal.user_id)
self.assertEqual(save_kwargs["payload"]["status"], "saved_by_learner")
self.assertEqual(save_kwargs["payload"]["sections"][0]["items"][0]["value"], "수정한 주호소")
self.assertEqual(response.status, "saved_by_learner")
self.assertEqual(response.sections[0].items[0].value, "수정한 주호소")
if __name__ == "__main__":
unittest.main()

View file

@ -21,13 +21,18 @@ _STAGE_LABELS = {
"EXPLORE": "탐색",
"INTERVENE": "개입",
"CLOSE": "정리",
"rapport": "라포",
"explore": "탐색",
"intervene": "개입",
"close": "정리",
}
def stage_label(stage: object) -> str:
"""Stage enum과 문자열 값을 같은 한글 라벨로 정규화한다."""
name = getattr(stage, "name", "")
return _STAGE_LABELS.get(name, str(getattr(stage, "value", stage)))
raw = str(getattr(stage, "value", stage))
return _STAGE_LABELS.get(name) or _STAGE_LABELS.get(raw) or raw
class SessionAccessError(str, Enum):