vignette/apps/api/app/test_client_affect_trace.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

535 lines
20 KiB
Python

"""관리자 전용 Jev 감정 trace와 원자 영속화 회귀."""
from __future__ import annotations
import math
import unittest
from dataclasses import replace
from unittest.mock import patch
from unittest.mock import AsyncMock
from pydantic import ValidationError
from . import session_persistence, turn_runtime
from .contracts.client_affect import ClientAffectDimensionTraceV1, ClientAffectTraceV1
from .services import client_affect, orchestrator, persona, state_machine
from .services.jev_client import (
AppraisalResult,
CHOICE_QUESTION_IDS,
ChoiceJudgment,
EMOTION_DIMENSIONS,
EmotionEstimate,
NOUL_QUESTION_IDS,
NoulJudgment,
SORE_SPOT_QUESTION_ID,
)
from .store import InProcSession, TurnRecord
def _appraisal(
*,
confidence: float = 0.5,
probabilities: tuple[float, ...] | None = (0.0, 0.5, 0.5, 0.0, 0.0),
) -> AppraisalResult:
return AppraisalResult(
emotions={
dimension: EmotionEstimate(
score=0.375,
confidence=confidence,
probabilities=probabilities,
)
for dimension in EMOTION_DIMENSIONS
},
noul_judgments={
question_id: NoulJudgment(probability=0.3, confidence=0.6)
for question_id in NOUL_QUESTION_IDS
},
choice_judgments={
question_id: ChoiceJudgment(choice="uncertain-fixture", probabilities={"uncertain-fixture": 1.0})
for question_id in CHOICE_QUESTION_IDS
},
sore_spot_count=0,
model="jev-test",
latency_ms=11,
input_tokens=13,
output_tokens=17,
provider="typesafe",
cost_usd=None,
)
def _trace() -> ClientAffectTraceV1:
appraisal = _appraisal()
return _trace_from_appraisal(appraisal)
def _trace_from_appraisal(appraisal: AppraisalResult) -> ClientAffectTraceV1:
before = {f"emotion_{dimension}": 0.5 for dimension in EMOTION_DIMENSIONS}
transition = client_affect.transition_emotions(
before,
{},
appraisal,
min_confidence=0.65,
)
return client_affect.build_client_affect_trace(
affect_state_before=before,
affect_baseline={},
affect_state_after=transition.affect_state,
appraisal=appraisal,
transition=transition,
turn_seq=1,
stage="라포",
resistance=0.65,
effective_openness=0.15,
rapport_credit=1.25,
min_confidence=0.65,
)
class ClientAffectTraceContractTest(unittest.TestCase):
def test_trace_preserves_transition_values_and_tentative_is_not_accepted(self) -> None:
trace = _trace()
self.assertEqual(trace.schema_version, 1)
self.assertEqual(trace.context.rapport_credit, 1.25)
self.assertEqual(
tuple(dimension.key for dimension in trace.dimensions),
EMOTION_DIMENSIONS,
)
self.assertTrue(
all(dimension.decision == "tentative" for dimension in trace.dimensions)
)
self.assertEqual(trace.dimensions[0].before, 0.5)
self.assertEqual(trace.dimensions[0].target, 0.375)
self.assertEqual(trace.dimensions[0].after, 0.48125)
self.assertEqual(trace.dimensions[0].probabilities, (0.0, 0.5, 0.5, 0.0, 0.0))
def test_dimension_contract_rejects_nonfinite_probability(self) -> None:
for invalid in (math.nan, math.inf):
with self.subTest(invalid=invalid), self.assertRaises(ValidationError):
ClientAffectDimensionTraceV1(
key="anxiety",
before=0.0,
target=None,
after=0.0,
confidence=None,
probabilities=(0.0, invalid, 0.0, 0.0, 1.0),
decision="held",
)
def test_accepted_and_held_trace_values_preserve_nullable_inputs(self) -> None:
accepted = _trace_from_appraisal(
_appraisal(
confidence=0.9,
probabilities=(0.0, 0.0, 0.4, 0.6, 0.0),
)
)
held_appraisal = AppraisalResult(
emotions={
dimension: EmotionEstimate(
score=math.nan,
confidence=None,
probabilities=None,
)
for dimension in EMOTION_DIMENSIONS
},
noul_judgments={},
choice_judgments={},
sore_spot_count=0,
model="jev-test",
latency_ms=11,
input_tokens=13,
output_tokens=17,
provider="typesafe",
cost_usd=None,
)
held = _trace_from_appraisal(held_appraisal)
self.assertTrue(all(item.decision == "accepted" for item in accepted.dimensions))
self.assertEqual(
accepted.dimensions[0].probabilities,
(0.0, 0.0, 0.4, 0.6, 0.0),
)
self.assertEqual(accepted.dimensions[0].target, 0.375)
self.assertEqual(accepted.dimensions[0].confidence, 0.9)
self.assertTrue(all(item.decision == "held" for item in held.dimensions))
self.assertTrue(
all(
item.target is None
and item.confidence is None
and item.probabilities is None
for item in held.dimensions
)
)
def _full_choice(codes: list[str], choice: str, *, confidence: float | None = 0.6) -> ChoiceJudgment:
return ChoiceJudgment(
choice=choice,
probabilities={code: (1.0 if code == choice else 0.0) for code in codes},
confidence=confidence,
)
_COPING_CODES = ["nothing_asked", "manageable", "stretch", "overwhelming"]
_MOVE_CODES = [
"reflection", "validation", "open_question", "closed_question", "clarification",
"confrontation", "interpretation", "advice", "information", "self_disclosure",
"topic_shift", "other",
]
_BEHAVIOR_CODES = [
"disclose_more", "stay_with_feeling", "hold_core", "ask_back", "minimal_response",
"shift_topic", "abstract_talk", "appease", "self_blame", "complain", "argue_back",
"take_control",
]
_DISPLAY_CODES = ["as_felt", "softened", "covered_by_agreement", "masked"]
def _v2_appraisal(
*,
score: float = 0.5,
confidence: float | None = 0.9,
sore_spot_count: int = 1,
) -> AppraisalResult:
return AppraisalResult(
emotions={
dimension: EmotionEstimate(score=score, confidence=confidence)
for dimension in EMOTION_DIMENSIONS
},
noul_judgments={
question_id: NoulJudgment(probability=0.3, confidence=0.7)
for question_id in NOUL_QUESTION_IDS
},
choice_judgments={
"a_coping": _full_choice(_COPING_CODES, "nothing_asked"),
"a_move": _full_choice(_MOVE_CODES, "reflection"),
SORE_SPOT_QUESTION_ID: _full_choice(["none", "spot_1"], "spot_1"),
"c_behavior": _full_choice(_BEHAVIOR_CODES, "disclose_more"),
"c_display": _full_choice(_DISPLAY_CODES, "as_felt"),
},
sore_spot_count=sore_spot_count,
model="jev-test",
latency_ms=11,
input_tokens=13,
output_tokens=17,
provider="typesafe",
cost_usd=None,
)
class TransitionMoodTest(unittest.TestCase):
def test_confirmed_transition_uses_direction_based_coefficients(self) -> None:
previous = {
"emotion_anxiety": 0.1, # 부정, score>old → 악화
"emotion_trust": 0.1, # 긍정, score>old → 회복
"emotion_sadness": 0.95, # 부정, score<old → 회복
"emotion_hope": 0.95, # 긍정, score<old → 악화
"emotion_anger": 0.5,
"emotion_shame": 0.5,
"emotion_guilt": 0.5,
"emotion_loneliness": 0.5,
"emotion_relief": 0.5,
}
appraisal = _v2_appraisal(score=0.9, confidence=0.9)
result = client_affect.transition_mood(previous, {}, appraisal, min_confidence=0.65)
self.assertAlmostEqual(result.affect_state["emotion_anxiety"], 0.25)
self.assertAlmostEqual(result.affect_state["emotion_trust"], 0.18)
self.assertAlmostEqual(result.affect_state["emotion_sadness"], 0.94)
self.assertAlmostEqual(result.affect_state["emotion_hope"], 0.9325)
self.assertEqual(set(result.accepted_dimensions), set(EMOTION_DIMENSIONS))
def test_tentative_transition_uses_smaller_direction_based_coefficients(self) -> None:
previous = {f"emotion_{dimension}": 0.2 for dimension in EMOTION_DIMENSIONS}
appraisal = _v2_appraisal(score=0.375, confidence=0.5)
appraisal = AppraisalResult(
emotions={
dimension: EmotionEstimate(
score=0.375, confidence=0.5, probabilities=(0.0, 0.5, 0.5, 0.0, 0.0)
)
for dimension in EMOTION_DIMENSIONS
},
noul_judgments=appraisal.noul_judgments,
choice_judgments=appraisal.choice_judgments,
sore_spot_count=appraisal.sore_spot_count,
model=appraisal.model,
latency_ms=appraisal.latency_ms,
input_tokens=appraisal.input_tokens,
output_tokens=appraisal.output_tokens,
provider=appraisal.provider,
cost_usd=appraisal.cost_usd,
)
result = client_affect.transition_mood(previous, {}, appraisal, min_confidence=0.65)
self.assertAlmostEqual(result.affect_state["emotion_anxiety"], 0.22625) # 악화 잠정
self.assertAlmostEqual(result.affect_state["emotion_trust"], 0.214) # 회복 잠정
self.assertEqual(set(result.tentative_dimensions), set(EMOTION_DIMENSIONS))
class ClientAffectTraceV2ContractTest(unittest.TestCase):
def _trace_v2(self) -> "client_affect.ClientAffectTraceV2": # type: ignore[name-defined]
appraisal = _v2_appraisal(score=0.9, confidence=0.9)
before = {f"emotion_{dimension}": 0.1 for dimension in EMOTION_DIMENSIONS}
transition = client_affect.transition_mood(before, {}, appraisal, min_confidence=0.65)
expression = client_affect.build_expression_plan(appraisal, effective_openness=0.5)
return client_affect.build_client_affect_trace_v2(
affect_state_before=before,
affect_baseline={},
affect_state_after=transition.affect_state,
appraisal=appraisal,
transition=transition,
expression=expression,
turn_seq=4,
stage="탐색",
resistance=0.4,
effective_openness=0.5,
rapport_credit=0.6,
min_confidence=0.65,
)
def test_trace_v2_has_schema_version_two_and_v1_shaped_dimensions(self) -> None:
trace = self._trace_v2()
self.assertEqual(trace.schema_version, 2)
self.assertEqual(trace.policy.version, "jev-affect-v2")
self.assertEqual(
tuple(dimension.key for dimension in trace.dimensions),
EMOTION_DIMENSIONS,
)
self.assertEqual(
tuple(dimension.key for dimension in trace.reaction),
EMOTION_DIMENSIONS,
)
def test_trace_v2_appraisal_only_lists_sent_a_layer_questions(self) -> None:
trace = self._trace_v2()
keys = {entry.key for entry in trace.appraisal}
self.assertEqual(
keys,
{"a_understood", "a_judged", "a_autonomy", "a_directionless", "a_fact_conflict",
"a_coping", "a_move", SORE_SPOT_QUESTION_ID},
)
self.assertNotIn("c_behavior", keys)
self.assertNotIn("c_display", keys)
self.assertNotIn("c_disclose_ready", keys)
def test_trace_v2_expression_carries_gate_and_hidden_gap(self) -> None:
trace = self._trace_v2()
self.assertEqual(trace.expression.behavior.choice, "disclose_more")
self.assertEqual(trace.expression.gated_behavior, "disclose_more")
self.assertIsNone(trace.expression.gate_reason)
self.assertEqual(trace.expression.display.choice, "as_felt")
self.assertIsInstance(trace.expression.hidden_gap, bool)
self.assertEqual(trace.sore_spot_count, 1)
def test_trace_v2_reaction_marks_included_only_when_confidence_at_least_035(self) -> None:
trace = self._trace_v2()
self.assertTrue(all(entry.included for entry in trace.reaction))
self.assertTrue(all(entry.value is not None for entry in trace.reaction))
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_trace_insert: bool = False) -> None:
self.fail_trace_insert = fail_trace_insert
self.transaction_context = _Transaction()
self.executed: list[str] = []
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)
if self.fail_trace_insert and "INSERT INTO app.client_affect_trace" in query:
raise RuntimeError("trace 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 ClientAffectTracePersistenceTest(unittest.IsolatedAsyncioTestCase):
async def test_atomic_write_assigns_turn_id_only_after_trace_and_state_write(self) -> None:
conn = _Connection()
turn = TurnRecord(
turn_seq=1,
speaker="client",
stage="라포",
text="조금 더 이야기해볼게요.",
text_masked="조금 더 이야기해볼게요.",
)
state = state_machine.SessionState(turn_seq=1)
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(),
)
self.assertTrue(stored)
self.assertEqual(turn.turn_id, "00000000-0000-0000-0000-000000000222")
self.assertIsNone(conn.transaction_context.error)
self.assertIn("INSERT INTO app.client_affect_trace", conn.executed[0])
self.assertIn("INSERT INTO app.session_state", conn.executed[1])
async def test_atomic_write_keeps_turn_identifier_unpublished_when_trace_insert_fails(self) -> None:
conn = _Connection(fail_trace_insert=True)
turn = TurnRecord(
turn_seq=1,
speaker="client",
stage="라포",
text="조금 더 이야기해볼게요.",
text_masked="조금 더 이야기해볼게요.",
)
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_machine.SessionState(turn_seq=1),
trace=_trace(),
)
self.assertIsNone(turn.turn_id)
self.assertIs(conn.transaction_context.error, RuntimeError)
class ClientAffectTraceRuntimeTest(unittest.IsolatedAsyncioTestCase):
def _session_and_result(
self,
) -> tuple[InProcSession, orchestrator.TurnContext, orchestrator.TurnResult]:
state_before = state_machine.SessionState()
state_after = replace(state_before, turn_seq=1)
sess = InProcSession(
session_id="trace-runtime-session",
case_id="trace-runtime-case",
learner_id="00000000-0000-0000-0000-000000000101",
persona_code=persona.P1.code,
theory_mode="humanistic",
persona=persona.P1,
state=state_before,
)
ctx = orchestrator.TurnContext(
session_id=sess.session_id,
case_id=sess.case_id,
persona=sess.persona,
state_before=state_before,
learner_text_raw="그 마음을 조금 더 들려주실 수 있을까요?",
learner_text_masked="그 마음을 조금 더 들려주실 수 있을까요?",
state_after=state_after,
client_affect_trace=_trace(),
)
result = orchestrator.TurnResult(
turn_seq=1,
stage=state_after.stage.value,
effective_openness=state_after.effective_openness,
client_reply="조금 더 이야기해볼게요.",
safety_flagged=False,
state_after=state_after,
)
return sess, ctx, result
async def test_trace_path_updates_runtime_mirrors_only_after_atomic_success(self) -> None:
sess, ctx, result = self._session_and_result()
append_counselor = AsyncMock()
append_atomic = AsyncMock(return_value=True)
update_state = AsyncMock()
with (
patch.object(turn_runtime, "append_completed_turn", append_counselor),
patch.object(
session_persistence,
"append_client_turn_with_affect_trace",
append_atomic,
),
patch.object(turn_runtime, "update_session_state", update_state),
):
await turn_runtime.record_completed_turn(
sess,
ctx,
result,
context_prefix="trace test",
)
append_atomic.assert_awaited_once()
append_counselor.assert_awaited_once()
update_state.assert_not_awaited()
self.assertIs(sess.state, result.state_after)
self.assertEqual([turn.speaker for turn in sess.turns], ["client"])
async def test_trace_path_keeps_runtime_mirrors_unchanged_when_atomic_write_fails(self) -> None:
sess, ctx, result = self._session_and_result()
append_counselor = AsyncMock()
update_state = AsyncMock()
with (
patch.object(turn_runtime, "append_completed_turn", append_counselor),
patch.object(
session_persistence,
"append_client_turn_with_affect_trace",
AsyncMock(
side_effect=session_persistence.ClientAffectTracePersistenceError(
"atomic write failed"
)
),
),
patch.object(turn_runtime, "update_session_state", update_state),
):
with self.assertRaises(session_persistence.ClientAffectTracePersistenceError):
await turn_runtime.record_completed_turn(
sess,
ctx,
result,
context_prefix="trace test",
)
append_counselor.assert_awaited_once()
update_state.assert_not_awaited()
self.assertIs(sess.state, ctx.state_before)
self.assertEqual(sess.turns, [])
if __name__ == "__main__":
unittest.main()