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

- 누적 작업트리 커밋: 회기 평가 복구·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

@ -26,6 +26,7 @@ from ..deps import CurrentPrincipal, Principal, Role
from ..engine_client import EngineError, engine_client
from ..persona_repository import get_catalog_persona
from ..runtime_policy import require_runtime_fallback_allowed
from ..session_evaluation_input import enriched_masked_turns
from ..services import (
evaluator,
guardrail,
@ -42,6 +43,7 @@ from ..session_read_model import (
LearnerSessionsResponse,
LearnerSessionSummary,
LEARNER_VISIBLE_AI_ROLE,
MISSING_SESSION_EVALUATION_GRACE_SECONDS,
ReviewCaseWorksheet,
ReviewCaseWorksheetSaveRequest,
ReviewWorksheetItem,
@ -70,6 +72,8 @@ from ..store import InProcSession, TurnRecord, store
router = APIRouter(prefix="/sessions", tags=["sessions"])
logger = logging.getLogger(__name__)
_SESSION_EVALUATION_IN_FLIGHT: set[str] = set()
_SESSION_EVALUATION_RECOVERY_TASK: asyncio.Task[int] | None = None
TheoryMode = Literal["humanistic", "cbt", "integrative"]
EndStateValue = str | int | float | bool | None | dict[str, float]
@ -102,7 +106,14 @@ class LiveCoachRequest(BaseModel):
class LiveCoachHistoryResponse(BaseModel):
source: Literal["database", "runtime"] = "runtime"
quota: live_coach.LiveCoachQuota = Field(
default_factory=lambda: live_coach.LiveCoachQuota(
remaining=session_persistence.LIVE_COACH_INITIAL_CREDITS,
max=session_persistence.LIVE_COACH_MAX_CREDITS,
)
)
events: list[live_coach.LiveCoachEvent] = Field(default_factory=list)
credit_events: list[live_coach.LiveCoachCreditEvent] = Field(default_factory=list)
class CrisisResourceResponse(BaseModel):
@ -220,12 +231,20 @@ async def _retrieve_live_coach_grounding(
body = chunk.body or chunk.behavior_cue or chunk.context_prefix or ""
if not body:
continue
meta = chunk.meta if isinstance(chunk.meta, dict) else {}
title = str(meta.get("source_title") or meta.get("title") or chunk.source_id or "Vignette KB").strip()
source_type = str(meta.get("source_type") or "").strip()
source_version = str(meta.get("source_version") or meta.get("version") or "").strip()
citation = str(meta.get("citation") or "").strip()
out.append(
live_coach.LiveCoachGrounding(
source_id=chunk.source_id or f"kb:{chunk.chunk_id}",
title=chunk.source_id or "Vignette KB",
title=title or "Vignette KB",
locator=chunk.heading_path,
kb_kind=chunk.kb_kind,
source_type=source_type or None,
version=source_version or None,
citation=citation or None,
summary=body[:500],
)
)
@ -643,8 +662,13 @@ async def _evaluate_stream_turn(ctx: orchestrator.TurnContext, final_reply: str)
audit_hook=session_persistence.record_llm_call_audit,
)
return await hook(ctx, final_reply)
except Exception:
return None
except Exception as exc:
logger.warning(
"turn fast-loop evaluation failed: session_id=%s",
ctx.session_id,
exc_info=True,
)
return orchestrator.turn_evaluation_error_payload(ctx, exc)
def _stream_result_from_done(
@ -678,11 +702,7 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
return
timeout_seconds = _session_evaluation_timeout_seconds()
enriched: list[dict[str, object]] = []
for index, turn in enumerate(sess.masked_turns(), start=1):
item: dict[str, object] = dict(turn)
item["seq"] = index
enriched.append(item)
enriched = enriched_masked_turns(sess.masked_turns())
try:
result = await asyncio.wait_for(
@ -753,6 +773,7 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
def _observe_session_evaluation_task(task: asyncio.Task[None], session_id: str) -> None:
_SESSION_EVALUATION_IN_FLIGHT.discard(session_id)
try:
task.result()
except asyncio.CancelledError:
@ -764,6 +785,13 @@ def _observe_session_evaluation_task(task: asyncio.Task[None], session_id: str)
def _schedule_session_evaluation(sess: InProcSession) -> asyncio.Task[None] | None:
if not sess.turns:
return None
if sess.session_id in _SESSION_EVALUATION_IN_FLIGHT:
logger.info(
"session evaluation already scheduled: session_id=%s",
sess.session_id,
)
return None
_SESSION_EVALUATION_IN_FLIGHT.add(sess.session_id)
task = asyncio.create_task(
_generate_and_save_session_evaluation(sess),
name=f"session-evaluation:{sess.session_id}",
@ -774,6 +802,66 @@ def _schedule_session_evaluation(sess: InProcSession) -> asyncio.Task[None] | No
return task
async def recover_missing_session_evaluations(*, limit: int | None = None) -> int:
recovery_limit = settings.session_evaluation_recovery_limit if limit is None else limit
if recovery_limit <= 0:
return 0
stale_after_seconds = (
_session_evaluation_timeout_seconds()
+ MISSING_SESSION_EVALUATION_GRACE_SECONDS
)
candidates, durable = await session_persistence.list_sessions_missing_session_evaluation(
older_than_seconds=stale_after_seconds,
limit=recovery_limit,
)
if not durable:
logger.warning("session evaluation recovery skipped: durable store unavailable")
return 0
scheduled = 0
for sess in candidates:
if _schedule_session_evaluation(sess) is not None:
scheduled += 1
if scheduled:
logger.info("session evaluation recovery scheduled %d session(s)", scheduled)
return scheduled
def _observe_session_evaluation_recovery_task(task: asyncio.Task[int]) -> None:
global _SESSION_EVALUATION_RECOVERY_TASK
if _SESSION_EVALUATION_RECOVERY_TASK is task:
_SESSION_EVALUATION_RECOVERY_TASK = None
try:
task.result()
except asyncio.CancelledError:
logger.warning("session evaluation recovery task cancelled")
except Exception:
logger.exception("session evaluation recovery task crashed")
def schedule_missing_session_evaluation_recovery() -> asyncio.Task[int] | None:
global _SESSION_EVALUATION_RECOVERY_TASK
if settings.session_evaluation_recovery_limit <= 0:
return None
if (
_SESSION_EVALUATION_RECOVERY_TASK is not None
and not _SESSION_EVALUATION_RECOVERY_TASK.done()
):
return _SESSION_EVALUATION_RECOVERY_TASK
task = asyncio.create_task(
recover_missing_session_evaluations(),
name="session-evaluation-recovery",
)
_SESSION_EVALUATION_RECOVERY_TASK = task
task.add_done_callback(_observe_session_evaluation_recovery_task)
return task
def cancel_missing_session_evaluation_recovery() -> None:
task = _SESSION_EVALUATION_RECOVERY_TASK
if task is not None and not task.done():
task.cancel()
def _session_evaluation_timeout_seconds() -> float:
configured = float(settings.session_evaluation_timeout or settings.engine_timeout)
return max(configured, 1.0)
@ -792,12 +880,12 @@ async def _load_learner_sessions(
include_turn_evaluation: bool = False,
) -> tuple[list[InProcSession], bool]:
if include_turn_evaluation:
sessions, durable = await session_persistence.list_sessions(
sessions, durable = await session_persistence.list_recent_sessions(
principal,
include_turn_evaluation=True,
)
else:
sessions, durable = await session_persistence.list_sessions(principal)
sessions, durable = await session_persistence.list_recent_sessions(principal)
if not durable:
require_runtime_fallback_allowed("session list")
sessions = [
@ -1254,9 +1342,16 @@ async def list_live_coach_history(
principal = _ensure_learner(principal)
await _load_session_or_404(session_id, principal)
events, durable = await session_persistence.list_live_coach_events(session_id, principal)
quota, quota_durable = await session_persistence.get_live_coach_quota(session_id, principal)
credit_events, credit_durable = await session_persistence.list_live_coach_credit_events(
session_id,
principal,
)
return LiveCoachHistoryResponse(
source="database" if durable else "runtime",
source="database" if durable and quota_durable and credit_durable else "runtime",
quota=live_coach.LiveCoachQuota(**quota),
events=[live_coach.LiveCoachEvent(**event) for event in events],
credit_events=[live_coach.LiveCoachCreditEvent(**event) for event in credit_events],
)
@ -1269,6 +1364,12 @@ async def live_coach_turn(
"""방금 완료된 턴에 대한 비차단 라이브 코칭을 반환한다."""
principal = _ensure_learner(principal)
sess = await _load_session_or_404(session_id, principal)
quota, _ = await session_persistence.get_live_coach_quota(session_id, principal)
if int(quota.get("remaining", 0)) <= 0:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="live coach credit exhausted",
)
turn_seq = body.turn_seq or max(1, int(getattr(sess.state, "turn_seq", 1) or 1))
stage = _stage_label(sess.state.stage)
grounding = await _retrieve_live_coach_grounding(
@ -1296,16 +1397,35 @@ async def live_coach_turn(
grounding=grounding,
audit_hook=session_persistence.record_llm_call_audit,
)
await session_persistence.save_live_coach_event(
session_id=sess.session_id,
learner_id=sess.learner_id,
turn_seq=turn_seq,
stage=stage,
learner_text=body.learner_text,
client_reply=body.client_reply,
suggestion=suggestion,
try:
_, coach_event_durable = await session_persistence.save_live_coach_event(
session_id=sess.session_id,
learner_id=sess.learner_id,
turn_seq=turn_seq,
stage=stage,
learner_text=body.learner_text,
client_reply=body.client_reply,
suggestion=suggestion,
)
except session_persistence.LiveCoachCreditExhausted as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="live coach credit exhausted",
) from exc
quota_after, quota_durable = await session_persistence.get_live_coach_quota(session_id, principal)
credit_events, credit_durable = await session_persistence.list_live_coach_credit_events(session_id, principal)
turn_credit_events = [
live_coach.LiveCoachCreditEvent(**event)
for event in credit_events
if int(event.get("turn_seq") or 0) == int(turn_seq)
]
return suggestion.model_copy(
update={
"persistence_source": "database" if coach_event_durable and quota_durable and credit_durable else "runtime",
"quota": live_coach.LiveCoachQuota(**quota_after),
"credit_events": turn_credit_events[-2:],
}
)
return suggestion
@router.post("/{session_id}/stream")
@ -1398,6 +1518,7 @@ async def end_session(
"""End a learner-owned session and prepare carry-over state."""
principal = _ensure_learner(principal)
sess = await _load_session_or_404(session_id, principal, allow_ended=True)
was_ended = bool(sess.ended)
recall = _RECALL_CACHE.get(session_id) or memory.RecallContext()
carry = memory.make_carry_over(
@ -1413,7 +1534,8 @@ async def end_session(
await _end_persisted_session(sess, carry)
_RECALL_CACHE.pop(session_id, None)
_KB_CUES_CACHE.pop(session_id, None)
_schedule_session_evaluation(sess)
if not was_ended:
_schedule_session_evaluation(sess)
return SessionEndResponse(
session_id=session_id,