322 lines
12 KiB
Python
322 lines
12 KiB
Python
"""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 ..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 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]) -> list[TeacherLearnerGrowth]:
|
|
metrics = session_metrics.build_learner_growth(
|
|
sessions,
|
|
learner_label=_learner_label,
|
|
limit=12,
|
|
)
|
|
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 _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
|
|
]
|
|
|
|
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.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,
|
|
)
|