feat: 운영 안정성과 세션 음성 경험 개선

This commit is contained in:
Yun Chan 2026-07-31 00:13:08 +09:00
parent facc4ad2d9
commit c788343467
95 changed files with 8431 additions and 1785 deletions

View file

@ -5,7 +5,7 @@ from __future__ import annotations
import time
from datetime import datetime, timezone
from decimal import Decimal
from typing import Annotated, Literal
from typing import Annotated, Literal, cast
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
@ -25,7 +25,14 @@ from ..auth_sessions import (
upsert_managed_user,
)
from ..config import settings
from ..contracts.engine_gateway import ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL
from ..contracts.engine_gateway import (
ENGINE_PROVIDER_DEFAULTS,
ENGINE_PROVIDERS,
ENGINE_REASONING_EFFORTS,
EngineCapabilitiesResponse,
EngineProvider,
ReasoningEffort,
)
from ..db import acquire, get_pool, healthcheck
from ..deps import Principal, require_admin_access
from ..engine_client import engine_client
@ -269,9 +276,10 @@ class AdminTicketsResponse(BaseModel):
class AdminEngineConfigResponse(BaseModel):
engine_mode: str
engine_mode: EngineProvider
engine_url: str
model: str
reasoning_effort: ReasoningEffort | None = None
updated_by: str | None = None
updated_at: float | None = None
durable: bool = False
@ -282,6 +290,7 @@ class AdminEngineConfigPatch(BaseModel):
engine_mode: str | None = None
engine_url: str | None = None
model: str | None = None
reasoning_effort: str | None = None
class AdminTicketPatch(BaseModel):
@ -977,7 +986,7 @@ class AdminUserDeleteResponse(BaseModel):
_ENGINE_CONFIG: AdminEngineConfigResponse | None = None
ENGINE_MODES = {"claude_api", "claude_cli", "openai", "solar"}
ENGINE_MODES = set(ENGINE_PROVIDERS)
ENGINE_MODE_ALIASES = {"messages_api": "claude_api"}
@ -992,23 +1001,37 @@ def _normalize_email(value: str) -> str:
def _default_engine_config() -> AdminEngineConfigResponse:
default_model, default_effort = ENGINE_PROVIDER_DEFAULTS[settings.engine_mode]
return AdminEngineConfigResponse(
engine_mode=settings.engine_mode,
engine_url=settings.engine_url,
model=ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL,
model=default_model,
reasoning_effort=default_effort,
durable=False,
source="runtime_default",
)
def _normalize_engine_mode(value: str) -> str:
def _normalize_engine_mode(value: str) -> EngineProvider:
mode = ENGINE_MODE_ALIASES.get(value.strip(), value.strip())
if mode not in ENGINE_MODES:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"unsupported engine mode {value}",
)
return mode
return cast(EngineProvider, mode)
def _normalize_reasoning_effort(value: str | None) -> ReasoningEffort | None:
effort = (value or "").strip().lower()
if not effort:
return None
if effort not in ENGINE_REASONING_EFFORTS:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"unsupported reasoning effort {value}",
)
return cast(ReasoningEffort, effort)
def _normalize_engine_url(value: str) -> str:
@ -1071,6 +1094,7 @@ def _engine_config_from_row(row) -> AdminEngineConfigResponse:
engine_mode=_normalize_engine_mode(row["engine_mode"]),
engine_url=_normalize_engine_url(row["engine_url"]),
model=row["model"],
reasoning_effort=_normalize_reasoning_effort(row.get("reasoning_effort")),
updated_by=row["updated_by"],
updated_at=_updated_at_ts(row["updated_at"]),
durable=True,
@ -1266,7 +1290,7 @@ async def _current_engine_config() -> AdminEngineConfigResponse:
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT engine_mode, engine_url, model, updated_by, updated_at
SELECT engine_mode, engine_url, model, reasoning_effort, updated_by, updated_at
FROM app.admin_engine_config
WHERE id = TRUE
"""
@ -1286,6 +1310,7 @@ async def apply_engine_config_from_store() -> AdminEngineConfigResponse:
base_url=config.engine_url,
engine_mode=config.engine_mode,
default_model=config.model,
default_reasoning_effort=config.reasoning_effort,
)
return config
@ -1752,6 +1777,74 @@ async def get_engine_config(principal: AdminPrincipal) -> AdminEngineConfigRespo
return await _current_engine_config()
@router.get("/engine-capabilities", response_model=EngineCapabilitiesResponse)
async def get_engine_capabilities(
principal: AdminPrincipal,
engine_mode: str | None = Query(default=None),
engine_url: str | None = Query(default=None),
force: bool = Query(default=False),
) -> EngineCapabilitiesResponse:
"""Return gateway-discovered models and reasoning levels for one provider."""
current = await _current_engine_config()
provider = _normalize_engine_mode(engine_mode or current.engine_mode)
capability_url = (
_normalize_engine_url(engine_url)
if engine_url is not None
else current.engine_url
)
try:
return await engine_client.capabilities(
provider=provider,
base_url=capability_url,
force=force,
)
except Exception as exc:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"engine capabilities unavailable: {exc}",
) from exc
async def _validate_engine_selection(
*,
provider: EngineProvider,
engine_url: str,
model: str,
reasoning_effort: ReasoningEffort | None,
) -> None:
try:
capabilities = await engine_client.capabilities(
provider=provider,
base_url=engine_url,
force=True,
)
except Exception as exc:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"선택한 엔진의 모델 목록을 검증할 수 없습니다: {exc}",
) from exc
if not capabilities.available:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=capabilities.detail or "선택한 엔진을 사용할 수 없습니다.",
)
selected = next((option for option in capabilities.models if option.id == model), None)
if selected is None:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"{provider}에서 사용할 수 없는 모델입니다: {model}",
)
if (
reasoning_effort is not None
and reasoning_effort not in selected.reasoning_efforts
):
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"{model}에서 사용할 수 없는 추론 강도입니다: {reasoning_effort}",
)
@router.patch("/engine-config", response_model=AdminEngineConfigResponse)
async def patch_engine_config(
body: AdminEngineConfigPatch,
@ -1763,10 +1856,28 @@ async def patch_engine_config(
current = await _current_engine_config()
next_mode = _normalize_engine_mode(body.engine_mode or current.engine_mode)
next_url = _normalize_engine_url(body.engine_url or current.engine_url)
next_model = (body.model or current.model).strip()
if not next_model:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="model is required",
)
next_effort = _normalize_reasoning_effort(
body.reasoning_effort
if "reasoning_effort" in body.model_fields_set
else current.reasoning_effort
)
await _validate_engine_selection(
provider=next_mode,
engine_url=next_url,
model=next_model,
reasoning_effort=next_effort,
)
next_config = AdminEngineConfigResponse(
engine_mode=next_mode,
engine_url=next_url,
model=(body.model or current.model).strip(),
model=next_model,
reasoning_effort=next_effort,
updated_by=principal.email,
updated_at=time.time(),
durable=False,
@ -1778,20 +1889,22 @@ async def patch_engine_config(
row = await conn.fetchrow(
"""
INSERT INTO app.admin_engine_config (
id, engine_mode, engine_url, model, updated_by, updated_at
id, engine_mode, engine_url, model, reasoning_effort, updated_by, updated_at
)
VALUES (TRUE, $1, $2, $3, $4, now())
VALUES (TRUE, $1, $2, $3, $4, $5, now())
ON CONFLICT (id) DO UPDATE SET
engine_mode = EXCLUDED.engine_mode,
engine_url = EXCLUDED.engine_url,
model = EXCLUDED.model,
reasoning_effort = EXCLUDED.reasoning_effort,
updated_by = EXCLUDED.updated_by,
updated_at = now()
RETURNING engine_mode, engine_url, model, updated_by, updated_at
RETURNING engine_mode, engine_url, model, reasoning_effort, updated_by, updated_at
""",
next_config.engine_mode,
next_config.engine_url,
next_config.model,
next_config.reasoning_effort,
principal.email,
)
next_config = _engine_config_from_row(row)
@ -1806,6 +1919,7 @@ async def patch_engine_config(
base_url=next_config.engine_url,
engine_mode=next_config.engine_mode,
default_model=next_config.model,
default_reasoning_effort=next_config.reasoning_effort,
)
return next_config

View file

@ -71,12 +71,13 @@ from ..session_read_model import (
session_share_payload as _session_share_payload,
stage_label as _stage_label,
)
from ..store import InProcSession, store
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
_STREAM_TURN_EVALUATION_TASKS: set[asyncio.Task[None]] = set()
TheoryMode = Literal["humanistic", "cbt", "integrative"]
EndStateValue = str | int | float | bool | None | dict[str, float]
@ -670,9 +671,11 @@ async def _end_persisted_session(sess: InProcSession, carry: memory.CarryOver) -
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))
asyncio.create_task(engine_client.close_session(sess.session_id))
return
require_runtime_fallback_allowed("session end")
store.end(sess.session_id)
asyncio.create_task(engine_client.close_session(sess.session_id))
def _should_schedule_session_digest_worker(carry: memory.CarryOver) -> bool:
@ -770,6 +773,84 @@ async def _evaluate_stream_turn(
return orchestrator.turn_evaluation_error_payload(ctx, exc)
async def _evaluate_and_persist_stream_turn(
*,
sess: InProcSession,
ctx: orchestrator.TurnContext,
final_reply: str,
result: orchestrator.TurnResult,
learner_turn: TurnRecord,
) -> None:
"""응답 완료 뒤 fast-loop 평가를 저장해 다음 발화의 임계 경로에서 분리한다."""
evaluation = await _evaluate_stream_turn(ctx, final_reply)
if evaluation is None:
return
if learner_turn.turn_id is not None:
saved = await session_persistence.replace_turn_evaluation(
turn_id=learner_turn.turn_id,
evaluation=evaluation,
)
if not saved:
logger.warning(
"turn fast-loop evaluation was not saved: session_id=%s turn_id=%s",
ctx.session_id,
learner_turn.turn_id,
)
return
learner_turn.evaluation = evaluation
cached = store.get(ctx.session_id)
if cached is not None:
for turn in cached.turns:
if learner_turn.turn_id and turn.turn_id == learner_turn.turn_id:
turn.evaluation = evaluation
break
if (
learner_turn.turn_id is None
and turn.speaker == "counselor"
and turn.turn_seq == learner_turn.turn_seq
):
turn.evaluation = evaluation
break
result.evaluation = evaluation
await turn_runtime.maybe_recharge_live_coach_credit(sess, ctx, result)
def _observe_stream_turn_evaluation_task(task: asyncio.Task[None]) -> None:
_STREAM_TURN_EVALUATION_TASKS.discard(task)
try:
task.result()
except asyncio.CancelledError:
logger.info("turn fast-loop evaluation background task cancelled")
except Exception:
logger.exception("turn fast-loop evaluation background task crashed")
def _schedule_stream_turn_evaluation(
*,
sess: InProcSession,
ctx: orchestrator.TurnContext,
final_reply: str,
result: orchestrator.TurnResult,
learner_turn: TurnRecord,
) -> asyncio.Task[None]:
task = asyncio.create_task(
_evaluate_and_persist_stream_turn(
sess=sess,
ctx=ctx,
final_reply=final_reply,
result=result,
learner_turn=learner_turn,
),
name=f"turn-evaluation:{ctx.session_id}:{result.turn_seq}",
)
_STREAM_TURN_EVALUATION_TASKS.add(task)
task.add_done_callback(_observe_stream_turn_evaluation_task)
return task
def _stream_result_from_done(
ctx: orchestrator.TurnContext,
final_reply: str,
@ -1649,15 +1730,22 @@ async def stream_turn(
).model_dump(),
}
if not finalized_turn:
evaluation = await _evaluate_stream_turn(ctx, final_reply)
result = _stream_result_from_done(
ctx, final_reply, data, evaluation
ctx, final_reply, data, None
)
await turn_runtime.finalize_completed_turn(
learner_turn = await turn_runtime.finalize_completed_turn(
sess,
ctx,
result,
context_prefix="session",
recharge_live_coach=False,
)
_schedule_stream_turn_evaluation(
sess=sess,
ctx=ctx,
final_reply=final_reply,
result=result,
learner_turn=learner_turn,
)
finalized_turn = True
yield {

View file

@ -158,14 +158,26 @@ _PROVIDER_EVENT_TYPE_FIELDS = ("event_type", "type", "kind", "label")
async def voice_health() -> JSONResponse:
"""Return voice service readiness."""
available = voice_service.is_available()
stt_available = voice_service.stt_available()
tts_available = voice_service.tts_available()
tts_provider = voice_service.tts_provider()
body = {
"status": "ok" if available else "degraded",
"available": available,
"stt_available": stt_available,
"tts_available": tts_available,
"stt_model": voice_svc.STT_MODEL,
"tts_model": voice_svc.TTS_MODEL,
"tts_model": (
voice_svc.HIGGS_TTS_MODEL
if tts_provider == "higgs"
else voice_svc.TTS_MODEL
),
"tts_provider": tts_provider,
"reason": None if available else "OPENAI_API_KEY is not configured",
"reason": (
None
if available
else "STT 또는 TTS provider가 준비되지 않았습니다"
),
}
return JSONResponse(body, status_code=200 if available else 503)
@ -192,12 +204,6 @@ async def voice_speech(
access_error = await _practice_access_error(learner)
if access_error is not None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=access_error)
if not voice_service.is_available():
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="OPENAI_API_KEY is not configured",
)
sess, err = await turn_runtime.load_owned_session(
body.session_id,
learner,
@ -224,6 +230,11 @@ async def voice_speech(
persona_code=sess.persona.code,
explicit_preset=None,
)
if not voice_service.tts_available(voice_preset):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="TTS provider is not configured",
)
try:
chunks = [
chunk.audio
@ -248,11 +259,11 @@ async def voice_speech(
)
return Response(
content=audio,
media_type="audio/mpeg",
media_type=voice_service.tts_media_type_for_voice(voice_preset),
headers={
"Cache-Control": "no-store",
"X-Vignette-TTS-Model": voice_svc.TTS_MODEL,
"X-Vignette-TTS-Provider": voice_service.tts_provider(),
"X-Vignette-TTS-Model": voice_service.tts_model_for_voice(voice_preset),
"X-Vignette-TTS-Provider": voice_service.tts_provider_for_voice(voice_preset),
},
)
@ -314,7 +325,7 @@ async def voice_ws(websocket: WebSocket) -> None:
"session_id": session_id,
"voice": voice_preset.openai_voice,
"preset": voice_preset.preset,
"tts_provider": voice_service.tts_provider(),
"tts_provider": voice_service.tts_provider_for_voice(voice_preset),
"state": "idle",
**bind_meta,
},
@ -751,7 +762,7 @@ async def _run_turn_and_speak(
"type": "state",
"state": "speaking",
"voice": context.voice_preset.openai_voice,
"tts_provider": voice_service.tts_provider(),
"tts_provider": voice_service.tts_provider_for_voice(context.voice_preset),
},
)
try:
@ -787,15 +798,35 @@ async def _load_voice_session(
def _client_turn_text_for_speech(sess: InProcSession, turn_seq: int) -> str | None:
"""Return the persisted client-visible reply for one completed turn."""
for turn in reversed(sess.turns):
if (
turn.turn_seq == turn_seq
and turn.speaker == "client"
and turn.is_visible_to("client")
):
text = (turn.text_masked or turn.text).strip()
return text or None
"""완료된 상담 턴 번호에 대응하는 client-visible 응답을 반환한다.
degraded 인메모리 미러는 상담자/내담자 쌍이 같은 논리 turn_seq를 쓰지만,
DB의 app.turns.seq는 발화마다 1 증가한다. DB 스냅샷에서 논리 1턴을
그대로 seq=1 찾으면 상담자 발화만 잡혀 TTS가 404 되므로 저장 형태를
명시적으로 구분한다.
"""
visible_clients = [
turn
for turn in sess.turns
if turn.speaker == "client" and turn.is_visible_to("client")
]
counselor_sequences = {
turn.turn_seq for turn in sess.turns if turn.speaker == "counselor"
}
paired_sequences = counselor_sequences.intersection(
turn.turn_seq for turn in visible_clients
)
if paired_sequences or not counselor_sequences:
match = next(
(turn for turn in reversed(visible_clients) if turn.turn_seq == turn_seq),
None,
)
else:
index = turn_seq - 1
match = visible_clients[index] if 0 <= index < len(visible_clients) else None
if match is not None:
text = (match.text_masked or match.text).strip()
return text or None
return None