음성 재생과 운영 배포 정리

This commit is contained in:
Yun Chan 2026-06-28 12:18:20 +09:00
parent 8ed185ce6c
commit ac7db95542
1020 changed files with 46863 additions and 2175 deletions

View file

@ -2,15 +2,15 @@
from __future__ import annotations
from datetime import datetime
from typing import Annotated, Any
from typing import Annotated, Literal
from fastapi import APIRouter, Depends
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 ..services import session_metrics
from ..store import InProcSession, store
router = APIRouter(prefix="/teacher", tags=["teacher"])
@ -32,6 +32,23 @@ class TeacherSessionSummary(BaseModel):
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)
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
class TeacherGrowthPoint(BaseModel):
@ -91,168 +108,63 @@ class TeacherDashboardResponse(BaseModel):
message: str
_APPROPRIATENESS_SCORE = {
"neg": 0.0,
"neutral": 0.5,
"pos": 1.0,
}
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 _safe_float(value: object) -> float | None:
try:
return float(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
def _avg(values: list[float]) -> float | None:
if not values:
return None
return round(sum(values) / len(values), 3)
def _turn_eval(turn: Any) -> dict[str, Any] | None:
ev = getattr(turn, "evaluation", None)
return ev if isinstance(ev, dict) else None
def _turn_score(ev: dict[str, Any]) -> float | None:
raw = str(ev.get("appropriateness") or "").strip().lower()
return _APPROPRIATENESS_SCORE.get(raw)
def _turn_rapport(ev: dict[str, Any]) -> float | None:
value = _safe_float(ev.get("rapport_signal"))
if value is None:
return None
return max(-1.0, min(1.0, value))
def _turn_techniques(ev: dict[str, Any]) -> list[str]:
raw = ev.get("techniques")
if not isinstance(raw, list):
return []
labels: list[str] = []
for item in raw:
if isinstance(item, dict):
label = item.get("label") or item.get("name") or item.get("id")
else:
label = item
if label:
labels.append(str(label))
return labels
def _session_growth_point(sess: InProcSession) -> TeacherGrowthPoint:
scores: list[float] = []
rapports: list[float] = []
technique_count = 0
watch_count = 0
for turn in sess.turns:
if turn.speaker != "counselor":
continue
ev = _turn_eval(turn)
if ev is None:
continue
score = _turn_score(ev)
if score is not None:
scores.append(score)
if score < 1.0:
watch_count += 1
rapport = _turn_rapport(ev)
if rapport is not None:
rapports.append(rapport)
technique_count += len(_turn_techniques(ev))
def _growth_point(point: session_metrics.SessionGrowthPoint) -> TeacherGrowthPoint:
return TeacherGrowthPoint(
session_id=sess.session_id,
session_no=sess.session_no,
persona_code=sess.persona_code,
stage=sess.state.stage.value,
started_at=_iso(sess.created_at) or "",
ended_at=_iso(sess.ended_at),
score=_avg(scores),
rapport=_avg(rapports),
technique_count=technique_count,
watch_count=watch_count,
session_id=point.session_id,
session_no=point.session_no,
persona_code=point.persona_code,
stage=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]:
grouped: dict[str, list[InProcSession]] = {}
for sess in sessions:
grouped.setdefault(sess.learner_id, []).append(sess)
result: list[TeacherLearnerGrowth] = []
for learner_id, learner_sessions in grouped.items():
ordered = sorted(learner_sessions, key=lambda sess: sess.created_at)
points = [_session_growth_point(sess) for sess in ordered]
scored = [point for point in points if point.score is not None]
rapport_values = [point.rapport for point in points if point.rapport is not None]
technique_counts: dict[str, int] = {}
for sess in ordered:
for turn in sess.turns:
if turn.speaker != "counselor":
continue
ev = _turn_eval(turn)
if ev is None:
continue
for label in _turn_techniques(ev):
technique_counts[label] = technique_counts.get(label, 0) + 1
first_score = scored[0].score if scored else None
latest_score = scored[-1].score if scored else None
score_delta: float | None = None
trend = "insufficient"
if first_score is not None and latest_score is not None:
score_delta = round(latest_score - first_score, 3)
if len(scored) >= 2:
if score_delta >= 0.1:
trend = "up"
elif score_delta <= -0.1:
trend = "down"
else:
trend = "flat"
latest_session = ordered[-1]
top_techniques = [
label
for label, _count in sorted(
technique_counts.items(),
key=lambda item: (-item[1], item[0]),
)[:3]
]
result.append(
TeacherLearnerGrowth(
learner_id=learner_id,
learner_label=_learner_label(learner_id),
sessions=len(ordered),
ended_sessions=sum(1 for sess in ordered if sess.ended),
latest_at=_iso(latest_session.ended_at or latest_session.created_at) or "",
first_score=first_score,
latest_score=latest_score,
score_delta=score_delta,
avg_score=_avg([point.score for point in scored if point.score is not None]),
avg_rapport=_avg([value for value in rapport_values if value is not None]),
trend=trend,
top_techniques=top_techniques,
points=points[-6:],
)
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],
)
return sorted(result, key=lambda item: item.latest_at, reverse=True)[:12]
for item in metrics
]
def _summary(sess: InProcSession) -> TeacherSessionSummary:
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 _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(
@ -267,8 +179,11 @@ def _summary(sess: InProcSession) -> TeacherSessionSummary:
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),
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,
)
@ -282,8 +197,19 @@ async def teacher_dashboard(principal: TeacherPrincipal) -> TeacherDashboardResp
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"]
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] = []
@ -331,3 +257,46 @@ async def teacher_dashboard(principal: TeacherPrincipal) -> TeacherDashboardResp
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,
)
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,
)