전 저장소 리팩터링과 SSOT 정비

This commit is contained in:
Yun Chan 2026-07-15 21:31:30 +09:00
parent 14ecbd4e7d
commit 3dfddcac6f
173 changed files with 19679 additions and 6952 deletions

View file

@ -12,11 +12,12 @@ import asyncio
import json
import logging
import secrets
import time
from datetime import datetime
from typing import Literal, Optional
from fastapi import APIRouter, HTTPException, Request, status
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from sse_starlette.sse import EventSourceResponse
from .. import db, session_persistence, turn_runtime
@ -46,15 +47,17 @@ from ..session_read_model import (
MISSING_SESSION_EVALUATION_GRACE_SECONDS,
ReviewCaseWorksheet,
ReviewCaseWorksheetSaveRequest,
ReviewWorksheetItem,
ReviewWorksheetSection,
ReviewWorksheetItem as ReviewWorksheetItem,
ReviewWorksheetSection as ReviewWorksheetSection,
SessionArchiveResponse,
SessionDetailResponse,
SessionReviewReadInput,
SessionReviewResponse,
SessionProgress,
SessionShareDeleteResponse,
SessionShareResponse,
StageLabel,
build_session_progress,
build_session_review,
dashboard_achievements as _dashboard_achievements,
dashboard_feedback as _dashboard_feedback,
@ -68,7 +71,7 @@ from ..session_read_model import (
session_share_payload as _session_share_payload,
stage_label as _stage_label,
)
from ..store import InProcSession, TurnRecord, store
from ..store import InProcSession, store
router = APIRouter(prefix="/sessions", tags=["sessions"])
logger = logging.getLogger(__name__)
@ -82,6 +85,19 @@ EndStateValue = str | int | float | bool | None | dict[str, float]
class SessionStartRequest(BaseModel):
persona_code: str = Field(..., examples=["P1"])
theory_mode: TheoryMode = "humanistic"
# 이번 회기 목표 단계(2026-07-13 회의 P1). 회의 권장은 2개 수준이지만
# 소유자 지시(2026-07-15)로 1~4개까지 자유 선택을 허용한다.
# 빈 리스트는 구계약 클라이언트 호환용 — 준비 페이지는 항상 1개 이상을 보낸다.
goal_stages: list[StageLabel] = Field(default_factory=list, max_length=4)
@field_validator("goal_stages")
@classmethod
def _dedupe_goal_stages(cls, value: list[StageLabel]) -> list[StageLabel]:
seen: list[StageLabel] = []
for stage in value:
if stage not in seen:
seen.append(stage)
return seen[:4]
class SessionStartResponse(BaseModel):
@ -92,6 +108,11 @@ class SessionStartResponse(BaseModel):
effective_openness: float
recall_summary: Optional[str] = None
degraded: bool = False
started_at: str = ""
goal_stages: list[StageLabel] = Field(default_factory=list)
# 시간 기반 회기 종료 계약(회의 P1): 프론트 타이머·10분 전 알람의 기준값.
duration_limit_seconds: int = 0
warning_before_end_seconds: int = 0
class TurnRequest(BaseModel):
@ -132,6 +153,8 @@ class TurnResponse(BaseModel):
crisis_resource: Optional[CrisisResourceResponse] = None
conversation_stopped: bool = False
output_error: Optional[str] = None
# P2 단계 누적 게이지·상세 수치 — 턴마다 갱신된 파생값.
progress: Optional[SessionProgress] = None
class SessionEndResponse(BaseModel):
@ -146,6 +169,20 @@ _RECALL_CACHE: dict[str, memory.RecallContext] = {}
_KB_CUES_CACHE: dict[str, list[str]] = {}
_RAG_WARM_SEMAPHORE = asyncio.Semaphore(1)
def cached_kb_cues(session_id: str) -> list[str]:
"""Return a defensive copy of the session-scoped, process-lifetime KB cues."""
return list(_KB_CUES_CACHE.get(session_id) or [])
def invalidate_session_context_cache(session_id: str) -> None:
"""Invalidate all derived turn context when a session reaches its terminal state."""
_RECALL_CACHE.pop(session_id, None)
_KB_CUES_CACHE.pop(session_id, None)
# ────────────────────────────────────────────────────────────────────────────
# RAG 배선 헬퍼 — 내담자(CLIENT) 뷰. 임베더/KB/DB 풀 미가용 시 빈 값으로 graceful
# degradation: 상담 루프를 절대 막지 않는다(라이브 루프 비차단이 계약). routes/kb.py가
@ -213,7 +250,13 @@ async def _retrieve_live_coach_grounding(
conn,
query=query,
k=4,
kinds=("theory", "technique", "supervisor_pattern", "microskill", "taxonomy"),
kinds=(
"theory",
"technique",
"supervisor_pattern",
"microskill",
"taxonomy",
),
)
try:
await rag.log_retrieval(
@ -233,9 +276,16 @@ async def _retrieve_live_coach_grounding(
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()
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()
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(
@ -252,7 +302,9 @@ async def _retrieve_live_coach_grounding(
return out
def _latest_turn_evaluation(sess: InProcSession, turn_seq: int | None) -> Optional[dict]:
def _latest_turn_evaluation(
sess: InProcSession, turn_seq: int | None
) -> Optional[dict]:
"""방금 상담자 발화에 붙은 fast-loop 평가를 찾는다."""
for turn in reversed(sess.turns):
if turn.speaker != "counselor":
@ -322,7 +374,8 @@ async def _load_case_memory(case_id: str) -> dict:
"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,
"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"]],
}
@ -361,7 +414,10 @@ async def _episodic_recall_snippets(case_id: str, query: str) -> list[str]:
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,
conn,
case_id=case_id,
query=query,
k=_RAG_RECALL_K,
)
return await _hydrate_episodic_text(conn, result)
except Exception:
@ -412,14 +468,39 @@ async def ensure_recall_context(sess: InProcSession) -> memory.RecallContext:
return recall
def session_time_over(sess: InProcSession) -> bool:
"""시간 기반 회기 종료(회의 P1): 제한 + 마무리 유예까지 지난 세션인지 판정.
제한 시간(기본 60) 도달 자체는 프론트가 정리 유도·자동 종료로 처리하고,
서버는 유예(기본 +10)까지 지난 뒤의 턴만 거부한다(마무리 인사 허용).
"""
if settings.session_duration_minutes <= 0:
return False
limit_seconds = (
settings.session_duration_minutes + settings.session_overtime_grace_minutes
) * 60
return (time.time() - sess.created_at) > limit_seconds
def _ensure_turn_time_allowed(sess: InProcSession) -> None:
if session_time_over(sess):
raise HTTPException(
status.HTTP_409_CONFLICT,
detail="session_time_over",
)
async def _prepare_turn_context(
*,
session_id: str,
sess: InProcSession,
learner_text: str,
) -> orchestrator.TurnContext:
_ensure_turn_time_allowed(sess)
recall = await ensure_recall_context(sess)
kb_cues = _KB_CUES_CACHE.get(session_id) or [] # 비차단: warm 전이면 빈 단서(graceful)
kb_cues = (
_KB_CUES_CACHE.get(session_id) or []
) # 비차단: warm 전이면 빈 단서(graceful)
ctx = orchestrator.prepare_turn(
session_id=session_id,
case_id=sess.case_id,
@ -446,7 +527,9 @@ async def _warm_rag_caches(session_id: str, case_id: str, card) -> None:
"""
async with _RAG_WARM_SEMAPHORE:
try:
_RECALL_CACHE[session_id] = await _build_start_recall(case_id=case_id, card=card)
_RECALL_CACHE[session_id] = await _build_start_recall(
case_id=case_id, card=card
)
except Exception:
pass
try:
@ -460,7 +543,9 @@ def _ensure_learner(principal: Principal) -> Principal:
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")
raise HTTPException(
status.HTTP_403_FORBIDDEN, detail="only learners can use sessions"
)
async def _ensure_practice_consent(principal: Principal) -> None:
@ -495,7 +580,9 @@ async def _load_session_or_404(
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")
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
@ -562,7 +649,9 @@ async def _load_review_session_or_404(
supervisor = _review_supervisor_principal(principal)
if supervisor is None:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="session review access denied")
raise HTTPException(
status.HTTP_403_FORBIDDEN, detail="session review access denied"
)
return (
await _load_supervisor_review_session_or_404(
session_id,
@ -587,7 +676,9 @@ async def _end_persisted_session(sess: InProcSession, carry: memory.CarryOver) -
def _should_schedule_session_digest_worker(carry: memory.CarryOver) -> bool:
return bool(settings.session_digest_worker_enabled and carry.compression_job is not None)
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:
@ -600,7 +691,9 @@ async def _run_session_digest_worker_for_session(session_id: str) -> None:
try:
db.get_pool()
async with db.acquire(role="admin") as conn:
loaded = await session_digest_worker.load_session_digest_job(conn, session_id)
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
@ -620,7 +713,9 @@ async def _run_session_digest_worker_for_session(session_id: str) -> None:
learner_id=loaded.learner_id,
)
except Exception:
logger.warning("session digest worker failed for session_id=%s", session_id, exc_info=True)
logger.warning(
"session digest worker failed for session_id=%s", session_id, exc_info=True
)
return
@ -653,7 +748,10 @@ 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]:
async def _evaluate_stream_turn(
ctx: orchestrator.TurnContext, final_reply: str
) -> Optional[dict]:
"""stream 경로 완료 후 fast-loop 평가를 계산한다. 실패는 턴 저장을 막지 않는다."""
if not final_reply:
return None
@ -689,7 +787,9 @@ def _stream_result_from_done(
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,
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,
@ -780,9 +880,13 @@ def _observe_session_evaluation_task(task: asyncio.Task[None], session_id: str)
try:
task.result()
except asyncio.CancelledError:
logger.warning("session evaluation background task cancelled: session_id=%s", session_id)
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)
logger.exception(
"session evaluation background task crashed: session_id=%s", session_id
)
def _schedule_session_evaluation(sess: InProcSession) -> asyncio.Task[None] | None:
@ -800,20 +904,26 @@ def _schedule_session_evaluation(sess: InProcSession) -> asyncio.Task[None] | No
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)
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
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
_session_evaluation_timeout_seconds() + MISSING_SESSION_EVALUATION_GRACE_SECONDS
)
candidates, durable = await session_persistence.list_sessions_missing_session_evaluation(
(
candidates,
durable,
) = await session_persistence.list_sessions_missing_session_evaluation(
older_than_seconds=stale_after_seconds,
limit=recovery_limit,
)
@ -892,9 +1002,7 @@ async def _load_learner_sessions(
if not durable:
require_runtime_fallback_allowed("session list")
sessions = [
sess
for sess in store.list()
if sess.learner_id == principal.user_id
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
@ -917,7 +1025,9 @@ 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])
results = await asyncio.gather(
*[_review_ready(sess, principal) for sess in sessions]
)
return {sess.session_id: ready for sess, ready in zip(sessions, results)}
@ -970,7 +1080,9 @@ async def list_learner_sessions(principal: CurrentPrincipal) -> LearnerSessionsR
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,
archived_at=_iso(float(archived_at))
if isinstance(archived_at, (int, float))
else None,
)
)
@ -1042,7 +1154,9 @@ async def archive_session(
allow_ended=True,
)
if not sess.ended:
raise HTTPException(status.HTTP_409_CONFLICT, detail="active sessions cannot be archived")
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,
@ -1053,7 +1167,9 @@ async def archive_session(
sess,
principal,
archived=True,
archived_at=_iso(float(archived_at)) if isinstance(archived_at, (int, float)) else None,
archived_at=_iso(float(archived_at))
if isinstance(archived_at, (int, float))
else None,
source="database" if durable else "runtime",
)
@ -1084,7 +1200,9 @@ async def restore_archived_session(
)
@router.post("", response_model=SessionStartResponse, status_code=status.HTTP_201_CREATED)
@router.post(
"", response_model=SessionStartResponse, status_code=status.HTTP_201_CREATED
)
async def start_session(
body: SessionStartRequest,
principal: CurrentPrincipal,
@ -1102,14 +1220,18 @@ async def start_session(
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}")
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)
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(),
@ -1117,6 +1239,7 @@ async def start_session(
)
carry_rapport = st.rapport_credit
goal_stages = [str(stage) for stage in body.goal_stages]
sess = await session_persistence.create_session(
learner_id=principal.user_id,
card=card,
@ -1127,6 +1250,7 @@ async def start_session(
persona_id=catalog_persona.persona_id,
persona_version=catalog_persona.version,
case_id=case_context.case_id if case_context else None,
goal_stages=goal_stages,
)
degraded = catalog_persona.degraded or sess is None
if sess is None:
@ -1138,6 +1262,7 @@ async def start_session(
state=st,
session_no=session_no,
carry_rapport=carry_rapport,
goal_stages=goal_stages,
)
else:
store.put(sess)
@ -1155,6 +1280,10 @@ async def start_session(
effective_openness=round(st.effective_openness, 4),
recall_summary=recall.recall_summary,
degraded=degraded,
started_at=_iso(sess.created_at) or "",
goal_stages=body.goal_stages,
duration_limit_seconds=settings.session_duration_minutes * 60,
warning_before_end_seconds=settings.session_warning_minutes * 60,
)
@ -1169,7 +1298,10 @@ async def get_session_review(
principal,
include_turn_evaluation=True,
)
evaluation_record, evaluation_durable = await session_persistence.load_session_evaluation(
(
evaluation_record,
evaluation_durable,
) = await session_persistence.load_session_evaluation(
session_id,
review_principal,
)
@ -1196,6 +1328,7 @@ async def get_session_review(
)
)
@router.post("/{session_id}/share", response_model=SessionShareResponse)
async def create_session_share(
session_id: str,
@ -1211,7 +1344,9 @@ async def create_session_share(
include_turn_evaluation=True,
)
if not sess.ended:
raise HTTPException(status.HTTP_409_CONFLICT, detail="session must be ended before sharing")
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)
@ -1228,7 +1363,14 @@ async def create_session_share(
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 ""
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"]),
@ -1334,6 +1476,11 @@ async def submit_turn(
crisis_resource=result.crisis_resource,
conversation_stopped=result.conversation_stopped,
output_error=result.output_error,
progress=build_session_progress(
result.state_after,
prev_rapport_credit=sess.prev_rapport_credit,
goal_stages=list(sess.goal_stages or []),
),
)
@ -1345,17 +1492,28 @@ async def list_live_coach_history(
"""현재 회기에서 학습자에게 실제로 전달된 라이브 코칭 이력을 반환한다."""
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(
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",
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],
credit_events=[
live_coach.LiveCoachCreditEvent(**event) for event in credit_events
],
)
@ -1382,6 +1540,16 @@ async def live_coach_turn(
stage=stage,
theory_mode=sess.theory_mode,
)
prior_events, _ = await session_persistence.list_live_coach_events(
session_id, principal
)
prior_coach = [
{
"title": str((event.get("suggestion") or {}).get("title") or ""),
"focus": str((event.get("suggestion") or {}).get("focus") or ""),
}
for event in prior_events[-2:]
]
item = live_coach.LiveCoachInput(
session_id=sess.session_id,
turn_seq=turn_seq,
@ -1394,6 +1562,8 @@ async def live_coach_turn(
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),
goal_stages=list(sess.goal_stages or []),
prior_coach=prior_coach,
)
suggestion = await live_coach.generate_live_coaching(
item,
@ -1416,8 +1586,13 @@ async def live_coach_turn(
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)
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
@ -1425,7 +1600,9 @@ async def live_coach_turn(
]
return suggestion.model_copy(
update={
"persistence_source": "database" if coach_event_durable and quota_durable and credit_durable else "runtime",
"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:],
}
@ -1462,10 +1639,20 @@ async def stream_turn(
final_reply += text
yield {"event": "token", "data": text}
elif ev.event == "done":
data = {**ev.data, "stage": _stage_label(ctx.state_after.stage)}
data = {
**ev.data,
"stage": _stage_label(ctx.state_after.stage),
"progress": build_session_progress(
ctx.state_after,
prev_rapport_credit=sess.prev_rapport_credit,
goal_stages=list(sess.goal_stages or []),
).model_dump(),
}
if not finalized_turn:
evaluation = await _evaluate_stream_turn(ctx, final_reply)
result = _stream_result_from_done(ctx, final_reply, data, evaluation)
result = _stream_result_from_done(
ctx, final_reply, data, evaluation
)
await turn_runtime.finalize_completed_turn(
sess,
ctx,
@ -1473,7 +1660,10 @@ async def stream_turn(
context_prefix="session",
)
finalized_turn = True
yield {"event": "done", "data": json.dumps(data, ensure_ascii=False)}
yield {
"event": "done",
"data": json.dumps(data, ensure_ascii=False),
}
elif (
ev.event == "safety"
and bool(ev.data.get("conversation_stopped"))
@ -1484,14 +1674,18 @@ async def stream_turn(
safety_data = {
"session_id": ctx.session_id,
"stage": _stage_label(ctx.state_after.stage),
"effective_openness": round(ctx.state_after.effective_openness, 4),
"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)
result = _stream_result_from_done(
ctx, final_reply, safety_data, None
)
await turn_runtime.finalize_completed_turn(
sess,
ctx,
@ -1499,16 +1693,25 @@ async def stream_turn(
context_prefix="session",
)
finalized_turn = True
yield {"event": ev.event, "data": json.dumps(ev.data, ensure_ascii=False)}
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)}
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)}
yield {
"event": "error",
"data": json.dumps({"detail": str(exc)}, ensure_ascii=False),
}
return
return EventSourceResponse(event_generator())
@ -1536,8 +1739,7 @@ async def end_session(
)
await _end_persisted_session(sess, carry)
_RECALL_CACHE.pop(session_id, None)
_KB_CUES_CACHE.pop(session_id, None)
invalidate_session_context_cache(session_id)
if not was_ended:
_schedule_session_evaluation(sess)