전 저장소 리팩터링과 SSOT 정비
This commit is contained in:
parent
14ecbd4e7d
commit
3dfddcac6f
173 changed files with 19679 additions and 6952 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue