Jev 내담자 평가·표현 v2와 속마음 공개

상담자 발화 판정(A)·감정(B)·표현(C) 20문항 질문 세트, 감쇠 없는 이번 턴 반응과 비대칭 기분 전이, 개방도 게이트로 생성 지시를 만들고 ccd.coping_strategy 전달 누락을 고친다.

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

View file

@ -13,7 +13,16 @@ 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, EMOTION_DIMENSIONS, EmotionEstimate
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
@ -31,6 +40,15 @@ def _appraisal(
)
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,
@ -115,6 +133,9 @@ class ClientAffectTraceContractTest(unittest.TestCase):
)
for dimension in EMOTION_DIMENSIONS
},
noul_judgments={},
choice_judgments={},
sore_spot_count=0,
model="jev-test",
latency_ms=11,
input_tokens=13,
@ -142,6 +163,174 @@ class ClientAffectTraceContractTest(unittest.TestCase):
)
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