vignette/apps/api/app/test_feedback_policy.py
Yun Chan a0311c5957
Some checks failed
API contract / OpenAPI type drift (push) Failing after 3m27s
회기 무발화 0턴 분리, 자기예측 락 불변식 및 TDD 회귀 검증 완료
2026-09-08 23:28:06 +09:00

642 lines
24 KiB
Python

"""계정별 학습자 AI 피드백 정책 회귀 테스트."""
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from unittest.mock import AsyncMock, patch
from uuid import UUID
from fastapi import HTTPException
from .deps import Principal, Role
from . import session_persistence
from .routes import admin as admin_routes
from .routes import (
calibration_transfer,
deliberate_practices,
measurements,
multimodal_alliance,
outcome_trajectories,
rupture_repairs,
sessions,
)
from .services import deliberate_practice_store, feedback_policy, state_machine
from .services.persona import P1
from .session_read_model import SessionReviewReadInput, build_session_review
from .store import InProcSession, TurnRecord
def _session(*, learner_feedback_enabled: bool) -> InProcSession:
state = state_machine.init_state(params=P1.openness_params())
return InProcSession(
session_id="00000000-0000-4000-8000-000000000401",
case_id="feedback-policy-case",
learner_id="00000000-0000-0000-0000-000000000401",
persona_code=P1.code,
theory_mode="humanistic",
persona=P1,
state=state,
created_at=1_000.0,
ended_at=1_120.0,
ended=True,
learner_feedback_enabled=learner_feedback_enabled,
turns=[
TurnRecord(
turn_seq=1,
speaker="counselor",
stage=state.stage.value,
text="상담자 발화",
text_masked="상담자 발화",
evaluation={
"appropriateness": "pos",
"appropriateness_note": "AI 학습 피드백",
"techniques": [{"label": "반영"}],
},
),
TurnRecord(
turn_seq=2,
speaker="client",
stage=state.stage.value,
text="가상 내담자 응답",
text_masked="가상 내담자 응답",
),
],
)
class FeedbackPolicyTest(unittest.TestCase):
def test_admin_contract_defaults_existing_and_new_accounts_to_enabled(self) -> None:
created = admin_routes.AdminUserCreate(
email="feedback-policy@hs.ac.kr",
display_name="피드백 정책 대상",
)
patched = admin_routes.AdminUserPatch(learner_feedback_enabled=False)
self.assertTrue(created.learner_feedback_enabled)
self.assertFalse(patched.learner_feedback_enabled)
def test_only_learner_viewer_is_blocked(self) -> None:
sess = _session(learner_feedback_enabled=False)
self.assertFalse(feedback_policy.learner_feedback_enabled(sess))
self.assertFalse(
feedback_policy.can_expose_learner_feedback(
sess,
viewer_role="learner",
)
)
self.assertTrue(
feedback_policy.can_expose_learner_feedback(
sess,
viewer_role="teacher",
)
)
def test_current_account_off_overrides_past_enabled_session_snapshot(self) -> None:
sess = _session(learner_feedback_enabled=True)
principal = Principal(
user_id=sess.learner_id,
role=Role.LEARNER,
learner_feedback_enabled=False,
)
self.assertFalse(
feedback_policy.effective_learner_feedback_enabled(sess, principal)
)
self.assertFalse(
feedback_policy.can_expose_principal_learner_feedback(sess, principal)
)
self.assertTrue(
feedback_policy.can_expose_learner_feedback(
sess,
viewer_role="admin",
)
)
def test_disabled_review_preserves_transcript_but_removes_feedback(self) -> None:
sess = _session(learner_feedback_enabled=False)
review = build_session_review(
SessionReviewReadInput(
session=sess,
evaluation_record={"status": "ready", "payload": {"summary": "AI 요약"}},
evaluation_durable=True,
learner_feedback_enabled=False,
expose_learner_feedback=False,
now_ts=1_120.0,
)
)
self.assertFalse(review.learnerFeedbackEnabled)
self.assertFalse(review.reviewReady)
self.assertEqual(review.supervisorState, "피드백 비활성")
self.assertIn("AI 학습 피드백을 비활성화", review.summary)
self.assertEqual([turn.text for turn in review.turns], ["상담자 발화", "가상 내담자 응답"])
self.assertTrue(all(turn.note is None for turn in review.turns))
self.assertTrue(all(not turn.techniques for turn in review.turns))
self.assertTrue(all(not turn.nonverbal for turn in review.turns))
self.assertEqual(review.rubric, [])
self.assertEqual(review.goodMoments, [])
self.assertEqual(review.growthPoints, [])
self.assertIsNone(review.nextLine)
self.assertIsNone(review.clientFeedback)
self.assertEqual(review.caseWorksheet.status, "empty")
def test_disabled_review_preserves_saved_learner_worksheet_verbatim(self) -> None:
sess = _session(learner_feedback_enabled=False)
saved = {
"status": "saved_by_learner",
"generatedBy": "learner",
"sections": [
{
"key": "learner-notes",
"title": "내 기록",
"items": [
{
"key": "presenting-complaint",
"label": "주호소",
"value": "학습자가 직접 저장한 원문",
"evidence": [],
"confidence": "none",
}
],
}
],
"limitations": ["학습자 작성"],
"savedAt": "2026-08-27T01:02:03Z",
}
review = build_session_review(
SessionReviewReadInput(
session=sess,
saved_worksheet_payload=saved,
learner_feedback_enabled=False,
expose_learner_feedback=False,
now_ts=1_120.0,
)
)
self.assertEqual(review.caseWorksheet.status, "saved_by_learner")
self.assertEqual(
review.caseWorksheet.sections[0].items[0].value,
"학습자가 직접 저장한 원문",
)
self.assertEqual(review.caseWorksheet.limitations, ["학습자 작성"])
def test_calibration_redaction_keeps_only_learner_authored_prediction(self) -> None:
payload = {
"prediction_histories": [
{
"history_id": "history-1",
"revisions": [{"revision_reason": "학습자 입력"}],
"lock": {"lock_id": "lock-1"},
"external_observation": {"status": "passed"},
}
],
"calibration_assessments": [{"assessment": "ai"}],
"transfer_suites": [{"suite": "ai"}],
"teacher_reviews": [{"review": "teacher"}],
"actual_executions": [{"execution": "ai"}],
"actual_transfer_assessments": [{"assessment": "ai"}],
}
redacted = calibration_transfer._learner_input_only_payload(payload)
self.assertEqual(
redacted["prediction_histories"][0]["revisions"],
[{"revision_reason": "학습자 입력"}],
)
self.assertEqual(
redacted["prediction_histories"][0]["lock"],
{"lock_id": "lock-1"},
)
self.assertIsNone(
redacted["prediction_histories"][0]["external_observation"]
)
for key in (
"calibration_assessments",
"transfer_suites",
"teacher_reviews",
"actual_executions",
"actual_transfer_assessments",
):
self.assertEqual(redacted[key], [])
class FeedbackPolicyRouteTest(unittest.IsolatedAsyncioTestCase):
@staticmethod
def _principal(*, enabled: bool, role: Role = Role.LEARNER) -> Principal:
return Principal(
user_id="00000000-0000-0000-0000-000000000401",
role=role,
learner_feedback_enabled=enabled,
)
async def test_session_derived_routes_return_403_before_store_read(self) -> None:
principal = self._principal(enabled=True)
sess = _session(learner_feedback_enabled=False)
session_id = UUID(sess.session_id)
with (
patch.object(
session_persistence,
"load_session",
AsyncMock(return_value=sess),
),
patch.object(
rupture_repairs.rupture_repair_store,
"read_rupture_repairs",
AsyncMock(),
) as rupture_read,
patch.object(
outcome_trajectories.outcome_trajectory_store,
"read_outcome_trajectory",
AsyncMock(),
) as outcome_read,
):
for call in (
lambda: rupture_repairs.get_rupture_repairs(session_id, principal),
lambda: outcome_trajectories.get_outcome_trajectory(
session_id,
principal,
),
lambda: outcome_trajectories.recompute_outcome_trajectory(
session_id,
outcome_trajectories.OutcomeTrajectoryRecomputeRequest(),
principal,
),
):
with self.assertRaises(HTTPException) as caught:
await call()
self.assertEqual(caught.exception.status_code, 403)
self.assertEqual(
caught.exception.detail,
feedback_policy.LEARNER_FEEDBACK_DISABLED_DETAIL,
)
rupture_read.assert_not_awaited()
outcome_read.assert_not_awaited()
async def test_current_off_redacts_past_enabled_review_without_eval_read(self) -> None:
principal = self._principal(enabled=False)
sess = _session(learner_feedback_enabled=True)
with (
patch.object(
sessions,
"_load_review_session_or_404",
AsyncMock(return_value=(sess, principal)),
),
patch.object(
sessions.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(),
) as evaluation_read,
patch.object(
sessions.session_persistence,
"load_case_worksheet",
AsyncMock(return_value=(None, True)),
),
):
review = await sessions.get_session_review(sess.session_id, principal)
evaluation_read.assert_not_awaited()
self.assertFalse(review.learnerFeedbackEnabled)
self.assertFalse(review.reviewReady)
self.assertEqual([turn.text for turn in review.turns], ["상담자 발화", "가상 내담자 응답"])
self.assertTrue(all(turn.note is None for turn in review.turns))
async def test_current_off_marks_past_enabled_session_detail_input_only(self) -> None:
principal = self._principal(enabled=False)
sess = _session(learner_feedback_enabled=True)
with (
patch.object(
sessions,
"_load_session_or_404",
AsyncMock(return_value=sess),
),
patch.object(
sessions.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(),
) as evaluation_read,
):
detail = await sessions.get_session_detail(sess.session_id, principal)
evaluation_read.assert_not_awaited()
self.assertFalse(detail.learner_feedback_enabled)
self.assertFalse(detail.review_ready)
self.assertEqual([turn.text for turn in detail.turns], ["상담자 발화", "가상 내담자 응답"])
async def test_deliberate_practice_read_returns_403_before_store_read(self) -> None:
principal = self._principal(enabled=False)
with patch.object(
deliberate_practices.deliberate_practice_store,
"read_deliberate_practice",
AsyncMock(),
) as read:
with self.assertRaises(HTTPException) as caught:
await deliberate_practices.get_my_deliberate_practice(principal)
self.assertEqual(caught.exception.status_code, 403)
self.assertEqual(
caught.exception.detail,
feedback_policy.LEARNER_FEEDBACK_DISABLED_DETAIL,
)
read.assert_not_awaited()
async def test_deliberate_practice_read_rejects_disabled_source_snapshot(self) -> None:
principal = self._principal(enabled=True)
with patch.object(
deliberate_practices.deliberate_practice_store,
"read_deliberate_practice",
AsyncMock(
side_effect=deliberate_practice_store.DeliberatePracticeFeedbackDisabledError(
"source session feedback disabled"
)
),
) as read:
with self.assertRaises(HTTPException) as caught:
await deliberate_practices.get_my_deliberate_practice(principal)
self.assertEqual(caught.exception.status_code, 403)
self.assertEqual(
caught.exception.detail,
feedback_policy.LEARNER_FEEDBACK_DISABLED_DETAIL,
)
read.assert_awaited_once_with(principal=principal)
async def test_calibration_read_redacts_derived_fields_at_route_boundary(self) -> None:
principal = self._principal(enabled=False)
payload = {
"learner_id": UUID(principal.user_id),
"requested_view": "learner",
"clinical_claim_allowed": False,
"prediction_histories": [],
"calibration_assessments": [{"private": "ai"}],
"transfer_suites": [{"private": "ai"}],
"teacher_reviews": [{"private": "teacher"}],
"actual_executions": [{"private": "ai"}],
"actual_transfer_assessments": [{"private": "ai"}],
}
with patch.object(
calibration_transfer.calibration_transfer_store,
"read_calibration_transfer",
AsyncMock(return_value=payload),
) as read:
response = await calibration_transfer.get_my_calibration_transfer(
principal,
)
read.assert_awaited_once()
self.assertEqual(response.prediction_histories, [])
self.assertEqual(response.calibration_assessments, [])
self.assertEqual(response.transfer_suites, [])
self.assertEqual(response.teacher_reviews, [])
self.assertEqual(response.actual_executions, [])
self.assertEqual(response.actual_transfer_assessments, [])
async def test_calibration_aggregate_current_on_redacts_past_disabled_snapshot(
self,
) -> None:
principal = self._principal(enabled=True)
payload = {
"learner_id": UUID(principal.user_id),
"requested_view": "learner",
"clinical_claim_allowed": False,
"_learner_feedback_snapshot_enabled": False,
"prediction_histories": [],
"calibration_assessments": [{"private": "past-session-ai"}],
"transfer_suites": [{"private": "past-session-ai"}],
"teacher_reviews": [{"private": "teacher"}],
"actual_executions": [{"private": "past-session-ai"}],
"actual_transfer_assessments": [{"private": "past-session-ai"}],
}
with patch.object(
calibration_transfer.calibration_transfer_store,
"read_calibration_transfer",
AsyncMock(return_value=payload),
) as read:
response = await calibration_transfer.get_my_calibration_transfer(
principal,
)
read.assert_awaited_once_with(principal=principal)
self.assertEqual(response.prediction_histories, [])
self.assertEqual(response.calibration_assessments, [])
self.assertEqual(response.transfer_suites, [])
self.assertEqual(response.teacher_reviews, [])
self.assertEqual(response.actual_executions, [])
self.assertEqual(response.actual_transfer_assessments, [])
async def test_actual_execution_input_is_saved_but_ai_result_is_redacted(self) -> None:
principal = self._principal(enabled=False)
body = calibration_transfer.ActualTransferExecutionRequest(
original_transfer_trial_record_id=UUID(
"00000000-0000-4000-8000-000000000411"
),
practice_session_id=UUID("00000000-0000-4000-8000-000000000412"),
)
with patch.object(
calibration_transfer.calibration_transfer_store,
"append_actual_transfer_execution",
AsyncMock(
return_value={
"execution": {"private": "ai"},
"assessment": {"private": "ai"},
"idempotent_replay": False,
}
),
) as append:
response = await calibration_transfer.create_actual_transfer_execution(
body,
principal,
)
append.assert_awaited_once_with(
principal=principal,
**body.model_dump(),
)
self.assertIsNone(response.execution)
self.assertIsNone(response.assessment)
self.assertFalse(response.idempotent_replay)
async def test_multimodal_derived_read_is_403_but_privacy_read_is_redacted(self) -> None:
principal = self._principal(enabled=True)
sess = _session(learner_feedback_enabled=False)
session_id = UUID(sess.session_id)
payload = {
"session_id": session_id,
"learner_id": UUID(principal.user_id),
"clinical_claim_allowed": False,
"consent_snapshots": [{"consent_status": "granted"}],
"timelines": [{"timeline_id": "derived"}],
"word_timestamps": [{"word_index": 1}],
"voice_events": [{"event_id": "derived"}],
"measurements": [{"axis": "bond"}],
"fusion_decisions": [{"axis": "bond"}],
"deletion_requests": [{"status": "requested"}],
}
with (
patch.object(
session_persistence,
"load_session",
AsyncMock(return_value=sess),
),
patch.object(
multimodal_alliance.multimodal_alliance_store,
"read_session_metadata",
AsyncMock(return_value=payload),
) as read,
):
with self.assertRaises(HTTPException) as caught:
await multimodal_alliance.get_multimodal_session_metadata(
session_id,
principal,
True,
)
privacy = await multimodal_alliance.get_multimodal_session_metadata(
session_id,
principal,
False,
)
self.assertEqual(caught.exception.status_code, 403)
self.assertEqual(caught.exception.detail, "learner_feedback_disabled")
read.assert_awaited_once()
self.assertEqual(privacy.consent_snapshots, payload["consent_snapshots"])
self.assertEqual(privacy.deletion_requests, payload["deletion_requests"])
self.assertEqual(privacy.timelines, [])
self.assertEqual(privacy.word_timestamps, [])
self.assertEqual(privacy.voice_events, [])
self.assertEqual(privacy.measurements, [])
self.assertEqual(privacy.fusion_decisions, [])
async def test_alliance_read_keeps_self_scores_but_redacts_measurements(self) -> None:
principal = self._principal(enabled=False)
session_id = UUID("00000000-0000-4000-8000-000000000421")
payload = {
"pulse_id": UUID("00000000-0000-4000-8000-000000000422"),
"checkpoint": "post",
"status": "ready",
"learner_locked_at": datetime.now(timezone.utc),
"revealed_at": datetime.now(timezone.utc),
"error_code": None,
"self_scores": {"goal": 0.8, "task": 0.6, "bond": 1.0},
"measurements": [{"private": "ai rationale and evidence"}],
}
with patch.object(
measurements.alliance_measurement,
"list_alliance_pulses",
AsyncMock(return_value=[payload]),
) as read:
response = await measurements.get_alliance_pulses(
session_id,
principal,
)
read.assert_awaited_once_with(principal=principal, session_id=session_id)
self.assertEqual(response.items[0].self_scores.model_dump(), payload["self_scores"])
self.assertEqual(response.items[0].measurements, [])
async def test_teacher_multimodal_view_keeps_derived_output(self) -> None:
principal = self._principal(enabled=False, role=Role.TEACHER)
session_id = UUID("00000000-0000-4000-8000-000000000401")
payload = {
"session_id": session_id,
"learner_id": UUID("00000000-0000-0000-0000-000000000401"),
"clinical_claim_allowed": False,
"consent_snapshots": [],
"timelines": [{"timeline_id": "teacher-visible"}],
"word_timestamps": [],
"voice_events": [],
"measurements": [],
"fusion_decisions": [],
"deletion_requests": [],
}
with patch.object(
multimodal_alliance.multimodal_alliance_store,
"read_session_metadata",
AsyncMock(return_value=payload),
):
response = await multimodal_alliance.get_multimodal_session_metadata(
session_id,
principal,
True,
)
self.assertEqual(response.timelines, payload["timelines"])
async def test_dashboard_keeps_session_counts_but_hides_feedback_metrics(self) -> None:
principal = Principal(
user_id="00000000-0000-0000-0000-000000000401",
role=Role.LEARNER,
learner_feedback_enabled=False,
)
sess = _session(learner_feedback_enabled=True)
with (
patch.object(
sessions.session_persistence,
"list_recent_sessions",
AsyncMock(return_value=([sess], True)),
),
patch.object(
sessions.session_evaluation_repository,
"load_session_evaluation",
AsyncMock(return_value=({"status": "ready"}, True)),
),
patch.object(
sessions.session_persistence,
"list_session_archives",
AsyncMock(return_value=({}, True)),
),
):
dashboard = await sessions.learner_dashboard(principal)
self.assertEqual(dashboard.overview.total_sessions, 1)
self.assertEqual(dashboard.overview.completed_sessions, 1)
self.assertEqual(dashboard.overview.review_ready_sessions, 0)
self.assertEqual(dashboard.growth.evaluated_sessions, 0)
self.assertEqual(dashboard.recent_feedback, [])
self.assertEqual(dashboard.persona_progress[0].sessions, 1)
self.assertIsNone(dashboard.persona_progress[0].latest_score)
async def test_live_coach_is_blocked_before_generation(self) -> None:
principal = Principal(
user_id="00000000-0000-0000-0000-000000000401",
role=Role.LEARNER,
learner_feedback_enabled=False,
)
sess = _session(learner_feedback_enabled=True)
with (
patch.object(
sessions,
"_load_session_or_404",
AsyncMock(return_value=sess),
),
patch.object(
sessions.live_coach,
"generate_live_coaching",
AsyncMock(),
) as generate,
):
with self.assertRaises(HTTPException) as caught:
await sessions.live_coach_turn(
sess.session_id,
sessions.LiveCoachRequest(learner_text="도와줘"),
principal,
)
self.assertEqual(caught.exception.status_code, 403)
self.assertEqual(
caught.exception.detail,
feedback_policy.LEARNER_FEEDBACK_DISABLED_DETAIL,
)
generate.assert_not_awaited()
if __name__ == "__main__":
unittest.main()