2578 lines
92 KiB
Python
2578 lines
92 KiB
Python
"""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,
|
|
}
|
|
|
|
|
|
def _coerce_error_message(error: BaseException | str) -> str:
|
|
if isinstance(error, BaseException):
|
|
message = str(error).strip()
|
|
return message or error.__class__.__name__
|
|
return str(error).strip() or "unknown session evaluation error"
|
|
|
|
|
|
@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=_coerce_error_message(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:
|
|
cached = _EVALUATION_CACHE.get(session_id) if runtime_fallback_allowed() else None
|
|
return cached, cached is None
|
|
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()
|