음성 재생과 운영 배포 정리
This commit is contained in:
parent
8ed185ce6c
commit
ac7db95542
1020 changed files with 46863 additions and 2175 deletions
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue