세션 평가와 교수자 분석 보강

This commit is contained in:
Yun Chan 2026-07-01 12:10:52 +09:00
parent 5c4ac04e06
commit fe2796f05a
51 changed files with 4928 additions and 240 deletions

View file

@ -17,6 +17,7 @@ in-proc cache/session fallback 은 local dev 에서만 허용한다.
from __future__ import annotations
import logging
from typing import Annotated, Any, Optional
from fastapi import APIRouter, Depends, HTTPException, status
@ -33,6 +34,7 @@ from ..store import InProcSession
from ..store import store
router = APIRouter(prefix="/eval", tags=["eval"])
logger = logging.getLogger(__name__)
# 교수자/관리자만 평가 조회·트리거 (학습자 비노출)
TeacherOrAdmin = Annotated[Principal, Depends(require_role(Role.TEACHER, Role.ADMIN))]
@ -52,6 +54,9 @@ class EvaluationSummary(BaseModel):
session_id: str
stage: StageLabel | None = None
status: str | None = None
error: str | None = None
durable: bool = False
deep: Optional[dict[str, Any]] = None
distribution: dict[str, Any] = Field(default_factory=dict)
@ -76,11 +81,14 @@ async def _load_session_or_404(session_id: str, principal: Principal) -> InProcS
def _theory_mode_of(sess) -> Optional[str]:
# 회기에서 학습자가 명시 선택한 이론 모드가 최우선이다.
sess_theory = str(getattr(sess, "theory_mode", "") or "").strip()
if sess_theory:
return sess_theory
tt = getattr(sess.persona, "theory_target", None)
if isinstance(tt, (list, tuple)) and tt:
return ", ".join(str(x) for x in tt)
# store 가 theory_mode 문자열도 보유(InProcSession.theory_mode)
return getattr(sess, "theory_mode", None)
return None
def _summary_stage(value: object) -> StageLabel | None:
@ -93,6 +101,12 @@ async def eval_health() -> dict[str, str]:
return {"status": "ok", "owner": "features:evaluator", "loops": "fast,deep"}
def _session_evaluation_error_status(error: str) -> int:
if error.startswith("engine_error"):
return status.HTTP_503_SERVICE_UNAVAILABLE
return status.HTTP_502_BAD_GATEWAY
# ════════════════════════════════════════════════════════════════════════════
# 회기 deep-loop 재평가 트리거 (교수자/관리자)
# ════════════════════════════════════════════════════════════════════════════
@ -131,18 +145,35 @@ async def reevaluate_session(
audit_hook=session_persistence.record_llm_call_audit,
)
except EngineError as e:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"engine unavailable: {e}")
if result.error and result.error.startswith("engine_error"):
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=result.error)
await session_persistence.save_session_evaluation(
session_persistence.SessionEvaluationWrite.from_result(
detail = f"engine unavailable: {e}"
write = session_persistence.SessionEvaluationWrite.from_error(
session_id=session_id,
learner_id=sess.learner_id,
result=result,
scope=body.scope if body.scope in ("session_end", "stage_transition") else "session_end",
stage=sess.state.stage.value,
error=detail,
)
saved = await session_persistence.save_session_evaluation(write)
if not saved:
logger.error(
"session evaluation retry error record did not reach durable store: session_id=%s error=%s",
session_id,
write.error,
)
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=detail)
write = session_persistence.SessionEvaluationWrite.from_result(
session_id=session_id,
learner_id=sess.learner_id,
result=result,
)
saved = await session_persistence.save_session_evaluation(write)
if not saved:
detail = "session evaluation retry result was generated but could not be saved"
logger.error("%s: session_id=%s status=%s", detail, session_id, write.status)
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=detail)
if result.error:
raise HTTPException(_session_evaluation_error_status(result.error), detail=result.error)
return result
@ -193,6 +224,7 @@ async def reevaluate_turn(
learner_text_masked=learner.text_masked,
state_after=sess.state, # 조회 시점 상태(정밀 재현은 DB 스냅샷 도입 시)
memory=TurnMemory(recent_turns=recent),
theory_mode=_theory_mode_of(sess),
)
result = await evaluator.evaluate_turn(
@ -219,15 +251,26 @@ async def get_session_evaluation(
아직 평가 트리거가 없었다면 deep=None + 분포.
"""
await _load_session_or_404(session_id, principal)
record, _durable = await session_persistence.load_session_evaluation(session_id, principal)
record, durable = await session_persistence.load_session_evaluation(session_id, principal)
if record is None:
return EvaluationSummary(session_id=session_id, stage=None, deep=None, distribution={})
return EvaluationSummary(
session_id=session_id,
stage=None,
status=None,
error=None,
durable=durable,
deep=None,
distribution={},
)
payload = record.get("payload")
deep = payload if isinstance(payload, dict) else {}
distribution = deep.get("distribution")
return EvaluationSummary(
session_id=session_id,
stage=_summary_stage(record.get("stage") or deep.get("stage")),
status=str(record.get("status") or "") or None,
error=str(record.get("error") or "") or None,
durable=durable,
deep=deep,
distribution=distribution if isinstance(distribution, dict) else {},
)