세션 평가·라이브코치·교수자 분석 라운드 마감 + 문서 정리 + 코드품질 리팩터
- 누적 작업트리 커밋: 회기 평가 복구·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
|
|
@ -10,11 +10,37 @@ from fastapi import HTTPException
|
|||
from .config import settings
|
||||
from .deps import Principal, Role
|
||||
from .routes import teacher
|
||||
from .session_read_model import MISSING_SESSION_EVALUATION_ERROR
|
||||
from .services import state_machine
|
||||
from .services.persona import P1
|
||||
from .store import InProcSession, TurnRecord
|
||||
|
||||
|
||||
class _ListSessionsConn:
|
||||
def __init__(self) -> None:
|
||||
self.query = ""
|
||||
self.args: tuple[object, ...] = ()
|
||||
|
||||
async def fetch(self, query: str, *args: object) -> list[object]:
|
||||
self.query = query
|
||||
self.args = args
|
||||
return []
|
||||
|
||||
async def execute(self, _query: str, *args: object) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _AcquireCtx:
|
||||
def __init__(self, conn: _ListSessionsConn) -> None:
|
||||
self.conn = conn
|
||||
|
||||
async def __aenter__(self) -> _ListSessionsConn:
|
||||
return self.conn
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _principal() -> Principal:
|
||||
return Principal(
|
||||
user_id="00000000-0000-0000-0000-000000000901",
|
||||
|
|
@ -31,6 +57,8 @@ def _session(
|
|||
rapport: float,
|
||||
technique: str,
|
||||
created_at: float,
|
||||
learner_label: str | None = None,
|
||||
with_turns: bool = True,
|
||||
) -> InProcSession:
|
||||
state = state_machine.init_state(params=P1.openness_params())
|
||||
state.stage = state_machine.Stage.EXPLORE
|
||||
|
|
@ -46,6 +74,7 @@ def _session(
|
|||
created_at=created_at,
|
||||
ended_at=created_at + 600,
|
||||
ended=True,
|
||||
learner_label=learner_label,
|
||||
turns=[
|
||||
TurnRecord(
|
||||
turn_seq=1,
|
||||
|
|
@ -66,11 +95,51 @@ def _session(
|
|||
text="내담자 응답",
|
||||
text_masked="내담자 응답",
|
||||
),
|
||||
],
|
||||
]
|
||||
if with_turns
|
||||
else [],
|
||||
)
|
||||
|
||||
|
||||
class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_list_all_sessions_has_no_recent_limit_for_teacher_summaries(self) -> None:
|
||||
principal = _principal()
|
||||
conn = _ListSessionsConn()
|
||||
|
||||
with (
|
||||
patch.object(teacher.session_persistence, "get_pool"),
|
||||
patch.object(
|
||||
teacher.session_persistence,
|
||||
"acquire",
|
||||
return_value=_AcquireCtx(conn),
|
||||
),
|
||||
):
|
||||
sessions, durable = await teacher.session_persistence.list_all_sessions(principal)
|
||||
|
||||
self.assertEqual(sessions, [])
|
||||
self.assertTrue(durable)
|
||||
self.assertNotIn("LIMIT", conn.query)
|
||||
self.assertEqual(conn.args, ())
|
||||
|
||||
async def test_list_recent_sessions_keeps_recent_limit_for_general_lists(self) -> None:
|
||||
principal = _principal()
|
||||
conn = _ListSessionsConn()
|
||||
|
||||
with (
|
||||
patch.object(teacher.session_persistence, "get_pool"),
|
||||
patch.object(
|
||||
teacher.session_persistence,
|
||||
"acquire",
|
||||
return_value=_AcquireCtx(conn),
|
||||
),
|
||||
):
|
||||
sessions, durable = await teacher.session_persistence.list_recent_sessions(principal)
|
||||
|
||||
self.assertEqual(sessions, [])
|
||||
self.assertTrue(durable)
|
||||
self.assertIn("LIMIT $1", conn.query)
|
||||
self.assertEqual(conn.args, (100,))
|
||||
|
||||
async def test_dashboard_returns_learner_growth_from_turn_evaluations(self) -> None:
|
||||
learner_id = "00000000-0000-0000-0000-000000000111"
|
||||
sessions = [
|
||||
|
|
@ -78,6 +147,7 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
|
|||
session_id="00000000-0000-0000-0000-00000000a111",
|
||||
session_no=1,
|
||||
learner_id=learner_id,
|
||||
learner_label="김민수",
|
||||
score="neutral",
|
||||
rapport=0.1,
|
||||
technique="reflection",
|
||||
|
|
@ -87,6 +157,7 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
|
|||
session_id="00000000-0000-0000-0000-00000000a112",
|
||||
session_no=2,
|
||||
learner_id=learner_id,
|
||||
learner_label="김민수",
|
||||
score="pos",
|
||||
rapport=0.5,
|
||||
technique="reflection",
|
||||
|
|
@ -98,9 +169,9 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
|
|||
with (
|
||||
patch.object(
|
||||
teacher.session_persistence,
|
||||
"list_sessions",
|
||||
"list_all_sessions",
|
||||
AsyncMock(return_value=(sessions, True)),
|
||||
) as list_sessions,
|
||||
) as list_all_sessions,
|
||||
patch.object(
|
||||
teacher.session_persistence,
|
||||
"list_safety_alerts",
|
||||
|
|
@ -111,13 +182,19 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
|
|||
"list_session_review_statuses",
|
||||
AsyncMock(return_value=({}, True)),
|
||||
),
|
||||
patch.object(
|
||||
teacher.session_persistence,
|
||||
"list_session_evaluations",
|
||||
AsyncMock(return_value=({}, True)),
|
||||
),
|
||||
):
|
||||
response = await teacher.teacher_dashboard(principal)
|
||||
|
||||
list_sessions.assert_awaited_once_with(principal, include_turn_evaluation=True)
|
||||
list_all_sessions.assert_awaited_once_with(principal, include_turn_evaluation=True)
|
||||
self.assertEqual(response.total_learners, 1)
|
||||
self.assertEqual(len(response.learner_growth), 1)
|
||||
growth = response.learner_growth[0]
|
||||
self.assertEqual(growth.learner_label, "김민수")
|
||||
self.assertEqual(growth.sessions, 2)
|
||||
self.assertEqual(growth.ended_sessions, 2)
|
||||
self.assertEqual(growth.first_score, 0.5)
|
||||
|
|
@ -126,8 +203,56 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(growth.trend, "up")
|
||||
self.assertEqual(growth.top_techniques, ["reflection"])
|
||||
self.assertEqual([point.session_no for point in growth.points], [1, 2])
|
||||
self.assertEqual(response.recent_sessions[0].learner_label, "김민수")
|
||||
self.assertEqual(len(response.pending_reviews), 2)
|
||||
|
||||
async def test_dashboard_does_not_truncate_student_analysis_list(self) -> None:
|
||||
sessions = [
|
||||
_session(
|
||||
session_id=f"many-learners-session-{idx:02d}",
|
||||
session_no=1,
|
||||
learner_id=f"many-learner-{idx:02d}",
|
||||
learner_label=f"학생 {idx:02d}",
|
||||
score="pos",
|
||||
rapport=0.2,
|
||||
technique="reflection",
|
||||
created_at=1_000.0 + idx,
|
||||
)
|
||||
for idx in range(13)
|
||||
]
|
||||
principal = _principal()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
teacher.session_persistence,
|
||||
"list_all_sessions",
|
||||
AsyncMock(return_value=(sessions, True)),
|
||||
),
|
||||
patch.object(
|
||||
teacher.session_persistence,
|
||||
"list_safety_alerts",
|
||||
AsyncMock(return_value=([], True)),
|
||||
),
|
||||
patch.object(
|
||||
teacher.session_persistence,
|
||||
"list_session_review_statuses",
|
||||
AsyncMock(return_value=({}, True)),
|
||||
),
|
||||
patch.object(
|
||||
teacher.session_persistence,
|
||||
"list_session_evaluations",
|
||||
AsyncMock(return_value=({}, True)),
|
||||
),
|
||||
):
|
||||
response = await teacher.teacher_dashboard(principal)
|
||||
|
||||
self.assertEqual(response.total_learners, 13)
|
||||
self.assertEqual(len(response.learner_growth), 13)
|
||||
self.assertEqual(
|
||||
{item.learner_id for item in response.learner_growth},
|
||||
{f"many-learner-{idx:02d}" for idx in range(13)},
|
||||
)
|
||||
|
||||
async def test_dashboard_excludes_closed_session_reviews_from_pending_queue(self) -> None:
|
||||
learner_id = "00000000-0000-0000-0000-000000000111"
|
||||
open_session = _session(
|
||||
|
|
@ -153,7 +278,7 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
|
|||
with (
|
||||
patch.object(
|
||||
teacher.session_persistence,
|
||||
"list_sessions",
|
||||
"list_all_sessions",
|
||||
AsyncMock(return_value=([open_session, closed_session], True)),
|
||||
),
|
||||
patch.object(
|
||||
|
|
@ -178,6 +303,11 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
|
|||
)
|
||||
),
|
||||
),
|
||||
patch.object(
|
||||
teacher.session_persistence,
|
||||
"list_session_evaluations",
|
||||
AsyncMock(return_value=({}, True)),
|
||||
),
|
||||
):
|
||||
response = await teacher.teacher_dashboard(principal)
|
||||
|
||||
|
|
@ -187,6 +317,159 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
|
|||
)
|
||||
self.assertEqual(closed_summary.review_status, "closed")
|
||||
|
||||
async def test_dashboard_excludes_empty_ended_sessions_from_pending_queue(self) -> None:
|
||||
principal = _principal()
|
||||
empty_session = _session(
|
||||
session_id="00000000-0000-0000-0000-00000000b411",
|
||||
session_no=1,
|
||||
learner_id="00000000-0000-0000-0000-000000000333",
|
||||
learner_label="하린",
|
||||
score="neutral",
|
||||
rapport=0.1,
|
||||
technique="reflection",
|
||||
created_at=1_000.0,
|
||||
with_turns=False,
|
||||
)
|
||||
reviewable_session = _session(
|
||||
session_id="00000000-0000-0000-0000-00000000b412",
|
||||
session_no=2,
|
||||
learner_id="00000000-0000-0000-0000-000000000333",
|
||||
learner_label="하린",
|
||||
score="pos",
|
||||
rapport=0.5,
|
||||
technique="reflection",
|
||||
created_at=2_000.0,
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
teacher.session_persistence,
|
||||
"list_all_sessions",
|
||||
AsyncMock(return_value=([empty_session, reviewable_session], True)),
|
||||
),
|
||||
patch.object(
|
||||
teacher.session_persistence,
|
||||
"list_safety_alerts",
|
||||
AsyncMock(return_value=([], True)),
|
||||
),
|
||||
patch.object(
|
||||
teacher.session_persistence,
|
||||
"list_session_review_statuses",
|
||||
AsyncMock(return_value=({}, True)),
|
||||
),
|
||||
patch.object(
|
||||
teacher.session_persistence,
|
||||
"list_session_evaluations",
|
||||
AsyncMock(return_value=({}, True)),
|
||||
),
|
||||
):
|
||||
response = await teacher.teacher_dashboard(principal)
|
||||
|
||||
self.assertEqual([item.session_id for item in response.pending_reviews], [reviewable_session.session_id])
|
||||
empty_summary = next(
|
||||
item for item in response.recent_sessions if item.session_id == empty_session.session_id
|
||||
)
|
||||
self.assertEqual(empty_summary.turn_count, 0)
|
||||
self.assertEqual(empty_summary.supervisor_state, "기록 대기")
|
||||
self.assertFalse(empty_summary.review_ready)
|
||||
|
||||
async def test_dashboard_surfaces_session_evaluation_failure(self) -> None:
|
||||
principal = _principal()
|
||||
sess = _session(
|
||||
session_id="00000000-0000-0000-0000-00000000b311",
|
||||
session_no=6,
|
||||
learner_id="00000000-0000-0000-0000-000000000333",
|
||||
learner_label="하린",
|
||||
score="neutral",
|
||||
rapport=0.1,
|
||||
technique="reflection",
|
||||
created_at=1_000.0,
|
||||
)
|
||||
evaluation_record = {
|
||||
"status": "error",
|
||||
"source": "engine",
|
||||
"scope": "session_end",
|
||||
"stage": "정리",
|
||||
"payload": {},
|
||||
"error": "session evaluation timeout after 45s",
|
||||
"updated_at": "2026-07-01T00:00:00Z",
|
||||
}
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
teacher.session_persistence,
|
||||
"list_all_sessions",
|
||||
AsyncMock(return_value=([sess], True)),
|
||||
),
|
||||
patch.object(
|
||||
teacher.session_persistence,
|
||||
"list_safety_alerts",
|
||||
AsyncMock(return_value=([], True)),
|
||||
),
|
||||
patch.object(
|
||||
teacher.session_persistence,
|
||||
"list_session_review_statuses",
|
||||
AsyncMock(return_value=({}, True)),
|
||||
),
|
||||
patch.object(
|
||||
teacher.session_persistence,
|
||||
"list_session_evaluations",
|
||||
AsyncMock(return_value=({sess.session_id: evaluation_record}, True)),
|
||||
),
|
||||
):
|
||||
response = await teacher.teacher_dashboard(principal)
|
||||
|
||||
failed = response.pending_reviews[0]
|
||||
self.assertEqual(failed.learner_label, "하린")
|
||||
self.assertEqual(failed.evaluation_status, "error")
|
||||
self.assertFalse(failed.review_ready)
|
||||
self.assertEqual(failed.supervisor_state, "평가 실패")
|
||||
self.assertEqual(failed.evaluation_error, "session evaluation timeout after 45s")
|
||||
|
||||
async def test_dashboard_marks_stale_missing_session_evaluation_as_failed(self) -> None:
|
||||
principal = _principal()
|
||||
sess = _session(
|
||||
session_id="00000000-0000-0000-0000-00000000b411",
|
||||
session_no=7,
|
||||
learner_id="00000000-0000-0000-0000-000000000333",
|
||||
learner_label="하린",
|
||||
score="neutral",
|
||||
rapport=0.1,
|
||||
technique="reflection",
|
||||
created_at=1_000.0,
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
teacher.session_persistence,
|
||||
"list_all_sessions",
|
||||
AsyncMock(return_value=([sess], True)),
|
||||
),
|
||||
patch.object(
|
||||
teacher.session_persistence,
|
||||
"list_safety_alerts",
|
||||
AsyncMock(return_value=([], True)),
|
||||
),
|
||||
patch.object(
|
||||
teacher.session_persistence,
|
||||
"list_session_review_statuses",
|
||||
AsyncMock(return_value=({}, True)),
|
||||
),
|
||||
patch.object(
|
||||
teacher.session_persistence,
|
||||
"list_session_evaluations",
|
||||
AsyncMock(return_value=({}, True)),
|
||||
),
|
||||
):
|
||||
response = await teacher.teacher_dashboard(principal)
|
||||
|
||||
failed = response.pending_reviews[0]
|
||||
self.assertEqual(failed.learner_label, "하린")
|
||||
self.assertEqual(failed.evaluation_status, "error")
|
||||
self.assertFalse(failed.review_ready)
|
||||
self.assertEqual(failed.supervisor_state, "평가 실패")
|
||||
self.assertEqual(failed.evaluation_error, MISSING_SESSION_EVALUATION_ERROR)
|
||||
|
||||
async def test_dashboard_fails_closed_when_safety_alerts_are_not_durable_outside_dev(self) -> None:
|
||||
principal = _principal()
|
||||
sess = _session(
|
||||
|
|
@ -204,7 +487,7 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
|
|||
with (
|
||||
patch.object(
|
||||
teacher.session_persistence,
|
||||
"list_sessions",
|
||||
"list_all_sessions",
|
||||
AsyncMock(return_value=([sess], True)),
|
||||
),
|
||||
patch.object(
|
||||
|
|
@ -217,6 +500,11 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
|
|||
"list_session_review_statuses",
|
||||
AsyncMock(return_value=({}, True)),
|
||||
),
|
||||
patch.object(
|
||||
teacher.session_persistence,
|
||||
"list_session_evaluations",
|
||||
AsyncMock(return_value=({}, True)),
|
||||
),
|
||||
):
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
await teacher.teacher_dashboard(principal)
|
||||
|
|
@ -244,6 +532,11 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
|
|||
"load_session",
|
||||
AsyncMock(return_value=sess),
|
||||
),
|
||||
patch.object(
|
||||
teacher.session_persistence,
|
||||
"load_session_evaluation",
|
||||
AsyncMock(return_value=({"status": "ready"}, True)),
|
||||
),
|
||||
patch.object(
|
||||
teacher.session_persistence,
|
||||
"save_session_review_status",
|
||||
|
|
@ -286,6 +579,46 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertIn("보호요인", response.worksheet_note)
|
||||
self.assertEqual(response.worksheet_reviewed_at, "2026-06-27T10:05:00Z")
|
||||
|
||||
async def test_teacher_cannot_close_review_before_ai_session_evaluation_ready(self) -> None:
|
||||
principal = _principal()
|
||||
sess = _session(
|
||||
session_id="00000000-0000-0000-0000-00000000c211",
|
||||
session_no=2,
|
||||
learner_id="00000000-0000-0000-0000-000000000222",
|
||||
score="pos",
|
||||
rapport=0.5,
|
||||
technique="reflection",
|
||||
created_at=3_000.0,
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
teacher.session_persistence,
|
||||
"load_session",
|
||||
AsyncMock(return_value=sess),
|
||||
),
|
||||
patch.object(
|
||||
teacher.session_persistence,
|
||||
"load_session_evaluation",
|
||||
AsyncMock(return_value=({"status": "error", "error": "timeout"}, True)),
|
||||
),
|
||||
patch.object(
|
||||
teacher.session_persistence,
|
||||
"save_session_review_status",
|
||||
AsyncMock(),
|
||||
) as save_status,
|
||||
):
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
await teacher.update_session_review_status(
|
||||
sess.session_id,
|
||||
teacher.TeacherSessionReviewStatusRequest(status="closed", note="완료"),
|
||||
principal,
|
||||
)
|
||||
|
||||
self.assertEqual(raised.exception.status_code, 409)
|
||||
self.assertIn("session evaluation must be ready", raised.exception.detail)
|
||||
save_status.assert_not_awaited()
|
||||
|
||||
async def test_learner_analysis_returns_full_ordered_history(self) -> None:
|
||||
learner_id = "00000000-0000-0000-0000-000000000111"
|
||||
other_learner_id = "00000000-0000-0000-0000-000000000222"
|
||||
|
|
@ -294,6 +627,7 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
|
|||
session_id=f"analysis-session-{idx}",
|
||||
session_no=idx,
|
||||
learner_id=learner_id,
|
||||
learner_label="이서연",
|
||||
score="pos" if idx >= 5 else "neutral",
|
||||
rapport=0.1 * idx,
|
||||
technique="reflection" if idx % 2 else "open question",
|
||||
|
|
@ -319,9 +653,9 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
|
|||
with (
|
||||
patch.object(
|
||||
teacher.session_persistence,
|
||||
"list_sessions",
|
||||
"list_all_sessions",
|
||||
AsyncMock(return_value=(sessions, True)),
|
||||
) as list_sessions,
|
||||
) as list_all_sessions,
|
||||
patch.object(
|
||||
teacher.session_persistence,
|
||||
"list_session_review_statuses",
|
||||
|
|
@ -339,17 +673,25 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
|
|||
)
|
||||
),
|
||||
),
|
||||
patch.object(
|
||||
teacher.session_persistence,
|
||||
"list_session_evaluations",
|
||||
AsyncMock(return_value=({}, True)),
|
||||
),
|
||||
):
|
||||
response = await teacher.learner_analysis(learner_id, principal)
|
||||
|
||||
list_sessions.assert_awaited_once_with(principal, include_turn_evaluation=True)
|
||||
list_all_sessions.assert_awaited_once_with(principal, include_turn_evaluation=True)
|
||||
self.assertEqual(response.source, "database")
|
||||
self.assertEqual(response.learner_id, learner_id)
|
||||
self.assertEqual(response.learner_label, "이서연")
|
||||
self.assertEqual(response.summary.learner_label, "이서연")
|
||||
self.assertEqual(response.total_sessions, 7)
|
||||
self.assertEqual(response.ended_sessions, 7)
|
||||
self.assertEqual(response.pending_reviews, 6)
|
||||
self.assertEqual(response.closed_reviews, 1)
|
||||
self.assertEqual([session.session_no for session in response.sessions], [1, 2, 3, 4, 5, 6, 7])
|
||||
self.assertTrue(all(session.learner_label == "이서연" for session in response.sessions))
|
||||
self.assertEqual([point.session_no for point in response.points], [1, 2, 3, 4, 5, 6, 7])
|
||||
self.assertEqual(response.sessions[1].review_status, "closed")
|
||||
explore = next(item for item in response.stage_breakdown if item.stage == "탐색")
|
||||
|
|
@ -360,7 +702,7 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
|
|||
principal = _principal()
|
||||
with patch.object(
|
||||
teacher.session_persistence,
|
||||
"list_sessions",
|
||||
"list_all_sessions",
|
||||
AsyncMock(return_value=([], True)),
|
||||
):
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue