Stabilize runtime auth and E2E coverage

This commit is contained in:
Yun Chan 2026-06-26 14:47:00 +09:00
parent 6a3e3b541c
commit 188e899394
133 changed files with 55987 additions and 6775 deletions

View file

@ -1,20 +1,37 @@
# ── DB (NAS PostgreSQL 16 + pgvector, 단일 SoR) ── # Vignette local development env example.
DATABASE_URL=postgresql://user:pass@nas:5432/vignette # Production/compose deployment should use infra/.env.example instead.
# ── 엔진 게이트웨이 (람다 소유, app 은 HTTP 호출만) ── ENVIRONMENT=dev
ENGINE_URL=http://engine:8100 DATABASE_URL=postgresql://vignette_app:change-me-app@127.0.0.1:55432/vignette
ENGINE_MODE=claude_api # claude_api | claude_cli | openai | solar
# ── 외부 LLM 키 (PII 마스킹 후만, 게이트웨이가 주로 사용) ── # 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
# External providers. Keep real secrets in .env files only.
ANTHROPIC_API_KEY= ANTHROPIC_API_KEY=
OPENAI_API_KEY= OPENAI_API_KEY=
OPENAI_BASE_URL=https://api.openai.com/v1
# ── 세션/인증 (BFF OAuth 2.1, 토큰 서버 보관) ── # Auth/session. Local dev may enable dev-login; public/prod must not.
SESSION_SECRET=dev-insecure-change-me SESSION_SECRET=dev-insecure-change-me
OAUTH_GOOGLE_CLIENT_ID= OAUTH_GOOGLE_CLIENT_ID=
OAUTH_GOOGLE_CLIENT_SECRET= OAUTH_GOOGLE_CLIENT_SECRET=
OAUTH_REDIRECT_URI=https://chanpaca.net/auth/callback OAUTH_REDIRECT_URI=https://api-vignette.chanpaca.net/auth/callback
AUTH_ALLOWED_EMAIL_DOMAINS=["hs.ac.kr","twentyoz.kr"]
AUTH_TEACHER_EMAILS=[]
AUTH_ADMIN_EMAILS=[]
AUTH_DEV_LOGIN_ENABLED=true
DEFAULT_AFFILIATION=
AUTO_SEED_PERSONAS=false
ALLOW_SEED_PERSONA_FALLBACK=false
EVALUATOR_GOLDEN_FEWSHOT_ENABLED=false
FRONTEND_BASE_URL=http://localhost:5173
CORS_ORIGINS=["http://localhost:5173","http://127.0.0.1:5173"]
# ── 엔진 CLI 폴백 ── # Live2D runtime. Models must be configured per persona via live2dModelUrl.
VITE_LIVE2D_CUBISM_CORE=/live2d/live2dcubismcore.min.js
# Engine CLI fallback path.
CLAUDE_P_PATH=claude CLAUDE_P_PATH=claude
GLM_OR_FALLBACK_MODEL= GLM_OR_FALLBACK_MODEL=

2
.gitignore vendored
View file

@ -22,6 +22,7 @@ build/
.next/ .next/
.turbo/ .turbo/
*.log *.log
*.err
.pnpm-store/ .pnpm-store/
# === Python / 백엔드 === # === Python / 백엔드 ===
@ -45,3 +46,4 @@ postgres-data/
apps/api/gateway.restart.* apps/api/gateway.restart.*
apps/api/e2e_*.py apps/api/e2e_*.py
apps/web/_pptr_check.cjs apps/web/_pptr_check.cjs
apps/web/_pptr*.cjs

View file

@ -0,0 +1,796 @@
"""Server-side browser sessions for the BFF auth boundary.
Sessions are DB-backed when PostgreSQL is available and fall back to the
process-local store for local degraded development. The browser only receives an
opaque HttpOnly cookie; roles and user identity stay on the server.
"""
from __future__ import annotations
import hashlib
import hmac
import secrets
import time
import uuid
from datetime import datetime, timezone
from dataclasses import dataclass
from typing import Literal
from .config import settings
from .db import get_pool
from .runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed
@dataclass(slots=True)
class SessionUser:
user_id: str
email: str
display_name: str
role: str
cohort_ids: list[str]
expires_at: float
@dataclass(slots=True)
class ManagedUser:
user_id: str
email: str
display_name: str
role: str
cohort_ids: list[str]
affiliation: str
created_at: float
last_seen_at: float
class InactiveUserError(Exception):
"""Raised when an inactive managed user attempts to create a login session."""
_sessions: dict[str, SessionUser] = {}
_users: dict[str, ManagedUser] = {}
_email_index: dict[str, str] = {}
_inactive_emails: set[str] = set()
DEFAULT_AFFILIATION = settings.default_affiliation.strip()
RoleName = Literal["learner", "teacher", "admin"]
DB_ROLE_BY_APP = {"learner": "learner", "teacher": "instructor", "admin": "admin"}
APP_ROLE_BY_DB = {"learner": "learner", "instructor": "teacher", "admin": "admin"}
def _sid_hash(raw_sid: str) -> str:
return hmac.new(
settings.session_secret.encode("utf-8"),
raw_sid.encode("utf-8"),
hashlib.sha256,
).hexdigest()
def user_id_from_email(email: str) -> str:
return str(uuid.uuid5(uuid.NAMESPACE_URL, f"vignette:user:{email.strip().lower()}"))
def _normalize_email(email: str) -> str:
return email.strip().lower()
def _db_role(role: str) -> str:
return DB_ROLE_BY_APP.get(role, role)
def _app_role(role: str) -> str:
return APP_ROLE_BY_DB.get(role, role)
def _ts(value: datetime | None) -> float:
return (value or datetime.now(timezone.utc)).timestamp()
def _cohort_ids(cohort: str | None) -> list[str]:
return [cohort] if cohort else []
def _cohort_value(cohort_ids: list[str] | None) -> str | None:
return (cohort_ids or [None])[0]
async def _runtime_tables_ready(conn) -> bool:
row = await conn.fetchrow(
"""
SELECT
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app'
AND table_name = 'app_user'
AND column_name IN ('affiliation', 'last_seen_at', 'updated_at')
GROUP BY table_schema, table_name
HAVING count(*) = 3
) AS has_user_columns,
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 = 'sessions'
AND column_name IN (
'runtime_case_id',
'persona_code',
'persona_display_name',
'persona_difficulty',
'prev_rapport_credit'
)
GROUP BY table_schema, table_name
HAVING count(*) = 5
) AS has_session_columns,
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app'
AND table_name = 'session_state'
AND column_name = 'turns_in_stage'
) AS has_state_columns,
(
SELECT count(*) = 4
FROM app.stage_def
WHERE stage_code IN ('라포','탐색','개입','정리')
) AS has_stage_defs,
EXISTS (
SELECT 1 FROM pg_policies
WHERE schemaname = 'app'
AND tablename = 'sessions'
AND policyname IN ('p_sessions_insert','p_sessions_update','p_sessions_delete')
GROUP BY schemaname, tablename
HAVING count(*) = 3
) AS has_session_write_policies,
NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE schemaname = 'app'
AND tablename = 'sessions'
AND policyname = 'p_sessions_modify'
) AS removed_old_session_policy,
EXISTS (
SELECT 1 FROM pg_policies
WHERE schemaname = 'app'
AND tablename = 'turns'
AND policyname IN ('p_turns_insert','p_turns_update','p_turns_delete')
GROUP BY schemaname, tablename
HAVING count(*) = 3
) AS has_turn_write_policies,
NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE schemaname = 'app'
AND tablename = 'turns'
AND policyname = 'p_turns_modify'
) AS removed_old_turn_policy
"""
)
return bool(
row
and row["has_user_columns"]
and row["has_auth_session"]
and row["has_preferences"]
and row["has_engine_config"]
and row["has_session_columns"]
and row["has_state_columns"]
and row["has_stage_defs"]
and row["has_session_write_policies"]
and row["removed_old_session_policy"]
and row["has_turn_write_policies"]
and row["removed_old_turn_policy"]
)
async def ensure_runtime_tables() -> None:
"""Ensure DB-backed auth/user runtime tables exist when a pool is available."""
pool = get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app'
AND table_name = 'app_user'
AND column_name = 'affiliation'
) THEN
ALTER TABLE app.app_user ALTER COLUMN affiliation SET DEFAULT '';
END IF;
END $$;
"""
)
if await _runtime_tables_ready(conn):
return
await conn.execute(
"""
ALTER TABLE app.app_user
ADD COLUMN IF NOT EXISTS affiliation TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
"""
)
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS app.auth_session (
sid_hash TEXT PRIMARY KEY,
user_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE CASCADE,
role TEXT NOT NULL,
display_name TEXT NOT NULL,
cohort_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""
)
await conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_auth_session_user_active
ON app.auth_session(user_id, expires_at)
WHERE revoked_at IS NULL
"""
)
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS app.user_preferences (
user_id UUID PRIMARY KEY REFERENCES app.app_user(user_id) ON DELETE CASCADE,
theme TEXT NOT NULL DEFAULT 'system',
voice_preset_id TEXT NOT NULL DEFAULT 'soft-young-fem',
voice_rate REAL NOT NULL DEFAULT 1.0,
notifications JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""
)
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS app.admin_engine_config (
id BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (id),
engine_mode TEXT NOT NULL,
engine_url TEXT NOT NULL,
model TEXT NOT NULL,
updated_by TEXT,
updated_at TIMESTAMPTZ
)
"""
)
await conn.execute(
"""
ALTER TABLE app.sessions
ADD COLUMN IF NOT EXISTS runtime_case_id UUID,
ADD COLUMN IF NOT EXISTS persona_code TEXT,
ADD COLUMN IF NOT EXISTS persona_display_name TEXT,
ADD COLUMN IF NOT EXISTS persona_difficulty TEXT,
ADD COLUMN IF NOT EXISTS prev_rapport_credit REAL NOT NULL DEFAULT 0.0
"""
)
await conn.execute(
"""
ALTER TABLE app.session_state
ADD COLUMN IF NOT EXISTS turns_in_stage INT NOT NULL DEFAULT 0
"""
)
await conn.execute(
"""
INSERT INTO app.stage_def (stage_code, display_name, seq, base_openness)
VALUES
('라포', '라포', 1, 0.15),
('탐색', '탐색', 2, 0.35),
('개입', '개입', 3, 0.55),
('정리', '정리', 4, 0.45)
ON CONFLICT (stage_code) DO UPDATE SET
display_name = EXCLUDED.display_name,
seq = EXCLUDED.seq,
base_openness = EXCLUDED.base_openness
"""
)
await conn.execute(
"""
DROP POLICY IF EXISTS p_sessions_modify ON app.sessions;
DROP POLICY IF EXISTS p_sessions_insert ON app.sessions;
DROP POLICY IF EXISTS p_sessions_update ON app.sessions;
DROP POLICY IF EXISTS p_sessions_delete ON app.sessions;
CREATE POLICY p_sessions_insert ON app.sessions FOR INSERT WITH CHECK (
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
OR learner_id = app.current_uid()
);
CREATE POLICY p_sessions_update ON app.sessions FOR UPDATE USING (
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
OR learner_id = app.current_uid()
) WITH CHECK (
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
OR learner_id = app.current_uid()
);
CREATE POLICY p_sessions_delete ON app.sessions FOR DELETE USING (
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
OR learner_id = app.current_uid()
)
"""
)
await conn.execute(
"""
DROP POLICY IF EXISTS p_turns_modify ON app.turns;
DROP POLICY IF EXISTS p_turns_insert ON app.turns;
DROP POLICY IF EXISTS p_turns_update ON app.turns;
DROP POLICY IF EXISTS p_turns_delete ON app.turns;
CREATE POLICY p_turns_insert ON app.turns FOR INSERT WITH CHECK (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
OR EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.turns.session_id
AND s.learner_id = app.current_uid()
)
);
CREATE POLICY p_turns_update ON app.turns FOR UPDATE USING (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
OR EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.turns.session_id
AND s.learner_id = app.current_uid()
)
) WITH CHECK (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
OR EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.turns.session_id
AND s.learner_id = app.current_uid()
)
);
CREATE POLICY p_turns_delete ON app.turns FOR DELETE USING (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
OR EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.turns.session_id
AND s.learner_id = app.current_uid()
)
)
"""
)
def _managed_user_from_row(row) -> ManagedUser:
return ManagedUser(
user_id=str(row["user_id"]),
email=row["email"],
display_name=row["display_name"] or row["email"],
role=_app_role(row["role"]),
cohort_ids=_cohort_ids(row.get("cohort") if hasattr(row, "get") else row["cohort"]),
affiliation=row["affiliation"] or DEFAULT_AFFILIATION,
created_at=_ts(row["created_at"]),
last_seen_at=_ts(row["last_seen_at"]),
)
def _memory_upsert_managed_user(
*,
email: str,
display_name: str,
role: str,
cohort_ids: list[str] | None = None,
user_id: str | None = None,
affiliation: str | None = None,
reactivate: bool = False,
) -> ManagedUser:
now = time.time()
normalized_email = _normalize_email(email)
if normalized_email in _inactive_emails and not reactivate:
raise InactiveUserError("user is inactive")
if reactivate:
_inactive_emails.discard(normalized_email)
uid = user_id or _email_index.get(normalized_email) or user_id_from_email(normalized_email)
current = _users.get(uid)
user = ManagedUser(
user_id=uid,
email=normalized_email,
display_name=(display_name.strip() if display_name else "") or normalized_email,
role=role,
cohort_ids=list(cohort_ids or current.cohort_ids if current else cohort_ids or []),
affiliation=(
affiliation.strip()
if affiliation
else (current.affiliation if current else DEFAULT_AFFILIATION)
),
created_at=current.created_at if current else now,
last_seen_at=now,
)
_users[uid] = user
_email_index[normalized_email] = uid
return user
async def upsert_managed_user(
*,
email: str,
display_name: str,
role: str,
cohort_ids: list[str] | None = None,
user_id: str | None = None,
affiliation: str | None = None,
reactivate: bool = False,
) -> ManagedUser:
normalized_email = _normalize_email(email)
try:
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO app.app_user (
external_id, email, display_name, role, cohort, affiliation, last_seen_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, 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,
cohort = COALESCE(EXCLUDED.cohort, app.app_user.cohort),
affiliation = COALESCE(NULLIF(EXCLUDED.affiliation, ''), app.app_user.affiliation),
is_active = CASE WHEN $7 THEN TRUE ELSE app.app_user.is_active END,
last_seen_at = now(),
updated_at = now()
WHERE app.app_user.is_active OR $7
RETURNING user_id, email, display_name, role, cohort, affiliation, created_at, last_seen_at
""",
f"email:{normalized_email}",
normalized_email,
(display_name.strip() if display_name else normalized_email),
_db_role(role),
_cohort_value(cohort_ids),
affiliation or DEFAULT_AFFILIATION,
reactivate,
)
if row is None:
_inactive_emails.add(normalized_email)
raise InactiveUserError("user is inactive")
user = _managed_user_from_row(row)
_memory_upsert_managed_user(
email=user.email,
display_name=user.display_name,
role=user.role,
cohort_ids=user.cohort_ids,
user_id=user.user_id,
affiliation=user.affiliation,
reactivate=True,
)
return user
except InactiveUserError:
raise
except Exception:
require_runtime_fallback_allowed("managed user")
return _memory_upsert_managed_user(
email=normalized_email,
display_name=display_name,
role=role,
cohort_ids=cohort_ids,
user_id=user_id,
affiliation=affiliation,
reactivate=reactivate,
)
async def get_managed_user(user_id: str) -> ManagedUser | None:
try:
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT user_id, email, display_name, role, cohort, affiliation, created_at, last_seen_at
FROM app.app_user
WHERE user_id = $1::uuid AND is_active
""",
user_id,
)
if row is not None:
return _managed_user_from_row(row)
except Exception:
require_runtime_fallback_allowed("managed user")
if not runtime_fallback_allowed():
return None
return _users.get(user_id)
async def list_managed_users() -> tuple[list[ManagedUser], bool]:
try:
pool = get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT user_id, email, display_name, role, cohort, affiliation, created_at, last_seen_at
FROM app.app_user
WHERE is_active
ORDER BY last_seen_at DESC
"""
)
return [_managed_user_from_row(row) for row in rows], True
except Exception:
require_runtime_fallback_allowed("managed user list")
return sorted(_users.values(), key=lambda u: u.last_seen_at, reverse=True), False
async def update_managed_user(
user_id: str,
*,
display_name: str | None = None,
role: RoleName | None = None,
affiliation: str | None = None,
cohort_ids: list[str] | None = None,
) -> ManagedUser | None:
try:
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
UPDATE app.app_user SET
display_name = COALESCE($2, display_name),
role = COALESCE($3, role),
cohort = CASE WHEN $4 THEN $5 ELSE cohort END,
affiliation = COALESCE($6, affiliation),
updated_at = now(),
last_seen_at = now()
WHERE user_id = $1::uuid AND is_active
RETURNING user_id, email, display_name, role, cohort, affiliation, created_at, last_seen_at
""",
user_id,
display_name.strip() if display_name is not None else None,
_db_role(role) if role is not None else None,
cohort_ids is not None,
_cohort_value(cohort_ids),
affiliation.strip() if affiliation is not None else None,
)
if row is not None:
next_user = _managed_user_from_row(row)
_memory_upsert_managed_user(
email=next_user.email,
display_name=next_user.display_name,
role=next_user.role,
cohort_ids=next_user.cohort_ids,
user_id=next_user.user_id,
affiliation=next_user.affiliation,
)
for session in _sessions.values():
if session.user_id == user_id:
session.display_name = next_user.display_name
session.role = next_user.role
session.cohort_ids = list(next_user.cohort_ids)
return next_user
except Exception:
require_runtime_fallback_allowed("managed user update")
if not runtime_fallback_allowed():
return None
current = _users.get(user_id)
if current is None:
return None
next_user = ManagedUser(
user_id=current.user_id,
email=current.email,
display_name=display_name.strip() if display_name is not None else current.display_name,
role=role if role is not None else current.role,
cohort_ids=list(cohort_ids) if cohort_ids is not None else current.cohort_ids,
affiliation=affiliation.strip() if affiliation is not None else current.affiliation,
created_at=current.created_at,
last_seen_at=time.time(),
)
_users[user_id] = next_user
for session in _sessions.values():
if session.user_id == user_id:
session.display_name = next_user.display_name
session.role = next_user.role
session.cohort_ids = list(next_user.cohort_ids)
return next_user
async def deactivate_managed_user(user_id: str) -> bool:
"""Deactivate a managed user and revoke their active browser sessions."""
changed = False
inactive_email: str | None = None
try:
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT email FROM app.app_user WHERE user_id = $1::uuid",
user_id,
)
if row is not None and row["email"]:
inactive_email = _normalize_email(row["email"])
result = await conn.execute(
"""
UPDATE app.app_user SET
is_active = FALSE,
updated_at = now(),
last_seen_at = now()
WHERE user_id = $1::uuid AND is_active
""",
user_id,
)
changed = result.endswith(" 1")
await conn.execute(
"UPDATE app.auth_session SET revoked_at = now() WHERE user_id = $1::uuid",
user_id,
)
except Exception:
require_runtime_fallback_allowed("managed user deactivation")
if not runtime_fallback_allowed():
return changed
memory_user = _users.pop(user_id, None)
if memory_user is not None:
inactive_email = _normalize_email(memory_user.email)
if inactive_email:
_inactive_emails.add(inactive_email)
for email, uid in list(_email_index.items()):
if uid == user_id:
_email_index.pop(email, None)
for sid_hash, session in list(_sessions.items()):
if session.user_id == user_id:
_sessions.pop(sid_hash, None)
return changed or memory_user is not None
async def active_session_count(user_id: str) -> int:
try:
pool = get_pool()
async with pool.acquire() as conn:
return int(
await conn.fetchval(
"""
SELECT count(*)
FROM app.auth_session
WHERE user_id = $1::uuid
AND revoked_at IS NULL
AND expires_at > now()
""",
user_id,
)
or 0
)
except Exception:
require_runtime_fallback_allowed("auth session count")
now = time.time()
count = 0
for session in _sessions.values():
if session.user_id == user_id and session.expires_at > now:
count += 1
return count
async def create_session(
*,
email: str,
display_name: str,
role: str,
cohort_ids: list[str] | None = None,
user_id: str | None = None,
) -> tuple[str, SessionUser]:
raw_sid = secrets.token_urlsafe(32)
normalized_email = _normalize_email(email)
managed = await upsert_managed_user(
email=normalized_email,
display_name=display_name,
role=role,
cohort_ids=cohort_ids,
user_id=user_id,
reactivate=False,
)
expires_at = time.time() + settings.session_ttl_seconds
user = SessionUser(
user_id=managed.user_id,
email=normalized_email,
display_name=managed.display_name,
role=managed.role,
cohort_ids=list(managed.cohort_ids),
expires_at=expires_at,
)
sid_hash = _sid_hash(raw_sid)
try:
pool = get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO app.auth_session (
sid_hash, user_id, role, display_name, cohort_ids, expires_at, last_seen_at
)
VALUES ($1, $2::uuid, $3, $4, $5::jsonb, $6, now())
ON CONFLICT (sid_hash) DO UPDATE SET
user_id = EXCLUDED.user_id,
role = EXCLUDED.role,
display_name = EXCLUDED.display_name,
cohort_ids = EXCLUDED.cohort_ids,
expires_at = EXCLUDED.expires_at,
revoked_at = NULL,
last_seen_at = now()
""",
sid_hash,
managed.user_id,
managed.role,
managed.display_name,
list(managed.cohort_ids),
datetime.fromtimestamp(expires_at, tz=timezone.utc),
)
except Exception:
require_runtime_fallback_allowed("browser session")
_sessions[sid_hash] = user
return raw_sid, user
async def get_session(raw_sid: str | None) -> SessionUser | None:
if not raw_sid:
return None
key = _sid_hash(raw_sid)
try:
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT
s.expires_at,
u.user_id,
u.email,
COALESCE(u.display_name, s.display_name, u.email) AS display_name,
u.role,
u.cohort
FROM app.auth_session s
JOIN app.app_user u ON u.user_id = s.user_id
WHERE s.sid_hash = $1
AND s.revoked_at IS NULL
AND s.expires_at > now()
AND u.is_active
""",
key,
)
if row is not None:
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",
row["user_id"],
)
return SessionUser(
user_id=str(row["user_id"]),
email=row["email"],
display_name=row["display_name"],
role=_app_role(row["role"]),
cohort_ids=_cohort_ids(row["cohort"]),
expires_at=_ts(row["expires_at"]),
)
except Exception:
require_runtime_fallback_allowed("browser session")
if not runtime_fallback_allowed():
return None
user = _sessions.get(key)
if user is None:
return None
if user.expires_at <= time.time():
_sessions.pop(key, None)
return None
managed = _users.get(user.user_id)
if managed is not None:
managed.last_seen_at = time.time()
return user
async def revoke_session(raw_sid: str | None) -> None:
if not raw_sid:
return
key = _sid_hash(raw_sid)
_sessions.pop(key, None)
try:
pool = get_pool()
async with pool.acquire() as conn:
await conn.execute(
"UPDATE app.auth_session SET revoked_at = now() WHERE sid_hash = $1",
key,
)
except Exception:
require_runtime_fallback_allowed("browser session revoke")

View file

@ -8,8 +8,9 @@ from __future__ import annotations
from functools import lru_cache from functools import lru_cache
from typing import Literal from typing import Literal
from urllib.parse import urlsplit
from pydantic import Field from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
# 엔진 어댑터 provider 플래그 (마스터플랜 §0, R1: claude -p 과금누수 회피) # 엔진 어댑터 provider 플래그 (마스터플랜 §0, R1: claude -p 과금누수 회피)
@ -20,12 +21,19 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
EngineMode = Literal["claude_api", "claude_cli", "openai", "solar"] EngineMode = Literal["claude_api", "claude_cli", "openai", "solar"]
def _is_local_url(value: str) -> bool:
parsed = urlsplit(value)
host = (parsed.hostname or "").lower()
return host in {"localhost", "127.0.0.1", "::1"}
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",
env_file_encoding="utf-8", env_file_encoding="utf-8",
extra="ignore", extra="ignore",
case_sensitive=False, case_sensitive=False,
populate_by_name=True,
) )
# ── 앱 ─────────────────────────────────────────────── # ── 앱 ───────────────────────────────────────────────
@ -59,6 +67,10 @@ class Settings(BaseSettings):
# ── 외부 LLM 키 (게이트웨이가 못 받을 때 직접 폴백, PII 마스킹 후만) ── # ── 외부 LLM 키 (게이트웨이가 못 받을 때 직접 폴백, PII 마스킹 후만) ──
anthropic_api_key: str = Field(default="", validation_alias="ANTHROPIC_API_KEY") anthropic_api_key: str = Field(default="", validation_alias="ANTHROPIC_API_KEY")
openai_api_key: str = Field(default="", validation_alias="OPENAI_API_KEY") openai_api_key: str = Field(default="", validation_alias="OPENAI_API_KEY")
openai_base_url: str = Field(
default="https://api.openai.com/v1",
validation_alias="OPENAI_BASE_URL",
)
# ── 세션/인증 (BFF OAuth 2.1, 토큰 서버 보관) ──────── # ── 세션/인증 (BFF OAuth 2.1, 토큰 서버 보관) ────────
session_secret: str = Field( session_secret: str = Field(
@ -75,16 +87,54 @@ class Settings(BaseSettings):
default="", validation_alias="OAUTH_GOOGLE_CLIENT_SECRET" default="", validation_alias="OAUTH_GOOGLE_CLIENT_SECRET"
) )
oauth_redirect_uri: str = Field( oauth_redirect_uri: str = Field(
default="https://chanpaca.net/auth/callback", default="https://api-vignette.chanpaca.net/auth/callback",
validation_alias="OAUTH_REDIRECT_URI", validation_alias="OAUTH_REDIRECT_URI",
) )
auth_allowed_email_domains: list[str] = Field(
default=["hs.ac.kr", "twentyoz.kr"],
validation_alias="AUTH_ALLOWED_EMAIL_DOMAINS",
)
auth_teacher_emails: list[str] = Field(
default=[],
validation_alias="AUTH_TEACHER_EMAILS",
)
auth_admin_emails: list[str] = Field(
default=[],
validation_alias="AUTH_ADMIN_EMAILS",
)
auth_dev_login_enabled: bool = Field(
default=False,
validation_alias="AUTH_DEV_LOGIN_ENABLED",
)
default_affiliation: str = Field(
default="",
validation_alias="DEFAULT_AFFILIATION",
)
frontend_base_url: str = Field(
default="http://localhost:5173",
validation_alias="FRONTEND_BASE_URL",
)
# ── CORS (정적 프론트 + SSE 분리경로) ──────────────── # ── CORS (정적 프론트 + SSE 분리경로) ────────────────
cors_origins: list[str] = Field( cors_origins: list[str] = Field(
default=["https://chanpaca.net", "https://stream.chanpaca.net", "http://localhost:5173"], default=[
"https://vignette.chanpaca.net",
"https://vignette-b1q.pages.dev",
],
validation_alias="CORS_ORIGINS", validation_alias="CORS_ORIGINS",
) )
# Built-in personas are developer/bootstrap fixtures, not runtime truth.
# Production should use approved rows from app.persona_card only.
auto_seed_personas: bool = Field(
default=False,
validation_alias="AUTO_SEED_PERSONAS",
)
allow_seed_persona_fallback: bool = Field(
default=False,
validation_alias="ALLOW_SEED_PERSONA_FALLBACK",
)
# ── SSE 스트리밍 ───────────────────────────────────── # ── SSE 스트리밍 ─────────────────────────────────────
sse_heartbeat_seconds: int = 30 # Cloudflare 100초 timeout 회피 (R2) sse_heartbeat_seconds: int = 30 # Cloudflare 100초 timeout 회피 (R2)
@ -92,6 +142,31 @@ class Settings(BaseSettings):
def is_prod(self) -> bool: def is_prod(self) -> bool:
return self.environment == "prod" return self.environment == "prod"
@model_validator(mode="after")
def validate_non_dev_runtime_flags(self) -> "Settings":
if self.environment != "dev":
forbidden: list[str] = []
if self.auth_dev_login_enabled:
forbidden.append("AUTH_DEV_LOGIN_ENABLED")
if self.auto_seed_personas:
forbidden.append("AUTO_SEED_PERSONAS")
if self.allow_seed_persona_fallback:
forbidden.append("ALLOW_SEED_PERSONA_FALLBACK")
if not self.oauth_google_client_id.strip():
forbidden.append("OAUTH_GOOGLE_CLIENT_ID")
if not self.oauth_google_client_secret.strip():
forbidden.append("OAUTH_GOOGLE_CLIENT_SECRET")
if self.session_secret == "dev-insecure-change-me":
forbidden.append("SESSION_SECRET")
if _is_local_url(self.frontend_base_url):
forbidden.append("FRONTEND_BASE_URL")
if any(_is_local_url(origin) for origin in self.cors_origins):
forbidden.append("CORS_ORIGINS")
if forbidden:
joined = ", ".join(forbidden)
raise ValueError(f"{joined} must be production-safe when ENVIRONMENT={self.environment}")
return self
@lru_cache @lru_cache
def get_settings() -> Settings: def get_settings() -> Settings:

View file

@ -1,7 +1,8 @@
"""asyncpg 연결 풀 + pgvector 등록. """asyncpg 연결 풀 + pgvector 등록.
DB = NAS PostgreSQL 16 단일 SoR (마스터플랜 §0). 스키마 4분할: app / kb / audit / ds. DB = NAS PostgreSQL 16 단일 SoR (마스터플랜 §0). 스키마 4분할: app / kb / audit / ds.
RLS 이중강제: 커넥션 획득 SET LOCAL app.current_role / app.current_ai_view 주입 RLS 이중강제: 커넥션 획득 SET LOCAL app.current_role / app.current_uid /
app.current_cohort / app.ai_context / app.current_ai_view / app.current_sens_max 주입
(deps.py RBAC 의존성과 ). 여기선 + 헬퍼만 제공한다. (deps.py RBAC 의존성과 ). 여기선 + 헬퍼만 제공한다.
""" """
@ -9,7 +10,7 @@ from __future__ import annotations
import json import json
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Any, AsyncIterator, Optional from typing import Any, AsyncIterator, Optional, Sequence
import asyncpg import asyncpg
@ -70,17 +71,44 @@ def get_pool() -> asyncpg.Pool:
return _pool return _pool
def _db_role(role: str) -> str:
return "instructor" if role == "teacher" else role
def _default_sensitivity_max(ai_view: str | None) -> int | None:
return {
"client": 1,
"counselor": 0,
"evaluator": 2,
}.get(ai_view or "")
def _cohort_value(cohort_ids: Sequence[str] | None, cohort: str | None) -> str:
if cohort:
return cohort
if not cohort_ids:
return ""
return cohort_ids[0] or ""
@asynccontextmanager @asynccontextmanager
async def acquire( async def acquire(
*, *,
role: Optional[str] = None, role: Optional[str] = None,
user_id: Optional[str] = None,
cohort_ids: Optional[Sequence[str]] = None,
cohort: Optional[str] = None,
ai_view: Optional[str] = None, ai_view: Optional[str] = None,
ai_context: Optional[bool] = None,
sensitivity_max: Optional[int] = None,
) -> AsyncIterator[asyncpg.Connection]: ) -> AsyncIterator[asyncpg.Connection]:
"""커넥션 획득 + RLS 컨텍스트 주입. """커넥션 획득 + RLS 컨텍스트 주입.
RLS 이중강제 (설계서 §4.1, F-30): RLS 이중강제 (설계서 §4.1, F-30):
레이어1 = AI 정보비대칭: app.current_ai_view (visible_to[] WHERE 강제) 레이어1 = AI 정보비대칭: app.ai_context + app.current_ai_view +
레이어2 = 인간 RBAC×cohort: app.current_role (RLS 정책) app.current_sens_max (visible_to[]/sensitivity WHERE 강제)
레이어2 = 인간 RBAC×cohort: app.current_role + app.current_uid +
app.current_cohort (RLS 정책)
트랜잭션 SET LOCAL 주입해 커넥션 재사용 누수 방지. 트랜잭션 SET LOCAL 주입해 커넥션 재사용 누수 방지.
NOTE: RLS 정책/세션변수는 Phase 0 마이그레이션에서 정의(설계서 §3.3 / §4). NOTE: RLS 정책/세션변수는 Phase 0 마이그레이션에서 정의(설계서 §3.3 / §4).
@ -89,20 +117,47 @@ async def acquire(
pool = get_pool() pool = get_pool()
async with pool.acquire() as conn: async with pool.acquire() as conn:
async with conn.transaction(): async with conn.transaction():
is_ai = ai_context if ai_context is not None else ai_view is not None
await conn.execute("SELECT set_config('app.ai_context', $1, true)", "1" if is_ai else "")
if role is not None: if role is not None:
await conn.execute("SELECT set_config('app.current_role', $1, true)", role) await conn.execute("SELECT set_config('app.current_role', $1, true)", _db_role(role))
if user_id is not None:
await conn.execute("SELECT set_config('app.current_uid', $1, true)", user_id)
cohort_name = _cohort_value(cohort_ids, cohort)
if cohort_name:
await conn.execute("SELECT set_config('app.current_cohort', $1, true)", cohort_name)
if ai_view is not None: if ai_view is not None:
await conn.execute("SELECT set_config('app.current_ai_view', $1, true)", ai_view) await conn.execute("SELECT set_config('app.current_ai_view', $1, true)", ai_view)
sens = sensitivity_max if sensitivity_max is not None else _default_sensitivity_max(ai_view)
if sens is not None:
await conn.execute(
"SELECT set_config('app.current_sens_max', $1, true)",
str(sens),
)
yield conn yield conn
async def healthcheck() -> bool: async def healthcheck() -> bool:
"""SELECT 1 핑. /health 에서 사용.""" """Return true only when the DB is reachable and required app tables exist."""
try: try:
pool = get_pool() pool = get_pool()
async with pool.acquire() as conn: async with pool.acquire() as conn:
val = await conn.fetchval("SELECT 1") row = await conn.fetchrow(
return val == 1 """
SELECT
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
"""
)
return bool(
row
and row["has_user"]
and row["has_auth_session"]
and row["has_preferences"]
and row["has_engine_config"]
)
except Exception: except Exception:
return False return False

View file

@ -1,12 +1,4 @@
"""의존성 — RBAC × visible_to 정보비대칭 게이트. """FastAPI dependencies for authentication, RBAC, and RLS context."""
2-레이어 강제 (설계서 §4.1, F-30):
레이어1 = AI 정보비대칭: current_ai_view (CLIENT_AI 분기엔 CCD/정답 로드 코드경로 자체 부재)
레이어2 = 인간 RBAC×cohort: current_role (RLS DB 레벨 방어선)
인간이 turn 읽을 게이트 AND. 여기선 요청 컨텍스트 추출 + DB 세션변수 주입 계약만 제공.
NOTE: 실제 세션/쿠키 검증은 auth.py BFF + Redis 세션 구현 완성(현재 스텁).
"""
from __future__ import annotations from __future__ import annotations
@ -16,36 +8,39 @@ from typing import Annotated, AsyncIterator, Optional
import asyncpg import asyncpg
from fastapi import Cookie, Depends, HTTPException, status from fastapi import Cookie, Depends, HTTPException, status
from .auth_sessions import get_session
from .config import Settings, get_settings from .config import Settings, get_settings
from .db import acquire from .db import acquire
# ── 인간 역할 (RBAC) ────────────────────────────────────
class Role(str, Enum): class Role(str, Enum):
LEARNER = "learner" # 본인 세션만 (/learn) LEARNER = "learner"
TEACHER = "teacher" # 담당 코호트 전체 열람+검수 (/teach) TEACHER = "teacher"
ADMIN = "admin" # 전부 + 교수활동 감사 (/admin) ADMIN = "admin"
# ── AI 뷰 (정보비대칭, current_ai_view enum) ────────────
class AIView(str, Enum): class AIView(str, Enum):
CLIENT = "client" # 가상내담자 AI — CCD/정답/점수 절대 비노출 CLIENT = "client"
COUNSELOR = "counselor" # 상담사 AI(보조) — 표면 대화만, DSM 차단 COUNSELOR = "counselor"
EVALUATOR = "evaluator" # 평가 AI — 전부 봄 (학습자엔 비노출) EVALUATOR = "evaluator"
class Principal: class Principal:
"""인증된 요청 주체. 인간 role + (선택) cohort 범위.""" """Authenticated human principal."""
def __init__( def __init__(
self, self,
user_id: str, user_id: str,
role: Role, role: Role,
cohort_ids: Optional[list[str]] = None, cohort_ids: Optional[list[str]] = None,
email: str = "",
display_name: str = "",
) -> None: ) -> None:
self.user_id = user_id self.user_id = user_id
self.role = role self.role = role
self.cohort_ids = cohort_ids or [] self.cohort_ids = cohort_ids or []
self.email = email
self.display_name = display_name
def get_settings_dep() -> Settings: def get_settings_dep() -> Settings:
@ -53,28 +48,37 @@ def get_settings_dep() -> Settings:
async def get_current_principal( async def get_current_principal(
# __Host- HttpOnly 쿠키 (config.cookie_name). 브라우저엔 토큰 미노출.
session_cookie: Annotated[Optional[str], Cookie(alias="__Host-vignette_sid")] = None, session_cookie: Annotated[Optional[str], Cookie(alias="__Host-vignette_sid")] = None,
dev_session_cookie: Annotated[Optional[str], Cookie(alias="vignette_sid")] = None,
) -> Principal: ) -> Principal:
"""세션 쿠키 -> Principal. """Restore the principal from the opaque HttpOnly browser session cookie."""
raw_cookie = session_cookie or (dev_session_cookie if get_settings().environment == "dev" else None)
TODO(auth.py 완성 ): Redis 세션 조회로 user_id/role/cohort 복원. session = await get_session(raw_cookie)
현재 스텁: 쿠키 없으면 401, 있으면 LEARNER 더미(개발용). if session is None:
prod 에선 session_cookie 검증 실패 무조건 401.
"""
if not session_cookie:
# dev 환경에선 쿠키 없어도 더미 학습자로 통과(로컬 라이브 테스트). prod 는 무조건 401.
if get_settings().environment != "dev":
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="not authenticated", detail="not authenticated",
) )
# TODO: Redis 세션 룩업. 아래는 개발 스텁.
return Principal(user_id="dev-user", role=Role.LEARNER, cohort_ids=[]) try:
role = Role(session.role)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid session role",
) from exc
return Principal(
user_id=session.user_id,
role=role,
cohort_ids=session.cohort_ids,
email=session.email,
display_name=session.display_name,
)
def require_role(*allowed: Role): def require_role(*allowed: Role):
"""역할 화이트리스트 의존성 팩토리. 예: Depends(require_role(Role.TEACHER, Role.ADMIN)).""" """Role allowlist dependency factory."""
async def _checker( async def _checker(
principal: Annotated[Principal, Depends(get_current_principal)], principal: Annotated[Principal, Depends(get_current_principal)],
@ -92,30 +96,24 @@ def require_role(*allowed: Role):
async def db_for_human( async def db_for_human(
principal: Annotated[Principal, Depends(get_current_principal)], principal: Annotated[Principal, Depends(get_current_principal)],
) -> AsyncIterator[asyncpg.Connection]: ) -> AsyncIterator[asyncpg.Connection]:
"""인간 요청용 RLS 컨텍스트 커넥션 (레이어2 강제). """Acquire a DB connection with the human RBAC context attached."""
async with acquire(
app.current_role 주입 -> RLS 정책이 코호트/소유권 필터. role=principal.role.value,
라우트에서: conn: Annotated[asyncpg.Connection, Depends(db_for_human)] user_id=principal.user_id,
""" cohort_ids=principal.cohort_ids,
async with acquire(role=principal.role.value) as conn: ) as conn:
# cohort 스코프는 RLS 정책이 current_role + 소유 테이블로 강제 (설계서 §4).
yield conn yield conn
def db_for_ai_view(view: AIView): def db_for_ai_view(view: AIView):
"""AI 역할용 RLS 컨텍스트 (레이어1 강제) 의존성 팩토리. """Dependency factory for AI-side RLS visibility context."""
app.current_ai_view 주입 -> visible_to[] WHERE 강제.
CLIENT 분기는 ccd/정답 로드 함수 자체를 부르지 않음(코드경로 부재 1차방어).
"""
async def _provider() -> AsyncIterator[asyncpg.Connection]: async def _provider() -> AsyncIterator[asyncpg.Connection]:
async with acquire(ai_view=view.value) as conn: async with acquire(ai_view=view.value, ai_context=True) as conn:
yield conn yield conn
return _provider return _provider
# 타입 별칭 (라우트 시그니처 간결화)
CurrentPrincipal = Annotated[Principal, Depends(get_current_principal)] CurrentPrincipal = Annotated[Principal, Depends(get_current_principal)]
HumanDB = Annotated[asyncpg.Connection, Depends(db_for_human)] HumanDB = Annotated[asyncpg.Connection, Depends(db_for_human)]

View file

@ -15,6 +15,7 @@ text 는 *PII 마스킹 후(text_masked)* 만 보낸다 (R7/F-03, 마스킹은
from __future__ import annotations from __future__ import annotations
import asyncio
from typing import Any, AsyncIterator, Literal, Optional from typing import Any, AsyncIterator, Literal, Optional
import httpx import httpx
@ -72,10 +73,18 @@ class EngineClient:
def __init__(self, base_url: Optional[str] = None) -> None: def __init__(self, base_url: Optional[str] = None) -> None:
self.base_url = (base_url or settings.engine_url).rstrip("/") self.base_url = (base_url or settings.engine_url).rstrip("/")
self.engine_mode = settings.engine_mode
self.default_model: Optional[str] = None
self._client: Optional[httpx.AsyncClient] = None self._client: Optional[httpx.AsyncClient] = None
self._lock = asyncio.Lock()
async def startup(self) -> None: async def startup(self) -> None:
self._client = httpx.AsyncClient( async with self._lock:
if self._client is None:
self._client = self._new_client()
def _new_client(self) -> httpx.AsyncClient:
return httpx.AsyncClient(
base_url=self.base_url, base_url=self.base_url,
timeout=httpx.Timeout( timeout=httpx.Timeout(
settings.engine_timeout, settings.engine_timeout,
@ -84,10 +93,43 @@ class EngineClient:
) )
async def shutdown(self) -> None: async def shutdown(self) -> None:
async with self._lock:
if self._client is not None: if self._client is not None:
await self._client.aclose() await self._client.aclose()
self._client = None self._client = None
@staticmethod
def _model_override(model: Optional[str]) -> Optional[str]:
value = (model or "").strip()
if not value or value == "gateway-default":
return None
return value
async def configure(
self,
*,
base_url: str,
engine_mode: str,
default_model: Optional[str] = None,
) -> None:
next_url = base_url.rstrip("/")
next_model = self._model_override(default_model)
async with self._lock:
url_changed = next_url != self.base_url
self.base_url = next_url
self.engine_mode = engine_mode
self.default_model = next_model
if self._client is not None and url_changed:
old_client = self._client
self._client = self._new_client()
await old_client.aclose()
def _payload(self, req: GenerateRequest) -> dict[str, Any]:
payload = req.model_dump(exclude_none=True)
if self.default_model and "model" not in payload:
payload["model"] = self.default_model
return payload
@property @property
def client(self) -> httpx.AsyncClient: def client(self) -> httpx.AsyncClient:
if self._client is None: if self._client is None:
@ -95,16 +137,40 @@ class EngineClient:
return self._client return self._client
async def health(self) -> bool: async def health(self) -> bool:
return bool((await self.health_detail()).get("ok"))
async def health_detail(self) -> dict[str, Any]:
try: try:
r = await self.client.get("/health") r = await self.client.get("/ready")
return r.status_code == 200 if r.status_code == 404:
except httpx.HTTPError: live = await self.client.get("/health")
return False return {
"ok": live.status_code == 200,
"detail": "gateway liveness only; readiness endpoint unavailable",
"status_code": live.status_code,
}
payload: dict[str, Any] = {}
try:
payload = r.json()
except ValueError:
payload = {}
return {
"ok": r.status_code == 200 and bool(payload.get("ok", False)),
"detail": str(payload.get("detail") or r.text or "engine readiness failed"),
"status_code": r.status_code,
"cached": bool(payload.get("cached", False)),
}
except httpx.HTTPError as exc:
return {
"ok": False,
"detail": f"engine readiness transport error: {exc}",
"status_code": None,
}
async def generate(self, req: GenerateRequest) -> GenerateResponse: async def generate(self, req: GenerateRequest) -> GenerateResponse:
"""단발 생성. TODO: 게이트웨이 응답 스키마 확정 후 cost 텔레메트리 turns 적재.""" """단발 생성. TODO: 게이트웨이 응답 스키마 확정 후 cost 텔레메트리 turns 적재."""
try: try:
r = await self.client.post("/v1/generate", json=req.model_dump(exclude_none=True)) r = await self.client.post("/v1/generate", json=self._payload(req))
r.raise_for_status() r.raise_for_status()
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
raise EngineError(f"engine generate {e.response.status_code}: {e.response.text}") from e raise EngineError(f"engine generate {e.response.status_code}: {e.response.text}") from e
@ -121,7 +187,7 @@ class EngineClient:
""" """
try: try:
async with self.client.stream( async with self.client.stream(
"POST", "/v1/stream", json=req.model_dump(exclude_none=True) "POST", "/v1/stream", json=self._payload(req)
) as r: ) as r:
r.raise_for_status() r.raise_for_status()
async for line in r.aiter_lines(): async for line in r.aiter_lines():

View file

@ -13,14 +13,22 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from . import __version__ from . import __version__
from .auth_sessions import ensure_runtime_tables
from .config import settings from .config import settings
from .db import close_pool, healthcheck, init_pool from .db import close_pool, healthcheck, init_pool
from .engine_client import engine_client from .engine_client import engine_client
from .persona_repository import materialize_seed_personas
from .session_persistence import ensure_review_tables
from .routes import auth as auth_routes from .routes import auth as auth_routes
from .routes import admin as admin_routes
from .routes import eval as eval_routes from .routes import eval as eval_routes
from .routes import kb as kb_routes from .routes import kb as kb_routes
from .routes import personas as persona_routes
from .routes import sessions as session_routes from .routes import sessions as session_routes
from .routes import teacher as teacher_routes
from .routes import users as user_routes
from .routes import voice as voice_routes from .routes import voice as voice_routes
from .services.voice import voice_service
@asynccontextmanager @asynccontextmanager
@ -31,15 +39,24 @@ async def lifespan(app: FastAPI):
""" """
try: try:
await init_pool() await init_pool()
await ensure_runtime_tables()
await ensure_review_tables()
if settings.auto_seed_personas:
await materialize_seed_personas()
await admin_routes.apply_engine_config_from_store()
except Exception as exc: # DB 없어도 store 폴백으로 1턴 동작 (dev/로컬) except Exception as exc: # DB 없어도 store 폴백으로 1턴 동작 (dev/로컬)
if settings.environment != "dev":
raise
import logging import logging
logging.getLogger("uvicorn.error").warning( logging.getLogger("uvicorn.error").warning(
"DB 풀 초기화 실패 — store 인메모리 폴백으로 degraded 기동: %s", exc "DB 풀 초기화 실패 — store 인메모리 폴백으로 degraded 기동: %s", exc
) )
await engine_client.startup() await engine_client.startup()
await voice_service.startup()
try: try:
yield yield
finally: finally:
await voice_service.shutdown()
await engine_client.shutdown() await engine_client.shutdown()
try: try:
await close_pool() await close_pool()
@ -59,14 +76,18 @@ app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=settings.cors_origins, allow_origins=settings.cors_origins,
allow_credentials=True, # __Host- HttpOnly 쿠키 전송 allow_credentials=True, # __Host- HttpOnly 쿠키 전송
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["*"], allow_headers=["*"],
) )
# TODO: Presidio PII 마스킹 미들웨어 (외부 LLM 경로 진입 전 하드 게이트, R7/F-03) # TODO: Presidio PII 마스킹 미들웨어 (외부 LLM 경로 진입 전 하드 게이트, R7/F-03)
app.include_router(auth_routes.router) app.include_router(auth_routes.router)
app.include_router(admin_routes.router)
app.include_router(persona_routes.router)
app.include_router(session_routes.router) app.include_router(session_routes.router)
app.include_router(teacher_routes.router)
app.include_router(user_routes.router)
# Features 트랙 스텁 라우터(evaluator/voice/rag 가 채움). 등록만 — import 가능 보장. # Features 트랙 스텁 라우터(evaluator/voice/rag 가 채움). 등록만 — import 가능 보장.
app.include_router(eval_routes.router) app.include_router(eval_routes.router)
app.include_router(voice_routes.router) app.include_router(voice_routes.router)
@ -77,12 +98,14 @@ app.include_router(kb_routes.router)
async def health() -> dict[str, object]: async def health() -> dict[str, object]:
"""liveness + DB + 엔진 게이트웨이 readiness.""" """liveness + DB + 엔진 게이트웨이 readiness."""
db_ok = await healthcheck() db_ok = await healthcheck()
engine_ok = await engine_client.health() engine = await engine_client.health_detail()
engine_ok = bool(engine.get("ok"))
return { return {
"status": "ok" if db_ok else "degraded", "status": "ok" if db_ok and engine_ok else "degraded",
"version": __version__, "version": __version__,
"environment": settings.environment, "environment": settings.environment,
"db": db_ok, "db": db_ok,
"engine": engine_ok, "engine": engine_ok,
"engine_detail": engine.get("detail"),
"engine_mode": settings.engine_mode, "engine_mode": settings.engine_mode,
} }

View file

@ -0,0 +1,243 @@
"""DB-backed approved persona catalog.
The runtime still uses services.persona.PersonaCard as the in-process card
shape. This module is the boundary that materializes seed cards into
app.persona_card and converts approved DB rows back into PersonaCard values.
"""
from __future__ import annotations
import json
import uuid
from dataclasses import dataclass
from typing import Any, Iterable
from .config import settings
from .db import acquire, get_pool
from .services.persona import PersonaCard, SEED_PERSONAS, get_seed_persona
SEED_VERSION = 1
_CARD_COLUMNS = """
persona_id, code, version, status, display_name, difficulty, theory_target,
demographics, presenting, history, big5, resistance, speech_style,
affect_baseline, ccd, dsm5_dimensional, source_provenance, is_synthetic
"""
@dataclass(frozen=True, slots=True)
class CatalogPersona:
card: PersonaCard
persona_id: str | None
version: int | None
source: str
degraded: bool = False
def seed_persona_id(code: str) -> str:
return str(uuid.uuid5(uuid.NAMESPACE_URL, f"vignette:persona:{code.upper()}"))
def _json_dict(value: Any) -> dict[str, Any]:
if value is None:
return {}
if isinstance(value, str):
parsed = json.loads(value)
return dict(parsed) if isinstance(parsed, dict) else {}
return dict(value)
def _string_list(value: Iterable[Any] | None) -> list[str]:
if value is None:
return []
return [str(item) for item in value]
def card_from_row(row: Any) -> PersonaCard:
return PersonaCard(
code=str(row["code"]).upper(),
display_name=str(row["display_name"]),
difficulty=str(row["difficulty"]),
theory_target=_string_list(row["theory_target"]),
demographics=_json_dict(row["demographics"]),
presenting=_json_dict(row["presenting"]),
history=_json_dict(row["history"]),
big5=_json_dict(row["big5"]),
resistance=_json_dict(row["resistance"]),
speech_style=_json_dict(row["speech_style"]),
affect_baseline=_json_dict(row["affect_baseline"]),
ccd=_json_dict(row["ccd"]),
dsm5_dimensional=_json_dict(row["dsm5_dimensional"]),
source_provenance=str(row["source_provenance"] or ""),
is_synthetic=bool(row["is_synthetic"]),
)
def catalog_persona_from_row(row: Any) -> CatalogPersona:
return CatalogPersona(
card=card_from_row(row),
persona_id=str(row["persona_id"]),
version=int(row["version"]),
source="database",
degraded=False,
)
def seed_fallback_persona(code: str) -> CatalogPersona | None:
card = get_seed_persona(code)
if card is None:
return None
return CatalogPersona(
card=card,
persona_id=seed_persona_id(card.code),
version=SEED_VERSION,
source="seed_fallback",
degraded=True,
)
def seed_fallback_personas() -> list[CatalogPersona]:
return [
CatalogPersona(
card=card,
persona_id=seed_persona_id(card.code),
version=SEED_VERSION,
source="seed_fallback",
degraded=True,
)
for card in SEED_PERSONAS.values()
]
async def materialize_seed_personas() -> int:
"""Upsert built-in seed personas as approved DB catalog rows."""
get_pool()
count = 0
async with acquire(role="admin") as conn:
for card in SEED_PERSONAS.values():
await conn.execute(
"""
INSERT INTO app.persona_card (
persona_id, code, version, status, display_name, difficulty,
theory_target, demographics, presenting, history, big5,
resistance, speech_style, affect_baseline, ccd,
dsm5_dimensional, source_provenance, is_synthetic,
approved_at
)
VALUES (
$1::uuid, $2, $3, 'approved', $4, $5,
$6::text[], $7::jsonb, $8::jsonb, $9::jsonb, $10::jsonb,
$11::jsonb, $12::jsonb, $13::jsonb, $14::jsonb,
$15::jsonb, $16, $17, now()
)
ON CONFLICT (code, version) DO UPDATE SET
status = 'approved',
display_name = EXCLUDED.display_name,
difficulty = EXCLUDED.difficulty,
theory_target = EXCLUDED.theory_target,
demographics = EXCLUDED.demographics,
presenting = EXCLUDED.presenting,
history = EXCLUDED.history,
big5 = EXCLUDED.big5,
resistance = EXCLUDED.resistance,
speech_style = EXCLUDED.speech_style,
affect_baseline = EXCLUDED.affect_baseline,
ccd = EXCLUDED.ccd,
dsm5_dimensional = EXCLUDED.dsm5_dimensional,
source_provenance = EXCLUDED.source_provenance,
is_synthetic = EXCLUDED.is_synthetic,
approved_at = COALESCE(persona_card.approved_at, now())
""",
seed_persona_id(card.code),
card.code,
SEED_VERSION,
card.display_name,
card.difficulty,
card.theory_target,
card.demographics,
card.presenting,
card.history,
card.big5,
card.resistance,
card.speech_style,
card.affect_baseline,
card.ccd,
card.dsm5_dimensional,
card.source_provenance,
card.is_synthetic,
)
count += 1
return count
async def list_approved_personas() -> list[CatalogPersona]:
get_pool()
async with acquire(ai_context=True) as conn:
rows = await conn.fetch(
f"""
SELECT {_CARD_COLUMNS}
FROM (
SELECT DISTINCT ON (code) {_CARD_COLUMNS}
FROM app.persona_card
WHERE status = 'approved'
ORDER BY code, version DESC
) approved
ORDER BY code
"""
)
return [catalog_persona_from_row(row) for row in rows]
async def get_approved_persona(code: str) -> CatalogPersona | None:
normalized = code.strip().upper()
if not normalized:
return None
get_pool()
async with acquire(ai_context=True) as conn:
row = await conn.fetchrow(
f"""
SELECT {_CARD_COLUMNS}
FROM app.persona_card
WHERE status = 'approved'
AND upper(code) = $1
ORDER BY version DESC
LIMIT 1
""",
normalized,
)
return catalog_persona_from_row(row) if row is not None else None
async def list_catalog_personas() -> list[CatalogPersona]:
try:
return await list_approved_personas()
except Exception:
if settings.allow_seed_persona_fallback:
return seed_fallback_personas()
raise
async def get_catalog_persona(code: str) -> CatalogPersona | None:
try:
return await get_approved_persona(code)
except Exception:
if settings.allow_seed_persona_fallback:
return seed_fallback_persona(code)
raise
__all__ = [
"CatalogPersona",
"SEED_VERSION",
"card_from_row",
"catalog_persona_from_row",
"get_approved_persona",
"get_catalog_persona",
"list_approved_personas",
"list_catalog_personas",
"materialize_seed_personas",
"seed_fallback_persona",
"seed_fallback_personas",
"seed_persona_id",
]

View file

@ -0,0 +1,539 @@
"""Admin operations routes."""
from __future__ import annotations
import time
from datetime import datetime, timezone
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from ..auth_sessions import (
active_session_count,
deactivate_managed_user,
list_managed_users,
update_managed_user,
upsert_managed_user,
)
from ..config import settings
from ..db import acquire, get_pool, healthcheck
from ..deps import Principal, Role, require_role
from ..engine_client import engine_client
from ..runtime_policy import require_runtime_fallback_allowed
from ..services.voice import voice_service
from ..services import rag
router = APIRouter(prefix="/admin", tags=["admin"])
AdminPrincipal = Annotated[Principal, Depends(require_role(Role.ADMIN))]
HealthStatus = Literal["ok", "degraded", "down"]
class AdminServiceHealth(BaseModel):
key: str
name: str
status: HealthStatus
detail: str
metric: str
load: float
class AdminHealthResponse(BaseModel):
status: HealthStatus
environment: str
engine_mode: str
services: list[AdminServiceHealth]
class AdminEngineConfigResponse(BaseModel):
engine_mode: str
engine_url: str
model: str
updated_by: str | None = None
updated_at: float | None = None
durable: bool = False
source: Literal["database", "runtime_cache", "runtime_default"] = "runtime_default"
class AdminEngineConfigPatch(BaseModel):
engine_mode: str | None = None
engine_url: str | None = None
model: str | None = None
RoleName = Literal["learner", "teacher", "admin"]
class AdminUserResponse(BaseModel):
user_id: str
email: str
display_name: str
role: RoleName
cohort_ids: list[str]
affiliation: str
active_sessions: int
created_at: float
last_seen_at: float
source: Literal["database", "server_session_registry"]
class AdminUsersResponse(BaseModel):
source: Literal["database", "server_session_registry"]
durable: bool
users: list[AdminUserResponse]
class AdminUserPatch(BaseModel):
display_name: str | None = Field(default=None, min_length=1, max_length=80)
role: RoleName | None = None
affiliation: str | None = Field(default=None, max_length=120)
cohort_ids: list[str] | None = None
class RuntimeHealthMetrics(BaseModel):
engine_latency_ms: float | None = None
db_pool_size: int = 0
db_pool_idle: int = 0
db_pool_max: int = 0
active_users: int = 0
active_auth_sessions: int = 0
active_sessions: int = 0
ended_sessions: int = 0
pending_reviews: int = 0
def _clamp01(value: float) -> float:
return round(max(0.0, min(1.0, value)), 3)
def _pool_load(metrics: RuntimeHealthMetrics) -> float:
if metrics.db_pool_max <= 0:
return 0.0
busy = max(0, metrics.db_pool_size - metrics.db_pool_idle)
return _clamp01(busy / metrics.db_pool_max)
def _workload_load(count: int, expected_capacity: int) -> float:
if expected_capacity <= 0:
return 0.0
return _clamp01(count / expected_capacity)
async def _runtime_health_metrics(*, db_ok: bool) -> RuntimeHealthMetrics:
metrics = RuntimeHealthMetrics()
try:
pool = get_pool()
metrics.db_pool_size = int(pool.get_size())
metrics.db_pool_idle = int(pool.get_idle_size())
metrics.db_pool_max = int(pool.get_max_size())
except Exception:
pass
if not db_ok:
return metrics
try:
async with acquire(role="admin") as conn:
row = await conn.fetchrow(
"""
SELECT
(SELECT COUNT(*) FROM app.app_user WHERE is_active) AS active_users,
(
SELECT COUNT(*)
FROM app.auth_session
WHERE revoked_at IS NULL AND expires_at > now()
) AS active_auth_sessions,
(
SELECT COUNT(*)
FROM app.sessions
WHERE ended_at IS NULL
) AS active_sessions,
(
SELECT COUNT(*)
FROM app.sessions
WHERE ended_at IS NOT NULL
) AS ended_sessions,
(
SELECT COUNT(*)
FROM app.sessions s
LEFT JOIN app.session_summary ss ON ss.session_id = s.id
WHERE s.ended_at IS NOT NULL AND ss.session_id IS NULL
) AS pending_reviews
"""
)
if row is not None:
metrics.active_users = int(row["active_users"] or 0)
metrics.active_auth_sessions = int(row["active_auth_sessions"] or 0)
metrics.active_sessions = int(row["active_sessions"] or 0)
metrics.ended_sessions = int(row["ended_sessions"] or 0)
metrics.pending_reviews = int(row["pending_reviews"] or 0)
except Exception:
return metrics
return metrics
class AdminUserCreate(BaseModel):
email: str = Field(..., min_length=3, max_length=254)
display_name: str = Field(..., min_length=1, max_length=80)
role: RoleName = "learner"
affiliation: str | None = Field(default=None, max_length=120)
cohort_ids: list[str] = Field(default_factory=list)
class AdminUserDeleteResponse(BaseModel):
ok: bool
user_id: str
_ENGINE_CONFIG: AdminEngineConfigResponse | None = None
ENGINE_MODES = {"claude_api", "claude_cli", "openai", "solar"}
ENGINE_MODE_ALIASES = {"messages_api": "claude_api"}
def _normalize_email(value: str) -> str:
email = value.strip().lower()
if "@" not in email:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail="email is invalid")
local, domain = email.rsplit("@", 1)
if not local or not domain:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail="email is invalid")
allowed = {item.strip().lower().lstrip("@") for item in settings.auth_allowed_email_domains if item.strip()}
if domain not in allowed:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="email domain is not allowed")
return email
def _default_engine_config() -> AdminEngineConfigResponse:
return AdminEngineConfigResponse(
engine_mode=settings.engine_mode,
engine_url=settings.engine_url,
model="gateway-default",
durable=False,
source="runtime_default",
)
def _normalize_engine_mode(value: str) -> str:
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
def _normalize_engine_url(value: str) -> str:
url = value.strip().rstrip("/")
if not (url.startswith("http://") or url.startswith("https://")):
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="engine_url must start with http:// or https://",
)
return url
def _updated_at_ts(value: datetime | None) -> float | None:
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.timestamp()
def _engine_config_from_row(row) -> AdminEngineConfigResponse:
return AdminEngineConfigResponse(
engine_mode=_normalize_engine_mode(row["engine_mode"]),
engine_url=_normalize_engine_url(row["engine_url"]),
model=row["model"],
updated_by=row["updated_by"],
updated_at=_updated_at_ts(row["updated_at"]),
durable=True,
source="database",
)
async def _current_engine_config() -> AdminEngineConfigResponse:
if _ENGINE_CONFIG is not None:
return _ENGINE_CONFIG
try:
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT engine_mode, engine_url, model, updated_by, updated_at
FROM app.admin_engine_config
WHERE id = TRUE
"""
)
if row is not None:
return _engine_config_from_row(row)
except Exception:
require_runtime_fallback_allowed("admin engine config")
require_runtime_fallback_allowed("admin engine config")
return _default_engine_config()
async def apply_engine_config_from_store() -> AdminEngineConfigResponse:
"""Load admin engine settings and apply them to the live engine client."""
config = await _current_engine_config()
await engine_client.configure(
base_url=config.engine_url,
engine_mode=config.engine_mode,
default_model=config.model,
)
return config
def _overall_status(services: list[AdminServiceHealth]) -> HealthStatus:
if any(s.status == "down" for s in services):
return "down"
if any(s.status == "degraded" for s in services):
return "degraded"
return "ok"
def _engine_unavailable_detail(detail: str) -> str:
if detail.lstrip().startswith("{") and '"ok":false' in detail:
return "Engine readiness failed"
return detail
async def _admin_user_response(user, *, durable: bool) -> AdminUserResponse:
return AdminUserResponse(
user_id=user.user_id,
email=user.email,
display_name=user.display_name,
role=user.role,
cohort_ids=user.cohort_ids,
affiliation=user.affiliation,
active_sessions=await active_session_count(user.user_id),
created_at=user.created_at,
last_seen_at=user.last_seen_at,
source="database" if durable else "server_session_registry",
)
@router.get("/health", response_model=AdminHealthResponse)
async def admin_health(principal: AdminPrincipal) -> AdminHealthResponse:
"""Return operational health from live backend checks."""
current_engine = await _current_engine_config()
db_ok = await healthcheck()
engine_started = time.perf_counter()
engine = await engine_client.health_detail()
engine_ok = bool(engine.get("ok"))
engine_detail = _engine_unavailable_detail(
str(engine.get("detail") or "engine readiness unavailable")
)
engine_latency_ms = (time.perf_counter() - engine_started) * 1000
voice_ok = voice_service.is_available()
metrics = await _runtime_health_metrics(db_ok=db_ok)
metrics.engine_latency_ms = engine_latency_ms if engine_ok else None
pool_load = _pool_load(metrics)
session_load = _workload_load(metrics.active_sessions, 50)
review_load = _workload_load(metrics.pending_reviews, 50)
runtime_fallback_is_enabled = settings.environment == "dev"
db_status: HealthStatus = "ok" if db_ok else ("degraded" if runtime_fallback_is_enabled else "down")
db_detail = (
"사용자, 세션, 리뷰 저장"
if db_ok
else (
"DB 연결 전까지 비영구 개발 런타임 기록 사용"
if runtime_fallback_is_enabled
else "DB 저장소에 연결할 수 없습니다"
)
)
db_metric = (
f"{max(0, metrics.db_pool_size - metrics.db_pool_idle)}/{metrics.db_pool_max}"
if db_ok
else ("비영구 런타임 기록" if runtime_fallback_is_enabled else "저장소 중단")
)
services = [
AdminServiceHealth(
key="engine",
name="응답 생성",
status="ok" if engine_ok else "down",
detail="AI 엔진 생성 준비 완료" if engine_ok else engine_detail,
metric=f"{engine_latency_ms:.0f}ms" if engine_ok else "로그인/설정 필요",
load=_clamp01(engine_latency_ms / 1500) if engine_ok else 0.0,
),
AdminServiceHealth(
key="db",
name="영구 저장소",
status=db_status,
detail=db_detail,
metric=db_metric,
load=max(pool_load, session_load) if db_ok else 0.0,
),
AdminServiceHealth(
key="voice",
name="음성 입력",
status="ok" if voice_ok else "degraded",
detail="음성 입력과 재생",
metric="OpenAI 연결" if voice_ok else "설정 필요",
load=0.05 if voice_ok else 0.0,
),
AdminServiceHealth(
key="evaluation",
name="리뷰 생성",
status="ok" if engine_ok else "degraded",
detail="회기 종료 후 피드백 생성",
metric=f"대기 {metrics.pending_reviews}",
load=review_load if engine_ok else 0.0,
),
AdminServiceHealth(
key="kb",
name="지식 검색",
status="ok" if db_ok else "degraded",
detail=f"검색 기준값 {rag.CRAG_TOP1_THRESHOLD}",
metric=(
f"활성 세션 {metrics.active_sessions}"
if db_ok
else "대기 중"
),
load=max(pool_load, session_load) if db_ok else 0.0,
),
]
return AdminHealthResponse(
status=_overall_status(services),
environment=settings.environment,
engine_mode=current_engine.engine_mode,
services=services,
)
@router.get("/engine-config", response_model=AdminEngineConfigResponse)
async def get_engine_config(principal: AdminPrincipal) -> AdminEngineConfigResponse:
"""Return the current admin-managed engine settings."""
return await _current_engine_config()
@router.patch("/engine-config", response_model=AdminEngineConfigResponse)
async def patch_engine_config(
body: AdminEngineConfigPatch,
principal: AdminPrincipal,
) -> AdminEngineConfigResponse:
"""Persist engine settings for administrators."""
global _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_config = AdminEngineConfigResponse(
engine_mode=next_mode,
engine_url=next_url,
model=(body.model or current.model).strip(),
updated_by=principal.email,
updated_at=time.time(),
durable=False,
source="runtime_cache",
)
try:
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO app.admin_engine_config (
id, engine_mode, engine_url, model, updated_by, updated_at
)
VALUES (TRUE, $1, $2, $3, $4, now())
ON CONFLICT (id) DO UPDATE SET
engine_mode = EXCLUDED.engine_mode,
engine_url = EXCLUDED.engine_url,
model = EXCLUDED.model,
updated_by = EXCLUDED.updated_by,
updated_at = now()
RETURNING engine_mode, engine_url, model, updated_by, updated_at
""",
next_config.engine_mode,
next_config.engine_url,
next_config.model,
principal.email,
)
next_config = _engine_config_from_row(row)
except Exception as exc:
if settings.environment != "dev":
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail="engine config persistence unavailable",
) from exc
_ENGINE_CONFIG = next_config
await engine_client.configure(
base_url=next_config.engine_url,
engine_mode=next_config.engine_mode,
default_model=next_config.model,
)
return next_config
@router.get("/users", response_model=AdminUsersResponse)
async def list_users(principal: AdminPrincipal) -> AdminUsersResponse:
"""Return users observed by the server-side auth/session boundary."""
users, durable = await list_managed_users()
if not durable:
require_runtime_fallback_allowed("admin user list")
return AdminUsersResponse(
source="database" if durable else "server_session_registry",
durable=durable,
users=[await _admin_user_response(user, durable=durable) for user in users],
)
@router.post("/users", response_model=AdminUserResponse, status_code=status.HTTP_201_CREATED)
async def create_user(
body: AdminUserCreate,
principal: AdminPrincipal,
) -> AdminUserResponse:
"""Create or reactivate a managed user without requiring that user to log in first."""
user = await upsert_managed_user(
email=_normalize_email(body.email),
display_name=body.display_name,
role=body.role,
affiliation=body.affiliation,
cohort_ids=body.cohort_ids,
reactivate=True,
)
users, durable = await list_managed_users()
if not durable:
require_runtime_fallback_allowed("admin user create")
return await _admin_user_response(user, durable=durable)
@router.patch("/users/{user_id}", response_model=AdminUserResponse)
async def patch_user(
user_id: str,
body: AdminUserPatch,
principal: AdminPrincipal,
) -> AdminUserResponse:
"""Update a server-known user's role/profile for the current API process."""
next_user = await update_managed_user(
user_id,
display_name=body.display_name,
role=body.role,
affiliation=body.affiliation,
cohort_ids=body.cohort_ids,
)
if next_user is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found")
users, durable = await list_managed_users()
if not durable:
require_runtime_fallback_allowed("admin user update")
return await _admin_user_response(next_user, durable=durable)
@router.delete("/users/{user_id}", response_model=AdminUserDeleteResponse)
async def delete_user(
user_id: str,
principal: AdminPrincipal,
) -> AdminUserDeleteResponse:
"""Deactivate a managed user and revoke any active browser sessions."""
if user_id == principal.user_id:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="cannot deactivate current admin")
if not await deactivate_managed_user(user_id):
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found")
return AdminUserDeleteResponse(ok=True, user_id=user_id)

View file

@ -1,84 +1,452 @@
"""인증 라우트 — BFF OAuth 2.1 Auth Code + PKCE(S256) 스텁. """BFF authentication routes.
마스터플랜 §5: BFF + OAuth 2.1, 토큰은 서버(Redis)에만, 브라우저엔 __Host- HttpOnly 쿠키. The production path is Google OIDC authorization code + PKCE. Until the DB
미성년 사례데이터 + 상담 민감정보 -> XSS 토큰탈취 원천 차단. session table is wired, the issued browser sessions are server-side in-proc
1 = Google OIDC 단독, 한신대 SSO 2(R11, Authlib provider 추상화 ). sessions backed by an opaque HttpOnly cookie. Local development also has a
dev-only server login endpoint so Playwright can exercise auth without trusting
파일은 라우트 시그니처 + 흐름 + TODO. 실제 OAuth 교환/Redis 세션은 Phase 2 트랙 B. browser localStorage.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Annotated, Optional import base64
import hashlib
import secrets
import time
from dataclasses import dataclass
from typing import Annotated, Literal, Optional
from urllib.parse import urlencode, urlsplit
from fastapi import APIRouter, HTTPException, Query, Response, status import httpx
from fastapi import APIRouter, Cookie, HTTPException, Query, Request, Response, status
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from pydantic import BaseModel from pydantic import BaseModel
from ..auth_sessions import InactiveUserError, SessionUser, create_session, revoke_session
from ..config import settings from ..config import settings
from ..deps import CurrentPrincipal from ..deps import CurrentPrincipal, Principal, Role
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
GOOGLE_AUTHORIZE_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
GOOGLE_TOKENINFO_URL = "https://oauth2.googleapis.com/tokeninfo"
OAUTH_STATE_TTL_SECONDS = 10 * 60
@dataclass(slots=True)
class OAuthState:
code_verifier: str
next_path: str
created_at: float
_oauth_states: dict[str, OAuthState] = {}
class MeResponse(BaseModel): class MeResponse(BaseModel):
user_id: str user_id: str
email: str
display_name: str
role: str role: str
cohort_ids: list[str] cohort_ids: list[str]
class AuthConfigResponse(BaseModel):
google_oauth_configured: bool
allowed_email_domains: list[str]
redirect_uri: str
dev_login_enabled: bool
class DevLoginRequest(BaseModel):
email: str
role: Literal["learner", "teacher", "admin"] = "learner"
display_name: str | None = None
def _normalize_domain(domain: str | None) -> str:
return (domain or "").strip().lower().lstrip("@")
def _normalize_email(email: str | None) -> str:
return (email or "").strip().lower()
def _email_domain(email: str | None) -> str:
value = _normalize_email(email)
if "@" not in value:
return ""
return value.rsplit("@", 1)[1]
def _normalize_email_set(values: list[str]) -> set[str]:
return {email for value in values if (email := _normalize_email(value))}
def allowed_email_domains() -> set[str]:
"""Configured login email domains, normalized for claim checks."""
return {
normalized
for domain in settings.auth_allowed_email_domains
if (normalized := _normalize_domain(domain))
}
def validate_google_identity_domain(
*,
email: str | None,
email_verified: bool,
hosted_domain: str | None = None,
) -> str:
"""Reject Google identities outside the allowed email domain list.
Google Console authorized domains protect app/redirect domains, not user
email domains. After id_token signature/audience/issuer validation, call
this check with the `email`, `email_verified`, and optional `hd` claims.
"""
normalized_email = _normalize_email(email)
domain = _email_domain(normalized_email)
allowed = allowed_email_domains()
if not allowed:
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="allowed email domains are not configured",
)
if not normalized_email or not domain:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="email claim is required")
if not email_verified:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="email is not verified")
if domain not in allowed:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="email domain is not allowed")
hd = _normalize_domain(hosted_domain)
if hd and hd not in allowed:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="hosted domain is not allowed")
return normalized_email
def _role_for_email(email: str) -> Role:
normalized = _normalize_email(email)
if normalized in _normalize_email_set(settings.auth_admin_emails):
return Role.ADMIN
if normalized in _normalize_email_set(settings.auth_teacher_emails):
return Role.TEACHER
return Role.LEARNER
def _safe_next_path(next_path: str | None) -> str:
if not next_path or not next_path.startswith("/") or next_path.startswith("//"):
return "/"
return next_path
def _url_origin(value: str | None) -> str | None:
if not value:
return None
parsed = urlsplit(value)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
return None
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
def _is_local_origin(origin: str) -> bool:
host = urlsplit(origin).hostname or ""
return host in {"localhost", "127.0.0.1", "::1"}
def _configured_frontend_origins() -> list[str]:
origins: list[str] = []
for value in [settings.frontend_base_url, *settings.cors_origins]:
origin = _url_origin(value)
if origin and origin not in origins:
origins.append(origin)
return origins
def _frontend_origin_for_request(request: Request | None = None) -> str:
origins = _configured_frontend_origins()
fallback = (_url_origin(settings.frontend_base_url) or "http://localhost:5173").rstrip("/")
if request is not None:
for header_name in ("origin", "referer"):
candidate = _url_origin(request.headers.get(header_name))
if candidate in origins:
return candidate
forwarded_host = request.headers.get("x-forwarded-host")
host = (forwarded_host or request.headers.get("host") or "").split(",", 1)[0].strip()
hostname = host.rsplit(":", 1)[0].lower() if host else ""
if hostname == "api-vignette.chanpaca.net":
return "https://vignette.chanpaca.net"
if hostname in {"localhost", "127.0.0.1", "::1"}:
return fallback
if not _is_local_origin(fallback):
return fallback
for origin in origins:
hostname = (urlsplit(origin).hostname or "").lower()
if not _is_local_origin(origin) and hostname != "api-vignette.chanpaca.net":
return origin
return fallback
def _frontend_url(path: str, request: Request | None = None) -> str:
base = _frontend_origin_for_request(request)
return f"{base}{_safe_next_path(path)}"
def _pkce_challenge(verifier: str) -> str:
digest = hashlib.sha256(verifier.encode("ascii")).digest()
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
def _prune_oauth_states() -> None:
cutoff = time.time() - OAUTH_STATE_TTL_SECONDS
stale = [key for key, value in _oauth_states.items() if value.created_at < cutoff]
for key in stale:
_oauth_states.pop(key, None)
def _cookie_secure() -> bool:
# The __Host- prefix requires Secure, Path=/, and no Domain. Modern Chrome
# accepts Secure cookies on localhost, which keeps dev and prod semantics
# aligned.
return settings.is_prod or settings.cookie_name.startswith("__Host-")
def _set_session_cookie(response: Response, sid: str) -> None:
response.set_cookie(
key=settings.cookie_name,
value=sid,
max_age=settings.session_ttl_seconds,
httponly=True,
secure=_cookie_secure(),
samesite="lax",
path="/",
)
if settings.environment == "dev":
response.set_cookie(
key="vignette_sid",
value=sid,
max_age=settings.session_ttl_seconds,
httponly=True,
secure=False,
samesite="lax",
path="/",
)
def _delete_session_cookie(response: Response) -> None:
response.delete_cookie(
settings.cookie_name,
httponly=True,
secure=_cookie_secure(),
samesite="lax",
path="/",
)
if settings.environment == "dev":
response.delete_cookie(
"vignette_sid",
httponly=True,
secure=False,
samesite="lax",
path="/",
)
def _me_response(user: SessionUser | Principal) -> MeResponse:
return MeResponse(
user_id=user.user_id,
email=getattr(user, "email", ""),
display_name=getattr(user, "display_name", "") or getattr(user, "email", ""),
role=user.role.value if isinstance(user.role, Role) else user.role,
cohort_ids=user.cohort_ids,
)
def _frontend_login_redirect(reason: str, request: Request) -> RedirectResponse:
base_url = _frontend_origin_for_request(request)
return RedirectResponse(f"{base_url}/login?{urlencode({'oauth': reason})}", status_code=302)
def _dev_login_available(request: Request) -> bool:
if settings.environment != "dev" or not settings.auth_dev_login_enabled:
return False
for header_name in ("origin", "referer"):
origin = _url_origin(request.headers.get(header_name))
if origin and not _is_local_origin(origin):
return False
forwarded_host = request.headers.get("x-forwarded-host")
host = (forwarded_host or request.headers.get("host") or "").split(",", 1)[0].strip()
origin = _url_origin(f"http://{host}") if host else None
return bool(origin and _is_local_origin(origin))
@router.get("/config", response_model=AuthConfigResponse)
async def auth_config(request: Request) -> AuthConfigResponse:
"""Return non-secret login configuration for the browser login screen."""
return AuthConfigResponse(
google_oauth_configured=bool(
settings.oauth_google_client_id and settings.oauth_google_client_secret
),
allowed_email_domains=sorted(allowed_email_domains()),
redirect_uri=settings.oauth_redirect_uri,
dev_login_enabled=_dev_login_available(request),
)
@router.get("/login") @router.get("/login")
async def login( async def login(
request: Request,
provider: Annotated[str, Query()] = "google", provider: Annotated[str, Query()] = "google",
next: Annotated[str | None, Query()] = None,
) -> RedirectResponse: ) -> RedirectResponse:
"""OAuth Auth Code + PKCE 시작 (BFF). """Start Google OIDC authorization code + PKCE login."""
절차:
1. code_verifier 생성 -> S256 code_challenge
2. state(CSRF) + verifier 서버 세션(Redis) 저장
3. provider authorize URL 302 (Google OIDC 1)
TODO: Authlib provider 추상화 + Redis state 저장. 현재 스텁 501.
"""
if provider != "google": if provider != "google":
# 한신대 SSO 는 2차 (R11) return _frontend_login_redirect("unsupported_provider", request)
raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail=f"provider {provider} not yet supported") if not settings.oauth_google_client_id or not settings.oauth_google_client_secret:
raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail="OAuth login TODO (Phase 2 트랙 B)") return _frontend_login_redirect("not_configured", request)
_prune_oauth_states()
state = secrets.token_urlsafe(32)
verifier = secrets.token_urlsafe(64)
_oauth_states[state] = OAuthState(
code_verifier=verifier,
next_path=_safe_next_path(next),
created_at=time.time(),
)
params = {
"client_id": settings.oauth_google_client_id,
"redirect_uri": settings.oauth_redirect_uri,
"response_type": "code",
"scope": "openid email profile",
"state": state,
"code_challenge": _pkce_challenge(verifier),
"code_challenge_method": "S256",
"prompt": "select_account",
}
return RedirectResponse(f"{GOOGLE_AUTHORIZE_URL}?{urlencode(params)}", status_code=302)
@router.get("/callback") @router.get("/callback")
async def callback( async def callback(
response: Response, request: Request,
code: Annotated[Optional[str], Query()] = None, code: Annotated[Optional[str], Query()] = None,
state: Annotated[Optional[str], Query()] = None, state: Annotated[Optional[str], Query()] = None,
) -> RedirectResponse: ) -> RedirectResponse:
"""OAuth 콜백 — code -> token 교환 후 서버 세션 발급. """Exchange Google auth code, validate identity, and issue a BFF cookie."""
if not code or not state:
return _frontend_login_redirect("missing_callback", request)
절차: _prune_oauth_states()
1. state 검증 (Redis 저장값과 대조, CSRF) stored = _oauth_states.pop(state, None)
2. code + code_verifier token 교환 (PKCE) if stored is None:
3. id_token 검증 -> user upsert -> role/cohort 매핑 return _frontend_login_redirect("invalid_state", request)
4. Redis 세션 생성 -> __Host- HttpOnly Secure SameSite=Lax 쿠키 set
5. IRB 동의 미이행 동의 게이트로 리다이렉트 (마스터플랜 §7) async with httpx.AsyncClient(timeout=10.0) as client:
TODO: 전체 교환 구현. 현재 스텁 501. token_res = await client.post(
GOOGLE_TOKEN_URL,
data={
"client_id": settings.oauth_google_client_id,
"client_secret": settings.oauth_google_client_secret,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": settings.oauth_redirect_uri,
"code_verifier": stored.code_verifier,
},
headers={"Accept": "application/json"},
)
if token_res.status_code >= 400:
return _frontend_login_redirect("token_exchange_failed", request)
token_payload = token_res.json()
id_token = token_payload.get("id_token")
if not isinstance(id_token, str) or not id_token:
return _frontend_login_redirect("id_token_missing", request)
info_res = await client.get(GOOGLE_TOKENINFO_URL, params={"id_token": id_token})
if info_res.status_code >= 400:
return _frontend_login_redirect("id_token_invalid", request)
claims = info_res.json()
if claims.get("aud") != settings.oauth_google_client_id:
return _frontend_login_redirect("audience_mismatch", request)
issuer = claims.get("iss")
if issuer not in {"accounts.google.com", "https://accounts.google.com"}:
return _frontend_login_redirect("issuer_mismatch", request)
try:
email = validate_google_identity_domain(
email=claims.get("email"),
email_verified=claims.get("email_verified") in {True, "true", "True", "1", 1},
hosted_domain=claims.get("hd"),
)
except HTTPException:
return _frontend_login_redirect("domain_not_allowed", request)
role = _role_for_email(email)
display_name = str(claims.get("name") or email)
try:
sid, _ = await create_session(
email=email,
display_name=display_name,
role=role.value,
cohort_ids=[],
)
except InactiveUserError as exc:
return _frontend_login_redirect("inactive_user", request)
response = RedirectResponse(_frontend_url(stored.next_path, request), status_code=302)
_set_session_cookie(response, sid)
return response
@router.post("/dev-login", response_model=MeResponse)
async def dev_login(request: Request, body: DevLoginRequest, response: Response) -> MeResponse:
"""Dev-only server login for local E2E and manual testing.
This is not a browser-side auth shortcut: the role is stored server-side and
the browser only gets the same opaque HttpOnly cookie used by OAuth.
""" """
raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail="OAuth callback TODO (Phase 2 트랙 B)") if not _dev_login_available(request):
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="dev login is disabled")
email = validate_google_identity_domain(
email=str(body.email),
email_verified=True,
hosted_domain=_email_domain(str(body.email)),
)
try:
sid, user = await create_session(
email=email,
display_name=body.display_name or email,
role=body.role,
cohort_ids=[],
)
except InactiveUserError as exc:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="user is inactive") from exc
_set_session_cookie(response, sid)
return _me_response(user)
@router.post("/logout") @router.post("/logout")
async def logout(response: Response) -> dict[str, bool]: async def logout(
"""세션 무효화 (Redis 삭제 + 쿠키 만료). IRB 철회 즉시 무효화 경로 겸용. response: Response,
session_cookie: Annotated[Optional[str], Cookie(alias="__Host-vignette_sid")] = None,
TODO: Redis 세션 삭제. 현재 쿠키 만료만. dev_session_cookie: Annotated[Optional[str], Cookie(alias="vignette_sid")] = None,
""" ) -> dict[str, bool]:
response.delete_cookie(settings.cookie_name, httponly=True, secure=settings.is_prod, samesite="lax") """Revoke the current server session and expire the browser cookie."""
await revoke_session(session_cookie or (dev_session_cookie if settings.environment == "dev" else None))
_delete_session_cookie(response)
return {"ok": True} return {"ok": True}
@router.get("/me", response_model=MeResponse) @router.get("/me", response_model=MeResponse)
async def me(principal: CurrentPrincipal) -> MeResponse: async def me(principal: CurrentPrincipal) -> MeResponse:
"""현재 세션 주체 (프론트 부트스트랩용). 미인증이면 deps 에서 401.""" """Return the current authenticated user. Unauthenticated requests are 401."""
return MeResponse( return _me_response(principal)
user_id=principal.user_id,
role=principal.role.value,
cohort_ids=principal.cohort_ids,
)

View file

@ -11,8 +11,8 @@ services/evaluator.py 의 2-loop 평가(fast/deep)를 교수자(TEACHER)·관리
POST /eval/sessions/{id}/reevaluate 회기 deep-loop 재평가 트리거(전체 축어록) POST /eval/sessions/{id}/reevaluate 회기 deep-loop 재평가 트리거(전체 축어록)
GET /eval/sessions/{id}/evaluation 회기 평가 조회(분포 + 최근 deep 결과) GET /eval/sessions/{id}/evaluation 회기 평가 조회(분포 + 최근 deep 결과)
DB(feedback_scores/supervisor_comment) SoR 적재는 Phase 2. 현재는 in-proc store + 엔진 직접 호출 평가 결과는 session_persistence DB-backed evaluation 저장소를 사용한다. DB 미가용
(degraded). DB 붙으면 조회 경로를 turns.evaluation / supervisor_comment 조인으로 교체한다. in-proc cache/session fallback local dev 에서만 허용한다.
""" """
from __future__ import annotations from __future__ import annotations
@ -22,10 +22,13 @@ from typing import Annotated, Any, Optional
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from .. import session_persistence
from ..deps import Principal, Role, require_role from ..deps import Principal, Role, require_role
from ..engine_client import EngineError, engine_client from ..engine_client import EngineError, engine_client
from ..runtime_policy import runtime_fallback_allowed
from ..services import evaluator from ..services import evaluator
from ..services.evaluator import SessionEvaluation, TurnEvaluation from ..services.evaluator import SessionEvaluation, TurnEvaluation
from ..store import InProcSession
from ..store import store from ..store import store
router = APIRouter(prefix="/eval", tags=["eval"]) router = APIRouter(prefix="/eval", tags=["eval"])
@ -52,13 +55,11 @@ class EvaluationSummary(BaseModel):
distribution: dict[str, Any] = Field(default_factory=dict) distribution: dict[str, Any] = Field(default_factory=dict)
# ── in-proc 평가 결과 캐시 (DB 적재 전 degraded 보관) ─────────────────────── async def _load_session_or_404(session_id: str, principal: Principal) -> InProcSession:
# DB 가 붙으면 turns.evaluation / supervisor_comment 로 대체. 지금은 트리거 결과를 보관해 sess = await session_persistence.load_session(session_id, principal, allow_ended=True)
# 조회 GET 이 재호출 없이 마지막 deep 결과를 돌려주게 한다. if sess is not None:
_DEEP_CACHE: dict[str, SessionEvaluation] = {} store.put(sess)
elif runtime_fallback_allowed():
def _load_session_or_404(session_id: str):
sess = store.get(session_id) sess = store.get(session_id)
if sess is None: if sess is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="session not found") raise HTTPException(status.HTTP_404_NOT_FOUND, detail="session not found")
@ -90,10 +91,10 @@ async def reevaluate_session(
) -> SessionEvaluation: ) -> SessionEvaluation:
"""회기 전체 deep-loop 재평가(슈퍼바이저 rationale/critique + 개선점 + 대안발화). """회기 전체 deep-loop 재평가(슈퍼바이저 rationale/critique + 개선점 + 대안발화).
in-proc store 마스킹 축어록을 evaluator.evaluate_session 으로 평가한다. 저장된 마스킹 축어록을 evaluator.evaluate_session 으로 평가한다.
엔진 장애는 503 으로 변환(평가는 비치명적이지만 트리거는 사용자 명시 요청이라 에러 노출). 엔진 장애는 503 으로 변환(평가는 비치명적이지만 트리거는 사용자 명시 요청이라 에러 노출).
""" """
sess = _load_session_or_404(session_id) sess = await _load_session_or_404(session_id, principal)
masked = sess.masked_turns() masked = sess.masked_turns()
# 발화 seq 보강(deep 프롬프트 가독성 — store 가 seq 미포함이라 인덱스로 부여) # 발화 seq 보강(deep 프롬프트 가독성 — store 가 seq 미포함이라 인덱스로 부여)
enriched: list[dict[str, Any]] = [] enriched: list[dict[str, Any]] = []
@ -121,7 +122,16 @@ async def reevaluate_session(
if result.error and result.error.startswith("engine_error"): if result.error and result.error.startswith("engine_error"):
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=result.error) raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=result.error)
_DEEP_CACHE[session_id] = result await session_persistence.save_session_evaluation(
session_id=session_id,
learner_id=sess.learner_id,
status="error" if result.error else "ready",
source="engine",
scope=result.scope,
stage=result.stage,
payload=result.to_dict(),
error=result.error,
)
return result return result
@ -136,10 +146,10 @@ async def reevaluate_turn(
) -> TurnEvaluation: ) -> TurnEvaluation:
"""단일 상담자 발화 fast-loop 재평가(기법/내담자상태/적절성/의도이탈). """단일 상담자 발화 fast-loop 재평가(기법/내담자상태/적절성/의도이탈).
store 축어록에서 해당 turn_seq 상담자 발화 + 직후 내담자 응답을 재구성해 저장된 축어록에서 해당 turn_seq 상담자 발화 + 직후 내담자 응답을 재구성해
경량 TurnContext evaluator.evaluate_turn 호출한다. 경량 TurnContext evaluator.evaluate_turn 호출한다.
""" """
sess = _load_session_or_404(session_id) sess = await _load_session_or_404(session_id, principal)
# 대상 상담자 발화 + 직후 내담자 응답 찾기 # 대상 상담자 발화 + 직후 내담자 응답 찾기
target_idx: Optional[int] = None target_idx: Optional[int] = None
@ -188,18 +198,20 @@ async def get_session_evaluation(
session_id: str, session_id: str,
principal: TeacherOrAdmin, principal: TeacherOrAdmin,
) -> EvaluationSummary: ) -> EvaluationSummary:
"""회기 평가 조회(읽기) — 마지막 deep 재평가 결과 + 기법 분포. """회기 평가 조회(읽기) — 저장된 마지막 deep 재평가 결과 + 기법 분포.
DB 적재 degraded: deep 결과는 reevaluate 트리거가 보관한 캐시에서, 분포는 결과에서.
아직 평가 트리거가 없었다면 deep=None + 분포. 아직 평가 트리거가 없었다면 deep=None + 분포.
""" """
_load_session_or_404(session_id) await _load_session_or_404(session_id, principal)
cached = _DEEP_CACHE.get(session_id) record, _durable = await session_persistence.load_session_evaluation(session_id, principal)
if cached is None: if record is None:
return EvaluationSummary(session_id=session_id, stage="", deep=None, distribution={}) return EvaluationSummary(session_id=session_id, stage="", deep=None, distribution={})
payload = record.get("payload")
deep = payload if isinstance(payload, dict) else {}
distribution = deep.get("distribution")
return EvaluationSummary( return EvaluationSummary(
session_id=session_id, session_id=session_id,
stage=cached.stage, stage=str(record.get("stage") or deep.get("stage") or ""),
deep=cached.to_dict(), deep=deep,
distribution=cached.distribution.model_dump(), distribution=distribution if isinstance(distribution, dict) else {},
) )

View file

@ -0,0 +1,66 @@
"""Persona catalog routes."""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, HTTPException, Response, status
from pydantic import BaseModel
from ..deps import CurrentPrincipal
from ..persona_repository import CatalogPersona, list_catalog_personas
router = APIRouter(prefix="/personas", tags=["personas"])
class PersonaSummary(BaseModel):
code: str
display_name: str
difficulty: str
theory_target: list[str]
demographics: dict[str, Any]
presenting_summary: str
voice_preset: str | None = None
source: str = "database"
degraded: bool = False
def _first_text_value(data: dict[str, Any]) -> str:
for value in data.values():
if isinstance(value, str) and value.strip():
return value.strip()
return ""
def _summary(entry: CatalogPersona) -> PersonaSummary:
card = entry.card
return PersonaSummary(
code=card.code,
display_name=card.display_name,
difficulty=card.difficulty,
theory_target=card.theory_target,
demographics=card.demographics,
presenting_summary=_first_text_value(card.presenting),
source=entry.source,
degraded=entry.degraded,
)
@router.get("", response_model=list[PersonaSummary])
async def list_personas(response: Response, _principal: CurrentPrincipal) -> list[PersonaSummary]:
"""Return latest approved personas from app.persona_card."""
try:
personas = await list_catalog_personas()
except Exception as exc:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail="persona catalog database unavailable",
) from exc
if any(entry.degraded for entry in personas):
response.headers["X-Vignette-Degraded"] = "true"
response.headers["X-Vignette-Catalog-Source"] = "seed_fallback"
else:
response.headers["X-Vignette-Catalog-Source"] = "database"
return [_summary(entry) for entry in personas]

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,103 @@
"""Teacher dashboard routes backed by real server session state."""
from __future__ import annotations
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from .. import session_persistence
from ..deps import Principal, Role, require_role
from ..runtime_policy import require_runtime_fallback_allowed
from ..store import InProcSession, store
router = APIRouter(prefix="/teacher", tags=["teacher"])
TeacherPrincipal = Annotated[Principal, Depends(require_role(Role.TEACHER, Role.ADMIN))]
class TeacherSessionSummary(BaseModel):
session_id: str
learner_id: str
learner_label: str
persona_code: str
persona_name: str
session_no: int
status: str
stage: str
turn_count: int
learner_turn_count: int
client_turn_count: int
started_at: str
ended_at: str | None = None
class TeacherDashboardResponse(BaseModel):
source: str = "in_memory"
cohort_label: str = "현재 학습 기록"
total_learners: int
active_sessions: int
ended_sessions: int
pending_reviews: list[TeacherSessionSummary] = Field(default_factory=list)
recent_sessions: list[TeacherSessionSummary] = Field(default_factory=list)
message: str
def _iso(ts: float | None) -> str | None:
if ts is None:
return None
return datetime.fromtimestamp(ts).isoformat(timespec="seconds")
def _learner_label(learner_id: str) -> str:
suffix = learner_id[-6:] if len(learner_id) > 6 else learner_id
return f"학습자 {suffix}"
def _summary(sess: InProcSession) -> TeacherSessionSummary:
learner_turns = sum(1 for turn in sess.turns if turn.speaker == "counselor")
client_turns = sum(1 for turn in sess.turns if turn.speaker == "client")
return TeacherSessionSummary(
session_id=sess.session_id,
learner_id=sess.learner_id,
learner_label=_learner_label(sess.learner_id),
persona_code=sess.persona_code,
persona_name=sess.persona.display_name,
session_no=sess.session_no,
status="ended" if sess.ended else "active",
stage=sess.state.stage.value,
turn_count=len(sess.turns),
learner_turn_count=learner_turns,
client_turn_count=client_turns,
started_at=_iso(sess.created_at) or "",
ended_at=_iso(sess.ended_at),
)
@router.get("/dashboard", response_model=TeacherDashboardResponse)
async def teacher_dashboard(principal: TeacherPrincipal) -> TeacherDashboardResponse:
"""Return teacher-visible dashboard data from real sessions only."""
sessions, durable = await session_persistence.list_sessions(principal)
if not durable:
require_runtime_fallback_allowed("teacher dashboard")
sessions = sorted(store.list(), key=lambda sess: sess.created_at, reverse=True)
summaries = [_summary(sess) for sess in sessions]
pending_reviews = [item for item in summaries if item.status == "ended"]
learners = {sess.learner_id for sess in sessions}
if sessions:
message = "현재 기록된 실제 학습 세션만 표시합니다."
else:
message = "아직 표시할 실제 학습자 세션이 없습니다."
return TeacherDashboardResponse(
source="database" if durable else "runtime",
total_learners=len(learners),
active_sessions=sum(1 for sess in sessions if not sess.ended),
ended_sessions=sum(1 for sess in sessions if sess.ended),
pending_reviews=pending_reviews[:20],
recent_sessions=summaries[:20],
message=message,
)

View file

@ -0,0 +1,280 @@
"""Current-user profile, preference, and voice-preset routes."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel, Field
from ..auth_sessions import DEFAULT_AFFILIATION, get_managed_user, update_managed_user
from ..db import get_pool
from ..deps import CurrentPrincipal
from ..runtime_policy import require_runtime_fallback_allowed
from ..services.voice import PRESET_RATE, PRESET_TO_OPENAI_VOICE
router = APIRouter(prefix="/users", tags=["users"])
class UserProfileResponse(BaseModel):
user_id: str
email: str
display_name: str
role: str
cohort_ids: list[str]
affiliation: str
class UserProfilePatch(BaseModel):
display_name: str | None = Field(default=None, min_length=1, max_length=80)
affiliation: str | None = Field(default=None, max_length=120)
class NotificationPreferences(BaseModel):
session_done: bool = True
safety_signal: bool = True
learner_progress: bool = False
product_news: bool = False
class UserPreferencesResponse(BaseModel):
theme: str = "system"
voice_preset_id: str = "soft-young-fem"
voice_rate: float = 1.0
notifications: NotificationPreferences = Field(default_factory=NotificationPreferences)
class UserPreferencesPatch(BaseModel):
theme: str | None = None
voice_preset_id: str | None = None
voice_rate: float | None = Field(default=None, ge=0.8, le=1.2)
notifications: NotificationPreferences | None = None
class VoicePresetResponse(BaseModel):
id: str
voice_id: str
name: str
desc: str
persona_hint: str
_preferences: dict[str, UserPreferencesResponse] = {}
VOICE_PRESET_META = {
"soft-young-fem": {
"name": "서린",
"desc": "부드럽고 낮은 긴장감",
"persona_hint": "청소년 내담자",
},
"calm-adult-male": {
"name": "민재",
"desc": "차분하고 안정적인 성인 남성",
"persona_hint": "성인 남성",
},
"warm-adult-fem": {
"name": "지영",
"desc": "따뜻하지만 지친 성인 여성",
"persona_hint": "성인 여성",
},
"neutral": {
"name": "기본",
"desc": "중립적인 기본 음성",
"persona_hint": "범용",
},
}
def _voice_preset_ids() -> set[str]:
return set(PRESET_TO_OPENAI_VOICE.keys())
def _normalize_voice_preset(value: str | None) -> str:
if value in _voice_preset_ids():
return str(value)
return "soft-young-fem"
def _assert_voice_preset(value: str | None) -> None:
if value is None:
return
if value not in _voice_preset_ids():
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"unsupported voice preset {value}",
)
def _voice_presets() -> list[VoicePresetResponse]:
presets: list[VoicePresetResponse] = []
for preset_id in PRESET_TO_OPENAI_VOICE.keys():
meta = VOICE_PRESET_META.get(
preset_id,
{
"name": preset_id,
"desc": f"rate {PRESET_RATE.get(preset_id, 1.0):.2f}",
"persona_hint": "사용자 지정",
},
)
presets.append(
VoicePresetResponse(
id=preset_id,
voice_id=preset_id,
name=meta["name"],
desc=meta["desc"],
persona_hint=meta["persona_hint"],
)
)
return presets
def _preferences_from_row(row) -> UserPreferencesResponse:
return UserPreferencesResponse(
theme=row["theme"],
voice_preset_id=_normalize_voice_preset(row["voice_preset_id"]),
voice_rate=float(row["voice_rate"]),
notifications=NotificationPreferences.model_validate(row["notifications"] or {}),
)
async def _profile_for(principal: CurrentPrincipal) -> UserProfileResponse:
managed = await get_managed_user(principal.user_id)
return UserProfileResponse(
user_id=principal.user_id,
email=principal.email,
display_name=(
(managed.display_name if managed else "")
or principal.display_name
or principal.email
),
role=(managed.role if managed else principal.role.value),
cohort_ids=(managed.cohort_ids if managed else principal.cohort_ids),
affiliation=(managed.affiliation if managed else DEFAULT_AFFILIATION),
)
@router.get("/me", response_model=UserProfileResponse)
async def get_me(principal: CurrentPrincipal) -> UserProfileResponse:
return await _profile_for(principal)
@router.patch("/me", response_model=UserProfileResponse)
async def patch_me(body: UserProfilePatch, principal: CurrentPrincipal) -> UserProfileResponse:
profile = await _profile_for(principal)
await update_managed_user(
principal.user_id,
display_name=body.display_name if body.display_name is not None else profile.display_name,
affiliation=body.affiliation if body.affiliation is not None else profile.affiliation,
)
return await _profile_for(principal)
@router.get("/me/preferences", response_model=UserPreferencesResponse)
async def get_preferences(principal: CurrentPrincipal) -> UserPreferencesResponse:
try:
pool = get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO app.user_preferences (user_id, notifications)
VALUES ($1::uuid, $2::jsonb)
ON CONFLICT (user_id) DO NOTHING
""",
principal.user_id,
NotificationPreferences().model_dump(),
)
row = await conn.fetchrow(
"""
SELECT theme, voice_preset_id, voice_rate, notifications
FROM app.user_preferences
WHERE user_id = $1::uuid
""",
principal.user_id,
)
if row is not None:
return _preferences_from_row(row)
except Exception:
require_runtime_fallback_allowed("user preferences")
return _preferences.setdefault(principal.user_id, UserPreferencesResponse())
@router.patch("/me/preferences", response_model=UserPreferencesResponse)
async def patch_preferences(
body: UserPreferencesPatch,
principal: CurrentPrincipal,
) -> UserPreferencesResponse:
_assert_voice_preset(body.voice_preset_id)
try:
pool = get_pool()
async with pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO app.user_preferences (user_id, notifications)
VALUES ($1::uuid, $2::jsonb)
ON CONFLICT (user_id) DO NOTHING
""",
principal.user_id,
NotificationPreferences().model_dump(),
)
current = await conn.fetchrow(
"""
SELECT theme, voice_preset_id, voice_rate, notifications
FROM app.user_preferences
WHERE user_id = $1::uuid
""",
principal.user_id,
)
current_prefs = _preferences_from_row(current)
next_prefs = UserPreferencesResponse(
theme=body.theme if body.theme is not None else current_prefs.theme,
voice_preset_id=(
body.voice_preset_id
if body.voice_preset_id is not None
else current_prefs.voice_preset_id
),
voice_rate=body.voice_rate if body.voice_rate is not None else current_prefs.voice_rate,
notifications=(
body.notifications
if body.notifications is not None
else current_prefs.notifications
),
)
row = await conn.fetchrow(
"""
UPDATE app.user_preferences SET
theme = $2,
voice_preset_id = $3,
voice_rate = $4,
notifications = $5::jsonb,
updated_at = now()
WHERE user_id = $1::uuid
RETURNING theme, voice_preset_id, voice_rate, notifications
""",
principal.user_id,
next_prefs.theme,
next_prefs.voice_preset_id,
next_prefs.voice_rate,
next_prefs.notifications.model_dump(),
)
return _preferences_from_row(row)
except HTTPException:
raise
except Exception:
require_runtime_fallback_allowed("user preferences")
current = _preferences.setdefault(principal.user_id, UserPreferencesResponse())
data = current.model_dump()
if body.theme is not None:
data["theme"] = body.theme
if body.voice_preset_id is not None:
data["voice_preset_id"] = body.voice_preset_id
if body.voice_rate is not None:
data["voice_rate"] = body.voice_rate
if body.notifications is not None:
data["notifications"] = body.notifications.model_dump()
next_prefs = UserPreferencesResponse.model_validate(data)
_preferences[principal.user_id] = next_prefs
return next_prefs
@router.get("/me/voice-presets", response_model=list[VoicePresetResponse])
async def get_voice_presets(principal: CurrentPrincipal) -> list[VoicePresetResponse]:
return _voice_presets()

View file

@ -1,22 +1,14 @@
"""음성 라우트 — OpenAI STT/TTS 캐스케이드 + WSS 실시간 턴테이킹. """Voice routes for the OpenAI STT/TTS cascade over WebSocket.
한신대 요구 '음성 필수'. 학습자가 마이크로 말하면 STT orchestrator 상담 1 Client sends JSON controls plus binary audio chunks:
내담자 텍스트 TTS 오디오 + 립싱크 힌트(설계 §4.3 RMS) 역방향으로 흘린다. audio_start -> binary audio chunks -> audio_end
캐스케이드(설계 §5.2 음성 오브 4상태 listeningthinkingspeakingidle): Server emits:
[클라] audio_start(JSON) 바이너리 오디오 청크들 audio_end(JSON) ready -> state(listening) -> state(thinking) -> transcript -> reply
[서버] state(listening) STT transcript(JSON) state(thinking) -> state(speaking) -> tts_chunk + binary audio chunks -> tts_end -> state(idle)
orchestrator.run_turn(가드레일·상태머신·페르소나·내담자AI·출력가드)
reply(JSON, 내담자 텍스트 + stage/openness) state(speaking)
[tts_chunk(JSON: seq/rms) + 바이너리 오디오] × N tts_end(JSON) state(idle)
프로토콜(JSON 제어 + 바이너리 오디오 혼합, 단일 WS): When voice is not configured, the route reports degraded state and closes
- 클라서버 텍스트 = JSON 제어({"type": ...}); 클라서버 바이너리 = 오디오 청크 cleanly instead of crashing.
- 서버클라 텍스트 = JSON 이벤트; 서버클라 바이너리 = TTS 오디오 청크
- TTS 바이너리 청크 *직전* 메타 JSON(tts_chunk: seq, rms) 보내 프론트가 짝짓는다.
음성 미설정(OPENAI_API_KEY 없음): GET /voice/health 503 degraded,
WS 핸드셰이크 직후 degraded 이벤트 + close(1011). 절대 크래시 금지.
""" """
from __future__ import annotations from __future__ import annotations
@ -28,67 +20,84 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from starlette.websockets import WebSocketState from starlette.websockets import WebSocketState
from .. import session_persistence
from ..auth_sessions import get_session
from ..config import settings
from ..deps import Principal, Role
from ..engine_client import EngineError, engine_client from ..engine_client import EngineError, engine_client
from ..services import memory, orchestrator, persona from ..persona_repository import get_catalog_persona
from ..runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed
from ..services import memory, orchestrator, state_machine
from ..services import voice as voice_svc from ..services import voice as voice_svc
from ..services.voice import VoicePreset, VoiceUnavailable, resolve_voice, voice_service from ..services.voice import VoicePreset, VoiceUnavailable, resolve_voice, voice_service
from ..store import TurnRecord, store from ..store import InProcSession, TurnRecord, store
router = APIRouter(prefix="/voice", tags=["voice"]) router = APIRouter(prefix="/voice", tags=["voice"])
# WS close 코드(섹션별 의미 명시) # WebSocket close codes.
WS_CLOSE_DEGRADED = 1011 # 서버측 음성 미설정/장애 WS_CLOSE_DEGRADED = 1011
WS_CLOSE_BAD_REQUEST = 1008 # 프로토콜 위반(세션 누락 등) WS_CLOSE_BAD_REQUEST = 1008
WS_CLOSE_UNAUTHORIZED = 1008
# 한 발화당 누적 오디오 상한(메모리 방어, ~10MB) # Per-utterance audio cap to avoid unbounded memory growth.
_MAX_AUDIO_BYTES = 10 * 1024 * 1024 _MAX_AUDIO_BYTES = 10 * 1024 * 1024
# ════════════════════════════════════════════════════════════════════════════
# 헬스 — 음성 가용성(키 설정) 노출
# ════════════════════════════════════════════════════════════════════════════
@router.get("/health") @router.get("/health")
async def voice_health() -> JSONResponse: async def voice_health() -> JSONResponse:
"""음성 라우터 헬스. 키 미설정이면 503 degraded(시연 투명성).""" """Return voice service readiness."""
available = voice_service.is_available() available = voice_service.is_available()
body = { body = {
"status": "ok" if available else "degraded", "status": "ok" if available else "degraded",
"available": available, "available": available,
"stt_model": voice_svc.STT_MODEL, "stt_model": voice_svc.STT_MODEL,
"tts_model": voice_svc.TTS_MODEL, "tts_model": voice_svc.TTS_MODEL,
"reason": None if available else "OPENAI_API_KEY 미설정", "reason": None if available else "OPENAI_API_KEY is not configured",
} }
return JSONResponse(body, status_code=200 if available else 503) return JSONResponse(body, status_code=200 if available else 503)
# ════════════════════════════════════════════════════════════════════════════
# WebSocket — 실시간 음성 캐스케이드
# ════════════════════════════════════════════════════════════════════════════
@router.websocket("/ws") @router.websocket("/ws")
async def voice_ws(websocket: WebSocket) -> None: async def voice_ws(websocket: WebSocket) -> None:
"""음성 실시간 턴 캐스케이드. """Run one authenticated learner voice cascade."""
쿼리: ?session_id=<hex> (없으면 persona_code 일회용 in-proc 세션 생성 시연용)
오디오 in(바이너리) STT 상담 1 TTS out(바이너리) + 립싱크 힌트.
"""
await websocket.accept() await websocket.accept()
# 1) 음성 미설정 → degraded 알리고 정상 종료(크래시 금지) # Authenticate the same server-side browser session used by REST routes.
if not voice_service.is_available(): principal = await _principal_from_websocket(websocket)
await _safe_send_json( if principal is None:
websocket, await _safe_send_json(websocket, {"type": "error", "detail": "not authenticated"})
{"type": "degraded", "reason": "OPENAI_API_KEY 미설정 — 음성 기능 비활성"}, await _safe_close(websocket, WS_CLOSE_UNAUTHORIZED)
) return
await _safe_close(websocket, WS_CLOSE_DEGRADED) if principal.role != Role.LEARNER:
await _safe_send_json(websocket, {"type": "error", "detail": "only learners can use voice"})
await _safe_close(websocket, WS_CLOSE_UNAUTHORIZED)
return return
# 2) 세션 바인딩 — session_id 우선, 없으면 persona_code 로 시연 세션 생성 # Bind to an existing session first. persona_code creation is dev-only.
session_id, voice_preset, err = _bind_session(websocket) session_id, voice_preset, err, bind_meta = await _bind_session(websocket, principal)
if err is not None: if err is not None:
await _safe_send_json(websocket, {"type": "error", "detail": err}) await _safe_send_json(websocket, {"type": "error", "detail": err})
await _safe_close(websocket, WS_CLOSE_BAD_REQUEST) await _safe_close(websocket, WS_CLOSE_BAD_REQUEST)
return return
assert session_id is not None and voice_preset is not None assert session_id is not None and voice_preset is not None
if bind_meta.get("degraded"):
await _safe_send_json(
websocket,
{
"type": "degraded",
"reason": bind_meta.get("degraded_reason", "voice session binding degraded"),
**bind_meta,
},
)
# Voice misconfiguration is reported explicitly and then closed cleanly.
if not voice_service.is_available():
await _safe_send_json(
websocket,
{"type": "degraded", "reason": "OPENAI_API_KEY is not configured"},
)
await _safe_close(websocket, WS_CLOSE_DEGRADED)
return
await _safe_send_json( await _safe_send_json(
websocket, websocket,
@ -98,6 +107,7 @@ async def voice_ws(websocket: WebSocket) -> None:
"voice": voice_preset.openai_voice, "voice": voice_preset.openai_voice,
"preset": voice_preset.preset, "preset": voice_preset.preset,
"state": "idle", "state": "idle",
**bind_meta,
}, },
) )
@ -111,10 +121,10 @@ async def voice_ws(websocket: WebSocket) -> None:
if mtype == "websocket.disconnect": if mtype == "websocket.disconnect":
break break
# ── 바이너리 = 오디오 청크 누적 ── # Binary frames are audio chunks.
if msg.get("bytes") is not None: if msg.get("bytes") is not None:
if not receiving: if not receiving:
# audio_start 없이 들어온 바이너리 — 관용적으로 자동 시작 # Be tolerant when audio arrives before audio_start.
receiving = True receiving = True
audio_buf.clear() audio_buf.clear()
await _safe_send_json(websocket, {"type": "state", "state": "listening"}) await _safe_send_json(websocket, {"type": "state", "state": "listening"})
@ -122,13 +132,13 @@ async def voice_ws(websocket: WebSocket) -> None:
if len(audio_buf) > _MAX_AUDIO_BYTES: if len(audio_buf) > _MAX_AUDIO_BYTES:
await _safe_send_json( await _safe_send_json(
websocket, websocket,
{"type": "error", "detail": "audio too large — 발화를 짧게 끊어 주세요"}, {"type": "error", "detail": "audio too large; please send a shorter utterance"},
) )
audio_buf.clear() audio_buf.clear()
receiving = False receiving = False
continue continue
# ── 텍스트 = JSON 제어 ── # Text frames are JSON controls.
text = msg.get("text") text = msg.get("text")
if text is None: if text is None:
continue continue
@ -149,6 +159,7 @@ async def voice_ws(websocket: WebSocket) -> None:
await _handle_utterance( await _handle_utterance(
websocket, websocket,
session_id=session_id, session_id=session_id,
principal=principal,
voice_preset=voice_preset, voice_preset=voice_preset,
audio=bytes(audio_buf), audio=bytes(audio_buf),
fmt=ctrl.get("format"), fmt=ctrl.get("format"),
@ -156,7 +167,7 @@ async def voice_ws(websocket: WebSocket) -> None:
audio_buf.clear() audio_buf.clear()
elif ctype == "text_turn": elif ctype == "text_turn":
# 음성 없이 텍스트만 보내는 경로(접근성/디버그): STT 건너뛰고 바로 턴. # Text-only path for accessibility and deterministic tests.
receiving = False receiving = False
audio_buf.clear() audio_buf.clear()
learner_text = (ctrl.get("text") or "").strip() learner_text = (ctrl.get("text") or "").strip()
@ -164,6 +175,7 @@ async def voice_ws(websocket: WebSocket) -> None:
await _run_turn_and_speak( await _run_turn_and_speak(
websocket, websocket,
session_id=session_id, session_id=session_id,
principal=principal,
voice_preset=voice_preset, voice_preset=voice_preset,
learner_text=learner_text, learner_text=learner_text,
) )
@ -176,30 +188,28 @@ async def voice_ws(websocket: WebSocket) -> None:
except WebSocketDisconnect: except WebSocketDisconnect:
pass pass
except Exception as e: # 어떤 예외도 WS 를 깨끗이 닫고 알린다(크래시 금지) except Exception as e:
await _safe_send_json(websocket, {"type": "error", "detail": f"voice ws error: {e}"}) await _safe_send_json(websocket, {"type": "error", "detail": f"voice ws error: {e}"})
finally: finally:
await _safe_close(websocket) await _safe_close(websocket)
# ════════════════════════════════════════════════════════════════════════════
# 발화 1건 처리 — STT → 턴 → TTS
# ════════════════════════════════════════════════════════════════════════════
async def _handle_utterance( async def _handle_utterance(
websocket: WebSocket, websocket: WebSocket,
*, *,
session_id: str, session_id: str,
principal: Principal,
voice_preset: VoicePreset, voice_preset: VoicePreset,
audio: bytes, audio: bytes,
fmt: Optional[str], fmt: Optional[str],
) -> None: ) -> None:
"""오디오 1발화 → STT → 상담 턴 → TTS 캐스케이드.""" """Transcribe one utterance, generate the client reply, then synthesize TTS."""
if not audio: if not audio:
await _safe_send_json(websocket, {"type": "transcript", "text": "", "final": True}) await _safe_send_json(websocket, {"type": "transcript", "text": "", "final": True})
await _safe_send_json(websocket, {"type": "state", "state": "idle"}) await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return return
# 1) STT (thinking 진입) # STT begins after the learner stops speaking.
await _safe_send_json(websocket, {"type": "state", "state": "thinking"}) await _safe_send_json(websocket, {"type": "state", "state": "thinking"})
filename, content_type = _audio_meta(fmt) filename, content_type = _audio_meta(fmt)
try: try:
@ -211,7 +221,7 @@ async def _handle_utterance(
await _safe_send_json(websocket, {"type": "state", "state": "idle"}) await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return return
except Exception as e: except Exception as e:
await _safe_send_json(websocket, {"type": "error", "detail": f"STT 실패: {e}"}) await _safe_send_json(websocket, {"type": "error", "detail": f"STT failed: {e}"})
await _safe_send_json(websocket, {"type": "state", "state": "idle"}) await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return return
@ -221,13 +231,13 @@ async def _handle_utterance(
{"type": "transcript", "text": learner_text, "final": True, "speaker": "counselor"}, {"type": "transcript", "text": learner_text, "final": True, "speaker": "counselor"},
) )
if not learner_text: if not learner_text:
# 무음/인식 실패 — 턴 진행 안 함
await _safe_send_json(websocket, {"type": "state", "state": "idle"}) await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return return
await _run_turn_and_speak( await _run_turn_and_speak(
websocket, websocket,
session_id=session_id, session_id=session_id,
principal=principal,
voice_preset=voice_preset, voice_preset=voice_preset,
learner_text=learner_text, learner_text=learner_text,
) )
@ -237,13 +247,14 @@ async def _run_turn_and_speak(
websocket: WebSocket, websocket: WebSocket,
*, *,
session_id: str, session_id: str,
principal: Principal,
voice_preset: VoicePreset, voice_preset: VoicePreset,
learner_text: str, learner_text: str,
) -> None: ) -> None:
"""상담 1턴(orchestrator) → 내담자 텍스트 → TTS 오디오/립싱크 힌트 역방향 전송.""" """Run one counseling turn and stream synthesized client speech."""
sess = store.get(session_id) sess, err = await _load_voice_session(session_id, principal)
if sess is None or sess.ended: if sess is None:
await _safe_send_json(websocket, {"type": "error", "detail": "세션 없음/종료됨"}) await _safe_send_json(websocket, {"type": "error", "detail": err or "session not found or ended"})
await _safe_send_json(websocket, {"type": "state", "state": "idle"}) await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return return
@ -260,19 +271,7 @@ async def _run_turn_and_speak(
) )
assert ctx.state_after is not None assert ctx.state_after is not None
# 학습자 발화 로깅(마스킹본) — sessions.py 패턴과 동일 # Voice needs the full client reply before TTS starts.
store.append_turn(
session_id,
TurnRecord(
turn_seq=ctx.state_after.turn_seq,
speaker="counselor",
stage=ctx.state_after.stage.value,
text=learner_text,
text_masked=ctx.learner_text_masked,
),
)
# 2) 내담자 AI 1턴(동기 — 음성은 TTS 전 전체 텍스트가 필요)
try: try:
result = await orchestrator.run_turn_generate(ctx, engine_client) result = await orchestrator.run_turn_generate(ctx, engine_client)
except EngineError as e: except EngineError as e:
@ -281,10 +280,22 @@ async def _run_turn_and_speak(
return return
reply = result.client_reply or "" reply = result.client_reply or ""
# 내담자 응답 로깅 + 상태 체크포인트 # Persist only after the client reply has been generated. A failed AI turn
# must not leave a learner-only transcript in review or history.
await _append_voice_turn(
sess,
TurnRecord(
turn_seq=ctx.state_after.turn_seq,
speaker="counselor",
stage=ctx.state_after.stage.value,
text=learner_text,
text_masked=ctx.learner_text_masked,
),
)
if reply: if reply:
store.append_turn( # Persist the generated client reply before TTS playback.
session_id, await _append_voice_turn(
sess,
TurnRecord( TurnRecord(
turn_seq=result.turn_seq, turn_seq=result.turn_seq,
speaker="client", speaker="client",
@ -293,9 +304,9 @@ async def _run_turn_and_speak(
text_masked=reply, text_masked=reply,
), ),
) )
store.update_state(session_id, result.state_after) await _update_voice_state(sess, result.state_after)
# 내담자 텍스트 이벤트(설계 §5.3 자막 — partial 없이 final) # Send the final client text before audio playback.
await _safe_send_json( await _safe_send_json(
websocket, websocket,
{ {
@ -314,7 +325,7 @@ async def _run_turn_and_speak(
await _safe_send_json(websocket, {"type": "state", "state": "idle"}) await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return return
# 3) TTS (speaking) — 청크별 메타 JSON(립싱크 rms) + 바이너리 오디오 # TTS speaking state comes before chunk metadata and binary audio.
await _safe_send_json( await _safe_send_json(
websocket, websocket,
{"type": "state", "state": "speaking", "voice": voice_preset.openai_voice}, {"type": "state", "state": "speaking", "voice": voice_preset.openai_voice},
@ -322,7 +333,7 @@ async def _run_turn_and_speak(
try: try:
n = 0 n = 0
async for ck in voice_service.synthesize_stream(reply, voice_preset): async for ck in voice_service.synthesize_stream(reply, voice_preset):
# 메타 먼저(프론트가 직후 바이너리와 짝지음) — 설계 §4.3 RMS 1채널 # Metadata precedes the binary chunk so the client can pair them.
await _safe_send_json( await _safe_send_json(
websocket, {"type": "tts_chunk", "seq": ck.seq, "rms": round(ck.rms, 4)} websocket, {"type": "tts_chunk", "seq": ck.seq, "rms": round(ck.rms, 4)}
) )
@ -332,46 +343,112 @@ async def _run_turn_and_speak(
except VoiceUnavailable as e: except VoiceUnavailable as e:
await _safe_send_json(websocket, {"type": "degraded", "reason": str(e)}) await _safe_send_json(websocket, {"type": "degraded", "reason": str(e)})
except Exception as e: except Exception as e:
await _safe_send_json(websocket, {"type": "error", "detail": f"TTS 실패: {e}"}) await _safe_send_json(websocket, {"type": "error", "detail": f"TTS failed: {e}"})
await _safe_send_json(websocket, {"type": "state", "state": "idle"}) await _safe_send_json(websocket, {"type": "state", "state": "idle"})
# ════════════════════════════════════════════════════════════════════════════ async def _load_voice_session(
# 세션 바인딩 / 메타 헬퍼 session_id: str,
# ════════════════════════════════════════════════════════════════════════════ principal: Principal,
def _bind_session( ) -> tuple[InProcSession | None, str | None]:
websocket: WebSocket, sess = await session_persistence.load_session(session_id, principal, allow_ended=True)
) -> tuple[Optional[str], Optional[VoicePreset], Optional[str]]: if sess is not None:
"""쿼리에서 세션을 바인딩(또는 시연 세션 생성)하고 voice preset 을 해석. store.put(sess)
elif runtime_fallback_allowed():
sess = store.get(session_id)
if sess is None:
return None, f"unknown session {session_id}"
if sess.learner_id != principal.user_id:
return None, "session does not belong to user"
if sess.ended:
return None, "session already ended"
return sess, None
우선순위:
?session_id=<hex> 기존 세션(REST 시작된) 음성 부착 async def _append_voice_turn(sess: InProcSession, turn: TurnRecord) -> None:
?persona_code=P1[&preset=] in-proc 시연 세션 생성(DB off 폴백) if await session_persistence.append_turn(
반환 (session_id, voice_preset, error). session_id=sess.session_id,
""" learner_id=sess.learner_id,
turn=turn,
):
sess.turns.append(turn)
store.put(sess)
return
require_runtime_fallback_allowed("voice session turn append")
store.append_turn(sess.session_id, turn)
async def _update_voice_state(
sess: InProcSession,
state: state_machine.SessionState,
) -> None:
if await session_persistence.update_state(
session_id=sess.session_id,
learner_id=sess.learner_id,
state=state,
):
sess.state = state
store.put(sess)
return
require_runtime_fallback_allowed("voice session state update")
store.update_state(sess.session_id, state)
async def _principal_from_websocket(websocket: WebSocket) -> Principal | None:
"""Restore the same server-side browser session used by REST routes."""
raw_cookie = websocket.cookies.get(settings.cookie_name)
if raw_cookie is None and settings.environment == "dev":
raw_cookie = websocket.cookies.get("vignette_sid")
session = await get_session(raw_cookie)
if session is None:
return None
try:
role = Role(session.role)
except ValueError:
return None
return Principal(
user_id=session.user_id,
role=role,
cohort_ids=session.cohort_ids,
email=session.email,
display_name=session.display_name,
)
async def _bind_session(
websocket: WebSocket,
principal: Principal,
) -> tuple[Optional[str], Optional[VoicePreset], Optional[str], dict[str, object]]:
"""Bind an existing session or create a dev-only voice session."""
qp = websocket.query_params qp = websocket.query_params
explicit_preset = qp.get("preset") explicit_preset = qp.get("preset")
session_id = qp.get("session_id") session_id = qp.get("session_id")
if session_id: if session_id:
sess = store.get(session_id) sess, err = await _load_voice_session(session_id, principal)
if sess is None: if sess is None:
return None, None, f"unknown session {session_id}" return None, None, err or f"unknown session {session_id}", {}
if sess.ended:
return None, None, "session already ended"
vp = resolve_voice(persona_code=sess.persona.code, preset=explicit_preset) vp = resolve_voice(persona_code=sess.persona.code, preset=explicit_preset)
return session_id, vp, None return session_id, vp, None, {"degraded": False, "persona_catalog_source": "session"}
# persona_code session creation is local-dev only. Production uses REST start.
if settings.environment != "dev":
return None, None, "session_id required", {}
# persona_code 로 시연 세션 생성(REST 미경유 음성 단독 데모)
persona_code = qp.get("persona_code") persona_code = qp.get("persona_code")
if not persona_code: if not persona_code:
return None, None, "session_id 또는 persona_code 쿼리 필요" return None, None, "session_id or persona_code query required", {}
card = persona.get_seed_persona(persona_code) try:
if card is None: catalog_persona = await get_catalog_persona(persona_code)
return None, None, f"unknown persona {persona_code}" except Exception:
return None, None, "persona catalog database unavailable", {}
from ..services import state_machine if catalog_persona is None:
return None, None, f"unknown persona {persona_code}", {}
card = catalog_persona.card
st = state_machine.init_state( st = state_machine.init_state(
base_resistance=card.base_resistance(), base_resistance=card.base_resistance(),
@ -379,19 +456,47 @@ def _bind_session(
decay_floor=card.decay_floor(), decay_floor=card.decay_floor(),
ideation_baseline=card.ideation_baseline(), ideation_baseline=card.ideation_baseline(),
) )
sess = await session_persistence.create_session(
learner_id=principal.user_id,
card=card,
theory_mode="humanistic",
state=st,
session_no=1,
carry_rapport=st.rapport_credit,
persona_id=catalog_persona.persona_id,
persona_version=catalog_persona.version,
)
session_source = "database"
if sess is None:
require_runtime_fallback_allowed("voice session creation")
sess = store.create( sess = store.create(
learner_id="dev-learner-voice", learner_id=principal.user_id,
persona=card, persona=card,
theory_mode="humanistic", theory_mode="humanistic",
state=st, state=st,
session_no=1, session_no=1,
carry_rapport=st.rapport_credit,
) )
session_source = "runtime"
else:
store.put(sess)
vp = resolve_voice(persona_code=card.code, preset=explicit_preset) vp = resolve_voice(persona_code=card.code, preset=explicit_preset)
return sess.session_id, vp, None degraded_reasons: list[str] = []
if catalog_persona.degraded:
degraded_reasons.append("카탈로그 원본을 확인하지 못해 음성 회기를 시작하지 않습니다")
if session_source == "runtime":
degraded_reasons.append("세션 저장소 연결 전까지 비영구 개발 런타임 기록을 사용합니다")
bind_meta = {
"degraded": bool(degraded_reasons),
"degraded_reason": "; ".join(degraded_reasons) if degraded_reasons else None,
"persona_catalog_source": catalog_persona.source,
"session_source": session_source,
}
return sess.session_id, vp, None, bind_meta
def _audio_meta(fmt: Optional[str]) -> tuple[str, str]: def _audio_meta(fmt: Optional[str]) -> tuple[str, str]:
"""클라가 알려준 포맷 → (filename, content_type). 기본 webm/opus.""" """Map the browser audio format to upload metadata."""
f = (fmt or "webm").lower().lstrip(".") f = (fmt or "webm").lower().lstrip(".")
table = { table = {
"webm": ("audio.webm", "audio/webm"), "webm": ("audio.webm", "audio/webm"),
@ -406,7 +511,6 @@ def _audio_meta(fmt: Optional[str]) -> tuple[str, str]:
return table.get(f, ("audio.webm", "audio/webm")) return table.get(f, ("audio.webm", "audio/webm"))
# ── 안전 송수신(연결 끊김 시 조용히 무시) ───────────────────────────────────
async def _safe_send_json(websocket: WebSocket, payload: dict) -> None: async def _safe_send_json(websocket: WebSocket, payload: dict) -> None:
if websocket.client_state != WebSocketState.CONNECTED: if websocket.client_state != WebSocketState.CONNECTED:
return return

View file

@ -0,0 +1,33 @@
"""Runtime fallback policy shared by auth/session routes.
The in-process stores exist only to keep local development usable when Docker or
NAS PostgreSQL is offline. Staging/prod must fail loudly instead of becoming a
second source of truth.
"""
from __future__ import annotations
from typing import NoReturn
from fastapi import HTTPException, status
from .config import settings
def runtime_fallback_allowed() -> bool:
return settings.environment == "dev"
def raise_runtime_fallback_disabled(feature: str) -> NoReturn:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=(
f"{feature} persistence unavailable; runtime fallback is disabled "
f"in {settings.environment}"
),
)
def require_runtime_fallback_allowed(feature: str) -> None:
if not runtime_fallback_allowed():
raise_runtime_fallback_disabled(feature)

View file

@ -301,9 +301,14 @@ def _client_state_candidates_block() -> str:
return f"[내담자 상태 후보 — code:라벨]\n- {items}" return f"[내담자 상태 후보 — code:라벨]\n- {items}"
# ─ few-shot 골든셋 예시(data/golden) — 발화→기법 라벨 일관성 보정 ──────────── # ─ few-shot 골든셋 예시(data/golden) — 명시적으로 켠 환경에서만 로딩 ─────────
# 윤찬 6결정 '재귀학습 few-shot부터' + taxonomy '0615 합성변형' 설계의 실제 활용. # 골든셋은 학습/평가 보정 자료이지 운영 런타임의 기본 데이터가 아니다.
# 컨테이너/배포에선 GOLDEN_DIR env 로 마운트 경로 지정. 없으면 graceful(빈 블록). _GOLDEN_FEWSHOT_ENABLED = os.environ.get("EVALUATOR_GOLDEN_FEWSHOT_ENABLED", "").lower() in {
"1",
"true",
"yes",
"on",
}
_GOLDEN_DIR = os.environ.get("GOLDEN_DIR") or os.path.join( _GOLDEN_DIR = os.environ.get("GOLDEN_DIR") or os.path.join(
os.path.dirname(__file__), "..", "..", "..", "..", "data", "golden" os.path.dirname(__file__), "..", "..", "..", "..", "data", "golden"
) )
@ -312,6 +317,8 @@ _GOLDEN_DIR = os.environ.get("GOLDEN_DIR") or os.path.join(
def _load_fewshot_examples(max_n: int = 6) -> list[dict[str, Any]]: def _load_fewshot_examples(max_n: int = 6) -> list[dict[str, Any]]:
"""골든셋에서 기법 다양성을 커버하는 상담자 발화 few-shot 예시(없으면 빈 리스트).""" """골든셋에서 기법 다양성을 커버하는 상담자 발화 few-shot 예시(없으면 빈 리스트)."""
out: list[dict[str, Any]] = [] out: list[dict[str, Any]] = []
if not _GOLDEN_FEWSHOT_ENABLED:
return out
seen: set[str] = set() seen: set[str] = set()
try: try:
files = sorted(f for f in os.listdir(_GOLDEN_DIR) if f.endswith(".jsonl")) files = sorted(f for f in os.listdir(_GOLDEN_DIR) if f.endswith(".jsonl"))

View file

@ -185,9 +185,9 @@ def estimate_chunk_rms(chunk: bytes) -> float:
class VoiceService: class VoiceService:
"""OpenAI STT/TTS 어댑터. 앱 수명주기 동안 1 인스턴스 재사용(httpx 풀 공유).""" """OpenAI STT/TTS 어댑터. 앱 수명주기 동안 1 인스턴스 재사용(httpx 풀 공유)."""
def __init__(self, api_key: Optional[str] = None, base_url: str = OPENAI_BASE_URL) -> None: def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None) -> None:
self._api_key = (api_key if api_key is not None else settings.openai_api_key) or "" self._api_key = (api_key if api_key is not None else settings.openai_api_key) or ""
self._base_url = base_url.rstrip("/") self._base_url = (base_url or settings.openai_base_url or OPENAI_BASE_URL).rstrip("/")
self._client: Optional[httpx.AsyncClient] = None self._client: Optional[httpx.AsyncClient] = None
# ── 수명주기 ────────────────────────────────────────── # ── 수명주기 ──────────────────────────────────────────

View file

@ -0,0 +1,656 @@
"""DB-backed counseling session persistence with in-process fallback support."""
from __future__ import annotations
import time
import uuid
from datetime import datetime, timezone
from typing import Any, Iterable
from .db import acquire, get_pool
from .deps import Principal
from .config import settings
from .persona_repository import SEED_VERSION, card_from_row, seed_fallback_persona, seed_persona_id
from .runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed
from .services import memory, state_machine
from .services.persona import PersonaCard
from .store import InProcSession, TurnRecord
_EVALUATION_CACHE: dict[str, dict[str, Any]] = {}
_JOINED_CARD_COLUMNS = (
"card_persona_id",
"card_code",
"card_version",
"card_status",
"card_display_name",
"card_difficulty",
"card_theory_target",
"card_demographics",
"card_presenting",
"card_history",
"card_big5",
"card_resistance",
"card_speech_style",
"card_affect_baseline",
"card_ccd",
"card_dsm5_dimensional",
"card_source_provenance",
"card_is_synthetic",
)
def _ts(value: datetime | None) -> float | None:
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.timestamp()
def _row_value(row, key: str):
try:
return row[key]
except Exception:
return None
def _card_from_joined_session_row(row) -> PersonaCard | None:
if _row_value(row, "card_persona_id") is None:
return None
card_row = {
key.removeprefix("card_"): _row_value(row, key)
for key in _JOINED_CARD_COLUMNS
}
return card_from_row(card_row)
def _stage(stage: object) -> str:
return getattr(stage, "value", str(stage))
def _state_from_row(row, card: PersonaCard) -> state_machine.SessionState:
if row is None:
return state_machine.init_state(
base_resistance=card.base_resistance(),
unlock_rate=card.unlock_rate(),
decay_floor=card.decay_floor(),
ideation_baseline=card.ideation_baseline(),
)
return state_machine.SessionState(
stage=state_machine.Stage(row["stage"]),
turn_seq=int(row["turn_seq"]),
effective_openness=float(row["effective_openness"]),
rapport_credit=float(row["rapport_credit"]),
resistance=float(row["resistance"]),
ideation_stage=int(row["ideation_stage"]),
turns_in_stage=int(row["turns_in_stage"] or 0),
affect_state=dict(row["affect_state"] or {}),
)
def _turn_from_row(row) -> TurnRecord:
created_at = _ts(row["created_at"]) or time.time()
return TurnRecord(
turn_seq=int(row["seq"]),
speaker=row["speaker"],
stage=row["stage"],
text=row["text"] or row["text_masked"] or "",
text_masked=row["text_masked"] or row["text"] or "",
created_at=created_at,
)
async def ensure_review_tables() -> None:
"""Create runtime review/evaluation storage when the DB role allows it."""
try:
get_pool()
async with acquire(role="admin") as conn:
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS app.session_evaluation (
session_id UUID PRIMARY KEY REFERENCES app.sessions(id) ON DELETE CASCADE,
status TEXT NOT NULL CHECK (status IN ('ready','degraded','error')),
source TEXT NOT NULL,
scope TEXT NOT NULL,
stage TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""
)
await conn.execute(
"""
ALTER TABLE app.session_evaluation ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_session_evaluation_select ON app.session_evaluation;
DROP POLICY IF EXISTS p_session_evaluation_insert ON app.session_evaluation;
DROP POLICY IF EXISTS p_session_evaluation_update ON app.session_evaluation;
DROP POLICY IF EXISTS p_session_evaluation_delete ON app.session_evaluation;
CREATE POLICY p_session_evaluation_select
ON app.session_evaluation FOR SELECT USING (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
OR EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.session_evaluation.session_id
AND s.learner_id = app.current_uid()
)
);
CREATE POLICY p_session_evaluation_insert
ON app.session_evaluation FOR INSERT WITH CHECK (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
OR EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.session_evaluation.session_id
AND s.learner_id = app.current_uid()
)
);
CREATE POLICY p_session_evaluation_update
ON app.session_evaluation FOR UPDATE USING (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
OR EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.session_evaluation.session_id
AND s.learner_id = app.current_uid()
)
) WITH CHECK (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
OR EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.session_evaluation.session_id
AND s.learner_id = app.current_uid()
)
);
CREATE POLICY p_session_evaluation_delete
ON app.session_evaluation FOR DELETE USING (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
OR EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.session_evaluation.session_id
AND s.learner_id = app.current_uid()
)
)
"""
)
except Exception:
return
async def save_session_evaluation(
*,
session_id: str,
learner_id: str,
status: str,
source: str,
scope: str,
stage: str,
payload: dict[str, Any],
error: str | None = None,
) -> bool:
record = {
"status": status,
"source": source,
"scope": scope,
"stage": stage,
"payload": payload,
"error": error,
}
if runtime_fallback_allowed():
_EVALUATION_CACHE[session_id] = record
try:
get_pool()
async with acquire(role="learner", user_id=learner_id) as conn:
await conn.execute(
"""
INSERT INTO app.session_evaluation (
session_id, status, source, scope, stage, payload, error,
created_at, updated_at
)
VALUES ($1::uuid, $2, $3, $4, $5, $6::jsonb, $7, now(), now())
ON CONFLICT (session_id) DO UPDATE SET
status = EXCLUDED.status,
source = EXCLUDED.source,
scope = EXCLUDED.scope,
stage = EXCLUDED.stage,
payload = EXCLUDED.payload,
error = EXCLUDED.error,
updated_at = now()
""",
session_id,
status,
source,
scope,
stage,
payload,
error,
)
return True
except Exception:
require_runtime_fallback_allowed("session evaluation")
return False
async def load_session_evaluation(
session_id: str,
principal: Principal,
) -> tuple[dict[str, Any] | None, bool]:
try:
get_pool()
async with acquire(
role=principal.role.value,
user_id=principal.user_id,
cohort_ids=principal.cohort_ids,
) as conn:
row = await conn.fetchrow(
"""
SELECT status, source, scope, stage, payload, error, updated_at
FROM app.session_evaluation
WHERE session_id = $1::uuid
""",
session_id,
)
if row is None:
return (
_EVALUATION_CACHE.get(session_id) if runtime_fallback_allowed() else None
), False
return {
"status": row["status"],
"source": row["source"],
"scope": row["scope"],
"stage": row["stage"],
"payload": dict(row["payload"] or {}),
"error": row["error"],
"updated_at": _ts(row["updated_at"]),
}, True
except Exception:
require_runtime_fallback_allowed("session evaluation")
return _EVALUATION_CACHE.get(session_id), False
def _session_from_rows(row, state_row, turn_rows: Iterable) -> InProcSession | None:
card = _card_from_joined_session_row(row)
if card is None:
if not settings.allow_seed_persona_fallback:
return None
persona_code = (row["persona_code"] or "").upper()
legacy_entry = seed_fallback_persona(persona_code)
if legacy_entry is None:
return None
card = legacy_entry.card
persona_code = card.code
ended_at = _ts(row["ended_at"])
started_at = _ts(row["started_at"]) or time.time()
return InProcSession(
session_id=str(row["id"]),
case_id=str(row["runtime_case_id"] or row["case_id"] or row["id"]),
learner_id=str(row["learner_id"]),
persona_code=persona_code,
theory_mode=row["theory_mode"] or "humanistic",
persona=card,
state=_state_from_row(state_row, card),
session_no=int(row["session_no"] or 1),
created_at=started_at,
ended_at=ended_at,
turns=[_turn_from_row(turn_row) for turn_row in turn_rows],
ended=ended_at is not None,
prev_rapport_credit=float(row["prev_rapport_credit"] or 0.0),
)
async def _upsert_state(conn, session_id: str, state: state_machine.SessionState) -> None:
await conn.execute(
"""
INSERT INTO app.session_state (
session_id, stage, turn_seq, effective_openness, rapport_credit,
resistance, ideation_stage, turns_in_stage, affect_state, updated_at
)
VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, now())
ON CONFLICT (session_id) DO UPDATE SET
stage = EXCLUDED.stage,
turn_seq = EXCLUDED.turn_seq,
effective_openness = EXCLUDED.effective_openness,
rapport_credit = EXCLUDED.rapport_credit,
resistance = EXCLUDED.resistance,
ideation_stage = EXCLUDED.ideation_stage,
turns_in_stage = EXCLUDED.turns_in_stage,
affect_state = EXCLUDED.affect_state,
updated_at = now()
""",
session_id,
_stage(state.stage),
state.turn_seq,
state.effective_openness,
state.rapport_credit,
state.resistance,
state.ideation_stage,
state.turns_in_stage,
state.affect_state,
)
async def create_session(
*,
learner_id: str,
card: PersonaCard,
theory_mode: str,
state: state_machine.SessionState,
session_no: int = 1,
carry_rapport: float = 0.0,
persona_id: str | None = None,
persona_version: int | None = None,
) -> InProcSession | None:
"""Create a DB-backed session, returning None when DB persistence is unavailable."""
try:
get_pool()
runtime_case_id = str(uuid.uuid4())
pinned_persona_id = persona_id or seed_persona_id(card.code)
pinned_persona_version = persona_version or SEED_VERSION
async with acquire(role="learner", user_id=learner_id) as conn:
row = await conn.fetchrow(
"""
INSERT INTO app.sessions (
runtime_case_id, learner_id, persona_id, persona_version,
persona_code, persona_display_name, persona_difficulty,
session_no, theory_mode, stage_path, prev_rapport_credit
)
VALUES (
$1::uuid, $2::uuid, $3::uuid, $4,
$5, $6, $7,
$8, $9, '[]'::jsonb, $10
)
RETURNING id, runtime_case_id, case_id, learner_id, persona_code,
session_no, theory_mode, started_at, ended_at, prev_rapport_credit
""",
runtime_case_id,
learner_id,
pinned_persona_id,
pinned_persona_version,
card.code,
card.display_name,
card.difficulty,
session_no,
theory_mode,
carry_rapport,
)
await _upsert_state(conn, str(row["id"]), state)
return InProcSession(
session_id=str(row["id"]),
case_id=runtime_case_id,
learner_id=learner_id,
persona_code=card.code,
theory_mode=theory_mode,
persona=card,
state=state,
session_no=session_no,
created_at=_ts(row["started_at"]) or time.time(),
ended_at=None,
turns=[],
ended=False,
prev_rapport_credit=carry_rapport,
)
except Exception:
require_runtime_fallback_allowed("session creation")
return None
async def load_session(
session_id: str,
principal: Principal,
*,
allow_ended: bool = False,
) -> InProcSession | None:
try:
get_pool()
async with acquire(
role=principal.role.value,
user_id=principal.user_id,
cohort_ids=principal.cohort_ids,
) as conn:
row = await conn.fetchrow(
"""
SELECT
s.id, s.runtime_case_id, s.case_id, s.learner_id, s.persona_code,
s.session_no, s.theory_mode, s.started_at, s.ended_at,
s.prev_rapport_credit,
pc.persona_id AS card_persona_id,
pc.code AS card_code,
pc.version AS card_version,
pc.status AS card_status,
pc.display_name AS card_display_name,
pc.difficulty AS card_difficulty,
pc.theory_target AS card_theory_target,
pc.demographics AS card_demographics,
pc.presenting AS card_presenting,
pc.history AS card_history,
pc.big5 AS card_big5,
pc.resistance AS card_resistance,
pc.speech_style AS card_speech_style,
pc.affect_baseline AS card_affect_baseline,
pc.ccd AS card_ccd,
pc.dsm5_dimensional AS card_dsm5_dimensional,
pc.source_provenance AS card_source_provenance,
pc.is_synthetic AS card_is_synthetic
FROM app.sessions s
LEFT JOIN app.persona_card pc
ON pc.persona_id = s.persona_id
AND pc.version = s.persona_version
WHERE s.id = $1::uuid
""",
session_id,
)
if row is None:
return None
if row["ended_at"] is not None and not allow_ended:
return None
state_row = await conn.fetchrow(
"""
SELECT stage, turn_seq, effective_openness, rapport_credit, resistance,
ideation_stage, turns_in_stage, affect_state
FROM app.session_state
WHERE session_id = $1::uuid
""",
session_id,
)
turn_rows = await conn.fetch(
"""
SELECT seq, speaker, stage, text, text_masked, created_at
FROM app.turns
WHERE session_id = $1::uuid
ORDER BY seq
""",
session_id,
)
return _session_from_rows(row, state_row, turn_rows)
except Exception:
require_runtime_fallback_allowed("session load")
return None
async def append_turn(
*,
session_id: str,
learner_id: str,
turn: TurnRecord,
) -> bool:
try:
get_pool()
async with acquire(role="learner", user_id=learner_id) as conn:
locked = await conn.fetchval(
"SELECT id FROM app.sessions WHERE id = $1::uuid FOR UPDATE",
session_id,
)
if locked is None:
return False
seq = int(
await conn.fetchval(
"SELECT COALESCE(MAX(seq), 0) + 1 FROM app.turns WHERE session_id = $1::uuid",
session_id,
)
or 1
)
await conn.execute(
"""
INSERT INTO app.turns (
session_id, seq, speaker, stage, text, text_masked, actor_kind, visible_to
)
VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8::text[])
ON CONFLICT (session_id, seq) DO NOTHING
""",
session_id,
seq,
turn.speaker,
turn.stage,
turn.text_masked,
turn.text_masked,
"human_learner" if turn.speaker == "counselor" else "client_ai",
["client", "counselor", "evaluator"],
)
return True
except Exception:
require_runtime_fallback_allowed("session turn append")
return False
async def update_state(
*,
session_id: str,
learner_id: str,
state: state_machine.SessionState,
) -> bool:
try:
get_pool()
async with acquire(role="learner", user_id=learner_id) as conn:
await _upsert_state(conn, session_id, state)
return True
except Exception:
require_runtime_fallback_allowed("session state update")
return False
async def end_session(sess: InProcSession, carry: memory.CarryOver) -> bool:
try:
get_pool()
digest = (
f"회기 축어록 {len(sess.turns)}개가 저장되었습니다. 정밀 리뷰는 생성 대기 중입니다."
if sess.turns
else "실제 발화가 없어 요약을 생성하지 않았습니다."
)
async with acquire(role="learner", user_id=sess.learner_id) as conn:
await conn.execute(
"""
UPDATE app.sessions
SET ended_at = COALESCE(ended_at, now())
WHERE id = $1::uuid
""",
sess.session_id,
)
await _upsert_state(conn, sess.session_id, sess.state)
await conn.execute(
"""
INSERT INTO app.session_summary (
session_id, case_id, session_no, end_state, rapport_delta,
digest, open_threads, created_at
)
VALUES ($1::uuid, $2::uuid, $3, $4::jsonb, $5, $6, $7::jsonb, now())
ON CONFLICT (session_id) DO UPDATE SET
end_state = EXCLUDED.end_state,
rapport_delta = EXCLUDED.rapport_delta,
digest = EXCLUDED.digest,
open_threads = EXCLUDED.open_threads
""",
sess.session_id,
sess.case_id,
sess.session_no,
carry.end_state,
carry.rapport_delta,
digest,
list(carry.compression_job.open_threads if carry.compression_job else []),
)
return True
except Exception:
require_runtime_fallback_allowed("session end")
return False
async def list_sessions(principal: Principal) -> tuple[list[InProcSession], bool]:
try:
get_pool()
learner_filter = "WHERE s.learner_id = $1::uuid" if principal.role.value == "learner" else ""
query_args = [principal.user_id] if principal.role.value == "learner" else []
async with acquire(
role=principal.role.value,
user_id=principal.user_id,
cohort_ids=principal.cohort_ids,
) as conn:
rows = await conn.fetch(
f"""
SELECT
s.id, s.runtime_case_id, s.case_id, s.learner_id, s.persona_code,
s.session_no, s.theory_mode, s.started_at, s.ended_at,
s.prev_rapport_credit,
pc.persona_id AS card_persona_id,
pc.code AS card_code,
pc.version AS card_version,
pc.status AS card_status,
pc.display_name AS card_display_name,
pc.difficulty AS card_difficulty,
pc.theory_target AS card_theory_target,
pc.demographics AS card_demographics,
pc.presenting AS card_presenting,
pc.history AS card_history,
pc.big5 AS card_big5,
pc.resistance AS card_resistance,
pc.speech_style AS card_speech_style,
pc.affect_baseline AS card_affect_baseline,
pc.ccd AS card_ccd,
pc.dsm5_dimensional AS card_dsm5_dimensional,
pc.source_provenance AS card_source_provenance,
pc.is_synthetic AS card_is_synthetic
FROM app.sessions s
LEFT JOIN app.persona_card pc
ON pc.persona_id = s.persona_id
AND pc.version = s.persona_version
{learner_filter}
ORDER BY s.started_at DESC
LIMIT 100
""",
*query_args,
)
sessions: list[InProcSession] = []
for row in rows:
session_id = str(row["id"])
state_row = await conn.fetchrow(
"""
SELECT stage, turn_seq, effective_openness, rapport_credit, resistance,
ideation_stage, turns_in_stage, affect_state
FROM app.session_state
WHERE session_id = $1::uuid
""",
session_id,
)
turn_rows = await conn.fetch(
"""
SELECT seq, speaker, stage, text, text_masked, created_at
FROM app.turns
WHERE session_id = $1::uuid
ORDER BY seq
""",
session_id,
)
sess = _session_from_rows(row, state_row, turn_rows)
if sess is not None:
sessions.append(sess)
return sessions, True
except Exception:
require_runtime_fallback_allowed("session list")
return [], False

View file

@ -42,6 +42,8 @@ class InProcSession:
persona: PersonaCard persona: PersonaCard
state: SessionState state: SessionState
session_no: int = 1 session_no: int = 1
created_at: float = field(default_factory=time.time)
ended_at: Optional[float] = None
turns: list[TurnRecord] = field(default_factory=list) turns: list[TurnRecord] = field(default_factory=list)
ended: bool = False ended: bool = False
prev_rapport_credit: float = 0.0 # carry-over delta 계산용 prev_rapport_credit: float = 0.0 # carry-over delta 계산용
@ -55,7 +57,7 @@ class InProcSession:
class SessionStore: class SessionStore:
"""in-memory 세션 레지스트리. DB degraded 시 SoR 대용.""" """in-memory 세션 저장소. DB degraded 시 SoR 대용."""
def __init__(self) -> None: def __init__(self) -> None:
self._sessions: dict[str, InProcSession] = {} self._sessions: dict[str, InProcSession] = {}
@ -89,6 +91,12 @@ class SessionStore:
def get(self, session_id: str) -> Optional[InProcSession]: def get(self, session_id: str) -> Optional[InProcSession]:
return self._sessions.get(session_id) return self._sessions.get(session_id)
def put(self, session: InProcSession) -> None:
self._sessions[session.session_id] = session
def list(self) -> list[InProcSession]:
return list(self._sessions.values())
def append_turn(self, session_id: str, turn: TurnRecord) -> None: def append_turn(self, session_id: str, turn: TurnRecord) -> None:
s = self._sessions.get(session_id) s = self._sessions.get(session_id)
if s is not None: if s is not None:
@ -103,6 +111,7 @@ class SessionStore:
s = self._sessions.get(session_id) s = self._sessions.get(session_id)
if s is not None: if s is not None:
s.ended = True s.ended = True
s.ended_at = time.time()
return s return s
def remove(self, session_id: str) -> None: def remove(self, session_id: str) -> None:

View file

@ -0,0 +1,269 @@
"""Regression tests for DB outage runtime fallback policy."""
from __future__ import annotations
import unittest
from contextlib import contextmanager
from unittest.mock import AsyncMock, patch
from fastapi import HTTPException
from . import auth_sessions, session_persistence
from .config import Settings, settings
from .deps import Principal, Role
from .routes import admin as admin_routes
from .routes import eval as eval_routes
from .routes import kb as kb_routes
from .routes import users as users_routes
@contextmanager
def environment(value: str):
previous = settings.environment
settings.environment = value
try:
yield
finally:
settings.environment = previous
class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
auth_sessions._sessions.clear()
auth_sessions._users.clear()
auth_sessions._email_index.clear()
auth_sessions._inactive_emails.clear()
users_routes._preferences.clear()
admin_routes._ENGINE_CONFIG = None
async def asyncTearDown(self) -> None:
admin_routes._ENGINE_CONFIG = None
async def test_dev_allows_auth_registry_fallback_when_db_pool_missing(self) -> None:
with environment("dev"):
users, durable = await auth_sessions.list_managed_users()
self.assertFalse(durable)
self.assertEqual(users, [])
async def test_staging_blocks_auth_registry_fallback_when_db_pool_missing(self) -> None:
with environment("staging"):
with self.assertRaises(HTTPException) as caught:
await auth_sessions.list_managed_users()
self.assertEqual(caught.exception.status_code, 503)
self.assertIn("runtime fallback is disabled in staging", caught.exception.detail)
async def test_prod_blocks_browser_session_creation_when_db_pool_missing(self) -> None:
with environment("prod"):
with self.assertRaises(HTTPException) as caught:
await auth_sessions.create_session(
email="learner@hs.ac.kr",
display_name="Learner",
role="learner",
)
self.assertEqual(caught.exception.status_code, 503)
self.assertIn("runtime fallback is disabled in prod", caught.exception.detail)
async def test_staging_blocks_session_store_fallback_when_db_pool_missing(self) -> None:
principal = Principal(
user_id="00000000-0000-0000-0000-000000000001",
role=Role.LEARNER,
cohort_ids=[],
email="learner@hs.ac.kr",
display_name="Learner",
)
with environment("staging"):
with self.assertRaises(HTTPException) as caught:
await session_persistence.list_sessions(principal)
self.assertEqual(caught.exception.status_code, 503)
self.assertIn("runtime fallback is disabled in staging", caught.exception.detail)
async def test_staging_blocks_eval_store_cache_fallback_when_db_pool_missing(self) -> None:
principal = Principal(
user_id="00000000-0000-0000-0000-000000000002",
role=Role.TEACHER,
cohort_ids=[],
email="teacher@hs.ac.kr",
display_name="Teacher",
)
with environment("staging"):
with self.assertRaises(HTTPException) as caught:
await eval_routes.get_session_evaluation("missing-session-id", principal)
self.assertEqual(caught.exception.status_code, 503)
self.assertIn("runtime fallback is disabled in staging", caught.exception.detail)
async def test_staging_kb_search_fails_closed_when_db_pool_missing(self) -> None:
with environment("staging"):
with self.assertRaises(HTTPException) as caught:
await kb_routes.search(kb_routes.KBSearchRequest(query="rapport"))
self.assertEqual(caught.exception.status_code, 503)
self.assertIn("DB not ready", caught.exception.detail)
async def test_staging_blocks_user_preferences_fallback_when_db_pool_missing(self) -> None:
principal = Principal(
user_id="00000000-0000-0000-0000-000000000003",
role=Role.LEARNER,
cohort_ids=[],
email="learner@hs.ac.kr",
display_name="Learner",
)
with environment("staging"):
with self.assertRaises(HTTPException) as caught:
await users_routes.get_preferences(principal)
self.assertEqual(caught.exception.status_code, 503)
self.assertIn("runtime fallback is disabled in staging", caught.exception.detail)
async def test_dev_allows_user_preferences_fallback_when_db_pool_missing(self) -> None:
principal = Principal(
user_id="00000000-0000-0000-0000-000000000004",
role=Role.LEARNER,
cohort_ids=[],
email="learner@hs.ac.kr",
display_name="Learner",
)
with environment("dev"):
prefs = await users_routes.get_preferences(principal)
self.assertEqual(prefs.voice_preset_id, "soft-young-fem")
async def test_staging_blocks_admin_engine_config_default_when_row_missing(self) -> None:
class EmptyConfigConn:
async def fetchrow(self, *args, **kwargs):
return None
class EmptyConfigAcquire:
async def __aenter__(self):
return EmptyConfigConn()
async def __aexit__(self, exc_type, exc, tb):
return None
class EmptyConfigPool:
def acquire(self):
return EmptyConfigAcquire()
with environment("staging"), patch.object(admin_routes, "get_pool", return_value=EmptyConfigPool()):
with self.assertRaises(HTTPException) as caught:
await admin_routes._current_engine_config()
self.assertEqual(caught.exception.status_code, 503)
self.assertIn("runtime fallback is disabled in staging", caught.exception.detail)
async def test_prod_admin_health_marks_db_down_when_persistence_unavailable(self) -> None:
principal = Principal(
user_id="00000000-0000-0000-0000-000000000005",
role=Role.ADMIN,
cohort_ids=[],
email="admin@twentyoz.kr",
display_name="Admin",
)
engine_config = admin_routes.AdminEngineConfigResponse(
engine_mode="claude_cli",
engine_url="http://127.0.0.1:9099",
model="gateway-default",
durable=True,
source="database",
)
with (
environment("prod"),
patch.object(admin_routes, "_current_engine_config", AsyncMock(return_value=engine_config)),
patch.object(admin_routes, "healthcheck", AsyncMock(return_value=False)),
patch.object(admin_routes.engine_client, "health_detail", AsyncMock(return_value={"ok": True})),
patch.object(admin_routes.voice_service, "is_available", return_value=False),
):
health = await admin_routes.admin_health(principal)
db = next(service for service in health.services if service.key == "db")
self.assertEqual(db.status, "down")
self.assertEqual(db.metric, "저장소 중단")
self.assertIn("DB 저장소", db.detail)
async def test_dev_admin_health_labels_db_fallback_as_non_durable_runtime(self) -> None:
principal = Principal(
user_id="00000000-0000-0000-0000-000000000006",
role=Role.ADMIN,
cohort_ids=[],
email="admin@twentyoz.kr",
display_name="Admin",
)
engine_config = admin_routes.AdminEngineConfigResponse(
engine_mode="claude_cli",
engine_url="http://127.0.0.1:9099",
model="gateway-default",
durable=False,
source="runtime_cache",
)
with (
environment("dev"),
patch.object(admin_routes, "_current_engine_config", AsyncMock(return_value=engine_config)),
patch.object(admin_routes, "healthcheck", AsyncMock(return_value=False)),
patch.object(admin_routes.engine_client, "health_detail", AsyncMock(return_value={"ok": True})),
patch.object(admin_routes.voice_service, "is_available", return_value=False),
):
health = await admin_routes.admin_health(principal)
db = next(service for service in health.services if service.key == "db")
self.assertEqual(db.status, "degraded")
self.assertEqual(db.metric, "비영구 런타임 기록")
self.assertIn("비영구 개발 런타임 기록", db.detail)
def test_non_dev_rejects_fixture_runtime_flags(self) -> None:
with self.assertRaises(ValueError) as caught:
Settings(
environment="staging",
auth_dev_login_enabled=True,
auto_seed_personas=True,
allow_seed_persona_fallback=True,
)
self.assertIn("AUTH_DEV_LOGIN_ENABLED", str(caught.exception))
self.assertIn("AUTO_SEED_PERSONAS", str(caught.exception))
self.assertIn("ALLOW_SEED_PERSONA_FALLBACK", str(caught.exception))
def test_non_dev_rejects_missing_public_runtime_config(self) -> None:
with self.assertRaises(ValueError) as caught:
Settings(
environment="prod",
auth_dev_login_enabled=False,
auto_seed_personas=False,
allow_seed_persona_fallback=False,
oauth_google_client_id="",
oauth_google_client_secret="",
session_secret="dev-insecure-change-me",
frontend_base_url="http://localhost:5173",
cors_origins=["http://localhost:5173"],
)
error = str(caught.exception)
self.assertIn("OAUTH_GOOGLE_CLIENT_ID", error)
self.assertIn("OAUTH_GOOGLE_CLIENT_SECRET", error)
self.assertIn("SESSION_SECRET", error)
self.assertIn("FRONTEND_BASE_URL", error)
def test_non_dev_accepts_public_runtime_config(self) -> None:
cfg = Settings(
environment="staging",
auth_dev_login_enabled=False,
auto_seed_personas=False,
allow_seed_persona_fallback=False,
session_secret="staging-secret-change-me",
oauth_google_client_id="google-client-id",
oauth_google_client_secret="google-client-secret",
frontend_base_url="https://vignette.chanpaca.net",
cors_origins=["https://vignette.chanpaca.net"],
)
self.assertEqual(cfg.environment, "staging")
self.assertEqual(cfg.cors_origins, ["https://vignette.chanpaca.net"])
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,143 @@
"""Regression tests for session turn persistence ordering."""
from __future__ import annotations
import unittest
from unittest.mock import AsyncMock, patch
from .deps import Principal, Role
from .engine_client import EngineError
from .routes import sessions
from .routes import voice as voice_routes
from .services import orchestrator, persona as persona_service, state_machine
from .services.voice import VoicePreset
from .store import InProcSession, store
def _principal() -> Principal:
return Principal(
user_id="00000000-0000-0000-0000-000000000101",
role=Role.LEARNER,
cohort_ids=[],
email="turn-test@hs.ac.kr",
display_name="Turn Test",
)
def _session(principal: Principal) -> InProcSession:
card = persona_service.P1
sess = InProcSession(
session_id="turn-persistence-session",
case_id="turn-persistence-case",
learner_id=principal.user_id,
persona_code=card.code,
theory_mode="humanistic",
persona=card,
state=state_machine.SessionState(
resistance=card.base_resistance(),
ideation_stage=card.ideation_baseline(),
),
)
store.put(sess)
return sess
async def _consume_event_source(response: object) -> bytes:
body = bytearray()
iterator = getattr(response, "body_iterator")
async for chunk in iterator:
if isinstance(chunk, str):
body.extend(chunk.encode("utf-8"))
elif isinstance(chunk, (bytes, bytearray)):
body.extend(chunk)
else:
body.extend(str(chunk).encode("utf-8"))
return bytes(body)
class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
store._sessions.clear()
async def asyncTearDown(self) -> None:
store._sessions.clear()
async def test_generate_turn_engine_failure_does_not_append_learner_turn(self) -> None:
principal = _principal()
sess = _session(principal)
with patch.object(
sessions.orchestrator,
"run_turn_generate",
AsyncMock(side_effect=EngineError("engine unavailable: test")),
):
with self.assertRaises(sessions.HTTPException) as caught:
await sessions.submit_turn(
sess.session_id,
sessions.TurnRequest(text="실패한 발화"),
principal,
)
self.assertEqual(caught.exception.status_code, 503)
self.assertEqual(sess.turns, [])
async def test_stream_turn_engine_error_event_does_not_append_partial_turns(self) -> None:
principal = _principal()
sess = _session(principal)
async def failing_stream(*args, **kwargs):
yield orchestrator.StreamEvent("token", {"text": "부분 응답"})
yield orchestrator.StreamEvent("error", {"detail": "engine unavailable: stream"})
with patch.object(sessions.orchestrator, "run_turn_stream", failing_stream):
response = await sessions.stream_turn(
sess.session_id,
sessions.TurnRequest(text="스트림 실패 발화"),
principal,
)
body = await _consume_event_source(response)
self.assertIn(b"engine unavailable: stream", body)
self.assertEqual(sess.turns, [])
async def test_voice_turn_engine_failure_does_not_append_learner_turn(self) -> None:
class FakeWebSocket:
def __init__(self) -> None:
self.messages: list[dict[str, object]] = []
self.client_state = voice_routes.WebSocketState.CONNECTED
async def send_text(self, data: str) -> None:
import json
self.messages.append(json.loads(data))
principal = _principal()
sess = _session(principal)
websocket = FakeWebSocket()
with patch.object(
voice_routes.orchestrator,
"run_turn_generate",
AsyncMock(side_effect=EngineError("voice engine unavailable")),
):
await voice_routes._run_turn_and_speak(
websocket, # type: ignore[arg-type]
session_id=sess.session_id,
principal=principal,
voice_preset=VoicePreset(preset="neutral", openai_voice="sage"),
learner_text="음성 실패 발화",
)
self.assertTrue(
any(
message.get("type") == "error"
and "engine unavailable" in str(message.get("detail"))
for message in websocket.messages
),
websocket.messages,
)
self.assertEqual(sess.turns, [])
if __name__ == "__main__":
unittest.main()

View file

@ -11,17 +11,21 @@ Vignette 엔진 게이트웨이 — 로컬 claude -p(Opus 4.8) 상주 멀티턴
import asyncio import asyncio
import json import json
import os import os
import time
import uuid import uuid
from typing import Any, Literal, Optional from typing import Any, Literal, Optional
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from fastapi.responses import StreamingResponse from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
CLAUDE_BIN = os.environ.get("CLAUDE_BIN", "claude") CLAUDE_BIN = os.environ.get("CLAUDE_BIN", "claude")
DEFAULT_MODEL = os.environ.get("ENGINE_MODEL", "") # 비우면 CLI 기본(Opus 4.8) DEFAULT_MODEL = os.environ.get("ENGINE_MODEL", "") # 비우면 CLI 기본(Opus 4.8)
FALLBACK_MODEL = os.environ.get("ENGINE_FALLBACK_MODEL", "") FALLBACK_MODEL = os.environ.get("ENGINE_FALLBACK_MODEL", "")
DEFAULT_BUDGET = float(os.environ.get("SESSION_BUDGET_USD", "5.0")) DEFAULT_BUDGET = float(os.environ.get("SESSION_BUDGET_USD", "5.0"))
READY_TTL_SECONDS = float(os.environ.get("ENGINE_READY_TTL_SECONDS", "30"))
READY_TIMEOUT_SECONDS = float(os.environ.get("ENGINE_READY_TIMEOUT_SECONDS", "20"))
READY_BUDGET_USD = float(os.environ.get("ENGINE_READY_BUDGET_USD", "0.5"))
BASE_ARGS = [ BASE_ARGS = [
"-p", "-p",
@ -32,13 +36,26 @@ BASE_ARGS = [
] ]
def _model_override(model: Optional[str]) -> Optional[str]:
value = (model or "").strip()
if not value or value == "gateway-default":
return None
return value
class EngineSession: class EngineSession:
"""claude -p 상주 프로세스 1개 = 상담 회기 1개.""" """claude -p 상주 프로세스 1개 = 상담 회기 1개."""
def __init__(self, system_prompt: str | None = None, budget: float = DEFAULT_BUDGET): def __init__(
self,
system_prompt: str | None = None,
budget: float = DEFAULT_BUDGET,
model: str | None = None,
):
self.id = uuid.uuid4().hex self.id = uuid.uuid4().hex
self.system_prompt = system_prompt self.system_prompt = system_prompt
self.budget = budget self.budget = budget
self.model = _model_override(model)
self.proc: asyncio.subprocess.Process | None = None self.proc: asyncio.subprocess.Process | None = None
self.lock = asyncio.Lock() # 한 회기 안의 턴은 직렬(상담 왕복) self.lock = asyncio.Lock() # 한 회기 안의 턴은 직렬(상담 왕복)
self.cost_usd = 0.0 self.cost_usd = 0.0
@ -46,8 +63,9 @@ class EngineSession:
async def start(self) -> None: async def start(self) -> None:
args = [CLAUDE_BIN, *BASE_ARGS, "--max-budget-usd", str(self.budget)] args = [CLAUDE_BIN, *BASE_ARGS, "--max-budget-usd", str(self.budget)]
if DEFAULT_MODEL: model = self.model or DEFAULT_MODEL
args += ["--model", DEFAULT_MODEL] if model:
args += ["--model", model]
if FALLBACK_MODEL: if FALLBACK_MODEL:
args += ["--fallback-model", FALLBACK_MODEL] args += ["--fallback-model", FALLBACK_MODEL]
if self.system_prompt: if self.system_prompt:
@ -93,12 +111,19 @@ class EngineSession:
result = await asyncio.wait_for(_read_until_result(), timeout=timeout) result = await asyncio.wait_for(_read_until_result(), timeout=timeout)
self.cost_usd = result.get("total_cost_usd", self.cost_usd) self.cost_usd = result.get("total_cost_usd", self.cost_usd)
self.turns += 1 self.turns += 1
error_detail = (
result.get("error")
or result.get("result")
or (result.get("errors") or [None])[0]
or "".join(text_parts)
or None
)
return { return {
"text": "".join(text_parts), "text": "".join(text_parts),
"cost_usd": self.cost_usd, "cost_usd": self.cost_usd,
"turns": self.turns, "turns": self.turns,
"is_error": result.get("is_error", False), "is_error": result.get("is_error", False),
"error": (result.get("errors") or [None])[0], "error": error_detail,
} }
async def turn_stream(self, content: str, timeout: float = 600.0): async def turn_stream(self, content: str, timeout: float = 600.0):
@ -153,13 +178,20 @@ class EngineSession:
elif t == "result": elif t == "result":
self.cost_usd = obj.get("total_cost_usd", self.cost_usd) self.cost_usd = obj.get("total_cost_usd", self.cost_usd)
self.turns += 1 self.turns += 1
error_detail = (
obj.get("error")
or obj.get("result")
or (obj.get("errors") or [None])[0]
or emitted
or None
)
yield { yield {
"type": "done", "type": "done",
"text": emitted, "text": emitted,
"cost_usd": self.cost_usd, "cost_usd": self.cost_usd,
"turns": self.turns, "turns": self.turns,
"is_error": obj.get("is_error", False), "is_error": obj.get("is_error", False),
"error": (obj.get("errors") or [None])[0], "error": error_detail,
} }
return return
@ -176,6 +208,8 @@ class EngineSession:
SESSIONS: dict[str, EngineSession] = {} SESSIONS: dict[str, EngineSession] = {}
_READY_CACHE: dict[str, Any] = {"checked_at": 0.0, "ok": False, "detail": "not checked"}
_READY_LOCK = asyncio.Lock()
app = FastAPI(title="Vignette Engine Gateway") app = FastAPI(title="Vignette Engine Gateway")
@ -193,6 +227,62 @@ 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_p", "model": DEFAULT_MODEL or "default(opus-4-8)", "sessions": len(SESSIONS)}
def _ready_response(*, cached: bool, age_seconds: float = 0.0) -> JSONResponse:
ok = bool(_READY_CACHE.get("ok"))
return JSONResponse(
{
"ok": ok,
"engine": "claude_p",
"model": DEFAULT_MODEL or "default(opus-4-8)",
"sessions": len(SESSIONS),
"detail": _READY_CACHE.get("detail"),
"age_seconds": round(max(0.0, age_seconds), 3),
"cached": cached,
},
status_code=200 if ok else 503,
)
@app.get("/ready")
async def ready(force: bool = False):
"""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)
if not force and age < READY_TTL_SECONDS:
return _ready_response(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,
)
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()
_READY_CACHE.update({"checked_at": time.monotonic(), "ok": ok, "detail": detail})
return _ready_response(cached=False)
@app.post("/session") @app.post("/session")
async def create_session(req: CreateReq): async def create_session(req: CreateReq):
s = EngineSession(system_prompt=req.system_prompt, budget=req.budget_usd or DEFAULT_BUDGET) s = EngineSession(system_prompt=req.system_prompt, budget=req.budget_usd or DEFAULT_BUDGET)
@ -291,12 +381,18 @@ async def _resolve_session(req: GwGenerateReq, system_prompt: str) -> tuple[Engi
반환: (session, ephemeral). ephemeral=True 호출부가 응답 close 한다. 반환: (session, ephemeral). ephemeral=True 호출부가 응답 close 한다.
""" """
requested_model = _model_override(req.model)
if req.session_id and req.session_id in SESSIONS: if req.session_id and req.session_id in SESSIONS:
s = SESSIONS[req.session_id] s = SESSIONS[req.session_id]
if s.proc is not None and s.proc.returncode is None: 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 return s, False
# 단발(또는 죽은 세션) → 1회성 세션 # 단발(또는 죽은 세션) → 1회성 세션
s = EngineSession(system_prompt=system_prompt or None, budget=DEFAULT_BUDGET) s = EngineSession(
system_prompt=system_prompt or None,
budget=DEFAULT_BUDGET,
model=requested_model,
)
await s.start() await s.start()
return s, True return s, True
@ -327,7 +423,7 @@ async def v1_generate(req: GwGenerateReq):
structured = None # 파싱 실패는 호출부가 text 로 폴백 structured = None # 파싱 실패는 호출부가 text 로 폴백
return { return {
"text": text, "text": text,
"model": DEFAULT_MODEL or "claude-opus-4-8", "model": s.model or DEFAULT_MODEL or "claude-opus-4-8",
"provider": "claude_cli", "provider": "claude_cli",
"tokens_in": 0, "tokens_in": 0,
"tokens_out": 0, "tokens_out": 0,

View file

@ -0,0 +1,125 @@
import asyncio
import unittest
from unittest.mock import patch
from engine_gateway import gateway
class _FakeStdin:
def close(self):
pass
class _FakeProcess:
def __init__(self):
self.returncode = None
self.stdin = _FakeStdin()
self.stdout = None
self.stderr = None
async def wait(self):
self.returncode = 0
def kill(self):
self.returncode = -9
def _capture_subprocess():
captured = []
async def fake_create_subprocess_exec(*args, **kwargs):
captured.append(args)
return _FakeProcess()
return captured, patch.object(
gateway.asyncio,
"create_subprocess_exec",
fake_create_subprocess_exec,
)
def _request(model=None, session_id=None):
return gateway.GwGenerateReq(
ai_role="client",
messages=[gateway.GwMessage(role="user", content="hello")],
model=model,
session_id=session_id,
)
def _model_arg(args):
if "--model" not in args:
return None
return args[args.index("--model") + 1]
class GatewayModelTest(unittest.TestCase):
def setUp(self):
gateway.SESSIONS.clear()
def tearDown(self):
gateway.SESSIONS.clear()
def test_resolve_session_uses_request_model_for_claude_cli(self):
captured, process_patch = _capture_subprocess()
with (
patch.object(gateway, "DEFAULT_MODEL", "env-default"),
patch.object(gateway, "FALLBACK_MODEL", ""),
process_patch,
):
session, ephemeral = asyncio.run(
gateway._resolve_session(_request(model=" request-model "), "system prompt")
)
try:
self.assertIs(ephemeral, True)
self.assertEqual(session.model, "request-model")
self.assertEqual(_model_arg(captured[0]), "request-model")
self.assertIn("--append-system-prompt", captured[0])
finally:
asyncio.run(session.close())
def test_resolve_session_preserves_default_model_for_gateway_default(self):
captured, process_patch = _capture_subprocess()
with (
patch.object(gateway, "DEFAULT_MODEL", "env-default"),
patch.object(gateway, "FALLBACK_MODEL", ""),
process_patch,
):
session, ephemeral = asyncio.run(
gateway._resolve_session(_request(model="gateway-default"), "")
)
try:
self.assertIs(ephemeral, True)
self.assertIsNone(session.model)
self.assertEqual(_model_arg(captured[0]), "env-default")
finally:
asyncio.run(session.close())
def test_resolve_session_does_not_reuse_session_with_different_model(self):
captured, process_patch = _capture_subprocess()
existing = gateway.EngineSession(model="old-model")
existing.proc = _FakeProcess()
gateway.SESSIONS["sid"] = existing
with (
patch.object(gateway, "DEFAULT_MODEL", ""),
patch.object(gateway, "FALLBACK_MODEL", ""),
process_patch,
):
session, ephemeral = asyncio.run(
gateway._resolve_session(_request(model="new-model", session_id="sid"), "")
)
try:
self.assertIs(ephemeral, True)
self.assertIsNot(session, existing)
self.assertEqual(_model_arg(captured[0]), "new-model")
self.assertIs(gateway.SESSIONS["sid"], existing)
finally:
asyncio.run(session.close())
if __name__ == "__main__":
unittest.main()

View file

@ -21,25 +21,34 @@ npm run typecheck # tsc --noEmit (타입 체크만)
``` ```
백엔드(FastAPI)는 `apps/api`에서 `uvicorn app.main:app --port 8000`으로 띄운다. 백엔드(FastAPI)는 `apps/api`에서 `uvicorn app.main:app --port 8000`으로 띄운다.
미가동이어도 프론트는 동작한다(개발용 mock 로그인으로 진입 가능). 프론트는 실제 API 세션과 DB 기반 데이터를 사용한다. 로컬 개발 로그인은
`AUTH_DEV_LOGIN_ENABLED=true`인 개발 API에서만 제공되며, 공개 환경은 Google OAuth만 사용한다.
### 환경변수 ### 환경변수
| 변수 | 기본값 | 설명 | | 변수 | 기본값 | 설명 |
|---|---|---| |---|---|---|
| `VITE_API_BASE` | `/api` | API 베이스. 개발은 vite proxy, 프로덕션은 nginx가 백엔드로 라우팅 | | `VITE_API_BASE` | `/api` | API 베이스. 개발은 vite proxy, 프로덕션은 nginx가 백엔드로 라우팅 |
| `VITE_LIVE2D_CUBISM_CORE` | `/live2d/live2dcubismcore.min.js` | Cubism Core JS URL. 페르소나별 `live2dModelUrl`이 있을 때만 사용 |
Live2D 샘플 자산은 `public/live2d/` 아래에 로컬 통합 테스트용으로 남겨 둔다.
세션 아바타는 기본적으로 SVG/persona 렌더러를 사용하며, 실제 페르소나 전용
`AvatarPersona.live2dModelUrl`이 있을 때만 Cubism 모델을 렌더링한다.
Mao/Haru 샘플 모델을 모든 페르소나의 공용 프로덕션 폴백으로 쓰지 않는다.
프로덕션 빌드는 번들된 Mao/Haru 샘플 URL을 무시하고 SVG 아바타를 유지한다.
모델이 없거나 로딩에 실패하면 세션 화면은 기존 SVG 아바타를 그대로 렌더링한다.
## 라우트 ## 라우트
| 경로 | 페이지 | 역할 | 비고 | | 경로 | 페이지 | 역할 | 비고 |
|---|---|---|---| |---|---|---|---|
| `/login` | Login | — | 완성. OAuth(한신대/구글) mock + 역할 선택 | | `/login` | Login | — | 실제 Google OAuth + 개발 API 한정 로컬 로그인 |
| `/learn` | LearnerHome | learner | 스텁 (Features 단계 교체) | | `/learn` | LearnerHome | learner | 실제 persona catalog와 서버 세션 기록 |
| `/learn/session/:sessionId` | Session | learner | 스텁 + 동작하는 아바타 미리보기 | | `/learn/session/:sessionId` | Session | learner | 실제 세션 시작/이어하기 + 서버 SSE 응답 |
| `/learn/session/:sessionId/review` | SessionReview | learner | 스텁 | | `/learn/session/:sessionId/review` | SessionReview | learner | 저장된 세션 기반 리뷰/기록 |
| `/teach` | Professor | teacher | 스텁. `data-role=instructor` accent | | `/teach` | Professor | teacher | 실제 담당 학습자/회기 요약 |
| `/admin` | Admin | admin | 스텁. `data-role=admin` accent | | `/admin` | Admin | admin | 실제 사용자 관리, health, AI 운영 설정 |
| `/settings` | Settings | 전체 | 스텁 | | `/settings` | Settings | 전체 | 실제 계정/환경설정, 관리자 AI 운영 설정 |
| `/` | → 역할 홈 | — | 미인증이면 `/login` | | `/` | → 역할 홈 | — | 미인증이면 `/login` |
보호 라우트는 `AuthContext` 기반 `RequireAuth` 가드(미인증 → `/login`, 보호 라우트는 `AuthContext` 기반 `RequireAuth` 가드(미인증 → `/login`,
@ -64,8 +73,9 @@ src/
ProgressBar Field/Input Dot Icon(inline svg) SectionHead. ProgressBar Field/Input Dot Icon(inline svg) SectionHead.
배럴: index.ts → import { Button, ... } from "../components/ui" 배럴: index.ts → import { Button, ... } from "../components/ui"
shell/ AppShell Sidebar Topbar (§6.3 공통 셸). shell/ AppShell Sidebar Topbar (§6.3 공통 셸).
avatar/ClientAvatar.tsx 가상 내담자 SVG 아바타(동작 placeholder). avatar/ClientAvatar.tsx 가상 내담자 아바타(Live2D + SVG 폴백).
pages/ Login(완성) + 6개 스텁. avatar/Live2DAvatar.tsx Cubism 4/Pixi 런타임 브리지.
pages/ Login + learner/teacher/admin/settings/session 화면.
``` ```
## 디자인 철칙 (위반 시 재작업) ## 디자인 철칙 (위반 시 재작업)

51
apps/web/e2e/README.md Normal file
View file

@ -0,0 +1,51 @@
# Playwright E2E
These tests exercise the app through the Vite `/api` proxy and a running local
FastAPI server. They do not replace `/auth/me`, `/personas`, sessions, admin, or
review endpoints with Playwright route fixtures.
Required local services:
```sh
# apps/api
python -m uvicorn app.main:app --host 127.0.0.1 --port 8000
# apps/web, started automatically by Playwright unless already running
npm run dev -- --host 127.0.0.1 --port 5173
```
Useful overrides:
```sh
PLAYWRIGHT_PORT=5174 npm run e2e
PLAYWRIGHT_BASE_URL=http://localhost:5173 npm run e2e
VITE_API_BASE=http://127.0.0.1:8000 npm run e2e
```
Public Google OAuth `/turn` smoke:
```sh
# 1) Verify the public API is not accidentally serving the dev runtime.
$env:E2E_PUBLIC_AUTH="1"
npx playwright test e2e/public-auth-turn.spec.ts --project=chromium-public-auth --grep "production-safe"
# 2) Open a browser, sign in with an allowed Google account, then close codegen.
npx playwright codegen https://vignette.chanpaca.net/login --save-storage=./node_modules/.tmp/public-auth.json
# 3) Reuse that authenticated storage state for the public API turn smoke.
$env:E2E_PUBLIC_AUTH="1"
$env:E2E_PUBLIC_STORAGE_STATE="./node_modules/.tmp/public-auth.json"
npx playwright test e2e/public-auth-turn.spec.ts --project=chromium-public-auth
```
Notes:
- `E2E_PUBLIC_AUTH=1` targets the public site and does not start the local Vite web server.
- `public-auth.json` contains the HttpOnly API session cookie exported by Playwright. Treat it as sensitive and keep it under `node_modules/.tmp`.
- The API session TTL is currently 8 hours, so recapture storage state when the smoke begins returning `401`.
Install browser binaries once with:
```sh
npx playwright install chromium
```

304
apps/web/e2e/admin.spec.ts Normal file
View file

@ -0,0 +1,304 @@
import { expect, test, type APIResponse, type Page, type Response, type TestInfo } from "@playwright/test";
import {
expectNoHorizontalOverflow,
signInAsLearner,
useRealApi,
withGlobalEngineConfigLock,
} from "./support";
type AdminHealthStatus = "ok" | "degraded" | "down";
interface AdminServiceHealth {
key: string;
name: string;
status: AdminHealthStatus;
detail: string;
metric: string;
load: number;
}
interface AdminHealthResponse {
status: AdminHealthStatus;
environment: string;
engine_mode: string;
services: AdminServiceHealth[];
}
interface AdminManagedUser {
user_id: string;
email: string;
display_name: string;
role: "learner" | "teacher" | "admin";
affiliation: string;
cohort_ids: string[];
}
interface AdminUsersResponse {
source: "database" | "server_session_registry";
durable: boolean;
users: AdminManagedUser[];
}
async function expectResponseOk(response: APIResponse | Response) {
if (!response.ok()) {
expect(response.ok(), await response.text()).toBeTruthy();
}
}
async function signInAsAdmin(page: Page) {
const res = await page.request.post("/api/auth/dev-login", {
data: {
email: "admin@twentyoz.kr",
role: "admin",
display_name: "E2E Admin",
},
});
await expectResponseOk(res);
}
function isAdminHealthResponse(response: Response) {
const url = new URL(response.url());
return response.request().method() === "GET" && url.pathname.endsWith("/admin/health");
}
function isAdminUsersResponse(response: Response) {
const url = new URL(response.url());
return response.request().method() === "GET" && url.pathname.endsWith("/admin/users");
}
function isAdminUserCreate(response: Response) {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname.endsWith("/admin/users");
}
function isAdminUserPatch(userId: string) {
return (response: Response) => {
const url = new URL(response.url());
return (
response.request().method() === "PATCH" &&
url.pathname.endsWith(`/admin/users/${userId}`)
);
};
}
function isAdminUserDelete(userId: string) {
return (response: Response) => {
const url = new URL(response.url());
return (
response.request().method() === "DELETE" &&
url.pathname.endsWith(`/admin/users/${userId}`)
);
};
}
function environmentLabel(value: string) {
if (value === "prod") return "운영";
if (value === "staging") return "스테이징";
if (value === "dev") return "개발";
return value;
}
function engineModeLabel(value: string) {
if (value === "claude_cli") return "Claude CLI 게이트웨이";
if (value === "claude_api" || value === "messages_api") return "Anthropic API";
if (value === "openai") return "OpenAI 호환";
if (value === "solar") return "Solar";
return value;
}
async function openAdminAndReadHealth(page: Page) {
const healthResponsePromise = page.waitForResponse(isAdminHealthResponse);
await page.goto("/admin");
const healthResponse = await healthResponsePromise;
await expectResponseOk(healthResponse);
const health = (await healthResponse.json()) as AdminHealthResponse;
expect(health.services.length).toBeGreaterThan(0);
return health;
}
async function openAdminAndReadUsers(page: Page) {
const usersResponsePromise = page.waitForResponse(isAdminUsersResponse);
await page.goto("/admin");
const usersResponse = await usersResponsePromise;
await expectResponseOk(usersResponse);
return (await usersResponse.json()) as AdminUsersResponse;
}
test.describe("admin route", () => {
test.beforeEach(async ({ page }) => {
await useRealApi(page);
});
test("allows an admin to open the live health dashboard", async ({ page }) => {
await signInAsAdmin(page);
await withGlobalEngineConfigLock("admin-health-dashboard", async () => {
const health = await openAdminAndReadHealth(page);
const serviceCards = page.locator(".ad-service:not(.ad-service--skeleton)");
const counts = {
ok: health.services.filter((service) => service.status === "ok").length,
degraded: health.services.filter((service) => service.status === "degraded").length,
down: health.services.filter((service) => service.status === "down").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([
String(health.services.length),
String(counts.ok),
String(counts.degraded),
String(counts.down),
]);
await expect(serviceCards).toHaveCount(health.services.length);
for (const service of health.services) {
const card = serviceCards.filter({ hasText: service.name });
await expect(card).toBeVisible();
await expect(card).toContainText(service.detail);
expect(service.load, `${service.key} load must be normalized`).toBeGreaterThanOrEqual(0);
expect(service.load, `${service.key} load must be normalized`).toBeLessThanOrEqual(1);
if (service.key === "engine" && service.status === "ok") {
await expect(card).toContainText(/\d+ms/);
} else if (service.key === "db" && service.status === "ok") {
await expect(card).toContainText(/풀 \d+\/\d+/);
} else if (service.key === "evaluation") {
await expect(card).toContainText(/대기 \d+건/);
} else if (service.key === "kb" && service.status === "ok") {
await expect(card).toContainText(/활성 세션 \d+건/);
} else {
await expect(card).toContainText(service.metric);
}
}
const byKey = Object.fromEntries(health.services.map((service) => [service.key, service]));
if (byKey.engine?.status === "ok") {
expect(byKey.engine.metric).toMatch(/^\d+ms$/);
}
if (byKey.db?.status === "ok") {
expect(byKey.db.metric).toMatch(/^풀 \d+\/\d+$/);
}
expect(byKey.evaluation?.metric).toMatch(/^대기 \d+건$/);
if (byKey.kb?.status === "ok") {
expect(byKey.kb.metric).toMatch(/^활성 세션 \d+건$/);
}
});
});
test("allows an admin to manage real server-known users", async ({ page }, testInfo) => {
await signInAsAdmin(page);
const slug = `${testInfo.project.name}.${testInfo.workerIndex}.${testInfo.retry}.${Date.now()}`;
const email = `admin-users.${slug}@hs.ac.kr`.toLowerCase();
const displayName = `관리 대상 ${testInfo.project.name}`;
const users = await openAdminAndReadUsers(page);
expect(users.source).toBe("database");
expect(users.durable).toBe(true);
await page.getByLabel("사용자 검색").fill(`no-match-${slug}`);
await expect(page.getByText("검색 조건에 맞는 사용자가 없습니다.")).toBeVisible();
await expect(page.getByText("아직 등록된 사용자가 없습니다.")).toHaveCount(0);
await page.getByLabel("사용자 검색").fill("");
await page.getByLabel("새 사용자 이메일").fill(email);
await page.getByLabel("새 사용자 표시 이름").fill(displayName);
await page.getByLabel("새 사용자 역할").selectOption("learner");
await page.getByLabel("새 사용자 코호트").fill(`created-${testInfo.project.name}`);
const createPromise = page.waitForResponse(isAdminUserCreate);
const reloadAfterCreatePromise = page.waitForResponse(isAdminUsersResponse);
await page.getByRole("button", { name: "사용자 등록" }).click();
const createResponse = await createPromise;
await expectResponseOk(createResponse);
await expectResponseOk(await reloadAfterCreatePromise);
const created = (await createResponse.json()) as AdminManagedUser;
expect(created).toMatchObject({
email,
display_name: displayName,
role: "learner",
cohort_ids: [`created-${testInfo.project.name}`],
});
await page.getByLabel("사용자 검색").fill(email);
const card = page.locator(".ad-user").filter({ hasText: email });
await expect(card).toBeVisible();
await expect(card).toContainText(displayName);
const nextName = `교수자 ${testInfo.project.name}`;
const nameInput = card.getByLabel(`${email} 표시 이름`);
const roleSelect = card.getByLabel(`${email} 역할`);
const cohortInput = card.getByLabel(`${email} 코호트`);
const saveButton = card.getByRole("button", { name: "저장" });
const nextCohort = `cohort-${testInfo.project.name}`;
await nameInput.fill(nextName);
await expect(nameInput).toHaveValue(nextName);
await page.evaluate(() => new Promise(requestAnimationFrame));
await roleSelect.selectOption("teacher");
await expect(roleSelect).toHaveValue("teacher");
await cohortInput.fill(nextCohort);
await expect(cohortInput).toHaveValue(nextCohort);
await page.evaluate(() => new Promise(requestAnimationFrame));
await expect(saveButton).toBeEnabled();
const patchPromise = page.waitForResponse(isAdminUserPatch(created.user_id));
await saveButton.click();
const patchResponse = await patchPromise;
await expectResponseOk(patchResponse);
const updated = (await patchResponse.json()) as AdminManagedUser;
expect(updated).toMatchObject({
user_id: created.user_id,
email,
display_name: nextName,
role: "teacher",
cohort_ids: [nextCohort],
});
await expect(card).toContainText(nextName);
await expect(card).toContainText("교수자");
await expect(card).toContainText(nextCohort);
const deletePromise = page.waitForResponse(isAdminUserDelete(created.user_id));
await card.getByRole("button", { name: "비활성화" }).click();
const deleteResponse = await deletePromise;
await expectResponseOk(deleteResponse);
await expect(card).toHaveCount(0);
const blockedLogin = await page.request.post("/api/auth/dev-login", {
data: {
email,
role: "teacher",
display_name: nextName,
},
});
expect(blockedLogin.status(), await blockedLogin.text()).toBe(403);
});
test("denies learner access to the admin API and UI", async ({ page }) => {
await signInAsLearner(page);
const denied = await page.request.get("/api/admin/health");
expect(denied.status(), await denied.text()).toBe(403);
const deniedUsers = await page.request.get("/api/admin/users");
expect(deniedUsers.status(), await deniedUsers.text()).toBe(403);
await page.goto("/admin");
await expect(page).toHaveURL(/\/learn$/);
await expect(page.locator(".ad-root")).toHaveCount(0);
});
test("does not horizontally overflow at a mobile viewport", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await signInAsAdmin(page);
await openAdminAndReadHealth(page);
await expectNoHorizontalOverflow(page);
});
});

183
apps/web/e2e/auth.spec.ts Normal file
View file

@ -0,0 +1,183 @@
import { expect, test } from "@playwright/test";
import { useRealApi } from "./support";
const apiBase = process.env.E2E_API_BASE ?? "http://127.0.0.1:8000";
const publicBase = process.env.E2E_PUBLIC_BASE_URL ?? "https://vignette.chanpaca.net";
const publicApiBase = process.env.E2E_PUBLIC_API_BASE ?? "https://api-vignette.chanpaca.net";
function isLocalHostname(hostname: string): boolean {
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
}
test.describe("auth domain policy", () => {
test.beforeEach(async ({ page }) => {
await useRealApi(page);
});
test("requires authentication for the persona catalog", async ({ page }) => {
const response = await page.request.get("/api/personas");
expect(response.status(), await response.text()).toBe(401);
});
test("allows only the configured school and operator email domains", async ({
page,
}, testInfo) => {
const slug = `${testInfo.project.name}.${testInfo.workerIndex}.${testInfo.retry}.${Date.now()}`;
const allowed = [
`domain-learner.${slug}@hs.ac.kr`,
`domain-admin.${slug}@twentyoz.kr`,
];
for (const email of allowed) {
const response = await page.request.post("/api/auth/dev-login", {
data: {
email: email.toLowerCase(),
role: email.endsWith("@twentyoz.kr") ? "admin" : "learner",
display_name: "Domain Policy E2E",
},
});
expect(response.ok(), await response.text()).toBeTruthy();
}
const denied = await page.request.post("/api/auth/dev-login", {
data: {
email: `domain-denied.${slug}@example.com`.toLowerCase(),
role: "learner",
display_name: "Denied Domain",
},
});
const deniedText = await denied.text();
expect(denied.status(), deniedText).toBe(403);
expect(deniedText).toContain("email domain is not allowed");
});
test("shows Google OAuth readiness without exposing a raw JSON error page", async ({
page,
}) => {
const configResponse = await page.request.get("/api/auth/config");
expect(configResponse.ok(), await configResponse.text()).toBeTruthy();
const config = (await configResponse.json()) as {
google_oauth_configured: boolean;
allowed_email_domains: string[];
redirect_uri: string;
dev_login_enabled: boolean;
};
await page.goto("/login");
const googleButtons = page.locator(".lg-obtn");
await expect(googleButtons).toHaveCount(2);
await expect(page.locator(".lg-policy b")).toContainText(config.allowed_email_domains);
const currentHost = new URL(page.url()).hostname;
const redirectHost = new URL(config.redirect_uri).hostname;
const localOAuthUnavailable =
isLocalHostname(currentHost) &&
config.dev_login_enabled &&
!isLocalHostname(redirectHost);
if (config.dev_login_enabled) {
await expect(page.locator(".lg-dev")).toBeVisible();
} else {
await expect(page.locator(".lg-dev")).toHaveCount(0);
}
if (config.google_oauth_configured && !localOAuthUnavailable) {
await expect(googleButtons.first()).toBeEnabled();
await expect(page.locator(".lg-config")).toHaveCount(0);
} else {
await expect(googleButtons.first()).toBeDisabled();
await expect(googleButtons.nth(1)).toBeDisabled();
await expect(page.locator(".lg-config")).toBeVisible();
await expect(page).toHaveURL(/\/login$/);
await expect(page.locator("body")).not.toContainText("Google OAuth is not configured");
if (localOAuthUnavailable) {
await expect(page.locator(".lg-config")).toContainText("로컬 테스트 계정으로 로그인");
return;
}
await page.goto("/api/auth/login?provider=google&next=%2Flearn");
await expect(page).toHaveURL(/\/login\?oauth=not_configured$/);
await expect(page.locator(".lg-error")).toContainText("Google 로그인이 아직 연결되지 않았습니다");
await expect(page.locator("body")).not.toContainText("Google OAuth is not configured");
}
});
test("logs in locally with the server dev session and redirects to learner home", async ({
page,
}) => {
await page.goto("/login");
await expect(page.locator(".lg-dev")).toBeVisible();
await Promise.all([
page.waitForURL(/\/learn(?:$|[/?#])/),
page.getByRole("button", { name: "로컬 테스트 계정으로 계속" }).click(),
]);
const me = await page.request.get("/api/auth/me");
expect(me.status(), await me.text()).toBe(200);
await expect(page.getByRole("heading", { name: "오늘의 회기를 준비합니다." })).toBeVisible();
});
test("keeps dev login closed for the public API origin", async ({ request }) => {
const publicHeaders = {
origin: "https://vignette.chanpaca.net",
"x-forwarded-host": "api-vignette.chanpaca.net",
};
const configResponse = await request.get(`${apiBase}/auth/config`, {
headers: publicHeaders,
});
expect(configResponse.ok(), await configResponse.text()).toBeTruthy();
const config = (await configResponse.json()) as { dev_login_enabled: boolean };
expect(config.dev_login_enabled).toBe(false);
const response = await request.post(`${apiBase}/auth/dev-login`, {
headers: publicHeaders,
data: {
email: "public-dev-login-probe@hs.ac.kr",
role: "learner",
display_name: "Public Probe",
},
});
expect(response.status(), await response.text()).toBe(404);
});
test("keeps the public login screen on real Google OAuth only", async ({ page, request }) => {
const configResponse = await request.get(`${publicApiBase}/auth/config`, {
headers: {
origin: publicBase,
"x-forwarded-host": "api-vignette.chanpaca.net",
},
});
expect(configResponse.ok(), await configResponse.text()).toBeTruthy();
const config = (await configResponse.json()) as {
google_oauth_configured: boolean;
allowed_email_domains: string[];
dev_login_enabled: boolean;
};
expect(config).toMatchObject({
google_oauth_configured: true,
dev_login_enabled: false,
});
expect(config.allowed_email_domains).toEqual(expect.arrayContaining(["hs.ac.kr", "twentyoz.kr"]));
await page.goto(`${publicBase}/login`, { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => undefined);
await expect(page.locator(".lg-obtn")).toHaveCount(2);
await expect(page.locator(".lg-dev")).toHaveCount(0);
await expect(page.getByText("로컬 테스트")).toHaveCount(0);
await expect(page.getByText("로컬 테스트 계정으로 계속")).toHaveCount(0);
await expect(page.locator("body")).not.toContainText("Google OAuth is not configured");
await expect(page.locator(".lg-obtn").first()).toBeEnabled();
await Promise.all([
page.waitForURL(/accounts\.google\.com/, { timeout: 20_000 }),
page.locator(".lg-obtn").first().click(),
]);
expect(page.url()).toContain("client_id=");
expect(page.url()).toContain("redirect_uri=https%3A%2F%2Fapi-vignette.chanpaca.net%2Fauth%2Fcallback");
});
});

View file

@ -0,0 +1,177 @@
import { expect, test, type APIResponse } from "@playwright/test";
import { useRealApi, withGlobalEngineConfigLock } from "./support";
interface HealthResponse {
db: boolean;
}
interface UserProfileResponse {
user_id: string;
email: string;
display_name: string;
role: string;
cohort_ids: string[];
affiliation: string;
}
interface UserPreferencesResponse {
theme: string;
voice_preset_id: string;
voice_rate: number;
notifications: {
session_done: boolean;
safety_signal: boolean;
learner_progress: boolean;
product_news: boolean;
};
}
interface AdminUsersResponse {
source: "database" | "server_session_registry";
durable: boolean;
users: UserProfileResponse[];
}
interface AdminEngineConfigResponse {
engine_mode: string;
engine_url: string;
model: string;
updated_by: string | null;
updated_at: number | null;
}
async function expectResponseOk(response: APIResponse) {
expect(response.ok(), await response.text()).toBeTruthy();
}
function slugFor(testInfo: { project: { name: string }; workerIndex: number; retry: number }) {
const project = testInfo.project.name.includes("mobile") ? "mob" : "desk";
return `${project}.${testInfo.workerIndex}.${testInfo.retry}.${Date.now()}`;
}
test.describe("database-backed runtime state", () => {
test.beforeEach(async ({ page }) => {
await useRealApi(page);
});
test("persists user settings and admin AI configuration through DB-backed APIs @single-run", async ({
page,
}, testInfo) => {
const healthResponse = await page.request.get("/api/health");
await expectResponseOk(healthResponse);
const health = (await healthResponse.json()) as HealthResponse;
test.skip(!health.db, "PostgreSQL is not connected for this run");
const slug = slugFor(testInfo);
const learnerEmail = `db-persist.${slug}@hs.ac.kr`.toLowerCase();
const learnerName = `DB Persist Learner ${testInfo.project.name}`;
const nextName = `DB Persist Updated ${testInfo.project.name}`;
const affiliation = "Hanshin University";
const learnerLogin = await page.request.post("/api/auth/dev-login", {
data: {
email: learnerEmail,
role: "learner",
display_name: learnerName,
},
});
await expectResponseOk(learnerLogin);
const profilePatch = await page.request.patch("/api/users/me", {
data: {
display_name: nextName,
affiliation,
},
});
await expectResponseOk(profilePatch);
const profile = (await profilePatch.json()) as UserProfileResponse;
expect(profile).toMatchObject({
email: learnerEmail,
display_name: nextName,
affiliation,
});
const preferencesPatch = await page.request.patch("/api/users/me/preferences", {
data: {
theme: "dark",
voice_rate: 1.1,
notifications: {
session_done: true,
safety_signal: true,
learner_progress: true,
product_news: true,
},
},
});
await expectResponseOk(preferencesPatch);
const preferences = (await preferencesPatch.json()) as UserPreferencesResponse;
expect(preferences.theme).toBe("dark");
expect(preferences.voice_rate).toBeCloseTo(1.1);
expect(preferences.notifications.product_news).toBe(true);
const adminEmail = `db-persist-admin.${slug}@twentyoz.kr`.toLowerCase();
const adminLogin = await page.request.post("/api/auth/dev-login", {
data: {
email: adminEmail,
role: "admin",
display_name: `DB Persist Admin ${testInfo.project.name}`,
},
});
await expectResponseOk(adminLogin);
const usersResponse = await page.request.get("/api/admin/users");
await expectResponseOk(usersResponse);
const users = (await usersResponse.json()) as AdminUsersResponse;
expect(users.source).toBe("database");
expect(users.durable).toBe(true);
const learner = users.users.find((user) => user.email === learnerEmail);
expect(learner, `Expected ${learnerEmail} in DB-backed admin list`).toBeTruthy();
expect(learner).toMatchObject({
display_name: nextName,
affiliation,
});
await withGlobalEngineConfigLock(`db-persistence-${slug}`, async () => {
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}`;
try {
const enginePatch = await page.request.patch("/api/admin/engine-config", {
data: {
engine_mode: currentEngine.engine_mode,
engine_url: currentEngine.engine_url,
model: nextModel,
},
});
await expectResponseOk(enginePatch);
const updatedEngine = (await enginePatch.json()) as AdminEngineConfigResponse;
expect(updatedEngine).toMatchObject({
engine_mode: currentEngine.engine_mode,
engine_url: currentEngine.engine_url,
model: nextModel,
updated_by: adminEmail,
});
expect(updatedEngine.updated_at).toBeGreaterThan(0);
const persistedEngineResponse = await page.request.get("/api/admin/engine-config");
await expectResponseOk(persistedEngineResponse);
const persistedEngine = (await persistedEngineResponse.json()) as AdminEngineConfigResponse;
expect(persistedEngine).toMatchObject({
model: nextModel,
updated_by: adminEmail,
});
} finally {
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);
}
});
});
});

View file

@ -0,0 +1,212 @@
import { expect, test } from "@playwright/test";
import {
expectNoDocumentOverflow,
expectNoHorizontalOverflow,
fetchAvailablePersona,
fetchAvailablePersonas,
signInAsLearner,
useRealApi,
} from "./support";
async function signInAsLearnerEmail(
page: import("@playwright/test").Page,
email: string,
displayName = "History Learner",
) {
const res = await page.request.post("/api/auth/dev-login", {
data: {
email,
role: "learner",
display_name: displayName,
},
});
expect(res.ok(), await res.text()).toBeTruthy();
}
async function createPracticeSession(page: import("@playwright/test").Page, personaCode?: string) {
const selectedPersonaCode = personaCode ?? (await fetchAvailablePersona(page)).code;
const res = await page.request.post("/api/sessions", {
data: {
persona_code: selectedPersonaCode,
theory_mode: "humanistic",
},
});
expect(res.ok(), await res.text()).toBeTruthy();
return (await res.json()) as { session_id: string };
}
async function expectNoLearnerInternalCopy(page: import("@playwright/test").Page) {
await expect(page.getByText(/API|GET \/|OPENAI_API_KEY|teacher\/dashboard/)).toHaveCount(0);
}
async function expectVisibleResumeLoadedSignal(page: import("@playwright/test").Page) {
const result = await page.evaluate(() => {
const candidates = Array.from(
document.querySelectorAll<HTMLElement>(
".sx-page--active .sx-mobile-context__resume, .sx-page--active .sx-mic-block__h",
),
);
return candidates.map((el) => {
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
return {
text: el.textContent ?? "",
visible:
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity) !== 0 &&
rect.width > 0 &&
rect.height > 0,
};
});
});
expect(
result.some(
(entry) => entry.visible && entry.text.includes("이전 회기 기록을 불러왔습니다."),
),
`Expected visible resume-loaded signal: ${JSON.stringify(result)}`,
).toBeTruthy();
}
test.describe("learner app shell and session launcher", () => {
test.beforeEach(async ({ page }) => {
await useRealApi(page);
});
test("redirects an unauthenticated learner route to login", async ({ page }) => {
await page.goto("/learn");
await expect(page).toHaveURL(/\/login$/);
await expect(page.getByRole("button", { name: /학교 Google 계정으로 계속/ })).toBeVisible();
expect(await page.evaluate(() => window.localStorage.getItem("vignette.dev-auth"))).toBeNull();
});
test("renders API personas without legacy session rows", async ({ page }) => {
await signInAsLearnerEmail(page, `learner.catalog.${Date.now()}@hs.ac.kr`, "Catalog Learner");
await page.goto("/learn");
const personas = await fetchAvailablePersonas(page);
const launcher = page.getByRole("listbox", { name: "연습 페르소나" });
await expect(launcher).toBeVisible();
await expect(launcher.getByRole("option")).toHaveCount(personas.length);
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();
}
await expect(page.getByText(/12회/)).toHaveCount(0);
await expect(page.getByText(/최근 연습/)).toHaveCount(0);
await expect(page.locator(".vg-nav")).toHaveCount(0);
await expect(page.locator(".vg-main")).toHaveClass(/(^|\s)vg-main--bleed(\s|$)/);
await expect(page.getByText("음성")).toBeInViewport();
await expect(page.getByRole("button", { name: "새 회기 시작" })).toBeInViewport();
await expectNoLearnerInternalCopy(page);
await expectNoDocumentOverflow(page);
await expectNoHorizontalOverflow(page);
});
test("routes the launcher CTA to the selected database persona", async ({ page }) => {
await signInAsLearner(page);
await page.goto("/learn");
const persona = await fetchAvailablePersona(page, 1);
const option = page.getByRole("option", { name: new RegExp(persona.code) });
await option.click();
await expect(option).toHaveAttribute("aria-selected", "true");
await page.getByRole("button", { name: "새 회기 시작" }).click();
await expect(page).toHaveURL(new RegExp(`/learn/session/${persona.code}$`));
await expect(page.getByRole("button", { name: "회기 시작" })).toBeVisible();
await expectNoDocumentOverflow(page);
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page.locator(".sx-grid")).toBeVisible();
await expectNoLearnerInternalCopy(page);
await expectNoDocumentOverflow(page);
await expectNoHorizontalOverflow(page);
});
test("shows real session history with resume, record, and retry actions", async ({
page,
}, testInfo) => {
const suffix = `${testInfo.project.name.replace(/\W+/g, "-")}-${testInfo.workerIndex}-${Date.now()}`.toLowerCase();
await signInAsLearnerEmail(page, `history.${suffix}@hs.ac.kr`);
const persona = await fetchAvailablePersona(page);
const active = await createPracticeSession(page, persona.code);
const ended = await createPracticeSession(page, persona.code);
const endResponse = await page.request.post(`/api/sessions/${ended.session_id}/end`);
expect(endResponse.ok(), await endResponse.text()).toBeTruthy();
await page.goto("/learn");
await expect(page.getByText("기존 회기")).toBeVisible();
await expect(page.getByRole("button", { name: "이어하기" })).toBeVisible();
await expect(page.getByRole("button", { name: "기록" })).toBeVisible();
await expect(page.getByRole("button", { name: "다시 연습" })).toHaveCount(2);
await expect(page.getByRole("button", { name: "새 회기 시작" })).toBeInViewport();
await expect(page.getByRole("button", { name: "이어하기" })).toBeInViewport();
await expect(page.getByRole("button", { name: "기록" })).toBeInViewport();
await expect(page.getByRole("button", { name: "다시 연습" }).first()).toBeInViewport();
await expect(page.locator(".lh-activity__stats")).toContainText("누적 회기");
await expect(page.locator(".lh-activity__stats")).toContainText("2");
await page.getByRole("button", { name: "이어하기" }).click();
await expect(page).toHaveURL(new RegExp(`/learn/session/${active.session_id}$`));
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
await expectVisibleResumeLoadedSignal(page);
await expectNoDocumentOverflow(page);
await expectNoHorizontalOverflow(page);
});
test("does not fall back to P1 for an unavailable persona code", async ({ page }) => {
await signInAsLearner(page);
await page.goto("/learn/session/P9");
await expect(page.getByText(/P9 페르소나는 현재 연습 목록에 없습니다/)).toBeVisible();
await expect(page.getByText("페르소나 P9")).toHaveCount(0);
await expect(
page.getByRole("heading", { name: "연습 대상 정보를 확인하고 있습니다." }),
).toBeVisible();
await expect(page.getByRole("button", { name: "회기 시작" })).toBeDisabled();
await expectNoDocumentOverflow(page);
await expectNoHorizontalOverflow(page);
});
test("blocks direct session starts for degraded or non-database personas", async ({ page }) => {
await signInAsLearner(page);
await page.route("**/api/personas", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{
code: "SEEDX",
display_name: "검증 불가 내담자",
difficulty: "easy",
theory_target: ["humanistic"],
demographics: { age_band: "20대" },
presenting_summary: "서버 카탈로그 원본이 확인되지 않은 항목",
voice_preset: null,
source: "seed_fallback",
degraded: true,
},
]),
});
});
await page.goto("/learn/session/SEEDX");
await expect(
page.getByRole("heading", { name: "이 내담자는 현재 연습에 사용할 수 없습니다." }),
).toBeVisible();
await expect(page.getByText("카탈로그 원본을 확인하지 못해 현재 연습에 사용할 수 없습니다.")).toBeVisible();
await expect(page.getByRole("button", { name: "회기 시작" })).toBeDisabled();
await expectNoDocumentOverflow(page);
await expectNoHorizontalOverflow(page);
});
});

View file

@ -0,0 +1,160 @@
import { expect, test } from "@playwright/test";
const publicApiBase = process.env.E2E_PUBLIC_API_BASE ?? "https://api-vignette.chanpaca.net";
interface PublicHealthResponse {
status: string;
environment: string;
db: boolean;
engine: boolean;
}
async function browserFetchJson<T>(
page: import("@playwright/test").Page,
path: string,
init: RequestInit = {},
) {
return page.evaluate(
async ({ apiBase, apiPath, requestInit }) => {
const response = await fetch(`${apiBase}${apiPath}`, {
credentials: "include",
...requestInit,
headers: {
"content-type": "application/json",
...(requestInit.headers ?? {}),
},
});
const text = await response.text();
let json: unknown = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = null;
}
return {
ok: response.ok,
status: response.status,
text,
json,
};
},
{
apiBase: publicApiBase,
apiPath: path,
requestInit: {
...init,
body: typeof init.body === "string" ? init.body : undefined,
},
},
) as Promise<{ ok: boolean; status: number; text: string; json: T | null }>;
}
test.describe("public Google OAuth turn smoke", () => {
test("uses production-safe public API configuration @public-auth", async ({
request,
playwright,
}) => {
const healthResponse = await request.get(`${publicApiBase}/health`);
expect(healthResponse.ok(), await healthResponse.text()).toBeTruthy();
const health = (await healthResponse.json()) as PublicHealthResponse;
expect(health.environment, JSON.stringify(health)).not.toBe("dev");
expect(health.db, JSON.stringify(health)).toBe(true);
expect(health.engine, JSON.stringify(health)).toBe(true);
const configResponse = await request.get(`${publicApiBase}/auth/config`, {
headers: {
origin: "https://vignette.chanpaca.net",
"x-forwarded-host": "api-vignette.chanpaca.net",
},
});
expect(configResponse.ok(), await configResponse.text()).toBeTruthy();
await expect(await configResponse.json()).toMatchObject({
google_oauth_configured: true,
allowed_email_domains: expect.arrayContaining(["hs.ac.kr", "twentyoz.kr"]),
dev_login_enabled: false,
});
const unauthRequest = await playwright.request.newContext();
try {
const personasResponse = await unauthRequest.get(`${publicApiBase}/personas`);
expect(personasResponse.status(), await personasResponse.text()).toBe(401);
} finally {
await unauthRequest.dispose();
}
});
test("starts a real public session and completes one /turn @public-auth", async ({ page }) => {
if (!process.env.E2E_PUBLIC_STORAGE_STATE) {
throw new Error(
"Set E2E_PUBLIC_STORAGE_STATE to a Playwright storageState file captured after Google login.",
);
}
await page.goto("/learn", { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => undefined);
await expect(page).toHaveURL(/\/learn(?:$|[/?#])/);
await expect(page.getByRole("button", { name: "새 회기 시작" })).toBeVisible({
timeout: 15_000,
});
const me = await browserFetchJson<{ email?: string; role?: string }>(page, "/auth/me");
expect(me.status, me.text).toBe(200);
expect(me.json).toMatchObject({ role: "learner" });
const personas = await browserFetchJson<
Array<{ code: string; source: string; degraded: boolean }>
>(page, "/personas");
expect(personas.status, personas.text).toBe(200);
const persona = personas.json?.find(
(item) => item.source === "database" && !item.degraded,
);
expect(persona, personas.text).toBeTruthy();
let startedSessionId: string | null = null;
const started = await browserFetchJson<{
session_id: string;
degraded: boolean;
}>(page, "/sessions", {
method: "POST",
body: JSON.stringify({
persona_code: persona!.code,
theory_mode: "humanistic",
}),
});
expect(started.status, started.text).toBe(201);
expect(started.json).toMatchObject({ degraded: false });
expect(started.json?.session_id).toEqual(expect.any(String));
startedSessionId = started.json!.session_id;
try {
const turn = await browserFetchJson<{
client_reply?: string;
stage?: string;
turn_seq?: number;
}>(page, `/sessions/${startedSessionId}/turn`, {
method: "POST",
body: JSON.stringify({
text: "처음 오신 자리라 긴장될 수 있어요. 지금 가장 이야기하고 싶은 것부터 천천히 말해도 괜찮습니다.",
}),
});
expect(turn.status, turn.text).toBe(200);
expect(turn.json?.client_reply).toEqual(expect.any(String));
expect(turn.json!.client_reply!.length).toBeGreaterThan(0);
const detail = await browserFetchJson<{
session_id: string;
turns: Array<{ speaker: string; text: string }>;
}>(page, `/sessions/${startedSessionId}`);
expect(detail.status, detail.text).toBe(200);
expect(detail.json?.turns.length).toBeGreaterThanOrEqual(2);
} finally {
if (startedSessionId) {
await browserFetchJson(page, `/sessions/${startedSessionId}/end`, {
method: "POST",
body: JSON.stringify({}),
}).catch(() => undefined);
}
}
});
});

View file

@ -0,0 +1,79 @@
import { expect, test, type APIResponse, type Page } from "@playwright/test";
import { expectNoHorizontalOverflow } from "./support";
async function expectResponseOk(response: APIResponse) {
if (!response.ok()) {
expect(response.ok(), await response.text()).toBeTruthy();
}
}
async function signIn(
page: Page,
role: "learner" | "admin",
email: string,
displayName: string,
) {
const res = await page.request.post("/api/auth/dev-login", {
data: { email, role, display_name: displayName },
});
await expectResponseOk(res);
}
test.describe("production readiness gates", () => {
test("learner-facing data comes from server persistence, not browser or seed mocks", async ({
page,
}, testInfo) => {
const email = `readiness.${testInfo.project.name}.${testInfo.workerIndex}.${Date.now()}@hs.ac.kr`;
await signIn(page, "learner", email.toLowerCase(), "Readiness Learner");
const personaResponse = await page.request.get("/api/personas");
await expectResponseOk(personaResponse);
expect(personaResponse.headers()["x-vignette-catalog-source"]).toBe("database");
const personas = (await personaResponse.json()) as Array<{
source: string;
degraded: boolean;
}>;
expect(personas.length).toBeGreaterThan(0);
expect(personas.every((persona) => persona.source === "database" && !persona.degraded)).toBe(
true,
);
const sessionsResponse = await page.request.get("/api/sessions");
await expectResponseOk(sessionsResponse);
const sessions = (await sessionsResponse.json()) as { source: string; sessions: unknown[] };
expect(sessions).toMatchObject({ source: "database", sessions: [] });
await page.goto("/learn");
await expect(page.getByText("지금까지 12회 연습했어요")).toHaveCount(0);
await expect(page.getByText("최근 8회")).toHaveCount(0);
await expect(page.getByText("기존 회기")).toBeVisible();
await expect(page.locator(".lh-activity__stats")).toContainText("누적 회기");
await expect(page.locator(".lh-activity__stats")).toContainText("0");
await expect(page.getByText("저장된 기존 회기가 없습니다.")).toBeVisible();
await expect(page.getByRole("button", { name: "새 회기 시작" })).toBeInViewport();
await expectNoHorizontalOverflow(page);
});
test("admin-owned runtime controls are durable database state", async ({ page }) => {
await signIn(page, "admin", "readiness-admin@twentyoz.kr", "Readiness Admin");
const usersResponse = await page.request.get("/api/admin/users");
await expectResponseOk(usersResponse);
const users = (await usersResponse.json()) as { source: string; durable: boolean };
expect(users).toMatchObject({ source: "database", durable: true });
const engineResponse = await page.request.get("/api/admin/engine-config");
await expectResponseOk(engineResponse);
const engine = (await engineResponse.json()) as {
durable: boolean;
source: string;
engine_mode: string;
engine_url: string;
model: string;
};
expect(engine).toMatchObject({ durable: true, source: "database" });
expect(["claude_cli", "claude_api", "openai", "solar"]).toContain(engine.engine_mode);
expect(engine.engine_url).toMatch(/^https?:\/\//);
expect(engine.model.trim().length).toBeGreaterThan(0);
});
});

View file

@ -0,0 +1,313 @@
import { expect, test, type Page } from "@playwright/test";
import {
expectNoDocumentOverflow,
expectNoHorizontalOverflow,
fetchAvailablePersona,
signInAsLearner,
} from "./support";
async function expectSessionPageHeightToMatchViewport(page: Page) {
const metrics = await page.evaluate(() => {
const sessionPage = document.querySelector<HTMLElement>(".sx-page--active");
const topbar = document.querySelector<HTMLElement>(".vg-topbar");
if (!sessionPage) {
return null;
}
const pageHeight = sessionPage.getBoundingClientRect().height;
const expectedHeight = window.innerHeight;
return {
pageHeight: Math.round(pageHeight),
expectedHeight: Math.round(expectedHeight),
delta: Math.abs(pageHeight - expectedHeight),
hasTopbar: Boolean(topbar),
};
});
expect(metrics, "Expected active session page to be present").not.toBeNull();
expect(metrics!.hasTopbar, "Active session should hide the global topbar").toBe(false);
expect(
metrics!.delta,
`Expected .sx-page height ${metrics!.pageHeight}px to match viewport ${metrics!.expectedHeight}px`,
).toBeLessThanOrEqual(1);
}
async function expectNoSessionInternalCopy(page: Page) {
await expect(page.getByText(/API|GET \/|OPENAI_API_KEY|API와 엔진/)).toHaveCount(0);
}
async function expectNoLocalStageDemoControl(page: Page) {
await expect(page.getByRole("button", { name: /다음 단계로/ })).toHaveCount(0);
await expect(page.locator(".sx-track__advance")).toHaveCount(0);
}
async function expectMobileContextIfNarrow(page: Page) {
const isNarrow = await page.evaluate(() =>
window.matchMedia("(max-width: 1180px)").matches,
);
if (!isNarrow) {
return;
}
const isPhoneLayout = await page.evaluate(() =>
window.matchMedia("(max-width: 880px)").matches,
);
if (isPhoneLayout) {
await expect(page.locator(".sx-page--active .sx-col-left")).toBeHidden();
await expect(page.locator(".sx-page--active .sx-col-right")).toBeHidden();
} else {
const feedbackSurface = page.locator(".sx-page--active .sx-col-right");
await expect(feedbackSurface).toBeVisible();
await expect(feedbackSurface).toBeInViewport();
}
const mobileContext = page.getByLabel("현재 회기 요약");
await expect(mobileContext).toBeVisible();
await expect(mobileContext).toContainText("조용히 표시");
await expect(mobileContext).toContainText("내담자");
await expect(mobileContext).toContainText("마이크");
}
async function expectSessionControlsInsideViewport(page: Page) {
const selectors = [
".sx-grid",
".sx-stage",
".sx-transcript",
".sx-transcript__scroll",
".sx-compose",
".sx-controlbar",
];
const result = await page.evaluate((items) => {
const viewport = { width: window.innerWidth, height: window.innerHeight };
const checks = items.map((selector) => {
const el = document.querySelector<HTMLElement>(selector);
if (!el) return { selector, ok: false, reason: "missing" };
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
const visible =
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity) !== 0 &&
rect.width > 0 &&
rect.height > 0;
const ok =
visible &&
rect.top >= -1 &&
rect.left >= -1 &&
rect.right <= viewport.width + 1 &&
rect.bottom <= viewport.height + 1;
return {
selector,
ok,
reason: visible ? "out-of-viewport" : "not-visible",
rect: {
top: Math.round(rect.top),
left: Math.round(rect.left),
right: Math.round(rect.right),
bottom: Math.round(rect.bottom),
width: Math.round(rect.width),
height: Math.round(rect.height),
},
};
});
return { viewport, checks };
}, selectors);
const failures = result.checks.filter((check) => !check.ok);
expect(
failures,
`Viewport ${result.viewport.width}x${result.viewport.height} clipped session controls: ${JSON.stringify(failures)}`,
).toEqual([]);
}
async function expectActiveSessionUsableLayout(page: Page) {
const result = await page.evaluate(() => {
const grid = document.querySelector<HTMLElement>(".sx-page--active .sx-grid");
const center = document.querySelector<HTMLElement>(".sx-page--active .sx-col-center");
const stage = document.querySelector<HTMLElement>(".sx-page--active .sx-stage");
const transcript = document.querySelector<HTMLElement>(".sx-page--active .sx-transcript");
const scroll = document.querySelector<HTMLElement>(".sx-page--active .sx-transcript__scroll");
const compose = document.querySelector<HTMLElement>(".sx-page--active .sx-compose");
const status = document.querySelector<HTMLElement>(".sx-page--active .sx-stage__status");
const timer = document.querySelector<HTMLElement>(".sx-page--active .sx-stage__timer");
if (!grid || !center || !stage || !transcript || !scroll || !compose || !status || !timer) {
return { ok: false, reason: "missing" };
}
const gridRect = grid.getBoundingClientRect();
const centerRect = center.getBoundingClientRect();
const stageRect = stage.getBoundingClientRect();
const transcriptRect = transcript.getBoundingClientRect();
const composeRect = compose.getBoundingClientRect();
const stageOverflow = stage.scrollHeight - stage.clientHeight;
const transcriptOverflow = transcript.scrollHeight - transcript.clientHeight;
const phone = window.matchMedia("(max-width: 880px)").matches;
return {
ok: true,
phone,
gridWidth: Math.round(gridRect.width),
centerWidth: Math.round(centerRect.width),
scrollHeight: Math.round(scroll.getBoundingClientRect().height),
stageOverflow,
transcriptOverflow,
stageBottom: Math.round(stageRect.bottom),
transcriptTop: Math.round(transcriptRect.top),
transcriptBottom: Math.round(transcriptRect.bottom),
composeTop: Math.round(composeRect.top),
statusText: status.textContent ?? "",
timerText: timer.textContent ?? "",
};
});
expect(result.ok, `Expected active session layout elements: ${JSON.stringify(result)}`).toBeTruthy();
if ("phone" in result && result.phone) {
expect(
Math.abs(result.gridWidth - result.centerWidth),
`Phone center column should use full grid width: ${JSON.stringify(result)}`,
).toBeLessThanOrEqual(2);
}
expect(result.scrollHeight, `Transcript viewport too small: ${JSON.stringify(result)}`).toBeGreaterThanOrEqual(110);
expect(result.stageOverflow, `Stage content clipped: ${JSON.stringify(result)}`).toBeLessThanOrEqual(4);
expect(result.transcriptOverflow, `Transcript chrome clipped: ${JSON.stringify(result)}`).toBeLessThanOrEqual(4);
expect(result.stageBottom, `Stage overlaps transcript: ${JSON.stringify(result)}`).toBeLessThanOrEqual(result.transcriptTop);
expect(result.composeTop, `Compose overlaps transcript bounds: ${JSON.stringify(result)}`).toBeLessThan(result.transcriptBottom);
expect(result.statusText, `Missing visible running status: ${JSON.stringify(result)}`).toContain("회기");
expect(result.timerText, `Missing visible timer: ${JSON.stringify(result)}`).toMatch(/\d/);
}
test.describe("learner session full-screen layout", () => {
test("keeps the prestart and active session routes inside the viewport", async ({ page }) => {
await signInAsLearner(page);
const persona = await fetchAvailablePersona(page, 1);
await page.goto(`/learn/session/${persona.code}`);
await expect(page.getByRole("button", { name: "회기 시작" })).toBeVisible();
await expectNoHorizontalOverflow(page);
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
await expect(page.locator(".sx-grid")).toBeVisible();
await expect(page.locator(".vg-topbar")).toHaveCount(0);
await expect(page.locator(".vg-nav")).toHaveCount(0);
await expect(page.locator(".vg-main")).toHaveClass(/(^|\s)vg-main--bleed(\s|$)/);
await expect(page.locator(".vg-shell__body")).toHaveClass(/(^|\s)vg-shell__body--bare(\s|$)/);
await expectNoSessionInternalCopy(page);
await expectNoLocalStageDemoControl(page);
await expectNoDocumentOverflow(page);
await expectSessionPageHeightToMatchViewport(page);
await expectMobileContextIfNarrow(page);
await expectSessionControlsInsideViewport(page);
});
test("keeps critical session controls visible across dense viewport sizes", async ({ page }) => {
await signInAsLearner(page);
const persona = await fetchAvailablePersona(page, 1);
const viewports = [
{ width: 1366, height: 768 },
{ width: 1366, height: 720 },
{ width: 1024, height: 768 },
{ width: 1024, height: 640 },
{ width: 820, height: 1180 },
{ width: 390, height: 844 },
{ width: 375, height: 667 },
{ width: 320, height: 568 },
];
await page.setViewportSize(viewports[0]);
await page.goto(`/learn/session/${persona.code}`);
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
for (const viewport of viewports) {
await page.setViewportSize(viewport);
await page.evaluate(() => new Promise(requestAnimationFrame));
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
await expectNoDocumentOverflow(page);
await expectNoHorizontalOverflow(page);
await expectNoLocalStageDemoControl(page);
await expectSessionControlsInsideViewport(page);
await expectSessionPageHeightToMatchViewport(page);
await expectActiveSessionUsableLayout(page);
if (viewport.width <= 1180) {
await expect(page.locator(".sx-page--active .sx-mobile-context")).toBeVisible();
}
if (viewport.width > 880 && viewport.width <= 1180) {
await expect(page.locator(".sx-page--active .sx-col-right")).toBeVisible();
await expect(page.locator(".sx-page--active .sx-col-right")).toBeInViewport();
await expect(page.locator(".sx-page--active .sx-col-left")).toBeVisible();
}
if (viewport.width <= 880) {
await expect(page.locator(".sx-page--active .sx-col-right")).toBeHidden();
}
}
});
test("does not leave an unsaved local transcript when a text turn is rejected", async ({ page }) => {
await signInAsLearner(page);
const persona = await fetchAvailablePersona(page, 1);
await page.goto(`/learn/session/${persona.code}`);
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
await page.route("**/api/sessions/*/stream", async (route) => {
await route.fulfill({
status: 503,
contentType: "application/json",
body: JSON.stringify({ detail: "engine unavailable: e2e rejection" }),
});
});
const learnerText = "오늘은 너무 힘들었어요";
const input = page.getByLabel("학습자 발화 입력");
await input.fill(learnerText);
await page.getByRole("button", { name: "보내기" }).click();
await expect(page.getByRole("alert")).toContainText("AI 엔진이 응답하지 않습니다");
await expect(input).toHaveValue(learnerText);
await expect(page.locator(".sx-utt")).toHaveCount(0);
await expect(page.locator(".sx-utt").filter({ hasText: learnerText })).toHaveCount(0);
await expect(page.getByText("내담자 응답 없음")).toHaveCount(0);
});
test("removes pending transcript when an accepted stream later errors", async ({ page }) => {
await signInAsLearner(page);
const persona = await fetchAvailablePersona(page, 1);
await page.goto(`/learn/session/${persona.code}`);
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
await page.route("**/api/sessions/*/stream", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: [
"event: token",
'data: {"text":"부분 응답"}',
"",
"event: error",
'data: {"detail":"engine unavailable: e2e stream error"}',
"",
].join("\n"),
});
});
const learnerText = "스트림 중간에 실패하면 남기지 말아 주세요";
const input = page.getByLabel("학습자 발화 입력");
await input.fill(learnerText);
await page.getByRole("button", { name: "보내기" }).click();
await expect(page.getByRole("alert")).toContainText("AI 엔진이 응답하지 않습니다");
await expect(input).toHaveValue(learnerText);
await expect(page.locator(".sx-utt")).toHaveCount(0);
await expect(page.locator(".sx-utt").filter({ hasText: learnerText })).toHaveCount(0);
await expect(page.getByText("부분 응답")).toHaveCount(0);
});
});

View file

@ -0,0 +1,95 @@
import { expect, test, type Page } from "@playwright/test";
import { fetchAvailablePersona, signInAsLearner, signInAsTeacher } from "./support";
interface SessionStartResponse {
session_id: string;
degraded: boolean;
}
async function expectResponseOk(response: { ok: () => boolean; text: () => Promise<string> }) {
if (!response.ok()) {
expect(response.ok(), await response.text()).toBeTruthy();
}
}
async function createSessionWithTurn(page: Page) {
const persona = await fetchAvailablePersona(page);
const startedResponse = await page.request.post("/api/sessions", {
data: {
persona_code: persona.code,
theory_mode: "humanistic",
},
});
await expectResponseOk(startedResponse);
const started = (await startedResponse.json()) as SessionStartResponse;
expect(started.degraded).toBe(false);
let turnResponse = await page.request.post(`/api/sessions/${started.session_id}/turn`, {
data: {
text: "오늘 많이 버거웠겠어요. 지금 가장 크게 남아 있는 마음은 어떤 건가요?",
},
});
for (let attempt = 0; attempt < 2 && !turnResponse.ok(); attempt += 1) {
await page.waitForTimeout(1000);
turnResponse = await page.request.post(`/api/sessions/${started.session_id}/turn`, {
data: {
text: "조금 천천히 이야기해도 괜찮습니다. 지금 마음에 남는 장면이 있나요?",
},
});
}
let expectedMinTurns = 2;
if (!turnResponse.ok()) {
expect(turnResponse.status(), await turnResponse.text()).toBe(503);
expectedMinTurns = 1;
}
const endedResponse = await page.request.post(`/api/sessions/${started.session_id}/end`);
await expectResponseOk(endedResponse);
return { sessionId: started.session_id, expectedMinTurns };
}
test.describe("session persistence", () => {
test("persists learner turns into DB-backed review and teacher dashboard @single-run", async ({ page }) => {
await signInAsLearner(page);
const { sessionId, expectedMinTurns } = await createSessionWithTurn(page);
const reviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`);
await expectResponseOk(reviewResponse);
const review = await reviewResponse.json();
expect(review.session_id).toBe(sessionId);
expect(review.turns.length).toBeGreaterThanOrEqual(expectedMinTurns);
expect(review.turns[0].speaker).toBe("learner");
if (review.reviewReady) {
expect(review.degraded).toBe(false);
expect(review.summary).toContain("평가 AI");
} else {
expect(review.degraded).toBe(true);
expect(review.rubric).toHaveLength(0);
expect(review.goodMoments).toHaveLength(0);
expect(review.growthPoints).toHaveLength(0);
expect(review.summary).not.toContain("잘한 구체적");
}
const hasClientTurn = review.turns.some(
(turn: { speaker: string }) => turn.speaker === "client",
);
if (hasClientTurn) {
expect(review.clientFeedback).toEqual(expect.any(String));
expect(review.clientFeedback.length).toBeGreaterThan(0);
}
await signInAsTeacher(page);
const dashboardResponse = await page.request.get("/api/teacher/dashboard");
await expectResponseOk(dashboardResponse);
const dashboard = await dashboardResponse.json();
expect(dashboard.source).toBe("database");
expect(
dashboard.recent_sessions.some(
(session: { session_id: string; turn_count: number }) =>
session.session_id === sessionId && session.turn_count >= expectedMinTurns,
),
).toBe(true);
});
});

View file

@ -0,0 +1,78 @@
import { expect, test, type Page, type Response } from "@playwright/test";
import { expectNoHorizontalOverflow, fetchAvailablePersona, signInAsLearner } from "./support";
interface SessionStartResponse {
session_id: string;
}
async function expectResponseOk(response: { ok: () => boolean; text: () => Promise<string> }) {
if (!response.ok()) {
expect(response.ok(), await response.text()).toBeTruthy();
}
}
function isReviewResponse(sessionId: string) {
return (response: Response) => {
const url = new URL(response.url());
return (
response.request().method() === "GET" &&
url.pathname.endsWith(`/sessions/${sessionId}/review`)
);
};
}
async function createEndedSession(page: Page) {
const persona = await fetchAvailablePersona(page, 1);
const start = await page.request.post("/api/sessions", {
data: {
persona_code: persona.code,
theory_mode: "humanistic",
},
});
await expectResponseOk(start);
const session = (await start.json()) as SessionStartResponse;
const ended = await page.request.post(`/api/sessions/${session.session_id}/end`);
await expectResponseOk(ended);
return session.session_id;
}
test.describe("session review", () => {
test("renders server review data without legacy transcript fixtures", async ({ page }) => {
await signInAsLearner(page);
const sessionId = await createEndedSession(page);
const reviewResponsePromise = page.waitForResponse(isReviewResponse(sessionId));
await page.goto(`/learn/session/${sessionId}/review`);
const reviewResponse = await reviewResponsePromise;
await expectResponseOk(reviewResponse);
const review = await reviewResponse.json();
expect(review.session_id).toBe(sessionId);
expect(review.reviewReady).toBe(false);
expect(review.turns).toHaveLength(0);
await expect(page).toHaveURL(new RegExp(`/learn/session/${sessionId}/review$`));
await expect(page.getByText("축어록 없음")).toBeVisible();
await expect(page.getByText("감정 타임라인 대기")).toBeVisible();
await expect(page.getByText("개선점 대기")).toBeVisible();
await expect(page.getByRole("button", { name: "오디오 다시 듣기" })).toBeDisabled();
await expect(page.getByRole("button", { name: "PDF 내보내기" })).toBeDisabled();
await expect(page.getByText("32분 14초")).toHaveCount(0);
await expect(page.getByText("시연")).toHaveCount(0);
const filterMetrics = await page.locator(".sr-chip-toggle").evaluateAll((buttons) =>
buttons.map((button) => {
const rect = button.getBoundingClientRect();
const style = window.getComputedStyle(button);
return {
height: Math.ceil(rect.height),
whiteSpace: style.whiteSpace,
};
}),
);
expect(filterMetrics.every((item) => item.height <= 34 && item.whiteSpace === "nowrap")).toBe(
true,
);
await expectNoHorizontalOverflow(page);
});
});

View file

@ -0,0 +1,428 @@
import { expect, test, type APIResponse, type Page, type Response, type TestInfo } from "@playwright/test";
import { expectNoHorizontalOverflow, useRealApi, withGlobalEngineConfigLock } from "./support";
type Role = "learner" | "admin";
interface UserProfileResponse {
user_id: string;
email: string;
display_name: string;
role: string;
cohort_ids: string[];
affiliation: string;
}
interface UserPreferencesResponse {
theme: string;
voice_preset_id: string;
voice_rate: number;
notifications: {
session_done: boolean;
safety_signal: boolean;
learner_progress: boolean;
product_news: boolean;
};
}
interface AdminEngineConfigResponse {
engine_mode: string;
engine_url: string;
model: string;
updated_by: string | null;
updated_at: number | null;
}
async function expectResponseOk(response: APIResponse | Response) {
if (!response.ok()) {
const body = await response.text();
const method =
typeof (response as Response).request === "function"
? (response as Response).request().method()
: "API";
expect(
response.ok(),
`Expected ${method} ${response.url()} to be OK, got ${response.status()}: ${body}`,
).toBeTruthy();
}
}
function isApiResponse(method: string, pathnameSuffix: string) {
return (response: Response) => {
const url = new URL(response.url());
return response.request().method() === method && url.pathname.endsWith(pathnameSuffix);
};
}
function isApiRequest(method: string, pathnameSuffix: string) {
return (request: { method: () => string; url: () => string }) => {
const url = new URL(request.url());
return request.method() === method && url.pathname.endsWith(pathnameSuffix);
};
}
async function runAndWaitForApiResponse(
page: Page,
method: string,
pathnameSuffix: string,
action: () => Promise<void>,
responsePredicate: (response: Response) => boolean = () => true,
) {
const responsePromise = page.waitForResponse(
(response) => isApiResponse(method, pathnameSuffix)(response) && responsePredicate(response),
{
timeout: 10_000,
},
);
const failedRequestPromise = page
.waitForEvent("requestfailed", {
predicate: isApiRequest(method, pathnameSuffix),
timeout: 10_000,
})
.then((request) => {
throw new Error(
`${method} ${pathnameSuffix} failed before a response: ${
request.failure()?.errorText ?? "unknown network error"
}`,
);
});
await action();
return Promise.race([responsePromise, failedRequestPromise]);
}
async function waitForReactInputCommit(page: Page) {
await page.evaluate(
() =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve());
});
}),
);
}
function hasEngineConfigRequestBody(engineMode: string, model: string) {
return (response: Response) => {
try {
const body = response.request().postDataJSON() as { engine_mode?: string; model?: string };
return body.engine_mode === engineMode && body.model === model;
} catch {
return false;
}
};
}
function slugFor(testInfo: TestInfo) {
let hash = 0;
for (const char of testInfo.title) {
hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
}
const project = testInfo.project.name.includes("mobile") ? "mob" : "desk";
return `${project}.${testInfo.workerIndex}.${testInfo.retry}.${hash.toString(36)}`;
}
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((next) => {
resolve = next;
});
return { promise, resolve };
}
function testEmail(role: Role, testInfo: TestInfo) {
const domain = role === "admin" ? "twentyoz.kr" : "hs.ac.kr";
return `settings.${role}.${slugFor(testInfo)}@${domain}`;
}
async function signInAs(
page: Page,
role: Role,
testInfo: TestInfo,
displayName: string,
email = testEmail(role, testInfo),
) {
const res = await page.request.post("/api/auth/dev-login", {
data: {
email,
role,
display_name: displayName,
},
});
await expectResponseOk(res);
return email;
}
async function openSettings(page: Page, options: { admin?: boolean } = {}) {
const profilePromise = page.waitForResponse(isApiResponse("GET", "/users/me"));
const preferencesPromise = page.waitForResponse(
isApiResponse("GET", "/users/me/preferences"),
);
const voicePresetsPromise = page.waitForResponse(
isApiResponse("GET", "/users/me/voice-presets"),
);
const engineConfigPromise = options.admin
? page.waitForResponse(isApiResponse("GET", "/admin/engine-config"))
: null;
await page.goto("/settings");
const [profileResponse, preferencesResponse, voicePresetsResponse, engineConfigResponse] =
await Promise.all([
profilePromise,
preferencesPromise,
voicePresetsPromise,
engineConfigPromise,
]);
await expectResponseOk(profileResponse);
await expectResponseOk(preferencesResponse);
await expectResponseOk(voicePresetsResponse);
if (engineConfigResponse) await expectResponseOk(engineConfigResponse);
const profile = (await profileResponse.json()) as UserProfileResponse;
const preferences = (await preferencesResponse.json()) as UserPreferencesResponse;
const engineConfig = engineConfigResponse
? ((await engineConfigResponse.json()) as AdminEngineConfigResponse)
: null;
await expect(page).toHaveURL(/\/settings$/);
await expect(page.locator(".vg-set")).toBeVisible();
return { profile, preferences, engineConfig };
}
test.describe("settings page", () => {
test.beforeEach(async ({ page }) => {
await useRealApi(page);
});
test("learner opens settings with server-backed account details", async ({ page }, testInfo) => {
await page.addInitScript(() => {
window.localStorage.setItem(
"vignette.dev-auth",
JSON.stringify({
email: "browser-local@hs.ac.kr",
name: "Browser Local Learner",
role: "learner",
}),
);
});
const displayName = `Settings Learner ${testInfo.project.name}`;
const email = await signInAs(page, "learner", testInfo, displayName);
const { profile } = await openSettings(page);
expect(profile).toMatchObject({
email,
display_name: displayName,
role: "learner",
});
const account = page.locator("#set-account");
await expect(account.locator(".vg-set__profile-meta .n")).toHaveText(displayName);
await expect(account.locator(".vg-set__profile-meta .e")).toHaveText(email);
await expect(account.locator("input").nth(0)).toHaveValue(displayName);
await expect(account.locator("input").nth(1)).toHaveValue(email);
await expect(page.getByText("Browser Local Learner")).toHaveCount(0);
await expect(page.getByText("browser-local@hs.ac.kr")).toHaveCount(0);
});
test("does not expose fallback settings before server state arrives", async ({
page,
}, testInfo) => {
const displayName = `Settings Loading Admin ${testInfo.project.name}`;
await signInAs(page, "admin", testInfo, displayName);
const preferencesGate = deferred();
const preferencesSeen = deferred();
const voicesGate = deferred();
const voicesSeen = deferred();
const engineGate = deferred();
const engineSeen = deferred();
await page.route("**/api/users/me/preferences", async (route) => {
preferencesSeen.resolve();
await preferencesGate.promise;
await route.continue();
});
await page.route("**/api/users/me/voice-presets", async (route) => {
voicesSeen.resolve();
await voicesGate.promise;
await route.continue();
});
await page.route("**/api/admin/engine-config", async (route) => {
engineSeen.resolve();
await engineGate.promise;
await route.continue();
});
await page.goto("/settings");
await Promise.all([preferencesSeen.promise, voicesSeen.promise, engineSeen.promise]);
await expect(page.getByTestId("settings-preferences-loading")).toBeVisible();
await expect(page.getByTestId("settings-voice-loading")).toBeVisible();
await expect(page.getByTestId("settings-notify-loading")).toBeVisible();
await expect(page.getByTestId("settings-engine-loading")).toBeVisible();
await expect(page.getByText("soft-young-fem")).toHaveCount(0);
await expect(page.getByRole("radio", { name: "Claude CLI 게이트웨이" })).toHaveCount(0);
await expect(page.getByLabel("말하기 속도")).toHaveCount(0);
await expect(page.getByRole("button", { name: "음성 저장" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "알림 저장" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "운영 설정 저장" })).toHaveCount(0);
preferencesGate.resolve();
voicesGate.resolve();
engineGate.resolve();
await expect(page.getByTestId("settings-preferences-loading")).toHaveCount(0);
await expect(page.getByRole("button", { name: "음성 저장" })).toBeVisible();
await expect(page.getByRole("button", { name: "알림 저장" })).toBeVisible();
await expect(page.getByRole("button", { name: "운영 설정 저장" })).toBeVisible();
});
test("learner saves display name and preferences", async ({ page }, testInfo) => {
const displayName = `Settings Save Learner ${testInfo.project.name}`;
await signInAs(page, "learner", testInfo, displayName);
await openSettings(page);
const account = page.locator("#set-account");
const displayNameInput = account.locator("input").nth(0);
const nextName = `Updated Learner ${testInfo.project.name}`;
await expect(displayNameInput).toHaveValue(displayName);
const profilePatchResponse = await runAndWaitForApiResponse(
page,
"PATCH",
"/users/me",
async () => {
await displayNameInput.fill(nextName);
await expect(displayNameInput).toHaveValue(nextName);
await waitForReactInputCommit(page);
await account.locator(".vg-set__foot .vg-btn").click();
},
);
await expectResponseOk(profilePatchResponse);
const profile = (await profilePatchResponse.json()) as UserProfileResponse;
expect(profile.display_name).toBe(nextName);
const persistedProfileResponse = await page.request.get("/api/users/me");
await expectResponseOk(persistedProfileResponse);
const persistedProfile = (await persistedProfileResponse.json()) as UserProfileResponse;
expect(persistedProfile.display_name).toBe(nextName);
await expect(account.locator(".vg-set__profile-meta .n")).toHaveText(nextName);
await expect(displayNameInput).toHaveValue(nextName);
const notify = page.locator("#set-notify");
const productNews = notify.getByRole("switch").last();
const currentProductNews = (await productNews.getAttribute("aria-checked")) === "true";
const nextProductNews = !currentProductNews;
await productNews.click();
await expect(productNews).toHaveAttribute("aria-checked", String(nextProductNews));
const preferencesPatchResponse = await runAndWaitForApiResponse(
page,
"PATCH",
"/users/me/preferences",
async () => {
const saveButton = notify.locator(".vg-set__foot .vg-btn");
await saveButton.scrollIntoViewIfNeeded();
await saveButton.focus();
await page.keyboard.press("Enter");
},
);
await expectResponseOk(preferencesPatchResponse);
const preferences = (await preferencesPatchResponse.json()) as UserPreferencesResponse;
expect(preferences.notifications.product_news).toBe(nextProductNews);
});
test("admin sees and updates the AI engine settings panel @single-run", async ({ page }, testInfo) => {
const displayName = `Settings Admin ${testInfo.project.name}`;
const email = await signInAs(page, "admin", testInfo, displayName);
await withGlobalEngineConfigLock(`settings-${slugFor(testInfo)}`, async () => {
const { engineConfig } = await openSettings(page, { admin: true });
expect(engineConfig).not.toBeNull();
const originalEngineConfig = engineConfig!;
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);
const nextMode =
originalEngineConfig.engine_mode === "claude_api" ? "claude_cli" : "claude_api";
const nextModel = `e2e-model-${slugFor(testInfo)}`;
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 waitForReactInputCommit(page);
const enginePatchResponse = await runAndWaitForApiResponse(
page,
"PATCH",
"/admin/engine-config",
async () => {
await engine.locator(".vg-set__foot .vg-btn").click();
},
hasEngineConfigRequestBody(nextMode, nextModel),
);
await expectResponseOk(enginePatchResponse);
const updatedEngine = (await enginePatchResponse.json()) as AdminEngineConfigResponse;
expect(updatedEngine).toMatchObject({
engine_mode: nextMode,
model: nextModel,
updated_by: email,
});
await expect(engine.locator("input").nth(1)).toHaveValue(nextModel);
const healthResponse = await page.request.get("/api/admin/health");
await expectResponseOk(healthResponse);
const health = await healthResponse.json();
expect(health.engine_mode).toBe(nextMode);
} finally {
const restoreResponse = await page.request.patch("/api/admin/engine-config", {
data: {
engine_mode: originalEngineConfig.engine_mode,
engine_url: originalEngineConfig.engine_url,
model: originalEngineConfig.model,
},
});
await expectResponseOk(restoreResponse);
}
});
});
test("admin engine settings panel stays readable at a mobile viewport", async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
const displayName = `Settings Mobile Admin ${testInfo.project.name}`;
await signInAs(page, "admin", testInfo, displayName);
const { engineConfig } = await openSettings(page, { admin: true });
expect(engineConfig).not.toBeNull();
const engine = page.locator("#set-engine");
await expect(engine).toBeVisible();
await expect(engine.locator("[data-engine-mode]")).toHaveCount(4);
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 expectNoHorizontalOverflow(page);
});
test("does not horizontally overflow at a mobile viewport", async ({ page }, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
await signInAs(page, "admin", testInfo, "E2E Admin", "admin@twentyoz.kr");
await openSettings(page, { admin: true });
await expectNoHorizontalOverflow(page);
});
});

172
apps/web/e2e/support.ts Normal file
View file

@ -0,0 +1,172 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
export interface E2EPersona {
code: string;
display_name: string;
source: string;
degraded: boolean;
}
export async function useRealApi(_page: Page) {
// Intentionally empty. E2E should exercise the local API through the Vite
// proxy instead of replacing app data with browser-side route fixtures.
}
function sleep(ms: number) {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
}
export async function withGlobalEngineConfigLock<T>(
label: string,
action: () => Promise<T>,
): Promise<T> {
const dir = path.join(process.cwd(), "node_modules", ".tmp");
const lockPath = path.join(dir, "engine-config.lock");
const startedAt = Date.now();
await fs.mkdir(dir, { recursive: true });
while (true) {
try {
const handle = await fs.open(lockPath, "wx");
try {
await handle.writeFile(`${process.pid} ${label} ${new Date().toISOString()}`);
return await action();
} finally {
await handle.close().catch(() => undefined);
await fs.unlink(lockPath).catch(() => undefined);
}
} catch (err) {
const code =
typeof err === "object" && err !== null && "code" in err
? String((err as { code?: unknown }).code)
: "";
if (code !== "EEXIST") throw err;
const stat = await fs.stat(lockPath).catch(() => null);
if (stat && Date.now() - stat.mtimeMs > 60_000) {
await fs.unlink(lockPath).catch(() => undefined);
continue;
}
if (Date.now() - startedAt > 30_000) {
throw new Error(`Timed out waiting for engine config lock: ${label}`);
}
await sleep(100);
}
}
}
export async function signInAsLearner(page: Page) {
const res = await page.request.post("/api/auth/dev-login", {
data: {
email: "learner@hs.ac.kr",
role: "learner",
display_name: "E2E Learner",
},
});
expect(res.ok(), await res.text()).toBeTruthy();
}
export async function signInAsTeacher(page: Page) {
const res = await page.request.post("/api/auth/dev-login", {
data: {
email: "teacher@hs.ac.kr",
role: "teacher",
display_name: "E2E Teacher",
},
});
expect(res.ok(), await res.text()).toBeTruthy();
}
export async function fetchAvailablePersonas(page: Page): Promise<E2EPersona[]> {
const res = await page.request.get("/api/personas");
expect(res.ok(), await res.text()).toBeTruthy();
const personas = (await res.json()) as E2EPersona[];
const usable = personas.filter((persona) => persona.source === "database" && !persona.degraded);
expect(usable.length, `Expected at least one database persona: ${JSON.stringify(personas)}`).toBeGreaterThan(0);
return usable;
}
export async function fetchAvailablePersona(page: Page, index = 0): Promise<E2EPersona> {
const personas = await fetchAvailablePersonas(page);
return personas[index] ?? personas[0];
}
export async function expectNoHorizontalOverflow(page: Page) {
await expect
.poll(async () => {
try {
return await page.evaluate(() => {
const doc = document.documentElement;
return Math.ceil(doc.scrollWidth - doc.clientWidth);
});
} catch (err) {
if (isNavigationRace(err)) return Number.MAX_SAFE_INTEGER;
throw err;
}
})
.toBeLessThanOrEqual(1);
const overflow = await page.evaluate(() => {
const doc = document.documentElement;
const viewportWidth = doc.clientWidth;
const delta = Math.ceil(doc.scrollWidth - viewportWidth);
const offenders = Array.from(document.querySelectorAll<HTMLElement>("body *"))
.map((el) => {
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
return {
tag: el.tagName.toLowerCase(),
className: String(el.className || ""),
text: (el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 80),
left: Math.floor(rect.left),
right: Math.ceil(rect.right),
width: Math.ceil(rect.width),
visible:
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity) !== 0 &&
rect.width > 0 &&
rect.height > 0,
};
})
.filter((item) => item.visible && (item.left < -1 || item.right > viewportWidth + 1))
.slice(0, 8);
return { delta, viewportWidth, offenders };
});
expect(
overflow.delta,
`Horizontal overflow ${overflow.delta}px at ${overflow.viewportWidth}px viewport. Offenders: ${JSON.stringify(
overflow.offenders,
)}`,
).toBeLessThanOrEqual(1);
}
export async function expectNoDocumentOverflow(page: Page) {
await expect
.poll(async () => {
try {
return await page.evaluate(() => {
const doc = document.documentElement;
return {
x: Math.ceil(doc.scrollWidth - doc.clientWidth),
y: Math.ceil(doc.scrollHeight - doc.clientHeight),
};
});
} catch (err) {
if (isNavigationRace(err)) return { x: Number.MAX_SAFE_INTEGER, y: Number.MAX_SAFE_INTEGER };
throw err;
}
})
.toEqual({ x: 0, y: 0 });
}
function isNavigationRace(err: unknown) {
return err instanceof Error && /Execution context was destroyed|most likely because of a navigation/i.test(err.message);
}

View file

@ -0,0 +1,111 @@
import { expect, test, type Page, type Response } from "@playwright/test";
import {
expectNoHorizontalOverflow,
fetchAvailablePersona,
signInAsLearner,
signInAsTeacher,
} from "./support";
interface SessionStartResponse {
session_id: string;
}
async function expectResponseOk(response: { ok: () => boolean; text: () => Promise<string> }) {
if (!response.ok()) {
expect(response.ok(), await response.text()).toBeTruthy();
}
}
function isTeacherDashboardResponse(response: Response) {
const url = new URL(response.url());
return response.request().method() === "GET" && url.pathname.endsWith("/teacher/dashboard");
}
async function createEndedLearnerSession(page: Page) {
await signInAsLearner(page);
const persona = await fetchAvailablePersona(page);
const start = await page.request.post("/api/sessions", {
data: {
persona_code: persona.code,
theory_mode: "humanistic",
},
});
await expectResponseOk(start);
const session = (await start.json()) as SessionStartResponse;
const ended = await page.request.post(`/api/sessions/${session.session_id}/end`);
await expectResponseOk(ended);
return session.session_id;
}
test.describe("teacher console", () => {
test("renders real server sessions from server-owned rows", async ({ page }) => {
const sessionId = await createEndedLearnerSession(page);
await signInAsTeacher(page);
const dashboardResponsePromise = page.waitForResponse(isTeacherDashboardResponse);
await page.goto("/teach");
const dashboardResponse = await dashboardResponsePromise;
await expectResponseOk(dashboardResponse);
const dashboard = await dashboardResponse.json();
expect(dashboard.recent_sessions.some((session: { session_id: string }) => session.session_id === sessionId)).toBe(
true,
);
await expect(page.locator("code").filter({ hasText: sessionId }).first()).toBeVisible();
await expect(page.getByRole("heading", { name: /\d+건의 리뷰가 대기 중입니다\./ })).toBeVisible();
await expect(page.getByText("3명에게 개입")).toHaveCount(0);
await expect(page.getByText("김상담")).toHaveCount(0);
await expectNoHorizontalOverflow(page);
});
test("keeps long teacher lists in bounded panels", async ({ page }) => {
await createEndedLearnerSession(page);
await signInAsTeacher(page);
await page.goto("/teach");
await expect(page.locator(".pf-list")).toBeVisible();
await expect(page.locator(".pf-tablewrap")).toBeVisible();
const metrics = await page.evaluate(() => {
const list = document.querySelector<HTMLElement>(".pf-list");
const table = document.querySelector<HTMLElement>(".pf-tablewrap");
const header = document.querySelector<HTMLElement>(".pf-table th");
if (!list || !table || !header) {
throw new Error("teacher list panels were not rendered");
}
const listStyle = window.getComputedStyle(list);
const tableStyle = window.getComputedStyle(table);
const headerStyle = window.getComputedStyle(header);
const doc = document.documentElement;
return {
docHeight: doc.scrollHeight,
viewportHeight: doc.clientHeight,
listMaxHeight: listStyle.maxHeight,
listOverflowY: listStyle.overflowY,
tableMaxHeight: tableStyle.maxHeight,
tableOverflowY: tableStyle.overflowY,
headerPosition: headerStyle.position,
};
});
expect(metrics.listMaxHeight).not.toBe("none");
expect(metrics.tableMaxHeight).not.toBe("none");
expect(["auto", "scroll"]).toContain(metrics.listOverflowY);
expect(["auto", "scroll"]).toContain(metrics.tableOverflowY);
expect(metrics.headerPosition).toBe("sticky");
expect(metrics.docHeight - metrics.viewportHeight).toBeLessThanOrEqual(2200);
await expectNoHorizontalOverflow(page);
});
test("denies learner access to the teacher dashboard API and UI", async ({ page }) => {
await signInAsLearner(page);
const denied = await page.request.get("/api/teacher/dashboard");
expect(denied.status(), await denied.text()).toBe(403);
await page.goto("/teach");
await expect(page).toHaveURL(/\/learn$/);
await expect(page.locator(".pf-root")).toHaveCount(0);
});
});

View file

@ -0,0 +1,360 @@
import { expect, test, type Page } from "@playwright/test";
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { existsSync } from "node:fs";
import http, { type IncomingMessage, type ServerResponse } from "node:http";
import net from "node:net";
interface TestServer {
url: string;
requests: () => string[];
close: () => Promise<void>;
}
interface SpawnedApi {
baseURL: string;
logs: () => string;
stop: () => Promise<void>;
}
interface VoiceProbe {
code: number;
messages: string[];
binaryChunks: number;
}
// This fixture intentionally starts a DB-offline API with ALLOW_SEED_PERSONA_FALLBACK=true
// so the voice provider cascade can be exercised without a Postgres dependency.
const SEEDED_VOICE_PERSONA_CODE = "P1";
function readBody(req: IncomingMessage): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
req.on("end", () => resolve(Buffer.concat(chunks)));
req.on("error", reject);
});
}
async function freePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
server.close(() => resolve(port));
});
});
}
async function startHttpServer(
handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>,
): Promise<TestServer> {
const port = await freePort();
const requests: string[] = [];
const server = http.createServer((req, res) => {
requests.push(`${req.method ?? "?"} ${req.url ?? "?"}`);
void Promise.resolve(handler(req, res)).catch((err) => {
res.writeHead(500, { "content-type": "text/plain" });
res.end(String(err));
});
});
await new Promise<void>((resolve) => server.listen(port, "127.0.0.1", resolve));
return {
url: `http://127.0.0.1:${port}`,
requests: () => requests,
close: () => new Promise((resolve) => server.close(() => resolve())),
};
}
async function startFakeOpenAI(): Promise<TestServer> {
return startHttpServer(async (req, res) => {
await readBody(req);
if (req.method === "POST" && req.url === "/v1/audio/transcriptions") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ text: "요즘 잠을 잘 못 자요.", language: "ko", duration: 1.2 }));
return;
}
if (req.method === "POST" && req.url === "/v1/audio/speech") {
res.writeHead(200, { "content-type": "audio/mpeg" });
res.end(Buffer.alloc(8192, 128));
return;
}
res.writeHead(404, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "not found" }));
});
}
async function startFakeEngine(): Promise<TestServer> {
return startHttpServer(async (req, res) => {
await readBody(req);
if (req.method === "GET" && req.url === "/health") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true, engine: "fake" }));
return;
}
if (req.method === "POST" && req.url === "/v1/generate") {
res.writeHead(200, { "content-type": "application/json" });
res.end(
JSON.stringify({
text: "괜찮아요. 천천히 말해볼게요.",
model: "fake-client",
provider: "e2e",
tokens_in: 1,
tokens_out: 1,
cost_usd: 0,
inference_geo: "us",
structured: null,
}),
);
return;
}
res.writeHead(404, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "not found" }));
});
}
async function waitForApi(baseURL: string, proc: ChildProcessWithoutNullStreams): Promise<void> {
const started = Date.now();
let lastError = "";
while (Date.now() - started < 20_000) {
if (proc.exitCode !== null) {
throw new Error(`API exited early with code ${proc.exitCode}: ${lastError}`);
}
try {
const response = await fetch(`${baseURL}/health`);
if (response.ok) return;
lastError = await response.text();
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(`Timed out waiting for API ${baseURL}: ${lastError}`);
}
async function startApi({
engineURL,
openAIBaseURL,
}: {
engineURL: string;
openAIBaseURL: string;
}): Promise<SpawnedApi> {
const port = await freePort();
const baseURL = `http://127.0.0.1:${port}`;
const localPython311 = process.env.USERPROFILE
? `${process.env.USERPROFILE}\\AppData\\Local\\Programs\\Python\\Python311\\python.exe`
: "";
const python =
process.env.E2E_PYTHON ??
process.env.PYTHON311 ??
(localPython311 && existsSync(localPython311) ? localPython311 : (process.env.PYTHON ?? "python"));
const proc = spawn(
python,
[
"-m",
"uvicorn",
"app.main:app",
"--host",
"127.0.0.1",
"--port",
String(port),
"--log-level",
"debug",
],
{
cwd: "../api",
env: {
...process.env,
PYTHONUNBUFFERED: "1",
ENVIRONMENT: "dev",
AUTH_DEV_LOGIN_ENABLED: "true",
AUTH_ALLOWED_EMAIL_DOMAINS: '["hs.ac.kr","twentyoz.kr"]',
ALLOW_SEED_PERSONA_FALLBACK: "true",
DATABASE_URL: "postgresql://user:pass@127.0.0.1:1/vignette",
DB_POOL_MIN_SIZE: "0",
DB_COMMAND_TIMEOUT: "1",
ENGINE_URL: engineURL,
ENGINE_MODE: "claude_api",
ENGINE_TIMEOUT: "10",
ENGINE_CONNECT_TIMEOUT: "2",
OPENAI_API_KEY: "e2e-fake-key",
OPENAI_BASE_URL: `${openAIBaseURL}/v1`,
FRONTEND_BASE_URL: "http://localhost:5173",
CORS_ORIGINS: '["http://localhost:5173"]',
},
windowsHide: true,
},
);
let logs = "";
proc.stdout.on("data", (chunk) => {
logs += String(chunk).slice(-4000);
});
proc.stderr.on("data", (chunk) => {
logs += String(chunk).slice(-4000);
});
await waitForApi(baseURL, proc).catch((err) => {
proc.kill();
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${logs}`);
});
return {
baseURL,
logs: () => logs,
stop: async () => {
if (proc.exitCode === null) proc.kill();
await new Promise<void>((resolve) => {
if (proc.exitCode !== null) {
resolve();
return;
}
proc.once("exit", () => resolve());
setTimeout(resolve, 3000);
});
},
};
}
async function probeVoiceCascade(page: Page, apiBaseURL: string, sessionId: string): Promise<VoiceProbe> {
return page.evaluate(
({ apiBase, sid }) =>
new Promise<VoiceProbe>((resolve) => {
const wsURL = new URL(`/voice/ws?session_id=${encodeURIComponent(sid)}`, apiBase);
wsURL.protocol = "ws:";
const ws = new WebSocket(wsURL.href);
ws.binaryType = "arraybuffer";
const messages: string[] = [];
let binaryChunks = 0;
let sawTtsEnd = false;
const timeout = window.setTimeout(() => {
ws.close();
resolve({ code: -1, messages, binaryChunks });
}, 20_000);
ws.onopen = () => {
ws.send(JSON.stringify({ type: "audio_start", format: "webm" }));
ws.send(new Uint8Array([1, 2, 3, 4, 5, 6]).buffer);
ws.send(JSON.stringify({ type: "audio_end", format: "webm" }));
};
ws.onmessage = (event) => {
if (typeof event.data === "string") {
messages.push(event.data);
try {
const parsed = JSON.parse(event.data) as { type?: string; state?: string };
if (parsed.type === "tts_end") sawTtsEnd = true;
if (sawTtsEnd && parsed.type === "state" && parsed.state === "idle") {
ws.send(JSON.stringify({ type: "close" }));
}
} catch {
messages.push(JSON.stringify({ type: "error", detail: "invalid json from ws" }));
}
} else {
binaryChunks += 1;
}
};
ws.onerror = () => {
messages.push(JSON.stringify({ type: "error", detail: "browser websocket error" }));
};
ws.onclose = (event) => {
window.clearTimeout(timeout);
resolve({ code: event.code, messages, binaryChunks });
};
}),
{ apiBase: apiBaseURL, sid: sessionId },
);
}
test.describe("voice cascade success path", () => {
test("runs STT, client turn, TTS, and audio chunks against controlled providers @single-run", async ({
page,
}, testInfo) => {
test.setTimeout(60_000);
const openai = await startFakeOpenAI();
const engine = await startFakeEngine();
const api = await startApi({ engineURL: engine.url, openAIBaseURL: openai.url });
try {
const health = await page.request.get(`${api.baseURL}/voice/health`);
expect(health.ok(), await health.text()).toBeTruthy();
await expect(await health.json()).toMatchObject({ status: "ok", available: true });
await page.goto(`${api.baseURL}/health`);
const browserSetup = await page.evaluate(async ({ apiBase, seededPersonaCode, workerIndex }) => {
const login = await fetch(`${apiBase}/auth/dev-login`, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({
email: `voice-success.${workerIndex}@hs.ac.kr`,
role: "learner",
display_name: "Voice Success",
}),
});
const loginBody = await login.text();
if (!login.ok) {
return { ok: false, step: "login", status: login.status, body: loginBody };
}
const me = await fetch(`${apiBase}/auth/me`, { credentials: "include" });
const meBody = await me.text();
if (!me.ok) {
return { ok: false, step: "me", status: me.status, body: meBody };
}
const start = await fetch(`${apiBase}/sessions`, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({ persona_code: seededPersonaCode, theory_mode: "humanistic" }),
});
const startBody = await start.text();
if (!start.ok) {
return { ok: false, step: "sessions", status: start.status, body: startBody };
}
return {
ok: true,
me: JSON.parse(meBody) as unknown,
started: JSON.parse(startBody) as { session_id: string },
};
}, {
apiBase: api.baseURL,
seededPersonaCode: SEEDED_VOICE_PERSONA_CODE,
workerIndex: testInfo.workerIndex,
});
expect(browserSetup, api.logs()).toMatchObject({ ok: true });
if (!browserSetup.ok) throw new Error(JSON.stringify(browserSetup));
const started = browserSetup.started;
const result = await probeVoiceCascade(page, api.baseURL, started.session_id);
const events = result.messages.map((message) => JSON.parse(message) as { type: string; [key: string]: unknown });
expect(
result.code,
[
JSON.stringify(result, null, 2),
`fakeOpenAI=${JSON.stringify(openai.requests())}`,
`fakeEngine=${JSON.stringify(engine.requests())}`,
api.logs(),
].join("\n\n"),
).toBe(1000);
expect(events.some((event) => event.type === "degraded")).toBe(false);
expect(events.some((event) => event.type === "error")).toBe(false);
expect(events).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "ready", session_id: started.session_id }),
expect.objectContaining({ type: "state", state: "listening" }),
expect.objectContaining({ type: "state", state: "thinking" }),
expect.objectContaining({ type: "transcript", text: "요즘 잠을 잘 못 자요." }),
expect.objectContaining({ type: "reply", text: "괜찮아요. 천천히 말해볼게요." }),
expect.objectContaining({ type: "state", state: "speaking" }),
expect.objectContaining({ type: "tts_chunk", seq: 0 }),
expect.objectContaining({ type: "tts_end" }),
expect.objectContaining({ type: "state", state: "idle" }),
]),
);
expect(result.binaryChunks).toBeGreaterThan(0);
} finally {
await api.stop();
await engine.close();
await openai.close();
}
});
});

115
apps/web/e2e/voice.spec.ts Normal file
View file

@ -0,0 +1,115 @@
import { expect, test, type Page, type TestInfo } from "@playwright/test";
import { fetchAvailablePersona } from "./support";
interface WsResult {
code: number;
messages: string[];
}
async function signInLearner(page: Page, testInfo: TestInfo, label: string) {
const res = await page.request.post("/api/auth/dev-login", {
data: {
email: `voice.${label}.${testInfo.project.name}.${testInfo.workerIndex}@hs.ac.kr`,
role: "learner",
display_name: `Voice ${label}`,
},
});
expect(res.ok(), await res.text()).toBeTruthy();
}
async function openVoiceSocket(page: Page, path: string): Promise<WsResult> {
return page.evaluate(
({ wsPath }) =>
new Promise<WsResult>((resolve) => {
const url = new URL(wsPath, window.location.href);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(url.href);
const messages: string[] = [];
const timeout = window.setTimeout(() => {
ws.close();
resolve({ code: -1, messages });
}, 5000);
ws.onmessage = (event) => {
messages.push(String(event.data));
};
ws.onclose = (event) => {
window.clearTimeout(timeout);
resolve({ code: event.code, messages });
};
ws.onerror = () => {
messages.push(JSON.stringify({ type: "error", detail: "browser websocket error" }));
};
}),
{ wsPath: path },
);
}
function parsedMessages(result: WsResult) {
return result.messages.map((message) => JSON.parse(message) as { type: string; detail?: string });
}
test.describe("voice websocket auth boundary", () => {
test("advertises only voice presets accepted by user preferences", async ({ page }, testInfo) => {
await signInLearner(page, testInfo, "presets");
const presetsResponse = await page.request.get("/api/users/me/voice-presets");
expect(presetsResponse.ok(), await presetsResponse.text()).toBeTruthy();
const presets = (await presetsResponse.json()) as { id: string; voice_id: string }[];
const ids = presets.map((preset) => preset.id);
expect(ids).toEqual(["soft-young-fem", "calm-adult-male", "warm-adult-fem", "neutral"]);
expect(ids).not.toContain("calm-adult-fem");
expect(ids).not.toContain("steady-adult-male");
for (const id of ids) {
const saveResponse = await page.request.patch("/api/users/me/preferences", {
data: { voice_preset_id: id },
});
expect(saveResponse.ok(), await saveResponse.text()).toBeTruthy();
expect(await saveResponse.json()).toMatchObject({ voice_preset_id: id });
}
const unsupported = await page.request.patch("/api/users/me/preferences", {
data: { voice_preset_id: "calm-adult-fem" },
});
expect(unsupported.status(), await unsupported.text()).toBe(422);
});
test("rejects unauthenticated websocket clients before degraded voice handling", async ({ page }) => {
await page.goto("/login");
const result = await openVoiceSocket(page, "/api/voice/ws?persona_code=UNAUTHENTICATED");
const messages = parsedMessages(result);
expect(result.code).toBe(1008);
expect(messages).toContainEqual({ type: "error", detail: "not authenticated" });
expect(messages.some((message) => message.type === "degraded")).toBeFalsy();
});
test("rejects binding another learner's session id", async ({ page }, testInfo) => {
await signInLearner(page, testInfo, "owner");
const persona = await fetchAvailablePersona(page);
const start = await page.request.post("/api/sessions", {
data: { persona_code: persona.code, theory_mode: "humanistic" },
});
expect(start.ok(), await start.text()).toBeTruthy();
const { session_id } = (await start.json()) as { session_id: string };
await signInLearner(page, testInfo, "other");
await page.goto("/learn");
const result = await openVoiceSocket(
page,
`/api/voice/ws?session_id=${encodeURIComponent(session_id)}`,
);
const messages = parsedMessages(result);
expect(result.code).toBe(1008);
expect(messages).toContainEqual({
type: "error",
detail: "session does not belong to user",
});
expect(messages.some((message) => message.type === "degraded")).toBeFalsy();
});
});

File diff suppressed because it is too large Load diff

View file

@ -9,17 +9,27 @@
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc -b", "typecheck": "tsc -b",
"lint": "tsc -b" "lint": "tsc -b",
"e2e": "playwright test",
"e2e:headed": "playwright test --headed",
"e2e:ui": "playwright test --ui"
}, },
"dependencies": { "dependencies": {
"pixi-live2d-display": "^0.4.0",
"pixi.js": "^6.5.10",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-router-dom": "^6.30.1" "react-router-dom": "^6.30.1"
}, },
"overrides": {
"gh-pages": "^6.3.0"
},
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.61.1",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0", "@vitejs/plugin-react": "^4.6.0",
"puppeteer-core": "^25.2.1",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^6.3.5" "vite": "^6.3.5"
} }

View file

@ -0,0 +1,89 @@
import { defineConfig, devices } from "@playwright/test";
const host = process.env.PLAYWRIGHT_HOST ?? "localhost";
const port = Number(process.env.PLAYWRIGHT_PORT ?? 5173);
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://${host}:${port}`;
const publicBaseURL = process.env.E2E_PUBLIC_BASE_URL ?? "https://vignette.chanpaca.net";
const publicStorageState = process.env.E2E_PUBLIC_STORAGE_STATE;
const includePublicAuth = process.env.E2E_PUBLIC_AUTH === "1";
const shouldStartWebServer =
!includePublicAuth && !process.env.PLAYWRIGHT_BASE_URL && !process.env.PLAYWRIGHT_SKIP_WEB_SERVER;
const singleRunPattern = /@single-run/;
const publicAuthPattern = /@public-auth/;
const localProjectExclusions = /@single-run|@public-auth/;
export default defineConfig({
testDir: "./e2e",
outputDir: "./node_modules/.tmp/playwright-results",
timeout: 30_000,
expect: {
timeout: 5_000,
},
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI
? [
["list"],
["html", { open: "never", outputFolder: "node_modules/.tmp/playwright-report" }],
]
: "list",
use: {
baseURL,
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
webServer: shouldStartWebServer
? {
command: `npm run dev -- --host ${host} --port ${port}`,
url: baseURL,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
env: {
VITE_API_BASE: process.env.VITE_API_BASE ?? "/api",
},
}
: undefined,
projects: [
{
name: "chromium-desktop",
grepInvert: localProjectExclusions,
use: {
...devices["Desktop Chrome"],
viewport: { width: 1440, height: 900 },
},
},
{
name: "chromium-mobile",
grepInvert: localProjectExclusions,
use: {
...devices["Pixel 5"],
},
},
{
name: "chromium-single-run",
grep: singleRunPattern,
grepInvert: publicAuthPattern,
use: {
...devices["Desktop Chrome"],
viewport: { width: 1280, height: 800 },
},
},
...(includePublicAuth
? [
{
name: "chromium-public-auth",
grep: publicAuthPattern,
use: {
...devices["Desktop Chrome"],
baseURL: publicBaseURL,
viewport: { width: 1440, height: 900 },
storageState: publicStorageState,
},
},
]
: []),
],
});

View file

@ -0,0 +1,30 @@
# Live2D Assets
This directory contains the Live2D runtime and optional sample models served by the Vite/nginx static web app.
Default runtime:
```text
/live2d/live2dcubismcore.min.js
```
Sample models:
```text
/live2d/mao/Mao.model3.json
/live2d/haru/haru_greeter_t03.model3.json
```
Sources:
- Cubism Core: https://cubism.live2d.com/sdk-web/cubismcore/live2dcubismcore.min.js
- Mao model: https://github.com/Live2D/CubismWebSamples/tree/develop/Samples/Resources/Mao
- Haru model: https://github.com/guansss/pixi-live2d-display/tree/master/test/assets/haru
The Live2D `CubismWebSamples` license lists `Samples/Resources/Mao` under Live2D's Free Material License.
The `pixi-live2d-display` README states that the Haru sample is redistributed under Live2D's Free Material License.
The local `model3.json` removes optional `DisplayInfo` and motion sound references that are absent from the copied asset set.
No sample model is loaded by default. Session avatars use the SVG/persona renderer unless the active `AvatarPersona.live2dModelUrl` points to a real persona-specific Cubism `*.model3.json` asset. The Mao and Haru samples are for local wiring checks only; production builds ignore those bundled sample URLs instead of rendering them as shared persona fallbacks.
The app also falls back to the SVG avatar when `live2dModelUrl` is omitted, set to `off`/`false`/`none`, or the model cannot be loaded.

View file

@ -0,0 +1,10 @@
{
"Type": "Live2D Expression",
"Parameters": [
{
"Id": "ParamMouthOpenY",
"Value": 0.27,
"Blend": "Add"
}
]
}

View file

@ -0,0 +1,35 @@
{
"Type": "Live2D Expression",
"Parameters": [
{
"Id": "ParamBrowLY",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamBrowRY",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamBrowLForm",
"Value": 1,
"Blend": "Add"
},
{
"Id": "ParamBrowRForm",
"Value": 1,
"Blend": "Add"
},
{
"Id": "ParamMouthOpenY",
"Value": 1,
"Blend": "Add"
},
{
"Id": "ParamEyeForm",
"Value": 0.54,
"Blend": "Add"
}
]
}

View file

@ -0,0 +1,55 @@
{
"Type": "Live2D Expression",
"Parameters": [
{
"Id": "ParamBrowLY",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamBrowRY",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamBrowLX",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamBrowRX",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamBrowRAngle",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamBrowLForm",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamBrowRForm",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamMouthForm",
"Value": -2,
"Blend": "Add"
},
{
"Id": "ParamMouthOpenY",
"Value": 1,
"Blend": "Add"
},
{
"Id": "ParamEyeForm",
"Value": -1,
"Blend": "Add"
}
]
}

View file

@ -0,0 +1,60 @@
{
"Type": "Live2D Expression",
"Parameters": [
{
"Id": "ParamEyeLOpen",
"Value": 0.8,
"Blend": "Multiply"
},
{
"Id": "ParamEyeROpen",
"Value": 0.8,
"Blend": "Multiply"
},
{
"Id": "ParamBrowLY",
"Value": -0.56,
"Blend": "Add"
},
{
"Id": "ParamBrowRY",
"Value": -0.56,
"Blend": "Add"
},
{
"Id": "ParamBrowRX",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamBrowLAngle",
"Value": 0.35,
"Blend": "Add"
},
{
"Id": "ParamBrowRAngle",
"Value": 0.35,
"Blend": "Add"
},
{
"Id": "ParamBrowLForm",
"Value": -0.74,
"Blend": "Add"
},
{
"Id": "ParamBrowRForm",
"Value": -0.74,
"Blend": "Add"
},
{
"Id": "ParamMouthForm",
"Value": -1.76,
"Blend": "Add"
},
{
"Id": "ParamEyeForm",
"Value": 1,
"Blend": "Add"
}
]
}

View file

@ -0,0 +1,35 @@
{
"Type": "Live2D Expression",
"Parameters": [
{
"Id": "ParamEyeLOpen",
"Value": 0,
"Blend": "Multiply"
},
{
"Id": "ParamEyeLSmile",
"Value": 1,
"Blend": "Add"
},
{
"Id": "ParamEyeROpen",
"Value": 0,
"Blend": "Multiply"
},
{
"Id": "ParamEyeRSmile",
"Value": 1,
"Blend": "Add"
},
{
"Id": "ParamBrowLY",
"Value": 0.32,
"Blend": "Add"
},
{
"Id": "ParamBrowRY",
"Value": 0.32,
"Blend": "Add"
}
]
}

View file

@ -0,0 +1,35 @@
{
"Type": "Live2D Expression",
"Parameters": [
{
"Id": "ParamEyeLOpen",
"Value": 2,
"Blend": "Multiply"
},
{
"Id": "ParamEyeROpen",
"Value": 2,
"Blend": "Multiply"
},
{
"Id": "ParamBrowLY",
"Value": 1,
"Blend": "Add"
},
{
"Id": "ParamBrowRY",
"Value": 1,
"Blend": "Add"
},
{
"Id": "ParamMouthForm",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamEyeBallForm",
"Value": -0.65,
"Blend": "Add"
}
]
}

View file

@ -0,0 +1,65 @@
{
"Type": "Live2D Expression",
"Parameters": [
{
"Id": "ParamEyeLOpen",
"Value": 0.89,
"Blend": "Multiply"
},
{
"Id": "ParamEyeROpen",
"Value": 0.89,
"Blend": "Multiply"
},
{
"Id": "ParamBrowLY",
"Value": -0.56,
"Blend": "Add"
},
{
"Id": "ParamBrowRY",
"Value": -0.56,
"Blend": "Add"
},
{
"Id": "ParamBrowLX",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamBrowRX",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamBrowLAngle",
"Value": 0.35,
"Blend": "Add"
},
{
"Id": "ParamBrowRAngle",
"Value": 0.35,
"Blend": "Add"
},
{
"Id": "ParamBrowLForm",
"Value": -0.74,
"Blend": "Add"
},
{
"Id": "ParamBrowRForm",
"Value": -0.74,
"Blend": "Add"
},
{
"Id": "ParamMouthForm",
"Value": -0.46,
"Blend": "Add"
},
{
"Id": "ParamTere",
"Value": 1,
"Blend": "Add"
}
]
}

View file

@ -0,0 +1,30 @@
{
"Type": "Live2D Expression",
"Parameters": [
{
"Id": "ParamEyeLOpen",
"Value": 0.8,
"Blend": "Multiply"
},
{
"Id": "ParamEyeROpen",
"Value": 0.8,
"Blend": "Multiply"
},
{
"Id": "ParamBrowLForm",
"Value": -0.33,
"Blend": "Add"
},
{
"Id": "ParamBrowRForm",
"Value": -0.33,
"Blend": "Add"
},
{
"Id": "ParamMouthForm",
"Value": -1.76,
"Blend": "Add"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

View file

@ -0,0 +1,94 @@
{
"Version": 3,
"FileReferences": {
"Moc": "haru_greeter_t03.moc3",
"Textures": [
"haru_greeter_t03.2048/texture_00.png",
"haru_greeter_t03.2048/texture_01.png"
],
"Physics": "haru_greeter_t03.physics3.json",
"Pose": "haru_greeter_t03.pose3.json",
"Expressions": [
{
"Name": "f00",
"File": "expressions/F01.exp3.json"
},
{
"Name": "f01",
"File": "expressions/F02.exp3.json"
},
{
"Name": "f02",
"File": "expressions/F03.exp3.json"
},
{
"Name": "f03",
"File": "expressions/F04.exp3.json"
},
{
"Name": "f04",
"File": "expressions/F05.exp3.json"
},
{
"Name": "f05",
"File": "expressions/F06.exp3.json"
},
{
"Name": "f06",
"File": "expressions/F07.exp3.json"
},
{
"Name": "f07",
"File": "expressions/F08.exp3.json"
}
],
"Motions": {
"Idle": [
{
"File": "motion/haru_g_idle.motion3.json"
},
{
"File": "motion/haru_g_m07.motion3.json"
},
{
"File": "motion/haru_g_m15.motion3.json"
}
],
"Tap": [
{
"File": "motion/haru_g_m14.motion3.json"
},
{
"File": "motion/haru_g_m05.motion3.json"
}
]
}
},
"Groups": [
{
"Target": "Parameter",
"Name": "EyeBlink",
"Ids": [
"ParamEyeLOpen",
"ParamEyeROpen"
]
},
{
"Target": "Parameter",
"Name": "LipSync",
"Ids": [
"ParamMouthOpenY"
]
}
],
"HitAreas": [
{
"Id": "HitArea",
"Name": "Head"
},
{
"Id": "HitArea2",
"Name": "Body"
}
]
}

View file

@ -0,0 +1,373 @@
{
"Version": 3,
"Meta": {
"PhysicsSettingCount": 4,
"TotalInputCount": 14,
"TotalOutputCount": 4,
"VertexCount": 8,
"EffectiveForces": {
"Gravity": {
"X": 0,
"Y": -1
},
"Wind": {
"X": 0,
"Y": 0
}
},
"PhysicsDictionary": [
{
"Id": "PhysicsSetting1",
"Name": "前髪"
},
{
"Id": "PhysicsSetting2",
"Name": "横髪"
},
{
"Id": "PhysicsSetting3",
"Name": "後ろ髪"
},
{
"Id": "PhysicsSetting4",
"Name": "スカーフ"
}
]
},
"PhysicsSettings": [
{
"Id": "PhysicsSetting1",
"Input": [
{
"Source": {
"Target": "Parameter",
"Id": "ParamAngleX"
},
"Weight": 60,
"Type": "X",
"Reflect": false
},
{
"Source": {
"Target": "Parameter",
"Id": "ParamAngleZ"
},
"Weight": 60,
"Type": "Angle",
"Reflect": false
},
{
"Source": {
"Target": "Parameter",
"Id": "ParamBodyAngleX"
},
"Weight": 40,
"Type": "X",
"Reflect": false
},
{
"Source": {
"Target": "Parameter",
"Id": "ParamBodyAngleZ"
},
"Weight": 40,
"Type": "Angle",
"Reflect": false
}
],
"Output": [
{
"Destination": {
"Target": "Parameter",
"Id": "ParamHairFront"
},
"VertexIndex": 1,
"Scale": 1.821,
"Weight": 100,
"Type": "Angle",
"Reflect": false
}
],
"Vertices": [
{
"Position": {
"X": 0,
"Y": 0
},
"Mobility": 1,
"Delay": 1,
"Acceleration": 1,
"Radius": 0
},
{
"Position": {
"X": 0,
"Y": 8
},
"Mobility": 0.95,
"Delay": 0.8,
"Acceleration": 1.5,
"Radius": 8
}
],
"Normalization": {
"Position": {
"Minimum": -10,
"Default": 0,
"Maximum": 10
},
"Angle": {
"Minimum": -10,
"Default": 0,
"Maximum": 10
}
}
},
{
"Id": "PhysicsSetting2",
"Input": [
{
"Source": {
"Target": "Parameter",
"Id": "ParamAngleX"
},
"Weight": 60,
"Type": "X",
"Reflect": false
},
{
"Source": {
"Target": "Parameter",
"Id": "ParamAngleZ"
},
"Weight": 60,
"Type": "Angle",
"Reflect": false
},
{
"Source": {
"Target": "Parameter",
"Id": "ParamBodyAngleX"
},
"Weight": 40,
"Type": "X",
"Reflect": false
},
{
"Source": {
"Target": "Parameter",
"Id": "ParamBodyAngleZ"
},
"Weight": 40,
"Type": "Angle",
"Reflect": false
}
],
"Output": [
{
"Destination": {
"Target": "Parameter",
"Id": "ParamHairSide"
},
"VertexIndex": 1,
"Scale": 1.593,
"Weight": 100,
"Type": "Angle",
"Reflect": false
}
],
"Vertices": [
{
"Position": {
"X": 0,
"Y": 0
},
"Mobility": 1,
"Delay": 1,
"Acceleration": 1,
"Radius": 0
},
{
"Position": {
"X": 0,
"Y": 8
},
"Mobility": 0.95,
"Delay": 0.8,
"Acceleration": 1,
"Radius": 8
}
],
"Normalization": {
"Position": {
"Minimum": -10,
"Default": 0,
"Maximum": 10
},
"Angle": {
"Minimum": -10,
"Default": 0,
"Maximum": 10
}
}
},
{
"Id": "PhysicsSetting3",
"Input": [
{
"Source": {
"Target": "Parameter",
"Id": "ParamAngleX"
},
"Weight": 60,
"Type": "X",
"Reflect": false
},
{
"Source": {
"Target": "Parameter",
"Id": "ParamAngleZ"
},
"Weight": 60,
"Type": "Angle",
"Reflect": false
},
{
"Source": {
"Target": "Parameter",
"Id": "ParamBodyAngleX"
},
"Weight": 40,
"Type": "X",
"Reflect": false
},
{
"Source": {
"Target": "Parameter",
"Id": "ParamBodyAngleZ"
},
"Weight": 40,
"Type": "Angle",
"Reflect": false
}
],
"Output": [
{
"Destination": {
"Target": "Parameter",
"Id": "ParamHairBack"
},
"VertexIndex": 1,
"Scale": 1.943,
"Weight": 100,
"Type": "Angle",
"Reflect": false
}
],
"Vertices": [
{
"Position": {
"X": 0,
"Y": 0
},
"Mobility": 1,
"Delay": 1,
"Acceleration": 1,
"Radius": 0
},
{
"Position": {
"X": 0,
"Y": 8
},
"Mobility": 0.95,
"Delay": 0.8,
"Acceleration": 1.5,
"Radius": 8
}
],
"Normalization": {
"Position": {
"Minimum": -10,
"Default": 0,
"Maximum": 10
},
"Angle": {
"Minimum": -10,
"Default": 0,
"Maximum": 10
}
}
},
{
"Id": "PhysicsSetting4",
"Input": [
{
"Source": {
"Target": "Parameter",
"Id": "ParamBodyAngleX"
},
"Weight": 100,
"Type": "X",
"Reflect": false
},
{
"Source": {
"Target": "Parameter",
"Id": "ParamBodyAngleZ"
},
"Weight": 100,
"Type": "Angle",
"Reflect": false
}
],
"Output": [
{
"Destination": {
"Target": "Parameter",
"Id": "ParamScarf"
},
"VertexIndex": 1,
"Scale": 0.873,
"Weight": 100,
"Type": "Angle",
"Reflect": false
}
],
"Vertices": [
{
"Position": {
"X": 0,
"Y": 0
},
"Mobility": 1,
"Delay": 1,
"Acceleration": 1,
"Radius": 0
},
{
"Position": {
"X": 0,
"Y": 10
},
"Mobility": 0.9,
"Delay": 0.6,
"Acceleration": 1.5,
"Radius": 10
}
],
"Normalization": {
"Position": {
"Minimum": -10,
"Default": 0,
"Maximum": 10
},
"Angle": {
"Minimum": -10,
"Default": 0,
"Maximum": 10
}
}
}
]
}

View file

@ -0,0 +1,25 @@
{
"Type": "Live2D Pose",
"Groups": [
[
{
"Id": "Part01ArmRA001",
"Link": []
},
{
"Id": "Part01ArmRB001",
"Link": []
}
],
[
{
"Id": "Part01ArmLA001",
"Link": []
},
{
"Id": "Part01ArmLB001",
"Link": []
}
]
]
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 MiB

View file

@ -0,0 +1,915 @@
{
"Version": 3,
"Parameters": [
{
"Id": "ParamAngleX",
"GroupId": "ParamGroupFace",
"Name": "角度 X"
},
{
"Id": "ParamAngleY",
"GroupId": "ParamGroupFace",
"Name": "角度 Y"
},
{
"Id": "ParamAngleZ",
"GroupId": "ParamGroupFace",
"Name": "角度 Z"
},
{
"Id": "ParamCheek",
"GroupId": "ParamGroupFace",
"Name": "照れ"
},
{
"Id": "ParamFaceInkOn",
"GroupId": "ParamGroupFace",
"Name": "顔インク 表示"
},
{
"Id": "ParamEyeLOpen",
"GroupId": "ParamGroupEyes",
"Name": "左目 開閉"
},
{
"Id": "ParamEyeLSmile",
"GroupId": "ParamGroupEyes",
"Name": "左目 笑顔"
},
{
"Id": "ParamEyeLForm",
"GroupId": "ParamGroupEyes",
"Name": "左目 変形"
},
{
"Id": "ParamEyeROpen",
"GroupId": "ParamGroupEyes",
"Name": "右目 開閉"
},
{
"Id": "ParamEyeRSmile",
"GroupId": "ParamGroupEyes",
"Name": "右目 笑顔"
},
{
"Id": "ParamEyeRForm",
"GroupId": "ParamGroupEyes",
"Name": "右目 変形"
},
{
"Id": "ParamEyeBallX",
"GroupId": "ParamGroupEyeballs",
"Name": "目玉 X"
},
{
"Id": "ParamEyeBallY",
"GroupId": "ParamGroupEyeballs",
"Name": "目玉 Y"
},
{
"Id": "ParamEyeBallForm",
"GroupId": "ParamGroupEyeballs",
"Name": "目玉 縮小"
},
{
"Id": "ParamEyeEffect",
"GroupId": "ParamGroupEyeballs",
"Name": "目 エフェクト"
},
{
"Id": "ParamBrowLY",
"GroupId": "ParamGroupBrows",
"Name": "左眉 上下"
},
{
"Id": "ParamBrowRY",
"GroupId": "ParamGroupBrows",
"Name": "右眉 上下"
},
{
"Id": "ParamBrowLX",
"GroupId": "ParamGroupBrows",
"Name": "左眉 左右"
},
{
"Id": "ParamBrowRX",
"GroupId": "ParamGroupBrows",
"Name": "右眉 左右"
},
{
"Id": "ParamBrowLAngle",
"GroupId": "ParamGroupBrows",
"Name": "左眉 角度"
},
{
"Id": "ParamBrowRAngle",
"GroupId": "ParamGroupBrows",
"Name": "右眉 角度"
},
{
"Id": "ParamBrowLForm",
"GroupId": "ParamGroupBrows",
"Name": "左眉 変形"
},
{
"Id": "ParamBrowRForm",
"GroupId": "ParamGroupBrows",
"Name": "右眉 変形"
},
{
"Id": "ParamA",
"GroupId": "ParamGroupMouth",
"Name": "あ"
},
{
"Id": "ParamI",
"GroupId": "ParamGroupMouth",
"Name": "い"
},
{
"Id": "ParamU",
"GroupId": "ParamGroupMouth",
"Name": "う"
},
{
"Id": "ParamE",
"GroupId": "ParamGroupMouth",
"Name": "え"
},
{
"Id": "ParamO",
"GroupId": "ParamGroupMouth",
"Name": "お"
},
{
"Id": "ParamMouthUp",
"GroupId": "ParamGroupMouth",
"Name": "上がり口"
},
{
"Id": "ParamMouthDown",
"GroupId": "ParamGroupMouth",
"Name": "下がり口"
},
{
"Id": "ParamMouthAngry",
"GroupId": "ParamGroupMouth",
"Name": "むくれ口"
},
{
"Id": "ParamMouthAngryLine",
"GroupId": "ParamGroupMouth",
"Name": "むくれ口線"
},
{
"Id": "ParamBodyAngleX",
"GroupId": "ParamGroupBody",
"Name": "体の回転 X"
},
{
"Id": "ParamBodyAngleY",
"GroupId": "ParamGroupBody",
"Name": "体の回転 Y"
},
{
"Id": "ParamBodyAngleZ",
"GroupId": "ParamGroupBody",
"Name": "体の回転 Z"
},
{
"Id": "ParamBreath",
"GroupId": "ParamGroupBody",
"Name": "呼吸"
},
{
"Id": "ParamLeftShoulderUp",
"GroupId": "ParamGroupBody",
"Name": "左肩の上下"
},
{
"Id": "ParamRightShoulderUp",
"GroupId": "ParamGroupBody",
"Name": "右肩の上下"
},
{
"Id": "ParamArmLA01",
"GroupId": "ParamGroupArmLA",
"Name": "左腕A 肩の回転"
},
{
"Id": "ParamArmLA02",
"GroupId": "ParamGroupArmLA",
"Name": "左腕A 肘の回転"
},
{
"Id": "ParamArmLA03",
"GroupId": "ParamGroupArmLA",
"Name": "左腕A 手首の回転"
},
{
"Id": "ParamHandLA",
"GroupId": "ParamGroupArmLA",
"Name": "左手A"
},
{
"Id": "ParamArmRA01",
"GroupId": "ParamGroupArmRA",
"Name": "右腕A 肩の回転"
},
{
"Id": "ParamArmRA02",
"GroupId": "ParamGroupArmRA",
"Name": "右腕A 肘の回転"
},
{
"Id": "ParamArmRA03",
"GroupId": "ParamGroupArmRA",
"Name": "右腕A 手首の回転"
},
{
"Id": "ParamWandRotate",
"GroupId": "ParamGroupArmRA",
"Name": "杖の回転"
},
{
"Id": "ParamHandRA",
"GroupId": "ParamGroupArmRA",
"Name": "右手A"
},
{
"Id": "ParamInkDrop",
"GroupId": "ParamGroupArmRA",
"Name": "インク垂れ"
},
{
"Id": "ParamInkDropRotate",
"GroupId": "ParamGroupArmRA",
"Name": "インク垂れ 回転"
},
{
"Id": "ParamInkDropOn",
"GroupId": "ParamGroupArmRA",
"Name": "インク垂れ 表示"
},
{
"Id": "ParamArmLB01",
"GroupId": "ParamGroupArmLB",
"Name": "左腕B 肩の回転"
},
{
"Id": "ParamArmLB02",
"GroupId": "ParamGroupArmLB",
"Name": "左腕B 肘の回転"
},
{
"Id": "ParamArmLB03",
"GroupId": "ParamGroupArmLB",
"Name": "左腕B 手首の回転"
},
{
"Id": "ParamHandLB",
"GroupId": "ParamGroupArmLB",
"Name": "左手B"
},
{
"Id": "ParamHatForm",
"GroupId": "ParamGroupArmLB",
"Name": "帽子の変形"
},
{
"Id": "ParamArmRB01",
"GroupId": "ParamGroupArmRB",
"Name": "右腕B 肩の回転"
},
{
"Id": "ParamArmRB02",
"GroupId": "ParamGroupArmRB",
"Name": "右腕B 肘の回転"
},
{
"Id": "ParamArmRB02Y",
"GroupId": "ParamGroupArmRB",
"Name": "右腕B 腕のY"
},
{
"Id": "ParamArmRB03",
"GroupId": "ParamGroupArmRB",
"Name": "右腕B 手首の回転"
},
{
"Id": "ParamHandRB",
"GroupId": "ParamGroupArmRB",
"Name": "右手B"
},
{
"Id": "ParamAllX",
"GroupId": "ParamGroupOverall",
"Name": "全体の移動 X"
},
{
"Id": "ParamAllY",
"GroupId": "ParamGroupOverall",
"Name": "全体の移動 Y"
},
{
"Id": "ParamAllRotate",
"GroupId": "ParamGroupOverall",
"Name": "全体の回転"
},
{
"Id": "ParamHairFront",
"GroupId": "ParamGroupSway",
"Name": "髪揺れ 前"
},
{
"Id": "ParamHairSideL",
"GroupId": "ParamGroupSway",
"Name": "髪揺れ 左横"
},
{
"Id": "ParamHairSideR",
"GroupId": "ParamGroupSway",
"Name": "髪揺れ 右横"
},
{
"Id": "ParamHairBack",
"GroupId": "ParamGroupSway",
"Name": "髪揺れ 後"
},
{
"Id": "ParamHairBackR",
"GroupId": "ParamGroupSway",
"Name": "髪揺れ 右後"
},
{
"Id": "ParamHairBackL",
"GroupId": "ParamGroupSway",
"Name": "髪揺れ 左後"
},
{
"Id": "ParamoHairMesh",
"GroupId": "ParamGroupSway",
"Name": "メッシュの揺れ"
},
{
"Id": "ParamHairFrontFuwa",
"GroupId": "ParamGroupSway",
"Name": "前髪 ふわ"
},
{
"Id": "ParamHairSideFuwa",
"GroupId": "ParamGroupSway",
"Name": "横髪 ふわ"
},
{
"Id": "ParamHairBackFuwa",
"GroupId": "ParamGroupSway",
"Name": "後ろ髪 ふわ"
},
{
"Id": "ParamWing",
"GroupId": "ParamGroupSway",
"Name": "羽の揺れ"
},
{
"Id": "ParamRibbon",
"GroupId": "ParamGroupSway",
"Name": "帽子リボンの揺れ"
},
{
"Id": "ParamHatBrim",
"GroupId": "ParamGroupSway",
"Name": "帽子つばの揺れ"
},
{
"Id": "ParamHatTop",
"GroupId": "ParamGroupSway",
"Name": "帽子 上の揺れ"
},
{
"Id": "ParamAccessory1",
"GroupId": "ParamGroupSway",
"Name": "首飾りの揺れ1"
},
{
"Id": "ParamAccessory2",
"GroupId": "ParamGroupSway",
"Name": "首飾りの揺れ2"
},
{
"Id": "ParamString",
"GroupId": "ParamGroupSway",
"Name": "パーカーひもの揺れ"
},
{
"Id": "ParamRobeL",
"GroupId": "ParamGroupSway",
"Name": "ローブの揺れ 左"
},
{
"Id": "ParamRobeR",
"GroupId": "ParamGroupSway",
"Name": "ローブの揺れ 右"
},
{
"Id": "ParamRobeFuwa",
"GroupId": "ParamGroupSway",
"Name": "ローブのふわ"
},
{
"Id": "ParamHeartMissOn",
"GroupId": "ParamGroupHeart",
"Name": "ハート失敗 表示"
},
{
"Id": "ParamHeartBackMissOn",
"GroupId": "ParamGroupHeart",
"Name": "ハート失敗後ろ 表示"
},
{
"Id": "ParamHeartColorRainbow",
"GroupId": "ParamGroupHeart",
"Name": "ハート失敗 虹色"
},
{
"Id": "ParamHeartHealOn",
"GroupId": "ParamGroupHeart",
"Name": "ハート回復 表示"
},
{
"Id": "ParamHeartBackHealOn",
"GroupId": "ParamGroupHeart",
"Name": "ハート回復後ろ 表示"
},
{
"Id": "ParamHeartColorHeal",
"GroupId": "ParamGroupHeart",
"Name": "ハート回復 緑色"
},
{
"Id": "ParamHeartDrow",
"GroupId": "ParamGroupHeart",
"Name": "ハート 描画"
},
{
"Id": "ParamHeartSize",
"GroupId": "ParamGroupHeart",
"Name": "ハート 拡縮"
},
{
"Id": "ParamHeartColorLight",
"GroupId": "ParamGroupHeart",
"Name": "ハート 色変化"
},
{
"Id": "ParamWandInkColorRainbow",
"GroupId": "ParamGroupInk",
"Name": "杖インク 虹色"
},
{
"Id": "ParamWandInkColorHeal",
"GroupId": "ParamGroupInk",
"Name": "杖インク 緑色"
},
{
"Id": "ParamWandInk",
"GroupId": "ParamGroupInk",
"Name": "杖インク"
},
{
"Id": "ParamSmokeOn",
"GroupId": "ParamGroupExplosion",
"Name": "煙 表示"
},
{
"Id": "ParamSmoke",
"GroupId": "ParamGroupExplosion",
"Name": "煙"
},
{
"Id": "ParamExplosionChargeOn",
"GroupId": "ParamGroupExplosion",
"Name": "爆発光溜め 表示"
},
{
"Id": "ParamExplosionLightCharge",
"GroupId": "ParamGroupExplosion",
"Name": "爆発光溜め"
},
{
"Id": "ParamExplosionOn",
"GroupId": "ParamGroupExplosion",
"Name": "爆発 表示"
},
{
"Id": "ParamExplosion",
"GroupId": "ParamGroupExplosion",
"Name": "爆発"
},
{
"Id": "ParamRabbitElimination",
"GroupId": "ParamGroupRabbit",
"Name": "うさぎ 消滅"
},
{
"Id": "ParamRabbitAppearance",
"GroupId": "ParamGroupRabbit",
"Name": "うさぎ 出現"
},
{
"Id": "ParamRabbitSize",
"GroupId": "ParamGroupRabbit",
"Name": "うさぎ 拡縮"
},
{
"Id": "ParamRabbitDraworder",
"GroupId": "ParamGroupRabbit",
"Name": "うさぎ 描画順"
},
{
"Id": "ParamRabbitEar",
"GroupId": "ParamGroupRabbit",
"Name": "うさぎ 耳"
},
{
"Id": "ParamRabbitDirection",
"GroupId": "ParamGroupRabbit",
"Name": "うさぎ 向き"
},
{
"Id": "ParamRabbitLight",
"GroupId": "ParamGroupRabbit",
"Name": "うさぎ 色変化"
},
{
"Id": "ParamRabbitLightSize",
"GroupId": "ParamGroupRabbit",
"Name": "うさぎ光 拡縮"
},
{
"Id": "ParamRabbitX",
"GroupId": "ParamGroupRabbit",
"Name": "うさぎの移動 X"
},
{
"Id": "ParamRabbitY",
"GroupId": "ParamGroupRabbit",
"Name": "うさぎの移動 Y"
},
{
"Id": "ParamRabbitRotate",
"GroupId": "ParamGroupRabbit",
"Name": "うさぎの回転"
},
{
"Id": "ParamAuraOn",
"GroupId": "ParamGroupAura",
"Name": "オーラ 表示"
},
{
"Id": "ParamAura",
"GroupId": "ParamGroupAura",
"Name": "オーラ"
},
{
"Id": "ParamAuraColor1",
"GroupId": "ParamGroupAura",
"Name": "オーラ 色変化1"
},
{
"Id": "ParamAuraColor2",
"GroupId": "ParamGroupAura",
"Name": "オーラ 色変化2"
},
{
"Id": "ParamHeartLightOn",
"GroupId": "ParamGroupLight",
"Name": "光 表示"
},
{
"Id": "ParamHeartLight",
"GroupId": "ParamGroupLight",
"Name": "光 星"
},
{
"Id": "ParamHeartLightColor",
"GroupId": "ParamGroupLight",
"Name": "光 色変化"
},
{
"Id": "ParamHealLightOn",
"GroupId": "ParamGroupLight",
"Name": "回復魔法光 表示"
},
{
"Id": "ParamHealLight",
"GroupId": "ParamGroupLight",
"Name": "回復魔法光"
},
{
"Id": "ParamStrengthenLightOn",
"GroupId": "ParamGroupLight",
"Name": "強化魔法光 表示"
},
{
"Id": "ParamStrengthenLight",
"GroupId": "ParamGroupLight",
"Name": "強化魔法光"
},
{
"Id": "ParamStrengthenLightMove",
"GroupId": "ParamGroupLight",
"Name": "強化魔法光 移動"
},
{
"Id": "ParamMagicPositionX",
"GroupId": "ParamGroupAllEffects",
"Name": "魔法の位置X"
},
{
"Id": "ParamMagicPositionY",
"GroupId": "ParamGroupAllEffects",
"Name": "魔法の位置Y"
},
{
"Id": "ParamAllColor1",
"GroupId": "ParamGroupAllEffects",
"Name": "全体の色1"
},
{
"Id": "ParamAllColor2",
"GroupId": "ParamGroupAllEffects",
"Name": "全体の色2"
},
{
"Id": "ParamSphereOn",
"GroupId": "ParamGroupColorSample",
"Name": "玉 表示"
},
{
"Id": "ParamSphereMove",
"GroupId": "ParamGroupColorSample",
"Name": "玉 移動"
},
{
"Id": "ParamSphereMultiplyColor",
"GroupId": "ParamGroupColorSample",
"Name": "玉 乗算色"
},
{
"Id": "ParamSphereScreenColor",
"GroupId": "ParamGroupColorSample",
"Name": "玉 スクリーン色"
}
],
"ParameterGroups": [
{
"Id": "ParamGroupFace",
"GroupId": "",
"Name": "顔"
},
{
"Id": "ParamGroupEyes",
"GroupId": "",
"Name": "目"
},
{
"Id": "ParamGroupEyeballs",
"GroupId": "",
"Name": "目玉"
},
{
"Id": "ParamGroupBrows",
"GroupId": "",
"Name": "眉"
},
{
"Id": "ParamGroupMouth",
"GroupId": "",
"Name": "口"
},
{
"Id": "ParamGroupBody",
"GroupId": "",
"Name": "体"
},
{
"Id": "ParamGroupArmLA",
"GroupId": "",
"Name": "左腕A"
},
{
"Id": "ParamGroupArmRA",
"GroupId": "",
"Name": "右腕A"
},
{
"Id": "ParamGroupArmLB",
"GroupId": "",
"Name": "左腕B"
},
{
"Id": "ParamGroupArmRB",
"GroupId": "",
"Name": "右腕B"
},
{
"Id": "ParamGroupOverall",
"GroupId": "",
"Name": "全体"
},
{
"Id": "ParamGroupSway",
"GroupId": "",
"Name": "揺れ"
},
{
"Id": "ParamGroupHeart",
"GroupId": "",
"Name": "ハート"
},
{
"Id": "ParamGroupInk",
"GroupId": "",
"Name": "インク"
},
{
"Id": "ParamGroupExplosion",
"GroupId": "",
"Name": "爆発"
},
{
"Id": "ParamGroupRabbit",
"GroupId": "",
"Name": "うさぎ"
},
{
"Id": "ParamGroupAura",
"GroupId": "",
"Name": "オーラ"
},
{
"Id": "ParamGroupLight",
"GroupId": "",
"Name": "光"
},
{
"Id": "ParamGroupAllEffects",
"GroupId": "",
"Name": "全体エフェクト"
},
{
"Id": "ParamGroupColorSample",
"GroupId": "",
"Name": "乗算色・スクリーン色サンプル"
}
],
"Parts": [
{
"Id": "PartCore",
"Name": "コア"
},
{
"Id": "PartRabbit",
"Name": "うさぎ"
},
{
"Id": "PartEffect",
"Name": "エフェクト"
},
{
"Id": "PartInk",
"Name": "インク"
},
{
"Id": "PartSmoke",
"Name": "煙"
},
{
"Id": "PartExplosionLight",
"Name": "爆発光"
},
{
"Id": "Partaura",
"Name": "オーラ"
},
{
"Id": "PartLight",
"Name": "光"
},
{
"Id": "PartHeart",
"Name": "ハート"
},
{
"Id": "PartHat",
"Name": "帽子"
},
{
"Id": "PartHairSide",
"Name": "横髪"
},
{
"Id": "PartHairFront",
"Name": "前髪"
},
{
"Id": "PartHairBack",
"Name": "後ろ髪"
},
{
"Id": "PartBrow",
"Name": "眉毛"
},
{
"Id": "PartEye",
"Name": "目"
},
{
"Id": "PartCheek",
"Name": "頬"
},
{
"Id": "PartNose",
"Name": "鼻"
},
{
"Id": "PartMouth",
"Name": "口"
},
{
"Id": "PartFace",
"Name": "顔"
},
{
"Id": "PartEar",
"Name": "耳"
},
{
"Id": "PartNeck",
"Name": "首"
},
{
"Id": "PartRobe",
"Name": "ローブ"
},
{
"Id": "PartHoodie",
"Name": "パーカー"
},
{
"Id": "PartLeg",
"Name": "脚"
},
{
"Id": "PartArmLA",
"Name": "左腕A"
},
{
"Id": "PartArmRA",
"Name": "右腕A"
},
{
"Id": "PartArmLB",
"Name": "左腕B"
},
{
"Id": "PartArmRB",
"Name": "右腕B"
},
{
"Id": "PartColorSample",
"Name": "乗算色・スクリーン色サンプル"
},
{
"Id": "PartSketch",
"Name": "[ 下絵 ]"
},
{
"Id": "PartEyeBall",
"Name": "目玉"
},
{
"Id": "PartWandA",
"Name": "杖A"
},
{
"Id": "PartWandB",
"Name": "杖B"
}
],
"CombinedParameters": [
[
"ParamAngleX",
"ParamAngleY"
],
[
"ParamAllX",
"ParamAllY"
],
[
"ParamMagicPositionX",
"ParamMagicPositionY"
]
]
}

Binary file not shown.

View file

@ -0,0 +1,103 @@
{
"Version": 3,
"FileReferences": {
"Moc": "Mao.moc3",
"Textures": [
"Mao.2048/texture_00.png"
],
"Physics": "Mao.physics3.json",
"Pose": "Mao.pose3.json",
"DisplayInfo": "Mao.cdi3.json",
"Expressions": [
{
"Name": "exp_01",
"File": "expressions/exp_01.exp3.json"
},
{
"Name": "exp_02",
"File": "expressions/exp_02.exp3.json"
},
{
"Name": "exp_03",
"File": "expressions/exp_03.exp3.json"
},
{
"Name": "exp_04",
"File": "expressions/exp_04.exp3.json"
},
{
"Name": "exp_05",
"File": "expressions/exp_05.exp3.json"
},
{
"Name": "exp_06",
"File": "expressions/exp_06.exp3.json"
},
{
"Name": "exp_07",
"File": "expressions/exp_07.exp3.json"
},
{
"Name": "exp_08",
"File": "expressions/exp_08.exp3.json"
}
],
"Motions": {
"Idle": [
{
"File": "motions/mtn_01.motion3.json"
},
{
"File": "motions/sample_01.motion3.json"
}
],
"TapBody": [
{
"File": "motions/mtn_02.motion3.json"
},
{
"File": "motions/mtn_03.motion3.json"
},
{
"File": "motions/mtn_04.motion3.json"
},
{
"File": "motions/special_01.motion3.json"
},
{
"File": "motions/special_02.motion3.json"
},
{
"File": "motions/special_03.motion3.json"
}
]
}
},
"Groups": [
{
"Target": "Parameter",
"Name": "LipSync",
"Ids": [
"ParamA"
]
},
{
"Target": "Parameter",
"Name": "EyeBlink",
"Ids": [
"ParamEyeLOpen",
"ParamEyeROpen"
]
}
],
"HitAreas": [
{
"Id": "HitAreaHead",
"Name": "Head"
},
{
"Id": "HitAreaBody",
"Name": "Body"
}
]
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,25 @@
{
"Type": "Live2D Pose",
"Groups": [
[
{
"Id": "PartArmLA",
"Link": []
},
{
"Id": "PartArmLB",
"Link": []
}
],
[
{
"Id": "PartArmRA",
"Link": []
},
{
"Id": "PartArmRB",
"Link": []
}
]
]
}

View file

@ -0,0 +1,147 @@
{
"Type": "Live2D Expression",
"FadeInTime": 0.5,
"FadeOutTime": 0.5,
"Parameters": [
{
"Id": "ParamCheek",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeLOpen",
"Value": 1,
"Blend": "Multiply"
},
{
"Id": "ParamEyeLSmile",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeLForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeROpen",
"Value": 1,
"Blend": "Multiply"
},
{
"Id": "ParamEyeRSmile",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeRForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeBallX",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeBallY",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeBallForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeEffect",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLY",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRY",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLX",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRX",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLAngle",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRAngle",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamA",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamI",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamU",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamE",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamO",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamMouthUp",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamMouthDown",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamMouthAngry",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamMouthAngryLine",
"Value": 0,
"Blend": "Add"
}
]
}

View file

@ -0,0 +1,147 @@
{
"Type": "Live2D Expression",
"FadeInTime": 0.5,
"FadeOutTime": 0.5,
"Parameters": [
{
"Id": "ParamCheek",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeLOpen",
"Value": 0,
"Blend": "Multiply"
},
{
"Id": "ParamEyeLSmile",
"Value": 1,
"Blend": "Add"
},
{
"Id": "ParamEyeLForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeROpen",
"Value": 0,
"Blend": "Multiply"
},
{
"Id": "ParamEyeRSmile",
"Value": 1,
"Blend": "Add"
},
{
"Id": "ParamEyeRForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeBallX",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeBallY",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeBallForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeEffect",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLY",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRY",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLX",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRX",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLAngle",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRAngle",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamA",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamI",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamU",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamE",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamO",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamMouthUp",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamMouthDown",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamMouthAngry",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamMouthAngryLine",
"Value": 0,
"Blend": "Add"
}
]
}

View file

@ -0,0 +1,147 @@
{
"Type": "Live2D Expression",
"FadeInTime": 0.5,
"FadeOutTime": 0.5,
"Parameters": [
{
"Id": "ParamCheek",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeLOpen",
"Value": 0,
"Blend": "Multiply"
},
{
"Id": "ParamEyeLSmile",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeLForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeROpen",
"Value": 0,
"Blend": "Multiply"
},
{
"Id": "ParamEyeRSmile",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeRForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeBallX",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeBallY",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeBallForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeEffect",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLY",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRY",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLX",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRX",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLAngle",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRAngle",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamA",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamI",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamU",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamE",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamO",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamMouthUp",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamMouthDown",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamMouthAngry",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamMouthAngryLine",
"Value": 0,
"Blend": "Add"
}
]
}

View file

@ -0,0 +1,147 @@
{
"Type": "Live2D Expression",
"FadeInTime": 0.5,
"FadeOutTime": 0.5,
"Parameters": [
{
"Id": "ParamCheek",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeLOpen",
"Value": 1.2,
"Blend": "Multiply"
},
{
"Id": "ParamEyeLSmile",
"Value": 1,
"Blend": "Add"
},
{
"Id": "ParamEyeLForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeROpen",
"Value": 1.2,
"Blend": "Multiply"
},
{
"Id": "ParamEyeRSmile",
"Value": 1,
"Blend": "Add"
},
{
"Id": "ParamEyeRForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeBallX",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeBallY",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeBallForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeEffect",
"Value": 1,
"Blend": "Add"
},
{
"Id": "ParamBrowLY",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRY",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLX",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRX",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLAngle",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRAngle",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamA",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamI",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamU",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamE",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamO",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamMouthUp",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamMouthDown",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamMouthAngry",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamMouthAngryLine",
"Value": 0,
"Blend": "Add"
}
]
}

View file

@ -0,0 +1,147 @@
{
"Type": "Live2D Expression",
"FadeInTime": 0.5,
"FadeOutTime": 0.5,
"Parameters": [
{
"Id": "ParamCheek",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeLOpen",
"Value": 1,
"Blend": "Multiply"
},
{
"Id": "ParamEyeLSmile",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeLForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeROpen",
"Value": 1,
"Blend": "Multiply"
},
{
"Id": "ParamEyeRSmile",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeRForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeBallX",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeBallY",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeBallForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeEffect",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLY",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRY",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLX",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRX",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLAngle",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamBrowRAngle",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamBrowLForm",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamBrowRForm",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamA",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamI",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamU",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamE",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamO",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamMouthUp",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamMouthDown",
"Value": 1,
"Blend": "Add"
},
{
"Id": "ParamMouthAngry",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamMouthAngryLine",
"Value": 0,
"Blend": "Add"
}
]
}

View file

@ -0,0 +1,147 @@
{
"Type": "Live2D Expression",
"FadeInTime": 0.5,
"FadeOutTime": 0.5,
"Parameters": [
{
"Id": "ParamCheek",
"Value": 1,
"Blend": "Add"
},
{
"Id": "ParamEyeLOpen",
"Value": 1,
"Blend": "Multiply"
},
{
"Id": "ParamEyeLSmile",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeLForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeROpen",
"Value": 1,
"Blend": "Multiply"
},
{
"Id": "ParamEyeRSmile",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeRForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeBallX",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeBallY",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeBallForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeEffect",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLY",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRY",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLX",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRX",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLAngle",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamBrowRAngle",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamBrowLForm",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamBrowRForm",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamA",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamI",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamU",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamE",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamO",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamMouthUp",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamMouthDown",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamMouthAngry",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamMouthAngryLine",
"Value": 0,
"Blend": "Add"
}
]
}

View file

@ -0,0 +1,147 @@
{
"Type": "Live2D Expression",
"FadeInTime": 0.5,
"FadeOutTime": 0.5,
"Parameters": [
{
"Id": "ParamCheek",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeLOpen",
"Value": 1.2,
"Blend": "Multiply"
},
{
"Id": "ParamEyeLSmile",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeLForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeROpen",
"Value": 1.2,
"Blend": "Multiply"
},
{
"Id": "ParamEyeRSmile",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeRForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeBallX",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeBallY",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeBallForm",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamEyeEffect",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLY",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRY",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLX",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRX",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLAngle",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRAngle",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLForm",
"Value": 1,
"Blend": "Add"
},
{
"Id": "ParamBrowRForm",
"Value": 1,
"Blend": "Add"
},
{
"Id": "ParamA",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamI",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamU",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamE",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamO",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamMouthUp",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamMouthDown",
"Value": 1,
"Blend": "Add"
},
{
"Id": "ParamMouthAngry",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamMouthAngryLine",
"Value": 0,
"Blend": "Add"
}
]
}

View file

@ -0,0 +1,147 @@
{
"Type": "Live2D Expression",
"FadeInTime": 0.5,
"FadeOutTime": 0.5,
"Parameters": [
{
"Id": "ParamCheek",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeLOpen",
"Value": 1,
"Blend": "Multiply"
},
{
"Id": "ParamEyeLSmile",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeLForm",
"Value": 1,
"Blend": "Add"
},
{
"Id": "ParamEyeROpen",
"Value": 1,
"Blend": "Multiply"
},
{
"Id": "ParamEyeRSmile",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeRForm",
"Value": 1,
"Blend": "Add"
},
{
"Id": "ParamEyeBallX",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeBallY",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeBallForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamEyeEffect",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLY",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRY",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLX",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRX",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLAngle",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRAngle",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowLForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRForm",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamA",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamI",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamU",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamE",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamO",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamMouthUp",
"Value": -1,
"Blend": "Add"
},
{
"Id": "ParamMouthDown",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamMouthAngry",
"Value": 1,
"Blend": "Add"
},
{
"Id": "ParamMouthAngryLine",
"Value": 1,
"Blend": "Add"
}
]
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -6,22 +6,22 @@
( 2), ( 2),
(·· , ), "감지되되 단언되지 않게". (·· , ), "감지되되 단언되지 않게".
(§4.7): SVG + CSS/Web Animations + Web Audio (1). (§4.7): Live2D Cubism 4 + SVG/CSS/Web Audio .
- rAF useAvatarMotion: 호흡 + + (RMS) + saccade - rAF useAvatarMotion: 호흡 + + (RMS) + saccade
- 6 (persona.ts): eyelidDrop/gazeAvert/shoulderTurn/ - 6 (persona.ts): eyelidDrop/gazeAvert/shoulderTurn/
breathRate/auraHue/blinkInterval. 8°0° ( ). breathRate/auraHue/blinkInterval. 8°0° ( ).
- transform/opacity (layout ). 2 . - transform/opacity (layout ). 2 .
- prefers-reduced-motion: 호흡/ + . - prefers-reduced-motion: 호흡/ + .
- analyser=null: / . - analyser=null: .
Rive 2 Live2D
.riv , .model3.json ,
(persona / state / affect / analyser / rapport) (persona / state / affect / analyser / rapport)
<svg>+useAvatarMotion <RiveAvatar> . Live2DAvatar Cubism model parameter wiring . /
AffectParams(persona.ts) .riv state-machine input wiring. SVG .
===================================================================== */ ===================================================================== */
import { useEffect, useMemo, useState } from "react"; import { Suspense, lazy, useCallback, useEffect, useMemo, useState } from "react";
import { import {
ageLookFor, ageLookFor,
baseResistanceOf, baseResistanceOf,
@ -41,6 +41,10 @@ import { Mouth } from "./Mouth";
Session.tsx ClientAvatar . */ Session.tsx ClientAvatar . */
export type { AvatarState, AvatarAffect, AvatarPersona } from "./persona"; export type { AvatarState, AvatarAffect, AvatarPersona } from "./persona";
const Live2DAvatar = lazy(() =>
import("./Live2DAvatar").then((mod) => ({ default: mod.Live2DAvatar })),
);
export interface ClientAvatarProps { export interface ClientAvatarProps {
persona: AvatarPersona; persona: AvatarPersona;
state: AvatarState; state: AvatarState;
@ -53,7 +57,7 @@ export interface ClientAvatarProps {
*/ */
rapport?: number; rapport?: number;
/** /**
* analyser 0~1. * analyser 0~1.
* null/ speaking . * null/ speaking .
*/ */
speakingProgress?: number | null; speakingProgress?: number | null;
@ -89,6 +93,29 @@ const STATE_TEXT: Record<AvatarState, string> = {
speaking: "이야기하는 중", speaking: "이야기하는 중",
}; };
const DISABLED_LIVE2D_MODEL_URLS = new Set(["off", "false", "none"]);
const BUNDLED_DEMO_LIVE2D_MODEL_PATHS = new Set([
"/live2d/mao/Mao.model3.json",
"/live2d/haru/haru_greeter_t03.model3.json",
]);
function isBundledDemoLive2DModel(url: string): boolean {
try {
return BUNDLED_DEMO_LIVE2D_MODEL_PATHS.has(
new URL(url, "http://vignette.local").pathname,
);
} catch {
return false;
}
}
function resolveLive2DModelUrl(persona: AvatarPersona): string | null {
const url = persona.live2dModelUrl?.trim();
if (!url || DISABLED_LIVE2D_MODEL_URLS.has(url.toLowerCase())) return null;
if (import.meta.env.PROD && isBundledDemoLive2DModel(url)) return null;
return url;
}
export function ClientAvatar({ export function ClientAvatar({
persona, persona,
state, state,
@ -115,6 +142,29 @@ export function ClientAvatar({
() => resolveAffectParams(affect, state, effectiveRapport), () => resolveAffectParams(affect, state, effectiveRapport),
[affect, state, effectiveRapport], [affect, state, effectiveRapport],
); );
const live2DModelUrl = useMemo(() => resolveLive2DModelUrl(persona), [persona]);
const [live2DReady, setLive2DReady] = useState(false);
const [live2DFailed, setLive2DFailed] = useState(false);
const [live2DError, setLive2DError] = useState<string | null>(null);
useEffect(() => {
setLive2DReady(false);
setLive2DFailed(false);
setLive2DError(null);
}, [live2DModelUrl]);
const handleLive2DReady = useCallback(() => {
setLive2DReady(true);
setLive2DError(null);
}, []);
const handleLive2DUnavailable = useCallback((reason: string) => {
setLive2DReady(false);
setLive2DFailed(true);
setLive2DError(reason);
}, []);
const shouldUseLive2D = Boolean(live2DModelUrl) && !live2DFailed;
// 모션 루프 (reduced 면 정지) // 모션 루프 (reduced 면 정지)
const frame = useAvatarMotion({ const frame = useAvatarMotion({
@ -148,6 +198,8 @@ export function ClientAvatar({
style={{ width: size }} style={{ width: size }}
data-state={state} data-state={state}
data-affect={affect} data-affect={affect}
data-live2d={live2DReady ? "ready" : shouldUseLive2D ? "loading" : "off"}
data-live2d-error={live2DError ?? undefined}
data-realism={realism} /* 사실성은 데이터로만 유지(시각 표식 비노출, §4.6) */ data-realism={realism} /* 사실성은 데이터로만 유지(시각 표식 비노출, §4.6) */
aria-label={`교육용 가상 내담자: ${persona.label}, ${STATE_TEXT[state]}`} aria-label={`교육용 가상 내담자: ${persona.label}, ${STATE_TEXT[state]}`}
> >
@ -171,7 +223,7 @@ export function ClientAvatar({
/> />
<svg <svg
className="vg-avatar__svg" className={"vg-avatar__svg" + (live2DReady ? " is-live2d-covered" : "")}
viewBox="0 0 200 200" viewBox="0 0 200 200"
width={size} width={size}
height={size} height={size}
@ -205,6 +257,23 @@ export function ClientAvatar({
</g> </g>
</g> </g>
</svg> </svg>
{shouldUseLive2D && live2DModelUrl ? (
<Suspense fallback={null}>
<Live2DAvatar
modelUrl={live2DModelUrl}
state={state}
affect={affect}
params={params}
analyser={analyser}
speakingProgress={speakingProgress}
reduced={reduced}
size={size}
onReady={handleLive2DReady}
onUnavailable={handleLive2DUnavailable}
/>
</Suspense>
) : null}
</div> </div>
{/* 페르소나 메타 + 상태 텍스트 */} {/* 페르소나 메타 + 상태 텍스트 */}
@ -237,7 +306,10 @@ const AVATAR_CSS = `
} }
.vg-avatar__aura.is-reduced{animation:none;} .vg-avatar__aura.is-reduced{animation:none;}
@keyframes vgAuraBreathe{0%,100%{opacity:.85;transform:scale(1)}50%{opacity:1;transform:scale(1.04)}} @keyframes vgAuraBreathe{0%,100%{opacity:.85;transform:scale(1)}50%{opacity:1;transform:scale(1.04)}}
.vg-avatar__svg{position:relative;z-index:1;display:block;} .vg-avatar__svg{position:relative;z-index:1;display:block;transition:opacity var(--dur-base) var(--ease-out);}
.vg-avatar__svg.is-live2d-covered{opacity:0;}
.vg-avatar__live2d{position:absolute;inset:0;z-index:2;display:flex;align-items:center;justify-content:center;pointer-events:none;}
.vg-avatar__live2d-canvas{width:100%;height:100%;display:block;}
.vg-avatar__meta{display:flex;flex-direction:column;align-items:center;gap:3px;text-align:center;} .vg-avatar__meta{display:flex;flex-direction:column;align-items:center;gap:3px;text-align:center;}
.vg-avatar__persona{font-size:var(--fs-sm);font-weight:600;color:var(--text-strong);} .vg-avatar__persona{font-size:var(--fs-sm);font-weight:600;color:var(--text-strong);}
.vg-avatar__state{font-size:var(--fs-xs);color:var(--text-muted);} .vg-avatar__state{font-size:var(--fs-xs);color:var(--text-muted);}

View file

@ -0,0 +1,383 @@
import { useEffect, useRef } from "react";
import type { AffectParams, AvatarAffect, AvatarState } from "./persona";
type PixiNamespace = typeof import("pixi.js");
type PixiApplication = import("pixi.js").Application;
type Live2DModule = typeof import("pixi-live2d-display/cubism4");
type Live2DModelCtor = Live2DModule["Live2DModel"];
type Live2DModelInstance = import("pixi-live2d-display/cubism4").Live2DModel;
interface Live2DAvatarProps {
modelUrl: string;
state: AvatarState;
affect: AvatarAffect;
params: AffectParams;
analyser: AnalyserNode | null;
speakingProgress: number | null;
reduced: boolean;
size: number;
onReady: () => void;
onUnavailable: (reason: string) => void;
}
type MutableLive2DModel = Live2DModelInstance & {
internalModel?: {
coreModel?: {
setParameterValueById?: (id: string, value: number, weight?: number) => void;
addParameterValueById?: (id: string, value: number, weight?: number) => void;
};
};
};
type Live2DRefState = Pick<
Live2DAvatarProps,
"state" | "affect" | "params" | "analyser" | "speakingProgress" | "reduced" | "size"
>;
declare global {
interface Window {
PIXI?: PixiNamespace;
Live2DCubismCore?: unknown;
}
}
const DEFAULT_CUBISM_CORE = "/live2d/live2dcubismcore.min.js";
const CORE_SCRIPT =
import.meta.env.VITE_LIVE2D_CUBISM_CORE?.trim() || DEFAULT_CUBISM_CORE;
let pixiRegistered = false;
let cubismCorePromise: Promise<void> | null = null;
let runtimePromise:
| Promise<{ PIXI: PixiNamespace; Live2DModel: Live2DModelCtor; MotionPreloadStrategy: Live2DModule["MotionPreloadStrategy"] }>
| null = null;
function registerPixi(PIXI: PixiNamespace, Live2DModel: Live2DModelCtor) {
if (pixiRegistered) return;
window.PIXI = PIXI;
Live2DModel.registerTicker(PIXI.Ticker);
pixiRegistered = true;
}
function loadLive2DRuntime() {
if (!runtimePromise) {
runtimePromise = Promise.all([
import("pixi.js"),
import("pixi-live2d-display/cubism4"),
]).then(([PIXI, live2d]) => {
registerPixi(PIXI, live2d.Live2DModel);
return {
PIXI,
Live2DModel: live2d.Live2DModel,
MotionPreloadStrategy: live2d.MotionPreloadStrategy,
};
});
}
return runtimePromise;
}
function hasCubismCore(): boolean {
return typeof window !== "undefined" && Boolean(window.Live2DCubismCore);
}
function appendScript(src: string): Promise<void> {
if (hasCubismCore()) return Promise.resolve();
const existing = Array.from(document.scripts).find(
(script) => script.dataset.vgLive2dCore === src,
);
if (existing) {
return new Promise((resolve, reject) => {
existing.addEventListener("load", () => resolve(), { once: true });
existing.addEventListener("error", () => reject(new Error(`Cubism Core load failed: ${src}`)), {
once: true,
});
});
}
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = src;
script.async = true;
script.dataset.vgLive2dCore = src;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`Cubism Core load failed: ${src}`));
document.head.appendChild(script);
});
}
function ensureCubismCore(): Promise<void> {
if (hasCubismCore()) return Promise.resolve();
if (!cubismCorePromise) {
cubismCorePromise = (async () => {
await appendScript(CORE_SCRIPT);
if (!hasCubismCore()) throw new Error("Cubism Core did not initialize.");
})().catch((err) => {
cubismCorePromise = null;
throw err;
});
}
return cubismCorePromise;
}
function fitModel(model: Live2DModelInstance, size: number) {
model.anchor.set(0.5, 0.5);
model.scale.set(1);
const bounds = model.getLocalBounds();
const boundsWidth = Math.max(1, bounds.width);
const boundsHeight = Math.max(1, bounds.height);
const scale = Math.min((size * 0.86) / boundsWidth, (size * 0.98) / boundsHeight);
model.scale.set(scale);
model.x = size / 2;
model.y = size * 0.58;
}
function fallbackMouthTarget(tSec: number, progress: number | null): number {
if (progress !== null && progress >= 1) return 0;
const syl = 0.5 + 0.5 * Math.sin(tSec * 2 * Math.PI * 5.5);
const jitter = 0.5 + 0.5 * Math.sin(tSec * 2 * Math.PI * 11 + 1.3);
return Math.min(0.85, 0.18 + 0.42 * syl * jitter);
}
function mouthFromAnalyser(
analyser: AnalyserNode | null,
buffer: Float32Array<ArrayBuffer>,
previous: number,
dtSec: number,
tSec: number,
progress: number | null,
): number {
let target = fallbackMouthTarget(tSec, progress);
if (analyser) {
analyser.getFloatTimeDomainData(buffer);
let sum = 0;
for (let i = 0; i < buffer.length; i++) sum += buffer[i] * buffer[i];
const rms = Math.sqrt(sum / buffer.length);
target = rms < 0.04 ? 0 : Math.min(1, rms * 3.2);
}
const tau = 0.16;
const alpha = 1 - Math.exp(-dtSec / tau);
return previous + (target - previous) * alpha;
}
function setParam(model: MutableLive2DModel, id: string, value: number, weight = 0.85) {
try {
model.internalModel?.coreModel?.setParameterValueById?.(id, value, weight);
} catch {
/* Some models omit optional standard parameters. */
}
}
function applyLive2DParams(
model: MutableLive2DModel,
snapshot: Live2DRefState,
mouth: number,
tSec: number,
) {
const { state, affect, params, reduced } = snapshot;
if (reduced) return;
const gazeBase = state === "listening" ? -params.gazeAvert * 0.15 : -params.gazeAvert * 0.22;
const thinkingDrift = state === "thinking" ? Math.sin(tSec * 0.9) * 0.08 : 0;
const speakEnergy = state === "speaking" ? mouth : 0;
const smile = Math.max(0, params.mouthCurve);
const downturn = Math.max(0, -params.mouthCurve);
setParam(model, "ParamMouthOpenY", speakEnergy, 1);
setParam(model, "ParamA", speakEnergy, 1);
setParam(model, "ParamMouthForm", params.mouthCurve * 0.25, 0.35);
setParam(model, "ParamMouthUp", smile * 0.45, 0.25);
setParam(model, "ParamMouthDown", downturn * 0.45, 0.25);
setParam(model, "ParamMouthAngry", affect === "resistant" ? 0.55 : 0, 0.25);
setParam(model, "ParamEyeBallX", gazeBase + thinkingDrift, 0.5);
setParam(model, "ParamEyeBallY", state === "thinking" ? -0.12 : 0.03, 0.45);
setParam(model, "ParamAngleX", gazeBase * 14, 0.35);
setParam(model, "ParamAngleY", state === "thinking" ? -3 : 1, 0.3);
setParam(model, "ParamAngleZ", -params.shoulderTurn * 0.25, 0.25);
setParam(model, "ParamBodyAngleX", -params.shoulderTurn * 0.45, 0.35);
setParam(model, "ParamBreath", 0.5 + Math.sin(tSec * 2 * Math.PI / params.breathPeriod) * 0.22, 0.35);
}
function focusPointFor(state: AvatarState, params: AffectParams, size: number): [number, number] {
if (state === "thinking") return [size * 0.42, size * 0.62];
if (state === "listening") return [size * 0.56, size * 0.48];
if (state === "speaking") return [size * 0.5, size * 0.46];
return [size * (0.5 - params.gazeAvert * 0.008), size * 0.52];
}
function expressionsFor(affect: AvatarAffect): string[] {
if (affect === "depressed") return ["sad", "f02", "f03", "exp_05", "exp_06"];
if (affect === "anxious") return ["surprised", "f03", "f04", "exp_03", "exp_04"];
if (affect === "resistant") return ["angry", "f06", "f07", "exp_07", "exp_08"];
return ["normal", "f00", "f01", "exp_01", "exp_02"];
}
async function tryMotion(model: Live2DModelInstance, groups: string[]) {
for (const group of groups) {
const ok = await model.motion(group).catch(() => false);
if (ok) return;
}
}
async function tryExpression(model: Live2DModelInstance, names: string[]) {
for (const name of names) {
const ok = await model.expression(name).catch(() => false);
if (ok) return;
}
}
export function Live2DAvatar({
modelUrl,
state,
affect,
params,
analyser,
speakingProgress,
reduced,
size,
onReady,
onUnavailable,
}: Live2DAvatarProps) {
const hostRef = useRef<HTMLDivElement>(null);
const appRef = useRef<PixiApplication | null>(null);
const modelRef = useRef<MutableLive2DModel | null>(null);
const snapshotRef = useRef<Live2DRefState>({
state,
affect,
params,
analyser,
speakingProgress,
reduced,
size,
});
useEffect(() => {
snapshotRef.current = { state, affect, params, analyser, speakingProgress, reduced, size };
}, [state, affect, params, analyser, speakingProgress, reduced, size]);
useEffect(() => {
const model = modelRef.current;
if (!model) return;
model.autoUpdate = !reduced;
}, [reduced]);
useEffect(() => {
const model = modelRef.current;
if (!model || reduced) return;
const [x, y] = focusPointFor(state, params, size);
model.focus(x, y);
void tryExpression(model, expressionsFor(affect));
if (state === "speaking") void tryMotion(model, ["Speak", "Speaking", "TapBody"]);
else if (state === "thinking") void tryMotion(model, ["Think", "Thinking"]);
else if (state === "idle") void tryMotion(model, ["Idle", "idle"]);
}, [affect, params, reduced, size, state]);
useEffect(() => {
const app = appRef.current;
const model = modelRef.current;
if (!app || !model) return;
app.renderer.resize(size, size);
fitModel(model, size);
}, [size]);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
let cancelled = false;
let mouth = 0;
let lastT = performance.now();
const t0 = lastT;
const audioBuffer: Float32Array<ArrayBuffer> = new Float32Array(1024);
const tick = () => {
const model = modelRef.current;
if (!model) return;
const now = performance.now();
const dtSec = Math.min(0.05, (now - lastT) / 1000);
const tSec = (now - t0) / 1000;
lastT = now;
const snapshot = snapshotRef.current;
if (snapshot.state === "speaking") {
mouth = mouthFromAnalyser(
snapshot.analyser,
audioBuffer,
mouth,
dtSec,
tSec,
snapshot.speakingProgress,
);
} else if (mouth > 0.001) {
mouth = mouth * Math.exp(-dtSec / 0.1);
if (mouth < 0.005) mouth = 0;
}
applyLive2DParams(model, snapshot, mouth, tSec);
};
(async () => {
try {
await ensureCubismCore();
const runtime = await loadLive2DRuntime();
if (cancelled) return;
const app = new runtime.PIXI.Application({
width: size,
height: size,
antialias: true,
autoDensity: true,
backgroundAlpha: 0,
resolution: Math.min(window.devicePixelRatio || 1, 2),
});
appRef.current = app;
const canvas = app.view as HTMLCanvasElement;
canvas.className = "vg-avatar__live2d-canvas";
canvas.setAttribute("aria-hidden", "true");
host.appendChild(canvas);
const model = (await runtime.Live2DModel.from(modelUrl, {
autoInteract: false,
autoUpdate: !snapshotRef.current.reduced,
motionPreload: runtime.MotionPreloadStrategy.IDLE,
})) as MutableLive2DModel;
if (cancelled) {
model.destroy({ children: true, texture: true, baseTexture: true });
app.destroy(true, { children: true, texture: true, baseTexture: true });
return;
}
modelRef.current = model;
fitModel(model, size);
app.stage.addChild(model);
app.ticker.add(tick);
void tryMotion(model, ["Idle", "idle"]);
onReady();
} catch (err) {
if (!cancelled) {
const reason = err instanceof Error ? err.message : "Live2D model load failed.";
onUnavailable(reason);
}
}
})();
return () => {
cancelled = true;
const app = appRef.current;
const model = modelRef.current;
if (app) app.ticker.remove(tick);
modelRef.current = null;
appRef.current = null;
if (model && !model.destroyed) {
model.destroy({ children: true, texture: true, baseTexture: true });
}
if (app) {
app.destroy(true, { children: true, texture: true, baseTexture: true });
}
host.replaceChildren();
};
}, [modelUrl, onReady, onUnavailable, size]);
return <div className="vg-avatar__live2d" ref={hostRef} aria-hidden="true" />;
}

View file

@ -34,6 +34,8 @@ export interface AvatarPersona {
* affect="resistant" 0.6, 0 . * affect="resistant" 0.6, 0 .
*/ */
resistance?: number; resistance?: number;
/** Optional persona-specific Live2D Cubism 3/4 model3.json URL. Omit for SVG fallback. */
live2dModelUrl?: string | null;
} }
/* 6 (§4.5) /* 6 (§4.5)

View file

@ -59,10 +59,10 @@ function smoothMouthFromRMS(
return prev + (target - prev) * alpha; return prev + (target - prev) * alpha;
} }
/* analyser (§ : / ) /* analyser
speakingProgress(0~1, ) , speakingProgress(0~1, )
RMS ( ). */ . . */
function fakeMouthTarget(tSec: number, progress: number | null): number { function fallbackMouthTarget(tSec: number, progress: number | null): number {
if (progress !== null) { if (progress !== null) {
// 타이핑 진행 중에만 입을 움직임. 진행이 멈추면(완료) 닫힘. // 타이핑 진행 중에만 입을 움직임. 진행이 멈추면(완료) 닫힘.
if (progress >= 1) return 0; if (progress >= 1) return 0;
@ -72,17 +72,14 @@ function fakeMouthTarget(tSec: number, progress: number | null): number {
const base = 0.18 + 0.42 * syl * jitter; const base = 0.18 + 0.42 * syl * jitter;
return Math.min(0.85, base); return Math.min(0.85, base);
} }
// progress 미제공: 차분한 의사 발화 (말하는 듯한 진폭) return 0;
const a = 0.5 + 0.5 * Math.sin(tSec * 2 * Math.PI * 4.5);
const b = 0.5 + 0.5 * Math.sin(tSec * 2 * Math.PI * 9.2 + 0.7);
return Math.min(0.8, 0.15 + 0.45 * a * b);
} }
export interface AvatarMotionOptions { export interface AvatarMotionOptions {
state: AvatarState; state: AvatarState;
params: AffectParams; params: AffectParams;
analyser: AnalyserNode | null; analyser: AnalyserNode | null;
/** analyser 없을 때 가짜 립싱크용 타이핑 진행도 0~1 (null=의사 발화) */ /** analyser 없을 때 선택적으로 쓰는 타이핑 진행도 0~1 */
speakingProgress?: number | null; speakingProgress?: number | null;
/** false 면 루프 정지(reduced-motion) — IDLE_FRAME 고정 */ /** false 면 루프 정지(reduced-motion) — IDLE_FRAME 고정 */
enabled: boolean; enabled: boolean;
@ -188,14 +185,14 @@ export function useAvatarMotion({
} }
} }
// ── 4) 립싱크 (speaking 시만; analyser 우선, 없으면 폴백) ── // ── 4) 립싱크 (speaking 시만; analyser 우선, 없으면 정적 폴백) ──
let mouth = mouthPrev; let mouth = mouthPrev;
if (st === "speaking") { if (st === "speaking") {
const a = analyserRef.current; const a = analyserRef.current;
if (a) { if (a) {
mouthPrev = smoothMouthFromRMS(a, audioBuf, mouthPrev, dtSec); mouthPrev = smoothMouthFromRMS(a, audioBuf, mouthPrev, dtSec);
} else { } else {
const target = fakeMouthTarget(tSec, progressRef.current ?? null); const target = fallbackMouthTarget(tSec, progressRef.current ?? null);
const tau = 0.12; const tau = 0.12;
const alpha = 1 - Math.exp(-dtSec / tau); const alpha = 1 - Math.exp(-dtSec / tau);
mouthPrev = mouthPrev + (target - mouthPrev) * alpha; mouthPrev = mouthPrev + (target - mouthPrev) * alpha;

View file

@ -11,6 +11,8 @@ export interface AppShellProps {
hideNav?: boolean; hideNav?: boolean;
/** 메인 패딩·최대폭 제거 (풀-블리드 레이아웃) */ /** 메인 패딩·최대폭 제거 (풀-블리드 레이아웃) */
bleed?: boolean; bleed?: boolean;
/** 톱바까지 제거하는 실제 전체화면 작업 공간 */
hideTopbar?: boolean;
} }
/** /**
@ -18,13 +20,13 @@ export interface AppShellProps {
* body[data-role] AuthProvider ( ). * body[data-role] AuthProvider ( ).
* ( ) (RequireAuth) . * ( ) (RequireAuth) .
*/ */
export function AppShell({ children, contextLabel, hideNav, bleed }: AppShellProps) { export function AppShell({ children, contextLabel, hideNav, bleed, hideTopbar }: AppShellProps) {
const { user } = useAuth(); const { user } = useAuth();
const showNav = !hideNav && !!user; const showNav = !hideNav && !!user;
return ( return (
<div className="vg-shell"> <div className={"vg-shell" + (hideTopbar ? " vg-shell--fullscreen" : "")}>
<Topbar contextLabel={contextLabel} /> {hideTopbar ? null : <Topbar contextLabel={contextLabel} />}
<div className={"vg-shell__body" + (showNav ? "" : " vg-shell__body--bare")}> <div className={"vg-shell__body" + (showNav ? "" : " vg-shell__body--bare")}>
{showNav ? <Sidebar role={user.role} /> : null} {showNav ? <Sidebar role={user.role} /> : null}
<main className={"vg-main" + (bleed ? " vg-main--bleed" : "")}> <main className={"vg-main" + (bleed ? " vg-main--bleed" : "")}>

View file

@ -8,6 +8,9 @@
min-height: 100vh; min-height: 100vh;
background: var(--bg-app); background: var(--bg-app);
} }
.vg-shell--fullscreen {
min-height: 100dvh;
}
/* ── 톱바 ── */ /* ── 톱바 ── */
.vg-topbar { .vg-topbar {
@ -42,7 +45,7 @@
.vg-topbar__wm { .vg-topbar__wm {
font-size: 17px; font-size: 17px;
font-weight: 700; font-weight: 700;
letter-spacing: -0.02em; letter-spacing: 0;
color: var(--text-strong); color: var(--text-strong);
} }
.vg-topbar__wm .v { .vg-topbar__wm .v {
@ -219,6 +222,9 @@
.vg-shell__body { .vg-shell__body {
grid-template-columns: var(--nav-w-collapsed) 1fr; grid-template-columns: var(--nav-w-collapsed) 1fr;
} }
.vg-shell__body--bare {
grid-template-columns: 1fr;
}
.vg-nav { .vg-nav {
padding: var(--sp-4) var(--sp-2); padding: var(--sp-4) var(--sp-2);
} }
@ -233,6 +239,49 @@
} }
} }
@media (max-width: 720px) { @media (max-width: 720px) {
html {
scroll-padding-top: calc(var(--topbar-h) + 68px);
scroll-padding-bottom: var(--sp-6);
}
.vg-shell__body {
display: block;
}
.vg-shell__body--bare {
display: block;
}
.vg-nav {
position: sticky;
top: var(--topbar-h);
z-index: 29;
height: 58px;
padding: 7px max(12px, env(safe-area-inset-left)) 7px max(12px, env(safe-area-inset-right));
border-right: 0;
border-top: 0;
border-bottom: 1px solid var(--hair);
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 8px;
overflow-x: auto;
overflow-y: hidden;
}
.vg-nav__label,
.vg-nav__spacer,
.vg-nav__foot {
display: none;
}
.vg-nav__item {
min-width: 112px;
height: 44px;
justify-content: center;
padding: 0 14px;
gap: 8px;
white-space: nowrap;
}
.vg-nav__item span:not(.vg-nav__ic) {
display: inline;
font-size: 13px;
}
.vg-topbar__role { .vg-topbar__role {
display: none; display: none;
} }
@ -242,4 +291,7 @@
.vg-main { .vg-main {
padding: var(--sp-5) var(--sp-4) var(--sp-7); padding: var(--sp-5) var(--sp-4) var(--sp-7);
} }
.vg-main--bleed {
padding: 0;
}
} }

View file

@ -1,4 +1,4 @@
import { useId } from "react"; import { forwardRef, useId } from "react";
import type { InputHTMLAttributes, ReactNode } from "react"; import type { InputHTMLAttributes, ReactNode } from "react";
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> { export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
@ -6,16 +6,20 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
} }
/** Input — radius 6px, 포커스 보더색 + ring. 좌측바 금지. §7.2 */ /** Input — radius 6px, 포커스 보더색 + ring. 좌측바 금지. §7.2 */
export function Input({ invalid, className, ...rest }: InputProps) { export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{ invalid, className, ...rest },
ref,
) {
const cls = ["vg-input", className ?? ""].filter(Boolean).join(" "); const cls = ["vg-input", className ?? ""].filter(Boolean).join(" ");
return ( return (
<input <input
ref={ref}
className={cls} className={cls}
aria-invalid={invalid ? "true" : undefined} aria-invalid={invalid ? "true" : undefined}
{...rest} {...rest}
/> />
); );
} });
export interface FieldProps { export interface FieldProps {
/** 라벨 텍스트 */ /** 라벨 텍스트 */

View file

@ -1,6 +1,6 @@
import { clamp01 } from "../../lib/format"; import { clamp01 } from "../../lib/format";
export type ProgressTone = "accent" | "muted" | "warn" | "clay"; export type ProgressTone = "accent" | "muted" | "warn" | "crit" | "clay";
export interface ProgressBarProps { export interface ProgressBarProps {
/** 0~1 비율 (또는 value/max) */ /** 0~1 비율 (또는 value/max) */

View file

@ -34,7 +34,7 @@
.vg-sechead__title { .vg-sechead__title {
font-size: var(--fs-h2); font-size: var(--fs-h2);
font-weight: 600; font-weight: 600;
letter-spacing: -0.015em; letter-spacing: 0;
color: var(--text-strong); color: var(--text-strong);
line-height: 1.35; line-height: 1.35;
} }
@ -228,7 +228,7 @@
font-weight: 700; font-weight: 700;
color: var(--text-strong); color: var(--text-strong);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
letter-spacing: -0.02em; letter-spacing: 0;
} }
.vg-statline__lab { .vg-statline__lab {
font-size: var(--fs-xs); font-size: var(--fs-xs);
@ -263,6 +263,9 @@
.vg-progress__fill--warn { .vg-progress__fill--warn {
background: var(--warn-solid); background: var(--warn-solid);
} }
.vg-progress__fill--crit {
background: var(--crit-solid);
}
.vg-progress__fill--clay { .vg-progress__fill--clay {
background: var(--clay); background: var(--clay);
} }
@ -321,7 +324,7 @@
.vg-empty__title { .vg-empty__title {
font-size: var(--fs-h1); font-size: var(--fs-h1);
font-weight: 700; font-weight: 700;
letter-spacing: -0.02em; letter-spacing: 0;
color: var(--text-strong); color: var(--text-strong);
line-height: 1.3; line-height: 1.3;
} }

View file

@ -3,12 +3,22 @@
계약: apps/api/app/routes (auth.py, sessions.py). 계약: apps/api/app/routes (auth.py, sessions.py).
- credentials:"include" (BFF __Host-vignette_sid HttpOnly ). - credentials:"include" (BFF __Host-vignette_sid HttpOnly ).
- ApiError . - ApiError .
- SSE: GET /sessions/{id}/stream token/done/ping/error . - SSE: POST /sessions/{id}/stream token/done/ping/error .
===================================================================== */ ===================================================================== */
// Vite 환경변수. 기본 "/api" (vite proxy 또는 nginx 가 백엔드로 라우팅). // Vite 환경변수. 기본 "/api" (vite proxy 또는 nginx 가 백엔드로 라우팅).
const API_BASE: string = function defaultApiBase(): string {
(import.meta.env.VITE_API_BASE as string | undefined) ?? "/api"; if (typeof window !== "undefined") {
const host = window.location.hostname;
if (host === "vignette.chanpaca.net" || host.endsWith(".pages.dev")) {
return "https://api-vignette.chanpaca.net";
}
}
return "/api";
}
const configuredApiBase = (import.meta.env.VITE_API_BASE as string | undefined)?.trim();
const API_BASE: string = configuredApiBase || defaultApiBase();
export class ApiError extends Error { export class ApiError extends Error {
readonly status: number; readonly status: number;
@ -43,6 +53,16 @@ function joinUrl(path: string): string {
return `${base}${p}`; return `${base}${p}`;
} }
export function apiUrl(path: string): string {
return joinUrl(path);
}
export function apiWsUrl(path: string): string {
const url = new URL(joinUrl(path), window.location.origin);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
return url.toString();
}
async function parseError(res: Response): Promise<ApiError> { async function parseError(res: Response): Promise<ApiError> {
let detail = res.statusText || "request failed"; let detail = res.statusText || "request failed";
let body: unknown = undefined; let body: unknown = undefined;
@ -126,19 +146,47 @@ export const api = {
/** GET /auth/me — auth.py MeResponse */ /** GET /auth/me — auth.py MeResponse */
export interface MeResponse { export interface MeResponse {
user_id: string; user_id: string;
email: string;
display_name: string;
role: string; // "learner" | "teacher" | "admin" role: string; // "learner" | "teacher" | "admin"
cohort_ids: string[]; cohort_ids: string[];
} }
export interface AuthConfigResponse {
google_oauth_configured: boolean;
allowed_email_domains: string[];
redirect_uri: string;
dev_login_enabled: boolean;
}
export const authApi = {
config: () => api.get<AuthConfigResponse>("/auth/config"),
};
export type SessionStage = "라포" | "탐색" | "개입" | "정리"; export type SessionStage = "라포" | "탐색" | "개입" | "정리";
/** GET /personas — personas.py PersonaSummary */
export interface PersonaSummary {
code: string;
display_name: string;
difficulty: "easy" | "moderate" | "hard" | string;
theory_target: string[];
demographics: Record<string, unknown>;
presenting_summary: string;
voice_preset: string | null;
source: string;
degraded: boolean;
}
/** POST /sessions — sessions.py SessionStartResponse */ /** POST /sessions — sessions.py SessionStartResponse */
export interface SessionStartResponse { export interface SessionStartResponse {
session_id: string; session_id: string;
case_id: string; case_id: string;
session_no: number; session_no: number;
stage: SessionStage; stage: SessionStage;
effective_openness: number;
recall_summary: string | null; recall_summary: string | null;
degraded: boolean;
} }
/** POST /sessions/{id}/turn — sessions.py TurnResponse */ /** POST /sessions/{id}/turn — sessions.py TurnResponse */
@ -157,17 +205,144 @@ export interface SessionEndResponse {
digest_pending: boolean; digest_pending: boolean;
} }
export interface LearnerSessionSummary {
session_id: string;
persona_code: string;
persona_name: string;
session_no: number;
status: "active" | "ended";
stage: string;
turn_count: number;
learner_turn_count: number;
client_turn_count: number;
started_at: string;
ended_at: string | null;
review_ready: boolean;
}
export interface LearnerSessionsResponse {
source: string;
sessions: LearnerSessionSummary[];
}
export interface SessionDetailTurn {
turn_seq: number;
speaker: "learner" | "client";
stage: string;
text: string;
created_at: string;
}
export interface SessionDetailResponse {
session_id: string;
case_id: string;
persona_code: string;
persona_name: string;
theory_mode: string;
status: "active" | "ended";
stage: SessionStage;
effective_openness: number;
started_at: string;
ended_at: string | null;
turns: SessionDetailTurn[];
review_ready: boolean;
}
export interface ReviewClient {
name: string;
initial: string;
persona: string;
}
export interface ReviewTechnique {
kind: string;
label: string;
}
export interface ReviewNote {
author: "ai" | "instructor" | string;
tone: "good" | "watch";
title: string;
body: string;
quote?: string | null;
}
export interface ReviewTurn {
id: string;
ts: string;
speaker: "learner" | "client";
who: string;
text: string;
techniques: ReviewTechnique[];
note?: ReviewNote | null;
}
export interface ReviewPhaseSegment {
key: string;
label: string;
weight: number;
}
export interface ReviewValencePoint {
t: number;
v: number;
}
export interface ReviewRubricRow {
name: string;
cluster: string;
ratio: number;
quality: "good" | "watch";
freq: string;
}
export interface ReviewPoint {
title: string;
body: string;
jumpTo?: string | null;
}
export interface SessionReviewResponse {
session_id: string;
client: ReviewClient;
date: string;
durationLabel: string;
durationSeconds: number;
reachedPhase: string;
sessionSignal: string;
supervisorState: string;
supervisorName: string;
summary: string;
phases: ReviewPhaseSegment[];
phaseAxis: string[];
valenceAxis: string[];
clientValence: ReviewValencePoint[];
counselorBaseline: ReviewValencePoint[];
turns: ReviewTurn[];
rubric: ReviewRubricRow[];
goodMoments: ReviewPoint[];
growthPoints: ReviewPoint[];
nextLine?: string | null;
clientFeedback?: string | null;
audioUrl?: string | null;
pdfExportUrl?: string | null;
degraded: boolean;
reviewReady: boolean;
}
/* ===================================================================== /* =====================================================================
SSE GET /sessions/{id}/stream SSE POST /sessions/{id}/stream
(sse_starlette): "token" | "done" | "ping" | "safety" | "error" (sse_starlette): "token" | "done" | "ping" | "safety" | "error"
EventSource same-origin BFF . EventSource fetch stream .
===================================================================== */ ===================================================================== */
export interface SessionStreamHandlers { export interface SessionStreamHandlers {
/** 서버가 요청을 수락했고 learner turn 이 저장 가능한 지점 */
onOpen?: () => void;
/** 내담자 AI 토큰 1조각 */ /** 내담자 AI 토큰 1조각 */
onToken?: (chunk: string) => void; onToken?: (chunk: string) => void;
/** 스트림 정상 종료 */ /** 스트림 정상 종료 */
onDone?: (data: { session_id: string }) => void; onDone?: (data: SessionStreamDone) => void;
/** 안전(위기) 신호 */ /** 안전(위기) 신호 */
onSafety?: (data: unknown) => void; onSafety?: (data: unknown) => void;
/** 에러 이벤트(백엔드 EngineError) 또는 연결 오류 */ /** 에러 이벤트(백엔드 EngineError) 또는 연결 오류 */
@ -176,9 +351,12 @@ export interface SessionStreamHandlers {
onPing?: () => void; onPing?: () => void;
} }
export interface SessionStreamHandle { export interface SessionStreamDone {
/** 스트림 종료(EventSource close) */ session_id: string;
close: () => void; stage?: SessionStage;
effective_openness?: number;
turn_seq?: number;
safety_flagged?: boolean;
} }
function safeParse(data: string): unknown { function safeParse(data: string): unknown {
@ -190,61 +368,285 @@ function safeParse(data: string): unknown {
} }
/** /**
* AI SSE . * AI SSE .
* @returns close() . close . * learner turn token/done .
*/ */
export function openSessionStream( export async function openSessionStream(
sessionId: string, sessionId: string,
text: string,
handlers: SessionStreamHandlers, handlers: SessionStreamHandlers,
): SessionStreamHandle { ): Promise<SessionStreamDone> {
const url = joinUrl(`/sessions/${encodeURIComponent(sessionId)}/stream`); const res = await fetch(joinUrl(`/sessions/${encodeURIComponent(sessionId)}/stream`), {
// withCredentials: same-origin 쿠키 전송 (BFF). cross-origin SSE 는 CORS 필요. method: "POST",
const es = new EventSource(url, { withCredentials: true }); credentials: "include",
headers: {
Accept: "text/event-stream",
"Content-Type": "application/json",
},
body: JSON.stringify({ text }),
});
// addEventListener 의 커스텀 이벤트 리스너 시그니처는 Event 를 받으므로 if (!res.ok) {
// MessageEvent 로 안전하게 좁힌다(.data 접근). throw await parseError(res);
const dataOf = (ev: Event): string | undefined => }
(ev as MessageEvent).data as string | undefined; if (!res.body) {
throw new ApiError(res.status, "스트림 응답 본문이 없습니다.");
es.addEventListener("token", (ev: Event) => {
const data = dataOf(ev);
if (data != null) handlers.onToken?.(data);
});
es.addEventListener("done", (ev: Event) => {
const parsed = safeParse(dataOf(ev) ?? "{}") as { session_id?: string };
handlers.onDone?.({ session_id: parsed.session_id ?? sessionId });
es.close();
});
es.addEventListener("safety", (ev: Event) => {
handlers.onSafety?.(safeParse(dataOf(ev) ?? "null"));
});
es.addEventListener("ping", () => {
handlers.onPing?.();
});
es.addEventListener("error", (ev: Event) => {
// sse_starlette 의 명시적 error 이벤트는 data 를 가짐.
// 브라우저 연결 오류 이벤트는 data 가 없음 → 일반 연결 오류로 처리.
const data = dataOf(ev);
if (data) {
const parsed = safeParse(data) as { detail?: string };
handlers.onError?.({ detail: parsed.detail ?? "stream error" });
} else if (es.readyState === EventSource.CLOSED) {
handlers.onError?.({ detail: "스트림 연결이 종료되었습니다." });
} }
});
return { handlers.onOpen?.();
close: () => es.close(),
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let eventName = "message";
let dataLines: string[] = [];
let donePayload: SessionStreamDone | null = null;
let streamError: ApiError | null = null;
const dispatch = () => {
if (!eventName && dataLines.length === 0) return;
const data = dataLines.join("\n");
const event = eventName || "message";
eventName = "message";
dataLines = [];
if (event === "token") {
handlers.onToken?.(data);
return;
}
if (event === "done") {
const parsed = safeParse(data || "{}") as Partial<SessionStreamDone>;
donePayload = {
session_id: parsed.session_id ?? sessionId,
stage: parsed.stage,
effective_openness: parsed.effective_openness,
turn_seq: parsed.turn_seq,
safety_flagged: parsed.safety_flagged,
}; };
handlers.onDone?.(donePayload);
return;
}
if (event === "safety") {
handlers.onSafety?.(safeParse(data || "null"));
return;
}
if (event === "ping") {
handlers.onPing?.();
return;
}
if (event === "error") {
const parsed = safeParse(data || "{}") as { detail?: string };
const detail = parsed.detail ?? "stream error";
handlers.onError?.({ detail });
streamError = new ApiError(503, detail, parsed);
}
};
const processLine = (line: string) => {
if (line === "") {
dispatch();
return;
}
if (line.startsWith(":")) return;
const idx = line.indexOf(":");
const field = idx === -1 ? line : line.slice(0, idx);
const rawValue = idx === -1 ? "" : line.slice(idx + 1);
const value = rawValue.startsWith(" ") ? rawValue.slice(1) : rawValue;
if (field === "event") eventName = value;
else if (field === "data") dataLines.push(value);
};
const processBuffer = (final = false) => {
const lines = buffer.split(/\r?\n/);
buffer = final ? "" : (lines.pop() ?? "");
for (const line of lines) processLine(line.endsWith("\r") ? line.slice(0, -1) : line);
if (final && buffer) processLine(buffer);
if (final && dataLines.length > 0) dispatch();
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
processBuffer();
if (streamError) break;
}
buffer += decoder.decode();
processBuffer(true);
if (streamError) throw streamError;
return donePayload ?? { session_id: sessionId };
} }
/* === 세션 API 헬퍼 (Features 단계 Session 페이지가 사용) === */ /* === 세션 API 헬퍼 (Features 단계 Session 페이지가 사용) === */
export const personaApi = {
list: () => api.get<PersonaSummary[]>("/personas"),
};
export const sessionApi = { export const sessionApi = {
list: () => api.get<LearnerSessionsResponse>("/sessions"),
get: (sessionId: string) =>
api.get<SessionDetailResponse>(`/sessions/${encodeURIComponent(sessionId)}`),
start: (persona_code: string, theory_mode: "humanistic" | "cbt" | "integrative" = "humanistic") => start: (persona_code: string, theory_mode: "humanistic" | "cbt" | "integrative" = "humanistic") =>
api.post<SessionStartResponse>("/sessions", { persona_code, theory_mode }), api.post<SessionStartResponse>("/sessions", { persona_code, theory_mode }),
turn: (sessionId: string, text: string) => turn: (sessionId: string, text: string) =>
api.post<TurnResponse>(`/sessions/${encodeURIComponent(sessionId)}/turn`, { text }), api.post<TurnResponse>(`/sessions/${encodeURIComponent(sessionId)}/turn`, { text }),
end: (sessionId: string) => end: (sessionId: string) =>
api.post<SessionEndResponse>(`/sessions/${encodeURIComponent(sessionId)}/end`), api.post<SessionEndResponse>(`/sessions/${encodeURIComponent(sessionId)}/end`),
review: (sessionId: string) =>
api.get<SessionReviewResponse>(`/sessions/${encodeURIComponent(sessionId)}/review`),
stream: openSessionStream, stream: openSessionStream,
}; };
export type AdminHealthStatus = "ok" | "degraded" | "down";
export interface AdminServiceHealth {
key: string;
name: string;
status: AdminHealthStatus;
detail: string;
metric: string;
load: number;
}
export interface AdminHealthResponse {
status: AdminHealthStatus;
environment: string;
engine_mode: string;
services: AdminServiceHealth[];
}
export const adminApi = {
health: () => api.get<AdminHealthResponse>("/admin/health"),
};
export interface AdminManagedUser {
user_id: string;
email: string;
display_name: string;
role: "learner" | "teacher" | "admin";
cohort_ids: string[];
affiliation: string;
active_sessions: number;
created_at: number;
last_seen_at: number;
source: "database" | "server_session_registry";
}
export interface AdminUsersResponse {
source: "database" | "server_session_registry";
durable: boolean;
users: AdminManagedUser[];
}
export type AdminUserCreateRequest = Pick<
AdminManagedUser,
"email" | "display_name" | "role" | "affiliation" | "cohort_ids"
>;
export const adminUsersApi = {
list: () => api.get<AdminUsersResponse>("/admin/users"),
create: (body: AdminUserCreateRequest) =>
apiFetch<AdminManagedUser>("/admin/users", { method: "POST", body }),
update: (
userId: string,
body: Partial<Pick<AdminManagedUser, "display_name" | "role" | "affiliation" | "cohort_ids">>,
) => apiFetch<AdminManagedUser>(`/admin/users/${encodeURIComponent(userId)}`, {
method: "PATCH",
body,
}),
deactivate: (userId: string) =>
apiFetch<{ ok: boolean; user_id: string }>(`/admin/users/${encodeURIComponent(userId)}`, {
method: "DELETE",
}),
};
export interface TeacherSessionSummary {
session_id: string;
learner_id: string;
learner_label: string;
persona_code: string;
persona_name: string;
session_no: number;
status: "active" | "ended" | string;
stage: string;
turn_count: number;
learner_turn_count: number;
client_turn_count: number;
started_at: string;
ended_at: string | null;
}
export interface TeacherDashboardResponse {
source: string;
cohort_label: string;
total_learners: number;
active_sessions: number;
ended_sessions: number;
pending_reviews: TeacherSessionSummary[];
recent_sessions: TeacherSessionSummary[];
message: string;
}
export const teacherApi = {
dashboard: () => api.get<TeacherDashboardResponse>("/teacher/dashboard"),
};
export interface UserProfileResponse {
user_id: string;
email: string;
display_name: string;
role: RoleString;
cohort_ids: string[];
affiliation: string;
}
export interface NotificationPreferences {
session_done: boolean;
safety_signal: boolean;
learner_progress: boolean;
product_news: boolean;
}
export interface UserPreferencesResponse {
theme: "system" | "light" | "dark" | string;
voice_preset_id: string;
voice_rate: number;
notifications: NotificationPreferences;
}
export interface VoicePresetResponse {
id: string;
voice_id: string;
name: string;
desc: string;
persona_hint: string;
}
export type RoleString = "learner" | "teacher" | "admin" | string;
export const userApi = {
me: () => api.get<UserProfileResponse>("/users/me"),
updateMe: (body: { display_name?: string; affiliation?: string }) =>
apiFetch<UserProfileResponse>("/users/me", { method: "PATCH", body }),
preferences: () => api.get<UserPreferencesResponse>("/users/me/preferences"),
updatePreferences: (body: Partial<UserPreferencesResponse>) =>
apiFetch<UserPreferencesResponse>("/users/me/preferences", { method: "PATCH", body }),
voicePresets: () => api.get<VoicePresetResponse[]>("/users/me/voice-presets"),
};
export interface AdminEngineConfigResponse {
engine_mode: string;
engine_url: string;
model: string;
updated_by: string | null;
updated_at: number | null;
durable: boolean;
source: "database" | "runtime_cache" | "runtime_default" | string;
}
export const adminEngineApi = {
get: () => api.get<AdminEngineConfigResponse>("/admin/engine-config"),
update: (body: Partial<Pick<AdminEngineConfigResponse, "engine_mode" | "engine_url" | "model">>) =>
apiFetch<AdminEngineConfigResponse>("/admin/engine-config", { method: "PATCH", body }),
};

View file

@ -1,13 +1,3 @@
/* =====================================================================
Vignette AuthContext
- user/role , login/logout.
- mock ( ) + /auth/me ( ).
- role <body data-role>·data-theme tokens.css §6.2 accent .
Role enum(deps.py): learner | teacher | admin.
accent(DESIGN_CONCEPT §6.2): learner | instructor | admin.
teacher data-role="instructor" (-).
===================================================================== */
import { import {
createContext, createContext,
useCallback, useCallback,
@ -17,16 +7,14 @@ import {
useState, useState,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { api, ApiError, type MeResponse } from "./api"; import { api, type MeResponse } from "./api";
/** 인증·인가 도메인 역할 (백엔드 deps.py Role 미러). */
export type Role = "learner" | "teacher" | "admin"; export type Role = "learner" | "teacher" | "admin";
/** tokens.css §6.2 accent 스왑용 data-role 값. */
export type DesignRole = "learner" | "instructor" | "admin"; export type DesignRole = "learner" | "instructor" | "admin";
export interface AuthUser { export interface AuthUser {
userId: string; userId: string;
email: string;
name: string; name: string;
role: Role; role: Role;
cohortIds: string[]; cohortIds: string[];
@ -35,26 +23,19 @@ export interface AuthUser {
export interface AuthContextValue { export interface AuthContextValue {
user: AuthUser | null; user: AuthUser | null;
role: Role | null; role: Role | null;
/** 부트스트랩(/auth/me) 진행 여부 — 가드 라우트의 깜빡임 방지 */
loading: boolean; loading: boolean;
/** 개발용 mock 로그인: 역할 선택으로 즉시 인증 상태 진입 */ login: (role: Role, opts?: { email?: string; displayName?: string }) => Promise<AuthUser>;
login: (role: Role, opts?: { name?: string; userId?: string }) => void;
/** 로그아웃 — 서버 세션 무효화 시도 후 로컬 상태 클리어 */
logout: () => Promise<void>; logout: () => Promise<void>;
} }
const STORAGE_KEY = "vignette.dev-auth";
/** API role → 디자인 data-role 매핑. */
export function designRoleOf(role: Role): DesignRole { export function designRoleOf(role: Role): DesignRole {
return role === "teacher" ? "instructor" : role; return role === "teacher" ? "instructor" : role;
} }
/** 역할별 한국어 컨텍스트 라벨 (톱바 좌측, §6.3). */
export function roleLabel(role: Role): string { export function roleLabel(role: Role): string {
switch (role) { switch (role) {
case "learner": case "learner":
return "학습 대시보드"; return "학습자 공간";
case "teacher": case "teacher":
return "교수 콘솔"; return "교수 콘솔";
case "admin": case "admin":
@ -62,7 +43,6 @@ export function roleLabel(role: Role): string {
} }
} }
/** 역할 진입 기본 경로. */
export function roleHomePath(role: Role): string { export function roleHomePath(role: Role): string {
switch (role) { switch (role) {
case "learner": case "learner":
@ -74,50 +54,42 @@ export function roleHomePath(role: Role): string {
} }
} }
const ROLE_DEFAULT_NAME: Record<Role, string> = { const DEV_EMAIL_BY_ROLE: Record<Role, string> = {
learner: "김수련", learner: "learner@hs.ac.kr",
teacher: "이교수", teacher: "teacher@hs.ac.kr",
admin: "운영자", admin: "admin@twentyoz.kr",
};
const DEV_NAME_BY_ROLE: Record<Role, string> = {
learner: "학습자",
teacher: "교수자",
admin: "관리자",
}; };
const AuthContext = createContext<AuthContextValue | null>(null); const AuthContext = createContext<AuthContextValue | null>(null);
function loadStored(): AuthUser | null { function userFromMe(me: MeResponse): AuthUser {
try { return {
const raw = localStorage.getItem(STORAGE_KEY); userId: me.user_id,
if (!raw) return null; email: me.email,
const parsed = JSON.parse(raw) as AuthUser; name: me.display_name || me.email || me.user_id,
if (parsed && typeof parsed.role === "string") return parsed; role: (me.role as Role) ?? "learner",
} catch { cohortIds: me.cohort_ids ?? [],
/* 무시 */ };
}
return null;
} }
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(() => loadStored()); const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// 부트스트랩: 실서버 세션이 있으면 우선. 없으면(401/네트워크오류) 저장된 mock 유지.
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
(async () => { (async () => {
try { try {
const me = await api.get<MeResponse>("/auth/me"); const me = await api.get<MeResponse>("/auth/me");
if (!alive) return; if (alive) setUser(userFromMe(me));
const serverUser: AuthUser = { } catch {
userId: me.user_id, if (alive) setUser(null);
name: me.user_id,
role: (me.role as Role) ?? "learner",
cohortIds: me.cohort_ids ?? [],
};
setUser(serverUser);
} catch (err) {
// 401(미인증) 또는 백엔드 미가동 → mock/로그아웃 상태 유지(에러 아님).
if (!(err instanceof ApiError) && !(err instanceof TypeError)) {
// 예기치 못한 오류는 콘솔로만 (UX 차단 안 함)
console.warn("[auth] /auth/me bootstrap failed", err);
}
} finally { } finally {
if (alive) setLoading(false); if (alive) setLoading(false);
} }
@ -127,42 +99,28 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}; };
}, []); }, []);
// role → <body data-role> 반영 (accent 스왑). 미인증이면 속성 제거.
useEffect(() => { useEffect(() => {
const body = document.body; const body = document.body;
if (user) { if (user) body.setAttribute("data-role", designRoleOf(user.role));
body.setAttribute("data-role", designRoleOf(user.role)); else body.removeAttribute("data-role");
} else {
body.removeAttribute("data-role");
}
}, [user]); }, [user]);
const login = useCallback<AuthContextValue["login"]>((role, opts) => { const login = useCallback<AuthContextValue["login"]>(async (role, opts) => {
const next: AuthUser = { const me = await api.post<MeResponse>("/auth/dev-login", {
userId: opts?.userId ?? `dev-${role}`, email: opts?.email ?? DEV_EMAIL_BY_ROLE[role],
name: opts?.name ?? ROLE_DEFAULT_NAME[role],
role, role,
cohortIds: [], display_name: opts?.displayName ?? DEV_NAME_BY_ROLE[role],
}; });
const next = userFromMe(me);
setUser(next); setUser(next);
try { return next;
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {
/* 저장 실패 무시 */
}
}, []); }, []);
const logout = useCallback<AuthContextValue["logout"]>(async () => { const logout = useCallback<AuthContextValue["logout"]>(async () => {
try { try {
await api.post("/auth/logout"); await api.post("/auth/logout");
} catch { } finally {
// 서버 미가동/스텁이어도 로컬 클리어는 진행
}
setUser(null); setUser(null);
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
/* 무시 */
} }
}, []); }, []);
@ -177,7 +135,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
export function useAuth(): AuthContextValue { export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext); const ctx = useContext(AuthContext);
if (!ctx) { if (!ctx) {
throw new Error("useAuth 는 <AuthProvider> 내부에서만 사용할 수 있습니다."); throw new Error("useAuth must be used inside AuthProvider");
} }
return ctx; return ctx;
} }

File diff suppressed because it is too large Load diff

Some files were not shown because too many files have changed in this diff Show more