diff --git a/apps/api/app/routes/sessions.py b/apps/api/app/routes/sessions.py index 126aed9..b32ebd9 100644 --- a/apps/api/app/routes/sessions.py +++ b/apps/api/app/routes/sessions.py @@ -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, diff --git a/apps/api/app/session_persistence.py b/apps/api/app/session_persistence.py index 288ae63..8781812 100644 --- a/apps/api/app/session_persistence.py +++ b/apps/api/app/session_persistence.py @@ -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: diff --git a/apps/api/app/test_session_turn_persistence.py b/apps/api/app/test_session_turn_persistence.py index 73293dc..ef315fa 100644 --- a/apps/api/app/test_session_turn_persistence.py +++ b/apps/api/app/test_session_turn_persistence.py @@ -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() diff --git a/apps/api/app/turn_runtime.py b/apps/api/app/turn_runtime.py index af479e0..1b62ba1 100644 --- a/apps/api/app/turn_runtime.py +++ b/apps/api/app/turn_runtime.py @@ -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): diff --git a/apps/web/src/lib/api.gen.ts b/apps/web/src/lib/api.gen.ts index b1e1b1d..3f0ede9 100644 --- a/apps/web/src/lib/api.gen.ts +++ b/apps/web/src/lib/api.gen.ts @@ -698,6 +698,26 @@ export interface paths { patch?: never; trace?: never; }; + "/sessions/{session_id}/review/worksheet": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Save Session Review Worksheet + * @description Persist the learner's edited case formulation worksheet for this session. + */ + put: operations["save_session_review_worksheet_sessions__session_id__review_worksheet_put"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/sessions/{session_id}/stream": { parameters: { query?: never; @@ -1594,14 +1614,23 @@ export interface components { generatedBy: string; /** Limitations */ limitations?: string[]; + /** Savedat */ + savedAt?: string | null; /** Sections */ - sections?: components["schemas"]["ReviewWorksheetSection"][]; + sections?: components["schemas"]["ReviewWorksheetSection-Output"][]; /** * Status * @default empty * @enum {string} */ - status: "empty" | "draft_from_transcript"; + status: "empty" | "draft_from_transcript" | "saved_by_learner"; + }; + /** ReviewCaseWorksheetSaveRequest */ + ReviewCaseWorksheetSaveRequest: { + /** Limitations */ + limitations?: string[]; + /** Sections */ + sections?: components["schemas"]["ReviewWorksheetSection-Input"][]; }; /** ReviewClient */ ReviewClient: { @@ -1738,7 +1767,16 @@ export interface components { value?: string | null; }; /** ReviewWorksheetSection */ - ReviewWorksheetSection: { + "ReviewWorksheetSection-Input": { + /** Items */ + items?: components["schemas"]["ReviewWorksheetItem"][]; + /** Key */ + key: string; + /** Title */ + title: string; + }; + /** ReviewWorksheetSection */ + "ReviewWorksheetSection-Output": { /** Items */ items?: components["schemas"]["ReviewWorksheetItem"][]; /** Key */ @@ -3546,6 +3584,44 @@ export interface operations { }; }; }; + save_session_review_worksheet_sessions__session_id__review_worksheet_put: { + parameters: { + query?: never; + header?: never; + path: { + session_id: string; + }; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ReviewCaseWorksheetSaveRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ReviewCaseWorksheet"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; stream_turn_sessions__session_id__stream_post: { parameters: { query?: never; diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index ede3a0b..b94d20b 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -209,8 +209,9 @@ export type ReviewPoint = ApiSchema<"ReviewPoint">; export type ReviewWorksheetEvidence = ApiSchema<"ReviewWorksheetEvidence">; export type ReviewWorksheetItem = ApiSchema<"ReviewWorksheetItem">; -export type ReviewWorksheetSection = ApiSchema<"ReviewWorksheetSection">; +export type ReviewWorksheetSection = ApiSchema<"ReviewWorksheetSection-Output">; export type ReviewCaseWorksheet = ApiSchema<"ReviewCaseWorksheet">; +export type ReviewCaseWorksheetSaveRequest = ApiSchema<"ReviewCaseWorksheetSaveRequest">; export type SessionReviewResponse = ApiSchema<"SessionReviewResponse">; /* ===================================================================== @@ -411,6 +412,11 @@ export const sessionApi = { api.post(`/sessions/${encodeURIComponent(sessionId)}/end`), review: (sessionId: string) => api.get(`/sessions/${encodeURIComponent(sessionId)}/review`), + saveWorksheet: (sessionId: string, payload: ReviewCaseWorksheetSaveRequest) => + api.put( + `/sessions/${encodeURIComponent(sessionId)}/review/worksheet`, + payload, + ), stream: openSessionStream, }; diff --git a/apps/web/src/pages/SessionReview.tsx b/apps/web/src/pages/SessionReview.tsx index 145bd0e..a451f6f 100644 --- a/apps/web/src/pages/SessionReview.tsx +++ b/apps/web/src/pages/SessionReview.tsx @@ -18,6 +18,7 @@ import { type ReviewPoint, type ReviewTechnique, type ReviewTurn, + type ReviewWorksheetSection, type SessionReviewResponse, } from "../lib/api"; import { ValenceChart } from "./session-review/ValenceChart"; @@ -130,21 +131,102 @@ function confidenceLabel(confidence: "none" | "low" | "medium") { return "빈칸"; } +function cloneWorksheetSections(sections: ReviewWorksheetSection[]): ReviewWorksheetSection[] { + return sections.map((section) => ({ + ...section, + items: (section.items ?? []).map((item) => ({ + ...item, + evidence: [...(item.evidence ?? [])], + })), + })); +} + function CaseWorksheetCard({ + sessionId, worksheet, onJump, + onSaved, }: { + sessionId: string; worksheet: ReviewCaseWorksheet | null | undefined; onJump: (id: string) => void; + onSaved: (worksheet: ReviewCaseWorksheet) => void; }) { const sections = worksheet?.sections ?? []; const limitations = worksheet?.limitations ?? []; + const [draftSections, setDraftSections] = useState(() => + cloneWorksheetSections(sections), + ); + const [dirty, setDirty] = useState(false); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + const isSaved = worksheet?.status === "saved_by_learner"; + + useEffect(() => { + setDraftSections(cloneWorksheetSections(sections)); + setDirty(false); + setSaveError(null); + }, [worksheet]); + + const updateValue = (sectionKey: string, itemKey: string, value: string) => { + setDraftSections((current) => + current.map((section) => + section.key !== sectionKey + ? section + : { + ...section, + items: (section.items ?? []).map((item) => + item.key !== itemKey + ? item + : { + ...item, + value, + confidence: value.trim() ? item.confidence === "none" ? "low" : item.confidence : "none", + emptyReason: value.trim() ? null : item.emptyReason, + }, + ), + }, + ), + ); + setDirty(true); + }; + + const saveWorksheet = async () => { + setSaving(true); + setSaveError(null); + try { + const saved = await sessionApi.saveWorksheet(sessionId, { + sections: draftSections, + limitations, + }); + onSaved(saved); + setDirty(false); + } catch { + setSaveError("워크시트를 저장하지 못했습니다."); + } finally { + setSaving(false); + } + }; + return ( - 사례개념화 워크시트 +
+
+ 사례개념화 워크시트 + {isSaved ? "저장된 학습자 제출본" : "축어록 기반 자동 초안"} +
+ +
- {sections.length > 0 ? ( - sections.map((section) => ( + {draftSections.length > 0 ? ( + draftSections.map((section) => (

{section.title}

@@ -158,7 +240,15 @@ function CaseWorksheetCard({ {confidenceLabel(item.confidence)}
-

{item.value || item.emptyReason || "근거 대기"}

+