"""G4 deliberate-practice append-only PostgreSQL store. Prescription authoring runs in the evaluator AI view. Learner attempts and derived competency snapshots run in the learner's RLS transaction. Teacher corrections are separate superseding events and never mutate the original evidence or graph. """ from __future__ import annotations from collections.abc import Mapping, Sequence from typing import Any from uuid import UUID, uuid5 import asyncpg from .. import db from ..contracts.deliberate_practice import ( CoachingCard, CompetencyGraph, CurriculumDecision, PracticeEpisodeAssessment, PracticeEpisodeInput, PracticeEvidenceRef, PracticePrescription, ) from ..deps import Principal, Role from .deliberate_practice import ( apply_episode_to_competency_graph, assess_practice_episode, prescribe_from_coaching_cards, select_next_practice, ) from .practice_runtime_observer import ( OBSERVER_VERSION, EvaluatedTurnPair, RuntimePracticeObservationError, derive_runtime_episode, observation_model_run_id, ) from .outcome_repository_values import ( canonical_hash as _canonical_hash, created_role as _created_role, public_row as _public_row, value as _value, ) _RUNTIME_ATTEMPT_NAMESPACE = UUID("52e24f06-34be-54cb-9092-5122e384c814") class DeliberatePracticeNotFoundError(LookupError): pass class DeliberatePracticeStateError(ValueError): pass class DeliberatePracticeConflictError(RuntimeError): pass class DeliberatePracticeFeedbackDisabledError(PermissionError): """처방/연습 원천 회기의 학습자 피드백 스냅샷이 비활성이다.""" def _ensure_unique_evidence( evidence_turn_ids: Sequence[UUID], *, required: bool = True ) -> tuple[UUID, ...]: normalized = tuple(evidence_turn_ids) if required and not normalized: raise DeliberatePracticeStateError("evidence_turn_ids must not be empty") if len(set(normalized)) != len(normalized): raise DeliberatePracticeStateError("evidence_turn_ids must be unique") return normalized def _uuid_evidence_refs(refs: Sequence[PracticeEvidenceRef]) -> tuple[UUID, ...]: identifiers: list[UUID] = [] seen: set[UUID] = set() for ref in refs: try: identifier = UUID(ref.ref_id) except ValueError as exc: raise DeliberatePracticeStateError( "persisted practice evidence ref_id must be a turn UUID" ) from exc if identifier not in seen: identifiers.append(identifier) seen.add(identifier) return _ensure_unique_evidence(identifiers) def _episode_evidence_turn_ids(episode: PracticeEpisodeInput) -> tuple[UUID, ...]: refs: list[PracticeEvidenceRef] = [] for attempt in episode.attempts: refs.extend(attempt.evidence_refs) refs.extend(attempt.criterion.evidence_refs) deduped: list[PracticeEvidenceRef] = [] seen: set[str] = set() for ref in refs: if ref.ref_id not in seen: seen.add(ref.ref_id) deduped.append(ref) return _uuid_evidence_refs(deduped) def _ensure_persistable_transfer(assessment: PracticeEpisodeAssessment) -> None: familiar_passes = [ item for item in assessment.attempts if item.outcome == "passed" and item.scenario_novelty == "familiar" ] unseen_passes = [ item for item in assessment.attempts if item.outcome == "passed" and item.scenario_novelty == "unseen_transfer" ] if assessment.progress != "mastered": return if not (familiar_passes or assessment.prior_familiar_demonstrations > 0) or not unseen_passes: raise DeliberatePracticeStateError( "mastery requires familiar and unseen transfer demonstrations" ) familiar_variants = {item.scenario_variant_id for item in familiar_passes} familiar_templates = { item.utterance_template_id for item in familiar_passes if item.utterance_template_id } if any( not item.utterance_template_id or item.scenario_variant_id in familiar_variants or item.utterance_template_id in familiar_templates for item in unseen_passes ): raise DeliberatePracticeStateError( "persisted mastery cannot reuse a familiar variant or memorized phrase" ) async def _visible_session( conn: asyncpg.Connection, session_id: UUID ) -> Mapping[str, Any]: row = await conn.fetchrow( """ SELECT id, learner_id, case_id, persona_id, started_at, ended_at FROM app.sessions WHERE id = $1 """, session_id, ) if row is None: raise DeliberatePracticeNotFoundError("session not found or not visible") return row async def _existing_submission( conn: asyncpg.Connection, *, table: str, id_column: str, submission_id: UUID, content_hash: str, ) -> Mapping[str, Any] | None: allowed = { ("app.practice_prescription_submission", "submission_id"), ("app.practice_episode_submission", "episode_submission_id"), ("app.practice_teacher_correction", "submission_id"), } if (table, id_column) not in allowed: raise AssertionError("unsupported deliberate-practice idempotency lookup") row = await conn.fetchrow( f"SELECT * FROM {table} WHERE {id_column} = $1", submission_id, ) if row is None: return None if str(_value(row, "content_hash")) != content_hash: raise DeliberatePracticeConflictError( "submission id was already used with different practice content" ) return row async def _latest_snapshot( conn: asyncpg.Connection, learner_id: UUID ) -> Mapping[str, Any] | None: return await conn.fetchrow( """ SELECT snapshot.snapshot_id, snapshot.session_id, snapshot.snapshot_no, snapshot.content_hash, snapshot.graph_payload, snapshot.evidence_turn_ids, snapshot.created_at, source_session.learner_feedback_enabled AS source_learner_feedback_enabled FROM app.competency_graph_snapshot snapshot JOIN app.sessions source_session ON source_session.id = snapshot.session_id WHERE snapshot.learner_id = $1 ORDER BY snapshot.snapshot_no DESC LIMIT 1 """, learner_id, ) async def _load_prescriptions( conn: asyncpg.Connection, learner_id: UUID ) -> tuple[tuple[PracticePrescription, ...], dict[str, UUID]]: rows = await conn.fetch( """ SELECT prescription_record_id, prescription_key, prescription_payload FROM app.practice_prescription WHERE learner_id = $1 ORDER BY created_at, prescription_record_id """, learner_id, ) models: list[PracticePrescription] = [] identifiers: dict[str, UUID] = {} for row in rows: model = PracticePrescription.model_validate(_value(row, "prescription_payload")) models.append(model) identifiers[model.prescription_id] = UUID( str(_value(row, "prescription_record_id")) ) return tuple(models), identifiers async def _insert_snapshot( conn: asyncpg.Connection, *, learner_id: UUID, session_id: UUID, graph: CompetencyGraph, evidence_turn_ids: Sequence[UUID], created_by_role: str, source_prescription_submission_id: UUID | None = None, source_episode_submission_id: UUID | None = None, ) -> Mapping[str, Any]: latest = await _latest_snapshot(conn, learner_id) snapshot_no = int(_value(latest or {}, "snapshot_no", 0)) + 1 payload = graph.model_dump(mode="json") row = await conn.fetchrow( """ INSERT INTO app.competency_graph_snapshot ( learner_id, session_id, snapshot_no, content_hash, supersedes_snapshot_id, source_prescription_submission_id, source_episode_submission_id, graph_payload, evidence_turn_ids, created_by_role ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9::uuid[],$10) RETURNING snapshot_id, snapshot_no, created_at """, learner_id, session_id, snapshot_no, _canonical_hash(payload), _value(latest or {}, "snapshot_id"), source_prescription_submission_id, source_episode_submission_id, payload, list(evidence_turn_ids), created_by_role, ) assert row is not None return row async def _insert_decision( conn: asyncpg.Connection, *, learner_id: UUID, session_id: UUID, snapshot_id: UUID, decision: CurriculumDecision, prescription_records: Mapping[str, UUID], created_by_role: str, ) -> Mapping[str, Any]: record_id = prescription_records.get(decision.selected_prescription_id) if record_id is None: raise DeliberatePracticeStateError( "curriculum decision selected a non-persisted prescription" ) payload = decision.model_dump(mode="json") row = await conn.fetchrow( """ INSERT INTO app.practice_curriculum_decision_event ( source_snapshot_id, selected_prescription_record_id, learner_id, session_id, content_hash, decision_payload, created_by_role ) VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7) RETURNING decision_id, created_at """, snapshot_id, record_id, learner_id, session_id, _canonical_hash(payload), payload, created_by_role, ) assert row is not None return row def _next_practice_or_state_error( graph: CompetencyGraph, prescriptions: Sequence[PracticePrescription], ) -> CurriculumDecision: try: return select_next_practice(graph, prescriptions) except ValueError as exc: raise DeliberatePracticeStateError(str(exc)) from exc async def append_prescription_submission( *, conn: asyncpg.Connection, session_id: UUID, submission_id: UUID, coaching_cards: Sequence[CoachingCard], graph: CompetencyGraph, evidence_turn_ids: Sequence[UUID], ) -> dict[str, Any]: """Append evaluator-authored cards, atomic prescriptions, graph and decision.""" if not coaching_cards: raise DeliberatePracticeStateError("coaching_cards must not be empty") evidence = _ensure_unique_evidence(evidence_turn_ids) try: prescriptions = prescribe_from_coaching_cards(coaching_cards) except ValueError as exc: raise DeliberatePracticeStateError(str(exc)) from exc payload = { "session_id": str(session_id), "coaching_cards": [item.model_dump(mode="json") for item in coaching_cards], "graph": graph.model_dump(mode="json"), "evidence_turn_ids": sorted(str(item) for item in evidence), } content_hash = _canonical_hash(payload) await conn.execute( "SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))", f"practice-prescription:{submission_id}", ) anchor = await _visible_session(conn, session_id) learner_id = UUID(str(_value(anchor, "learner_id"))) await conn.execute( "SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))", f"practice-graph:{learner_id}", ) existing = await _existing_submission( conn, table="app.practice_prescription_submission", id_column="submission_id", submission_id=submission_id, content_hash=content_hash, ) if existing is not None: rows = await conn.fetch( """ SELECT prescription_key FROM app.practice_prescription WHERE submission_id = $1 ORDER BY created_at, prescription_record_id """, submission_id, ) snapshot = await conn.fetchrow( """ SELECT snapshot_id FROM app.competency_graph_snapshot WHERE source_prescription_submission_id = $1 """, submission_id, ) decision = await conn.fetchrow( """ SELECT d.decision_id, d.decision_payload FROM app.practice_curriculum_decision_event d WHERE d.source_snapshot_id = $1 """, _value(snapshot or {}, "snapshot_id"), ) return { "submission_id": submission_id, "prescription_ids": [ str(_value(item, "prescription_key")) for item in rows ], "snapshot_id": _value(snapshot or {}, "snapshot_id"), "decision_id": _value(decision or {}, "decision_id"), "next_prescription_id": _value( _value(decision or {}, "decision_payload", {}), "selected_prescription_id", ), "idempotent_replay": True, } latest = await _latest_snapshot(conn, learner_id) if latest is not None: latest_graph = CompetencyGraph.model_validate(_value(latest, "graph_payload")) if latest_graph != graph: raise DeliberatePracticeConflictError( "prescription submission used a stale competency graph snapshot" ) elif any(state.band == "transfer_verified" for state in graph.states): raise DeliberatePracticeStateError( "initial competency graph cannot import unverified mastery" ) try: await conn.execute( """ INSERT INTO app.practice_prescription_submission ( submission_id, session_id, learner_id, content_hash, created_by_role ) VALUES ($1,$2,$3,$4,'agent') """, submission_id, session_id, learner_id, content_hash, ) records: dict[str, UUID] = {} for card in coaching_cards: card_row = await conn.fetchrow( """ INSERT INTO app.practice_coaching_card ( submission_id, session_id, learner_id, card_key, scene_id, coach_claim, card_payload, evidence_turn_ids, source_refs, uncertainty, counterevidence ) VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,$8::uuid[],$9::text[],$10,$11::text[]) RETURNING coaching_card_record_id """, submission_id, session_id, learner_id, card.card_id, card.scene_id, card.coach_claim, card.model_dump(mode="json"), list(evidence), list(card.source_refs), card.uncertainty, list(card.counterevidence), ) assert card_row is not None card_record_id = UUID(str(_value(card_row, "coaching_card_record_id"))) for prescription in ( item for item in prescriptions if item.coaching_card_id == card.card_id ): row = await conn.fetchrow( """ INSERT INTO app.practice_prescription ( prescription_key, submission_id, coaching_card_record_id, session_id, learner_id, competency_id, criterion_id, observable_behavior, activity_mode, scenario_variant_id, scenario_novelty, difficulty_level, prescription_payload, evidence_turn_ids, uncertainty, counterevidence ) VALUES ( $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13::jsonb, $14::uuid[],$15,$16::text[] ) RETURNING prescription_record_id """, prescription.prescription_id, submission_id, card_record_id, session_id, learner_id, prescription.competency_id, prescription.criterion_id, prescription.observable_behavior, prescription.activity.mode, prescription.activity.scenario_variant_id, prescription.activity.scenario_novelty, prescription.activity.difficulty_level, prescription.model_dump(mode="json"), list(evidence), prescription.uncertainty, list(prescription.counterevidence), ) assert row is not None records[prescription.prescription_id] = UUID( str(_value(row, "prescription_record_id")) ) snapshot = await _insert_snapshot( conn, learner_id=learner_id, session_id=session_id, graph=graph, evidence_turn_ids=evidence, created_by_role="agent", source_prescription_submission_id=submission_id, ) all_prescriptions, all_records = await _load_prescriptions(conn, learner_id) decision = _next_practice_or_state_error(graph, all_prescriptions) decision_row = await _insert_decision( conn, learner_id=learner_id, session_id=session_id, snapshot_id=UUID(str(_value(snapshot, "snapshot_id"))), decision=decision, prescription_records=all_records, created_by_role="agent", ) except (asyncpg.CheckViolationError, asyncpg.ForeignKeyViolationError) as exc: raise DeliberatePracticeStateError( "practice prescription violated ownership, evidence, or curriculum invariants" ) from exc except asyncpg.UniqueViolationError as exc: raise DeliberatePracticeConflictError( "practice prescription submission or key already exists" ) from exc return { "submission_id": submission_id, "prescription_ids": list(records), "snapshot_id": UUID(str(_value(snapshot, "snapshot_id"))), "decision_id": UUID(str(_value(decision_row, "decision_id"))), "next_prescription_id": decision.selected_prescription_id, "idempotent_replay": False, } async def _existing_episode_result( conn: asyncpg.Connection, episode_submission_id: UUID ) -> dict[str, Any]: episode = await conn.fetchrow( """ SELECT episode_submission_id, progress, mastery_allowed FROM app.practice_episode_submission WHERE episode_submission_id = $1 """, episode_submission_id, ) snapshot = await conn.fetchrow( """ SELECT snapshot_id FROM app.competency_graph_snapshot WHERE source_episode_submission_id = $1 """, episode_submission_id, ) decision = await conn.fetchrow( """ SELECT decision_id, decision_payload FROM app.practice_curriculum_decision_event WHERE source_snapshot_id = $1 """, _value(snapshot or {}, "snapshot_id"), ) return { "submission_id": episode_submission_id, "progress": _value(episode or {}, "progress"), "mastery_allowed": bool(_value(episode or {}, "mastery_allowed", False)), "snapshot_id": _value(snapshot or {}, "snapshot_id"), "decision_id": _value(decision or {}, "decision_id"), "next_prescription_id": _value( _value(decision or {}, "decision_payload", {}), "selected_prescription_id", ), "idempotent_replay": True, } async def append_learner_attempt_submission( *, principal: Principal, submission_id: UUID, prescription_id: str, episode: PracticeEpisodeInput, practice_session_id: UUID | None = None, ) -> dict[str, Any]: if principal.role != Role.LEARNER: raise DeliberatePracticeStateError("practice attempt requires learner role") if episode.prescription_id != prescription_id: raise DeliberatePracticeStateError( "route prescription id must match practice episode" ) evidence = _episode_evidence_turn_ids(episode) payload = { "prescription_id": prescription_id, "practice_session_id": str(practice_session_id) if practice_session_id else None, "episode": episode.model_dump(mode="json"), } content_hash = _canonical_hash(payload) learner_id = UUID(principal.user_id) async with db.acquire( role=principal.role.value, user_id=principal.user_id, cohort_ids=principal.cohort_ids, ) as conn: await conn.execute( "SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))", f"practice-attempt:{submission_id}", ) prescription_row = await conn.fetchrow( """ SELECT prescription.prescription_record_id, prescription.session_id, prescription.prescription_payload, prescription.created_at, source_session.learner_feedback_enabled AS source_learner_feedback_enabled FROM app.practice_prescription prescription JOIN app.sessions source_session ON source_session.id = prescription.session_id WHERE prescription.learner_id = $1 AND prescription.prescription_key = $2 """, learner_id, prescription_id, ) if prescription_row is None: raise DeliberatePracticeNotFoundError( "practice prescription not found or not visible" ) if not bool( _value( prescription_row, "source_learner_feedback_enabled", True, ) ): raise DeliberatePracticeFeedbackDisabledError( "learner feedback was disabled for the prescription source session" ) existing = await _existing_submission( conn, table="app.practice_episode_submission", id_column="episode_submission_id", submission_id=submission_id, content_hash=content_hash, ) if existing is not None: return await _existing_episode_result(conn, submission_id) await conn.execute( "SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))", f"practice-graph:{learner_id}", ) source_session_id = UUID(str(_value(prescription_row, "session_id"))) session_id = practice_session_id or source_session_id practice_session = await _visible_session(conn, session_id) if practice_session_id is not None: if practice_session_id == source_session_id: raise DeliberatePracticeStateError( "runtime practice evidence requires a later session" ) if _value(practice_session, "ended_at") is None: raise DeliberatePracticeStateError( "runtime practice session must be ended before observation" ) prescription_created_at = _value(prescription_row, "created_at") practice_started_at = _value(practice_session, "started_at") if ( prescription_created_at is not None and practice_started_at is not None and practice_started_at < prescription_created_at ): raise DeliberatePracticeStateError( "runtime practice session must start after its prescription" ) prescription = PracticePrescription.model_validate( _value(prescription_row, "prescription_payload") ) latest = await _latest_snapshot(conn, learner_id) if latest is None: raise DeliberatePracticeStateError( "practice attempt requires a competency graph snapshot" ) graph = CompetencyGraph.model_validate(_value(latest, "graph_payload")) try: prior_state = next( ( state for state in graph.states if state.competency_id == prescription.competency_id ), None, ) assessment = assess_practice_episode( prescription, episode, prior_state=prior_state, ) except ValueError as exc: raise DeliberatePracticeStateError(str(exc)) from exc _ensure_persistable_transfer(assessment) try: updated_graph = apply_episode_to_competency_graph(graph, assessment) except ValueError as exc: raise DeliberatePracticeStateError(str(exc)) from exc all_prescriptions, prescription_records = await _load_prescriptions( conn, learner_id ) decision = _next_practice_or_state_error(updated_graph, all_prescriptions) try: await conn.execute( """ INSERT INTO app.practice_episode_submission ( episode_submission_id, episode_key, prescription_record_id, session_id, learner_id, content_hash, assessment_payload, progress, mastery_allowed, mastery_blockers, uncertainty, evidence_turn_ids, counterevidence, created_by_role ) VALUES ( $1,$2,$3,$4,$5,$6,$7::jsonb,$8,$9,$10::text[],$11, $12::uuid[],$13::text[],'learner' ) """, submission_id, episode.episode_id, _value(prescription_row, "prescription_record_id"), session_id, learner_id, content_hash, assessment.model_dump(mode="json"), assessment.progress, assessment.mastery_allowed, list(assessment.mastery_blockers), assessment.uncertainty, list(evidence), list(assessment.counterevidence), ) for observation, result in zip( episode.attempts, assessment.attempts, strict=True ): attempt_refs = tuple( dict.fromkeys( ( *observation.evidence_refs, *observation.criterion.evidence_refs, ) ) ) attempt_evidence = _uuid_evidence_refs(attempt_refs) await conn.execute( """ INSERT INTO app.practice_attempt_evidence ( attempt_key, episode_submission_id, session_id, learner_id, sequence_no, scenario_variant_id, scenario_novelty, difficulty_level, criterion_status, client_response, outcome, utterance_template_id, learner_claimed_success, uncertainty, evidence_turn_ids, counterevidence, attempt_payload ) VALUES ( $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14, $15::uuid[],$16::text[],$17::jsonb ) """, observation.attempt_id, submission_id, session_id, learner_id, observation.sequence_no, result.scenario_variant_id, result.scenario_novelty, result.difficulty_level, result.criterion_status, result.client_response, result.outcome, result.utterance_template_id, observation.learner_claimed_success, result.uncertainty, list(attempt_evidence), list(result.counterevidence), { "observation": observation.model_dump(mode="json"), "assessment": result.model_dump(mode="json"), }, ) snapshot = await _insert_snapshot( conn, learner_id=learner_id, session_id=session_id, graph=updated_graph, evidence_turn_ids=evidence, created_by_role="learner", source_episode_submission_id=submission_id, ) decision_row = await _insert_decision( conn, learner_id=learner_id, session_id=session_id, snapshot_id=UUID(str(_value(snapshot, "snapshot_id"))), decision=decision, prescription_records=prescription_records, created_by_role="learner", ) except (asyncpg.CheckViolationError, asyncpg.ForeignKeyViolationError) as exc: raise DeliberatePracticeStateError( "practice attempt violated ownership, transfer, or curriculum invariants" ) from exc except asyncpg.UniqueViolationError as exc: raise DeliberatePracticeConflictError( "practice attempt submission or episode key already exists" ) from exc return { "submission_id": submission_id, "progress": assessment.progress, "mastery_allowed": assessment.mastery_allowed, "snapshot_id": UUID(str(_value(snapshot, "snapshot_id"))), "decision_id": UUID(str(_value(decision_row, "decision_id"))), "next_prescription_id": decision.selected_prescription_id, "idempotent_replay": False, } def _runtime_turn_pairs(rows: Sequence[Mapping[str, Any]]) -> tuple[EvaluatedTurnPair, ...]: pairs: list[EvaluatedTurnPair] = [] for row in rows: try: counselor_turn_id = UUID(str(_value(row, "counselor_turn_id"))) client_turn_value = _value(row, "client_turn_id") pairs.append( EvaluatedTurnPair( counselor_turn_id=counselor_turn_id, counselor_turn_seq=int(_value(row, "counselor_turn_seq")), client_turn_id=( UUID(str(client_turn_value)) if client_turn_value else None ), client_turn_seq=( int(_value(row, "client_turn_seq")) if client_turn_value is not None else None ), technique_codes=tuple(_value(row, "technique_codes", ()) or ()), client_state_codes=tuple( _value(row, "client_state_codes", ()) or () ), appropriateness=str( _value(row, "appropriateness", "neutral") or "neutral" ), intent_deviation_dimensions=tuple( _value(row, "intent_deviation_dimensions", ()) or () ), evaluator_error=_value(row, "evaluator_error"), utterance_fingerprint=_value(row, "utterance_fingerprint"), has_voice_feature=bool(_value(row, "has_voice_feature", False)), ) ) except (TypeError, ValueError): continue return tuple(pairs) async def _ensure_runtime_observer_model_runs( *, principal: Principal, prescription_id: str, practice_session_id: UUID, pairs: Sequence[EvaluatedTurnPair], ) -> None: 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: for pair in pairs: input_payload = { "observer_version": OBSERVER_VERSION, "prescription_id": prescription_id, "practice_session_id": str(practice_session_id), "counselor_turn_id": str(pair.counselor_turn_id), "client_turn_id": ( str(pair.client_turn_id) if pair.client_turn_id else None ), "technique_codes": sorted(pair.technique_codes), "client_state_codes": sorted(pair.client_state_codes), "appropriateness": pair.appropriateness, "intent_deviation_dimensions": sorted( pair.intent_deviation_dimensions ), "evaluator_error": bool(pair.evaluator_error), "has_voice_feature": pair.has_voice_feature, } await conn.execute( """ INSERT INTO audit.model_run ( model_run_id, session_id, turn_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,$3,'evaluator','vignette-runtime','practice-runtime-observer', 'practice-runtime-observer',$4,$5, 'vignette.practice-runtime-observation.v1',$6,'ready',$7::jsonb ) ON CONFLICT (model_run_id) DO NOTHING """, observation_model_run_id( prescription_id=prescription_id, practice_session_id=practice_session_id, counselor_turn_id=pair.counselor_turn_id, ), practice_session_id, pair.counselor_turn_id, OBSERVER_VERSION, _canonical_hash({"observer_version": OBSERVER_VERSION}), _canonical_hash(input_payload), input_payload, ) async def append_runtime_practice_session( *, principal: Principal, prescription_id: str, practice_session_id: UUID, ) -> dict[str, Any]: """종료된 새 회기의 evaluator 근거로 서버 주도 연습 시도를 기록한다.""" if principal.role != Role.LEARNER: raise DeliberatePracticeStateError( "runtime practice observation requires learner role" ) learner_id = UUID(principal.user_id) async with db.acquire( role=principal.role.value, user_id=principal.user_id, cohort_ids=principal.cohort_ids, ) as conn: source = await conn.fetchrow( """ SELECT p.prescription_payload, p.session_id AS source_session_id, p.created_at AS prescription_created_at, source_session.case_id AS source_case_id, source_session.persona_id AS source_persona_id FROM app.practice_prescription p JOIN app.sessions source_session ON source_session.id = p.session_id WHERE p.learner_id = $1 AND p.prescription_key = $2 """, learner_id, prescription_id, ) if source is None: raise DeliberatePracticeNotFoundError( "practice prescription not found or not visible" ) session = await conn.fetchrow( """ SELECT s.id, s.case_id, s.persona_id, s.started_at, s.ended_at, e.status AS evaluation_status, e.scope AS evaluation_scope FROM app.sessions s LEFT JOIN app.session_evaluation e ON e.session_id = s.id WHERE s.id = $1 AND s.learner_id = $2 """, practice_session_id, learner_id, ) if session is None: raise DeliberatePracticeNotFoundError( "runtime practice session not found or not visible" ) if UUID(str(_value(source, "source_session_id"))) == practice_session_id: raise DeliberatePracticeStateError( "runtime practice evidence requires a later session" ) if _value(session, "ended_at") is None: raise DeliberatePracticeStateError( "runtime practice session must be ended before observation" ) if ( _value(session, "evaluation_status") != "ready" or _value(session, "evaluation_scope") != "session_end" ): raise DeliberatePracticeStateError( "runtime practice session evaluation must be ready" ) if _value(session, "started_at") < _value(source, "prescription_created_at"): raise DeliberatePracticeStateError( "runtime practice session must start after its prescription" ) rows = await conn.fetch( """ SELECT turn.id AS counselor_turn_id, turn.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 = turn.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 = turn.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 = turn.id AND comment.intent_deviation IS NOT NULL ORDER BY comment.created_at, comment.id ) AS intent_deviation_dimensions, evaluator_error.rationale AS evaluator_error, ( turn.audio_ref IS NOT NULL OR turn.silence_ms IS NOT NULL OR turn.speech_rate IS NOT NULL ) AS has_voice_feature, 'sha256:' || encode( app.digest(convert_to(COALESCE(turn.text_masked, turn.text, ''), 'UTF8'), 'sha256'), 'hex' ) AS utterance_fingerprint FROM app.turns turn LEFT JOIN LATERAL ( SELECT candidate.id, candidate.seq FROM app.turns candidate WHERE candidate.session_id = turn.session_id AND candidate.speaker = 'client' AND candidate.seq > turn.seq ORDER BY candidate.seq LIMIT 1 ) response ON TRUE LEFT JOIN LATERAL ( SELECT score FROM app.feedback_scores score WHERE score.turn_id = turn.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 = turn.id AND score.dimension = 'error' ORDER BY score.created_at DESC LIMIT 1 ) evaluator_error ON TRUE WHERE turn.session_id = $1 AND turn.speaker = 'counselor' ORDER BY turn.seq """, practice_session_id, ) prescription = PracticePrescription.model_validate( _value(source, "prescription_payload") ) pairs = _runtime_turn_pairs(rows) try: episode = derive_runtime_episode( prescription=prescription, practice_session_id=practice_session_id, source_case_id=_value(source, "source_case_id"), source_persona_id=_value(source, "source_persona_id"), practice_case_id=_value(session, "case_id"), practice_persona_id=_value(session, "persona_id"), turn_pairs=pairs, ) except RuntimePracticeObservationError as exc: raise DeliberatePracticeStateError(str(exc)) from exc await _ensure_runtime_observer_model_runs( principal=principal, prescription_id=prescription_id, practice_session_id=practice_session_id, pairs=pairs, ) submission_id = uuid5( _RUNTIME_ATTEMPT_NAMESPACE, f"runtime-practice:{prescription_id}:{practice_session_id}", ) return await append_learner_attempt_submission( principal=principal, submission_id=submission_id, prescription_id=prescription_id, episode=episode, practice_session_id=practice_session_id, ) async def append_teacher_correction( *, principal: Principal, attempt_record_id: UUID, submission_id: UUID, corrected_outcome: str, correction_reason: str, evidence_turn_ids: Sequence[UUID], counterevidence: Sequence[str], ) -> dict[str, Any]: if principal.role not in {Role.TEACHER, Role.ADMIN}: raise DeliberatePracticeStateError( "practice correction requires teacher or admin role" ) evidence = _ensure_unique_evidence(evidence_turn_ids) reason = correction_reason.strip() if not reason: raise DeliberatePracticeStateError("correction_reason must not be blank") if corrected_outcome not in {"passed", "needs_retry", "insufficient_evidence"}: raise DeliberatePracticeStateError("unsupported corrected_outcome") payload = { "attempt_record_id": str(attempt_record_id), "corrected_outcome": corrected_outcome, "correction_reason": reason, "evidence_turn_ids": sorted(str(item) for item in evidence), "counterevidence": list(counterevidence), } 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: await conn.execute( "SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))", f"practice-correction:{attempt_record_id}", ) existing = await _existing_submission( conn, table="app.practice_teacher_correction", id_column="submission_id", submission_id=submission_id, content_hash=content_hash, ) if existing is not None: return { "submission_id": submission_id, "correction_id": UUID(str(_value(existing, "correction_id"))), "correction_no": int(_value(existing, "correction_no")), "idempotent_replay": True, } target = await conn.fetchrow( """ SELECT attempt_record_id, episode_submission_id, session_id, learner_id FROM app.practice_attempt_evidence WHERE attempt_record_id = $1 """, attempt_record_id, ) if target is None: raise DeliberatePracticeNotFoundError( "practice attempt not found or not visible" ) latest = await conn.fetchrow( """ SELECT correction_id, correction_no FROM app.practice_teacher_correction WHERE attempt_record_id = $1 ORDER BY correction_no DESC LIMIT 1 """, attempt_record_id, ) correction_no = int(_value(latest or {}, "correction_no", 0)) + 1 try: row = await conn.fetchrow( """ INSERT INTO app.practice_teacher_correction ( submission_id, content_hash, attempt_record_id, episode_submission_id, session_id, learner_id, correction_no, supersedes_correction_id, corrected_outcome, correction_reason, evidence_turn_ids, counterevidence, created_by_uid, created_by_role ) VALUES ( $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::uuid[],$12::text[],$13,$14 ) RETURNING correction_id, correction_no """, submission_id, content_hash, attempt_record_id, _value(target, "episode_submission_id"), _value(target, "session_id"), _value(target, "learner_id"), correction_no, _value(latest or {}, "correction_id"), corrected_outcome, reason, list(evidence), list(counterevidence), UUID(principal.user_id), _created_role(principal), ) except (asyncpg.CheckViolationError, asyncpg.ForeignKeyViolationError) as exc: raise DeliberatePracticeStateError( "teacher correction violated evidence or transfer invariants" ) from exc except asyncpg.UniqueViolationError as exc: raise DeliberatePracticeConflictError( "teacher correction submission or supersession conflict" ) from exc assert row is not None return { "submission_id": submission_id, "correction_id": UUID(str(_value(row, "correction_id"))), "correction_no": int(_value(row, "correction_no")), "idempotent_replay": False, } async def read_deliberate_practice( *, 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 DeliberatePracticeNotFoundError("learner practice is not visible") elif principal.role in {Role.TEACHER, Role.ADMIN}: if learner_id is None: raise DeliberatePracticeStateError( "teacher/admin practice read requires learner_id" ) target_learner_id = learner_id else: raise DeliberatePracticeStateError("unsupported practice 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 DeliberatePracticeNotFoundError( "learner practice not found or outside cohort scope" ) prescriptions = list( await conn.fetch( """ SELECT p.prescription_record_id, p.prescription_key, p.session_id, p.competency_id, p.criterion_id, p.observable_behavior, p.activity_mode, p.scenario_variant_id, p.scenario_novelty, p.difficulty_level, p.prescription_payload, p.created_at, c.card_key, c.coach_claim, c.evidence_turn_ids, c.source_refs, c.uncertainty, c.counterevidence, source_session.learner_feedback_enabled AS source_learner_feedback_enabled FROM app.practice_prescription p JOIN app.practice_coaching_card c ON c.coaching_card_record_id = p.coaching_card_record_id JOIN app.sessions source_session ON source_session.id = p.session_id WHERE p.learner_id = $1 ORDER BY p.created_at, p.prescription_record_id """, target_learner_id, ) ) episodes = list( await conn.fetch( """ SELECT episode.episode_submission_id, episode.episode_key, episode.session_id, episode.progress, episode.mastery_allowed, episode.mastery_blockers, episode.uncertainty, episode.evidence_turn_ids, episode.counterevidence, episode.assessment_payload, episode.created_at, source_session.learner_feedback_enabled AS source_learner_feedback_enabled FROM app.practice_episode_submission episode JOIN app.sessions source_session ON source_session.id = episode.session_id WHERE episode.learner_id = $1 ORDER BY episode.created_at, episode.episode_submission_id """, target_learner_id, ) ) episode_ids = [ UUID(str(_value(item, "episode_submission_id"))) for item in episodes ] attempts = ( list( await conn.fetch( """ SELECT attempt_record_id, attempt_key, episode_submission_id, sequence_no, scenario_variant_id, scenario_novelty, difficulty_level, criterion_status, client_response, outcome, utterance_template_id, learner_claimed_success, uncertainty, evidence_turn_ids, counterevidence, attempt_payload, created_at FROM app.practice_attempt_evidence WHERE episode_submission_id = ANY($1::uuid[]) ORDER BY episode_submission_id, sequence_no """, episode_ids, ) ) if episode_ids else [] ) attempt_ids = [ UUID(str(_value(item, "attempt_record_id"))) for item in attempts ] corrections = ( list( await conn.fetch( """ SELECT correction_id, submission_id, attempt_record_id, correction_no, supersedes_correction_id, corrected_outcome, correction_reason, evidence_turn_ids, counterevidence, created_by_uid, created_by_role, created_at FROM app.practice_teacher_correction WHERE attempt_record_id = ANY($1::uuid[]) ORDER BY attempt_record_id, correction_no """, attempt_ids, ) ) if attempt_ids else [] ) snapshot = await _latest_snapshot(conn, target_learner_id) if principal.role == Role.LEARNER and any( not bool( _value( item, "source_learner_feedback_enabled", True, ) ) for item in (*prescriptions, *episodes, *([snapshot] if snapshot else [])) ): raise DeliberatePracticeFeedbackDisabledError( "learner feedback was disabled for a practice source session" ) decision = ( await conn.fetchrow( """ SELECT decision_id, source_snapshot_id, decision_payload, created_at FROM app.practice_curriculum_decision_event WHERE source_snapshot_id = $1 """, _value(snapshot or {}, "snapshot_id"), ) if snapshot is not None else None ) corrections_by_attempt: dict[UUID, list[dict[str, Any]]] = {} for row in corrections: corrections_by_attempt.setdefault( UUID(str(_value(row, "attempt_record_id"))), [] ).append(dict(row)) attempts_by_episode: dict[UUID, list[dict[str, Any]]] = {} for row in attempts: payload = dict(row) payload["corrections"] = corrections_by_attempt.get( UUID(str(_value(row, "attempt_record_id"))), [] ) attempts_by_episode.setdefault( UUID(str(_value(row, "episode_submission_id"))), [] ).append(payload) episode_payloads: list[dict[str, Any]] = [] for row in episodes: payload = _public_row(row) payload["attempts"] = attempts_by_episode.get( UUID(str(_value(row, "episode_submission_id"))), [] ) episode_payloads.append(payload) return { "learner_id": target_learner_id, "clinical_claim_allowed": False, "prescriptions": [_public_row(item) for item in prescriptions], "episodes": episode_payloads, "competency_graph": ( _value(snapshot, "graph_payload") if snapshot is not None else None ), "snapshot_id": _value(snapshot or {}, "snapshot_id"), "snapshot_no": _value(snapshot or {}, "snapshot_no"), "next_practice": _value(decision or {}, "decision_payload"), "decision_id": _value(decision or {}, "decision_id"), } __all__ = [ "DeliberatePracticeConflictError", "DeliberatePracticeFeedbackDisabledError", "DeliberatePracticeNotFoundError", "DeliberatePracticeStateError", "append_learner_attempt_submission", "append_runtime_practice_session", "append_prescription_submission", "append_teacher_correction", "read_deliberate_practice", ]