"""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 Literal, Optional from pydantic import BaseModel, Field from . import turn_runtime from .config import settings from .services import session_metrics from .store import InProcSession, TurnRecord StageLabel = Literal["라포", "탐색", "개입", "정리"] WorksheetSpeaker = Literal["learner", "client"] WorksheetItemSpec = tuple[str, str, list[str], WorksheetSpeaker | None] WorksheetSectionSpec = tuple[str, str, list[WorksheetItemSpec]] LEARNER_VISIBLE_AI_ROLE = "counselor" _PHASE_KEY_BY_LABEL = { "라포": "rapport", "탐색": "explore", "개입": "intervene", "정리": "closing", } 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: str label: str 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 class SessionReviewResponse(BaseModel): session_id: str client: ReviewClient date: str durationLabel: str durationSeconds: int reachedPhase: str 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) -> str: return turn_runtime.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: str, 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[str]) -> list[ReviewPhaseSegment]: counts = Counter(stage_labels) return [ ReviewPhaseSegment( key=_PHASE_KEY_BY_LABEL.get(label, 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 _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_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") return payload if isinstance(payload, dict) else {} _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) 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] = [] if turn.silence_ms is not None and turn.silence_ms >= 1000: 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 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)) evaluation_record = read_input.evaluation_record 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, ) 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, )