8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본 폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을 git 이력으로 고정하는 것이 목적이다. - contracts/routes/services: measurement, outcome_trajectory, rupture_repair, deliberate_practice, calibration_transfer, supervision_research, multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트 - infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행) - apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가 - docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG - scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트 engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
1345 lines
51 KiB
Python
1345 lines
51 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
|
|
|
|
|
|
def _value(row: Mapping[str, Any], key: str, default: Any = None) -> Any:
|
|
try:
|
|
return row[key]
|
|
except (KeyError, TypeError):
|
|
return default
|
|
|
|
|
|
def _canonical_hash(payload: Mapping[str, Any]) -> str:
|
|
serialized = json.dumps(
|
|
payload,
|
|
ensure_ascii=False,
|
|
separators=(",", ":"),
|
|
sort_keys=True,
|
|
default=str,
|
|
)
|
|
return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _created_role(principal: Principal) -> str:
|
|
return "instructor" if principal.role == Role.TEACHER else principal.role.value
|
|
|
|
|
|
def _ensure_unique_evidence(
|
|
evidence_turn_ids: Sequence[UUID], *, required: bool = 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_id, session_id, snapshot_no, content_hash, graph_payload,
|
|
evidence_turn_ids, created_at
|
|
FROM app.competency_graph_snapshot
|
|
WHERE learner_id = $1
|
|
ORDER BY 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}",
|
|
)
|
|
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}",
|
|
)
|
|
prescription_row = await conn.fetchrow(
|
|
"""
|
|
SELECT prescription_record_id, session_id, prescription_payload, created_at
|
|
FROM app.practice_prescription
|
|
WHERE learner_id = $1 AND prescription_key = $2
|
|
""",
|
|
learner_id,
|
|
prescription_id,
|
|
)
|
|
if prescription_row is None:
|
|
raise DeliberatePracticeNotFoundError(
|
|
"practice prescription not found or not visible"
|
|
)
|
|
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
|
|
FROM app.practice_prescription p
|
|
JOIN app.practice_coaching_card c
|
|
ON c.coaching_card_record_id = p.coaching_card_record_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_submission_id, episode_key, session_id,
|
|
progress, mastery_allowed, mastery_blockers, uncertainty,
|
|
evidence_turn_ids, counterevidence, assessment_payload, created_at
|
|
FROM app.practice_episode_submission
|
|
WHERE learner_id = $1
|
|
ORDER BY created_at, 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)
|
|
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 = dict(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": [dict(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",
|
|
"DeliberatePracticeNotFoundError",
|
|
"DeliberatePracticeStateError",
|
|
"append_learner_attempt_submission",
|
|
"append_runtime_practice_session",
|
|
"append_prescription_submission",
|
|
"append_teacher_correction",
|
|
"read_deliberate_practice",
|
|
]
|