세션 평가·라이브코치·교수자 분석 라운드 마감 + 문서 정리 + 코드품질 리팩터

- 누적 작업트리 커밋: 회기 평가 복구·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:
Yun Chan 2026-07-02 02:50:36 +09:00
parent 7c41c3ce79
commit 778e8526d4
108 changed files with 6457 additions and 455 deletions

View file

@ -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,