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

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

@ -16,7 +16,7 @@ from .deps import Principal, Role
from .engine_client import EngineError
from .routes import sessions
from .routes import voice as voice_routes
from .services import live_coach, memory, orchestrator, persona as persona_service, state_machine
from .services import guardrail, live_coach, memory, orchestrator, persona as persona_service, rag, state_machine
from .services.voice import TTSChunk, TranscriptResult, VoicePreset
from .store import InProcSession, TurnRecord, store
@ -29,6 +29,17 @@ async def _decoded_stream_packets(stream_engine, req):
yield packet
class _AsyncConnContext:
def __init__(self, conn):
self.conn = conn
async def __aenter__(self):
return self.conn
async def __aexit__(self, exc_type, exc, tb):
return False
def _principal() -> Principal:
return Principal(
user_id="00000000-0000-0000-0000-000000000101",
@ -83,6 +94,22 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
sessions._RECALL_CACHE.clear()
sessions._KB_CUES_CACHE.clear()
async def test_end_session_does_not_reschedule_evaluation_for_already_ended_session(self) -> None:
principal = _principal()
sess = _session(principal)
sess.ended = True
sess.ended_at = 1_000.0
with (
patch.object(sessions, "_load_session_or_404", AsyncMock(return_value=sess)),
patch.object(sessions, "_end_persisted_session", AsyncMock(return_value=None)),
patch.object(sessions, "_schedule_session_evaluation") as schedule_session_evaluation,
):
response = await sessions.end_session(sess.session_id, principal)
self.assertEqual(response.session_id, sess.session_id)
schedule_session_evaluation.assert_not_called()
async def test_append_turn_writes_provider_events_to_db(self) -> None:
class FakeConn:
def __init__(self) -> None:
@ -161,6 +188,80 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(caught.exception.status_code, 503)
self.assertEqual(sess.turns, [])
async def test_live_coach_turn_marks_runtime_persistence_source(self) -> None:
principal = _principal()
sess = _session(principal)
sess.turns.extend(
[
TurnRecord(
turn_seq=1,
speaker="counselor",
stage="explore",
text="요즘 가장 크게 남는 마음은 무엇인가요?",
text_masked="요즘 가장 크게 남는 마음은 무엇인가요?",
),
TurnRecord(
turn_seq=2,
speaker="client",
stage="explore",
text="잘 모르겠지만 계속 답답해요.",
text_masked="잘 모르겠지만 계속 답답해요.",
),
]
)
suggestion = live_coach.LiveCoachSuggestion(
status="ready",
tone="pos",
focus="emotion",
title="감정 반영이 선명합니다",
message="방금 응답의 정서를 먼저 붙잡았습니다.",
)
with (
patch.object(
sessions,
"_retrieve_live_coach_grounding",
AsyncMock(return_value=[]),
),
patch.object(
sessions.live_coach,
"generate_live_coaching",
AsyncMock(return_value=suggestion),
),
patch.object(
sessions.session_persistence,
"get_live_coach_quota",
AsyncMock(
side_effect=[
({"remaining": 3, "max": 3}, True),
({"remaining": 2, "max": 3}, True),
]
),
),
patch.object(
sessions.session_persistence,
"save_live_coach_event",
AsyncMock(return_value=({"event_id": "cached-live-coach"}, False)),
),
patch.object(
sessions.session_persistence,
"list_live_coach_credit_events",
AsyncMock(return_value=([], True)),
),
):
response = await sessions.live_coach_turn(
sess.session_id,
sessions.LiveCoachRequest(
learner_text="요즘 가장 크게 남는 마음은 무엇인가요?",
client_reply="잘 모르겠지만 계속 답답해요.",
turn_seq=1,
),
principal,
)
self.assertEqual(response.persistence_source, "runtime")
self.assertEqual(response.quota.remaining, 2)
async def test_generate_turn_persists_client_engine_telemetry(self) -> None:
principal = _principal()
sess = _session(principal)
@ -462,6 +563,70 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertIn("조금 말해볼게요", learner_turn.evaluation["appropriateness_note"])
self.assertIsNone(client_turn.evaluation)
async def test_stream_turn_surfaces_fast_loop_evaluation_failure_on_review(self) -> None:
principal = _principal()
sess = _session(principal)
async def successful_stream(ctx, engine, **kwargs):
assert ctx.state_after is not None
yield orchestrator.StreamEvent("token", {"text": "조금 말해볼게요."})
yield orchestrator.StreamEvent(
"done",
{
"session_id": ctx.session_id,
"stage": ctx.state_after.stage.value,
"effective_openness": ctx.state_after.effective_openness,
"turn_seq": ctx.state_after.turn_seq,
"safety_flagged": False,
"llm_provider": "claude_cli",
"model": "gateway-default",
"tokens_in": 9,
"tokens_out": 10,
"cost_usd": 0.001,
},
)
async def failing_eval_hook(ctx, client_reply):
raise RuntimeError("김서연 평가 timeout 010-1234-5678")
with patch.object(sessions.orchestrator, "run_turn_stream", successful_stream), patch.object(
sessions.evaluator,
"make_eval_hook",
return_value=failing_eval_hook,
):
response = await sessions.stream_turn(
sess.session_id,
sessions.TurnRequest(text="스트림 평가 실패 발화"),
principal,
)
await _consume_event_source(response)
self.assertEqual(len(sess.turns), 2)
learner_turn = sess.turns[0]
self.assertIsNotNone(learner_turn.evaluation)
assert learner_turn.evaluation is not None
self.assertEqual(learner_turn.evaluation["loop"], "fast")
self.assertEqual(learner_turn.evaluation["appropriateness"], "neutral")
self.assertEqual(learner_turn.evaluation["error"], "RuntimeError")
self.assertNotIn("김서연", learner_turn.evaluation["error"])
self.assertNotIn("010-1234-5678", learner_turn.evaluation["error"])
review = sessions.build_session_review(
sessions.SessionReviewReadInput(
session=sess,
evaluation_record=None,
evaluation_durable=False,
)
)
review_turn = review.turns[0]
self.assertIsNotNone(review_turn.note)
assert review_turn.note is not None
self.assertEqual(review_turn.note.title, "턴 평가 실패")
self.assertIn("fast-loop 평가를 완료하지 못했습니다", review_turn.note.body)
self.assertIn("RuntimeError", review_turn.note.body)
self.assertNotIn("김서연", review_turn.note.body)
self.assertNotIn("010-1234-5678", review_turn.note.body)
async def test_live_coach_degrades_to_rule_based_suggestion_when_engine_fails(self) -> None:
principal = _principal()
sess = _session(principal)
@ -507,12 +672,58 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(len(history.events), 1)
self.assertEqual(history.events[0].turn_seq, 1)
self.assertEqual(history.events[0].stage, "라포")
self.assertEqual(history.events[0].suggestion.status, "degraded")
self.assertEqual(history.events[0].suggestion.title, response.title)
self.assertIn("학교", history.events[0].learner_text_excerpt or "")
session_persistence._LIVE_COACH_EVENT_CACHE[sess.session_id][0]["stage"] = "unknown-stage"
legacy_history = await sessions.list_live_coach_history(sess.session_id, principal)
self.assertIsNone(legacy_history.events[0].stage)
async def test_live_coach_rag_grounding_preserves_source_pack_metadata(self) -> None:
retrieval = rag.RetrievalResult(
chunks=[
rag.RetrievedChunk(
chunk_id=17,
score=0.91,
kb_kind="supervisor_pattern",
heading_path="risk/protective-factors",
context_prefix="공식 자살위험 평가 지침 / 보호요인 / 2026-06-15",
body="최근성, 보호요인, 안전계획을 확인한다.",
meta={
"source_title": "공식 자살위험 평가 지침",
"source_type": "official_guideline_summary",
"source_version": "2026-06-15",
"citation": "허가된 공식 지침 요약",
},
source_id="official_suicide_risk_guidelines",
)
],
policy_name="evaluator:k=supervisor_pattern:s<=2",
top1_score=0.91,
latency_ms=12,
query_text="정리 humanistic 자살 위험 최근성 보호요인",
)
with (
patch.object(sessions.db, "acquire", return_value=_AsyncConnContext(object())),
patch.object(sessions.rag, "retrieve_eval_grounding", AsyncMock(return_value=retrieval)),
patch.object(sessions.rag, "log_retrieval", AsyncMock()),
):
grounding = await sessions._retrieve_live_coach_grounding(
learner_text="자살 위험 최근성과 보호요인을 확인했어요.",
client_reply="그래도 오늘은 친구에게 연락할 수 있어요.",
stage="정리",
theory_mode="humanistic",
)
self.assertEqual(len(grounding), 1)
source = grounding[0]
self.assertEqual(source.source_id, "official_suicide_risk_guidelines")
self.assertEqual(source.title, "공식 자살위험 평가 지침")
self.assertEqual(source.source_type, "official_guideline_summary")
self.assertEqual(source.version, "2026-06-15")
self.assertEqual(source.citation, "허가된 공식 지침 요약")
async def test_live_coach_persistence_failure_is_not_swallowed(self) -> None:
principal = _principal()
sess = _session(principal)
@ -1306,6 +1517,101 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertIn("주호소", response.teacherReview.worksheetNote)
self.assertEqual(response.teacherReview.worksheetReviewedAt, "2026-06-27T10:05:00Z")
async def test_review_remasks_legacy_session_evaluation_payload(self) -> None:
class FakeKoRecognizer:
def analyze(self, text: str):
spans = []
for entity_type, value in (
("NAME", "보라별"),
("ORG", "미래학교상담연구랩"),
):
start = text.find(value)
if start >= 0:
spans.append(guardrail.PiiEntitySpan(entity_type, start, start + len(value)))
return spans
guardrail.set_ko_pii_recognizer(FakeKoRecognizer())
self.addCleanup(guardrail.set_ko_pii_recognizer, None)
principal = _principal()
sess = _session(principal)
sess.ended = True
sess.ended_at = sess.created_at + 180
sess.turns.extend(
[
TurnRecord(
turn_seq=1,
speaker="counselor",
stage=sess.state.stage.value,
text="감정을 먼저 확인해보겠습니다.",
text_masked="감정을 먼저 확인해보겠습니다.",
created_at=sess.created_at + 1,
),
TurnRecord(
turn_seq=2,
speaker="client",
stage=sess.state.stage.value,
text="연락처 이야기는 부담스러워요.",
text_masked="연락처 이야기는 부담스러워요.",
created_at=sess.created_at + 2,
),
]
)
raw_record = {
"status": "ready",
"source": "engine",
"scope": "session_end",
"stage": "정리",
"payload": {
"loop": "deep",
"session_id": sess.session_id,
"stage": "정리",
"scope": "session_end",
"turns_evaluated": 2,
"distribution": {},
"strengths": ["보라별의 감정을 반영했다."],
"improvements": ["미래학교상담연구랩과 010-1234-5678 재확인을 줄인다."],
"intent_deviations": [
{
"dimension": "pacing",
"expected": "보라별의 감정 확인",
"actual": "010-1234-5678 연락처 재질문",
"severity": "moderate",
}
],
"supervisor_rationale": "보라별의 호소를 요약했다.",
"supervisor_critique": "미래학교상담연구랩 언급이 반복됐다.",
"alternative_utterances": ["보라별님, 지금 감정부터 천천히 볼까요?"],
},
"error": None,
}
with (
patch.object(
sessions.session_persistence,
"load_session",
AsyncMock(return_value=sess),
),
patch.object(
sessions.session_persistence,
"load_case_worksheet",
AsyncMock(return_value=(None, False)),
),
patch.object(
sessions.session_persistence,
"load_session_evaluation",
AsyncMock(return_value=(raw_record, True)),
),
):
response = await sessions.get_session_review(sess.session_id, principal)
blob = response.model_dump_json()
for raw in ("보라별", "미래학교상담연구랩", "010-1234-5678"):
self.assertNotIn(raw, blob)
for masked in ("[NAME]", "[ORG]", "[PHONE]"):
self.assertIn(masked, blob)
self.assertTrue(response.reviewReady)
self.assertEqual(response.supervisorState, "평가 완료")
async def test_review_surfaces_session_evaluation_error_record(self) -> None:
principal = _principal()
sess = _session(principal)
@ -1366,6 +1672,53 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(response.goodMoments, [])
self.assertEqual(response.growthPoints, [])
def test_review_marks_stale_missing_session_evaluation_as_failed(self) -> None:
principal = _principal()
sess = _session(principal)
sess.ended = True
sess.ended_at = sess.created_at + 180
sess.turns.extend(
[
TurnRecord(
turn_seq=1,
speaker="counselor",
stage=sess.state.stage.value,
text="많이 지쳐 보였어요. 지금 제일 버거운 마음이 뭔가요?",
text_masked="많이 지쳐 보였어요. 지금 제일 버거운 마음이 뭔가요?",
created_at=sess.created_at + 1,
),
TurnRecord(
turn_seq=2,
speaker="client",
stage=sess.state.stage.value,
text="그냥 아무것도 하고 싶지 않아요.",
text_masked="그냥 아무것도 하고 싶지 않아요.",
created_at=sess.created_at + 2,
),
]
)
previous_timeout = settings.session_evaluation_timeout
settings.session_evaluation_timeout = 10.0
try:
response = sessions.build_session_review(
sessions.SessionReviewReadInput(
session=sess,
evaluation_record=None,
evaluation_durable=True,
now_ts=(sess.ended_at or sess.created_at) + 41.0,
)
)
finally:
settings.session_evaluation_timeout = previous_timeout
self.assertFalse(response.reviewReady)
self.assertTrue(response.degraded)
self.assertEqual(response.supervisorState, "평가 실패")
self.assertIn("AI 평가 재시도가 필요합니다", response.summary)
self.assertEqual(response.rubric, [])
self.assertEqual(response.goodMoments, [])
self.assertEqual(response.growthPoints, [])
async def test_learner_can_save_case_formulation_worksheet(self) -> None:
principal = _principal()
sess = _session(principal)