"""Teacher dashboard routes backed by real server session state.""" from __future__ import annotations from typing import Annotated, Literal from fastapi import APIRouter, Depends, HTTPException, status 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 ..stage_contract import STAGE_LABEL_VALUES from ..store import InProcSession, store router = APIRouter(prefix="/teacher", tags=["teacher"]) TeacherPrincipal = Annotated[Principal, Depends(require_role(Role.TEACHER, Role.ADMIN))] class TeacherSessionSummary(BaseModel): session_id: str learner_id: str learner_label: str persona_code: str persona_name: str session_no: int status: str stage: StageLabel turn_count: int learner_turn_count: int client_turn_count: int started_at: str ended_at: str | None = None review_status: Literal["pending", "viewed", "closed"] = "pending" review_note: str | None = None reviewed_at: str | None = None 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): session_id: str status: Literal["pending", "viewed", "closed"] = "pending" note: str = "" 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: 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 TeacherLearnerGrowth(BaseModel): learner_id: str learner_label: str sessions: int ended_sessions: int latest_at: str 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" top_techniques: list[str] = Field(default_factory=list) points: list[TeacherGrowthPoint] = Field(default_factory=list) class TeacherStageBreakdown(BaseModel): stage: StageLabel sessions: int = 0 turns: int = 0 class TeacherLearnerAnalysisResponse(BaseModel): source: str = "in_memory" learner_id: str learner_label: str total_sessions: int active_sessions: int ended_sessions: int pending_reviews: int closed_reviews: int summary: TeacherLearnerGrowth points: list[TeacherGrowthPoint] = Field(default_factory=list) stage_breakdown: list[TeacherStageBreakdown] = Field(default_factory=list) sessions: list[TeacherSessionSummary] = Field(default_factory=list) message: str class TeacherSafetyAlert(BaseModel): id: str session_id: str learner_id: str learner_label: str persona_code: str session_no: int trigger_type: str ko_risk_level: int escalated: bool created_at: str resource_title: str = "자살예방상담전화 109" resource_number: str = "109" class TeacherDashboardResponse(BaseModel): source: str = "in_memory" cohort_label: str = "현재 학습 기록" total_learners: int active_sessions: int ended_sessions: int safety_alerts: list[TeacherSafetyAlert] = Field(default_factory=list) learner_growth: list[TeacherLearnerGrowth] = Field(default_factory=list) pending_reviews: list[TeacherSessionSummary] = Field(default_factory=list) recent_sessions: list[TeacherSessionSummary] = Field(default_factory=list) message: str def _learner_label(learner_id: str) -> str: suffix = learner_id[-6:] if len(learner_id) > 6 else learner_id return f"학습자 {suffix}" def _growth_point(point: session_metrics.SessionGrowthPoint) -> TeacherGrowthPoint: return TeacherGrowthPoint( 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 _build_learner_growth( sessions: list[InProcSession], *, limit: int | None = 12, point_limit: int | None = 6, ) -> list[TeacherLearnerGrowth]: metrics = session_metrics.build_learner_growth( sessions, learner_label=_learner_label, limit=limit, point_limit=point_limit, ) return [ TeacherLearnerGrowth( learner_id=item.learner_id, learner_label=item.learner_label, sessions=item.sessions, ended_sessions=item.ended_sessions, latest_at=item.latest_at, 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, top_techniques=item.top_techniques, points=[_growth_point(point) for point in item.points], ) for item in metrics ] def _stage_breakdown(sessions: list[InProcSession]) -> list[TeacherStageBreakdown]: buckets = { stage: {"sessions": 0, "turns": 0} for stage in STAGE_LABEL_VALUES } for sess in sessions: label = stage_label(sess.state.stage) bucket = buckets[label] bucket["sessions"] += 1 bucket["turns"] += len(sess.turns) return [ TeacherStageBreakdown( stage=stage, sessions=buckets[stage]["sessions"], turns=buckets[stage]["turns"], ) for stage in STAGE_LABEL_VALUES ] def _review_status_value(record: dict[str, object] | None) -> Literal["pending", "viewed", "closed"]: value = str((record or {}).get("status") or "pending") if value in {"viewed", "closed"}: return value # type: ignore[return-value] 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, ) -> TeacherSessionSummary: learner_turns = sum(1 for turn in sess.turns if turn.speaker == "counselor") client_turns = sum(1 for turn in sess.turns if turn.speaker == "client") return TeacherSessionSummary( session_id=sess.session_id, learner_id=sess.learner_id, learner_label=_learner_label(sess.learner_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(sess.turns), learner_turn_count=learner_turns, client_turn_count=client_turns, started_at=session_metrics.iso_datetime(sess.created_at) or "", ended_at=session_metrics.iso_datetime(sess.ended_at), review_status=_review_status_value(review_status), review_note=str(review_status.get("note") or "") if review_status else None, reviewed_at=str(review_status.get("reviewed_at") or "") if review_status else None, ) @router.get("/dashboard", response_model=TeacherDashboardResponse) async def teacher_dashboard(principal: TeacherPrincipal) -> TeacherDashboardResponse: """Return teacher-visible dashboard data from real sessions only.""" sessions, durable = await session_persistence.list_sessions( principal, include_turn_evaluation=True, ) if not durable: require_runtime_fallback_allowed("teacher dashboard") sessions = sorted(store.list(), key=lambda sess: sess.created_at, reverse=True) review_statuses, _ = await session_persistence.list_session_review_statuses( [sess.session_id for sess in sessions if sess.ended], principal, ) summaries = [ _summary(sess, review_statuses.get(sess.session_id)) for sess in sessions ] pending_reviews = [ item for item in summaries if item.status == "ended" and item.review_status != "closed" ] learners = {sess.learner_id for sess in sessions} learner_growth = _build_learner_growth(sessions) safety_alerts: list[TeacherSafetyAlert] = [] if durable: raw_alerts, alerts_durable = await session_persistence.list_safety_alerts(principal) if alerts_durable: safety_alerts = [ TeacherSafetyAlert( id=str(item.get("id") or ""), session_id=str(item.get("session_id") or ""), learner_id=str(item.get("learner_id") or ""), learner_label=str(item.get("learner_label") or "학습자"), persona_code=str(item.get("persona_code") or ""), session_no=int(item.get("session_no") or 0), trigger_type=str(item.get("trigger_type") or "crisis"), ko_risk_level=int(item.get("ko_risk_level") or 0), escalated=bool(item.get("escalated")), created_at=str(item.get("created_at") or ""), resource_title=str( (item.get("detail") or {}).get("crisis_resource", {}).get( "title", "자살예방상담전화 109", ) ), resource_number=str( (item.get("detail") or {}).get("crisis_resource", {}).get("number", "109") ), ) for item in raw_alerts ] else: require_runtime_fallback_allowed("safety alert list") if sessions: message = "현재 기록된 실제 학습 세션만 표시합니다." else: message = "아직 표시할 실제 학습자 세션이 없습니다." return TeacherDashboardResponse( source="database" if durable else "runtime", total_learners=len(learners), active_sessions=sum(1 for sess in sessions if not sess.ended), ended_sessions=sum(1 for sess in sessions if sess.ended), safety_alerts=safety_alerts, learner_growth=learner_growth, pending_reviews=pending_reviews[:20], recent_sessions=summaries[:20], message=message, ) @router.get( "/learners/{learner_id}/analysis", response_model=TeacherLearnerAnalysisResponse, ) async def learner_analysis( learner_id: str, principal: TeacherPrincipal, ) -> TeacherLearnerAnalysisResponse: """Return one learner's full teacher-visible session timeline and trend.""" sessions, durable = await session_persistence.list_sessions( principal, include_turn_evaluation=True, ) if not durable: require_runtime_fallback_allowed("teacher learner analysis") sessions = sorted(store.list(), key=lambda sess: sess.created_at, reverse=True) learner_sessions = [sess for sess in sessions if sess.learner_id == learner_id] if not learner_sessions: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="learner not found") ordered = sorted( learner_sessions, key=lambda sess: (sess.created_at, sess.session_no, sess.session_id), ) review_statuses, _ = await session_persistence.list_session_review_statuses( [sess.session_id for sess in ordered if sess.ended], principal, ) summaries = [ _summary(sess, review_statuses.get(sess.session_id)) for sess in ordered ] growth = _build_learner_growth(ordered, limit=None, point_limit=None)[0] pending_reviews = sum( 1 for item in summaries if item.status == "ended" and item.review_status != "closed" ) closed_reviews = sum(1 for item in summaries if item.review_status == "closed") return TeacherLearnerAnalysisResponse( source="database" if durable else "runtime", learner_id=learner_id, learner_label=growth.learner_label, total_sessions=len(ordered), active_sessions=sum(1 for sess in ordered if not sess.ended), ended_sessions=sum(1 for sess in ordered if sess.ended), pending_reviews=pending_reviews, closed_reviews=closed_reviews, summary=growth, points=growth.points, stage_breakdown=_stage_breakdown(ordered), sessions=summaries, message=f"{growth.learner_label}의 실제 회기 {len(ordered)}건을 오래된 순서로 표시합니다.", ) @router.put( "/sessions/{session_id}/review-status", response_model=TeacherSessionReviewStatusResponse, ) async def update_session_review_status( session_id: str, request: TeacherSessionReviewStatusRequest, principal: TeacherPrincipal, ) -> TeacherSessionReviewStatusResponse: sess = await session_persistence.load_session( session_id, principal, allow_ended=True, ) if sess is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="session not found") if not sess.ended and request.status == "closed": raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="active sessions cannot be closed as reviewed", ) saved, _ = await session_persistence.save_session_review_status( session_id=session_id, reviewer_id=principal.user_id, status=request.status, note=request.note, principal=principal, worksheet_status=request.worksheet_status, worksheet_note=request.worksheet_note, ) if saved is None: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="review status unavailable", ) return TeacherSessionReviewStatusResponse( session_id=session_id, status=_review_status_value(saved), note=str(saved.get("note") or ""), 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, )