세션 평가·라이브코치·교수자 분석 라운드 마감 + 문서 정리 + 코드품질 리팩터
- 누적 작업트리 커밋: 회기 평가 복구·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
This commit is contained in:
parent
7c41c3ce79
commit
778e8526d4
108 changed files with 6457 additions and 455 deletions
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
|
@ -10,7 +11,12 @@ 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 ..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
|
||||
|
|
@ -37,6 +43,10 @@ class TeacherSessionSummary(BaseModel):
|
|||
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):
|
||||
|
|
@ -137,9 +147,14 @@ class TeacherDashboardResponse(BaseModel):
|
|||
message: str
|
||||
|
||||
|
||||
def _learner_label(learner_id: str) -> 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}"
|
||||
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:
|
||||
|
|
@ -163,9 +178,15 @@ def _build_learner_growth(
|
|||
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=_learner_label,
|
||||
learner_label=lambda learner_id: learner_labels.get(learner_id)
|
||||
or _learner_label_from_id(learner_id),
|
||||
limit=limit,
|
||||
point_limit=point_limit,
|
||||
)
|
||||
|
|
@ -225,22 +246,68 @@ def _worksheet_review_status_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:
|
||||
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")
|
||||
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=_learner_label(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(sess.turns),
|
||||
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 "",
|
||||
|
|
@ -248,34 +315,53 @@ def _summary(
|
|||
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_sessions(
|
||||
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(
|
||||
[sess.session_id for sess in sessions if sess.ended],
|
||||
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))
|
||||
_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 item.status == "ended" and item.review_status != "closed"
|
||||
]
|
||||
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)
|
||||
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)
|
||||
|
|
@ -334,7 +420,7 @@ async def learner_analysis(
|
|||
principal: TeacherPrincipal,
|
||||
) -> TeacherLearnerAnalysisResponse:
|
||||
"""Return one learner's full teacher-visible session timeline and trend."""
|
||||
sessions, durable = await session_persistence.list_sessions(
|
||||
sessions, durable = await session_persistence.list_all_sessions(
|
||||
principal,
|
||||
include_turn_evaluation=True,
|
||||
)
|
||||
|
|
@ -350,20 +436,23 @@ async def learner_analysis(
|
|||
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(
|
||||
[sess.session_id for sess in ordered if sess.ended],
|
||||
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))
|
||||
_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 item.status == "ended" and item.review_status != "closed"
|
||||
)
|
||||
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(
|
||||
|
|
@ -404,6 +493,18 @@ async def update_session_review_status(
|
|||
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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue