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

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

@ -15,6 +15,7 @@ import secrets
import time
from datetime import datetime
from typing import Literal, Optional
from uuid import UUID
from fastapi import APIRouter, HTTPException, Request, status
from pydantic import BaseModel, Field, field_validator
@ -28,6 +29,12 @@ from ..engine_client import EngineError, engine_client
from ..persona_repository import get_catalog_persona
from ..runtime_policy import require_runtime_fallback_allowed
from ..session_evaluation_input import enriched_masked_turns
from ..session_evaluation_timeout import (
session_evaluation_outer_timeout_seconds,
session_evaluation_timeout_seconds as _session_evaluation_timeout_seconds,
session_evaluation_stale_after_seconds,
session_evaluation_transport_timeout_seconds,
)
from ..services import (
evaluator,
feedback_policy,
@ -44,11 +51,14 @@ from ..services import (
state_machine,
)
from ..session_read_model import (
CaseMemoryPreview,
CaseProgressStats,
LearnerCaseListResponse,
LearnerCaseSummary,
LearnerDashboardResponse,
LearnerSessionsResponse,
LearnerSessionSummary,
LEARNER_VISIBLE_AI_ROLE,
MISSING_SESSION_EVALUATION_GRACE_SECONDS,
ReviewCaseWorksheet,
ReviewCaseWorksheetSaveRequest,
ReviewWorksheetItem as ReviewWorksheetItem,
@ -96,6 +106,10 @@ EndStateValue = str | int | float | bool | None | dict[str, float]
class SessionStartRequest(BaseModel):
persona_code: str = Field(..., examples=["P1"])
theory_mode: TheoryMode = "humanistic"
# continue는 선택한 사례의 압축 기억을 이어 받고, fresh는 새 case_id/S1으로 시작한다.
# 구클라이언트는 기존 동작을 보존하도록 continue가 기본이다.
start_mode: Literal["continue", "fresh"] = "continue"
case_id: UUID | None = None
# 이번 회기 목표 단계(2026-07-13 회의 P1). 회의 권장은 2개 수준이지만
# 소유자 지시(2026-07-15)로 1~4개까지 자유 선택을 허용한다.
# 빈 리스트는 구계약 클라이언트 호환용 — 준비 페이지는 항상 1개 이상을 보낸다.
@ -127,6 +141,7 @@ class SessionStartResponse(BaseModel):
duration_limit_seconds: int = 0
warning_before_end_seconds: int = 0
learner_feedback_enabled: bool = True
start_mode: Literal["continue", "fresh"] = "continue"
class TurnRequest(BaseModel):
@ -916,6 +931,7 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
return
timeout_seconds = _session_evaluation_timeout_seconds()
transport_timeout_seconds = session_evaluation_transport_timeout_seconds()
enriched = enriched_masked_turns(
sess.masked_turns(),
counselor_identity=sess.learner_label,
@ -933,8 +949,12 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
theory_mode=sess.theory_mode,
scope="session_end",
audit_hook=session_persistence.record_llm_call_audit,
timeout=transport_timeout_seconds,
),
timeout=timeout_seconds,
# gateway의 생성 deadline과 HTTP deadline을 같게 두면 요청 초기화·응답 수신
# 비용만으로 app이 먼저 취소될 수 있다. transport grace와 durable audit 기록
# 예산 뒤에 outer grace를 두어 정상 결과를 timeout error로 바꾸지 않는다.
timeout=session_evaluation_outer_timeout_seconds(),
)
write = session_persistence.SessionEvaluationWrite.from_result(
session_id=sess.session_id,
@ -1050,9 +1070,7 @@ async def recover_missing_session_evaluations(*, limit: int | None = None) -> in
)
if recovery_limit <= 0:
return 0
stale_after_seconds = (
_session_evaluation_timeout_seconds() + MISSING_SESSION_EVALUATION_GRACE_SECONDS
)
stale_after_seconds = session_evaluation_stale_after_seconds()
(
candidates,
durable,
@ -1108,11 +1126,6 @@ def cancel_missing_session_evaluation_recovery() -> None:
task.cancel()
def _session_evaluation_timeout_seconds() -> float:
configured = float(settings.session_evaluation_timeout or settings.engine_timeout)
return max(configured, 1.0)
async def _enqueue_session_review_ready_notification(session_id: str) -> None:
try:
await notifications.enqueue_session_review_ready(session_id=session_id)
@ -1236,6 +1249,180 @@ async def list_learner_sessions(principal: CurrentPrincipal) -> LearnerSessionsR
)
def _preview_text(value: object, *, limit: int) -> str | None:
"""Keep the learner foldout bounded even when a legacy digest is verbose."""
text = str(value or "").strip()
if not text:
return None
if len(text) <= limit:
return text
return f"{text[: max(1, limit - 1)].rstrip()}"
def _preview_items(value: object, *, limit: int, item_limit: int) -> list[str]:
if not isinstance(value, (list, tuple)):
return []
items: list[str] = []
for raw in value:
clipped = _preview_text(raw, limit=item_limit)
if clipped:
items.append(clipped)
if len(items) >= limit:
break
return items
def _db_datetime_iso(value: object) -> str | None:
return value.isoformat() if isinstance(value, datetime) else None
@router.get("/cases", response_model=LearnerCaseListResponse)
async def list_learner_cases(
persona_code: str,
principal: CurrentPrincipal,
) -> LearnerCaseListResponse:
"""Return complete case-local progress for one NPC, not a capped history slice."""
principal = _ensure_learner(principal)
try:
catalog_persona = await get_catalog_persona(persona_code)
except Exception as exc:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail="persona catalog database unavailable",
) from exc
if catalog_persona is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND, detail=f"unknown persona {persona_code}"
)
try:
rows = await session_persistence.list_case_summaries(
learner_id=principal.user_id,
persona_id=catalog_persona.persona_id,
)
except session_persistence.CaseProgressUnavailableError as exc:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail="case_progress_unavailable",
) from exc
card = catalog_persona.card
return LearnerCaseListResponse(
cases=[
LearnerCaseSummary(
case_id=str(row["case_id"]),
persona_code=card.code,
persona_name=card.display_name,
last_session_no=int(row["last_session_no"] or 0),
progress=CaseProgressStats(
total_sessions=int(row["total_sessions"] or 0),
completed_sessions=int(row["completed_sessions"] or 0),
total_turns=int(row["total_turns"] or 0),
total_duration_seconds=int(row["total_duration_seconds"] or 0),
active_session_id=(
str(row["active_session_id"])
if row.get("active_session_id") is not None
else None
),
active_session_no=(
int(row["active_session_no"])
if row.get("active_session_no") is not None
else None
),
active_started_at=_db_datetime_iso(row.get("active_started_at")),
last_activity_at=_db_datetime_iso(row.get("last_activity_at")),
),
)
for row in rows
]
)
@router.get("/cases/{case_id}/memory", response_model=CaseMemoryPreview)
async def get_learner_case_memory_preview(
case_id: UUID,
principal: CurrentPrincipal,
) -> CaseMemoryPreview:
"""Load only the learner-safe compact memory when its foldout is opened."""
principal = _ensure_learner(principal)
case_key = str(case_id)
try:
async with db.acquire(role="learner", user_id=principal.user_id) as conn:
case_row = await conn.fetchrow(
"""
SELECT case_digest
FROM app.case_profile
WHERE case_id = $1::uuid
AND learner_id = $2::uuid
""",
case_key,
principal.user_id,
)
if case_row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="case_not_found")
summary_row = await conn.fetchrow(
"""
SELECT ss.digest, ss.open_threads
FROM app.session_summary AS ss
JOIN app.sessions AS s ON s.id = ss.session_id
WHERE ss.case_id = $1::uuid
AND s.learner_id = $2::uuid
ORDER BY ss.session_no DESC, ss.created_at DESC
LIMIT 1
""",
case_key,
principal.user_id,
)
fact_rows = await conn.fetch(
"""
SELECT LEFT(pf.value, 160) AS value
FROM app.pinned_fact AS pf
JOIN app.case_profile AS cp ON cp.case_id = pf.case_id
WHERE pf.case_id = $1::uuid
AND cp.learner_id = $2::uuid
AND pf.status IN ('stable', 'evolving', 'locked')
AND 'client' = ANY(pf.visible_to)
ORDER BY pf.updated_at DESC
LIMIT 8
""",
case_key,
principal.user_id,
)
except HTTPException:
raise
except Exception as exc:
logger.exception("case memory preview read failed", extra={"case_id": case_key})
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail="case_memory_unavailable",
) from exc
case_digest = _preview_text(case_row["case_digest"], limit=600)
latest_session_digest = _preview_text(
summary_row["digest"] if summary_row is not None else None,
limit=600,
)
open_threads = _preview_items(
summary_row["open_threads"] if summary_row is not None else [],
limit=6,
item_limit=160,
)
pinned_facts = _preview_items(
[row["value"] for row in fact_rows],
limit=8,
item_limit=160,
)
return CaseMemoryPreview(
case_id=case_key,
memory_available=bool(
case_digest or latest_session_digest or open_threads or pinned_facts
),
case_digest=case_digest,
latest_session_digest=latest_session_digest,
open_threads=open_threads,
pinned_facts=pinned_facts,
)
@router.get("/dashboard", response_model=LearnerDashboardResponse)
async def learner_dashboard(principal: CurrentPrincipal) -> LearnerDashboardResponse:
"""Return the current learner's real practice dashboard aggregates."""
@ -1391,15 +1578,16 @@ async def start_session(
status.HTTP_404_NOT_FOUND, detail=f"unknown persona {body.persona_code}"
)
card = catalog_persona.card
if body.start_mode == "fresh" and body.case_id is not None:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="fresh_start_must_not_select_case",
)
case_context = await session_persistence.get_case_context(
learner_id=principal.user_id,
persona_id=catalog_persona.persona_id,
)
recall = await _build_seed_recall(
case_id=case_context.case_id if case_context else None
)
session_no = (case_context.last_session_no + 1) if case_context else 1
# The durable transaction chooses or creates the case only after active-case
# validation. Start with an empty recall here so a fresh request cannot see
# any legacy case before its own empty case exists.
recall = memory.build_recall_context()
st = state_machine.init_state(
params=card.openness_params(),
carry=recall.carry,
@ -1408,20 +1596,51 @@ async def start_session(
carry_rapport = st.rapport_credit
goal_stages = [str(stage) for stage in body.goal_stages]
learner_feedback_enabled = principal.learner_feedback_enabled
async def build_locked_start_state(
stable_case_id: str,
_session_no: int,
) -> state_machine.SessionState:
nonlocal recall
recall = (
memory.build_recall_context()
if body.start_mode == "fresh"
else await _build_seed_recall(case_id=stable_case_id)
)
return state_machine.init_state(
params=card.openness_params(),
carry=recall.carry,
)
try:
sess = await session_persistence.create_session(
learner_id=principal.user_id,
card=card,
theory_mode=body.theory_mode,
state=st,
session_no=session_no,
session_no=1,
carry_rapport=carry_rapport,
persona_id=catalog_persona.persona_id,
persona_version=catalog_persona.version,
case_id=case_context.case_id if case_context else None,
case_id=str(body.case_id) if body.case_id is not None else None,
start_mode=body.start_mode,
goal_stages=goal_stages,
learner_feedback_enabled=learner_feedback_enabled,
locked_state_factory=build_locked_start_state,
)
except session_persistence.ActiveSessionExistsError as exc:
raise HTTPException(
status.HTTP_409_CONFLICT,
detail={
"code": "active_session_exists",
"session_id": exc.session_id,
},
) from exc
except session_persistence.CaseNotFoundError as exc:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
detail="case_not_found",
) from exc
except session_persistence.SessionCreationPersistenceError as exc:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
@ -1430,6 +1649,64 @@ async def start_session(
degraded = catalog_persona.degraded or sess is None
if sess is None:
require_runtime_fallback_allowed("session creation")
# Runtime fallback cannot prove durable case memory. Keep it empty rather
# than leaking a guessed legacy recall, while preserving a selected
# in-process case ID when one is available.
recall = memory.build_recall_context()
st = state_machine.init_state(
params=card.openness_params(),
carry=recall.carry,
)
carry_rapport = st.rapport_credit
active_session = store.find_active(
learner_id=principal.user_id,
persona_id=catalog_persona.persona_id,
persona_code=card.code,
)
if active_session is not None:
raise HTTPException(
status.HTTP_409_CONFLICT,
detail={
"code": "active_session_exists",
"session_id": active_session.session_id,
},
)
runtime_case_id: str | None = None
runtime_session_no = 1
if body.start_mode == "continue":
related = [
candidate
for candidate in store.list()
if candidate.learner_id == principal.user_id
and (
candidate.persona_id == catalog_persona.persona_id
or candidate.persona_code == card.code
)
]
if body.case_id is not None:
runtime_case_id = str(body.case_id)
related = [
candidate
for candidate in related
if candidate.case_id == runtime_case_id
]
if not related:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
detail="case_not_found",
)
elif related:
newest = max(related, key=lambda candidate: candidate.created_at)
runtime_case_id = newest.case_id
related = [
candidate
for candidate in related
if candidate.case_id == runtime_case_id
]
if related:
runtime_session_no = max(
candidate.session_no for candidate in related
) + 1
sess = store.create(
learner_id=principal.user_id,
persona=card,
@ -1437,12 +1714,14 @@ async def start_session(
state=st,
persona_id=catalog_persona.persona_id,
persona_version=catalog_persona.version,
session_no=session_no,
case_id=runtime_case_id,
session_no=runtime_session_no,
carry_rapport=carry_rapport,
goal_stages=goal_stages,
learner_feedback_enabled=learner_feedback_enabled,
)
else:
st = sess.state
store.put(sess)
# DB 재조회 전의 첫 턴과 runtime fallback에서도 인증된 학습자 표시명을
@ -1470,6 +1749,7 @@ async def start_session(
duration_limit_seconds=settings.session_duration_minutes * 60,
warning_before_end_seconds=settings.session_warning_minutes * 60,
learner_feedback_enabled=sess.learner_feedback_enabled,
start_mode=body.start_mode,
)