vignette/apps/api/app/session_persistence.py
Yun Chan 085460b5e0 대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·misconduct 반응 + 게이트웨이 격리·RAG 비차단 수정
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
2026-06-27 02:30:46 +09:00

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