Jev 기반 내담자 감정 상태와 응답 일관성 개선
This commit is contained in:
parent
77f8421818
commit
8344bc2ad2
23 changed files with 3384 additions and 25 deletions
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue