SSOT 대시보드:
- 한신대 기술분석 PDF(19쪽) 정합성 분석 + 이번 세션 발견 섹션 추가
- 섹션 폴드아웃(접기)·상단 목차(드릴다운)·모두 펼치기/접기 — 내용 보존, 레이아웃만 정리
페르소나 반응 강화('저항·반응 조절' 핵심 차별):
- PersonaCard.triggers(역린) 필드 + CCD 핵심상처 파생 역린 블록
- L0에 무례·모욕·조롱 시 현실적 동맹 균열 반응 지침
버그·성능 수정(라이브/E2E로 포착):
- 게이트웨이 페르소나 격리: --append-system-prompt를 --system-prompt(교체)로 + --exclude-dynamic-system-prompt-sections (내담자 캐릭터 붕괴·개발맥락 누출 차단)
- RAG: 임베더 동기 로드(약 7-13초)를 _warm_rag_caches 백그라운드 warm으로(세션 생성 블로킹 회귀 수정)
- voice TTS RMS 데드힌트 제거, init_state OpennessParams 파라미터객체화
- 한국어 PII(날짜·금액·주소) 마스킹 보강
- 레이아웃 시각 게이트: 폼 컨트롤 값 스크롤 오탐 제외(7/7)
검증: 백엔드 84/84, E2E 42(데스크톱 27·모바일 11·아바타 4), 시각 게이트 7/7
732 lines
26 KiB
Python
732 lines
26 KiB
Python
"""DB-backed counseling session persistence with in-process fallback support."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Iterable
|
|
|
|
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 memory, state_machine
|
|
from .services.persona import PersonaCard
|
|
from .store import DEFAULT_TURN_VISIBLE_TO, InProcSession, TurnRecord
|
|
|
|
_EVALUATION_CACHE: dict[str, dict[str, Any]] = {}
|
|
_SESSION_AUDIT_ROLES = {"teacher", "admin"}
|
|
|
|
|
|
_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_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 _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))
|
|
|
|
|
|
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,
|
|
)
|
|
|
|
|
|
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) -> 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 "",
|
|
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"),
|
|
visible_to=tuple(_row_value(row, "visible_to") or DEFAULT_TURN_VISIBLE_TO),
|
|
)
|
|
|
|
|
|
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()
|
|
)
|
|
)
|
|
"""
|
|
)
|
|
except Exception:
|
|
return
|
|
|
|
|
|
async def save_session_evaluation(
|
|
*,
|
|
session_id: str,
|
|
learner_id: str,
|
|
status: str,
|
|
source: str,
|
|
scope: str,
|
|
stage: str,
|
|
payload: dict[str, Any],
|
|
error: str | None = None,
|
|
) -> bool:
|
|
record = {
|
|
"status": status,
|
|
"source": source,
|
|
"scope": scope,
|
|
"stage": stage,
|
|
"payload": payload,
|
|
"error": error,
|
|
}
|
|
if runtime_fallback_allowed():
|
|
_EVALUATION_CACHE[session_id] = record
|
|
try:
|
|
get_pool()
|
|
async with acquire(role="learner", user_id=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()
|
|
""",
|
|
session_id,
|
|
status,
|
|
source,
|
|
scope,
|
|
stage,
|
|
payload,
|
|
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
|
|
|
|
|
|
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["runtime_case_id"] or row["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 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,
|
|
) -> 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:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
INSERT INTO app.sessions (
|
|
runtime_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,
|
|
$5, $6, $7,
|
|
$8, $9, '[]'::jsonb, $10
|
|
)
|
|
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,
|
|
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=runtime_case_id,
|
|
learner_id=learner_id,
|
|
persona_code=card.code,
|
|
theory_mode=theory_mode,
|
|
persona=card,
|
|
state=state,
|
|
session_no=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,
|
|
) -> 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.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 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, 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,
|
|
},
|
|
)
|
|
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
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
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, visible_to
|
|
)
|
|
VALUES (
|
|
$1::uuid, $2, $3, $4, $5, $6, $7,
|
|
$8, $9, $10, $11, $12,
|
|
$13, $14, $15, $16, $17::text[]
|
|
)
|
|
ON CONFLICT (session_id, seq) DO NOTHING
|
|
""",
|
|
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,
|
|
list(turn.visible_to or DEFAULT_TURN_VISIBLE_TO),
|
|
)
|
|
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 end_session(sess: InProcSession, carry: memory.CarryOver) -> bool:
|
|
try:
|
|
get_pool()
|
|
digest = (
|
|
f"회기 축어록 {len(sess.turns)}개가 저장되었습니다. 정밀 리뷰는 생성 대기 중입니다."
|
|
if sess.turns
|
|
else "실제 발화가 없어 요약을 생성하지 않았습니다."
|
|
)
|
|
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
|
|
""",
|
|
sess.session_id,
|
|
sess.case_id,
|
|
sess.session_no,
|
|
carry.end_state,
|
|
carry.rapport_delta,
|
|
digest,
|
|
list(carry.compression_job.open_threads if carry.compression_job else []),
|
|
)
|
|
return True
|
|
except Exception:
|
|
require_runtime_fallback_allowed("session end")
|
|
return False
|
|
|
|
|
|
async def list_sessions(principal: Principal) -> 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.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 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, 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:
|
|
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
|