"""DB-backed counseling session persistence with in-process fallback support.""" from __future__ import annotations import time import uuid import hashlib from dataclasses import dataclass from datetime import datetime, timezone from typing import Any, Iterable, Protocol from .db import acquire, get_pool from .deps import Principal from .config import settings from .persona_repository import SEED_VERSION, card_from_row, seed_fallback_persona, seed_persona_id from .runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed from .services import guardrail, memory, state_machine from .services.persona import PersonaCard from .store import DEFAULT_TURN_VISIBLE_TO, InProcSession, TurnRecord _EVALUATION_CACHE: dict[str, dict[str, Any]] = {} _CASE_WORKSHEET_CACHE: dict[str, dict[str, Any]] = {} _SESSION_REVIEW_STATUS_CACHE: dict[str, dict[str, Any]] = {} _SESSION_SHARE_CACHE: dict[str, dict[str, Any]] = {} _SESSION_SHARE_TOKEN_INDEX: dict[str, str] = {} _LIVE_COACH_EVENT_CACHE: dict[str, list[dict[str, Any]]] = {} _SESSION_ARCHIVE_CACHE: dict[str, dict[str, Any]] = {} _SESSION_AUDIT_ROLES = {"teacher", "admin"} _WORKSHEET_REVIEW_STATUS_VALUES = { "pending", "approved", "changes_requested", "rejected", } _APPROPRIATENESS_SCORE = { "warn": 1.0, "neutral": 3.0, "pos": 5.0, } @dataclass(slots=True) class CaseContext: case_id: str last_session_no: int @dataclass(slots=True) class SessionSummaryWrite: session_id: str case_id: str session_no: int end_state: dict[str, Any] rapport_delta: float digest: str open_threads: list[str] class _SessionEvaluationResult(Protocol): scope: str stage: str error: str | None def to_dict(self) -> dict[str, Any]: ... @dataclass(slots=True) class SessionEvaluationWrite: session_id: str learner_id: str status: str source: str scope: str stage: str payload: dict[str, Any] error: str | None = None @classmethod def from_result( cls, *, session_id: str, learner_id: str, result: _SessionEvaluationResult, source: str = "engine", ) -> "SessionEvaluationWrite": return cls( session_id=session_id, learner_id=learner_id, status="error" if result.error else "ready", source=source, scope=result.scope, stage=result.stage, payload=result.to_dict(), error=result.error, ) @classmethod def from_error( cls, *, session_id: str, learner_id: str, scope: str, stage: str, error: BaseException | str, source: str = "engine", ) -> "SessionEvaluationWrite": return cls( session_id=session_id, learner_id=learner_id, status="error", source=source, scope=scope, stage=stage, payload={}, error=str(error), ) def cache_record(self) -> dict[str, Any]: return { "status": self.status, "source": self.source, "scope": self.scope, "stage": self.stage, "payload": self.payload, "error": self.error, } _JOINED_CARD_COLUMNS = ( "card_persona_id", "card_code", "card_version", "card_status", "card_display_name", "card_difficulty", "card_theory_target", "card_demographics", "card_presenting", "card_history", "card_big5", "card_resistance", "card_speech_style", "card_affect_baseline", "card_ccd", "card_dsm5_dimensional", "card_triggers", "card_source_provenance", "card_is_synthetic", ) def _ts(value: datetime | None) -> float | None: if value is None: return None if value.tzinfo is None: value = value.replace(tzinfo=timezone.utc) return value.timestamp() def share_token_hash(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() def _share_record_from_row(row) -> dict[str, Any]: return { "session_id": str(row["session_id"]), "payload": dict(row["payload"] or {}), "created_at": _ts(row["created_at"]), "updated_at": _ts(row["updated_at"]), "revoked_at": _ts(row["revoked_at"]), } def _archive_record_from_row(row) -> dict[str, Any]: return { "session_id": str(row["session_id"]), "learner_id": str(row["learner_id"]), "archived_at": _ts(row["archived_at"]), "updated_at": _ts(row["updated_at"]), } def _model_payload(value: Any) -> dict[str, Any]: if hasattr(value, "model_dump"): return value.model_dump(mode="json") if isinstance(value, dict): return dict(value) return {} def _masked_excerpt(value: str | None, *, limit: int = 220) -> str | None: text = (value or "").strip() if not text: return None result = guardrail.mask_pii(text) masked = str(getattr(result, "text_masked", text)).strip() compact = " ".join(masked.split()) if len(compact) <= limit: return compact return f"{compact[: max(0, limit - 1)].rstrip()}..." def _live_coach_event_from_row(row) -> dict[str, Any]: return { "event_id": str(row["id"]), "session_id": str(row["session_id"]), "turn_seq": int(row["turn_seq"] or 1), "stage": str(row["stage"] or ""), "created_at": _iso_dt(row["created_at"]), "learner_text_excerpt": row["learner_text_excerpt"], "client_reply_excerpt": row["client_reply_excerpt"], "suggestion": dict(row["payload"] or {}), } def _live_coach_cache_record( *, session_id: str, turn_seq: int, stage: str, learner_text: str, client_reply: str | None, suggestion: Any, ) -> dict[str, Any]: now = datetime.now(timezone.utc) return { "event_id": str(uuid.uuid4()), "session_id": session_id, "turn_seq": int(turn_seq), "stage": stage, "created_at": now.isoformat().replace("+00:00", "Z"), "learner_text_excerpt": _masked_excerpt(learner_text), "client_reply_excerpt": _masked_excerpt(client_reply), "suggestion": _model_payload(suggestion), } def _row_value(row, key: str): try: return row[key] except Exception: return None def _card_from_joined_session_row(row) -> PersonaCard | None: if _row_value(row, "card_persona_id") is None: return None card_row = { key.removeprefix("card_"): _row_value(row, key) for key in _JOINED_CARD_COLUMNS } return card_from_row(card_row) def _stage(stage: object) -> str: return getattr(stage, "value", str(stage)) def _clean_text(value: Any) -> str | None: if value is None: return None text = str(value).strip() return text or None def _safe_float(value: Any) -> float | None: if isinstance(value, (int, float)): return float(value) return None def _safe_int(value: Any) -> int | None: if isinstance(value, bool): return int(value) if isinstance(value, int): return value if isinstance(value, float): return int(value) return None def _evaluation_loop(evaluation: dict[str, Any]) -> str: loop = _clean_text(evaluation.get("loop")) or "fast" return loop if loop in {"fast", "deep"} else "fast" def _dict_items(value: Any) -> list[dict[str, Any]]: if not isinstance(value, list): return [] return [item for item in value if isinstance(item, dict)] def _evaluation_feedback_rows(evaluation: dict[str, Any] | None) -> list[dict[str, Any]]: """Normalize scalar/rationale turn-evaluation fields into feedback_scores rows.""" if not isinstance(evaluation, dict): return [] loop = _evaluation_loop(evaluation) rows: list[dict[str, Any]] = [] def add( dimension: str, *, score: float | None = None, rationale: str | None = None, top1_score: float | None = None, ) -> None: dim = _clean_text(dimension) if not dim: return rows.append( { "dimension": dim, "score": score, "rationale": rationale, "top1_score": top1_score, "loop": loop, } ) appropriateness = _clean_text(evaluation.get("appropriateness")) or "neutral" if appropriateness not in _APPROPRIATENESS_SCORE: appropriateness = "neutral" add( "appropriateness", score=_APPROPRIATENESS_SCORE[appropriateness], rationale=_clean_text(evaluation.get("appropriateness_note")), ) rapport = _safe_float(evaluation.get("rapport_signal")) if rapport is not None: add("rapport_signal", score=max(-1.0, min(1.0, rapport))) theory_mode = _clean_text(evaluation.get("theory_mode")) if theory_mode: add("theory_mode", rationale=theory_mode) error = _clean_text(evaluation.get("error")) if error: add("error", rationale=error) for tag in _dict_items(evaluation.get("techniques")): code = _clean_text(tag.get("code")) rationale = _clean_text(tag.get("rationale")) if code and rationale: add(f"technique:{code}", rationale=rationale) for state in _dict_items(evaluation.get("client_state_read")): code = _clean_text(state.get("code")) rationale = _clean_text(state.get("rationale")) if code and rationale: add(f"client_state:{code}", rationale=rationale) return rows def _evaluation_technique_rows(evaluation: dict[str, Any] | None) -> list[dict[str, str]]: if not isinstance(evaluation, dict): return [] rows: list[dict[str, str]] = [] for tag in _dict_items(evaluation.get("techniques")): code = _clean_text(tag.get("code")) if not code: continue rows.append( { "code": code, "label_ko": _clean_text(tag.get("label_ko")) or code, "category": _clean_text(tag.get("category")) or "", } ) return rows def _evaluation_client_state_rows(evaluation: dict[str, Any] | None) -> list[dict[str, str]]: if not isinstance(evaluation, dict): return [] rows: list[dict[str, str]] = [] for state in _dict_items(evaluation.get("client_state_read")): code = _clean_text(state.get("code")) if not code: continue rows.append( { "code": code, "label_ko": _clean_text(state.get("label_ko")) or code, } ) return rows def _evaluation_comment_rows(evaluation: dict[str, Any] | None) -> list[dict[str, Any]]: if not isinstance(evaluation, dict): return [] deviation = evaluation.get("intent_deviation") if not isinstance(deviation, dict): return [] note = _clean_text(evaluation.get("appropriateness_note")) or "의도와 다른 부분" return [{"kind": "critique", "text": note, "intent_deviation": deviation}] def _evaluation_alternative_rows(evaluation: dict[str, Any] | None) -> list[dict[str, str | None]]: if not isinstance(evaluation, dict): return [] alternatives = evaluation.get("alternative_utterances") if not isinstance(alternatives, list): return [] rows: list[dict[str, str | None]] = [] for item in alternatives: if isinstance(item, dict): suggestion = _clean_text(item.get("suggestion") or item.get("text")) rationale = _clean_text(item.get("rationale")) else: suggestion = _clean_text(item) rationale = None if suggestion: rows.append({"suggestion": suggestion, "rationale": rationale}) return rows def _appropriateness_from_score(score: Any) -> str: value = _safe_float(score) if value is None: return "neutral" if value >= 4.0: return "pos" if value <= 2.0: return "warn" return "neutral" def _base_turn_evaluation(turn_seq: int, stage: str) -> dict[str, Any]: return { "loop": "fast", "turn_seq": turn_seq, "stage": stage, "techniques": [], "client_state_read": [], "appropriateness": "neutral", } def _rebuild_turn_evaluations( turn_refs: list[tuple[str, int, str]], *, feedback_rows: Iterable[Any], technique_rows: Iterable[Any], client_state_rows: Iterable[Any], comment_rows: Iterable[Any], alternative_rows: Iterable[Any] = (), ) -> dict[str, dict[str, Any]]: """Rehydrate normalized DB rows back into the TurnRecord.evaluation shape.""" refs = {turn_id: (turn_seq, stage) for turn_id, turn_seq, stage in turn_refs} evaluations: dict[str, dict[str, Any]] = {} rationale_by_dimension: dict[str, dict[str, str]] = {} def ensure(turn_id: str) -> dict[str, Any]: if turn_id not in evaluations: turn_seq, stage = refs[turn_id] evaluations[turn_id] = _base_turn_evaluation(turn_seq, stage) return evaluations[turn_id] for row in feedback_rows: turn_id = str(row["turn_id"]) if turn_id not in refs: continue ev = ensure(turn_id) loop = _clean_text(row["loop"]) if loop in {"fast", "deep"}: ev["loop"] = loop dimension = _clean_text(row["dimension"]) or "" rationale = _clean_text(row["rationale"]) if rationale: rationale_by_dimension.setdefault(turn_id, {})[dimension] = rationale if dimension == "appropriateness": ev["appropriateness"] = _appropriateness_from_score(row["score"]) if rationale: ev["appropriateness_note"] = rationale elif dimension == "rapport_signal": score = _safe_float(row["score"]) if score is not None: ev["rapport_signal"] = max(-1.0, min(1.0, score)) elif dimension == "theory_mode" and rationale: ev["theory_mode"] = rationale elif dimension == "error" and rationale: ev["error"] = rationale for row in technique_rows: turn_id = str(row["turn_id"]) if turn_id not in refs: continue code = _clean_text(row["code"]) if not code: continue item = { "code": code, "label_ko": _clean_text(row["label_ko"]) or code, "category": _clean_text(row["category"]) or "", } rationale = rationale_by_dimension.get(turn_id, {}).get(f"technique:{code}") if rationale: item["rationale"] = rationale ensure(turn_id)["techniques"].append(item) for row in client_state_rows: turn_id = str(row["turn_id"]) if turn_id not in refs: continue code = _clean_text(row["code"]) if not code: continue item = { "code": code, "label_ko": _clean_text(row["label_ko"]) or code, } rationale = rationale_by_dimension.get(turn_id, {}).get(f"client_state:{code}") if rationale: item["rationale"] = rationale ensure(turn_id)["client_state_read"].append(item) for row in comment_rows: turn_id = str(row["turn_id"]) if turn_id not in refs: continue deviation = row["intent_deviation"] if isinstance(deviation, dict): ensure(turn_id)["intent_deviation"] = deviation for row in alternative_rows: turn_id = str(row["turn_id"]) if turn_id not in refs: continue suggestion = _clean_text(row["suggestion"]) if suggestion: ensure(turn_id).setdefault("alternative_utterances", []).append(suggestion) return evaluations async def _record_session_read_audit( conn: Any, principal: Principal, *, target_kind: str, target_id: str, detail: dict[str, Any], ) -> None: if principal.role.value not in _SESSION_AUDIT_ROLES: return await conn.execute( """ INSERT INTO audit.audit_log ( actor_uid, action, target_kind, target_id, detail ) VALUES ($1::uuid, $2, $3, $4, $5::jsonb) """, principal.user_id, "read_session", target_kind, target_id, detail, ) async def record_llm_call_audit(payload: dict[str, Any]) -> bool: """Append provider/token/cost metadata for an external LLM call. The audit table intentionally stores no prompt or completion text. A DB outage must not block the counseling loop, so failures are reported as False. """ try: get_pool() except Exception: return False try: async with acquire(ai_context=True, ai_view="evaluator") as conn: await conn.execute( """ INSERT INTO audit.llm_call_log ( session_id, turn_id, provider, model, tokens_in, tokens_out, cost_usd, inference_geo, latency_ms ) VALUES ( $1::uuid, $2::uuid, $3, $4, $5, $6, $7, $8, $9 ) """, _clean_text(payload.get("session_id")), _clean_text(payload.get("turn_id")), _clean_text(payload.get("provider")), _clean_text(payload.get("model")), _safe_int(payload.get("tokens_in")), _safe_int(payload.get("tokens_out")), _safe_float(payload.get("cost_usd")), _clean_text(payload.get("inference_geo")), _safe_int(payload.get("latency_ms")), ) return True except Exception: return False def _state_from_row(row, card: PersonaCard) -> state_machine.SessionState: if row is None: return state_machine.init_state( params=card.openness_params(), ) return state_machine.SessionState( stage=state_machine.Stage(row["stage"]), turn_seq=int(row["turn_seq"]), effective_openness=float(row["effective_openness"]), rapport_credit=float(row["rapport_credit"]), resistance=float(row["resistance"]), ideation_stage=int(row["ideation_stage"]), turns_in_stage=int(row["turns_in_stage"] or 0), affect_state=dict(row["affect_state"] or {}), ) def _turn_from_row(row, evaluation: dict[str, Any] | None = None) -> TurnRecord: created_at = _ts(row["created_at"]) or time.time() return TurnRecord( turn_seq=int(row["seq"]), speaker=row["speaker"], stage=row["stage"], text=row["text"] or row["text_masked"] or "", text_masked=row["text_masked"] or row["text"] or "", turn_id=str(_row_value(row, "id")) if _row_value(row, "id") is not None else None, created_at=created_at, llm_provider=_row_value(row, "llm_provider"), model=_row_value(row, "model"), tokens_in=_row_value(row, "tokens_in"), tokens_out=_row_value(row, "tokens_out"), cost_usd=_row_value(row, "cost_usd"), audio_ref=_row_value(row, "audio_ref"), silence_ms=_row_value(row, "silence_ms"), speech_rate=_row_value(row, "speech_rate"), barge_in=_row_value(row, "barge_in"), provider_events=_dict_items(_row_value(row, "provider_events")), evaluation=evaluation, visible_to=tuple(_row_value(row, "visible_to") or DEFAULT_TURN_VISIBLE_TO), ) async def _persist_turn_evaluation(conn: Any, turn_id: str, evaluation: dict[str, Any] | None) -> None: if not isinstance(evaluation, dict): return await conn.execute("SELECT set_config('app.ai_context', '1', true)") await conn.execute("SELECT set_config('app.current_ai_view', 'evaluator', true)") await conn.execute("SELECT set_config('app.current_sens_max', '2', true)") for row in _evaluation_feedback_rows(evaluation): await conn.execute( """ INSERT INTO app.feedback_scores ( turn_id, dimension, score, rationale, top1_score, loop ) VALUES ($1::uuid, $2, $3, $4, $5, $6) ON CONFLICT (turn_id, dimension) DO UPDATE SET score = EXCLUDED.score, rationale = EXCLUDED.rationale, top1_score = EXCLUDED.top1_score, loop = EXCLUDED.loop """, turn_id, row["dimension"], row["score"], row["rationale"], row["top1_score"], row["loop"], ) for row in _evaluation_technique_rows(evaluation): label_id = await conn.fetchval( """ INSERT INTO app.technique_label_def (code, display_name, category) VALUES ($1, $2, $3) ON CONFLICT (code, version) DO UPDATE SET display_name = EXCLUDED.display_name, category = EXCLUDED.category, is_active = TRUE RETURNING label_id """, row["code"], row["label_ko"], row["category"], ) await conn.execute( """ INSERT INTO app.turn_technique (turn_id, label_id) VALUES ($1::uuid, $2) ON CONFLICT DO NOTHING """, turn_id, label_id, ) for row in _evaluation_client_state_rows(evaluation): label_id = await conn.fetchval( """ INSERT INTO app.client_state_def (code, display_name) VALUES ($1, $2) ON CONFLICT (code, version) DO UPDATE SET display_name = EXCLUDED.display_name, is_active = TRUE RETURNING label_id """, row["code"], row["label_ko"], ) await conn.execute( """ INSERT INTO app.turn_client_state (turn_id, label_id) VALUES ($1::uuid, $2) ON CONFLICT DO NOTHING """, turn_id, label_id, ) for row in _evaluation_comment_rows(evaluation): await conn.execute( """ INSERT INTO app.supervisor_comment ( turn_id, kind, text, intent_deviation ) VALUES ($1::uuid, $2, $3, $4::jsonb) """, turn_id, row["kind"], row["text"], row["intent_deviation"], ) await conn.execute("DELETE FROM app.alternative_utterance WHERE turn_id = $1::uuid", turn_id) for row in _evaluation_alternative_rows(evaluation): await conn.execute( """ INSERT INTO app.alternative_utterance ( turn_id, suggestion, rationale ) VALUES ($1::uuid, $2, $3) """, turn_id, row["suggestion"], row["rationale"], ) async def _load_turn_evaluations( conn: Any, turn_refs: list[tuple[str, int, str]], ) -> dict[str, dict[str, Any]]: turn_ids = [turn_id for turn_id, _, _ in turn_refs] if not turn_ids: return {} feedback_rows = await conn.fetch( """ SELECT turn_id::text AS turn_id, dimension, score, rationale, top1_score, loop FROM app.feedback_scores WHERE turn_id = ANY($1::uuid[]) ORDER BY created_at, dimension """, turn_ids, ) technique_rows = await conn.fetch( """ SELECT tt.turn_id::text AS turn_id, d.code, d.display_name AS label_ko, d.category FROM app.turn_technique tt JOIN app.technique_label_def d ON d.label_id = tt.label_id WHERE tt.turn_id = ANY($1::uuid[]) ORDER BY tt.turn_id, d.code """, turn_ids, ) client_state_rows = await conn.fetch( """ SELECT ts.turn_id::text AS turn_id, d.code, d.display_name AS label_ko FROM app.turn_client_state ts JOIN app.client_state_def d ON d.label_id = ts.label_id WHERE ts.turn_id = ANY($1::uuid[]) ORDER BY ts.turn_id, d.code """, turn_ids, ) comment_rows = await conn.fetch( """ SELECT turn_id::text AS turn_id, intent_deviation FROM app.supervisor_comment WHERE turn_id = ANY($1::uuid[]) AND intent_deviation IS NOT NULL ORDER BY created_at """, turn_ids, ) alternative_rows = await conn.fetch( """ SELECT turn_id::text AS turn_id, suggestion, rationale FROM app.alternative_utterance WHERE turn_id = ANY($1::uuid[]) ORDER BY turn_id, created_at, id """, turn_ids, ) return _rebuild_turn_evaluations( turn_refs, feedback_rows=feedback_rows, technique_rows=technique_rows, client_state_rows=client_state_rows, comment_rows=comment_rows, alternative_rows=alternative_rows, ) async def _hydrate_session_turn_evaluations(sess: InProcSession) -> None: turn_refs = [ (turn.turn_id, turn.turn_seq, turn.stage) for turn in sess.turns if turn.turn_id is not None ] if not turn_refs: return async with acquire(ai_view="evaluator") as conn: evaluations = await _load_turn_evaluations(conn, turn_refs) for turn in sess.turns: if turn.turn_id and turn.turn_id in evaluations: turn.evaluation = evaluations[turn.turn_id] async def ensure_review_tables() -> None: """Create runtime review/evaluation storage when the DB role allows it.""" try: get_pool() async with acquire(role="admin") as conn: await conn.execute( """ CREATE TABLE IF NOT EXISTS app.session_evaluation ( session_id UUID PRIMARY KEY REFERENCES app.sessions(id) ON DELETE CASCADE, status TEXT NOT NULL CHECK (status IN ('ready','degraded','error')), source TEXT NOT NULL, scope TEXT NOT NULL, stage TEXT NOT NULL, payload JSONB NOT NULL DEFAULT '{}'::jsonb, error TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) """ ) await conn.execute( """ ALTER TABLE app.session_evaluation ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS p_session_evaluation_select ON app.session_evaluation; DROP POLICY IF EXISTS p_session_evaluation_insert ON app.session_evaluation; DROP POLICY IF EXISTS p_session_evaluation_update ON app.session_evaluation; DROP POLICY IF EXISTS p_session_evaluation_delete ON app.session_evaluation; CREATE POLICY p_session_evaluation_select ON app.session_evaluation FOR SELECT USING ( app.is_ai_context() OR app.current_role_name() IN ('admin','instructor') OR EXISTS ( SELECT 1 FROM app.sessions s WHERE s.id = app.session_evaluation.session_id AND s.learner_id = app.current_uid() ) ); CREATE POLICY p_session_evaluation_insert ON app.session_evaluation FOR INSERT WITH CHECK ( app.is_ai_context() OR app.current_role_name() IN ('admin','instructor') OR EXISTS ( SELECT 1 FROM app.sessions s WHERE s.id = app.session_evaluation.session_id AND s.learner_id = app.current_uid() ) ); CREATE POLICY p_session_evaluation_update ON app.session_evaluation FOR UPDATE USING ( app.is_ai_context() OR app.current_role_name() IN ('admin','instructor') OR EXISTS ( SELECT 1 FROM app.sessions s WHERE s.id = app.session_evaluation.session_id AND s.learner_id = app.current_uid() ) ) WITH CHECK ( app.is_ai_context() OR app.current_role_name() IN ('admin','instructor') OR EXISTS ( SELECT 1 FROM app.sessions s WHERE s.id = app.session_evaluation.session_id AND s.learner_id = app.current_uid() ) ); CREATE POLICY p_session_evaluation_delete ON app.session_evaluation FOR DELETE USING ( app.is_ai_context() OR app.current_role_name() IN ('admin','instructor') OR EXISTS ( SELECT 1 FROM app.sessions s WHERE s.id = app.session_evaluation.session_id AND s.learner_id = app.current_uid() ) ) """ ) await conn.execute( """ CREATE TABLE IF NOT EXISTS app.case_worksheet ( session_id UUID PRIMARY KEY REFERENCES app.sessions(id) ON DELETE CASCADE, payload JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) """ ) await conn.execute( """ ALTER TABLE app.case_worksheet ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS p_case_worksheet_select ON app.case_worksheet; DROP POLICY IF EXISTS p_case_worksheet_insert ON app.case_worksheet; DROP POLICY IF EXISTS p_case_worksheet_update ON app.case_worksheet; DROP POLICY IF EXISTS p_case_worksheet_delete ON app.case_worksheet; CREATE POLICY p_case_worksheet_select ON app.case_worksheet FOR SELECT USING ( app.current_role_name() IN ('admin','instructor') OR EXISTS ( SELECT 1 FROM app.sessions s WHERE s.id = app.case_worksheet.session_id AND s.learner_id = app.current_uid() ) ); CREATE POLICY p_case_worksheet_insert ON app.case_worksheet FOR INSERT WITH CHECK ( EXISTS ( SELECT 1 FROM app.sessions s WHERE s.id = app.case_worksheet.session_id AND s.learner_id = app.current_uid() ) ); CREATE POLICY p_case_worksheet_update ON app.case_worksheet FOR UPDATE USING ( EXISTS ( SELECT 1 FROM app.sessions s WHERE s.id = app.case_worksheet.session_id AND s.learner_id = app.current_uid() ) ) WITH CHECK ( EXISTS ( SELECT 1 FROM app.sessions s WHERE s.id = app.case_worksheet.session_id AND s.learner_id = app.current_uid() ) ); CREATE POLICY p_case_worksheet_delete ON app.case_worksheet FOR DELETE USING ( EXISTS ( SELECT 1 FROM app.sessions s WHERE s.id = app.case_worksheet.session_id AND s.learner_id = app.current_uid() ) ) """ ) await conn.execute( """ CREATE TABLE IF NOT EXISTS app.live_coach_events ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE CASCADE, turn_seq INT NOT NULL CHECK (turn_seq >= 1), stage TEXT NOT NULL, learner_text_excerpt TEXT, client_reply_excerpt TEXT, payload JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_live_coach_events_session_turn ON app.live_coach_events(session_id, turn_seq, created_at); ALTER TABLE app.live_coach_events ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS p_live_coach_events_select ON app.live_coach_events; DROP POLICY IF EXISTS p_live_coach_events_insert ON app.live_coach_events; CREATE POLICY p_live_coach_events_select ON app.live_coach_events FOR SELECT USING ( app.current_role_name() IN ('admin','instructor') OR EXISTS ( SELECT 1 FROM app.sessions s WHERE s.id = app.live_coach_events.session_id AND s.learner_id = app.current_uid() ) ); CREATE POLICY p_live_coach_events_insert ON app.live_coach_events FOR INSERT WITH CHECK ( EXISTS ( SELECT 1 FROM app.sessions s WHERE s.id = app.live_coach_events.session_id AND s.learner_id = app.current_uid() ) ) """ ) await conn.execute( """ CREATE TABLE IF NOT EXISTS app.session_review_status ( session_id UUID PRIMARY KEY REFERENCES app.sessions(id) ON DELETE CASCADE, reviewer_id UUID REFERENCES app.app_user(user_id), status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','viewed','closed')), note TEXT NOT NULL DEFAULT '', worksheet_status TEXT NOT NULL DEFAULT 'pending', worksheet_note TEXT NOT NULL DEFAULT '', worksheet_reviewed_at TIMESTAMPTZ, reviewed_at TIMESTAMPTZ, updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) """ ) await conn.execute( """ ALTER TABLE app.session_review_status ADD COLUMN IF NOT EXISTS worksheet_status TEXT NOT NULL DEFAULT 'pending'; ALTER TABLE app.session_review_status ADD COLUMN IF NOT EXISTS worksheet_note TEXT NOT NULL DEFAULT ''; ALTER TABLE app.session_review_status ADD COLUMN IF NOT EXISTS worksheet_reviewed_at TIMESTAMPTZ; """ ) await conn.execute( """ ALTER TABLE app.session_review_status ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS p_session_review_status_select ON app.session_review_status; DROP POLICY IF EXISTS p_session_review_status_insert ON app.session_review_status; DROP POLICY IF EXISTS p_session_review_status_update ON app.session_review_status; DROP POLICY IF EXISTS p_session_review_status_delete ON app.session_review_status; CREATE POLICY p_session_review_status_select ON app.session_review_status FOR SELECT USING ( app.current_role_name() IN ('admin','instructor') ); CREATE POLICY p_session_review_status_insert ON app.session_review_status FOR INSERT WITH CHECK ( app.current_role_name() IN ('admin','instructor') ); CREATE POLICY p_session_review_status_update ON app.session_review_status FOR UPDATE USING ( app.current_role_name() IN ('admin','instructor') ) WITH CHECK ( app.current_role_name() IN ('admin','instructor') ); CREATE POLICY p_session_review_status_delete ON app.session_review_status FOR DELETE USING ( app.current_role_name() IN ('admin','instructor') ) """ ) await conn.execute( """ CREATE TABLE IF NOT EXISTS app.session_share_link ( session_id UUID PRIMARY KEY REFERENCES app.sessions(id) ON DELETE CASCADE, created_by UUID NOT NULL REFERENCES app.app_user(user_id), token_hash TEXT NOT NULL UNIQUE, payload JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), revoked_at TIMESTAMPTZ ); CREATE INDEX IF NOT EXISTS idx_session_share_token_active ON app.session_share_link(token_hash) WHERE revoked_at IS NULL; ALTER TABLE app.session_share_link ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS p_session_share_select ON app.session_share_link; DROP POLICY IF EXISTS p_session_share_insert ON app.session_share_link; DROP POLICY IF EXISTS p_session_share_update ON app.session_share_link; DROP POLICY IF EXISTS p_session_share_delete ON app.session_share_link; CREATE POLICY p_session_share_select ON app.session_share_link FOR SELECT USING ( app.is_ai_context() OR app.current_role_name() IN ('admin','instructor') OR EXISTS ( SELECT 1 FROM app.sessions s WHERE s.id = app.session_share_link.session_id AND s.learner_id = app.current_uid() ) ); CREATE POLICY p_session_share_insert ON app.session_share_link FOR INSERT WITH CHECK ( app.is_ai_context() OR ( created_by = app.current_uid() AND EXISTS ( SELECT 1 FROM app.sessions s WHERE s.id = app.session_share_link.session_id AND s.learner_id = app.current_uid() ) ) ); CREATE POLICY p_session_share_update ON app.session_share_link FOR UPDATE USING ( app.is_ai_context() OR EXISTS ( SELECT 1 FROM app.sessions s WHERE s.id = app.session_share_link.session_id AND s.learner_id = app.current_uid() ) ) WITH CHECK ( app.is_ai_context() OR ( created_by = app.current_uid() AND EXISTS ( SELECT 1 FROM app.sessions s WHERE s.id = app.session_share_link.session_id AND s.learner_id = app.current_uid() ) ) ); CREATE POLICY p_session_share_delete ON app.session_share_link FOR DELETE USING ( app.is_ai_context() OR EXISTS ( SELECT 1 FROM app.sessions s WHERE s.id = app.session_share_link.session_id AND s.learner_id = app.current_uid() ) ); """ ) await conn.execute( """ CREATE TABLE IF NOT EXISTS app.session_archive_state ( session_id UUID PRIMARY KEY REFERENCES app.sessions(id) ON DELETE CASCADE, learner_id UUID NOT NULL REFERENCES app.app_user(user_id), archived_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_session_archive_learner ON app.session_archive_state(learner_id, archived_at DESC); ALTER TABLE app.session_archive_state ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS p_session_archive_select ON app.session_archive_state; DROP POLICY IF EXISTS p_session_archive_insert ON app.session_archive_state; DROP POLICY IF EXISTS p_session_archive_update ON app.session_archive_state; DROP POLICY IF EXISTS p_session_archive_delete ON app.session_archive_state; CREATE POLICY p_session_archive_select ON app.session_archive_state FOR SELECT USING ( app.current_role_name() IN ('admin','instructor') OR learner_id = app.current_uid() ); CREATE POLICY p_session_archive_insert ON app.session_archive_state FOR INSERT WITH CHECK ( learner_id = app.current_uid() AND EXISTS ( SELECT 1 FROM app.sessions s WHERE s.id = app.session_archive_state.session_id AND s.learner_id = app.current_uid() ) ); CREATE POLICY p_session_archive_update ON app.session_archive_state FOR UPDATE USING ( learner_id = app.current_uid() ) WITH CHECK ( learner_id = app.current_uid() AND EXISTS ( SELECT 1 FROM app.sessions s WHERE s.id = app.session_archive_state.session_id AND s.learner_id = app.current_uid() ) ); CREATE POLICY p_session_archive_delete ON app.session_archive_state FOR DELETE USING ( learner_id = app.current_uid() ) """ ) except Exception: return async def save_session_evaluation(write: SessionEvaluationWrite) -> bool: record = write.cache_record() if runtime_fallback_allowed(): _EVALUATION_CACHE[write.session_id] = record try: get_pool() async with acquire(role="learner", user_id=write.learner_id) as conn: await conn.execute( """ INSERT INTO app.session_evaluation ( session_id, status, source, scope, stage, payload, error, created_at, updated_at ) VALUES ($1::uuid, $2, $3, $4, $5, $6::jsonb, $7, now(), now()) ON CONFLICT (session_id) DO UPDATE SET status = EXCLUDED.status, source = EXCLUDED.source, scope = EXCLUDED.scope, stage = EXCLUDED.stage, payload = EXCLUDED.payload, error = EXCLUDED.error, updated_at = now() """, write.session_id, write.status, write.source, write.scope, write.stage, write.payload, write.error, ) return True except Exception: require_runtime_fallback_allowed("session evaluation") return False async def load_session_evaluation( session_id: str, principal: Principal, ) -> tuple[dict[str, Any] | None, bool]: try: get_pool() async with acquire( role=principal.role.value, user_id=principal.user_id, cohort_ids=principal.cohort_ids, ) as conn: row = await conn.fetchrow( """ SELECT status, source, scope, stage, payload, error, updated_at FROM app.session_evaluation WHERE session_id = $1::uuid """, session_id, ) if row is None: return ( _EVALUATION_CACHE.get(session_id) if runtime_fallback_allowed() else None ), False return { "status": row["status"], "source": row["source"], "scope": row["scope"], "stage": row["stage"], "payload": dict(row["payload"] or {}), "error": row["error"], "updated_at": _ts(row["updated_at"]), }, True except Exception: require_runtime_fallback_allowed("session evaluation") return _EVALUATION_CACHE.get(session_id), False async def save_case_worksheet( *, session_id: str, learner_id: str, payload: dict[str, Any], ) -> bool: if runtime_fallback_allowed(): _CASE_WORKSHEET_CACHE[session_id] = payload try: get_pool() async with acquire(role="learner", user_id=learner_id) as conn: await conn.execute( """ INSERT INTO app.case_worksheet (session_id, payload, created_at, updated_at) VALUES ($1::uuid, $2::jsonb, now(), now()) ON CONFLICT (session_id) DO UPDATE SET payload = EXCLUDED.payload, updated_at = now() """, session_id, payload, ) return True except Exception: require_runtime_fallback_allowed("case worksheet save") return False async def load_case_worksheet( session_id: str, principal: Principal, ) -> tuple[dict[str, Any] | None, bool]: try: get_pool() async with acquire( role=principal.role.value, user_id=principal.user_id, cohort_ids=principal.cohort_ids, ) as conn: row = await conn.fetchrow( """ SELECT payload, updated_at FROM app.case_worksheet WHERE session_id = $1::uuid """, session_id, ) if row is None: return ( _CASE_WORKSHEET_CACHE.get(session_id) if runtime_fallback_allowed() else None ), False payload = dict(row["payload"] or {}) payload.setdefault("savedAt", _iso_dt(row["updated_at"])) return payload, True except Exception: require_runtime_fallback_allowed("case worksheet load") return _CASE_WORKSHEET_CACHE.get(session_id), False async def save_live_coach_event( *, session_id: str, learner_id: str, turn_seq: int, stage: str, learner_text: str, client_reply: str | None, suggestion: Any, ) -> tuple[dict[str, Any] | None, bool]: record = _live_coach_cache_record( session_id=session_id, turn_seq=turn_seq, stage=stage, learner_text=learner_text, client_reply=client_reply, suggestion=suggestion, ) if runtime_fallback_allowed(): _LIVE_COACH_EVENT_CACHE.setdefault(session_id, []).append(record) try: get_pool() async with acquire(role="learner", user_id=learner_id) as conn: row = await conn.fetchrow( """ INSERT INTO app.live_coach_events ( session_id, turn_seq, stage, learner_text_excerpt, client_reply_excerpt, payload, created_at ) VALUES ($1::uuid, $2, $3, $4, $5, $6::jsonb, now()) RETURNING id, session_id, turn_seq, stage, learner_text_excerpt, client_reply_excerpt, payload, created_at """, session_id, int(turn_seq), stage, record.get("learner_text_excerpt"), record.get("client_reply_excerpt"), _model_payload(suggestion), ) return (_live_coach_event_from_row(row) if row else None), True except Exception: require_runtime_fallback_allowed("live coach event save") return record, False async def list_live_coach_events( session_id: str, principal: Principal, ) -> tuple[list[dict[str, Any]], bool]: try: get_pool() async with acquire( role=principal.role.value, user_id=principal.user_id, cohort_ids=principal.cohort_ids, ) as conn: rows = await conn.fetch( """ SELECT id, session_id, turn_seq, stage, learner_text_excerpt, client_reply_excerpt, payload, created_at FROM app.live_coach_events WHERE session_id = $1::uuid ORDER BY created_at ASC, turn_seq ASC """, session_id, ) return [_live_coach_event_from_row(row) for row in rows], True except Exception: require_runtime_fallback_allowed("live coach event list") return [dict(item) for item in _LIVE_COACH_EVENT_CACHE.get(session_id, [])], False def _review_status_from_row(row: Any) -> dict[str, Any]: return { "session_id": str(row["session_id"]), "reviewer_id": str(row["reviewer_id"] or ""), "status": str(row["status"] or "pending"), "note": str(row["note"] or ""), "worksheet_status": _worksheet_review_status(row["worksheet_status"]), "worksheet_note": str(row["worksheet_note"] or ""), "worksheet_reviewed_at": _iso_dt(row["worksheet_reviewed_at"]), "reviewed_at": _iso_dt(row["reviewed_at"]), "updated_at": _iso_dt(row["updated_at"]), } def _worksheet_review_status(value: object) -> str: raw = str(value or "pending") if raw in _WORKSHEET_REVIEW_STATUS_VALUES: return raw return "pending" def _review_status_cache_record( *, session_id: str, reviewer_id: str, status: str, note: str, worksheet_status: str | None = None, worksheet_note: str | None = None, ) -> dict[str, Any]: now = datetime.now(timezone.utc) previous = _SESSION_REVIEW_STATUS_CACHE.get(session_id) or {} reviewed_at = previous.get("reviewed_at") if status == "closed" and not reviewed_at: reviewed_at = now.isoformat().replace("+00:00", "Z") if status != "closed": reviewed_at = None worksheet_reviewed_at = previous.get("worksheet_reviewed_at") if worksheet_status is None: worksheet_status = _worksheet_review_status(previous.get("worksheet_status")) worksheet_note = str(previous.get("worksheet_note") or "") else: worksheet_status = _worksheet_review_status(worksheet_status) worksheet_note = str(worksheet_note or "").strip() if worksheet_status == "pending": worksheet_reviewed_at = None else: worksheet_reviewed_at = now.isoformat().replace("+00:00", "Z") return { "session_id": session_id, "reviewer_id": reviewer_id, "status": status, "note": note, "worksheet_status": worksheet_status, "worksheet_note": worksheet_note, "worksheet_reviewed_at": worksheet_reviewed_at, "reviewed_at": reviewed_at, "updated_at": now.isoformat().replace("+00:00", "Z"), } async def list_session_review_statuses( session_ids: list[str], principal: Principal, ) -> tuple[dict[str, dict[str, Any]], bool]: if not session_ids: return {}, True try: get_pool() async with acquire( role=principal.role.value, user_id=principal.user_id, cohort_ids=principal.cohort_ids, ) as conn: rows = await conn.fetch( """ SELECT session_id, reviewer_id, status, note, worksheet_status, worksheet_note, worksheet_reviewed_at, reviewed_at, updated_at FROM app.session_review_status WHERE session_id = ANY($1::uuid[]) """, session_ids, ) return { str(row["session_id"]): _review_status_from_row(row) for row in rows }, True except Exception: require_runtime_fallback_allowed("session review status list") return { session_id: dict(record) for session_id in session_ids if (record := _SESSION_REVIEW_STATUS_CACHE.get(session_id)) is not None }, False async def load_session_review_status( session_id: str, principal: Principal, ) -> tuple[dict[str, Any] | None, bool]: statuses, durable = await list_session_review_statuses([session_id], principal) return statuses.get(session_id), durable async def save_session_review_status( *, session_id: str, reviewer_id: str, status: str, note: str, principal: Principal, worksheet_status: str | None = None, worksheet_note: str | None = None, ) -> tuple[dict[str, Any] | None, bool]: note = note.strip() if worksheet_status is not None: worksheet_note = str(worksheet_note or "").strip() if runtime_fallback_allowed(): _SESSION_REVIEW_STATUS_CACHE[session_id] = _review_status_cache_record( session_id=session_id, reviewer_id=reviewer_id, status=status, note=note, worksheet_status=worksheet_status, worksheet_note=worksheet_note, ) try: get_pool() async with acquire( role=principal.role.value, user_id=principal.user_id, cohort_ids=principal.cohort_ids, ) as conn: row = await conn.fetchrow( """ INSERT INTO app.session_review_status ( session_id, reviewer_id, status, note, worksheet_status, worksheet_note, worksheet_reviewed_at, reviewed_at, updated_at ) VALUES ( $1::uuid, $2::uuid, $3, $4, COALESCE($5, 'pending'), CASE WHEN $5::text IS NULL THEN '' ELSE COALESCE($6, '') END, CASE WHEN $5::text IS NULL OR $5 = 'pending' THEN NULL ELSE now() END, CASE WHEN $3 = 'closed' THEN now() ELSE NULL END, now() ) ON CONFLICT (session_id) DO UPDATE SET reviewer_id = EXCLUDED.reviewer_id, status = EXCLUDED.status, note = EXCLUDED.note, worksheet_status = CASE WHEN $5::text IS NULL THEN app.session_review_status.worksheet_status ELSE $5 END, worksheet_note = CASE WHEN $5::text IS NULL THEN app.session_review_status.worksheet_note ELSE COALESCE($6, '') END, worksheet_reviewed_at = CASE WHEN $5::text IS NULL THEN app.session_review_status.worksheet_reviewed_at WHEN $5 = 'pending' THEN NULL ELSE now() END, reviewed_at = CASE WHEN EXCLUDED.status = 'closed' THEN COALESCE(app.session_review_status.reviewed_at, now()) ELSE NULL END, updated_at = now() RETURNING session_id, reviewer_id, status, note, worksheet_status, worksheet_note, worksheet_reviewed_at, reviewed_at, updated_at """, session_id, reviewer_id, status, note, worksheet_status, worksheet_note, ) return (_review_status_from_row(row) if row else None), True except Exception: require_runtime_fallback_allowed("session review status save") return _SESSION_REVIEW_STATUS_CACHE.get(session_id), False async def save_session_share( *, session_id: str, learner_id: str, token_hash: str, payload: dict[str, Any], ) -> dict[str, Any] | None: record = { "session_id": session_id, "payload": payload, "created_at": time.time(), "updated_at": time.time(), "revoked_at": None, } if runtime_fallback_allowed(): old = _SESSION_SHARE_CACHE.get(session_id) if old: _SESSION_SHARE_TOKEN_INDEX.pop(str(old.get("token_hash") or ""), None) _SESSION_SHARE_CACHE[session_id] = {**record, "token_hash": token_hash} _SESSION_SHARE_TOKEN_INDEX[token_hash] = session_id try: get_pool() async with acquire(role="learner", user_id=learner_id) as conn: row = await conn.fetchrow( """ INSERT INTO app.session_share_link ( session_id, created_by, token_hash, payload, created_at, updated_at, revoked_at ) VALUES ($1::uuid, $2::uuid, $3, $4::jsonb, now(), now(), NULL) ON CONFLICT (session_id) DO UPDATE SET created_by = EXCLUDED.created_by, token_hash = EXCLUDED.token_hash, payload = EXCLUDED.payload, updated_at = now(), revoked_at = NULL RETURNING session_id, payload, created_at, updated_at, revoked_at """, session_id, learner_id, token_hash, payload, ) return _share_record_from_row(row) if row is not None else None except Exception: require_runtime_fallback_allowed("session share save") return record async def revoke_session_share(*, session_id: str, learner_id: str) -> bool: if runtime_fallback_allowed(): old = _SESSION_SHARE_CACHE.pop(session_id, None) if old: _SESSION_SHARE_TOKEN_INDEX.pop(str(old.get("token_hash") or ""), None) try: get_pool() async with acquire(role="learner", user_id=learner_id) as conn: status = await conn.execute( """ UPDATE app.session_share_link SET revoked_at = now(), updated_at = now() WHERE session_id = $1::uuid """, session_id, ) return status != "UPDATE 0" except Exception: require_runtime_fallback_allowed("session share revoke") return True async def load_public_session_share(token_hash: str) -> dict[str, Any] | None: try: get_pool() async with acquire(ai_context=True) as conn: row = await conn.fetchrow( """ SELECT session_id, payload, created_at, updated_at, revoked_at FROM app.session_share_link WHERE token_hash = $1 AND revoked_at IS NULL """, token_hash, ) return _share_record_from_row(row) if row is not None else None except Exception: require_runtime_fallback_allowed("session share public load") session_id = _SESSION_SHARE_TOKEN_INDEX.get(token_hash) if not session_id: return None cached = _SESSION_SHARE_CACHE.get(session_id) if not cached or cached.get("revoked_at") is not None: return None return { "session_id": str(cached["session_id"]), "payload": dict(cached.get("payload") or {}), "created_at": cached.get("created_at"), "updated_at": cached.get("updated_at"), "revoked_at": cached.get("revoked_at"), } async def list_session_archives( session_ids: list[str], principal: Principal, ) -> tuple[dict[str, dict[str, Any]], bool]: """Return learner archive state keyed by session id.""" if not session_ids: return {}, True try: get_pool() async with acquire( role=principal.role.value, user_id=principal.user_id, cohort_ids=principal.cohort_ids, ) as conn: rows = await conn.fetch( """ SELECT session_id, learner_id, archived_at, updated_at FROM app.session_archive_state WHERE session_id = ANY($1::uuid[]) """, session_ids, ) return {str(row["session_id"]): _archive_record_from_row(row) for row in rows}, True except Exception: require_runtime_fallback_allowed("session archive list") return { session_id: dict(record) for session_id in session_ids if (record := _SESSION_ARCHIVE_CACHE.get(session_id)) is not None }, False async def set_session_archived( *, session_id: str, learner_id: str, archived: bool, ) -> tuple[dict[str, Any] | None, bool]: """Archive or restore a learner-owned session without deleting evidence.""" now = datetime.now(timezone.utc) cache_record = { "session_id": session_id, "learner_id": learner_id, "archived_at": now.timestamp(), "updated_at": now.timestamp(), } if runtime_fallback_allowed(): if archived: _SESSION_ARCHIVE_CACHE[session_id] = cache_record else: _SESSION_ARCHIVE_CACHE.pop(session_id, None) try: get_pool() async with acquire(role="learner", user_id=learner_id) as conn: if archived: row = await conn.fetchrow( """ INSERT INTO app.session_archive_state ( session_id, learner_id, archived_at, updated_at ) VALUES ($1::uuid, $2::uuid, now(), now()) ON CONFLICT (session_id) DO UPDATE SET learner_id = EXCLUDED.learner_id, archived_at = COALESCE(app.session_archive_state.archived_at, EXCLUDED.archived_at), updated_at = now() RETURNING session_id, learner_id, archived_at, updated_at """, session_id, learner_id, ) return _archive_record_from_row(row), True deleted = await conn.fetchrow( """ DELETE FROM app.session_archive_state WHERE session_id = $1::uuid AND learner_id = $2::uuid RETURNING session_id, learner_id, archived_at, updated_at """, session_id, learner_id, ) return (_archive_record_from_row(deleted) if deleted is not None else None), True except Exception: require_runtime_fallback_allowed("session archive update") return (_SESSION_ARCHIVE_CACHE.get(session_id) if archived else None), False def _session_from_rows(row, state_row, turn_rows: Iterable) -> InProcSession | None: card = _card_from_joined_session_row(row) if card is None: if not settings.allow_seed_persona_fallback: return None persona_code = (row["persona_code"] or "").upper() legacy_entry = seed_fallback_persona(persona_code) if legacy_entry is None: return None card = legacy_entry.card persona_code = card.code ended_at = _ts(row["ended_at"]) started_at = _ts(row["started_at"]) or time.time() return InProcSession( session_id=str(row["id"]), case_id=str(row["case_id"] or row["runtime_case_id"] or row["id"]), learner_id=str(row["learner_id"]), persona_code=persona_code, theory_mode=row["theory_mode"] or "humanistic", persona=card, state=_state_from_row(state_row, card), session_no=int(row["session_no"] or 1), created_at=started_at, ended_at=ended_at, turns=[_turn_from_row(turn_row) for turn_row in turn_rows], ended=ended_at is not None, prev_rapport_credit=float(row["prev_rapport_credit"] or 0.0), ) async def _upsert_state(conn, session_id: str, state: state_machine.SessionState) -> None: await conn.execute( """ INSERT INTO app.session_state ( session_id, stage, turn_seq, effective_openness, rapport_credit, resistance, ideation_stage, turns_in_stage, affect_state, updated_at ) VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, now()) ON CONFLICT (session_id) DO UPDATE SET stage = EXCLUDED.stage, turn_seq = EXCLUDED.turn_seq, effective_openness = EXCLUDED.effective_openness, rapport_credit = EXCLUDED.rapport_credit, resistance = EXCLUDED.resistance, ideation_stage = EXCLUDED.ideation_stage, turns_in_stage = EXCLUDED.turns_in_stage, affect_state = EXCLUDED.affect_state, updated_at = now() """, session_id, _stage(state.stage), state.turn_seq, state.effective_openness, state.rapport_credit, state.resistance, state.ideation_stage, state.turns_in_stage, state.affect_state, ) async def get_case_context( *, learner_id: str, persona_id: str, ) -> CaseContext | None: """Return the stable learner-persona case row, creating it when possible.""" try: get_pool() async with acquire(role="learner", user_id=learner_id) as conn: row = await conn.fetchrow( """ INSERT INTO app.case_profile (persona_id, learner_id) VALUES ($1::uuid, $2::uuid) ON CONFLICT (persona_id, learner_id) DO UPDATE SET updated_at = app.case_profile.updated_at RETURNING case_id, last_session_no """, persona_id, learner_id, ) return CaseContext( case_id=str(row["case_id"]), last_session_no=int(row["last_session_no"] or 0), ) except Exception: return None async def create_session( *, learner_id: str, card: PersonaCard, theory_mode: str, state: state_machine.SessionState, session_no: int = 1, carry_rapport: float = 0.0, persona_id: str | None = None, persona_version: int | None = None, case_id: str | None = None, ) -> InProcSession | None: """Create a DB-backed session, returning None when DB persistence is unavailable.""" try: get_pool() runtime_case_id = str(uuid.uuid4()) pinned_persona_id = persona_id or seed_persona_id(card.code) pinned_persona_version = persona_version or SEED_VERSION async with acquire(role="learner", user_id=learner_id) as conn: async with conn.transaction(): stable_case_id = case_id if stable_case_id is None: case_row = await conn.fetchrow( """ INSERT INTO app.case_profile (persona_id, learner_id) VALUES ($1::uuid, $2::uuid) ON CONFLICT (persona_id, learner_id) DO UPDATE SET updated_at = app.case_profile.updated_at RETURNING case_id, last_session_no """, pinned_persona_id, learner_id, ) stable_case_id = str(case_row["case_id"]) session_no = int(case_row["last_session_no"] or 0) + 1 counter_row = await conn.fetchrow( """ UPDATE app.case_profile SET last_session_no = GREATEST(last_session_no + 1, $2), updated_at = now() WHERE case_id = $1::uuid AND learner_id = $3::uuid RETURNING last_session_no """, stable_case_id, session_no, learner_id, ) if counter_row is not None: session_no = int(counter_row["last_session_no"] or session_no) row = await conn.fetchrow( """ INSERT INTO app.sessions ( runtime_case_id, case_id, learner_id, persona_id, persona_version, persona_code, persona_display_name, persona_difficulty, session_no, theory_mode, stage_path, prev_rapport_credit ) VALUES ( $1::uuid, $2::uuid, $3::uuid, $4::uuid, $5, $6, $7, $8, $9, $10, '[]'::jsonb, $11 ) RETURNING id, runtime_case_id, case_id, learner_id, persona_code, session_no, theory_mode, started_at, ended_at, prev_rapport_credit """, runtime_case_id, stable_case_id, learner_id, pinned_persona_id, pinned_persona_version, card.code, card.display_name, card.difficulty, session_no, theory_mode, carry_rapport, ) await _upsert_state(conn, str(row["id"]), state) return InProcSession( session_id=str(row["id"]), case_id=str(row["case_id"] or row["runtime_case_id"] or row["id"]), learner_id=learner_id, persona_code=card.code, theory_mode=theory_mode, persona=card, state=state, session_no=int(row["session_no"] or session_no), created_at=_ts(row["started_at"]) or time.time(), ended_at=None, turns=[], ended=False, prev_rapport_credit=carry_rapport, ) except Exception: require_runtime_fallback_allowed("session creation") return None async def load_session( session_id: str, principal: Principal, *, allow_ended: bool = False, include_turn_evaluation: bool = False, ) -> InProcSession | None: try: get_pool() async with acquire( role=principal.role.value, user_id=principal.user_id, cohort_ids=principal.cohort_ids, ) as conn: row = await conn.fetchrow( """ SELECT s.id, s.runtime_case_id, s.case_id, s.learner_id, s.persona_code, s.session_no, s.theory_mode, s.started_at, s.ended_at, s.prev_rapport_credit, pc.persona_id AS card_persona_id, pc.code AS card_code, pc.version AS card_version, pc.status AS card_status, pc.display_name AS card_display_name, pc.difficulty AS card_difficulty, pc.theory_target AS card_theory_target, pc.demographics AS card_demographics, pc.presenting AS card_presenting, pc.history AS card_history, pc.big5 AS card_big5, pc.resistance AS card_resistance, pc.speech_style AS card_speech_style, pc.affect_baseline AS card_affect_baseline, pc.ccd AS card_ccd, pc.dsm5_dimensional AS card_dsm5_dimensional, pc.triggers AS card_triggers, pc.source_provenance AS card_source_provenance, pc.is_synthetic AS card_is_synthetic FROM app.sessions s LEFT JOIN app.persona_card pc ON pc.persona_id = s.persona_id AND pc.version = s.persona_version WHERE s.id = $1::uuid """, session_id, ) if row is None: return None if row["ended_at"] is not None and not allow_ended: return None state_row = await conn.fetchrow( """ SELECT stage, turn_seq, effective_openness, rapport_credit, resistance, ideation_stage, turns_in_stage, affect_state FROM app.session_state WHERE session_id = $1::uuid """, session_id, ) turn_rows = await conn.fetch( """ SELECT id, seq, speaker, stage, text, text_masked, created_at, llm_provider, model, tokens_in, tokens_out, cost_usd, audio_ref, silence_ms, speech_rate, barge_in, provider_events, visible_to FROM app.turns WHERE session_id = $1::uuid ORDER BY seq """, session_id, ) sess = _session_from_rows(row, state_row, turn_rows) if sess is not None: await _record_session_read_audit( conn, principal, target_kind="session", target_id=session_id, detail={ "access": "load_session", "role": principal.role.value, "learner_id": sess.learner_id, }, ) if sess is not None and include_turn_evaluation: await _hydrate_session_turn_evaluations(sess) return sess except Exception: require_runtime_fallback_allowed("session load") return None async def append_turn( *, session_id: str, learner_id: str, turn: TurnRecord, ) -> bool: try: get_pool() async with acquire(role="learner", user_id=learner_id) as conn: locked = await conn.fetchval( "SELECT id FROM app.sessions WHERE id = $1::uuid FOR UPDATE", session_id, ) if locked is None: return False seq = int( await conn.fetchval( "SELECT COALESCE(MAX(seq), 0) + 1 FROM app.turns WHERE session_id = $1::uuid", session_id, ) or 1 ) inserted_turn_id = await conn.fetchval( """ INSERT INTO app.turns ( session_id, seq, speaker, stage, text, text_masked, actor_kind, llm_provider, model, tokens_in, tokens_out, cost_usd, audio_ref, silence_ms, speech_rate, barge_in, provider_events, visible_to ) VALUES ( $1::uuid, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17::jsonb, $18::text[] ) ON CONFLICT (session_id, seq) DO NOTHING RETURNING id """, session_id, seq, turn.speaker, turn.stage, turn.text_masked, turn.text_masked, "human_learner" if turn.speaker == "counselor" else "client_ai", turn.llm_provider, turn.model, turn.tokens_in, turn.tokens_out, turn.cost_usd, turn.audio_ref, turn.silence_ms, turn.speech_rate, turn.barge_in, turn.provider_events or [], list(turn.visible_to or DEFAULT_TURN_VISIBLE_TO), ) if inserted_turn_id is None: return False turn.turn_id = str(inserted_turn_id) # 원시 평가 row는 학습자 축어록이 아니라 evaluator 전용 데이터다. await _persist_turn_evaluation(conn, turn.turn_id, turn.evaluation) return True except Exception: require_runtime_fallback_allowed("session turn append") return False async def update_state( *, session_id: str, learner_id: str, state: state_machine.SessionState, ) -> bool: try: get_pool() async with acquire(role="learner", user_id=learner_id) as conn: await _upsert_state(conn, session_id, state) return True except Exception: require_runtime_fallback_allowed("session state update") return False async def _insert_pinned_fact_history( conn: Any, *, fact_id: Any, case_id: str, old_value: Any, new_value: Any, reason: str, session_no: int, turn_id: str | None, ) -> None: await conn.execute( """ INSERT INTO app.pinned_fact_history ( fact_id, case_id, old_value, new_value, reason, session_no, turn_id ) VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6, $7::uuid) """, fact_id, case_id, old_value, new_value, reason, session_no, turn_id, ) async def _upsert_pinned_fact_candidates(conn: Any, sess: InProcSession) -> None: turn_rows = [ { "speaker": turn.speaker, "text": turn.text_masked, "turn_id": turn.turn_id, } for turn in sess.turns_visible_to("client") ] candidates = memory.extract_pinned_fact_candidates(turn_rows) for fact in candidates: if fact.status == "contradicted": row = await conn.fetchrow( """ WITH existing AS ( SELECT id, value FROM app.pinned_fact WHERE case_id = $1::uuid AND key = $2 AND status <> 'locked' FOR UPDATE ), updated AS ( UPDATE app.pinned_fact SET value = $3, fact_type = $4, status = 'contradicted', source_turn = COALESCE($5::uuid, app.pinned_fact.source_turn), confidence = GREATEST(app.pinned_fact.confidence, $6), version = app.pinned_fact.version + 1, updated_session_no = $7, visible_to = $8::text[], updated_at = now() FROM existing WHERE app.pinned_fact.id = existing.id RETURNING app.pinned_fact.id, existing.value AS old_value, app.pinned_fact.value AS new_value ) SELECT id, old_value, new_value FROM updated """, sess.case_id, fact.key, fact.value, fact.fact_type, fact.source_turn_id, fact.confidence, sess.session_no, ["evaluator"], ) if not row: continue old_value = row["old_value"] new_value = row["new_value"] if old_value == new_value: continue await _insert_pinned_fact_history( conn, fact_id=row["id"], case_id=sess.case_id, old_value=old_value, new_value=new_value, reason="contradiction", session_no=sess.session_no, turn_id=fact.source_turn_id, ) continue row = await conn.fetchrow( """ WITH existing AS ( SELECT id, value, status FROM app.pinned_fact WHERE case_id = $1::uuid AND key = $2 FOR UPDATE ), upserted AS ( INSERT INTO app.pinned_fact ( case_id, key, value, fact_type, status, source_turn, confidence, updated_session_no, visible_to, updated_at ) VALUES ( $1::uuid, $2, $3, $4, $5, $6::uuid, $7, $8, $9::text[], now() ) ON CONFLICT (case_id, key) DO UPDATE SET value = EXCLUDED.value, fact_type = EXCLUDED.fact_type, status = EXCLUDED.status, source_turn = COALESCE(EXCLUDED.source_turn, app.pinned_fact.source_turn), confidence = GREATEST(app.pinned_fact.confidence, EXCLUDED.confidence), version = CASE WHEN app.pinned_fact.value IS DISTINCT FROM EXCLUDED.value THEN app.pinned_fact.version + 1 ELSE app.pinned_fact.version END, updated_session_no = EXCLUDED.updated_session_no, visible_to = EXCLUDED.visible_to, updated_at = now() WHERE app.pinned_fact.status <> 'locked' RETURNING app.pinned_fact.id, (SELECT value FROM existing) AS old_value, app.pinned_fact.value AS new_value ) SELECT id, old_value, new_value FROM upserted """, sess.case_id, fact.key, fact.value, fact.fact_type, fact.status, fact.source_turn_id, fact.confidence, sess.session_no, ["client", "evaluator"], ) if not row: continue old_value = row["old_value"] new_value = row["new_value"] if old_value is not None and old_value == new_value: continue reason = "progression" if old_value is None else "clarification" await _insert_pinned_fact_history( conn, fact_id=row["id"], case_id=sess.case_id, old_value=old_value, new_value=new_value, reason=reason, session_no=sess.session_no, turn_id=fact.source_turn_id, ) def _build_session_summary_write(sess: InProcSession, carry: memory.CarryOver) -> SessionSummaryWrite: digest_input = memory.build_session_digest_input( session_id=sess.session_id, case_id=sess.case_id, session_no=sess.session_no, masked_turns=sess.masked_turns(visible_to="client"), open_threads=carry.compression_job.open_threads if carry.compression_job else [], ) digest_result = memory.build_fallback_digest_result(digest_input, end_state=carry.end_state) return SessionSummaryWrite( session_id=sess.session_id, case_id=sess.case_id, session_no=sess.session_no, end_state=carry.end_state, rapport_delta=carry.rapport_delta, digest=digest_result.digest, open_threads=list(digest_result.open_threads), ) async def end_session(sess: InProcSession, carry: memory.CarryOver) -> bool: try: get_pool() summary_write = _build_session_summary_write(sess, carry) async with acquire(role="learner", user_id=sess.learner_id) as conn: await conn.execute( """ UPDATE app.sessions SET ended_at = COALESCE(ended_at, now()) WHERE id = $1::uuid """, sess.session_id, ) await _upsert_state(conn, sess.session_id, sess.state) await conn.execute( """ INSERT INTO app.session_summary ( session_id, case_id, session_no, end_state, rapport_delta, digest, open_threads, created_at ) VALUES ($1::uuid, $2::uuid, $3, $4::jsonb, $5, $6, $7::jsonb, now()) ON CONFLICT (session_id) DO UPDATE SET end_state = EXCLUDED.end_state, rapport_delta = EXCLUDED.rapport_delta, digest = EXCLUDED.digest, open_threads = EXCLUDED.open_threads, compressed_by = NULL, token_count = NULL """, summary_write.session_id, summary_write.case_id, summary_write.session_no, summary_write.end_state, summary_write.rapport_delta, summary_write.digest, summary_write.open_threads, ) case_row = await conn.fetchrow( """ SELECT case_digest, rapport_trajectory, alliance_level FROM app.case_profile WHERE case_id = $1::uuid AND learner_id = $2::uuid """, sess.case_id, sess.learner_id, ) if case_row is not None: trajectory_point = memory.rapport_trajectory_point( session_no=sess.session_no, end_state=carry.end_state, ) case_digest = memory.merge_case_digest( existing_digest=case_row["case_digest"], session_no=sess.session_no, session_digest=summary_write.digest, ) rapport_trajectory = memory.merge_rapport_trajectory( case_row["rapport_trajectory"], trajectory_point, ) alliance_level = memory.update_alliance_level( case_row["alliance_level"], trajectory_point.get("end_rapport"), ) await conn.execute( """ UPDATE app.case_profile SET case_digest = $3, rapport_trajectory = $4::jsonb, alliance_level = $5, updated_at = now() WHERE case_id = $1::uuid AND learner_id = $2::uuid """, sess.case_id, sess.learner_id, case_digest, rapport_trajectory, alliance_level, ) await _upsert_pinned_fact_candidates(conn, sess) return True except Exception: require_runtime_fallback_allowed("session end") return False async def list_sessions( principal: Principal, *, include_turn_evaluation: bool = False, ) -> tuple[list[InProcSession], bool]: try: get_pool() learner_filter = "WHERE s.learner_id = $1::uuid" if principal.role.value == "learner" else "" query_args = [principal.user_id] if principal.role.value == "learner" else [] async with acquire( role=principal.role.value, user_id=principal.user_id, cohort_ids=principal.cohort_ids, ) as conn: rows = await conn.fetch( f""" SELECT s.id, s.runtime_case_id, s.case_id, s.learner_id, s.persona_code, s.session_no, s.theory_mode, s.started_at, s.ended_at, s.prev_rapport_credit, pc.persona_id AS card_persona_id, pc.code AS card_code, pc.version AS card_version, pc.status AS card_status, pc.display_name AS card_display_name, pc.difficulty AS card_difficulty, pc.theory_target AS card_theory_target, pc.demographics AS card_demographics, pc.presenting AS card_presenting, pc.history AS card_history, pc.big5 AS card_big5, pc.resistance AS card_resistance, pc.speech_style AS card_speech_style, pc.affect_baseline AS card_affect_baseline, pc.ccd AS card_ccd, pc.dsm5_dimensional AS card_dsm5_dimensional, pc.triggers AS card_triggers, pc.source_provenance AS card_source_provenance, pc.is_synthetic AS card_is_synthetic FROM app.sessions s LEFT JOIN app.persona_card pc ON pc.persona_id = s.persona_id AND pc.version = s.persona_version {learner_filter} ORDER BY s.started_at DESC LIMIT 100 """, *query_args, ) sessions: list[InProcSession] = [] for row in rows: session_id = str(row["id"]) state_row = await conn.fetchrow( """ SELECT stage, turn_seq, effective_openness, rapport_credit, resistance, ideation_stage, turns_in_stage, affect_state FROM app.session_state WHERE session_id = $1::uuid """, session_id, ) turn_rows = await conn.fetch( """ SELECT id, seq, speaker, stage, text, text_masked, created_at, llm_provider, model, tokens_in, tokens_out, cost_usd, audio_ref, silence_ms, speech_rate, barge_in, provider_events, visible_to FROM app.turns WHERE session_id = $1::uuid ORDER BY seq """, session_id, ) sess = _session_from_rows(row, state_row, turn_rows) if sess is not None: if include_turn_evaluation: await _hydrate_session_turn_evaluations(sess) sessions.append(sess) await _record_session_read_audit( conn, principal, target_kind="session_list", target_id="sessions", detail={ "access": "list_sessions", "role": principal.role.value, "result_count": len(sessions), }, ) return sessions, True except Exception: require_runtime_fallback_allowed("session list") return [], False async def list_safety_alerts( principal: Principal, *, limit: int = 20, ) -> tuple[list[dict[str, Any]], bool]: """Teacher/admin-visible crisis alerts from app.safety_events.""" try: get_pool() async with acquire( role=principal.role.value, user_id=principal.user_id, cohort_ids=principal.cohort_ids, ) as conn: rows = await conn.fetch( """ SELECT se.id, se.session_id, se.trigger_type, se.ko_risk_level, se.escalated, se.detail, se.created_at, s.learner_id, s.persona_code, s.session_no FROM app.safety_events se LEFT JOIN app.sessions s ON s.id = se.session_id WHERE se.escalated = TRUE ORDER BY se.created_at DESC LIMIT $1 """, limit, ) return [ { "id": str(row["id"]), "session_id": str(row["session_id"]), "learner_id": str(row["learner_id"] or ""), "learner_label": _learner_label_from_id(str(row["learner_id"] or "")), "persona_code": str(row["persona_code"] or ""), "session_no": int(row["session_no"] or 0), "trigger_type": str(row["trigger_type"] or "crisis"), "ko_risk_level": int(row["ko_risk_level"] or 0), "escalated": bool(row["escalated"]), "detail": dict(row["detail"] or {}), "created_at": _iso_dt(row["created_at"]), } for row in rows ], True except Exception: require_runtime_fallback_allowed("safety alert list") return [], False def _learner_label_from_id(learner_id: str) -> str: suffix = learner_id[-6:] if len(learner_id) > 6 else learner_id return f"학습자 {suffix}" if suffix else "학습자" def _iso_dt(value: datetime | None) -> str: if value is None: return "" return value.astimezone(timezone.utc).isoformat()