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
343
apps/api/app/services/measurement_legacy.py
Normal file
343
apps/api/app/services/measurement_legacy.py
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
"""기존 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",
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue