"""학습자 대시보드 DTO와 순수 projection.""" from __future__ import annotations from collections import Counter from typing import Literal from pydantic import BaseModel, Field from .services import session_metrics from .session_projection import _rapport_percent, learner_visible_turns, stage_label from .stage_contract import StageLabel from .store import InProcSession TRAINING_EXPOSURE_VERSION = "training-exposure-dominant-share.v1" TRAINING_EXPOSURE_MIN_COMPLETED = 4 TRAINING_EXPOSURE_ATTENTION_THRESHOLD = 0.75 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" rapport_percent: int = 0 class LearnerDashboardTrainingExposure(BaseModel): version: str = TRAINING_EXPOSURE_VERSION status: Literal["insufficient", "attention", "balanced"] = "insufficient" label: Literal["판정 근거 부족", "훈련 집중 주의", "균형"] = "판정 근거 부족" completed_sessions: int = 0 minimum_completed_sessions: int = TRAINING_EXPOSURE_MIN_COMPLETED attention_threshold: float = TRAINING_EXPOSURE_ATTENTION_THRESHOLD dominant_persona_code: str | None = None dominant_persona_name: str | None = None dominant_sessions: int = 0 dominant_share: float | None = None definition: str = ( "종료 회기의 페르소나별 최다 노출 비중을 보여 주는 투명한 훈련 노출 지표이며, " "공정성 평가나 임상진단이 아닙니다." ) 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) training_exposure: LearnerDashboardTrainingExposure = Field( default_factory=LearnerDashboardTrainingExposure ) achievements: list[LearnerDashboardAchievement] = Field(default_factory=list) recent_feedback: list[LearnerDashboardFeedbackItem] = Field(default_factory=list) message: str 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 and any(t.speaker in ("counselor", "learner") for t in sess.turns) ), 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], learner_feedback_enabled: dict[str, bool] | None = None, ) -> 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] metric_sessions = ( ordered if learner_feedback_enabled is None else [ sess for sess in ordered if learner_feedback_enabled.get(sess.session_id, True) ] ) metrics = session_metrics.build_learner_growth( metric_sessions, 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 and any(t.speaker in ("counselor", "learner") for t in sess.turns) ), 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", rapport_percent=_rapport_percent(latest.state.rapport_credit), ) ) return sorted(rows, key=lambda row: row.latest_at or "", reverse=True) def dashboard_training_exposure( sessions: list[InProcSession], ) -> LearnerDashboardTrainingExposure: """Compute a transparent practice-exposure signal from ended sessions only.""" completed = [ sess for sess in sessions if sess.ended and any(t.speaker in ("counselor", "learner") for t in sess.turns) ] counts = Counter(sess.persona_code for sess in completed) if not counts: return LearnerDashboardTrainingExposure() dominant_code, dominant_count = sorted( counts.items(), key=lambda item: (-item[1], item[0]), )[0] dominant_session = next( sess for sess in completed if sess.persona_code == dominant_code ) total = len(completed) share = dominant_count / total if total < TRAINING_EXPOSURE_MIN_COMPLETED: status: Literal["insufficient", "attention", "balanced"] = "insufficient" label: Literal["판정 근거 부족", "훈련 집중 주의", "균형"] = "판정 근거 부족" elif share >= TRAINING_EXPOSURE_ATTENTION_THRESHOLD: status = "attention" label = "훈련 집중 주의" else: status = "balanced" label = "균형" return LearnerDashboardTrainingExposure( status=status, label=label, completed_sessions=total, dominant_persona_code=dominant_code, dominant_persona_name=dominant_session.persona.display_name, dominant_sessions=dominant_count, dominant_share=round(share, 4), ) 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]: practice_sessions = [ sess for sess in sessions if any(turn.speaker in ("counselor", "learner") for turn in sess.turns) ] completed = sum(1 for sess in practice_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 practice_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