"""Browser-facing session read models and deterministic builders. Routes keep ownership of auth, RLS-backed DB reads, and persistence. This module owns the response DTOs plus pure projection logic so a future Node.js read API has one contract surface to mirror. """ from __future__ import annotations import re from collections import Counter from dataclasses import dataclass from datetime import datetime from typing import Any, Literal, Optional, cast from pydantic import BaseModel, Field from .config import settings from .services import guardrail, session_metrics from .stage_contract import ( ReviewPhaseKey, StageLabel, review_phase_key, stage_label as _normalize_stage_label, stage_label_or_none, ) from .store import InProcSession, TurnRecord WorksheetSpeaker = Literal["learner", "client"] WorksheetItemSpec = tuple[str, str, list[str], WorksheetSpeaker | None] WorksheetSectionSpec = tuple[str, str, list[WorksheetItemSpec]] LEARNER_VISIBLE_AI_ROLE = "counselor" MISSING_SESSION_EVALUATION_GRACE_SECONDS = 30.0 MISSING_SESSION_EVALUATION_ERROR = ( "회기말 평가가 제한 시간 이후에도 저장되지 않았습니다. AI 평가 재시도가 필요합니다." ) class LearnerSessionSummary(BaseModel): session_id: str persona_code: str persona_name: str session_no: int status: Literal["active", "ended"] stage: StageLabel turn_count: int learner_turn_count: int client_turn_count: int started_at: str ended_at: str | None = None review_ready: bool = False archived: bool = False archived_at: str | None = None class LearnerSessionsResponse(BaseModel): source: str = "runtime" sessions: list[LearnerSessionSummary] = Field(default_factory=list) class LearnerDashboardOverview(BaseModel): total_sessions: int = 0 completed_sessions: int = 0 active_sessions: int = 0 review_ready_sessions: int = 0 archived_sessions: int = 0 learner_turns: int = 0 client_turns: int = 0 last_practiced_at: str | None = None class LearnerDashboardGrowthPoint(BaseModel): session_id: str session_no: int persona_code: str stage: StageLabel started_at: str ended_at: str | None = None score: float | None = None rapport: float | None = None technique_count: int = 0 watch_count: int = 0 class LearnerDashboardGrowth(BaseModel): first_score: float | None = None latest_score: float | None = None score_delta: float | None = None avg_score: float | None = None avg_rapport: float | None = None trend: str = "insufficient" evaluated_sessions: int = 0 top_techniques: list[str] = Field(default_factory=list) points: list[LearnerDashboardGrowthPoint] = Field(default_factory=list) class LearnerDashboardPersonaProgress(BaseModel): persona_code: str persona_name: str sessions: int = 0 completed_sessions: int = 0 active_sessions: int = 0 review_ready_sessions: int = 0 latest_at: str | None = None latest_stage: StageLabel | None = None latest_score: float | None = None trend: str = "insufficient" class LearnerDashboardAchievement(BaseModel): id: str label: str state: Literal["done", "available", "locked"] = "locked" detail: str class LearnerDashboardFeedbackItem(BaseModel): session_id: str persona_code: str persona_name: str session_no: int stage: StageLabel turn_seq: int created_at: str score: float | None = None rapport: float | None = None note: str techniques: list[str] = Field(default_factory=list) class LearnerDashboardResponse(BaseModel): source: str = "runtime" overview: LearnerDashboardOverview growth: LearnerDashboardGrowth persona_progress: list[LearnerDashboardPersonaProgress] = Field(default_factory=list) achievements: list[LearnerDashboardAchievement] = Field(default_factory=list) recent_feedback: list[LearnerDashboardFeedbackItem] = Field(default_factory=list) message: str class SessionArchiveResponse(BaseModel): session_id: str archived: bool archived_at: str | None = None source: str = "runtime" session: LearnerSessionSummary class SessionDetailTurn(BaseModel): turn_seq: int speaker: Literal["learner", "client"] stage: StageLabel text: str created_at: str class SessionDetailResponse(BaseModel): session_id: str case_id: str persona_code: str persona_name: str theory_mode: str status: Literal["active", "ended"] stage: StageLabel effective_openness: float started_at: str ended_at: str | None = None turns: list[SessionDetailTurn] = Field(default_factory=list) review_ready: bool = False class ReviewClient(BaseModel): name: str initial: str persona: str class ReviewTechnique(BaseModel): kind: str label: str class ReviewNonverbalEvent(BaseModel): kind: Literal["audio", "silence", "pace", "barge_in", "paralinguistic", "prosody", "audio_quality"] label: str detail: str class ReviewNote(BaseModel): author: str tone: str title: str body: str quote: Optional[str] = None class ReviewTurn(BaseModel): id: str ts: str speaker: Literal["learner", "client"] who: str text: str techniques: list[ReviewTechnique] = Field(default_factory=list) nonverbal: list[ReviewNonverbalEvent] = Field(default_factory=list) note: Optional[ReviewNote] = None class ReviewPhaseSegment(BaseModel): key: ReviewPhaseKey label: StageLabel weight: float class ReviewValencePoint(BaseModel): t: float v: float class ReviewRubricRow(BaseModel): name: str cluster: str ratio: float quality: Literal["good", "watch"] freq: str class ReviewPoint(BaseModel): title: str body: str jumpTo: Optional[str] = None class ReviewWorksheetEvidence(BaseModel): turnId: str speaker: Literal["learner", "client"] quote: str class ReviewWorksheetItem(BaseModel): key: str label: str value: Optional[str] = None evidence: list[ReviewWorksheetEvidence] = Field(default_factory=list) confidence: Literal["none", "low", "medium"] = "none" emptyReason: Optional[str] = None class ReviewWorksheetSection(BaseModel): key: str title: str items: list[ReviewWorksheetItem] = Field(default_factory=list) class ReviewCaseWorksheet(BaseModel): 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) CASE_WORKSHEET_SECTION_SPECS: list[WorksheetSectionSpec] = [ ( "exploration_11", "탐색 11항목", [ ("presenting_complaint", "주호소", ["힘들", "문제", "걱정", "불안", "우울", "스트레스", "관계"], "client"), ("trigger_context", "계기·상황", ["언제", "상황", "최근", "계기", "때"], "client"), ("emotion", "정서", ["불안", "우울", "화", "슬프", "답답", "무섭", "외롭", "걱정"], "client"), ("cognition", "생각", ["생각", "느낌", "해야", "못", "실패", "의미"], "client"), ("behavior", "행동", ["피하", "잠", "먹", "울", "말", "연락", "공부", "멈"], "client"), ("body", "신체·수면", ["잠", "식욕", "몸", "두통", "심장", "숨", "피곤"], "client"), ("relationship", "관계", ["친구", "가족", "부모", "엄마", "아빠", "교수", "사람", "관계"], "client"), ("resources", "자원", ["도움", "지지", "친구", "상담", "선생님", "가족"], "client"), ("risk", "위험 신호", ["죽", "자살", "해치", "사라지고", "끝내", "위험"], "client"), ("motivation", "변화동기", ["원", "바라", "변화", "해보고", "싶"], None), ("first_goal", "상담 목표 초안", ["목표", "계획", "다음", "해볼", "원하"], "learner"), ], ), ( "five_domains", "호소 5영역", [ ("domain_emotion", "정서", ["불안", "우울", "화", "슬프", "답답", "외롭"], "client"), ("domain_cognition", "인지", ["생각", "걱정", "실패", "못", "의미"], "client"), ("domain_behavior", "행동", ["피하", "연락", "공부", "잠", "멈"], "client"), ("domain_relationship", "대인관계", ["친구", "가족", "사람", "관계", "부모"], "client"), ("domain_body", "신체", ["잠", "식욕", "몸", "두통", "피곤", "숨"], "client"), ], ), ( "cognitive_triad_emotions", "인지삼제·1/2차 감정", [ ("triad_self", "자기", ["나는", "내가", "나 자신", "스스로"], "client"), ("triad_world", "타인·세계", ["사람", "세상", "학교", "가족", "친구"], "client"), ("triad_future", "미래", ["앞으로", "미래", "계속", "나중"], "client"), ("primary_emotion", "1차 감정", ["불안", "슬프", "무섭", "외롭", "걱정"], "client"), ("secondary_emotion", "2차 감정", ["화", "짜증", "수치", "죄책", "부끄"], "client"), ], ), ( "protective_barrier_quadrants", "보호·방해 4사분면", [ ("internal_protective", "내적 보호요인", ["해보고", "버텼", "노력", "원", "견뎠"], None), ("internal_barrier", "내적 방해요인", ["못", "두려", "불안", "회피", "걱정"], "client"), ("external_protective", "외적 보호요인", ["친구", "가족", "상담", "교수", "도움"], "client"), ("external_barrier", "외적 방해요인", ["갈등", "압박", "비난", "스트레스", "혼자"], "client"), ], ), ( "biopsychosocial_goals", "생물·심리·사회 목표", [ ("bio_goal", "생물", ["잠", "식사", "운동", "몸", "피곤"], "client"), ("psy_goal", "심리", ["생각", "감정", "불안", "연습", "조절"], None), ("social_goal", "사회", ["관계", "대화", "연락", "도움", "친구"], None), ], ), ] class SessionTeacherReviewStatus(BaseModel): status: Literal["pending", "viewed", "closed"] = "pending" note: str = "" reviewerId: str | None = None reviewedAt: str | None = None updatedAt: str | None = None worksheetStatus: Literal["pending", "approved", "changes_requested", "rejected"] = "pending" worksheetNote: str = "" worksheetReviewedAt: str | None = None class SessionReviewResponse(BaseModel): session_id: str client: ReviewClient date: str durationLabel: str durationSeconds: int reachedPhase: StageLabel sessionSignal: str supervisorState: str supervisorName: str summary: str phases: list[ReviewPhaseSegment] = Field(default_factory=list) phaseAxis: list[str] = Field(default_factory=list) valenceAxis: list[str] = Field(default_factory=list) clientValence: list[ReviewValencePoint] = Field(default_factory=list) counselorBaseline: list[ReviewValencePoint] = Field(default_factory=list) turns: list[ReviewTurn] = Field(default_factory=list) rubric: list[ReviewRubricRow] = Field(default_factory=list) goodMoments: list[ReviewPoint] = Field(default_factory=list) growthPoints: list[ReviewPoint] = Field(default_factory=list) caseWorksheet: ReviewCaseWorksheet = Field(default_factory=ReviewCaseWorksheet) nextLine: Optional[str] = None clientFeedback: Optional[str] = None audioUrl: Optional[str] = None pdfExportUrl: Optional[str] = None degraded: bool = True reviewReady: bool = False teacherReview: SessionTeacherReviewStatus | None = None class SessionShareResponse(BaseModel): shareUrl: str title: str description: str imageUrl: str createdAt: str class SessionShareDeleteResponse(BaseModel): revoked: bool @dataclass(frozen=True) class SessionReviewReadInput: session: InProcSession evaluation_record: dict[str, object] | None = None evaluation_durable: bool = False saved_worksheet_payload: dict[str, object] | None = None include_teacher_review: bool = False teacher_review_record: dict[str, object] | None = None now_ts: float | None = None def stage_label(stage: object) -> StageLabel: return cast(StageLabel, _normalize_stage_label(stage)) def iso(ts: float | None) -> str | None: if ts is None: return None return datetime.fromtimestamp(ts).isoformat(timespec="seconds") def learner_visible_turns(sess: InProcSession) -> list[TurnRecord]: return sess.turns_visible_to(LEARNER_VISIBLE_AI_ROLE) def learner_summary( sess: InProcSession, *, review_ready: bool = False, archived: bool = False, archived_at: str | None = None, ) -> LearnerSessionSummary: turns = learner_visible_turns(sess) learner_turns = sum(1 for turn in turns if turn.speaker == "counselor") client_turns = sum(1 for turn in turns if turn.speaker == "client") return LearnerSessionSummary( session_id=sess.session_id, persona_code=sess.persona_code, persona_name=sess.persona.display_name, session_no=sess.session_no, status="ended" if sess.ended else "active", stage=stage_label(sess.state.stage), turn_count=len(turns), learner_turn_count=learner_turns, client_turn_count=client_turns, started_at=iso(sess.created_at) or "", ended_at=iso(sess.ended_at), review_ready=review_ready, archived=archived, archived_at=archived_at, ) def dashboard_overview( sessions: list[InProcSession], *, visible_review_ready: dict[str, bool], archived_sessions: int, ) -> LearnerDashboardOverview: return LearnerDashboardOverview( total_sessions=len(sessions), completed_sessions=sum(1 for sess in sessions if sess.ended), active_sessions=sum(1 for sess in sessions if not sess.ended), review_ready_sessions=sum(1 for ready in visible_review_ready.values() if ready), archived_sessions=archived_sessions, learner_turns=sum( 1 for sess in sessions for turn in learner_visible_turns(sess) if turn.speaker == "counselor" ), client_turns=sum( 1 for sess in sessions for turn in learner_visible_turns(sess) if turn.speaker == "client" ), last_practiced_at=session_metrics.iso_datetime( max((session_metrics.session_activity_time(sess) for sess in sessions), default=0.0) ) if sessions else None, ) def _dashboard_growth_point(point: session_metrics.SessionGrowthPoint) -> LearnerDashboardGrowthPoint: return LearnerDashboardGrowthPoint( session_id=point.session_id, session_no=point.session_no, persona_code=point.persona_code, stage=stage_label(point.stage), started_at=point.started_at, ended_at=point.ended_at, score=point.score, rapport=point.rapport, technique_count=point.technique_count, watch_count=point.watch_count, ) def dashboard_growth(sessions: list[InProcSession]) -> LearnerDashboardGrowth: metrics = session_metrics.build_learner_growth( sessions, learner_label=lambda _learner_id: "나", limit=1, ) if not metrics: return LearnerDashboardGrowth() item = metrics[0] points = [_dashboard_growth_point(point) for point in item.points] return LearnerDashboardGrowth( first_score=item.first_score, latest_score=item.latest_score, score_delta=item.score_delta, avg_score=item.avg_score, avg_rapport=item.avg_rapport, trend=item.trend, evaluated_sessions=sum(1 for point in points if point.score is not None), top_techniques=item.top_techniques, points=points, ) def dashboard_persona_progress( sessions: list[InProcSession], review_ready: dict[str, bool], ) -> list[LearnerDashboardPersonaProgress]: grouped: dict[str, list[InProcSession]] = {} for sess in sessions: grouped.setdefault(sess.persona_code, []).append(sess) rows: list[LearnerDashboardPersonaProgress] = [] for persona_code, items in grouped.items(): ordered = sorted(items, key=session_metrics.session_activity_time) latest = ordered[-1] metrics = session_metrics.build_learner_growth( ordered, learner_label=lambda _learner_id: "나", limit=1, ) growth = metrics[0] if metrics else None rows.append( LearnerDashboardPersonaProgress( persona_code=persona_code, persona_name=latest.persona.display_name, sessions=len(ordered), completed_sessions=sum(1 for sess in ordered if sess.ended), active_sessions=sum(1 for sess in ordered if not sess.ended), review_ready_sessions=sum( 1 for sess in ordered if review_ready.get(sess.session_id, False) ), latest_at=session_metrics.iso_datetime( session_metrics.session_activity_time(latest) ), latest_stage=stage_label(latest.state.stage), latest_score=growth.latest_score if growth else None, trend=growth.trend if growth else "insufficient", ) ) return sorted( rows, key=lambda row: row.latest_at or "", reverse=True, ) def _achievement_state(done: bool, available: bool) -> Literal["done", "available", "locked"]: if done: return "done" if available: return "available" return "locked" def dashboard_achievements( sessions: list[InProcSession], review_ready: dict[str, bool], ) -> list[LearnerDashboardAchievement]: completed = sum(1 for sess in sessions if sess.ended) active = sum(1 for sess in sessions if not sess.ended) review_count = sum(1 for ready in review_ready.values() if ready) by_persona = Counter(sess.persona_code for sess in sessions) max_persona_sessions = max(by_persona.values(), default=0) persona_coverage = len(by_persona) return [ LearnerDashboardAchievement( id="first_session_complete", label="첫 회기 완료", state=_achievement_state(completed >= 1, active >= 1), detail="한 회기를 종료하면 리뷰와 워크시트 흐름이 열립니다.", ), LearnerDashboardAchievement( id="review_ready", label="리뷰 확인 가능", state=_achievement_state(review_count >= 1, completed >= 1), detail=f"현재 리뷰 가능한 회기 {review_count}건입니다.", ), LearnerDashboardAchievement( id="persona_repeat", label="같은 내담자 반복 연습", state=_achievement_state(max_persona_sessions >= 3, max_persona_sessions >= 1), detail="같은 페르소나를 반복하면 변화 추이를 더 안정적으로 볼 수 있습니다.", ), LearnerDashboardAchievement( id="persona_coverage", label="여러 페르소나 경험", state=_achievement_state(persona_coverage >= 3, persona_coverage >= 2), detail=f"현재 {persona_coverage}개 페르소나에서 연습 기록이 있습니다.", ), ] def dashboard_feedback(sessions: list[InProcSession]) -> list[LearnerDashboardFeedbackItem]: items: list[LearnerDashboardFeedbackItem] = [] for item in session_metrics.recent_feedback_notes(sessions, limit=5): score = item.get("score") rapport = item.get("rapport") items.append( LearnerDashboardFeedbackItem( session_id=str(item["session_id"]), persona_code=str(item["persona_code"]), persona_name=str(item["persona_name"]), session_no=int(item["session_no"]), stage=stage_label(item["stage"]), turn_seq=int(item["turn_seq"]), created_at=str(item["created_at"]), score=float(score) if isinstance(score, (int, float)) else None, rapport=float(rapport) if isinstance(rapport, (int, float)) else None, note=str(item["note"]), techniques=[str(label) for label in item.get("techniques", [])], ) ) return items def session_detail( sess: InProcSession, *, review_ready: bool = False, ) -> SessionDetailResponse: turns = learner_visible_turns(sess) return SessionDetailResponse( session_id=sess.session_id, case_id=sess.case_id, persona_code=sess.persona_code, persona_name=sess.persona.display_name, theory_mode=sess.theory_mode, status="ended" if sess.ended else "active", stage=stage_label(sess.state.stage), effective_openness=round(sess.state.effective_openness, 4), started_at=iso(sess.created_at) or "", ended_at=iso(sess.ended_at), turns=[ SessionDetailTurn( turn_seq=turn.turn_seq, speaker="learner" if turn.speaker == "counselor" else "client", stage=stage_label(turn.stage), text=turn.text_masked, created_at=iso(turn.created_at) or "", ) for turn in turns ], review_ready=review_ready, ) def _offset_label(seconds: float) -> str: whole = max(0, int(round(seconds))) minutes, sec = divmod(whole, 60) return f"{minutes}:{sec:02d}" def _duration_label(seconds: int) -> str: if seconds < 60: return f"{seconds}초" minutes, sec = divmod(seconds, 60) return f"{minutes}분 {sec}초" def _client_name(raw: str) -> str: name = raw.split("(", 1)[0].strip() return name or raw.strip() or "내담자" def _review_summary(*, client_name: str, reached_phase: StageLabel, turns: list[ReviewTurn]) -> str: if not turns: return ( "아직 실제 발화가 없어 리뷰를 만들 수 없습니다. 회기를 진행한 뒤 종료하면 " "저장된 축어록을 기준으로 리뷰가 표시됩니다." ) learner_count = sum(1 for turn in turns if turn.speaker == "learner") client_count = sum(1 for turn in turns if turn.speaker == "client") return ( f"이 리뷰는 현재 세션에 저장된 실제 축어록 {len(turns)}개를 기반으로 합니다. " f"{client_name}와의 회기는 {reached_phase} 단계까지 진행되었고, " f"학습자 발화 {learner_count}개와 내담자 응답 {client_count}개가 기록되었습니다. " "평가 AI 또는 교수자 코멘트가 아직 생성되지 않은 항목은 빈 상태로 남겨 둡니다." ) def _phase_segments(stage_labels: list[StageLabel]) -> list[ReviewPhaseSegment]: counts = Counter(stage_labels) return [ ReviewPhaseSegment( key=review_phase_key(label), label=label, weight=float(count), ) for label, count in counts.items() if count > 0 ] def _clamp_ratio(value: float) -> float: return round(max(0.0, min(1.0, value)), 3) def _compact_text(text: str) -> str: return " ".join(text.split()) def _clip_text(text: str, limit: int = 180) -> str: compact = _compact_text(text) if len(compact) <= limit: return compact return f"{compact[: max(0, limit - 1)].rstrip()}..." def _point_title(text: str, fallback: str) -> str: compact = _clip_text(text, 72) for sep in (".", "。", "!", "?", "\n"): if sep in compact: first = compact.split(sep, 1)[0].strip() if first: return _clip_text(first, 44) return _clip_text(compact, 44) or fallback def _ai_review_points(values: object, *, fallback_prefix: str) -> list[ReviewPoint]: if not isinstance(values, list): return [] points: list[ReviewPoint] = [] for index, value in enumerate(values, start=1): body = _compact_text(str(value or "")) if not body: continue points.append( ReviewPoint( title=_point_title(body, f"{fallback_prefix} {index}"), body=body, jumpTo=None, ) ) return points[:3] def _intent_deviation_points(values: object) -> list[ReviewPoint]: if not isinstance(values, list): return [] points: list[ReviewPoint] = [] for index, value in enumerate(values, start=1): if not isinstance(value, dict): continue dimension = _compact_text(str(value.get("dimension") or f"의도 이탈 {index}")) expected = _compact_text(str(value.get("expected") or "")) actual = _compact_text(str(value.get("actual") or "")) severity = _compact_text(str(value.get("severity") or "minor")) body_parts = [] if expected: body_parts.append(f"기대: {expected}") if actual: body_parts.append(f"실제: {actual}") if severity: body_parts.append(f"심각도: {severity}") if body_parts: points.append( ReviewPoint( title=dimension, body=" · ".join(body_parts), jumpTo=None, ) ) return points[:3] def _rubric_from_evaluation(payload: dict[str, object]) -> list[ReviewRubricRow]: distribution = payload.get("distribution") if not isinstance(distribution, dict): return [] by_category = distribution.get("by_category") if not isinstance(by_category, dict): return [] total = int(distribution.get("total") or 0) if total <= 0: return [] overused = {str(item) for item in distribution.get("overused") or []} underused = {str(item) for item in distribution.get("underused") or []} rows: list[ReviewRubricRow] = [] for category, raw_count in sorted(by_category.items(), key=lambda item: str(item[0])): try: count = int(raw_count) except (TypeError, ValueError): continue code = str(category) watch = code in overused or code in underused rows.append( ReviewRubricRow( name=code.replace("_", " ").title(), cluster="평가 AI 기법 분포", ratio=_clamp_ratio(count / max(1, total)), quality="watch" if watch else "good", freq=f"{count}/{total} labels", ) ) return rows def _review_summary_from_evaluation( *, fallback: str, evaluation_record: dict[str, object] | None, payload: dict[str, object], ) -> str: if not evaluation_record: return fallback status = str(evaluation_record.get("status") or "") if status != "ready": error = _compact_text(str(evaluation_record.get("error") or payload.get("error") or "")) return ( "저장된 축어록은 확인했지만 평가 AI 산출물이 아직 준비되지 않았습니다. " + (f"사유: {error}" if error else "평가가 완료되면 코칭 항목이 갱신됩니다.") ) rationale = _compact_text(str(payload.get("supervisor_rationale") or "")) critique = _compact_text(str(payload.get("supervisor_critique") or "")) evaluated = payload.get("turns_evaluated") prefix = f"평가 AI가 학습자 발화 {evaluated}개를 deep-loop로 분석했습니다. " details = " ".join(part for part in [rationale, critique] if part) return prefix + (details if details else "아래 코칭 항목은 저장된 축어록과 평가 AI 결과를 기준으로 합니다.") def _session_evaluation_timeout_seconds() -> float: configured = float(settings.session_evaluation_timeout or settings.engine_timeout) return max(1.0, configured) def _missing_session_evaluation_record( sess: InProcSession, *, has_visible_turns: bool, now_ts: float, ) -> dict[str, object] | None: if not sess.ended or not has_visible_turns or sess.ended_at is None: return None stale_after = _session_evaluation_timeout_seconds() + MISSING_SESSION_EVALUATION_GRACE_SECONDS if now_ts - sess.ended_at < stale_after: return None return { "status": "error", "source": "read_model", "scope": "session_end", "stage": stage_label(sess.state.stage), "payload": {"error": MISSING_SESSION_EVALUATION_ERROR}, "error": MISSING_SESSION_EVALUATION_ERROR, "updated_at": iso(now_ts), } def missing_session_evaluation_record( sess: InProcSession, *, has_visible_turns: bool, now_ts: float, ) -> dict[str, object] | None: return _missing_session_evaluation_record( sess, has_visible_turns=has_visible_turns, now_ts=now_ts, ) def _next_line_from_evaluation(payload: dict[str, object]) -> str | None: alternatives = payload.get("alternative_utterances") if not isinstance(alternatives, list): return None for value in alternatives: line = _compact_text(str(value or "")) if line: return line return None def _latest_client_feedback(turns: list[ReviewTurn]) -> str | None: for turn in reversed(turns): if turn.speaker == "client": return _clip_text(turn.text) return None def _worksheet_evidence(turn: ReviewTurn) -> ReviewWorksheetEvidence: return ReviewWorksheetEvidence( turnId=turn.id, speaker=turn.speaker, quote=_clip_text(turn.text, 120), ) def _worksheet_item( *, key: str, label: str, turns: list[ReviewTurn], keywords: list[str], preferred_speaker: Literal["learner", "client"] | None = None, fallback_turn: ReviewTurn | None = None, ) -> ReviewWorksheetItem: lowered_keywords = [keyword.lower() for keyword in keywords if keyword] candidates = turns if preferred_speaker: preferred = [turn for turn in turns if turn.speaker == preferred_speaker] candidates = preferred + [turn for turn in turns if turn.speaker != preferred_speaker] for turn in candidates: text = _compact_text(turn.text) lower_text = text.lower() if lowered_keywords and any(keyword in lower_text for keyword in lowered_keywords): return ReviewWorksheetItem( key=key, label=label, value=_clip_text(text, 140), evidence=[_worksheet_evidence(turn)], confidence="medium", ) if fallback_turn is not None: return ReviewWorksheetItem( key=key, label=label, value=_clip_text(fallback_turn.text, 140), evidence=[_worksheet_evidence(fallback_turn)], confidence="low", ) return ReviewWorksheetItem( key=key, label=label, value=None, evidence=[], confidence="none", emptyReason="저장된 축어록에서 명시 근거를 찾지 못했습니다.", ) def _worksheet_section( key: str, title: str, specs: list[WorksheetItemSpec], turns: list[ReviewTurn], fallback_client: ReviewTurn | None, fallback_learner: ReviewTurn | None, ) -> ReviewWorksheetSection: items: list[ReviewWorksheetItem] = [] for item_key, label, keywords, speaker in specs: fallback = fallback_client if speaker == "client" else fallback_learner if speaker == "learner" else None items.append( _worksheet_item( key=item_key, label=label, turns=turns, keywords=keywords, preferred_speaker=speaker, fallback_turn=fallback if item_key in {"presenting_complaint", "first_goal"} else None, ) ) return ReviewWorksheetSection(key=key, title=title, items=items) def case_worksheet_template_item_keys() -> dict[str, set[str]]: return { section_key: {item_key for item_key, _, _, _ in item_specs} for section_key, _, item_specs in CASE_WORKSHEET_SECTION_SPECS } def case_worksheet_from_turns(turns: list[ReviewTurn]) -> ReviewCaseWorksheet: if not turns: return ReviewCaseWorksheet( status="empty", sections=[], limitations=["저장된 축어록이 없어 사례개념화 워크시트를 생성하지 않았습니다."], ) fallback_client = next((turn for turn in turns if turn.speaker == "client"), None) fallback_learner = next((turn for turn in turns if turn.speaker == "learner"), None) sections = [ _worksheet_section( key, title, specs, turns, fallback_client, fallback_learner, ) for key, title, specs in CASE_WORKSHEET_SECTION_SPECS ] return ReviewCaseWorksheet( status="draft_from_transcript", sections=sections, limitations=[ "저장된 축어록에서 키워드 근거를 추출한 1차 초안입니다.", "임상팀 루브릭, 교수자 검수, 학습자 수정 입력 전에는 확정 사례개념화로 보지 않습니다.", ], ) 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 _worksheet_review_status_value( value: object, ) -> Literal["pending", "approved", "changes_requested", "rejected"]: raw = str(value or "pending") if raw in {"approved", "changes_requested", "rejected"}: return raw # type: ignore[return-value] return "pending" def _worksheet_share_highlights(worksheet: ReviewCaseWorksheet, *, limit: int = 4) -> list[dict[str, str]]: highlights: list[dict[str, str]] = [] for section in worksheet.sections: for item in section.items: value = _compact_text(item.value or "") if not value: continue highlights.append( { "section": section.title, "label": item.label, "value": "비공개 요약 항목", } ) if len(highlights) >= limit: return highlights return highlights def _review_point_titles(points: list[ReviewPoint], *, limit: int = 3) -> list[str]: return [_clip_text(point.title or point.body, 72) for point in points[:limit] if (point.title or point.body)] def _share_image_url() -> str: return f"{settings.frontend_base_url.rstrip('/')}/design-elements/clinical-paper-ambient.png" def session_share_payload(review: SessionReviewResponse) -> dict[str, object]: title = f"Vignette 회기 리뷰 · {review.client.name} {review.date}" description = _clip_text(review.summary, 156) return { "version": 1, "title": title, "description": description, "summary": _clip_text(review.summary, 420), "clientName": review.client.name, "persona": review.client.persona, "date": review.date, "durationLabel": review.durationLabel, "reachedPhase": review.reachedPhase, "sessionSignal": review.sessionSignal, "reviewReady": review.reviewReady, "goodMoments": _review_point_titles(review.goodMoments), "growthPoints": _review_point_titles(review.growthPoints), "worksheetHighlights": _worksheet_share_highlights(review.caseWorksheet), "imageUrl": _share_image_url(), "appUrl": settings.frontend_base_url.rstrip("/"), "privacy": "공유 카드에는 회기 원문 축어록과 학습자 식별 정보를 포함하지 않습니다.", } def _evaluation_payload(record: dict[str, object] | None) -> dict[str, object]: if not record: return {} payload = record.get("payload") if not isinstance(payload, dict): return {} masked = _mask_payload_text_values(payload) return masked if isinstance(masked, dict) else {} def _mask_payload_text(value: object) -> str: return guardrail.mask_pii(str(value or "")).text_masked def _mask_payload_text_values(value: Any) -> Any: if isinstance(value, str): return _mask_payload_text(value) if isinstance(value, dict): return {str(key): _mask_payload_text_values(child) for key, child in value.items()} if isinstance(value, list): return [_mask_payload_text_values(child) for child in value] if isinstance(value, tuple): return [_mask_payload_text_values(child) for child in value] return value _TECHNIQUE_KIND_BY_CATEGORY = { "relational": "empathy", "exploratory": "explore", "intervention": "confront", "stabilizing": "reflect", "structuring": "closed", } def _review_techniques_from_turn_eval(ev: dict[str, object] | None) -> list[ReviewTechnique]: if not isinstance(ev, dict): return [] out: list[ReviewTechnique] = [] for tag in ev.get("techniques") or []: if not isinstance(tag, dict): continue label = str(tag.get("label_ko") or tag.get("code") or "").strip() if not label: continue kind = _TECHNIQUE_KIND_BY_CATEGORY.get(str(tag.get("category") or ""), "explore") out.append(ReviewTechnique(kind=kind, label=label)) return out def _review_note_body_markdown(text: str) -> str: body = text.strip() body = re.sub( r"`?\beffective[_\s-]?openness\b`?(?!\(유효 개방도\))", "`effective openness(유효 개방도)`", body, flags=re.IGNORECASE, ) body = re.sub( r"(? str | None: clean = " ".join(str(text or "").split()) if not clean: return None sentences = [part.strip() for part in re.split(r"(?<=[.!?。!?])\s+", clean) if part.strip()] for sentence in sentences: if "가장 큰 마음" in sentence: return sentence if len(sentence) <= limit else f"{sentence[: limit - 3].rstrip()}..." for sentence in sentences: if "?" in sentence: return sentence if len(sentence) <= limit else f"{sentence[: limit - 3].rstrip()}..." return clean if len(clean) <= limit else f"{clean[: limit - 3].rstrip()}..." def _review_note_from_turn_eval( ev: dict[str, object] | None, learner_text: str | None = None, ) -> Optional[ReviewNote]: if not isinstance(ev, dict): return None quote = _review_quote_excerpt(learner_text) error_text = str(ev.get("error") or "").strip() if error_text: return ReviewNote( author="평가 AI", tone="warn", title="턴 평가 실패", body=_review_note_body_markdown( f"이 발화의 fast-loop 평가를 완료하지 못했습니다.\n\n사유: {error_text}" ), quote=quote, ) dev = ev.get("intent_deviation") if isinstance(dev, dict): dimension = str(dev.get("dimension") or "").strip() expected = str(dev.get("expected") or "").strip() actual = str(dev.get("actual") or "").strip() body_parts = ( f"- 권장: {expected}" if expected else "", f"- 실제: {actual}" if actual else "", ) body = "\n".join(p for p in body_parts if p) return ReviewNote( author="평가 AI", tone="warn", title=f"의도와 다른 부분 · {dimension}".rstrip(" ·") or "의도와 다른 부분", body=_review_note_body_markdown(body or "권장 반응과 실제 반응에 차이가 있었어요."), quote=quote, ) appropriateness = str(ev.get("appropriateness") or "neutral") note_text = str(ev.get("appropriateness_note") or "").strip() if appropriateness == "pos": return ReviewNote( author="평가 AI", tone="good", title="적절한 개입", body=_review_note_body_markdown( note_text or ( "타당화·공감·탐색이 회기 흐름에 맞았습니다.\n\n" "`effective openness(유효 개방도)`가 낮은 내담자라면 다음 질문은 " "더 작고 구체적인 선택지로 낮춰도 좋습니다." ) ), quote=quote, ) if appropriateness == "warn" and note_text: return ReviewNote( author="평가 AI", tone="warn", title="점검해볼 지점", body=_review_note_body_markdown(note_text), quote=quote, ) return None def _seconds_label(milliseconds: int) -> str: seconds = max(0, milliseconds) / 1000.0 if seconds >= 10: return f"{seconds:.0f}초" return f"{seconds:.1f}초" _PROVIDER_REVIEW_EVENT_LABELS: dict[str, tuple[str, str, str]] = { "sigh": ("paralinguistic", "음성 단서", "한숨 감지"), "cry": ("paralinguistic", "음성 단서", "울음 감지"), "laugh": ("paralinguistic", "음성 단서", "웃음 감지"), "breath": ("paralinguistic", "음성 단서", "호흡 변화"), "pitch": ("prosody", "운율", "피치 변화"), "intonation": ("prosody", "운율", "억양 변화"), "prosody": ("prosody", "운율", "운율 변화"), "background_noise": ("audio_quality", "오디오 품질", "배경 소음"), } def _provider_confidence_label(event: dict[str, object]) -> str: raw = event.get("confidence", event.get("score")) if not isinstance(raw, (int, float)): return "" value = float(raw) if 0 <= value <= 1: return f"신뢰도 {value * 100:.0f}%" if 1 < value <= 100: return f"신뢰도 {value:.0f}%" return "" def _provider_duration_label(event: dict[str, object]) -> str: raw = event.get("duration_ms") if not isinstance(raw, (int, float)): return "" milliseconds = int(raw) if milliseconds <= 0: return "" return _seconds_label(milliseconds) def _review_provider_event(event: dict[str, object]) -> ReviewNonverbalEvent | None: event_type = str(event.get("event_type") or "").strip() if not event_type: return None if event_type == "barge_in": return ReviewNonverbalEvent(kind="barge_in", label="끼어듦", detail="provider 감지") if event_type == "silence": detail = _provider_duration_label(event) or "provider 감지" return ReviewNonverbalEvent(kind="silence", label="침묵", detail=detail) if event_type == "speech_rate": return ReviewNonverbalEvent(kind="pace", label="발화 속도", detail="provider 감지") mapped = _PROVIDER_REVIEW_EVENT_LABELS.get(event_type) if mapped is None: return None kind, label, detail = mapped extras = [item for item in (_provider_confidence_label(event), _provider_duration_label(event)) if item] if extras: detail = f"{detail} · {' · '.join(extras)}" return ReviewNonverbalEvent(kind=kind, label=label, detail=detail) def _review_nonverbal_events(turn: TurnRecord) -> list[ReviewNonverbalEvent]: events: list[ReviewNonverbalEvent] = [] has_turn_level_silence = turn.silence_ms is not None and turn.silence_ms >= 1000 if has_turn_level_silence: events.append( ReviewNonverbalEvent( kind="silence", label="침묵", detail=_seconds_label(turn.silence_ms), ) ) if turn.speech_rate is not None: events.append( ReviewNonverbalEvent( kind="pace", label="발화 속도", detail=f"분당 {turn.speech_rate:.0f}자", ) ) if turn.barge_in is True: events.append( ReviewNonverbalEvent( kind="barge_in", label="끼어듦", detail="내담자 발화 중 시작", ) ) if turn.audio_ref: events.append( ReviewNonverbalEvent( kind="audio", label="음성 발화", detail="오디오 메타 저장됨", ) ) for event in turn.provider_events: if not isinstance(event, dict): continue if has_turn_level_silence and str(event.get("event_type") or "").strip() == "silence": continue review_event = _review_provider_event(event) if review_event is not None: events.append(review_event) return events def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewResponse: sess = read_input.session visible_turns = learner_visible_turns(sess) hidden_turns = len(visible_turns) != len(sess.turns) end_ts = sess.ended_at or read_input.now_ts or datetime.now().timestamp() duration_seconds = max(0, int(round(end_ts - sess.created_at))) client_name = _client_name(sess.persona.display_name) client_initial = client_name[:1] or "내" reached_phase = stage_label(sess.state.stage) 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)) now_ts = read_input.now_ts or datetime.now().timestamp() evaluation_record = read_input.evaluation_record if evaluation_record is None and not hidden_turns: evaluation_record = _missing_session_evaluation_record( sess, has_visible_turns=bool(visible_turns), now_ts=now_ts, ) evaluation_payload = {} if hidden_turns else _evaluation_payload(evaluation_record) evaluation_status = ( "" if hidden_turns else str(evaluation_record.get("status") or "") if evaluation_record else "" ) evaluation_ready = not hidden_turns and evaluation_status == "ready" first_turn_ts = visible_turns[0].created_at if visible_turns else sess.created_at turns: list[ReviewTurn] = [] for index, turn in enumerate(visible_turns): speaker: Literal["learner", "client"] = ( "learner" if turn.speaker == "counselor" else "client" ) turn_eval = turn.evaluation if (speaker == "learner" and not hidden_turns) else None turns.append( ReviewTurn( id=f"t{index + 1}", ts=_offset_label(turn.created_at - first_turn_ts), speaker=speaker, who="학습자" if speaker == "learner" else client_name, text=turn.text_masked, techniques=_review_techniques_from_turn_eval(turn_eval), nonverbal=_review_nonverbal_events(turn) if speaker == "learner" else [], note=_review_note_from_turn_eval(turn_eval, turn.text_masked), ) ) if not turns: session_signal = "기록 없음" elif sess.ended: session_signal = "종료됨" else: session_signal = "진행 중" transcript_summary = _review_summary( client_name=client_name, reached_phase=reached_phase, turns=turns, ) rubric: list[ReviewRubricRow] = [] good_moments: list[ReviewPoint] = [] growth_points: list[ReviewPoint] = [] next_line: str | None = None if evaluation_ready: rubric = _rubric_from_evaluation(evaluation_payload) good_moments = _ai_review_points( evaluation_payload.get("strengths"), fallback_prefix="강점", ) growth_points = _ai_review_points( evaluation_payload.get("improvements"), fallback_prefix="개선점", ) if not growth_points: growth_points = _intent_deviation_points(evaluation_payload.get("intent_deviations")) next_line = _next_line_from_evaluation(evaluation_payload) client_feedback = _latest_client_feedback(turns) review_degraded = bool(turns) and not evaluation_ready if evaluation_ready: supervisor_state = "평가 완료" elif evaluation_status == "error": supervisor_state = "평가 실패" elif turns: supervisor_state = "평가 대기" else: supervisor_state = "기록 대기" summary = _review_summary_from_evaluation( fallback=transcript_summary, evaluation_record=None if hidden_turns else evaluation_record, payload=evaluation_payload, ) if evaluation_record and not hidden_turns and not read_input.evaluation_durable: summary += " 현재 평가는 런타임 캐시에서 복원되었습니다." generated_worksheet = case_worksheet_from_turns(turns) case_worksheet = ( saved_case_worksheet_from_payload(read_input.saved_worksheet_payload) or generated_worksheet ) teacher_review: SessionTeacherReviewStatus | None = None if read_input.include_teacher_review: review_status = read_input.teacher_review_record or {} review_status_value = str(review_status.get("status") or "pending") if review_status_value not in {"viewed", "closed"}: review_status_value = "pending" teacher_review = SessionTeacherReviewStatus( status=review_status_value, # type: ignore[arg-type] note=str(review_status.get("note") or ""), reviewerId=str(review_status.get("reviewer_id") or "") or None, reviewedAt=str(review_status.get("reviewed_at") or "") or None, updatedAt=str(review_status.get("updated_at") or "") or None, worksheetStatus=_worksheet_review_status_value(review_status.get("worksheet_status")), worksheetNote=str(review_status.get("worksheet_note") or ""), worksheetReviewedAt=str(review_status.get("worksheet_reviewed_at") or "") or None, ) return SessionReviewResponse( session_id=sess.session_id, client=ReviewClient( name=client_name, initial=client_initial, persona=f"{sess.persona_code} · {sess.persona.difficulty}", ), date=datetime.fromtimestamp(sess.created_at).strftime("%Y-%m-%d"), durationLabel=_duration_label(duration_seconds), durationSeconds=duration_seconds, reachedPhase=reached_phase, sessionSignal=session_signal, supervisorState=supervisor_state, supervisorName="AI", summary=summary, phases=_phase_segments(stage_labels), phaseAxis=axis, valenceAxis=axis, clientValence=[], counselorBaseline=[], turns=turns, rubric=rubric, goodMoments=good_moments, growthPoints=growth_points, caseWorksheet=case_worksheet, nextLine=next_line, clientFeedback=client_feedback, audioUrl=None, pdfExportUrl=None, degraded=review_degraded, reviewReady=evaluation_ready, teacherReview=teacher_review, )