8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본 폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을 git 이력으로 고정하는 것이 목적이다. - contracts/routes/services: measurement, outcome_trajectory, rupture_repair, deliberate_practice, calibration_transfer, supervision_research, multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트 - infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행) - apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가 - docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG - scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트 engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
285 lines
10 KiB
Python
285 lines
10 KiB
Python
"""종료된 재연습 회기의 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,
|
|
)
|
|
|
|
|
|
_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]:
|
|
key = competency_id.lower()
|
|
if any(token in key for token in ("empathy", "empathic", "reflection")):
|
|
return frozenset({"empathy", "reflection", "validation", "restatement"})
|
|
if any(token in key for token in ("open_question", "open-question")):
|
|
return frozenset({"facilitative_question", "exploration", "clarification"})
|
|
if any(token in key for token in ("rupture", "repair", "impact")):
|
|
return frozenset(
|
|
{"opinion_check", "validation", "reflection", "here_and_now_focus"}
|
|
)
|
|
if any(token in key for token in ("goal", "collaborative", "reagreement")):
|
|
return frozenset(
|
|
{"consent_motivation_check", "opinion_check", "restatement"}
|
|
)
|
|
if any(token in key for token in ("presence", "response-space")):
|
|
return frozenset({"holding", "reflection", "here_and_now_focus"})
|
|
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",
|
|
]
|