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

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

@ -11,12 +11,12 @@ import re
from collections import Counter
from dataclasses import dataclass
from datetime import datetime
from typing import Literal, Optional, cast
from typing import Any, Literal, Optional, cast
from pydantic import BaseModel, Field
from .config import settings
from .services import session_metrics
from .services import guardrail, session_metrics
from .stage_contract import (
ReviewPhaseKey,
StageLabel,
@ -31,6 +31,10 @@ WorksheetItemSpec = tuple[str, str, list[str], WorksheetSpeaker | None]
WorksheetSectionSpec = tuple[str, str, list[WorksheetItemSpec]]
LEARNER_VISIBLE_AI_ROLE = "counselor"
MISSING_SESSION_EVALUATION_GRACE_SECONDS = 30.0
MISSING_SESSION_EVALUATION_ERROR = (
"회기말 평가가 제한 시간 이후에도 저장되지 않았습니다. AI 평가 재시도가 필요합니다."
)
class LearnerSessionSummary(BaseModel):
@ -816,6 +820,46 @@ def _review_summary_from_evaluation(
return prefix + (details if details else "아래 코칭 항목은 저장된 축어록과 평가 AI 결과를 기준으로 합니다.")
def _session_evaluation_timeout_seconds() -> float:
configured = float(settings.session_evaluation_timeout or settings.engine_timeout)
return max(1.0, configured)
def _missing_session_evaluation_record(
sess: InProcSession,
*,
has_visible_turns: bool,
now_ts: float,
) -> dict[str, object] | None:
if not sess.ended or not has_visible_turns or sess.ended_at is None:
return None
stale_after = _session_evaluation_timeout_seconds() + MISSING_SESSION_EVALUATION_GRACE_SECONDS
if now_ts - sess.ended_at < stale_after:
return None
return {
"status": "error",
"source": "read_model",
"scope": "session_end",
"stage": stage_label(sess.state.stage),
"payload": {"error": MISSING_SESSION_EVALUATION_ERROR},
"error": MISSING_SESSION_EVALUATION_ERROR,
"updated_at": iso(now_ts),
}
def missing_session_evaluation_record(
sess: InProcSession,
*,
has_visible_turns: bool,
now_ts: float,
) -> dict[str, object] | None:
return _missing_session_evaluation_record(
sess,
has_visible_turns=has_visible_turns,
now_ts=now_ts,
)
def _next_line_from_evaluation(payload: dict[str, object]) -> str | None:
alternatives = payload.get("alternative_utterances")
if not isinstance(alternatives, list):
@ -1025,7 +1069,26 @@ def _evaluation_payload(record: dict[str, object] | None) -> dict[str, object]:
if not record:
return {}
payload = record.get("payload")
return payload if isinstance(payload, dict) else {}
if not isinstance(payload, dict):
return {}
masked = _mask_payload_text_values(payload)
return masked if isinstance(masked, dict) else {}
def _mask_payload_text(value: object) -> str:
return guardrail.mask_pii(str(value or "")).text_masked
def _mask_payload_text_values(value: Any) -> Any:
if isinstance(value, str):
return _mask_payload_text(value)
if isinstance(value, dict):
return {str(key): _mask_payload_text_values(child) for key, child in value.items()}
if isinstance(value, list):
return [_mask_payload_text_values(child) for child in value]
if isinstance(value, tuple):
return [_mask_payload_text_values(child) for child in value]
return value
_TECHNIQUE_KIND_BY_CATEGORY = {
@ -1091,6 +1154,17 @@ def _review_note_from_turn_eval(
if not isinstance(ev, dict):
return None
quote = _review_quote_excerpt(learner_text)
error_text = str(ev.get("error") or "").strip()
if error_text:
return ReviewNote(
author="평가 AI",
tone="warn",
title="턴 평가 실패",
body=_review_note_body_markdown(
f"이 발화의 fast-loop 평가를 완료하지 못했습니다.\n\n사유: {error_text}"
),
quote=quote,
)
dev = ev.get("intent_deviation")
if isinstance(dev, dict):
dimension = str(dev.get("dimension") or "").strip()
@ -1261,7 +1335,14 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes
if duration_seconds > 0:
axis.append(_offset_label(duration_seconds))
now_ts = read_input.now_ts or datetime.now().timestamp()
evaluation_record = read_input.evaluation_record
if evaluation_record is None and not hidden_turns:
evaluation_record = _missing_session_evaluation_record(
sess,
has_visible_turns=bool(visible_turns),
now_ts=now_ts,
)
evaluation_payload = {} if hidden_turns else _evaluation_payload(evaluation_record)
evaluation_status = (
"" if hidden_turns else str(evaluation_record.get("status") or "") if evaluation_record else ""