828 lines
33 KiB
Python
828 lines
33 KiB
Python
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"])
|
|
self.assertTrue(result["_learner_feedback_snapshot_enabled"])
|
|
|
|
async def test_read_reports_disabled_historical_session_snapshot(self) -> None:
|
|
principal = _principal(Role.LEARNER)
|
|
history_id = uuid4()
|
|
conn = AsyncMock()
|
|
conn.fetch.side_effect = [
|
|
[
|
|
{
|
|
"history_id": history_id,
|
|
"session_id": uuid4(),
|
|
"source_learner_feedback_enabled": False,
|
|
}
|
|
],
|
|
[],
|
|
[],
|
|
[],
|
|
[],
|
|
[],
|
|
[],
|
|
[],
|
|
]
|
|
|
|
@asynccontextmanager
|
|
async def fake_acquire(**_kwargs):
|
|
yield conn
|
|
|
|
with patch.object(calibration_transfer_store.db, "acquire", fake_acquire):
|
|
result = await calibration_transfer_store.read_calibration_transfer(
|
|
principal=principal
|
|
)
|
|
|
|
self.assertFalse(result["_learner_feedback_snapshot_enabled"])
|
|
self.assertNotIn(
|
|
"source_learner_feedback_enabled",
|
|
result["prediction_histories"][0],
|
|
)
|
|
history_query = conn.fetch.await_args_list[0].args[0]
|
|
self.assertIn("source_session.learner_feedback_enabled", history_query)
|
|
|
|
|
|
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()
|