세션 평가·라이브코치·교수자 분석 라운드 마감 + 문서 정리 + 코드품질 리팩터
- 누적 작업트리 커밋: 회기 평가 복구·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:
parent
7c41c3ce79
commit
778e8526d4
108 changed files with 6457 additions and 455 deletions
|
|
@ -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 ""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue