회기 연속성과 멀티 케이스 계약을 영속화

This commit is contained in:
Yun Chan 2026-09-01 11:45:16 +09:00
parent be08c0b573
commit 72353ecd82
26 changed files with 2170 additions and 127 deletions

View file

@ -9,7 +9,7 @@ import time
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Iterable, Protocol
from typing import Any, Awaitable, Callable, Iterable, Literal, Protocol
from .db import acquire, get_pool
from .deps import Principal
@ -38,6 +38,22 @@ class SessionCreationPersistenceError(RuntimeError):
"""fail-closed 환경에서 영속 세션 생성이 실패했다."""
class ActiveSessionExistsError(RuntimeError):
"""같은 learner-persona 전체에 미종료 회기가 이미 존재한다."""
def __init__(self, session_id: str) -> None:
self.session_id = session_id
super().__init__("active_session_exists")
class CaseNotFoundError(RuntimeError):
"""선택한 연속 사례가 이 learner-persona에 존재하지 않는다."""
class CaseProgressUnavailableError(RuntimeError):
"""DB 전체 집계가 필요한 사례 진행 수치를 안전하게 읽지 못했다."""
_EVALUATION_CACHE: dict[str, dict[str, Any]] = {}
_CASE_WORKSHEET_CACHE: dict[str, dict[str, Any]] = {}
_SESSION_REVIEW_STATUS_CACHE: dict[str, dict[str, Any]] = {}
@ -1768,7 +1784,9 @@ async def save_session_evaluation(write: SessionEvaluationWrite) -> bool:
)
record = write.cache_record()
if runtime_fallback_allowed():
_EVALUATION_CACHE[write.session_id] = record
existing = _EVALUATION_CACHE.get(write.session_id)
if _should_replace_evaluation_record(existing, record):
_EVALUATION_CACHE[write.session_id] = record
try:
get_pool()
async with acquire(role="learner", user_id=write.learner_id) as conn:
@ -1787,6 +1805,8 @@ async def save_session_evaluation(write: SessionEvaluationWrite) -> bool:
payload = EXCLUDED.payload,
error = EXCLUDED.error,
updated_at = now()
WHERE app.session_evaluation.status <> 'ready'
OR EXCLUDED.status = 'ready'
""",
write.session_id,
write.status,
@ -2731,22 +2751,45 @@ async def get_case_context(
*,
learner_id: str,
persona_id: str,
case_id: str | None = None,
) -> CaseContext | None:
"""Return the stable learner-persona case row, creating it when possible."""
"""Return one owned continuation case without creating or mutating a row.
Legacy callers without ``case_id`` receive the most recently active case.
Fresh starts must never call this helper: their case row is created only after
the global active-session check inside ``create_session`` succeeds.
"""
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,
)
if case_id:
row = await conn.fetchrow(
"""
SELECT case_id, last_session_no
FROM app.case_profile
WHERE case_id = $1::uuid
AND persona_id = $2::uuid
AND learner_id = $3::uuid
""",
case_id,
persona_id,
learner_id,
)
else:
row = await conn.fetchrow(
"""
SELECT case_id, last_session_no
FROM app.case_profile
WHERE persona_id = $1::uuid
AND learner_id = $2::uuid
ORDER BY updated_at DESC, case_id DESC
LIMIT 1
""",
persona_id,
learner_id,
)
if row is None:
return None
return CaseContext(
case_id=str(row["case_id"]),
last_session_no=int(row["last_session_no"] or 0),
@ -2755,6 +2798,97 @@ async def get_case_context(
return None
async def list_case_summaries(
*,
learner_id: str,
persona_id: str,
) -> list[dict[str, Any]]:
"""Read complete case-scoped progress without the recent-session list cap.
Duration and turn counts deliberately use different CTEs so joining turns
cannot multiply the duration of a session. This is DB-only: returning a
runtime approximation would make the learner choose a continuation on false
progress data.
"""
try:
get_pool()
async with acquire(role="learner", user_id=learner_id) as conn:
rows = await conn.fetch(
"""
WITH scoped_cases AS (
SELECT cp.case_id, cp.last_session_no, cp.updated_at
FROM app.case_profile AS cp
WHERE cp.learner_id = $1::uuid
AND cp.persona_id = $2::uuid
),
session_stats AS (
SELECT
s.case_id,
count(*)::int AS total_sessions,
count(*) FILTER (WHERE s.ended_at IS NOT NULL)::int AS completed_sessions,
COALESCE(
sum(
GREATEST(
0,
EXTRACT(EPOCH FROM (COALESCE(s.ended_at, now()) - s.started_at))
)
),
0
)::bigint AS total_duration_seconds,
max(COALESCE(s.ended_at, s.started_at)) AS last_activity_at
FROM app.sessions AS s
JOIN scoped_cases AS c ON c.case_id = s.case_id
GROUP BY s.case_id
),
turn_stats AS (
SELECT s.case_id, count(t.id)::int AS total_turns
FROM app.sessions AS s
JOIN scoped_cases AS c ON c.case_id = s.case_id
LEFT JOIN app.turns AS t
ON t.session_id = s.id
AND 'counselor' = ANY(t.visible_to)
GROUP BY s.case_id
),
active_session AS (
SELECT DISTINCT ON (s.case_id)
s.case_id,
s.id AS active_session_id,
s.session_no AS active_session_no,
s.started_at AS active_started_at
FROM app.sessions AS s
JOIN scoped_cases AS c ON c.case_id = s.case_id
WHERE s.ended_at IS NULL
ORDER BY s.case_id, s.started_at DESC, s.id DESC
)
SELECT
c.case_id,
c.last_session_no,
COALESCE(ss.total_sessions, 0)::int AS total_sessions,
COALESCE(ss.completed_sessions, 0)::int AS completed_sessions,
COALESCE(ts.total_turns, 0)::int AS total_turns,
COALESCE(ss.total_duration_seconds, 0)::bigint AS total_duration_seconds,
active.active_session_id,
active.active_session_no,
active.active_started_at,
ss.last_activity_at
FROM scoped_cases AS c
LEFT JOIN session_stats AS ss USING (case_id)
LEFT JOIN turn_stats AS ts USING (case_id)
LEFT JOIN active_session AS active USING (case_id)
ORDER BY COALESCE(ss.last_activity_at, c.updated_at) DESC, c.case_id DESC
""",
learner_id,
persona_id,
)
return [dict(row) for row in rows]
except Exception as exc:
logger.exception(
"case progress read failed",
extra={"learner_id": learner_id, "persona_id": persona_id},
)
raise CaseProgressUnavailableError("case_progress_unavailable") from exc
async def create_session(
*,
learner_id: str,
@ -2766,10 +2900,25 @@ async def create_session(
persona_id: str | None = None,
persona_version: int | None = None,
case_id: str | None = None,
start_mode: Literal["continue", "fresh"] = "continue",
goal_stages: list[str] | None = None,
learner_feedback_enabled: bool = True,
locked_state_factory: (
Callable[[str, int], Awaitable[state_machine.SessionState]] | None
) = None,
) -> InProcSession | None:
"""Create a DB-backed session, returning None when DB persistence is unavailable."""
"""Create a DB-backed session, returning None when DB persistence is unavailable.
A fresh start creates a new case only after the global learner-persona active
session guard passes. A continuation selects the requested owned case (or the
most recent legacy case) in the same transaction. ``locked_state_factory``
therefore sees either the committed selected case or the empty new case, never
a cross-case or in-between memory state.
"""
if start_mode not in ("continue", "fresh"):
raise ValueError(f"unsupported session start mode: {start_mode}")
if start_mode == "fresh" and case_id is not None:
raise ValueError("fresh start must not select an existing case")
try:
get_pool()
runtime_case_id = str(uuid.uuid4())
@ -2777,21 +2926,86 @@ async def create_session(
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:
# A case row cannot serialize two simultaneous first/fresh starts,
# so lock the learner-persona scope before inspecting active rows.
await conn.execute(
"SELECT pg_advisory_xact_lock(hashtextextended($1, 0))",
f"{learner_id}:{pinned_persona_id}",
)
active_row = await conn.fetchrow(
"""
SELECT id
FROM app.sessions
WHERE learner_id = $1::uuid
AND persona_id = $2::uuid
AND ended_at IS NULL
LIMIT 1
FOR UPDATE
""",
learner_id,
pinned_persona_id,
)
if active_row is not None:
raise ActiveSessionExistsError(str(active_row["id"]))
stable_case_id: str
if start_mode == "fresh":
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 = 1
elif case_id is not None:
case_row = await conn.fetchrow(
"""
SELECT case_id, last_session_no
FROM app.case_profile
WHERE case_id = $1::uuid
AND learner_id = $2::uuid
AND persona_id = $3::uuid
FOR UPDATE
""",
case_id,
learner_id,
pinned_persona_id,
)
if case_row is None:
raise CaseNotFoundError("case_not_found")
stable_case_id = str(case_row["case_id"])
session_no = int(case_row["last_session_no"] or 0) + 1
else:
case_row = await conn.fetchrow(
"""
SELECT case_id, last_session_no
FROM app.case_profile
WHERE persona_id = $1::uuid
AND learner_id = $2::uuid
ORDER BY updated_at DESC, case_id DESC
LIMIT 1
FOR UPDATE
""",
pinned_persona_id,
learner_id,
)
if case_row is None:
case_row = await conn.fetchrow(
"""
INSERT INTO app.case_profile (persona_id, learner_id)
VALUES ($1::uuid, $2::uuid)
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
@ -2805,8 +3019,16 @@ async def create_session(
session_no,
learner_id,
)
if counter_row is not None:
session_no = int(counter_row["last_session_no"] or session_no)
if counter_row is None:
raise CaseNotFoundError("case_not_found")
session_no = int(counter_row["last_session_no"] or session_no)
persisted_state = state
if locked_state_factory is not None:
persisted_state = await locked_state_factory(
stable_case_id,
session_no,
)
carry_rapport = persisted_state.rapport_credit
row = await conn.fetchrow(
"""
INSERT INTO app.sessions (
@ -2837,7 +3059,7 @@ async def create_session(
list(goal_stages or []),
learner_feedback_enabled,
)
await _upsert_state(conn, str(row["id"]), state)
await _upsert_state(conn, str(row["id"]), persisted_state)
return InProcSession(
session_id=str(row["id"]),
case_id=str(row["case_id"] or row["runtime_case_id"] or row["id"]),
@ -2845,7 +3067,7 @@ async def create_session(
persona_code=card.code,
theory_mode=theory_mode,
persona=card,
state=state,
state=persisted_state,
persona_id=pinned_persona_id,
persona_version=pinned_persona_version,
session_no=int(row["session_no"] or session_no),
@ -2857,6 +3079,8 @@ async def create_session(
goal_stages=list(goal_stages or []),
learner_feedback_enabled=learner_feedback_enabled,
)
except (ActiveSessionExistsError, CaseNotFoundError):
raise
except Exception as exc:
logger.exception(
"durable session creation failed",
@ -3218,6 +3442,21 @@ async def end_session(sess: InProcSession, carry: memory.CarryOver) -> bool:
get_pool()
summary_write = _build_session_summary_write(sess, carry)
async with acquire(role="learner", user_id=sess.learner_id) as conn:
# start_session/create_session also locks this stable case before it
# inspects active rows. Taking the case lock first gives end/start a
# single lock order: the next session either sees S1 still active or
# sees its committed summary and carry-over, never an in-between row.
await conn.fetchval(
"""
SELECT case_id
FROM app.case_profile
WHERE case_id = $1::uuid
AND learner_id = $2::uuid
FOR NO KEY UPDATE
""",
sess.case_id,
sess.learner_id,
)
await conn.execute(
"""
UPDATE app.sessions
@ -3463,3 +3702,12 @@ def _iso_dt(value: datetime | None) -> str:
if value is None:
return ""
return value.astimezone(timezone.utc).isoformat()
def _should_replace_evaluation_record(
existing: dict[str, Any] | None,
replacement: dict[str, Any],
) -> bool:
"""늦게 도착한 실패가 이미 확정된 ready 평가를 덮지 못하게 한다."""
return not (
str((existing or {}).get("status") or "") == "ready"
and str(replacement.get("status") or "") != "ready"
)