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

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

@ -28,7 +28,15 @@ from ..deps import Principal, Role, require_role
from ..engine_client import EngineError, engine_client
from ..runtime_policy import runtime_fallback_allowed
from ..session_evaluation_input import enriched_masked_turns
from ..session_read_model import StageLabel, stage_label_or_none
from ..session_evaluation_timeout import (
session_evaluation_transport_timeout_seconds,
)
from ..session_read_model import (
ReviewEvaluationFailure,
StageLabel,
stage_label_or_none,
teacher_evaluation_failure,
)
from ..services import evaluator
from ..services.evaluator import SessionEvaluation, TurnEvaluation
from ..store import InProcSession
@ -51,12 +59,13 @@ class TurnReevaluateRequest(BaseModel):
class EvaluationSummary(BaseModel):
"""회기 평가 조회 응답(분포 + deep 결과 합본)."""
"""회기 평가 조회 응답(분포 + deep 결과 합본, provider 오류 원문 제외)."""
session_id: str
stage: StageLabel | None = None
status: str | None = None
error: str | None = None
failure: ReviewEvaluationFailure | None = None
durable: bool = False
deep: Optional[dict[str, Any]] = None
distribution: dict[str, Any] = Field(default_factory=dict)
@ -108,6 +117,15 @@ def _session_evaluation_error_status(error: str) -> int:
return status.HTTP_502_BAD_GATEWAY
def _safe_session_evaluation_retry_detail() -> str:
"""재시도 HTTP 응답에서는 provider 예외 원문을 내보내지 않는다.
원인은 durable evaluation record에 서버 전용으로 보존하고, 교수자 화면은 review의
안전 분류(evaluationFailure) 다음 행동만 안내한다.
"""
return "AI 평가를 완료하지 못했습니다. 최신 평가 상태를 확인해 주세요."
# ════════════════════════════════════════════════════════════════════════════
# 회기 deep-loop 재평가 트리거 (교수자/관리자)
# ════════════════════════════════════════════════════════════════════════════
@ -120,7 +138,8 @@ async def reevaluate_session(
"""회기 전체 deep-loop 재평가(슈퍼바이저 rationale/critique + 개선점 + 대안발화).
저장된 마스킹 축어록을 evaluator.evaluate_session 으로 평가한다.
엔진 장애는 503 으로 변환(평가는 비치명적이지만 트리거는 사용자 명시 요청이라 에러 노출).
엔진 장애는 503으로 변환한다. provider 예외 원문은 durable 기록에만 남기고 HTTP에는
안전한 안내만 반환한다.
"""
sess = await _load_session_or_404(session_id, principal)
counselor_identity = getattr(sess, "learner_label", None)
@ -144,6 +163,7 @@ async def reevaluate_session(
theory_mode=_theory_mode_of(sess),
scope=body.scope if body.scope in ("session_end", "stage_transition") else "session_end",
audit_hook=session_persistence.record_llm_call_audit,
timeout=session_evaluation_transport_timeout_seconds(),
)
except EngineError as e:
detail = f"engine unavailable: {e}"
@ -163,7 +183,10 @@ async def reevaluate_session(
session_id,
write.error,
)
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=detail)
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail=_safe_session_evaluation_retry_detail(),
)
write = session_persistence.SessionEvaluationWrite.from_result(
session_id=session_id,
@ -178,7 +201,10 @@ async def reevaluate_session(
logger.error("%s: session_id=%s status=%s", detail, session_id, write.status)
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=detail)
if result.error:
raise HTTPException(_session_evaluation_error_status(result.error), detail=result.error)
raise HTTPException(
_session_evaluation_error_status(result.error),
detail=_safe_session_evaluation_retry_detail(),
)
return result
@ -256,7 +282,10 @@ async def reevaluate_turn(
)
learner.evaluation = result_payload
if result.error:
raise HTTPException(_session_evaluation_error_status(result.error), detail=result.error)
raise HTTPException(
_session_evaluation_error_status(result.error),
detail=_safe_session_evaluation_retry_detail(),
)
return result
@ -285,13 +314,18 @@ async def get_session_evaluation(
distribution={},
)
payload = record.get("payload")
deep = payload if isinstance(payload, dict) else {}
deep = dict(payload) if isinstance(payload, dict) else {}
# SessionEvaluation.to_dict()에는 server-side error가 함께 저장된다. deep 객체도
# 교수자 API 경계에서는 같은 원칙으로 제거한다.
deep.pop("error", None)
distribution = deep.get("distribution")
failure = teacher_evaluation_failure(record)
return EvaluationSummary(
session_id=session_id,
stage=_summary_stage(record.get("stage") or deep.get("stage")),
status=str(record.get("status") or "") or None,
error=str(record.get("error") or "") or None,
error=_safe_session_evaluation_retry_detail() if failure is not None else None,
failure=failure,
durable=durable,
deep=deep,
distribution=distribution if isinstance(distribution, dict) else {},

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,
)

View file

@ -16,6 +16,8 @@ from ..session_read_model import (
learner_visible_turns,
missing_session_evaluation_record,
stage_label,
teacher_evaluation_failure,
teacher_evaluation_failure_message,
)
from ..services import session_metrics
from ..stage_contract import STAGE_LABEL_VALUES
@ -298,6 +300,7 @@ def _summary(
learner_turns = sum(1 for turn in visible_turns if turn.speaker == "counselor")
client_turns = sum(1 for turn in visible_turns if turn.speaker == "client")
evaluation_status = _evaluation_status_value(summary_evaluation_record)
evaluation_failure = teacher_evaluation_failure(summary_evaluation_record)
return TeacherSessionSummary(
session_id=sess.session_id,
learner_id=sess.learner_id,
@ -322,9 +325,7 @@ def _summary(
summary_evaluation_record,
has_visible_turns=bool(visible_turns),
),
evaluation_error=(
str(summary_evaluation_record.get("error") or "") if summary_evaluation_record else None
),
evaluation_error=teacher_evaluation_failure_message(evaluation_failure),
)