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
789
apps/api/app/test_calibration_transfer_store.py
Normal file
789
apps/api/app/test_calibration_transfer_store.py
Normal file
|
|
@ -0,0 +1,789 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import asyncpg
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import SecretStr, ValidationError
|
||||
|
||||
from .contracts.calibration_transfer import TransferSuiteInput
|
||||
from .deps import Principal, Role
|
||||
from .routes import calibration_transfer
|
||||
from .services import calibration_transfer_store
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
SQL = (REPO_ROOT / "infra" / "db" / "init" / "11_calibration_transfer.sql").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
ACTUAL_SQL_PATH = (
|
||||
REPO_ROOT / "infra" / "db" / "init" / "16_calibration_transfer_actual_execution.sql"
|
||||
)
|
||||
|
||||
|
||||
def _principal(role: Role = Role.LEARNER) -> Principal:
|
||||
return Principal(
|
||||
user_id=str(uuid4()),
|
||||
role=role,
|
||||
cohort_ids=["g5-cohort"],
|
||||
)
|
||||
|
||||
|
||||
class CalibrationTransferSchemaTests(unittest.TestCase):
|
||||
def test_schema_owns_append_only_g5_ledgers(self) -> None:
|
||||
tables = (
|
||||
"calibration_prediction_history",
|
||||
"calibration_prediction_revision",
|
||||
"calibration_prediction_lock",
|
||||
"calibration_performance_observation",
|
||||
"calibration_assessment_snapshot",
|
||||
"calibration_metacognitive_prescription",
|
||||
"calibration_transfer_suite",
|
||||
"calibration_transfer_trial",
|
||||
"calibration_transfer_assessment",
|
||||
"calibration_subgroup_drift_report",
|
||||
"calibration_teacher_review_event",
|
||||
)
|
||||
for table in tables:
|
||||
self.assertIn(f"CREATE TABLE IF NOT EXISTS app.{table}", SQL)
|
||||
self.assertIn(f"'{table}'", SQL)
|
||||
self.assertIn("audit.reject_measurement_mutation()", SQL)
|
||||
self.assertNotIn("FOR UPDATE", SQL)
|
||||
self.assertIn("'calibration-mirror-g5', '1.0.0'", SQL)
|
||||
self.assertIn("'unseen-transfer-g5', '1.0.0'", SQL)
|
||||
self.assertIn('"aggregate_total_forbidden":true', SQL)
|
||||
|
||||
def test_prediction_revision_is_blocked_after_lock_or_reveal(self) -> None:
|
||||
self.assertIn(
|
||||
"self-prediction cannot be revised after lock or external reveal",
|
||||
SQL,
|
||||
)
|
||||
self.assertIn("prediction lock must target the latest revision", SQL)
|
||||
self.assertIn("revealed_sequence <= lock_sequence", SQL)
|
||||
self.assertIn("trg_calibration_prediction_revision_contract", SQL)
|
||||
self.assertIn("trg_calibration_observation_reveal_contract", SQL)
|
||||
|
||||
def test_rls_is_learner_self_teacher_cohort_and_admin(self) -> None:
|
||||
self.assertIn("learner_id = app.current_uid()", SQL)
|
||||
self.assertIn(
|
||||
"u.cohort = current_setting(''app.current_cohort'', true)", SQL
|
||||
)
|
||||
self.assertIn("app.current_role_name() = 'admin'", SQL)
|
||||
self.assertIn("current_ai_view'', true) = ''evaluator''", SQL)
|
||||
|
||||
def test_schema_rejects_transcript_and_aggregate_score_payloads(self) -> None:
|
||||
self.assertIn("raw_transcript", SQL)
|
||||
self.assertIn("total_score", SQL)
|
||||
self.assertIn(
|
||||
"calibration payload cannot store transcript text or aggregate score",
|
||||
SQL,
|
||||
)
|
||||
self.assertNotRegex(SQL.lower(), r"\btotal_score\s+(double|numeric|real|int)")
|
||||
|
||||
def test_actual_execution_migration_is_append_only_and_server_derived(self) -> None:
|
||||
actual_sql = ACTUAL_SQL_PATH.read_text(encoding="utf-8")
|
||||
self.assertIn(
|
||||
"CREATE TABLE IF NOT EXISTS app.calibration_transfer_execution_event",
|
||||
actual_sql,
|
||||
)
|
||||
self.assertIn("audit.reject_measurement_mutation()", actual_sql)
|
||||
self.assertIn("trg_calibration_transfer_execution_contract", actual_sql)
|
||||
self.assertIn("original_transfer_trial_record_id", actual_sql)
|
||||
self.assertIn("practice_session_id", actual_sql)
|
||||
self.assertIn("normalized_evaluator_labels", actual_sql)
|
||||
self.assertIn("model_run_id", actual_sql)
|
||||
self.assertIn("source_kind", actual_sql)
|
||||
self.assertIn("perspective", actual_sql)
|
||||
self.assertIn("instrument_id", actual_sql)
|
||||
self.assertIn("instrument_version", actual_sql)
|
||||
self.assertIn("unseen-transfer-g5", actual_sql)
|
||||
self.assertIn("ADD COLUMN IF NOT EXISTS instrument_id", actual_sql)
|
||||
self.assertIn("ADD COLUMN IF NOT EXISTS instrument_version", actual_sql)
|
||||
self.assertIn("ALTER COLUMN instrument_id SET NOT NULL", actual_sql)
|
||||
self.assertIn("ALTER COLUMN instrument_version SET NOT NULL", actual_sql)
|
||||
self.assertIn("IF NOT EXISTS (", actual_sql)
|
||||
self.assertIn("calibration_transfer_execution_instrument_fkey", actual_sql)
|
||||
self.assertIn("calibration_transfer_execution_instrument_check", actual_sql)
|
||||
self.assertIn("learner_id = app.current_uid()", actual_sql)
|
||||
self.assertNotIn("raw_transcript", actual_sql)
|
||||
self.assertNotIn("text_masked", actual_sql)
|
||||
|
||||
|
||||
class CalibrationTransferOpenAPITests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(calibration_transfer.router)
|
||||
cls.schema = app.openapi()
|
||||
|
||||
def test_standalone_openapi_has_all_role_boundaries(self) -> None:
|
||||
expected = {
|
||||
"/calibration/predictions/revisions",
|
||||
"/calibration/predictions/{history_id}/lock",
|
||||
"/internal/calibration/performance-observations",
|
||||
"/internal/sessions/{session_id}/calibration/assessments",
|
||||
"/internal/sessions/{session_id}/calibration/transfer-suites",
|
||||
"/calibration/reviews",
|
||||
"/calibration/learners/me",
|
||||
"/calibration/learners/{learner_id}",
|
||||
"/calibration/transfer-executions",
|
||||
}
|
||||
self.assertTrue(expected.issubset(self.schema["paths"]))
|
||||
|
||||
def test_internal_paths_publish_dedicated_header(self) -> None:
|
||||
operation = self.schema["paths"][
|
||||
"/internal/calibration/performance-observations"
|
||||
]["post"]
|
||||
headers = {
|
||||
item["name"]
|
||||
for item in operation["parameters"]
|
||||
if item["in"] == "header"
|
||||
}
|
||||
self.assertIn(
|
||||
calibration_transfer.INTERNAL_TOKEN_HEADER,
|
||||
headers,
|
||||
)
|
||||
|
||||
def test_read_model_has_no_total_score_field(self) -> None:
|
||||
schema = self.schema["components"]["schemas"][
|
||||
"CalibrationTransferReadModelResponse"
|
||||
]
|
||||
properties = schema["properties"]
|
||||
self.assertNotIn("total", properties)
|
||||
self.assertNotIn("score", properties)
|
||||
self.assertEqual(properties["clinical_claim_allowed"]["const"], False)
|
||||
|
||||
|
||||
class CalibrationTransferRequestTests(unittest.TestCase):
|
||||
def test_observation_requires_compatible_model_provenance(self) -> None:
|
||||
with self.assertRaises(ValidationError):
|
||||
calibration_transfer.PerformanceObservationRequest(
|
||||
submission_id=uuid4(),
|
||||
observation_id=uuid4(),
|
||||
history_id=uuid4(),
|
||||
status="passed",
|
||||
source_kind="model_inferred",
|
||||
perspective="runtime_observation",
|
||||
instrument_id="g5-performance",
|
||||
instrument_version="1.0.0",
|
||||
uncertainty=0.2,
|
||||
evidence_turn_ids=[uuid4()],
|
||||
revealed_sequence=3,
|
||||
)
|
||||
|
||||
def test_teacher_correction_payload_rejects_raw_transcript(self) -> None:
|
||||
with self.assertRaises(ValidationError):
|
||||
calibration_transfer.TeacherReviewRequest(
|
||||
submission_id=uuid4(),
|
||||
review_id=uuid4(),
|
||||
target_kind="calibration_assessment",
|
||||
target_id=uuid4(),
|
||||
disposition="corrected",
|
||||
correction_payload={"raw_transcript": "do not persist"},
|
||||
review_reason="근거를 다시 검토했다.",
|
||||
)
|
||||
|
||||
def test_non_correction_review_cannot_smuggle_payload(self) -> None:
|
||||
with self.assertRaises(ValidationError):
|
||||
calibration_transfer.TeacherReviewRequest(
|
||||
submission_id=uuid4(),
|
||||
review_id=uuid4(),
|
||||
target_kind="drift_report",
|
||||
target_id=uuid4(),
|
||||
disposition="confirmed",
|
||||
correction_payload={"status": "stable"},
|
||||
review_reason="합성 subgroup 근거를 확인했다.",
|
||||
)
|
||||
|
||||
def test_actual_execution_request_accepts_only_server_identifiers(self) -> None:
|
||||
body = calibration_transfer.ActualTransferExecutionRequest(
|
||||
original_transfer_trial_record_id=uuid4(),
|
||||
practice_session_id=uuid4(),
|
||||
)
|
||||
self.assertEqual(
|
||||
set(body.model_dump()),
|
||||
{"original_transfer_trial_record_id", "practice_session_id"},
|
||||
)
|
||||
with self.assertRaises(ValidationError):
|
||||
calibration_transfer.ActualTransferExecutionRequest(
|
||||
original_transfer_trial_record_id=uuid4(),
|
||||
practice_session_id=uuid4(),
|
||||
context_variant="browser-forged-context",
|
||||
)
|
||||
|
||||
|
||||
class CalibrationTransferStoreTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_transfer_suite_route_preserves_typed_nested_suite(self) -> None:
|
||||
session_id = uuid4()
|
||||
body = calibration_transfer.TransferSuiteSubmissionRequest(
|
||||
submission_id=uuid4(),
|
||||
transfer_suite_record_id=uuid4(),
|
||||
suite={
|
||||
"suite_id": "oas-g5-suite-route-typed",
|
||||
"training_phrase_family_ids": ["training-route-typed"],
|
||||
"trials": [
|
||||
{
|
||||
"trial_id": "oas-g5-transfer-route-typed",
|
||||
"competency_id": "competency.empathic_attunement",
|
||||
"scenario_variant_id": "unseen-route-typed",
|
||||
"variation": {
|
||||
"context_variant": "academic-transition",
|
||||
"relationship_style": "withdrawn",
|
||||
"difficulty_level": 3,
|
||||
"expression_variant": "indirect-emotion",
|
||||
"synthetic_subgroup": "synthetic-route-typed",
|
||||
"scenario_family_id": "family-academic-transition",
|
||||
"phrase_family_id": "novel-route-typed",
|
||||
},
|
||||
"status": "passed",
|
||||
"uncertainty": 0.2,
|
||||
"evidence_refs": [str(uuid4())],
|
||||
"counterevidence": [],
|
||||
}
|
||||
],
|
||||
},
|
||||
model_run_id=uuid4(),
|
||||
instrument_id="unseen-transfer-g5",
|
||||
instrument_version="1.0.0",
|
||||
)
|
||||
mocked = AsyncMock(
|
||||
return_value={
|
||||
"submission_id": body.submission_id,
|
||||
"transfer_suite_record_id": body.transfer_suite_record_id,
|
||||
"trial_count": 1,
|
||||
"assessment_count": 1,
|
||||
"drift_report_count": 1,
|
||||
"idempotent_replay": False,
|
||||
}
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
calibration_transfer.calibration_transfer_store,
|
||||
"append_transfer_suite",
|
||||
mocked,
|
||||
):
|
||||
response = await calibration_transfer.create_transfer_suite(
|
||||
session_id=session_id,
|
||||
body=body,
|
||||
conn=AsyncMock(),
|
||||
)
|
||||
|
||||
self.assertEqual(response.trial_count, 1)
|
||||
self.assertIs(mocked.await_args.kwargs["suite"], body.suite)
|
||||
self.assertIsInstance(mocked.await_args.kwargs["suite"], TransferSuiteInput)
|
||||
|
||||
async def test_transfer_suite_passes_json_object_to_registered_codec(self) -> None:
|
||||
learner_id = uuid4()
|
||||
session_id = uuid4()
|
||||
evidence_id = uuid4()
|
||||
conn = AsyncMock()
|
||||
conn.fetchrow.side_effect = [
|
||||
{"id": session_id, "learner_id": learner_id, "case_id": uuid4()},
|
||||
None,
|
||||
]
|
||||
suite = TransferSuiteInput.model_validate(
|
||||
{
|
||||
"suite_id": "oas-g5-suite-json-codec",
|
||||
"training_phrase_family_ids": ["training-json-codec"],
|
||||
"trials": [
|
||||
{
|
||||
"trial_id": "oas-g5-transfer-json-codec",
|
||||
"competency_id": "competency.empathic_attunement",
|
||||
"scenario_variant_id": "unseen-json-codec",
|
||||
"variation": {
|
||||
"context_variant": "academic-transition",
|
||||
"relationship_style": "withdrawn",
|
||||
"difficulty_level": 3,
|
||||
"expression_variant": "indirect-emotion",
|
||||
"synthetic_subgroup": "synthetic-json-codec",
|
||||
"scenario_family_id": "family-academic-transition",
|
||||
"phrase_family_id": "novel-json-codec",
|
||||
},
|
||||
"status": "passed",
|
||||
"uncertainty": 0.2,
|
||||
"evidence_refs": [str(evidence_id)],
|
||||
"counterevidence": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
result = await calibration_transfer_store.append_transfer_suite(
|
||||
conn=conn,
|
||||
submission_id=uuid4(),
|
||||
transfer_suite_record_id=uuid4(),
|
||||
session_id=session_id,
|
||||
suite=suite,
|
||||
model_run_id=uuid4(),
|
||||
instrument_id="unseen-transfer-g5",
|
||||
instrument_version="1.0.0",
|
||||
)
|
||||
|
||||
assessment_insert = next(
|
||||
call
|
||||
for call in conn.execute.await_args_list
|
||||
if "INSERT INTO app.calibration_transfer_assessment" in call.args[0]
|
||||
)
|
||||
self.assertIsInstance(assessment_insert.args[7], dict)
|
||||
self.assertEqual(result["trial_count"], 1)
|
||||
|
||||
async def test_actual_execution_rejects_before_prediction_lock(self) -> None:
|
||||
principal = _principal(Role.LEARNER)
|
||||
now = datetime(2026, 8, 7, tzinfo=UTC)
|
||||
conn = AsyncMock()
|
||||
conn.fetchrow.return_value = {
|
||||
"transfer_trial_record_id": uuid4(),
|
||||
"transfer_suite_record_id": uuid4(),
|
||||
"session_id": uuid4(),
|
||||
"learner_id": UUID(principal.user_id),
|
||||
"competency_id": "competency.empathic-check",
|
||||
"created_at": now,
|
||||
"prediction_locked": False,
|
||||
}
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_acquire(**kwargs):
|
||||
self.assertTrue(kwargs["ai_context"])
|
||||
self.assertEqual(kwargs["ai_view"], "evaluator")
|
||||
self.assertEqual(kwargs["user_id"], principal.user_id)
|
||||
yield conn
|
||||
|
||||
with patch.object(calibration_transfer_store.db, "acquire", fake_acquire):
|
||||
with self.assertRaisesRegex(
|
||||
calibration_transfer_store.CalibrationTransferStateError,
|
||||
"self-prediction",
|
||||
):
|
||||
await calibration_transfer_store.append_actual_transfer_execution(
|
||||
principal=principal,
|
||||
original_transfer_trial_record_id=uuid4(),
|
||||
practice_session_id=uuid4(),
|
||||
)
|
||||
self.assertEqual(conn.fetchrow.await_count, 1)
|
||||
self.assertIn("prediction_lock", conn.fetchrow.await_args.args[0])
|
||||
|
||||
async def test_actual_execution_requires_ended_ready_later_session(self) -> None:
|
||||
principal = _principal(Role.LEARNER)
|
||||
learner_id = UUID(principal.user_id)
|
||||
trial_id = uuid4()
|
||||
original_session_id = uuid4()
|
||||
practice_session_id = uuid4()
|
||||
now = datetime(2026, 8, 7, tzinfo=UTC)
|
||||
conn = AsyncMock()
|
||||
conn.fetchrow.side_effect = [
|
||||
{
|
||||
"transfer_trial_record_id": trial_id,
|
||||
"transfer_suite_record_id": uuid4(),
|
||||
"session_id": original_session_id,
|
||||
"learner_id": learner_id,
|
||||
"competency_id": "competency.empathic-check",
|
||||
"created_at": now,
|
||||
"prediction_locked": True,
|
||||
},
|
||||
None,
|
||||
{
|
||||
"id": practice_session_id,
|
||||
"learner_id": learner_id,
|
||||
"started_at": now + timedelta(minutes=1),
|
||||
"ended_at": None,
|
||||
"evaluation_status": "ready",
|
||||
"evaluation_scope": "session_end",
|
||||
},
|
||||
]
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_acquire(**_kwargs):
|
||||
yield conn
|
||||
|
||||
with patch.object(calibration_transfer_store.db, "acquire", fake_acquire):
|
||||
with self.assertRaisesRegex(
|
||||
calibration_transfer_store.CalibrationTransferStateError,
|
||||
"must be ended",
|
||||
):
|
||||
await calibration_transfer_store.append_actual_transfer_execution(
|
||||
principal=principal,
|
||||
original_transfer_trial_record_id=trial_id,
|
||||
practice_session_id=practice_session_id,
|
||||
)
|
||||
self.assertEqual(conn.fetch.await_count, 0)
|
||||
|
||||
async def test_actual_execution_persists_and_reads_g0_instrument_provenance(
|
||||
self,
|
||||
) -> None:
|
||||
principal = _principal(Role.LEARNER)
|
||||
learner_id = UUID(principal.user_id)
|
||||
trial_id = uuid4()
|
||||
suite_id = uuid4()
|
||||
source_session_id = uuid4()
|
||||
practice_session_id = uuid4()
|
||||
counselor_turn_id = uuid4()
|
||||
client_turn_id = uuid4()
|
||||
now = datetime(2026, 8, 7, tzinfo=UTC)
|
||||
original = {
|
||||
"transfer_trial_record_id": trial_id,
|
||||
"transfer_suite_record_id": suite_id,
|
||||
"session_id": source_session_id,
|
||||
"learner_id": learner_id,
|
||||
"competency_id": "competency.empathic-check",
|
||||
"scenario_variant_id": "actual-variant",
|
||||
"scenario_novelty": "unseen_transfer",
|
||||
"context_variant": "학업",
|
||||
"relationship_style": "withdrawn",
|
||||
"difficulty_level": 3,
|
||||
"expression_variant": "우회형",
|
||||
"synthetic_subgroup": "synthetic-a",
|
||||
"scenario_family_id": "family-school",
|
||||
"phrase_family_id": "actual-phrase",
|
||||
"training_phrase_family_ids": ["training-phrase"],
|
||||
"created_at": now,
|
||||
"prediction_locked": True,
|
||||
}
|
||||
session = {
|
||||
"id": practice_session_id,
|
||||
"learner_id": learner_id,
|
||||
"started_at": now + timedelta(minutes=1),
|
||||
"ended_at": now + timedelta(minutes=10),
|
||||
"evaluation_status": "ready",
|
||||
"evaluation_scope": "session_end",
|
||||
}
|
||||
execution_row = {
|
||||
"execution_event_id": uuid4(),
|
||||
"original_transfer_trial_record_id": trial_id,
|
||||
"transfer_suite_record_id": suite_id,
|
||||
"practice_session_id": practice_session_id,
|
||||
"learner_id": learner_id,
|
||||
"competency_id": "competency.empathic-check",
|
||||
"scenario_variant_id": "actual-variant",
|
||||
"scenario_novelty": "unseen_transfer",
|
||||
"context_variant": "학업",
|
||||
"relationship_style": "withdrawn",
|
||||
"difficulty_level": 3,
|
||||
"expression_variant": "우회형",
|
||||
"synthetic_subgroup": "synthetic-a",
|
||||
"scenario_family_id": "family-school",
|
||||
"phrase_family_id": "actual-phrase",
|
||||
"training_phrase_collision": False,
|
||||
"status": "passed",
|
||||
"uncertainty": 0.25,
|
||||
"evidence_turn_ids": [counselor_turn_id, client_turn_id],
|
||||
"normalized_evaluator_labels": {
|
||||
"technique_codes": ["reflection"],
|
||||
"client_state_codes": ["affect_contact"],
|
||||
"appropriateness": ["pos"],
|
||||
"intent_deviation_dimensions": [],
|
||||
"evaluator_error_count": 0,
|
||||
},
|
||||
"counterevidence": [],
|
||||
"source_kind": "model_inferred",
|
||||
"perspective": "independent_observer",
|
||||
"model_run_id": uuid4(),
|
||||
"instrument_id": "unseen-transfer-g5",
|
||||
"instrument_version": "1.0.0",
|
||||
"observer_version": "calibration-actual-transfer-observer-v1",
|
||||
"created_at": now + timedelta(minutes=10),
|
||||
}
|
||||
conn = AsyncMock()
|
||||
conn.fetchrow.side_effect = [original, None, session, execution_row]
|
||||
conn.fetch.side_effect = [
|
||||
[
|
||||
{
|
||||
"counselor_turn_id": counselor_turn_id,
|
||||
"client_turn_id": client_turn_id,
|
||||
"technique_codes": ["reflection"],
|
||||
"client_state_codes": ["affect_contact"],
|
||||
"appropriateness": "pos",
|
||||
"intent_deviation_dimensions": [],
|
||||
"evaluator_error": False,
|
||||
}
|
||||
],
|
||||
[execution_row],
|
||||
]
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_acquire(**_kwargs):
|
||||
yield conn
|
||||
|
||||
with patch.object(calibration_transfer_store.db, "acquire", fake_acquire):
|
||||
result = await calibration_transfer_store.append_actual_transfer_execution(
|
||||
principal=principal,
|
||||
original_transfer_trial_record_id=trial_id,
|
||||
practice_session_id=practice_session_id,
|
||||
)
|
||||
|
||||
insert_call = conn.fetchrow.await_args_list[3]
|
||||
self.assertIn("instrument_id", insert_call.args[0])
|
||||
self.assertIn("instrument_version", insert_call.args[0])
|
||||
self.assertIsInstance(insert_call.args[19], dict)
|
||||
model_run_insert = next(
|
||||
call
|
||||
for call in conn.execute.await_args_list
|
||||
if "INSERT INTO audit.model_run" in call.args[0]
|
||||
)
|
||||
self.assertIsInstance(model_run_insert.args[6], dict)
|
||||
self.assertEqual(insert_call.args[22], "model_inferred")
|
||||
self.assertEqual(insert_call.args[23], "independent_observer")
|
||||
self.assertEqual(insert_call.args[24], "unseen-transfer-g5")
|
||||
self.assertEqual(insert_call.args[25], "1.0.0")
|
||||
self.assertEqual(
|
||||
insert_call.args[26], "calibration-actual-transfer-observer-v1"
|
||||
)
|
||||
self.assertEqual(result["execution"]["source_kind"], "model_inferred")
|
||||
self.assertEqual(
|
||||
result["execution"]["perspective"], "independent_observer"
|
||||
)
|
||||
self.assertEqual(result["execution"]["instrument_id"], "unseen-transfer-g5")
|
||||
self.assertEqual(result["execution"]["instrument_version"], "1.0.0")
|
||||
self.assertEqual(
|
||||
result["execution"]["observer_version"],
|
||||
"calibration-actual-transfer-observer-v1",
|
||||
)
|
||||
|
||||
async def test_idempotent_same_content_returns_existing_identifier(self) -> None:
|
||||
identifier = uuid4()
|
||||
conn = AsyncMock()
|
||||
conn.fetchrow.return_value = {
|
||||
"observation_id": identifier,
|
||||
"content_hash": "a" * 64,
|
||||
}
|
||||
result = await calibration_transfer_store._existing_by_submission(
|
||||
conn,
|
||||
table="app.calibration_performance_observation",
|
||||
submission_id=uuid4(),
|
||||
content_hash="a" * 64,
|
||||
)
|
||||
self.assertEqual(result, identifier)
|
||||
|
||||
async def test_idempotent_changed_content_is_conflict(self) -> None:
|
||||
conn = AsyncMock()
|
||||
conn.fetchrow.return_value = {
|
||||
"observation_id": uuid4(),
|
||||
"content_hash": "a" * 64,
|
||||
}
|
||||
with self.assertRaises(
|
||||
calibration_transfer_store.CalibrationTransferConflictError
|
||||
):
|
||||
await calibration_transfer_store._existing_by_submission(
|
||||
conn,
|
||||
table="app.calibration_performance_observation",
|
||||
submission_id=uuid4(),
|
||||
content_hash="b" * 64,
|
||||
)
|
||||
|
||||
async def test_post_lock_revision_db_guard_maps_to_state_error(self) -> None:
|
||||
principal = _principal(Role.LEARNER)
|
||||
learner_id = UUID(principal.user_id)
|
||||
session_id = uuid4()
|
||||
history_id = uuid4()
|
||||
prior_revision_id = uuid4()
|
||||
conn = AsyncMock()
|
||||
conn.fetchrow.side_effect = [
|
||||
{"id": session_id, "learner_id": learner_id, "case_id": uuid4()},
|
||||
None,
|
||||
{
|
||||
"history_id": history_id,
|
||||
"session_id": session_id,
|
||||
"learner_id": learner_id,
|
||||
"competency_id": "competency.empathic-check",
|
||||
"practice_block_id": "oas-g5-block-one",
|
||||
"scenario_variant_id": "variant-1",
|
||||
"phrase_family_id": "phrase-1",
|
||||
},
|
||||
asyncpg.ObjectNotInPrerequisiteStateError(
|
||||
"self-prediction cannot be revised after lock or external reveal"
|
||||
),
|
||||
]
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_acquire(**_kwargs):
|
||||
yield conn
|
||||
|
||||
with patch.object(calibration_transfer_store.db, "acquire", fake_acquire):
|
||||
with self.assertRaises(
|
||||
calibration_transfer_store.CalibrationTransferStateError
|
||||
):
|
||||
await calibration_transfer_store.append_prediction_revision(
|
||||
principal=principal,
|
||||
submission_id=uuid4(),
|
||||
prediction_revision_id=uuid4(),
|
||||
history_id=history_id,
|
||||
session_id=session_id,
|
||||
competency_id="competency.empathic-check",
|
||||
practice_block_id="oas-g5-block-one",
|
||||
scenario_variant_id="variant-1",
|
||||
phrase_family_id="phrase-1",
|
||||
revision_no=2,
|
||||
supersedes_prediction_revision_id=prior_revision_id,
|
||||
predicted_success_probability=0.8,
|
||||
confidence=0.8,
|
||||
recorded_sequence=2,
|
||||
revision_reason="잠금 뒤 수정 차단",
|
||||
instrument_id="calibration-mirror-g5",
|
||||
instrument_version="1.0.0",
|
||||
evidence_turn_ids=(),
|
||||
)
|
||||
|
||||
async def test_evidence_refs_are_uuid_only(self) -> None:
|
||||
self.assertEqual(
|
||||
calibration_transfer_store._uuid_evidence(
|
||||
["30000000-0000-4000-8000-000000000001"], required=True
|
||||
),
|
||||
(UUID("30000000-0000-4000-8000-000000000001"),),
|
||||
)
|
||||
with self.assertRaises(
|
||||
calibration_transfer_store.CalibrationTransferStateError
|
||||
):
|
||||
calibration_transfer_store._uuid_evidence(
|
||||
["상담 축어록 본문"], required=True
|
||||
)
|
||||
|
||||
async def test_learner_cannot_append_teacher_review(self) -> None:
|
||||
with self.assertRaisesRegex(
|
||||
calibration_transfer_store.CalibrationTransferStateError,
|
||||
"teacher or admin",
|
||||
):
|
||||
await calibration_transfer_store.append_teacher_review(
|
||||
principal=_principal(Role.LEARNER),
|
||||
submission_id=uuid4(),
|
||||
review_id=uuid4(),
|
||||
target_kind="calibration_assessment",
|
||||
target_id=uuid4(),
|
||||
disposition="confirmed",
|
||||
correction_payload={},
|
||||
review_reason="확인",
|
||||
evidence_turn_ids=(),
|
||||
counterevidence=(),
|
||||
)
|
||||
|
||||
async def test_model_observation_is_appended_with_lock_provenance(self) -> None:
|
||||
session_id = uuid4()
|
||||
learner_id = uuid4()
|
||||
lock_id = uuid4()
|
||||
history_id = uuid4()
|
||||
observation_id = uuid4()
|
||||
conn = AsyncMock()
|
||||
conn.fetchrow.side_effect = [
|
||||
{
|
||||
"history_id": history_id,
|
||||
"session_id": session_id,
|
||||
"learner_id": learner_id,
|
||||
"competency_id": "competency.empathic-check",
|
||||
"practice_block_id": "oas-g5-block-one",
|
||||
"scenario_variant_id": "variant-1",
|
||||
"phrase_family_id": "phrase-1",
|
||||
"lock_id": lock_id,
|
||||
},
|
||||
None,
|
||||
{"observation_id": observation_id},
|
||||
]
|
||||
result = await calibration_transfer_store.append_performance_observation(
|
||||
conn=conn,
|
||||
submission_id=uuid4(),
|
||||
observation_id=observation_id,
|
||||
history_id=history_id,
|
||||
status="passed",
|
||||
source_kind="model_inferred",
|
||||
perspective="independent_observer",
|
||||
model_run_id=uuid4(),
|
||||
instrument_id="g5-performance",
|
||||
instrument_version="1.0.0",
|
||||
uncertainty=0.2,
|
||||
evidence_turn_ids=(uuid4(),),
|
||||
counterevidence=(),
|
||||
revealed_sequence=3,
|
||||
)
|
||||
self.assertEqual(result["observation_id"], observation_id)
|
||||
insert = conn.fetchrow.await_args_list[-1]
|
||||
self.assertIn("prediction_lock_id", insert.args[0])
|
||||
self.assertEqual(insert.args[5], lock_id)
|
||||
|
||||
async def test_empty_read_is_role_safe_for_learner(self) -> None:
|
||||
principal = _principal(Role.LEARNER)
|
||||
conn = AsyncMock()
|
||||
conn.fetch.side_effect = [[], [], [], [], [], [], []]
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_acquire(**kwargs):
|
||||
self.assertEqual(kwargs["role"], "learner")
|
||||
self.assertEqual(kwargs["user_id"], principal.user_id)
|
||||
yield conn
|
||||
|
||||
with patch.object(calibration_transfer_store.db, "acquire", fake_acquire):
|
||||
result = await calibration_transfer_store.read_calibration_transfer(
|
||||
principal=principal
|
||||
)
|
||||
self.assertEqual(result["requested_view"], "learner")
|
||||
self.assertEqual(result["prediction_histories"], [])
|
||||
self.assertEqual(result["transfer_suites"], [])
|
||||
self.assertFalse(result["clinical_claim_allowed"])
|
||||
|
||||
|
||||
class CalibrationTransferInternalAuthenticationTests(
|
||||
unittest.IsolatedAsyncioTestCase
|
||||
):
|
||||
TOKEN = "g5-calibration-transfer-token-at-least-32-characters"
|
||||
|
||||
async def _assert_rejected_before_db(
|
||||
self, configured: str, presented: str | None, expected_status: int
|
||||
) -> None:
|
||||
reached = False
|
||||
|
||||
async def fake_provider():
|
||||
nonlocal reached
|
||||
reached = True
|
||||
yield AsyncMock()
|
||||
|
||||
settings = SimpleNamespace(
|
||||
calibration_transfer_internal_token=SecretStr(configured)
|
||||
)
|
||||
with patch.object(
|
||||
calibration_transfer, "_evaluator_db_provider", fake_provider
|
||||
):
|
||||
dependency = (
|
||||
calibration_transfer.calibration_transfer_internal_evaluator_db(
|
||||
settings=settings,
|
||||
presented_token=presented
|
||||
)
|
||||
)
|
||||
with self.assertRaises(HTTPException) as captured:
|
||||
await anext(dependency)
|
||||
self.assertEqual(captured.exception.status_code, expected_status)
|
||||
self.assertFalse(reached)
|
||||
|
||||
async def test_unconfigured_token_fails_closed(self) -> None:
|
||||
await self._assert_rejected_before_db("", None, 503)
|
||||
|
||||
async def test_missing_token_is_401(self) -> None:
|
||||
await self._assert_rejected_before_db(self.TOKEN, None, 401)
|
||||
|
||||
async def test_wrong_token_is_403(self) -> None:
|
||||
await self._assert_rejected_before_db(self.TOKEN, "wrong-token", 403)
|
||||
|
||||
async def test_valid_token_acquires_evaluator_connection(self) -> None:
|
||||
conn = AsyncMock()
|
||||
|
||||
async def fake_provider():
|
||||
yield conn
|
||||
|
||||
settings = SimpleNamespace(
|
||||
calibration_transfer_internal_token=SecretStr(self.TOKEN)
|
||||
)
|
||||
with patch.object(
|
||||
calibration_transfer, "_evaluator_db_provider", fake_provider
|
||||
):
|
||||
dependency = (
|
||||
calibration_transfer.calibration_transfer_internal_evaluator_db(
|
||||
settings=settings,
|
||||
presented_token=self.TOKEN
|
||||
)
|
||||
)
|
||||
self.assertIs(await anext(dependency), conn)
|
||||
await dependency.aclose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue