vignette/apps/api/app/routes/teacher.py
2026-06-26 14:47:00 +09:00

103 lines
3.5 KiB
Python

"""Teacher dashboard routes backed by real server session state."""
from __future__ import annotations
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends
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 ..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: str
turn_count: int
learner_turn_count: int
client_turn_count: int
started_at: str
ended_at: str | None = None
class TeacherDashboardResponse(BaseModel):
source: str = "in_memory"
cohort_label: str = "현재 학습 기록"
total_learners: int
active_sessions: int
ended_sessions: int
pending_reviews: list[TeacherSessionSummary] = Field(default_factory=list)
recent_sessions: list[TeacherSessionSummary] = Field(default_factory=list)
message: str
def _iso(ts: float | None) -> str | None:
if ts is None:
return None
return datetime.fromtimestamp(ts).isoformat(timespec="seconds")
def _learner_label(learner_id: str) -> str:
suffix = learner_id[-6:] if len(learner_id) > 6 else learner_id
return f"학습자 {suffix}"
def _summary(sess: InProcSession) -> 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=sess.state.stage.value,
turn_count=len(sess.turns),
learner_turn_count=learner_turns,
client_turn_count=client_turns,
started_at=_iso(sess.created_at) or "",
ended_at=_iso(sess.ended_at),
)
@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)
if not durable:
require_runtime_fallback_allowed("teacher dashboard")
sessions = sorted(store.list(), key=lambda sess: sess.created_at, reverse=True)
summaries = [_summary(sess) for sess in sessions]
pending_reviews = [item for item in summaries if item.status == "ended"]
learners = {sess.learner_id for sess in sessions}
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),
pending_reviews=pending_reviews[:20],
recent_sessions=summaries[:20],
message=message,
)