vignette/apps/api/app/routes/sessions.py
Yun Chan 778e8526d4 세션 평가·라이브코치·교수자 분석 라운드 마감 + 문서 정리 + 코드품질 리팩터
- 누적 작업트리 커밋: 회기 평가 복구·durable 저장, 라이브 코치 이력/근거, 교수자 학생분석, 음성 비언어 메타, PII 마스킹, 운영 티켓/헬스 등
- 문서: 완료 기록 docs/archive/ 냉동 보관, docs/ 단일 인덱스(docs/README.md)+통합 TODO(docs/TODO.md)로 정리
- 리팩터(행위 보존): Stage enum SSOT(taxonomy 소유·state_machine re-export), store recent/masked_turns 중복 제거, speaker_ko_label 단일 헬퍼, _list_sessions N+1 제거(state/turns 배치 + 턴평가 하이드레이션 배치)
- 검증: 백엔드 pytest 352 passed, _list_sessions E2E chromium-single-run 2 passed
2026-07-02 02:50:36 +09:00

1545 lines
56 KiB
Python

"""Counseling session routes.
The DB-backed source of truth is still pending, so this route uses the existing
in-process session store when DB is degraded. Unlike the previous dev fallback,
all browser calls now require a verified server-side auth session and every
session operation checks learner ownership.
"""
from __future__ import annotations
import asyncio
import json
import logging
import secrets
from datetime import datetime
from typing import Literal, Optional
from fastapi import APIRouter, HTTPException, Request, status
from pydantic import BaseModel, Field
from sse_starlette.sse import EventSourceResponse
from .. import db, session_persistence, turn_runtime
from ..auth_sessions import user_has_consent, user_onboarding_complete
from ..config import settings
from ..deps import CurrentPrincipal, Principal, Role
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 ..services import (
evaluator,
guardrail,
live_coach,
memory,
notifications,
orchestrator,
rag,
session_digest_worker,
state_machine,
)
from ..session_read_model import (
LearnerDashboardResponse,
LearnerSessionsResponse,
LearnerSessionSummary,
LEARNER_VISIBLE_AI_ROLE,
MISSING_SESSION_EVALUATION_GRACE_SECONDS,
ReviewCaseWorksheet,
ReviewCaseWorksheetSaveRequest,
ReviewWorksheetItem,
ReviewWorksheetSection,
SessionArchiveResponse,
SessionDetailResponse,
SessionReviewReadInput,
SessionReviewResponse,
SessionShareDeleteResponse,
SessionShareResponse,
StageLabel,
build_session_review,
dashboard_achievements as _dashboard_achievements,
dashboard_feedback as _dashboard_feedback,
dashboard_growth as _dashboard_growth,
dashboard_overview as _dashboard_overview,
dashboard_persona_progress as _dashboard_persona_progress,
iso as _iso,
learner_summary as _learner_summary,
learner_visible_turns as _learner_visible_turns,
session_detail as _session_detail,
session_share_payload as _session_share_payload,
stage_label as _stage_label,
)
from ..store import InProcSession, TurnRecord, store
router = APIRouter(prefix="/sessions", tags=["sessions"])
logger = logging.getLogger(__name__)
_SESSION_EVALUATION_IN_FLIGHT: set[str] = set()
_SESSION_EVALUATION_RECOVERY_TASK: asyncio.Task[int] | None = None
TheoryMode = Literal["humanistic", "cbt", "integrative"]
EndStateValue = str | int | float | bool | None | dict[str, float]
class SessionStartRequest(BaseModel):
persona_code: str = Field(..., examples=["P1"])
theory_mode: TheoryMode = "humanistic"
class SessionStartResponse(BaseModel):
session_id: str
case_id: str
session_no: int
stage: StageLabel
effective_openness: float
recall_summary: Optional[str] = None
degraded: bool = False
class TurnRequest(BaseModel):
text: str = Field(..., min_length=1)
class LiveCoachRequest(BaseModel):
learner_text: str = Field(..., min_length=1)
client_reply: Optional[str] = None
turn_seq: Optional[int] = Field(default=None, ge=1)
class LiveCoachHistoryResponse(BaseModel):
source: Literal["database", "runtime"] = "runtime"
quota: live_coach.LiveCoachQuota = Field(
default_factory=lambda: live_coach.LiveCoachQuota(
remaining=session_persistence.LIVE_COACH_INITIAL_CREDITS,
max=session_persistence.LIVE_COACH_MAX_CREDITS,
)
)
events: list[live_coach.LiveCoachEvent] = Field(default_factory=list)
credit_events: list[live_coach.LiveCoachCreditEvent] = Field(default_factory=list)
class CrisisResourceResponse(BaseModel):
title: str
number: str
message: str
class TurnResponse(BaseModel):
turn_seq: int
stage: StageLabel
effective_openness: float
client_reply: Optional[str] = None
safety_flagged: bool = False
crisis_kind: str = "none"
crisis_resource: Optional[CrisisResourceResponse] = None
conversation_stopped: bool = False
class SessionEndResponse(BaseModel):
session_id: str
session_no: int
digest_pending: bool
end_state: dict[str, EndStateValue]
_RECALL_CACHE: dict[str, memory.RecallContext] = {}
# 세션별 KB 증상 행동단서(회기 1회 산출·캐시). 빈 list 캐시 = 회기 내 재시도 안 함(안정성).
_KB_CUES_CACHE: dict[str, list[str]] = {}
_RAG_WARM_SEMAPHORE = asyncio.Semaphore(1)
# ────────────────────────────────────────────────────────────────────────────
# RAG 배선 헬퍼 — 내담자(CLIENT) 뷰. 임베더/KB/DB 풀 미가용 시 빈 값으로 graceful
# degradation: 상담 루프를 절대 막지 않는다(라이브 루프 비차단이 계약). routes/kb.py가
# 같은 예외를 503으로 올리는 것과 의도적으로 다르다. 임베딩은 rag가 스레드풀로 offload.
# ────────────────────────────────────────────────────────────────────────────
_RAG_RECALL_K = 5
_KB_CUES_K = 4
def _persona_kb_query(card) -> str:
"""페르소나 증상·호소 → KB 행동단서 검색 질의(임베더/tsquery 입력 전용, LLM 미주입).
질의는 프롬프트에 들어가지 않는다. 회수된 behavior_cue만 L2로 주입되고, CLIENT 정책
(expose_body=False)이 본문을 잘라 '행동단서'만 돌려준다(CCD 본문 비노출 자동 보존).
"""
parts: list[str] = []
presenting = getattr(card, "presenting", None) or {}
if presenting.get("주호소"):
parts.append(str(presenting["주호소"]))
if presenting.get("표층"):
parts.append(str(presenting["표층"]))
dsm = getattr(card, "dsm5_dimensional", None) or {}
parts.extend(str(key) for key in dsm.keys() if key != "note")
return " ".join(p for p in parts if p).strip()
async def _retrieve_kb_behavior_cues(card) -> list[str]:
"""KB 증상 행동단서 회수(CLIENT 정책). 미가용 시 빈 리스트(비차단)."""
query = _persona_kb_query(card)
if not query:
return []
try:
async with db.acquire(ai_view=rag.AIRole.CLIENT.value) as conn:
result = await rag.search_kb(
conn,
query=query,
role=rag.AIRole.CLIENT,
k=_KB_CUES_K,
)
return [c.behavior_cue for c in result.chunks if c.behavior_cue]
except Exception:
# rag.NotConfigured(임베더/KB 미가용)·RuntimeError(풀 미초기화)·DB 오류 포함.
# 비치명적: 빈 단서로 진행. CancelledError는 BaseException이라 미포착.
return []
async def _retrieve_live_coach_grounding(
*,
learner_text: str,
client_reply: str | None,
stage: str,
theory_mode: str,
) -> list[live_coach.LiveCoachGrounding]:
"""라이브 코치용 평가 근거 회수. 미가용 시 빈 리스트로 진행한다."""
learner_masked = guardrail.mask_pii(learner_text).text_masked
client_masked = guardrail.mask_pii(client_reply or "").text_masked
query = " ".join(
part for part in [stage, theory_mode, learner_masked, client_masked] if part
).strip()
if not query:
return []
try:
async with db.acquire(ai_view=rag.AIRole.EVALUATOR.value) as conn:
result = await rag.retrieve_eval_grounding(
conn,
query=query,
k=4,
kinds=("theory", "technique", "supervisor_pattern", "microskill", "taxonomy"),
)
try:
await rag.log_retrieval(
conn,
result=result,
ai_role="evaluator",
used_in_answer=True,
)
except Exception:
pass
except Exception:
return []
out: list[live_coach.LiveCoachGrounding] = []
for chunk in result.chunks:
body = chunk.body or chunk.behavior_cue or chunk.context_prefix or ""
if not body:
continue
meta = chunk.meta if isinstance(chunk.meta, dict) else {}
title = str(meta.get("source_title") or meta.get("title") or chunk.source_id or "Vignette KB").strip()
source_type = str(meta.get("source_type") or "").strip()
source_version = str(meta.get("source_version") or meta.get("version") or "").strip()
citation = str(meta.get("citation") or "").strip()
out.append(
live_coach.LiveCoachGrounding(
source_id=chunk.source_id or f"kb:{chunk.chunk_id}",
title=title or "Vignette KB",
locator=chunk.heading_path,
kb_kind=chunk.kb_kind,
source_type=source_type or None,
version=source_version or None,
citation=citation or None,
summary=body[:500],
)
)
return out
def _latest_turn_evaluation(sess: InProcSession, turn_seq: int | None) -> Optional[dict]:
"""방금 상담자 발화에 붙은 fast-loop 평가를 찾는다."""
for turn in reversed(sess.turns):
if turn.speaker != "counselor":
continue
if turn_seq is not None and turn.turn_seq != turn_seq:
continue
if isinstance(turn.evaluation, dict):
return turn.evaluation
return None
return None
async def _ensure_kb_cues(session_id: str, card) -> list[str]:
"""세션별 KB 행동단서(회기 1회 산출·캐시, 서버 재시작/재개 시 lazy 재계산)."""
cached = _KB_CUES_CACHE.get(session_id)
if cached is not None:
return cached
cues = await _retrieve_kb_behavior_cues(card)
_KB_CUES_CACHE[session_id] = cues
return cues
async def _load_case_memory(case_id: str) -> dict:
"""case-level 큰그림 + 직전 요약 + client-visible pinned fact를 한 번에 읽는다."""
empty = {"case_digest": None, "prev_summary": None, "pinned_facts": []}
try:
async with db.acquire(ai_view=rag.AIRole.CLIENT.value) as conn:
case_row = await conn.fetchrow(
"""
SELECT case_digest
FROM app.case_profile
WHERE case_id = $1::uuid
""",
case_id,
)
summary_row = await conn.fetchrow(
"""
SELECT digest, open_threads, end_state
FROM app.session_summary
WHERE case_id = $1::uuid
ORDER BY session_no DESC, created_at DESC
LIMIT 1
""",
case_id,
)
fact_rows = await conn.fetch(
"""
SELECT value
FROM app.pinned_fact
WHERE case_id = $1::uuid
AND status IN ('stable', 'evolving', 'locked')
AND $2 = ANY(visible_to)
ORDER BY updated_at DESC
LIMIT 12
""",
case_id,
rag.AIRole.CLIENT.value,
)
except Exception:
return empty
prev_summary = None
if summary_row is not None:
prev_summary = {
"digest": summary_row["digest"],
"open_threads": list(summary_row["open_threads"] or []),
"end_state": dict(summary_row["end_state"] or {}),
}
return {
"case_digest": (case_row["case_digest"] if case_row is not None else None) or None,
"prev_summary": prev_summary,
"pinned_facts": [row["value"] for row in fact_rows if row["value"]],
}
async def _hydrate_episodic_text(conn, result) -> list[str]:
"""retrieve_persona_memory가 돌려준 turn_id → app.turns 마스킹 본문 조인(내담자 발화)."""
turn_ids = [c.meta.get("turn_id") for c in result.chunks if c.meta.get("turn_id")]
if not turn_ids:
return []
rows = await conn.fetch(
"""
SELECT id, text_masked FROM app.turns
WHERE id = ANY($1::uuid[]) AND speaker = 'client'
""",
turn_ids,
)
by_id = {str(r["id"]): r["text_masked"] for r in rows}
return [by_id[t] for t in turn_ids if by_id.get(t)]
def _recall_query(prev_summary: Optional[dict], card) -> str:
"""episodic recall 질의: 직전 open_threads 우선, 없으면 주호소."""
if prev_summary:
threads = prev_summary.get("open_threads") or []
if threads:
return " ".join(str(t) for t in threads)
presenting = getattr(card, "presenting", None) or {}
return str(presenting.get("주호소") or "").strip()
async def _episodic_recall_snippets(case_id: str, query: str) -> list[str]:
"""case 스코프 episodic 벡터 recall → 내담자 발화 단편(마스킹본). 미가용 시 []."""
if not query:
return []
try:
async with db.acquire(ai_view=rag.AIRole.CLIENT.value) as conn:
result = await rag.retrieve_persona_memory(
conn, case_id=case_id, query=query, k=_RAG_RECALL_K,
)
return await _hydrate_episodic_text(conn, result)
except Exception:
return []
async def _build_start_recall(*, case_id: str, card) -> memory.RecallContext:
"""회기 시작 회상 조립: prev_summary(case) + episodic recall을 build_recall_context로
합본. 전 구간 graceful(미가용 시 빈 회상).
"""
try:
db.get_pool() # 풀 미초기화 시 RuntimeError → 첫 회기와 동일한 빈 회상
except RuntimeError:
return memory.build_recall_context()
case_memory = await _load_case_memory(case_id)
prev_summary = case_memory.get("prev_summary")
query = _recall_query(prev_summary, card)
episodic = await _episodic_recall_snippets(case_id, query)
return memory.build_recall_context(
case_digest=case_memory.get("case_digest"),
prev_summary=prev_summary,
episodic_snippets=episodic,
pinned_facts=case_memory.get("pinned_facts") or [],
)
async def _build_seed_recall(*, case_id: str | None) -> memory.RecallContext:
if not case_id:
return memory.build_recall_context()
try:
db.get_pool()
except RuntimeError:
return memory.build_recall_context()
case_memory = await _load_case_memory(case_id)
return memory.build_recall_context(
case_digest=case_memory.get("case_digest"),
prev_summary=case_memory.get("prev_summary"),
pinned_facts=case_memory.get("pinned_facts") or [],
)
async def ensure_recall_context(sess: InProcSession) -> memory.RecallContext:
cached = _RECALL_CACHE.get(sess.session_id)
if cached is not None:
return cached
recall = await _build_seed_recall(case_id=sess.case_id)
_RECALL_CACHE[sess.session_id] = recall
return recall
async def _prepare_turn_context(
*,
session_id: str,
sess: InProcSession,
learner_text: str,
) -> orchestrator.TurnContext:
recall = await ensure_recall_context(sess)
kb_cues = _KB_CUES_CACHE.get(session_id) or [] # 비차단: warm 전이면 빈 단서(graceful)
ctx = orchestrator.prepare_turn(
session_id=session_id,
case_id=sess.case_id,
card=sess.persona,
state=sess.state,
learner_text=learner_text,
memory=orchestrator.TurnMemory(
recall_summary=recall.recall_summary,
pinned_facts=recall.pinned_facts,
recent_turns=sess.recent_turns(visible_to="client"),
kb_behavior_cues=kb_cues,
),
theory_mode=sess.theory_mode,
)
assert ctx.state_after is not None
return ctx
async def _warm_rag_caches(session_id: str, case_id: str, card) -> None:
"""RAG 회상·KB 행동단서를 **백그라운드**로 산출해 캐시한다(요청 경로 비차단).
BGE-M3 임베더 첫 로드(~수 초)가 회기 시작/턴 응답을 막지 않도록 create_task로 띄운다.
warm 완료 전 턴은 빈 회상/단서로 진행(graceful), 이후 턴부터 RAG 주입. 전 구간 비치명적.
"""
async with _RAG_WARM_SEMAPHORE:
try:
_RECALL_CACHE[session_id] = await _build_start_recall(case_id=case_id, card=card)
except Exception:
pass
try:
_KB_CUES_CACHE[session_id] = await _retrieve_kb_behavior_cues(card)
except Exception:
pass
def _ensure_learner(principal: Principal) -> Principal:
if principal.role == Role.LEARNER:
return principal
if principal.can_access_role(Role.LEARNER):
return principal.with_role(Role.LEARNER)
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="only learners can use sessions")
async def _ensure_practice_consent(principal: Principal) -> None:
if principal.consent_at is not None:
return
if await user_has_consent(principal.user_id):
return
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="consent_required")
async def _ensure_onboarding_complete(principal: Principal) -> None:
if principal.profile_completed_at is not None:
return
if await user_onboarding_complete(principal.user_id):
return
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="onboarding_required")
async def _load_session_or_404(
session_id: str,
principal: Principal,
*,
allow_ended: bool = False,
include_turn_evaluation: bool = False,
) -> InProcSession:
sess, err = await turn_runtime.load_owned_session(
session_id,
principal,
allow_ended=allow_ended,
include_turn_evaluation=include_turn_evaluation,
)
if err == turn_runtime.SessionAccessError.NOT_FOUND:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="session not found")
if err == turn_runtime.SessionAccessError.FORBIDDEN:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="session does not belong to user")
if err == turn_runtime.SessionAccessError.ENDED:
raise HTTPException(status.HTTP_409_CONFLICT, detail="session already ended")
assert sess is not None
return sess
def _review_supervisor_principal(principal: Principal) -> Principal | None:
if principal.role in {Role.TEACHER, Role.ADMIN}:
return principal
if principal.super_admin:
return principal.with_role(Role.ADMIN)
return None
async def _load_supervisor_review_session_or_404(
session_id: str,
principal: Principal,
*,
include_turn_evaluation: bool = False,
) -> InProcSession:
sess = await session_persistence.load_session(
session_id,
principal,
allow_ended=True,
include_turn_evaluation=include_turn_evaluation,
)
if sess is None and turn_runtime.runtime_fallback_allowed():
sess = store.get(session_id)
if sess is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="session not found")
return sess
async def _load_review_session_or_404(
session_id: str,
principal: Principal,
*,
include_turn_evaluation: bool = False,
) -> tuple[InProcSession, Principal]:
if principal.role == Role.LEARNER:
try:
sess = await _load_session_or_404(
session_id,
principal,
allow_ended=True,
include_turn_evaluation=include_turn_evaluation,
)
return sess, principal
except HTTPException as exc:
supervisor = _review_supervisor_principal(principal)
if supervisor is None or exc.status_code not in {
status.HTTP_403_FORBIDDEN,
status.HTTP_404_NOT_FOUND,
}:
raise
return (
await _load_supervisor_review_session_or_404(
session_id,
supervisor,
include_turn_evaluation=include_turn_evaluation,
),
supervisor,
)
supervisor = _review_supervisor_principal(principal)
if supervisor is None:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="session review access denied")
return (
await _load_supervisor_review_session_or_404(
session_id,
supervisor,
include_turn_evaluation=include_turn_evaluation,
),
supervisor,
)
async def _end_persisted_session(sess: InProcSession, carry: memory.CarryOver) -> None:
if await session_persistence.end_session(sess, carry):
sess.ended = True
sess.ended_at = datetime.now().timestamp()
store.put(sess)
if _should_schedule_session_digest_worker(carry):
asyncio.create_task(_run_session_digest_worker_for_session(sess.session_id))
asyncio.create_task(_write_episodic_embeddings(sess))
return
require_runtime_fallback_allowed("session end")
store.end(sess.session_id)
def _should_schedule_session_digest_worker(carry: memory.CarryOver) -> bool:
return bool(settings.session_digest_worker_enabled and carry.compression_job is not None)
async def _run_session_digest_worker_for_session(session_id: str) -> None:
"""Best-effort M2 LLM digest compressor.
The DB connection is held only for load/apply. Engine generation runs outside
the transaction so a slow provider cannot pin the pool.
"""
try:
db.get_pool()
async with db.acquire(role="admin") as conn:
loaded = await session_digest_worker.load_session_digest_job(conn, session_id)
if loaded is None:
return
model = settings.session_digest_worker_model.strip() or None
worker = await session_digest_worker.run_session_digest_worker(
loaded.job,
engine_client,
existing_case_digest=loaded.existing_case_digest,
model=model,
audit_hook=session_persistence.record_llm_call_audit,
)
if worker.apply_plan is None:
return
async with db.acquire(role="admin") as conn:
await session_digest_worker.apply_session_digest_plan(
conn,
worker.apply_plan,
learner_id=loaded.learner_id,
)
except Exception:
logger.warning("session digest worker failed for session_id=%s", session_id, exc_info=True)
return
async def _write_episodic_embeddings(sess: InProcSession) -> None:
"""Best-effort M2 episodic writer.
Only masked client-visible client turns are eligible. Missing BGE-M3, pgvector,
or DB runtime should not change session-end persistence semantics.
"""
inputs = rag.episodic_turn_inputs_from_records(
session_id=sess.session_id,
case_id=sess.case_id,
turns=sess.turns,
)
if not inputs:
return
try:
db.get_pool()
async with db.acquire(
role="learner",
user_id=sess.learner_id,
ai_view=rag.AIRole.CLIENT.value,
) as conn:
await rag.write_persona_turn_embeddings(conn, turns=inputs)
except Exception:
pass
def _public_share_url(request: Request, token: str) -> str:
base = str(request.base_url).rstrip("/")
return f"{base}/share/session/{token}"
async def _evaluate_stream_turn(ctx: orchestrator.TurnContext, final_reply: str) -> Optional[dict]:
"""stream 경로 완료 후 fast-loop 평가를 계산한다. 실패는 턴 저장을 막지 않는다."""
if not final_reply:
return None
try:
hook = evaluator.make_eval_hook(
engine_client,
audit_hook=session_persistence.record_llm_call_audit,
)
return await hook(ctx, final_reply)
except Exception as exc:
logger.warning(
"turn fast-loop evaluation failed: session_id=%s",
ctx.session_id,
exc_info=True,
)
return orchestrator.turn_evaluation_error_payload(ctx, exc)
def _stream_result_from_done(
ctx: orchestrator.TurnContext,
final_reply: str,
data: dict[str, object],
evaluation: Optional[dict],
) -> orchestrator.TurnResult:
assert ctx.state_after is not None
return orchestrator.TurnResult(
turn_seq=ctx.state_after.turn_seq,
stage=_stage_label(ctx.state_after.stage),
effective_openness=ctx.state_after.effective_openness,
client_reply=final_reply or None,
safety_flagged=bool(data.get("safety_flagged")),
state_after=ctx.state_after,
evaluation=evaluation,
crisis_kind=ctx.crisis.kind.value if ctx.crisis else "none",
crisis_resource=data.get("crisis_resource") if isinstance(data.get("crisis_resource"), dict) else None,
conversation_stopped=bool(data.get("conversation_stopped")),
llm_provider=str(data.get("llm_provider") or "") or None,
model=str(data.get("model") or "") or None,
tokens_in=int(data.get("tokens_in") or 0),
tokens_out=int(data.get("tokens_out") or 0),
cost_usd=float(data.get("cost_usd") or 0.0),
)
async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
if not sess.turns:
return
timeout_seconds = _session_evaluation_timeout_seconds()
enriched = enriched_masked_turns(sess.masked_turns())
try:
result = await asyncio.wait_for(
evaluator.evaluate_session(
session_id=sess.session_id,
stage=_stage_label(sess.state.stage),
masked_turns=enriched,
engine=engine_client,
technique_codes=[],
theory_mode=sess.theory_mode,
scope="session_end",
audit_hook=session_persistence.record_llm_call_audit,
),
timeout=timeout_seconds,
)
write = session_persistence.SessionEvaluationWrite.from_result(
session_id=sess.session_id,
learner_id=sess.learner_id,
result=result,
)
saved = await session_persistence.save_session_evaluation(write)
if not saved:
logger.error(
"session evaluation save did not reach durable store: session_id=%s status=%s scope=%s",
sess.session_id,
write.status,
write.scope,
)
if saved:
await _enqueue_session_review_ready_notification(sess.session_id)
except asyncio.TimeoutError:
message = f"session evaluation timeout after {timeout_seconds:g}s"
logger.exception("%s: session_id=%s", message, sess.session_id)
write = session_persistence.SessionEvaluationWrite.from_error(
session_id=sess.session_id,
learner_id=sess.learner_id,
scope="session_end",
stage=_stage_label(sess.state.stage),
error=message,
)
saved = await session_persistence.save_session_evaluation(write)
if not saved:
logger.error(
"session evaluation error save did not reach durable store: session_id=%s error=%s",
sess.session_id,
write.error,
)
if saved:
await _enqueue_session_review_ready_notification(sess.session_id)
except Exception as exc:
logger.exception("session evaluation failed: session_id=%s", sess.session_id)
write = session_persistence.SessionEvaluationWrite.from_error(
session_id=sess.session_id,
learner_id=sess.learner_id,
scope="session_end",
stage=_stage_label(sess.state.stage),
error=exc,
)
saved = await session_persistence.save_session_evaluation(write)
if not saved:
logger.error(
"session evaluation failure record did not reach durable store: session_id=%s error=%s",
sess.session_id,
write.error,
)
if saved:
await _enqueue_session_review_ready_notification(sess.session_id)
def _observe_session_evaluation_task(task: asyncio.Task[None], session_id: str) -> None:
_SESSION_EVALUATION_IN_FLIGHT.discard(session_id)
try:
task.result()
except asyncio.CancelledError:
logger.warning("session evaluation background task cancelled: session_id=%s", session_id)
except Exception:
logger.exception("session evaluation background task crashed: session_id=%s", session_id)
def _schedule_session_evaluation(sess: InProcSession) -> asyncio.Task[None] | None:
if not sess.turns:
return None
if sess.session_id in _SESSION_EVALUATION_IN_FLIGHT:
logger.info(
"session evaluation already scheduled: session_id=%s",
sess.session_id,
)
return None
_SESSION_EVALUATION_IN_FLIGHT.add(sess.session_id)
task = asyncio.create_task(
_generate_and_save_session_evaluation(sess),
name=f"session-evaluation:{sess.session_id}",
)
task.add_done_callback(
lambda done, session_id=sess.session_id: _observe_session_evaluation_task(done, session_id)
)
return task
async def recover_missing_session_evaluations(*, limit: int | None = None) -> int:
recovery_limit = settings.session_evaluation_recovery_limit if limit is None else limit
if recovery_limit <= 0:
return 0
stale_after_seconds = (
_session_evaluation_timeout_seconds()
+ MISSING_SESSION_EVALUATION_GRACE_SECONDS
)
candidates, durable = await session_persistence.list_sessions_missing_session_evaluation(
older_than_seconds=stale_after_seconds,
limit=recovery_limit,
)
if not durable:
logger.warning("session evaluation recovery skipped: durable store unavailable")
return 0
scheduled = 0
for sess in candidates:
if _schedule_session_evaluation(sess) is not None:
scheduled += 1
if scheduled:
logger.info("session evaluation recovery scheduled %d session(s)", scheduled)
return scheduled
def _observe_session_evaluation_recovery_task(task: asyncio.Task[int]) -> None:
global _SESSION_EVALUATION_RECOVERY_TASK
if _SESSION_EVALUATION_RECOVERY_TASK is task:
_SESSION_EVALUATION_RECOVERY_TASK = None
try:
task.result()
except asyncio.CancelledError:
logger.warning("session evaluation recovery task cancelled")
except Exception:
logger.exception("session evaluation recovery task crashed")
def schedule_missing_session_evaluation_recovery() -> asyncio.Task[int] | None:
global _SESSION_EVALUATION_RECOVERY_TASK
if settings.session_evaluation_recovery_limit <= 0:
return None
if (
_SESSION_EVALUATION_RECOVERY_TASK is not None
and not _SESSION_EVALUATION_RECOVERY_TASK.done()
):
return _SESSION_EVALUATION_RECOVERY_TASK
task = asyncio.create_task(
recover_missing_session_evaluations(),
name="session-evaluation-recovery",
)
_SESSION_EVALUATION_RECOVERY_TASK = task
task.add_done_callback(_observe_session_evaluation_recovery_task)
return task
def cancel_missing_session_evaluation_recovery() -> None:
task = _SESSION_EVALUATION_RECOVERY_TASK
if task is not None and not task.done():
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)
except Exception as exc:
logger.warning("session review notification enqueue failed: %s", exc)
async def _load_learner_sessions(
principal: Principal,
*,
include_turn_evaluation: bool = False,
) -> tuple[list[InProcSession], bool]:
if include_turn_evaluation:
sessions, durable = await session_persistence.list_recent_sessions(
principal,
include_turn_evaluation=True,
)
else:
sessions, durable = await session_persistence.list_recent_sessions(principal)
if not durable:
require_runtime_fallback_allowed("session list")
sessions = [
sess
for sess in store.list()
if sess.learner_id == principal.user_id
]
sessions.sort(key=lambda sess: sess.created_at, reverse=True)
return sessions, durable
async def _review_ready(sess: InProcSession, principal: Principal) -> bool:
turns = _learner_visible_turns(sess)
if not sess.ended or not turns:
return False
if len(turns) != len(sess.turns):
return False
evaluation_record, _ = await session_persistence.load_session_evaluation(
sess.session_id,
principal,
)
return bool(evaluation_record and evaluation_record.get("status") == "ready")
async def _review_ready_map(
sessions: list[InProcSession],
principal: Principal,
) -> dict[str, bool]:
results = await asyncio.gather(*[_review_ready(sess, principal) for sess in sessions])
return {sess.session_id: ready for sess, ready in zip(sessions, results)}
async def _archive_map(
sessions: list[InProcSession],
principal: Principal,
) -> dict[str, dict[str, object]]:
records, _ = await session_persistence.list_session_archives(
[sess.session_id for sess in sessions],
principal,
)
return records
async def _session_archive_response(
sess: InProcSession,
principal: Principal,
*,
archived: bool,
archived_at: str | None,
source: str,
) -> SessionArchiveResponse:
return SessionArchiveResponse(
session_id=sess.session_id,
archived=archived,
archived_at=archived_at,
source=source,
session=_learner_summary(
sess,
review_ready=await _review_ready(sess, principal),
archived=archived,
archived_at=archived_at,
),
)
@router.get("", response_model=LearnerSessionsResponse)
async def list_learner_sessions(principal: CurrentPrincipal) -> LearnerSessionsResponse:
"""Return the current learner's real practice sessions."""
principal = _ensure_learner(principal)
sessions, durable = await _load_learner_sessions(principal)
archives = await _archive_map(sessions, principal)
summaries: list[LearnerSessionSummary] = []
for sess in sessions[:20]:
archive_record = archives.get(sess.session_id)
archived_at = archive_record.get("archived_at") if archive_record else None
summaries.append(
_learner_summary(
sess,
review_ready=await _review_ready(sess, principal),
archived=archive_record is not None,
archived_at=_iso(float(archived_at)) if isinstance(archived_at, (int, float)) else None,
)
)
return LearnerSessionsResponse(
source="database" if durable else "runtime",
sessions=summaries,
)
@router.get("/dashboard", response_model=LearnerDashboardResponse)
async def learner_dashboard(principal: CurrentPrincipal) -> LearnerDashboardResponse:
"""Return the current learner's real practice dashboard aggregates."""
principal = _ensure_learner(principal)
sessions, durable = await _load_learner_sessions(
principal,
include_turn_evaluation=True,
)
review_ready = await _review_ready_map(sessions, principal)
archives = await _archive_map(sessions, principal)
visible_review_ready = {
session_id: ready
for session_id, ready in review_ready.items()
if session_id not in archives
}
return LearnerDashboardResponse(
source="database" if durable else "runtime",
overview=_dashboard_overview(
sessions,
visible_review_ready=visible_review_ready,
archived_sessions=len(archives),
),
growth=_dashboard_growth(sessions),
persona_progress=_dashboard_persona_progress(sessions, visible_review_ready),
achievements=_dashboard_achievements(sessions, visible_review_ready),
recent_feedback=_dashboard_feedback(sessions),
message=(
"실제 연습 기록을 기준으로 개인 학습 흐름을 표시합니다."
if sessions
else "아직 표시할 실제 연습 기록이 없습니다."
),
)
@router.get("/{session_id}", response_model=SessionDetailResponse)
async def get_session_detail(
session_id: str,
principal: CurrentPrincipal,
) -> SessionDetailResponse:
"""Return a learner-owned session with transcript for resume/history."""
principal = _ensure_learner(principal)
sess = await _load_session_or_404(
session_id,
principal,
allow_ended=True,
)
return _session_detail(sess, review_ready=await _review_ready(sess, principal))
@router.post("/{session_id}/archive", response_model=SessionArchiveResponse)
async def archive_session(
session_id: str,
principal: CurrentPrincipal,
) -> SessionArchiveResponse:
"""Archive an ended learner-owned session without deleting transcript or review evidence."""
principal = _ensure_learner(principal)
sess = await _load_session_or_404(
session_id,
principal,
allow_ended=True,
)
if not sess.ended:
raise HTTPException(status.HTTP_409_CONFLICT, detail="active sessions cannot be archived")
record, durable = await session_persistence.set_session_archived(
session_id=sess.session_id,
learner_id=principal.user_id,
archived=True,
)
archived_at = record.get("archived_at") if record else None
return await _session_archive_response(
sess,
principal,
archived=True,
archived_at=_iso(float(archived_at)) if isinstance(archived_at, (int, float)) else None,
source="database" if durable else "runtime",
)
@router.post("/{session_id}/restore", response_model=SessionArchiveResponse)
async def restore_archived_session(
session_id: str,
principal: CurrentPrincipal,
) -> SessionArchiveResponse:
"""Restore an archived learner-owned session to the normal history/review queues."""
principal = _ensure_learner(principal)
sess = await _load_session_or_404(
session_id,
principal,
allow_ended=True,
)
_, durable = await session_persistence.set_session_archived(
session_id=sess.session_id,
learner_id=principal.user_id,
archived=False,
)
return await _session_archive_response(
sess,
principal,
archived=False,
archived_at=None,
source="database" if durable else "runtime",
)
@router.post("", response_model=SessionStartResponse, status_code=status.HTTP_201_CREATED)
async def start_session(
body: SessionStartRequest,
principal: CurrentPrincipal,
) -> SessionStartResponse:
"""Start a learner-owned practice session."""
principal = _ensure_learner(principal)
await _ensure_onboarding_complete(principal)
await _ensure_practice_consent(principal)
try:
catalog_persona = await get_catalog_persona(body.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 {body.persona_code}")
card = catalog_persona.card
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
st = state_machine.init_state(
params=card.openness_params(),
carry=recall.carry,
)
carry_rapport = st.rapport_credit
sess = await session_persistence.create_session(
learner_id=principal.user_id,
card=card,
theory_mode=body.theory_mode,
state=st,
session_no=session_no,
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,
)
degraded = catalog_persona.degraded or sess is None
if sess is None:
require_runtime_fallback_allowed("session creation")
sess = store.create(
learner_id=principal.user_id,
persona=card,
theory_mode=body.theory_mode,
state=st,
session_no=session_no,
carry_rapport=carry_rapport,
)
else:
store.put(sess)
# 즉시 빈/carry 회상으로 응답을 막지 않는다. RAG 회상·KB 단서(임베더 로드 수 초)는
# 백그라운드 warm으로 캐시 — 회기 시작/턴 응답이 임베더 로드에 블로킹되지 않게(성능 회귀 방지).
_RECALL_CACHE[sess.session_id] = recall
asyncio.create_task(_warm_rag_caches(sess.session_id, sess.case_id, card))
return SessionStartResponse(
session_id=sess.session_id,
case_id=sess.case_id,
session_no=sess.session_no,
stage=_stage_label(st.stage),
effective_openness=round(st.effective_openness, 4),
recall_summary=recall.recall_summary,
degraded=degraded,
)
@router.get("/{session_id}/review", response_model=SessionReviewResponse)
async def get_session_review(
session_id: str,
principal: CurrentPrincipal,
) -> SessionReviewResponse:
"""Return a role-safe review built only from the stored session transcript."""
sess, review_principal = await _load_review_session_or_404(
session_id,
principal,
include_turn_evaluation=True,
)
evaluation_record, evaluation_durable = await session_persistence.load_session_evaluation(
session_id,
review_principal,
)
saved_worksheet_payload, _ = await session_persistence.load_case_worksheet(
session_id,
review_principal,
)
include_teacher_review = review_principal.role in {Role.TEACHER, Role.ADMIN}
teacher_review_record = None
if include_teacher_review:
teacher_review_record, _ = await session_persistence.load_session_review_status(
session_id,
review_principal,
)
return build_session_review(
SessionReviewReadInput(
session=sess,
evaluation_record=evaluation_record,
evaluation_durable=evaluation_durable,
saved_worksheet_payload=saved_worksheet_payload,
include_teacher_review=include_teacher_review,
teacher_review_record=teacher_review_record,
)
)
@router.post("/{session_id}/share", response_model=SessionShareResponse)
async def create_session_share(
session_id: str,
request: Request,
principal: CurrentPrincipal,
) -> SessionShareResponse:
"""Create a public unfurl URL for a learner-owned ended session review."""
principal = _ensure_learner(principal)
sess = await _load_session_or_404(
session_id,
principal,
allow_ended=True,
include_turn_evaluation=True,
)
if not sess.ended:
raise HTTPException(status.HTTP_409_CONFLICT, detail="session must be ended before sharing")
review = await get_session_review(session_id, principal)
token = secrets.token_urlsafe(32)
payload = _session_share_payload(review)
saved = await session_persistence.save_session_share(
session_id=session_id,
learner_id=principal.user_id,
token_hash=session_persistence.share_token_hash(token),
payload=payload,
)
if saved is None:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail="session share persistence unavailable",
)
created_at = saved.get("created_at")
created_label = _iso(created_at if isinstance(created_at, (int, float)) else datetime.now().timestamp()) or ""
return SessionShareResponse(
shareUrl=_public_share_url(request, token),
title=str(payload["title"]),
description=str(payload["description"]),
imageUrl=str(payload["imageUrl"]),
createdAt=created_label,
)
@router.delete("/{session_id}/share", response_model=SessionShareDeleteResponse)
async def revoke_session_share(
session_id: str,
principal: CurrentPrincipal,
) -> SessionShareDeleteResponse:
"""Revoke the public share URL for a learner-owned session."""
principal = _ensure_learner(principal)
await _load_session_or_404(session_id, principal, allow_ended=True)
revoked = await session_persistence.revoke_session_share(
session_id=session_id,
learner_id=principal.user_id,
)
return SessionShareDeleteResponse(revoked=revoked)
@router.put("/{session_id}/review/worksheet", response_model=ReviewCaseWorksheet)
async def save_session_review_worksheet(
session_id: str,
body: ReviewCaseWorksheetSaveRequest,
principal: CurrentPrincipal,
) -> ReviewCaseWorksheet:
"""Persist the learner's edited case formulation worksheet for this session."""
principal = _ensure_learner(principal)
await _load_session_or_404(
session_id,
principal,
allow_ended=True,
include_turn_evaluation=False,
)
worksheet = ReviewCaseWorksheet(
status="saved_by_learner",
generatedBy="learner-edited worksheet",
sections=body.sections,
limitations=body.limitations,
)
ok = await session_persistence.save_case_worksheet(
session_id=session_id,
learner_id=principal.user_id,
payload=worksheet.model_dump(mode="json"),
)
if not ok:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail="case worksheet persistence unavailable",
)
return worksheet
@router.post("/{session_id}/turn", response_model=TurnResponse)
async def submit_turn(
session_id: str,
body: TurnRequest,
principal: CurrentPrincipal,
) -> TurnResponse:
"""Submit one trainee utterance and return the generated client reply."""
principal = _ensure_learner(principal)
sess = await _load_session_or_404(session_id, principal)
ctx = await _prepare_turn_context(
session_id=session_id,
learner_text=body.text,
sess=sess,
)
try:
result = await orchestrator.run_turn_generate(
ctx,
engine_client,
eval_hook=evaluator.make_eval_hook(
engine_client,
audit_hook=session_persistence.record_llm_call_audit,
),
audit_hook=session_persistence.record_llm_call_audit,
)
except EngineError as exc:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"engine unavailable: {exc}",
) from exc
await turn_runtime.finalize_completed_turn(
sess,
ctx,
result,
context_prefix="session",
)
return TurnResponse(
turn_seq=result.turn_seq,
stage=_stage_label(result.state_after.stage),
effective_openness=round(result.effective_openness, 4),
client_reply=result.client_reply,
safety_flagged=result.safety_flagged,
crisis_kind=result.crisis_kind,
crisis_resource=result.crisis_resource,
conversation_stopped=result.conversation_stopped,
)
@router.get("/{session_id}/live-coach", response_model=LiveCoachHistoryResponse)
async def list_live_coach_history(
session_id: str,
principal: CurrentPrincipal,
) -> LiveCoachHistoryResponse:
"""현재 회기에서 학습자에게 실제로 전달된 라이브 코칭 이력을 반환한다."""
principal = _ensure_learner(principal)
await _load_session_or_404(session_id, principal)
events, durable = await session_persistence.list_live_coach_events(session_id, principal)
quota, quota_durable = await session_persistence.get_live_coach_quota(session_id, principal)
credit_events, credit_durable = await session_persistence.list_live_coach_credit_events(
session_id,
principal,
)
return LiveCoachHistoryResponse(
source="database" if durable and quota_durable and credit_durable else "runtime",
quota=live_coach.LiveCoachQuota(**quota),
events=[live_coach.LiveCoachEvent(**event) for event in events],
credit_events=[live_coach.LiveCoachCreditEvent(**event) for event in credit_events],
)
@router.post("/{session_id}/live-coach", response_model=live_coach.LiveCoachSuggestion)
async def live_coach_turn(
session_id: str,
body: LiveCoachRequest,
principal: CurrentPrincipal,
) -> live_coach.LiveCoachSuggestion:
"""방금 완료된 턴에 대한 비차단 라이브 코칭을 반환한다."""
principal = _ensure_learner(principal)
sess = await _load_session_or_404(session_id, principal)
quota, _ = await session_persistence.get_live_coach_quota(session_id, principal)
if int(quota.get("remaining", 0)) <= 0:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="live coach credit exhausted",
)
turn_seq = body.turn_seq or max(1, int(getattr(sess.state, "turn_seq", 1) or 1))
stage = _stage_label(sess.state.stage)
grounding = await _retrieve_live_coach_grounding(
learner_text=body.learner_text,
client_reply=body.client_reply,
stage=stage,
theory_mode=sess.theory_mode,
)
item = live_coach.LiveCoachInput(
session_id=sess.session_id,
turn_seq=turn_seq,
stage=stage,
effective_openness=sess.state.effective_openness,
theory_mode=sess.theory_mode,
persona_code=sess.persona_code,
persona_name=sess.persona.display_name,
learner_text=body.learner_text,
client_reply=body.client_reply,
recent_turns=sess.recent_turns(k=8, visible_to=LEARNER_VISIBLE_AI_ROLE),
evaluation=_latest_turn_evaluation(sess, body.turn_seq),
)
suggestion = await live_coach.generate_live_coaching(
item,
engine=engine_client,
grounding=grounding,
audit_hook=session_persistence.record_llm_call_audit,
)
try:
_, coach_event_durable = await session_persistence.save_live_coach_event(
session_id=sess.session_id,
learner_id=sess.learner_id,
turn_seq=turn_seq,
stage=stage,
learner_text=body.learner_text,
client_reply=body.client_reply,
suggestion=suggestion,
)
except session_persistence.LiveCoachCreditExhausted as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="live coach credit exhausted",
) from exc
quota_after, quota_durable = await session_persistence.get_live_coach_quota(session_id, principal)
credit_events, credit_durable = await session_persistence.list_live_coach_credit_events(session_id, principal)
turn_credit_events = [
live_coach.LiveCoachCreditEvent(**event)
for event in credit_events
if int(event.get("turn_seq") or 0) == int(turn_seq)
]
return suggestion.model_copy(
update={
"persistence_source": "database" if coach_event_durable and quota_durable and credit_durable else "runtime",
"quota": live_coach.LiveCoachQuota(**quota_after),
"credit_events": turn_credit_events[-2:],
}
)
@router.post("/{session_id}/stream")
async def stream_turn(
session_id: str,
body: TurnRequest,
principal: CurrentPrincipal,
):
"""Stream a generated client reply for one trainee utterance."""
principal = _ensure_learner(principal)
sess = await _load_session_or_404(session_id, principal)
ctx = await _prepare_turn_context(
session_id=session_id,
learner_text=body.text,
sess=sess,
)
async def event_generator():
finalized_turn = False
last_beat = asyncio.get_running_loop().time()
final_reply = ""
try:
async for ev in orchestrator.run_turn_stream(
ctx,
engine_client,
audit_hook=session_persistence.record_llm_call_audit,
):
if ev.event == "token":
text = str(ev.data.get("text", ""))
final_reply += text
yield {"event": "token", "data": text}
elif ev.event == "done":
data = {**ev.data, "stage": _stage_label(ctx.state_after.stage)}
if not finalized_turn:
evaluation = await _evaluate_stream_turn(ctx, final_reply)
result = _stream_result_from_done(ctx, final_reply, data, evaluation)
await turn_runtime.finalize_completed_turn(
sess,
ctx,
result,
context_prefix="session",
)
finalized_turn = True
yield {"event": "done", "data": json.dumps(data, ensure_ascii=False)}
elif (
ev.event == "safety"
and bool(ev.data.get("conversation_stopped"))
and ctx.crisis is not None
and ctx.crisis.escalate
and not finalized_turn
):
safety_data = {
"session_id": ctx.session_id,
"stage": _stage_label(ctx.state_after.stage),
"effective_openness": round(ctx.state_after.effective_openness, 4),
"turn_seq": ctx.state_after.turn_seq,
"safety_flagged": True,
"crisis_kind": ctx.crisis.kind.value,
"crisis_resource": ev.data.get("crisis_resource"),
"conversation_stopped": True,
}
result = _stream_result_from_done(ctx, final_reply, safety_data, None)
await turn_runtime.finalize_completed_turn(
sess,
ctx,
result,
context_prefix="session",
)
finalized_turn = True
yield {"event": ev.event, "data": json.dumps(ev.data, ensure_ascii=False)}
else:
yield {"event": ev.event, "data": json.dumps(ev.data, ensure_ascii=False)}
now = asyncio.get_running_loop().time()
if now - last_beat >= settings.sse_heartbeat_seconds:
yield {"event": "ping", "data": "{}"}
last_beat = now
except Exception as exc:
yield {"event": "error", "data": json.dumps({"detail": str(exc)}, ensure_ascii=False)}
return
return EventSourceResponse(event_generator())
@router.post("/{session_id}/end", response_model=SessionEndResponse)
async def end_session(
session_id: str,
principal: CurrentPrincipal,
) -> SessionEndResponse:
"""End a learner-owned session and prepare carry-over state."""
principal = _ensure_learner(principal)
sess = await _load_session_or_404(session_id, principal, allow_ended=True)
was_ended = bool(sess.ended)
recall = _RECALL_CACHE.get(session_id) or memory.RecallContext()
carry = memory.make_carry_over(
state=sess.state,
session_id=session_id,
case_id=sess.case_id,
session_no=sess.session_no,
masked_turns=sess.masked_turns(visible_to="client"),
prev_rapport_credit=sess.prev_rapport_credit,
open_threads=recall.open_threads,
)
await _end_persisted_session(sess, carry)
_RECALL_CACHE.pop(session_id, None)
_KB_CUES_CACHE.pop(session_id, None)
if not was_ended:
_schedule_session_evaluation(sess)
return SessionEndResponse(
session_id=session_id,
session_no=sess.session_no,
digest_pending=carry.compression_job is not None,
end_state=carry.end_state,
)