현재 작업 전체 반영

This commit is contained in:
Yun Chan 2026-06-27 16:08:41 +09:00
parent 5560638e54
commit c0dddab594
85 changed files with 11322 additions and 539 deletions

View file

@ -22,14 +22,14 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse
from starlette.websockets import WebSocketState
from .. import session_persistence
from .. import session_persistence, turn_runtime
from ..auth_sessions import get_session
from ..config import settings
from ..deps import Principal, Role
from ..engine_client import EngineError, engine_client
from ..persona_repository import get_catalog_persona
from ..runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed
from ..services import evaluator, memory, orchestrator, state_machine
from ..runtime_policy import require_runtime_fallback_allowed
from ..services import evaluator, orchestrator, state_machine
from ..services import voice as voice_svc
from ..services.voice import VoicePreset, VoiceUnavailable, resolve_voice, voice_service
from ..store import InProcSession, TurnRecord, store
@ -292,7 +292,10 @@ async def _run_turn_and_speak(
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
recall = memory.RecallContext()
from . import sessions as session_routes
recall = await session_routes.ensure_recall_context(sess)
kb_cues = session_routes._KB_CUES_CACHE.get(session_id) or []
ctx = orchestrator.prepare_turn(
session_id=session_id,
case_id=sess.case_id,
@ -302,6 +305,7 @@ async def _run_turn_and_speak(
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
@ -311,7 +315,11 @@ async def _run_turn_and_speak(
result = await orchestrator.run_turn_generate(
ctx,
engine_client,
eval_hook=evaluator.make_eval_hook(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 e:
await _safe_send_json(websocket, {"type": "error", "detail": f"engine unavailable: {e}"})
@ -321,12 +329,15 @@ async def _run_turn_and_speak(
reply = result.client_reply or ""
# Persist only after the client reply has been generated. A failed AI turn
# must not leave a learner-only transcript in review or history.
await _append_voice_turn(
await turn_runtime.record_completed_turn(
sess,
TurnRecord(
ctx,
result,
context_prefix="voice session",
counselor_turn=TurnRecord(
turn_seq=ctx.state_after.turn_seq,
speaker="counselor",
stage=ctx.state_after.stage.value,
stage=turn_runtime.stage_label(ctx.state_after.stage),
text=learner_text,
text_masked=ctx.learner_text_masked,
audio_ref=audio_ref,
@ -336,24 +347,7 @@ async def _run_turn_and_speak(
evaluation=result.evaluation,
),
)
if reply:
# Persist the generated client reply before TTS playback.
await _append_voice_turn(
sess,
TurnRecord(
turn_seq=result.turn_seq,
speaker="client",
stage=result.stage,
text=reply,
text_masked=reply,
llm_provider=result.llm_provider,
model=result.model,
tokens_in=result.tokens_in,
tokens_out=result.tokens_out,
cost_usd=result.cost_usd,
),
)
await _update_voice_state(sess, result.state_after)
await turn_runtime.record_safety_event(sess, ctx, result)
# Send the final client text before audio playback.
await _safe_send_json(
@ -367,6 +361,8 @@ async def _run_turn_and_speak(
"turn_seq": result.turn_seq,
"safety_flagged": result.safety_flagged,
"crisis_kind": result.crisis_kind,
"crisis_resource": result.crisis_resource,
"conversation_stopped": result.conversation_stopped,
},
)
@ -403,49 +399,17 @@ async def _load_voice_session(
session_id: str,
principal: Principal,
) -> tuple[InProcSession | None, str | None]:
sess = await session_persistence.load_session(session_id, principal, allow_ended=True)
if sess is not None:
store.put(sess)
elif runtime_fallback_allowed():
sess = store.get(session_id)
if sess is None:
sess, err = await turn_runtime.load_owned_session(session_id, principal)
if err == turn_runtime.SessionAccessError.NOT_FOUND:
return None, f"unknown session {session_id}"
if sess.learner_id != principal.user_id:
if err == turn_runtime.SessionAccessError.FORBIDDEN:
return None, "session does not belong to user"
if sess.ended:
if err == turn_runtime.SessionAccessError.ENDED:
return None, "session already ended"
assert sess is not None
return sess, None
async def _append_voice_turn(sess: InProcSession, turn: TurnRecord) -> None:
if await session_persistence.append_turn(
session_id=sess.session_id,
learner_id=sess.learner_id,
turn=turn,
):
sess.turns.append(turn)
store.put(sess)
return
require_runtime_fallback_allowed("voice session turn append")
store.append_turn(sess.session_id, turn)
async def _update_voice_state(
sess: InProcSession,
state: state_machine.SessionState,
) -> None:
if await session_persistence.update_state(
session_id=sess.session_id,
learner_id=sess.learner_id,
state=state,
):
sess.state = state
store.put(sess)
return
require_runtime_fallback_allowed("voice session state update")
store.update_state(sess.session_id, state)
async def _principal_from_websocket(websocket: WebSocket) -> Principal | None:
"""Restore the same server-side browser session used by REST routes."""
raw_cookie = websocket.cookies.get(settings.cookie_name)