세션 평가·라이브코치·교수자 분석 라운드 마감 + 문서 정리 + 코드품질 리팩터
- 누적 작업트리 커밋: 회기 평가 복구·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
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue