vignette/apps/api/app/services/deliberate_practice_store.py

1403 lines
54 KiB
Python

"""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
import hashlib
import json
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,
)
_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 _value(row: Mapping[str, Any], key: str, default: Any = None) -> Any:
try:
return row[key]
except (KeyError, TypeError):
return default
def _public_row(row: Mapping[str, Any]) -> dict[str, Any]:
"""API 응답에서 정책 판정 전용 내부 열을 제거한다."""
payload = dict(row)
payload.pop("source_learner_feedback_enabled", None)
return payload
def _canonical_hash(payload: Mapping[str, Any]) -> str:
serialized = json.dumps(
payload,
ensure_ascii=False,
separators=(",", ":"),
sort_keys=True,
default=str,
)
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
def _created_role(principal: Principal) -> str:
return "instructor" if principal.role == Role.TEACHER else principal.role.value
def _ensure_unique_evidence(
evidence_turn_ids: Sequence[UUID], *, required: bool = 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",
]