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
371
apps/api/app/services/outcome_trajectory.py
Normal file
371
apps/api/app/services/outcome_trajectory.py
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
"""G2 교육용 장기 성과 궤적의 결정론 코어.
|
||||
|
||||
축별 기대분포 대비 편차만 계산하며 세 축의 숫자 총점은 만들지 않는다. safety는
|
||||
별도 원장 참조로 보존하고 결과 판정의 feature로 사용하지 않는다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from ..contracts.measurement import AIView
|
||||
from ..contracts.outcome_trajectory import (
|
||||
OUTCOME_AXES,
|
||||
AxisTrajectoryAssessment,
|
||||
LongitudinalOutcomeAssessment,
|
||||
LongitudinalOutcomeInput,
|
||||
OutcomeAxis,
|
||||
OutcomeAxisObservation,
|
||||
RelationshipMemoryEvent,
|
||||
RelationshipMemoryProjection,
|
||||
RoleSafeTrajectoryReadModel,
|
||||
SessionTrajectoryAssessment,
|
||||
SyntheticExpectedDistribution,
|
||||
TrajectoryBenchmarkPack,
|
||||
TrajectoryStatus,
|
||||
)
|
||||
|
||||
|
||||
WATCH_Z = 0.75
|
||||
OFF_TRACK_Z = 1.50
|
||||
DETERIORATING_Z = 2.00
|
||||
DETERIORATING_Z_CHANGE = 0.50
|
||||
ALERT_STATUSES = frozenset({"off_track", "deteriorating"})
|
||||
|
||||
|
||||
_AXIS_QUESTION = {
|
||||
"distress_load": "최근 회기 사이 부담감의 변화와 그 변화가 두드러진 장면을 다시 확인해 볼까요?",
|
||||
"daily_functioning": "수면·학업·일상 기능에서 실제로 달라진 점과 그대로인 점을 구분해 볼까요?",
|
||||
"learning_engagement": "이번 회기 목표와 연습 과제가 본인에게 납득됐는지 다시 합의해 볼까요?",
|
||||
}
|
||||
|
||||
_MISSING_QUESTION = {
|
||||
"distress_load": "부담감 축의 근거가 비어 있습니다. 같은 도구로 다시 확인할까요?",
|
||||
"daily_functioning": "일상 기능 축의 근거가 비어 있습니다. 관찰 가능한 변화를 확인할까요?",
|
||||
"learning_engagement": "학습 참여 축의 근거가 비어 있습니다. 목표·과제 합의를 직접 확인할까요?",
|
||||
}
|
||||
|
||||
|
||||
def _adverse_z(
|
||||
observation: OutcomeAxisObservation,
|
||||
expected: SyntheticExpectedDistribution,
|
||||
) -> float:
|
||||
if observation.value is None:
|
||||
raise ValueError("missing observations do not have an adverse z score")
|
||||
if expected.expected_direction == "lower_is_better":
|
||||
return (observation.value - expected.mean) / expected.standard_deviation
|
||||
return (expected.mean - observation.value) / expected.standard_deviation
|
||||
|
||||
|
||||
def _classify_axis(
|
||||
*,
|
||||
session_no: int,
|
||||
observation: OutcomeAxisObservation,
|
||||
expected: SyntheticExpectedDistribution,
|
||||
prior_adverse_z: float | None,
|
||||
) -> AxisTrajectoryAssessment:
|
||||
if observation.status != "observed":
|
||||
return AxisTrajectoryAssessment(
|
||||
session_no=session_no,
|
||||
axis=observation.axis,
|
||||
status="insufficient_evidence",
|
||||
observed_value=None,
|
||||
expected_mean=expected.mean,
|
||||
adverse_z=None,
|
||||
adverse_z_change=None,
|
||||
uncertainty=1.0,
|
||||
decision_basis=(
|
||||
f"observation_status:{observation.status}",
|
||||
f"missing_reason:{observation.missing_reason}",
|
||||
"no_value_imputation",
|
||||
),
|
||||
counterevidence=("expected_distribution_not_used_as_observation",),
|
||||
evidence_refs=observation.evidence_refs,
|
||||
)
|
||||
|
||||
adverse_z = _adverse_z(observation, expected)
|
||||
adverse_change = (
|
||||
adverse_z - prior_adverse_z if prior_adverse_z is not None else None
|
||||
)
|
||||
counterevidence: list[str] = []
|
||||
|
||||
if (
|
||||
adverse_z >= DETERIORATING_Z
|
||||
and prior_adverse_z is not None
|
||||
and adverse_change is not None
|
||||
and adverse_change >= DETERIORATING_Z_CHANGE
|
||||
):
|
||||
status: TrajectoryStatus = "deteriorating"
|
||||
elif adverse_z > OFF_TRACK_Z:
|
||||
status = "off_track"
|
||||
if prior_adverse_z is None:
|
||||
counterevidence.append("single_session_deviation_not_yet_a_worsening_trend")
|
||||
elif adverse_change is not None and adverse_change < 0:
|
||||
counterevidence.append("deviation_is_recovering_from_prior_session")
|
||||
elif adverse_z > WATCH_Z:
|
||||
status = "watch"
|
||||
counterevidence.append("inside_off_track_threshold")
|
||||
if prior_adverse_z is None or prior_adverse_z <= WATCH_Z:
|
||||
counterevidence.append("deviation_not_yet_sustained")
|
||||
else:
|
||||
status = "on_track"
|
||||
counterevidence.append("inside_expected_tolerance")
|
||||
|
||||
confidence = observation.confidence
|
||||
assert confidence is not None
|
||||
decision_basis = [
|
||||
f"axis:{observation.axis}",
|
||||
f"adverse_z:{adverse_z:.3f}",
|
||||
f"thresholds:watch>{WATCH_Z:.2f},off_track>{OFF_TRACK_Z:.2f}",
|
||||
(
|
||||
f"source:{observation.source_kind}/{observation.perspective}/"
|
||||
f"{observation.instrument_id}@{observation.instrument_version}"
|
||||
),
|
||||
]
|
||||
if adverse_change is not None:
|
||||
decision_basis.append(f"adverse_z_change:{adverse_change:.3f}")
|
||||
|
||||
return AxisTrajectoryAssessment(
|
||||
session_no=session_no,
|
||||
axis=observation.axis,
|
||||
status=status,
|
||||
observed_value=observation.value,
|
||||
expected_mean=expected.mean,
|
||||
adverse_z=round(adverse_z, 6),
|
||||
adverse_z_change=(round(adverse_change, 6) if adverse_change is not None else None),
|
||||
uncertainty=round(1.0 - confidence, 6),
|
||||
decision_basis=tuple(decision_basis),
|
||||
counterevidence=tuple(counterevidence),
|
||||
evidence_refs=observation.evidence_refs,
|
||||
)
|
||||
|
||||
|
||||
def _session_status(axes: Iterable[AxisTrajectoryAssessment]) -> TrajectoryStatus:
|
||||
statuses = {item.status for item in axes}
|
||||
for status in ("deteriorating", "off_track"):
|
||||
if status in statuses:
|
||||
return status
|
||||
# 누락이 on_track으로 오인되지 않도록 관측된 watch보다도 보수적으로 앞세운다.
|
||||
if "insufficient_evidence" in statuses:
|
||||
return "insufficient_evidence"
|
||||
if "watch" in statuses:
|
||||
return "watch"
|
||||
return "on_track"
|
||||
|
||||
|
||||
def _next_questions(axes: Iterable[AxisTrajectoryAssessment]) -> tuple[str, ...]:
|
||||
questions: list[str] = []
|
||||
for item in axes:
|
||||
if item.status == "insufficient_evidence":
|
||||
questions.append(_MISSING_QUESTION[item.axis])
|
||||
elif item.status in {"watch", "off_track", "deteriorating"}:
|
||||
questions.append(_AXIS_QUESTION[item.axis])
|
||||
if any(item.status == "deteriorating" for item in axes):
|
||||
questions.append(
|
||||
"측정 시점·도구·응답 맥락이 이전 회기와 같았는지 확인해 변화와 측정 오차를 구분할까요?"
|
||||
)
|
||||
# 질문 순서를 결정적으로 유지하면서 중복을 제거한다.
|
||||
return tuple(dict.fromkeys(questions))
|
||||
|
||||
|
||||
def assess_longitudinal_outcome(
|
||||
trajectory: LongitudinalOutcomeInput,
|
||||
) -> LongitudinalOutcomeAssessment:
|
||||
"""1~5회기 각 축을 독립 판정한다.
|
||||
|
||||
safety signal은 결과 객체에 전달만 하고 아래 판정에는 절대 사용하지 않는다.
|
||||
"""
|
||||
|
||||
prior_by_axis: dict[OutcomeAxis, tuple[int, float] | None] = {
|
||||
axis: None for axis in OUTCOME_AXES
|
||||
}
|
||||
timeline: list[SessionTrajectoryAssessment] = []
|
||||
|
||||
for session in trajectory.sessions:
|
||||
axis_results: list[AxisTrajectoryAssessment] = []
|
||||
for axis in OUTCOME_AXES:
|
||||
observation = session.observation_for(axis)
|
||||
expected = trajectory.expected_arc.distribution_for(session.session_no, axis)
|
||||
prior = prior_by_axis[axis]
|
||||
prior_adverse_z = (
|
||||
prior[1]
|
||||
if prior is not None and prior[0] == session.session_no - 1
|
||||
else None
|
||||
)
|
||||
result = _classify_axis(
|
||||
session_no=session.session_no,
|
||||
observation=observation,
|
||||
expected=expected,
|
||||
prior_adverse_z=prior_adverse_z,
|
||||
)
|
||||
axis_results.append(result)
|
||||
if result.adverse_z is not None:
|
||||
prior_by_axis[axis] = (session.session_no, result.adverse_z)
|
||||
else:
|
||||
prior_by_axis[axis] = None
|
||||
|
||||
missing_axes = tuple(
|
||||
item.axis
|
||||
for item in axis_results
|
||||
if item.status == "insufficient_evidence"
|
||||
)
|
||||
timeline.append(
|
||||
SessionTrajectoryAssessment(
|
||||
session_no=session.session_no,
|
||||
status=_session_status(axis_results),
|
||||
axes=tuple(axis_results),
|
||||
missing_axes=missing_axes,
|
||||
next_check_questions=_next_questions(axis_results),
|
||||
safety_signals=session.safety_signals,
|
||||
)
|
||||
)
|
||||
|
||||
return LongitudinalOutcomeAssessment(
|
||||
expected_arc_id=trajectory.expected_arc.arc_id,
|
||||
sessions=tuple(timeline),
|
||||
)
|
||||
|
||||
|
||||
def project_relationship_memory(
|
||||
events: Iterable[RelationshipMemoryEvent],
|
||||
*,
|
||||
view: AIView,
|
||||
) -> tuple[RelationshipMemoryProjection, ...]:
|
||||
"""요청 역할에 명시적으로 공개된 관계 기억만 투영한다."""
|
||||
|
||||
projected: list[RelationshipMemoryProjection] = []
|
||||
for event in sorted(events, key=lambda item: (item.session_no, item.event_id)):
|
||||
if view not in event.visible_to:
|
||||
continue
|
||||
projected.append(
|
||||
RelationshipMemoryProjection(
|
||||
event_id=event.event_id,
|
||||
session_no=event.session_no,
|
||||
event_type=event.event_type,
|
||||
summary=event.summaries[view],
|
||||
evidence_refs=event.evidence_refs,
|
||||
resolved_by_event_id=event.resolved_by_event_id,
|
||||
)
|
||||
)
|
||||
return tuple(projected)
|
||||
|
||||
|
||||
def build_role_safe_read_model(
|
||||
trajectory: LongitudinalOutcomeInput,
|
||||
*,
|
||||
view: AIView,
|
||||
) -> RoleSafeTrajectoryReadModel:
|
||||
assessment = assess_longitudinal_outcome(trajectory)
|
||||
safety_signals = tuple(
|
||||
signal for session in trajectory.sessions for signal in session.safety_signals
|
||||
)
|
||||
relationship_events = tuple(
|
||||
event
|
||||
for session in trajectory.sessions
|
||||
for event in session.relationship_events
|
||||
)
|
||||
return RoleSafeTrajectoryReadModel(
|
||||
assessment=assessment,
|
||||
safety_signals=safety_signals,
|
||||
relationship_memory=project_relationship_memory(
|
||||
relationship_events,
|
||||
view=view,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def load_trajectory_benchmark(path: Path) -> TrajectoryBenchmarkPack:
|
||||
return TrajectoryBenchmarkPack.model_validate_json(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def evaluate_trajectory_benchmark(pack: TrajectoryBenchmarkPack) -> dict[str, object]:
|
||||
"""조기경보 recall과 false-alert rate를 합성 gold에 대해 결정적으로 계산한다."""
|
||||
|
||||
true_positive = false_negative = false_positive = true_negative = 0
|
||||
status_hits = 0
|
||||
rows: list[dict[str, object]] = []
|
||||
|
||||
for case in pack.cases:
|
||||
result = assess_longitudinal_outcome(
|
||||
LongitudinalOutcomeInput(
|
||||
expected_arc=pack.expected_arc,
|
||||
sessions=case.sessions,
|
||||
)
|
||||
)
|
||||
expected_by_session = {item.session_no: item.status for item in case.expected}
|
||||
for actual in result.sessions:
|
||||
expected = expected_by_session[actual.session_no]
|
||||
expected_alert = expected in ALERT_STATUSES
|
||||
actual_alert = actual.status in ALERT_STATUSES
|
||||
if expected_alert and actual_alert:
|
||||
true_positive += 1
|
||||
elif expected_alert:
|
||||
false_negative += 1
|
||||
elif actual_alert:
|
||||
false_positive += 1
|
||||
else:
|
||||
true_negative += 1
|
||||
matched = actual.status == expected
|
||||
status_hits += int(matched)
|
||||
rows.append(
|
||||
{
|
||||
"case_id": case.case_id,
|
||||
"session_no": actual.session_no,
|
||||
"expected_status": expected,
|
||||
"actual_status": actual.status,
|
||||
"status_match": matched,
|
||||
"expected_alert": expected_alert,
|
||||
"actual_alert": actual_alert,
|
||||
"missing_axes": list(actual.missing_axes),
|
||||
"uncertainty": {
|
||||
item.axis: item.uncertainty for item in actual.axes
|
||||
},
|
||||
"false_alert_counterevidence": {
|
||||
item.axis: list(item.counterevidence) for item in actual.axes
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
positives = true_positive + false_negative
|
||||
negatives = false_positive + true_negative
|
||||
total = len(rows)
|
||||
return {
|
||||
"schema_version": "vignette.outcome-trajectory-benchmark-report.v1",
|
||||
"data_classification": "synthetic_educational",
|
||||
"clinical_claim_allowed": False,
|
||||
"case_count": len(pack.cases),
|
||||
"session_count": total,
|
||||
"early_warning_recall": true_positive / positives if positives else None,
|
||||
"false_alert_rate": false_positive / negatives if negatives else None,
|
||||
"status_accuracy": status_hits / total if total else None,
|
||||
"confusion": {
|
||||
"true_positive": true_positive,
|
||||
"false_negative": false_negative,
|
||||
"false_positive": false_positive,
|
||||
"true_negative": true_negative,
|
||||
},
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
|
||||
def render_benchmark_report(report: dict[str, object]) -> str:
|
||||
"""DB나 임상 효능으로 오인하지 않는 감사 가능한 JSON 보고서."""
|
||||
|
||||
return json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ALERT_STATUSES",
|
||||
"DETERIORATING_Z",
|
||||
"DETERIORATING_Z_CHANGE",
|
||||
"OFF_TRACK_Z",
|
||||
"WATCH_Z",
|
||||
"assess_longitudinal_outcome",
|
||||
"build_role_safe_read_model",
|
||||
"evaluate_trajectory_benchmark",
|
||||
"load_trajectory_benchmark",
|
||||
"project_relationship_memory",
|
||||
"render_benchmark_report",
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue