Stabilize runtime auth and E2E coverage

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

View file

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

View file

@ -8,8 +8,9 @@ from __future__ import annotations
from functools import lru_cache
from typing import Literal
from urllib.parse import urlsplit
from pydantic import Field
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
# 엔진 어댑터 provider 플래그 (마스터플랜 §0, R1: claude -p 과금누수 회피)
@ -20,12 +21,19 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
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):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
case_sensitive=False,
populate_by_name=True,
)
# ── 앱 ───────────────────────────────────────────────
@ -59,6 +67,10 @@ class Settings(BaseSettings):
# ── 외부 LLM 키 (게이트웨이가 못 받을 때 직접 폴백, PII 마스킹 후만) ──
anthropic_api_key: str = Field(default="", validation_alias="ANTHROPIC_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, 토큰 서버 보관) ────────
session_secret: str = Field(
@ -75,16 +87,54 @@ class Settings(BaseSettings):
default="", validation_alias="OAUTH_GOOGLE_CLIENT_SECRET"
)
oauth_redirect_uri: str = Field(
default="https://chanpaca.net/auth/callback",
default="https://api-vignette.chanpaca.net/auth/callback",
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_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",
)
# 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_heartbeat_seconds: int = 30 # Cloudflare 100초 timeout 회피 (R2)
@ -92,6 +142,31 @@ class Settings(BaseSettings):
def is_prod(self) -> bool:
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
def get_settings() -> Settings:

View file

@ -1,7 +1,8 @@
"""asyncpg 연결 풀 + pgvector 등록.
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 의존성과 ). 여기선 + 헬퍼만 제공한다.
"""
@ -9,7 +10,7 @@ from __future__ import annotations
import json
from contextlib import asynccontextmanager
from typing import Any, AsyncIterator, Optional
from typing import Any, AsyncIterator, Optional, Sequence
import asyncpg
@ -70,17 +71,44 @@ def get_pool() -> asyncpg.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
async def acquire(
*,
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_context: Optional[bool] = None,
sensitivity_max: Optional[int] = None,
) -> AsyncIterator[asyncpg.Connection]:
"""커넥션 획득 + RLS 컨텍스트 주입.
RLS 이중강제 (설계서 §4.1, F-30):
레이어1 = AI 정보비대칭: app.current_ai_view (visible_to[] WHERE 강제)
레이어2 = 인간 RBAC×cohort: app.current_role (RLS 정책)
레이어1 = AI 정보비대칭: app.ai_context + app.current_ai_view +
app.current_sens_max (visible_to[]/sensitivity WHERE 강제)
레이어2 = 인간 RBAC×cohort: app.current_role + app.current_uid +
app.current_cohort (RLS 정책)
트랜잭션 SET LOCAL 주입해 커넥션 재사용 누수 방지.
NOTE: RLS 정책/세션변수는 Phase 0 마이그레이션에서 정의(설계서 §3.3 / §4).
@ -89,20 +117,47 @@ async def acquire(
pool = get_pool()
async with pool.acquire() as conn:
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:
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:
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
async def healthcheck() -> bool:
"""SELECT 1 핑. /health 에서 사용."""
"""Return true only when the DB is reachable and required app tables exist."""
try:
pool = get_pool()
async with pool.acquire() as conn:
val = await conn.fetchval("SELECT 1")
return val == 1
row = await conn.fetchrow(
"""
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:
return False

View file

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

View file

@ -15,6 +15,7 @@ text 는 *PII 마스킹 후(text_masked)* 만 보낸다 (R7/F-03, 마스킹은
from __future__ import annotations
import asyncio
from typing import Any, AsyncIterator, Literal, Optional
import httpx
@ -72,10 +73,18 @@ class EngineClient:
def __init__(self, base_url: Optional[str] = None) -> None:
self.base_url = (base_url or settings.engine_url).rstrip("/")
self.engine_mode = settings.engine_mode
self.default_model: Optional[str] = None
self._client: Optional[httpx.AsyncClient] = None
self._lock = asyncio.Lock()
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,
timeout=httpx.Timeout(
settings.engine_timeout,
@ -84,9 +93,42 @@ class EngineClient:
)
async def shutdown(self) -> None:
if self._client is not None:
await self._client.aclose()
self._client = None
async with self._lock:
if self._client is not None:
await self._client.aclose()
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
def client(self) -> httpx.AsyncClient:
@ -95,16 +137,40 @@ class EngineClient:
return self._client
async def health(self) -> bool:
return bool((await self.health_detail()).get("ok"))
async def health_detail(self) -> dict[str, Any]:
try:
r = await self.client.get("/health")
return r.status_code == 200
except httpx.HTTPError:
return False
r = await self.client.get("/ready")
if r.status_code == 404:
live = await self.client.get("/health")
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:
"""단발 생성. TODO: 게이트웨이 응답 스키마 확정 후 cost 텔레메트리 turns 적재."""
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()
except httpx.HTTPStatusError as e:
raise EngineError(f"engine generate {e.response.status_code}: {e.response.text}") from e
@ -121,7 +187,7 @@ class EngineClient:
"""
try:
async with self.client.stream(
"POST", "/v1/stream", json=req.model_dump(exclude_none=True)
"POST", "/v1/stream", json=self._payload(req)
) as r:
r.raise_for_status()
async for line in r.aiter_lines():

View file

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

View file

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

View file

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

View file

@ -1,84 +1,452 @@
"""인증 라우트 — BFF OAuth 2.1 Auth Code + PKCE(S256) 스텁.
"""BFF authentication routes.
마스터플랜 §5: BFF + OAuth 2.1, 토큰은 서버(Redis)에만, 브라우저엔 __Host- HttpOnly 쿠키.
미성년 사례데이터 + 상담 민감정보 -> XSS 토큰탈취 원천 차단.
1 = Google OIDC 단독, 한신대 SSO 2(R11, Authlib provider 추상화 ).
파일은 라우트 시그니처 + 흐름 + TODO. 실제 OAuth 교환/Redis 세션은 Phase 2 트랙 B.
The production path is Google OIDC authorization code + PKCE. Until the DB
session table is wired, the issued browser sessions are server-side in-proc
sessions backed by an opaque HttpOnly cookie. Local development also has a
dev-only server login endpoint so Playwright can exercise auth without trusting
browser localStorage.
"""
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 pydantic import BaseModel
from ..auth_sessions import InactiveUserError, SessionUser, create_session, revoke_session
from ..config import settings
from ..deps import CurrentPrincipal
from ..deps import CurrentPrincipal, Principal, Role
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):
user_id: str
email: str
display_name: str
role: 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")
async def login(
request: Request,
provider: Annotated[str, Query()] = "google",
next: Annotated[str | None, Query()] = None,
) -> RedirectResponse:
"""OAuth Auth Code + PKCE 시작 (BFF).
절차:
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.
"""
"""Start Google OIDC authorization code + PKCE login."""
if provider != "google":
# 한신대 SSO 는 2차 (R11)
raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail=f"provider {provider} not yet supported")
raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail="OAuth login TODO (Phase 2 트랙 B)")
return _frontend_login_redirect("unsupported_provider", request)
if not settings.oauth_google_client_id or not settings.oauth_google_client_secret:
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")
async def callback(
response: Response,
request: Request,
code: Annotated[Optional[str], Query()] = None,
state: Annotated[Optional[str], Query()] = None,
) -> 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)
절차:
1. state 검증 (Redis 저장값과 대조, CSRF)
2. code + code_verifier token 교환 (PKCE)
3. id_token 검증 -> user upsert -> role/cohort 매핑
4. Redis 세션 생성 -> __Host- HttpOnly Secure SameSite=Lax 쿠키 set
5. IRB 동의 미이행 동의 게이트로 리다이렉트 (마스터플랜 §7)
TODO: 전체 교환 구현. 현재 스텁 501.
_prune_oauth_states()
stored = _oauth_states.pop(state, None)
if stored is None:
return _frontend_login_redirect("invalid_state", request)
async with httpx.AsyncClient(timeout=10.0) as client:
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")
async def logout(response: Response) -> dict[str, bool]:
"""세션 무효화 (Redis 삭제 + 쿠키 만료). IRB 철회 즉시 무효화 경로 겸용.
TODO: Redis 세션 삭제. 현재 쿠키 만료만.
"""
response.delete_cookie(settings.cookie_name, httponly=True, secure=settings.is_prod, samesite="lax")
async def logout(
response: Response,
session_cookie: Annotated[Optional[str], Cookie(alias="__Host-vignette_sid")] = None,
dev_session_cookie: Annotated[Optional[str], Cookie(alias="vignette_sid")] = None,
) -> dict[str, bool]:
"""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}
@router.get("/me", response_model=MeResponse)
async def me(principal: CurrentPrincipal) -> MeResponse:
"""현재 세션 주체 (프론트 부트스트랩용). 미인증이면 deps 에서 401."""
return MeResponse(
user_id=principal.user_id,
role=principal.role.value,
cohort_ids=principal.cohort_ids,
)
"""Return the current authenticated user. Unauthenticated requests are 401."""
return _me_response(principal)

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -1,22 +1,14 @@
"""음성 라우트 — OpenAI STT/TTS 캐스케이드 + WSS 실시간 턴테이킹.
"""Voice routes for the OpenAI STT/TTS cascade over WebSocket.
한신대 요구 '음성 필수'. 학습자가 마이크로 말하면 STT orchestrator 상담 1
내담자 텍스트 TTS 오디오 + 립싱크 힌트(설계 §4.3 RMS) 역방향으로 흘린다.
Client sends JSON controls plus binary audio chunks:
audio_start -> binary audio chunks -> audio_end
캐스케이드(설계 §5.2 음성 오브 4상태 listeningthinkingspeakingidle):
[클라] audio_start(JSON) 바이너리 오디오 청크들 audio_end(JSON)
[서버] state(listening) STT transcript(JSON) state(thinking)
orchestrator.run_turn(가드레일·상태머신·페르소나·내담자AI·출력가드)
reply(JSON, 내담자 텍스트 + stage/openness) state(speaking)
[tts_chunk(JSON: seq/rms) + 바이너리 오디오] × N tts_end(JSON) state(idle)
Server emits:
ready -> state(listening) -> state(thinking) -> transcript -> reply
-> state(speaking) -> tts_chunk + binary audio chunks -> tts_end -> state(idle)
프로토콜(JSON 제어 + 바이너리 오디오 혼합, 단일 WS):
- 클라서버 텍스트 = JSON 제어({"type": ...}); 클라서버 바이너리 = 오디오 청크
- 서버클라 텍스트 = JSON 이벤트; 서버클라 바이너리 = TTS 오디오 청크
- TTS 바이너리 청크 *직전* 메타 JSON(tts_chunk: seq, rms) 보내 프론트가 짝짓는다.
음성 미설정(OPENAI_API_KEY 없음): GET /voice/health 503 degraded,
WS 핸드셰이크 직후 degraded 이벤트 + close(1011). 절대 크래시 금지.
When voice is not configured, the route reports degraded state and closes
cleanly instead of crashing.
"""
from __future__ import annotations
@ -28,67 +20,84 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse
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 ..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.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"])
# WS close 코드(섹션별 의미 명시)
WS_CLOSE_DEGRADED = 1011 # 서버측 음성 미설정/장애
WS_CLOSE_BAD_REQUEST = 1008 # 프로토콜 위반(세션 누락 등)
# WebSocket close codes.
WS_CLOSE_DEGRADED = 1011
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
# ════════════════════════════════════════════════════════════════════════════
# 헬스 — 음성 가용성(키 설정) 노출
# ════════════════════════════════════════════════════════════════════════════
@router.get("/health")
async def voice_health() -> JSONResponse:
"""음성 라우터 헬스. 키 미설정이면 503 degraded(시연 투명성)."""
"""Return voice service readiness."""
available = voice_service.is_available()
body = {
"status": "ok" if available else "degraded",
"available": available,
"stt_model": voice_svc.STT_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)
# ════════════════════════════════════════════════════════════════════════════
# WebSocket — 실시간 음성 캐스케이드
# ════════════════════════════════════════════════════════════════════════════
@router.websocket("/ws")
async def voice_ws(websocket: WebSocket) -> None:
"""음성 실시간 턴 캐스케이드.
쿼리: ?session_id=<hex> (없으면 persona_code 일회용 in-proc 세션 생성 시연용)
오디오 in(바이너리) STT 상담 1 TTS out(바이너리) + 립싱크 힌트.
"""
"""Run one authenticated learner voice cascade."""
await websocket.accept()
# 1) 음성 미설정 → degraded 알리고 정상 종료(크래시 금지)
if not voice_service.is_available():
await _safe_send_json(
websocket,
{"type": "degraded", "reason": "OPENAI_API_KEY 미설정 — 음성 기능 비활성"},
)
await _safe_close(websocket, WS_CLOSE_DEGRADED)
# Authenticate the same server-side browser session used by REST routes.
principal = await _principal_from_websocket(websocket)
if principal is None:
await _safe_send_json(websocket, {"type": "error", "detail": "not authenticated"})
await _safe_close(websocket, WS_CLOSE_UNAUTHORIZED)
return
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
# 2) 세션 바인딩 — session_id 우선, 없으면 persona_code 로 시연 세션 생성
session_id, voice_preset, err = _bind_session(websocket)
# Bind to an existing session first. persona_code creation is dev-only.
session_id, voice_preset, err, bind_meta = await _bind_session(websocket, principal)
if err is not None:
await _safe_send_json(websocket, {"type": "error", "detail": err})
await _safe_close(websocket, WS_CLOSE_BAD_REQUEST)
return
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(
websocket,
@ -98,6 +107,7 @@ async def voice_ws(websocket: WebSocket) -> None:
"voice": voice_preset.openai_voice,
"preset": voice_preset.preset,
"state": "idle",
**bind_meta,
},
)
@ -111,10 +121,10 @@ async def voice_ws(websocket: WebSocket) -> None:
if mtype == "websocket.disconnect":
break
# ── 바이너리 = 오디오 청크 누적 ──
# Binary frames are audio chunks.
if msg.get("bytes") is not None:
if not receiving:
# audio_start 없이 들어온 바이너리 — 관용적으로 자동 시작
# Be tolerant when audio arrives before audio_start.
receiving = True
audio_buf.clear()
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:
await _safe_send_json(
websocket,
{"type": "error", "detail": "audio too large — 발화를 짧게 끊어 주세요"},
{"type": "error", "detail": "audio too large; please send a shorter utterance"},
)
audio_buf.clear()
receiving = False
continue
# ── 텍스트 = JSON 제어 ──
# Text frames are JSON controls.
text = msg.get("text")
if text is None:
continue
@ -149,6 +159,7 @@ async def voice_ws(websocket: WebSocket) -> None:
await _handle_utterance(
websocket,
session_id=session_id,
principal=principal,
voice_preset=voice_preset,
audio=bytes(audio_buf),
fmt=ctrl.get("format"),
@ -156,7 +167,7 @@ async def voice_ws(websocket: WebSocket) -> None:
audio_buf.clear()
elif ctype == "text_turn":
# 음성 없이 텍스트만 보내는 경로(접근성/디버그): STT 건너뛰고 바로 턴.
# Text-only path for accessibility and deterministic tests.
receiving = False
audio_buf.clear()
learner_text = (ctrl.get("text") or "").strip()
@ -164,6 +175,7 @@ async def voice_ws(websocket: WebSocket) -> None:
await _run_turn_and_speak(
websocket,
session_id=session_id,
principal=principal,
voice_preset=voice_preset,
learner_text=learner_text,
)
@ -176,30 +188,28 @@ async def voice_ws(websocket: WebSocket) -> None:
except WebSocketDisconnect:
pass
except Exception as e: # 어떤 예외도 WS 를 깨끗이 닫고 알린다(크래시 금지)
except Exception as e:
await _safe_send_json(websocket, {"type": "error", "detail": f"voice ws error: {e}"})
finally:
await _safe_close(websocket)
# ════════════════════════════════════════════════════════════════════════════
# 발화 1건 처리 — STT → 턴 → TTS
# ════════════════════════════════════════════════════════════════════════════
async def _handle_utterance(
websocket: WebSocket,
*,
session_id: str,
principal: Principal,
voice_preset: VoicePreset,
audio: bytes,
fmt: Optional[str],
) -> None:
"""오디오 1발화 → STT → 상담 턴 → TTS 캐스케이드."""
"""Transcribe one utterance, generate the client reply, then synthesize TTS."""
if not audio:
await _safe_send_json(websocket, {"type": "transcript", "text": "", "final": True})
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
# 1) STT (thinking 진입)
# STT begins after the learner stops speaking.
await _safe_send_json(websocket, {"type": "state", "state": "thinking"})
filename, content_type = _audio_meta(fmt)
try:
@ -211,7 +221,7 @@ async def _handle_utterance(
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
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"})
return
@ -221,13 +231,13 @@ async def _handle_utterance(
{"type": "transcript", "text": learner_text, "final": True, "speaker": "counselor"},
)
if not learner_text:
# 무음/인식 실패 — 턴 진행 안 함
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
await _run_turn_and_speak(
websocket,
session_id=session_id,
principal=principal,
voice_preset=voice_preset,
learner_text=learner_text,
)
@ -237,13 +247,14 @@ async def _run_turn_and_speak(
websocket: WebSocket,
*,
session_id: str,
principal: Principal,
voice_preset: VoicePreset,
learner_text: str,
) -> None:
"""상담 1턴(orchestrator) → 내담자 텍스트 → TTS 오디오/립싱크 힌트 역방향 전송."""
sess = store.get(session_id)
if sess is None or sess.ended:
await _safe_send_json(websocket, {"type": "error", "detail": "세션 없음/종료됨"})
"""Run one counseling turn and stream synthesized client speech."""
sess, err = await _load_voice_session(session_id, principal)
if sess is None:
await _safe_send_json(websocket, {"type": "error", "detail": err or "session not found or ended"})
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
@ -260,19 +271,7 @@ async def _run_turn_and_speak(
)
assert ctx.state_after is not None
# 학습자 발화 로깅(마스킹본) — sessions.py 패턴과 동일
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 전 전체 텍스트가 필요)
# Voice needs the full client reply before TTS starts.
try:
result = await orchestrator.run_turn_generate(ctx, engine_client)
except EngineError as e:
@ -281,10 +280,22 @@ async def _run_turn_and_speak(
return
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:
store.append_turn(
session_id,
# Persist the generated client reply before TTS playback.
await _append_voice_turn(
sess,
TurnRecord(
turn_seq=result.turn_seq,
speaker="client",
@ -293,9 +304,9 @@ async def _run_turn_and_speak(
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(
websocket,
{
@ -314,7 +325,7 @@ async def _run_turn_and_speak(
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
# 3) TTS (speaking) — 청크별 메타 JSON(립싱크 rms) + 바이너리 오디오
# TTS speaking state comes before chunk metadata and binary audio.
await _safe_send_json(
websocket,
{"type": "state", "state": "speaking", "voice": voice_preset.openai_voice},
@ -322,7 +333,7 @@ async def _run_turn_and_speak(
try:
n = 0
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(
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:
await _safe_send_json(websocket, {"type": "degraded", "reason": str(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"})
# ════════════════════════════════════════════════════════════════════════════
# 세션 바인딩 / 메타 헬퍼
# ════════════════════════════════════════════════════════════════════════════
def _bind_session(
websocket: WebSocket,
) -> tuple[Optional[str], Optional[VoicePreset], Optional[str]]:
"""쿼리에서 세션을 바인딩(또는 시연 세션 생성)하고 voice preset 을 해석.
async def _load_voice_session(
session_id: str,
principal: Principal,
) -> tuple[InProcSession | None, str | None]:
sess = await session_persistence.load_session(session_id, principal, allow_ended=True)
if sess is not None:
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 시작된) 음성 부착
?persona_code=P1[&preset=] in-proc 시연 세션 생성(DB off 폴백)
반환 (session_id, voice_preset, error).
"""
async def _append_voice_turn(sess: InProcSession, turn: TurnRecord) -> None:
if await session_persistence.append_turn(
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
explicit_preset = qp.get("preset")
session_id = qp.get("session_id")
if session_id:
sess = store.get(session_id)
sess, err = await _load_voice_session(session_id, principal)
if sess is None:
return None, None, f"unknown session {session_id}"
if sess.ended:
return None, None, "session already ended"
return None, None, err or f"unknown session {session_id}", {}
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")
if not persona_code:
return None, None, "session_id 또는 persona_code 쿼리 필요"
card = persona.get_seed_persona(persona_code)
if card is None:
return None, None, f"unknown persona {persona_code}"
from ..services import state_machine
return None, None, "session_id or persona_code query required", {}
try:
catalog_persona = await get_catalog_persona(persona_code)
except Exception:
return None, None, "persona catalog database unavailable", {}
if catalog_persona is None:
return None, None, f"unknown persona {persona_code}", {}
card = catalog_persona.card
st = state_machine.init_state(
base_resistance=card.base_resistance(),
@ -379,19 +456,47 @@ def _bind_session(
decay_floor=card.decay_floor(),
ideation_baseline=card.ideation_baseline(),
)
sess = store.create(
learner_id="dev-learner-voice",
persona=card,
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(
learner_id=principal.user_id,
persona=card,
theory_mode="humanistic",
state=st,
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)
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]:
"""클라가 알려준 포맷 → (filename, content_type). 기본 webm/opus."""
"""Map the browser audio format to upload metadata."""
f = (fmt or "webm").lower().lstrip(".")
table = {
"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"))
# ── 안전 송수신(연결 끊김 시 조용히 무시) ───────────────────────────────────
async def _safe_send_json(websocket: WebSocket, payload: dict) -> None:
if websocket.client_state != WebSocketState.CONNECTED:
return

View file

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

View file

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

View file

@ -185,9 +185,9 @@ def estimate_chunk_rms(chunk: bytes) -> float:
class VoiceService:
"""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._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
# ── 수명주기 ──────────────────────────────────────────

View file

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

View file

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

View file

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

View file

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