Stabilize runtime auth and E2E coverage
This commit is contained in:
parent
6a3e3b541c
commit
188e899394
133 changed files with 55987 additions and 6775 deletions
796
apps/api/app/auth_sessions.py
Normal file
796
apps/api/app/auth_sessions.py
Normal file
|
|
@ -0,0 +1,796 @@
|
|||
"""Server-side browser sessions for the BFF auth boundary.
|
||||
|
||||
Sessions are DB-backed when PostgreSQL is available and fall back to the
|
||||
process-local store for local degraded development. The browser only receives an
|
||||
opaque HttpOnly cookie; roles and user identity stay on the server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from .config import settings
|
||||
from .db import get_pool
|
||||
from .runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SessionUser:
|
||||
user_id: str
|
||||
email: str
|
||||
display_name: str
|
||||
role: str
|
||||
cohort_ids: list[str]
|
||||
expires_at: float
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ManagedUser:
|
||||
user_id: str
|
||||
email: str
|
||||
display_name: str
|
||||
role: str
|
||||
cohort_ids: list[str]
|
||||
affiliation: str
|
||||
created_at: float
|
||||
last_seen_at: float
|
||||
|
||||
|
||||
class InactiveUserError(Exception):
|
||||
"""Raised when an inactive managed user attempts to create a login session."""
|
||||
|
||||
|
||||
_sessions: dict[str, SessionUser] = {}
|
||||
_users: dict[str, ManagedUser] = {}
|
||||
_email_index: dict[str, str] = {}
|
||||
_inactive_emails: set[str] = set()
|
||||
|
||||
DEFAULT_AFFILIATION = settings.default_affiliation.strip()
|
||||
RoleName = Literal["learner", "teacher", "admin"]
|
||||
DB_ROLE_BY_APP = {"learner": "learner", "teacher": "instructor", "admin": "admin"}
|
||||
APP_ROLE_BY_DB = {"learner": "learner", "instructor": "teacher", "admin": "admin"}
|
||||
|
||||
|
||||
def _sid_hash(raw_sid: str) -> str:
|
||||
return hmac.new(
|
||||
settings.session_secret.encode("utf-8"),
|
||||
raw_sid.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def user_id_from_email(email: str) -> str:
|
||||
return str(uuid.uuid5(uuid.NAMESPACE_URL, f"vignette:user:{email.strip().lower()}"))
|
||||
|
||||
|
||||
def _normalize_email(email: str) -> str:
|
||||
return email.strip().lower()
|
||||
|
||||
|
||||
def _db_role(role: str) -> str:
|
||||
return DB_ROLE_BY_APP.get(role, role)
|
||||
|
||||
|
||||
def _app_role(role: str) -> str:
|
||||
return APP_ROLE_BY_DB.get(role, role)
|
||||
|
||||
|
||||
def _ts(value: datetime | None) -> float:
|
||||
return (value or datetime.now(timezone.utc)).timestamp()
|
||||
|
||||
|
||||
def _cohort_ids(cohort: str | None) -> list[str]:
|
||||
return [cohort] if cohort else []
|
||||
|
||||
|
||||
def _cohort_value(cohort_ids: list[str] | None) -> str | None:
|
||||
return (cohort_ids or [None])[0]
|
||||
|
||||
|
||||
async def _runtime_tables_ready(conn) -> bool:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT
|
||||
EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'app'
|
||||
AND table_name = 'app_user'
|
||||
AND column_name IN ('affiliation', 'last_seen_at', 'updated_at')
|
||||
GROUP BY table_schema, table_name
|
||||
HAVING count(*) = 3
|
||||
) AS has_user_columns,
|
||||
to_regclass('app.auth_session') IS NOT NULL AS has_auth_session,
|
||||
to_regclass('app.user_preferences') IS NOT NULL AS has_preferences,
|
||||
to_regclass('app.admin_engine_config') IS NOT NULL AS has_engine_config,
|
||||
EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'app'
|
||||
AND table_name = 'sessions'
|
||||
AND column_name IN (
|
||||
'runtime_case_id',
|
||||
'persona_code',
|
||||
'persona_display_name',
|
||||
'persona_difficulty',
|
||||
'prev_rapport_credit'
|
||||
)
|
||||
GROUP BY table_schema, table_name
|
||||
HAVING count(*) = 5
|
||||
) AS has_session_columns,
|
||||
EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'app'
|
||||
AND table_name = 'session_state'
|
||||
AND column_name = 'turns_in_stage'
|
||||
) AS has_state_columns,
|
||||
(
|
||||
SELECT count(*) = 4
|
||||
FROM app.stage_def
|
||||
WHERE stage_code IN ('라포','탐색','개입','정리')
|
||||
) AS has_stage_defs,
|
||||
EXISTS (
|
||||
SELECT 1 FROM pg_policies
|
||||
WHERE schemaname = 'app'
|
||||
AND tablename = 'sessions'
|
||||
AND policyname IN ('p_sessions_insert','p_sessions_update','p_sessions_delete')
|
||||
GROUP BY schemaname, tablename
|
||||
HAVING count(*) = 3
|
||||
) AS has_session_write_policies,
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM pg_policies
|
||||
WHERE schemaname = 'app'
|
||||
AND tablename = 'sessions'
|
||||
AND policyname = 'p_sessions_modify'
|
||||
) AS removed_old_session_policy,
|
||||
EXISTS (
|
||||
SELECT 1 FROM pg_policies
|
||||
WHERE schemaname = 'app'
|
||||
AND tablename = 'turns'
|
||||
AND policyname IN ('p_turns_insert','p_turns_update','p_turns_delete')
|
||||
GROUP BY schemaname, tablename
|
||||
HAVING count(*) = 3
|
||||
) AS has_turn_write_policies,
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM pg_policies
|
||||
WHERE schemaname = 'app'
|
||||
AND tablename = 'turns'
|
||||
AND policyname = 'p_turns_modify'
|
||||
) AS removed_old_turn_policy
|
||||
"""
|
||||
)
|
||||
return bool(
|
||||
row
|
||||
and row["has_user_columns"]
|
||||
and row["has_auth_session"]
|
||||
and row["has_preferences"]
|
||||
and row["has_engine_config"]
|
||||
and row["has_session_columns"]
|
||||
and row["has_state_columns"]
|
||||
and row["has_stage_defs"]
|
||||
and row["has_session_write_policies"]
|
||||
and row["removed_old_session_policy"]
|
||||
and row["has_turn_write_policies"]
|
||||
and row["removed_old_turn_policy"]
|
||||
)
|
||||
|
||||
|
||||
async def ensure_runtime_tables() -> None:
|
||||
"""Ensure DB-backed auth/user runtime tables exist when a pool is available."""
|
||||
pool = get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'app'
|
||||
AND table_name = 'app_user'
|
||||
AND column_name = 'affiliation'
|
||||
) THEN
|
||||
ALTER TABLE app.app_user ALTER COLUMN affiliation SET DEFAULT '';
|
||||
END IF;
|
||||
END $$;
|
||||
"""
|
||||
)
|
||||
if await _runtime_tables_ready(conn):
|
||||
return
|
||||
await conn.execute(
|
||||
"""
|
||||
ALTER TABLE app.app_user
|
||||
ADD COLUMN IF NOT EXISTS affiliation TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
"""
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS app.auth_session (
|
||||
sid_hash TEXT PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
cohort_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
"""
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_session_user_active
|
||||
ON app.auth_session(user_id, expires_at)
|
||||
WHERE revoked_at IS NULL
|
||||
"""
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS app.user_preferences (
|
||||
user_id UUID PRIMARY KEY REFERENCES app.app_user(user_id) ON DELETE CASCADE,
|
||||
theme TEXT NOT NULL DEFAULT 'system',
|
||||
voice_preset_id TEXT NOT NULL DEFAULT 'soft-young-fem',
|
||||
voice_rate REAL NOT NULL DEFAULT 1.0,
|
||||
notifications JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
"""
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS app.admin_engine_config (
|
||||
id BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (id),
|
||||
engine_mode TEXT NOT NULL,
|
||||
engine_url TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
updated_by TEXT,
|
||||
updated_at TIMESTAMPTZ
|
||||
)
|
||||
"""
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
ALTER TABLE app.sessions
|
||||
ADD COLUMN IF NOT EXISTS runtime_case_id UUID,
|
||||
ADD COLUMN IF NOT EXISTS persona_code TEXT,
|
||||
ADD COLUMN IF NOT EXISTS persona_display_name TEXT,
|
||||
ADD COLUMN IF NOT EXISTS persona_difficulty TEXT,
|
||||
ADD COLUMN IF NOT EXISTS prev_rapport_credit REAL NOT NULL DEFAULT 0.0
|
||||
"""
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
ALTER TABLE app.session_state
|
||||
ADD COLUMN IF NOT EXISTS turns_in_stage INT NOT NULL DEFAULT 0
|
||||
"""
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO app.stage_def (stage_code, display_name, seq, base_openness)
|
||||
VALUES
|
||||
('라포', '라포', 1, 0.15),
|
||||
('탐색', '탐색', 2, 0.35),
|
||||
('개입', '개입', 3, 0.55),
|
||||
('정리', '정리', 4, 0.45)
|
||||
ON CONFLICT (stage_code) DO UPDATE SET
|
||||
display_name = EXCLUDED.display_name,
|
||||
seq = EXCLUDED.seq,
|
||||
base_openness = EXCLUDED.base_openness
|
||||
"""
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
DROP POLICY IF EXISTS p_sessions_modify ON app.sessions;
|
||||
DROP POLICY IF EXISTS p_sessions_insert ON app.sessions;
|
||||
DROP POLICY IF EXISTS p_sessions_update ON app.sessions;
|
||||
DROP POLICY IF EXISTS p_sessions_delete ON app.sessions;
|
||||
|
||||
CREATE POLICY p_sessions_insert ON app.sessions FOR INSERT WITH CHECK (
|
||||
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
|
||||
OR learner_id = app.current_uid()
|
||||
);
|
||||
CREATE POLICY p_sessions_update ON app.sessions FOR UPDATE USING (
|
||||
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
|
||||
OR learner_id = app.current_uid()
|
||||
) WITH CHECK (
|
||||
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
|
||||
OR learner_id = app.current_uid()
|
||||
);
|
||||
CREATE POLICY p_sessions_delete ON app.sessions FOR DELETE USING (
|
||||
app.is_ai_context() OR app.current_role_name() IN ('admin','instructor')
|
||||
OR learner_id = app.current_uid()
|
||||
)
|
||||
"""
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
DROP POLICY IF EXISTS p_turns_modify ON app.turns;
|
||||
DROP POLICY IF EXISTS p_turns_insert ON app.turns;
|
||||
DROP POLICY IF EXISTS p_turns_update ON app.turns;
|
||||
DROP POLICY IF EXISTS p_turns_delete ON app.turns;
|
||||
|
||||
CREATE POLICY p_turns_insert ON app.turns FOR INSERT WITH CHECK (
|
||||
app.is_ai_context()
|
||||
OR app.current_role_name() IN ('admin','instructor')
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM app.sessions s
|
||||
WHERE s.id = app.turns.session_id
|
||||
AND s.learner_id = app.current_uid()
|
||||
)
|
||||
);
|
||||
CREATE POLICY p_turns_update ON app.turns FOR UPDATE USING (
|
||||
app.is_ai_context()
|
||||
OR app.current_role_name() IN ('admin','instructor')
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM app.sessions s
|
||||
WHERE s.id = app.turns.session_id
|
||||
AND s.learner_id = app.current_uid()
|
||||
)
|
||||
) WITH CHECK (
|
||||
app.is_ai_context()
|
||||
OR app.current_role_name() IN ('admin','instructor')
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM app.sessions s
|
||||
WHERE s.id = app.turns.session_id
|
||||
AND s.learner_id = app.current_uid()
|
||||
)
|
||||
);
|
||||
CREATE POLICY p_turns_delete ON app.turns FOR DELETE USING (
|
||||
app.is_ai_context()
|
||||
OR app.current_role_name() IN ('admin','instructor')
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM app.sessions s
|
||||
WHERE s.id = app.turns.session_id
|
||||
AND s.learner_id = app.current_uid()
|
||||
)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _managed_user_from_row(row) -> ManagedUser:
|
||||
return ManagedUser(
|
||||
user_id=str(row["user_id"]),
|
||||
email=row["email"],
|
||||
display_name=row["display_name"] or row["email"],
|
||||
role=_app_role(row["role"]),
|
||||
cohort_ids=_cohort_ids(row.get("cohort") if hasattr(row, "get") else row["cohort"]),
|
||||
affiliation=row["affiliation"] or DEFAULT_AFFILIATION,
|
||||
created_at=_ts(row["created_at"]),
|
||||
last_seen_at=_ts(row["last_seen_at"]),
|
||||
)
|
||||
|
||||
|
||||
def _memory_upsert_managed_user(
|
||||
*,
|
||||
email: str,
|
||||
display_name: str,
|
||||
role: str,
|
||||
cohort_ids: list[str] | None = None,
|
||||
user_id: str | None = None,
|
||||
affiliation: str | None = None,
|
||||
reactivate: bool = False,
|
||||
) -> ManagedUser:
|
||||
now = time.time()
|
||||
normalized_email = _normalize_email(email)
|
||||
if normalized_email in _inactive_emails and not reactivate:
|
||||
raise InactiveUserError("user is inactive")
|
||||
if reactivate:
|
||||
_inactive_emails.discard(normalized_email)
|
||||
uid = user_id or _email_index.get(normalized_email) or user_id_from_email(normalized_email)
|
||||
current = _users.get(uid)
|
||||
user = ManagedUser(
|
||||
user_id=uid,
|
||||
email=normalized_email,
|
||||
display_name=(display_name.strip() if display_name else "") or normalized_email,
|
||||
role=role,
|
||||
cohort_ids=list(cohort_ids or current.cohort_ids if current else cohort_ids or []),
|
||||
affiliation=(
|
||||
affiliation.strip()
|
||||
if affiliation
|
||||
else (current.affiliation if current else DEFAULT_AFFILIATION)
|
||||
),
|
||||
created_at=current.created_at if current else now,
|
||||
last_seen_at=now,
|
||||
)
|
||||
_users[uid] = user
|
||||
_email_index[normalized_email] = uid
|
||||
return user
|
||||
|
||||
|
||||
async def upsert_managed_user(
|
||||
*,
|
||||
email: str,
|
||||
display_name: str,
|
||||
role: str,
|
||||
cohort_ids: list[str] | None = None,
|
||||
user_id: str | None = None,
|
||||
affiliation: str | None = None,
|
||||
reactivate: bool = False,
|
||||
) -> ManagedUser:
|
||||
normalized_email = _normalize_email(email)
|
||||
try:
|
||||
pool = get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO app.app_user (
|
||||
external_id, email, display_name, role, cohort, affiliation, last_seen_at, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, now(), now())
|
||||
ON CONFLICT (external_id) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
display_name = COALESCE(NULLIF(EXCLUDED.display_name, ''), app.app_user.display_name),
|
||||
role = EXCLUDED.role,
|
||||
cohort = COALESCE(EXCLUDED.cohort, app.app_user.cohort),
|
||||
affiliation = COALESCE(NULLIF(EXCLUDED.affiliation, ''), app.app_user.affiliation),
|
||||
is_active = CASE WHEN $7 THEN TRUE ELSE app.app_user.is_active END,
|
||||
last_seen_at = now(),
|
||||
updated_at = now()
|
||||
WHERE app.app_user.is_active OR $7
|
||||
RETURNING user_id, email, display_name, role, cohort, affiliation, created_at, last_seen_at
|
||||
""",
|
||||
f"email:{normalized_email}",
|
||||
normalized_email,
|
||||
(display_name.strip() if display_name else normalized_email),
|
||||
_db_role(role),
|
||||
_cohort_value(cohort_ids),
|
||||
affiliation or DEFAULT_AFFILIATION,
|
||||
reactivate,
|
||||
)
|
||||
if row is None:
|
||||
_inactive_emails.add(normalized_email)
|
||||
raise InactiveUserError("user is inactive")
|
||||
user = _managed_user_from_row(row)
|
||||
_memory_upsert_managed_user(
|
||||
email=user.email,
|
||||
display_name=user.display_name,
|
||||
role=user.role,
|
||||
cohort_ids=user.cohort_ids,
|
||||
user_id=user.user_id,
|
||||
affiliation=user.affiliation,
|
||||
reactivate=True,
|
||||
)
|
||||
return user
|
||||
except InactiveUserError:
|
||||
raise
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("managed user")
|
||||
return _memory_upsert_managed_user(
|
||||
email=normalized_email,
|
||||
display_name=display_name,
|
||||
role=role,
|
||||
cohort_ids=cohort_ids,
|
||||
user_id=user_id,
|
||||
affiliation=affiliation,
|
||||
reactivate=reactivate,
|
||||
)
|
||||
|
||||
|
||||
async def get_managed_user(user_id: str) -> ManagedUser | None:
|
||||
try:
|
||||
pool = get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT user_id, email, display_name, role, cohort, affiliation, created_at, last_seen_at
|
||||
FROM app.app_user
|
||||
WHERE user_id = $1::uuid AND is_active
|
||||
""",
|
||||
user_id,
|
||||
)
|
||||
if row is not None:
|
||||
return _managed_user_from_row(row)
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("managed user")
|
||||
if not runtime_fallback_allowed():
|
||||
return None
|
||||
return _users.get(user_id)
|
||||
|
||||
|
||||
async def list_managed_users() -> tuple[list[ManagedUser], bool]:
|
||||
try:
|
||||
pool = get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT user_id, email, display_name, role, cohort, affiliation, created_at, last_seen_at
|
||||
FROM app.app_user
|
||||
WHERE is_active
|
||||
ORDER BY last_seen_at DESC
|
||||
"""
|
||||
)
|
||||
return [_managed_user_from_row(row) for row in rows], True
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("managed user list")
|
||||
return sorted(_users.values(), key=lambda u: u.last_seen_at, reverse=True), False
|
||||
|
||||
|
||||
async def update_managed_user(
|
||||
user_id: str,
|
||||
*,
|
||||
display_name: str | None = None,
|
||||
role: RoleName | None = None,
|
||||
affiliation: str | None = None,
|
||||
cohort_ids: list[str] | None = None,
|
||||
) -> ManagedUser | None:
|
||||
try:
|
||||
pool = get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
UPDATE app.app_user SET
|
||||
display_name = COALESCE($2, display_name),
|
||||
role = COALESCE($3, role),
|
||||
cohort = CASE WHEN $4 THEN $5 ELSE cohort END,
|
||||
affiliation = COALESCE($6, affiliation),
|
||||
updated_at = now(),
|
||||
last_seen_at = now()
|
||||
WHERE user_id = $1::uuid AND is_active
|
||||
RETURNING user_id, email, display_name, role, cohort, affiliation, created_at, last_seen_at
|
||||
""",
|
||||
user_id,
|
||||
display_name.strip() if display_name is not None else None,
|
||||
_db_role(role) if role is not None else None,
|
||||
cohort_ids is not None,
|
||||
_cohort_value(cohort_ids),
|
||||
affiliation.strip() if affiliation is not None else None,
|
||||
)
|
||||
if row is not None:
|
||||
next_user = _managed_user_from_row(row)
|
||||
_memory_upsert_managed_user(
|
||||
email=next_user.email,
|
||||
display_name=next_user.display_name,
|
||||
role=next_user.role,
|
||||
cohort_ids=next_user.cohort_ids,
|
||||
user_id=next_user.user_id,
|
||||
affiliation=next_user.affiliation,
|
||||
)
|
||||
for session in _sessions.values():
|
||||
if session.user_id == user_id:
|
||||
session.display_name = next_user.display_name
|
||||
session.role = next_user.role
|
||||
session.cohort_ids = list(next_user.cohort_ids)
|
||||
return next_user
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("managed user update")
|
||||
|
||||
if not runtime_fallback_allowed():
|
||||
return None
|
||||
current = _users.get(user_id)
|
||||
if current is None:
|
||||
return None
|
||||
|
||||
next_user = ManagedUser(
|
||||
user_id=current.user_id,
|
||||
email=current.email,
|
||||
display_name=display_name.strip() if display_name is not None else current.display_name,
|
||||
role=role if role is not None else current.role,
|
||||
cohort_ids=list(cohort_ids) if cohort_ids is not None else current.cohort_ids,
|
||||
affiliation=affiliation.strip() if affiliation is not None else current.affiliation,
|
||||
created_at=current.created_at,
|
||||
last_seen_at=time.time(),
|
||||
)
|
||||
_users[user_id] = next_user
|
||||
|
||||
for session in _sessions.values():
|
||||
if session.user_id == user_id:
|
||||
session.display_name = next_user.display_name
|
||||
session.role = next_user.role
|
||||
session.cohort_ids = list(next_user.cohort_ids)
|
||||
return next_user
|
||||
|
||||
|
||||
async def deactivate_managed_user(user_id: str) -> bool:
|
||||
"""Deactivate a managed user and revoke their active browser sessions."""
|
||||
changed = False
|
||||
inactive_email: str | None = None
|
||||
try:
|
||||
pool = get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT email FROM app.app_user WHERE user_id = $1::uuid",
|
||||
user_id,
|
||||
)
|
||||
if row is not None and row["email"]:
|
||||
inactive_email = _normalize_email(row["email"])
|
||||
result = await conn.execute(
|
||||
"""
|
||||
UPDATE app.app_user SET
|
||||
is_active = FALSE,
|
||||
updated_at = now(),
|
||||
last_seen_at = now()
|
||||
WHERE user_id = $1::uuid AND is_active
|
||||
""",
|
||||
user_id,
|
||||
)
|
||||
changed = result.endswith(" 1")
|
||||
await conn.execute(
|
||||
"UPDATE app.auth_session SET revoked_at = now() WHERE user_id = $1::uuid",
|
||||
user_id,
|
||||
)
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("managed user deactivation")
|
||||
|
||||
if not runtime_fallback_allowed():
|
||||
return changed
|
||||
memory_user = _users.pop(user_id, None)
|
||||
if memory_user is not None:
|
||||
inactive_email = _normalize_email(memory_user.email)
|
||||
if inactive_email:
|
||||
_inactive_emails.add(inactive_email)
|
||||
for email, uid in list(_email_index.items()):
|
||||
if uid == user_id:
|
||||
_email_index.pop(email, None)
|
||||
for sid_hash, session in list(_sessions.items()):
|
||||
if session.user_id == user_id:
|
||||
_sessions.pop(sid_hash, None)
|
||||
return changed or memory_user is not None
|
||||
|
||||
|
||||
async def active_session_count(user_id: str) -> int:
|
||||
try:
|
||||
pool = get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
return int(
|
||||
await conn.fetchval(
|
||||
"""
|
||||
SELECT count(*)
|
||||
FROM app.auth_session
|
||||
WHERE user_id = $1::uuid
|
||||
AND revoked_at IS NULL
|
||||
AND expires_at > now()
|
||||
""",
|
||||
user_id,
|
||||
)
|
||||
or 0
|
||||
)
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("auth session count")
|
||||
now = time.time()
|
||||
count = 0
|
||||
for session in _sessions.values():
|
||||
if session.user_id == user_id and session.expires_at > now:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
async def create_session(
|
||||
*,
|
||||
email: str,
|
||||
display_name: str,
|
||||
role: str,
|
||||
cohort_ids: list[str] | None = None,
|
||||
user_id: str | None = None,
|
||||
) -> tuple[str, SessionUser]:
|
||||
raw_sid = secrets.token_urlsafe(32)
|
||||
normalized_email = _normalize_email(email)
|
||||
managed = await upsert_managed_user(
|
||||
email=normalized_email,
|
||||
display_name=display_name,
|
||||
role=role,
|
||||
cohort_ids=cohort_ids,
|
||||
user_id=user_id,
|
||||
reactivate=False,
|
||||
)
|
||||
expires_at = time.time() + settings.session_ttl_seconds
|
||||
user = SessionUser(
|
||||
user_id=managed.user_id,
|
||||
email=normalized_email,
|
||||
display_name=managed.display_name,
|
||||
role=managed.role,
|
||||
cohort_ids=list(managed.cohort_ids),
|
||||
expires_at=expires_at,
|
||||
)
|
||||
sid_hash = _sid_hash(raw_sid)
|
||||
try:
|
||||
pool = get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO app.auth_session (
|
||||
sid_hash, user_id, role, display_name, cohort_ids, expires_at, last_seen_at
|
||||
)
|
||||
VALUES ($1, $2::uuid, $3, $4, $5::jsonb, $6, now())
|
||||
ON CONFLICT (sid_hash) DO UPDATE SET
|
||||
user_id = EXCLUDED.user_id,
|
||||
role = EXCLUDED.role,
|
||||
display_name = EXCLUDED.display_name,
|
||||
cohort_ids = EXCLUDED.cohort_ids,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
revoked_at = NULL,
|
||||
last_seen_at = now()
|
||||
""",
|
||||
sid_hash,
|
||||
managed.user_id,
|
||||
managed.role,
|
||||
managed.display_name,
|
||||
list(managed.cohort_ids),
|
||||
datetime.fromtimestamp(expires_at, tz=timezone.utc),
|
||||
)
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("browser session")
|
||||
_sessions[sid_hash] = user
|
||||
return raw_sid, user
|
||||
|
||||
|
||||
async def get_session(raw_sid: str | None) -> SessionUser | None:
|
||||
if not raw_sid:
|
||||
return None
|
||||
key = _sid_hash(raw_sid)
|
||||
try:
|
||||
pool = get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT
|
||||
s.expires_at,
|
||||
u.user_id,
|
||||
u.email,
|
||||
COALESCE(u.display_name, s.display_name, u.email) AS display_name,
|
||||
u.role,
|
||||
u.cohort
|
||||
FROM app.auth_session s
|
||||
JOIN app.app_user u ON u.user_id = s.user_id
|
||||
WHERE s.sid_hash = $1
|
||||
AND s.revoked_at IS NULL
|
||||
AND s.expires_at > now()
|
||||
AND u.is_active
|
||||
""",
|
||||
key,
|
||||
)
|
||||
if row is not None:
|
||||
await conn.execute(
|
||||
"UPDATE app.auth_session SET last_seen_at = now() WHERE sid_hash = $1",
|
||||
key,
|
||||
)
|
||||
await conn.execute(
|
||||
"UPDATE app.app_user SET last_seen_at = now() WHERE user_id = $1",
|
||||
row["user_id"],
|
||||
)
|
||||
return SessionUser(
|
||||
user_id=str(row["user_id"]),
|
||||
email=row["email"],
|
||||
display_name=row["display_name"],
|
||||
role=_app_role(row["role"]),
|
||||
cohort_ids=_cohort_ids(row["cohort"]),
|
||||
expires_at=_ts(row["expires_at"]),
|
||||
)
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("browser session")
|
||||
|
||||
if not runtime_fallback_allowed():
|
||||
return None
|
||||
user = _sessions.get(key)
|
||||
if user is None:
|
||||
return None
|
||||
if user.expires_at <= time.time():
|
||||
_sessions.pop(key, None)
|
||||
return None
|
||||
managed = _users.get(user.user_id)
|
||||
if managed is not None:
|
||||
managed.last_seen_at = time.time()
|
||||
return user
|
||||
|
||||
|
||||
async def revoke_session(raw_sid: str | None) -> None:
|
||||
if not raw_sid:
|
||||
return
|
||||
key = _sid_hash(raw_sid)
|
||||
_sessions.pop(key, None)
|
||||
try:
|
||||
pool = get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"UPDATE app.auth_session SET revoked_at = now() WHERE sid_hash = $1",
|
||||
key,
|
||||
)
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("browser session revoke")
|
||||
|
|
@ -8,8 +8,9 @@ from __future__ import annotations
|
|||
|
||||
from functools import lru_cache
|
||||
from 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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
243
apps/api/app/persona_repository.py
Normal file
243
apps/api/app/persona_repository.py
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
"""DB-backed approved persona catalog.
|
||||
|
||||
The runtime still uses services.persona.PersonaCard as the in-process card
|
||||
shape. This module is the boundary that materializes seed cards into
|
||||
app.persona_card and converts approved DB rows back into PersonaCard values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable
|
||||
|
||||
from .config import settings
|
||||
from .db import acquire, get_pool
|
||||
from .services.persona import PersonaCard, SEED_PERSONAS, get_seed_persona
|
||||
|
||||
|
||||
SEED_VERSION = 1
|
||||
|
||||
_CARD_COLUMNS = """
|
||||
persona_id, code, version, status, display_name, difficulty, theory_target,
|
||||
demographics, presenting, history, big5, resistance, speech_style,
|
||||
affect_baseline, ccd, dsm5_dimensional, source_provenance, is_synthetic
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CatalogPersona:
|
||||
card: PersonaCard
|
||||
persona_id: str | None
|
||||
version: int | None
|
||||
source: str
|
||||
degraded: bool = False
|
||||
|
||||
|
||||
def seed_persona_id(code: str) -> str:
|
||||
return str(uuid.uuid5(uuid.NAMESPACE_URL, f"vignette:persona:{code.upper()}"))
|
||||
|
||||
|
||||
def _json_dict(value: Any) -> dict[str, Any]:
|
||||
if value is None:
|
||||
return {}
|
||||
if isinstance(value, str):
|
||||
parsed = json.loads(value)
|
||||
return dict(parsed) if isinstance(parsed, dict) else {}
|
||||
return dict(value)
|
||||
|
||||
|
||||
def _string_list(value: Iterable[Any] | None) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
return [str(item) for item in value]
|
||||
|
||||
|
||||
def card_from_row(row: Any) -> PersonaCard:
|
||||
return PersonaCard(
|
||||
code=str(row["code"]).upper(),
|
||||
display_name=str(row["display_name"]),
|
||||
difficulty=str(row["difficulty"]),
|
||||
theory_target=_string_list(row["theory_target"]),
|
||||
demographics=_json_dict(row["demographics"]),
|
||||
presenting=_json_dict(row["presenting"]),
|
||||
history=_json_dict(row["history"]),
|
||||
big5=_json_dict(row["big5"]),
|
||||
resistance=_json_dict(row["resistance"]),
|
||||
speech_style=_json_dict(row["speech_style"]),
|
||||
affect_baseline=_json_dict(row["affect_baseline"]),
|
||||
ccd=_json_dict(row["ccd"]),
|
||||
dsm5_dimensional=_json_dict(row["dsm5_dimensional"]),
|
||||
source_provenance=str(row["source_provenance"] or ""),
|
||||
is_synthetic=bool(row["is_synthetic"]),
|
||||
)
|
||||
|
||||
|
||||
def catalog_persona_from_row(row: Any) -> CatalogPersona:
|
||||
return CatalogPersona(
|
||||
card=card_from_row(row),
|
||||
persona_id=str(row["persona_id"]),
|
||||
version=int(row["version"]),
|
||||
source="database",
|
||||
degraded=False,
|
||||
)
|
||||
|
||||
|
||||
def seed_fallback_persona(code: str) -> CatalogPersona | None:
|
||||
card = get_seed_persona(code)
|
||||
if card is None:
|
||||
return None
|
||||
return CatalogPersona(
|
||||
card=card,
|
||||
persona_id=seed_persona_id(card.code),
|
||||
version=SEED_VERSION,
|
||||
source="seed_fallback",
|
||||
degraded=True,
|
||||
)
|
||||
|
||||
|
||||
def seed_fallback_personas() -> list[CatalogPersona]:
|
||||
return [
|
||||
CatalogPersona(
|
||||
card=card,
|
||||
persona_id=seed_persona_id(card.code),
|
||||
version=SEED_VERSION,
|
||||
source="seed_fallback",
|
||||
degraded=True,
|
||||
)
|
||||
for card in SEED_PERSONAS.values()
|
||||
]
|
||||
|
||||
|
||||
async def materialize_seed_personas() -> int:
|
||||
"""Upsert built-in seed personas as approved DB catalog rows."""
|
||||
get_pool()
|
||||
count = 0
|
||||
async with acquire(role="admin") as conn:
|
||||
for card in SEED_PERSONAS.values():
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO app.persona_card (
|
||||
persona_id, code, version, status, display_name, difficulty,
|
||||
theory_target, demographics, presenting, history, big5,
|
||||
resistance, speech_style, affect_baseline, ccd,
|
||||
dsm5_dimensional, source_provenance, is_synthetic,
|
||||
approved_at
|
||||
)
|
||||
VALUES (
|
||||
$1::uuid, $2, $3, 'approved', $4, $5,
|
||||
$6::text[], $7::jsonb, $8::jsonb, $9::jsonb, $10::jsonb,
|
||||
$11::jsonb, $12::jsonb, $13::jsonb, $14::jsonb,
|
||||
$15::jsonb, $16, $17, now()
|
||||
)
|
||||
ON CONFLICT (code, version) DO UPDATE SET
|
||||
status = 'approved',
|
||||
display_name = EXCLUDED.display_name,
|
||||
difficulty = EXCLUDED.difficulty,
|
||||
theory_target = EXCLUDED.theory_target,
|
||||
demographics = EXCLUDED.demographics,
|
||||
presenting = EXCLUDED.presenting,
|
||||
history = EXCLUDED.history,
|
||||
big5 = EXCLUDED.big5,
|
||||
resistance = EXCLUDED.resistance,
|
||||
speech_style = EXCLUDED.speech_style,
|
||||
affect_baseline = EXCLUDED.affect_baseline,
|
||||
ccd = EXCLUDED.ccd,
|
||||
dsm5_dimensional = EXCLUDED.dsm5_dimensional,
|
||||
source_provenance = EXCLUDED.source_provenance,
|
||||
is_synthetic = EXCLUDED.is_synthetic,
|
||||
approved_at = COALESCE(persona_card.approved_at, now())
|
||||
""",
|
||||
seed_persona_id(card.code),
|
||||
card.code,
|
||||
SEED_VERSION,
|
||||
card.display_name,
|
||||
card.difficulty,
|
||||
card.theory_target,
|
||||
card.demographics,
|
||||
card.presenting,
|
||||
card.history,
|
||||
card.big5,
|
||||
card.resistance,
|
||||
card.speech_style,
|
||||
card.affect_baseline,
|
||||
card.ccd,
|
||||
card.dsm5_dimensional,
|
||||
card.source_provenance,
|
||||
card.is_synthetic,
|
||||
)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
async def list_approved_personas() -> list[CatalogPersona]:
|
||||
get_pool()
|
||||
async with acquire(ai_context=True) as conn:
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT {_CARD_COLUMNS}
|
||||
FROM (
|
||||
SELECT DISTINCT ON (code) {_CARD_COLUMNS}
|
||||
FROM app.persona_card
|
||||
WHERE status = 'approved'
|
||||
ORDER BY code, version DESC
|
||||
) approved
|
||||
ORDER BY code
|
||||
"""
|
||||
)
|
||||
return [catalog_persona_from_row(row) for row in rows]
|
||||
|
||||
|
||||
async def get_approved_persona(code: str) -> CatalogPersona | None:
|
||||
normalized = code.strip().upper()
|
||||
if not normalized:
|
||||
return None
|
||||
get_pool()
|
||||
async with acquire(ai_context=True) as conn:
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
SELECT {_CARD_COLUMNS}
|
||||
FROM app.persona_card
|
||||
WHERE status = 'approved'
|
||||
AND upper(code) = $1
|
||||
ORDER BY version DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
normalized,
|
||||
)
|
||||
return catalog_persona_from_row(row) if row is not None else None
|
||||
|
||||
|
||||
async def list_catalog_personas() -> list[CatalogPersona]:
|
||||
try:
|
||||
return await list_approved_personas()
|
||||
except Exception:
|
||||
if settings.allow_seed_persona_fallback:
|
||||
return seed_fallback_personas()
|
||||
raise
|
||||
|
||||
|
||||
async def get_catalog_persona(code: str) -> CatalogPersona | None:
|
||||
try:
|
||||
return await get_approved_persona(code)
|
||||
except Exception:
|
||||
if settings.allow_seed_persona_fallback:
|
||||
return seed_fallback_persona(code)
|
||||
raise
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CatalogPersona",
|
||||
"SEED_VERSION",
|
||||
"card_from_row",
|
||||
"catalog_persona_from_row",
|
||||
"get_approved_persona",
|
||||
"get_catalog_persona",
|
||||
"list_approved_personas",
|
||||
"list_catalog_personas",
|
||||
"materialize_seed_personas",
|
||||
"seed_fallback_persona",
|
||||
"seed_fallback_personas",
|
||||
"seed_persona_id",
|
||||
]
|
||||
539
apps/api/app/routes/admin.py
Normal file
539
apps/api/app/routes/admin.py
Normal file
|
|
@ -0,0 +1,539 @@
|
|||
"""Admin operations routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..auth_sessions import (
|
||||
active_session_count,
|
||||
deactivate_managed_user,
|
||||
list_managed_users,
|
||||
update_managed_user,
|
||||
upsert_managed_user,
|
||||
)
|
||||
from ..config import settings
|
||||
from ..db import acquire, get_pool, healthcheck
|
||||
from ..deps import Principal, Role, require_role
|
||||
from ..engine_client import engine_client
|
||||
from ..runtime_policy import require_runtime_fallback_allowed
|
||||
from ..services.voice import voice_service
|
||||
from ..services import rag
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
AdminPrincipal = Annotated[Principal, Depends(require_role(Role.ADMIN))]
|
||||
HealthStatus = Literal["ok", "degraded", "down"]
|
||||
|
||||
|
||||
class AdminServiceHealth(BaseModel):
|
||||
key: str
|
||||
name: str
|
||||
status: HealthStatus
|
||||
detail: str
|
||||
metric: str
|
||||
load: float
|
||||
|
||||
|
||||
class AdminHealthResponse(BaseModel):
|
||||
status: HealthStatus
|
||||
environment: str
|
||||
engine_mode: str
|
||||
services: list[AdminServiceHealth]
|
||||
|
||||
|
||||
class AdminEngineConfigResponse(BaseModel):
|
||||
engine_mode: str
|
||||
engine_url: str
|
||||
model: str
|
||||
updated_by: str | None = None
|
||||
updated_at: float | None = None
|
||||
durable: bool = False
|
||||
source: Literal["database", "runtime_cache", "runtime_default"] = "runtime_default"
|
||||
|
||||
|
||||
class AdminEngineConfigPatch(BaseModel):
|
||||
engine_mode: str | None = None
|
||||
engine_url: str | None = None
|
||||
model: str | None = None
|
||||
|
||||
|
||||
RoleName = Literal["learner", "teacher", "admin"]
|
||||
|
||||
|
||||
class AdminUserResponse(BaseModel):
|
||||
user_id: str
|
||||
email: str
|
||||
display_name: str
|
||||
role: RoleName
|
||||
cohort_ids: list[str]
|
||||
affiliation: str
|
||||
active_sessions: int
|
||||
created_at: float
|
||||
last_seen_at: float
|
||||
source: Literal["database", "server_session_registry"]
|
||||
|
||||
|
||||
class AdminUsersResponse(BaseModel):
|
||||
source: Literal["database", "server_session_registry"]
|
||||
durable: bool
|
||||
users: list[AdminUserResponse]
|
||||
|
||||
|
||||
class AdminUserPatch(BaseModel):
|
||||
display_name: str | None = Field(default=None, min_length=1, max_length=80)
|
||||
role: RoleName | None = None
|
||||
affiliation: str | None = Field(default=None, max_length=120)
|
||||
cohort_ids: list[str] | None = None
|
||||
|
||||
|
||||
class RuntimeHealthMetrics(BaseModel):
|
||||
engine_latency_ms: float | None = None
|
||||
db_pool_size: int = 0
|
||||
db_pool_idle: int = 0
|
||||
db_pool_max: int = 0
|
||||
active_users: int = 0
|
||||
active_auth_sessions: int = 0
|
||||
active_sessions: int = 0
|
||||
ended_sessions: int = 0
|
||||
pending_reviews: int = 0
|
||||
|
||||
|
||||
def _clamp01(value: float) -> float:
|
||||
return round(max(0.0, min(1.0, value)), 3)
|
||||
|
||||
|
||||
def _pool_load(metrics: RuntimeHealthMetrics) -> float:
|
||||
if metrics.db_pool_max <= 0:
|
||||
return 0.0
|
||||
busy = max(0, metrics.db_pool_size - metrics.db_pool_idle)
|
||||
return _clamp01(busy / metrics.db_pool_max)
|
||||
|
||||
|
||||
def _workload_load(count: int, expected_capacity: int) -> float:
|
||||
if expected_capacity <= 0:
|
||||
return 0.0
|
||||
return _clamp01(count / expected_capacity)
|
||||
|
||||
|
||||
async def _runtime_health_metrics(*, db_ok: bool) -> RuntimeHealthMetrics:
|
||||
metrics = RuntimeHealthMetrics()
|
||||
|
||||
try:
|
||||
pool = get_pool()
|
||||
metrics.db_pool_size = int(pool.get_size())
|
||||
metrics.db_pool_idle = int(pool.get_idle_size())
|
||||
metrics.db_pool_max = int(pool.get_max_size())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not db_ok:
|
||||
return metrics
|
||||
|
||||
try:
|
||||
async with acquire(role="admin") as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM app.app_user WHERE is_active) AS active_users,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM app.auth_session
|
||||
WHERE revoked_at IS NULL AND expires_at > now()
|
||||
) AS active_auth_sessions,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM app.sessions
|
||||
WHERE ended_at IS NULL
|
||||
) AS active_sessions,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM app.sessions
|
||||
WHERE ended_at IS NOT NULL
|
||||
) AS ended_sessions,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM app.sessions s
|
||||
LEFT JOIN app.session_summary ss ON ss.session_id = s.id
|
||||
WHERE s.ended_at IS NOT NULL AND ss.session_id IS NULL
|
||||
) AS pending_reviews
|
||||
"""
|
||||
)
|
||||
if row is not None:
|
||||
metrics.active_users = int(row["active_users"] or 0)
|
||||
metrics.active_auth_sessions = int(row["active_auth_sessions"] or 0)
|
||||
metrics.active_sessions = int(row["active_sessions"] or 0)
|
||||
metrics.ended_sessions = int(row["ended_sessions"] or 0)
|
||||
metrics.pending_reviews = int(row["pending_reviews"] or 0)
|
||||
except Exception:
|
||||
return metrics
|
||||
|
||||
return metrics
|
||||
|
||||
|
||||
class AdminUserCreate(BaseModel):
|
||||
email: str = Field(..., min_length=3, max_length=254)
|
||||
display_name: str = Field(..., min_length=1, max_length=80)
|
||||
role: RoleName = "learner"
|
||||
affiliation: str | None = Field(default=None, max_length=120)
|
||||
cohort_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AdminUserDeleteResponse(BaseModel):
|
||||
ok: bool
|
||||
user_id: str
|
||||
|
||||
|
||||
_ENGINE_CONFIG: AdminEngineConfigResponse | None = None
|
||||
ENGINE_MODES = {"claude_api", "claude_cli", "openai", "solar"}
|
||||
ENGINE_MODE_ALIASES = {"messages_api": "claude_api"}
|
||||
|
||||
|
||||
def _normalize_email(value: str) -> str:
|
||||
email = value.strip().lower()
|
||||
if "@" not in email:
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail="email is invalid")
|
||||
local, domain = email.rsplit("@", 1)
|
||||
if not local or not domain:
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail="email is invalid")
|
||||
allowed = {item.strip().lower().lstrip("@") for item in settings.auth_allowed_email_domains if item.strip()}
|
||||
if domain not in allowed:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="email domain is not allowed")
|
||||
return email
|
||||
|
||||
|
||||
def _default_engine_config() -> AdminEngineConfigResponse:
|
||||
return AdminEngineConfigResponse(
|
||||
engine_mode=settings.engine_mode,
|
||||
engine_url=settings.engine_url,
|
||||
model="gateway-default",
|
||||
durable=False,
|
||||
source="runtime_default",
|
||||
)
|
||||
|
||||
|
||||
def _normalize_engine_mode(value: str) -> str:
|
||||
mode = ENGINE_MODE_ALIASES.get(value.strip(), value.strip())
|
||||
if mode not in ENGINE_MODES:
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"unsupported engine mode {value}",
|
||||
)
|
||||
return mode
|
||||
|
||||
|
||||
def _normalize_engine_url(value: str) -> str:
|
||||
url = value.strip().rstrip("/")
|
||||
if not (url.startswith("http://") or url.startswith("https://")):
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="engine_url must start with http:// or https://",
|
||||
)
|
||||
return url
|
||||
|
||||
|
||||
def _updated_at_ts(value: datetime | None) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
return value.timestamp()
|
||||
|
||||
|
||||
def _engine_config_from_row(row) -> AdminEngineConfigResponse:
|
||||
return AdminEngineConfigResponse(
|
||||
engine_mode=_normalize_engine_mode(row["engine_mode"]),
|
||||
engine_url=_normalize_engine_url(row["engine_url"]),
|
||||
model=row["model"],
|
||||
updated_by=row["updated_by"],
|
||||
updated_at=_updated_at_ts(row["updated_at"]),
|
||||
durable=True,
|
||||
source="database",
|
||||
)
|
||||
|
||||
|
||||
async def _current_engine_config() -> AdminEngineConfigResponse:
|
||||
if _ENGINE_CONFIG is not None:
|
||||
return _ENGINE_CONFIG
|
||||
try:
|
||||
pool = get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT engine_mode, engine_url, model, updated_by, updated_at
|
||||
FROM app.admin_engine_config
|
||||
WHERE id = TRUE
|
||||
"""
|
||||
)
|
||||
if row is not None:
|
||||
return _engine_config_from_row(row)
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("admin engine config")
|
||||
require_runtime_fallback_allowed("admin engine config")
|
||||
return _default_engine_config()
|
||||
|
||||
|
||||
async def apply_engine_config_from_store() -> AdminEngineConfigResponse:
|
||||
"""Load admin engine settings and apply them to the live engine client."""
|
||||
config = await _current_engine_config()
|
||||
await engine_client.configure(
|
||||
base_url=config.engine_url,
|
||||
engine_mode=config.engine_mode,
|
||||
default_model=config.model,
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def _overall_status(services: list[AdminServiceHealth]) -> HealthStatus:
|
||||
if any(s.status == "down" for s in services):
|
||||
return "down"
|
||||
if any(s.status == "degraded" for s in services):
|
||||
return "degraded"
|
||||
return "ok"
|
||||
|
||||
|
||||
def _engine_unavailable_detail(detail: str) -> str:
|
||||
if detail.lstrip().startswith("{") and '"ok":false' in detail:
|
||||
return "Engine readiness failed"
|
||||
return detail
|
||||
|
||||
|
||||
async def _admin_user_response(user, *, durable: bool) -> AdminUserResponse:
|
||||
return AdminUserResponse(
|
||||
user_id=user.user_id,
|
||||
email=user.email,
|
||||
display_name=user.display_name,
|
||||
role=user.role,
|
||||
cohort_ids=user.cohort_ids,
|
||||
affiliation=user.affiliation,
|
||||
active_sessions=await active_session_count(user.user_id),
|
||||
created_at=user.created_at,
|
||||
last_seen_at=user.last_seen_at,
|
||||
source="database" if durable else "server_session_registry",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/health", response_model=AdminHealthResponse)
|
||||
async def admin_health(principal: AdminPrincipal) -> AdminHealthResponse:
|
||||
"""Return operational health from live backend checks."""
|
||||
current_engine = await _current_engine_config()
|
||||
db_ok = await healthcheck()
|
||||
engine_started = time.perf_counter()
|
||||
engine = await engine_client.health_detail()
|
||||
engine_ok = bool(engine.get("ok"))
|
||||
engine_detail = _engine_unavailable_detail(
|
||||
str(engine.get("detail") or "engine readiness unavailable")
|
||||
)
|
||||
engine_latency_ms = (time.perf_counter() - engine_started) * 1000
|
||||
voice_ok = voice_service.is_available()
|
||||
metrics = await _runtime_health_metrics(db_ok=db_ok)
|
||||
metrics.engine_latency_ms = engine_latency_ms if engine_ok else None
|
||||
pool_load = _pool_load(metrics)
|
||||
session_load = _workload_load(metrics.active_sessions, 50)
|
||||
review_load = _workload_load(metrics.pending_reviews, 50)
|
||||
runtime_fallback_is_enabled = settings.environment == "dev"
|
||||
db_status: HealthStatus = "ok" if db_ok else ("degraded" if runtime_fallback_is_enabled else "down")
|
||||
db_detail = (
|
||||
"사용자, 세션, 리뷰 저장"
|
||||
if db_ok
|
||||
else (
|
||||
"DB 연결 전까지 비영구 개발 런타임 기록 사용"
|
||||
if runtime_fallback_is_enabled
|
||||
else "DB 저장소에 연결할 수 없습니다"
|
||||
)
|
||||
)
|
||||
db_metric = (
|
||||
f"풀 {max(0, metrics.db_pool_size - metrics.db_pool_idle)}/{metrics.db_pool_max}"
|
||||
if db_ok
|
||||
else ("비영구 런타임 기록" if runtime_fallback_is_enabled else "저장소 중단")
|
||||
)
|
||||
|
||||
services = [
|
||||
AdminServiceHealth(
|
||||
key="engine",
|
||||
name="응답 생성",
|
||||
status="ok" if engine_ok else "down",
|
||||
detail="AI 엔진 생성 준비 완료" if engine_ok else engine_detail,
|
||||
metric=f"{engine_latency_ms:.0f}ms" if engine_ok else "로그인/설정 필요",
|
||||
load=_clamp01(engine_latency_ms / 1500) if engine_ok else 0.0,
|
||||
),
|
||||
AdminServiceHealth(
|
||||
key="db",
|
||||
name="영구 저장소",
|
||||
status=db_status,
|
||||
detail=db_detail,
|
||||
metric=db_metric,
|
||||
load=max(pool_load, session_load) if db_ok else 0.0,
|
||||
),
|
||||
AdminServiceHealth(
|
||||
key="voice",
|
||||
name="음성 입력",
|
||||
status="ok" if voice_ok else "degraded",
|
||||
detail="음성 입력과 재생",
|
||||
metric="OpenAI 연결" if voice_ok else "설정 필요",
|
||||
load=0.05 if voice_ok else 0.0,
|
||||
),
|
||||
AdminServiceHealth(
|
||||
key="evaluation",
|
||||
name="리뷰 생성",
|
||||
status="ok" if engine_ok else "degraded",
|
||||
detail="회기 종료 후 피드백 생성",
|
||||
metric=f"대기 {metrics.pending_reviews}건",
|
||||
load=review_load if engine_ok else 0.0,
|
||||
),
|
||||
AdminServiceHealth(
|
||||
key="kb",
|
||||
name="지식 검색",
|
||||
status="ok" if db_ok else "degraded",
|
||||
detail=f"검색 기준값 {rag.CRAG_TOP1_THRESHOLD}",
|
||||
metric=(
|
||||
f"활성 세션 {metrics.active_sessions}건"
|
||||
if db_ok
|
||||
else "대기 중"
|
||||
),
|
||||
load=max(pool_load, session_load) if db_ok else 0.0,
|
||||
),
|
||||
]
|
||||
|
||||
return AdminHealthResponse(
|
||||
status=_overall_status(services),
|
||||
environment=settings.environment,
|
||||
engine_mode=current_engine.engine_mode,
|
||||
services=services,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/engine-config", response_model=AdminEngineConfigResponse)
|
||||
async def get_engine_config(principal: AdminPrincipal) -> AdminEngineConfigResponse:
|
||||
"""Return the current admin-managed engine settings."""
|
||||
return await _current_engine_config()
|
||||
|
||||
|
||||
@router.patch("/engine-config", response_model=AdminEngineConfigResponse)
|
||||
async def patch_engine_config(
|
||||
body: AdminEngineConfigPatch,
|
||||
principal: AdminPrincipal,
|
||||
) -> AdminEngineConfigResponse:
|
||||
"""Persist engine settings for administrators."""
|
||||
global _ENGINE_CONFIG
|
||||
|
||||
current = await _current_engine_config()
|
||||
next_mode = _normalize_engine_mode(body.engine_mode or current.engine_mode)
|
||||
next_url = _normalize_engine_url(body.engine_url or current.engine_url)
|
||||
next_config = AdminEngineConfigResponse(
|
||||
engine_mode=next_mode,
|
||||
engine_url=next_url,
|
||||
model=(body.model or current.model).strip(),
|
||||
updated_by=principal.email,
|
||||
updated_at=time.time(),
|
||||
durable=False,
|
||||
source="runtime_cache",
|
||||
)
|
||||
try:
|
||||
pool = get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO app.admin_engine_config (
|
||||
id, engine_mode, engine_url, model, updated_by, updated_at
|
||||
)
|
||||
VALUES (TRUE, $1, $2, $3, $4, now())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
engine_mode = EXCLUDED.engine_mode,
|
||||
engine_url = EXCLUDED.engine_url,
|
||||
model = EXCLUDED.model,
|
||||
updated_by = EXCLUDED.updated_by,
|
||||
updated_at = now()
|
||||
RETURNING engine_mode, engine_url, model, updated_by, updated_at
|
||||
""",
|
||||
next_config.engine_mode,
|
||||
next_config.engine_url,
|
||||
next_config.model,
|
||||
principal.email,
|
||||
)
|
||||
next_config = _engine_config_from_row(row)
|
||||
except Exception as exc:
|
||||
if settings.environment != "dev":
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="engine config persistence unavailable",
|
||||
) from exc
|
||||
_ENGINE_CONFIG = next_config
|
||||
await engine_client.configure(
|
||||
base_url=next_config.engine_url,
|
||||
engine_mode=next_config.engine_mode,
|
||||
default_model=next_config.model,
|
||||
)
|
||||
return next_config
|
||||
|
||||
|
||||
@router.get("/users", response_model=AdminUsersResponse)
|
||||
async def list_users(principal: AdminPrincipal) -> AdminUsersResponse:
|
||||
"""Return users observed by the server-side auth/session boundary."""
|
||||
users, durable = await list_managed_users()
|
||||
if not durable:
|
||||
require_runtime_fallback_allowed("admin user list")
|
||||
return AdminUsersResponse(
|
||||
source="database" if durable else "server_session_registry",
|
||||
durable=durable,
|
||||
users=[await _admin_user_response(user, durable=durable) for user in users],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/users", response_model=AdminUserResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(
|
||||
body: AdminUserCreate,
|
||||
principal: AdminPrincipal,
|
||||
) -> AdminUserResponse:
|
||||
"""Create or reactivate a managed user without requiring that user to log in first."""
|
||||
user = await upsert_managed_user(
|
||||
email=_normalize_email(body.email),
|
||||
display_name=body.display_name,
|
||||
role=body.role,
|
||||
affiliation=body.affiliation,
|
||||
cohort_ids=body.cohort_ids,
|
||||
reactivate=True,
|
||||
)
|
||||
users, durable = await list_managed_users()
|
||||
if not durable:
|
||||
require_runtime_fallback_allowed("admin user create")
|
||||
return await _admin_user_response(user, durable=durable)
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}", response_model=AdminUserResponse)
|
||||
async def patch_user(
|
||||
user_id: str,
|
||||
body: AdminUserPatch,
|
||||
principal: AdminPrincipal,
|
||||
) -> AdminUserResponse:
|
||||
"""Update a server-known user's role/profile for the current API process."""
|
||||
next_user = await update_managed_user(
|
||||
user_id,
|
||||
display_name=body.display_name,
|
||||
role=body.role,
|
||||
affiliation=body.affiliation,
|
||||
cohort_ids=body.cohort_ids,
|
||||
)
|
||||
if next_user is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found")
|
||||
users, durable = await list_managed_users()
|
||||
if not durable:
|
||||
require_runtime_fallback_allowed("admin user update")
|
||||
return await _admin_user_response(next_user, durable=durable)
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}", response_model=AdminUserDeleteResponse)
|
||||
async def delete_user(
|
||||
user_id: str,
|
||||
principal: AdminPrincipal,
|
||||
) -> AdminUserDeleteResponse:
|
||||
"""Deactivate a managed user and revoke any active browser sessions."""
|
||||
if user_id == principal.user_id:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="cannot deactivate current admin")
|
||||
if not await deactivate_managed_user(user_id):
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found")
|
||||
return AdminUserDeleteResponse(ok=True, user_id=user_id)
|
||||
|
|
@ -1,84 +1,452 @@
|
|||
"""인증 라우트 — BFF OAuth 2.1 Auth Code + PKCE(S256) 스텁.
|
||||
"""BFF authentication routes.
|
||||
|
||||
마스터플랜 §5: BFF + OAuth 2.1, 토큰은 서버(Redis)에만, 브라우저엔 __Host- HttpOnly 쿠키.
|
||||
미성년 사례데이터 + 상담 민감정보 -> 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)
|
||||
|
|
|
|||
|
|
@ -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 {},
|
||||
)
|
||||
|
|
|
|||
66
apps/api/app/routes/personas.py
Normal file
66
apps/api/app/routes/personas.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"""Persona catalog routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Response, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..deps import CurrentPrincipal
|
||||
from ..persona_repository import CatalogPersona, list_catalog_personas
|
||||
|
||||
router = APIRouter(prefix="/personas", tags=["personas"])
|
||||
|
||||
|
||||
class PersonaSummary(BaseModel):
|
||||
code: str
|
||||
display_name: str
|
||||
difficulty: str
|
||||
theory_target: list[str]
|
||||
demographics: dict[str, Any]
|
||||
presenting_summary: str
|
||||
voice_preset: str | None = None
|
||||
source: str = "database"
|
||||
degraded: bool = False
|
||||
|
||||
|
||||
def _first_text_value(data: dict[str, Any]) -> str:
|
||||
for value in data.values():
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _summary(entry: CatalogPersona) -> PersonaSummary:
|
||||
card = entry.card
|
||||
return PersonaSummary(
|
||||
code=card.code,
|
||||
display_name=card.display_name,
|
||||
difficulty=card.difficulty,
|
||||
theory_target=card.theory_target,
|
||||
demographics=card.demographics,
|
||||
presenting_summary=_first_text_value(card.presenting),
|
||||
source=entry.source,
|
||||
degraded=entry.degraded,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[PersonaSummary])
|
||||
async def list_personas(response: Response, _principal: CurrentPrincipal) -> list[PersonaSummary]:
|
||||
"""Return latest approved personas from app.persona_card."""
|
||||
try:
|
||||
personas = await list_catalog_personas()
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="persona catalog database unavailable",
|
||||
) from exc
|
||||
|
||||
if any(entry.degraded for entry in personas):
|
||||
response.headers["X-Vignette-Degraded"] = "true"
|
||||
response.headers["X-Vignette-Catalog-Source"] = "seed_fallback"
|
||||
else:
|
||||
response.headers["X-Vignette-Catalog-Source"] = "database"
|
||||
|
||||
return [_summary(entry) for entry in personas]
|
||||
File diff suppressed because it is too large
Load diff
103
apps/api/app/routes/teacher.py
Normal file
103
apps/api/app/routes/teacher.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""Teacher dashboard routes backed by real server session state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .. import session_persistence
|
||||
from ..deps import Principal, Role, require_role
|
||||
from ..runtime_policy import require_runtime_fallback_allowed
|
||||
from ..store import InProcSession, store
|
||||
|
||||
router = APIRouter(prefix="/teacher", tags=["teacher"])
|
||||
|
||||
TeacherPrincipal = Annotated[Principal, Depends(require_role(Role.TEACHER, Role.ADMIN))]
|
||||
|
||||
|
||||
class TeacherSessionSummary(BaseModel):
|
||||
session_id: str
|
||||
learner_id: str
|
||||
learner_label: str
|
||||
persona_code: str
|
||||
persona_name: str
|
||||
session_no: int
|
||||
status: str
|
||||
stage: str
|
||||
turn_count: int
|
||||
learner_turn_count: int
|
||||
client_turn_count: int
|
||||
started_at: str
|
||||
ended_at: str | None = None
|
||||
|
||||
|
||||
class TeacherDashboardResponse(BaseModel):
|
||||
source: str = "in_memory"
|
||||
cohort_label: str = "현재 학습 기록"
|
||||
total_learners: int
|
||||
active_sessions: int
|
||||
ended_sessions: int
|
||||
pending_reviews: list[TeacherSessionSummary] = Field(default_factory=list)
|
||||
recent_sessions: list[TeacherSessionSummary] = Field(default_factory=list)
|
||||
message: str
|
||||
|
||||
|
||||
def _iso(ts: float | None) -> str | None:
|
||||
if ts is None:
|
||||
return None
|
||||
return datetime.fromtimestamp(ts).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _learner_label(learner_id: str) -> str:
|
||||
suffix = learner_id[-6:] if len(learner_id) > 6 else learner_id
|
||||
return f"학습자 {suffix}"
|
||||
|
||||
|
||||
def _summary(sess: InProcSession) -> TeacherSessionSummary:
|
||||
learner_turns = sum(1 for turn in sess.turns if turn.speaker == "counselor")
|
||||
client_turns = sum(1 for turn in sess.turns if turn.speaker == "client")
|
||||
return TeacherSessionSummary(
|
||||
session_id=sess.session_id,
|
||||
learner_id=sess.learner_id,
|
||||
learner_label=_learner_label(sess.learner_id),
|
||||
persona_code=sess.persona_code,
|
||||
persona_name=sess.persona.display_name,
|
||||
session_no=sess.session_no,
|
||||
status="ended" if sess.ended else "active",
|
||||
stage=sess.state.stage.value,
|
||||
turn_count=len(sess.turns),
|
||||
learner_turn_count=learner_turns,
|
||||
client_turn_count=client_turns,
|
||||
started_at=_iso(sess.created_at) or "",
|
||||
ended_at=_iso(sess.ended_at),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/dashboard", response_model=TeacherDashboardResponse)
|
||||
async def teacher_dashboard(principal: TeacherPrincipal) -> TeacherDashboardResponse:
|
||||
"""Return teacher-visible dashboard data from real sessions only."""
|
||||
sessions, durable = await session_persistence.list_sessions(principal)
|
||||
if not durable:
|
||||
require_runtime_fallback_allowed("teacher dashboard")
|
||||
sessions = sorted(store.list(), key=lambda sess: sess.created_at, reverse=True)
|
||||
summaries = [_summary(sess) for sess in sessions]
|
||||
pending_reviews = [item for item in summaries if item.status == "ended"]
|
||||
learners = {sess.learner_id for sess in sessions}
|
||||
|
||||
if sessions:
|
||||
message = "현재 기록된 실제 학습 세션만 표시합니다."
|
||||
else:
|
||||
message = "아직 표시할 실제 학습자 세션이 없습니다."
|
||||
|
||||
return TeacherDashboardResponse(
|
||||
source="database" if durable else "runtime",
|
||||
total_learners=len(learners),
|
||||
active_sessions=sum(1 for sess in sessions if not sess.ended),
|
||||
ended_sessions=sum(1 for sess in sessions if sess.ended),
|
||||
pending_reviews=pending_reviews[:20],
|
||||
recent_sessions=summaries[:20],
|
||||
message=message,
|
||||
)
|
||||
280
apps/api/app/routes/users.py
Normal file
280
apps/api/app/routes/users.py
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
"""Current-user profile, preference, and voice-preset routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..auth_sessions import DEFAULT_AFFILIATION, get_managed_user, update_managed_user
|
||||
from ..db import get_pool
|
||||
from ..deps import CurrentPrincipal
|
||||
from ..runtime_policy import require_runtime_fallback_allowed
|
||||
from ..services.voice import PRESET_RATE, PRESET_TO_OPENAI_VOICE
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
class UserProfileResponse(BaseModel):
|
||||
user_id: str
|
||||
email: str
|
||||
display_name: str
|
||||
role: str
|
||||
cohort_ids: list[str]
|
||||
affiliation: str
|
||||
|
||||
|
||||
class UserProfilePatch(BaseModel):
|
||||
display_name: str | None = Field(default=None, min_length=1, max_length=80)
|
||||
affiliation: str | None = Field(default=None, max_length=120)
|
||||
|
||||
|
||||
class NotificationPreferences(BaseModel):
|
||||
session_done: bool = True
|
||||
safety_signal: bool = True
|
||||
learner_progress: bool = False
|
||||
product_news: bool = False
|
||||
|
||||
|
||||
class UserPreferencesResponse(BaseModel):
|
||||
theme: str = "system"
|
||||
voice_preset_id: str = "soft-young-fem"
|
||||
voice_rate: float = 1.0
|
||||
notifications: NotificationPreferences = Field(default_factory=NotificationPreferences)
|
||||
|
||||
|
||||
class UserPreferencesPatch(BaseModel):
|
||||
theme: str | None = None
|
||||
voice_preset_id: str | None = None
|
||||
voice_rate: float | None = Field(default=None, ge=0.8, le=1.2)
|
||||
notifications: NotificationPreferences | None = None
|
||||
|
||||
|
||||
class VoicePresetResponse(BaseModel):
|
||||
id: str
|
||||
voice_id: str
|
||||
name: str
|
||||
desc: str
|
||||
persona_hint: str
|
||||
|
||||
|
||||
_preferences: dict[str, UserPreferencesResponse] = {}
|
||||
|
||||
VOICE_PRESET_META = {
|
||||
"soft-young-fem": {
|
||||
"name": "서린",
|
||||
"desc": "부드럽고 낮은 긴장감",
|
||||
"persona_hint": "청소년 내담자",
|
||||
},
|
||||
"calm-adult-male": {
|
||||
"name": "민재",
|
||||
"desc": "차분하고 안정적인 성인 남성",
|
||||
"persona_hint": "성인 남성",
|
||||
},
|
||||
"warm-adult-fem": {
|
||||
"name": "지영",
|
||||
"desc": "따뜻하지만 지친 성인 여성",
|
||||
"persona_hint": "성인 여성",
|
||||
},
|
||||
"neutral": {
|
||||
"name": "기본",
|
||||
"desc": "중립적인 기본 음성",
|
||||
"persona_hint": "범용",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _voice_preset_ids() -> set[str]:
|
||||
return set(PRESET_TO_OPENAI_VOICE.keys())
|
||||
|
||||
|
||||
def _normalize_voice_preset(value: str | None) -> str:
|
||||
if value in _voice_preset_ids():
|
||||
return str(value)
|
||||
return "soft-young-fem"
|
||||
|
||||
|
||||
def _assert_voice_preset(value: str | None) -> None:
|
||||
if value is None:
|
||||
return
|
||||
if value not in _voice_preset_ids():
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"unsupported voice preset {value}",
|
||||
)
|
||||
|
||||
|
||||
def _voice_presets() -> list[VoicePresetResponse]:
|
||||
presets: list[VoicePresetResponse] = []
|
||||
for preset_id in PRESET_TO_OPENAI_VOICE.keys():
|
||||
meta = VOICE_PRESET_META.get(
|
||||
preset_id,
|
||||
{
|
||||
"name": preset_id,
|
||||
"desc": f"rate {PRESET_RATE.get(preset_id, 1.0):.2f}",
|
||||
"persona_hint": "사용자 지정",
|
||||
},
|
||||
)
|
||||
presets.append(
|
||||
VoicePresetResponse(
|
||||
id=preset_id,
|
||||
voice_id=preset_id,
|
||||
name=meta["name"],
|
||||
desc=meta["desc"],
|
||||
persona_hint=meta["persona_hint"],
|
||||
)
|
||||
)
|
||||
return presets
|
||||
|
||||
|
||||
def _preferences_from_row(row) -> UserPreferencesResponse:
|
||||
return UserPreferencesResponse(
|
||||
theme=row["theme"],
|
||||
voice_preset_id=_normalize_voice_preset(row["voice_preset_id"]),
|
||||
voice_rate=float(row["voice_rate"]),
|
||||
notifications=NotificationPreferences.model_validate(row["notifications"] or {}),
|
||||
)
|
||||
|
||||
|
||||
async def _profile_for(principal: CurrentPrincipal) -> UserProfileResponse:
|
||||
managed = await get_managed_user(principal.user_id)
|
||||
return UserProfileResponse(
|
||||
user_id=principal.user_id,
|
||||
email=principal.email,
|
||||
display_name=(
|
||||
(managed.display_name if managed else "")
|
||||
or principal.display_name
|
||||
or principal.email
|
||||
),
|
||||
role=(managed.role if managed else principal.role.value),
|
||||
cohort_ids=(managed.cohort_ids if managed else principal.cohort_ids),
|
||||
affiliation=(managed.affiliation if managed else DEFAULT_AFFILIATION),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserProfileResponse)
|
||||
async def get_me(principal: CurrentPrincipal) -> UserProfileResponse:
|
||||
return await _profile_for(principal)
|
||||
|
||||
|
||||
@router.patch("/me", response_model=UserProfileResponse)
|
||||
async def patch_me(body: UserProfilePatch, principal: CurrentPrincipal) -> UserProfileResponse:
|
||||
profile = await _profile_for(principal)
|
||||
await update_managed_user(
|
||||
principal.user_id,
|
||||
display_name=body.display_name if body.display_name is not None else profile.display_name,
|
||||
affiliation=body.affiliation if body.affiliation is not None else profile.affiliation,
|
||||
)
|
||||
return await _profile_for(principal)
|
||||
|
||||
|
||||
@router.get("/me/preferences", response_model=UserPreferencesResponse)
|
||||
async def get_preferences(principal: CurrentPrincipal) -> UserPreferencesResponse:
|
||||
try:
|
||||
pool = get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO app.user_preferences (user_id, notifications)
|
||||
VALUES ($1::uuid, $2::jsonb)
|
||||
ON CONFLICT (user_id) DO NOTHING
|
||||
""",
|
||||
principal.user_id,
|
||||
NotificationPreferences().model_dump(),
|
||||
)
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT theme, voice_preset_id, voice_rate, notifications
|
||||
FROM app.user_preferences
|
||||
WHERE user_id = $1::uuid
|
||||
""",
|
||||
principal.user_id,
|
||||
)
|
||||
if row is not None:
|
||||
return _preferences_from_row(row)
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("user preferences")
|
||||
return _preferences.setdefault(principal.user_id, UserPreferencesResponse())
|
||||
|
||||
|
||||
@router.patch("/me/preferences", response_model=UserPreferencesResponse)
|
||||
async def patch_preferences(
|
||||
body: UserPreferencesPatch,
|
||||
principal: CurrentPrincipal,
|
||||
) -> UserPreferencesResponse:
|
||||
_assert_voice_preset(body.voice_preset_id)
|
||||
try:
|
||||
pool = get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO app.user_preferences (user_id, notifications)
|
||||
VALUES ($1::uuid, $2::jsonb)
|
||||
ON CONFLICT (user_id) DO NOTHING
|
||||
""",
|
||||
principal.user_id,
|
||||
NotificationPreferences().model_dump(),
|
||||
)
|
||||
current = await conn.fetchrow(
|
||||
"""
|
||||
SELECT theme, voice_preset_id, voice_rate, notifications
|
||||
FROM app.user_preferences
|
||||
WHERE user_id = $1::uuid
|
||||
""",
|
||||
principal.user_id,
|
||||
)
|
||||
current_prefs = _preferences_from_row(current)
|
||||
next_prefs = UserPreferencesResponse(
|
||||
theme=body.theme if body.theme is not None else current_prefs.theme,
|
||||
voice_preset_id=(
|
||||
body.voice_preset_id
|
||||
if body.voice_preset_id is not None
|
||||
else current_prefs.voice_preset_id
|
||||
),
|
||||
voice_rate=body.voice_rate if body.voice_rate is not None else current_prefs.voice_rate,
|
||||
notifications=(
|
||||
body.notifications
|
||||
if body.notifications is not None
|
||||
else current_prefs.notifications
|
||||
),
|
||||
)
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
UPDATE app.user_preferences SET
|
||||
theme = $2,
|
||||
voice_preset_id = $3,
|
||||
voice_rate = $4,
|
||||
notifications = $5::jsonb,
|
||||
updated_at = now()
|
||||
WHERE user_id = $1::uuid
|
||||
RETURNING theme, voice_preset_id, voice_rate, notifications
|
||||
""",
|
||||
principal.user_id,
|
||||
next_prefs.theme,
|
||||
next_prefs.voice_preset_id,
|
||||
next_prefs.voice_rate,
|
||||
next_prefs.notifications.model_dump(),
|
||||
)
|
||||
return _preferences_from_row(row)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("user preferences")
|
||||
|
||||
current = _preferences.setdefault(principal.user_id, UserPreferencesResponse())
|
||||
data = current.model_dump()
|
||||
if body.theme is not None:
|
||||
data["theme"] = body.theme
|
||||
if body.voice_preset_id is not None:
|
||||
data["voice_preset_id"] = body.voice_preset_id
|
||||
if body.voice_rate is not None:
|
||||
data["voice_rate"] = body.voice_rate
|
||||
if body.notifications is not None:
|
||||
data["notifications"] = body.notifications.model_dump()
|
||||
next_prefs = UserPreferencesResponse.model_validate(data)
|
||||
_preferences[principal.user_id] = next_prefs
|
||||
return next_prefs
|
||||
|
||||
|
||||
@router.get("/me/voice-presets", response_model=list[VoicePresetResponse])
|
||||
async def get_voice_presets(principal: CurrentPrincipal) -> list[VoicePresetResponse]:
|
||||
return _voice_presets()
|
||||
|
|
@ -1,22 +1,14 @@
|
|||
"""음성 라우트 — OpenAI STT/TTS 캐스케이드 + WSS 실시간 턴테이킹.
|
||||
"""Voice routes for the OpenAI STT/TTS cascade over WebSocket.
|
||||
|
||||
한신대 요구 '음성 필수'. 학습자가 마이크로 말하면 → STT → orchestrator 상담 1턴 →
|
||||
내담자 텍스트 → TTS 오디오 + 립싱크 힌트(설계 §4.3 RMS)를 역방향으로 흘린다.
|
||||
Client sends JSON controls plus binary audio chunks:
|
||||
audio_start -> binary audio chunks -> audio_end
|
||||
|
||||
캐스케이드(설계 §5.2 음성 오브 4상태 listening→thinking→speaking→idle):
|
||||
[클라] 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
|
||||
|
|
|
|||
33
apps/api/app/runtime_policy.py
Normal file
33
apps/api/app/runtime_policy.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"""Runtime fallback policy shared by auth/session routes.
|
||||
|
||||
The in-process stores exist only to keep local development usable when Docker or
|
||||
NAS PostgreSQL is offline. Staging/prod must fail loudly instead of becoming a
|
||||
second source of truth.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import NoReturn
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from .config import settings
|
||||
|
||||
|
||||
def runtime_fallback_allowed() -> bool:
|
||||
return settings.environment == "dev"
|
||||
|
||||
|
||||
def raise_runtime_fallback_disabled(feature: str) -> NoReturn:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=(
|
||||
f"{feature} persistence unavailable; runtime fallback is disabled "
|
||||
f"in {settings.environment}"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def require_runtime_fallback_allowed(feature: str) -> None:
|
||||
if not runtime_fallback_allowed():
|
||||
raise_runtime_fallback_disabled(feature)
|
||||
|
|
@ -301,9 +301,14 @@ def _client_state_candidates_block() -> str:
|
|||
return f"[내담자 상태 후보 — code:라벨]\n- {items}"
|
||||
|
||||
|
||||
# ─ 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"))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
# ── 수명주기 ──────────────────────────────────────────
|
||||
|
|
|
|||
656
apps/api/app/session_persistence.py
Normal file
656
apps/api/app/session_persistence.py
Normal file
|
|
@ -0,0 +1,656 @@
|
|||
"""DB-backed counseling session persistence with in-process fallback support."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Iterable
|
||||
|
||||
from .db import acquire, get_pool
|
||||
from .deps import Principal
|
||||
from .config import settings
|
||||
from .persona_repository import SEED_VERSION, card_from_row, seed_fallback_persona, seed_persona_id
|
||||
from .runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed
|
||||
from .services import memory, state_machine
|
||||
from .services.persona import PersonaCard
|
||||
from .store import InProcSession, TurnRecord
|
||||
|
||||
_EVALUATION_CACHE: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
_JOINED_CARD_COLUMNS = (
|
||||
"card_persona_id",
|
||||
"card_code",
|
||||
"card_version",
|
||||
"card_status",
|
||||
"card_display_name",
|
||||
"card_difficulty",
|
||||
"card_theory_target",
|
||||
"card_demographics",
|
||||
"card_presenting",
|
||||
"card_history",
|
||||
"card_big5",
|
||||
"card_resistance",
|
||||
"card_speech_style",
|
||||
"card_affect_baseline",
|
||||
"card_ccd",
|
||||
"card_dsm5_dimensional",
|
||||
"card_source_provenance",
|
||||
"card_is_synthetic",
|
||||
)
|
||||
|
||||
|
||||
def _ts(value: datetime | None) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
return value.timestamp()
|
||||
|
||||
|
||||
def _row_value(row, key: str):
|
||||
try:
|
||||
return row[key]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _card_from_joined_session_row(row) -> PersonaCard | None:
|
||||
if _row_value(row, "card_persona_id") is None:
|
||||
return None
|
||||
card_row = {
|
||||
key.removeprefix("card_"): _row_value(row, key)
|
||||
for key in _JOINED_CARD_COLUMNS
|
||||
}
|
||||
return card_from_row(card_row)
|
||||
|
||||
|
||||
def _stage(stage: object) -> str:
|
||||
return getattr(stage, "value", str(stage))
|
||||
|
||||
|
||||
def _state_from_row(row, card: PersonaCard) -> state_machine.SessionState:
|
||||
if row is None:
|
||||
return state_machine.init_state(
|
||||
base_resistance=card.base_resistance(),
|
||||
unlock_rate=card.unlock_rate(),
|
||||
decay_floor=card.decay_floor(),
|
||||
ideation_baseline=card.ideation_baseline(),
|
||||
)
|
||||
return state_machine.SessionState(
|
||||
stage=state_machine.Stage(row["stage"]),
|
||||
turn_seq=int(row["turn_seq"]),
|
||||
effective_openness=float(row["effective_openness"]),
|
||||
rapport_credit=float(row["rapport_credit"]),
|
||||
resistance=float(row["resistance"]),
|
||||
ideation_stage=int(row["ideation_stage"]),
|
||||
turns_in_stage=int(row["turns_in_stage"] or 0),
|
||||
affect_state=dict(row["affect_state"] or {}),
|
||||
)
|
||||
|
||||
|
||||
def _turn_from_row(row) -> TurnRecord:
|
||||
created_at = _ts(row["created_at"]) or time.time()
|
||||
return TurnRecord(
|
||||
turn_seq=int(row["seq"]),
|
||||
speaker=row["speaker"],
|
||||
stage=row["stage"],
|
||||
text=row["text"] or row["text_masked"] or "",
|
||||
text_masked=row["text_masked"] or row["text"] or "",
|
||||
created_at=created_at,
|
||||
)
|
||||
|
||||
|
||||
async def ensure_review_tables() -> None:
|
||||
"""Create runtime review/evaluation storage when the DB role allows it."""
|
||||
try:
|
||||
get_pool()
|
||||
async with acquire(role="admin") as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS app.session_evaluation (
|
||||
session_id UUID PRIMARY KEY REFERENCES app.sessions(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL CHECK (status IN ('ready','degraded','error')),
|
||||
source TEXT NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
stage TEXT NOT NULL,
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
"""
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
ALTER TABLE app.session_evaluation ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS p_session_evaluation_select ON app.session_evaluation;
|
||||
DROP POLICY IF EXISTS p_session_evaluation_insert ON app.session_evaluation;
|
||||
DROP POLICY IF EXISTS p_session_evaluation_update ON app.session_evaluation;
|
||||
DROP POLICY IF EXISTS p_session_evaluation_delete ON app.session_evaluation;
|
||||
|
||||
CREATE POLICY p_session_evaluation_select
|
||||
ON app.session_evaluation FOR SELECT USING (
|
||||
app.is_ai_context()
|
||||
OR app.current_role_name() IN ('admin','instructor')
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM app.sessions s
|
||||
WHERE s.id = app.session_evaluation.session_id
|
||||
AND s.learner_id = app.current_uid()
|
||||
)
|
||||
);
|
||||
CREATE POLICY p_session_evaluation_insert
|
||||
ON app.session_evaluation FOR INSERT WITH CHECK (
|
||||
app.is_ai_context()
|
||||
OR app.current_role_name() IN ('admin','instructor')
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM app.sessions s
|
||||
WHERE s.id = app.session_evaluation.session_id
|
||||
AND s.learner_id = app.current_uid()
|
||||
)
|
||||
);
|
||||
CREATE POLICY p_session_evaluation_update
|
||||
ON app.session_evaluation FOR UPDATE USING (
|
||||
app.is_ai_context()
|
||||
OR app.current_role_name() IN ('admin','instructor')
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM app.sessions s
|
||||
WHERE s.id = app.session_evaluation.session_id
|
||||
AND s.learner_id = app.current_uid()
|
||||
)
|
||||
) WITH CHECK (
|
||||
app.is_ai_context()
|
||||
OR app.current_role_name() IN ('admin','instructor')
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM app.sessions s
|
||||
WHERE s.id = app.session_evaluation.session_id
|
||||
AND s.learner_id = app.current_uid()
|
||||
)
|
||||
);
|
||||
CREATE POLICY p_session_evaluation_delete
|
||||
ON app.session_evaluation FOR DELETE USING (
|
||||
app.is_ai_context()
|
||||
OR app.current_role_name() IN ('admin','instructor')
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM app.sessions s
|
||||
WHERE s.id = app.session_evaluation.session_id
|
||||
AND s.learner_id = app.current_uid()
|
||||
)
|
||||
)
|
||||
"""
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
async def save_session_evaluation(
|
||||
*,
|
||||
session_id: str,
|
||||
learner_id: str,
|
||||
status: str,
|
||||
source: str,
|
||||
scope: str,
|
||||
stage: str,
|
||||
payload: dict[str, Any],
|
||||
error: str | None = None,
|
||||
) -> bool:
|
||||
record = {
|
||||
"status": status,
|
||||
"source": source,
|
||||
"scope": scope,
|
||||
"stage": stage,
|
||||
"payload": payload,
|
||||
"error": error,
|
||||
}
|
||||
if runtime_fallback_allowed():
|
||||
_EVALUATION_CACHE[session_id] = record
|
||||
try:
|
||||
get_pool()
|
||||
async with acquire(role="learner", user_id=learner_id) as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO app.session_evaluation (
|
||||
session_id, status, source, scope, stage, payload, error,
|
||||
created_at, updated_at
|
||||
)
|
||||
VALUES ($1::uuid, $2, $3, $4, $5, $6::jsonb, $7, now(), now())
|
||||
ON CONFLICT (session_id) DO UPDATE SET
|
||||
status = EXCLUDED.status,
|
||||
source = EXCLUDED.source,
|
||||
scope = EXCLUDED.scope,
|
||||
stage = EXCLUDED.stage,
|
||||
payload = EXCLUDED.payload,
|
||||
error = EXCLUDED.error,
|
||||
updated_at = now()
|
||||
""",
|
||||
session_id,
|
||||
status,
|
||||
source,
|
||||
scope,
|
||||
stage,
|
||||
payload,
|
||||
error,
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("session evaluation")
|
||||
return False
|
||||
|
||||
|
||||
async def load_session_evaluation(
|
||||
session_id: str,
|
||||
principal: Principal,
|
||||
) -> tuple[dict[str, Any] | None, bool]:
|
||||
try:
|
||||
get_pool()
|
||||
async with acquire(
|
||||
role=principal.role.value,
|
||||
user_id=principal.user_id,
|
||||
cohort_ids=principal.cohort_ids,
|
||||
) as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT status, source, scope, stage, payload, error, updated_at
|
||||
FROM app.session_evaluation
|
||||
WHERE session_id = $1::uuid
|
||||
""",
|
||||
session_id,
|
||||
)
|
||||
if row is None:
|
||||
return (
|
||||
_EVALUATION_CACHE.get(session_id) if runtime_fallback_allowed() else None
|
||||
), False
|
||||
return {
|
||||
"status": row["status"],
|
||||
"source": row["source"],
|
||||
"scope": row["scope"],
|
||||
"stage": row["stage"],
|
||||
"payload": dict(row["payload"] or {}),
|
||||
"error": row["error"],
|
||||
"updated_at": _ts(row["updated_at"]),
|
||||
}, True
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("session evaluation")
|
||||
return _EVALUATION_CACHE.get(session_id), False
|
||||
|
||||
|
||||
def _session_from_rows(row, state_row, turn_rows: Iterable) -> InProcSession | None:
|
||||
card = _card_from_joined_session_row(row)
|
||||
if card is None:
|
||||
if not settings.allow_seed_persona_fallback:
|
||||
return None
|
||||
persona_code = (row["persona_code"] or "").upper()
|
||||
legacy_entry = seed_fallback_persona(persona_code)
|
||||
if legacy_entry is None:
|
||||
return None
|
||||
card = legacy_entry.card
|
||||
persona_code = card.code
|
||||
|
||||
ended_at = _ts(row["ended_at"])
|
||||
started_at = _ts(row["started_at"]) or time.time()
|
||||
return InProcSession(
|
||||
session_id=str(row["id"]),
|
||||
case_id=str(row["runtime_case_id"] or row["case_id"] or row["id"]),
|
||||
learner_id=str(row["learner_id"]),
|
||||
persona_code=persona_code,
|
||||
theory_mode=row["theory_mode"] or "humanistic",
|
||||
persona=card,
|
||||
state=_state_from_row(state_row, card),
|
||||
session_no=int(row["session_no"] or 1),
|
||||
created_at=started_at,
|
||||
ended_at=ended_at,
|
||||
turns=[_turn_from_row(turn_row) for turn_row in turn_rows],
|
||||
ended=ended_at is not None,
|
||||
prev_rapport_credit=float(row["prev_rapport_credit"] or 0.0),
|
||||
)
|
||||
|
||||
|
||||
async def _upsert_state(conn, session_id: str, state: state_machine.SessionState) -> None:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO app.session_state (
|
||||
session_id, stage, turn_seq, effective_openness, rapport_credit,
|
||||
resistance, ideation_stage, turns_in_stage, affect_state, updated_at
|
||||
)
|
||||
VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, now())
|
||||
ON CONFLICT (session_id) DO UPDATE SET
|
||||
stage = EXCLUDED.stage,
|
||||
turn_seq = EXCLUDED.turn_seq,
|
||||
effective_openness = EXCLUDED.effective_openness,
|
||||
rapport_credit = EXCLUDED.rapport_credit,
|
||||
resistance = EXCLUDED.resistance,
|
||||
ideation_stage = EXCLUDED.ideation_stage,
|
||||
turns_in_stage = EXCLUDED.turns_in_stage,
|
||||
affect_state = EXCLUDED.affect_state,
|
||||
updated_at = now()
|
||||
""",
|
||||
session_id,
|
||||
_stage(state.stage),
|
||||
state.turn_seq,
|
||||
state.effective_openness,
|
||||
state.rapport_credit,
|
||||
state.resistance,
|
||||
state.ideation_stage,
|
||||
state.turns_in_stage,
|
||||
state.affect_state,
|
||||
)
|
||||
|
||||
|
||||
async def create_session(
|
||||
*,
|
||||
learner_id: str,
|
||||
card: PersonaCard,
|
||||
theory_mode: str,
|
||||
state: state_machine.SessionState,
|
||||
session_no: int = 1,
|
||||
carry_rapport: float = 0.0,
|
||||
persona_id: str | None = None,
|
||||
persona_version: int | None = None,
|
||||
) -> InProcSession | None:
|
||||
"""Create a DB-backed session, returning None when DB persistence is unavailable."""
|
||||
try:
|
||||
get_pool()
|
||||
runtime_case_id = str(uuid.uuid4())
|
||||
pinned_persona_id = persona_id or seed_persona_id(card.code)
|
||||
pinned_persona_version = persona_version or SEED_VERSION
|
||||
async with acquire(role="learner", user_id=learner_id) as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO app.sessions (
|
||||
runtime_case_id, learner_id, persona_id, persona_version,
|
||||
persona_code, persona_display_name, persona_difficulty,
|
||||
session_no, theory_mode, stage_path, prev_rapport_credit
|
||||
)
|
||||
VALUES (
|
||||
$1::uuid, $2::uuid, $3::uuid, $4,
|
||||
$5, $6, $7,
|
||||
$8, $9, '[]'::jsonb, $10
|
||||
)
|
||||
RETURNING id, runtime_case_id, case_id, learner_id, persona_code,
|
||||
session_no, theory_mode, started_at, ended_at, prev_rapport_credit
|
||||
""",
|
||||
runtime_case_id,
|
||||
learner_id,
|
||||
pinned_persona_id,
|
||||
pinned_persona_version,
|
||||
card.code,
|
||||
card.display_name,
|
||||
card.difficulty,
|
||||
session_no,
|
||||
theory_mode,
|
||||
carry_rapport,
|
||||
)
|
||||
await _upsert_state(conn, str(row["id"]), state)
|
||||
return InProcSession(
|
||||
session_id=str(row["id"]),
|
||||
case_id=runtime_case_id,
|
||||
learner_id=learner_id,
|
||||
persona_code=card.code,
|
||||
theory_mode=theory_mode,
|
||||
persona=card,
|
||||
state=state,
|
||||
session_no=session_no,
|
||||
created_at=_ts(row["started_at"]) or time.time(),
|
||||
ended_at=None,
|
||||
turns=[],
|
||||
ended=False,
|
||||
prev_rapport_credit=carry_rapport,
|
||||
)
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("session creation")
|
||||
return None
|
||||
|
||||
|
||||
async def load_session(
|
||||
session_id: str,
|
||||
principal: Principal,
|
||||
*,
|
||||
allow_ended: bool = False,
|
||||
) -> InProcSession | None:
|
||||
try:
|
||||
get_pool()
|
||||
async with acquire(
|
||||
role=principal.role.value,
|
||||
user_id=principal.user_id,
|
||||
cohort_ids=principal.cohort_ids,
|
||||
) as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT
|
||||
s.id, s.runtime_case_id, s.case_id, s.learner_id, s.persona_code,
|
||||
s.session_no, s.theory_mode, s.started_at, s.ended_at,
|
||||
s.prev_rapport_credit,
|
||||
pc.persona_id AS card_persona_id,
|
||||
pc.code AS card_code,
|
||||
pc.version AS card_version,
|
||||
pc.status AS card_status,
|
||||
pc.display_name AS card_display_name,
|
||||
pc.difficulty AS card_difficulty,
|
||||
pc.theory_target AS card_theory_target,
|
||||
pc.demographics AS card_demographics,
|
||||
pc.presenting AS card_presenting,
|
||||
pc.history AS card_history,
|
||||
pc.big5 AS card_big5,
|
||||
pc.resistance AS card_resistance,
|
||||
pc.speech_style AS card_speech_style,
|
||||
pc.affect_baseline AS card_affect_baseline,
|
||||
pc.ccd AS card_ccd,
|
||||
pc.dsm5_dimensional AS card_dsm5_dimensional,
|
||||
pc.source_provenance AS card_source_provenance,
|
||||
pc.is_synthetic AS card_is_synthetic
|
||||
FROM app.sessions s
|
||||
LEFT JOIN app.persona_card pc
|
||||
ON pc.persona_id = s.persona_id
|
||||
AND pc.version = s.persona_version
|
||||
WHERE s.id = $1::uuid
|
||||
""",
|
||||
session_id,
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
if row["ended_at"] is not None and not allow_ended:
|
||||
return None
|
||||
state_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT stage, turn_seq, effective_openness, rapport_credit, resistance,
|
||||
ideation_stage, turns_in_stage, affect_state
|
||||
FROM app.session_state
|
||||
WHERE session_id = $1::uuid
|
||||
""",
|
||||
session_id,
|
||||
)
|
||||
turn_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT seq, speaker, stage, text, text_masked, created_at
|
||||
FROM app.turns
|
||||
WHERE session_id = $1::uuid
|
||||
ORDER BY seq
|
||||
""",
|
||||
session_id,
|
||||
)
|
||||
return _session_from_rows(row, state_row, turn_rows)
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("session load")
|
||||
return None
|
||||
|
||||
|
||||
async def append_turn(
|
||||
*,
|
||||
session_id: str,
|
||||
learner_id: str,
|
||||
turn: TurnRecord,
|
||||
) -> bool:
|
||||
try:
|
||||
get_pool()
|
||||
async with acquire(role="learner", user_id=learner_id) as conn:
|
||||
locked = await conn.fetchval(
|
||||
"SELECT id FROM app.sessions WHERE id = $1::uuid FOR UPDATE",
|
||||
session_id,
|
||||
)
|
||||
if locked is None:
|
||||
return False
|
||||
seq = int(
|
||||
await conn.fetchval(
|
||||
"SELECT COALESCE(MAX(seq), 0) + 1 FROM app.turns WHERE session_id = $1::uuid",
|
||||
session_id,
|
||||
)
|
||||
or 1
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO app.turns (
|
||||
session_id, seq, speaker, stage, text, text_masked, actor_kind, visible_to
|
||||
)
|
||||
VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8::text[])
|
||||
ON CONFLICT (session_id, seq) DO NOTHING
|
||||
""",
|
||||
session_id,
|
||||
seq,
|
||||
turn.speaker,
|
||||
turn.stage,
|
||||
turn.text_masked,
|
||||
turn.text_masked,
|
||||
"human_learner" if turn.speaker == "counselor" else "client_ai",
|
||||
["client", "counselor", "evaluator"],
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("session turn append")
|
||||
return False
|
||||
|
||||
|
||||
async def update_state(
|
||||
*,
|
||||
session_id: str,
|
||||
learner_id: str,
|
||||
state: state_machine.SessionState,
|
||||
) -> bool:
|
||||
try:
|
||||
get_pool()
|
||||
async with acquire(role="learner", user_id=learner_id) as conn:
|
||||
await _upsert_state(conn, session_id, state)
|
||||
return True
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("session state update")
|
||||
return False
|
||||
|
||||
|
||||
async def end_session(sess: InProcSession, carry: memory.CarryOver) -> bool:
|
||||
try:
|
||||
get_pool()
|
||||
digest = (
|
||||
f"회기 축어록 {len(sess.turns)}개가 저장되었습니다. 정밀 리뷰는 생성 대기 중입니다."
|
||||
if sess.turns
|
||||
else "실제 발화가 없어 요약을 생성하지 않았습니다."
|
||||
)
|
||||
async with acquire(role="learner", user_id=sess.learner_id) as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE app.sessions
|
||||
SET ended_at = COALESCE(ended_at, now())
|
||||
WHERE id = $1::uuid
|
||||
""",
|
||||
sess.session_id,
|
||||
)
|
||||
await _upsert_state(conn, sess.session_id, sess.state)
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO app.session_summary (
|
||||
session_id, case_id, session_no, end_state, rapport_delta,
|
||||
digest, open_threads, created_at
|
||||
)
|
||||
VALUES ($1::uuid, $2::uuid, $3, $4::jsonb, $5, $6, $7::jsonb, now())
|
||||
ON CONFLICT (session_id) DO UPDATE SET
|
||||
end_state = EXCLUDED.end_state,
|
||||
rapport_delta = EXCLUDED.rapport_delta,
|
||||
digest = EXCLUDED.digest,
|
||||
open_threads = EXCLUDED.open_threads
|
||||
""",
|
||||
sess.session_id,
|
||||
sess.case_id,
|
||||
sess.session_no,
|
||||
carry.end_state,
|
||||
carry.rapport_delta,
|
||||
digest,
|
||||
list(carry.compression_job.open_threads if carry.compression_job else []),
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("session end")
|
||||
return False
|
||||
|
||||
|
||||
async def list_sessions(principal: Principal) -> tuple[list[InProcSession], bool]:
|
||||
try:
|
||||
get_pool()
|
||||
learner_filter = "WHERE s.learner_id = $1::uuid" if principal.role.value == "learner" else ""
|
||||
query_args = [principal.user_id] if principal.role.value == "learner" else []
|
||||
async with acquire(
|
||||
role=principal.role.value,
|
||||
user_id=principal.user_id,
|
||||
cohort_ids=principal.cohort_ids,
|
||||
) as conn:
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT
|
||||
s.id, s.runtime_case_id, s.case_id, s.learner_id, s.persona_code,
|
||||
s.session_no, s.theory_mode, s.started_at, s.ended_at,
|
||||
s.prev_rapport_credit,
|
||||
pc.persona_id AS card_persona_id,
|
||||
pc.code AS card_code,
|
||||
pc.version AS card_version,
|
||||
pc.status AS card_status,
|
||||
pc.display_name AS card_display_name,
|
||||
pc.difficulty AS card_difficulty,
|
||||
pc.theory_target AS card_theory_target,
|
||||
pc.demographics AS card_demographics,
|
||||
pc.presenting AS card_presenting,
|
||||
pc.history AS card_history,
|
||||
pc.big5 AS card_big5,
|
||||
pc.resistance AS card_resistance,
|
||||
pc.speech_style AS card_speech_style,
|
||||
pc.affect_baseline AS card_affect_baseline,
|
||||
pc.ccd AS card_ccd,
|
||||
pc.dsm5_dimensional AS card_dsm5_dimensional,
|
||||
pc.source_provenance AS card_source_provenance,
|
||||
pc.is_synthetic AS card_is_synthetic
|
||||
FROM app.sessions s
|
||||
LEFT JOIN app.persona_card pc
|
||||
ON pc.persona_id = s.persona_id
|
||||
AND pc.version = s.persona_version
|
||||
{learner_filter}
|
||||
ORDER BY s.started_at DESC
|
||||
LIMIT 100
|
||||
""",
|
||||
*query_args,
|
||||
)
|
||||
sessions: list[InProcSession] = []
|
||||
for row in rows:
|
||||
session_id = str(row["id"])
|
||||
state_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT stage, turn_seq, effective_openness, rapport_credit, resistance,
|
||||
ideation_stage, turns_in_stage, affect_state
|
||||
FROM app.session_state
|
||||
WHERE session_id = $1::uuid
|
||||
""",
|
||||
session_id,
|
||||
)
|
||||
turn_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT seq, speaker, stage, text, text_masked, created_at
|
||||
FROM app.turns
|
||||
WHERE session_id = $1::uuid
|
||||
ORDER BY seq
|
||||
""",
|
||||
session_id,
|
||||
)
|
||||
sess = _session_from_rows(row, state_row, turn_rows)
|
||||
if sess is not None:
|
||||
sessions.append(sess)
|
||||
return sessions, True
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("session list")
|
||||
return [], False
|
||||
|
|
@ -42,6 +42,8 @@ class InProcSession:
|
|||
persona: PersonaCard
|
||||
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:
|
||||
|
|
|
|||
269
apps/api/app/test_runtime_policy.py
Normal file
269
apps/api/app/test_runtime_policy.py
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
"""Regression tests for DB outage runtime fallback policy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from . import auth_sessions, session_persistence
|
||||
from .config import Settings, settings
|
||||
from .deps import Principal, Role
|
||||
from .routes import admin as admin_routes
|
||||
from .routes import eval as eval_routes
|
||||
from .routes import kb as kb_routes
|
||||
from .routes import users as users_routes
|
||||
|
||||
|
||||
@contextmanager
|
||||
def environment(value: str):
|
||||
previous = settings.environment
|
||||
settings.environment = value
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
settings.environment = previous
|
||||
|
||||
|
||||
class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self) -> None:
|
||||
auth_sessions._sessions.clear()
|
||||
auth_sessions._users.clear()
|
||||
auth_sessions._email_index.clear()
|
||||
auth_sessions._inactive_emails.clear()
|
||||
users_routes._preferences.clear()
|
||||
admin_routes._ENGINE_CONFIG = None
|
||||
|
||||
async def asyncTearDown(self) -> None:
|
||||
admin_routes._ENGINE_CONFIG = None
|
||||
|
||||
async def test_dev_allows_auth_registry_fallback_when_db_pool_missing(self) -> None:
|
||||
with environment("dev"):
|
||||
users, durable = await auth_sessions.list_managed_users()
|
||||
|
||||
self.assertFalse(durable)
|
||||
self.assertEqual(users, [])
|
||||
|
||||
async def test_staging_blocks_auth_registry_fallback_when_db_pool_missing(self) -> None:
|
||||
with environment("staging"):
|
||||
with self.assertRaises(HTTPException) as caught:
|
||||
await auth_sessions.list_managed_users()
|
||||
|
||||
self.assertEqual(caught.exception.status_code, 503)
|
||||
self.assertIn("runtime fallback is disabled in staging", caught.exception.detail)
|
||||
|
||||
async def test_prod_blocks_browser_session_creation_when_db_pool_missing(self) -> None:
|
||||
with environment("prod"):
|
||||
with self.assertRaises(HTTPException) as caught:
|
||||
await auth_sessions.create_session(
|
||||
email="learner@hs.ac.kr",
|
||||
display_name="Learner",
|
||||
role="learner",
|
||||
)
|
||||
|
||||
self.assertEqual(caught.exception.status_code, 503)
|
||||
self.assertIn("runtime fallback is disabled in prod", caught.exception.detail)
|
||||
|
||||
async def test_staging_blocks_session_store_fallback_when_db_pool_missing(self) -> None:
|
||||
principal = Principal(
|
||||
user_id="00000000-0000-0000-0000-000000000001",
|
||||
role=Role.LEARNER,
|
||||
cohort_ids=[],
|
||||
email="learner@hs.ac.kr",
|
||||
display_name="Learner",
|
||||
)
|
||||
with environment("staging"):
|
||||
with self.assertRaises(HTTPException) as caught:
|
||||
await session_persistence.list_sessions(principal)
|
||||
|
||||
self.assertEqual(caught.exception.status_code, 503)
|
||||
self.assertIn("runtime fallback is disabled in staging", caught.exception.detail)
|
||||
|
||||
async def test_staging_blocks_eval_store_cache_fallback_when_db_pool_missing(self) -> None:
|
||||
principal = Principal(
|
||||
user_id="00000000-0000-0000-0000-000000000002",
|
||||
role=Role.TEACHER,
|
||||
cohort_ids=[],
|
||||
email="teacher@hs.ac.kr",
|
||||
display_name="Teacher",
|
||||
)
|
||||
with environment("staging"):
|
||||
with self.assertRaises(HTTPException) as caught:
|
||||
await eval_routes.get_session_evaluation("missing-session-id", principal)
|
||||
|
||||
self.assertEqual(caught.exception.status_code, 503)
|
||||
self.assertIn("runtime fallback is disabled in staging", caught.exception.detail)
|
||||
|
||||
async def test_staging_kb_search_fails_closed_when_db_pool_missing(self) -> None:
|
||||
with environment("staging"):
|
||||
with self.assertRaises(HTTPException) as caught:
|
||||
await kb_routes.search(kb_routes.KBSearchRequest(query="rapport"))
|
||||
|
||||
self.assertEqual(caught.exception.status_code, 503)
|
||||
self.assertIn("DB not ready", caught.exception.detail)
|
||||
|
||||
async def test_staging_blocks_user_preferences_fallback_when_db_pool_missing(self) -> None:
|
||||
principal = Principal(
|
||||
user_id="00000000-0000-0000-0000-000000000003",
|
||||
role=Role.LEARNER,
|
||||
cohort_ids=[],
|
||||
email="learner@hs.ac.kr",
|
||||
display_name="Learner",
|
||||
)
|
||||
with environment("staging"):
|
||||
with self.assertRaises(HTTPException) as caught:
|
||||
await users_routes.get_preferences(principal)
|
||||
|
||||
self.assertEqual(caught.exception.status_code, 503)
|
||||
self.assertIn("runtime fallback is disabled in staging", caught.exception.detail)
|
||||
|
||||
async def test_dev_allows_user_preferences_fallback_when_db_pool_missing(self) -> None:
|
||||
principal = Principal(
|
||||
user_id="00000000-0000-0000-0000-000000000004",
|
||||
role=Role.LEARNER,
|
||||
cohort_ids=[],
|
||||
email="learner@hs.ac.kr",
|
||||
display_name="Learner",
|
||||
)
|
||||
with environment("dev"):
|
||||
prefs = await users_routes.get_preferences(principal)
|
||||
|
||||
self.assertEqual(prefs.voice_preset_id, "soft-young-fem")
|
||||
|
||||
async def test_staging_blocks_admin_engine_config_default_when_row_missing(self) -> None:
|
||||
class EmptyConfigConn:
|
||||
async def fetchrow(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
class EmptyConfigAcquire:
|
||||
async def __aenter__(self):
|
||||
return EmptyConfigConn()
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return None
|
||||
|
||||
class EmptyConfigPool:
|
||||
def acquire(self):
|
||||
return EmptyConfigAcquire()
|
||||
|
||||
with environment("staging"), patch.object(admin_routes, "get_pool", return_value=EmptyConfigPool()):
|
||||
with self.assertRaises(HTTPException) as caught:
|
||||
await admin_routes._current_engine_config()
|
||||
|
||||
self.assertEqual(caught.exception.status_code, 503)
|
||||
self.assertIn("runtime fallback is disabled in staging", caught.exception.detail)
|
||||
|
||||
async def test_prod_admin_health_marks_db_down_when_persistence_unavailable(self) -> None:
|
||||
principal = Principal(
|
||||
user_id="00000000-0000-0000-0000-000000000005",
|
||||
role=Role.ADMIN,
|
||||
cohort_ids=[],
|
||||
email="admin@twentyoz.kr",
|
||||
display_name="Admin",
|
||||
)
|
||||
engine_config = admin_routes.AdminEngineConfigResponse(
|
||||
engine_mode="claude_cli",
|
||||
engine_url="http://127.0.0.1:9099",
|
||||
model="gateway-default",
|
||||
durable=True,
|
||||
source="database",
|
||||
)
|
||||
|
||||
with (
|
||||
environment("prod"),
|
||||
patch.object(admin_routes, "_current_engine_config", AsyncMock(return_value=engine_config)),
|
||||
patch.object(admin_routes, "healthcheck", AsyncMock(return_value=False)),
|
||||
patch.object(admin_routes.engine_client, "health_detail", AsyncMock(return_value={"ok": True})),
|
||||
patch.object(admin_routes.voice_service, "is_available", return_value=False),
|
||||
):
|
||||
health = await admin_routes.admin_health(principal)
|
||||
|
||||
db = next(service for service in health.services if service.key == "db")
|
||||
self.assertEqual(db.status, "down")
|
||||
self.assertEqual(db.metric, "저장소 중단")
|
||||
self.assertIn("DB 저장소", db.detail)
|
||||
|
||||
async def test_dev_admin_health_labels_db_fallback_as_non_durable_runtime(self) -> None:
|
||||
principal = Principal(
|
||||
user_id="00000000-0000-0000-0000-000000000006",
|
||||
role=Role.ADMIN,
|
||||
cohort_ids=[],
|
||||
email="admin@twentyoz.kr",
|
||||
display_name="Admin",
|
||||
)
|
||||
engine_config = admin_routes.AdminEngineConfigResponse(
|
||||
engine_mode="claude_cli",
|
||||
engine_url="http://127.0.0.1:9099",
|
||||
model="gateway-default",
|
||||
durable=False,
|
||||
source="runtime_cache",
|
||||
)
|
||||
|
||||
with (
|
||||
environment("dev"),
|
||||
patch.object(admin_routes, "_current_engine_config", AsyncMock(return_value=engine_config)),
|
||||
patch.object(admin_routes, "healthcheck", AsyncMock(return_value=False)),
|
||||
patch.object(admin_routes.engine_client, "health_detail", AsyncMock(return_value={"ok": True})),
|
||||
patch.object(admin_routes.voice_service, "is_available", return_value=False),
|
||||
):
|
||||
health = await admin_routes.admin_health(principal)
|
||||
|
||||
db = next(service for service in health.services if service.key == "db")
|
||||
self.assertEqual(db.status, "degraded")
|
||||
self.assertEqual(db.metric, "비영구 런타임 기록")
|
||||
self.assertIn("비영구 개발 런타임 기록", db.detail)
|
||||
|
||||
def test_non_dev_rejects_fixture_runtime_flags(self) -> None:
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
Settings(
|
||||
environment="staging",
|
||||
auth_dev_login_enabled=True,
|
||||
auto_seed_personas=True,
|
||||
allow_seed_persona_fallback=True,
|
||||
)
|
||||
|
||||
self.assertIn("AUTH_DEV_LOGIN_ENABLED", str(caught.exception))
|
||||
self.assertIn("AUTO_SEED_PERSONAS", str(caught.exception))
|
||||
self.assertIn("ALLOW_SEED_PERSONA_FALLBACK", str(caught.exception))
|
||||
|
||||
def test_non_dev_rejects_missing_public_runtime_config(self) -> None:
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
Settings(
|
||||
environment="prod",
|
||||
auth_dev_login_enabled=False,
|
||||
auto_seed_personas=False,
|
||||
allow_seed_persona_fallback=False,
|
||||
oauth_google_client_id="",
|
||||
oauth_google_client_secret="",
|
||||
session_secret="dev-insecure-change-me",
|
||||
frontend_base_url="http://localhost:5173",
|
||||
cors_origins=["http://localhost:5173"],
|
||||
)
|
||||
|
||||
error = str(caught.exception)
|
||||
self.assertIn("OAUTH_GOOGLE_CLIENT_ID", error)
|
||||
self.assertIn("OAUTH_GOOGLE_CLIENT_SECRET", error)
|
||||
self.assertIn("SESSION_SECRET", error)
|
||||
self.assertIn("FRONTEND_BASE_URL", error)
|
||||
|
||||
def test_non_dev_accepts_public_runtime_config(self) -> None:
|
||||
cfg = Settings(
|
||||
environment="staging",
|
||||
auth_dev_login_enabled=False,
|
||||
auto_seed_personas=False,
|
||||
allow_seed_persona_fallback=False,
|
||||
session_secret="staging-secret-change-me",
|
||||
oauth_google_client_id="google-client-id",
|
||||
oauth_google_client_secret="google-client-secret",
|
||||
frontend_base_url="https://vignette.chanpaca.net",
|
||||
cors_origins=["https://vignette.chanpaca.net"],
|
||||
)
|
||||
|
||||
self.assertEqual(cfg.environment, "staging")
|
||||
self.assertEqual(cfg.cors_origins, ["https://vignette.chanpaca.net"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
143
apps/api/app/test_session_turn_persistence.py
Normal file
143
apps/api/app/test_session_turn_persistence.py
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
"""Regression tests for session turn persistence ordering."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from .deps import Principal, Role
|
||||
from .engine_client import EngineError
|
||||
from .routes import sessions
|
||||
from .routes import voice as voice_routes
|
||||
from .services import orchestrator, persona as persona_service, state_machine
|
||||
from .services.voice import VoicePreset
|
||||
from .store import InProcSession, store
|
||||
|
||||
|
||||
def _principal() -> Principal:
|
||||
return Principal(
|
||||
user_id="00000000-0000-0000-0000-000000000101",
|
||||
role=Role.LEARNER,
|
||||
cohort_ids=[],
|
||||
email="turn-test@hs.ac.kr",
|
||||
display_name="Turn Test",
|
||||
)
|
||||
|
||||
|
||||
def _session(principal: Principal) -> InProcSession:
|
||||
card = persona_service.P1
|
||||
sess = InProcSession(
|
||||
session_id="turn-persistence-session",
|
||||
case_id="turn-persistence-case",
|
||||
learner_id=principal.user_id,
|
||||
persona_code=card.code,
|
||||
theory_mode="humanistic",
|
||||
persona=card,
|
||||
state=state_machine.SessionState(
|
||||
resistance=card.base_resistance(),
|
||||
ideation_stage=card.ideation_baseline(),
|
||||
),
|
||||
)
|
||||
store.put(sess)
|
||||
return sess
|
||||
|
||||
|
||||
async def _consume_event_source(response: object) -> bytes:
|
||||
body = bytearray()
|
||||
iterator = getattr(response, "body_iterator")
|
||||
async for chunk in iterator:
|
||||
if isinstance(chunk, str):
|
||||
body.extend(chunk.encode("utf-8"))
|
||||
elif isinstance(chunk, (bytes, bytearray)):
|
||||
body.extend(chunk)
|
||||
else:
|
||||
body.extend(str(chunk).encode("utf-8"))
|
||||
return bytes(body)
|
||||
|
||||
|
||||
class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncSetUp(self) -> None:
|
||||
store._sessions.clear()
|
||||
|
||||
async def asyncTearDown(self) -> None:
|
||||
store._sessions.clear()
|
||||
|
||||
async def test_generate_turn_engine_failure_does_not_append_learner_turn(self) -> None:
|
||||
principal = _principal()
|
||||
sess = _session(principal)
|
||||
|
||||
with patch.object(
|
||||
sessions.orchestrator,
|
||||
"run_turn_generate",
|
||||
AsyncMock(side_effect=EngineError("engine unavailable: test")),
|
||||
):
|
||||
with self.assertRaises(sessions.HTTPException) as caught:
|
||||
await sessions.submit_turn(
|
||||
sess.session_id,
|
||||
sessions.TurnRequest(text="실패한 발화"),
|
||||
principal,
|
||||
)
|
||||
|
||||
self.assertEqual(caught.exception.status_code, 503)
|
||||
self.assertEqual(sess.turns, [])
|
||||
|
||||
async def test_stream_turn_engine_error_event_does_not_append_partial_turns(self) -> None:
|
||||
principal = _principal()
|
||||
sess = _session(principal)
|
||||
|
||||
async def failing_stream(*args, **kwargs):
|
||||
yield orchestrator.StreamEvent("token", {"text": "부분 응답"})
|
||||
yield orchestrator.StreamEvent("error", {"detail": "engine unavailable: stream"})
|
||||
|
||||
with patch.object(sessions.orchestrator, "run_turn_stream", failing_stream):
|
||||
response = await sessions.stream_turn(
|
||||
sess.session_id,
|
||||
sessions.TurnRequest(text="스트림 실패 발화"),
|
||||
principal,
|
||||
)
|
||||
body = await _consume_event_source(response)
|
||||
|
||||
self.assertIn(b"engine unavailable: stream", body)
|
||||
self.assertEqual(sess.turns, [])
|
||||
|
||||
async def test_voice_turn_engine_failure_does_not_append_learner_turn(self) -> None:
|
||||
class FakeWebSocket:
|
||||
def __init__(self) -> None:
|
||||
self.messages: list[dict[str, object]] = []
|
||||
self.client_state = voice_routes.WebSocketState.CONNECTED
|
||||
|
||||
async def send_text(self, data: str) -> None:
|
||||
import json
|
||||
|
||||
self.messages.append(json.loads(data))
|
||||
|
||||
principal = _principal()
|
||||
sess = _session(principal)
|
||||
websocket = FakeWebSocket()
|
||||
|
||||
with patch.object(
|
||||
voice_routes.orchestrator,
|
||||
"run_turn_generate",
|
||||
AsyncMock(side_effect=EngineError("voice engine unavailable")),
|
||||
):
|
||||
await voice_routes._run_turn_and_speak(
|
||||
websocket, # type: ignore[arg-type]
|
||||
session_id=sess.session_id,
|
||||
principal=principal,
|
||||
voice_preset=VoicePreset(preset="neutral", openai_voice="sage"),
|
||||
learner_text="음성 실패 발화",
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
any(
|
||||
message.get("type") == "error"
|
||||
and "engine unavailable" in str(message.get("detail"))
|
||||
for message in websocket.messages
|
||||
),
|
||||
websocket.messages,
|
||||
)
|
||||
self.assertEqual(sess.turns, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue