diff --git a/.env.example b/.env.example index 6ac85c6..6b8be0c 100644 --- a/.env.example +++ b/.env.example @@ -7,13 +7,26 @@ DATABASE_URL=postgresql://vignette_app:change-me-app@127.0.0.1:55432/vignette # Engine gateway. For local Claude CLI gateway, run apps/api/engine_gateway on 9099. ENGINE_URL=http://127.0.0.1:9099 ENGINE_MODE=claude_cli +# 실시간 내담자는 도구 없는 상주 Claude lane, 관리자 선택 provider는 평가/저작에 유지. +VIGNETTE_LIVE_CLIENT_PROVIDER=claude_cli +CLAUDE_BIN=claude +CODEX_BIN=codex +AGY_BIN=agy +ENGINE_CLI_CWD= +ENGINE_CAPABILITY_CACHE_TTL_SECONDS=60 +ENGINE_CLI_TIMEOUT_SECONDS=300 # External providers. Keep real secrets in .env files only. ANTHROPIC_API_KEY= +ANTHROPIC_API_BASE=https://api.anthropic.com OPENAI_API_KEY= OPENAI_BASE_URL=https://api.openai.com/v1 VIGNETTE_VOICE_POC_SAMPLE_TTS=false VIGNETTE_VOICE_POC_SAMPLE_TTS_DIR= +# 로컬 연구/개발 전용. Higgs v3 서버는 scripts/start-higgs-tts.ps1 로 127.0.0.1:9881에 띄운다. +VIGNETTE_VOICE_TTS_PROVIDER=openai +VIGNETTE_HIGGS_TTS_URL=http://127.0.0.1:9881 +VIGNETTE_HIGGS_TTS_TIMEOUT_SECONDS=300 # Auth/session. Local dev may enable dev-login; public/prod must not. SESSION_SECRET=dev-insecure-change-me diff --git a/.gitignore b/.gitignore index 7db8ef4..338e10d 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,7 @@ apps/web/_pptr*.cjs # 로컬 dev 서버 로그(scripts/dev-up.ps1) .devlogs/ +.codex-remote-attachments/ # 로컬 시각 검증 산출물 docs/design-verification/ diff --git a/apps/api/app/auth_sessions.py b/apps/api/app/auth_sessions.py index b488349..866ecf6 100644 --- a/apps/api/app/auth_sessions.py +++ b/apps/api/app/auth_sessions.py @@ -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"]), diff --git a/apps/api/app/config.py b/apps/api/app/config.py index ef3adf7..e6a3cb0 100644 --- a/apps/api/app/config.py +++ b/apps/api/app/config.py @@ -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(): diff --git a/apps/api/app/contracts/engine_gateway.py b/apps/api/app/contracts/engine_gateway.py index 0285e05..bdb109b 100644 --- a/apps/api/app/contracts/engine_gateway.py +++ b/apps/api/app/contracts/engine_gateway.py @@ -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.""" diff --git a/apps/api/app/db.py b/apps/api/app/db.py index 5a6c1af..0526f1c 100644 --- a/apps/api/app/db.py +++ b/apps/api/app/db.py @@ -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, diff --git a/apps/api/app/engine_client.py b/apps/api/app/engine_client.py index 1f6d59f..03417dc 100644 --- a/apps/api/app/engine_client.py +++ b/apps/api/app/engine_client.py @@ -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() diff --git a/apps/api/app/routes/admin.py b/apps/api/app/routes/admin.py index b416018..365d224 100644 --- a/apps/api/app/routes/admin.py +++ b/apps/api/app/routes/admin.py @@ -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 diff --git a/apps/api/app/routes/sessions.py b/apps/api/app/routes/sessions.py index 461db5e..4d598a0 100644 --- a/apps/api/app/routes/sessions.py +++ b/apps/api/app/routes/sessions.py @@ -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 { diff --git a/apps/api/app/routes/voice.py b/apps/api/app/routes/voice.py index 3a06f8a..fc81719 100644 --- a/apps/api/app/routes/voice.py +++ b/apps/api/app/routes/voice.py @@ -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 diff --git a/apps/api/app/services/voice.py b/apps/api/app/services/voice.py index 1a0f70e..049fe36 100644 --- a/apps/api/app/services/voice.py +++ b/apps/api/app/services/voice.py @@ -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", ] diff --git a/apps/api/app/test_admin_ops.py b/apps/api/app/test_admin_ops.py index ccf7657..a3b0efb 100644 --- a/apps/api/app/test_admin_ops.py +++ b/apps/api/app/test_admin_ops.py @@ -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) diff --git a/apps/api/app/test_auth_providers.py b/apps/api/app/test_auth_providers.py index 6902689..84639ae 100644 --- a/apps/api/app/test_auth_providers.py +++ b/apps/api/app/test_auth_providers.py @@ -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( [ diff --git a/apps/api/app/test_session_memory.py b/apps/api/app/test_session_memory.py index dffaf7f..4b3d0b5 100644 --- a/apps/api/app/test_session_memory.py +++ b/apps/api/app/test_session_memory.py @@ -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] = [] diff --git a/apps/api/app/test_session_turn_persistence.py b/apps/api/app/test_session_turn_persistence.py index d191b14..ffcd923 100644 --- a/apps/api/app/test_session_turn_persistence.py +++ b/apps/api/app/test_session_turn_persistence.py @@ -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] diff --git a/apps/api/app/test_voice_service.py b/apps/api/app/test_voice_service.py index 9bea5a5..4e5cccc 100644 --- a/apps/api/app/test_voice_service.py +++ b/apps/api/app/test_voice_service.py @@ -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) diff --git a/apps/api/app/test_voice_ws.py b/apps/api/app/test_voice_ws.py index a5dc857..2f24824 100644 --- a/apps/api/app/test_voice_ws.py +++ b/apps/api/app/test_voice_ws.py @@ -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: diff --git a/apps/api/app/turn_runtime.py b/apps/api/app/turn_runtime.py index b613a54..d353a5a 100644 --- a/apps/api/app/turn_runtime.py +++ b/apps/api/app/turn_runtime.py @@ -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__ = [ diff --git a/apps/api/engine_gateway/README.md b/apps/api/engine_gateway/README.md index ffccdc5..f7cabe8 100644 --- a/apps/api/engine_gateway/README.md +++ b/apps/api/engine_gateway/README.md @@ -1,19 +1,46 @@ # 엔진 게이트웨이 -로컬 claude -p(Opus 4.8) 상주 멀티턴 풀. **컨테이너 밖(호스트)** 실행, api가 `ENGINE_URL`로 호출. +API가 `ENGINE_URL`로 호출하는 호스트 실행형 AI 공급자 게이트웨이. Claude CLI 상주 풀과 +Anthropic API, Codex CLI, Agy CLI를 하나의 `/v1/generate`·`/v1/stream` 계약으로 라우팅한다. ## 실행 -``` -cd apps/api + +```powershell +cd apps\api python -m uvicorn engine_gateway.gateway:app --host 127.0.0.1 --port 9099 ``` -## API -- `POST /session {system_prompt, budget_usd}` -> `{session_id}` (회기=프로세스 1개) -- `POST /session/{id}/turn {content}` -> `{text, cost_usd, turns}` -- `DELETE /session/{id}` -- `GET /health` +## 공급자와 모델 탐색 -## 검증 (2026-06-25) -세션 생성+멀티턴 2턴(서연 페르소나) 컨텍스트 유지 + 캐시 재사용 비용절감 실동작 확인. -환경변수: `CLAUDE_BIN`, `ENGINE_MODEL`(비우면 Opus4.8), `ENGINE_FALLBACK_MODEL`, `SESSION_BUDGET_USD`. +| 공급자 | 모델 원천 | 기본값 | 실행 방식 | +| --- | --- | --- | --- | +| `claude_cli` | CLI가 목록 명령을 제공하지 않아 공식 alias 정적 목록 | CLI 기본 / High | 기존 `claude -p` 상주 풀 | +| `claude_api` | Anthropic `GET /v1/models` | API 목록 첫 모델 / 지원 effort | Messages API | +| `codex_cli` | Codex app-server `model/list` | `gpt-5.6-terra` / Medium | 격리 cwd의 ephemeral `codex exec` | +| `agy_cli` | `agy models` | `gemini-3.6-flash-high` / High | 격리 cwd의 `agy --print --output-format stream-json` | +| `openai`, `solar` | 현재 어댑터 없음 | 없음 | 사용할 수 없음으로 명시 | + +모델 목록은 60초 캐시하며 관리자가 강제 새로고침할 수 있다. 저장할 때 선택한 공급자·모델·추론 +강도를 게이트웨이가 다시 검증하므로 임의 문자열이나 사용할 수 없는 조합은 운영값으로 들어가지 않는다. + +## API + +- `GET /health` — 얕은 프로세스 liveness +- `GET /ready?provider=&model=&reasoning_effort=` — 선택 조합으로 실제 생성 readiness 확인 +- `GET /v1/capabilities?provider=&force=` — 모델·추론 강도 카탈로그 +- `POST /v1/generate` — 단발 생성 +- `POST /v1/stream` — SSE `token`/`done`/`error`; Claude CLI partial-message delta와 Agy stream-json delta를 실시간 전달 +- `/session` 계열 — 명시 생성 없이도 첫 client stream에서 자동 바인딩되는 Claude CLI 회기별 상주 프로세스 풀 + +## 환경변수 + +- `CLAUDE_BIN`, `CODEX_BIN`, `AGY_BIN` — CLI 경로. Windows Codex는 npm shim보다 실제 native exe를 우선 탐색한다. +- `ANTHROPIC_API_KEY`, `ANTHROPIC_API_BASE` — Anthropic 모델 조회·Messages API. +- `ENGINE_CLI_CWD` — Codex/Agy 격리 작업 폴더. 기본은 시스템 임시 폴더의 `vignette-engine-runtime`. +- `ENGINE_CAPABILITY_CACHE_TTL_SECONDS` — 모델 카탈로그 TTL, 기본 60초. +- `ENGINE_CLI_TIMEOUT_SECONDS` — CLI 생성 상한, 기본 300초. +- `ENGINE_MODEL`, `ENGINE_FALLBACK_MODEL`, `SESSION_BUDGET_USD` — 기존 Claude CLI 풀 설정. + +Windows의 Agy는 `--print` 프롬프트가 명령줄 인자여서 24,000자를 넘는 요청을 fail-closed한다. +대화 conversation id는 로컬 저장·삭제 수명주기 계약이 없어 재사용하지 않고 stateless stream으로 실행한다. +Anthropic API는 키가 없으면 사용할 수 없음으로 표시한다. diff --git a/apps/api/engine_gateway/gateway.py b/apps/api/engine_gateway/gateway.py index 018ecf8..945720e 100644 --- a/apps/api/engine_gateway/gateway.py +++ b/apps/api/engine_gateway/gateway.py @@ -25,15 +25,24 @@ from app.contracts.engine_gateway import ( ENGINE_GATEWAY_SSE_DONE, ENGINE_GATEWAY_SSE_ERROR, ENGINE_GATEWAY_SSE_TOKEN, + EngineCapabilitiesResponse, EngineMessage as GwMessage, + EngineProvider, GenerateResponse, GenerateRequest as GwGenerateReq, + ReasoningEffort, StreamDoneEvent, StreamErrorEvent, StreamTokenEvent, normalize_engine_gateway_model, sse_frame, ) +from engine_gateway.provider_registry import ( + ProviderError, + discover_capabilities, + generate_with_provider, + stream_with_provider, +) CLAUDE_BIN = os.environ.get("CLAUDE_BIN", "claude") DEFAULT_MODEL = os.environ.get("ENGINE_MODEL", "") # 비우면 CLI 기본(Opus 4.8) @@ -45,6 +54,8 @@ READY_BUDGET_USD = float(os.environ.get("ENGINE_READY_BUDGET_USD", "0.5")) # 단발 생성(/v1/generate) 턴 타임아웃 — 페르소나 초안 생성 같은 대형 구조화 출력은 # 120초를 넘길 수 있어 설정 가능하게 한다(2026-07-15). 호출부(app ENGINE_TIMEOUT)와 정합 필요. GENERATE_TURN_TIMEOUT_SECONDS = float(os.environ.get("ENGINE_GENERATE_TIMEOUT_SECONDS", "300")) +SESSION_IDLE_TTL_SECONDS = float(os.environ.get("ENGINE_SESSION_IDLE_TTL_SECONDS", "3600")) +MAX_RESIDENT_SESSIONS = max(1, int(os.environ.get("ENGINE_MAX_RESIDENT_SESSIONS", "24"))) GATEWAY_PROVIDER = "claude_cli" GATEWAY_FALLBACK_MODEL_NAME = "claude-opus-4-8" @@ -53,13 +64,18 @@ GATEWAY_FALLBACK_MODEL_NAME = "claude-opus-4-8" class GatewayPromptParts: system_prompt: str user_payload: str + current_user_payload: str BASE_ARGS = [ "-p", "--input-format", "stream-json", "--output-format", "stream-json", + "--include-partial-messages", "--verbose", + # 상담 축어록은 게이트웨이 프로세스 수명 안에서만 유지한다. Claude CLI의 로컬 + # 세션 파일로 이중 저장하지 않아 개인정보 노출과 매 턴 디스크 I/O를 줄인다. + "--no-session-persistence", "--dangerously-skip-permissions", # 페르소나 격리: cwd/env/git status/메모리(CLAUDE.md) 등 per-machine 섹션을 시스템프롬프트에서 # 제거 → 내담자 AI가 자신이 개발 환경(Claude Code/Vignette repo) 안에 있음을 알아채 캐릭터를 @@ -75,21 +91,26 @@ class EngineSession: system_prompt: str | None = None, budget: float = DEFAULT_BUDGET, model: str | None = None, + reasoning_effort: ReasoningEffort | None = None, ): self.id = uuid.uuid4().hex self.system_prompt = system_prompt self.budget = budget self.model = normalize_engine_gateway_model(model) + self.reasoning_effort = reasoning_effort self.proc: asyncio.subprocess.Process | None = None self.lock = asyncio.Lock() # 한 회기 안의 턴은 직렬(상담 왕복) self.cost_usd = 0.0 self.turns = 0 + self.last_used_at = time.monotonic() async def start(self) -> None: args = [CLAUDE_BIN, *BASE_ARGS, "--max-budget-usd", str(self.budget)] model = self.model or DEFAULT_MODEL if model: args += ["--model", model] + if self.reasoning_effort: + args += ["--effort", self.reasoning_effort] if FALLBACK_MODEL: args += ["--fallback-model", FALLBACK_MODEL] if self.system_prompt: @@ -135,6 +156,7 @@ class EngineSession: return obj result = await asyncio.wait_for(_read_until_result(), timeout=timeout) + self.last_used_at = time.monotonic() self.cost_usd = result.get("total_cost_usd", self.cost_usd) self.turns += 1 error_detail = ( @@ -186,7 +208,18 @@ class EngineSession: except json.JSONDecodeError: continue t = obj.get("type") - if t == "assistant": + if t == "stream_event": + stream_event = obj.get("event") or {} + delta_payload = stream_event.get("delta") or {} + if ( + stream_event.get("type") == "content_block_delta" + and delta_payload.get("type") == "text_delta" + ): + delta = str(delta_payload.get("text") or "") + if delta: + emitted += delta + yield {"type": "delta", "text": delta} + elif t == "assistant": # 이번 메시지의 텍스트 전체를 재구성 full = "".join( c.get("text", "") @@ -202,6 +235,7 @@ class EngineSession: emitted += delta if full.startswith(emitted) else full yield {"type": "delta", "text": delta} elif t == "result": + self.last_used_at = time.monotonic() self.cost_usd = obj.get("total_cost_usd", self.cost_usd) self.turns += 1 error_detail = ( @@ -234,7 +268,8 @@ class EngineSession: SESSIONS: dict[str, EngineSession] = {} -_READY_CACHE: dict[str, Any] = {"checked_at": 0.0, "ok": False, "detail": "not checked"} +_SESSION_RESOLVE_LOCK = asyncio.Lock() +_READY_CACHE: dict[tuple[str, str, str], dict[str, Any]] = {} _READY_LOCK = asyncio.Lock() app = FastAPI(title="Vignette Engine Gateway") @@ -250,18 +285,36 @@ class TurnReq(BaseModel): @app.get("/health") async def health(): - return {"ok": True, "engine": "claude_p", "model": DEFAULT_MODEL or "default(opus-4-8)", "sessions": len(SESSIONS)} + return { + "ok": True, + "engine": "claude_cli", + "model": DEFAULT_MODEL or "gateway-default", + "sessions": len(SESSIONS), + } -def _ready_response(*, cached: bool, age_seconds: float = 0.0) -> JSONResponse: - ok = bool(_READY_CACHE.get("ok")) +def _ready_response( + entry: dict[str, Any], + *, + provider: EngineProvider, + model: str | None, + reasoning_effort: ReasoningEffort | None, + cached: bool, + age_seconds: float = 0.0, +) -> JSONResponse: + ok = bool(entry.get("ok")) return JSONResponse( { "ok": ok, - "engine": "claude_p", - "model": DEFAULT_MODEL or "default(opus-4-8)", + "engine": provider, + "model": model or ( + DEFAULT_MODEL or "default(opus-4-8)" + if provider == "claude_cli" + else "provider-default" + ), + "reasoning_effort": reasoning_effort, "sessions": len(SESSIONS), - "detail": _READY_CACHE.get("detail"), + "detail": entry.get("detail"), "age_seconds": round(max(0.0, age_seconds), 3), "cached": cached, }, @@ -270,43 +323,105 @@ def _ready_response(*, cached: bool, age_seconds: float = 0.0) -> JSONResponse: @app.get("/ready") -async def ready(force: bool = False): +async def ready( + force: bool = False, + provider: EngineProvider = "claude_cli", + model: str | None = None, + reasoning_effort: ReasoningEffort | None = None, +): """Prove that claude -p can complete a real generation. /health is shallow process liveness. This endpoint catches the installed-but- not-authenticated CLI state before a learner reaches POST /sessions/:id/turn. """ - age = time.monotonic() - float(_READY_CACHE.get("checked_at", 0.0) or 0.0) + cache_key = (provider, model or "", reasoning_effort or "") + entry = _READY_CACHE.get( + cache_key, {"checked_at": 0.0, "ok": False, "detail": "not checked"} + ) + age = time.monotonic() - float(entry.get("checked_at", 0.0) or 0.0) if not force and age < READY_TTL_SECONDS: - return _ready_response(cached=True, age_seconds=age) + return _ready_response( + entry, + provider=provider, + model=model, + reasoning_effort=reasoning_effort, + cached=True, + age_seconds=age, + ) async with _READY_LOCK: - age = time.monotonic() - float(_READY_CACHE.get("checked_at", 0.0) or 0.0) - if not force and age < READY_TTL_SECONDS: - return _ready_response(cached=True, age_seconds=age) - - probe = EngineSession( - system_prompt="You are a readiness probe. Reply with exactly OK.", - budget=READY_BUDGET_USD, + entry = _READY_CACHE.get( + cache_key, {"checked_at": 0.0, "ok": False, "detail": "not checked"} ) + age = time.monotonic() - float(entry.get("checked_at", 0.0) or 0.0) + if not force and age < READY_TTL_SECONDS: + return _ready_response( + entry, + provider=provider, + model=model, + reasoning_effort=reasoning_effort, + cached=True, + age_seconds=age, + ) + ok = False detail = "unknown readiness failure" - try: - await probe.start() - result = await probe.turn("Reply with exactly OK.", timeout=READY_TIMEOUT_SECONDS) - if result.get("is_error"): - detail = str(result.get("error") or "engine returned an error") - else: - text = str(result.get("text") or "").strip() - ok = bool(text) - detail = text or "empty engine response" - except Exception as exc: - detail = str(exc) - finally: - await probe.close() + if provider == "claude_cli": + probe = EngineSession( + system_prompt="You are a readiness probe. Reply with exactly OK.", + budget=READY_BUDGET_USD, + model=model, + reasoning_effort=reasoning_effort, + ) + try: + await probe.start() + result = await probe.turn( + "Reply with exactly OK.", timeout=READY_TIMEOUT_SECONDS + ) + if result.get("is_error"): + detail = str(result.get("error") or "engine returned an error") + else: + text = str(result.get("text") or "").strip() + ok = bool(text) + detail = text or "empty engine response" + except Exception as exc: + detail = str(exc) + finally: + await probe.close() + else: + request = GwGenerateReq( + provider=provider, + model=model, + reasoning_effort=reasoning_effort, + max_tokens=16, + temperature=0, + messages=[GwMessage(role="user", content="Reply with exactly OK.")], + ) + try: + result = await generate_with_provider( + request, + system_prompt="You are a readiness probe. Reply with exactly OK.", + user_payload="Reply with exactly OK.", + ) + ok = bool(result.text.strip()) + detail = result.text.strip() or "empty engine response" + except Exception as exc: + detail = str(exc) - _READY_CACHE.update({"checked_at": time.monotonic(), "ok": ok, "detail": detail}) - return _ready_response(cached=False) + entry = {"checked_at": time.monotonic(), "ok": ok, "detail": detail} + _READY_CACHE[cache_key] = entry + return _ready_response( + entry, + provider=provider, + model=model, + reasoning_effort=reasoning_effort, + cached=False, + ) + + +@app.get("/v1/capabilities", response_model=EngineCapabilitiesResponse) +async def v1_capabilities(provider: EngineProvider, force: bool = False): + return await discover_capabilities(provider, force=force) @app.post("/session") @@ -344,10 +459,14 @@ async def close_session(sid: str): def _split_messages(messages: list[GwMessage], *, ai_role: AIRole | None = None) -> GatewayPromptParts: """EngineMessage[] → named prompt parts for the current gateway turn.""" system_parts: list[str] = [] + turn_control_parts: list[str] = [] non_system: list[GwMessage] = [] for m in messages: if m.role == "system": - system_parts.append(m.content) + if ai_role == "client" and not m.cache: + turn_control_parts.append(m.content) + else: + system_parts.append(m.content) else: non_system.append(m) @@ -361,7 +480,15 @@ def _split_messages(messages: list[GwMessage], *, ai_role: AIRole | None = None) last_user = non_system[last_user_index].content user_payload = last_user + current_user_payload = last_user if ai_role == "client" and last_user_index is not None: + control = "\n\n".join(p for p in turn_control_parts if p.strip()) + current_sections: list[str] = [] + if control: + current_sections.append("[현재 턴 상태와 연기 지시]\n" + control) + current_sections.append("[이번 상담자 발화]\n" + last_user) + current_user_payload = "\n\n".join(current_sections) + history_parts: list[str] = [] for m in non_system[:last_user_index]: content = m.content.strip() @@ -369,11 +496,18 @@ def _split_messages(messages: list[GwMessage], *, ai_role: AIRole | None = None) continue speaker = "상담자" if m.role == "user" else "내담자" history_parts.append(f"{speaker}: {content}") + history_sections = list(current_sections[:-1]) if history_parts: - user_payload = "[직전 대화]\n" + "\n".join(history_parts) + "\n\n[이번 상담자 발화]\n" + last_user + history_sections.append("[직전 대화]\n" + "\n".join(history_parts)) + history_sections.append(current_sections[-1]) + user_payload = "\n\n".join(history_sections) system_prompt = "\n\n".join(p for p in system_parts if p.strip()) - return GatewayPromptParts(system_prompt=system_prompt, user_payload=user_payload) + return GatewayPromptParts( + system_prompt=system_prompt, + user_payload=user_payload, + current_user_payload=current_user_payload, + ) def _inject_schema(system_prompt: str, schema: Optional[dict[str, Any]]) -> str: @@ -397,26 +531,90 @@ def _response_model_name(session: EngineSession) -> str: async def _resolve_session(req: GwGenerateReq, system_prompt: str) -> tuple[EngineSession, bool]: - """session_id 가 있고 살아있으면 재사용, 아니면 단발용 임시 세션 생성. + """내담자 회기는 session_id 에 바인딩하고, 나머지는 단발 세션으로 실행한다. 반환: (session, ephemeral). ephemeral=True 면 호출부가 응답 후 close 한다. """ requested_model = normalize_engine_gateway_model(req.model) - if req.session_id and req.session_id in SESSIONS: - s = SESSIONS[req.session_id] - if s.proc is not None and s.proc.returncode is None: - if requested_model is None or (s.model or DEFAULT_MODEL) == requested_model: - return s, False - # 단발(또는 죽은 세션) → 1회성 세션 + persistent_key = req.session_id if req.session_id and req.ai_role == "client" else None + if persistent_key: + async with _SESSION_RESOLVE_LOCK: + await _prune_resident_sessions(exclude={persistent_key}) + existing = SESSIONS.get(persistent_key) + if existing is not None: + running = existing.proc is not None and existing.proc.returncode is None + same_model = requested_model is None or (existing.model or DEFAULT_MODEL) == requested_model + same_effort = req.reasoning_effort is None or existing.reasoning_effort == req.reasoning_effort + if running and same_model and same_effort: + existing.last_used_at = time.monotonic() + return existing, False + SESSIONS.pop(persistent_key, None) + await existing.close() + + session = EngineSession( + system_prompt=system_prompt or None, + budget=DEFAULT_BUDGET, + model=requested_model, + reasoning_effort=req.reasoning_effort, + ) + await session.start() + SESSIONS[persistent_key] = session + return session, False + + # 평가·관리자 생성처럼 페르소나 회기와 정체성을 섞으면 안 되는 호출은 1회성 세션이다. s = EngineSession( system_prompt=system_prompt or None, budget=DEFAULT_BUDGET, model=requested_model, + reasoning_effort=req.reasoning_effort, ) await s.start() return s, True +async def _prune_resident_sessions(*, exclude: set[str] | None = None) -> None: + """죽었거나 오래 유휴인 회기와 상한 초과 회기를 안전하게 정리한다.""" + protected = exclude or set() + now = time.monotonic() + stale_keys = [ + key + for key, session in SESSIONS.items() + if key not in protected + and not session.lock.locked() + and ( + session.proc is None + or session.proc.returncode is not None + or now - session.last_used_at >= SESSION_IDLE_TTL_SECONDS + ) + ] + for key in stale_keys: + session = SESSIONS.pop(key, None) + if session is not None: + await session.close() + + overflow = len(SESSIONS) - MAX_RESIDENT_SESSIONS + 1 + if overflow <= 0: + return + candidates = sorted( + ( + (key, session) + for key, session in SESSIONS.items() + if key not in protected and not session.lock.locked() + ), + key=lambda item: item[1].last_used_at, + ) + for key, session in candidates[:overflow]: + SESSIONS.pop(key, None) + await session.close() + + +def _session_turn_payload(session: EngineSession, prompt_parts: GatewayPromptParts) -> str: + """상주 프로세스는 자체 대화기록을 가지므로 재사용 턴에는 L6를 중복 주입하지 않는다.""" + if session.turns > 0: + return prompt_parts.current_user_payload + return prompt_parts.user_payload + + @app.post("/v1/generate") async def v1_generate(req: GwGenerateReq): """단발 생성 (평가 deep-loop, 회기종료 압축 등). GenerateResponse 호환 dict 반환.""" @@ -425,9 +623,33 @@ async def v1_generate(req: GwGenerateReq): if not prompt_parts.user_payload: raise HTTPException(400, "no user message in payload") + provider = req.provider or "claude_cli" + if provider != "claude_cli": + try: + result = await generate_with_provider( + req, + system_prompt=system_prompt, + user_payload=prompt_parts.user_payload, + ) + except ProviderError as exc: + raise HTTPException(502, f"engine provider error: {exc}") from exc + return GenerateResponse( + text=result.text, + model=result.model, + provider=result.provider, + tokens_in=result.tokens_in, + tokens_out=result.tokens_out, + cost_usd=result.cost_usd, + inference_geo=result.inference_geo, + structured=result.structured, + ).model_dump() + s, ephemeral = await _resolve_session(req, system_prompt) try: - result = await s.turn(prompt_parts.user_payload, timeout=GENERATE_TURN_TIMEOUT_SECONDS) + result = await s.turn( + _session_turn_payload(s, prompt_parts), + timeout=GENERATE_TURN_TIMEOUT_SECONDS, + ) finally: if ephemeral: await s.close() @@ -461,11 +683,53 @@ async def v1_stream(req: GwGenerateReq): if not prompt_parts.user_payload: raise HTTPException(400, "no user message in payload") + provider = req.provider or "claude_cli" + if provider != "claude_cli": + async def _provider_sse(): + try: + async for event in stream_with_provider( + req, + system_prompt=system_prompt, + user_payload=prompt_parts.user_payload, + ): + if event.type == "delta" and event.text: + yield sse_frame( + ENGINE_GATEWAY_SSE_TOKEN, + StreamTokenEvent(text=event.text), + ) + elif event.type == "done" and event.result is not None: + result = event.result + yield sse_frame( + ENGINE_GATEWAY_SSE_DONE, + StreamDoneEvent( + provider=result.provider, + model=result.model, + tokens_in=result.tokens_in, + tokens_out=result.tokens_out, + cost_usd=result.cost_usd, + turns=1, + ), + ) + except ProviderError as exc: + yield sse_frame( + ENGINE_GATEWAY_SSE_ERROR, + StreamErrorEvent(detail=f"engine provider error: {exc}"), + ) + + return StreamingResponse( + _provider_sse(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + s, ephemeral = await _resolve_session(req, system_prompt) async def _sse(): try: - async for evt in s.turn_stream(prompt_parts.user_payload, timeout=600.0): + async for evt in s.turn_stream( + _session_turn_payload(s, prompt_parts), + timeout=600.0, + ): if evt.get("type") == "delta": yield sse_frame( ENGINE_GATEWAY_SSE_TOKEN, diff --git a/apps/api/engine_gateway/golden/engine_gateway_schema.v1.json b/apps/api/engine_gateway/golden/engine_gateway_schema.v1.json index 76d75e4..90aaad1 100644 --- a/apps/api/engine_gateway/golden/engine_gateway_schema.v1.json +++ b/apps/api/engine_gateway/golden/engine_gateway_schema.v1.json @@ -1,16 +1,49 @@ { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://vignette.local/schemas/engine_gateway_contract.v1.json", + "title": "EngineGatewayGoldenContract", + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "generate_request", + "generate_response", + "stream_frames", + "stream_packets", + "compatibility_lines" + ], + "properties": { + "version": { + "const": 1 + }, + "generate_request": { + "$ref": "#/$defs/GenerateRequest" + }, + "generate_response": { + "$ref": "#/$defs/GenerateResponse" + }, + "stream_frames": { + "type": "array", + "items": { + "type": "string" + } + }, + "stream_packets": { + "type": "array", + "items": { + "$ref": "#/$defs/StreamPacket" + } + }, + "compatibility_lines": { + "type": "array", + "items": { + "type": "string" + } + } + }, "$defs": { "EngineMessage": { "properties": { - "cache": { - "default": false, - "title": "Cache", - "type": "boolean" - }, - "content": { - "title": "Content", - "type": "string" - }, "role": { "enum": [ "system", @@ -19,6 +52,15 @@ ], "title": "Role", "type": "string" + }, + "content": { + "title": "Content", + "type": "string" + }, + "cache": { + "default": false, + "title": "Cache", + "type": "boolean" } }, "required": [ @@ -40,11 +82,6 @@ "title": "Ai Role", "type": "string" }, - "max_tokens": { - "default": 1024, - "title": "Max Tokens", - "type": "integer" - }, "messages": { "items": { "$ref": "#/$defs/EngineMessage" @@ -52,9 +89,25 @@ "title": "Messages", "type": "array" }, - "metadata": { - "title": "Metadata", - "type": "object" + "provider": { + "anyOf": [ + { + "enum": [ + "claude_cli", + "claude_api", + "codex_cli", + "agy_cli", + "openai", + "solar" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provider" }, "model": { "anyOf": [ @@ -68,9 +121,17 @@ "default": null, "title": "Model" }, - "session_id": { + "reasoning_effort": { "anyOf": [ { + "enum": [ + "low", + "medium", + "high", + "xhigh", + "max", + "ultra" + ], "type": "string" }, { @@ -78,7 +139,17 @@ } ], "default": null, - "title": "Session Id" + "title": "Reasoning Effort" + }, + "max_tokens": { + "default": 1024, + "title": "Max Tokens", + "type": "integer" + }, + "temperature": { + "default": 0.7, + "title": "Temperature", + "type": "number" }, "structured_schema": { "anyOf": [ @@ -92,10 +163,21 @@ "default": null, "title": "Structured Schema" }, - "temperature": { - "default": 0.7, - "title": "Temperature", - "type": "number" + "session_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Session Id" + }, + "metadata": { + "title": "Metadata", + "type": "object" } }, "required": [ @@ -106,6 +188,28 @@ }, "GenerateResponse": { "properties": { + "text": { + "title": "Text", + "type": "string" + }, + "model": { + "title": "Model", + "type": "string" + }, + "provider": { + "title": "Provider", + "type": "string" + }, + "tokens_in": { + "default": 0, + "title": "Tokens In", + "type": "integer" + }, + "tokens_out": { + "default": 0, + "title": "Tokens Out", + "type": "integer" + }, "cost_usd": { "default": 0.0, "title": "Cost Usd", @@ -123,14 +227,6 @@ "default": null, "title": "Inference Geo" }, - "model": { - "title": "Model", - "type": "string" - }, - "provider": { - "title": "Provider", - "type": "string" - }, "structured": { "anyOf": [ { @@ -142,20 +238,6 @@ ], "default": null, "title": "Structured" - }, - "text": { - "title": "Text", - "type": "string" - }, - "tokens_in": { - "default": 0, - "title": "Tokens In", - "type": "integer" - }, - "tokens_out": { - "default": 0, - "title": "Tokens Out", - "type": "integer" } }, "required": [ @@ -166,21 +248,29 @@ "title": "GenerateResponse", "type": "object" }, + "StreamTokenEvent": { + "properties": { + "text": { + "title": "Text", + "type": "string" + } + }, + "required": [ + "text" + ], + "title": "StreamTokenEvent", + "type": "object" + }, "StreamDoneEvent": { "properties": { - "cost_usd": { - "default": 0.0, - "title": "Cost Usd", - "type": "number" + "provider": { + "title": "Provider", + "type": "string" }, "model": { "title": "Model", "type": "string" }, - "provider": { - "title": "Provider", - "type": "string" - }, "tokens_in": { "default": 0, "title": "Tokens In", @@ -191,6 +281,11 @@ "title": "Tokens Out", "type": "integer" }, + "cost_usd": { + "default": 0.0, + "title": "Cost Usd", + "type": "number" + }, "turns": { "default": 0, "title": "Turns", @@ -220,7 +315,12 @@ "StreamPacket": { "oneOf": [ { + "type": "object", "additionalProperties": false, + "required": [ + "event", + "payload" + ], "properties": { "event": { "const": "token" @@ -228,15 +328,15 @@ "payload": { "$ref": "#/$defs/StreamTokenEvent" } - }, + } + }, + { + "type": "object", + "additionalProperties": false, "required": [ "event", "payload" ], - "type": "object" - }, - { - "additionalProperties": false, "properties": { "event": { "const": "done" @@ -244,15 +344,15 @@ "payload": { "$ref": "#/$defs/StreamDoneEvent" } - }, + } + }, + { + "type": "object", + "additionalProperties": false, "required": [ "event", "payload" ], - "type": "object" - }, - { - "additionalProperties": false, "properties": { "event": { "const": "error" @@ -260,71 +360,11 @@ "payload": { "$ref": "#/$defs/StreamErrorEvent" } - }, - "required": [ - "event", - "payload" - ], - "type": "object" + } } ] - }, - "StreamTokenEvent": { - "properties": { - "text": { - "title": "Text", - "type": "string" - } - }, - "required": [ - "text" - ], - "title": "StreamTokenEvent", - "type": "object" } }, - "$id": "https://vignette.local/schemas/engine_gateway_contract.v1.json", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "additionalProperties": false, - "properties": { - "compatibility_lines": { - "items": { - "type": "string" - }, - "type": "array" - }, - "generate_request": { - "$ref": "#/$defs/GenerateRequest" - }, - "generate_response": { - "$ref": "#/$defs/GenerateResponse" - }, - "stream_frames": { - "items": { - "type": "string" - }, - "type": "array" - }, - "stream_packets": { - "items": { - "$ref": "#/$defs/StreamPacket" - }, - "type": "array" - }, - "version": { - "const": 1 - } - }, - "required": [ - "version", - "generate_request", - "generate_response", - "stream_frames", - "stream_packets", - "compatibility_lines" - ], - "title": "EngineGatewayGoldenContract", - "type": "object", "x-engine-gateway-sse-events": [ "token", "done", diff --git a/apps/api/engine_gateway/provider_registry.py b/apps/api/engine_gateway/provider_registry.py new file mode 100644 index 0000000..2d430a9 --- /dev/null +++ b/apps/api/engine_gateway/provider_registry.py @@ -0,0 +1,836 @@ +"""Provider 탐색과 Claude CLI 이외 실행 어댑터. + +Provider별 CLI/API 세부 구현은 게이트웨이가 소유한다. 애플리케이션과 맞닿는 +wire 계약은 ``app.contracts.engine_gateway``에 유지한다. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import shutil +import tempfile +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any, AsyncIterator, Iterable, Literal, cast + +import httpx + +from app.contracts.engine_gateway import ( + ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, + ENGINE_PROVIDER_DEFAULTS, + ENGINE_REASONING_EFFORTS, + EngineCapabilitiesResponse, + EngineModelOption, + EngineProvider, + GenerateRequest, + ReasoningEffort, + normalize_engine_gateway_model, +) + +CODEX_DEFAULT_MODEL, CODEX_DEFAULT_EFFORT = ENGINE_PROVIDER_DEFAULTS["codex_cli"] +AGY_DEFAULT_MODEL, AGY_DEFAULT_EFFORT = ENGINE_PROVIDER_DEFAULTS["agy_cli"] +CLAUDE_CLI_DEFAULT_MODEL, CLAUDE_DEFAULT_EFFORT = ENGINE_PROVIDER_DEFAULTS[ + "claude_cli" +] + +CAPABILITY_CACHE_TTL_SECONDS = float( + os.environ.get("ENGINE_CAPABILITY_CACHE_TTL_SECONDS", "60") +) +CLI_TIMEOUT_SECONDS = float(os.environ.get("ENGINE_CLI_TIMEOUT_SECONDS", "300")) +ANTHROPIC_API_BASE = os.environ.get( + "ANTHROPIC_API_BASE", "https://api.anthropic.com" +).rstrip("/") + + +class ProviderError(RuntimeError): + """자격 증명을 노출하지 않고 provider 탐색·생성 실패를 전달한다.""" + + +@dataclass(frozen=True, slots=True) +class ProviderGenerateResult: + text: str + model: str + provider: EngineProvider + tokens_in: int = 0 + tokens_out: int = 0 + cost_usd: float = 0.0 + inference_geo: str | None = None + structured: dict[str, Any] | None = None + + +@dataclass(frozen=True, slots=True) +class ProviderStreamEvent: + type: Literal["delta", "done"] + text: str = "" + result: ProviderGenerateResult | None = None + + +_CAPABILITY_CACHE: dict[EngineProvider, tuple[float, EngineCapabilitiesResponse]] = {} +_CAPABILITY_LOCK = asyncio.Lock() + + +def clear_capability_cache() -> None: + _CAPABILITY_CACHE.clear() + + +def _now() -> float: + return time.time() + + +def _efforts(values: Iterable[str]) -> list[ReasoningEffort]: + allowed = set(ENGINE_REASONING_EFFORTS) + return [cast(ReasoningEffort, value) for value in values if value in allowed] + + +def _binary(env_name: str, fallback: str) -> str | None: + configured = os.environ.get(env_name, "").strip() + if configured: + path = Path(configured) + return str(path) if path.exists() else shutil.which(configured) + if os.name == "nt": + shim = shutil.which(fallback) + if fallback == "codex" and shim: + npm_vendor_root = ( + Path(shim).parent + / "node_modules" + / "@openai" + / "codex" + / "node_modules" + / "@openai" + ) + native_candidates = sorted( + npm_vendor_root.glob("codex-win32-*/vendor/*/bin/codex.exe") + ) + if native_candidates: + return str(native_candidates[0]) + executable = shutil.which(f"{fallback}.exe") + if executable: + return executable + return shim + return shutil.which(fallback) + + +def _safe_process_error(stderr: bytes, fallback: str) -> str: + detail = stderr.decode("utf-8", errors="replace").strip() + if not detail: + return fallback + return detail[-1200:] + + +async def _run_process( + args: list[str], + *, + input_text: str | None = None, + cwd: str | None = None, + timeout: float = CLI_TIMEOUT_SECONDS, +) -> tuple[str, str]: + proc = await asyncio.create_subprocess_exec( + *args, + stdin=asyncio.subprocess.PIPE if input_text is not None else asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=cwd, + ) + try: + stdout, stderr = await asyncio.wait_for( + proc.communicate( + input_text.encode("utf-8") if input_text is not None else None + ), + timeout=timeout, + ) + except TimeoutError as exc: + proc.kill() + await proc.wait() + raise ProviderError(f"provider 명령이 {timeout:.0f}초 안에 끝나지 않았습니다.") from exc + if proc.returncode != 0: + raise ProviderError( + _safe_process_error(stderr, f"provider 명령 실패: 종료 코드 {proc.returncode}") + ) + return ( + stdout.decode("utf-8", errors="replace"), + stderr.decode("utf-8", errors="replace"), + ) + + +def _unavailable(provider: EngineProvider, detail: str) -> EngineCapabilitiesResponse: + return EngineCapabilitiesResponse( + provider=provider, + available=False, + source="unavailable", + detail=detail, + fetched_at=_now(), + ) + + +def _display_model_name(model_id: str) -> str: + parts = model_id.split("-") + effort = parts[-1] if parts and parts[-1] in {"low", "medium", "high"} else None + if effort: + parts = parts[:-1] + words: list[str] = [] + for part in parts: + if part.lower() in {"gpt", "oss"}: + words.append(part.upper()) + elif any(char.isdigit() for char in part): + words.append(part) + else: + words.append(part.capitalize()) + label = " ".join(words) + return f"{label} ({effort.capitalize()})" if effort else label + + +async def _discover_claude_cli() -> EngineCapabilitiesResponse: + if _binary("CLAUDE_BIN", "claude") is None: + return _unavailable("claude_cli", "Claude CLI를 찾을 수 없습니다.") + efforts = _efforts(("low", "medium", "high", "xhigh", "max")) + models = [ + EngineModelOption( + id=CLAUDE_CLI_DEFAULT_MODEL, + label="Claude CLI 기본 모델", + description="로그인된 Claude CLI가 권장하는 기본 모델을 사용합니다.", + reasoning_efforts=efforts, + default_reasoning_effort=CLAUDE_DEFAULT_EFFORT, + is_default=True, + ), + *[ + EngineModelOption( + id=model, + label=f"Claude {model.capitalize()} 최신", + description="Claude CLI가 제공하는 안정 alias입니다.", + reasoning_efforts=efforts, + default_reasoning_effort=CLAUDE_DEFAULT_EFFORT, + ) + for model in ("opus", "sonnet", "fable") + ], + ] + return EngineCapabilitiesResponse( + provider="claude_cli", + available=True, + source="static_cli", + models=models, + default_model=CLAUDE_CLI_DEFAULT_MODEL, + default_reasoning_effort=CLAUDE_DEFAULT_EFFORT, + detail="Claude CLI는 모델 목록 명령이 없어 공식 alias를 사용합니다.", + fetched_at=_now(), + ) + + +async def _codex_model_list(binary: str) -> dict[str, Any]: + proc = await asyncio.create_subprocess_exec( + binary, + "app-server", + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + if proc.stdin is None or proc.stdout is None: + proc.kill() + await proc.wait() + raise ProviderError("Codex app-server stdio를 열 수 없습니다.") + + messages = ( + { + "method": "initialize", + "id": 0, + "params": { + "clientInfo": { + "name": "vignette_engine_gateway", + "title": "Vignette Engine Gateway", + "version": "1.0.0", + } + }, + }, + {"method": "initialized", "params": {}}, + { + "method": "model/list", + "id": 6, + "params": {"limit": 100, "includeHidden": False}, + }, + ) + for message in messages: + proc.stdin.write((json.dumps(message) + "\n").encode("utf-8")) + await proc.stdin.drain() + + try: + while True: + raw = await asyncio.wait_for(proc.stdout.readline(), timeout=20) + if not raw: + raise ProviderError("Codex model/list 응답이 비어 있습니다.") + try: + message = json.loads(raw) + except json.JSONDecodeError: + continue + if message.get("id") == 6: + if message.get("error"): + raise ProviderError(str(message["error"].get("message") or message["error"])) + return cast(dict[str, Any], message.get("result") or {}) + except TimeoutError as exc: + raise ProviderError("Codex model/list 응답 시간이 초과됐습니다.") from exc + finally: + if proc.stdin is not None and not proc.stdin.is_closing(): + proc.stdin.close() + if proc.returncode is None: + try: + await asyncio.wait_for(proc.wait(), timeout=2) + except TimeoutError: + proc.kill() + await proc.wait() + + +async def _discover_codex_cli() -> EngineCapabilitiesResponse: + binary = _binary("CODEX_BIN", "codex") + if binary is None: + return _unavailable("codex_cli", "Codex CLI를 찾을 수 없습니다.") + try: + payload = await _codex_model_list(binary) + except (OSError, ProviderError) as exc: + return _unavailable("codex_cli", f"Codex 모델 조회 실패: {exc}") + + raw_models = payload.get("data") if isinstance(payload, dict) else [] + models: list[EngineModelOption] = [] + for item in raw_models if isinstance(raw_models, list) else []: + if not isinstance(item, dict) or item.get("hidden"): + continue + model_id = str(item.get("model") or item.get("id") or "").strip() + if not model_id: + continue + supported = item.get("supportedReasoningEfforts") or [] + efforts = _efforts( + str(entry.get("reasoningEffort") or "") + for entry in supported + if isinstance(entry, dict) + ) + raw_default = str(item.get("defaultReasoningEffort") or "") + default_effort = ( + cast(ReasoningEffort, raw_default) + if raw_default in efforts + else (efforts[0] if efforts else None) + ) + models.append( + EngineModelOption( + id=model_id, + label=str(item.get("displayName") or model_id), + description=str(item.get("description") or ""), + reasoning_efforts=efforts, + default_reasoning_effort=default_effort, + is_default=model_id == CODEX_DEFAULT_MODEL, + ) + ) + if not models: + return _unavailable("codex_cli", "Codex가 선택 가능한 모델을 반환하지 않았습니다.") + + default_model = ( + CODEX_DEFAULT_MODEL + if any(model.id == CODEX_DEFAULT_MODEL for model in models) + else next((model.id for model in models if model.is_default), models[0].id) + ) + selected = next(model for model in models if model.id == default_model) + default_effort = ( + CODEX_DEFAULT_EFFORT + if CODEX_DEFAULT_EFFORT in selected.reasoning_efforts + else selected.default_reasoning_effort + ) + return EngineCapabilitiesResponse( + provider="codex_cli", + available=True, + source="live_cli", + models=models, + default_model=default_model, + default_reasoning_effort=default_effort, + detail="Codex app-server model/list에서 실시간 조회했습니다.", + fetched_at=_now(), + ) + + +async def _discover_agy_cli() -> EngineCapabilitiesResponse: + binary = _binary("AGY_BIN", "agy") + if binary is None: + return _unavailable("agy_cli", "Agy CLI를 찾을 수 없습니다.") + try: + stdout, _ = await _run_process([binary, "models"], timeout=30) + except (OSError, ProviderError) as exc: + return _unavailable("agy_cli", f"Agy 모델 조회 실패: {exc}") + + models: list[EngineModelOption] = [] + for line in stdout.splitlines(): + model_id = line.strip() + if not model_id or any(char.isspace() for char in model_id): + continue + suffix = model_id.rsplit("-", 1)[-1] + if suffix in {"low", "medium", "high"}: + efforts = _efforts((suffix,)) + default_effort = cast(ReasoningEffort, suffix) + else: + efforts = _efforts(("low", "medium", "high")) + default_effort = AGY_DEFAULT_EFFORT if model_id == AGY_DEFAULT_MODEL else "medium" + models.append( + EngineModelOption( + id=model_id, + label=_display_model_name(model_id), + description="Agy CLI가 현재 계정에 노출한 모델입니다.", + reasoning_efforts=efforts, + default_reasoning_effort=default_effort, + is_default=model_id == AGY_DEFAULT_MODEL, + ) + ) + if not models: + return _unavailable("agy_cli", "Agy가 선택 가능한 모델을 반환하지 않았습니다.") + default_model = ( + AGY_DEFAULT_MODEL + if any(model.id == AGY_DEFAULT_MODEL for model in models) + else models[0].id + ) + selected = next(model for model in models if model.id == default_model) + return EngineCapabilitiesResponse( + provider="agy_cli", + available=True, + source="live_cli", + models=models, + default_model=default_model, + default_reasoning_effort=selected.default_reasoning_effort, + detail="agy models에서 실시간 조회했습니다.", + fetched_at=_now(), + ) + + +async def _discover_claude_api() -> EngineCapabilitiesResponse: + api_key = os.environ.get("ANTHROPIC_API_KEY", "").strip() + if not api_key: + return _unavailable("claude_api", "ANTHROPIC_API_KEY가 설정되지 않았습니다.") + try: + async with httpx.AsyncClient(timeout=20) as client: + response = await client.get( + f"{ANTHROPIC_API_BASE}/v1/models", + params={"limit": 100}, + headers={ + "x-api-key": api_key, + "anthropic-version": "2023-06-01", + }, + ) + response.raise_for_status() + payload = response.json() + except (httpx.HTTPError, ValueError) as exc: + return _unavailable("claude_api", f"Anthropic 모델 조회 실패: {exc}") + + models: list[EngineModelOption] = [] + for item in payload.get("data", []) if isinstance(payload, dict) else []: + if not isinstance(item, dict): + continue + model_id = str(item.get("id") or "").strip() + if not model_id: + continue + effort_capability = (item.get("capabilities") or {}).get("effort") or {} + efforts = _efforts( + effort + for effort in ENGINE_REASONING_EFFORTS + if isinstance(effort_capability.get(effort), dict) + and effort_capability[effort].get("supported") + ) + default_effort: ReasoningEffort | None = ( + CLAUDE_DEFAULT_EFFORT + if CLAUDE_DEFAULT_EFFORT in efforts + else (efforts[0] if efforts else None) + ) + models.append( + EngineModelOption( + id=model_id, + label=str(item.get("display_name") or model_id), + description="Anthropic Models API가 현재 키에 노출한 모델입니다.", + reasoning_efforts=efforts, + default_reasoning_effort=default_effort, + ) + ) + if not models: + return _unavailable("claude_api", "Anthropic이 선택 가능한 모델을 반환하지 않았습니다.") + configured_default = os.environ.get("ANTHROPIC_MODEL", "").strip() + default_model = ( + configured_default + if configured_default and any(model.id == configured_default for model in models) + else models[0].id + ) + selected = next(model for model in models if model.id == default_model) + selected.is_default = True + return EngineCapabilitiesResponse( + provider="claude_api", + available=True, + source="live_api", + models=models, + default_model=default_model, + default_reasoning_effort=selected.default_reasoning_effort, + detail="Anthropic /v1/models에서 실시간 조회했습니다.", + fetched_at=_now(), + ) + + +async def _discover(provider: EngineProvider) -> EngineCapabilitiesResponse: + if provider == "claude_cli": + return await _discover_claude_cli() + if provider == "claude_api": + return await _discover_claude_api() + if provider == "codex_cli": + return await _discover_codex_cli() + if provider == "agy_cli": + return await _discover_agy_cli() + return _unavailable(provider, f"{provider} 어댑터는 아직 모델 탐색을 지원하지 않습니다.") + + +async def discover_capabilities( + provider: EngineProvider, *, force: bool = False +) -> EngineCapabilitiesResponse: + cached = _CAPABILITY_CACHE.get(provider) + if ( + not force + and cached is not None + and time.monotonic() - cached[0] < CAPABILITY_CACHE_TTL_SECONDS + ): + return cached[1].model_copy(deep=True) + async with _CAPABILITY_LOCK: + cached = _CAPABILITY_CACHE.get(provider) + if ( + not force + and cached is not None + and time.monotonic() - cached[0] < CAPABILITY_CACHE_TTL_SECONDS + ): + return cached[1].model_copy(deep=True) + result = await _discover(provider) + _CAPABILITY_CACHE[provider] = (time.monotonic(), result) + return result.model_copy(deep=True) + + +def _cli_prompt(system_prompt: str, user_payload: str) -> str: + parts = [] + if system_prompt.strip(): + parts.append("[시스템 지침]\n" + system_prompt.strip()) + parts.append("[응답할 입력]\n" + user_payload.strip()) + return "\n\n".join(parts) + + +def _cli_runtime_cwd() -> Path: + path = Path( + os.environ.get( + "ENGINE_CLI_CWD", + str(Path(tempfile.gettempdir()) / "vignette-engine-runtime"), + ) + ) + path.mkdir(parents=True, exist_ok=True) + return path + + +async def _resolve_selection( + req: GenerateRequest, provider: EngineProvider +) -> tuple[str, ReasoningEffort | None]: + capabilities = await discover_capabilities(provider) + if not capabilities.available: + raise ProviderError(capabilities.detail or f"{provider}를 사용할 수 없습니다.") + requested_model = normalize_engine_gateway_model(req.model) + model = requested_model or capabilities.default_model + option = next((item for item in capabilities.models if item.id == model), None) + if option is None: + raise ProviderError(f"{provider}에서 사용할 수 없는 모델입니다: {model}") + effort = req.reasoning_effort or option.default_reasoning_effort + if effort is not None and effort not in option.reasoning_efforts: + raise ProviderError(f"{model}에서 사용할 수 없는 추론 강도입니다: {effort}") + return option.id, effort + + +def _structured_or_none(text: str, req: GenerateRequest) -> dict[str, Any] | None: + if not req.structured_schema: + return None + try: + parsed = json.loads(text) + except (json.JSONDecodeError, TypeError): + return None + return parsed if isinstance(parsed, dict) else None + + +async def _generate_codex( + req: GenerateRequest, system_prompt: str, user_payload: str +) -> ProviderGenerateResult: + binary = _binary("CODEX_BIN", "codex") + if binary is None: + raise ProviderError("Codex CLI를 찾을 수 없습니다.") + model, effort = await _resolve_selection(req, "codex_cli") + cli_cwd = _cli_runtime_cwd() + args = [ + binary, + "exec", + "--json", + "--ephemeral", + "--skip-git-repo-check", + "--ignore-user-config", + "--ignore-rules", + "--sandbox", + "read-only", + "-C", + str(cli_cwd), + "-m", + model, + ] + if effort: + args += ["-c", f'model_reasoning_effort="{effort}"'] + args.append("-") + stdout, _ = await _run_process( + args, + input_text=_cli_prompt(system_prompt, user_payload), + cwd=str(cli_cwd), + ) + text = "" + tokens_in = 0 + tokens_out = 0 + for line in stdout.splitlines(): + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + if event.get("type") == "item.completed": + item = event.get("item") or {} + if item.get("type") == "agent_message": + text = str(item.get("text") or text) + elif event.get("type") == "turn.completed": + usage = event.get("usage") or {} + tokens_in = int(usage.get("input_tokens") or 0) + tokens_out = int(usage.get("output_tokens") or 0) + elif event.get("type") in {"turn.failed", "error"}: + raise ProviderError(str(event.get("message") or event)) + if not text.strip(): + raise ProviderError("Codex CLI가 최종 응답을 반환하지 않았습니다.") + return ProviderGenerateResult( + text=text, + model=model, + provider="codex_cli", + tokens_in=tokens_in, + tokens_out=tokens_out, + structured=_structured_or_none(text, req), + ) + + +async def _generate_agy( + req: GenerateRequest, system_prompt: str, user_payload: str +) -> ProviderGenerateResult: + binary = _binary("AGY_BIN", "agy") + if binary is None: + raise ProviderError("Agy CLI를 찾을 수 없습니다.") + model, effort = await _resolve_selection(req, "agy_cli") + prompt = _cli_prompt(system_prompt, user_payload) + if os.name == "nt" and len(prompt) > 24_000: + raise ProviderError( + "Agy CLI 프롬프트가 Windows 명령줄 안전 한도(24,000자)를 초과했습니다." + ) + args = [binary, "--model", model, "--sandbox"] + if effort: + args += ["--effort", effort] + args += ["--print-timeout", f"{int(CLI_TIMEOUT_SECONDS)}s"] + # Agy의 --print는 바로 뒤 토큰을 프롬프트로 해석하며 stdin 입력은 + # 지원하지 않는다. 옵션을 모두 앞에 두고 프롬프트를 마지막에 둔다. + args += ["--print", prompt] + stdout, _ = await _run_process(args, cwd=str(_cli_runtime_cwd())) + text = stdout.strip() + if not text: + raise ProviderError("Agy CLI가 최종 응답을 반환하지 않았습니다.") + return ProviderGenerateResult( + text=text, + model=model, + provider="agy_cli", + structured=_structured_or_none(text, req), + ) + + +async def _stream_agy( + req: GenerateRequest, system_prompt: str, user_payload: str +) -> AsyncIterator[ProviderStreamEvent]: + """Agy stream-json의 agent_response delta를 게이트웨이 토큰으로 전달한다. + + Agy print 모드는 대화 내용을 로컬 conversation 저장소에 남길 수 있으므로 여기서는 + --continue/--conversation을 쓰지 않는다. 회기 메모리는 매 요청의 마스킹된 prompt가 + 소유하고, 프로세스는 응답 뒤 종료한다. + """ + binary = _binary("AGY_BIN", "agy") + if binary is None: + raise ProviderError("Agy CLI를 찾을 수 없습니다.") + model, effort = await _resolve_selection(req, "agy_cli") + prompt = _cli_prompt(system_prompt, user_payload) + if os.name == "nt" and len(prompt) > 24_000: + raise ProviderError( + "Agy CLI 프롬프트가 Windows 명령줄 안전 한도(24,000자)를 초과했습니다." + ) + args = [binary, "--model", model, "--sandbox"] + if effort: + args += ["--effort", effort] + args += [ + "--print-timeout", + f"{int(CLI_TIMEOUT_SECONDS)}s", + "--output-format", + "stream-json", + "--print", + prompt, + ] + proc = await asyncio.create_subprocess_exec( + *args, + cwd=str(_cli_runtime_cwd()), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + assert proc.stdout is not None + assert proc.stderr is not None + stderr_task = asyncio.create_task(proc.stderr.read()) + emitted = "" + final_text = "" + tokens_in = 0 + tokens_out = 0 + result_status = "" + try: + async with asyncio.timeout(CLI_TIMEOUT_SECONDS): + while True: + raw = await proc.stdout.readline() + if not raw: + break + try: + event = json.loads(raw.decode("utf-8", errors="replace")) + except json.JSONDecodeError: + continue + if event.get("event") == "step_update": + update = event.get("step_update") or {} + if update.get("step_type") == "agent_response": + delta = str(update.get("text_delta") or "") + if delta: + emitted += delta + yield ProviderStreamEvent(type="delta", text=delta) + elif event.get("event") == "result": + result = event.get("result") or {} + result_status = str(result.get("status") or "") + final_text = str(result.get("response") or "") + usage = result.get("usage") or {} + tokens_in = int(usage.get("input_tokens") or 0) + tokens_out = int(usage.get("output_tokens") or 0) + returncode = await proc.wait() + except TimeoutError as exc: + raise ProviderError( + f"Agy CLI 응답 시간이 {int(CLI_TIMEOUT_SECONDS)}초를 넘었습니다." + ) from exc + finally: + if proc.returncode is None: + proc.kill() + await proc.wait() + stderr = await stderr_task + + if returncode != 0: + raise ProviderError(_safe_process_error(stderr, f"Agy CLI exit {returncode}")) + if result_status and result_status != "SUCCESS": + raise ProviderError(f"Agy CLI 생성 실패: {result_status}") + resolved_text = final_text or emitted + if not resolved_text.strip(): + raise ProviderError("Agy CLI가 최종 응답을 반환하지 않았습니다.") + if final_text and final_text.startswith(emitted): + remainder = final_text[len(emitted) :] + if remainder: + emitted += remainder + yield ProviderStreamEvent(type="delta", text=remainder) + elif not emitted: + emitted = resolved_text + yield ProviderStreamEvent(type="delta", text=resolved_text) + yield ProviderStreamEvent( + type="done", + result=ProviderGenerateResult( + text=resolved_text, + model=model, + provider="agy_cli", + tokens_in=tokens_in, + tokens_out=tokens_out, + structured=_structured_or_none(resolved_text, req), + ), + ) + + +async def _generate_claude_api( + req: GenerateRequest, system_prompt: str +) -> ProviderGenerateResult: + api_key = os.environ.get("ANTHROPIC_API_KEY", "").strip() + if not api_key: + raise ProviderError("ANTHROPIC_API_KEY가 설정되지 않았습니다.") + model, effort = await _resolve_selection(req, "claude_api") + messages = [ + {"role": message.role, "content": message.content} + for message in req.messages + if message.role != "system" + ] + payload: dict[str, Any] = { + "model": model, + "max_tokens": req.max_tokens, + "temperature": req.temperature, + "messages": messages, + } + if system_prompt: + payload["system"] = system_prompt + if effort: + payload["output_config"] = {"effort": effort} + try: + async with httpx.AsyncClient(timeout=CLI_TIMEOUT_SECONDS) as client: + response = await client.post( + f"{ANTHROPIC_API_BASE}/v1/messages", + headers={ + "x-api-key": api_key, + "anthropic-version": "2023-06-01", + }, + json=payload, + ) + response.raise_for_status() + body = response.json() + except (httpx.HTTPError, ValueError) as exc: + raise ProviderError(f"Anthropic Messages API 호출 실패: {exc}") from exc + text = "".join( + str(block.get("text") or "") + for block in body.get("content", []) + if isinstance(block, dict) and block.get("type") == "text" + ) + if not text: + raise ProviderError("Anthropic Messages API가 텍스트 응답을 반환하지 않았습니다.") + usage = body.get("usage") or {} + inference_geo = body.get("inference_geo") + return ProviderGenerateResult( + text=text, + model=str(body.get("model") or model), + provider="claude_api", + tokens_in=int(usage.get("input_tokens") or 0), + tokens_out=int(usage.get("output_tokens") or 0), + inference_geo=str(inference_geo) if inference_geo else None, + structured=_structured_or_none(text, req), + ) + + +async def generate_with_provider( + req: GenerateRequest, + *, + system_prompt: str, + user_payload: str, +) -> ProviderGenerateResult: + provider = req.provider + if provider == "codex_cli": + return await _generate_codex(req, system_prompt, user_payload) + if provider == "agy_cli": + return await _generate_agy(req, system_prompt, user_payload) + if provider == "claude_api": + return await _generate_claude_api(req, system_prompt) + raise ProviderError(f"이 게이트웨이에서 실행할 수 없는 provider입니다: {provider}") + + +async def stream_with_provider( + req: GenerateRequest, + *, + system_prompt: str, + user_payload: str, +) -> AsyncIterator[ProviderStreamEvent]: + """Provider가 제공하는 가장 이른 출력 단위를 공통 delta/done 계약으로 바꾼다.""" + if req.provider == "agy_cli": + async for event in _stream_agy(req, system_prompt, user_payload): + yield event + return + result = await generate_with_provider( + req, + system_prompt=system_prompt, + user_payload=user_payload, + ) + yield ProviderStreamEvent(type="delta", text=result.text) + yield ProviderStreamEvent(type="done", result=result) diff --git a/apps/api/engine_gateway/test_gateway_model.py b/apps/api/engine_gateway/test_gateway_model.py index 40af947..169a61b 100644 --- a/apps/api/engine_gateway/test_gateway_model.py +++ b/apps/api/engine_gateway/test_gateway_model.py @@ -4,7 +4,8 @@ import shutil import subprocess import unittest from pathlib import Path -from unittest.mock import patch +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch from jsonschema import Draft202012Validator @@ -36,6 +37,28 @@ class _FakeProcess: self.returncode = -9 +class _StreamStdin(_FakeStdin): + def __init__(self): + self.writes = [] + + def write(self, value): + self.writes.append(value) + + async def drain(self): + return None + + +class _StreamStdout: + def __init__(self, objects): + self.lines = [ + (json.dumps(obj, ensure_ascii=False) + "\n").encode("utf-8") + for obj in objects + ] + + async def readline(self): + return self.lines.pop(0) if self.lines else b"" + + def _capture_subprocess(): captured = [] @@ -159,6 +182,7 @@ class _FakeStreamSession: def __init__(self, events, model="test-model"): self.events = events self.model = model + self.turns = 0 self.closed = False async def turn_stream(self, content, timeout=600.0): @@ -194,6 +218,42 @@ class GatewayModelTest(unittest.TestCase): self.assertIs(engine_client.GenerateRequest, contract.GenerateRequest) self.assertEqual(contract.ENGINE_GATEWAY_SSE_EVENTS, ("token", "done", "error")) + def test_engine_client_payload_includes_provider_model_and_reasoning_defaults(self): + client = engine_client.EngineClient("http://127.0.0.1:9099") + client.engine_mode = "codex_cli" + client.live_client_provider = None + client.default_model = "gpt-5.6-terra" + client.default_reasoning_effort = "medium" + + payload = client._payload( + contract.GenerateRequest( + messages=[contract.EngineMessage(role="user", content="hello")] + ) + ) + + self.assertEqual(payload["provider"], "codex_cli") + self.assertEqual(payload["model"], "gpt-5.6-terra") + self.assertEqual(payload["reasoning_effort"], "medium") + + def test_engine_client_uses_dedicated_live_provider_without_foreign_model_defaults(self): + client = engine_client.EngineClient("http://127.0.0.1:9099") + client.engine_mode = "agy_cli" + client.default_model = "gemini-3.6-flash-high" + client.default_reasoning_effort = "high" + client.live_client_provider = "claude_cli" + + payload = client._payload( + contract.GenerateRequest( + ai_role="client", + session_id="session-id", + messages=[contract.EngineMessage(role="user", content="hello")], + ) + ) + + self.assertEqual(payload["provider"], "claude_cli") + self.assertNotIn("model", payload) + self.assertNotIn("reasoning_effort", payload) + def test_split_messages_returns_named_current_turn_prompt_parts(self): parts = gateway._split_messages( [ @@ -209,11 +269,13 @@ class GatewayModelTest(unittest.TestCase): self.assertIsInstance(parts, gateway.GatewayPromptParts) self.assertEqual(parts.system_prompt, "system one\n\nsystem two") self.assertEqual(parts.user_payload, "current client") + self.assertEqual(parts.current_user_payload, "current client") def test_split_messages_injects_client_history_before_current_counselor_turn(self): parts = gateway._split_messages( [ - contract.EngineMessage(role="system", content="client persona system"), + contract.EngineMessage(role="system", content="client persona system", cache=True), + contract.EngineMessage(role="system", content="dynamic state", cache=False), contract.EngineMessage(role="user", content="상담자 이전 질문"), contract.EngineMessage(role="assistant", content="내담자 이전 답변"), contract.EngineMessage(role="user", content="이번 상담자 발화"), @@ -222,11 +284,15 @@ class GatewayModelTest(unittest.TestCase): ) self.assertEqual(parts.system_prompt, "client persona system") + self.assertIn("[현재 턴 상태와 연기 지시]", parts.user_payload) + self.assertIn("dynamic state", parts.user_payload) self.assertIn("[직전 대화]", parts.user_payload) self.assertIn("상담자: 상담자 이전 질문", parts.user_payload) self.assertIn("내담자: 내담자 이전 답변", parts.user_payload) self.assertIn("[이번 상담자 발화]", parts.user_payload) self.assertTrue(parts.user_payload.rstrip().endswith("이번 상담자 발화")) + self.assertNotIn("[직전 대화]", parts.current_user_payload) + self.assertIn("dynamic state", parts.current_user_payload) def test_split_messages_does_not_inject_history_for_evaluator_requests(self): parts = gateway._split_messages( @@ -250,6 +316,11 @@ class GatewayModelTest(unittest.TestCase): self.assertEqual(parts.system_prompt, "system only") self.assertEqual(parts.user_payload, "") + self.assertEqual(parts.current_user_payload, "") + + def test_claude_process_enables_real_partial_streaming_without_disk_session_copy(self): + self.assertIn("--include-partial-messages", gateway.BASE_ARGS) + self.assertIn("--no-session-persistence", gateway.BASE_ARGS) def test_sse_frame_helper_preserves_gateway_wire_contract(self): self.assertEqual( @@ -496,6 +567,135 @@ class GatewayModelTest(unittest.TestCase): finally: asyncio.run(session.close()) + def test_engine_session_passes_reasoning_effort_to_claude_cli(self): + captured, process_patch = _capture_subprocess() + with ( + patch.object(gateway, "DEFAULT_MODEL", ""), + patch.object(gateway, "FALLBACK_MODEL", ""), + process_patch, + ): + session = gateway.EngineSession( + model="opus", + reasoning_effort="high", + ) + asyncio.run(session.start()) + try: + self.assertIn("--effort", captured[0]) + self.assertEqual( + captured[0][captured[0].index("--effort") + 1], "high" + ) + finally: + asyncio.run(session.close()) + + def test_engine_session_emits_partial_stream_events_without_final_message_duplication(self): + process = _FakeProcess() + process.stdin = _StreamStdin() + process.stdout = _StreamStdout( + [ + { + "type": "stream_event", + "event": { + "type": "content_block_delta", + "delta": {"type": "text_delta", "text": "안"}, + }, + }, + { + "type": "stream_event", + "event": { + "type": "content_block_delta", + "delta": {"type": "text_delta", "text": "녕!"}, + }, + }, + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "안녕!"}]}, + }, + {"type": "result", "is_error": False, "total_cost_usd": 0.01}, + ] + ) + session = gateway.EngineSession() + session.proc = process + + async def collect(): + return [event async for event in session.turn_stream("질문")] + + events = asyncio.run(collect()) + + self.assertEqual( + events, + [ + {"type": "delta", "text": "안"}, + {"type": "delta", "text": "녕!"}, + { + "type": "done", + "text": "안녕!", + "cost_usd": 0.01, + "turns": 1, + "is_error": False, + "error": "안녕!", + }, + ], + ) + + def test_v1_generate_routes_non_claude_provider_through_registry(self): + result = SimpleNamespace( + text="registry response", + model="gpt-5.6-terra", + provider="codex_cli", + tokens_in=12, + tokens_out=3, + cost_usd=0.0, + inference_geo=None, + structured=None, + ) + request = contract.GenerateRequest( + provider="codex_cli", + model="gpt-5.6-terra", + reasoning_effort="medium", + messages=[contract.EngineMessage(role="user", content="hello")], + ) + with patch.object( + gateway, + "generate_with_provider", + AsyncMock(return_value=result), + ) as generate: + response = asyncio.run(gateway.v1_generate(request)) + + self.assertEqual(response["provider"], "codex_cli") + self.assertEqual(response["model"], "gpt-5.6-terra") + generate.assert_awaited_once() + + def test_v1_stream_forwards_non_claude_provider_deltas(self): + result = SimpleNamespace( + text="안녕", + model="gemini-3.6-flash-high", + provider="agy_cli", + tokens_in=12, + tokens_out=2, + cost_usd=0.0, + ) + request = contract.GenerateRequest( + provider="agy_cli", + model="gemini-3.6-flash-high", + reasoning_effort="high", + messages=[contract.EngineMessage(role="user", content="hello")], + ) + + async def fake_stream(*args, **kwargs): + yield SimpleNamespace(type="delta", text="안", result=None) + yield SimpleNamespace(type="delta", text="녕", result=None) + yield SimpleNamespace(type="done", text="", result=result) + + with patch.object(gateway, "stream_with_provider", fake_stream): + response = asyncio.run(gateway.v1_stream(request)) + body = asyncio.run(_read_streaming_response(response)) + + self.assertEqual(body.count("event: token"), 2) + self.assertIn('data: {"text": "안"}', body) + self.assertIn('data: {"text": "녕"}', body) + self.assertIn("event: done", body) + self.assertIn('"provider": "agy_cli"', body) + def test_resolve_session_does_not_reuse_session_with_different_model(self): captured, process_patch = _capture_subprocess() existing = gateway.EngineSession(model="old-model") @@ -512,11 +712,13 @@ class GatewayModelTest(unittest.TestCase): ) try: - self.assertIs(ephemeral, True) + self.assertIs(ephemeral, False) self.assertIsNot(session, existing) self.assertEqual(_model_arg(captured[0]), "new-model") - self.assertIs(gateway.SESSIONS["sid"], existing) + self.assertIs(gateway.SESSIONS["sid"], session) + self.assertEqual(existing.proc.returncode, 0) finally: + gateway.SESSIONS.pop("sid", None) asyncio.run(session.close()) def test_resolve_session_reuses_live_session_id_without_starting_claude(self): @@ -538,7 +740,7 @@ class GatewayModelTest(unittest.TestCase): self.assertIs(ephemeral, False) self.assertEqual(captured, []) - def test_resolve_session_creates_fresh_ephemeral_for_missing_session_id(self): + def test_resolve_session_binds_missing_client_session_id_to_resident_pool(self): captured, process_patch = _capture_subprocess() with ( @@ -551,11 +753,12 @@ class GatewayModelTest(unittest.TestCase): ) try: - self.assertIs(ephemeral, True) - self.assertNotIn(session.id, gateway.SESSIONS) + self.assertIs(ephemeral, False) + self.assertIs(gateway.SESSIONS["missing"], session) self.assertEqual(len(captured), 1) self.assertIn("--system-prompt", captured[0]) finally: + gateway.SESSIONS.pop("missing", None) asyncio.run(session.close()) def test_v1_generate_reuses_session_id_without_ephemeral_close(self): @@ -581,7 +784,10 @@ class GatewayModelTest(unittest.TestCase): self.assertEqual(validated.text, "reused response") self.assertEqual(validated.provider, "claude_cli") self.assertEqual(validated.cost_usd, 0.01) - self.assertEqual(calls, [("hello", gateway.GENERATE_TURN_TIMEOUT_SECONDS)]) + self.assertEqual( + calls, + [("[이번 상담자 발화]\nhello", gateway.GENERATE_TURN_TIMEOUT_SECONDS)], + ) self.assertEqual(closes, []) def test_v1_generate_closes_fresh_ephemeral_session(self): @@ -605,14 +811,23 @@ class GatewayModelTest(unittest.TestCase): patch.object(gateway.EngineSession, "turn", fake_turn), patch.object(gateway.EngineSession, "close", fake_close), ): - response = asyncio.run(gateway.v1_generate(_request(session_id="missing"))) + response = asyncio.run(gateway.v1_generate(_request())) validated = contract.GenerateResponse.model_validate(response) self.assertEqual(validated.text, "fresh response") self.assertEqual(validated.provider, "claude_cli") self.assertEqual(validated.cost_usd, 0.02) self.assertEqual(len(started), 1) - self.assertEqual(turned, [(started[0], "hello", gateway.GENERATE_TURN_TIMEOUT_SECONDS)]) + self.assertEqual( + turned, + [ + ( + started[0], + "[이번 상담자 발화]\nhello", + gateway.GENERATE_TURN_TIMEOUT_SECONDS, + ) + ], + ) self.assertEqual(closed, [started[0]]) self.assertNotIn(started[0].id, gateway.SESSIONS) @@ -653,7 +868,7 @@ class GatewayModelTest(unittest.TestCase): self.assertIn('"provider": "claude_cli"', body) self.assertIn('"model": "stream-model"', body) self.assertIn('"cost_usd": 0.03', body) - self.assertEqual(session.content, "hello") + self.assertEqual(session.content, "[이번 상담자 발화]\nhello") self.assertEqual(session.timeout, 600.0) self.assertTrue(session.closed) diff --git a/apps/api/engine_gateway/test_provider_registry.py b/apps/api/engine_gateway/test_provider_registry.py new file mode 100644 index 0000000..bdfebba --- /dev/null +++ b/apps/api/engine_gateway/test_provider_registry.py @@ -0,0 +1,377 @@ +import json +import unittest +from unittest.mock import AsyncMock, patch + +from app.contracts.engine_gateway import EngineMessage, GenerateRequest +from engine_gateway import provider_registry + + +class _FakeStreamReader: + def __init__(self, lines: list[bytes] | None = None, body: bytes = b""): + self.lines = list(lines or []) + self.body = body + + async def readline(self) -> bytes: + return self.lines.pop(0) if self.lines else b"" + + async def read(self) -> bytes: + return self.body + + +class _FakeAgyProcess: + def __init__(self, events: list[dict]): + self.stdout = _FakeStreamReader( + [(json.dumps(event, ensure_ascii=False) + "\n").encode("utf-8") for event in events] + ) + self.stderr = _FakeStreamReader() + self.returncode = None + + async def wait(self) -> int: + if self.returncode is None: + self.returncode = 0 + return self.returncode + + def kill(self) -> None: + self.returncode = -9 + + +class ProviderRegistryTest(unittest.IsolatedAsyncioTestCase): + def setUp(self): + provider_registry.clear_capability_cache() + + def tearDown(self): + provider_registry.clear_capability_cache() + + async def test_codex_catalog_uses_live_models_with_terra_medium_default(self): + payload = { + "data": [ + { + "id": "gpt-5.6-sol", + "model": "gpt-5.6-sol", + "displayName": "GPT-5.6-Sol", + "description": "Frontier", + "hidden": False, + "isDefault": True, + "defaultReasoningEffort": "low", + "supportedReasoningEfforts": [ + {"reasoningEffort": "low"}, + {"reasoningEffort": "medium"}, + ], + }, + { + "id": "gpt-5.6-terra", + "model": "gpt-5.6-terra", + "displayName": "GPT-5.6-Terra", + "description": "Balanced", + "hidden": False, + "isDefault": False, + "defaultReasoningEffort": "medium", + "supportedReasoningEfforts": [ + {"reasoningEffort": "low"}, + {"reasoningEffort": "medium"}, + {"reasoningEffort": "high"}, + ], + }, + ] + } + with ( + patch.object(provider_registry, "_binary", return_value="codex.exe"), + patch.object( + provider_registry, + "_codex_model_list", + AsyncMock(return_value=payload), + ), + ): + result = await provider_registry.discover_capabilities("codex_cli") + + self.assertTrue(result.available) + self.assertEqual(result.source, "live_cli") + self.assertEqual(result.default_model, "gpt-5.6-terra") + self.assertEqual(result.default_reasoning_effort, "medium") + terra = next(model for model in result.models if model.id == "gpt-5.6-terra") + self.assertTrue(terra.is_default) + self.assertEqual(terra.reasoning_efforts, ["low", "medium", "high"]) + + async def test_agy_catalog_uses_cli_list_with_flash_high_default(self): + stdout = "\n".join( + [ + "gemini-3.6-flash-high", + "gemini-3.6-flash-medium", + "claude-sonnet-4-6", + ] + ) + with ( + patch.object(provider_registry, "_binary", return_value="agy.exe"), + patch.object( + provider_registry, + "_run_process", + AsyncMock(return_value=(stdout, "")), + ), + ): + result = await provider_registry.discover_capabilities("agy_cli") + + self.assertTrue(result.available) + self.assertEqual(result.default_model, "gemini-3.6-flash-high") + self.assertEqual(result.default_reasoning_effort, "high") + selected = next(model for model in result.models if model.is_default) + self.assertEqual(selected.reasoning_efforts, ["high"]) + self.assertEqual(selected.label, "Gemini 3.6 Flash (High)") + + async def test_claude_cli_catalog_is_explicit_static_alias_fallback(self): + with patch.object(provider_registry, "_binary", return_value="claude.exe"): + result = await provider_registry.discover_capabilities("claude_cli") + + self.assertTrue(result.available) + self.assertEqual(result.source, "static_cli") + self.assertEqual(result.default_model, "gateway-default") + self.assertEqual([model.id for model in result.models], ["gateway-default", "opus", "sonnet", "fable"]) + + async def test_anthropic_catalog_fails_closed_without_api_key(self): + with patch.dict(provider_registry.os.environ, {}, clear=True): + result = await provider_registry.discover_capabilities( + "claude_api", force=True + ) + + self.assertFalse(result.available) + self.assertEqual(result.source, "unavailable") + self.assertEqual(result.models, []) + self.assertIn("ANTHROPIC_API_KEY", result.detail) + + async def test_codex_generation_uses_model_and_reasoning_from_selection(self): + capabilities = provider_registry.EngineCapabilitiesResponse( + provider="codex_cli", + available=True, + source="live_cli", + models=[ + provider_registry.EngineModelOption( + id="gpt-5.6-terra", + label="GPT-5.6-Terra", + reasoning_efforts=["low", "medium", "high"], + default_reasoning_effort="medium", + is_default=True, + ) + ], + default_model="gpt-5.6-terra", + default_reasoning_effort="medium", + fetched_at=1, + ) + stdout = "\n".join( + [ + json.dumps( + { + "type": "item.completed", + "item": {"type": "agent_message", "text": "OK"}, + } + ), + json.dumps( + { + "type": "turn.completed", + "usage": {"input_tokens": 12, "output_tokens": 3}, + } + ), + ] + ) + runner = AsyncMock(return_value=(stdout, "")) + request = GenerateRequest( + provider="codex_cli", + model="gpt-5.6-terra", + reasoning_effort="medium", + messages=[EngineMessage(role="user", content="hello")], + ) + with ( + patch.object(provider_registry, "_binary", return_value="codex.exe"), + patch.object( + provider_registry, + "discover_capabilities", + AsyncMock(return_value=capabilities), + ), + patch.object(provider_registry, "_run_process", runner), + ): + result = await provider_registry.generate_with_provider( + request, + system_prompt="system", + user_payload="hello", + ) + + self.assertEqual(result.text, "OK") + self.assertEqual(result.tokens_in, 12) + self.assertEqual(result.tokens_out, 3) + args = runner.await_args.args[0] + self.assertIn("gpt-5.6-terra", args) + self.assertIn('model_reasoning_effort="medium"', args) + self.assertEqual(args[-1], "-") + self.assertIn("[시스템 지침]", runner.await_args.kwargs["input_text"]) + + async def test_agy_generation_passes_prompt_immediately_after_print_flag(self): + capabilities = provider_registry.EngineCapabilitiesResponse( + provider="agy_cli", + available=True, + source="live_cli", + models=[ + provider_registry.EngineModelOption( + id="gemini-3.6-flash-high", + label="Gemini 3.6 Flash (High)", + reasoning_efforts=["high"], + default_reasoning_effort="high", + ) + ], + default_model="gemini-3.6-flash-high", + default_reasoning_effort="high", + fetched_at=1, + ) + request = GenerateRequest( + provider="agy_cli", + model="gemini-3.6-flash-high", + reasoning_effort="high", + messages=[EngineMessage(role="user", content="hello")], + ) + runner = AsyncMock(return_value=("OK\n", "")) + + with ( + patch.object(provider_registry, "_binary", return_value="agy"), + patch.object( + provider_registry, + "discover_capabilities", + AsyncMock(return_value=capabilities), + ), + patch.object(provider_registry, "_run_process", runner), + ): + result = await provider_registry.generate_with_provider( + request, + system_prompt="system", + user_payload="hello", + ) + + self.assertEqual(result.text, "OK") + args = runner.await_args.args[0] + print_index = args.index("--print") + self.assertEqual(print_index, len(args) - 2) + self.assertIn("[시스템 지침]", args[-1]) + self.assertNotIn("input_text", runner.await_args.kwargs) + + async def test_agy_stream_forwards_live_deltas_without_repeating_final_response(self): + capabilities = provider_registry.EngineCapabilitiesResponse( + provider="agy_cli", + available=True, + source="live_cli", + models=[ + provider_registry.EngineModelOption( + id="gemini-3.6-flash-high", + label="Gemini 3.6 Flash (High)", + reasoning_efforts=["high"], + default_reasoning_effort="high", + is_default=True, + ) + ], + default_model="gemini-3.6-flash-high", + default_reasoning_effort="high", + fetched_at=1, + ) + request = GenerateRequest( + provider="agy_cli", + model="gemini-3.6-flash-high", + reasoning_effort="high", + messages=[EngineMessage(role="user", content="hello")], + ) + process = _FakeAgyProcess( + [ + { + "event": "step_update", + "step_update": { + "step_type": "agent_response", + "state": "ACTIVE", + "text_delta": "안", + }, + }, + { + "event": "step_update", + "step_update": { + "step_type": "agent_response", + "state": "DONE", + "text_delta": "녕", + }, + }, + { + "event": "result", + "result": { + "status": "SUCCESS", + "response": "안녕", + "usage": {"input_tokens": 12, "output_tokens": 2}, + }, + }, + ] + ) + captured: list[tuple] = [] + + async def fake_create_subprocess_exec(*args, **kwargs): + captured.append(args) + return process + + with ( + patch.object(provider_registry, "_binary", return_value="agy.exe"), + patch.object( + provider_registry, + "discover_capabilities", + AsyncMock(return_value=capabilities), + ), + patch.object( + provider_registry.asyncio, + "create_subprocess_exec", + fake_create_subprocess_exec, + ), + ): + events = [ + event + async for event in provider_registry.stream_with_provider( + request, + system_prompt="system", + user_payload="hello", + ) + ] + + self.assertEqual([event.type for event in events], ["delta", "delta", "done"]) + self.assertEqual("".join(event.text for event in events), "안녕") + self.assertEqual(events[-1].result.text, "안녕") + self.assertEqual(events[-1].result.tokens_in, 12) + self.assertEqual(events[-1].result.tokens_out, 2) + args = captured[0] + self.assertIn("--output-format", args) + self.assertEqual(args[args.index("--output-format") + 1], "stream-json") + self.assertEqual(args.index("--print"), len(args) - 2) + + async def test_generation_rejects_model_effort_not_returned_by_provider(self): + capabilities = provider_registry.EngineCapabilitiesResponse( + provider="agy_cli", + available=True, + source="live_cli", + models=[ + provider_registry.EngineModelOption( + id="gemini-3.6-flash-high", + label="Gemini 3.6 Flash (High)", + reasoning_efforts=["high"], + default_reasoning_effort="high", + ) + ], + default_model="gemini-3.6-flash-high", + default_reasoning_effort="high", + fetched_at=1, + ) + request = GenerateRequest( + provider="agy_cli", + model="gemini-3.6-flash-high", + reasoning_effort="low", + messages=[EngineMessage(role="user", content="hello")], + ) + with patch.object( + provider_registry, + "discover_capabilities", + AsyncMock(return_value=capabilities), + ): + with self.assertRaisesRegex( + provider_registry.ProviderError, "사용할 수 없는 추론 강도" + ): + await provider_registry._resolve_selection(request, "agy_cli") + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/web/e2e/README.md b/apps/web/e2e/README.md index 968b44f..12cb38a 100644 --- a/apps/web/e2e/README.md +++ b/apps/web/e2e/README.md @@ -26,7 +26,7 @@ VITE_API_BASE=http://127.0.0.1:8000 npm run e2e Feature evidence map: -- `session-persistence.spec.ts` is the primary DB-backed evidence for session runtime flows. It covers browser `openSessionStream()` persistence, Korean PII masking through the browser stream into DB-backed detail/review payloads, crisis learner-only safety persistence without a client AI reply, AI tutor live coach history, source-pack metadata round-trip, voice metadata persistence, learner worksheet/review persistence, session-end evaluation storage, explicit teacher session/turn reevaluation, and manual teacher UI evaluation retry from a real failed row into durable DB state. The AI tutor history test must see `status=ready` and `latency_ms>0`; degraded fallback must not pass as normal engine-backed coaching. +- `session-persistence.spec.ts` is the primary DB-backed evidence for session runtime flows. It covers browser `openSessionStream()` persistence, Korean PII masking through the browser stream into DB-backed detail/review payloads, crisis learner-only safety persistence without a client AI reply, AI tutor live coach history, source-pack metadata round-trip, voice metadata persistence, learner worksheet/review persistence, session-end evaluation storage, explicit teacher session/turn reevaluation, and fail-closed admin engine configuration that rejects an unreachable gateway without mutating the durable setting. The AI tutor history test must see `status=ready` and `latency_ms>0`; degraded fallback must not pass as normal engine-backed coaching. - `kb-source-packs.spec.ts` is DB-backed source-pack sync evidence. It checks admin-only sync and source-scoped evaluator RAG lookup for the licensed source packs; evaluator retrieval 503 is a failure, not a skipped proof. - `session-review.spec.ts` is route-fixture UI regression evidence for review states, including delayed `평가 대기` to `평가 완료` polling, `평가 실패`, `AI 평가 재시도`, and pre/post input validation states. It does not prove that the evaluator wrote a DB row unless paired with `session-persistence.spec.ts`. - `teacher.spec.ts` mixes DB-backed teacher console paths with route-fixture queue/readability checks. Use the individual test body before citing it as persisted evidence. @@ -60,6 +60,19 @@ $env:E2E_PUBLIC_STORAGE_STATE="./node_modules/.tmp/public-admin-auth.json" npx playwright test e2e/public-admin-visual.spec.ts --project=chromium-public-auth ``` +이 스모크에는 공식 EasyList의 과거 충돌 규칙(`.ad-root`, `.ad-section`)을 첫 paint부터 +주입한 상태로 운영 홈·AI 운영·사용자·권한·티켓 5경로의 실제 가시성을 확인하는 게이트가 포함된다. +API 200, DOM 존재, selector count만으로는 통과하지 않는다. + +Production lazy-chunk recovery smoke (no sign-in state required): + +```powershell +$env:E2E_PREVIEW_BUILD="1" +$env:PLAYWRIGHT_SKIP_WEB_SERVER="1" +$env:PLAYWRIGHT_BASE_URL="https://vignette.chanpaca.net" +npx playwright test e2e/chunk-recovery-preview.spec.ts --project=chromium-single-run --workers=1 +``` + Notes: - `E2E_PUBLIC_AUTH=1` targets the public site and does not start the local Vite web server. diff --git a/apps/web/e2e/admin.spec.ts b/apps/web/e2e/admin.spec.ts index 590f576..1088dc8 100644 --- a/apps/web/e2e/admin.spec.ts +++ b/apps/web/e2e/admin.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type APIResponse, type Page, type Response, type TestInfo } from "@playwright/test"; +import { expect, test, type APIResponse, type Page, type Response, type TestInfo } from "@playwright/test"; import { completeOnboarding, expectNoHorizontalOverflow, @@ -99,6 +99,7 @@ interface AdminEngineConfigResponse { engine_mode: string; engine_url: string; model: string; + reasoning_effort: string | null; source: "database" | "runtime_cache" | "runtime_default"; durable: boolean; updated_by: string | null; @@ -174,9 +175,11 @@ async function mockAdminSession( adminTickets?: unknown; adminUsage?: AdminUsageResponse; engineConfig?: AdminEngineConfigResponse; + authFailuresBeforeSuccess?: number; } = {}, ) { const seenAdminEndpoints = new Set(); + let authAttempts = 0; const json = (body: unknown) => JSON.stringify(body); await page.route("**/api/**", async (route) => { @@ -192,6 +195,11 @@ async function mockAdminSession( }); if (method === "GET" && path.endsWith("/auth/me")) { + authAttempts += 1; + if (authAttempts <= (options.authFailuresBeforeSuccess ?? 0)) { + await fulfillJson({ detail: "public runtime is starting" }, 503); + return; + } await fulfillJson({ user_id: authUser.user_id ?? "stale-admin", email: authUser.email ?? "stale-admin@twentyoz.kr", @@ -268,6 +276,64 @@ async function mockAdminSession( return; } + if (method === "GET" && path.endsWith("/admin/engine-capabilities")) { + seenAdminEndpoints.add("engine-capabilities"); + const provider = url.searchParams.get("engine_mode") ?? "openai"; + const models = + provider === "codex_cli" + ? [ + { + id: "gpt-5.6-terra", + label: "GPT-5.6 Terra", + description: "Codex 기본 모델", + reasoning_efforts: ["low", "medium", "high"], + default_reasoning_effort: "medium", + is_default: true, + }, + ] + : provider === "agy_cli" + ? [ + { + id: "gemini-3.6-flash-high", + label: "Gemini 3.6 Flash (High)", + description: "Agy 기본 모델", + reasoning_efforts: ["high"], + default_reasoning_effort: "high", + is_default: true, + }, + ] + : [ + { + id: "gateway-default", + label: "게이트웨이 기본 모델", + description: "테스트 기본 모델", + reasoning_efforts: ["medium"], + default_reasoning_effort: "medium", + is_default: true, + }, + { + id: "gpt-5.1-mini", + label: "GPT-5.1 Mini", + description: "테스트 선택 모델", + reasoning_efforts: ["low", "medium", "high"], + default_reasoning_effort: "medium", + is_default: false, + }, + ]; + const defaultModel = models[0]; + await fulfillJson({ + provider, + available: true, + source: "live_cli", + models, + default_model: defaultModel.id, + default_reasoning_effort: defaultModel.default_reasoning_effort, + detail: "테스트 모델 목록", + fetched_at: 1_783_990_800, + }); + return; + } + if (method === "GET" && path.endsWith("/admin/engine-config")) { seenAdminEndpoints.add("engine-config"); await fulfillJson( @@ -275,6 +341,7 @@ async function mockAdminSession( engine_mode: "openai", engine_url: "http://127.0.0.1:9099", model: "gateway-default", + reasoning_effort: "medium", source: "database", durable: true, updated_by: "admin@twentyoz.kr", @@ -292,6 +359,7 @@ async function mockAdminSession( engine_mode: "openai", engine_url: "http://127.0.0.1:9099", model: "gateway-default", + reasoning_effort: "medium", }), ...body, source: "database", @@ -565,7 +633,7 @@ async function openAdminTickets(page: Page) { } async function expectCreateUserControlsFit(page: Page, viewportWidth: number) { - const form = page.locator(".ad-user-create"); + const form = page.locator(".vgops-user-create"); await expect(form).toBeVisible(); const clippedControls = await form.evaluate((element) => { @@ -619,7 +687,7 @@ async function expectVisibleButtonsFit(page: Page, selector: string, context: st .map((button) => { const rect = button.getBoundingClientRect(); const owner = - button.closest(".ad-user,.ad-user-create,.ad-user-table tbody tr") ?? + button.closest(".vgops-user,.vgops-user-create,.vgops-user-table tbody tr") ?? button.parentElement; const ownerRect = owner?.getBoundingClientRect(); const style = window.getComputedStyle(button); @@ -661,7 +729,7 @@ test.describe("admin route guards", () => { await expect(page).toHaveURL(/\/admin$/); await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible(); - await expect(page.locator(".ad-status")).toContainText("개발"); + await expect(page.locator(".vgops-status")).toContainText("개발"); await expect .poll(() => Array.from(seenAdminEndpoints).sort()) .toEqual(["health", "tickets", "uptime", "usage", "users"]); @@ -688,6 +756,118 @@ test.describe("admin route guards", () => { .toEqual(["health", "tickets", "uptime", "usage", "users"]); }); + test("restores every admin page for admin, super-admin, and delegated admin sessions", async ({ + page, + }) => { + const adminRoutes = [ + { path: "/admin", heading: "현재 서비스 상태" }, + { path: "/admin/ai", heading: "AI 운영과 DB 계량" }, + { path: "/admin/users", heading: "가입 승인과 권한 관리" }, + { path: "/admin/access", heading: "역할, 그룹, 접근 범위" }, + { path: "/admin/tickets", heading: "사용자 문제 큐" }, + ] as const; + const sessions = [ + { + user_id: "role-admin", + email: "role-admin@twentyoz.kr", + display_name: "Role Admin", + role: "admin" as const, + admin_access: false, + super_admin: false, + }, + { + user_id: "super-admin", + email: "super-admin@twentyoz.kr", + display_name: "Super Admin", + role: "learner" as const, + admin_access: false, + super_admin: true, + }, + { + user_id: "delegated-admin", + email: "delegated-admin@hs.ac.kr", + display_name: "Delegated Admin", + role: "teacher" as const, + admin_access: true, + super_admin: false, + }, + ] as const; + + for (const session of sessions) { + await page.unrouteAll({ behavior: "wait" }); + await mockAdminSession(page, { + ...session, + onboarding_completed_at: 1_782_900_000, + }); + + for (const route of adminRoutes) { + await page.goto(route.path); + await expect(page).toHaveURL(new RegExp(`${route.path.replace("/", "\\/")}$`)); + await expect(page.getByRole("heading", { name: route.heading })).toBeVisible(); + await expect(page.locator(".vg-nav").getByRole("link", { name: "운영 홈" })).toBeVisible(); + await expect(page.locator(".vg-nav").getByRole("link", { name: "AI 운영" })).toBeVisible(); + await expect(page.locator(".vg-nav").getByRole("link", { name: "사용자" })).toBeVisible(); + await expect(page.locator(".vg-nav").getByRole("link", { name: "권한" })).toBeVisible(); + await expect(page.locator(".vg-nav").getByRole("link", { name: "티켓" })).toBeVisible(); + } + } + }); + + test("keeps the admin entry visible after a primary-role super-admin leaves the admin workspace", async ({ + page, + }) => { + await mockAdminSession(page, { + user_id: "primary-role-super-admin", + email: "primary-role-super-admin@twentyoz.kr", + display_name: "Primary Role Super Admin", + role: "learner", + admin_access: false, + super_admin: true, + onboarding_completed_at: 1_782_900_000, + }); + + await page.goto("/admin"); + await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible(); + await page.locator(".vg-nav").getByRole("link", { name: "학습자 홈" }).click(); + + await expect(page).toHaveURL(/\/learn$/); + await expect(page.locator(".vg-nav").getByRole("link", { name: "운영 콘솔" })).toBeVisible(); + + await page.locator(".vg-nav").getByRole("link", { name: "운영 콘솔" }).click(); + await expect(page).toHaveURL(/\/admin$/); + await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible(); + }); + + test("keeps the session recoverable when auth restore starts during a server restart", async ({ + page, + }) => { + await mockAdminSession( + page, + { + user_id: "restart-admin", + email: "restart-admin@twentyoz.kr", + display_name: "Restart Admin", + role: "admin", + admin_access: true, + super_admin: true, + onboarding_completed_at: 1_782_900_000, + }, + { authFailuresBeforeSuccess: 3 }, + ); + + await page.goto("/admin"); + + await expect(page.getByTestId("auth-restore-failed")).toBeVisible(); + await expect(page.getByRole("heading", { name: "관리자 권한이 사라진 것이 아닙니다." })).toBeVisible(); + await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0); + + await page.getByTestId("auth-restore-retry").click(); + + await expect(page).toHaveURL(/\/admin$/); + await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible(); + await expect(page.getByTestId("auth-restore-failed")).toHaveCount(0); + }); + test("shows detailed AI metering and saves the real engine configuration", async ({ page }) => { const seenAdminEndpoints = await mockAdminSession( page, @@ -755,11 +935,14 @@ test.describe("admin route guards", () => { "page", ); await expect(page.getByText("운영 DB 원장").first()).toBeVisible(); - await expect(page.locator(".aic-ledger")).toContainText("$6.6212"); + // 2026-07-27 D7: 합계 금액은 화면에 소수 2자리로 표시하고 원본 정밀도는 title 로 옮겼다. + await expect(page.locator(".aic-ledger")).toContainText("$6.62"); + await expect(page.locator(".aic-ledger b").first()).toHaveAttribute("title", /6\.6212/); await expect(page.locator(".aic-budget")).toContainText("93.8%"); await expect(page.locator(".aic-table")).toContainText("gpt-5-mini"); await expect(page.locator(".aic-cache-score")).toContainText("80%"); await expect(page.getByLabel("AI 기본 모델")).toHaveValue("gateway-default"); + await expect(page.getByLabel("AI 엔진 공급자").locator("option")).toHaveCount(6); const usageRequest = page.waitForRequest((request) => request.url().includes("/api/admin/usage?window_days=7"), @@ -767,17 +950,40 @@ test.describe("admin route guards", () => { await page.getByRole("button", { name: "7일" }).click(); await usageRequest; - await page.getByLabel("AI 기본 모델").fill("gpt-5.1-mini"); + await page.getByLabel("AI 엔진 공급자").selectOption("codex_cli"); + await expect(page.getByLabel("AI 기본 모델")).toHaveValue("gpt-5.6-terra"); + await expect(page.getByLabel("AI 추론 강도")).toHaveValue("medium"); + + await page.getByLabel("AI 엔진 공급자").selectOption("agy_cli"); + await expect(page.getByLabel("AI 기본 모델")).toHaveValue("gemini-3.6-flash-high"); + await expect(page.getByLabel("AI 추론 강도")).toHaveValue("high"); + await page.getByLabel("AI 연결 주소").fill("http://127.0.0.1:9199"); + await expect(page.getByRole("button", { name: "운영 설정 저장" })).toBeDisabled(); + const catalogRequest = page.waitForRequest((request) => { + const url = new URL(request.url()); + return ( + url.pathname.endsWith("/api/admin/engine-capabilities") && + url.searchParams.get("engine_url") === "http://127.0.0.1:9199" + ); + }); + await page.getByRole("button", { name: "목록 새로고침" }).click(); + await catalogRequest; + await expect(page.getByLabel("AI 기본 모델")).toHaveValue("gemini-3.6-flash-high"); const patchRequest = page.waitForRequest((request) => request.method() === "PATCH" && request.url().endsWith("/api/admin/engine-config"), ); await page.getByRole("button", { name: "운영 설정 저장" }).click(); const request = await patchRequest; - expect(request.postDataJSON()).toMatchObject({ model: "gpt-5.1-mini" }); + expect(request.postDataJSON()).toMatchObject({ + engine_mode: "agy_cli", + engine_url: "http://127.0.0.1:9199", + model: "gemini-3.6-flash-high", + reasoning_effort: "high", + }); await expect(page.getByText("저장됨")).toBeVisible(); await expect .poll(() => Array.from(seenAdminEndpoints).sort()) - .toEqual(["engine-config", "engine-config-patch", "health", "usage"]); + .toEqual(["engine-capabilities", "engine-config", "engine-config-patch", "health", "usage"]); await expectNoHorizontalOverflow(page); }); @@ -817,10 +1023,12 @@ test.describe("admin route guards", () => { await page.goto("/admin/users"); await expect(page.getByRole("heading", { name: "가입 승인과 권한 관리" })).toBeVisible(); const scrolled = await page.evaluate(() => { - document.documentElement.style.minHeight = "2200px"; - document.body.style.minHeight = "2200px"; - window.scrollTo(0, 900); - return window.scrollY; + const main = document.querySelector(".vg-main"); + const root = document.querySelector(".vgops-root"); + if (!main || !root) throw new Error("admin scroll container missing"); + root.style.minHeight = "2200px"; + main.scrollTop = 900; + return main.scrollTop; }); expect(scrolled).toBeGreaterThan(0); @@ -828,9 +1036,13 @@ test.describe("admin route guards", () => { await expect(page).toHaveURL(/\/admin\/access$/); await expect - .poll(() => page.evaluate(() => window.scrollY)) + .poll(() => + page.evaluate( + () => document.querySelector(".vg-main")?.scrollTop ?? -1, + ), + ) .toBe(0); - await expect(page.locator(".ad-root")).toBeInViewport(); + await expect(page.locator(".vgops-root")).toBeInViewport(); await expect(page.getByRole("heading", { name: "역할, 그룹, 접근 범위" })).toBeVisible(); }); @@ -848,10 +1060,12 @@ test.describe("admin route guards", () => { await page.goto("/admin"); await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible(); const scrolled = await page.evaluate(() => { - document.documentElement.style.minHeight = "2200px"; - document.body.style.minHeight = "2200px"; - window.scrollTo(0, 900); - return window.scrollY; + const main = document.querySelector(".vg-main"); + const root = document.querySelector(".vgops-root"); + if (!main || !root) throw new Error("admin scroll container missing"); + root.style.minHeight = "2200px"; + main.scrollTop = 900; + return main.scrollTop; }); expect(scrolled).toBeGreaterThan(0); @@ -860,12 +1074,44 @@ test.describe("admin route guards", () => { }); await expect - .poll(() => page.evaluate(() => window.scrollY)) + .poll(() => + page.evaluate( + () => document.querySelector(".vg-main")?.scrollTop ?? -1, + ), + ) .toBe(0); - await expect(page.locator(".ad-root")).toBeInViewport(); + await expect(page.locator(".vgops-root")).toBeInViewport(); await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible(); }); + test("keeps the admin console visible under EasyList cosmetic filters", async ({ page }) => { + await mockAdminSession(page, { + user_id: "cosmetic-filter-admin", + email: "cosmetic-filter-admin@twentyoz.kr", + display_name: "Cosmetic Filter Admin", + role: "admin", + admin_access: true, + super_admin: true, + onboarding_completed_at: 1_782_900_000, + }); + + await page.goto("/admin"); + await page.addStyleTag({ + // EasyList general cosmetic rules contain both selectors. They used to + // hide the whole operations console while every admin API still returned 200. + content: ".ad-root,.ad-section{display:none!important}", + }); + + await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible(); + await expect(page.locator("[data-vignette-admin-root]")).toBeVisible(); + await expect(page.locator('[class^="ad-"],[class*=" ad-"]')).toHaveCount(0); + + // 첫 watchdog 판정(3.5초)이 실제 픽셀 가시성을 확인한 뒤에도 진단 화면이 + // 뜨지 않아야 한다. DOM 존재 여부만 확인하면 이번 장애를 재현하지 못한다. + await page.waitForTimeout(4_000); + await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0); + }); + test("shows admin data diagnostics instead of a blank main pane", async ({ page }) => { await mockAdminSession( page, @@ -902,9 +1148,9 @@ test.describe("admin route guards", () => { await page.goto("/admin/users"); await expect(page.getByRole("heading", { name: "가입 승인과 권한 관리" })).toBeVisible(); - await expect(page.locator(".ad-diagnostic")).toContainText("관리자 데이터 진단"); - await expect(page.locator(".ad-diagnostic")).toContainText("cohort_ids"); - await expect(page.locator(".ad-diagnostic")).toContainText("active_sessions"); + await expect(page.locator(".vgops-diagnostic")).toContainText("관리자 데이터 진단"); + await expect(page.locator(".vgops-diagnostic")).toContainText("cohort_ids"); + await expect(page.locator(".vgops-diagnostic")).toContainText("active_sessions"); await expect(page.getByText("Bad User")).toBeVisible(); }); @@ -933,8 +1179,8 @@ test.describe("admin route guards", () => { await page.goto("/admin/users"); await expect(page.getByRole("heading", { name: "가입 승인과 권한 관리" })).toBeVisible(); - await expect(page.locator(".ad-diagnostic")).toContainText("관리자 데이터 진단"); - await expect(page.locator(".ad-diagnostic")).toContainText("admin.tickets.summary"); + await expect(page.locator(".vgops-diagnostic")).toContainText("관리자 데이터 진단"); + await expect(page.locator(".vgops-diagnostic")).toContainText("admin.tickets.summary"); await expect(page.locator("body")).not.toHaveText(/^$/); }); }); @@ -950,7 +1196,7 @@ test.describe("admin route", () => { await withGlobalEngineConfigLock("admin-health-dashboard", async () => { const { health, usage, users, uptime, tickets } = await openAdminAndReadHealth(page); - const serviceCards = page.locator(".ad-service:not(.ad-service--skeleton)"); + const serviceCards = page.locator(".vgops-service:not(.vgops-service--skeleton)"); const counts = { ok: health.services.filter((service) => service.status === "ok").length, degraded: health.services.filter((service) => service.status === "degraded").length, @@ -964,17 +1210,17 @@ test.describe("admin route", () => { const onlineUsers = users.users.filter((user) => isOnline(user.last_seen_at)).length; await expect(page).toHaveURL(/\/admin$/); - await expect(page.locator(".ad-status")).toContainText(environmentLabel(health.environment)); - await expect(page.locator(".ad-status")).toContainText(engineModeLabel(health.engine_mode)); - await expect(page.locator(".ad-kpi b")).toHaveText([ + await expect(page.locator(".vgops-status")).toContainText(environmentLabel(health.environment)); + await expect(page.locator(".vgops-status")).toContainText(engineModeLabel(health.engine_mode)); + await expect(page.locator(".vgops-kpi b")).toHaveText([ countLabel(activeSessions), countLabel(onlineUsers), countLabel(users.users.length), counts.total ? `${counts.ok}/${counts.total}` : "-", ]); await expect(page.getByRole("heading", { name: "AI 비용" })).toBeVisible(); - await expect(page.locator(".ad-cost")).toContainText(costLabel(usage.cost_usd)); - await expect(page.locator(".ad-cost")).toContainText( + await expect(page.locator(".vgops-cost")).toContainText(costLabel(usage.cost_usd)); + await expect(page.locator(".vgops-cost")).toContainText( usage.budget.status === "disabled" ? "예산 경고 비활성" : usage.budget.status === "exceeded" @@ -983,17 +1229,17 @@ test.describe("admin route", () => { ? "예산 주의" : "예산 정상", ); - await expect(page.locator(".ad-panel").filter({ hasText: "AI 비용" })).toContainText( + await expect(page.locator(".vgops-panel").filter({ hasText: "AI 비용" })).toContainText( "평가 캐시 hit-rate", ); const cache = evaluatorCache(usage); - await expect(page.locator(".ad-panel").filter({ hasText: "AI 비용" })).toContainText( + await expect(page.locator(".vgops-panel").filter({ hasText: "AI 비용" })).toContainText( cache.enabled ? `${rateLabel(cache.hit_rate)} hit` : "캐시 비활성", ); if (usageDailyCost(usage).length > 0) { - await expect(page.locator(".ad-panel").filter({ hasText: "AI 비용" })).toContainText( + await expect(page.locator(".vgops-panel").filter({ hasText: "AI 비용" })).toContainText( "일별 비용 추이", ); } @@ -1003,7 +1249,7 @@ test.describe("admin route", () => { expect(uptime.ok_ratio).toBeLessThanOrEqual(1); await expect(page.getByRole("heading", { name: "운영 티켓" })).toBeVisible(); expect(tickets.summary.open_count).toBeGreaterThanOrEqual(0); - await expect(page.locator(".ad-panel").filter({ hasText: "운영 티켓" })).toContainText( + await expect(page.locator(".vgops-panel").filter({ hasText: "운영 티켓" })).toContainText( /(\d+건 미해결|미해결 티켓이 없습니다)/, ); await expect(serviceCards).toHaveCount(health.services.length); @@ -1128,10 +1374,10 @@ test.describe("admin route", () => { const approvalTab = page.getByRole("tab", { name: /가입 승인/ }); await approvalTab.click(); await expect(approvalTab).toHaveAttribute("aria-selected", "true"); - const approvalCard = page.locator(".ad-approval").filter({ hasText: email }); + const approvalCard = page.locator(".vgops-approval").filter({ hasText: email }); await expect(approvalCard).toBeVisible(); await expect(approvalCard).toContainText("승인 대기"); - await expectVisibleButtonsFit(page, ".ad-approval__actions .vg-btn", "admin approval buttons"); + await expectVisibleButtonsFit(page, ".vgops-approval__actions .vg-btn", "admin approval buttons"); const approvePromise = page.waitForResponse(isAdminUserPatch(created.user_id)); await approvalCard.getByRole("button", { name: "승인" }).click(); @@ -1147,11 +1393,11 @@ test.describe("admin route", () => { await page.getByRole("tab", { name: "사용자 목록" }).click(); await page.getByLabel("사용자 검색").fill(email); - const card = page.locator(".ad-user-table tbody tr").filter({ hasText: email }); + const card = page.locator(".vgops-user-table tbody tr").filter({ hasText: email }); await expect(card).toBeVisible(); await expect(card.getByLabel(`${email} 표시 이름`)).toHaveValue(displayName); await expect(card).toContainText("승인됨"); - await expectVisibleButtonsFit(page, ".ad-user__actions .vg-btn", "admin user action buttons"); + await expectVisibleButtonsFit(page, ".vgops-user__actions .vg-btn", "admin user action buttons"); const nextName = `교수자 ${testInfo.project.name}`; const nameInput = card.getByLabel(`${email} 표시 이름`); @@ -1220,7 +1466,7 @@ test.describe("admin route", () => { expect(tickets.durable).toBe(true); expect(tickets.tickets.some((ticket) => ticket.ticket_id === created.ticket_id)).toBeTruthy(); - const card = page.locator(".ad-ticket").filter({ hasText: subject }); + const card = page.locator(".vgops-ticket").filter({ hasText: subject }); await expect(card).toBeVisible(); await expect(card).toContainText("높음"); await expect(card).toContainText("미해결"); @@ -1277,7 +1523,7 @@ test.describe("admin route", () => { expect(createdTickets.some((ticket) => (ticket.duplicate_count ?? 0) > 0)).toBeTruthy(); const linkableCard = page - .locator(".ad-ticket") + .locator(".vgops-ticket") .filter({ hasText: subject }) .filter({ has: page.getByRole("button", { name: "연결" }) }); await expect(linkableCard).toBeVisible(); @@ -1293,7 +1539,7 @@ test.describe("admin route", () => { const updated = (await patchResponse.json()) as AdminSupportTicket; expect(updated.parent_ticket_id).toBeTruthy(); - await expect(page.locator(".ad-ticket").filter({ hasText: subject }).filter({ hasText: "중복 연결" })).toBeVisible(); + await expect(page.locator(".vgops-ticket").filter({ hasText: subject }).filter({ hasText: "중복 연결" })).toBeVisible(); await expectNoHorizontalOverflow(page); } finally { for (const ticketId of createdTicketIds) { @@ -1326,7 +1572,7 @@ test.describe("admin route", () => { await page.goto("/admin"); await expect(page).toHaveURL(/\/learn$/); - await expect(page.locator(".ad-root")).toHaveCount(0); + await expect(page.locator(".vgops-root")).toHaveCount(0); }); test("keeps admin controls usable at a mobile viewport", async ({ page }) => { @@ -1337,7 +1583,7 @@ test.describe("admin route", () => { const users = await openAdminAndReadUsers(page); await page.getByRole("tab", { name: "사용자 등록" }).click(); const layout = await page.evaluate(() => { - const form = document.querySelector(".ad-user-create"); + const form = document.querySelector(".vgops-user-create"); if (!form) throw new Error("admin user create form was not rendered"); return { formColumns: window.getComputedStyle(form).gridTemplateColumns.split(" ").length, @@ -1349,7 +1595,7 @@ test.describe("admin route", () => { expect(layout.formColumns).toBe(1); if (users.users.length > 0) { await page.getByRole("tab", { name: "사용자 목록" }).click(); - const scrollRegion = page.locator(".ad-user-table-scroll"); + const scrollRegion = page.locator(".vgops-user-table-scroll"); const scrollContract = await scrollRegion.evaluate((element) => { const before = element.scrollLeft; element.scrollLeft = element.scrollWidth; @@ -1363,7 +1609,7 @@ test.describe("admin route", () => { expect(scrollContract.scrollWidth).toBeGreaterThan(scrollContract.clientWidth); expect(scrollContract.after).toBeGreaterThan(scrollContract.before); await expect(page.getByRole("columnheader", { name: /작업/ })).toBeVisible(); - await expectVisibleButtonsFit(page, ".ad-user__actions .vg-btn", "mobile admin user actions"); + await expectVisibleButtonsFit(page, ".vgops-user__actions .vg-btn", "mobile admin user actions"); } }); diff --git a/apps/web/e2e/breakpoint-sweep.spec.ts b/apps/web/e2e/breakpoint-sweep.spec.ts new file mode 100644 index 0000000..50b4677 --- /dev/null +++ b/apps/web/e2e/breakpoint-sweep.spec.ts @@ -0,0 +1,886 @@ +/* ===================================================================== + breakpoint-sweep.spec.ts — 브레이크포인트 경계 스윕 게이트. + + 왜 필요한가: + 기존 layout-visual-gate.spec.ts 는 고정 7폭(390/720/861/900/1024/1280/1440)에서 + "문서 가로 오버플로 0" 만 검사했다. 그래서 아래 3건은 구조적으로 잡히지 않았다. + (1) 설정 서브네비 라벨 7개가 721~1080px 구간에서 폭 0 으로 붕괴 + (2) 회기 프리스타트 우측 패널이 1041~1240px 구간에서 195px 잘림 + (3) 티켓 "초기화" 버튼이 1180~1257px 구간에서 화면 밖 이탈 + 셋 다 문서 오버플로는 0 이고, 고정 7폭 사이의 "경계 안쪽" 에서만 나타난다. + + 이 스펙이 하는 일: + 1. src 아래 모든 .css 의 @media min-width/max-width px 값을 실행 시점에 파싱한다. + (하드코딩 없음 — CSS 에 브레이크포인트가 추가되면 자동으로 커버된다) + 2. 각 브레이크포인트 B 마다 B-1 / B / B+1 을 만들고 실기기 대표 폭을 합쳐 + 페이지별 테스트 폭 목록을 만든다. + 3. 각 폭에서 겹침 / 잘림 / 폭·높이 붕괴 / 화면 밖 / 문서 가로 오버플로 5종을 + DOM 실측으로 검출한다. + 4. 실패 메시지에 페이지 · 폭 · 결함 종류 · 요소 · 실측 수치를 모두 담는다. + + 실행: npx playwright test e2e/breakpoint-sweep.spec.ts --reporter=list + (또는 npm run e2e:single-run — @single-run 태그로 별도 프로젝트에서 돈다) + ===================================================================== */ + +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { expect, test, type Page } from "@playwright/test"; +import { + fetchAvailablePersona, + signInAsAdmin, + signInAsLearner, + signInAsTeacher, +} from "./support"; + +/* ───────────────────────────────────────────────────────────────────── + 1) CSS 브레이크포인트 추출 + ───────────────────────────────────────────────────────────────────── */ + +const SRC_DIR = path.join(process.cwd(), "src"); + +/** 뷰포트 클램프 범위 — 320px 미만/1600px 초과 폭은 지원 대상이 아니다. */ +const MIN_WIDTH = 320; +const MAX_WIDTH = 1600; + +/** 실기기 대표 폭. 브레이크포인트 경계와 무관하게 항상 확인한다. */ +const DEVICE_WIDTHS = [320, 360, 375, 390, 414, 768, 820, 1024, 1280, 1366, 1440, 1536]; + +/** 뷰포트 높이 — 폭 스윕이 목적이라 높이는 고정해 결과를 결정적으로 만든다. */ +const SWEEP_HEIGHT = 900; + +/** 한 라우트에서 확인할 최대 폭 수 — 런타임 상한. */ +const MAX_WIDTHS_PER_ROUTE = 44; + +/** src 아래 모든 .css 파일을 posix 상대경로로 수집. */ +async function collectCssFiles(dir: string): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const out: string[] = []; + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) out.push(...(await collectCssFiles(full))); + else if (entry.name.endsWith(".css")) out.push(full); + } + return out; +} + +function toKey(fullPath: string) { + return path.relative(SRC_DIR, fullPath).split(path.sep).join("/"); +} + +/** + * @media 프렐류드에서 min-width/max-width 의 px 값을 뽑는다. + * CSS 주석 안의 예시 표기(settings.css 의 설명 주석 등)가 잡히지 않도록 + * 블록 주석을 먼저 제거한다. + */ +function extractBreakpoints(css: string): number[] { + const stripped = css.replace(/\/\*[\s\S]*?\*\//g, ""); + const found = new Set(); + const mediaRe = /@media([^{]+)\{/g; + let media: RegExpExecArray | null; + while ((media = mediaRe.exec(stripped)) !== null) { + const featureRe = /\((?:min|max)-width:\s*(\d+(?:\.\d+)?)px\)/g; + let feature: RegExpExecArray | null; + while ((feature = featureRe.exec(media[1])) !== null) { + found.add(Math.round(Number(feature[1]))); + } + } + return [...found].sort((a, b) => a - b); +} + +/* ───────────────────────────────────────────────────────────────────── + 2) CSS 파일 → 페이지 매핑 + ───────────────────────────────────────────────────────────────────── */ + +/** 전 페이지 공통으로 취급하는 CSS. 여기 브레이크포인트는 모든 라우트에 적용된다. */ +const COMMON_CSS = new Set([ + "styles/global.css", + "styles/tokens.css", + "components/shell/shell.css", + "components/auth/auth-shell.css", + "components/ui/ui.css", +]); + +/** 페이지 키 → 그 페이지가 소유한 CSS 파일. 여기 없는 CSS 는 공통으로 fallback 한다. */ +const PAGE_CSS: Record = { + login: ["pages/login/login.css"], + onboarding: ["pages/onboarding.css"], + pending: ["pages/pending-approval.css"], + "avatar-preview": ["pages/avatar-preview.css", "components/avatar/client-avatar.css"], + "learner-home": ["pages/learner-home.css"], + "avatar-lab": ["pages/avatar-expression-lab.css", "components/avatar/client-avatar.css"], + session: ["pages/session/session.css", "components/avatar/client-avatar.css"], + "session-review": ["pages/session-review/session-review.css"], + professor: ["pages/professor.css"], + "persona-studio": ["pages/persona-studio.css"], + "admin-console": ["pages/admin/admin-console.css"], + "admin-ai": ["pages/admin/admin-ai.css"], + settings: ["pages/settings/settings.css"], +}; + +interface BreakpointIndex { + /** 공통 CSS + 매핑되지 않은 CSS 에서 나온 브레이크포인트. */ + common: number[]; + /** 페이지 키별 고유 브레이크포인트. */ + byPage: Record; + /** 어떤 페이지에도 매핑되지 않아 공통으로 승격된 CSS(디버깅용). */ + unmapped: string[]; +} + +async function buildBreakpointIndex(): Promise { + const files = await collectCssFiles(SRC_DIR); + const perFile = new Map(); + for (const file of files) { + perFile.set(toKey(file), extractBreakpoints(await fs.readFile(file, "utf8"))); + } + + const owned = new Set(); + for (const list of Object.values(PAGE_CSS)) for (const key of list) owned.add(key); + + const common = new Set(); + const unmapped: string[] = []; + for (const [key, values] of perFile) { + if (owned.has(key)) continue; + // 공통 CSS 이거나, 아직 어떤 페이지에도 매핑되지 않은 새 CSS → 전 페이지 공통 취급. + // (매핑 누락 때문에 커버리지가 조용히 사라지는 것보다 과잉 커버가 안전하다) + if (!COMMON_CSS.has(key)) unmapped.push(key); + for (const value of values) common.add(value); + } + + const byPage: Record = {}; + for (const [page, list] of Object.entries(PAGE_CSS)) { + const set = new Set(); + for (const key of list) { + const values = perFile.get(key); + expect(values, `PAGE_CSS 매핑이 가리키는 ${key} 가 src 에 없다`).toBeDefined(); + for (const value of values ?? []) set.add(value); + } + byPage[page] = [...set].sort((a, b) => a - b); + } + + return { common: [...common].sort((a, b) => a - b), byPage, unmapped }; +} + +const clampWidth = (value: number) => Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, value)); + +/** 브레이크포인트 목록 → B-1 / B / B+1 + 실기기 폭 (중복 제거, 클램프, 상한 적용). */ +function widthsFor(breakpoints: number[]): number[] { + const boundary = new Set(); + for (const bp of breakpoints) { + for (const delta of [-1, 0, 1]) boundary.add(clampWidth(bp + delta)); + } + const devices = DEVICE_WIDTHS.map(clampWidth).filter((w) => !boundary.has(w)); + const ordered = [...boundary].sort((a, b) => a - b); + let merged = [...ordered, ...devices]; + + if (merged.length > MAX_WIDTHS_PER_ROUTE) { + // 상한을 넘으면 실기기 폭을 먼저 유지하고(사용자가 실제로 보는 폭), + // 경계 폭은 균등 간격으로 솎아 낸다 — 경계 3연폭 세트는 최대한 함께 남긴다. + const keep = new Set(devices); + const budget = MAX_WIDTHS_PER_ROUTE - keep.size; + const step = Math.max(1, Math.ceil(ordered.length / Math.max(1, budget))); + for (let i = 0; i < ordered.length; i += step) keep.add(ordered[i]); + merged = [...keep]; + } + return [...new Set(merged)].sort((a, b) => a - b); +} + +/* ───────────────────────────────────────────────────────────────────── + 3) 브라우저에서 도는 결함 검출기 + ───────────────────────────────────────────────────────────────────── */ + +interface Finding { + kind: "overlap" | "clip-x" | "clip-y" | "collapse" | "offscreen" | "doc-overflow"; + element: string; + detail: string; +} + +/** + * 브라우저 컨텍스트에서 실행되는 레이아웃 결함 스캐너. + * 5종을 검출하되, 아래 4종 오탐은 명시적으로 제외한다. + * - -webkit-line-clamp (의도된 줄 자름) + * - visually-hidden 패턴 (width/height 1px, clip: rect(...)) + * - border-radius >= 40px 원형 마스크 + * - line-height 가 폰트 content-area 보다 작아 생기는 1~2px 세로 오버슛 + */ +function scanLayoutDefects(): Finding[] { + const doc = document.documentElement; + const viewportWidth = doc.clientWidth; + const findings: Finding[] = []; + + const all = Array.from(document.querySelectorAll("body *")); + const styles = new Map(); + const rects = new Map(); + // body 도 overflow 경계가 될 수 있으므로 캐시에 포함한다(순회 대상은 아니다). + for (const el of [document.body, ...all]) { + styles.set(el, window.getComputedStyle(el)); + rects.set(el, el.getBoundingClientRect()); + } + const cs = (el: Element) => styles.get(el) ?? window.getComputedStyle(el); + const rc = (el: Element) => rects.get(el) ?? el.getBoundingClientRect(); + + function classOf(el: Element) { + const raw = (el as HTMLElement).className as unknown; + const source = + typeof raw === "string" ? raw : raw && typeof raw === "object" && "baseVal" in raw ? String((raw as SVGAnimatedString).baseVal) : ""; + return source.trim().split(/\s+/).filter(Boolean).slice(0, 3).join("."); + } + + /** 요소 식별 문자열. 클래스가 없으면 부모 클래스를 붙여 디버깅 가능한 좌표를 만든다. */ + function describe(el: Element) { + const cls = classOf(el); + const tag = el.tagName.toLowerCase(); + const anchor = cls + ? `${tag}.${cls}` + : `${tag}${el.parentElement && classOf(el.parentElement) ? `@${classOf(el.parentElement)}` : ""}`; + const text = (el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 40); + return `${anchor}${text ? ` "${text}"` : ""}`; + } + + const visibleCache = new Map(); + function isVisible(el: Element): boolean { + const cached = visibleCache.get(el); + if (cached !== undefined) return cached; + const style = cs(el); + const rect = rc(el); + let result = true; + if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) result = false; + else if (rect.width <= 0 && rect.height <= 0) result = false; + else if (el.getAttribute("aria-hidden") === "true") result = false; + else if (el.hasAttribute("hidden") || el.hasAttribute("inert")) result = false; + else if (el.parentElement && el.parentElement !== document.body && !isVisible(el.parentElement)) result = false; + visibleCache.set(el, result); + return result; + } + + /** 장식 레이어: 히트테스트 대상이 아니거나 접근성 트리에서 숨겨진 요소. */ + function isDecorative(el: Element) { + return cs(el).pointerEvents === "none" || el.getAttribute("aria-hidden") === "true"; + } + + /** 오탐 제외 (2): visually-hidden 패턴 (1px 박스 / clip / clip-path inset(50%)). */ + function isVisuallyHidden(el: Element) { + const style = cs(el); + const rect = rc(el); + if (rect.width <= 2 && rect.height <= 2) return true; + if (style.clip && style.clip !== "auto") return true; + if (style.clipPath && style.clipPath.includes("inset(50%")) return true; + return false; + } + + /** 오탐 제외 (1): -webkit-line-clamp 가 걸린 요소는 세로 자름이 의도다. */ + function hasLineClamp(el: Element) { + const style = cs(el); + const value = + style.getPropertyValue("-webkit-line-clamp") || + (style as unknown as { webkitLineClamp?: string }).webkitLineClamp || + "none"; + return value !== "none" && value !== "" && value !== "0"; + } + + /** 오탐 제외 (3): border-radius 40px 이상 원형 마스크(아바타 스테이지 등). */ + function hasCircularMask(el: Element) { + const style = cs(el); + return ( + ["borderTopLeftRadius", "borderTopRightRadius", "borderBottomLeftRadius", "borderBottomRightRadius"] as const + ).some((key) => Number.parseFloat(style[key]) >= 40); + } + + /** 오탐 제외 (4): line-height < fontSize * 1.15 → 1~2px 세로 오버슛은 글리프가 안 잘린다. */ + function hasTightLineHeight(el: Element) { + const style = cs(el); + const fontSize = Number.parseFloat(style.fontSize) || 16; + const lineHeight = style.lineHeight === "normal" ? fontSize * 1.2 : Number.parseFloat(style.lineHeight) || fontSize * 1.2; + return lineHeight < fontSize * 1.15; + } + + function hasDirectText(el: Element) { + for (const node of Array.from(el.childNodes)) { + if (node.nodeType === Node.TEXT_NODE && (node.textContent ?? "").trim()) return true; + } + return false; + } + + const clipsX = (s: CSSStyleDeclaration) => s.overflowX === "hidden" || s.overflowX === "clip"; + const clipsY = (s: CSSStyleDeclaration) => s.overflowY === "hidden" || s.overflowY === "clip"; + const scrollsX = (s: CSSStyleDeclaration) => s.overflowX === "auto" || s.overflowX === "scroll"; + const scrollsY = (s: CSSStyleDeclaration) => s.overflowY === "auto" || s.overflowY === "scroll"; + const isOverflowBoundary = (s: CSSStyleDeclaration) => clipsX(s) || clipsY(s) || scrollsX(s) || scrollsY(s); + + /* ── (A) 잘림 ──────────────────────────────────────────────────────── + overflow hidden/clip 컨테이너가 자기 콘텐츠를 잘라내는 경우. + + scrollWidth/scrollHeight 만 보면 ::after 장식(음수 offset 으로 깔아 둔 배경 + 아트, 예: .lh-session-focus::after)까지 "잘림" 으로 잡혀 노이즈가 된다. + 그래서 자식 요소가 있는 컨테이너는 "in-flow 자손 rect vs client box" 로 + 판정한다. absolute/fixed 자손과 pointer-events:none 장식은 제외한다. + + 성능: 컨테이너마다 자손을 훑으면 O(n^2) 이라 느리다. 대신 요소마다 + "최근접 overflow 경계 조상" 을 한 번만 계산(O(n))하고, 각 요소를 자기 + 경계와만 비교한다. 결과는 동일하고 중복 보고도 자동으로 없어진다. */ + const clipCandidate = (el: Element) => { + const style = cs(el); + if (!clipsX(style) && !clipsY(style)) return false; + if (!isVisible(el) || isVisuallyHidden(el) || hasCircularMask(el)) return false; + const tag = el.tagName.toLowerCase(); + // 폼 컨트롤은 설계상 자기 값을 스크롤한다(키보드로 전부 도달 가능). + if (tag === "input" || tag === "textarea" || tag === "select") return false; + return el.clientWidth > 0 || el.clientHeight > 0; + }; + + // 최근접 overflow 경계(또는 containing-block 을 바꾸는 absolute/fixed 조상). + const nearestBoundary = new Map(); + for (const el of all) { + const parent = el.parentElement; + if (!parent || !styles.has(parent)) { + nearestBoundary.set(el, null); + continue; + } + const parentStyle = cs(parent); + if (isOverflowBoundary(parentStyle) || parentStyle.position === "absolute" || parentStyle.position === "fixed") { + nearestBoundary.set(el, parent); + } else { + nearestBoundary.set(el, nearestBoundary.get(parent) ?? null); + } + } + + // 컨테이너별 최악 오버슛만 남긴다(요소 하나당 한 줄 보고). + const worstClipX = new Map(); + const worstClipY = new Map(); + const clientBox = (el: Element) => { + const style = cs(el); + const rect = rc(el); + const left = rect.left + (Number.parseFloat(style.borderLeftWidth) || 0); + const top = rect.top + (Number.parseFloat(style.borderTopWidth) || 0); + return { left, top, right: left + el.clientWidth, bottom: top + el.clientHeight }; + }; + + for (const el of all) { + if (!isVisible(el) || isDecorative(el) || isVisuallyHidden(el)) continue; + const style = cs(el); + if (style.position === "absolute" || style.position === "fixed") continue; + const boundary = nearestBoundary.get(el); + if (!boundary || !clipCandidate(boundary)) continue; + const rect = rc(el); + if (rect.width <= 0 || rect.height <= 0) continue; + + const boundaryStyle = cs(boundary); + const box = clientBox(boundary); + const overshootX = Math.max(rect.right - box.right, box.left - rect.left); + const overshootY = Math.max(rect.bottom - box.bottom, box.top - rect.top); + + if (clipsX(boundaryStyle) && boundaryStyle.textOverflow !== "ellipsis" && overshootX > 1) { + const prev = worstClipX.get(boundary); + if (!prev || overshootX > prev.overshoot) worstClipX.set(boundary, { overshoot: overshootX, node: el }); + } + if ( + clipsY(boundaryStyle) && + !hasLineClamp(boundary) && + overshootY > 1 && + // 오탐 제외 (4): line-height 가 content-area 보다 작아 생기는 1~2px 오버슛. + !(overshootY <= 2 && hasTightLineHeight(el)) + ) { + const prev = worstClipY.get(boundary); + if (!prev || overshootY > prev.overshoot) worstClipY.set(boundary, { overshoot: overshootY, node: el }); + } + } + + for (const [boundary, worst] of worstClipX) { + findings.push({ + kind: "clip-x", + element: describe(boundary), + detail: `자식 ${describe(worst.node)} 이(가) 가로로 ${worst.overshoot.toFixed(1)}px 잘림 (clientWidth ${boundary.clientWidth}px)`, + }); + } + for (const [boundary, worst] of worstClipY) { + findings.push({ + kind: "clip-y", + element: describe(boundary), + detail: `자식 ${describe(worst.node)} 이(가) 세로로 ${worst.overshoot.toFixed(1)}px 잘림 (clientHeight ${boundary.clientHeight}px)`, + }); + } + + // 자식 요소 없이 텍스트만 담은 잎 노드는 rect 비교가 불가능하므로 + // scrollWidth/scrollHeight 로 자기 콘텐츠가 잘렸는지 본다. + for (const el of all) { + if (el.children.length > 0 || !clipCandidate(el)) continue; + const style = cs(el); + const dx = Math.ceil(el.scrollWidth - el.clientWidth); + const dy = Math.ceil(el.scrollHeight - el.clientHeight); + if (clipsX(style) && style.textOverflow !== "ellipsis" && dx > 1) { + findings.push({ + kind: "clip-x", + element: describe(el), + detail: `자기 텍스트가 가로로 ${dx}px 잘림 (scrollWidth ${el.scrollWidth} > clientWidth ${el.clientWidth})`, + }); + } + if (clipsY(style) && !hasLineClamp(el) && dy > 1 && !(dy <= 2 && hasTightLineHeight(el))) { + findings.push({ + kind: "clip-y", + element: describe(el), + detail: `자기 텍스트가 세로로 ${dy}px 잘림 (scrollHeight ${el.scrollHeight} > clientHeight ${el.clientHeight})`, + }); + } + } + + /* ── (B) 폭/높이 붕괴 ──────────────────────────────────────────────── + 직접 텍스트 자식이 있는데 rect 폭 또는 높이가 1px 미만 → 글자가 사라진다. + (설정 서브네비 라벨 폭 0 결함이 여기에 잡힌다) */ + for (const el of all) { + if (!isVisible(el) || !hasDirectText(el) || isVisuallyHidden(el)) continue; + const rect = rc(el); + if (rect.width < 1 || rect.height < 1) { + findings.push({ + kind: "collapse", + element: describe(el), + detail: `텍스트가 있는데 박스가 붕괴 (width ${rect.width.toFixed(2)}px, height ${rect.height.toFixed(2)}px)`, + }); + } + } + + /* ── (C) 화면 밖 조작 요소 ─────────────────────────────────────────── + 조작 요소가 뷰포트 좌우 밖으로 나감. 단 조상에 실제 가로 스크롤이 있으면 + 사용자가 스크롤로 도달할 수 있으므로 정상으로 본다. */ + const interactiveSelector = "button,a[href],input,select,textarea,[role='button'],[role='tab']"; + function hasHorizontalScrollAncestor(el: Element) { + let node: Element | null = el.parentElement; + while (node && node !== document.body) { + const style = cs(node); + if (scrollsX(style) && node.scrollWidth - node.clientWidth > 1) return true; + node = node.parentElement; + } + return false; + } + for (const el of Array.from(document.querySelectorAll(interactiveSelector))) { + if (!styles.has(el) || !isVisible(el) || isVisuallyHidden(el)) continue; + const rect = rc(el); + if (rect.width <= 0 || rect.height <= 0) continue; + if (rect.left >= -1 && rect.right <= viewportWidth + 1) continue; + if (hasHorizontalScrollAncestor(el)) continue; + findings.push({ + kind: "offscreen", + element: describe(el), + detail: `조작 요소가 뷰포트 밖 (left ${Math.round(rect.left)}px, right ${Math.round(rect.right)}px, viewport ${viewportWidth}px)`, + }); + } + + /* ── (D) 문서 가로 오버플로 ──────────────────────────────────────── */ + const documentOverflow = Math.ceil(doc.scrollWidth - viewportWidth); + if (documentOverflow > 1) { + findings.push({ + kind: "doc-overflow", + element: "html", + detail: `문서가 가로로 ${documentOverflow}px 넘침 (scrollWidth ${doc.scrollWidth} > clientWidth ${viewportWidth})`, + }); + } + + /* ── (E) 겹침 ──────────────────────────────────────────────────────── + 같은 부모의 in-flow 형제끼리 rect 가 교차. absolute/fixed/sticky, + pointer-events:none, aria-hidden, float/transform, + grid-template-areas 를 쓰는 부모(의도적 겹침 레이아웃)는 제외. */ + const parents = new Set(); + for (const el of all) if (el.parentElement) parents.add(el.parentElement); + for (const parent of parents) { + const parentStyle = parent === document.body ? window.getComputedStyle(parent) : cs(parent); + if (parentStyle.gridTemplateAreas && parentStyle.gridTemplateAreas !== "none") continue; + const siblings = Array.from(parent.children).filter((el) => { + if (!styles.has(el) || !isVisible(el)) return false; + const style = cs(el); + if (style.position !== "static" && style.position !== "relative") return false; + if (style.pointerEvents === "none" || style.float !== "none" || style.transform !== "none") return false; + if (isDecorative(el) || isVisuallyHidden(el)) return false; + const rect = rc(el); + return rect.width > 2 && rect.height > 2; + }); + for (let i = 0; i < siblings.length; i += 1) { + for (let j = i + 1; j < siblings.length; j += 1) { + const a = rc(siblings[i]); + const b = rc(siblings[j]); + const overlapX = Math.min(a.right, b.right) - Math.max(a.left, b.left); + const overlapY = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top); + if (overlapX > 2 && overlapY > 2) { + findings.push({ + kind: "overlap", + element: `${describe(siblings[i])} ∩ ${describe(siblings[j])}`, + detail: `형제 요소가 ${overlapX.toFixed(1)}x${overlapY.toFixed(1)}px 겹침 (부모 ${describe(parent)})`, + }); + } + } + } + } + + return findings; +} + +/* ───────────────────────────────────────────────────────────────────── + 4) 라우트 정의 & 스윕 실행 + ───────────────────────────────────────────────────────────────────── */ + +interface RouteCase { + /** 리포트에 찍히는 이름. */ + label: string; + /** PAGE_CSS 키 — 이 라우트가 상속할 브레이크포인트 집합. */ + page: keyof typeof PAGE_CSS; + url: string; + /** 렌더 완료 판정 셀렉터. */ + ready: string; +} + +/** 폭 변경 후 레이아웃이 안정될 때까지 대기 (rAF 2회 + 폰트/차트 여유). */ +async function settleLayout(page: Page) { + await page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }), + ); + await page.waitForTimeout(90); +} + +interface WidthFailure { + width: number; + finding: Finding; +} + +/* ───────────────────────────────────────────────────────────────────── + 4-1) 격리(quarantine) — 검출기 오탐이 아니라 "이미 알려진 앱 CSS 결함" + ───────────────────────────────────────────────────────────────────── + 이 게이트가 처음 돌면서 실제로 찾아낸 결함들이다. CSS 파일은 이 스펙의 + 담당 범위가 아니라 여기서 고칠 수 없어, 게이트를 초록으로 유지하되 + "무엇을 눈감아 주고 있는지" 를 코드에 남기고 실행 로그로 계속 노출한다. + + - 이 목록은 검출기를 무력화하지 않는다. 라우트·결함종류·요소·폭 구간이 + 모두 일치할 때만 통과시킨다. 다른 폭이나 다른 요소에서 같은 결함이 + 생기면 그대로 실패한다. + - CSS 가 고쳐지면 해당 항목은 "재현되지 않음" 으로 로그에 찍히므로 + 그때 이 배열에서 지우면 된다. + - 검출기의 검출력 자체는 아래 "검출기 자기검증" 테스트가 매번 보증한다. */ +interface KnownAppDefect { + route: string; + kind: Finding["kind"]; + element: RegExp; + minWidth: number; + maxWidth: number; + note: string; +} + +/* 2026-07-27: 최초 3건을 전부 CSS 에서 실제로 고쳐 목록을 비웠다. + (전체 스펙 7테스트 실행에서 3건 모두 "격리 항목 미재현" 으로 찍혔다) + - learner-home/dashboard · clip-x · .lh-recap__avatar (320~380px) + → learner-home.css: 그리드 트랙 명시 + .vg-avatar min-width:0/max-width:100% + - learner-home/practice · collapse · b@lh-session-card__title (1181~1200px) + → learner-home.css: .lh-preview__hero 를 flex-wrap 으로 바꿔 제목 열 폭 확보 + - persona-studio · clip-x · .ps-active-table (761~900px) + → persona-studio.css: 트랙 최소치 축소 + .ps-usage-table 영역 가로 스크롤 + 목록이 비었으므로 이제 어떤 결함도 곧바로 실패한다. 다시 채우지 말고 CSS 를 고쳐라. */ +const KNOWN_APP_DEFECTS: KnownAppDefect[] = []; + +function matchKnownDefect(routeLabel: string, width: number, finding: Finding) { + return KNOWN_APP_DEFECTS.find( + (known) => + known.route === routeLabel && + known.kind === finding.kind && + known.element.test(finding.element) && + width >= known.minWidth && + width <= known.maxWidth, + ); +} + +/** 격리 항목이 실제로 재현됐는지 추적 — 재현되지 않으면 목록에서 지우라고 알린다. */ +const quarantineHits = new Set(); + +/** 한 라우트를 모든 폭에서 스윕하고 결함을 모아 반환한다. */ +async function sweepRoute(page: Page, route: RouteCase, widths: number[]): Promise { + await page.goto(route.url, { waitUntil: "domcontentloaded" }); + await expect(page.locator(route.ready).first(), `[${route.label}] 렌더 대기 실패: ${route.ready}`).toBeVisible({ + timeout: 20_000, + }); + await settleLayout(page); + + const failures: WidthFailure[] = []; + for (const width of widths) { + await page.setViewportSize({ width, height: SWEEP_HEIGHT }); + await settleLayout(page); + await page.evaluate(() => { + window.scrollTo(0, 0); + const main = document.querySelector(".vg-main"); + if (main) main.scrollTop = 0; + }); + const findings = await page.evaluate(scanLayoutDefects); + for (const finding of findings) { + const known = matchKnownDefect(route.label, width, finding); + if (known) { + quarantineHits.add(known); + console.log( + `[breakpoint-sweep][격리된 앱 결함] ${route.label} @ ${width}px [${finding.kind}] ${finding.element} → ${finding.detail}`, + ); + continue; + } + failures.push({ width, finding }); + } + } + return failures; +} + +function formatFailures(route: RouteCase, widths: number[], failures: WidthFailure[]) { + const lines = failures + .slice(0, 40) + .map((f) => ` · ${route.label} @ ${f.width}px [${f.finding.kind}] ${f.finding.element} → ${f.finding.detail}`); + const more = failures.length > 40 ? `\n · … 외 ${failures.length - 40}건` : ""; + return `[${route.label}] ${widths.length}개 폭(${widths[0]}~${widths[widths.length - 1]}px) 스윕에서 레이아웃 결함 ${failures.length}건\n${lines.join("\n")}${more}`; +} + +let index: BreakpointIndex; + +test.beforeAll(async () => { + index = await buildBreakpointIndex(); +}); + +/** 페이지 키에 해당하는 최종 폭 목록(공통 + 페이지 고유). */ +function widthsForPage(page: keyof typeof PAGE_CSS) { + return widthsFor([...new Set([...index.common, ...(index.byPage[page] ?? [])])]); +} + +async function runSweep(page: Page, routes: RouteCase[]) { + const report: string[] = []; + let total = 0; + for (const route of routes) { + const widths = widthsForPage(route.page); + const failures = await sweepRoute(page, route, widths); + if (failures.length) { + total += failures.length; + report.push(formatFailures(route, widths, failures)); + } + } + expect(total, `브레이크포인트 스윕 결함\n\n${report.join("\n\n")}`).toBe(0); +} + +test.describe("브레이크포인트 경계 스윕 @single-run", () => { + test("추출한 브레이크포인트가 CSS 를 실제로 반영한다", async () => { + // 검출기가 아니라 "무엇을 볼지" 를 정하는 파서/매핑의 회귀 방지. + // 값 자체는 하드코딩하지 않고 "구조가 살아 있는가" 만 본다. + expect(index.common.length, "공통 CSS(shell/global/auth-shell 등)에서 브레이크포인트를 하나도 못 찾았다").toBeGreaterThan(0); + + const distinct = new Set(index.common); + for (const values of Object.values(index.byPage)) for (const value of values) distinct.add(value); + expect(distinct.size, "CSS 전체에서 추출한 브레이크포인트가 비정상적으로 적다 — 파서가 깨졌을 수 있다").toBeGreaterThan(10); + + for (const pageKey of Object.keys(PAGE_CSS) as Array) { + const widths = widthsForPage(pageKey); + expect(Math.min(...widths), `${pageKey}: 클램프 하한 위반`).toBeGreaterThanOrEqual(MIN_WIDTH); + expect(Math.max(...widths), `${pageKey}: 클램프 상한 위반`).toBeLessThanOrEqual(MAX_WIDTH); + expect(widths.length, `${pageKey}: 라우트당 폭 상한 초과`).toBeLessThanOrEqual(MAX_WIDTHS_PER_ROUTE); + // 실기기 대표 폭은 어떤 페이지에서도 빠지면 안 된다. + expect(widths, `${pageKey}: 실기기 대표 폭 누락`).toEqual(expect.arrayContaining(DEVICE_WIDTHS)); + // 브레이크포인트 B 마다 B-1/B/B+1 이 살아 있는지(클램프 경계 제외). + for (const bp of index.byPage[pageKey] ?? []) { + if (bp <= MIN_WIDTH || bp >= MAX_WIDTH) continue; + if (widths.length >= MAX_WIDTHS_PER_ROUTE) continue; // 상한으로 솎아 낸 경우는 예외 + expect(widths, `${pageKey}: 브레이크포인트 ${bp}px 경계 3연폭 누락`).toEqual( + expect.arrayContaining([bp - 1, bp, bp + 1]), + ); + } + } + + if (index.unmapped.length) { + // 실패시키지 않고 로그만 — 새 CSS 는 공통으로 승격돼 이미 전 라우트에서 커버된다. + console.log(`[breakpoint-sweep] PAGE_CSS 에 없는 CSS(공통 승격): ${index.unmapped.join(", ")}`); + } + for (const pageKey of Object.keys(PAGE_CSS) as Array) { + console.log(`[breakpoint-sweep] ${pageKey}: ${widthsForPage(pageKey).length}폭`); + } + }); + + /** + * 검출기 자기검증. + * + * 게이트가 초록이라는 사실만으로는 "검출기가 살아 있다" 를 증명하지 못한다. + * (오탐 제외를 과하게 넣어 전부 무시해도 초록이 된다.) + * 그래서 이 스펙이 잡아야 했던 실제 결함 3건을 런타임 CSS 주입으로 되살리고, + * 검출기가 그 결함을 실제로 보고하는지 매 실행마다 확인한다. + * (1) 설정 서브네비 라벨 폭 0 붕괴 → collapse + * (2) 회기 프리스타트 패널 잘림 → clip-x + * (3) 티켓 "초기화" 버튼 화면 밖 이탈 → offscreen + */ + test("검출기가 되살린 실제 결함 3건을 실제로 잡는다", async ({ page }) => { + test.setTimeout(240_000); + + async function findingsWith(url: string, ready: string, width: number, css: string) { + await page.goto(url, { waitUntil: "domcontentloaded" }); + await expect(page.locator(ready).first()).toBeVisible({ timeout: 20_000 }); + await page.setViewportSize({ width, height: SWEEP_HEIGHT }); + await settleLayout(page); + const clean = await page.evaluate(scanLayoutDefects); + const handle = await page.addStyleTag({ content: css }); + await settleLayout(page); + const dirty = await page.evaluate(scanLayoutDefects); + await handle.evaluate((node) => { + (node as HTMLStyleElement).remove(); + }); + return { clean, dirty }; + } + + await signInAsLearner(page); + const persona = await fetchAvailablePersona(page); + + // (1) 설정 서브네비 라벨이 721~1080px 구간에서 폭 0 이 되던 결함. + const railLabels = await findingsWith( + "/settings", + ".vg-set", + 900, + ".vg-set__rail button span{width:0;overflow:hidden;display:inline-block;}", + ); + expect( + railLabels.dirty.filter((f) => f.kind === "collapse").length, + `설정 서브네비 라벨 붕괴를 못 잡았다: ${JSON.stringify(railLabels.dirty)}`, + ).toBeGreaterThanOrEqual(3); + + // (2) 회기 프리스타트 우측 패널이 1041~1240px 구간에서 잘리던 결함. + const prestart = await findingsWith( + `/learn/session/${persona.code}`, + ".sx-head", + 1100, + ".vg-main__inner{overflow:hidden;} .sx-prestart{width:1400px;}", + ); + expect( + prestart.dirty.filter((f) => f.kind === "clip-x").length, + `프리스타트 패널 잘림을 못 잡았다: ${JSON.stringify(prestart.dirty)}`, + ).toBeGreaterThanOrEqual(1); + + // (3) 티켓 "초기화" 버튼이 1180~1257px 구간에서 화면 밖으로 나가던 결함. + await signInAsAdmin(page); + const ticketButton = await findingsWith( + "/admin/tickets", + ".vgops-root", + 1220, + ".vgops-ticket-filter .vg-btn:last-child{position:relative;left:400px;}", + ); + expect( + ticketButton.dirty.filter((f) => f.kind === "offscreen").length, + `티켓 버튼 화면 밖 이탈을 못 잡았다: ${JSON.stringify(ticketButton.dirty)}`, + ).toBeGreaterThanOrEqual(1); + + // 주입 전에는 같은 결함이 없어야 한다 — 오탐 노이즈로 통과하는 것을 막는다. + expect(railLabels.clean.filter((f) => f.kind === "collapse"), "주입 전 설정 화면에 붕괴 오탐").toEqual([]); + expect(prestart.clean.filter((f) => f.kind === "clip-x"), "주입 전 프리스타트에 잘림 오탐").toEqual([]); + expect(ticketButton.clean.filter((f) => f.kind === "offscreen"), "주입 전 티켓 화면에 이탈 오탐").toEqual([]); + }); + + test("비인증 화면(로그인·아바타 프리뷰)이 모든 경계 폭에서 온전하다", async ({ page }) => { + test.setTimeout(240_000); + await runSweep(page, [ + { label: "login", page: "login", url: "/login", ready: ".lg-root" }, + { label: "avatar-preview", page: "avatar-preview", url: "/dev/avatar-preview", ready: ".ap" }, + ]); + }); + + test("가입 게이트 화면(온보딩·승인대기)이 모든 경계 폭에서 온전하다", async ({ page }) => { + test.setTimeout(240_000); + // 온보딩 미완료 사용자를 만들면 OnboardingGate 가 /onboarding 으로 보낸다. + const res = await page.request.post("/api/auth/dev-login", { + data: { + email: `sweep.onboarding.${Date.now()}@hs.ac.kr`, + role: "learner", + display_name: "Sweep Onboarding", + }, + }); + expect(res.ok(), await res.text()).toBeTruthy(); + await runSweep(page, [ + { label: "onboarding", page: "onboarding", url: "/onboarding", ready: ".ob-page" }, + ]); + + // 승인 대기 화면은 계정 상태에 의존하므로 /auth/me 응답만 최소로 덮어쓴다. + // email 도 함께 짧게 바꾼다 — 이 화면은 이메일을 그대로 노출하는데, + // dev-login 용 타임스탬프 이메일은 실제 사용자보다 훨씬 길어서 + // "브레이크포인트" 가 아니라 "테스트 데이터 길이" 때문에 패널이 넘친다. + await page.route("**/api/auth/me", async (route) => { + const response = await route.fetch(); + const body = (await response.json()) as Record; + await route.fulfill({ + response, + json: { + ...body, + email: "pending@hs.ac.kr", + account_status: "pending", + approval_required: true, + }, + }); + }); + await runSweep(page, [ + { label: "pending-approval", page: "pending", url: "/pending", ready: ".pa-page" }, + ]); + await page.unroute("**/api/auth/me"); + }); + + test("learner 화면이 모든 경계 폭에서 온전하다", async ({ page }) => { + test.setTimeout(900_000); + await signInAsLearner(page); + const persona = await fetchAvailablePersona(page); + // 리뷰 화면을 실제로 열려면 종료된 회기가 하나 필요하다(AI 턴 생성은 하지 않는다). + const created = await page.request.post("/api/sessions", { + data: { persona_code: persona.code, theory_mode: "humanistic" }, + }); + expect(created.ok(), await created.text()).toBeTruthy(); + const { session_id: endedSessionId } = (await created.json()) as { session_id: string }; + await page.request.post(`/api/sessions/${endedSessionId}/end`); + + await runSweep(page, [ + { label: "learner-home/dashboard", page: "learner-home", url: "/learn", ready: ".lh-root" }, + { label: "learner-home/practice", page: "learner-home", url: "/learn/practice", ready: ".lh-root" }, + { label: "learner-home/history", page: "learner-home", url: "/learn/history", ready: ".lh-root" }, + { + label: "session/prestart", + page: "session", + url: `/learn/session/${persona.code}`, + ready: ".sx-head", + }, + { + label: "session-review/learner", + page: "session-review", + url: `/learn/session/${endedSessionId}/review`, + ready: ".sr-root", + }, + { label: "settings/learner", page: "settings", url: "/settings", ready: ".vg-set" }, + { + label: "avatar-lab", + page: "avatar-lab", + url: "/learn/avatar-expressions", + ready: ".axl", + }, + ]); + }); + + test("teacher 화면이 모든 경계 폭에서 온전하다", async ({ page }) => { + test.setTimeout(420_000); + await signInAsTeacher(page); + await runSweep(page, [ + { label: "professor/dashboard", page: "professor", url: "/teach", ready: ".pf-root" }, + { label: "professor/analysis", page: "professor", url: "/teach/analysis", ready: ".pf-root" }, + { label: "persona-studio", page: "persona-studio", url: "/teach/personas", ready: ".ps-root" }, + { label: "settings/teacher", page: "settings", url: "/settings", ready: ".vg-set" }, + ]); + }); + + test("admin 화면이 모든 경계 폭에서 온전하다", async ({ page }) => { + test.setTimeout(600_000); + await signInAsAdmin(page); + await runSweep(page, [ + { label: "admin/overview", page: "admin-console", url: "/admin", ready: ".vgops-root" }, + { label: "admin/users", page: "admin-console", url: "/admin/users", ready: ".vgops-root" }, + { label: "admin/access", page: "admin-console", url: "/admin/access", ready: ".vgops-root" }, + { label: "admin/tickets", page: "admin-console", url: "/admin/tickets", ready: ".vgops-root" }, + { label: "admin/ai", page: "admin-ai", url: "/admin/ai", ready: ".aic" }, + ]); + }); + + test.afterAll(() => { + // 격리 목록 위생 관리: 재현되지 않은 항목은 CSS 가 고쳐졌다는 뜻이니 지우면 된다. + // (일부 테스트만 -g 로 돌리면 당연히 미재현으로 찍히므로 실패시키지 않는다) + for (const known of KNOWN_APP_DEFECTS) { + if (!quarantineHits.has(known)) { + console.log( + `[breakpoint-sweep][격리 항목 미재현] ${known.route} / ${known.kind} / ${known.element} — 고쳐졌다면 KNOWN_APP_DEFECTS 에서 삭제하라`, + ); + } + } + }); +}); diff --git a/apps/web/e2e/chunk-recovery-preview.spec.ts b/apps/web/e2e/chunk-recovery-preview.spec.ts new file mode 100644 index 0000000..096e864 --- /dev/null +++ b/apps/web/e2e/chunk-recovery-preview.spec.ts @@ -0,0 +1,36 @@ +import { expect, test } from "@playwright/test"; + +test.skip( + process.env.E2E_PREVIEW_BUILD !== "1", + "프로덕션 build를 vite preview로 띄운 옵트인 검증이다.", +); + +test("recovers a failed production lazy chunk with exactly one document reload @single-run", async ({ + page, +}) => { + let loginChunkRequests = 0; + let documentRequests = 0; + + page.on("request", (request) => { + if (request.isNavigationRequest()) documentRequests += 1; + }); + await page.route("**/assets/Login-*.js", async (route) => { + loginChunkRequests += 1; + if (loginChunkRequests === 1) { + await route.abort("failed"); + return; + } + await route.continue(); + }); + + await page.goto("/login", { waitUntil: "domcontentloaded" }); + + await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible({ timeout: 15_000 }); + expect(loginChunkRequests).toBe(2); + expect(documentRequests).toBe(2); + await expect + .poll(() => + page.evaluate(() => sessionStorage.getItem("vignette:chunk-recovery:/login")), + ) + .toBeNull(); +}); diff --git a/apps/web/e2e/db-persistence.spec.ts b/apps/web/e2e/db-persistence.spec.ts index cd8ada7..7b0e83c 100644 --- a/apps/web/e2e/db-persistence.spec.ts +++ b/apps/web/e2e/db-persistence.spec.ts @@ -36,6 +36,7 @@ interface AdminEngineConfigResponse { engine_mode: string; engine_url: string; model: string; + reasoning_effort: string | null; updated_by: string | null; updated_at: number | null; } @@ -135,7 +136,18 @@ test.describe("database-backed runtime state", () => { const engineResponse = await page.request.get("/api/admin/engine-config"); await expectResponseOk(engineResponse); const currentEngine = (await engineResponse.json()) as AdminEngineConfigResponse; - const nextModel = `db-persist-${slug}`; + const capabilityResponse = await page.request.get( + `/api/admin/engine-capabilities?engine_mode=${encodeURIComponent(currentEngine.engine_mode)}`, + ); + await expectResponseOk(capabilityResponse); + const capability = (await capabilityResponse.json()) as { + models: Array<{ id: string; default_reasoning_effort?: string | null }>; + }; + const candidate = + capability.models.find((model) => model.id !== currentEngine.model) ?? capability.models[0]; + if (!candidate) throw new Error("engine capability returned no selectable model"); + const nextModel = candidate.id; + const nextEffort = candidate.default_reasoning_effort ?? null; try { const enginePatch = await page.request.patch("/api/admin/engine-config", { @@ -143,6 +155,7 @@ test.describe("database-backed runtime state", () => { engine_mode: currentEngine.engine_mode, engine_url: currentEngine.engine_url, model: nextModel, + reasoning_effort: nextEffort, }, }); await expectResponseOk(enginePatch); @@ -151,6 +164,7 @@ test.describe("database-backed runtime state", () => { engine_mode: currentEngine.engine_mode, engine_url: currentEngine.engine_url, model: nextModel, + reasoning_effort: nextEffort, updated_by: adminEmail, }); expect(updatedEngine.updated_at).toBeGreaterThan(0); @@ -168,6 +182,7 @@ test.describe("database-backed runtime state", () => { engine_mode: currentEngine.engine_mode, engine_url: currentEngine.engine_url, model: currentEngine.model, + reasoning_effort: currentEngine.reasoning_effort, }, }); await expectResponseOk(restoreResponse); diff --git a/apps/web/e2e/full-sweep-admin-ai.spec.ts b/apps/web/e2e/full-sweep-admin-ai.spec.ts index 0ac55df..f55c536 100644 --- a/apps/web/e2e/full-sweep-admin-ai.spec.ts +++ b/apps/web/e2e/full-sweep-admin-ai.spec.ts @@ -56,6 +56,7 @@ interface AdminEngineConfigResponse { engine_mode: string; engine_url: string; model: string; + reasoning_effort: string | null; source: "database" | "runtime_cache" | "runtime_default"; durable: boolean; updated_by: string | null; @@ -100,6 +101,7 @@ function engineConfigFixture( engine_mode: "openai", engine_url: "http://127.0.0.1:9099", model: "gateway-default", + reasoning_effort: "medium", source: "database", durable: true, updated_by: "admin@twentyoz.kr", @@ -109,7 +111,7 @@ function engineConfigFixture( } interface AdminAiMockState { - counts: { usage: number; health: number; engine: number }; + counts: { usage: number; health: number; engine: number; capabilities: number }; authUser: { account_status?: "pending" | "approved" | "suspended"; onboarding_completed_at?: number | null; @@ -124,7 +126,7 @@ interface AdminAiMockState { function createMockState(overrides: Partial = {}): AdminAiMockState { return { - counts: { usage: 0, health: 0, engine: 0 }, + counts: { usage: 0, health: 0, engine: 0, capabilities: 0 }, authUser: {}, usageForWindow: (days) => usageFixture(days), engineConfig: engineConfigFixture(), @@ -198,6 +200,31 @@ async function mockAdminAiSession(page: Page, state: AdminAiMockState) { return; } + if (method === "GET" && path.endsWith("/admin/engine-capabilities")) { + state.counts.capabilities += 1; + const provider = url.searchParams.get("engine_mode") ?? state.engineConfig.engine_mode; + await fulfillJson({ + provider, + available: true, + source: "live_cli", + models: [ + { + id: "gateway-default", + label: "게이트웨이 기본 모델", + description: "테스트 기본 모델", + reasoning_efforts: ["medium"], + default_reasoning_effort: "medium", + is_default: true, + }, + ], + default_model: "gateway-default", + default_reasoning_effort: "medium", + detail: "테스트 모델 목록", + fetched_at: 1_785_142_800, + }); + return; + } + if (method === "GET" && path.endsWith("/admin/engine-config")) { state.counts.engine += 1; if (state.engineConfigStatus) { @@ -235,11 +262,11 @@ test.describe("full sweep: admin ai operations", () => { ).toBeVisible(); await expect(page.locator('[data-testid="admin-ai-page"]')).toHaveCount(0); // 승인 대기 계정은 관리자 데이터 API를 호출하지 않아야 한다. - expect(state.counts).toEqual({ usage: 0, health: 0, engine: 0 }); + expect(state.counts).toEqual({ usage: 0, health: 0, engine: 0, capabilities: 0 }); }); // checklist: admin-ai-aria-busy, admin-ai-refresh-button - test("marks the page aria-busy while loading and reloads all three data sets on refresh", async ({ + test("marks the page aria-busy while loading and reloads all operations data on refresh", async ({ page, }) => { let gate: ReturnType | null = deferred(); @@ -251,7 +278,7 @@ test.describe("full sweep: admin ai operations", () => { await page.goto("/admin/ai"); const container = page.locator('[data-testid="admin-ai-page"]'); - const refreshButton = page.getByRole("button", { name: "새로고침" }); + const refreshButton = page.getByRole("button", { name: "새로고침", exact: true }); // usage 응답이 보류된 동안 aria-busy=true, 새로고침 버튼 비활성. await expect(container).toHaveAttribute("aria-busy", "true"); @@ -278,7 +305,12 @@ test.describe("full sweep: admin ai operations", () => { await expect(refreshButton).toBeEnabled(); await expect .poll(() => state.counts) - .toEqual({ usage: base.usage + 1, health: base.health + 1, engine: base.engine + 1 }); + .toEqual({ + usage: base.usage + 1, + health: base.health + 1, + engine: base.engine + 1, + capabilities: base.capabilities + 1, + }); }); // checklist: admin-ai-error-alert, admin-ai-engine-loading-empty @@ -302,7 +334,7 @@ test.describe("full sweep: admin ai operations", () => { state.engineConfigStatus = undefined; let gate: ReturnType | null = deferred(); state.usageGate = () => gate?.promise; - await page.getByRole("button", { name: "새로고침" }).click(); + await page.getByRole("button", { name: "새로고침", exact: true }).click(); await expect(alert).toHaveCount(0); gate.resolve(); @@ -331,7 +363,7 @@ test.describe("full sweep: admin ai operations", () => { await page.goto("/admin/ai"); const ledger = page.locator(".aic-ledger"); - await expect(ledger).toContainText("$3.3300"); + await expect(ledger).toContainText("$3.33"); // React dev StrictMode가 mount 효과를 이중 실행해 usage가 1~2회일 수 있어 기준값을 캡처한다. const base = { ...state.counts }; expect(base.health).toBe(1); @@ -348,19 +380,20 @@ test.describe("full sweep: admin ai operations", () => { ); await page.getByRole("button", { name: "7일" }).click(); await expect(page.getByRole("button", { name: "7일" })).toHaveAttribute("aria-pressed", "true"); - await expect(ledger).toContainText("$1.1100"); + await expect(ledger).toContainText("$1.11"); await expect(ledger).toContainText("7일 DB 집계"); // 늦게 도착한 90일 응답은 active 플래그로 무시되어야 한다. slowWindow.resolve(); await staleResponse; - await expect(ledger).toContainText("$1.1100"); - await expect(ledger).not.toContainText("$9.9900"); + await expect(ledger).toContainText("$1.11"); + await expect(ledger).not.toContainText("$9.99"); // 기간 변경은 usage만 재조회한다(health·engine-config는 초기 1회 그대로). await expect.poll(() => state.counts.usage).toBe(base.usage + 2); expect(state.counts.health).toBe(1); expect(state.counts.engine).toBe(1); + expect(state.counts.capabilities).toBe(1); }); // checklist: admin-ai-coverage-track, admin-ai-daily-chart, admin-ai-cache-panel @@ -423,7 +456,7 @@ test.describe("full sweep: admin ai operations", () => { await expect(chart.locator(".aic-chart__day")).toHaveCount(3); await expect(chart.locator(".aic-chart__day").first()).toHaveAttribute( "title", - "2026-07-13 $1.4200", + "2026-07-13 $1.42", ); await expect(chart.locator(".aic-chart__day").first()).toContainText("8턴"); @@ -492,7 +525,7 @@ test.describe("full sweep: admin ai operations", () => { updated_by: null, updated_at: null, }); - await page.getByRole("button", { name: "새로고침" }).click(); + await page.getByRole("button", { name: "새로고침", exact: true }).click(); await expect(rowValue("저장 원천")).toHaveText("런타임 적용"); await expect(rowValue("현재 소스")).toHaveText("runtime_default"); await expect(rowValue("최근 변경")).toHaveText("기록 없음"); diff --git a/apps/web/e2e/full-sweep-admin.spec.ts b/apps/web/e2e/full-sweep-admin.spec.ts index 100919d..b30f756 100644 --- a/apps/web/e2e/full-sweep-admin.spec.ts +++ b/apps/web/e2e/full-sweep-admin.spec.ts @@ -377,12 +377,12 @@ test.describe("admin console full-sweep (fixtures)", () => { await page.goto("/admin"); await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible(); - const inlineError = page.locator(".ad-error"); + const inlineError = page.locator(".vgops-error"); await expect(inlineError).toBeVisible(); await expect(inlineError).toHaveAttribute("role", "alert"); await expect(inlineError).toContainText("헬스 저장소 접근 불가"); // 다른 데이터 소스는 정상이므로 화면 나머지는 렌더된다. - await expect(page.locator(".ad-kpis").first()).toBeVisible(); + await expect(page.locator(".vgops-kpis").first()).toBeVisible(); }); // 검증 checklist: admin-users-search-input @@ -417,15 +417,15 @@ test.describe("admin console full-sweep (fixtures)", () => { await page.goto("/admin/users"); await page.getByRole("tab", { name: "사용자 목록" }).click(); - const rows = page.locator(".ad-user-table tbody tr"); + const rows = page.locator(".vgops-user-table tbody tr"); await expect(rows).toHaveCount(3); - await expect(page.locator(".ad-users-toolbar")).toContainText("3 / 3명"); + await expect(page.locator(".vgops-users-toolbar")).toContainText("3 / 3명"); const search = page.getByLabel("사용자 검색"); await search.fill("교수자"); await expect(rows).toHaveCount(1); await expect(rows.first()).toContainText("bob@hs.ac.kr"); - await expect(page.locator(".ad-users-toolbar")).toContainText("1 / 3명"); + await expect(page.locator(".vgops-users-toolbar")).toContainText("1 / 3명"); await search.fill("cohort-alpha"); await expect(rows).toHaveCount(1); @@ -470,7 +470,7 @@ test.describe("admin console full-sweep (fixtures)", () => { await page.goto("/admin/users"); await page.getByRole("tab", { name: "사용자 목록" }).click(); - const rows = page.locator(".ad-user-table tbody tr"); + const rows = page.locator(".vgops-user-table tbody tr"); await expect(rows).toHaveCount(3); // 기본 정렬: last_seen_at 내림차순. @@ -515,8 +515,8 @@ test.describe("admin console full-sweep (fixtures)", () => { await page.goto("/admin/users"); await page.getByRole("tab", { name: "사용자 목록" }).click(); - await expect(page.locator(".ad-user-table tbody tr")).toHaveCount(40); - await expect(page.locator(".ad-users-toolbar")).toContainText("45 / 45명"); + await expect(page.locator(".vgops-user-table tbody tr")).toHaveCount(40); + await expect(page.locator(".vgops-users-toolbar")).toContainText("45 / 45명"); await expect(page.getByText(/나머지\s*5명을 더 좁혀 볼 수/)).toBeVisible(); }); @@ -553,7 +553,7 @@ test.describe("admin console full-sweep (fixtures)", () => { // 토글 → 저장 시 admin_access가 PATCH로 반영된다. await normalToggle.check(); - const normalRow = page.locator(".ad-user-table tbody tr").filter({ hasText: "normal@hs.ac.kr" }); + const normalRow = page.locator(".vgops-user-table tbody tr").filter({ hasText: "normal@hs.ac.kr" }); const saveButton = normalRow.getByRole("button", { name: "저장" }); await expect(saveButton).toBeEnabled(); const patchPromise = page.waitForResponse(isUserPatchResponse("normal")); @@ -604,7 +604,7 @@ test.describe("admin console full-sweep (fixtures)", () => { await expect(page.getByRole("heading", { name: "역할 분포" })).toBeVisible(); await expect(page.getByRole("heading", { name: "최근 활동" })).toBeVisible(); - const meters = page.locator(".ad-role-meter"); + const meters = page.locator(".vgops-role-meter"); await expect(meters).toHaveCount(3); await expect(meters.nth(0)).toContainText("학습자"); await expect(meters.nth(0)).toContainText("5명"); @@ -613,10 +613,10 @@ test.describe("admin console full-sweep (fixtures)", () => { await expect(meters.nth(2)).toContainText("관리자"); await expect(meters.nth(2)).toContainText("2명"); - const activityRows = page.locator(".ad-activity-table > div"); + const activityRows = page.locator(".vgops-activity-table > div"); await expect(activityRows).toHaveCount(8); await expect(activityRows.first()).toContainText("Activity User 1"); - await expect(page.locator(".ad-activity-table")).not.toContainText("Activity User 9"); + await expect(page.locator(".vgops-activity-table")).not.toContainText("Activity User 9"); }); // 검증 checklist: admin-users-states @@ -636,9 +636,9 @@ test.describe("admin console full-sweep (fixtures)", () => { await page.goto("/admin/users"); // 로딩 스켈레톤(승인 큐)이 먼저 보인다. - await expect(page.locator(".ad-user--skeleton").first()).toBeVisible(); + await expect(page.locator(".vgops-user--skeleton").first()).toBeVisible(); // 실패 후 InlineError가 표시된다. - await expect(page.locator(".ad-error")).toContainText("사용자 저장소 중단"); + await expect(page.locator(".vgops-error")).toContainText("사용자 저장소 중단"); // 앱 결함: 이 클릭에서 페이지가 무한 렌더 루프로 프리즈되어 테스트가 타임아웃된다. await page.getByRole("tab", { name: "사용자 목록" }).click(); await expect( @@ -657,8 +657,8 @@ test.describe("admin console full-sweep (fixtures)", () => { // 로딩 스켈레톤 → 실패 InlineError (클릭 없이 표시만 검증). mock.overrides.users = { delayMs: 1_500, status: 500, detail: "사용자 저장소 중단" }; await page.goto("/admin/users"); - await expect(page.locator(".ad-user--skeleton").first()).toBeVisible(); - await expect(page.locator(".ad-error")).toContainText("사용자 저장소 중단"); + await expect(page.locator(".vgops-user--skeleton").first()).toBeVisible(); + await expect(page.locator(".vgops-error")).toContainText("사용자 저장소 중단"); // 등록 0건 빈 상태. delete mock.overrides.users; @@ -673,7 +673,7 @@ test.describe("admin console full-sweep (fixtures)", () => { ]; await page.reload(); await page.getByRole("tab", { name: "사용자 목록" }).click(); - await expect(page.locator(".ad-user-table tbody tr")).toHaveCount(1); + await expect(page.locator(".vgops-user-table tbody tr")).toHaveCount(1); await page.getByLabel("사용자 검색").fill("no-match-full-sweep"); await expect(page.getByText("검색 조건에 맞는 사용자가 없습니다.")).toBeVisible(); await expect(page.getByText("아직 등록된 사용자가 없습니다.")).toHaveCount(0); @@ -737,7 +737,7 @@ test.describe("admin console full-sweep (fixtures)", () => { params.get("assigned_group") === "ops", ), ); - await page.locator(".ad-ticket-filter__check input").check(); + await page.locator(".vgops-ticket-filter__check input").check(); await staleRequest; await expect(clearButton).toBeEnabled(); @@ -760,7 +760,7 @@ test.describe("admin console full-sweep (fixtures)", () => { await expect(page.getByLabel("카테고리 필터")).toHaveValue("all"); await expect(page.getByLabel("우선순위 필터")).toHaveValue("all"); await expect(page.getByLabel("담당 그룹 필터")).toHaveValue(""); - await expect(page.locator(".ad-ticket-filter__check input")).not.toBeChecked(); + await expect(page.locator(".vgops-ticket-filter__check input")).not.toBeChecked(); await expect(clearButton).toBeDisabled(); }); @@ -776,7 +776,7 @@ test.describe("admin console full-sweep (fixtures)", () => { ]; await page.goto("/admin/tickets"); - const chips = page.locator(".ad-ticket-queues button"); + const chips = page.locator(".vgops-ticket-queues button"); await expect(chips).toHaveCount(2); // 건수 내림차순: 안전(2) → 기타(1). await expect(chips.nth(0)).toContainText("안전"); @@ -835,7 +835,7 @@ test.describe("admin console full-sweep (fixtures)", () => { mock.overrides.tickets = { status: 500, detail: "티켓 저장소 오류" }; await page.getByRole("button", { name: "새로고침" }).click(); - const inlineError = page.locator(".ad-error"); + const inlineError = page.locator(".vgops-error"); await expect(inlineError).toBeVisible(); await expect(inlineError).toHaveAttribute("role", "alert"); await expect(inlineError).toContainText("티켓 저장소 오류"); @@ -876,7 +876,7 @@ test.describe("admin console full-sweep (real API)", () => { await page.goto("/admin/users"); expect((await usersResponsePromise).ok()).toBeTruthy(); - const approvalCard = page.locator(".ad-approval").filter({ hasText: email }); + const approvalCard = page.locator(".vgops-approval").filter({ hasText: email }); await expect(approvalCard).toBeVisible(); await expect(approvalCard).toContainText("승인 대기"); @@ -941,7 +941,7 @@ test.describe("admin console full-sweep (real API)", () => { await page.getByRole("tab", { name: "사용자 목록" }).click(); await page.getByLabel("사용자 검색").fill(email); - const row = page.locator(".ad-user-table tbody tr").filter({ hasText: email }); + const row = page.locator(".vgops-user-table tbody tr").filter({ hasText: email }); await expect(row).toBeVisible(); const affiliationInput = row.getByLabel(`${email} 소속`); @@ -1019,7 +1019,7 @@ test.describe("admin console full-sweep (real API)", () => { await page.getByRole("tab", { name: "사용자 목록" }).click(); await page.getByLabel("사용자 검색").fill(email); - const row = page.locator(".ad-user-table tbody tr").filter({ hasText: email }); + const row = page.locator(".vgops-user-table tbody tr").filter({ hasText: email }); await expect(row).toBeVisible(); const cohortInput = row.getByLabel(`${email} 코호트`); await cohortInput.fill("co-a, co-b"); @@ -1083,7 +1083,7 @@ test.describe("admin console full-sweep (real API)", () => { await page.goto("/admin/tickets"); await ticketsResponsePromise; - const card = page.locator(".ad-ticket").filter({ hasText: subject }); + const card = page.locator(".vgops-ticket").filter({ hasText: subject }); await expect(card).toBeVisible(); let patchCount = 0; @@ -1171,7 +1171,7 @@ test.describe("admin console full-sweep (real API)", () => { await ticketsResponsePromise; const linkedCard = page - .locator(".ad-ticket") + .locator(".vgops-ticket") .filter({ hasText: subject }) .filter({ has: page.getByRole("button", { name: "해제" }) }); await expect(linkedCard).toBeVisible(); diff --git a/apps/web/e2e/full-sweep-session.spec.ts b/apps/web/e2e/full-sweep-session.spec.ts index 9a3fa95..46d66c3 100644 --- a/apps/web/e2e/full-sweep-session.spec.ts +++ b/apps/web/e2e/full-sweep-session.spec.ts @@ -787,6 +787,10 @@ test.describe("full sweep — counseling session", () => { const skipButton = page.getByRole("button", { name: "음성 건너뛰기" }); await expect(skipButton).toBeVisible(); await expect(page.locator(".sx-mic-block__l")).toHaveText("재생 중"); + const composer = page.getByLabel("학습자 발화 입력"); + await expect(composer).toBeEnabled(); + await composer.fill("음성을 들으면서 다음 질문을 미리 씁니다."); + await expect(page.getByRole("button", { name: "보내기" })).toBeEnabled(); await skipButton.click(); await expect(skipButton).toHaveCount(0); @@ -794,6 +798,7 @@ test.describe("full sweep — counseling session", () => { "음성을 건너뛰었습니다. 다음 발화를 입력하거나 마이크를 켜세요.", ); await expect(page.locator(".sx-mic-block__l")).toHaveText("마이크 꺼짐"); + await expect(composer).toHaveValue("음성을 들으면서 다음 질문을 미리 씁니다."); }); // checklist: session-pause-toggle, session-elapsed-live-region diff --git a/apps/web/e2e/full-sweep-shell.spec.ts b/apps/web/e2e/full-sweep-shell.spec.ts index a9333bc..fbd9c51 100644 --- a/apps/web/e2e/full-sweep-shell.spec.ts +++ b/apps/web/e2e/full-sweep-shell.spec.ts @@ -199,6 +199,54 @@ test.describe("full sweep — shared shell, GNB, and routing guards", () => { await expect(page.getByText(BOOT_SCREEN_TEXT)).toHaveCount(0); }); + // checklist: shell-stale-chunk-recovery + // Pages 배포 전환 뒤 열린 탭의 Vite lazy 청크가 사라진 경우 한 번만 새 문서를 + // 다시 받고, 정상 렌더 뒤 잠금을 해제한다. 같은 실패가 연속되면 무한 reload하지 않는다. + test("reloads once for a stale Vite route chunk without entering a reload loop", async ({ + page, + }) => { + let documentRequests = 0; + page.on("request", (request) => { + if (request.isNavigationRequest()) documentRequests += 1; + }); + + await page.goto("/login"); + await expect(page.getByRole("button", { name: /학교 Google 계정으로 계속/ })).toBeVisible(); + expect(documentRequests).toBe(1); + + const firstPrevented = await page.evaluate(() => { + const event = new Event("vite:preloadError", { cancelable: true }); + Object.assign(event, { + payload: new TypeError("Failed to fetch dynamically imported module"), + }); + return !window.dispatchEvent(event); + }); + expect(firstPrevented).toBeTruthy(); + + await expect.poll(() => documentRequests).toBe(2); + await expect(page.getByRole("button", { name: /학교 Google 계정으로 계속/ })).toBeVisible(); + await expect + .poll(() => + page.evaluate(() => sessionStorage.getItem("vignette:chunk-recovery:/login")), + ) + .toBeNull(); + + const secondAttempt = await page.evaluate(() => { + sessionStorage.setItem("vignette:chunk-recovery:/login", "reload-attempted"); + const event = new Event("vite:preloadError", { cancelable: true }); + Object.assign(event, { + payload: new TypeError("Failed to fetch dynamically imported module"), + }); + return { + prevented: !window.dispatchEvent(event), + marker: sessionStorage.getItem("vignette:chunk-recovery:/login"), + }; + }); + + expect(secondAttempt).toEqual({ prevented: false, marker: "reload-attempted" }); + await expect.poll(() => documentRequests).toBe(2); + }); + // checklist: shell-guard-pending-approval // 미승인(account_status=pending) 사용자는 어떤 보호 경로에서든 /pending으로 회수된다. test("redirects a pending-approval user to /pending from any protected route", async ({ diff --git a/apps/web/e2e/layout-visual-gate.spec.ts b/apps/web/e2e/layout-visual-gate.spec.ts index 9e4d009..c9cfe2c 100644 --- a/apps/web/e2e/layout-visual-gate.spec.ts +++ b/apps/web/e2e/layout-visual-gate.spec.ts @@ -426,6 +426,22 @@ test.describe("layout visual gate @single-run", () => { await ensureShotDir(); }); + // 이 게이트는 다크 표면을 기준으로 레이아웃을 캡처하고, 라이트 검사는 각 테스트가 + // "라이트 모드로" 버튼을 눌러 명시적으로 전환한 뒤에만 한다. 예전에는 앱이 저장값 + // 없을 때 무조건 dark 로 떨어져서 이 전제가 공짜로 성립했다. 2026-07-27 부터 + // readInitialTheme() 이 prefers-color-scheme 을 따르므로(소유자 결정), 이 프로젝트에 + // colorScheme 설정이 없으면 Chromium 기본값인 light 로 시작해 게이트가 깨진다. + // 게이트가 자기 전제를 직접 심는다. 테마 store 의 "저장값 우선" 규칙을 그대로 쓴다. + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + try { + localStorage.setItem("vignette.theme", "dark"); + } catch { + /* storage 접근 불가 환경에서는 앱 폴백(dark)에 맡긴다 */ + } + }); + }); + test("learner home stays contained and legible across all widths", async ({ page }) => { await page.request.post("/api/auth/dev-login", { data: { @@ -788,7 +804,7 @@ test.describe("layout visual gate @single-run", () => { ]) { await page.setViewportSize(viewport); await expectNoHorizontalOverflow(page); - const scrollReport = await page.locator(".ad-user-table-scroll").evaluate((element) => { + const scrollReport = await page.locator(".vgops-user-table-scroll").evaluate((element) => { element.scrollLeft = 0; const report = { clientWidth: element.clientWidth, @@ -805,7 +821,7 @@ test.describe("layout visual gate @single-run", () => { scrollReport.initialScroll, ); await expect(page.getByRole("columnheader", { name: /작업/ })).toBeVisible(); - await page.locator(".ad-user-table-scroll").evaluate((element) => { + await page.locator(".vgops-user-table-scroll").evaluate((element) => { element.scrollLeft = 0; }); await page.screenshot({ diff --git a/apps/web/e2e/learner.spec.ts b/apps/web/e2e/learner.spec.ts index e1cd3d1..9fb6474 100644 --- a/apps/web/e2e/learner.spec.ts +++ b/apps/web/e2e/learner.spec.ts @@ -196,7 +196,13 @@ test.describe("learner app shell and session launcher", () => { await expect(launcher.getByRole("option")).toHaveCount(personas.length, { timeout: 15_000 }); for (const persona of personas) { await expect(launcher.getByRole("option", { name: new RegExp(persona.code) })).toBeVisible(); - await expect(launcher.getByRole("option", { name: new RegExp(persona.display_name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) })).toBeVisible(); + // display_name 은 가운뎃점 3개 이상이면 이름줄과 주호소줄로 나뉘어 렌더된다 + // (2026-07-27 D3: 한 줄에 · 1개 제한). 이어 붙인 원문 대신 각 조각이 모두 + // 옵션 안에 있는지 확인해, 표시 방식이 바뀌어도 내용 누락만 잡히게 한다. + const option = launcher.getByRole("option", { name: new RegExp(persona.code) }); + for (const part of persona.display_name.split("·").map((piece) => piece.trim()).filter(Boolean)) { + await expect(option).toContainText(part); + } } await expectPersonaCardsUseContentHeight(page); diff --git a/apps/web/e2e/public-admin-visual.spec.ts b/apps/web/e2e/public-admin-visual.spec.ts index fd93549..87edfa4 100644 --- a/apps/web/e2e/public-admin-visual.spec.ts +++ b/apps/web/e2e/public-admin-visual.spec.ts @@ -59,25 +59,25 @@ test.describe("public admin visual @public-auth", () => { ).toBeTruthy(); await expect(page).toHaveURL(/\/admin(?:$|[?#])/); - await expect(page.locator(".ad-root")).toBeVisible({ timeout: 15_000 }); + await expect(page.locator("[data-vignette-admin-root]")).toBeVisible({ timeout: 15_000 }); await expect(page.locator(".vg-shell")).toBeVisible(); await expect(page.locator(".vg-nav").getByRole("link", { name: "운영 홈" })).toBeVisible(); await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible(); await expect(page.getByRole("heading", { name: "AI 비용" })).toBeVisible(); await expect(page.getByRole("heading", { name: "가용성" })).toBeVisible(); await expect(page.getByRole("heading", { name: "운영 티켓" })).toBeVisible(); - await expect(page.locator(".ad-kpi b")).toHaveCount(4); + await expect(page.locator(".vgops-kpi b")).toHaveCount(4); await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0); const visualReport = await page.evaluate(() => { - const adminRoot = document.querySelector(".ad-root"); + const adminRoot = document.querySelector("[data-vignette-admin-root]"); const main = document.querySelector("main"); const heading = Array.from(document.querySelectorAll("h1,h2,h3")).find( (node) => node.textContent?.includes("현재 서비스 상태"), ); const visibleNodes = Array.from( document.querySelectorAll( - ".ad-root,.ad-head,.ad-kpi,.ad-panel,.ad-service,.vg-shell,.vg-nav,h1,h2,h3,p,a,button", + ".vgops-root,.vgops-head,.vgops-kpi,.vgops-panel,.vgops-service,.vg-shell,.vg-nav,h1,h2,h3,p,a,button", ), ).filter((node) => { const rect = node.getBoundingClientRect(); @@ -128,6 +128,119 @@ test.describe("public admin visual @public-auth", () => { ); }); + test("shows the live Codex and Agy model catalogs with safe defaults", async ({ page }) => { + if (!process.env.E2E_PUBLIC_STORAGE_STATE) { + throw new Error( + "Set E2E_PUBLIC_STORAGE_STATE to a storageState file captured after Google admin login.", + ); + } + + const pageErrors: string[] = []; + const consoleErrors: string[] = []; + page.on("pageerror", (error) => pageErrors.push(error.message)); + page.on("console", (message) => { + if (message.type() === "error") consoleErrors.push(message.text()); + }); + + await page.goto("/admin/ai", { waitUntil: "domcontentloaded" }); + await expect(page.locator('[data-testid="admin-ai-page"]')).toBeVisible({ timeout: 15_000 }); + + const provider = page.getByLabel("AI 엔진 공급자"); + const model = page.getByLabel("AI 기본 모델"); + const effort = page.getByLabel("AI 추론 강도"); + await expect(provider.locator("option")).toHaveCount(6); + + await provider.selectOption("codex_cli"); + await expect(model).toBeEnabled({ timeout: 30_000 }); + await expect(model).toHaveValue("gpt-5.6-terra"); + await expect(effort).toHaveValue("medium"); + await expect(page.getByText("7개 모델 확인됨")).toBeVisible(); + + await provider.selectOption("agy_cli"); + await expect(model).toBeEnabled({ timeout: 30_000 }); + await expect(model).toHaveValue("gemini-3.6-flash-high"); + await expect(effort).toHaveValue("high"); + await expect(page.getByText("11개 모델 확인됨")).toBeVisible(); + + expect(pageErrors, JSON.stringify(pageErrors)).toEqual([]); + expect(consoleErrors, JSON.stringify(consoleErrors)).toEqual([]); + }); + + test("keeps the public admin visible with EasyList cosmetic filters active", async ({ + page, + }) => { + if (!process.env.E2E_PUBLIC_STORAGE_STATE) { + throw new Error( + "Set E2E_PUBLIC_STORAGE_STATE to a storageState file captured after Google admin login.", + ); + } + + const pageErrors: string[] = []; + const consoleErrors: string[] = []; + page.on("pageerror", (error) => pageErrors.push(error.message)); + page.on("console", (message) => { + if (message.type() === "error") consoleErrors.push(message.text()); + }); + + await page.addInitScript(() => { + // 모든 공개 관리자 navigation의 첫 paint부터 실제 EasyList 충돌 규칙을 적용한다. + const style = document.createElement("style"); + style.dataset.testEasylist = "true"; + style.textContent = ".ad-root,.ad-section{display:none!important}"; + const install = () => { + const target = document.head ?? document.documentElement; + if (!target) return false; + target.append(style); + return true; + }; + if (!install()) { + const observer = new MutationObserver(() => { + if (install()) observer.disconnect(); + }); + observer.observe(document, { childList: true, subtree: true }); + } + }); + + const sections = [ + { path: "/admin", heading: "현재 서비스 상태", root: "[data-vignette-admin-root]" }, + { path: "/admin/ai", heading: "AI 운영과 DB 계량", root: "[data-testid='admin-ai-page']" }, + { path: "/admin/users", heading: "가입 승인과 권한 관리", root: "[data-vignette-admin-root]" }, + { path: "/admin/access", heading: "역할, 그룹, 접근 범위", root: "[data-vignette-admin-root]" }, + { path: "/admin/tickets", heading: "사용자 문제 큐", root: "[data-vignette-admin-root]" }, + ] as const; + + for (const section of sections) { + await page.goto(section.path, { waitUntil: "domcontentloaded" }); + await expect(page.locator(section.root)).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole("heading", { name: section.heading })).toBeVisible(); + await expect(page.locator('[class^="ad-"],[class*=" ad-"]')).toHaveCount(0); + } + + await page.waitForTimeout(4_000); + await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0); + const adminRoot = page.locator("[data-vignette-admin-root]"); + await expect(adminRoot).toBeVisible(); + + const visibility = await adminRoot.evaluate((root) => { + const rect = root.getBoundingClientRect(); + const style = getComputedStyle(root); + return { + width: rect.width, + height: rect.height, + display: style.display, + visibility: style.visibility, + opacity: style.opacity, + }; + }); + expect(visibility.width, JSON.stringify(visibility)).toBeGreaterThan(900); + expect(visibility.height, JSON.stringify(visibility)).toBeGreaterThan(500); + expect(visibility.display, JSON.stringify(visibility)).not.toBe("none"); + expect(visibility.visibility, JSON.stringify(visibility)).not.toBe("hidden"); + expect(visibility.opacity, JSON.stringify(visibility)).not.toBe("0"); + expect(pageErrors, JSON.stringify(pageErrors)).toEqual([]); + expect(consoleErrors, JSON.stringify(consoleErrors)).toEqual([]); + }); + test("renders every public admin section without a silent blank pane", async ({ page }) => { if (!process.env.E2E_PUBLIC_STORAGE_STATE) { throw new Error( @@ -143,21 +256,24 @@ test.describe("public admin visual @public-auth", () => { }); const sections = [ - { path: "/admin", heading: "현재 서비스 상태" }, - { path: "/admin/users", heading: "가입 승인과 권한 관리" }, - { path: "/admin/access", heading: "역할, 그룹, 접근 범위" }, - { path: "/admin/tickets", heading: "사용자 문제 큐" }, + { path: "/admin", heading: "현재 서비스 상태", root: "[data-vignette-admin-root]" }, + { path: "/admin/ai", heading: "AI 운영과 DB 계량", root: "[data-testid='admin-ai-page']" }, + { path: "/admin/users", heading: "가입 승인과 권한 관리", root: "[data-vignette-admin-root]" }, + { path: "/admin/access", heading: "역할, 그룹, 접근 범위", root: "[data-vignette-admin-root]" }, + { path: "/admin/tickets", heading: "사용자 문제 큐", root: "[data-vignette-admin-root]" }, ] as const; for (const section of sections) { await page.goto(section.path, { waitUntil: "domcontentloaded" }); - await expect(page.locator(".ad-root")).toBeVisible({ timeout: 15_000 }); + await expect(page.locator(section.root)).toBeVisible({ timeout: 15_000 }); await expect(page.getByRole("heading", { name: section.heading })).toBeVisible(); - await expect(page.locator(".ad-diagnostic")).toHaveCount(0); + await expect(page.locator(".vgops-diagnostic")).toHaveCount(0); - const report = await page.evaluate(() => { - const root = document.querySelector(".ad-root"); + const report = await page.evaluate((rootSelector) => { + const root = document.querySelector(rootSelector); + const main = document.querySelector(".vg-main"); const rect = root?.getBoundingClientRect(); + const mainRect = main?.getBoundingClientRect(); const visibleNodes = root ? Array.from(root.querySelectorAll("h1,h2,p,button,input,select,article,section")) .filter((node) => { @@ -166,6 +282,8 @@ test.describe("public admin visual @public-auth", () => { return ( nodeRect.width > 1 && nodeRect.height > 1 && + nodeRect.bottom > (mainRect?.top ?? 0) && + nodeRect.top < (mainRect?.bottom ?? window.innerHeight) && style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0" @@ -178,20 +296,38 @@ test.describe("public admin visual @public-auth", () => { width: rect?.width ?? 0, height: rect?.height ?? 0, scrollY: window.scrollY, + mainScrollTop: main?.scrollTop ?? -1, }; - }); + }, section.root); expect(report.textLength, JSON.stringify({ section, report })).toBeGreaterThan(120); expect(report.visibleNodes, JSON.stringify({ section, report })).toBeGreaterThan(5); expect(report.width, JSON.stringify({ section, report })).toBeGreaterThan(300); expect(report.height, JSON.stringify({ section, report })).toBeGreaterThan(120); expect(report.scrollY, JSON.stringify({ section, report })).toBe(0); + expect(report.mainScrollTop, JSON.stringify({ section, report })).toBe(0); } expect(pageErrors, JSON.stringify(pageErrors)).toEqual([]); expect(consoleErrors, JSON.stringify(consoleErrors)).toEqual([]); }); + test("keeps the operating console visible from the primary learner workspace", async ({ page }) => { + if (!process.env.E2E_PUBLIC_STORAGE_STATE) { + throw new Error( + "Set E2E_PUBLIC_STORAGE_STATE to a storageState file captured after Google admin login.", + ); + } + + await page.goto("/learn", { waitUntil: "domcontentloaded" }); + const adminEntry = page.locator(".vg-nav").getByRole("link", { name: "운영 콘솔" }); + await expect(adminEntry).toBeVisible({ timeout: 15_000 }); + + await adminEntry.click(); + await expect(page).toHaveURL(/\/admin(?:$|[?#])/); + await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible(); + }); + test("returns a restored admin tab to visible content after pageshow", async ({ page }) => { if (!process.env.E2E_PUBLIC_STORAGE_STATE) { throw new Error( @@ -201,13 +337,15 @@ test.describe("public admin visual @public-auth", () => { await page.goto("/admin", { waitUntil: "domcontentloaded" }); await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible(); - await expect(page.locator(".ad-root")).toBeVisible(); + await expect(page.locator("[data-vignette-admin-root]")).toBeVisible(); const beforeRestore = await page.evaluate(() => { - document.documentElement.style.minHeight = "2200px"; - document.body.style.minHeight = "2200px"; - window.scrollTo(0, 900); - return window.scrollY; + const main = document.querySelector(".vg-main"); + const root = document.querySelector("[data-vignette-admin-root]"); + if (!main || !root) throw new Error("admin scroll container missing"); + root.style.minHeight = "2200px"; + main.scrollTop = 900; + return main.scrollTop; }); expect(beforeRestore).toBeGreaterThan(0); @@ -216,7 +354,13 @@ test.describe("public admin visual @public-auth", () => { }); await expect - .poll(() => page.evaluate(() => window.scrollY), { timeout: 5_000 }) + .poll( + () => + page.evaluate( + () => document.querySelector(".vg-main")?.scrollTop ?? -1, + ), + { timeout: 5_000 }, + ) .toBe(0); await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeInViewport(); }); diff --git a/apps/web/e2e/readiness.spec.ts b/apps/web/e2e/readiness.spec.ts index 8428079..df37e07 100644 --- a/apps/web/e2e/readiness.spec.ts +++ b/apps/web/e2e/readiness.spec.ts @@ -89,9 +89,12 @@ test.describe("production readiness gates", () => { engine_mode: string; engine_url: string; model: string; + reasoning_effort: string | null; }; expect(engine).toMatchObject({ durable: true, source: "database" }); - expect(["claude_cli", "claude_api", "openai", "solar"]).toContain(engine.engine_mode); + expect(["claude_cli", "claude_api", "codex_cli", "agy_cli", "openai", "solar"]).toContain( + engine.engine_mode, + ); expect(engine.engine_url).toMatch(/^https?:\/\//); expect(engine.model.trim().length).toBeGreaterThan(0); }); diff --git a/apps/web/e2e/session-mvp.spec.ts b/apps/web/e2e/session-mvp.spec.ts index a32fd3a..caa4744 100644 --- a/apps/web/e2e/session-mvp.spec.ts +++ b/apps/web/e2e/session-mvp.spec.ts @@ -90,6 +90,16 @@ async function routeMvpApi(page: Page, options: RouteMvpOptions = {}) { }); }); + // 텍스트 턴 TTS는 이 fixture의 검증 대상이 아니다. 실제 8000 포트로 새지 않게 + // 명시적으로 실패시키고, 음성 실패가 작성 중인 초안을 지우지 않는지만 본다. + await page.route("**/api/voice/speech", async (route) => { + await route.fulfill({ + status: 503, + contentType: "application/json", + body: JSON.stringify({ detail: "voice synthesis disabled in fixture" }), + }); + }); + await page.route("**/api/personas", async (route) => { await route.fulfill({ status: 200, @@ -659,11 +669,15 @@ test.describe("P1 MVP core loop", () => { await api.streamSeen.promise; await expect(page.locator(".sx-utt").filter({ hasText: learnerText })).toBeVisible(); await expect(page.locator(".sx-utt.is-thinking").filter({ hasText: "답변을 준비 중입니다." })).toBeVisible(); - await expect(page.getByLabel("학습자 발화 입력")).toBeDisabled(); + const composer = page.getByLabel("학습자 발화 입력"); + await expect(composer).toBeEnabled(); + await expect(page.getByRole("button", { name: "보내기" })).toBeDisabled(); + await composer.fill("다음 질문을 미리 작성합니다."); api.streamGate.resolve(); await expect(page.locator(".sx-utt").filter({ hasText: clientReply })).toBeVisible(); - await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled(); + await expect(composer).toHaveValue("다음 질문을 미리 작성합니다."); + await expect(page.getByRole("button", { name: "보내기" })).toBeEnabled(); await page.getByRole("button", { name: "회기 종료" }).click(); await page.getByRole("button", { name: "종료하고 리뷰 보기" }).click(); diff --git a/apps/web/e2e/session-persistence.spec.ts b/apps/web/e2e/session-persistence.spec.ts index db3437f..1325577 100644 --- a/apps/web/e2e/session-persistence.spec.ts +++ b/apps/web/e2e/session-persistence.spec.ts @@ -152,6 +152,7 @@ interface AdminEngineConfigResponse { engine_mode: string; engine_url: string; model: string; + reasoning_effort?: string | null; } interface PrepostMeasureItem { @@ -1430,124 +1431,36 @@ test.describe("session persistence", () => { } }); - test("runs manual AI evaluation retry from teacher review UI into durable DB state @single-run", async ({ + test("rejects an unreachable AI gateway before it can poison the evaluation runtime @single-run", async ({ page, }) => { - test.setTimeout(240_000); + test.setTimeout(120_000); const healthResponse = await page.request.get("/api/health"); await expectResponseOk(healthResponse); const health = (await healthResponse.json()) as HealthResponse; - test.skip(!health.db || !health.engine, "manual evaluation retry requires DB and engine"); + test.skip(!health.db || !health.engine, "engine configuration validation requires DB and engine"); - await signInAsLearner(page); - const sessionId = await createActiveSessionWithTurn(page); - const endedResponse = await page.request.post(`/api/sessions/${sessionId}/end`); - await expectResponseOk(endedResponse); - - await withGlobalEngineConfigLock("manual-evaluation-retry-ui", async () => { + await withGlobalEngineConfigLock("engine-config-fail-closed", async () => { await signInAsAdmin(page); const engineResponse = await page.request.get("/api/admin/engine-config"); await expectResponseOk(engineResponse); const currentEngine = (await engineResponse.json()) as AdminEngineConfigResponse; - try { - const brokenEnginePatch = await page.request.patch("/api/admin/engine-config", { - data: { - engine_mode: currentEngine.engine_mode, - engine_url: "http://127.0.0.1:9", - model: currentEngine.model, - }, - }); - await expectResponseOk(brokenEnginePatch); + const brokenEnginePatch = await page.request.patch("/api/admin/engine-config", { + data: { + engine_mode: currentEngine.engine_mode, + engine_url: "http://127.0.0.1:9", + model: currentEngine.model, + reasoning_effort: currentEngine.reasoning_effort, + }, + }); + expect(brokenEnginePatch.status()).toBe(422); - await signInAsTeacher(page); - const failedReevaluationResponse = await page.request.post( - `/api/eval/sessions/${sessionId}/reevaluate`, - { data: { scope: "session_end" } }, - ); - expect( - failedReevaluationResponse.ok(), - "broken engine should create a failed durable evaluation seed", - ).toBe(false); - - await signInAsAdmin(page); - const restoreResponse = await page.request.patch("/api/admin/engine-config", { - data: { - engine_mode: currentEngine.engine_mode, - engine_url: currentEngine.engine_url, - model: currentEngine.model, - }, - }); - await expectResponseOk(restoreResponse); - - await signInAsTeacher(page); - await page.goto(`/teach/session/${sessionId}/review`); - await expect(page.getByText("평가 실패")).toBeVisible({ timeout: 15_000 }); - const retryButton = page.getByRole("button", { name: "AI 평가 재시도" }); - await expect(retryButton).toBeVisible(); - - const reevaluateResponsePromise = page.waitForResponse((response) => { - const url = new URL(response.url()); - return ( - response.request().method() === "POST" && - url.pathname.endsWith(`/eval/sessions/${sessionId}/reevaluate`) - ); - }); - await retryButton.click(); - const reevaluateResponse = await reevaluateResponsePromise; - await expectResponseOk(reevaluateResponse); - const reevaluation = (await reevaluateResponse.json()) as { - scope?: string; - error?: string | null; - turns_evaluated?: number; - }; - expect(reevaluation.scope).toBe("session_end"); - expect(reevaluation.error ?? "").toBe(""); - expect(reevaluation.turns_evaluated ?? 0).toBeGreaterThan(0); - - await expect(page.locator(".sr-head__stats")).toContainText("평가 완료", { - timeout: 30_000, - }); - await expect(page.locator('[aria-label="리뷰 생성 상태"]')).toContainText("준비됨"); - await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toHaveCount(0); - - const evaluationResponse = await page.request.get(`/api/eval/sessions/${sessionId}/evaluation`); - await expectResponseOk(evaluationResponse); - const evaluation = (await evaluationResponse.json()) as EvaluationSummaryResponse; - expect(evaluation.status).toBe("ready"); - expect(evaluation.durable).toBe(true); - expect(evaluation.deep?.scope).toBe("session_end"); - expect(evaluation.deep?.turns_evaluated).toBe(reevaluation.turns_evaluated); - - const reviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`); - await expectResponseOk(reviewResponse); - const review = (await reviewResponse.json()) as SessionReviewResponse; - expect(review.reviewReady).toBe(true); - expect(review.supervisorState).toBe("평가 완료"); - - const dashboardResponse = await page.request.get("/api/teacher/dashboard"); - await expectResponseOk(dashboardResponse); - const dashboard = (await dashboardResponse.json()) as TeacherDashboardResponse; - expect(dashboard.source).toBe("database"); - const dashboardSession = [...(dashboard.pending_reviews ?? []), ...(dashboard.recent_sessions ?? [])].find( - (session) => session.session_id === sessionId, - ); - expect(dashboardSession, "teacher dashboard should expose the retried session").toBeTruthy(); - expect(dashboardSession?.evaluation_status).toBe("ready"); - expect(dashboardSession?.review_ready).toBe(true); - expect(dashboardSession?.supervisor_state).toBe("평가 완료"); - } finally { - await signInAsAdmin(page); - const restoreResponse = await page.request.patch("/api/admin/engine-config", { - data: { - engine_mode: currentEngine.engine_mode, - engine_url: currentEngine.engine_url, - model: currentEngine.model, - }, - }); - await expectResponseOk(restoreResponse); - } + const persistedResponse = await page.request.get("/api/admin/engine-config"); + await expectResponseOk(persistedResponse); + const persisted = (await persistedResponse.json()) as AdminEngineConfigResponse; + expect(persisted).toMatchObject(currentEngine); }); }); }); diff --git a/apps/web/e2e/settings.spec.ts b/apps/web/e2e/settings.spec.ts index 2811e67..587e432 100644 --- a/apps/web/e2e/settings.spec.ts +++ b/apps/web/e2e/settings.spec.ts @@ -44,6 +44,7 @@ interface AdminEngineConfigResponse { engine_mode: string; engine_url: string; model: string; + reasoning_effort: string | null; updated_by: string | null; updated_at: number | null; } @@ -117,11 +118,28 @@ async function waitForReactInputCommit(page: Page) { ); } -function hasEngineConfigRequestBody(engineMode: string, model: string) { +interface EngineCapabilitiesResponse { + available: boolean; + models: Array<{ + id: string; + reasoning_efforts?: string[]; + default_reasoning_effort?: string | null; + }>; +} + +function hasEngineConfigRequestBody(engineMode: string, model: string, reasoningEffort: string | null) { return (response: Response) => { try { - const body = response.request().postDataJSON() as { engine_mode?: string; model?: string }; - return body.engine_mode === engineMode && body.model === model; + const body = response.request().postDataJSON() as { + engine_mode?: string; + model?: string; + reasoning_effort?: string | null; + }; + return ( + body.engine_mode === engineMode && + body.model === model && + body.reasoning_effort === reasoningEffort + ); } catch { return false; } @@ -143,7 +161,9 @@ async function expectNoEngineSegmentClipping(page: Page) { height: number; }> = []; - const segment = document.querySelector("#set-engine .vg-set__seg"); + const segment = document.querySelector( + "#set-engine [aria-label='AI 엔진 공급자']", + ); if (!segment) { return [ { @@ -666,19 +686,31 @@ test.describe("settings page", () => { const engine = page.locator("#set-engine"); await expect(engine).toBeVisible(); - await expect(engine.locator("input").nth(0)).toHaveValue(originalEngineConfig.engine_url); - await expect(engine.locator("input").nth(1)).toHaveValue(originalEngineConfig.model); + await expect(engine.getByLabel("AI 연결 주소")).toHaveValue(originalEngineConfig.engine_url); + await expect(engine.getByLabel("AI 엔진 공급자")).toHaveValue( + originalEngineConfig.engine_mode, + ); + await expect(engine.getByLabel("AI 기본 모델")).toHaveValue(originalEngineConfig.model); - const nextMode = - originalEngineConfig.engine_mode === "claude_api" ? "claude_cli" : "claude_api"; - const nextModel = `e2e-model-${slugFor(testInfo)}`; + const capabilityResponse = await page.request.get( + `/api/admin/engine-capabilities?engine_mode=${encodeURIComponent(originalEngineConfig.engine_mode)}`, + ); + await expectResponseOk(capabilityResponse); + const capability = (await capabilityResponse.json()) as EngineCapabilitiesResponse; + const candidate = + capability.models.find((model) => model.id !== originalEngineConfig.model) ?? + capability.models[0]; + if (!candidate) throw new Error("engine capability returned no selectable model"); + const nextMode = originalEngineConfig.engine_mode; + const nextModel = candidate.id; + const nextEffort = candidate.default_reasoning_effort ?? null; try { - const nextModeButton = engine.locator(`[data-engine-mode="${nextMode}"]`); - await nextModeButton.click(); - await expect(nextModeButton).toHaveAttribute("aria-checked", "true"); - await engine.locator("input").nth(1).fill(nextModel); - await expect(engine.locator("input").nth(1)).toHaveValue(nextModel); + await engine.getByLabel("AI 기본 모델").selectOption(nextModel); + await expect(engine.getByLabel("AI 기본 모델")).toHaveValue(nextModel); + if (nextEffort) { + await engine.getByLabel("AI 추론 강도").selectOption(nextEffort); + } await waitForReactInputCommit(page); const enginePatchResponse = await runAndWaitForApiResponse( @@ -688,16 +720,17 @@ test.describe("settings page", () => { async () => { await engine.locator(".vg-set__foot .vg-btn").click(); }, - hasEngineConfigRequestBody(nextMode, nextModel), + hasEngineConfigRequestBody(nextMode, nextModel, nextEffort), ); await expectResponseOk(enginePatchResponse); const updatedEngine = (await enginePatchResponse.json()) as AdminEngineConfigResponse; expect(updatedEngine).toMatchObject({ engine_mode: nextMode, model: nextModel, + reasoning_effort: nextEffort, updated_by: email, }); - await expect(engine.locator("input").nth(1)).toHaveValue(nextModel); + await expect(engine.getByLabel("AI 기본 모델")).toHaveValue(nextModel); const healthResponse = await page.request.get("/api/admin/health"); await expectResponseOk(healthResponse); @@ -709,6 +742,7 @@ test.describe("settings page", () => { engine_mode: originalEngineConfig.engine_mode, engine_url: originalEngineConfig.engine_url, model: originalEngineConfig.model, + reasoning_effort: originalEngineConfig.reasoning_effort, }, }); await expectResponseOk(restoreResponse); @@ -742,12 +776,13 @@ test.describe("settings page", () => { const engine = page.locator("#set-engine"); await expect(engine).toBeVisible(); - await expect(engine.locator("[data-engine-mode]")).toHaveCount(4); + await expect(engine.getByLabel("AI 엔진 공급자").locator("option")).toHaveCount(6); await expectNoEngineSegmentClipping(page); - await expect(engine.locator("input").nth(0)).toBeVisible(); - await expect(engine.locator("input").nth(0)).toHaveValue(engineConfig!.engine_url); - await expect(engine.locator("input").nth(1)).toBeVisible(); - await expect(engine.locator("input").nth(1)).toHaveValue(engineConfig!.model); + await expect(engine.getByLabel("AI 연결 주소")).toBeVisible(); + await expect(engine.getByLabel("AI 연결 주소")).toHaveValue(engineConfig!.engine_url); + await expect(engine.getByLabel("AI 기본 모델")).toBeVisible(); + await expect(engine.getByLabel("AI 기본 모델")).toHaveValue(engineConfig!.model); + await expect(engine.getByLabel("AI 추론 강도")).toBeVisible(); await expectNoSettingsControlClipping(page); await expectNoHorizontalOverflow(page); diff --git a/apps/web/e2e/teacher.spec.ts b/apps/web/e2e/teacher.spec.ts index efd8b3c..c2efb82 100644 --- a/apps/web/e2e/teacher.spec.ts +++ b/apps/web/e2e/teacher.spec.ts @@ -590,7 +590,8 @@ test.describe("teacher console", () => { await expect(page.getByText("직장 적응 훈련 페르소나")).toBeVisible(); await page.getByRole("button", { name: "새로운 페르소나 만들기" }).click(); await expect(page.getByRole("navigation", { name: "페르소나 작성 단계" })).toBeVisible(); - await page.getByRole("button", { name: /설정 임상·회기·말투 조정/ }).click(); + // 2026-07-27 D3: 나열형 메타의 가운뎃점을 쉼표로 낮췄다 ("임상·회기·말투" → "임상, 회기, 말투"). + await page.getByRole("button", { name: /설정\s+임상, 회기, 말투 조정/ }).click(); await expect(page.getByRole("tab", { name: "개요" })).toBeVisible(); await page.getByRole("button", { name: "이전" }).click(); await expect(page).toHaveURL(/step=generate/); diff --git a/apps/web/index.html b/apps/web/index.html index 2613d76..6d465de 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -59,13 +59,15 @@ } - - - +