711 lines
28 KiB
Python
711 lines
28 KiB
Python
"""Jev 감정 상태의 순수 전이와 실제 생성 경계 회귀."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import math
|
|
import unittest
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
from .deps import Principal, Role
|
|
from .engine_client import EngineError, GenerateResponse
|
|
from .contracts.engine_gateway import EngineGatewaySseLineDecoder
|
|
from .routes import sessions
|
|
from .services import (
|
|
client_affect,
|
|
guardrail,
|
|
memory,
|
|
orchestrator,
|
|
persona,
|
|
rupture_scenario_director,
|
|
state_machine,
|
|
)
|
|
from .services.jev_client import AppraisalResult, EMOTION_DIMENSIONS, EmotionEstimate, JevError
|
|
from .store import InProcSession, store
|
|
|
|
|
|
def _appraisal(
|
|
*,
|
|
score: float = 1.0,
|
|
confidence: float | None = 0.9,
|
|
probabilities: tuple[float, ...] | None = None,
|
|
provider: str = "typesafe",
|
|
cost_usd: float | None = None,
|
|
) -> AppraisalResult:
|
|
return AppraisalResult(
|
|
emotions={
|
|
dimension: EmotionEstimate(
|
|
score=score,
|
|
confidence=confidence,
|
|
probabilities=probabilities,
|
|
)
|
|
for dimension in EMOTION_DIMENSIONS
|
|
},
|
|
model="jev-test",
|
|
latency_ms=11,
|
|
input_tokens=13,
|
|
output_tokens=17,
|
|
provider=provider,
|
|
cost_usd=cost_usd,
|
|
)
|
|
|
|
|
|
def _context() -> orchestrator.TurnContext:
|
|
state = state_machine.init_state(params=persona.P1.openness_params())
|
|
return orchestrator.prepare_turn(
|
|
session_id="00000000-0000-0000-0000-000000000111",
|
|
case_id=None,
|
|
card=persona.P1,
|
|
state=state,
|
|
learner_text="조금 더 이야기해도 괜찮아요.",
|
|
learner_identity="김상담",
|
|
memory=orchestrator.TurnMemory(
|
|
recall_summary="김상담이 [PHONE] 관련해서 물었다.",
|
|
pinned_facts=["서연은 엄마와 갈등을 겪는다."],
|
|
recent_turns=[{"speaker": "client", "text": "서연은 많이 지쳤어요."}],
|
|
),
|
|
)
|
|
|
|
|
|
class _GenerateEngine:
|
|
engine_mode = "fake"
|
|
default_model = None
|
|
|
|
def __init__(self) -> None:
|
|
self.request = None
|
|
self.calls = 0
|
|
|
|
async def generate(self, request):
|
|
self.request = request
|
|
self.calls += 1
|
|
return GenerateResponse(
|
|
text="그냥… 잘 모르겠어요.",
|
|
model="fake-model",
|
|
provider="fake-provider",
|
|
tokens_in=1,
|
|
tokens_out=2,
|
|
cost_usd=0.0,
|
|
)
|
|
|
|
|
|
class _StreamEngine:
|
|
engine_mode = "fake"
|
|
default_model = "fake-model"
|
|
|
|
def __init__(self) -> None:
|
|
self.request = None
|
|
self.calls = 0
|
|
|
|
async def stream_packets(self, request):
|
|
self.request = request
|
|
self.calls += 1
|
|
decoder = EngineGatewaySseLineDecoder()
|
|
for raw in (
|
|
"event: token",
|
|
"data: " + json.dumps({"text": "그냥… 잘 모르겠어요."}, ensure_ascii=False),
|
|
"event: done",
|
|
'data: {"provider":"fake-provider","model":"fake-model","tokens_in":1,"tokens_out":2,"cost_usd":0.0}',
|
|
):
|
|
packet = decoder.feed_line(raw)
|
|
if packet is not None:
|
|
yield packet
|
|
|
|
|
|
class _InterruptedStreamEngine(_StreamEngine):
|
|
def __init__(self, interruption: BaseException | None = None) -> None:
|
|
super().__init__()
|
|
self.interruption = interruption
|
|
|
|
async def stream_packets(self, request):
|
|
self.request = request
|
|
self.calls += 1
|
|
decoder = EngineGatewaySseLineDecoder()
|
|
for raw in (
|
|
"event: token",
|
|
"data: " + json.dumps({"text": "부분 응답"}, ensure_ascii=False),
|
|
):
|
|
packet = decoder.feed_line(raw)
|
|
if packet is not None:
|
|
yield packet
|
|
if self.interruption is not None:
|
|
raise self.interruption
|
|
for raw in ("event: error", 'data: {"detail":"gateway interrupted"}'):
|
|
packet = decoder.feed_line(raw)
|
|
if packet is not None:
|
|
yield packet
|
|
|
|
|
|
async def _consume_event_source(response: object) -> bytes:
|
|
body = bytearray()
|
|
async for chunk in getattr(response, "body_iterator"):
|
|
if isinstance(chunk, str):
|
|
body.extend(chunk.encode("utf-8"))
|
|
elif isinstance(chunk, (bytes, bytearray)):
|
|
body.extend(chunk)
|
|
else:
|
|
body.extend(str(chunk).encode("utf-8"))
|
|
return bytes(body)
|
|
|
|
|
|
class ClientAffectTransitionTest(unittest.TestCase):
|
|
def test_baseline_and_inertia_preserve_existing_clinical_keys(self) -> None:
|
|
baseline = {"anxiety": 0.6, "negative_affect": 0.4, "hopelessness": 0.25}
|
|
result = client_affect.transition_emotions(
|
|
{"negative_affect": 0.9, "emotion_anxiety": 0.2},
|
|
baseline,
|
|
_appraisal(score=1.0),
|
|
min_confidence=0.65,
|
|
)
|
|
|
|
self.assertEqual(result.affect_state["negative_affect"], 0.9)
|
|
self.assertEqual(result.affect_state["emotion_anxiety"], 0.35)
|
|
self.assertEqual(result.affect_state["emotion_sadness"], 0.55)
|
|
self.assertEqual(result.affect_state["emotion_hope"], 0.8375)
|
|
self.assertEqual(set(result.accepted_dimensions), set(EMOTION_DIMENSIONS))
|
|
self.assertEqual(result.tentative_dimensions, ())
|
|
|
|
def test_low_confidence_holds_exact_previous_vector(self) -> None:
|
|
previous = {f"emotion_{dimension}": 0.31 for dimension in EMOTION_DIMENSIONS}
|
|
result = client_affect.transition_emotions(
|
|
previous,
|
|
{},
|
|
_appraisal(score=1.0, confidence=0.64),
|
|
min_confidence=0.65,
|
|
)
|
|
|
|
self.assertEqual(result.affect_state, previous)
|
|
self.assertEqual(result.accepted_dimensions, ())
|
|
self.assertEqual(set(result.held_dimensions), set(EMOTION_DIMENSIONS))
|
|
|
|
def test_concentrated_mid_confidence_distribution_allows_small_tentative_step(self) -> None:
|
|
previous = {f"emotion_{dimension}": 0.5 for dimension in EMOTION_DIMENSIONS}
|
|
result = client_affect.transition_emotions(
|
|
previous,
|
|
{},
|
|
_appraisal(
|
|
score=0.375,
|
|
confidence=0.35,
|
|
probabilities=(0.0, 0.5, 0.5, 0.0, 0.0),
|
|
),
|
|
min_confidence=0.65,
|
|
)
|
|
|
|
self.assertEqual(result.affect_state["emotion_anxiety"], 0.48125)
|
|
self.assertEqual(set(result.accepted_dimensions), set(EMOTION_DIMENSIONS))
|
|
self.assertEqual(set(result.tentative_dimensions), set(EMOTION_DIMENSIONS))
|
|
self.assertEqual(result.held_dimensions, ())
|
|
|
|
def test_tentative_normalizes_rounded_distribution_and_caps_both_directions(self) -> None:
|
|
for probabilities in ((0.0, 0.495, 0.495, 0.0, 0.0), (0.0, 0.505, 0.505, 0.0, 0.0)):
|
|
with self.subTest(probabilities=probabilities):
|
|
normalized = client_affect.transition_emotions(
|
|
{f"emotion_{dimension}": 0.5 for dimension in EMOTION_DIMENSIONS},
|
|
{},
|
|
_appraisal(score=0.375, confidence=0.5, probabilities=probabilities),
|
|
min_confidence=0.65,
|
|
)
|
|
self.assertEqual(normalized.affect_state["emotion_anxiety"], 0.48125)
|
|
|
|
upward = client_affect.transition_emotions(
|
|
{f"emotion_{dimension}": 0.0 for dimension in EMOTION_DIMENSIONS},
|
|
{},
|
|
_appraisal(
|
|
score=0.875,
|
|
confidence=0.5,
|
|
probabilities=(0.0, 0.0, 0.0, 0.5, 0.5),
|
|
),
|
|
min_confidence=0.65,
|
|
)
|
|
downward = client_affect.transition_emotions(
|
|
{f"emotion_{dimension}": 1.0 for dimension in EMOTION_DIMENSIONS},
|
|
{},
|
|
_appraisal(
|
|
score=0.125,
|
|
confidence=0.5,
|
|
probabilities=(0.5, 0.5, 0.0, 0.0, 0.0),
|
|
),
|
|
min_confidence=0.65,
|
|
)
|
|
self.assertEqual(upward.affect_state["emotion_anxiety"], 0.075)
|
|
self.assertEqual(downward.affect_state["emotion_anxiety"], 0.925)
|
|
|
|
def test_tentative_requires_concentrated_valid_distribution(self) -> None:
|
|
previous = {f"emotion_{dimension}": 0.5 for dimension in EMOTION_DIMENSIONS}
|
|
for probabilities in (
|
|
(0.2, 0.2, 0.2, 0.2, 0.2),
|
|
(0.5, 0.0, 0.0, 0.0, 0.5),
|
|
None,
|
|
(math.nan, 0.0, 1.0, 0.0, 0.0),
|
|
):
|
|
with self.subTest(probabilities=probabilities):
|
|
result = client_affect.transition_emotions(
|
|
previous,
|
|
{},
|
|
_appraisal(score=1.0, confidence=0.5, probabilities=probabilities),
|
|
min_confidence=0.65,
|
|
)
|
|
self.assertEqual(result.affect_state, previous)
|
|
self.assertEqual(result.accepted_dimensions, ())
|
|
self.assertEqual(result.tentative_dimensions, ())
|
|
self.assertEqual(set(result.held_dimensions), set(EMOTION_DIMENSIONS))
|
|
|
|
below_floor = client_affect.transition_emotions(
|
|
previous,
|
|
{},
|
|
_appraisal(
|
|
score=1.0,
|
|
confidence=0.34,
|
|
probabilities=(0.0, 0.5, 0.5, 0.0, 0.0),
|
|
),
|
|
min_confidence=0.65,
|
|
)
|
|
self.assertEqual(below_floor.affect_state, previous)
|
|
self.assertEqual(below_floor.tentative_dimensions, ())
|
|
|
|
def test_invalid_scores_confidences_and_threshold_hold_without_mutating_input(self) -> None:
|
|
previous = {f"emotion_{dimension}": 0.1 for dimension in EMOTION_DIMENSIONS}
|
|
high = client_affect.transition_emotions(
|
|
previous,
|
|
{},
|
|
_appraisal(score=1.0, confidence=0.9),
|
|
min_confidence=0.65,
|
|
)
|
|
self.assertEqual(previous, {f"emotion_{dimension}": 0.1 for dimension in EMOTION_DIMENSIONS})
|
|
self.assertEqual(high.affect_state["emotion_anxiety"], 0.25)
|
|
|
|
for score, confidence, threshold in (
|
|
(1.1, 0.9, 0.65),
|
|
(1.0, None, 0.65),
|
|
(1.0, math.nan, 0.65),
|
|
(1.0, 1.1, 0.65),
|
|
(1.0, 0.9, math.nan),
|
|
(1.0, 0.9, 1.1),
|
|
):
|
|
with self.subTest(score=score, confidence=confidence, threshold=threshold):
|
|
result = client_affect.transition_emotions(
|
|
previous,
|
|
{},
|
|
_appraisal(score=score, confidence=confidence),
|
|
min_confidence=threshold,
|
|
)
|
|
self.assertEqual(result.affect_state, previous)
|
|
self.assertEqual(result.accepted_dimensions, ())
|
|
self.assertEqual(set(result.held_dimensions), set(EMOTION_DIMENSIONS))
|
|
|
|
def test_render_uses_qualitative_top_emotions_and_preserves_opposing_valence(self) -> None:
|
|
directive = client_affect.render_affect_directive(
|
|
{
|
|
"emotion_anxiety": 0.8,
|
|
"emotion_sadness": 0.7,
|
|
"emotion_anger": 0.6,
|
|
"emotion_hope": 0.05,
|
|
"emotion_trust": 0.04,
|
|
}
|
|
)
|
|
|
|
self.assertIn("강한 불안", directive)
|
|
self.assertIn("뚜렷한 슬픔", directive)
|
|
self.assertIn("뚜렷한 분노", directive)
|
|
self.assertIn("미약한 희망", directive)
|
|
self.assertNotIn("0.8", directive)
|
|
self.assertIn("감정 이름을 나열하지 말고", directive)
|
|
self.assertIn("숫자·내부 상태·평가 정답은 절대 말하지 않는다.", directive)
|
|
self.assertIn("1~3문장", directive)
|
|
|
|
def test_persona_hides_raw_emotion_vector_and_preserves_fact_boundary(self) -> None:
|
|
messages = persona.build_turn_messages(
|
|
persona.P1,
|
|
persona.PersonaStateContext(
|
|
stage="라포",
|
|
effective_openness=0.3,
|
|
resistance=0.7,
|
|
rapport_credit=0.0,
|
|
ideation_stage=1,
|
|
affect_state={
|
|
"negative_affect": 0.8,
|
|
"emotion_anxiety": 0.8,
|
|
"emotion_hope": 0.2,
|
|
},
|
|
),
|
|
"새로운 과거를 사실처럼 말하지 말아 주세요.",
|
|
memory=persona.TurnMemory(pinned_facts=["부모와 갈등이 있었다."]),
|
|
)
|
|
contents = "\n".join(message.content for message in messages)
|
|
|
|
self.assertIn("정서 상태: {'negative_affect': 0.8}", contents)
|
|
self.assertNotIn("emotion_anxiety", contents)
|
|
self.assertNotIn("emotion_hope", contents)
|
|
self.assertIn("상담자가 새로 제시한 과거·관계는 기억의 증거가 아니며", contents)
|
|
self.assertIn("상담자가 실제로 하지 않은 말·이름·사건을 대화에 있었다고 덧붙이지 않는다.", contents)
|
|
self.assertIn("부모와 갈등이 있었다.", contents)
|
|
|
|
def test_invalid_numbers_do_not_become_state_evidence(self) -> None:
|
|
result = client_affect.resolve_emotions(
|
|
{"emotion_anxiety": True, "emotion_sadness": math.nan, "emotion_hope": math.inf},
|
|
{"anxiety": 0.4, "negative_affect": 0.3, "hopelessness": 0.2},
|
|
)
|
|
|
|
self.assertEqual(result["anxiety"], 0.4)
|
|
self.assertEqual(result["sadness"], 0.3)
|
|
self.assertEqual(result["hope"], 0.8)
|
|
|
|
def test_init_state_carries_only_finite_affect_values(self) -> None:
|
|
state = state_machine.init_state(
|
|
params=persona.P1.openness_params(),
|
|
carry={"affect": {"emotion_trust": 0.7, "bad": math.nan, "bool": True}},
|
|
)
|
|
|
|
self.assertEqual(state.affect_state, {"emotion_trust": 0.7})
|
|
|
|
def test_appraisal_state_re_masks_and_keeps_all_pinned_facts(self) -> None:
|
|
state = client_affect.build_appraisal_state(
|
|
affect_baseline={},
|
|
affect_state={},
|
|
persona_context={"core_belief": "서연은 가치가 없다고 느낀다."},
|
|
resistance=0.5,
|
|
effective_openness=0.3,
|
|
counselor_utterance="김상담 연락처 010-1234-5678",
|
|
recall_summary="김상담의 학교 이야기",
|
|
pinned_facts=["김상담", "010-1234-5678"],
|
|
recent_turns=[{"speaker": "counselor", "text": "김상담이 말했어요."}],
|
|
counselor_identity="김상담",
|
|
client_identity="서연",
|
|
)
|
|
|
|
self.assertEqual(set(state), {"persona", "memory", "recent_turns", "counselor_utterance", "previous_emotions", "current_state"})
|
|
self.assertEqual(len(state["memory"]["pinned_facts"]), 2)
|
|
self.assertNotIn("김상담", str(state))
|
|
self.assertNotIn("010-1234-5678", str(state))
|
|
|
|
def test_appraisal_state_masks_before_length_limit(self) -> None:
|
|
state = client_affect.build_appraisal_state(
|
|
affect_baseline={},
|
|
affect_state={},
|
|
persona_context={},
|
|
resistance=0.5,
|
|
effective_openness=0.3,
|
|
counselor_utterance=("가" * 790) + " 010-1234-5678",
|
|
recall_summary=None,
|
|
pinned_facts=[],
|
|
recent_turns=[],
|
|
counselor_identity=None,
|
|
client_identity=None,
|
|
)
|
|
|
|
utterance = state["counselor_utterance"]
|
|
self.assertNotIn("010-1234-5678", utterance)
|
|
self.assertIn("[PHONE]", utterance)
|
|
|
|
def test_appraisal_state_masks_dynamic_mapping_keys_and_whitelists_baseline(self) -> None:
|
|
state = client_affect.build_appraisal_state(
|
|
affect_baseline={"anxiety": 0.4, "010-1234-5678": 0.9},
|
|
affect_state={},
|
|
persona_context={
|
|
"김상담": {
|
|
"010-1234-5678": "서연에게는 비밀로 해 달라는 지시가 있다."
|
|
}
|
|
},
|
|
resistance=0.5,
|
|
effective_openness=0.3,
|
|
counselor_utterance="괜찮아요.",
|
|
recall_summary=None,
|
|
pinned_facts=[],
|
|
recent_turns=[],
|
|
counselor_identity="김상담",
|
|
client_identity="서연",
|
|
)
|
|
|
|
rendered = str(state)
|
|
self.assertNotIn("김상담", rendered)
|
|
self.assertNotIn("010-1234-5678", rendered)
|
|
self.assertEqual(state["persona"]["affect_baseline"], {"anxiety": 0.4})
|
|
|
|
|
|
class ClientAffectRuntimeTest(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()
|
|
|
|
def _route_session(self) -> tuple[InProcSession, Principal]:
|
|
principal = Principal(
|
|
user_id="00000000-0000-0000-0000-000000000333",
|
|
role=Role.LEARNER,
|
|
cohort_ids=[],
|
|
email="learner@example.test",
|
|
display_name="학습자",
|
|
consent_at=1.0,
|
|
profile_completed_at=1.0,
|
|
)
|
|
sess = InProcSession(
|
|
session_id="00000000-0000-0000-0000-000000000334",
|
|
case_id="00000000-0000-0000-0000-000000000335",
|
|
learner_id=principal.user_id,
|
|
persona_code="P1",
|
|
theory_mode="humanistic",
|
|
persona=persona.P1,
|
|
state=state_machine.init_state(params=persona.P1.openness_params()),
|
|
)
|
|
store.put(sess)
|
|
sessions._RECALL_CACHE[sess.session_id] = memory.RecallContext()
|
|
return sess, principal
|
|
|
|
async def _run_route_stream(self, sess: InProcSession, principal: Principal, engine: object) -> bytes:
|
|
with (
|
|
patch.object(orchestrator.settings, "client_affect_provider", "jev"),
|
|
patch.object(orchestrator.jev_client, "appraise", AsyncMock(return_value=_appraisal())),
|
|
patch.object(sessions, "engine_client", engine),
|
|
patch.object(
|
|
sessions.rupture_scenario_director,
|
|
"load_stored_scenario_context",
|
|
AsyncMock(return_value=None),
|
|
),
|
|
patch.object(
|
|
sessions.turn_runtime.session_persistence,
|
|
"append_client_turn_with_affect_trace",
|
|
AsyncMock(return_value=True),
|
|
),
|
|
patch.object(sessions, "_schedule_stream_turn_evaluation"),
|
|
):
|
|
response = await sessions.stream_turn(
|
|
sess.session_id,
|
|
sessions.TurnRequest(text="조금 더 말해도 괜찮아요."),
|
|
principal,
|
|
)
|
|
return await _consume_event_source(response)
|
|
|
|
async def test_generate_applies_once_before_request_with_internal_provenance(self) -> None:
|
|
ctx = _context()
|
|
engine = _GenerateEngine()
|
|
audits: list[dict] = []
|
|
|
|
async def audit(payload: dict) -> None:
|
|
audits.append(payload)
|
|
|
|
with (
|
|
patch.object(orchestrator.settings, "client_affect_provider", "jev"),
|
|
patch.object(
|
|
orchestrator.jev_client,
|
|
"appraise",
|
|
AsyncMock(return_value=_appraisal(provider="OpenRouter", cost_usd=0.000019992)),
|
|
) as appraise,
|
|
):
|
|
result = await orchestrator.run_turn_generate(ctx, engine, audit_hook=audit) # type: ignore[arg-type]
|
|
|
|
self.assertEqual(appraise.await_count, 1)
|
|
self.assertEqual(engine.calls, 1)
|
|
self.assertGreater(result.state_after.affect_state["emotion_anxiety"], 0.0)
|
|
self.assertEqual(engine.request.metadata["client_affect"]["provider"], "OpenRouter")
|
|
self.assertEqual(engine.request.metadata["client_affect"]["tentative_dimensions"], [])
|
|
self.assertEqual([payload["provider"] for payload in audits], ["OpenRouter", "fake-provider"])
|
|
self.assertEqual(audits[0]["cost_usd"], 0.000019992)
|
|
self.assertNotIn("previous_emotions", str(engine.request.metadata))
|
|
|
|
async def test_appraisal_rebuild_preserves_theory_and_scenario_directives(self) -> None:
|
|
ctx = _context()
|
|
ctx.theory_mode = "cbt"
|
|
cue = "고개를 숙이고 잠시 대답을 미룬다."
|
|
ctx.scenario_directive = rupture_scenario_director.ScenarioDirective(
|
|
scenario_id="g3-scenario-0123456789abcdef0123456789abcdef",
|
|
rupture_type="withdrawal",
|
|
behavior_cue=cue,
|
|
turn_seq=ctx.state_after.turn_seq,
|
|
opportunity_index=0,
|
|
context_fingerprint="test-context",
|
|
)
|
|
orchestrator._rebuild_persona_messages(ctx)
|
|
engine = _GenerateEngine()
|
|
|
|
with (
|
|
patch.object(orchestrator.settings, "client_affect_provider", "jev"),
|
|
patch.object(orchestrator.jev_client, "appraise", AsyncMock(return_value=_appraisal())),
|
|
):
|
|
await orchestrator.run_turn_generate(ctx, engine) # type: ignore[arg-type]
|
|
|
|
contents = "\n".join(message.content for message in engine.request.messages)
|
|
self.assertIn("[L3-T 이론모드: CBT]", contents)
|
|
self.assertIn("자동적 사고, 감정, 행동의 연결", contents)
|
|
self.assertIn(cue, contents)
|
|
|
|
async def test_legacy_does_not_appraise_or_add_baseline_vector(self) -> None:
|
|
ctx = _context()
|
|
engine = _GenerateEngine()
|
|
with (
|
|
patch.object(orchestrator.settings, "client_affect_provider", "legacy"),
|
|
patch.object(orchestrator.jev_client, "appraise", AsyncMock()) as appraise,
|
|
):
|
|
await orchestrator.run_turn_generate(ctx, engine) # type: ignore[arg-type]
|
|
|
|
self.assertEqual(appraise.await_count, 0)
|
|
self.assertFalse(any(key.startswith("emotion_") for key in ctx.state_after.affect_state))
|
|
|
|
async def test_crisis_stops_before_jev_and_generation(self) -> None:
|
|
ctx = _context()
|
|
ctx.crisis = guardrail.CrisisResult(
|
|
kind=guardrail.CrisisKind.LEARNER_REAL,
|
|
risk_level=3,
|
|
escalate=True,
|
|
)
|
|
engine = _GenerateEngine()
|
|
with (
|
|
patch.object(orchestrator.settings, "client_affect_provider", "jev"),
|
|
patch.object(orchestrator.jev_client, "appraise", AsyncMock()) as appraise,
|
|
):
|
|
result = await orchestrator.run_turn_generate(ctx, engine) # type: ignore[arg-type]
|
|
|
|
self.assertEqual(appraise.await_count, 0)
|
|
self.assertEqual(engine.calls, 0)
|
|
self.assertTrue(result.conversation_stopped)
|
|
|
|
async def test_appraisal_failure_prevents_generation_without_mutating_original_state(self) -> None:
|
|
ctx = _context()
|
|
before = dict(ctx.state_before.affect_state)
|
|
engine = _GenerateEngine()
|
|
with (
|
|
patch.object(orchestrator.settings, "client_affect_provider", "jev"),
|
|
patch.object(
|
|
orchestrator.jev_client,
|
|
"appraise",
|
|
AsyncMock(side_effect=JevError("timeout")),
|
|
),
|
|
):
|
|
with self.assertRaisesRegex(EngineError, "client_affect_timeout"):
|
|
await orchestrator.run_turn_generate(ctx, engine) # type: ignore[arg-type]
|
|
|
|
self.assertEqual(engine.calls, 0)
|
|
self.assertEqual(ctx.state_before.affect_state, before)
|
|
|
|
async def test_cancellation_propagates_from_appraisal(self) -> None:
|
|
ctx = _context()
|
|
engine = _GenerateEngine()
|
|
with (
|
|
patch.object(orchestrator.settings, "client_affect_provider", "jev"),
|
|
patch.object(
|
|
orchestrator.jev_client,
|
|
"appraise",
|
|
AsyncMock(side_effect=asyncio.CancelledError()),
|
|
),
|
|
):
|
|
with self.assertRaises(asyncio.CancelledError):
|
|
await orchestrator.run_turn_generate(ctx, engine) # type: ignore[arg-type]
|
|
|
|
async def test_stream_applies_once_before_engine_and_hides_affect_metadata_from_sse(self) -> None:
|
|
ctx = _context()
|
|
engine = _StreamEngine()
|
|
with (
|
|
patch.object(orchestrator.settings, "client_affect_provider", "jev"),
|
|
patch.object(orchestrator.jev_client, "appraise", AsyncMock(return_value=_appraisal())) as appraise,
|
|
):
|
|
events = [
|
|
event
|
|
async for event in orchestrator.run_turn_stream(ctx, engine) # type: ignore[arg-type]
|
|
]
|
|
|
|
self.assertEqual(appraise.await_count, 1)
|
|
self.assertEqual(engine.calls, 1)
|
|
self.assertEqual(events[-1].event, "done")
|
|
self.assertIn("client_affect", engine.request.metadata)
|
|
self.assertNotIn("client_affect", events[-1].data)
|
|
self.assertNotIn("accepted_dimensions", events[-1].data)
|
|
|
|
async def test_stream_appraisal_failure_emits_error_without_generation(self) -> None:
|
|
ctx = _context()
|
|
engine = _StreamEngine()
|
|
with (
|
|
patch.object(orchestrator.settings, "client_affect_provider", "jev"),
|
|
patch.object(
|
|
orchestrator.jev_client,
|
|
"appraise",
|
|
AsyncMock(side_effect=JevError("timeout")),
|
|
),
|
|
):
|
|
events = [
|
|
event
|
|
async for event in orchestrator.run_turn_stream(ctx, engine) # type: ignore[arg-type]
|
|
]
|
|
|
|
self.assertEqual([(event.event, event.data) for event in events], [("error", {"detail": "client_affect_timeout"})])
|
|
self.assertEqual(engine.calls, 0)
|
|
|
|
async def test_route_stream_error_after_appraisal_does_not_finalize_affect(self) -> None:
|
|
sess, principal = self._route_session()
|
|
before = dict(sess.state.affect_state)
|
|
|
|
body = await self._run_route_stream(sess, principal, _InterruptedStreamEngine())
|
|
|
|
self.assertIn(b"gateway interrupted", body)
|
|
self.assertEqual(sess.state.affect_state, before)
|
|
self.assertEqual(sess.turns, [])
|
|
|
|
async def test_route_stream_cancellation_after_appraisal_does_not_finalize_affect(self) -> None:
|
|
sess, principal = self._route_session()
|
|
before = dict(sess.state.affect_state)
|
|
engine = _InterruptedStreamEngine(asyncio.CancelledError())
|
|
|
|
with self.assertRaises(asyncio.CancelledError):
|
|
await self._run_route_stream(sess, principal, engine)
|
|
|
|
self.assertEqual(sess.state.affect_state, before)
|
|
self.assertEqual(sess.turns, [])
|
|
|
|
async def test_route_stream_done_finalizes_jev_affect(self) -> None:
|
|
sess, principal = self._route_session()
|
|
|
|
body = await self._run_route_stream(sess, principal, _StreamEngine())
|
|
|
|
self.assertIn(b"done", body)
|
|
self.assertIn("emotion_anxiety", sess.state.affect_state)
|
|
self.assertEqual(len(sess.turns), 2)
|
|
|
|
def test_public_end_state_retains_clinical_affect_but_hides_jev_vector(self) -> None:
|
|
internal = {
|
|
"stage": "라포",
|
|
"affect": {
|
|
"negative_affect": 0.7,
|
|
"emotion_anxiety": 0.6,
|
|
"emotion_trust": 0.2,
|
|
},
|
|
}
|
|
|
|
public = client_affect.public_end_state(internal)
|
|
|
|
self.assertEqual(internal["affect"]["emotion_anxiety"], 0.6)
|
|
self.assertEqual(public["affect"], {"negative_affect": 0.7})
|
|
|
|
async def test_end_route_preserves_internal_snapshot_and_hides_jev_vector(self) -> None:
|
|
sess = InProcSession(
|
|
session_id="00000000-0000-0000-0000-000000000222",
|
|
case_id="00000000-0000-0000-0000-000000000223",
|
|
learner_id="00000000-0000-0000-0000-000000000224",
|
|
persona_code="P1",
|
|
theory_mode="humanistic",
|
|
persona=persona.P1,
|
|
state=state_machine.SessionState(
|
|
affect_state={"negative_affect": 0.7, "emotion_anxiety": 0.6},
|
|
),
|
|
)
|
|
carry = memory.CarryOver(end_state=sess.state.snapshot())
|
|
principal = Principal(
|
|
user_id=sess.learner_id,
|
|
role=Role.LEARNER,
|
|
cohort_ids=[],
|
|
email="learner@example.test",
|
|
display_name="학습자",
|
|
consent_at=1.0,
|
|
profile_completed_at=1.0,
|
|
)
|
|
with (
|
|
patch.object(sessions, "_load_session_or_404", AsyncMock(return_value=sess)),
|
|
patch.object(sessions.memory, "make_carry_over", return_value=carry),
|
|
patch.object(sessions, "_end_persisted_session", AsyncMock()),
|
|
patch.object(sessions, "invalidate_session_context_cache"),
|
|
patch.object(sessions.rupture_runtime, "schedule_session_scan"),
|
|
):
|
|
response = await sessions.end_session(sess.session_id, principal)
|
|
|
|
self.assertEqual(carry.end_state["affect"]["emotion_anxiety"], 0.6)
|
|
self.assertEqual(response.end_state["affect"], {"negative_affect": 0.7})
|