vignette/apps/api/app/test_client_inner_reaction.py
Yun Chan 29c406d89f Jev 내담자 평가·표현 v2와 속마음 공개
상담자 발화 판정(A)·감정(B)·표현(C) 20문항 질문 세트, 감쇠 없는 이번 턴 반응과 비대칭 기분 전이, 개방도 게이트로 생성 지시를 만들고 ccd.coping_strategy 전달 누락을 고친다.

trace v2와 고정 문구 속마음 요약(migration 24, AI 경로 차단 RLS)을 같은 트랜잭션에 저장하고 피드백 정책이 켜진 경우에만 done·TurnResponse·음성 reply·리뷰로 노출한다. 회기 화면 속마음 보기 토글, 리뷰 접힘 블록, 관리자 감정 관측 v2 표시를 추가한다.
2026-09-30 13:23:09 +09:00

740 lines
27 KiB
Python

"""학습자·교수자용 속마음 요약(§8.2·§9) 저장·조회·노출 경로 회귀."""
from __future__ import annotations
import json
import unittest
from unittest.mock import AsyncMock, patch
from . import session_persistence
from .contracts.client_affect import (
CLIENT_AFFECT_DIMENSIONS,
ClientAffectContextV1,
ClientAffectDimensionTraceV1,
ClientAffectPolicyV1,
ClientAffectTraceV1,
ClientInnerDisplayV1,
ClientInnerFeelingV1,
ClientInnerReactionV1,
ClientInnerStanceV1,
)
from .deps import Principal, Role
from .paths import repo_path
from .routes import sessions
from .routes import voice as voice_routes
from .services import inner_reaction_exposure, orchestrator
from .services import persona as persona_service
from .services import state_machine
from .services.voice import TTSChunk, VoicePreset
from .session_read_model import SessionReviewReadInput, build_session_review
from .store import InProcSession, TurnRecord, store
def _inner_reaction(turn_seq: int = 2) -> ClientInnerReactionV1:
return ClientInnerReactionV1(
schema_version=1,
turn_seq=turn_seq,
experienced=("평가받거나 탓을 듣는 것처럼 느꼈다",),
feelings=(ClientInnerFeelingV1(label="수치심", intensity="뚜렷한"),),
stance=ClientInnerStanceV1(code="pull_back", label="한발 물러났다"),
display=ClientInnerDisplayV1(
code="covered_by_agreement",
label="속마음과 달리 겉으로는 수긍하는 말로 덮었다",
),
hidden_gap=True,
)
def _trace() -> ClientAffectTraceV1:
return ClientAffectTraceV1(
schema_version=1,
provider="typesafe",
model="jev-test",
latency_ms=10,
input_tokens=1,
output_tokens=1,
cost_usd=None,
turn_seq=2,
policy=ClientAffectPolicyV1(
version="jev-affect-v1",
min_confidence=0.65,
accepted_alpha=0.35,
accepted_cap=0.15,
tentative_alpha=0.15,
tentative_cap=0.075,
tentative_confidence_floor=0.35,
adjacent_probability_threshold=0.005,
),
context=ClientAffectContextV1(
stage="라포",
resistance=0.5,
effective_openness=0.2,
rapport_credit=1.0,
),
dimensions=tuple(
ClientAffectDimensionTraceV1(
key=dimension,
before=0.5,
target=None,
after=0.5,
confidence=None,
probabilities=None,
decision="held",
)
for dimension in CLIENT_AFFECT_DIMENSIONS
),
)
class _Transaction:
def __init__(self) -> None:
self.error: type[BaseException] | None = None
async def __aenter__(self) -> None:
return None
async def __aexit__(self, exc_type, exc, tb) -> bool:
self.error = exc_type
return False
class _Connection:
def __init__(self, *, fail_inner_reaction_insert: bool = False) -> None:
self.fail_inner_reaction_insert = fail_inner_reaction_insert
self.transaction_context = _Transaction()
self.executed: list[tuple[str, tuple[object, ...]]] = []
def transaction(self) -> _Transaction:
return self.transaction_context
async def fetchval(self, query: str, *args: object) -> object:
if "FROM app.sessions" in query:
return "00000000-0000-0000-0000-000000000111"
if "COALESCE(MAX(seq)" in query:
return 2
if "INSERT INTO app.turns" in query:
return "00000000-0000-0000-0000-000000000222"
raise AssertionError(f"unexpected query: {query}")
async def execute(self, query: str, *args: object) -> str:
self.executed.append((query, args))
if self.fail_inner_reaction_insert and "INSERT INTO app.client_inner_reaction" in query:
raise RuntimeError("inner reaction insert failed")
return "INSERT 0 1"
class _Acquire:
def __init__(self, conn: _Connection) -> None:
self.conn = conn
async def __aenter__(self) -> _Connection:
return self.conn
async def __aexit__(self, exc_type, exc, tb) -> bool:
return False
class ClientInnerReactionPersistenceTest(unittest.IsolatedAsyncioTestCase):
async def test_atomic_write_inserts_trace_then_inner_reaction_in_same_transaction(
self,
) -> None:
conn = _Connection()
turn = TurnRecord(
turn_seq=2,
speaker="client",
stage="라포",
text="조금 더 이야기해볼게요.",
text_masked="조금 더 이야기해볼게요.",
)
state = state_machine.SessionState(turn_seq=2)
reaction = _inner_reaction()
with (
patch.object(session_persistence, "get_pool", return_value=object()),
patch.object(session_persistence, "acquire", return_value=_Acquire(conn)),
):
stored = await session_persistence.append_client_turn_with_affect_trace(
session_id="00000000-0000-0000-0000-000000000111",
learner_id="00000000-0000-0000-0000-000000000101",
turn=turn,
state=state,
trace=_trace(),
inner_reaction=reaction,
)
self.assertTrue(stored)
self.assertEqual(turn.turn_id, "00000000-0000-0000-0000-000000000222")
queries = [query for query, _args in conn.executed]
self.assertEqual(
[q.split()[0:3] for q in queries if "INSERT INTO app.client" in q],
[
["INSERT", "INTO", "app.client_affect_trace"],
["INSERT", "INTO", "app.client_inner_reaction"],
],
)
inner_reaction_query, inner_reaction_args = next(
(query, args)
for query, args in conn.executed
if "INSERT INTO app.client_inner_reaction" in query
)
self.assertEqual(
inner_reaction_args,
(
"00000000-0000-0000-0000-000000000222",
"00000000-0000-0000-0000-000000000111",
reaction.model_dump(mode="json"),
),
)
async def test_inner_reaction_none_skips_insert(self) -> None:
conn = _Connection()
turn = TurnRecord(
turn_seq=2,
speaker="client",
stage="라포",
text="조금 더 이야기해볼게요.",
text_masked="조금 더 이야기해볼게요.",
)
state = state_machine.SessionState(turn_seq=2)
with (
patch.object(session_persistence, "get_pool", return_value=object()),
patch.object(session_persistence, "acquire", return_value=_Acquire(conn)),
):
stored = await session_persistence.append_client_turn_with_affect_trace(
session_id="00000000-0000-0000-0000-000000000111",
learner_id="00000000-0000-0000-0000-000000000101",
turn=turn,
state=state,
trace=_trace(),
inner_reaction=None,
)
self.assertTrue(stored)
self.assertFalse(
any("INSERT INTO app.client_inner_reaction" in query for query, _ in conn.executed)
)
async def test_inner_reaction_insert_failure_rolls_back_entire_turn(self) -> None:
conn = _Connection(fail_inner_reaction_insert=True)
turn = TurnRecord(
turn_seq=2,
speaker="client",
stage="라포",
text="조금 더 이야기해볼게요.",
text_masked="조금 더 이야기해볼게요.",
)
state = state_machine.SessionState(turn_seq=2)
with (
patch.object(session_persistence, "get_pool", return_value=object()),
patch.object(session_persistence, "acquire", return_value=_Acquire(conn)),
):
with self.assertRaises(session_persistence.ClientAffectTracePersistenceError):
await session_persistence.append_client_turn_with_affect_trace(
session_id="00000000-0000-0000-0000-000000000111",
learner_id="00000000-0000-0000-0000-000000000101",
turn=turn,
state=state,
trace=_trace(),
inner_reaction=_inner_reaction(),
)
self.assertIsNone(turn.turn_id)
self.assertIs(conn.transaction_context.error, RuntimeError)
class ListClientInnerReactionTest(unittest.IsolatedAsyncioTestCase):
async def test_reads_with_caller_role_user_and_cohort_ids(self) -> None:
reaction = _inner_reaction()
teacher_principal = Principal(
user_id="00000000-0000-0000-0000-000000000901",
role=Role.TEACHER,
cohort_ids=["cohort-a"],
email="teacher@hs.ac.kr",
display_name="Teacher",
consent_at=1.0,
profile_completed_at=1.0,
)
class FakeConn:
async def fetch(self, query: str, *args: object):
self.query = query
self.args = args
return [
{
"turn_id": "00000000-0000-0000-0000-000000000222",
"reaction": reaction.model_dump(mode="json"),
}
]
conn = FakeConn()
with (
patch.object(session_persistence, "get_pool", return_value=object()),
patch.object(
session_persistence, "acquire", return_value=_Acquire(conn)
) as acquire_mock,
):
result = await session_persistence.list_client_inner_reactions(
"00000000-0000-0000-0000-000000000111",
teacher_principal,
)
acquire_mock.assert_called_once_with(
role="teacher",
user_id="00000000-0000-0000-0000-000000000901",
cohort_ids=["cohort-a"],
)
self.assertIn("00000000-0000-0000-0000-000000000222", result)
self.assertEqual(
result["00000000-0000-0000-0000-000000000222"].stance.code, "pull_back"
)
async def test_db_failure_falls_back_to_empty_dict_in_dev(self) -> None:
learner_principal = Principal(
user_id="00000000-0000-0000-0000-000000000101",
role=Role.LEARNER,
cohort_ids=[],
email="learner@hs.ac.kr",
display_name="Learner",
consent_at=1.0,
profile_completed_at=1.0,
)
with patch.object(
session_persistence, "get_pool", side_effect=RuntimeError("no pool")
):
result = await session_persistence.list_client_inner_reactions(
"00000000-0000-0000-0000-000000000111",
learner_principal,
)
self.assertEqual(result, {})
class InnerReactionExposureTest(unittest.TestCase):
def test_returns_none_when_reaction_missing(self) -> None:
self.assertIsNone(
inner_reaction_exposure.expose_client_inner_reaction(
None, stored=True, feedback_enabled=True
)
)
def test_returns_none_when_not_stored(self) -> None:
self.assertIsNone(
inner_reaction_exposure.expose_client_inner_reaction(
_inner_reaction(), stored=False, feedback_enabled=True
)
)
def test_returns_none_when_feedback_policy_disabled(self) -> None:
self.assertIsNone(
inner_reaction_exposure.expose_client_inner_reaction(
_inner_reaction(), stored=True, feedback_enabled=False
)
)
def test_returns_reaction_when_stored_and_policy_enabled(self) -> None:
reaction = _inner_reaction()
exposed = inner_reaction_exposure.expose_client_inner_reaction(
reaction, stored=True, feedback_enabled=True
)
self.assertIs(exposed, reaction)
class ClientInnerReactionMigrationSqlTest(unittest.TestCase):
def setUp(self) -> None:
self.sql = repo_path(
"infra", "db", "init", "24_client_inner_reaction.sql"
).read_text(encoding="utf-8")
def test_has_exactly_select_and_insert_policies(self) -> None:
self.assertIn(
"CREATE POLICY p_client_inner_reaction_select", self.sql
)
self.assertIn(
"CREATE POLICY p_client_inner_reaction_insert_learner", self.sql
)
self.assertEqual(self.sql.count("CREATE POLICY"), 2)
def test_has_no_update_or_delete_policy(self) -> None:
self.assertNotIn("FOR UPDATE", self.sql)
self.assertNotIn("FOR DELETE", self.sql)
def test_select_policy_blocks_ai_context(self) -> None:
self.assertIn("NOT app.is_ai_context()", self.sql)
def test_select_policy_allows_admin_instructor_or_session_owner(self) -> None:
self.assertIn(
"app.current_role_name() IN ('admin', 'instructor')", self.sql
)
self.assertIn("s.learner_id = app.current_uid()", self.sql)
def _principal(*, learner_feedback_enabled: bool = True) -> Principal:
return Principal(
user_id="00000000-0000-0000-0000-000000000501",
role=Role.LEARNER,
cohort_ids=[],
email="inner-reaction-route-test@hs.ac.kr",
display_name="Inner Reaction Route Test",
consent_at=1.0,
profile_completed_at=1.0,
learner_feedback_enabled=learner_feedback_enabled,
)
def _session(principal: Principal, session_id: str) -> InProcSession:
card = persona_service.P1
sess = InProcSession(
session_id=session_id,
case_id=f"{session_id}-case",
learner_id=principal.user_id,
persona_code=card.code,
theory_mode="humanistic",
persona=card,
state=state_machine.SessionState(
resistance=card.base_resistance(),
ideation_stage=card.ideation_baseline(),
),
)
store.put(sess)
return sess
async def _successful_turn_with_reaction(reaction, ctx, engine, **kwargs):
assert ctx.state_after is not None
ctx.client_affect_trace = _trace()
ctx.client_inner_reaction = reaction
return orchestrator.TurnResult(
turn_seq=ctx.state_after.turn_seq,
stage=ctx.state_after.stage.value,
effective_openness=ctx.state_after.effective_openness,
client_reply="조금 더 말해볼게요.",
safety_flagged=False,
state_after=ctx.state_after,
)
class RouteInnerReactionParityTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
store._sessions.clear()
sessions._RECALL_CACHE.clear()
async def asyncTearDown(self) -> None:
store._sessions.clear()
sessions._RECALL_CACHE.clear()
async def test_submit_turn_includes_inner_reaction_when_policy_enabled(self) -> None:
principal = _principal(learner_feedback_enabled=True)
sess = _session(principal, "inner-reaction-turn-on")
reaction = _inner_reaction()
async def successful_turn(ctx, engine, **kwargs):
return await _successful_turn_with_reaction(reaction, ctx, engine, **kwargs)
with (
patch.object(sessions.orchestrator, "run_turn_generate", successful_turn),
patch.object(
session_persistence,
"append_client_turn_with_affect_trace",
AsyncMock(return_value=True),
),
):
response = await sessions.submit_turn(
sess.session_id,
sessions.TurnRequest(text="속마음 노출 테스트"),
principal,
)
self.assertIsNotNone(response.inner_reaction)
self.assertTrue(response.inner_reaction.hidden_gap)
self.assertEqual(response.inner_reaction.stance.code, "pull_back")
async def test_submit_turn_omits_inner_reaction_when_policy_disabled(self) -> None:
principal = _principal(learner_feedback_enabled=False)
sess = _session(principal, "inner-reaction-turn-off")
reaction = _inner_reaction()
async def successful_turn(ctx, engine, **kwargs):
return await _successful_turn_with_reaction(reaction, ctx, engine, **kwargs)
with (
patch.object(sessions.orchestrator, "run_turn_generate", successful_turn),
patch.object(
session_persistence,
"append_client_turn_with_affect_trace",
AsyncMock(return_value=True),
),
):
response = await sessions.submit_turn(
sess.session_id,
sessions.TurnRequest(text="속마음 비노출 테스트"),
principal,
)
self.assertIsNone(response.inner_reaction)
async def _stream_done_payload(self, response: object) -> dict:
async for chunk in response.body_iterator: # type: ignore[attr-defined]
if isinstance(chunk, dict) and chunk.get("event") == "done":
return json.loads(chunk["data"])
raise AssertionError("done event not found in stream")
async def test_stream_turn_done_includes_inner_reaction_when_policy_enabled(
self,
) -> None:
principal = _principal(learner_feedback_enabled=True)
sess = _session(principal, "inner-reaction-stream-on")
reaction = _inner_reaction()
async def successful_stream(ctx, engine, **kwargs):
assert ctx.state_after is not None
ctx.client_affect_trace = _trace()
ctx.client_inner_reaction = reaction
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,
},
)
with (
patch.object(sessions.orchestrator, "run_turn_stream", successful_stream),
patch.object(
session_persistence,
"append_client_turn_with_affect_trace",
AsyncMock(return_value=True),
),
):
response = await sessions.stream_turn(
sess.session_id,
sessions.TurnRequest(text="속마음 스트림 발화"),
principal,
)
done_payload = await self._stream_done_payload(response)
self.assertIsNotNone(done_payload.get("inner_reaction"))
self.assertEqual(done_payload["inner_reaction"]["stance"]["code"], "pull_back")
async def test_stream_turn_done_omits_inner_reaction_when_policy_disabled(
self,
) -> None:
principal = _principal(learner_feedback_enabled=False)
sess = _session(principal, "inner-reaction-stream-off")
reaction = _inner_reaction()
async def successful_stream(ctx, engine, **kwargs):
assert ctx.state_after is not None
ctx.client_affect_trace = _trace()
ctx.client_inner_reaction = reaction
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,
},
)
with (
patch.object(sessions.orchestrator, "run_turn_stream", successful_stream),
patch.object(
session_persistence,
"append_client_turn_with_affect_trace",
AsyncMock(return_value=True),
),
):
response = await sessions.stream_turn(
sess.session_id,
sessions.TurnRequest(text="속마음 스트림 비노출 발화"),
principal,
)
done_payload = await self._stream_done_payload(response)
self.assertIsNone(done_payload.get("inner_reaction"))
async def test_voice_reply_includes_inner_reaction_when_policy_enabled(self) -> None:
principal = _principal(learner_feedback_enabled=True)
sess = _session(principal, "inner-reaction-voice-on")
reaction = _inner_reaction()
class FakeWebSocket:
def __init__(self) -> None:
self.messages: list[dict[str, object]] = []
self.client_state = voice_routes.WebSocketState.CONNECTED
async def send_text(self, data: str) -> None:
self.messages.append(json.loads(data))
async def send_bytes(self, data: bytes) -> None:
pass
async def successful_turn(ctx, engine, **kwargs):
return await _successful_turn_with_reaction(reaction, ctx, engine, **kwargs)
async def fake_synthesize_stream(text, voice_preset):
yield TTSChunk(audio=b"tts-audio")
websocket = FakeWebSocket()
with (
patch.object(voice_routes.orchestrator, "run_turn_generate", successful_turn),
patch.object(
session_persistence,
"append_client_turn_with_affect_trace",
AsyncMock(return_value=True),
),
patch.object(
voice_routes.voice_service, "synthesize_stream", fake_synthesize_stream
),
):
await voice_routes._run_turn_and_speak(
websocket, # type: ignore[arg-type]
voice_routes.VoiceSessionContext(
session_id=sess.session_id,
principal=principal,
voice_preset=VoicePreset(preset="neutral", openai_voice="sage"),
),
voice_routes.VoiceTurnInput(learner_text="속마음 노출 음성 발화"),
)
reply_message = next(
message for message in websocket.messages if message.get("type") == "reply"
)
self.assertIsNotNone(reply_message.get("inner_reaction"))
self.assertEqual(reply_message["inner_reaction"]["stance"]["code"], "pull_back")
async def test_voice_reply_omits_inner_reaction_when_policy_disabled(self) -> None:
principal = _principal(learner_feedback_enabled=False)
sess = _session(principal, "inner-reaction-voice-off")
reaction = _inner_reaction()
class FakeWebSocket:
def __init__(self) -> None:
self.messages: list[dict[str, object]] = []
self.client_state = voice_routes.WebSocketState.CONNECTED
async def send_text(self, data: str) -> None:
self.messages.append(json.loads(data))
async def send_bytes(self, data: bytes) -> None:
pass
async def successful_turn(ctx, engine, **kwargs):
return await _successful_turn_with_reaction(reaction, ctx, engine, **kwargs)
async def fake_synthesize_stream(text, voice_preset):
yield TTSChunk(audio=b"tts-audio")
websocket = FakeWebSocket()
with (
patch.object(voice_routes.orchestrator, "run_turn_generate", successful_turn),
patch.object(
session_persistence,
"append_client_turn_with_affect_trace",
AsyncMock(return_value=True),
),
patch.object(
voice_routes.voice_service, "synthesize_stream", fake_synthesize_stream
),
):
await voice_routes._run_turn_and_speak(
websocket, # type: ignore[arg-type]
voice_routes.VoiceSessionContext(
session_id=sess.session_id,
principal=principal,
voice_preset=VoicePreset(preset="neutral", openai_voice="sage"),
),
voice_routes.VoiceTurnInput(learner_text="속마음 비노출 음성 발화"),
)
reply_message = next(
message for message in websocket.messages if message.get("type") == "reply"
)
self.assertIsNone(reply_message.get("inner_reaction"))
def _review_session(*, learner_feedback_enabled: bool) -> InProcSession:
state = state_machine.init_state(params=persona_service.P1.openness_params())
return InProcSession(
session_id="00000000-0000-4000-8000-000000000402",
case_id="inner-reaction-review-case",
learner_id="00000000-0000-0000-0000-000000000402",
persona_code=persona_service.P1.code,
theory_mode="humanistic",
persona=persona_service.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="상담자 발화",
turn_id="00000000-0000-0000-0000-000000000501",
),
TurnRecord(
turn_seq=2,
speaker="client",
stage=state.stage.value,
text="가상 내담자 응답",
text_masked="가상 내담자 응답",
turn_id="00000000-0000-0000-0000-000000000502",
),
],
)
class ReviewInnerReactionMappingTest(unittest.TestCase):
def test_client_turn_gets_inner_reaction_mapped_by_turn_id(self) -> None:
sess = _review_session(learner_feedback_enabled=True)
reaction = _inner_reaction()
review = build_session_review(
SessionReviewReadInput(
session=sess,
evaluation_record={"status": "ready", "payload": {"summary": "AI 요약"}},
evaluation_durable=True,
learner_feedback_enabled=True,
expose_learner_feedback=True,
inner_reactions={"00000000-0000-0000-0000-000000000502": reaction},
now_ts=1_120.0,
)
)
learner_turn, client_turn = review.turns
self.assertIsNone(learner_turn.innerReaction)
self.assertIsNotNone(client_turn.innerReaction)
self.assertEqual(client_turn.innerReaction.stance.code, "pull_back")
def test_feedback_hidden_omits_inner_reaction_even_when_present(self) -> None:
sess = _review_session(learner_feedback_enabled=False)
reaction = _inner_reaction()
review = build_session_review(
SessionReviewReadInput(
session=sess,
learner_feedback_enabled=False,
expose_learner_feedback=False,
inner_reactions={"00000000-0000-0000-0000-000000000502": reaction},
now_ts=1_120.0,
)
)
self.assertTrue(all(turn.innerReaction is None for turn in review.turns))
if __name__ == "__main__":
unittest.main()