회기 연속성과 멀티 케이스 계약을 영속화
This commit is contained in:
parent
be08c0b573
commit
72353ecd82
26 changed files with 2170 additions and 127 deletions
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue