vignette/apps/api/app/services/measurement_legacy.py
Yun Chan 16e791e044 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 산출물은 커밋에서 제외했다.
2026-08-08 01:30:53 +09:00

343 lines
11 KiB
Python

"""기존 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",
]