"""Jev 감정 평가 결과를 회기 상태와 생성 프롬프트에 연결하는 순수 함수.""" from __future__ import annotations import math from dataclasses import dataclass from typing import Any, Iterable, Mapping from ..contracts.client_affect import ( ClientAffectContextV1, ClientAffectDimensionTraceV1, ClientAffectPolicyV1, ClientAffectTraceV1, ) from . import guardrail from .jev_client import AppraisalResult, EMOTION_DIMENSIONS _RECENT_TURN_LIMIT = 12 _RECENT_TURN_TEXT_LIMIT = 800 _RECALL_SUMMARY_LIMIT = 1_600 _AFFECT_BASELINE_KEYS = frozenset( { "negative_affect", "hopelessness", "anhedonia", "sleep", "anxiety", "suicide_ideation_stage", *(f"emotion_{dimension}" for dimension in EMOTION_DIMENSIONS), } ) _EMOTION_LABELS = { "anxiety": "불안", "sadness": "슬픔", "anger": "분노", "shame": "수치심", "guilt": "죄책감", "loneliness": "외로움", "relief": "안도감", "hope": "희망", "trust": "신뢰감", } _NEGATIVE_EMOTIONS = frozenset( {"anxiety", "sadness", "anger", "shame", "guilt", "loneliness"} ) _POSITIVE_EMOTIONS = frozenset({"relief", "hope", "trust"}) _AFFECT_POLICY_VERSION = "jev-affect-v1" _ACCEPTED_ALPHA = 0.35 _ACCEPTED_CAP = 0.15 _TENTATIVE_ALPHA = 0.15 _TENTATIVE_CAP = 0.075 _TENTATIVE_CONFIDENCE_FLOOR = 0.35 _ADJACENT_PROBABILITY_THRESHOLD = 0.8 _PROBABILITY_SUM_TOLERANCE = 0.025000001 @dataclass(frozen=True, slots=True) class AffectTransition: """평가 적용 뒤의 영속 정서와 차원별 수용 여부.""" affect_state: dict[str, float] accepted_dimensions: tuple[str, ...] held_dimensions: tuple[str, ...] tentative_dimensions: tuple[str, ...] = () def _finite_number(value: Any) -> float | None: if isinstance(value, bool) or not isinstance(value, (int, float)): return None number = float(value) return number if math.isfinite(number) else None def _clamp01(value: float) -> float: return max(0.0, min(1.0, value)) def _unit_number(value: Any) -> float | None: number = _finite_number(value) if number is None or not 0.0 <= number <= 1.0: return None return number def _tentative_distribution_is_concentrated(probabilities: Any) -> bool: """불확실한 score를 잠정 전이에 쓸 만큼 한 구간에 모였는지 확인한다.""" if not isinstance(probabilities, tuple) or len(probabilities) != 5: return False values = tuple(_unit_number(value) for value in probabilities) if any(value is None for value in values): return False total = sum(value for value in values if value is not None) if not math.isclose(total, 1.0, abs_tol=_PROBABILITY_SUM_TOLERANCE): return False normalized = tuple(value / total for value in values if value is not None) return ( max(normalized[index] + normalized[index + 1] for index in range(4)) >= _ADJACENT_PROBABILITY_THRESHOLD ) def _baseline_value(affect_baseline: Mapping[str, Any], key: str) -> float | None: value = _finite_number(affect_baseline.get(key)) return _clamp01(value) if value is not None else None def baseline_emotions(affect_baseline: Mapping[str, Any]) -> dict[str, float]: """카드의 기존 임상 기저선을 9축 정서 벡터로 안전하게 변환한다.""" baseline = {dimension: 0.0 for dimension in EMOTION_DIMENSIONS} for dimension in EMOTION_DIMENSIONS: explicit = _baseline_value(affect_baseline, f"emotion_{dimension}") if explicit is not None: baseline[dimension] = explicit anxiety = _baseline_value(affect_baseline, "anxiety") if _baseline_value(affect_baseline, "emotion_anxiety") is None and anxiety is not None: baseline["anxiety"] = anxiety sadness = _baseline_value(affect_baseline, "negative_affect") if _baseline_value(affect_baseline, "emotion_sadness") is None and sadness is not None: baseline["sadness"] = sadness hopelessness = _baseline_value(affect_baseline, "hopelessness") if _baseline_value(affect_baseline, "emotion_hope") is None and hopelessness is not None: baseline["hope"] = 1.0 - hopelessness return baseline def resolve_emotions( affect_state: Mapping[str, Any], affect_baseline: Mapping[str, Any], ) -> dict[str, float]: """기존의 유효한 emotion_* 값을 우선하고 없으면 카드 기저선을 쓴다.""" resolved = baseline_emotions(affect_baseline) for dimension in EMOTION_DIMENSIONS: value = _finite_number(affect_state.get(f"emotion_{dimension}")) if value is not None: resolved[dimension] = _clamp01(value) return resolved def transition_emotions( affect_state: Mapping[str, Any], affect_baseline: Mapping[str, Any], appraisal: AppraisalResult, *, min_confidence: float, ) -> AffectTransition: """신뢰도 게이트를 거친 관성 전이를 계산한다. 낮은 신뢰도나 잘못된 estimate는 기존 정서를 정확히 유지한다. 기존 임상 affect 키는 손대지 않고, 새 emotion_* 키만 회기 상태에 더한다. """ updated = dict(affect_state) previous = resolve_emotions(affect_state, affect_baseline) accepted: list[str] = [] held: list[str] = [] tentative: list[str] = [] threshold = _unit_number(min_confidence) if threshold is None: for dimension in EMOTION_DIMENSIONS: updated[f"emotion_{dimension}"] = previous[dimension] return AffectTransition( affect_state=updated, accepted_dimensions=(), held_dimensions=tuple(EMOTION_DIMENSIONS), ) for dimension in EMOTION_DIMENSIONS: old = previous[dimension] estimate = appraisal.emotions.get(dimension) score = _unit_number(estimate.score) if estimate is not None else None confidence = _unit_number(estimate.confidence) if estimate is not None else None if score is None or confidence is None: updated[f"emotion_{dimension}"] = old held.append(dimension) continue if confidence >= threshold: alpha = _ACCEPTED_ALPHA cap = _ACCEPTED_CAP elif ( confidence >= _TENTATIVE_CONFIDENCE_FLOOR and _tentative_distribution_is_concentrated(estimate.probabilities) ): # confidence는 정답 확률이 아니라 분포 집중도 요약이다. alpha = _TENTATIVE_ALPHA cap = _TENTATIVE_CAP tentative.append(dimension) else: updated[f"emotion_{dimension}"] = old held.append(dimension) continue delta = max(-cap, min(cap, alpha * (score - old))) updated[f"emotion_{dimension}"] = _clamp01(old + delta) accepted.append(dimension) return AffectTransition( affect_state=updated, accepted_dimensions=tuple(accepted), held_dimensions=tuple(held), tentative_dimensions=tuple(tentative), ) def _trace_probabilities(value: Any) -> tuple[float, float, float, float, float] | None: if not isinstance(value, tuple) or len(value) != 5: return None normalized = tuple(_unit_number(item) for item in value) if any(item is None for item in normalized): return None return ( normalized[0], normalized[1], normalized[2], normalized[3], normalized[4], ) def build_client_affect_trace( *, affect_state_before: Mapping[str, Any], affect_baseline: Mapping[str, Any], affect_state_after: Mapping[str, Any], appraisal: AppraisalResult, transition: AffectTransition, turn_seq: int, stage: str, resistance: float, effective_openness: float, rapport_credit: float, min_confidence: float, ) -> ClientAffectTraceV1: """전이와 같은 입력으로 관리자 전용 trace를 고정 순서로 만든다.""" before = resolve_emotions(affect_state_before, affect_baseline) after = resolve_emotions(affect_state_after, affect_baseline) tentative = set(transition.tentative_dimensions) accepted = set(transition.accepted_dimensions) dimensions: list[ClientAffectDimensionTraceV1] = [] for key in EMOTION_DIMENSIONS: estimate = appraisal.emotions.get(key) target = _unit_number(estimate.score) if estimate is not None else None confidence = _unit_number(estimate.confidence) if estimate is not None else None probabilities = ( _trace_probabilities(estimate.probabilities) if estimate is not None else None ) if key in tentative: decision = "tentative" elif key in accepted: decision = "accepted" else: decision = "held" dimensions.append( ClientAffectDimensionTraceV1( key=key, before=before[key], target=target, after=after[key], confidence=confidence, probabilities=probabilities, decision=decision, ) ) return ClientAffectTraceV1( schema_version=1, provider=appraisal.provider, model=appraisal.model, latency_ms=appraisal.latency_ms, input_tokens=appraisal.input_tokens, output_tokens=appraisal.output_tokens, cost_usd=appraisal.cost_usd, turn_seq=turn_seq, policy=ClientAffectPolicyV1( version=_AFFECT_POLICY_VERSION, min_confidence=min_confidence, accepted_alpha=_ACCEPTED_ALPHA, accepted_cap=_ACCEPTED_CAP, tentative_alpha=_TENTATIVE_ALPHA, tentative_cap=_TENTATIVE_CAP, tentative_confidence_floor=_TENTATIVE_CONFIDENCE_FLOOR, adjacent_probability_threshold=_ADJACENT_PROBABILITY_THRESHOLD, ), context=ClientAffectContextV1( stage=stage, resistance=resistance, effective_openness=effective_openness, rapport_credit=rapport_credit, ), dimensions=tuple(dimensions), ) def _mask_text( value: Any, *, counselor_identity: str | None, client_identity: str | None, ) -> str: return guardrail.mask_role_identities( str(value or ""), counselor_identity=counselor_identity, client_identity=client_identity, ).text_masked def _bounded_text(value: Any, limit: int) -> str: text = str(value or "") return text[:limit] def _masked_value( value: Any, *, counselor_identity: str | None, client_identity: str | None, ) -> Any: if isinstance(value, Mapping): return { _mask_text( key, counselor_identity=counselor_identity, client_identity=client_identity, ): _masked_value( item, counselor_identity=counselor_identity, client_identity=client_identity, ) for key, item in value.items() } if isinstance(value, (list, tuple)): return [ _masked_value( item, counselor_identity=counselor_identity, client_identity=client_identity, ) for item in value ] if isinstance(value, str): return _mask_text( value, counselor_identity=counselor_identity, client_identity=client_identity, ) number = _finite_number(value) return number if number is not None else None def render_affect_directive(affect_state: Mapping[str, Any]) -> str: """9축 정서를 내담자 발화 지시로만 렌더한다.""" def intensity(value: float) -> str: if value < 0.2: return "미약한" if value < 0.5: return "중간 정도의" if value < 0.75: return "뚜렷한" return "강한" meaningful = sorted( ( (dimension, _clamp01(value)) for dimension in EMOTION_DIMENSIONS if (value := _finite_number(affect_state.get(f"emotion_{dimension}"))) is not None and value >= 0.05 ), key=lambda item: item[1], reverse=True, ) if not meaningful: return ( "현재 감정을 과장하지 말고, 말투와 반응의 결로 자연스럽게 드러낸다. " "숫자·내부 상태·평가 정답은 절대 말하지 않는다. 응답은 기본적으로 1~3문장으로 한다." ) selected = meaningful[:3] selected_dimensions = {dimension for dimension, _ in selected} selected_has_negative = bool(selected_dimensions & _NEGATIVE_EMOTIONS) selected_has_positive = bool(selected_dimensions & _POSITIVE_EMOTIONS) if selected_has_negative != selected_has_positive: opposing = _POSITIVE_EMOTIONS if selected_has_negative else _NEGATIVE_EMOTIONS opposing_candidate = next( ( item for item in meaningful if item[0] in opposing and item[0] not in selected_dimensions ), None, ) if opposing_candidate is not None: selected.append(opposing_candidate) rendered = ", ".join( f"{intensity(value)} {_EMOTION_LABELS[dimension]}" for dimension, value in selected ) return ( f"현재 반응에는 {rendered}이 함께 배어 있을 수 있다. 상충하는 감정도 동시에 가질 수 있다. " "감정 이름을 나열하지 말고, 말투·선택·침묵·주저함으로만 표현한다. " "숫자·내부 상태·평가 정답은 절대 말하지 않는다. " "상담자 역할로 바뀌거나 조언하지 않으며, 부정 감정을 즉시 해소하려 하지 않는다. " "응답은 기본적으로 1~3문장으로 하고, 꼭 필요할 때만 더 길게 말한다." ) def build_appraisal_state( *, affect_baseline: Mapping[str, Any], affect_state: Mapping[str, Any], persona_context: Mapping[str, Any], resistance: Any, effective_openness: Any, counselor_utterance: Any, recall_summary: Any, pinned_facts: Iterable[Any], recent_turns: Iterable[Mapping[str, Any]], counselor_identity: str | None, client_identity: str | None, ) -> dict[str, Any]: """외부 Jev 경계에 보내는 최소·재마스킹된 synthetic state를 조립한다.""" def masked_bounded(value: Any, limit: int) -> str: return _bounded_text( _mask_text( value, counselor_identity=counselor_identity, client_identity=client_identity, ), limit, ) recent = list(recent_turns)[-_RECENT_TURN_LIMIT:] rendered_recent = [ { "speaker": "counselor" if turn.get("speaker") == "counselor" else "client", "text": masked_bounded(turn.get("text", ""), _RECENT_TURN_TEXT_LIMIT), } for turn in recent ] pinned = [ _mask_text( value, counselor_identity=counselor_identity, client_identity=client_identity, ) for value in pinned_facts ] return { "persona": { "affect_baseline": { key: value for key, value in affect_baseline.items() if key in _AFFECT_BASELINE_KEYS and _finite_number(value) is not None }, "context": _masked_value( persona_context, counselor_identity=counselor_identity, client_identity=client_identity, ), }, "memory": { "recall_summary": masked_bounded(recall_summary, _RECALL_SUMMARY_LIMIT), "pinned_facts": pinned, }, "recent_turns": rendered_recent, "counselor_utterance": masked_bounded(counselor_utterance, _RECENT_TURN_TEXT_LIMIT), "previous_emotions": resolve_emotions(affect_state, affect_baseline), "current_state": { "resistance": _clamp01(_finite_number(resistance) or 0.0), "effective_openness": _clamp01(_finite_number(effective_openness) or 0.0), }, } def public_end_state(end_state: Mapping[str, Any]) -> dict[str, Any]: """학습자 응답에는 새 감정 벡터를 숨기고 내부 snapshot은 그대로 보존한다.""" public_state = dict(end_state) affect = end_state.get("affect") if isinstance(affect, Mapping): public_state["affect"] = { key: value for key, value in affect.items() if not (isinstance(key, str) and key.startswith("emotion_")) } return public_state __all__ = [ "AffectTransition", "baseline_emotions", "build_client_affect_trace", "build_appraisal_state", "public_end_state", "resolve_emotions", "render_affect_directive", "transition_emotions", ]