feat: 운영 안정성과 세션 음성 경험 개선
This commit is contained in:
parent
facc4ad2d9
commit
c788343467
95 changed files with 8431 additions and 1785 deletions
|
|
@ -346,7 +346,12 @@ async def _runtime_tables_ready(conn) -> bool:
|
|||
to_regclass('app.persona_voice_map') IS NOT NULL AS has_persona_voice_map,
|
||||
to_regclass('app.auth_session') IS NOT NULL AS has_auth_session,
|
||||
to_regclass('app.user_preferences') IS NOT NULL AS has_preferences,
|
||||
to_regclass('app.admin_engine_config') IS NOT NULL AS has_engine_config,
|
||||
EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'app'
|
||||
AND table_name = 'admin_engine_config'
|
||||
AND column_name = 'reasoning_effort'
|
||||
) AS has_engine_config,
|
||||
EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'app'
|
||||
|
|
@ -650,11 +655,18 @@ async def ensure_runtime_tables() -> None:
|
|||
engine_mode TEXT NOT NULL,
|
||||
engine_url TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
reasoning_effort TEXT,
|
||||
updated_by TEXT,
|
||||
updated_at TIMESTAMPTZ
|
||||
)
|
||||
"""
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
ALTER TABLE app.admin_engine_config
|
||||
ADD COLUMN IF NOT EXISTS reasoning_effort TEXT
|
||||
"""
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS app.admin_health_event (
|
||||
|
|
@ -1226,6 +1238,15 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
|
|||
normalized_email = _normalize_email(data.email)
|
||||
normalized_external_id = _normalize_external_id(data.external_id, normalized_email)
|
||||
manual_external_id = f"email:{normalized_email}"
|
||||
desired_admin_access = (
|
||||
data.admin_access
|
||||
if data.admin_access is not None
|
||||
else (
|
||||
True
|
||||
if has_admin_access(normalized_email, data.role, False)
|
||||
else None
|
||||
)
|
||||
)
|
||||
desired_account_status = data.account_status or _initial_account_status(
|
||||
email=normalized_email,
|
||||
external_id=normalized_external_id,
|
||||
|
|
@ -1242,6 +1263,10 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
|
|||
display_name = COALESCE(NULLIF(display_name, ''), $3),
|
||||
cohort = COALESCE(cohort, $4),
|
||||
affiliation = COALESCE(NULLIF(affiliation, ''), $5),
|
||||
admin_access = CASE
|
||||
WHEN $7::boolean IS NULL THEN admin_access
|
||||
ELSE $7::boolean
|
||||
END,
|
||||
last_seen_at = now(),
|
||||
updated_at = now()
|
||||
WHERE user_id = (
|
||||
|
|
@ -1293,6 +1318,7 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
|
|||
_cohort_value(data.cohort_ids),
|
||||
data.affiliation or DEFAULT_AFFILIATION,
|
||||
manual_external_id,
|
||||
desired_admin_access,
|
||||
)
|
||||
if row is not None:
|
||||
user = _managed_user_from_row(row)
|
||||
|
|
@ -1314,13 +1340,13 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
|
|||
last_seen_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, COALESCE($9, false), $5, $6, $8, now(), now())
|
||||
VALUES ($1, $2, $3, $4, COALESCE($9::boolean, false), $5, $6, $8, now(), now())
|
||||
ON CONFLICT (external_id) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
display_name = COALESCE(NULLIF(EXCLUDED.display_name, ''), app.app_user.display_name),
|
||||
role = EXCLUDED.role,
|
||||
admin_access = CASE
|
||||
WHEN $9 IS NULL THEN app.app_user.admin_access
|
||||
WHEN $9::boolean IS NULL THEN app.app_user.admin_access
|
||||
ELSE EXCLUDED.admin_access
|
||||
END,
|
||||
cohort = COALESCE(EXCLUDED.cohort, app.app_user.cohort),
|
||||
|
|
@ -1369,7 +1395,7 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
|
|||
data.affiliation or DEFAULT_AFFILIATION,
|
||||
data.reactivate,
|
||||
desired_account_status,
|
||||
data.admin_access,
|
||||
desired_admin_access,
|
||||
)
|
||||
if row is None:
|
||||
_inactive_emails.add(normalized_email)
|
||||
|
|
@ -1382,6 +1408,8 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
|
|||
except InactiveUserError:
|
||||
raise
|
||||
except Exception:
|
||||
if not runtime_fallback_allowed():
|
||||
logger.exception("managed user persistence failed")
|
||||
require_runtime_fallback_allowed("managed user")
|
||||
current = _users.get(data.user_id or "") or _users.get(
|
||||
_email_index.get(normalized_email, "")
|
||||
|
|
@ -1405,7 +1433,7 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
|
|||
email=normalized_email,
|
||||
display_name=data.display_name,
|
||||
role=data.role,
|
||||
admin_access=data.admin_access,
|
||||
admin_access=desired_admin_access,
|
||||
account_status=fallback_account_status,
|
||||
cohort_ids=data.cohort_ids,
|
||||
user_id=fallback_uid,
|
||||
|
|
@ -2008,24 +2036,42 @@ async def get_session(raw_sid: str | None) -> SessionUser | None:
|
|||
key,
|
||||
)
|
||||
if row is not None:
|
||||
app_role = _app_role(row["role"])
|
||||
stored_admin_access = bool(
|
||||
_row_value(row, "admin_access", False)
|
||||
)
|
||||
effective_admin_access = has_admin_access(
|
||||
row["email"],
|
||||
app_role,
|
||||
stored_admin_access,
|
||||
)
|
||||
await conn.execute(
|
||||
"UPDATE app.auth_session SET last_seen_at = now() WHERE sid_hash = $1",
|
||||
key,
|
||||
)
|
||||
await conn.execute(
|
||||
"UPDATE app.app_user SET last_seen_at = now() WHERE user_id = $1",
|
||||
"""
|
||||
UPDATE app.app_user SET
|
||||
last_seen_at = now(),
|
||||
admin_access = CASE
|
||||
WHEN $2 THEN TRUE
|
||||
ELSE admin_access
|
||||
END,
|
||||
updated_at = CASE
|
||||
WHEN $2 AND NOT admin_access THEN now()
|
||||
ELSE updated_at
|
||||
END
|
||||
WHERE user_id = $1
|
||||
""",
|
||||
row["user_id"],
|
||||
effective_admin_access,
|
||||
)
|
||||
return SessionUser(
|
||||
user_id=str(row["user_id"]),
|
||||
email=row["email"],
|
||||
display_name=row["display_name"],
|
||||
role=_app_role(row["role"]),
|
||||
admin_access=has_admin_access(
|
||||
row["email"],
|
||||
_app_role(row["role"]),
|
||||
bool(_row_value(row, "admin_access", False)),
|
||||
),
|
||||
role=app_role,
|
||||
admin_access=effective_admin_access,
|
||||
super_admin=is_super_admin_email(row["email"]),
|
||||
account_status=_account_status(row["account_status"]),
|
||||
cohort_ids=_cohort_ids(row["cohort"]),
|
||||
|
|
|
|||
|
|
@ -13,12 +13,16 @@ from urllib.parse import urlsplit
|
|||
from pydantic import Field, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
from .contracts.engine_gateway import EngineProvider
|
||||
|
||||
# 엔진 어댑터 provider 플래그 (마스터플랜 §0, R1: claude -p 과금누수 회피)
|
||||
# claude_api = Anthropic Messages API 직결 (기본)
|
||||
# claude_cli = 로컬 claude -p 상주풀 (stream-json, 옵션/시연용)
|
||||
# codex_cli = 로컬 Codex CLI (app-server 모델 탐색 + exec 생성)
|
||||
# agy_cli = 로컬 Agy/Google Antigravity CLI
|
||||
# openai = OpenAI 호환 (폴백/평가 보조)
|
||||
# solar = 국내 모델 라우팅 (PII 민감구간 inference_geo:kr)
|
||||
EngineMode = Literal["claude_api", "claude_cli", "openai", "solar"]
|
||||
EngineMode = EngineProvider
|
||||
|
||||
|
||||
def _is_local_url(value: str) -> bool:
|
||||
|
|
@ -78,6 +82,12 @@ class Settings(BaseSettings):
|
|||
default="claude_api",
|
||||
validation_alias="ENGINE_MODE",
|
||||
)
|
||||
# 실시간 내담자 응답은 평가/저작 provider와 분리할 수 있다. Agy/Codex 같은
|
||||
# 범용 agent CLI의 도구 prompt와 프로세스 기동 지연을 상담 왕복에 강제하지 않는다.
|
||||
live_client_provider: EngineMode | None = Field(
|
||||
default=None,
|
||||
validation_alias="VIGNETTE_LIVE_CLIENT_PROVIDER",
|
||||
)
|
||||
engine_timeout: float = 120.0 # SSE 롱리브드 (50분 상담 대비, 스트림은 무제한 별도)
|
||||
engine_connect_timeout: float = 10.0
|
||||
admin_usage_budget_usd: float = Field(
|
||||
|
|
@ -136,6 +146,18 @@ class Settings(BaseSettings):
|
|||
default="",
|
||||
validation_alias="VIGNETTE_VOICE_POC_SAMPLE_TTS_DIR",
|
||||
)
|
||||
voice_tts_provider: Literal["openai", "higgs"] = Field(
|
||||
default="openai",
|
||||
validation_alias="VIGNETTE_VOICE_TTS_PROVIDER",
|
||||
)
|
||||
higgs_tts_url: str = Field(
|
||||
default="http://127.0.0.1:9881",
|
||||
validation_alias="VIGNETTE_HIGGS_TTS_URL",
|
||||
)
|
||||
higgs_tts_timeout_seconds: float = Field(
|
||||
default=300.0,
|
||||
validation_alias="VIGNETTE_HIGGS_TTS_TIMEOUT_SECONDS",
|
||||
)
|
||||
|
||||
# ── 세션/인증 (BFF OAuth 2.1, 토큰 서버 보관) ────────
|
||||
session_secret: str = Field(
|
||||
|
|
@ -310,6 +332,9 @@ class Settings(BaseSettings):
|
|||
forbidden.append("ALLOW_SEED_PERSONA_FALLBACK")
|
||||
if self.voice_poc_sample_tts_enabled:
|
||||
forbidden.append("VIGNETTE_VOICE_POC_SAMPLE_TTS")
|
||||
if self.voice_tts_provider == "higgs":
|
||||
# Higgs Audio v3 TTS 4B는 연구/비상업 라이선스이므로 로컬 dev에서만 쓴다.
|
||||
forbidden.append("VIGNETTE_VOICE_TTS_PROVIDER=higgs")
|
||||
if not self.oauth_google_client_id.strip():
|
||||
forbidden.append("OAUTH_GOOGLE_CLIENT_ID")
|
||||
if not self.oauth_google_client_secret.strip():
|
||||
|
|
|
|||
|
|
@ -15,6 +15,16 @@ from pydantic import BaseModel, Field
|
|||
AIRole = Literal["client", "counselor", "evaluator"]
|
||||
EngineMessageRole = Literal["system", "user", "assistant"]
|
||||
EngineGatewaySseEvent = Literal["token", "done", "error"]
|
||||
EngineProvider = Literal[
|
||||
"claude_cli",
|
||||
"claude_api",
|
||||
"codex_cli",
|
||||
"agy_cli",
|
||||
"openai",
|
||||
"solar",
|
||||
]
|
||||
ReasoningEffort = Literal["low", "medium", "high", "xhigh", "max", "ultra"]
|
||||
EngineCapabilitySource = Literal["live_cli", "live_api", "static_cli", "unavailable"]
|
||||
|
||||
ENGINE_GATEWAY_SSE_TOKEN: EngineGatewaySseEvent = "token"
|
||||
ENGINE_GATEWAY_SSE_DONE: EngineGatewaySseEvent = "done"
|
||||
|
|
@ -25,6 +35,30 @@ ENGINE_GATEWAY_SSE_EVENTS: tuple[EngineGatewaySseEvent, ...] = (
|
|||
ENGINE_GATEWAY_SSE_ERROR,
|
||||
)
|
||||
ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL = "gateway-default"
|
||||
ENGINE_PROVIDERS: tuple[EngineProvider, ...] = (
|
||||
"claude_cli",
|
||||
"claude_api",
|
||||
"codex_cli",
|
||||
"agy_cli",
|
||||
"openai",
|
||||
"solar",
|
||||
)
|
||||
ENGINE_REASONING_EFFORTS: tuple[ReasoningEffort, ...] = (
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
"ultra",
|
||||
)
|
||||
ENGINE_PROVIDER_DEFAULTS: dict[EngineProvider, tuple[str, Optional[ReasoningEffort]]] = {
|
||||
"claude_cli": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, "high"),
|
||||
"claude_api": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, "high"),
|
||||
"codex_cli": ("gpt-5.6-terra", "medium"),
|
||||
"agy_cli": ("gemini-3.6-flash-high", "high"),
|
||||
"openai": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, None),
|
||||
"solar": (ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, None),
|
||||
}
|
||||
|
||||
|
||||
def normalize_engine_gateway_model(model: Optional[str]) -> Optional[str]:
|
||||
|
|
@ -45,7 +79,9 @@ class EngineMessage(BaseModel):
|
|||
class GenerateRequest(BaseModel):
|
||||
ai_role: AIRole = "client"
|
||||
messages: list[EngineMessage]
|
||||
provider: Optional[EngineProvider] = None
|
||||
model: Optional[str] = None
|
||||
reasoning_effort: Optional[ReasoningEffort] = None
|
||||
max_tokens: int = 1024
|
||||
temperature: float = 0.7
|
||||
structured_schema: Optional[dict[str, Any]] = None
|
||||
|
|
@ -68,6 +104,26 @@ class GenerateResponse(BaseModel):
|
|||
structured: Optional[dict[str, Any]] = None
|
||||
|
||||
|
||||
class EngineModelOption(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
description: str = ""
|
||||
reasoning_efforts: list[ReasoningEffort] = Field(default_factory=list)
|
||||
default_reasoning_effort: Optional[ReasoningEffort] = None
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
class EngineCapabilitiesResponse(BaseModel):
|
||||
provider: EngineProvider
|
||||
available: bool
|
||||
source: EngineCapabilitySource
|
||||
models: list[EngineModelOption] = Field(default_factory=list)
|
||||
default_model: Optional[str] = None
|
||||
default_reasoning_effort: Optional[ReasoningEffort] = None
|
||||
detail: str = ""
|
||||
fetched_at: float
|
||||
|
||||
|
||||
def structured_payload_from_response(resp: GenerateResponse) -> dict[str, Any] | None:
|
||||
"""Return structured output, or a JSON object embedded in legacy text."""
|
||||
|
||||
|
|
|
|||
|
|
@ -148,7 +148,12 @@ async def healthcheck() -> bool:
|
|||
to_regclass('app.app_user') IS NOT NULL AS has_user,
|
||||
to_regclass('app.auth_session') IS NOT NULL AS has_auth_session,
|
||||
to_regclass('app.user_preferences') IS NOT NULL AS has_preferences,
|
||||
to_regclass('app.admin_engine_config') IS NOT NULL AS has_engine_config,
|
||||
EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'app'
|
||||
AND table_name = 'admin_engine_config'
|
||||
AND column_name = 'reasoning_effort'
|
||||
) AS has_engine_config,
|
||||
to_regclass('app.admin_health_event') IS NOT NULL AS has_admin_health_event,
|
||||
to_regclass('app.admin_health_daily_rollup') IS NOT NULL AS has_admin_health_daily_rollup,
|
||||
to_regclass('app.support_ticket') IS NOT NULL AS has_support_ticket,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
"""엔진 게이트웨이 HTTP 클라이언트.
|
||||
|
||||
⚠️ 게이트웨이 자체(apps/api/engine_gateway/)는 람다가 직접 만든다. 여기는 *호출부*만.
|
||||
게이트웨이가 provider 라우팅(claude_api/claude_cli/openai/solar)·캐싱·상주 claude -p 풀을
|
||||
흡수한다(마스터플랜 §0, R1). 이 백엔드는 ENGINE_URL 로 HTTP 호출만 한다.
|
||||
게이트웨이가 provider 라우팅(claude_cli/claude_api/codex_cli/agy_cli/openai/solar)·
|
||||
모델 탐색·캐싱·상주 claude -p 풀을 흡수한다(마스터플랜 §0, R1).
|
||||
이 백엔드는 ENGINE_URL로 HTTP 호출만 한다.
|
||||
|
||||
계약 (람다와 합의할 게이트웨이 API):
|
||||
POST {ENGINE_URL}/v1/generate — 단발 생성 (평가 deep-loop 등)
|
||||
POST {ENGINE_URL}/v1/stream — SSE 토큰 스트림 (내담자 AI 응답)
|
||||
GET {ENGINE_URL}/v1/capabilities — provider별 사용 가능 모델·추론 강도
|
||||
GET {ENGINE_URL}/health
|
||||
|
||||
요청 바디는 3-AI 역할별 system 레이어(L0~L6, 설계서 §1.2)를 게이트웨이에 넘기되,
|
||||
|
|
@ -23,11 +25,14 @@ import httpx
|
|||
from .config import settings
|
||||
from .contracts.engine_gateway import (
|
||||
AIRole as AIRole,
|
||||
EngineCapabilitiesResponse,
|
||||
EngineMessage as EngineMessage,
|
||||
EngineGatewaySseLineDecoder,
|
||||
EngineGatewaySsePacket,
|
||||
EngineProvider,
|
||||
GenerateRequest,
|
||||
GenerateResponse,
|
||||
ReasoningEffort,
|
||||
StreamRequest,
|
||||
normalize_engine_gateway_model,
|
||||
)
|
||||
|
|
@ -43,7 +48,9 @@ class EngineClient:
|
|||
def __init__(self, base_url: Optional[str] = None) -> None:
|
||||
self.base_url = (base_url or settings.engine_url).rstrip("/")
|
||||
self.engine_mode = settings.engine_mode
|
||||
self.live_client_provider: Optional[EngineProvider] = settings.live_client_provider
|
||||
self.default_model: Optional[str] = None
|
||||
self.default_reasoning_effort: Optional[ReasoningEffort] = None
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
|
|
@ -71,8 +78,9 @@ class EngineClient:
|
|||
self,
|
||||
*,
|
||||
base_url: str,
|
||||
engine_mode: str,
|
||||
engine_mode: EngineProvider,
|
||||
default_model: Optional[str] = None,
|
||||
default_reasoning_effort: Optional[ReasoningEffort] = None,
|
||||
) -> None:
|
||||
next_url = base_url.rstrip("/")
|
||||
next_model = normalize_engine_gateway_model(default_model)
|
||||
|
|
@ -81,6 +89,7 @@ class EngineClient:
|
|||
self.base_url = next_url
|
||||
self.engine_mode = engine_mode
|
||||
self.default_model = next_model
|
||||
self.default_reasoning_effort = default_reasoning_effort
|
||||
if self._client is not None and url_changed:
|
||||
old_client = self._client
|
||||
self._client = self._new_client()
|
||||
|
|
@ -88,8 +97,22 @@ class EngineClient:
|
|||
|
||||
def _payload(self, req: GenerateRequest) -> dict[str, Any]:
|
||||
payload = req.model_dump(exclude_none=True)
|
||||
if self.default_model and "model" not in payload:
|
||||
provider = self.engine_mode
|
||||
if req.ai_role == "client" and req.session_id and self.live_client_provider:
|
||||
provider = self.live_client_provider
|
||||
if "provider" not in payload:
|
||||
payload["provider"] = provider
|
||||
# 관리자 기본 모델/추론 강도는 그 provider에 속한 값이다. 실시간 lane이
|
||||
# 다른 provider면 잘못된 모델 slug를 넘기지 않고 해당 provider 기본값을 쓴다.
|
||||
same_provider = payload["provider"] == self.engine_mode
|
||||
if same_provider and self.default_model and "model" not in payload:
|
||||
payload["model"] = self.default_model
|
||||
if (
|
||||
same_provider
|
||||
and self.default_reasoning_effort
|
||||
and "reasoning_effort" not in payload
|
||||
):
|
||||
payload["reasoning_effort"] = self.default_reasoning_effort
|
||||
return payload
|
||||
|
||||
@property
|
||||
|
|
@ -103,7 +126,12 @@ class EngineClient:
|
|||
|
||||
async def health_detail(self) -> dict[str, Any]:
|
||||
try:
|
||||
r = await self.client.get("/ready")
|
||||
params: dict[str, str] = {"provider": self.engine_mode}
|
||||
if self.default_model:
|
||||
params["model"] = self.default_model
|
||||
if self.default_reasoning_effort:
|
||||
params["reasoning_effort"] = self.default_reasoning_effort
|
||||
r = await self.client.get("/ready", params=params)
|
||||
if r.status_code == 404:
|
||||
live = await self.client.get("/health")
|
||||
return {
|
||||
|
|
@ -129,6 +157,33 @@ class EngineClient:
|
|||
"status_code": None,
|
||||
}
|
||||
|
||||
async def capabilities(
|
||||
self,
|
||||
*,
|
||||
provider: EngineProvider,
|
||||
base_url: str | None = None,
|
||||
force: bool = False,
|
||||
) -> EngineCapabilitiesResponse:
|
||||
target_url = (base_url or self.base_url).rstrip("/")
|
||||
params = {"provider": provider, "force": str(force).lower()}
|
||||
try:
|
||||
if target_url == self.base_url:
|
||||
response = await self.client.get("/v1/capabilities", params=params)
|
||||
else:
|
||||
async with httpx.AsyncClient(
|
||||
base_url=target_url,
|
||||
timeout=httpx.Timeout(30, connect=settings.engine_connect_timeout),
|
||||
) as client:
|
||||
response = await client.get("/v1/capabilities", params=params)
|
||||
response.raise_for_status()
|
||||
return EngineCapabilitiesResponse.model_validate(response.json())
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise EngineError(
|
||||
f"engine capabilities {exc.response.status_code}: {exc.response.text}"
|
||||
) from exc
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
raise EngineError(f"engine capabilities unavailable: {exc}") from exc
|
||||
|
||||
async def generate(self, req: GenerateRequest) -> GenerateResponse:
|
||||
"""단발 생성."""
|
||||
try:
|
||||
|
|
@ -172,6 +227,18 @@ class EngineClient:
|
|||
if packet is not None:
|
||||
yield packet
|
||||
|
||||
async def close_session(self, session_id: str) -> bool:
|
||||
"""회기 종료 시 게이트웨이의 상주 페르소나 프로세스를 회수한다."""
|
||||
if not session_id:
|
||||
return False
|
||||
try:
|
||||
response = await self.client.delete(f"/session/{session_id}")
|
||||
response.raise_for_status()
|
||||
return bool(response.json().get("closed"))
|
||||
except (httpx.HTTPError, ValueError):
|
||||
# DB 회기 종료 성공을 게이트웨이 정리 실패 때문에 되돌리지는 않는다.
|
||||
return False
|
||||
|
||||
|
||||
# 앱 전역 싱글톤 (main lifespan 에서 startup/shutdown)
|
||||
engine_client = EngineClient()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"""음성 캐스케이드 — OpenAI STT(전사) + TTS(멀티보이스) 어댑터.
|
||||
"""음성 캐스케이드 — OpenAI STT + OpenAI/Higgs TTS 어댑터.
|
||||
|
||||
MASTERPLAN '음성 필수'(한신대 요구) / DESIGN_CONCEPT §5.2(음성 오브 4상태) / §4.3(립싱크 RMS):
|
||||
STT : OpenAI /v1/audio/transcriptions (gpt-4o-transcribe | whisper-1). 학습자 음성 → 텍스트.
|
||||
TTS : OpenAI /v1/audio/speech (gpt-4o-mini-tts | tts-1). 내담자 텍스트 → 음성(페르소나 voice).
|
||||
TTS : OpenAI /v1/audio/speech 또는 로컬 Higgs v3. 내담자 텍스트 → 음성.
|
||||
|
||||
설계 원칙(이 모듈의 경계):
|
||||
- 순수 어댑터: httpx 로 OpenAI 음성 엔드포인트만 호출한다. 상담 로직(orchestrator)·상태머신은
|
||||
|
|
@ -34,6 +34,7 @@ from ..paths import repo_root, repo_path
|
|||
OPENAI_BASE_URL = "https://api.openai.com/v1"
|
||||
STT_ENDPOINT = "/audio/transcriptions"
|
||||
TTS_ENDPOINT = "/audio/speech"
|
||||
HIGGS_TTS_ENDPOINT = "/tts"
|
||||
|
||||
# STT 모델: gpt-4o-transcribe(고품질) — 미가용 폴백은 whisper-1.
|
||||
STT_MODEL = "gpt-4o-transcribe"
|
||||
|
|
@ -41,6 +42,7 @@ STT_MODEL_FALLBACK = "whisper-1"
|
|||
# TTS 모델: gpt-4o-mini-tts(저지연·표현력) — 폴백 tts-1.
|
||||
TTS_MODEL = "gpt-4o-mini-tts"
|
||||
TTS_MODEL_FALLBACK = "tts-1"
|
||||
HIGGS_TTS_MODEL = "higgs-audio-v3-tts-4b"
|
||||
|
||||
# 전사 언어 힌트(상담은 한국어). OpenAI 는 ISO-639-1.
|
||||
STT_LANGUAGE = "ko"
|
||||
|
|
@ -76,6 +78,25 @@ _POC_SAMPLE_TTS_KEYWORDS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|||
),
|
||||
)
|
||||
|
||||
_HIGGS_DELIVERY_TAGS: tuple[tuple[tuple[str, ...], str], ...] = (
|
||||
(
|
||||
("엄마", "비밀", "말하지", "불안", "무서", "걱정", "들키", "갈래"),
|
||||
"<|emotion:fear|><|prosody:speed_fast|><|prosody:pitch_high|>",
|
||||
),
|
||||
(
|
||||
("잠", "피곤", "무거", "아무것도", "지쳐", "힘들", "에너지"),
|
||||
"<|emotion:sadness|><|prosody:speed_slow|><|prosody:expressive_low|>",
|
||||
),
|
||||
(
|
||||
("오늘은", "친구", "웃", "괜찮았", "좋았", "해냈"),
|
||||
"<|emotion:contentment|><|prosody:speed_fast|>",
|
||||
),
|
||||
(
|
||||
("괜찮", "들어", "고마", "선생님", "편해", "조금", "말해"),
|
||||
"<|emotion:relief|><|prosody:speed_slow|>",
|
||||
),
|
||||
)
|
||||
|
||||
# OpenAI 공식 voice 풀(2026 기준): alloy, ash, ballad, coral, echo, fable,
|
||||
# nova, onyx, sage, shimmer, verse. 페르소나 톤별로 골라 매핑한다.
|
||||
_OPENAI_VOICES = {
|
||||
|
|
@ -236,6 +257,35 @@ def resolve_voice_from_map(
|
|||
)
|
||||
|
||||
|
||||
def build_higgs_prompt(text: str, voice: VoicePreset) -> str:
|
||||
"""합성 seed의 화자 정체성을 지키면서 감정·속도 태그를 첫 단어 뒤에 넣는다."""
|
||||
normalized = text.casefold()
|
||||
tags = ""
|
||||
if voice.preset == POC_SAMPLE_TTS_PRESET:
|
||||
for keywords, candidate in _HIGGS_DELIVERY_TAGS:
|
||||
if any(keyword.casefold() in normalized for keyword in keywords):
|
||||
tags = candidate
|
||||
break
|
||||
if not tags:
|
||||
tags = (
|
||||
"<|emotion:helplessness|><|prosody:speed_slow|>"
|
||||
"<|prosody:expressive_low|>"
|
||||
)
|
||||
elif voice.rate <= 0.85:
|
||||
tags = "<|prosody:speed_slow|>"
|
||||
elif voice.rate >= 1.15:
|
||||
tags = "<|prosody:speed_fast|>"
|
||||
if not tags:
|
||||
return text
|
||||
|
||||
# Higgs 강한 감정 태그를 맨 앞에 두면 reference 화자가 흔들릴 수 있다. 첫 단어로
|
||||
# 화자를 먼저 고정한 뒤 태그 다음 단어를 공백 없이 이어 붙인다.
|
||||
match = re.match(r"^(\S+\s+)(.+)$", text, flags=re.DOTALL)
|
||||
if match:
|
||||
return f"{match.group(1)}{tags}{match.group(2).lstrip()}"
|
||||
return tags + text
|
||||
|
||||
|
||||
# 비언어 지문 패턴: (…)·(…)·[…]·【…】. 내담자 발화의 무대지시(고개 끄덕/한숨/침묵 등).
|
||||
_STAGE_DIRECTION_RE = re.compile(r"[\((\[【][^\))\]】]*[\))\]】]")
|
||||
|
||||
|
|
@ -311,7 +361,7 @@ def assess_end_of_turn(
|
|||
# OpenAI 음성 서비스
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
class VoiceService:
|
||||
"""OpenAI STT/TTS 어댑터. 앱 수명주기 동안 1 인스턴스 재사용(httpx 풀 공유)."""
|
||||
"""OpenAI STT와 선택형 OpenAI/Higgs TTS 어댑터(httpx 풀 공유)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
|
@ -321,10 +371,27 @@ class VoiceService:
|
|||
poc_sample_tts_enabled: Optional[bool] = None,
|
||||
environment: Optional[str] = None,
|
||||
poc_sample_tts_dir: Optional[str | Path] = None,
|
||||
tts_provider: Optional[str] = None,
|
||||
higgs_base_url: Optional[str] = None,
|
||||
higgs_timeout_seconds: Optional[float] = None,
|
||||
) -> None:
|
||||
self._api_key = (api_key if api_key is not None else settings.openai_api_key) or ""
|
||||
self._base_url = (base_url or settings.openai_base_url or OPENAI_BASE_URL).rstrip("/")
|
||||
self._environment = environment if environment is not None else settings.environment
|
||||
self._tts_provider = (
|
||||
tts_provider if tts_provider is not None else settings.voice_tts_provider
|
||||
).strip().lower()
|
||||
self._higgs_base_url = (
|
||||
higgs_base_url if higgs_base_url is not None else settings.higgs_tts_url
|
||||
).rstrip("/")
|
||||
self._higgs_timeout_seconds = max(
|
||||
1.0,
|
||||
float(
|
||||
higgs_timeout_seconds
|
||||
if higgs_timeout_seconds is not None
|
||||
else settings.higgs_tts_timeout_seconds
|
||||
),
|
||||
)
|
||||
self._poc_sample_tts_enabled = (
|
||||
bool(settings.voice_poc_sample_tts_enabled)
|
||||
if poc_sample_tts_enabled is None
|
||||
|
|
@ -340,35 +407,77 @@ class VoiceService:
|
|||
sample_dir = repo_root() / sample_dir
|
||||
self._poc_sample_tts_dir = sample_dir
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
self._higgs_client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
# ── 수명주기 ──────────────────────────────────────────
|
||||
async def startup(self) -> None:
|
||||
if not self._api_key:
|
||||
return # 키 없으면 클라이언트도 안 띄움(degraded). 라우트가 503 처리.
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self._base_url,
|
||||
headers={"Authorization": f"Bearer {self._api_key}"},
|
||||
timeout=httpx.Timeout(60.0, connect=10.0),
|
||||
)
|
||||
if self._api_key:
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self._base_url,
|
||||
headers={"Authorization": f"Bearer {self._api_key}"},
|
||||
timeout=httpx.Timeout(60.0, connect=10.0),
|
||||
)
|
||||
if self._higgs_enabled():
|
||||
self._higgs_client = httpx.AsyncClient(
|
||||
base_url=self._higgs_base_url,
|
||||
timeout=httpx.Timeout(self._higgs_timeout_seconds, connect=3.0),
|
||||
)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
if self._client is not None:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
if self._higgs_client is not None:
|
||||
await self._higgs_client.aclose()
|
||||
self._higgs_client = None
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""음성 기능 가용 여부(키 설정됨). 라우트가 핸드셰이크에서 검사."""
|
||||
"""마이크 캐스케이드(STT+TTS) 전체 가용 여부."""
|
||||
return self.stt_available() and self.tts_available()
|
||||
|
||||
def stt_available(self) -> bool:
|
||||
return bool(self._api_key)
|
||||
|
||||
def tts_available(self, voice: VoicePreset | None = None) -> bool:
|
||||
if self._higgs_enabled() and (voice is None or self._should_use_higgs_tts(voice)):
|
||||
return True
|
||||
if self._poc_sample_tts_available():
|
||||
return True
|
||||
return bool(self._api_key)
|
||||
|
||||
def tts_provider(self) -> str:
|
||||
if self._higgs_enabled():
|
||||
return "higgs"
|
||||
if self._poc_sample_tts_available():
|
||||
return "p1-sample-poc"
|
||||
if self._tts_provider == "higgs" and self._environment != "dev":
|
||||
return "disabled-non-dev"
|
||||
if self._api_key:
|
||||
return "openai"
|
||||
if self._poc_sample_tts_enabled and self._environment != "dev":
|
||||
return "disabled-non-dev"
|
||||
return "unavailable"
|
||||
|
||||
def tts_provider_for_voice(self, voice: VoicePreset) -> str:
|
||||
if self._should_use_higgs_tts(voice):
|
||||
return "higgs"
|
||||
if self._should_use_poc_sample_tts(voice):
|
||||
return "p1-sample-poc"
|
||||
return "openai" if self._api_key else "unavailable"
|
||||
|
||||
def tts_model_for_voice(self, voice: VoicePreset) -> str:
|
||||
return HIGGS_TTS_MODEL if self._should_use_higgs_tts(voice) else TTS_MODEL
|
||||
|
||||
def tts_media_type_for_voice(self, voice: VoicePreset) -> str:
|
||||
return "audio/wav" if self._should_use_higgs_tts(voice) else "audio/mpeg"
|
||||
|
||||
def _higgs_enabled(self) -> bool:
|
||||
return self._tts_provider == "higgs" and self._environment == "dev"
|
||||
|
||||
def _should_use_higgs_tts(self, voice: VoicePreset) -> bool:
|
||||
# 현재 권리 안전한 synthetic reference는 P1 서연 프리셋만 보유한다.
|
||||
return self._higgs_enabled() and voice.preset == POC_SAMPLE_TTS_PRESET
|
||||
|
||||
def poc_sample_tts_available(self) -> bool:
|
||||
return self._poc_sample_tts_available()
|
||||
|
||||
|
|
@ -402,6 +511,17 @@ class VoiceService:
|
|||
)
|
||||
return self._client
|
||||
|
||||
@property
|
||||
def _higgs_http(self) -> httpx.AsyncClient:
|
||||
if not self._higgs_enabled():
|
||||
raise VoiceUnavailable("Higgs TTS는 로컬 dev 환경에서만 사용할 수 있습니다.")
|
||||
if self._higgs_client is None:
|
||||
self._higgs_client = httpx.AsyncClient(
|
||||
base_url=self._higgs_base_url,
|
||||
timeout=httpx.Timeout(self._higgs_timeout_seconds, connect=3.0),
|
||||
)
|
||||
return self._higgs_client
|
||||
|
||||
# ── STT (transcriptions) ─────────────────────────────
|
||||
async def transcribe(
|
||||
self,
|
||||
|
|
@ -456,16 +576,20 @@ class VoiceService:
|
|||
model: str = TTS_MODEL,
|
||||
response_format: str = TTS_RESPONSE_FORMAT,
|
||||
) -> AsyncIterator[TTSChunk]:
|
||||
"""텍스트 → 음성 스트리밍(OpenAI /audio/speech). 청크 + RMS 힌트 yield.
|
||||
"""텍스트 → 음성 스트리밍(OpenAI 또는 로컬 Higgs). 오디오 청크를 yield한다.
|
||||
|
||||
설계 §5.2 'speaking' 상태: 오디오 청크를 흘리며 진폭 힌트(립싱크)를 같이 보낸다.
|
||||
키 없으면 VoiceUnavailable. OpenAI 오류는 RuntimeError 전파.
|
||||
선택 provider가 준비되지 않으면 VoiceUnavailable, 전송 오류는 RuntimeError로 전파한다.
|
||||
"""
|
||||
# 비언어 지문((고개 끄덕)·(한숨)·[침묵])은 음성으로 읽지 않는다. 자막엔 남고
|
||||
# 아바타 애니메이션이 표현한다. 지문만 있는 발화는 합성 생략(빈 오디오).
|
||||
text = speakable_text(text)
|
||||
if not text:
|
||||
return
|
||||
if self._should_use_higgs_tts(voice):
|
||||
async for chunk in self._synthesize_higgs_tts(text, voice):
|
||||
yield chunk
|
||||
return
|
||||
if self._should_use_poc_sample_tts(voice):
|
||||
async for chunk in self._synthesize_poc_sample_tts(text):
|
||||
yield chunk
|
||||
|
|
@ -516,6 +640,31 @@ class VoiceService:
|
|||
if chunk:
|
||||
yield TTSChunk(audio=chunk)
|
||||
|
||||
async def _synthesize_higgs_tts(
|
||||
self, text: str, voice: VoicePreset
|
||||
) -> AsyncIterator[TTSChunk]:
|
||||
payload = {
|
||||
"text": build_higgs_prompt(text, voice),
|
||||
"preset": voice.preset,
|
||||
}
|
||||
try:
|
||||
async with self._higgs_http.stream(
|
||||
"POST", HIGGS_TTS_ENDPOINT, json=payload
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
async for chunk in response.aiter_bytes(chunk_size=POC_SAMPLE_TTS_CHUNK_SIZE):
|
||||
if chunk:
|
||||
yield TTSChunk(audio=chunk)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
body = ""
|
||||
try:
|
||||
body = (await exc.response.aread()).decode("utf-8", "ignore")[:200]
|
||||
except Exception:
|
||||
pass
|
||||
raise RuntimeError(f"Higgs TTS {exc.response.status_code}: {body}") from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise RuntimeError(f"Higgs TTS transport error: {exc}") from exc
|
||||
|
||||
def _select_poc_sample_id(self, text: str) -> str:
|
||||
normalized = text.casefold()
|
||||
for sample_id, keywords in _POC_SAMPLE_TTS_KEYWORDS:
|
||||
|
|
@ -576,6 +725,7 @@ __all__ = [
|
|||
"resolve_voice",
|
||||
"resolve_voice_from_map",
|
||||
"build_tts_payload",
|
||||
"build_higgs_prompt",
|
||||
"assess_end_of_turn",
|
||||
"EOT_SILENCE_THRESHOLD_MS",
|
||||
"PRESET_TO_OPENAI_VOICE",
|
||||
|
|
@ -583,4 +733,5 @@ __all__ = [
|
|||
"DEFAULT_OPENAI_VOICE",
|
||||
"STT_MODEL",
|
||||
"TTS_MODEL",
|
||||
"HIGGS_TTS_MODEL",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ import unittest
|
|||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from .contracts.engine_gateway import EngineCapabilitiesResponse, EngineModelOption
|
||||
from .deps import Principal, Role
|
||||
from .routes import admin as admin_routes
|
||||
|
||||
|
|
@ -572,3 +575,224 @@ class AdminOpsTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertIn("measure_name IN ('self_efficacy','skill_proficiency','training_satisfaction')", schema)
|
||||
self.assertIn("ALTER TABLE app.learner_prepost_measure ENABLE ROW LEVEL SECURITY", schema)
|
||||
self.assertIn("CREATE POLICY p_learner_prepost_measure_insert", schema)
|
||||
|
||||
|
||||
class AdminEngineConfigTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self) -> None:
|
||||
self.previous_config = admin_routes._ENGINE_CONFIG
|
||||
admin_routes._ENGINE_CONFIG = admin_routes.AdminEngineConfigResponse(
|
||||
engine_mode="claude_cli",
|
||||
engine_url="http://127.0.0.1:9099",
|
||||
model="gateway-default",
|
||||
reasoning_effort="high",
|
||||
durable=True,
|
||||
source="database",
|
||||
)
|
||||
self.principal = Principal(
|
||||
user_id="00000000-0000-0000-0000-000000000099",
|
||||
role=Role.ADMIN,
|
||||
cohort_ids=[],
|
||||
email="admin@twentyoz.kr",
|
||||
display_name="Admin",
|
||||
)
|
||||
|
||||
async def asyncTearDown(self) -> None:
|
||||
admin_routes._ENGINE_CONFIG = self.previous_config
|
||||
|
||||
async def test_capabilities_endpoint_uses_requested_gateway_url(self) -> None:
|
||||
capabilities = EngineCapabilitiesResponse(
|
||||
provider="codex_cli",
|
||||
available=True,
|
||||
source="live_cli",
|
||||
models=[
|
||||
EngineModelOption(
|
||||
id="gpt-5.6-terra",
|
||||
label="GPT-5.6-Terra",
|
||||
reasoning_efforts=["medium"],
|
||||
default_reasoning_effort="medium",
|
||||
)
|
||||
],
|
||||
default_model="gpt-5.6-terra",
|
||||
default_reasoning_effort="medium",
|
||||
fetched_at=1,
|
||||
)
|
||||
with patch.object(
|
||||
admin_routes.engine_client,
|
||||
"capabilities",
|
||||
AsyncMock(return_value=capabilities),
|
||||
) as lookup:
|
||||
result = await admin_routes.get_engine_capabilities(
|
||||
self.principal,
|
||||
engine_mode="codex_cli",
|
||||
engine_url="http://127.0.0.1:9199/",
|
||||
force=True,
|
||||
)
|
||||
|
||||
self.assertEqual(result.default_model, "gpt-5.6-terra")
|
||||
lookup.assert_awaited_once_with(
|
||||
provider="codex_cli",
|
||||
base_url="http://127.0.0.1:9199",
|
||||
force=True,
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
admin_routes.engine_client,
|
||||
"capabilities",
|
||||
AsyncMock(return_value=capabilities),
|
||||
) as current_lookup:
|
||||
await admin_routes.get_engine_capabilities(
|
||||
self.principal,
|
||||
engine_mode="codex_cli",
|
||||
engine_url=None,
|
||||
force=False,
|
||||
)
|
||||
current_lookup.assert_awaited_once_with(
|
||||
provider="codex_cli",
|
||||
base_url="http://127.0.0.1:9099",
|
||||
force=False,
|
||||
)
|
||||
|
||||
async def test_patch_rejects_model_missing_from_live_catalog(self) -> None:
|
||||
capabilities = EngineCapabilitiesResponse(
|
||||
provider="codex_cli",
|
||||
available=True,
|
||||
source="live_cli",
|
||||
models=[],
|
||||
fetched_at=1,
|
||||
)
|
||||
with patch.object(
|
||||
admin_routes.engine_client,
|
||||
"capabilities",
|
||||
AsyncMock(return_value=capabilities),
|
||||
):
|
||||
with self.assertRaises(HTTPException) as caught:
|
||||
await admin_routes.patch_engine_config(
|
||||
admin_routes.AdminEngineConfigPatch(
|
||||
engine_mode="codex_cli",
|
||||
model="made-up-model",
|
||||
reasoning_effort="medium",
|
||||
),
|
||||
self.principal,
|
||||
)
|
||||
|
||||
self.assertEqual(caught.exception.status_code, 422)
|
||||
self.assertIn("사용할 수 없는 모델", caught.exception.detail)
|
||||
|
||||
async def test_patch_persists_and_applies_reasoning_effort(self) -> None:
|
||||
capabilities = EngineCapabilitiesResponse(
|
||||
provider="codex_cli",
|
||||
available=True,
|
||||
source="live_cli",
|
||||
models=[
|
||||
EngineModelOption(
|
||||
id="gpt-5.6-terra",
|
||||
label="GPT-5.6-Terra",
|
||||
reasoning_efforts=["low", "medium", "high"],
|
||||
default_reasoning_effort="medium",
|
||||
)
|
||||
],
|
||||
default_model="gpt-5.6-terra",
|
||||
default_reasoning_effort="medium",
|
||||
fetched_at=1,
|
||||
)
|
||||
|
||||
class ConfigConn:
|
||||
def __init__(self):
|
||||
self.query = ""
|
||||
self.args = ()
|
||||
|
||||
async def fetchrow(self, query, *args):
|
||||
self.query = query
|
||||
self.args = args
|
||||
return {
|
||||
"engine_mode": args[0],
|
||||
"engine_url": args[1],
|
||||
"model": args[2],
|
||||
"reasoning_effort": args[3],
|
||||
"updated_by": args[4],
|
||||
"updated_at": None,
|
||||
}
|
||||
|
||||
conn = ConfigConn()
|
||||
|
||||
class ConfigPool:
|
||||
def acquire(self):
|
||||
return _Acquire(conn)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
admin_routes.engine_client,
|
||||
"capabilities",
|
||||
AsyncMock(return_value=capabilities),
|
||||
),
|
||||
patch.object(admin_routes, "get_pool", return_value=ConfigPool()),
|
||||
patch.object(
|
||||
admin_routes.engine_client,
|
||||
"configure",
|
||||
AsyncMock(),
|
||||
) as configure,
|
||||
):
|
||||
result = await admin_routes.patch_engine_config(
|
||||
admin_routes.AdminEngineConfigPatch(
|
||||
engine_mode="codex_cli",
|
||||
engine_url="http://127.0.0.1:9099",
|
||||
model="gpt-5.6-terra",
|
||||
reasoning_effort="medium",
|
||||
),
|
||||
self.principal,
|
||||
)
|
||||
|
||||
self.assertEqual(result.reasoning_effort, "medium")
|
||||
self.assertIn("reasoning_effort", conn.query)
|
||||
self.assertEqual(conn.args[3], "medium")
|
||||
configure.assert_awaited_once_with(
|
||||
base_url="http://127.0.0.1:9099",
|
||||
engine_mode="codex_cli",
|
||||
default_model="gpt-5.6-terra",
|
||||
default_reasoning_effort="medium",
|
||||
)
|
||||
|
||||
async def test_patch_can_explicitly_clear_reasoning_effort(self) -> None:
|
||||
capabilities = EngineCapabilitiesResponse(
|
||||
provider="claude_api",
|
||||
available=True,
|
||||
source="live_api",
|
||||
models=[EngineModelOption(id="claude-legacy", label="Claude Legacy")],
|
||||
default_model="claude-legacy",
|
||||
fetched_at=1,
|
||||
)
|
||||
|
||||
class ConfigConn:
|
||||
async def fetchrow(self, _query, *args):
|
||||
return {
|
||||
"engine_mode": args[0],
|
||||
"engine_url": args[1],
|
||||
"model": args[2],
|
||||
"reasoning_effort": args[3],
|
||||
"updated_by": args[4],
|
||||
"updated_at": None,
|
||||
}
|
||||
|
||||
class ConfigPool:
|
||||
def acquire(self):
|
||||
return _Acquire(ConfigConn())
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
admin_routes.engine_client,
|
||||
"capabilities",
|
||||
AsyncMock(return_value=capabilities),
|
||||
),
|
||||
patch.object(admin_routes, "get_pool", return_value=ConfigPool()),
|
||||
patch.object(admin_routes.engine_client, "configure", AsyncMock()),
|
||||
):
|
||||
result = await admin_routes.patch_engine_config(
|
||||
admin_routes.AdminEngineConfigPatch(
|
||||
engine_mode="claude_api",
|
||||
model="claude-legacy",
|
||||
reasoning_effort=None,
|
||||
),
|
||||
self.principal,
|
||||
)
|
||||
|
||||
self.assertIsNone(result.reasoning_effort)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from __future__ import annotations
|
|||
import unittest
|
||||
import base64
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlencode, urlsplit
|
||||
|
||||
|
|
@ -390,6 +391,36 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
|
|||
hoonjung_role = auth_routes._role_for_email("hoonjungkoo@hs.ac.kr")
|
||||
self.assertEqual(hoonjung_role, Role.ADMIN)
|
||||
|
||||
async def test_existing_primary_role_super_admin_persists_admin_access_on_login(
|
||||
self,
|
||||
) -> None:
|
||||
email = "persistent-root@twentyoz.kr"
|
||||
existing = _managed_user(email=email, role="learner", admin_access=False)
|
||||
auth_sessions._users[existing.user_id] = existing
|
||||
auth_sessions._email_index[email] = existing.user_id
|
||||
|
||||
with (
|
||||
patched_settings(
|
||||
environment="dev",
|
||||
auth_super_admin_emails=[email],
|
||||
auth_admin_emails=[],
|
||||
auth_teacher_emails=[],
|
||||
auth_approved_emails=[],
|
||||
),
|
||||
patch.object(auth_sessions, "get_pool", side_effect=RuntimeError("no db")),
|
||||
):
|
||||
_, user = await auth_sessions.create_session(
|
||||
email=email,
|
||||
display_name="Persistent Root",
|
||||
role="learner",
|
||||
external_id="google:persistent-root",
|
||||
)
|
||||
|
||||
self.assertEqual(user.role, "learner")
|
||||
self.assertTrue(user.super_admin)
|
||||
self.assertTrue(user.admin_access)
|
||||
self.assertTrue(auth_sessions._users[existing.user_id].admin_access)
|
||||
|
||||
async def test_pending_provider_user_enqueues_account_approval_notification(self) -> None:
|
||||
with (
|
||||
patched_settings(
|
||||
|
|
@ -544,6 +575,69 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertTrue(updated.admin_access)
|
||||
self.assertEqual(updated.role, "learner")
|
||||
|
||||
async def test_super_admin_oauth_upsert_casts_nullable_admin_access_as_boolean(
|
||||
self,
|
||||
) -> None:
|
||||
class RecordingConn:
|
||||
def __init__(self) -> None:
|
||||
self.queries: list[str] = []
|
||||
|
||||
async def fetchrow(self, query: str, *args: Any) -> dict[str, Any] | None:
|
||||
self.queries.append(query)
|
||||
if len(self.queries) == 1:
|
||||
return None
|
||||
now = datetime.now(timezone.utc)
|
||||
return {
|
||||
"user_id": "00000000-0000-0000-0000-000000000605",
|
||||
"email": "yunchan@twentyoz.kr",
|
||||
"display_name": "Yun Chan",
|
||||
"role": "learner",
|
||||
"admin_access": True,
|
||||
"account_status": "approved",
|
||||
"cohort": "",
|
||||
"affiliation": "",
|
||||
"created_at": now,
|
||||
"last_seen_at": now,
|
||||
}
|
||||
|
||||
class RecordingAcquire:
|
||||
def __init__(self, conn: RecordingConn) -> None:
|
||||
self.conn = conn
|
||||
|
||||
async def __aenter__(self) -> RecordingConn:
|
||||
return self.conn
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||||
return None
|
||||
|
||||
class RecordingPool:
|
||||
def __init__(self, conn: RecordingConn) -> None:
|
||||
self.conn = conn
|
||||
|
||||
def acquire(self) -> RecordingAcquire:
|
||||
return RecordingAcquire(self.conn)
|
||||
|
||||
conn = RecordingConn()
|
||||
with (
|
||||
patched_settings(
|
||||
auth_super_admin_emails=["yunchan@twentyoz.kr"],
|
||||
auth_admin_emails=[],
|
||||
),
|
||||
patch.object(auth_sessions, "get_pool", return_value=RecordingPool(conn)),
|
||||
):
|
||||
user = await auth_sessions.upsert_managed_user(
|
||||
auth_sessions.ManagedUserUpsertInput(
|
||||
email="yunchan@twentyoz.kr",
|
||||
display_name="Yun Chan",
|
||||
role="learner",
|
||||
external_id="google:111856072590637505974",
|
||||
)
|
||||
)
|
||||
|
||||
self.assertTrue(user.admin_access)
|
||||
self.assertIn("$7::boolean", conn.queries[0])
|
||||
self.assertIn("$9::boolean", conn.queries[1])
|
||||
|
||||
async def test_auth_config_allows_dev_login_from_configured_tailnet_forwarded_host(self) -> None:
|
||||
request = _request(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -382,7 +382,10 @@ class SessionMemoryPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
await sessions._end_persisted_session(sess, carry)
|
||||
|
||||
self.assertTrue(sess.ended)
|
||||
self.assertEqual(len(scheduled), 1)
|
||||
self.assertEqual(
|
||||
[coro.cr_code.co_name for coro in scheduled],
|
||||
["_write_episodic_embeddings", "close_session"],
|
||||
)
|
||||
|
||||
async def test_end_persisted_session_schedules_digest_worker_only_when_enabled(self) -> None:
|
||||
scheduled: list[str] = []
|
||||
|
|
@ -434,7 +437,11 @@ class SessionMemoryPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
self.assertEqual(
|
||||
scheduled,
|
||||
["_run_session_digest_worker_for_session", "_write_episodic_embeddings"],
|
||||
[
|
||||
"_run_session_digest_worker_for_session",
|
||||
"_write_episodic_embeddings",
|
||||
"close_session",
|
||||
],
|
||||
)
|
||||
|
||||
async def test_end_persisted_session_keeps_digest_worker_default_off(self) -> None:
|
||||
|
|
@ -485,7 +492,7 @@ class SessionMemoryPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
):
|
||||
await sessions._end_persisted_session(sess, carry)
|
||||
|
||||
self.assertEqual(scheduled, ["_write_episodic_embeddings"])
|
||||
self.assertEqual(scheduled, ["_write_episodic_embeddings", "close_session"])
|
||||
|
||||
async def test_session_digest_worker_releases_db_connection_during_engine_call(self) -> None:
|
||||
order: list[str] = []
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
|
@ -609,6 +610,8 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
principal,
|
||||
)
|
||||
await _consume_event_source(response)
|
||||
if sessions._STREAM_TURN_EVALUATION_TASKS:
|
||||
await asyncio.gather(*tuple(sessions._STREAM_TURN_EVALUATION_TASKS))
|
||||
|
||||
self.assertEqual(len(sess.turns), 2)
|
||||
learner_turn, client_turn = sess.turns
|
||||
|
|
@ -620,6 +623,65 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
)
|
||||
self.assertIsNone(client_turn.evaluation)
|
||||
|
||||
async def test_stream_turn_done_does_not_wait_for_fast_loop_evaluation(
|
||||
self,
|
||||
) -> None:
|
||||
principal = _principal()
|
||||
sess = _session(principal)
|
||||
evaluation_started = asyncio.Event()
|
||||
release_evaluation = asyncio.Event()
|
||||
|
||||
async def successful_stream(ctx, engine, **kwargs):
|
||||
assert ctx.state_after is not None
|
||||
yield orchestrator.StreamEvent("token", {"text": "지금 답할게요."})
|
||||
yield orchestrator.StreamEvent(
|
||||
"done",
|
||||
{
|
||||
"session_id": ctx.session_id,
|
||||
"turn_seq": ctx.state_after.turn_seq,
|
||||
"safety_flagged": False,
|
||||
"llm_provider": "claude_cli",
|
||||
"model": "gateway-default",
|
||||
},
|
||||
)
|
||||
|
||||
async def slow_eval_hook(ctx, client_reply):
|
||||
evaluation_started.set()
|
||||
await release_evaluation.wait()
|
||||
return {
|
||||
"loop": "fast",
|
||||
"turn_seq": ctx.state_after.turn_seq,
|
||||
"stage": ctx.state_after.stage.value,
|
||||
"appropriateness": "pos",
|
||||
}
|
||||
|
||||
with (
|
||||
patch.object(sessions.orchestrator, "run_turn_stream", successful_stream),
|
||||
patch.object(
|
||||
sessions.evaluator,
|
||||
"make_eval_hook",
|
||||
return_value=slow_eval_hook,
|
||||
),
|
||||
):
|
||||
response = await sessions.stream_turn(
|
||||
sess.session_id,
|
||||
sessions.TurnRequest(text="평가를 기다리지 않는 발화"),
|
||||
principal,
|
||||
)
|
||||
consume_task = asyncio.create_task(_consume_event_source(response))
|
||||
await asyncio.wait_for(evaluation_started.wait(), timeout=1)
|
||||
body = await asyncio.wait_for(consume_task, timeout=0.2)
|
||||
|
||||
self.assertIn("'event': 'done'", body.decode("utf-8"))
|
||||
self.assertEqual(len(sess.turns), 2)
|
||||
self.assertIsNone(sess.turns[0].evaluation)
|
||||
|
||||
release_evaluation.set()
|
||||
if sessions._STREAM_TURN_EVALUATION_TASKS:
|
||||
await asyncio.gather(*tuple(sessions._STREAM_TURN_EVALUATION_TASKS))
|
||||
|
||||
self.assertEqual(sess.turns[0].evaluation["appropriateness"], "pos")
|
||||
|
||||
async def test_stream_turn_surfaces_fast_loop_evaluation_failure_on_review(
|
||||
self,
|
||||
) -> None:
|
||||
|
|
@ -662,6 +724,8 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
principal,
|
||||
)
|
||||
await _consume_event_source(response)
|
||||
if sessions._STREAM_TURN_EVALUATION_TASKS:
|
||||
await asyncio.gather(*tuple(sessions._STREAM_TURN_EVALUATION_TASKS))
|
||||
|
||||
self.assertEqual(len(sess.turns), 2)
|
||||
learner_turn = sess.turns[0]
|
||||
|
|
|
|||
|
|
@ -12,9 +12,12 @@ from .services.voice import (
|
|||
TTS_ENDPOINT,
|
||||
TTS_MODEL,
|
||||
TTS_MODEL_FALLBACK,
|
||||
HIGGS_TTS_ENDPOINT,
|
||||
HIGGS_TTS_MODEL,
|
||||
VoicePreset,
|
||||
VoiceService,
|
||||
assess_end_of_turn,
|
||||
build_higgs_prompt,
|
||||
build_tts_payload,
|
||||
resolve_voice,
|
||||
resolve_voice_from_map,
|
||||
|
|
@ -96,6 +99,16 @@ class VoicePresetResolutionTest(unittest.TestCase):
|
|||
|
||||
|
||||
class TTSPayloadTest(unittest.TestCase):
|
||||
def test_higgs_prompt_keeps_first_word_before_emotion_tags(self) -> None:
|
||||
voice = VoicePreset(preset="soft-young-fem", openai_voice="coral", rate=0.96)
|
||||
|
||||
prompt = build_higgs_prompt("그냥 학교 가도 아무 의미 없는 것 같아요.", voice)
|
||||
|
||||
self.assertTrue(prompt.startswith("그냥 "))
|
||||
self.assertIn("<|emotion:helplessness|>", prompt)
|
||||
self.assertIn("<|prosody:speed_slow|>", prompt)
|
||||
self.assertNotIn("<|emotion:helplessness|> ", prompt)
|
||||
|
||||
def test_payload_contains_openai_tts_fields_and_clamps_high_speed(self) -> None:
|
||||
voice = VoicePreset(
|
||||
preset="soft-young-fem",
|
||||
|
|
@ -219,6 +232,48 @@ class VoiceServiceStreamTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(payload["instructions"], "Keep the tone grounded.")
|
||||
self.assertEqual([chunk.audio for chunk in chunks], [b"\x80\x80", b"\xff\x00"])
|
||||
|
||||
async def test_higgs_tts_uses_local_synthetic_voice_server_in_dev(self) -> None:
|
||||
client = _CaptureTTSClient([b"RIFF", b"synthetic-wav"])
|
||||
service = VoiceService(
|
||||
api_key="",
|
||||
environment="dev",
|
||||
tts_provider="higgs",
|
||||
higgs_base_url="http://127.0.0.1:9881",
|
||||
)
|
||||
service._higgs_client = client # type: ignore[assignment]
|
||||
voice = VoicePreset(preset="soft-young-fem", openai_voice="coral")
|
||||
|
||||
chunks = [
|
||||
chunk
|
||||
async for chunk in service.synthesize_stream(
|
||||
"엄마한테 말하지 않는 거죠?",
|
||||
voice,
|
||||
)
|
||||
]
|
||||
|
||||
self.assertFalse(service.stt_available())
|
||||
self.assertTrue(service.tts_available(voice))
|
||||
self.assertEqual(service.tts_provider_for_voice(voice), "higgs")
|
||||
self.assertEqual(service.tts_model_for_voice(voice), HIGGS_TTS_MODEL)
|
||||
self.assertEqual(service.tts_media_type_for_voice(voice), "audio/wav")
|
||||
self.assertEqual(len(client.calls), 1)
|
||||
method, endpoint, payload = client.calls[0]
|
||||
self.assertEqual((method, endpoint), ("POST", HIGGS_TTS_ENDPOINT))
|
||||
self.assertIn("<|emotion:fear|>", str(payload["text"]))
|
||||
self.assertEqual(payload["preset"], "soft-young-fem")
|
||||
self.assertEqual(b"".join(chunk.audio for chunk in chunks), b"RIFFsynthetic-wav")
|
||||
|
||||
async def test_higgs_tts_is_fail_closed_outside_dev(self) -> None:
|
||||
service = VoiceService(
|
||||
api_key="",
|
||||
environment="prod",
|
||||
tts_provider="higgs",
|
||||
)
|
||||
voice = VoicePreset(preset="soft-young-fem", openai_voice="coral")
|
||||
|
||||
self.assertFalse(service.tts_available(voice))
|
||||
self.assertEqual(service.tts_provider(), "disabled-non-dev")
|
||||
|
||||
async def test_dev_p1_sample_tts_streams_local_mp3_without_openai_key(self) -> None:
|
||||
with TemporaryDirectory() as tmp:
|
||||
sample_dir = Path(tmp)
|
||||
|
|
|
|||
|
|
@ -166,6 +166,48 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertIsNone(voice_routes._client_turn_text_for_speech(session, 3))
|
||||
self.assertIsNone(voice_routes._client_turn_text_for_speech(session, 99))
|
||||
|
||||
def test_text_tts_maps_logical_turn_to_db_transcript_sequence(self) -> None:
|
||||
session = SimpleNamespace(
|
||||
turns=[
|
||||
TurnRecord(
|
||||
turn_seq=1,
|
||||
speaker="counselor",
|
||||
stage="초기",
|
||||
text="첫 질문",
|
||||
text_masked="첫 질문",
|
||||
),
|
||||
TurnRecord(
|
||||
turn_seq=2,
|
||||
speaker="client",
|
||||
stage="초기",
|
||||
text="첫 응답",
|
||||
text_masked="첫 응답",
|
||||
),
|
||||
TurnRecord(
|
||||
turn_seq=3,
|
||||
speaker="counselor",
|
||||
stage="초기",
|
||||
text="둘째 질문",
|
||||
text_masked="둘째 질문",
|
||||
),
|
||||
TurnRecord(
|
||||
turn_seq=4,
|
||||
speaker="client",
|
||||
stage="초기",
|
||||
text="둘째 응답",
|
||||
text_masked="둘째 응답",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
voice_routes._client_turn_text_for_speech(session, 1), "첫 응답"
|
||||
)
|
||||
self.assertEqual(
|
||||
voice_routes._client_turn_text_for_speech(session, 2), "둘째 응답"
|
||||
)
|
||||
self.assertIsNone(voice_routes._client_turn_text_for_speech(session, 3))
|
||||
|
||||
async def test_text_turn_speech_returns_openai_audio_for_owned_persisted_turn(
|
||||
self,
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ async def record_completed_turn(
|
|||
*,
|
||||
context_prefix: str,
|
||||
counselor_turn: TurnRecord | None = None,
|
||||
) -> None:
|
||||
) -> TurnRecord:
|
||||
"""상담자 발화와 내담자 응답을 한 번에 기록하고 상태를 갱신한다."""
|
||||
assert ctx.state_after is not None
|
||||
learner_turn = counselor_turn or TurnRecord(
|
||||
|
|
@ -144,6 +144,7 @@ async def record_completed_turn(
|
|||
result.state_after,
|
||||
context=f"{context_prefix} state update",
|
||||
)
|
||||
return learner_turn
|
||||
|
||||
|
||||
async def record_safety_event(
|
||||
|
|
@ -267,17 +268,20 @@ async def finalize_completed_turn(
|
|||
*,
|
||||
context_prefix: str,
|
||||
counselor_turn: TurnRecord | None = None,
|
||||
) -> None:
|
||||
recharge_live_coach: bool = True,
|
||||
) -> TurnRecord:
|
||||
"""Persist a completed turn and emit any derived safety alert in route-safe order."""
|
||||
await record_completed_turn(
|
||||
learner_turn = await record_completed_turn(
|
||||
sess,
|
||||
ctx,
|
||||
result,
|
||||
context_prefix=context_prefix,
|
||||
counselor_turn=counselor_turn,
|
||||
)
|
||||
await maybe_recharge_live_coach_credit(sess, ctx, result)
|
||||
if recharge_live_coach:
|
||||
await maybe_recharge_live_coach_credit(sess, ctx, result)
|
||||
await record_safety_event(sess, ctx, result)
|
||||
return learner_turn
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue