"""종료된 재연습 회기의 durable evaluator 근거를 G4 시도로 변환한다. 이 adapter는 학습자의 자기평가나 브라우저가 보낸 성공/전이 플래그를 읽지 않는다. 저장된 턴 UUID, 정규화된 fast-loop 라벨, 내담자 반응 라벨과 회기 identity만 사용하며 원문은 결과 원장에 복제하지 않는다. """ from __future__ import annotations from dataclasses import dataclass from typing import Iterable from uuid import UUID, uuid5 from ..contracts.deliberate_practice import ( CriterionObservation, PracticeAttemptObservation, PracticeEpisodeInput, PracticeEvidenceRef, PracticePrescription, ScenarioNovelty, ) from .practice_competency import target_techniques _OBSERVER_NAMESPACE = UUID("2d2df938-056c-56cc-86d2-99ad53bd3507") OBSERVER_VERSION = "practice-runtime-observer-v1" _POSITIVE_CLIENT_STATES = frozenset( { "affect_contact", "thought_organizing", "responds_to_exploration", "expresses_plan", "defense_loosening", } ) _WITHDRAWN_CLIENT_STATES = frozenset( {"defensive", "involuntary", "affect_masking", "active_passivity"} ) class RuntimePracticeObservationError(ValueError): """durable 근거가 독립 판정에 충분하지 않을 때 fail-closed한다.""" @dataclass(frozen=True, slots=True) class EvaluatedTurnPair: counselor_turn_id: UUID counselor_turn_seq: int client_turn_id: UUID | None client_turn_seq: int | None technique_codes: tuple[str, ...] client_state_codes: tuple[str, ...] appropriateness: str intent_deviation_dimensions: tuple[str, ...] = () evaluator_error: str | None = None utterance_fingerprint: str | None = None has_voice_feature: bool = False def observation_model_run_id( *, prescription_id: str, practice_session_id: UUID, counselor_turn_id: UUID ) -> UUID: return uuid5( _OBSERVER_NAMESPACE, f"{OBSERVER_VERSION}:{prescription_id}:{practice_session_id}:{counselor_turn_id}", ) def _target_techniques(competency_id: str) -> frozenset[str]: targets = target_techniques(competency_id) if targets is not None: return targets raise RuntimePracticeObservationError( f"unsupported runtime practice competency: {competency_id}" ) def _novelty( *, source_case_id: UUID | None, source_persona_id: UUID | None, practice_case_id: UUID | None, practice_persona_id: UUID | None, ) -> ScenarioNovelty: # identity가 불완전하면 전이를 낙관적으로 추론하지 않는다. if source_case_id is None or practice_case_id is None: return "familiar" if source_case_id == practice_case_id: return "familiar" if ( source_persona_id is not None and practice_persona_id is not None and source_persona_id == practice_persona_id ): return "familiar" return "unseen_transfer" def _client_response(states: Iterable[str]) -> str: normalized = frozenset(states) positive = bool(normalized & _POSITIVE_CLIENT_STATES) negative = bool(normalized & _WITHDRAWN_CLIENT_STATES) if "compliant_surface" in normalized: return "compliance_only" if positive and negative: return "mixed" if "expresses_plan" in normalized and positive: return "explicit_alignment" if positive: return "engaged" if negative: return "withdrawn" return "mixed" def derive_runtime_episode( *, prescription: PracticePrescription, practice_session_id: UUID, source_case_id: UUID | None, source_persona_id: UUID | None, practice_case_id: UUID | None, practice_persona_id: UUID | None, turn_pairs: Iterable[EvaluatedTurnPair], ) -> PracticeEpisodeInput: targets = _target_techniques(prescription.competency_id) pairs = tuple(sorted(turn_pairs, key=lambda item: item.counselor_turn_seq)) if not pairs: raise RuntimePracticeObservationError( "completed practice session has no durable counselor turn evidence" ) novelty = _novelty( source_case_id=source_case_id, source_persona_id=source_persona_id, practice_case_id=practice_case_id, practice_persona_id=practice_persona_id, ) scene_id = f"practice-session:{practice_session_id}" variant_id = f"runtime-session-{practice_session_id}" attempts: list[PracticeAttemptObservation] = [] for sequence_no, pair in enumerate(pairs, start=1): model_run_id = observation_model_run_id( prescription_id=prescription.prescription_id, practice_session_id=practice_session_id, counselor_turn_id=pair.counselor_turn_id, ) learner_ref = PracticeEvidenceRef( ref_id=str(pair.counselor_turn_id), scene_id=scene_id, turn_index=pair.counselor_turn_seq, actor="learner", kind="learner_behavior", ) technique_match = bool(set(pair.technique_codes) & targets) target_deviation = any( token in prescription.competency_id.lower() or token in prescription.criterion_id.lower() for token in pair.intent_deviation_dimensions if token ) counterevidence: list[str] = [] if pair.client_turn_id is None or pair.client_turn_seq is None: criterion = CriterionObservation( criterion_id=prescription.criterion_id, status="error", source_kind="model_inferred", perspective="independent_observer", model_run_id=model_run_id, evidence_refs=(), counterevidence=(), uncertainty=1.0, error_code="client_response_turn_missing", ) attempts.append( PracticeAttemptObservation( attempt_id=f"oas-g4-attempt-{practice_session_id.hex}-{sequence_no}", prescription_id=prescription.prescription_id, competency_id=prescription.competency_id, sequence_no=sequence_no, scenario_variant_id=variant_id, scenario_novelty=novelty, difficulty_level=prescription.activity.difficulty_level, criterion=criterion, evidence_refs=(learner_ref,), uncertainty=1.0, counterevidence=("client_response_turn_missing",), utterance_template_id=pair.utterance_fingerprint, error_code="client_response_turn_missing", ) ) continue response = _client_response(pair.client_state_codes) observed = ( technique_match and pair.appropriateness == "pos" and not target_deviation and not pair.evaluator_error ) if not technique_match: counterevidence.append("target_technique_not_observed") if pair.appropriateness != "pos": counterevidence.append("appropriateness_not_positive") if target_deviation: counterevidence.append("target_intent_deviation_observed") if pair.evaluator_error: counterevidence.append("turn_evaluation_error") if response not in {"engaged", "explicit_alignment"}: counterevidence.append("client_response_does_not_support_effect") client_ref = PracticeEvidenceRef( ref_id=str(pair.client_turn_id), scene_id=scene_id, turn_index=pair.client_turn_seq, actor="client", kind="client_response", ) voice_refs: tuple[PracticeEvidenceRef, ...] = () if pair.has_voice_feature: voice_refs = ( PracticeEvidenceRef( ref_id=str(pair.counselor_turn_id), scene_id=scene_id, turn_index=pair.counselor_turn_seq, actor="runtime", kind="voice_feature", ), ) criterion = CriterionObservation( criterion_id=prescription.criterion_id, status="observed" if observed else "not_observed", source_kind="model_inferred", perspective="independent_observer", model_run_id=model_run_id, evidence_refs=(learner_ref,) if observed else (), counterevidence=tuple(counterevidence) if not observed else (), uncertainty=0.25 if observed else 0.4, ) attempts.append( PracticeAttemptObservation( attempt_id=f"oas-g4-attempt-{practice_session_id.hex}-{sequence_no}", prescription_id=prescription.prescription_id, competency_id=prescription.competency_id, sequence_no=sequence_no, scenario_variant_id=variant_id, scenario_novelty=novelty, difficulty_level=prescription.activity.difficulty_level, criterion=criterion, client_response=response, evidence_refs=( (client_ref, *voice_refs) if observed else (learner_ref, client_ref, *voice_refs) ), uncertainty=0.25 if observed else 0.4, counterevidence=tuple(counterevidence), utterance_template_id=pair.utterance_fingerprint, learner_claimed_success=False, ) ) return PracticeEpisodeInput( episode_id=f"oas-g4-episode-{practice_session_id.hex}", prescription_id=prescription.prescription_id, attempts=tuple(attempts), ) __all__ = [ "EvaluatedTurnPair", "OBSERVER_VERSION", "RuntimePracticeObservationError", "derive_runtime_episode", "observation_model_run_id", ]