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 산출물은 커밋에서 제외했다.
1802 lines
70 KiB
Python
1802 lines
70 KiB
Python
"""Append-only persistence boundary for G5 calibration and unseen transfer."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
from collections.abc import Mapping, Sequence
|
|
from datetime import UTC, datetime
|
|
from typing import Any, Literal
|
|
from uuid import UUID, uuid4, uuid5
|
|
|
|
import asyncpg
|
|
|
|
from .. import db
|
|
from ..contracts.calibration_transfer import (
|
|
ActualTransferExecution,
|
|
CompetencyCalibrationAssessment,
|
|
MetacognitivePrescription,
|
|
NormalizedEvaluatorLabels,
|
|
TransferVariation,
|
|
TransferSuiteInput,
|
|
)
|
|
from ..deps import Principal, Role
|
|
from .calibration_transfer import (
|
|
assess_actual_transfer_executions,
|
|
assess_synthetic_subgroup_drift,
|
|
assess_transfer,
|
|
)
|
|
|
|
|
|
class CalibrationTransferNotFoundError(LookupError):
|
|
pass
|
|
|
|
|
|
class CalibrationTransferConflictError(RuntimeError):
|
|
pass
|
|
|
|
|
|
class CalibrationTransferStateError(ValueError):
|
|
pass
|
|
|
|
|
|
_IDEMPOTENCY_TABLES = {
|
|
"app.calibration_prediction_revision": "prediction_revision_id",
|
|
"app.calibration_prediction_lock": "lock_id",
|
|
"app.calibration_performance_observation": "observation_id",
|
|
"app.calibration_assessment_snapshot": "assessment_snapshot_id",
|
|
"app.calibration_transfer_suite": "transfer_suite_record_id",
|
|
"app.calibration_teacher_review_event": "review_id",
|
|
}
|
|
|
|
_ACTUAL_TRANSFER_NAMESPACE = UUID("4a563cc6-f8d3-51bf-a315-41d2a98b02ce")
|
|
_ACTUAL_TRANSFER_OBSERVER_VERSION = "calibration-actual-transfer-observer-v1"
|
|
_ACTUAL_TRANSFER_INSTRUMENT_ID = "unseen-transfer-g5"
|
|
_ACTUAL_TRANSFER_INSTRUMENT_VERSION = "1.0.0"
|
|
_POSITIVE_CLIENT_STATES = frozenset(
|
|
{
|
|
"affect_contact",
|
|
"thought_organizing",
|
|
"responds_to_exploration",
|
|
"expresses_plan",
|
|
"defense_loosening",
|
|
}
|
|
)
|
|
|
|
|
|
def _value(row: Mapping[str, Any], key: str, default: Any = None) -> Any:
|
|
try:
|
|
return row[key]
|
|
except (KeyError, TypeError):
|
|
return default
|
|
|
|
|
|
def _canonical_hash(payload: Mapping[str, Any]) -> str:
|
|
serialized = json.dumps(
|
|
payload,
|
|
ensure_ascii=False,
|
|
separators=(",", ":"),
|
|
sort_keys=True,
|
|
default=str,
|
|
)
|
|
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _created_role(principal: Principal) -> str:
|
|
return "instructor" if principal.role == Role.TEACHER else principal.role.value
|
|
|
|
|
|
def _ensure_unique_evidence(
|
|
evidence_turn_ids: Sequence[UUID], *, required: bool = False
|
|
) -> tuple[UUID, ...]:
|
|
normalized = tuple(evidence_turn_ids)
|
|
if required and not normalized:
|
|
raise CalibrationTransferStateError("evidence_turn_ids must not be empty")
|
|
if len(set(normalized)) != len(normalized):
|
|
raise CalibrationTransferStateError("evidence_turn_ids must be unique")
|
|
return normalized
|
|
|
|
|
|
def _uuid_evidence(refs: Sequence[str], *, required: bool = False) -> tuple[UUID, ...]:
|
|
values: list[UUID] = []
|
|
for ref in refs:
|
|
try:
|
|
values.append(UUID(ref))
|
|
except (TypeError, ValueError) as exc:
|
|
raise CalibrationTransferStateError(
|
|
"G5 evidence refs must be transcript turn UUIDs, never transcript text"
|
|
) from exc
|
|
return _ensure_unique_evidence(values, required=required)
|
|
|
|
|
|
async def _existing_by_submission(
|
|
conn: asyncpg.Connection,
|
|
*,
|
|
table: str,
|
|
submission_id: UUID,
|
|
content_hash: str,
|
|
) -> UUID | None:
|
|
id_column = _IDEMPOTENCY_TABLES.get(table)
|
|
if id_column is None:
|
|
raise AssertionError("unsupported calibration idempotency table")
|
|
row = await conn.fetchrow(
|
|
f"SELECT {id_column}, content_hash FROM {table} WHERE submission_id = $1",
|
|
submission_id,
|
|
)
|
|
if row is None:
|
|
return None
|
|
if str(_value(row, "content_hash")) != content_hash:
|
|
raise CalibrationTransferConflictError(
|
|
"submission id was already used with different content"
|
|
)
|
|
return UUID(str(_value(row, id_column)))
|
|
|
|
|
|
async def _visible_session(
|
|
conn: asyncpg.Connection, session_id: UUID
|
|
) -> Mapping[str, Any]:
|
|
row = await conn.fetchrow(
|
|
"SELECT id, learner_id, case_id FROM app.sessions WHERE id = $1",
|
|
session_id,
|
|
)
|
|
if row is None:
|
|
raise CalibrationTransferNotFoundError("session not found or not visible")
|
|
return row
|
|
|
|
|
|
async def append_prediction_revision(
|
|
*,
|
|
principal: Principal,
|
|
submission_id: UUID,
|
|
prediction_revision_id: UUID,
|
|
history_id: UUID,
|
|
session_id: UUID,
|
|
competency_id: str,
|
|
practice_block_id: str,
|
|
scenario_variant_id: str,
|
|
phrase_family_id: str,
|
|
revision_no: int,
|
|
supersedes_prediction_revision_id: UUID | None,
|
|
predicted_success_probability: float,
|
|
confidence: float,
|
|
recorded_sequence: int,
|
|
revision_reason: str,
|
|
instrument_id: str,
|
|
instrument_version: str,
|
|
evidence_turn_ids: Sequence[UUID] = (),
|
|
) -> dict[str, Any]:
|
|
if principal.role != Role.LEARNER:
|
|
raise CalibrationTransferStateError(
|
|
"self-prediction revision requires learner role"
|
|
)
|
|
learner_id = UUID(principal.user_id)
|
|
evidence = _ensure_unique_evidence(evidence_turn_ids)
|
|
reason = revision_reason.strip()
|
|
if not reason:
|
|
raise CalibrationTransferStateError("revision_reason must not be blank")
|
|
payload = {
|
|
"prediction_revision_id": str(prediction_revision_id),
|
|
"history_id": str(history_id),
|
|
"session_id": str(session_id),
|
|
"learner_id": str(learner_id),
|
|
"competency_id": competency_id,
|
|
"practice_block_id": practice_block_id,
|
|
"scenario_variant_id": scenario_variant_id,
|
|
"phrase_family_id": phrase_family_id,
|
|
"revision_no": revision_no,
|
|
"supersedes_prediction_revision_id": (
|
|
str(supersedes_prediction_revision_id)
|
|
if supersedes_prediction_revision_id
|
|
else None
|
|
),
|
|
"predicted_success_probability": predicted_success_probability,
|
|
"confidence": confidence,
|
|
"recorded_sequence": recorded_sequence,
|
|
"revision_reason": reason,
|
|
"instrument_id": instrument_id,
|
|
"instrument_version": instrument_version,
|
|
"evidence_turn_ids": sorted(str(item) for item in evidence),
|
|
}
|
|
content_hash = _canonical_hash(payload)
|
|
async with db.acquire(
|
|
role=principal.role.value,
|
|
user_id=principal.user_id,
|
|
cohort_ids=principal.cohort_ids,
|
|
) as conn:
|
|
session = await _visible_session(conn, session_id)
|
|
if UUID(str(_value(session, "learner_id"))) != learner_id:
|
|
raise CalibrationTransferNotFoundError("session does not belong to learner")
|
|
existing = await _existing_by_submission(
|
|
conn,
|
|
table="app.calibration_prediction_revision",
|
|
submission_id=submission_id,
|
|
content_hash=content_hash,
|
|
)
|
|
if existing is not None:
|
|
return {
|
|
"submission_id": submission_id,
|
|
"history_id": history_id,
|
|
"prediction_revision_id": existing,
|
|
"revision_no": revision_no,
|
|
"idempotent_replay": True,
|
|
}
|
|
await conn.execute(
|
|
"SELECT pg_advisory_xact_lock(hashtextextended($1::text, 5))",
|
|
str(history_id),
|
|
)
|
|
history = await conn.fetchrow(
|
|
"SELECT * FROM app.calibration_prediction_history WHERE history_id = $1",
|
|
history_id,
|
|
)
|
|
if history is None:
|
|
if revision_no != 1 or supersedes_prediction_revision_id is not None:
|
|
raise CalibrationTransferStateError(
|
|
"new prediction history must start at revision one"
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO app.calibration_prediction_history (
|
|
history_id, session_id, learner_id, competency_id,
|
|
practice_block_id, scenario_variant_id, phrase_family_id,
|
|
created_by_role
|
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,'learner')
|
|
""",
|
|
history_id,
|
|
session_id,
|
|
learner_id,
|
|
competency_id,
|
|
practice_block_id,
|
|
scenario_variant_id,
|
|
phrase_family_id,
|
|
)
|
|
else:
|
|
expected = (
|
|
str(session_id),
|
|
str(learner_id),
|
|
competency_id,
|
|
practice_block_id,
|
|
scenario_variant_id,
|
|
phrase_family_id,
|
|
)
|
|
actual = (
|
|
str(_value(history, "session_id")),
|
|
str(_value(history, "learner_id")),
|
|
str(_value(history, "competency_id")),
|
|
str(_value(history, "practice_block_id")),
|
|
str(_value(history, "scenario_variant_id")),
|
|
str(_value(history, "phrase_family_id")),
|
|
)
|
|
if actual != expected:
|
|
raise CalibrationTransferConflictError(
|
|
"prediction history target cannot change"
|
|
)
|
|
try:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
INSERT INTO app.calibration_prediction_revision (
|
|
prediction_revision_id, submission_id, content_hash,
|
|
history_id, session_id, learner_id, revision_no,
|
|
supersedes_prediction_revision_id,
|
|
predicted_success_probability, confidence, recorded_sequence,
|
|
revision_reason, instrument_id, instrument_version,
|
|
evidence_turn_ids, created_by_role
|
|
) VALUES (
|
|
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15::uuid[],'learner'
|
|
)
|
|
RETURNING prediction_revision_id, revision_no
|
|
""",
|
|
prediction_revision_id,
|
|
submission_id,
|
|
content_hash,
|
|
history_id,
|
|
session_id,
|
|
learner_id,
|
|
revision_no,
|
|
supersedes_prediction_revision_id,
|
|
predicted_success_probability,
|
|
confidence,
|
|
recorded_sequence,
|
|
reason,
|
|
instrument_id,
|
|
instrument_version,
|
|
list(evidence),
|
|
)
|
|
except asyncpg.UniqueViolationError as exc:
|
|
raise CalibrationTransferConflictError(
|
|
"prediction revision submission or chain conflict"
|
|
) from exc
|
|
except (
|
|
asyncpg.CheckViolationError,
|
|
asyncpg.ForeignKeyViolationError,
|
|
asyncpg.ObjectNotInPrerequisiteStateError,
|
|
) as exc:
|
|
raise CalibrationTransferStateError(
|
|
"prediction revision violated provenance or history invariants"
|
|
) from exc
|
|
assert row is not None
|
|
return {
|
|
"submission_id": submission_id,
|
|
"history_id": history_id,
|
|
"prediction_revision_id": UUID(
|
|
str(_value(row, "prediction_revision_id"))
|
|
),
|
|
"revision_no": int(_value(row, "revision_no")),
|
|
"idempotent_replay": False,
|
|
}
|
|
|
|
|
|
async def append_prediction_lock(
|
|
*,
|
|
principal: Principal,
|
|
submission_id: UUID,
|
|
lock_id: UUID,
|
|
history_id: UUID,
|
|
prediction_revision_id: UUID,
|
|
locked_sequence: int,
|
|
) -> dict[str, Any]:
|
|
if principal.role != Role.LEARNER:
|
|
raise CalibrationTransferStateError("prediction lock requires learner role")
|
|
learner_id = UUID(principal.user_id)
|
|
payload = {
|
|
"lock_id": str(lock_id),
|
|
"history_id": str(history_id),
|
|
"prediction_revision_id": str(prediction_revision_id),
|
|
"locked_sequence": locked_sequence,
|
|
"learner_id": str(learner_id),
|
|
}
|
|
content_hash = _canonical_hash(payload)
|
|
async with db.acquire(
|
|
role=principal.role.value,
|
|
user_id=principal.user_id,
|
|
cohort_ids=principal.cohort_ids,
|
|
) as conn:
|
|
history = await conn.fetchrow(
|
|
"""
|
|
SELECT history_id, session_id, learner_id
|
|
FROM app.calibration_prediction_history
|
|
WHERE history_id = $1
|
|
""",
|
|
history_id,
|
|
)
|
|
if history is None or UUID(str(_value(history, "learner_id"))) != learner_id:
|
|
raise CalibrationTransferNotFoundError(
|
|
"prediction history not found or not visible"
|
|
)
|
|
existing = await _existing_by_submission(
|
|
conn,
|
|
table="app.calibration_prediction_lock",
|
|
submission_id=submission_id,
|
|
content_hash=content_hash,
|
|
)
|
|
if existing is not None:
|
|
return {
|
|
"submission_id": submission_id,
|
|
"history_id": history_id,
|
|
"lock_id": existing,
|
|
"idempotent_replay": True,
|
|
}
|
|
try:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
INSERT INTO app.calibration_prediction_lock (
|
|
lock_id, submission_id, content_hash, history_id,
|
|
prediction_revision_id, session_id, learner_id,
|
|
locked_sequence, created_by_role
|
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'learner')
|
|
RETURNING lock_id
|
|
""",
|
|
lock_id,
|
|
submission_id,
|
|
content_hash,
|
|
history_id,
|
|
prediction_revision_id,
|
|
_value(history, "session_id"),
|
|
learner_id,
|
|
locked_sequence,
|
|
)
|
|
except asyncpg.UniqueViolationError as exc:
|
|
raise CalibrationTransferConflictError(
|
|
"prediction lock submission or history conflict"
|
|
) from exc
|
|
except (asyncpg.CheckViolationError, asyncpg.ForeignKeyViolationError) as exc:
|
|
raise CalibrationTransferStateError(
|
|
"prediction lock must target the latest pre-reveal revision"
|
|
) from exc
|
|
assert row is not None
|
|
return {
|
|
"submission_id": submission_id,
|
|
"history_id": history_id,
|
|
"lock_id": UUID(str(_value(row, "lock_id"))),
|
|
"idempotent_replay": False,
|
|
}
|
|
|
|
|
|
async def append_performance_observation(
|
|
*,
|
|
conn: asyncpg.Connection,
|
|
submission_id: UUID,
|
|
observation_id: UUID,
|
|
history_id: UUID,
|
|
status: Literal["passed", "failed", "insufficient_evidence"],
|
|
source_kind: Literal["model_inferred", "observed_runtime"],
|
|
perspective: Literal["independent_observer", "runtime_observation"],
|
|
model_run_id: UUID | None,
|
|
instrument_id: str,
|
|
instrument_version: str,
|
|
uncertainty: float,
|
|
evidence_turn_ids: Sequence[UUID],
|
|
counterevidence: Sequence[str],
|
|
revealed_sequence: int,
|
|
) -> dict[str, Any]:
|
|
evidence = _ensure_unique_evidence(
|
|
evidence_turn_ids, required=status != "insufficient_evidence"
|
|
)
|
|
if status == "insufficient_evidence" and (evidence or uncertainty != 1.0):
|
|
raise CalibrationTransferStateError(
|
|
"insufficient observation must be evidence-free with full uncertainty"
|
|
)
|
|
if status == "failed" and not counterevidence:
|
|
raise CalibrationTransferStateError(
|
|
"failed observation requires counterevidence"
|
|
)
|
|
if (source_kind, perspective) not in {
|
|
("model_inferred", "independent_observer"),
|
|
("observed_runtime", "runtime_observation"),
|
|
}:
|
|
raise CalibrationTransferStateError(
|
|
"performance observation source and perspective are incompatible"
|
|
)
|
|
if source_kind == "model_inferred" and model_run_id is None:
|
|
raise CalibrationTransferStateError(
|
|
"model-inferred observation requires model_run_id"
|
|
)
|
|
history = await conn.fetchrow(
|
|
"""
|
|
SELECT h.*, l.lock_id
|
|
FROM app.calibration_prediction_history h
|
|
JOIN app.calibration_prediction_lock l ON l.history_id = h.history_id
|
|
WHERE h.history_id = $1
|
|
""",
|
|
history_id,
|
|
)
|
|
if history is None:
|
|
raise CalibrationTransferNotFoundError(
|
|
"locked prediction history not found or not visible"
|
|
)
|
|
payload = {
|
|
"observation_id": str(observation_id),
|
|
"history_id": str(history_id),
|
|
"status": status,
|
|
"source_kind": source_kind,
|
|
"perspective": perspective,
|
|
"model_run_id": str(model_run_id) if model_run_id else None,
|
|
"instrument_id": instrument_id,
|
|
"instrument_version": instrument_version,
|
|
"uncertainty": uncertainty,
|
|
"evidence_turn_ids": sorted(str(item) for item in evidence),
|
|
"counterevidence": list(counterevidence),
|
|
"revealed_sequence": revealed_sequence,
|
|
}
|
|
content_hash = _canonical_hash(payload)
|
|
existing = await _existing_by_submission(
|
|
conn,
|
|
table="app.calibration_performance_observation",
|
|
submission_id=submission_id,
|
|
content_hash=content_hash,
|
|
)
|
|
if existing is not None:
|
|
return {
|
|
"submission_id": submission_id,
|
|
"history_id": history_id,
|
|
"observation_id": existing,
|
|
"idempotent_replay": True,
|
|
}
|
|
try:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
INSERT INTO app.calibration_performance_observation (
|
|
observation_id, submission_id, content_hash, history_id,
|
|
prediction_lock_id, session_id, learner_id, competency_id,
|
|
practice_block_id, scenario_variant_id, phrase_family_id,
|
|
status, source_kind, perspective, model_run_id,
|
|
instrument_id, instrument_version, uncertainty,
|
|
evidence_turn_ids, counterevidence, revealed_sequence
|
|
) VALUES (
|
|
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,
|
|
$19::uuid[],$20::text[],$21
|
|
)
|
|
RETURNING observation_id
|
|
""",
|
|
observation_id,
|
|
submission_id,
|
|
content_hash,
|
|
history_id,
|
|
_value(history, "lock_id"),
|
|
_value(history, "session_id"),
|
|
_value(history, "learner_id"),
|
|
_value(history, "competency_id"),
|
|
_value(history, "practice_block_id"),
|
|
_value(history, "scenario_variant_id"),
|
|
_value(history, "phrase_family_id"),
|
|
status,
|
|
source_kind,
|
|
perspective,
|
|
model_run_id,
|
|
instrument_id,
|
|
instrument_version,
|
|
uncertainty,
|
|
list(evidence),
|
|
list(counterevidence),
|
|
revealed_sequence,
|
|
)
|
|
except asyncpg.UniqueViolationError as exc:
|
|
raise CalibrationTransferConflictError(
|
|
"performance observation submission or reveal conflict"
|
|
) from exc
|
|
except (asyncpg.CheckViolationError, asyncpg.ForeignKeyViolationError) as exc:
|
|
raise CalibrationTransferStateError(
|
|
"performance observation violated lock, reveal, or provenance invariants"
|
|
) from exc
|
|
assert row is not None
|
|
return {
|
|
"submission_id": submission_id,
|
|
"history_id": history_id,
|
|
"observation_id": UUID(str(_value(row, "observation_id"))),
|
|
"idempotent_replay": False,
|
|
}
|
|
|
|
|
|
async def append_calibration_assessment(
|
|
*,
|
|
conn: asyncpg.Connection,
|
|
submission_id: UUID,
|
|
assessment_snapshot_id: UUID,
|
|
prescription_id: UUID,
|
|
session_id: UUID,
|
|
assessment: CompetencyCalibrationAssessment,
|
|
prescription: MetacognitivePrescription,
|
|
source_observation_ids: Sequence[UUID],
|
|
model_run_id: UUID,
|
|
instrument_id: str,
|
|
instrument_version: str,
|
|
evidence_turn_ids: Sequence[UUID],
|
|
) -> dict[str, Any]:
|
|
if assessment.competency_id != prescription.competency_id:
|
|
raise CalibrationTransferStateError(
|
|
"assessment and metacognitive prescription competency must match"
|
|
)
|
|
evidence = _ensure_unique_evidence(evidence_turn_ids)
|
|
observation_ids = tuple(source_observation_ids)
|
|
if not observation_ids or len(set(observation_ids)) != len(observation_ids):
|
|
raise CalibrationTransferStateError(
|
|
"source_observation_ids must be non-empty and unique"
|
|
)
|
|
session = await _visible_session(conn, session_id)
|
|
learner_id = UUID(str(_value(session, "learner_id")))
|
|
payload = {
|
|
"assessment_snapshot_id": str(assessment_snapshot_id),
|
|
"prescription_id": str(prescription_id),
|
|
"session_id": str(session_id),
|
|
"learner_id": str(learner_id),
|
|
"assessment": assessment.model_dump(mode="json"),
|
|
"prescription": prescription.model_dump(mode="json"),
|
|
"source_observation_ids": sorted(str(item) for item in observation_ids),
|
|
"model_run_id": str(model_run_id),
|
|
"instrument_id": instrument_id,
|
|
"instrument_version": instrument_version,
|
|
"evidence_turn_ids": sorted(str(item) for item in evidence),
|
|
}
|
|
content_hash = _canonical_hash(payload)
|
|
existing = await _existing_by_submission(
|
|
conn,
|
|
table="app.calibration_assessment_snapshot",
|
|
submission_id=submission_id,
|
|
content_hash=content_hash,
|
|
)
|
|
if existing is not None:
|
|
child = await conn.fetchrow(
|
|
"""
|
|
SELECT prescription_id
|
|
FROM app.calibration_metacognitive_prescription
|
|
WHERE assessment_snapshot_id = $1
|
|
""",
|
|
existing,
|
|
)
|
|
return {
|
|
"submission_id": submission_id,
|
|
"assessment_snapshot_id": existing,
|
|
"prescription_id": UUID(str(_value(child or {}, "prescription_id"))),
|
|
"idempotent_replay": True,
|
|
}
|
|
latest = await conn.fetchrow(
|
|
"""
|
|
SELECT assessment_snapshot_id, snapshot_no
|
|
FROM app.calibration_assessment_snapshot
|
|
WHERE learner_id = $1 AND competency_id = $2
|
|
ORDER BY snapshot_no DESC LIMIT 1
|
|
""",
|
|
learner_id,
|
|
assessment.competency_id,
|
|
)
|
|
snapshot_no = int(_value(latest or {}, "snapshot_no", 0)) + 1
|
|
supersedes = _value(latest or {}, "assessment_snapshot_id")
|
|
try:
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO app.calibration_assessment_snapshot (
|
|
assessment_snapshot_id, submission_id, content_hash,
|
|
session_id, learner_id, competency_id, snapshot_no,
|
|
supersedes_assessment_snapshot_id, source_observation_ids,
|
|
assessment_payload, model_run_id, instrument_id,
|
|
instrument_version, evidence_turn_ids
|
|
) VALUES (
|
|
$1,$2,$3,$4,$5,$6,$7,$8,$9::uuid[],$10::jsonb,$11,$12,$13,$14::uuid[]
|
|
)
|
|
""",
|
|
assessment_snapshot_id,
|
|
submission_id,
|
|
content_hash,
|
|
session_id,
|
|
learner_id,
|
|
assessment.competency_id,
|
|
snapshot_no,
|
|
supersedes,
|
|
list(observation_ids),
|
|
assessment.model_dump(mode="json"),
|
|
model_run_id,
|
|
instrument_id,
|
|
instrument_version,
|
|
list(evidence),
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO app.calibration_metacognitive_prescription (
|
|
prescription_id, assessment_snapshot_id, session_id, learner_id,
|
|
competency_id, prescription_payload, model_run_id,
|
|
instrument_id, instrument_version, evidence_turn_ids
|
|
) VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9,$10::uuid[])
|
|
""",
|
|
prescription_id,
|
|
assessment_snapshot_id,
|
|
session_id,
|
|
learner_id,
|
|
assessment.competency_id,
|
|
prescription.model_dump(mode="json"),
|
|
model_run_id,
|
|
instrument_id,
|
|
instrument_version,
|
|
list(evidence),
|
|
)
|
|
except asyncpg.UniqueViolationError as exc:
|
|
raise CalibrationTransferConflictError(
|
|
"calibration assessment submission or snapshot conflict"
|
|
) from exc
|
|
except (asyncpg.CheckViolationError, asyncpg.ForeignKeyViolationError) as exc:
|
|
raise CalibrationTransferStateError(
|
|
"calibration assessment violated source or provenance invariants"
|
|
) from exc
|
|
return {
|
|
"submission_id": submission_id,
|
|
"assessment_snapshot_id": assessment_snapshot_id,
|
|
"prescription_id": prescription_id,
|
|
"idempotent_replay": False,
|
|
}
|
|
|
|
|
|
async def append_transfer_suite(
|
|
*,
|
|
conn: asyncpg.Connection,
|
|
submission_id: UUID,
|
|
transfer_suite_record_id: UUID,
|
|
session_id: UUID,
|
|
suite: TransferSuiteInput,
|
|
model_run_id: UUID,
|
|
instrument_id: str,
|
|
instrument_version: str,
|
|
) -> dict[str, Any]:
|
|
session = await _visible_session(conn, session_id)
|
|
learner_id = UUID(str(_value(session, "learner_id")))
|
|
payload = {
|
|
"transfer_suite_record_id": str(transfer_suite_record_id),
|
|
"session_id": str(session_id),
|
|
"learner_id": str(learner_id),
|
|
"suite": suite.model_dump(mode="json"),
|
|
"model_run_id": str(model_run_id),
|
|
"instrument_id": instrument_id,
|
|
"instrument_version": instrument_version,
|
|
}
|
|
content_hash = _canonical_hash(payload)
|
|
existing = await _existing_by_submission(
|
|
conn,
|
|
table="app.calibration_transfer_suite",
|
|
submission_id=submission_id,
|
|
content_hash=content_hash,
|
|
)
|
|
if existing is not None:
|
|
counts = await conn.fetchrow(
|
|
"""
|
|
SELECT
|
|
(SELECT count(*) FROM app.calibration_transfer_trial WHERE transfer_suite_record_id = $1) AS trial_count,
|
|
(SELECT count(*) FROM app.calibration_transfer_assessment WHERE transfer_suite_record_id = $1) AS assessment_count,
|
|
(SELECT count(*) FROM app.calibration_subgroup_drift_report WHERE transfer_suite_record_id = $1) AS drift_report_count
|
|
""",
|
|
existing,
|
|
)
|
|
return {
|
|
"submission_id": submission_id,
|
|
"transfer_suite_record_id": existing,
|
|
"trial_count": int(_value(counts or {}, "trial_count", 0)),
|
|
"assessment_count": int(_value(counts or {}, "assessment_count", 0)),
|
|
"drift_report_count": int(
|
|
_value(counts or {}, "drift_report_count", 0)
|
|
),
|
|
"idempotent_replay": True,
|
|
}
|
|
assessments = assess_transfer(suite)
|
|
drift_reports = assess_synthetic_subgroup_drift(suite)
|
|
try:
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO app.calibration_transfer_suite (
|
|
transfer_suite_record_id, submission_id, content_hash, suite_key,
|
|
session_id, learner_id, training_phrase_family_ids,
|
|
model_run_id, instrument_id, instrument_version
|
|
) VALUES ($1,$2,$3,$4,$5,$6,$7::text[],$8,$9,$10)
|
|
""",
|
|
transfer_suite_record_id,
|
|
submission_id,
|
|
content_hash,
|
|
suite.suite_id,
|
|
session_id,
|
|
learner_id,
|
|
list(suite.training_phrase_family_ids),
|
|
model_run_id,
|
|
instrument_id,
|
|
instrument_version,
|
|
)
|
|
trial_records: dict[str, tuple[UUID, tuple[UUID, ...]]] = {}
|
|
for trial in suite.trials:
|
|
trial_record_id = uuid4()
|
|
evidence = _uuid_evidence(
|
|
trial.evidence_refs,
|
|
required=trial.status != "insufficient_evidence",
|
|
)
|
|
trial_records[trial.trial_id] = (trial_record_id, evidence)
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO app.calibration_transfer_trial (
|
|
transfer_trial_record_id, transfer_suite_record_id,
|
|
session_id, learner_id, trial_key, competency_id,
|
|
scenario_variant_id, context_variant, relationship_style,
|
|
difficulty_level, expression_variant, synthetic_subgroup,
|
|
scenario_family_id, phrase_family_id, status, uncertainty,
|
|
evidence_turn_ids, counterevidence, model_run_id,
|
|
instrument_id, instrument_version
|
|
) VALUES (
|
|
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,
|
|
$17::uuid[],$18::text[],$19,$20,$21
|
|
)
|
|
""",
|
|
trial_record_id,
|
|
transfer_suite_record_id,
|
|
session_id,
|
|
learner_id,
|
|
trial.trial_id,
|
|
trial.competency_id,
|
|
trial.scenario_variant_id,
|
|
trial.variation.context_variant,
|
|
trial.variation.relationship_style,
|
|
trial.variation.difficulty_level,
|
|
trial.variation.expression_variant,
|
|
trial.variation.synthetic_subgroup,
|
|
trial.variation.scenario_family_id,
|
|
trial.variation.phrase_family_id,
|
|
trial.status,
|
|
trial.uncertainty,
|
|
list(evidence),
|
|
list(trial.counterevidence),
|
|
model_run_id,
|
|
instrument_id,
|
|
instrument_version,
|
|
)
|
|
for assessment in assessments:
|
|
source = [
|
|
trial_records[trial.trial_id]
|
|
for trial in suite.trials
|
|
if trial.competency_id == assessment.competency_id
|
|
]
|
|
source_ids = [item[0] for item in source]
|
|
evidence = tuple(dict.fromkeys(ref for item in source for ref in item[1]))
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO app.calibration_transfer_assessment (
|
|
transfer_assessment_id, transfer_suite_record_id,
|
|
session_id, learner_id, competency_id, source_trial_ids,
|
|
assessment_payload, evidence_turn_ids, model_run_id,
|
|
instrument_id, instrument_version
|
|
) VALUES ($1,$2,$3,$4,$5,$6::uuid[],$7::jsonb,$8::uuid[],$9,$10,$11)
|
|
""",
|
|
uuid4(),
|
|
transfer_suite_record_id,
|
|
session_id,
|
|
learner_id,
|
|
assessment.competency_id,
|
|
source_ids,
|
|
assessment.model_dump(mode="json"),
|
|
list(evidence),
|
|
model_run_id,
|
|
instrument_id,
|
|
instrument_version,
|
|
)
|
|
for report in drift_reports:
|
|
source = [
|
|
trial_records[trial.trial_id]
|
|
for trial in suite.trials
|
|
if trial.competency_id == report.competency_id
|
|
]
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO app.calibration_subgroup_drift_report (
|
|
drift_report_id, transfer_suite_record_id, session_id,
|
|
learner_id, competency_id, source_trial_ids, report_payload,
|
|
model_run_id, instrument_id, instrument_version
|
|
) VALUES ($1,$2,$3,$4,$5,$6::uuid[],$7::jsonb,$8,$9,$10)
|
|
""",
|
|
uuid4(),
|
|
transfer_suite_record_id,
|
|
session_id,
|
|
learner_id,
|
|
report.competency_id,
|
|
[item[0] for item in source],
|
|
report.model_dump(mode="json"),
|
|
model_run_id,
|
|
instrument_id,
|
|
instrument_version,
|
|
)
|
|
except asyncpg.UniqueViolationError as exc:
|
|
raise CalibrationTransferConflictError(
|
|
"transfer suite submission or child conflict"
|
|
) from exc
|
|
except (asyncpg.CheckViolationError, asyncpg.ForeignKeyViolationError) as exc:
|
|
raise CalibrationTransferStateError(
|
|
"transfer suite violated unseen, evidence, or provenance invariants"
|
|
) from exc
|
|
return {
|
|
"submission_id": submission_id,
|
|
"transfer_suite_record_id": transfer_suite_record_id,
|
|
"trial_count": len(suite.trials),
|
|
"assessment_count": len(assessments),
|
|
"drift_report_count": len(drift_reports),
|
|
"idempotent_replay": False,
|
|
}
|
|
|
|
|
|
def _actual_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 CalibrationTransferStateError(
|
|
f"unsupported actual transfer competency: {competency_id}"
|
|
)
|
|
|
|
|
|
def _actual_execution_from_row(row: Mapping[str, Any]) -> ActualTransferExecution:
|
|
labels_payload = _value(row, "normalized_evaluator_labels", {}) or {}
|
|
if isinstance(labels_payload, str):
|
|
labels_payload = json.loads(labels_payload)
|
|
return ActualTransferExecution(
|
|
execution_event_id=UUID(str(_value(row, "execution_event_id"))),
|
|
original_transfer_trial_record_id=UUID(
|
|
str(_value(row, "original_transfer_trial_record_id"))
|
|
),
|
|
practice_session_id=UUID(str(_value(row, "practice_session_id"))),
|
|
competency_id=str(_value(row, "competency_id")),
|
|
scenario_variant_id=str(_value(row, "scenario_variant_id")),
|
|
scenario_novelty=str(_value(row, "scenario_novelty")),
|
|
variation=TransferVariation(
|
|
context_variant=str(_value(row, "context_variant")),
|
|
relationship_style=str(_value(row, "relationship_style")),
|
|
difficulty_level=int(_value(row, "difficulty_level")),
|
|
expression_variant=str(_value(row, "expression_variant")),
|
|
synthetic_subgroup=str(_value(row, "synthetic_subgroup")),
|
|
scenario_family_id=str(_value(row, "scenario_family_id")),
|
|
phrase_family_id=str(_value(row, "phrase_family_id")),
|
|
),
|
|
status=str(_value(row, "status")),
|
|
uncertainty=float(_value(row, "uncertainty")),
|
|
evidence_turn_ids=tuple(_value(row, "evidence_turn_ids", ()) or ()),
|
|
normalized_evaluator_labels=NormalizedEvaluatorLabels.model_validate(
|
|
labels_payload
|
|
),
|
|
counterevidence=tuple(_value(row, "counterevidence", ()) or ()),
|
|
model_run_id=UUID(str(_value(row, "model_run_id"))),
|
|
source_kind=str(_value(row, "source_kind")),
|
|
perspective=str(_value(row, "perspective")),
|
|
instrument_id=str(_value(row, "instrument_id")),
|
|
instrument_version=str(_value(row, "instrument_version")),
|
|
observer_version=str(_value(row, "observer_version")),
|
|
training_phrase_collision=bool(
|
|
_value(row, "training_phrase_collision", False)
|
|
),
|
|
created_at=_value(row, "created_at") or datetime.now(UTC),
|
|
)
|
|
|
|
|
|
def _derive_actual_execution_labels(
|
|
rows: Sequence[Mapping[str, Any]], *, competency_id: str
|
|
) -> tuple[
|
|
Literal["passed", "failed", "insufficient_evidence"],
|
|
float,
|
|
tuple[UUID, ...],
|
|
NormalizedEvaluatorLabels,
|
|
tuple[str, ...],
|
|
]:
|
|
targets = _actual_target_techniques(competency_id)
|
|
technique_codes = tuple(
|
|
sorted(
|
|
{
|
|
str(code)
|
|
for row in rows
|
|
for code in (_value(row, "technique_codes", ()) or ())
|
|
}
|
|
)
|
|
)
|
|
client_state_codes = tuple(
|
|
sorted(
|
|
{
|
|
str(code)
|
|
for row in rows
|
|
for code in (_value(row, "client_state_codes", ()) or ())
|
|
}
|
|
)
|
|
)
|
|
appropriateness = tuple(
|
|
dict.fromkeys(
|
|
str(_value(row, "appropriateness", "neutral") or "neutral")
|
|
for row in rows
|
|
)
|
|
)
|
|
deviation_dimensions = tuple(
|
|
sorted(
|
|
{
|
|
str(code).lower()
|
|
for row in rows
|
|
for code in (
|
|
_value(row, "intent_deviation_dimensions", ()) or ()
|
|
)
|
|
if code
|
|
}
|
|
)
|
|
)
|
|
error_count = sum(bool(_value(row, "evaluator_error")) for row in rows)
|
|
labels = NormalizedEvaluatorLabels(
|
|
technique_codes=technique_codes,
|
|
client_state_codes=client_state_codes,
|
|
appropriateness=appropriateness,
|
|
intent_deviation_dimensions=deviation_dimensions,
|
|
evaluator_error_count=error_count,
|
|
)
|
|
complete = [item for item in rows if _value(item, "client_turn_id")]
|
|
if not complete:
|
|
return "insufficient_evidence", 1.0, (), labels, ()
|
|
|
|
competency_tokens = {
|
|
token
|
|
for token in competency_id.lower().replace("competency.", "").replace("-", "_").split("_")
|
|
if len(token) >= 4
|
|
}
|
|
counterevidence: list[str] = []
|
|
passed = False
|
|
evidence: list[UUID] = []
|
|
for row in complete:
|
|
counselor_id = UUID(str(_value(row, "counselor_turn_id")))
|
|
client_id = UUID(str(_value(row, "client_turn_id")))
|
|
evidence.extend((counselor_id, client_id))
|
|
techniques = set(_value(row, "technique_codes", ()) or ())
|
|
states = set(_value(row, "client_state_codes", ()) or ())
|
|
dimensions = {
|
|
str(item).lower()
|
|
for item in (_value(row, "intent_deviation_dimensions", ()) or ())
|
|
if item
|
|
}
|
|
target_deviation = any(
|
|
token in dimension or dimension in token
|
|
for token in competency_tokens
|
|
for dimension in dimensions
|
|
)
|
|
technique_match = bool(techniques & targets)
|
|
client_support = bool(states & _POSITIVE_CLIENT_STATES)
|
|
row_passed = (
|
|
technique_match
|
|
and _value(row, "appropriateness", "neutral") == "pos"
|
|
and client_support
|
|
and not target_deviation
|
|
and not bool(_value(row, "evaluator_error"))
|
|
)
|
|
passed = passed or row_passed
|
|
if not technique_match:
|
|
counterevidence.append("target_technique_not_observed")
|
|
if _value(row, "appropriateness", "neutral") != "pos":
|
|
counterevidence.append("appropriateness_not_positive")
|
|
if not client_support:
|
|
counterevidence.append("client_response_does_not_support_effect")
|
|
if target_deviation:
|
|
counterevidence.append("target_intent_deviation_observed")
|
|
if _value(row, "evaluator_error"):
|
|
counterevidence.append("turn_evaluation_error")
|
|
unique_evidence = tuple(dict.fromkeys(evidence))
|
|
if passed:
|
|
return "passed", 0.25, unique_evidence, labels, ()
|
|
return (
|
|
"failed",
|
|
0.4,
|
|
unique_evidence,
|
|
labels,
|
|
tuple(dict.fromkeys(counterevidence)) or ("target_behavior_not_observed",),
|
|
)
|
|
|
|
|
|
async def _actual_events_for_competency(
|
|
conn: asyncpg.Connection, *, learner_id: UUID, competency_id: str
|
|
) -> tuple[ActualTransferExecution, ...]:
|
|
rows = await conn.fetch(
|
|
"""
|
|
SELECT *
|
|
FROM app.calibration_transfer_execution_event
|
|
WHERE learner_id = $1 AND competency_id = $2
|
|
ORDER BY created_at, execution_event_id
|
|
""",
|
|
learner_id,
|
|
competency_id,
|
|
)
|
|
return tuple(_actual_execution_from_row(row) for row in rows)
|
|
|
|
|
|
async def append_actual_transfer_execution(
|
|
*,
|
|
principal: Principal,
|
|
original_transfer_trial_record_id: UUID,
|
|
practice_session_id: UUID,
|
|
) -> dict[str, Any]:
|
|
"""완료된 후속 회기의 정규화 라벨만으로 실제 transfer 실행을 기록한다."""
|
|
|
|
if principal.role != Role.LEARNER:
|
|
raise CalibrationTransferStateError(
|
|
"actual transfer execution requires learner role"
|
|
)
|
|
learner_id = UUID(principal.user_id)
|
|
event_id = uuid5(
|
|
_ACTUAL_TRANSFER_NAMESPACE,
|
|
f"event:{original_transfer_trial_record_id}:{practice_session_id}",
|
|
)
|
|
model_run_id = uuid5(
|
|
_ACTUAL_TRANSFER_NAMESPACE,
|
|
f"model:{original_transfer_trial_record_id}:{practice_session_id}",
|
|
)
|
|
async with db.acquire(
|
|
role=principal.role.value,
|
|
user_id=principal.user_id,
|
|
cohort_ids=principal.cohort_ids,
|
|
ai_context=True,
|
|
ai_view="evaluator",
|
|
) as conn:
|
|
await conn.execute(
|
|
"SELECT pg_advisory_xact_lock(hashtextextended($1::text, 5))",
|
|
f"{original_transfer_trial_record_id}:{practice_session_id}",
|
|
)
|
|
original = await conn.fetchrow(
|
|
"""
|
|
SELECT trial.transfer_trial_record_id,
|
|
trial.transfer_suite_record_id, trial.session_id,
|
|
trial.learner_id, trial.competency_id,
|
|
trial.scenario_variant_id, trial.scenario_novelty,
|
|
trial.context_variant, trial.relationship_style,
|
|
trial.difficulty_level, trial.expression_variant,
|
|
trial.synthetic_subgroup, trial.scenario_family_id,
|
|
trial.phrase_family_id, trial.created_at,
|
|
suite.training_phrase_family_ids,
|
|
EXISTS (
|
|
SELECT 1
|
|
FROM app.calibration_prediction_history history
|
|
JOIN app.calibration_prediction_lock prediction_lock
|
|
ON prediction_lock.history_id = history.history_id
|
|
WHERE history.learner_id = trial.learner_id
|
|
AND history.competency_id = trial.competency_id
|
|
AND prediction_lock.created_at <= trial.created_at
|
|
) AS prediction_locked
|
|
FROM app.calibration_transfer_trial trial
|
|
JOIN app.calibration_transfer_suite suite
|
|
ON suite.transfer_suite_record_id = trial.transfer_suite_record_id
|
|
WHERE trial.transfer_trial_record_id = $1
|
|
AND trial.learner_id = $2
|
|
""",
|
|
original_transfer_trial_record_id,
|
|
learner_id,
|
|
)
|
|
if original is None:
|
|
raise CalibrationTransferNotFoundError(
|
|
"original transfer trial not found or not visible"
|
|
)
|
|
if not bool(_value(original, "prediction_locked")):
|
|
raise CalibrationTransferStateError(
|
|
"self-prediction must be locked before transfer reveal"
|
|
)
|
|
|
|
existing = await conn.fetchrow(
|
|
"""
|
|
SELECT * FROM app.calibration_transfer_execution_event
|
|
WHERE original_transfer_trial_record_id = $1
|
|
AND practice_session_id = $2 AND learner_id = $3
|
|
""",
|
|
original_transfer_trial_record_id,
|
|
practice_session_id,
|
|
learner_id,
|
|
)
|
|
if existing is not None:
|
|
execution = _actual_execution_from_row(existing)
|
|
all_events = await _actual_events_for_competency(
|
|
conn,
|
|
learner_id=learner_id,
|
|
competency_id=execution.competency_id,
|
|
)
|
|
assessment = assess_actual_transfer_executions(all_events)[0]
|
|
return {
|
|
"execution": execution.model_dump(mode="python"),
|
|
"assessment": assessment.model_dump(mode="python"),
|
|
"idempotent_replay": True,
|
|
}
|
|
|
|
session = await conn.fetchrow(
|
|
"""
|
|
SELECT session.id, session.learner_id, session.started_at,
|
|
session.ended_at, evaluation.status AS evaluation_status,
|
|
evaluation.scope AS evaluation_scope
|
|
FROM app.sessions session
|
|
LEFT JOIN app.session_evaluation evaluation
|
|
ON evaluation.session_id = session.id
|
|
WHERE session.id = $1 AND session.learner_id = $2
|
|
""",
|
|
practice_session_id,
|
|
learner_id,
|
|
)
|
|
if session is None:
|
|
raise CalibrationTransferNotFoundError(
|
|
"actual transfer practice session not found or not visible"
|
|
)
|
|
if practice_session_id == UUID(str(_value(original, "session_id"))):
|
|
raise CalibrationTransferStateError(
|
|
"actual transfer evidence requires a later practice session"
|
|
)
|
|
if _value(session, "ended_at") is None:
|
|
raise CalibrationTransferStateError(
|
|
"actual transfer practice session must be ended"
|
|
)
|
|
if (
|
|
_value(session, "evaluation_status") != "ready"
|
|
or _value(session, "evaluation_scope") != "session_end"
|
|
):
|
|
raise CalibrationTransferStateError(
|
|
"actual transfer practice evaluation must be ready at session_end"
|
|
)
|
|
if _value(session, "started_at") <= _value(original, "created_at"):
|
|
raise CalibrationTransferStateError(
|
|
"actual transfer practice must start after the original trial"
|
|
)
|
|
|
|
rows = list(
|
|
await conn.fetch(
|
|
"""
|
|
SELECT
|
|
counselor.id AS counselor_turn_id,
|
|
counselor.seq AS counselor_turn_seq,
|
|
response.id AS client_turn_id,
|
|
response.seq AS client_turn_seq,
|
|
ARRAY(
|
|
SELECT definition.code
|
|
FROM app.turn_technique tagged
|
|
JOIN app.technique_label_def definition
|
|
ON definition.label_id = tagged.label_id
|
|
WHERE tagged.turn_id = counselor.id
|
|
ORDER BY definition.code
|
|
) AS technique_codes,
|
|
ARRAY(
|
|
SELECT definition.code
|
|
FROM app.turn_client_state tagged
|
|
JOIN app.client_state_def definition
|
|
ON definition.label_id = tagged.label_id
|
|
WHERE tagged.turn_id = response.id
|
|
ORDER BY definition.code
|
|
) AS client_state_codes,
|
|
CASE
|
|
WHEN appropriateness.score >= 4 THEN 'pos'
|
|
WHEN appropriateness.score <= 2 THEN 'warn'
|
|
ELSE 'neutral'
|
|
END AS appropriateness,
|
|
ARRAY(
|
|
SELECT lower(comment.intent_deviation->>'dimension')
|
|
FROM app.supervisor_comment comment
|
|
WHERE comment.turn_id = counselor.id
|
|
AND comment.intent_deviation IS NOT NULL
|
|
ORDER BY comment.created_at, comment.id
|
|
) AS intent_deviation_dimensions,
|
|
(evaluator_error.rationale IS NOT NULL) AS evaluator_error
|
|
FROM app.turns counselor
|
|
LEFT JOIN LATERAL (
|
|
SELECT candidate.id, candidate.seq
|
|
FROM app.turns candidate
|
|
WHERE candidate.session_id = counselor.session_id
|
|
AND candidate.speaker = 'client'
|
|
AND candidate.seq > counselor.seq
|
|
ORDER BY candidate.seq LIMIT 1
|
|
) response ON TRUE
|
|
LEFT JOIN LATERAL (
|
|
SELECT score
|
|
FROM app.feedback_scores score
|
|
WHERE score.turn_id = counselor.id
|
|
AND score.dimension = 'appropriateness'
|
|
ORDER BY score.created_at DESC LIMIT 1
|
|
) appropriateness ON TRUE
|
|
LEFT JOIN LATERAL (
|
|
SELECT rationale
|
|
FROM app.feedback_scores score
|
|
WHERE score.turn_id = counselor.id AND score.dimension = 'error'
|
|
ORDER BY score.created_at DESC LIMIT 1
|
|
) evaluator_error ON TRUE
|
|
WHERE counselor.session_id = $1 AND counselor.speaker = 'counselor'
|
|
ORDER BY counselor.seq
|
|
""",
|
|
practice_session_id,
|
|
)
|
|
)
|
|
competency_id = str(_value(original, "competency_id"))
|
|
status_value, uncertainty, evidence, labels, counterevidence = (
|
|
_derive_actual_execution_labels(rows, competency_id=competency_id)
|
|
)
|
|
label_payload = labels.model_dump(mode="json")
|
|
evidence_payload = {
|
|
"observer_version": _ACTUAL_TRANSFER_OBSERVER_VERSION,
|
|
"original_transfer_trial_record_id": str(
|
|
original_transfer_trial_record_id
|
|
),
|
|
"practice_session_id": str(practice_session_id),
|
|
"competency_id": competency_id,
|
|
"evidence_turn_ids": [str(item) for item in evidence],
|
|
"normalized_evaluator_labels": label_payload,
|
|
}
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO audit.model_run (
|
|
model_run_id, session_id, agent_role, provider, model,
|
|
prompt_bundle_id, prompt_bundle_version, prompt_bundle_hash,
|
|
structured_schema_version, input_evidence_hash, status, metadata
|
|
) VALUES (
|
|
$1,$2,'evaluator','vignette-runtime','calibration-actual-transfer-observer',
|
|
'calibration-actual-transfer-observer',$3,$4,
|
|
'vignette.calibration-actual-transfer-execution.v1',$5,'ready',$6::jsonb
|
|
) ON CONFLICT (model_run_id) DO NOTHING
|
|
""",
|
|
model_run_id,
|
|
practice_session_id,
|
|
_ACTUAL_TRANSFER_OBSERVER_VERSION,
|
|
_canonical_hash(
|
|
{"observer_version": _ACTUAL_TRANSFER_OBSERVER_VERSION}
|
|
),
|
|
_canonical_hash(evidence_payload),
|
|
evidence_payload,
|
|
)
|
|
training_phrases = set(
|
|
_value(original, "training_phrase_family_ids", ()) or ()
|
|
)
|
|
try:
|
|
inserted = await conn.fetchrow(
|
|
"""
|
|
INSERT INTO app.calibration_transfer_execution_event (
|
|
execution_event_id, original_transfer_trial_record_id,
|
|
transfer_suite_record_id, practice_session_id, learner_id,
|
|
competency_id, scenario_variant_id, context_variant,
|
|
relationship_style, difficulty_level, expression_variant,
|
|
synthetic_subgroup, scenario_family_id, phrase_family_id,
|
|
training_phrase_collision, status, uncertainty,
|
|
evidence_turn_ids, normalized_evaluator_labels,
|
|
counterevidence, model_run_id, source_kind, perspective,
|
|
instrument_id, instrument_version, observer_version
|
|
) VALUES (
|
|
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,
|
|
$18::uuid[],$19::jsonb,$20::text[],$21,$22,$23,$24,$25,$26
|
|
) RETURNING *
|
|
""",
|
|
event_id,
|
|
original_transfer_trial_record_id,
|
|
_value(original, "transfer_suite_record_id"),
|
|
practice_session_id,
|
|
learner_id,
|
|
competency_id,
|
|
_value(original, "scenario_variant_id"),
|
|
_value(original, "context_variant"),
|
|
_value(original, "relationship_style"),
|
|
_value(original, "difficulty_level"),
|
|
_value(original, "expression_variant"),
|
|
_value(original, "synthetic_subgroup"),
|
|
_value(original, "scenario_family_id"),
|
|
_value(original, "phrase_family_id"),
|
|
_value(original, "phrase_family_id") in training_phrases,
|
|
status_value,
|
|
uncertainty,
|
|
list(evidence),
|
|
label_payload,
|
|
list(counterevidence),
|
|
model_run_id,
|
|
"model_inferred",
|
|
"independent_observer",
|
|
_ACTUAL_TRANSFER_INSTRUMENT_ID,
|
|
_ACTUAL_TRANSFER_INSTRUMENT_VERSION,
|
|
_ACTUAL_TRANSFER_OBSERVER_VERSION,
|
|
)
|
|
except asyncpg.UniqueViolationError as exc:
|
|
raise CalibrationTransferConflictError(
|
|
"actual transfer practice session was already recorded"
|
|
) from exc
|
|
except (asyncpg.CheckViolationError, asyncpg.ForeignKeyViolationError) as exc:
|
|
raise CalibrationTransferStateError(
|
|
"actual transfer execution violated source or evidence invariants"
|
|
) from exc
|
|
assert inserted is not None
|
|
execution = _actual_execution_from_row(inserted)
|
|
all_events = await _actual_events_for_competency(
|
|
conn, learner_id=learner_id, competency_id=competency_id
|
|
)
|
|
assessment = assess_actual_transfer_executions(all_events)[0]
|
|
return {
|
|
"execution": execution.model_dump(mode="python"),
|
|
"assessment": assessment.model_dump(mode="python"),
|
|
"idempotent_replay": False,
|
|
}
|
|
|
|
|
|
async def append_teacher_review(
|
|
*,
|
|
principal: Principal,
|
|
submission_id: UUID,
|
|
review_id: UUID,
|
|
target_kind: Literal[
|
|
"calibration_assessment", "transfer_assessment", "drift_report"
|
|
],
|
|
target_id: UUID,
|
|
disposition: Literal["confirmed", "corrected", "needs_more_evidence"],
|
|
correction_payload: Mapping[str, Any],
|
|
review_reason: str,
|
|
evidence_turn_ids: Sequence[UUID],
|
|
counterevidence: Sequence[str],
|
|
) -> dict[str, Any]:
|
|
if principal.role not in {Role.TEACHER, Role.ADMIN}:
|
|
raise CalibrationTransferStateError(
|
|
"calibration review requires teacher or admin role"
|
|
)
|
|
reason = review_reason.strip()
|
|
if not reason:
|
|
raise CalibrationTransferStateError("review_reason must not be blank")
|
|
if disposition != "corrected" and correction_payload:
|
|
raise CalibrationTransferStateError(
|
|
"only corrected reviews may carry correction_payload"
|
|
)
|
|
evidence = _ensure_unique_evidence(evidence_turn_ids)
|
|
target_tables = {
|
|
"calibration_assessment": (
|
|
"app.calibration_assessment_snapshot",
|
|
"assessment_snapshot_id",
|
|
),
|
|
"transfer_assessment": (
|
|
"app.calibration_transfer_assessment",
|
|
"transfer_assessment_id",
|
|
),
|
|
"drift_report": (
|
|
"app.calibration_subgroup_drift_report",
|
|
"drift_report_id",
|
|
),
|
|
}
|
|
table, id_column = target_tables[target_kind]
|
|
payload = {
|
|
"review_id": str(review_id),
|
|
"target_kind": target_kind,
|
|
"target_id": str(target_id),
|
|
"disposition": disposition,
|
|
"correction_payload": dict(correction_payload),
|
|
"review_reason": reason,
|
|
"evidence_turn_ids": sorted(str(item) for item in evidence),
|
|
"counterevidence": list(counterevidence),
|
|
"created_by_uid": principal.user_id,
|
|
}
|
|
content_hash = _canonical_hash(payload)
|
|
async with db.acquire(
|
|
role=principal.role.value,
|
|
user_id=principal.user_id,
|
|
cohort_ids=principal.cohort_ids,
|
|
) as conn:
|
|
target = await conn.fetchrow(
|
|
f"SELECT session_id, learner_id FROM {table} WHERE {id_column} = $1",
|
|
target_id,
|
|
)
|
|
if target is None:
|
|
raise CalibrationTransferNotFoundError(
|
|
"review target not found or outside cohort scope"
|
|
)
|
|
existing = await _existing_by_submission(
|
|
conn,
|
|
table="app.calibration_teacher_review_event",
|
|
submission_id=submission_id,
|
|
content_hash=content_hash,
|
|
)
|
|
if existing is not None:
|
|
row = await conn.fetchrow(
|
|
"SELECT review_no FROM app.calibration_teacher_review_event WHERE review_id = $1",
|
|
existing,
|
|
)
|
|
return {
|
|
"submission_id": submission_id,
|
|
"review_id": existing,
|
|
"review_no": int(_value(row or {}, "review_no", 1)),
|
|
"idempotent_replay": True,
|
|
}
|
|
latest = await conn.fetchrow(
|
|
"""
|
|
SELECT review_id, review_no
|
|
FROM app.calibration_teacher_review_event
|
|
WHERE target_kind = $1 AND target_id = $2
|
|
ORDER BY review_no DESC LIMIT 1
|
|
""",
|
|
target_kind,
|
|
target_id,
|
|
)
|
|
review_no = int(_value(latest or {}, "review_no", 0)) + 1
|
|
try:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
INSERT INTO app.calibration_teacher_review_event (
|
|
review_id, submission_id, content_hash, target_kind,
|
|
target_id, session_id, learner_id, review_no,
|
|
supersedes_review_id, disposition, correction_payload,
|
|
review_reason, evidence_turn_ids, counterevidence,
|
|
created_by_uid, created_by_role
|
|
) VALUES (
|
|
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12,
|
|
$13::uuid[],$14::text[],$15,$16
|
|
) RETURNING review_id, review_no
|
|
""",
|
|
review_id,
|
|
submission_id,
|
|
content_hash,
|
|
target_kind,
|
|
target_id,
|
|
_value(target, "session_id"),
|
|
_value(target, "learner_id"),
|
|
review_no,
|
|
_value(latest or {}, "review_id"),
|
|
disposition,
|
|
dict(correction_payload),
|
|
reason,
|
|
list(evidence),
|
|
list(counterevidence),
|
|
UUID(principal.user_id),
|
|
_created_role(principal),
|
|
)
|
|
except asyncpg.UniqueViolationError as exc:
|
|
raise CalibrationTransferConflictError(
|
|
"teacher review submission or supersession conflict"
|
|
) from exc
|
|
except (asyncpg.CheckViolationError, asyncpg.ForeignKeyViolationError) as exc:
|
|
raise CalibrationTransferStateError(
|
|
"teacher review violated target, evidence, or payload invariants"
|
|
) from exc
|
|
assert row is not None
|
|
return {
|
|
"submission_id": submission_id,
|
|
"review_id": UUID(str(_value(row, "review_id"))),
|
|
"review_no": int(_value(row, "review_no")),
|
|
"idempotent_replay": False,
|
|
}
|
|
|
|
|
|
async def read_calibration_transfer(
|
|
*, principal: Principal, learner_id: UUID | None = None
|
|
) -> dict[str, Any]:
|
|
if principal.role == Role.LEARNER:
|
|
target_learner_id = UUID(principal.user_id)
|
|
if learner_id is not None and learner_id != target_learner_id:
|
|
raise CalibrationTransferNotFoundError(
|
|
"learner calibration is not visible"
|
|
)
|
|
requested_view = "learner"
|
|
elif principal.role in {Role.TEACHER, Role.ADMIN}:
|
|
if learner_id is None:
|
|
raise CalibrationTransferStateError(
|
|
"teacher/admin calibration read requires learner_id"
|
|
)
|
|
target_learner_id = learner_id
|
|
requested_view = "supervisor"
|
|
else:
|
|
raise CalibrationTransferStateError("unsupported calibration reader role")
|
|
async with db.acquire(
|
|
role=principal.role.value,
|
|
user_id=principal.user_id,
|
|
cohort_ids=principal.cohort_ids,
|
|
) as conn:
|
|
if principal.role in {Role.TEACHER, Role.ADMIN}:
|
|
visible = await conn.fetchval(
|
|
"SELECT EXISTS(SELECT 1 FROM app.sessions WHERE learner_id = $1)",
|
|
target_learner_id,
|
|
)
|
|
if not visible:
|
|
raise CalibrationTransferNotFoundError(
|
|
"learner calibration not found or outside cohort scope"
|
|
)
|
|
histories = list(
|
|
await conn.fetch(
|
|
"""
|
|
SELECT history_id, session_id, competency_id, practice_block_id,
|
|
scenario_variant_id, phrase_family_id, created_at
|
|
FROM app.calibration_prediction_history
|
|
WHERE learner_id = $1 ORDER BY created_at, history_id
|
|
""",
|
|
target_learner_id,
|
|
)
|
|
)
|
|
history_ids = [UUID(str(_value(item, "history_id"))) for item in histories]
|
|
revisions = (
|
|
list(
|
|
await conn.fetch(
|
|
"""
|
|
SELECT prediction_revision_id, submission_id, history_id,
|
|
revision_no, supersedes_prediction_revision_id,
|
|
predicted_success_probability, confidence,
|
|
recorded_sequence, revision_reason, source_kind,
|
|
perspective, instrument_id, instrument_version,
|
|
evidence_turn_ids, created_at
|
|
FROM app.calibration_prediction_revision
|
|
WHERE history_id = ANY($1::uuid[])
|
|
ORDER BY history_id, revision_no
|
|
""",
|
|
history_ids,
|
|
)
|
|
)
|
|
if history_ids
|
|
else []
|
|
)
|
|
locks = (
|
|
list(
|
|
await conn.fetch(
|
|
"""
|
|
SELECT lock_id, submission_id, history_id,
|
|
prediction_revision_id, locked_sequence, created_at
|
|
FROM app.calibration_prediction_lock
|
|
WHERE history_id = ANY($1::uuid[])
|
|
""",
|
|
history_ids,
|
|
)
|
|
)
|
|
if history_ids
|
|
else []
|
|
)
|
|
observations = (
|
|
list(
|
|
await conn.fetch(
|
|
"""
|
|
SELECT observation_id, submission_id, history_id, status,
|
|
source_kind, perspective, model_run_id,
|
|
instrument_id, instrument_version, uncertainty,
|
|
evidence_turn_ids, counterevidence,
|
|
revealed_sequence, created_at
|
|
FROM app.calibration_performance_observation
|
|
WHERE history_id = ANY($1::uuid[])
|
|
""",
|
|
history_ids,
|
|
)
|
|
)
|
|
if history_ids
|
|
else []
|
|
)
|
|
assessments = list(
|
|
await conn.fetch(
|
|
"""
|
|
SELECT a.assessment_snapshot_id, a.submission_id, a.session_id,
|
|
a.competency_id, a.snapshot_no,
|
|
a.supersedes_assessment_snapshot_id,
|
|
a.source_observation_ids, a.assessment_payload,
|
|
a.model_run_id, a.instrument_id, a.instrument_version,
|
|
a.evidence_turn_ids, a.created_at,
|
|
p.prescription_id, p.prescription_payload
|
|
FROM app.calibration_assessment_snapshot a
|
|
JOIN app.calibration_metacognitive_prescription p
|
|
ON p.assessment_snapshot_id = a.assessment_snapshot_id
|
|
WHERE a.learner_id = $1
|
|
ORDER BY a.competency_id, a.snapshot_no
|
|
""",
|
|
target_learner_id,
|
|
)
|
|
)
|
|
suites = list(
|
|
await conn.fetch(
|
|
"""
|
|
SELECT transfer_suite_record_id, submission_id, suite_key,
|
|
session_id, training_phrase_family_ids, model_run_id,
|
|
instrument_id, instrument_version, data_classification,
|
|
clinical_claim_allowed, created_at
|
|
FROM app.calibration_transfer_suite
|
|
WHERE learner_id = $1 ORDER BY created_at, transfer_suite_record_id
|
|
""",
|
|
target_learner_id,
|
|
)
|
|
)
|
|
suite_ids = [
|
|
UUID(str(_value(item, "transfer_suite_record_id"))) for item in suites
|
|
]
|
|
trials = (
|
|
list(
|
|
await conn.fetch(
|
|
"""
|
|
SELECT transfer_trial_record_id, transfer_suite_record_id,
|
|
trial_key, competency_id, scenario_variant_id,
|
|
scenario_novelty, context_variant, relationship_style,
|
|
difficulty_level, expression_variant, synthetic_subgroup,
|
|
scenario_family_id, phrase_family_id, status, uncertainty,
|
|
evidence_turn_ids, counterevidence, model_run_id,
|
|
instrument_id, instrument_version, created_at
|
|
FROM app.calibration_transfer_trial
|
|
WHERE transfer_suite_record_id = ANY($1::uuid[])
|
|
ORDER BY transfer_suite_record_id, created_at, transfer_trial_record_id
|
|
""",
|
|
suite_ids,
|
|
)
|
|
)
|
|
if suite_ids
|
|
else []
|
|
)
|
|
transfer_assessments = (
|
|
list(
|
|
await conn.fetch(
|
|
"""
|
|
SELECT transfer_assessment_id, transfer_suite_record_id,
|
|
competency_id, source_trial_ids, assessment_payload,
|
|
evidence_turn_ids, model_run_id, instrument_id,
|
|
instrument_version, created_at
|
|
FROM app.calibration_transfer_assessment
|
|
WHERE transfer_suite_record_id = ANY($1::uuid[])
|
|
ORDER BY transfer_suite_record_id, competency_id
|
|
""",
|
|
suite_ids,
|
|
)
|
|
)
|
|
if suite_ids
|
|
else []
|
|
)
|
|
drift_reports = (
|
|
list(
|
|
await conn.fetch(
|
|
"""
|
|
SELECT drift_report_id, transfer_suite_record_id,
|
|
competency_id, source_trial_ids, report_payload,
|
|
model_run_id, instrument_id, instrument_version,
|
|
data_classification, clinical_claim_allowed, created_at
|
|
FROM app.calibration_subgroup_drift_report
|
|
WHERE transfer_suite_record_id = ANY($1::uuid[])
|
|
ORDER BY transfer_suite_record_id, competency_id
|
|
""",
|
|
suite_ids,
|
|
)
|
|
)
|
|
if suite_ids
|
|
else []
|
|
)
|
|
reviews = list(
|
|
await conn.fetch(
|
|
"""
|
|
SELECT review_id, submission_id, target_kind, target_id,
|
|
review_no, supersedes_review_id, disposition,
|
|
correction_payload, review_reason, evidence_turn_ids,
|
|
counterevidence, created_by_uid, created_by_role, created_at
|
|
FROM app.calibration_teacher_review_event
|
|
WHERE learner_id = $1
|
|
ORDER BY target_kind, target_id, review_no
|
|
""",
|
|
target_learner_id,
|
|
)
|
|
)
|
|
actual_event_rows = list(
|
|
await conn.fetch(
|
|
"""
|
|
SELECT *
|
|
FROM app.calibration_transfer_execution_event
|
|
WHERE learner_id = $1
|
|
ORDER BY competency_id, created_at, execution_event_id
|
|
""",
|
|
target_learner_id,
|
|
)
|
|
)
|
|
|
|
revisions_by_history: dict[UUID, list[dict[str, Any]]] = {}
|
|
for row in revisions:
|
|
revisions_by_history.setdefault(
|
|
UUID(str(_value(row, "history_id"))), []
|
|
).append(dict(row))
|
|
locks_by_history = {
|
|
UUID(str(_value(row, "history_id"))): dict(row) for row in locks
|
|
}
|
|
observations_by_history = {
|
|
UUID(str(_value(row, "history_id"))): dict(row) for row in observations
|
|
}
|
|
prediction_histories: list[dict[str, Any]] = []
|
|
for row in histories:
|
|
item = dict(row)
|
|
history_id = UUID(str(_value(row, "history_id")))
|
|
item["revisions"] = revisions_by_history.get(history_id, [])
|
|
item["lock"] = locks_by_history.get(history_id)
|
|
item["external_observation"] = observations_by_history.get(history_id)
|
|
prediction_histories.append(item)
|
|
trials_by_suite: dict[UUID, list[dict[str, Any]]] = {}
|
|
for row in trials:
|
|
trials_by_suite.setdefault(
|
|
UUID(str(_value(row, "transfer_suite_record_id"))), []
|
|
).append(dict(row))
|
|
assessments_by_suite: dict[UUID, list[dict[str, Any]]] = {}
|
|
for row in transfer_assessments:
|
|
assessments_by_suite.setdefault(
|
|
UUID(str(_value(row, "transfer_suite_record_id"))), []
|
|
).append(dict(row))
|
|
drift_by_suite: dict[UUID, list[dict[str, Any]]] = {}
|
|
for row in drift_reports:
|
|
drift_by_suite.setdefault(
|
|
UUID(str(_value(row, "transfer_suite_record_id"))), []
|
|
).append(dict(row))
|
|
suite_payloads: list[dict[str, Any]] = []
|
|
for row in suites:
|
|
item = dict(row)
|
|
suite_id = UUID(str(_value(row, "transfer_suite_record_id")))
|
|
item["trials"] = trials_by_suite.get(suite_id, [])
|
|
item["assessments"] = assessments_by_suite.get(suite_id, [])
|
|
item["drift_reports"] = drift_by_suite.get(suite_id, [])
|
|
suite_payloads.append(item)
|
|
actual_executions = tuple(
|
|
_actual_execution_from_row(row) for row in actual_event_rows
|
|
)
|
|
actual_assessments = assess_actual_transfer_executions(actual_executions)
|
|
return {
|
|
"learner_id": target_learner_id,
|
|
"requested_view": requested_view,
|
|
"clinical_claim_allowed": False,
|
|
"prediction_histories": prediction_histories,
|
|
"calibration_assessments": [dict(item) for item in assessments],
|
|
"transfer_suites": suite_payloads,
|
|
"teacher_reviews": [dict(item) for item in reviews],
|
|
"actual_executions": [
|
|
item.model_dump(mode="python") for item in actual_executions
|
|
],
|
|
"actual_transfer_assessments": [
|
|
item.model_dump(mode="python") for item in actual_assessments
|
|
],
|
|
}
|
|
|
|
|
|
__all__ = [
|
|
"CalibrationTransferConflictError",
|
|
"CalibrationTransferNotFoundError",
|
|
"CalibrationTransferStateError",
|
|
"append_calibration_assessment",
|
|
"append_performance_observation",
|
|
"append_prediction_lock",
|
|
"append_prediction_revision",
|
|
"append_teacher_review",
|
|
"append_transfer_suite",
|
|
"append_actual_transfer_execution",
|
|
"read_calibration_transfer",
|
|
]
|