Stabilize runtime auth and E2E coverage
This commit is contained in:
parent
6a3e3b541c
commit
188e899394
133 changed files with 55987 additions and 6775 deletions
656
apps/api/app/session_persistence.py
Normal file
656
apps/api/app/session_persistence.py
Normal file
|
|
@ -0,0 +1,656 @@
|
|||
"""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 InProcSession, TurnRecord
|
||||
|
||||
_EVALUATION_CACHE: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
_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))
|
||||
|
||||
|
||||
def _state_from_row(row, card: PersonaCard) -> state_machine.SessionState:
|
||||
if row is None:
|
||||
return state_machine.init_state(
|
||||
base_resistance=card.base_resistance(),
|
||||
unlock_rate=card.unlock_rate(),
|
||||
decay_floor=card.decay_floor(),
|
||||
ideation_baseline=card.ideation_baseline(),
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
FROM app.turns
|
||||
WHERE session_id = $1::uuid
|
||||
ORDER BY seq
|
||||
""",
|
||||
session_id,
|
||||
)
|
||||
return _session_from_rows(row, state_row, turn_rows)
|
||||
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, visible_to
|
||||
)
|
||||
VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8::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",
|
||||
["client", "counselor", "evaluator"],
|
||||
)
|
||||
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
|
||||
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)
|
||||
return sessions, True
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("session list")
|
||||
return [], False
|
||||
Loading…
Add table
Add a link
Reference in a new issue