Jev 기반 내담자 감정 상태와 응답 일관성 개선

This commit is contained in:
Yun Chan 2026-09-22 21:32:26 +09:00
parent 77f8421818
commit 8344bc2ad2
23 changed files with 3384 additions and 25 deletions

View file

@ -20,9 +20,10 @@ MASTERPLAN §2.2 / MEMORY_DESIGN §2-B 턴 사이클:
from __future__ import annotations
import time
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from typing import Any, AsyncIterator, Awaitable, Callable, Optional
from ..config import settings
from ..engine_client import (
EngineClient,
EngineError,
@ -40,7 +41,8 @@ from ..contracts.engine_gateway import (
StreamErrorEvent,
StreamTokenEvent,
)
from . import guardrail, persona, rupture_scenario_director, state_machine
from . import client_affect, guardrail, persona, rupture_scenario_director, state_machine
from .jev_client import AppraisalResult, JevError, jev_client
from .llm_audit import LlmAuditHook, generate_with_audit, record_llm_audit
from .persona import PersonaCard, PersonaStateContext, TurnMemory
from .state_machine import SessionState
@ -90,6 +92,8 @@ class TurnContext:
theory_mode: Optional[str] = None
# Scenario Director 내부 선택. ID/유형/provenance는 엔진 request metadata에만 존재한다.
scenario_directive: Optional[rupture_scenario_director.ScenarioDirective] = None
# 외부 감정 평가의 안전한 provenance. 원문·점수·확률은 넣지 않는다.
client_affect_metadata: Optional[dict[str, Any]] = None
def to_state_context(self) -> PersonaStateContext:
st = self.state_after or self.state_before
@ -295,9 +299,106 @@ def _client_request_metadata(ctx: TurnContext) -> dict[str, Any]:
metadata: dict[str, Any] = {"stage": ctx.state_after.stage.value}
if ctx.scenario_directive is not None:
metadata["scenario_director"] = ctx.scenario_directive.request_metadata()
if ctx.client_affect_metadata is not None:
metadata["client_affect"] = dict(ctx.client_affect_metadata)
return metadata
def _rebuild_persona_messages(ctx: TurnContext) -> None:
"""Jev 전이 뒤 같은 시나리오·회상 계약으로 L3를 다시 조립한다."""
hidden_behavior_cue = rupture_scenario_director.render_hidden_behavior_prompt(
ctx.scenario_directive
)
ctx.messages = persona.build_turn_messages(
ctx.persona,
ctx.to_state_context(),
ctx.learner_text_masked,
memory=ctx.memory,
theory_mode=ctx.theory_mode,
hidden_behavior_cue=hidden_behavior_cue,
)
def _minimal_persona_context(card: PersonaCard) -> dict[str, Any]:
"""Jev가 반응을 해석할 최소 페르소나 단서만 고른다."""
return {
"big5": card.big5,
"resistance": card.resistance,
"speech_style": card.speech_style,
"presenting": card.presenting,
"history": card.history,
"ccd": {
key: card.ccd.get(key)
for key in ("core_belief", "automatic_thought", "coping")
if key in card.ccd
},
"triggers": card.triggers,
}
async def _record_client_affect_audit(
ctx: TurnContext,
appraisal: AppraisalResult,
audit_hook: Optional[LlmAuditHook],
) -> None:
"""생성 모델과 구분한 Jev 호출 provenance를 기존 감사 계약에 남긴다."""
await record_llm_audit(
audit_hook,
session_id=ctx.session_id,
provider=appraisal.provider,
model=appraisal.model,
tokens_in=appraisal.input_tokens,
tokens_out=appraisal.output_tokens,
cost_usd=appraisal.cost_usd,
inference_geo=None,
latency_ms=appraisal.latency_ms,
)
async def _apply_client_affect(
ctx: TurnContext,
*,
audit_hook: Optional[LlmAuditHook],
) -> None:
"""활성 Jev 평가를 1회 적용하고 생성 요청 직전 L3를 갱신한다."""
if settings.client_affect_provider != "jev":
return
assert ctx.state_after is not None
state = client_affect.build_appraisal_state(
affect_baseline=ctx.persona.affect_baseline,
affect_state=ctx.state_after.affect_state,
persona_context=_minimal_persona_context(ctx.persona),
resistance=ctx.state_after.resistance,
effective_openness=ctx.state_after.effective_openness,
counselor_utterance=ctx.learner_text_masked,
recall_summary=ctx.memory.recall_summary,
pinned_facts=ctx.memory.pinned_facts,
recent_turns=ctx.memory.recent_turns,
counselor_identity=ctx.counselor_identity,
client_identity=ctx.client_identity,
)
appraisal = await jev_client.appraise(state)
transition = client_affect.transition_emotions(
ctx.state_after.affect_state,
ctx.persona.affect_baseline,
appraisal,
min_confidence=settings.jev_min_confidence,
)
ctx.state_after = replace(ctx.state_after, affect_state=transition.affect_state)
ctx.client_affect_metadata = {
"provider": appraisal.provider,
"model": appraisal.model,
"latency_ms": appraisal.latency_ms,
"tokens_in": appraisal.input_tokens,
"tokens_out": appraisal.output_tokens,
"accepted_dimensions": list(transition.accepted_dimensions),
"held_dimensions": list(transition.held_dimensions),
"tentative_dimensions": list(transition.tentative_dimensions),
}
_rebuild_persona_messages(ctx)
await _record_client_affect_audit(ctx, appraisal, audit_hook)
def _safe_engine_error_detail(error: BaseException | str, *, fallback: str) -> str:
detail = str(error).strip() or fallback
if rupture_scenario_director.contains_internal_scenario_leakage(detail):
@ -320,11 +421,17 @@ async def run_turn_generate(
eval_hook 은 Features 가 주입(없으면 생략). 엔진 장애는 EngineError 전파.
"""
assert ctx.state_after is not None
st = ctx.state_after
if ctx.crisis is not None and ctx.crisis.escalate:
return _crisis_gate_result(ctx)
try:
await _apply_client_affect(ctx, audit_hook=audit_hook)
except JevError as exc:
raise EngineError(f"client_affect_{exc.code}") from exc
st = ctx.state_after
assert st is not None
# 4) 내담자 AI 생성
req = GenerateRequest(
ai_role="client",
@ -468,13 +575,6 @@ async def run_turn_stream(
assert ctx.state_after is not None
st = ctx.state_after
req = StreamRequest(
ai_role="client",
messages=ctx.messages,
session_id=ctx.session_id,
metadata=_client_request_metadata(ctx),
)
accumulated = ""
flagged = False
output_error: str | None = None
@ -508,6 +608,20 @@ async def run_turn_stream(
)
return
try:
await _apply_client_affect(ctx, audit_hook=audit_hook)
except JevError as exc:
yield StreamEvent("error", {"detail": f"client_affect_{exc.code}"})
return
st = ctx.state_after
assert st is not None
req = StreamRequest(
ai_role="client",
messages=ctx.messages,
session_id=ctx.session_id,
metadata=_client_request_metadata(ctx),
)
try:
started = time.perf_counter()
async for packet in engine.stream_packets(req):