G0~G8 성과·동맹 측정 OS 작업 일괄 고정
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 산출물은 커밋에서 제외했다.
This commit is contained in:
parent
93dd8f82d7
commit
16e791e044
390 changed files with 243188 additions and 499 deletions
285
apps/api/app/services/practice_runtime_observer.py
Normal file
285
apps/api/app/services/practice_runtime_observer.py
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
"""종료된 재연습 회기의 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",
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue