vignette/apps/api/app/services/supervision_research.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

317 lines
11 KiB
Python

"""G6 attention queue, calibration dataset, version drift와 manifest 코어."""
from __future__ import annotations
import hashlib
import json
from collections import defaultdict
from pathlib import Path
from typing import Iterable
from ..contracts.supervision_research import (
AttentionQueueItem,
AttentionQueueReason,
CalibrationDatasetRow,
EvaluationVersionBatch,
EvaluationVersionDriftReport,
LearnerAttentionSignal,
Phase3EvidenceArtifact,
Phase3OutcomeEvidenceManifest,
SubgroupVersionMetric,
SupervisionResearchBenchmarkPack,
TeacherAiDisagreement,
)
_SIGNAL_PRIORITY = {
"safety_boundary": 0,
"deterioration": 1,
"unresolved_rupture": 2,
"persistent_overconfidence": 3,
"growth_stagnation": 4,
"transfer_failure": 5,
}
_SEVERITY_PRIORITY = {"high": 0, "moderate": 1, "low": 2}
MIN_DRIFT_MATCHES = 4
MIN_SUBGROUP_MATCHES = 2
ACCURACY_DROP_THRESHOLD = 0.05
SUBGROUP_DROP_THRESHOLD = 0.10
def build_attention_queue(
signals: Iterable[LearnerAttentionSignal],
) -> tuple[AttentionQueueItem, ...]:
"""활성 신호를 계획의 임상 워크벤치 우선순위로 정렬한다."""
grouped: dict[str, list[LearnerAttentionSignal]] = defaultdict(list)
seen: set[str] = set()
for signal in signals:
if signal.signal_id in seen:
raise ValueError(f"duplicate attention signal id: {signal.signal_id}")
seen.add(signal.signal_id)
if signal.state in {"active", "monitoring"}:
grouped[signal.learner_ref].append(signal)
ranked: list[
tuple[tuple[int, int, int, str], str, list[LearnerAttentionSignal]]
] = []
for learner_ref, items in grouped.items():
ordered = sorted(
items,
key=lambda item: (
_SIGNAL_PRIORITY[item.signal_type],
_SEVERITY_PRIORITY[item.severity],
item.observed_sequence,
item.signal_id,
),
)
primary = ordered[0]
ranked.append(
(
(
_SIGNAL_PRIORITY[primary.signal_type],
_SEVERITY_PRIORITY[primary.severity],
min(item.observed_sequence for item in ordered),
learner_ref,
),
learner_ref,
ordered,
)
)
output: list[AttentionQueueItem] = []
for position, (_, learner_ref, items) in enumerate(sorted(ranked), start=1):
routes = tuple(
dict.fromkeys(
evidence.route_hint for item in items for evidence in item.evidence
)
)[:3]
output.append(
AttentionQueueItem(
learner_ref=learner_ref,
queue_position=position,
primary_signal=items[0].signal_type,
oldest_active_sequence=min(item.observed_sequence for item in items),
reasons=tuple(
AttentionQueueReason(
signal_id=item.signal_id,
signal_type=item.signal_type,
severity=item.severity,
uncertainty=item.uncertainty,
evidence=item.evidence,
)
for item in items
),
drilldown_routes=routes,
)
)
return tuple(output)
def _dataset_row_id(item: TeacherAiDisagreement) -> str:
payload = {
"disagreement_id": item.disagreement_id,
"case_ref": item.case_ref,
"competency_id": item.competency_id,
"ai_label": item.ai_label,
"teacher_label": item.teacher_label,
"ai_model": item.ai_model,
"prompt_version": item.prompt_version,
"instrument_id": item.instrument_id,
"instrument_version": item.instrument_version,
"ai_evidence": sorted(value.event_id for value in item.ai_evidence),
"teacher_evidence": sorted(
value.event_id for value in item.teacher_correction_evidence
),
"correction_reason_code": item.correction_reason_code,
}
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def build_calibration_dataset(
disagreements: Iterable[TeacherAiDisagreement],
) -> tuple[CalibrationDatasetRow, ...]:
rows: list[CalibrationDatasetRow] = []
seen: set[str] = set()
for item in disagreements:
if item.disagreement_id in seen:
raise ValueError(f"duplicate disagreement id: {item.disagreement_id}")
seen.add(item.disagreement_id)
evidence_ids = tuple(
dict.fromkeys(
value.event_id
for value in (*item.ai_evidence, *item.teacher_correction_evidence)
)
)
rows.append(
CalibrationDatasetRow(
row_id=_dataset_row_id(item),
disagreement_id=item.disagreement_id,
case_ref=item.case_ref,
competency_id=item.competency_id,
ai_label=item.ai_label,
teacher_label=item.teacher_label,
ai_model=item.ai_model,
prompt_version=item.prompt_version,
instrument_id=item.instrument_id,
instrument_version=item.instrument_version,
evidence_event_ids=evidence_ids,
correction_reason_code=item.correction_reason_code,
)
)
return tuple(rows)
def compare_evaluation_versions(
baseline: EvaluationVersionBatch,
candidate: EvaluationVersionBatch,
) -> EvaluationVersionDriftReport:
baseline_by_key = {
(item.case_ref, item.competency_id): item for item in baseline.observations
}
candidate_by_key = {
(item.case_ref, item.competency_id): item for item in candidate.observations
}
keys = sorted(set(baseline_by_key) & set(candidate_by_key))
if len(keys) < MIN_DRIFT_MATCHES:
return EvaluationVersionDriftReport(
baseline_batch_id=baseline.batch_id,
candidate_batch_id=candidate.batch_id,
matched_count=len(keys),
status="insufficient_evidence",
disagreement_case_refs=(),
subgroup_metrics=(),
alerts=(f"matched_cases_below_minimum:{len(keys)}/{MIN_DRIFT_MATCHES}",),
)
baseline_correct = [
baseline_by_key[key].predicted_label == baseline_by_key[key].gold_label
for key in keys
]
candidate_correct = [
candidate_by_key[key].predicted_label == candidate_by_key[key].gold_label
for key in keys
]
baseline_accuracy = sum(baseline_correct) / len(keys)
candidate_accuracy = sum(candidate_correct) / len(keys)
accuracy_delta = candidate_accuracy - baseline_accuracy
alerts: list[str] = []
if accuracy_delta < -ACCURACY_DROP_THRESHOLD:
alerts.append("overall_accuracy_regression")
subgroup_metrics: list[SubgroupVersionMetric] = []
subgroups = sorted(
{baseline_by_key[key].synthetic_subgroup for key in keys}
| {candidate_by_key[key].synthetic_subgroup for key in keys}
)
for subgroup in subgroups:
subgroup_keys = [
key
for key in keys
if baseline_by_key[key].synthetic_subgroup == subgroup
and candidate_by_key[key].synthetic_subgroup == subgroup
]
if len(subgroup_keys) < MIN_SUBGROUP_MATCHES:
subgroup_metrics.append(
SubgroupVersionMetric(
subgroup=subgroup, matched_count=len(subgroup_keys)
)
)
continue
baseline_rate = sum(
baseline_by_key[key].predicted_label == baseline_by_key[key].gold_label
for key in subgroup_keys
) / len(subgroup_keys)
candidate_rate = sum(
candidate_by_key[key].predicted_label == candidate_by_key[key].gold_label
for key in subgroup_keys
) / len(subgroup_keys)
delta = candidate_rate - baseline_rate
subgroup_metrics.append(
SubgroupVersionMetric(
subgroup=subgroup,
matched_count=len(subgroup_keys),
baseline_accuracy=baseline_rate,
candidate_accuracy=candidate_rate,
accuracy_delta=delta,
)
)
if delta < -SUBGROUP_DROP_THRESHOLD:
alerts.append(f"synthetic_subgroup_regression:{subgroup}")
disagreements = tuple(
sorted(
{
key[0]
for key in keys
if baseline_by_key[key].predicted_label
!= candidate_by_key[key].predicted_label
}
)
)
return EvaluationVersionDriftReport(
baseline_batch_id=baseline.batch_id,
candidate_batch_id=candidate.batch_id,
matched_count=len(keys),
status="drift_flagged" if alerts else "stable",
baseline_accuracy=baseline_accuracy,
candidate_accuracy=candidate_accuracy,
accuracy_delta=accuracy_delta,
disagreement_case_refs=disagreements,
subgroup_metrics=tuple(subgroup_metrics),
alerts=tuple(dict.fromkeys(alerts)),
)
def build_phase3_outcome_manifest(
artifacts: Iterable[Phase3EvidenceArtifact],
) -> Phase3OutcomeEvidenceManifest:
return Phase3OutcomeEvidenceManifest(
schema_version="vignette.phase3-outcome-evidence-manifest.v1",
artifacts=tuple(artifacts),
)
def load_supervision_research_benchmark(
path: str | Path,
) -> SupervisionResearchBenchmarkPack:
return SupervisionResearchBenchmarkPack.model_validate_json(
Path(path).read_text(encoding="utf-8")
)
def evaluate_supervision_research_benchmark(
pack: SupervisionResearchBenchmarkPack,
) -> dict[str, object]:
queue = build_attention_queue(pack.attention_signals)
dataset = build_calibration_dataset(pack.disagreements)
drift = compare_evaluation_versions(pack.baseline_batch, pack.candidate_batch)
manifest = build_phase3_outcome_manifest(pack.phase3_artifacts)
return {
"schema_version": "vignette.supervision-research-benchmark-report.v1",
"data_classification": pack.data_classification,
"clinical_claim_allowed": pack.clinical_claim_allowed,
"queue_order_correct": tuple(item.learner_ref for item in queue)
== pack.expected_queue_order,
"queue_items": [item.model_dump(mode="json") for item in queue],
"calibration_dataset_rows": len(dataset),
"raw_transcript_rows": sum(item.raw_transcript_included for item in dataset),
"drift_status": drift.status,
"drift_status_correct": drift.status == pack.expected_drift_status,
"manifest_domains": sorted(item.domain for item in manifest.artifacts),
}
__all__ = [
"ACCURACY_DROP_THRESHOLD",
"MIN_DRIFT_MATCHES",
"MIN_SUBGROUP_MATCHES",
"SUBGROUP_DROP_THRESHOLD",
"build_attention_queue",
"build_calibration_dataset",
"build_phase3_outcome_manifest",
"compare_evaluation_versions",
"evaluate_supervision_research_benchmark",
"load_supervision_research_benchmark",
]