vignette/apps/api/app/routes/teacher.py
Yun Chan 778e8526d4 세션 평가·라이브코치·교수자 분석 라운드 마감 + 문서 정리 + 코드품질 리팩터
- 누적 작업트리 커밋: 회기 평가 복구·durable 저장, 라이브 코치 이력/근거, 교수자 학생분석, 음성 비언어 메타, PII 마스킹, 운영 티켓/헬스 등
- 문서: 완료 기록 docs/archive/ 냉동 보관, docs/ 단일 인덱스(docs/README.md)+통합 TODO(docs/TODO.md)로 정리
- 리팩터(행위 보존): Stage enum SSOT(taxonomy 소유·state_machine re-export), store recent/masked_turns 중복 제거, speaker_ko_label 단일 헬퍼, _list_sessions N+1 제거(state/turns 배치 + 턴평가 하이드레이션 배치)
- 검증: 백엔드 pytest 352 passed, _list_sessions E2E chromium-single-run 2 passed
2026-07-02 02:50:36 +09:00

532 lines
19 KiB
Python

"""Teacher dashboard routes backed by real server session state."""
from __future__ import annotations
from datetime import datetime
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,
learner_visible_turns,
missing_session_evaluation_record,
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
evaluation_status: Literal["pending", "ready", "error"] = "pending"
review_ready: bool = False
supervisor_state: Literal["기록 대기", "평가 대기", "평가 완료", "평가 실패"] = "기록 대기"
evaluation_error: 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_from_id(learner_id: str) -> str:
suffix = learner_id[-6:] if len(learner_id) > 6 else learner_id
return f"학습자 {suffix}" if suffix else "학습자"
def _session_learner_label(sess: InProcSession) -> str:
label = str(getattr(sess, "learner_label", "") or "").strip()
return label or _learner_label_from_id(sess.learner_id)
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]:
learner_labels = {
sess.learner_id: label
for sess in sessions
if (label := str(getattr(sess, "learner_label", "") or "").strip())
}
metrics = session_metrics.build_learner_growth(
sessions,
learner_label=lambda learner_id: learner_labels.get(learner_id)
or _learner_label_from_id(learner_id),
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 _evaluation_status_value(record: dict[str, object] | None) -> Literal["pending", "ready", "error"]:
value = str((record or {}).get("status") or "pending")
if value in {"ready", "error"}:
return value # type: ignore[return-value]
return "pending"
def _supervisor_state(
sess: InProcSession,
evaluation_record: dict[str, object] | None,
*,
has_visible_turns: bool | None = None,
) -> Literal["기록 대기", "평가 대기", "평가 완료", "평가 실패"]:
status = _evaluation_status_value(evaluation_record)
if status == "ready":
return "평가 완료"
if status == "error":
return "평가 실패"
if has_visible_turns is None:
has_visible_turns = bool(learner_visible_turns(sess))
if has_visible_turns:
return "평가 대기"
return "기록 대기"
def _dashboard_evaluation_record(
sess: InProcSession,
evaluation_record: dict[str, object] | None,
) -> dict[str, object] | None:
visible_turns = learner_visible_turns(sess)
hidden_turns = len(visible_turns) != len(sess.turns)
if evaluation_record is not None:
return None if hidden_turns else evaluation_record
if hidden_turns:
return None
return missing_session_evaluation_record(
sess,
has_visible_turns=bool(visible_turns),
now_ts=datetime.now().timestamp(),
)
def _summary(
sess: InProcSession,
review_status: dict[str, object] | None = None,
evaluation_record: dict[str, object] | None = None,
) -> TeacherSessionSummary:
visible_turns = learner_visible_turns(sess)
summary_evaluation_record = _dashboard_evaluation_record(sess, evaluation_record)
learner_turns = sum(1 for turn in visible_turns if turn.speaker == "counselor")
client_turns = sum(1 for turn in visible_turns if turn.speaker == "client")
evaluation_status = _evaluation_status_value(summary_evaluation_record)
return TeacherSessionSummary(
session_id=sess.session_id,
learner_id=sess.learner_id,
learner_label=_session_learner_label(sess),
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(visible_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,
evaluation_status=evaluation_status,
review_ready=evaluation_status == "ready",
supervisor_state=_supervisor_state(
sess,
summary_evaluation_record,
has_visible_turns=bool(visible_turns),
),
evaluation_error=(
str(summary_evaluation_record.get("error") or "") if summary_evaluation_record else None
),
)
def _needs_teacher_review(item: TeacherSessionSummary) -> bool:
if item.status != "ended" or item.review_status == "closed":
return False
return item.turn_count > 0 or item.evaluation_status in {"ready", "error"}
@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_all_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)
ended_session_ids = [sess.session_id for sess in sessions if sess.ended]
review_statuses, _ = await session_persistence.list_session_review_statuses(
ended_session_ids,
principal,
)
evaluation_records, evaluations_durable = await session_persistence.list_session_evaluations(
ended_session_ids,
principal,
)
if not evaluations_durable:
require_runtime_fallback_allowed("session evaluation list")
summaries = [
_summary(sess, review_statuses.get(sess.session_id), evaluation_records.get(sess.session_id))
for sess in sessions
]
pending_reviews = [item for item in summaries if _needs_teacher_review(item)]
learners = {sess.learner_id for sess in sessions}
learner_growth = _build_learner_growth(sessions, limit=None)
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_all_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),
)
ended_session_ids = [sess.session_id for sess in ordered if sess.ended]
review_statuses, _ = await session_persistence.list_session_review_statuses(
ended_session_ids,
principal,
)
evaluation_records, evaluations_durable = await session_persistence.list_session_evaluations(
ended_session_ids,
principal,
)
if not evaluations_durable:
require_runtime_fallback_allowed("session evaluation list")
summaries = [
_summary(sess, review_statuses.get(sess.session_id), evaluation_records.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 _needs_teacher_review(item))
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",
)
if sess.ended and request.status == "closed" and learner_visible_turns(sess):
evaluation_record, evaluation_durable = await session_persistence.load_session_evaluation(
session_id,
principal,
)
if not evaluation_durable:
require_runtime_fallback_allowed("session evaluation")
if _evaluation_status_value(evaluation_record) != "ready":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="session evaluation must be ready before closing teacher review",
)
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,
)