"""기존 Vignette 신호를 측정 원장 의미로 안전하게 투영한다. legacy 이름을 임상 구성개념으로 승격하지 않는다. 특히 ``rapport_credit``와 ``case_profile.alliance_level``은 독립 동맹 측정이 아니라 시뮬레이션 진행 신호다. """ from __future__ import annotations from dataclasses import dataclass, replace from datetime import datetime from typing import Any, Iterable, Mapping from uuid import UUID from ..contracts.measurement import MeasurementEvent from .evaluation_contract import GROWTH_APPROPRIATENESS_SCORE_01 @dataclass(frozen=True, slots=True) class LegacySignalDefinition: signal: str source_kind: str perspective: str construct: str dimension: str instrument_id: str instrument_version: str scale_min: float scale_max: float clinical_claim_allowed: bool note: str LEGACY_SIGNAL_INVENTORY: tuple[LegacySignalDefinition, ...] = ( LegacySignalDefinition( signal="session_state.rapport_credit", source_kind="simulated_state", perspective="client_simulation", construct="simulation_progress", dimension="rapport_credit", instrument_id="vignette-state-machine", instrument_version="legacy-1", scale_min=0.0, scale_max=1.0, clinical_claim_allowed=False, note="상담자 발화 규칙에 따른 결정론적 누적 상태다.", ), LegacySignalDefinition( signal="case_profile.alliance_level", source_kind="simulated_state", perspective="client_simulation", construct="simulation_progress", dimension="legacy_alliance_level", instrument_id="vignette-alliance-ewma", instrument_version="legacy-1", scale_min=0.0, scale_max=1.0, clinical_claim_allowed=False, note="rapport_credit 회기말 값의 EWMA이며 Working Alliance 측정이 아니다.", ), LegacySignalDefinition( signal="TurnEvaluation.appropriateness", source_kind="model_inferred", perspective="independent_observer", construct="counselor_skill", dimension="appropriateness", instrument_id="vignette-fast-evaluator", instrument_version="legacy-1", scale_min=0.0, scale_max=1.0, clinical_claim_allowed=False, note="LLM 기반 경량 훈련 피드백 신호다.", ), LegacySignalDefinition( signal="TurnEvaluation.rapport_signal", source_kind="model_inferred", perspective="independent_observer", construct="counselor_skill", dimension="rapport_signal", instrument_id="vignette-fast-evaluator", instrument_version="legacy-1", scale_min=-1.0, scale_max=1.0, clinical_claim_allowed=False, note="평가 모델이 추정한 턴 단위 라포 방향 신호다.", ), LegacySignalDefinition( signal="SessionEvaluation.distribution", source_kind="model_inferred", perspective="independent_observer", construct="counselor_skill", dimension="technique_occurrence_count", instrument_id="vignette-deep-evaluator", instrument_version="legacy-1", scale_min=0.0, scale_max=1000.0, clinical_claim_allowed=False, note="모델 태그의 빈도이며 숙련도·치료성과가 아니다.", ), LegacySignalDefinition( signal="Phase3.prepost", source_kind="learner_reported", perspective="learner_self_report", construct="self_calibration", dimension="self_reported_training_change", instrument_id="phase3-prepost", instrument_version="design-1", scale_min=0.0, scale_max=1.0, clinical_claim_allowed=False, note="문항·타당화가 확정되기 전까지 교육 파일럿 자기보고다.", ), LegacySignalDefinition( signal="Phase3.runtime_kpi", source_kind="observed_runtime", perspective="runtime_observation", construct="transfer", dimension="pilot_runtime_metric", instrument_id="phase3-runtime-kpi", instrument_version="design-1", scale_min=0.0, scale_max=1.0, clinical_claim_allowed=False, note="완주·환각검수·IAA 등 파일럿 증거이며 개인 치료성과가 아니다.", ), ) _INVENTORY_BY_SIGNAL = {item.signal: item for item in LEGACY_SIGNAL_INVENTORY} def _event( definition: LegacySignalDefinition, *, session_id: UUID, value: float | None, turn_id: UUID | None = None, model_run_id: UUID | None = None, evidence_turn_ids: tuple[UUID, ...] = (), status: str = "ready", error_code: str | None = None, created_at: datetime | None = None, metadata: Mapping[str, Any] | None = None, ) -> MeasurementEvent: payload: dict[str, Any] = { "session_id": session_id, "turn_id": turn_id, "construct": definition.construct, "dimension": definition.dimension, "perspective": definition.perspective, "source_kind": definition.source_kind, "instrument_id": definition.instrument_id, "instrument_version": definition.instrument_version, "value": value, "scale_min": definition.scale_min, "scale_max": definition.scale_max, "status": status, "error_code": error_code, "evidence_turn_ids": evidence_turn_ids, "model_run_id": model_run_id, "visible_to": ("evaluator", "supervisor"), "metadata": { "legacy_signal": definition.signal, "clinical_claim_allowed": definition.clinical_claim_allowed, "provenance_note": definition.note, **dict(metadata or {}), }, } if created_at is not None: payload["created_at"] = created_at return MeasurementEvent.model_validate(payload) def adapt_legacy_simulation_signals( *, session_id: UUID, rapport_credit: float, alliance_level: float, turn_id: UUID | None = None, ) -> tuple[MeasurementEvent, MeasurementEvent]: """기존 두 값을 ``working_alliance``가 아닌 simulation_progress로 보존한다.""" evidence = (turn_id,) if turn_id is not None else () return ( _event( _INVENTORY_BY_SIGNAL["session_state.rapport_credit"], session_id=session_id, turn_id=turn_id, value=float(rapport_credit), evidence_turn_ids=evidence, ), _event( _INVENTORY_BY_SIGNAL["case_profile.alliance_level"], session_id=session_id, turn_id=turn_id, value=float(alliance_level), evidence_turn_ids=evidence, ), ) def adapt_fast_evaluation( *, session_id: UUID, turn_id: UUID, evaluation: Mapping[str, Any], model_run_id: UUID, ) -> tuple[MeasurementEvent, ...]: """fast-loop 평가를 모델 추정 훈련지표로 명시한다.""" error = str(evaluation.get("error") or "").strip() or None if error: return ( _event( _INVENTORY_BY_SIGNAL["TurnEvaluation.appropriateness"], session_id=session_id, turn_id=turn_id, value=None, model_run_id=model_run_id, evidence_turn_ids=(turn_id,), status="error", error_code=error[:120], ), ) appropriateness = str(evaluation.get("appropriateness") or "neutral") score = GROWTH_APPROPRIATENESS_SCORE_01.get(appropriateness, 0.5) events = [ _event( _INVENTORY_BY_SIGNAL["TurnEvaluation.appropriateness"], session_id=session_id, turn_id=turn_id, value=score, model_run_id=model_run_id, evidence_turn_ids=(turn_id,), metadata={"legacy_label": appropriateness}, ) ] rapport_signal = evaluation.get("rapport_signal") if isinstance(rapport_signal, (int, float)): events.append( _event( _INVENTORY_BY_SIGNAL["TurnEvaluation.rapport_signal"], session_id=session_id, turn_id=turn_id, value=float(rapport_signal), model_run_id=model_run_id, evidence_turn_ids=(turn_id,), ) ) return tuple(events) def adapt_deep_evaluation( *, session_id: UUID, evaluation: Mapping[str, Any], model_run_id: UUID, evidence_turn_ids: tuple[UUID, ...] = (), ) -> MeasurementEvent: """deep-loop 기법 분포를 숙련도가 아닌 모델 태그 빈도로 보존한다.""" definition = _INVENTORY_BY_SIGNAL["SessionEvaluation.distribution"] error = str(evaluation.get("error") or "").strip() or None if error: return _event( definition, session_id=session_id, value=None, model_run_id=model_run_id, evidence_turn_ids=evidence_turn_ids, status="error", error_code=error[:120], ) distribution = evaluation.get("distribution") total = distribution.get("total", 0) if isinstance(distribution, Mapping) else 0 return _event( definition, session_id=session_id, value=float(max(0, int(total))), model_run_id=model_run_id, evidence_turn_ids=evidence_turn_ids, ) _LEARNER_REPORTED_KPIS = { "self_efficacy_prepost", "skill_proficiency_prepost", "training_satisfaction_prepost", "sus", } def adapt_phase3_metric( *, session_id: UUID, metric_name: str, value: float | None, status: str = "ready", ) -> MeasurementEvent: """Phase 3 KPI를 자기보고와 운영 관측으로 분리한다.""" base = ( _INVENTORY_BY_SIGNAL["Phase3.prepost"] if metric_name in _LEARNER_REPORTED_KPIS else _INVENTORY_BY_SIGNAL["Phase3.runtime_kpi"] ) definition = replace(base, dimension=metric_name) return _event( definition, session_id=session_id, value=value, status=status, error_code="metric_not_ready" if status in {"error", "rejected"} else None, ) def require_homogeneous_provenance( events: Iterable[MeasurementEvent], *, operation: str, ) -> tuple[MeasurementEvent, ...]: """서로 다른 출처층을 하나의 평균·총점으로 합치는 것을 차단한다. 관점 비교 UI는 이 함수를 호출하지 않고 층별 series를 나란히 표시한다. """ materialized = tuple(events) layers = {(event.source_kind, event.perspective) for event in materialized} if len(layers) > 1: raise ValueError( f"{operation} cannot aggregate heterogeneous measurement provenance: {sorted(layers)}" ) return materialized __all__ = [ "LEGACY_SIGNAL_INVENTORY", "LegacySignalDefinition", "adapt_deep_evaluation", "adapt_fast_evaluation", "adapt_legacy_simulation_signals", "adapt_phase3_metric", "require_homogeneous_provenance", ]