From 30441bd93bc1b76017771b0bfe8388d32a5ca90a Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Sun, 28 Jun 2026 23:53:37 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9B=8C=ED=81=AC=EC=8B=9C=ED=8A=B8=20?= =?UTF-8?q?=EA=B2=80=EC=88=98=20UI=EC=99=80=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EB=8B=A4=ED=81=AC=ED=86=A4=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/app/routes/teacher.py | 28 ++- apps/api/app/test_teacher_dashboard.py | 11 ++ apps/web/src/lib/api.gen.ts | 111 +++++++---- apps/web/src/pages/Session.tsx | 60 ++++-- apps/web/src/pages/SessionReview.tsx | 123 +++++++++++- .../pages/session-review/session-review.css | 11 ++ apps/web/src/pages/session/session.css | 181 ++++++++++++------ 7 files changed, 404 insertions(+), 121 deletions(-) diff --git a/apps/api/app/routes/teacher.py b/apps/api/app/routes/teacher.py index fa9a198..4d7547c 100644 --- a/apps/api/app/routes/teacher.py +++ b/apps/api/app/routes/teacher.py @@ -10,6 +10,7 @@ from pydantic import BaseModel, Field from .. import session_persistence from ..deps import Principal, Role, require_role from ..runtime_policy import require_runtime_fallback_allowed +from ..session_read_model import StageLabel, stage_label from ..services import session_metrics from ..store import InProcSession, store @@ -26,7 +27,7 @@ class TeacherSessionSummary(BaseModel): persona_name: str session_no: int status: str - stage: str + stage: StageLabel turn_count: int learner_turn_count: int client_turn_count: int @@ -40,6 +41,8 @@ class TeacherSessionSummary(BaseModel): class TeacherSessionReviewStatusRequest(BaseModel): status: Literal["viewed", "closed"] = "closed" note: str = Field(default="", max_length=2000) + worksheet_status: Literal["approved", "changes_requested", "rejected", "pending"] | None = None + worksheet_note: str | None = Field(default=None, max_length=2000) class TeacherSessionReviewStatusResponse(BaseModel): @@ -49,13 +52,16 @@ class TeacherSessionReviewStatusResponse(BaseModel): reviewer_id: str | None = None reviewed_at: str | None = None updated_at: str | None = None + worksheet_status: Literal["pending", "approved", "changes_requested", "rejected"] = "pending" + worksheet_note: str = "" + worksheet_reviewed_at: str | None = None class TeacherGrowthPoint(BaseModel): session_id: str session_no: int persona_code: str - stage: str + stage: StageLabel started_at: str ended_at: str | None = None score: float | None = None @@ -118,7 +124,7 @@ def _growth_point(point: session_metrics.SessionGrowthPoint) -> TeacherGrowthPoi session_id=point.session_id, session_no=point.session_no, persona_code=point.persona_code, - stage=point.stage, + stage=stage_label(point.stage), started_at=point.started_at, ended_at=point.ended_at, score=point.score, @@ -161,6 +167,15 @@ def _review_status_value(record: dict[str, object] | None) -> Literal["pending", return "pending" +def _worksheet_review_status_value( + record: dict[str, object] | None, +) -> Literal["pending", "approved", "changes_requested", "rejected"]: + value = str((record or {}).get("worksheet_status") or "pending") + if value in {"approved", "changes_requested", "rejected"}: + return value # type: ignore[return-value] + return "pending" + + def _summary( sess: InProcSession, review_status: dict[str, object] | None = None, @@ -175,7 +190,7 @@ def _summary( persona_name=sess.persona.display_name, session_no=sess.session_no, status="ended" if sess.ended else "active", - stage=sess.state.stage.value, + stage=stage_label(sess.state.stage), turn_count=len(sess.turns), learner_turn_count=learner_turns, client_turn_count=client_turns, @@ -286,6 +301,8 @@ async def update_session_review_status( status=request.status, note=request.note, principal=principal, + worksheet_status=request.worksheet_status, + worksheet_note=request.worksheet_note, ) if saved is None: raise HTTPException( @@ -299,4 +316,7 @@ async def update_session_review_status( reviewer_id=str(saved.get("reviewer_id") or "") or None, reviewed_at=str(saved.get("reviewed_at") or "") or None, updated_at=str(saved.get("updated_at") or "") or None, + worksheet_status=_worksheet_review_status_value(saved), + worksheet_note=str(saved.get("worksheet_note") or ""), + worksheet_reviewed_at=str(saved.get("worksheet_reviewed_at") or "") or None, ) diff --git a/apps/api/app/test_teacher_dashboard.py b/apps/api/app/test_teacher_dashboard.py index c980aac..080709f 100644 --- a/apps/api/app/test_teacher_dashboard.py +++ b/apps/api/app/test_teacher_dashboard.py @@ -212,6 +212,9 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase): "reviewer_id": principal.user_id, "status": "closed", "note": "다음 회기에서 반영 질문을 늘리도록 지도", + "worksheet_status": "changes_requested", + "worksheet_note": "보호요인 근거를 한 줄 더 보강", + "worksheet_reviewed_at": "2026-06-27T10:05:00Z", "reviewed_at": "2026-06-27T10:00:00Z", "updated_at": "2026-06-27T10:00:00Z", }, @@ -225,13 +228,21 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase): teacher.TeacherSessionReviewStatusRequest( status="closed", note="다음 회기에서 반영 질문을 늘리도록 지도", + worksheet_status="changes_requested", + worksheet_note="보호요인 근거를 한 줄 더 보강", ), principal, ) save_status.assert_awaited_once() + save_kwargs = save_status.await_args.kwargs + self.assertEqual(save_kwargs["worksheet_status"], "changes_requested") + self.assertIn("보호요인", save_kwargs["worksheet_note"]) self.assertEqual(response.status, "closed") self.assertIn("반영 질문", response.note) + self.assertEqual(response.worksheet_status, "changes_requested") + self.assertIn("보호요인", response.worksheet_note) + self.assertEqual(response.worksheet_reviewed_at, "2026-06-27T10:05:00Z") if __name__ == "__main__": diff --git a/apps/web/src/lib/api.gen.ts b/apps/web/src/lib/api.gen.ts index 1efbd1a..8b9e4d5 100644 --- a/apps/web/src/lib/api.gen.ts +++ b/apps/web/src/lib/api.gen.ts @@ -1851,7 +1851,7 @@ export interface components { /** Session Id */ session_id: string; /** Stage */ - stage: string; + stage?: ("라포" | "탐색" | "개입" | "정리") | null; }; /** HTTPValidationError */ HTTPValidationError: { @@ -2287,7 +2287,7 @@ export interface components { /** Session Id */ session_id: string; /** Stage */ - stage: string; + stage?: ("라포" | "탐색" | "개입" | "정리") | null; suggestion: components["schemas"]["LiveCoachSuggestion"]; /** Turn Seq */ turn_seq: number; @@ -2906,7 +2906,7 @@ export interface components { */ privacy: string; /** Reachedphase */ - reachedPhase: string; + reachedPhase?: ("라포" | "탐색" | "개입" | "정리") | null; /** * Reviewready * @default false @@ -2995,10 +2995,16 @@ export interface components { }; /** ReviewPhaseSegment */ ReviewPhaseSegment: { - /** Key */ - key: string; - /** Label */ - label: string; + /** + * Key + * @enum {string} + */ + key: "rapport" | "explore" | "intervene" | "closing"; + /** + * Label + * @enum {string} + */ + label: "라포" | "탐색" | "개입" | "정리"; /** Weight */ weight: number; }; @@ -3196,13 +3202,8 @@ export interface components { /** Session No */ session_no: number; }; - /** - * SessionEvaluation - * @description deep-loop 회기말/단계전환 정밀 평가 결과. - * - * 기법분포 + 잘한 순간 + 개선점(최대3) + 슈퍼바이저 rationale/critique + 의도이탈 집계. - */ - SessionEvaluation: { + /** SessionEvaluationResponse */ + SessionEvaluationResponse: { /** Alternative Utterances */ alternative_utterances?: string[]; distribution?: components["schemas"]["TechniqueDistribution"]; @@ -3224,8 +3225,11 @@ export interface components { scope: string; /** Session Id */ session_id: string; - /** Stage */ - stage: string; + /** + * Stage + * @enum {string} + */ + stage: "라포" | "탐색" | "개입" | "정리"; /** Strengths */ strengths?: string[]; /** Supervisor Critique */ @@ -3275,8 +3279,11 @@ export interface components { phaseAxis?: string[]; /** Phases */ phases?: components["schemas"]["ReviewPhaseSegment"][]; - /** Reachedphase */ - reachedPhase: string; + /** + * Reachedphase + * @enum {string} + */ + reachedPhase: "라포" | "탐색" | "개입" | "정리"; /** * Reviewready * @default false @@ -3374,6 +3381,19 @@ export interface components { status: "pending" | "viewed" | "closed"; /** Updatedat */ updatedAt?: string | null; + /** + * Worksheetnote + * @default + */ + worksheetNote: string; + /** Worksheetreviewedat */ + worksheetReviewedAt?: string | null; + /** + * Worksheetstatus + * @default pending + * @enum {string} + */ + worksheetStatus: "pending" | "approved" | "changes_requested" | "rejected"; }; /** TeacherDashboardResponse */ TeacherDashboardResponse: { @@ -3418,8 +3438,11 @@ export interface components { session_id: string; /** Session No */ session_no: number; - /** Stage */ - stage: string; + /** + * Stage + * @enum {string} + */ + stage: "라포" | "탐색" | "개입" | "정리"; /** Started At */ started_at: string; /** @@ -3511,6 +3534,10 @@ export interface components { * @enum {string} */ status: "viewed" | "closed"; + /** Worksheet Note */ + worksheet_note?: string | null; + /** Worksheet Status */ + worksheet_status?: ("approved" | "changes_requested" | "rejected" | "pending") | null; }; /** TeacherSessionReviewStatusResponse */ TeacherSessionReviewStatusResponse: { @@ -3533,6 +3560,19 @@ export interface components { status: "pending" | "viewed" | "closed"; /** Updated At */ updated_at?: string | null; + /** + * Worksheet Note + * @default + */ + worksheet_note: string; + /** Worksheet Reviewed At */ + worksheet_reviewed_at?: string | null; + /** + * Worksheet Status + * @default pending + * @enum {string} + */ + worksheet_status: "pending" | "approved" | "changes_requested" | "rejected"; }; /** TeacherSessionSummary */ TeacherSessionSummary: { @@ -3564,8 +3604,11 @@ export interface components { session_id: string; /** Session No */ session_no: number; - /** Stage */ - stage: string; + /** + * Stage + * @enum {string} + */ + stage: "라포" | "탐색" | "개입" | "정리"; /** Started At */ started_at: string; /** Status */ @@ -3610,17 +3653,8 @@ export interface components { /** Rationale */ rationale?: string | null; }; - /** - * TurnEvaluation - * @description fast-loop 턴 평가 결과(턴 직후 경량 4차원). - * - * 4차원: - * ① technique[] : 학습자(상담자) 발화에 부착된 기법 라벨(복수) - * ② client_state_read[] : 내담자 응답에서 읽은 상태(복수) - * ③ appropriateness : 적절성 신호 pos|warn|neutral (경량) - * ④ intent_deviation : '의도와 다른 부분' 있으면 구조화(없으면 None) - */ - TurnEvaluation: { + /** TurnEvaluationResponse */ + TurnEvaluationResponse: { /** * Appropriateness * @default neutral @@ -3640,8 +3674,11 @@ export interface components { loop: string; /** Rapport Signal */ rapport_signal?: number | null; - /** Stage */ - stage: string; + /** + * Stage + * @enum {string} + */ + stage: "라포" | "탐색" | "개입" | "정리"; /** Techniques */ techniques?: components["schemas"]["TechniqueTag"][]; /** Theory Mode */ @@ -4791,7 +4828,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SessionEvaluation"]; + "application/json": components["schemas"]["SessionEvaluationResponse"]; }; }; /** @description Validation Error */ @@ -4829,7 +4866,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["TurnEvaluation"]; + "application/json": components["schemas"]["TurnEvaluationResponse"]; }; }; /** @description Validation Error */ diff --git a/apps/web/src/pages/Session.tsx b/apps/web/src/pages/Session.tsx index 50a7343..162d807 100644 --- a/apps/web/src/pages/Session.tsx +++ b/apps/web/src/pages/Session.tsx @@ -1611,6 +1611,33 @@ export default function Session() { sending; const textTurnBlocked = sending || voiceStatus === "thinking"; const elapsedLabel = formatElapsed(elapsed); + const recommendedSessionSeconds = 30 * 60; + const remainingLabel = formatElapsed(Math.max(0, recommendedSessionSeconds - elapsed)); + const turnCount = utterances.filter((utterance) => !utterance.partial).length; + const latestClientUtterance = [...utterances] + .reverse() + .find((utterance) => utterance.speaker === "client" && utterance.text.trim()); + const stageClientLine = + latestClientUtterance?.text ?? + primaryContext?.v ?? + `${clientName}님이 당신의 첫 질문을 기다리고 있습니다.`; + const voiceInputStatus = + voiceAvailable === false + ? "음성 미설정" + : micOn + ? "수신 중" + : voiceStatus === "requesting" + ? "권한 요청" + : "대기"; + const responseStatus = + sending || clientReplyPending + ? "응답 대기" + : voiceStatus === "thinking" + ? "전사 중" + : voiceStatus === "speaking" + ? "재생 중" + : "안정"; + const safetyStatusText = safety ? "확인 필요" : "안전"; const personaIsUsable = personaSummary ? isUsablePersona(personaSummary) : false; const consentRequired = user?.role === "learner" && user.consentAt == null; const canStartSession = personaLoadState === "ready" && personaIsUsable && !consentRequired; @@ -2370,23 +2397,20 @@ export default function Session() { - {/* 안전 점검(있을 때만, warn 톤 — 빨강 아님) */} - {safety ? ( -
- - - - - 안전 점검 · {safety} - {crisisResource ? ( - - {crisisResource.title} - {crisisResource.number} - - ) : null} - -
- ) : null} +
+ + + + + 안전 점검 · {safety ?? "현재 감지된 위기 신호 없음"} + {crisisResource ? ( + + {crisisResource.title} + {crisisResource.number} + + ) : null} + +
@@ -2530,7 +2554,7 @@ export default function Session() { } >
- {event.turn_seq}번 턴 · {event.stage} + {event.turn_seq}번 턴 · {event.stage ?? "단계 미상"} diff --git a/apps/web/src/pages/SessionReview.tsx b/apps/web/src/pages/SessionReview.tsx index 7ebbb22..37026ce 100644 --- a/apps/web/src/pages/SessionReview.tsx +++ b/apps/web/src/pages/SessionReview.tsx @@ -31,6 +31,8 @@ import "./session-review/session-review.css"; type LoadState = "loading" | "ready" | "error"; type PrepostMeasureName = UserPrepostMeasureItem["measure_name"]; type PrepostTimepoint = UserPrepostMeasureItem["timepoint"]; +type WorksheetReviewStatus = "pending" | "approved" | "changes_requested" | "rejected"; +type WorksheetDecisionStatus = Exclude; const PREPOST_PILOT_ID = "phase3-pilot-draft"; const PREPOST_INSTRUMENT_VERSION = "pilot-prepost-scaffold-2026-06-28"; @@ -52,6 +54,13 @@ function prepostDraftKey(measure: PrepostMeasureName, timepoint: PrepostTimepoin return `${measure}:${timepoint}`; } +function worksheetReviewStatusLabel(status: WorksheetReviewStatus) { + if (status === "approved") return "승인"; + if (status === "changes_requested") return "수정요청"; + if (status === "rejected") return "반려"; + return "검수 대기"; +} + function splitPrepostDraftKey(key: string): [PrepostMeasureName, PrepostTimepoint] | null { const [measure, timepoint] = key.split(":"); if ( @@ -667,7 +676,10 @@ export default function SessionReview() { const [shareUrl, setShareUrl] = useState(null); const [shareError, setShareError] = useState(null); const [teacherNote, setTeacherNote] = useState(""); + const [teacherWorksheetNote, setTeacherWorksheetNote] = useState(""); const [teacherReviewSaving, setTeacherReviewSaving] = useState<"viewed" | "closed" | null>(null); + const [teacherWorksheetSaving, setTeacherWorksheetSaving] = + useState(null); const [teacherReviewError, setTeacherReviewError] = useState(null); const turnRefs = useRef>({}); @@ -696,6 +708,7 @@ export default function SessionReview() { if (!alive) return; setData(next); setTeacherNote(next.teacherReview?.note ?? ""); + setTeacherWorksheetNote(next.teacherReview?.worksheetNote ?? ""); setTeacherReviewError(null); setLoadState("ready"); } catch (err) { @@ -762,6 +775,7 @@ export default function SessionReview() { const saved = await teacherApi.updateSessionReviewStatus(sessionId, { status, note: teacherNote, + worksheet_note: teacherWorksheetNote, }); setData((current) => current @@ -773,11 +787,15 @@ export default function SessionReview() { reviewerId: saved.reviewer_id ?? null, reviewedAt: saved.reviewed_at ?? null, updatedAt: saved.updated_at ?? null, + worksheetStatus: saved.worksheet_status ?? "pending", + worksheetNote: saved.worksheet_note ?? "", + worksheetReviewedAt: saved.worksheet_reviewed_at ?? null, }, } : current, ); setTeacherNote(saved.note); + setTeacherWorksheetNote(saved.worksheet_note ?? ""); } catch (err) { setTeacherReviewError( err instanceof Error ? err.message : "교수자 검토 상태를 저장하지 못했습니다.", @@ -787,6 +805,45 @@ export default function SessionReview() { } } + async function saveWorksheetReviewStatus(status: WorksheetDecisionStatus) { + if (!sessionId || !data) return; + setTeacherWorksheetSaving(status); + setTeacherReviewError(null); + try { + const saved = await teacherApi.updateSessionReviewStatus(sessionId, { + status: teacherReviewStatus === "closed" ? "closed" : "viewed", + note: teacherNote, + worksheet_status: status, + worksheet_note: teacherWorksheetNote, + }); + setData((current) => + current + ? { + ...current, + teacherReview: { + status: saved.status, + note: saved.note, + reviewerId: saved.reviewer_id ?? null, + reviewedAt: saved.reviewed_at ?? null, + updatedAt: saved.updated_at ?? null, + worksheetStatus: saved.worksheet_status ?? "pending", + worksheetNote: saved.worksheet_note ?? "", + worksheetReviewedAt: saved.worksheet_reviewed_at ?? null, + }, + } + : current, + ); + setTeacherNote(saved.note); + setTeacherWorksheetNote(saved.worksheet_note ?? ""); + } catch (err) { + setTeacherReviewError( + err instanceof Error ? err.message : "워크시트 검수 상태를 저장하지 못했습니다.", + ); + } finally { + setTeacherWorksheetSaving(null); + } + } + if (loadState === "loading") { return ( @@ -838,7 +895,11 @@ export default function SessionReview() { : teacherReviewStatus === "viewed" ? "검토 중" : "검토 대기"; + const worksheetReviewStatus = (teacherReview?.worksheetStatus ?? "pending") as WorksheetReviewStatus; + const worksheetReviewLabel = worksheetReviewStatusLabel(worksheetReviewStatus); + const hasLearnerWorksheet = data.caseWorksheet?.status === "saved_by_learner"; const canCloseTeacherReview = isSupervisorView && data.sessionSignal === "종료됨"; + const canDecideWorksheet = canCloseTeacherReview && hasLearnerWorksheet; const reviewReadiness = hasTranscript ? `${turns.length}개 발화 기반` : "축어록 저장 후 생성"; @@ -1147,7 +1208,7 @@ export default function SessionReview() {
+
+
+
+ 워크시트 검수 + {worksheetReviewLabel} +
+ + {worksheetReviewLabel} + +
+