Stabilize runtime auth and E2E coverage
This commit is contained in:
parent
6a3e3b541c
commit
188e899394
133 changed files with 55987 additions and 6775 deletions
35
.env.example
35
.env.example
|
|
@ -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
2
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
796
apps/api/app/auth_sessions.py
Normal file
796
apps/api/app/auth_sessions.py
Normal 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")
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
243
apps/api/app/persona_repository.py
Normal file
243
apps/api/app/persona_repository.py
Normal 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",
|
||||||
|
]
|
||||||
539
apps/api/app/routes/admin.py
Normal file
539
apps/api/app/routes/admin.py
Normal 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)
|
||||||
|
|
@ -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,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -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 {},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
66
apps/api/app/routes/personas.py
Normal file
66
apps/api/app/routes/personas.py
Normal 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
103
apps/api/app/routes/teacher.py
Normal file
103
apps/api/app/routes/teacher.py
Normal 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,
|
||||||
|
)
|
||||||
280
apps/api/app/routes/users.py
Normal file
280
apps/api/app/routes/users.py
Normal 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()
|
||||||
|
|
@ -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상태 listening→thinking→speaking→idle):
|
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
|
||||||
|
|
|
||||||
33
apps/api/app/runtime_policy.py
Normal file
33
apps/api/app/runtime_policy.py
Normal 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)
|
||||||
|
|
@ -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"))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
# ── 수명주기 ──────────────────────────────────────────
|
# ── 수명주기 ──────────────────────────────────────────
|
||||||
|
|
|
||||||
656
apps/api/app/session_persistence.py
Normal file
656
apps/api/app/session_persistence.py
Normal 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
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
269
apps/api/app/test_runtime_policy.py
Normal file
269
apps/api/app/test_runtime_policy.py
Normal 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()
|
||||||
143
apps/api/app/test_session_turn_persistence.py
Normal file
143
apps/api/app/test_session_turn_persistence.py
Normal 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()
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
125
apps/api/engine_gateway/test_gateway_model.py
Normal file
125
apps/api/engine_gateway/test_gateway_model.py
Normal 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()
|
||||||
|
|
@ -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
51
apps/web/e2e/README.md
Normal 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
304
apps/web/e2e/admin.spec.ts
Normal 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
183
apps/web/e2e/auth.spec.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
177
apps/web/e2e/db-persistence.spec.ts
Normal file
177
apps/web/e2e/db-persistence.spec.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
212
apps/web/e2e/learner.spec.ts
Normal file
212
apps/web/e2e/learner.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
160
apps/web/e2e/public-auth-turn.spec.ts
Normal file
160
apps/web/e2e/public-auth-turn.spec.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
79
apps/web/e2e/readiness.spec.ts
Normal file
79
apps/web/e2e/readiness.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
313
apps/web/e2e/session-layout.spec.ts
Normal file
313
apps/web/e2e/session-layout.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
95
apps/web/e2e/session-persistence.spec.ts
Normal file
95
apps/web/e2e/session-persistence.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
78
apps/web/e2e/session-review.spec.ts
Normal file
78
apps/web/e2e/session-review.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
428
apps/web/e2e/settings.spec.ts
Normal file
428
apps/web/e2e/settings.spec.ts
Normal 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
172
apps/web/e2e/support.ts
Normal 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);
|
||||||
|
}
|
||||||
111
apps/web/e2e/teacher.spec.ts
Normal file
111
apps/web/e2e/teacher.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
360
apps/web/e2e/voice-success.spec.ts
Normal file
360
apps/web/e2e/voice-success.spec.ts
Normal 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
115
apps/web/e2e/voice.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
1719
apps/web/package-lock.json
generated
1719
apps/web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
89
apps/web/playwright.config.ts
Normal file
89
apps/web/playwright.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
});
|
||||||
30
apps/web/public/live2d/README.md
Normal file
30
apps/web/public/live2d/README.md
Normal 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.
|
||||||
10
apps/web/public/live2d/haru/expressions/F01.exp3.json
Normal file
10
apps/web/public/live2d/haru/expressions/F01.exp3.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"Type": "Live2D Expression",
|
||||||
|
"Parameters": [
|
||||||
|
{
|
||||||
|
"Id": "ParamMouthOpenY",
|
||||||
|
"Value": 0.27,
|
||||||
|
"Blend": "Add"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
35
apps/web/public/live2d/haru/expressions/F02.exp3.json
Normal file
35
apps/web/public/live2d/haru/expressions/F02.exp3.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
55
apps/web/public/live2d/haru/expressions/F03.exp3.json
Normal file
55
apps/web/public/live2d/haru/expressions/F03.exp3.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
60
apps/web/public/live2d/haru/expressions/F04.exp3.json
Normal file
60
apps/web/public/live2d/haru/expressions/F04.exp3.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
35
apps/web/public/live2d/haru/expressions/F05.exp3.json
Normal file
35
apps/web/public/live2d/haru/expressions/F05.exp3.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
35
apps/web/public/live2d/haru/expressions/F06.exp3.json
Normal file
35
apps/web/public/live2d/haru/expressions/F06.exp3.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
65
apps/web/public/live2d/haru/expressions/F07.exp3.json
Normal file
65
apps/web/public/live2d/haru/expressions/F07.exp3.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
30
apps/web/public/live2d/haru/expressions/F08.exp3.json
Normal file
30
apps/web/public/live2d/haru/expressions/F08.exp3.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
apps/web/public/live2d/haru/haru_greeter_t03.2048/texture_00.png
Normal file
BIN
apps/web/public/live2d/haru/haru_greeter_t03.2048/texture_00.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
BIN
apps/web/public/live2d/haru/haru_greeter_t03.2048/texture_01.png
Normal file
BIN
apps/web/public/live2d/haru/haru_greeter_t03.2048/texture_01.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
BIN
apps/web/public/live2d/haru/haru_greeter_t03.moc3
Normal file
BIN
apps/web/public/live2d/haru/haru_greeter_t03.moc3
Normal file
Binary file not shown.
94
apps/web/public/live2d/haru/haru_greeter_t03.model3.json
Normal file
94
apps/web/public/live2d/haru/haru_greeter_t03.model3.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
373
apps/web/public/live2d/haru/haru_greeter_t03.physics3.json
Normal file
373
apps/web/public/live2d/haru/haru_greeter_t03.physics3.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
25
apps/web/public/live2d/haru/haru_greeter_t03.pose3.json
Normal file
25
apps/web/public/live2d/haru/haru_greeter_t03.pose3.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"Type": "Live2D Pose",
|
||||||
|
"Groups": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"Id": "Part01ArmRA001",
|
||||||
|
"Link": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "Part01ArmRB001",
|
||||||
|
"Link": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"Id": "Part01ArmLA001",
|
||||||
|
"Link": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "Part01ArmLB001",
|
||||||
|
"Link": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
2266
apps/web/public/live2d/haru/motion/haru_g_idle.motion3.json
Normal file
2266
apps/web/public/live2d/haru/motion/haru_g_idle.motion3.json
Normal file
File diff suppressed because it is too large
Load diff
1443
apps/web/public/live2d/haru/motion/haru_g_m05.motion3.json
Normal file
1443
apps/web/public/live2d/haru/motion/haru_g_m05.motion3.json
Normal file
File diff suppressed because it is too large
Load diff
1497
apps/web/public/live2d/haru/motion/haru_g_m07.motion3.json
Normal file
1497
apps/web/public/live2d/haru/motion/haru_g_m07.motion3.json
Normal file
File diff suppressed because it is too large
Load diff
2176
apps/web/public/live2d/haru/motion/haru_g_m14.motion3.json
Normal file
2176
apps/web/public/live2d/haru/motion/haru_g_m14.motion3.json
Normal file
File diff suppressed because it is too large
Load diff
1357
apps/web/public/live2d/haru/motion/haru_g_m15.motion3.json
Normal file
1357
apps/web/public/live2d/haru/motion/haru_g_m15.motion3.json
Normal file
File diff suppressed because it is too large
Load diff
9
apps/web/public/live2d/live2dcubismcore.min.js
vendored
Normal file
9
apps/web/public/live2d/live2dcubismcore.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
apps/web/public/live2d/mao/Mao.2048/texture_00.png
Normal file
BIN
apps/web/public/live2d/mao/Mao.2048/texture_00.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3 MiB |
915
apps/web/public/live2d/mao/Mao.cdi3.json
Normal file
915
apps/web/public/live2d/mao/Mao.cdi3.json
Normal 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"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
apps/web/public/live2d/mao/Mao.moc3
Normal file
BIN
apps/web/public/live2d/mao/Mao.moc3
Normal file
Binary file not shown.
103
apps/web/public/live2d/mao/Mao.model3.json
Normal file
103
apps/web/public/live2d/mao/Mao.model3.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
1361
apps/web/public/live2d/mao/Mao.physics3.json
Normal file
1361
apps/web/public/live2d/mao/Mao.physics3.json
Normal file
File diff suppressed because it is too large
Load diff
25
apps/web/public/live2d/mao/Mao.pose3.json
Normal file
25
apps/web/public/live2d/mao/Mao.pose3.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"Type": "Live2D Pose",
|
||||||
|
"Groups": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"Id": "PartArmLA",
|
||||||
|
"Link": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "PartArmLB",
|
||||||
|
"Link": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"Id": "PartArmRA",
|
||||||
|
"Link": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Id": "PartArmRB",
|
||||||
|
"Link": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
147
apps/web/public/live2d/mao/expressions/exp_01.exp3.json
Normal file
147
apps/web/public/live2d/mao/expressions/exp_01.exp3.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
147
apps/web/public/live2d/mao/expressions/exp_02.exp3.json
Normal file
147
apps/web/public/live2d/mao/expressions/exp_02.exp3.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
147
apps/web/public/live2d/mao/expressions/exp_03.exp3.json
Normal file
147
apps/web/public/live2d/mao/expressions/exp_03.exp3.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
147
apps/web/public/live2d/mao/expressions/exp_04.exp3.json
Normal file
147
apps/web/public/live2d/mao/expressions/exp_04.exp3.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
147
apps/web/public/live2d/mao/expressions/exp_05.exp3.json
Normal file
147
apps/web/public/live2d/mao/expressions/exp_05.exp3.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
147
apps/web/public/live2d/mao/expressions/exp_06.exp3.json
Normal file
147
apps/web/public/live2d/mao/expressions/exp_06.exp3.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
147
apps/web/public/live2d/mao/expressions/exp_07.exp3.json
Normal file
147
apps/web/public/live2d/mao/expressions/exp_07.exp3.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
147
apps/web/public/live2d/mao/expressions/exp_08.exp3.json
Normal file
147
apps/web/public/live2d/mao/expressions/exp_08.exp3.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
2355
apps/web/public/live2d/mao/motions/mtn_01.motion3.json
Normal file
2355
apps/web/public/live2d/mao/motions/mtn_01.motion3.json
Normal file
File diff suppressed because it is too large
Load diff
2337
apps/web/public/live2d/mao/motions/mtn_02.motion3.json
Normal file
2337
apps/web/public/live2d/mao/motions/mtn_02.motion3.json
Normal file
File diff suppressed because it is too large
Load diff
2207
apps/web/public/live2d/mao/motions/mtn_03.motion3.json
Normal file
2207
apps/web/public/live2d/mao/motions/mtn_03.motion3.json
Normal file
File diff suppressed because it is too large
Load diff
2666
apps/web/public/live2d/mao/motions/mtn_04.motion3.json
Normal file
2666
apps/web/public/live2d/mao/motions/mtn_04.motion3.json
Normal file
File diff suppressed because it is too large
Load diff
2428
apps/web/public/live2d/mao/motions/sample_01.motion3.json
Normal file
2428
apps/web/public/live2d/mao/motions/sample_01.motion3.json
Normal file
File diff suppressed because it is too large
Load diff
3433
apps/web/public/live2d/mao/motions/special_01.motion3.json
Normal file
3433
apps/web/public/live2d/mao/motions/special_01.motion3.json
Normal file
File diff suppressed because it is too large
Load diff
4250
apps/web/public/live2d/mao/motions/special_02.motion3.json
Normal file
4250
apps/web/public/live2d/mao/motions/special_02.motion3.json
Normal file
File diff suppressed because it is too large
Load diff
4871
apps/web/public/live2d/mao/motions/special_03.motion3.json
Normal file
4871
apps/web/public/live2d/mao/motions/special_03.motion3.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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);}
|
||||||
|
|
|
||||||
383
apps/web/src/components/avatar/Live2DAvatar.tsx
Normal file
383
apps/web/src/components/avatar/Live2DAvatar.tsx
Normal 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" />;
|
||||||
|
}
|
||||||
|
|
@ -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) ──────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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" : "")}>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
/** 라벨 텍스트 */
|
/** 라벨 텍스트 */
|
||||||
|
|
|
||||||
|
|
@ -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) */
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }),
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue