feat(engine): claude -p 상주 멀티턴 엔진 게이트웨이 + 실동작 검증

- engine_gateway/gateway.py: 회기당 claude -p 상주 프로세스(stream-json), 턴 직렬, budget 제한
- 세션 생성/턴/종료 HTTP API(FastAPI, :9099)
- 검증: 멀티턴 컨텍스트 유지 + prompt caching 재사용(턴2 +$0.07) 실동작 확인
This commit is contained in:
Yun Chan 2026-06-25 21:43:27 +09:00
parent d5b86c5f89
commit 84eb6e2173
20 changed files with 2038 additions and 1 deletions

View file

@ -1,5 +1,20 @@
# ── DB (NAS PostgreSQL 16 + pgvector, 단일 SoR) ──
DATABASE_URL=postgresql://user:pass@nas:5432/vignette
# ── 엔진 게이트웨이 (람다 소유, app 은 HTTP 호출만) ──
ENGINE_URL=http://engine:8100
ENGINE_MODE=claude_api # claude_api | claude_cli | openai | solar
# ── 외부 LLM 키 (PII 마스킹 후만, 게이트웨이가 주로 사용) ──
ANTHROPIC_API_KEY=
OPENAI_API_KEY=
DATABASE_URL=postgresql://user:pass@nas:5432/vignette
# ── 세션/인증 (BFF OAuth 2.1, 토큰 서버 보관) ──
SESSION_SECRET=dev-insecure-change-me
OAUTH_GOOGLE_CLIENT_ID=
OAUTH_GOOGLE_CLIENT_SECRET=
OAUTH_REDIRECT_URI=https://chanpaca.net/auth/callback
# ── 엔진 CLI 폴백 ──
CLAUDE_P_PATH=claude
GLM_OR_FALLBACK_MODEL=

10
apps/api/app/__init__.py Normal file
View file

@ -0,0 +1,10 @@
"""Vignette FastAPI backend.
상담 시뮬레이션 훈련 플랫폼 백엔드.
소유: 상태머신(결정론) · 가드레일 · 엔진 어댑터 호출부 · SSE 스트리머 · RBAC.
엔진 게이트웨이 자체(apps/api/engine_gateway/) 람다가 직접 소유한다.
패키지는 ENGINE_URL로 게이트웨이를 HTTP 호출하는 클라이언트(engine_client.py) 가진다.
"""
__version__ = "0.1.0"

102
apps/api/app/config.py Normal file
View file

@ -0,0 +1,102 @@
"""환경설정 (env -> 타입드 Settings).
마스터플랜 §1.1 원칙: 엔진/DB/음성 엔드포인트는 전부 env 주입(이미지에 굽지 않음 = 이식성).
Docker Compose secrets/.env 주입, 코드에 하드코딩 금지.
"""
from __future__ import annotations
from functools import lru_cache
from typing import Literal
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
# 엔진 어댑터 provider 플래그 (마스터플랜 §0, R1: claude -p 과금누수 회피)
# claude_api = Anthropic Messages API 직결 (기본)
# claude_cli = 로컬 claude -p 상주풀 (stream-json, 옵션/시연용)
# openai = OpenAI 호환 (폴백/평가 보조)
# solar = 국내 모델 라우팅 (PII 민감구간 inference_geo:kr)
EngineMode = Literal["claude_api", "claude_cli", "openai", "solar"]
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
case_sensitive=False,
)
# ── 앱 ───────────────────────────────────────────────
app_name: str = "vignette-api"
environment: Literal["dev", "staging", "prod"] = "dev"
debug: bool = False
# ── DB (NAS PostgreSQL 16 + pgvector, 단일 SoR) ─────
# 예: postgresql://user:pass@nas:5432/vignette
database_url: str = Field(
default="postgresql://user:pass@localhost:5432/vignette",
validation_alias="DATABASE_URL",
)
db_pool_min_size: int = 2
db_pool_max_size: int = 10
db_command_timeout: float = 30.0
# ── 엔진 게이트웨이 (람다 소유, 이 앱은 HTTP 호출만) ──
# engine_gateway/ 서비스 베이스 URL. 게이트웨이가 provider 라우팅을 흡수.
engine_url: str = Field(
default="http://engine:8100",
validation_alias="ENGINE_URL",
)
engine_mode: EngineMode = Field(
default="claude_api",
validation_alias="ENGINE_MODE",
)
engine_timeout: float = 120.0 # SSE 롱리브드 (50분 상담 대비, 스트림은 무제한 별도)
engine_connect_timeout: float = 10.0
# ── 외부 LLM 키 (게이트웨이가 못 받을 때 직접 폴백, PII 마스킹 후만) ──
anthropic_api_key: str = Field(default="", validation_alias="ANTHROPIC_API_KEY")
openai_api_key: str = Field(default="", validation_alias="OPENAI_API_KEY")
# ── 세션/인증 (BFF OAuth 2.1, 토큰 서버 보관) ────────
session_secret: str = Field(
default="dev-insecure-change-me",
validation_alias="SESSION_SECRET",
)
# __Host- 쿠키 정책: prod 에선 secure=True 강제
cookie_name: str = "__Host-vignette_sid"
session_ttl_seconds: int = 60 * 60 * 8 # 8h
# Google OIDC (1차, 한신대 SSO 는 2차 — R11)
oauth_google_client_id: str = Field(default="", validation_alias="OAUTH_GOOGLE_CLIENT_ID")
oauth_google_client_secret: str = Field(
default="", validation_alias="OAUTH_GOOGLE_CLIENT_SECRET"
)
oauth_redirect_uri: str = Field(
default="https://chanpaca.net/auth/callback",
validation_alias="OAUTH_REDIRECT_URI",
)
# ── CORS (정적 프론트 + SSE 분리경로) ────────────────
cors_origins: list[str] = Field(
default=["https://chanpaca.net", "https://stream.chanpaca.net", "http://localhost:5173"],
validation_alias="CORS_ORIGINS",
)
# ── SSE 스트리밍 ─────────────────────────────────────
sse_heartbeat_seconds: int = 30 # Cloudflare 100초 timeout 회피 (R2)
@property
def is_prod(self) -> bool:
return self.environment == "prod"
@lru_cache
def get_settings() -> Settings:
"""프로세스 1회 로드 (lru_cache). 의존성 주입은 deps.get_settings_dep 사용."""
return Settings()
settings = get_settings()

126
apps/api/app/db.py Normal file
View file

@ -0,0 +1,126 @@
"""asyncpg 연결 풀 + pgvector 등록.
DB = NAS PostgreSQL 16 단일 SoR (마스터플랜 §0). 스키마 4분할: app / kb / audit / ds.
RLS 이중강제: 커넥션 획득 SET LOCAL app.current_role / app.current_ai_view 주입
(deps.py RBAC 의존성과 ). 여기선 + 헬퍼만 제공한다.
"""
from __future__ import annotations
import json
from contextlib import asynccontextmanager
from typing import Any, AsyncIterator, Optional
import asyncpg
from .config import settings
# 전역 풀 핸들. main.py lifespan 에서 init/close.
_pool: Optional[asyncpg.Pool] = None
async def _init_connection(conn: asyncpg.Connection) -> None:
"""커넥션 단위 코덱 등록.
- jsonb: dict 직렬화 자동 (asyncpg 기본은 str 반환)
- vector(1024): pgvector. 런타임 인코딩은 RAG 경로에서 처리(여기선 텍스트 캐스트 허용).
TODO: pgvector 바이너리 코덱 등록(register_vector) RAG 라우터 구현 BGE-M3 1024d 연동.
"""
await conn.set_type_codec(
"jsonb",
encoder=lambda v: json.dumps(v, ensure_ascii=False),
decoder=json.loads,
schema="pg_catalog",
)
await conn.set_type_codec(
"json",
encoder=lambda v: json.dumps(v, ensure_ascii=False),
decoder=json.loads,
schema="pg_catalog",
)
async def init_pool() -> asyncpg.Pool:
"""풀 생성 (main lifespan startup)."""
global _pool
if _pool is not None:
return _pool
_pool = await asyncpg.create_pool(
dsn=settings.database_url,
min_size=settings.db_pool_min_size,
max_size=settings.db_pool_max_size,
command_timeout=settings.db_command_timeout,
init=_init_connection,
)
return _pool
async def close_pool() -> None:
"""풀 종료 (main lifespan shutdown)."""
global _pool
if _pool is not None:
await _pool.close()
_pool = None
def get_pool() -> asyncpg.Pool:
"""초기화된 풀 반환. lifespan 밖 호출 시 RuntimeError."""
if _pool is None:
raise RuntimeError("DB pool not initialized — init_pool() must run in lifespan startup")
return _pool
@asynccontextmanager
async def acquire(
*,
role: Optional[str] = None,
ai_view: Optional[str] = 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 정책)
트랜잭션 SET LOCAL 주입해 커넥션 재사용 누수 방지.
NOTE: RLS 정책/세션변수는 Phase 0 마이그레이션에서 정의(설계서 §3.3 / §4).
여기선 변수 주입 계약만 확정.
"""
pool = get_pool()
async with pool.acquire() as conn:
async with conn.transaction():
if role is not None:
await conn.execute("SELECT set_config('app.current_role', $1, true)", role)
if ai_view is not None:
await conn.execute("SELECT set_config('app.current_ai_view', $1, true)", ai_view)
yield conn
async def healthcheck() -> bool:
"""SELECT 1 핑. /health 에서 사용."""
try:
pool = get_pool()
async with pool.acquire() as conn:
val = await conn.fetchval("SELECT 1")
return val == 1
except Exception:
return False
async def fetch(query: str, *args: Any, role: Optional[str] = None, ai_view: Optional[str] = None):
async with acquire(role=role, ai_view=ai_view) as conn:
return await conn.fetch(query, *args)
async def fetchrow(
query: str, *args: Any, role: Optional[str] = None, ai_view: Optional[str] = None
):
async with acquire(role=role, ai_view=ai_view) as conn:
return await conn.fetchrow(query, *args)
async def execute(
query: str, *args: Any, role: Optional[str] = None, ai_view: Optional[str] = None
) -> str:
async with acquire(role=role, ai_view=ai_view) as conn:
return await conn.execute(query, *args)

119
apps/api/app/deps.py Normal file
View file

@ -0,0 +1,119 @@
"""의존성 — 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 세션 구현 완성(현재 스텁).
"""
from __future__ import annotations
from enum import Enum
from typing import Annotated, AsyncIterator, Optional
import asyncpg
from fastapi import Cookie, Depends, HTTPException, status
from .config import Settings, get_settings
from .db import acquire
# ── 인간 역할 (RBAC) ────────────────────────────────────
class Role(str, Enum):
LEARNER = "learner" # 본인 세션만 (/learn)
TEACHER = "teacher" # 담당 코호트 전체 열람+검수 (/teach)
ADMIN = "admin" # 전부 + 교수활동 감사 (/admin)
# ── AI 뷰 (정보비대칭, current_ai_view enum) ────────────
class AIView(str, Enum):
CLIENT = "client" # 가상내담자 AI — CCD/정답/점수 절대 비노출
COUNSELOR = "counselor" # 상담사 AI(보조) — 표면 대화만, DSM 차단
EVALUATOR = "evaluator" # 평가 AI — 전부 봄 (학습자엔 비노출)
class Principal:
"""인증된 요청 주체. 인간 role + (선택) cohort 범위."""
def __init__(
self,
user_id: str,
role: Role,
cohort_ids: Optional[list[str]] = None,
) -> None:
self.user_id = user_id
self.role = role
self.cohort_ids = cohort_ids or []
def get_settings_dep() -> Settings:
return get_settings()
async def get_current_principal(
# __Host- HttpOnly 쿠키 (config.cookie_name). 브라우저엔 토큰 미노출.
session_cookie: Annotated[Optional[str], Cookie(alias="__Host-vignette_sid")] = None,
) -> Principal:
"""세션 쿠키 -> Principal.
TODO(auth.py 완성 ): Redis 세션 조회로 user_id/role/cohort 복원.
현재 스텁: 쿠키 없으면 401, 있으면 LEARNER 더미(개발용).
prod 에선 session_cookie 검증 실패 무조건 401.
"""
if not session_cookie:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="not authenticated",
)
# TODO: Redis 세션 룩업. 아래는 개발 스텁.
return Principal(user_id="dev-user", role=Role.LEARNER, cohort_ids=[])
def require_role(*allowed: Role):
"""역할 화이트리스트 의존성 팩토리. 예: Depends(require_role(Role.TEACHER, Role.ADMIN))."""
async def _checker(
principal: Annotated[Principal, Depends(get_current_principal)],
) -> Principal:
if principal.role not in allowed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"role {principal.role.value} not permitted",
)
return principal
return _checker
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).
yield conn
def db_for_ai_view(view: AIView):
"""AI 역할용 RLS 컨텍스트 (레이어1 강제) 의존성 팩토리.
app.current_ai_view 주입 -> visible_to[] WHERE 강제.
CLIENT 분기는 ccd/정답 로드 함수 자체를 부르지 않음(코드경로 부재 1차방어).
"""
async def _provider() -> AsyncIterator[asyncpg.Connection]:
async with acquire(ai_view=view.value) as conn:
yield conn
return _provider
# 타입 별칭 (라우트 시그니처 간결화)
CurrentPrincipal = Annotated[Principal, Depends(get_current_principal)]
HumanDB = Annotated[asyncpg.Connection, Depends(db_for_human)]

View file

@ -0,0 +1,137 @@
"""엔진 게이트웨이 HTTP 클라이언트.
게이트웨이 자체(apps/api/engine_gateway/) 람다가 직접 만든다. 여기는 *호출부*.
게이트웨이가 provider 라우팅(claude_api/claude_cli/openai/solar)·캐싱·상주 claude -p 풀을
흡수한다(마스터플랜 §0, R1). 백엔드는 ENGINE_URL HTTP 호출만 한다.
계약 (람다와 합의할 게이트웨이 API):
POST {ENGINE_URL}/v1/generate 단발 생성 (평가 deep-loop )
POST {ENGINE_URL}/v1/stream SSE 토큰 스트림 (내담자 AI 응답)
GET {ENGINE_URL}/health
요청 바디는 3-AI 역할별 system 레이어(L0~L6, 설계서 §1.2) 게이트웨이에 넘기되,
text *PII 마스킹 (text_masked)* 보낸다 (R7/F-03, 마스킹은 호출 가드레일이 완료).
"""
from __future__ import annotations
from typing import Any, AsyncIterator, Literal, Optional
import httpx
from pydantic import BaseModel, Field
from .config import settings
AIRole = Literal["client", "counselor", "evaluator"]
# ── 요청/응답 계약 모델 ─────────────────────────────────
class EngineMessage(BaseModel):
role: Literal["system", "user", "assistant"]
content: str
# 프롬프트 캐싱 힌트 (설계서 §1.2 L0~L2 cache_control). 게이트웨이가 해석.
cache: bool = False
class GenerateRequest(BaseModel):
"""단발 생성 요청 (평가 AI deep-loop, 회기종료 압축 등)."""
ai_role: AIRole
messages: list[EngineMessage]
# tier 라우팅 힌트: client=Sonnet/Solar, evaluator=Opus, fast=Haiku (마스터플랜 §5)
tier: Literal["client", "feedback", "fast"] = "client"
model: Optional[str] = None # 명시 시 게이트웨이 override
max_tokens: int = 1024
temperature: float = 0.7
structured_schema: Optional[dict[str, Any]] = None # Structured Outputs (CCD 비노출 강제)
session_id: Optional[str] = None # 비용 텔레메트리 귀속
metadata: dict[str, Any] = Field(default_factory=dict)
class GenerateResponse(BaseModel):
text: str
model: str
provider: str
tokens_in: int = 0
tokens_out: int = 0
cost_usd: float = 0.0
inference_geo: Optional[str] = None # 'kr'|'us' (데이터 주권 audit)
structured: Optional[dict[str, Any]] = None
class StreamRequest(GenerateRequest):
"""SSE 스트림 요청 (내담자 AI 실시간 응답)."""
class EngineError(RuntimeError):
"""게이트웨이 호출 실패. 라우트가 503/502 로 변환."""
class EngineClient:
"""ENGINE_URL 게이트웨이 비동기 클라이언트. 앱 수명주기 동안 1 인스턴스 재사용."""
def __init__(self, base_url: Optional[str] = None) -> None:
self.base_url = (base_url or settings.engine_url).rstrip("/")
self._client: Optional[httpx.AsyncClient] = None
async def startup(self) -> None:
self._client = httpx.AsyncClient(
base_url=self.base_url,
timeout=httpx.Timeout(
settings.engine_timeout,
connect=settings.engine_connect_timeout,
),
)
async def shutdown(self) -> None:
if self._client is not None:
await self._client.aclose()
self._client = None
@property
def client(self) -> httpx.AsyncClient:
if self._client is None:
raise EngineError("EngineClient not started — call startup() in lifespan")
return self._client
async def health(self) -> bool:
try:
r = await self.client.get("/health")
return r.status_code == 200
except httpx.HTTPError:
return False
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.raise_for_status()
except httpx.HTTPStatusError as e:
raise EngineError(f"engine generate {e.response.status_code}: {e.response.text}") from e
except httpx.HTTPError as e:
raise EngineError(f"engine generate transport error: {e}") from e
return GenerateResponse.model_validate(r.json())
async def stream(self, req: StreamRequest) -> AsyncIterator[str]:
"""SSE 토큰 스트림 프록시.
게이트웨이 SSE(`text/event-stream`) data: 청크를 그대로 yield.
라우트(sessions.py) 이걸 받아 자체 SSE 이벤트(heartbeat 포함) 재방출.
TODO: 게이트웨이 이벤트 프레이밍 확정(토큰/usage/done 이벤트 구분).
"""
try:
async with self.client.stream(
"POST", "/v1/stream", json=req.model_dump(exclude_none=True)
) as r:
r.raise_for_status()
async for line in r.aiter_lines():
if line:
yield line
except httpx.HTTPStatusError as e:
raise EngineError(f"engine stream {e.response.status_code}") from e
except httpx.HTTPError as e:
raise EngineError(f"engine stream transport error: {e}") from e
# 앱 전역 싱글톤 (main lifespan 에서 startup/shutdown)
engine_client = EngineClient()

69
apps/api/app/main.py Normal file
View file

@ -0,0 +1,69 @@
"""Vignette FastAPI 앱 엔트리포인트.
Dockerfile CMD: uvicorn app.main:app --host 0.0.0.0 --port 8000
소유: 상태머신·가드레일·엔진 어댑터 호출부·SSE 스트리머·RBAC (마스터플랜 §5).
엔진 게이트웨이(engine_gateway/) 람다 직접 소유 여기선 engine_client 호출만.
"""
from __future__ import annotations
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from . import __version__
from .config import settings
from .db import close_pool, healthcheck, init_pool
from .engine_client import engine_client
from .routes import auth as auth_routes
from .routes import sessions as session_routes
@asynccontextmanager
async def lifespan(app: FastAPI):
"""startup: DB 풀 + 엔진 클라이언트 / shutdown: 정리."""
await init_pool()
await engine_client.startup()
try:
yield
finally:
await engine_client.shutdown()
await close_pool()
app = FastAPI(
title="Vignette API",
version=__version__,
description="상담 시뮬레이션 훈련 플랫폼 백엔드 (FastAPI · 상태머신 · 가드레일 · RBAC)",
lifespan=lifespan,
)
# CORS — 정적 프론트(chanpaca.net) + SSE 분리경로(stream.chanpaca.net)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True, # __Host- HttpOnly 쿠키 전송
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["*"],
)
# TODO: Presidio PII 마스킹 미들웨어 (외부 LLM 경로 진입 전 하드 게이트, R7/F-03)
app.include_router(auth_routes.router)
app.include_router(session_routes.router)
@app.get("/health", tags=["meta"])
async def health() -> dict[str, object]:
"""liveness + DB + 엔진 게이트웨이 readiness."""
db_ok = await healthcheck()
engine_ok = await engine_client.health()
return {
"status": "ok" if db_ok else "degraded",
"version": __version__,
"environment": settings.environment,
"db": db_ok,
"engine": engine_ok,
"engine_mode": settings.engine_mode,
}

View file

@ -0,0 +1 @@
"""API 라우터 모음."""

View file

@ -0,0 +1,84 @@
"""인증 라우트 — BFF OAuth 2.1 Auth Code + PKCE(S256) 스텁.
마스터플랜 §5: BFF + OAuth 2.1, 토큰은 서버(Redis)에만, 브라우저엔 __Host- HttpOnly 쿠키.
미성년 사례데이터 + 상담 민감정보 -> XSS 토큰탈취 원천 차단.
1 = Google OIDC 단독, 한신대 SSO 2(R11, Authlib provider 추상화 ).
파일은 라우트 시그니처 + 흐름 + TODO. 실제 OAuth 교환/Redis 세션은 Phase 2 트랙 B.
"""
from __future__ import annotations
from typing import Annotated, Optional
from fastapi import APIRouter, HTTPException, Query, Response, status
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from ..config import settings
from ..deps import CurrentPrincipal
router = APIRouter(prefix="/auth", tags=["auth"])
class MeResponse(BaseModel):
user_id: str
role: str
cohort_ids: list[str]
@router.get("/login")
async def login(
provider: Annotated[str, Query()] = "google",
) -> 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.
"""
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)")
@router.get("/callback")
async def callback(
response: Response,
code: Annotated[Optional[str], Query()] = None,
state: Annotated[Optional[str], Query()] = None,
) -> RedirectResponse:
"""OAuth 콜백 — code -> token 교환 후 서버 세션 발급.
절차:
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.
"""
raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail="OAuth callback TODO (Phase 2 트랙 B)")
@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")
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,
)

View file

@ -0,0 +1,188 @@
"""상담 세션 라우트 — 시작 / 턴 / 종료 + SSE 스트림 스텁.
흐름 (설계서 §2 회기 라이프사이클 + 마스터플랜 §2.2 사이클):
POST /sessions 회기 시작 (case_profile/summary 회상 + session_state 초기화)
POST /sessions/{id}/turn 수련생 발화 1 (가드레일상태머신내담자AI평가)
GET /sessions/{id}/stream 내담자 AI 응답 SSE 스트림 (Cloudflare 우회 heartbeat)
POST /sessions/{id}/end 회기 종료 (무손실 carry-over + LLM 압축 트리거)
상태머신(라포탐색개입정리) 백엔드가 결정론적으로 소유(LLM 아님, 마스터플랜 §0).
파일은 핸들러 시그니처 + 계약 + TODO. 실제 상태머신/가드레일/압축은 Phase 1~2a 트랙 A.
"""
from __future__ import annotations
import asyncio
import json
from typing import Annotated, Literal, Optional
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sse_starlette.sse import EventSourceResponse
from ..config import settings
from ..deps import CurrentPrincipal, HumanDB
from ..engine_client import EngineMessage, StreamRequest, engine_client, EngineError
router = APIRouter(prefix="/sessions", tags=["sessions"])
Stage = Literal["라포", "탐색", "개입", "정리"]
# ── 요청/응답 모델 ──────────────────────────────────────
class SessionStartRequest(BaseModel):
persona_code: str = Field(..., examples=["P1"]) # 시드 페르소나 (P1/P2/P3)
theory_mode: Literal["humanistic", "cbt", "integrative"] = "humanistic"
class SessionStartResponse(BaseModel):
session_id: UUID
case_id: UUID
session_no: int
stage: Stage
# 회기 시작 회상 요약 (큰그림→세부, UI 카드용. CCD/정답은 절대 미포함)
recall_summary: Optional[str] = None
class TurnRequest(BaseModel):
text: str = Field(..., min_length=1) # 수련생 발화 (저장 전 PII 마스킹)
class TurnResponse(BaseModel):
turn_seq: int
stage: Stage
effective_openness: float
# 내담자 응답은 스트림(GET /stream)으로 받는 게 기본. 동기 응답은 폴백/테스트용.
client_reply: Optional[str] = None
safety_flagged: bool = False
class SessionEndResponse(BaseModel):
session_id: UUID
session_no: int
digest_pending: bool # 압축은 비동기 비블로킹 (설계서 §2-C)
# ── 핸들러 ──────────────────────────────────────────────
@router.post("", response_model=SessionStartResponse, status_code=status.HTTP_201_CREATED)
async def start_session(
body: SessionStartRequest,
principal: CurrentPrincipal,
conn: HumanDB,
) -> SessionStartResponse:
"""회기 시작 — 회상 + 상태 복원 (설계서 §2-A).
절차:
1. persona_card(approved) 조회 + (persona_id, learner_id) -> case_profile upsert
2. case_digest + 직전 session_summary + episodic recall (Phase 2a, 1차는 단일회기)
3. session_state 초기화: stage='라포', carry-over (rapport×0.7, ideation 보수적 유지) [P2]
4. sessions insert
TODO: persona 조회/회상/상태머신 init 구현 (트랙 A). 현재 스텁 응답.
"""
# TODO: SELECT persona_id FROM app.persona_card WHERE code=$1 AND status='approved'
# TODO: init_session_state_from_history() — 결정론 carry-over
session_id = uuid4()
case_id = uuid4()
return SessionStartResponse(
session_id=session_id,
case_id=case_id,
session_no=1,
stage="라포",
recall_summary=None, # Phase 2a 회상 채움
)
@router.post("/{session_id}/turn", response_model=TurnResponse)
async def submit_turn(
session_id: UUID,
body: TurnRequest,
principal: CurrentPrincipal,
conn: HumanDB,
) -> TurnResponse:
"""수련생 발화 1턴 (마스터플랜 §2.2 / 설계서 §2-B).
파이프라인 (전부 백엔드 결정론 게이트):
1. [입력 가드레일] Presidio PII 마스킹 + 위기분류(실제위기 vs 페르소나 연기) [R7/F-03]
2. [상태머신] effective_openness = clamp(stage.openness
+ rapport_credit*unlock_rate - resistance*decay, 0, 1) [P2, 결정론]
3. [모순 검사] pinned_fact locked 모순 -> 차단·재생성 (설계서 §2-B)
4. [내담자 AI] engine_client.stream/generate (CCD 직접노출 금지, Structured Outputs)
5. [출력 가드레일] 자살수단 차단, ideation_stage <= 3 상한 [R5]
6. [평가 AI] fast-loop 4차원 태깅 (deep-loop 단계전환/회기말)
7. [working 갱신] session_state UPSERT (체크포인트)
8. [로깅] turns insert + 임베딩 (재귀학습 원천)
TODO: 1~8 구현 (트랙 A). 현재 스텁: 발화 검증만.
"""
# TODO: load session_state, run guardrail + state machine deterministically
# 동기 응답은 폴백. 기본 UX 는 GET /stream 으로 토큰 스트리밍.
return TurnResponse(
turn_seq=0,
stage="라포",
effective_openness=0.15,
client_reply=None,
safety_flagged=False,
)
@router.get("/{session_id}/stream")
async def stream_client_reply(
session_id: UUID,
principal: CurrentPrincipal,
):
"""내담자 AI 응답 SSE 스트림 (마스터플랜 §1.1 SSE 분리경로).
- Cloudflare 100 timeout 회피: settings.sse_heartbeat_seconds 마다 ping 이벤트 [R2]
- 게이트웨이 SSE(engine_client.stream) 프록시해 토큰을 재방출
- 이벤트: {event: "token"|"done"|"safety"|"ping", data: ...}
TODO: 게이트웨이와 StreamRequest 바디 결합(현재 stage 회상 컨텍스트 없이 placeholder).
상태머신 컨텍스트(L3 stage/openness) + 마스킹된 최근 N턴 주입.
"""
async def event_generator():
# heartbeat 와 엔진 스트림을 병행 (Cloudflare 버퍼링/타임아웃 회피)
last_beat = asyncio.get_event_loop().time()
# TODO: 실제 StreamRequest 조립 — session_state 에서 stage/openness/최근턴 로드
req = StreamRequest(
ai_role="client",
tier="client",
messages=[
EngineMessage(role="system", content="<persona L0~L2 cache_control 주입 TODO>", cache=True),
EngineMessage(role="user", content="<masked latest learner turn TODO>"),
],
)
try:
async for chunk in engine_client.stream(req):
yield {"event": "token", "data": chunk}
now = asyncio.get_event_loop().time()
if now - last_beat >= settings.sse_heartbeat_seconds:
yield {"event": "ping", "data": "{}"}
last_beat = now
yield {"event": "done", "data": json.dumps({"session_id": str(session_id)})}
except EngineError as e:
yield {"event": "error", "data": json.dumps({"detail": str(e)})}
return EventSourceResponse(event_generator())
@router.post("/{session_id}/end", response_model=SessionEndResponse)
async def end_session(
session_id: UUID,
principal: CurrentPrincipal,
conn: HumanDB,
) -> SessionEndResponse:
"""회기 종료 — carry-over + 압축 트리거 (설계서 §2-C, 비동기 비블로킹).
절차:
(A) 무손실 carry-over: end_state = session_state 종료 snapshot (코드 복사, LLM 미경유) [P4]
(B) salience 산출 -> 망각/유지
(C) narrative 압축 (LLM, 상주 claude -p 재사용) 비동기
(D~G) digest 임베딩 / case_profile 병합 / pinned 모순처리 / RAG 동기화
+ 상주 프로세스 회수 (말투표류 회기경계 차단)
TODO: (A) 동기 수행 (B~G) BackgroundTasks/큐로 비블로킹. 현재 스텁.
"""
# TODO: UPDATE app.sessions SET ended_at=now(); copy end_state; enqueue compression
return SessionEndResponse(session_id=session_id, session_no=1, digest_pending=True)

285
apps/api/app/taxonomy.py Normal file
View file

@ -0,0 +1,285 @@
"""발화 라벨 taxonomy + annotation 데이터 모델 (0615 파란색 라벨 → 구조화 스키마).
근거:
- 데이터/README.md: 0615 축어록 파란색(#3057B9) 주석 = 3종 혼재
(기법 태그 / 내담자 상태 태그 / 슈퍼바이저 논평).
- MASTERPLAN.md §3.1: 0615.hwpx 직접 파싱 결과 파랑 라벨 89·77, 셀에 3 혼재
-> 학습 신호 오염 방지를 위해 **3 분리 필드로 격상** 스키마 설계의 출발점.
- MASTERPLAN.md §3.2: technique_label_def / client_state_def / stage_def 코드테이블 +
turn_technique / turn_client_state 다대다 + supervisor_comment(kind: rationale|critique).
- MEMORY_KNOWLEDGE_PERSONA_DESIGN.md §3.6: kb.chunk.label_id taxonomy 라벨을 FK 참조.
모듈은 평가 AI 정답셋(golden label) + 페르소나 상태전이 정답의 **단일 코드 원천**이다.
DB 코드테이블(technique_label_def ) 동기화 대상 -> seed enum.value code 컬럼에 적재.
설계 원칙:
- 위계적(hierarchical): 기법은 군집(category) 아래에 둔다 -> 평가 분포·집계·과다/과소 판정용.
- 조작적 정의(operational definition) docs/taxonomy.md SoT, 여기엔 enum + 메타만.
- 라벨은 **버전드**(코드 불변, 라벨 추가는 append-only) -> 골든셋 재현성 보존.
원본(0615) 자체는 절대 적재하지 않는다(미성년·자살사고, .gitignore data/raw). 합성 변형만 예시화.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
TAXONOMY_VERSION = "1.0.0" # 라벨 코드 불변 보장 버전. 새 라벨 추가 시 minor++.
# ════════════════════════════════════════════════════════════════════════════
# 1. 회기 단계 (stage) — 결정론 상태머신이 소유 (MASTERPLAN §2.2)
# ════════════════════════════════════════════════════════════════════════════
class Stage(str, Enum):
"""상담 회기 단계. FastAPI 백엔드가 결정론적으로 전이(LLM 아님)."""
RAPPORT = "라포" # 라포 형성 — 안전감·관계 구축, 비밀보장 구조화
EXPLORE = "탐색" # 호소문제·정서·인지·위험요인 탐색
INTERVENE = "개입" # 직면·해석·인지재구성·교정적 정서체험
CLOSE = "정리" # 요약·과제·다음 회기 연결·종결
# ════════════════════════════════════════════════════════════════════════════
# 2. 화자 (speaker) — 발화의 최소 원자 출처
# ════════════════════════════════════════════════════════════════════════════
class Speaker(str, Enum):
"""발화 화자. 0615 축어록의 '상N'/'내N' 라벨에 대응."""
COUNSELOR = "counselor" # 상담자(수련생 또는 상담사 AI)
CLIENT = "client" # 내담자(가상내담자 AI)
# ════════════════════════════════════════════════════════════════════════════
# 3. 기법 군집 (TechniqueCategory) — 위계 상위층
# ════════════════════════════════════════════════════════════════════════════
class TechniqueCategory(str, Enum):
"""상담자 기법의 상위 군집. 평가 분포·과다/과소 집계·리뷰 사이드 게이지의 축."""
RELATIONAL = "relational" # 관계 형성: 공감·반영·타당화·홀딩·자기공개·유머
EXPLORATORY = "exploratory" # 탐색: 촉진질문·탐색·위험사정·동의/동기 확인
INTERVENTION = "intervention" # 개입: 직면·해석·인지재구성·교정적 정서체험
STABILIZING = "stabilizing" # 안정/지지: 안정화·희망고취·강화·정상화
STRUCTURING = "structuring" # 구조화: 상담원칙/비밀보장 설명·과제·심리교육
# ════════════════════════════════════════════════════════════════════════════
# 4. 기법 라벨 (Technique) — 위계 하위층 (0615 파란색 기법 태그 origin)
# ════════════════════════════════════════════════════════════════════════════
class Technique(str, Enum):
"""발화별 상담 기법 라벨(복수 부착 가능). 값(value)=DB code, 0615 표면 라벨에서 정규화.
조작적 정의·예시는 docs/taxonomy.md 참조. 여기 값은 안정적 머신 코드.
"""
# ── RELATIONAL ──
EMPATHY = "empathy" # 공감 (0615: "공감")
REFLECTION = "reflection" # 반영 (0615: "반영", "정서 반영", "내담자의 반응을 반영함")
VALIDATION = "validation" # 타당화 (0615: "타당화", "감정 노출의 타당화")
HOLDING = "holding" # 홀딩 — 침묵 견디기/기다려 줌 (0615: "홀딩", "탐색과 홀딩")
SELF_DISCLOSURE = "self_disclosure" # 자기공개 (0615: "라포형성을 위한 상담자의 자기 개방")
AFFECT_CONVEYANCE = "affect_conveyance" # 상담자 정서 전달 (0615: "상담자의 정서 전달")
HUMOR = "humor" # 유머 (0615: "유머")
# ── EXPLORATORY ──
EXPLORATION = "exploration" # 탐색 (0615: "탐색", "탐색질문")
FACILITATIVE_QUESTION = "facilitative_question" # 촉진/개방 질문 (0615: "촉진", "참여 촉진 질문")
RISK_ASSESSMENT = "risk_assessment" # 위험요인/자해·자살 탐색 (0615: "위험요인 탐색", "자해 위험 및 경험 탐색")
CONSENT_MOTIVATION_CHECK = "consent_motivation_check" # 동의/동기 확인 (0615: "동의확인, 동기수준 확인")
OPINION_CHECK = "opinion_check" # 내담자 의견·반응 확인 (0615: "내담자 의견 확인", "주호소 문제 재확인")
# ── INTERVENTION ──
CONFRONTATION = "confrontation" # 직면 (0615: "기초자료를 활용하여 반응의 불일치에 직면시킴")
INTERPRETATION = "interpretation" # 해석 (0615: "모순의 의미를 해석함", "비언어적 자극 해석", "행동의 재해석")
# ── STABILIZING ──
STABILIZATION = "stabilization" # 안정화 (0615: "안정화")
HOPE_INSTILLATION = "hope_instillation" # 희망고취 (0615: "동기부여, 희망고취", "희망 고취, 욕구 반영")
REINFORCEMENT = "reinforcement" # 강화 (0615: "강화")
NORMALIZATION = "normalization" # 정상화 (0615: "감정 반응을 노출하는 것을 정상화함")
# ── STRUCTURING ──
PRINCIPLE_EXPLANATION = "principle_explanation" # 상담원칙/비밀보장 설명 (0615: "상담원칙에 대한 설명", "비밀보장 제외 원칙 설명")
PSYCHOEDUCATION = "psychoeducation" # 심리교육/보편교범 전달 (0615: "보편적 교범 전달, 주관적 해석과 구별")
HOMEWORK = "homework" # 과제 부여 (0615: "다음 회기까지 해 와야 하는 과제를 설명")
# 기법 -> 군집 매핑 (위계). 집계·분포·과다/과소 판정에 사용.
TECHNIQUE_CATEGORY: dict[Technique, TechniqueCategory] = {
Technique.EMPATHY: TechniqueCategory.RELATIONAL,
Technique.REFLECTION: TechniqueCategory.RELATIONAL,
Technique.VALIDATION: TechniqueCategory.RELATIONAL,
Technique.HOLDING: TechniqueCategory.RELATIONAL,
Technique.SELF_DISCLOSURE: TechniqueCategory.RELATIONAL,
Technique.AFFECT_CONVEYANCE: TechniqueCategory.RELATIONAL,
Technique.HUMOR: TechniqueCategory.RELATIONAL,
Technique.EXPLORATION: TechniqueCategory.EXPLORATORY,
Technique.FACILITATIVE_QUESTION: TechniqueCategory.EXPLORATORY,
Technique.RISK_ASSESSMENT: TechniqueCategory.EXPLORATORY,
Technique.CONSENT_MOTIVATION_CHECK: TechniqueCategory.EXPLORATORY,
Technique.OPINION_CHECK: TechniqueCategory.EXPLORATORY,
Technique.CONFRONTATION: TechniqueCategory.INTERVENTION,
Technique.INTERPRETATION: TechniqueCategory.INTERVENTION,
Technique.STABILIZATION: TechniqueCategory.STABILIZING,
Technique.HOPE_INSTILLATION: TechniqueCategory.STABILIZING,
Technique.REINFORCEMENT: TechniqueCategory.STABILIZING,
Technique.NORMALIZATION: TechniqueCategory.STABILIZING,
Technique.PRINCIPLE_EXPLANATION: TechniqueCategory.STRUCTURING,
Technique.PSYCHOEDUCATION: TechniqueCategory.STRUCTURING,
Technique.HOMEWORK: TechniqueCategory.STRUCTURING,
}
# 한글 표시명(UI 라벨 칩·리뷰 화면). docs/taxonomy.md 와 일치.
TECHNIQUE_KO: dict[Technique, str] = {
Technique.EMPATHY: "공감",
Technique.REFLECTION: "반영",
Technique.VALIDATION: "타당화",
Technique.HOLDING: "홀딩",
Technique.SELF_DISCLOSURE: "자기공개",
Technique.AFFECT_CONVEYANCE: "정서 전달",
Technique.HUMOR: "유머",
Technique.EXPLORATION: "탐색",
Technique.FACILITATIVE_QUESTION: "촉진질문",
Technique.RISK_ASSESSMENT: "위험사정",
Technique.CONSENT_MOTIVATION_CHECK: "동의·동기 확인",
Technique.OPINION_CHECK: "의견 확인",
Technique.CONFRONTATION: "직면",
Technique.INTERPRETATION: "해석",
Technique.STABILIZATION: "안정화",
Technique.HOPE_INSTILLATION: "희망고취",
Technique.REINFORCEMENT: "강화",
Technique.NORMALIZATION: "정상화",
Technique.PRINCIPLE_EXPLANATION: "상담원칙 설명",
Technique.PSYCHOEDUCATION: "심리교육",
Technique.HOMEWORK: "과제 부여",
}
# ════════════════════════════════════════════════════════════════════════════
# 5. 내담자 상태 태그 (ClientState) — 0615 파란색 상태 태그 origin
# ════════════════════════════════════════════════════════════════════════════
class ClientState(str, Enum):
"""내담자 발화/비언어에 부착되는 상태 태그(복수 가능).
페르소나 상태전이(저항 엔진) 정답 신호 + 평가 AI '반응 읽기' 채점 근거.
0615 파란색 상태 태그에서 정규화.
"""
INVOLUNTARY = "involuntary" # 비자발적 태도 (0615: "(아니오)는 비자발적 태도", "(쓴웃음)은 비자발적 태도")
DEFENSIVE = "defensive" # 방어 (0615: "방어", "부모자녀 관계에 대한 방어적 태도")
SUICIDAL_IDEATION_ADMIT = "suicidal_ideation_admit" # 자살사고 인정 (0615: "자살사고 인정")
NEGATIVE_SELF_PERCEPTION = "negative_self_perception" # 부정적 자기인식 (0615: "부정적 자기인식", "부정적인 자기인식")
CONFLICTED = "conflicted" # 갈등 상태 (0615: "갈등 상태")
LACK_OF_CONFIDENCE = "lack_of_confidence" # 확신/자신감 부족 (0615: "확신의 부족")
AFFECT_CONTACT = "affect_contact" # 정서와 접촉/표현 (0615: "정서와 접촉함", "정서와 사고의 표현")
THOUGHT_ORGANIZING = "thought_organizing" # 생각을 정리함 (0615: "생각을 정리함")
RESPONDS_TO_EXPLORATION = "responds_to_exploration" # 탐색에 반응함 (0615: "상담자의 탐색에 반응함")
EXPRESSES_PLAN = "expresses_plan" # 자기 계획/욕구 표현 (0615: "자신의 계획을 표현함")
DEFENSE_LOOSENING = "defense_loosening" # 방어가 서서히 풀림 (0615: "방어가 서서히 풀어지고 있음")
CLIENT_STATE_KO: dict[ClientState, str] = {
ClientState.INVOLUNTARY: "비자발적 태도",
ClientState.DEFENSIVE: "방어",
ClientState.SUICIDAL_IDEATION_ADMIT: "자살사고 인정",
ClientState.NEGATIVE_SELF_PERCEPTION: "부정적 자기인식",
ClientState.CONFLICTED: "갈등 상태",
ClientState.LACK_OF_CONFIDENCE: "확신 부족",
ClientState.AFFECT_CONTACT: "정서 접촉/표현",
ClientState.THOUGHT_ORGANIZING: "생각 정리",
ClientState.RESPONDS_TO_EXPLORATION: "탐색에 반응",
ClientState.EXPRESSES_PLAN: "계획/욕구 표현",
ClientState.DEFENSE_LOOSENING: "방어 완화",
}
# ════════════════════════════════════════════════════════════════════════════
# 6. 슈퍼바이저 논평 (SupervisorComment) — 0615 논평 origin
# ════════════════════════════════════════════════════════════════════════════
class CommentKind(str, Enum):
"""슈퍼바이저 논평 종류 (MASTERPLAN §3.1, §3.2: rationale vs critique 분리).
윤찬 지시: '의도와 다른 부분'(intent_deviation) 1 시민.
"""
RATIONALE = "rationale" # 근거/의도 설명 — 왜 이 반응이 적절한가 (0615 회색 '·' 논평 + 일부 파랑)
CRITIQUE = "critique" # 개선점/주의 — 과도/부족/평가적 시각 등 (0615: "과도한 자기개방일 수 있음")
@dataclass(slots=True)
class SupervisorComment:
"""발화에 달린 슈퍼바이저 논평 한 건.
critique 경우 intent_deviation 으로 '의도(expected) vs 실제(actual)' 격차를 구조화.
) 0615 "비언어적 반응의 반영이 더 되었으면" -> dimension=reflection, severity=minor.
"""
kind: CommentKind
text: str
# critique 전용(선택): 평가 차원·기대·실제·심각도. README/§3.2 intent_deviation JSONB 대응.
dimension: Optional[str] = None # 관련 평가 차원 (예: "reflection", "self_disclosure", "pacing")
expected: Optional[str] = None # 권장된 반응/의도
actual: Optional[str] = None # 실제 나타난 반응
severity: Optional[str] = None # "minor" | "moderate" | "major"
author: str = "ai" # "ai"(자동 1R) | "supervisor"(교수 검수 2R)
# ════════════════════════════════════════════════════════════════════════════
# 7. 발화 annotation (Annotation) — 3축 분리 최종 레코드 (golden 단위)
# ════════════════════════════════════════════════════════════════════════════
@dataclass(slots=True)
class Annotation:
"""발화 1개의 구조화 annotation = golden_schema.jsonl 한 줄에 대응.
README 스키마:
{"발화ID", "화자", "단계", "발화텍스트", "기법라벨"[], "내담자상태"[], "슈퍼바이저논평"}
3 분리(기법/내담자상태/논평) 격상한 머신 표현.
부착 규칙(평가 AI 정답셋 일관성):
- techniques speaker=COUNSELOR 발화에만 부착(내담자 발화엔 리스트).
- client_states speaker=CLIENT 발화(또는 비언어 표현)에만 부착.
- comments 화자 모두 가능(슈퍼비전 관점).
- nonverbal: 괄호 표기 비언어 단서("(쓴웃음)","(침묵 10초)") -> 검증/상태판정 보조.
"""
utterance_id: str # 예 "0615-C-001"(상1), "0615-K-003"(내3). 화자+seq 인코딩
speaker: Speaker
stage: Stage
text: str # 발화 텍스트 (예시는 합성 변형, 원문 미적재)
seq: int # 회기 내 발화 순서(0-based)
techniques: list[Technique] = field(default_factory=list) # 기법 라벨(복수)
client_states: list[ClientState] = field(default_factory=list) # 내담자 상태(복수)
comments: list[SupervisorComment] = field(default_factory=list) # 슈퍼바이저 논평(복수)
nonverbal: list[str] = field(default_factory=list) # 비언어 단서 원문 표기
source: str = "0615_synthetic" # provenance: 0615 합성 변형(원문 미적재, F-05)
def __post_init__(self) -> None:
# 정합성: 기법은 상담자, 상태는 내담자에만 (평가 신호 오염 방지).
if self.techniques and self.speaker is not Speaker.COUNSELOR:
raise ValueError(
f"techniques 는 상담자 발화에만 부착 가능: {self.utterance_id}"
)
if self.client_states and self.speaker is not Speaker.CLIENT:
raise ValueError(
f"client_states 는 내담자 발화에만 부착 가능: {self.utterance_id}"
)
def technique_categories(self) -> set[TechniqueCategory]:
"""부착된 기법들의 군집 집합 (분포 집계용)."""
return {TECHNIQUE_CATEGORY[t] for t in self.techniques}
__all__ = [
"TAXONOMY_VERSION",
"Stage",
"Speaker",
"TechniqueCategory",
"Technique",
"TECHNIQUE_CATEGORY",
"TECHNIQUE_KO",
"ClientState",
"CLIENT_STATE_KO",
"CommentKind",
"SupervisorComment",
"Annotation",
]

View file

@ -0,0 +1,19 @@
# 엔진 게이트웨이
로컬 claude -p(Opus 4.8) 상주 멀티턴 풀. **컨테이너 밖(호스트)** 실행, api가 `ENGINE_URL`로 호출.
## 실행
```
cd apps/api
python -m uvicorn engine_gateway.gateway:app --host 127.0.0.1 --port 9099
```
## API
- `POST /session {system_prompt, budget_usd}` -> `{session_id}` (회기=프로세스 1개)
- `POST /session/{id}/turn {content}` -> `{text, cost_usd, turns}`
- `DELETE /session/{id}`
- `GET /health`
## 검증 (2026-06-25)
세션 생성+멀티턴 2턴(서연 페르소나) 컨텍스트 유지 + 캐시 재사용 비용절감 실동작 확인.
환경변수: `CLAUDE_BIN`, `ENGINE_MODEL`(비우면 Opus4.8), `ENGINE_FALLBACK_MODEL`, `SESSION_BUDGET_USD`.

View file

View file

@ -0,0 +1,153 @@
"""
Vignette 엔진 게이트웨이 로컬 claude -p(Opus 4.8) 상주 멀티턴 .
컨테이너 (호스트)에서 실행한다. api 컨테이너가 ENGINE_URL(: http://host.docker.internal:9099) HTTP 호출.
회기당 1 EngineSession = claude -p 1 프로세스 상주. 페르소나 시스템프롬프트를 고정하고 발화마다 stdin 주입.
컨텍스트 유지 + prompt caching 재사용(2026-06-25 실증, reference_claude_p_resident_engine).
실행:
cd apps/api && uvicorn engine_gateway.gateway:app --host 0.0.0.0 --port 9099
"""
import asyncio
import json
import os
import uuid
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
CLAUDE_BIN = os.environ.get("CLAUDE_BIN", "claude")
DEFAULT_MODEL = os.environ.get("ENGINE_MODEL", "") # 비우면 CLI 기본(Opus 4.8)
FALLBACK_MODEL = os.environ.get("ENGINE_FALLBACK_MODEL", "")
DEFAULT_BUDGET = float(os.environ.get("SESSION_BUDGET_USD", "5.0"))
BASE_ARGS = [
"-p",
"--input-format", "stream-json",
"--output-format", "stream-json",
"--verbose",
"--dangerously-skip-permissions",
]
class EngineSession:
"""claude -p 상주 프로세스 1개 = 상담 회기 1개."""
def __init__(self, system_prompt: str | None = None, budget: float = DEFAULT_BUDGET):
self.id = uuid.uuid4().hex
self.system_prompt = system_prompt
self.budget = budget
self.proc: asyncio.subprocess.Process | None = None
self.lock = asyncio.Lock() # 한 회기 안의 턴은 직렬(상담 왕복)
self.cost_usd = 0.0
self.turns = 0
async def start(self) -> None:
args = [CLAUDE_BIN, *BASE_ARGS, "--max-budget-usd", str(self.budget)]
if DEFAULT_MODEL:
args += ["--model", DEFAULT_MODEL]
if FALLBACK_MODEL:
args += ["--fallback-model", FALLBACK_MODEL]
if self.system_prompt:
args += ["--append-system-prompt", self.system_prompt]
self.proc = await asyncio.create_subprocess_exec(
*args,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
async def turn(self, content: str, timeout: float = 120.0) -> dict:
if self.proc is None or self.proc.returncode is not None:
raise RuntimeError("engine session not running")
async with self.lock:
msg = json.dumps(
{"type": "user", "message": {"role": "user", "content": content}},
ensure_ascii=False,
)
self.proc.stdin.write((msg + "\n").encode("utf-8"))
await self.proc.stdin.drain()
text_parts: list[str] = []
async def _read_until_result() -> dict:
# 한 턴: system(init/hook) 라인 무시 → assistant 텍스트 누적 → result 에서 종료
while True:
line = await self.proc.stdout.readline()
if not line:
return {}
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
t = obj.get("type")
if t == "assistant":
for c in obj.get("message", {}).get("content", []):
if c.get("type") == "text":
text_parts.append(c["text"])
elif t == "result":
return obj
result = await asyncio.wait_for(_read_until_result(), timeout=timeout)
self.cost_usd = result.get("total_cost_usd", self.cost_usd)
self.turns += 1
return {
"text": "".join(text_parts),
"cost_usd": self.cost_usd,
"turns": self.turns,
"is_error": result.get("is_error", False),
"error": (result.get("errors") or [None])[0],
}
async def close(self) -> None:
if self.proc and self.proc.returncode is None:
try:
self.proc.stdin.close()
except Exception:
pass
try:
await asyncio.wait_for(self.proc.wait(), timeout=5)
except Exception:
self.proc.kill()
SESSIONS: dict[str, EngineSession] = {}
app = FastAPI(title="Vignette Engine Gateway")
class CreateReq(BaseModel):
system_prompt: str | None = None
budget_usd: float | None = None
class TurnReq(BaseModel):
content: str
@app.get("/health")
async def health():
return {"ok": True, "engine": "claude_p", "model": DEFAULT_MODEL or "default(opus-4-8)", "sessions": len(SESSIONS)}
@app.post("/session")
async def create_session(req: CreateReq):
s = EngineSession(system_prompt=req.system_prompt, budget=req.budget_usd or DEFAULT_BUDGET)
await s.start()
SESSIONS[s.id] = s
return {"session_id": s.id}
@app.post("/session/{sid}/turn")
async def turn(sid: str, req: TurnReq):
s = SESSIONS.get(sid)
if not s:
raise HTTPException(404, "session not found")
return await s.turn(req.content)
@app.delete("/session/{sid}")
async def close_session(sid: str):
s = SESSIONS.pop(sid, None)
if s:
await s.close()
return {"closed": bool(s)}

View file

@ -0,0 +1 @@
fastapi\nuvicorn[standard]

View file

@ -2,5 +2,7 @@ fastapi
uvicorn[standard]
asyncpg
pydantic
pydantic-settings
python-multipart
httpx
sse-starlette

5
docs/golden_schema.jsonl Normal file
View file

@ -0,0 +1,5 @@
{"utterance_id": "0615-C-001", "speaker": "counselor", "stage": "라포", "seq": 0, "text": "서연이한테 정식으로 인사할게요. 상담자 이OO이에요. 대학원에서 상담심리를 전공했고, 지금 여기서 서연이 같은 친구들을 많이 만나고 있어요. 혹시 나한테 궁금한 거 있을까요?", "techniques": ["self_disclosure"], "client_states": [], "comments": [{"kind": "rationale", "text": "라포 형성을 위한 상담자의 자기개방. 어머니와 다른 생각을 가질 수 있음과 내담자 생각을 존중하는 태도를 전달함.", "author": "ai"}], "nonverbal": [], "source": "0615_synthetic"}
{"utterance_id": "0615-K-002", "speaker": "client", "stage": "라포", "seq": 1, "text": "글쎄요. (한숨, 침묵 10초)", "techniques": [], "client_states": ["involuntary"], "comments": [{"kind": "rationale", "text": "단답과 한숨·침묵은 비자발적 태도의 신호. 상담 초기 저항 수준을 반영한다.", "author": "ai"}], "nonverbal": ["(한숨)", "(침묵 10초)"], "source": "0615_synthetic"}
{"utterance_id": "0615-C-003", "speaker": "counselor", "stage": "라포", "seq": 2, "text": "한숨이 무겁게 느껴지네요. 상담이 처음이라 어디서부터 어떻게 말해야 하나 막막할 수도 있어요. 무리하지 말고 천천히, 바로잡고 싶은 게 떠오르면 이야기해 줘요.", "techniques": ["empathy", "holding"], "client_states": [], "comments": [{"kind": "rationale", "text": "비자발적 내담자의 초기 상담에서는 상담자의 적극적 안내와 기다림(홀딩)이 필요함.", "author": "ai"}], "nonverbal": [], "source": "0615_synthetic"}
{"utterance_id": "0615-C-004", "speaker": "counselor", "stage": "탐색", "seq": 3, "text": "그냥 익숙하다… 음… 혹시 그 단어들이 서연이한테 익숙해질 만한 경험들이 있었던 걸까? (잠시 멈춤) 아니라고 말을 안 하네. 혹시 경험이 있었다면 언제일까요? 최근에도?", "techniques": ["exploration", "holding"], "client_states": [], "comments": [{"kind": "rationale", "text": "다루기 무거운 주제이지만 직접 확인이 필요한 사안이라, 반영과 홀딩을 섞어 차분히 탐색 질문을 이어감.", "author": "ai"}], "nonverbal": ["(잠시 멈춤)"], "source": "0615_synthetic"}
{"utterance_id": "0615-K-005", "speaker": "client", "stage": "탐색", "seq": 4, "text": "그냥 이렇게 살아서 뭐 하나 (잠시 멈춤) 죽어야겠다, 죽고 싶다 정도….", "techniques": [], "client_states": ["affect_contact", "suicidal_ideation_admit", "defense_loosening"], "comments": [{"kind": "rationale", "text": "상담자의 지속된 반영과 탐색 질문으로 방어가 서서히 풀리며 자살사고를 인정함. 정서와 사고를 처음으로 표현함.", "author": "ai"}, {"kind": "critique", "text": "다음 상담자 반응에서 자살사고의 의미에 머무르기보다 욕구 차원 치환으로 빠르게 넘어갈 위험이 있음. 눈물·현재 감정 등 비언어 반영에 더 머물 수 있었음.", "dimension": "reflection", "expected": "내담자의 자살사고 표현 직후 정서에 머물러 의미를 더 탐색", "actual": "긍정적 욕구로의 치환 질문으로 이동", "severity": "minor", "author": "supervisor"}], "nonverbal": ["(잠시 멈춤)"], "source": "0615_synthetic"}

371
infra/db/init/02_schema.sql Normal file
View file

@ -0,0 +1,371 @@
-- =============================================================================
-- Vignette · 02_schema.sql
-- app 스키마: 세션 + 라벨 코드테이블 + 페르소나(불변 카드) + 메모리 4계층
-- (① working / ② episodic / ③ summary / ④ semantic)
-- 근거: MEMORY_KNOWLEDGE_PERSONA_DESIGN.md §3.1~3.5, MASTERPLAN.md §3.2
-- 전제: 01_extensions.sql 이 vector 확장 + 스키마(app/audit/kb) 선생성.
-- 적용 순서: 01 → 02(본 파일) → 03_kb → 04_audit_eval_rls
-- 원칙(설계서 §0.3): 원본 append-only, 상태수치 결정론 carry-over, visible_to DB 강제.
-- Phase 0 에 전체 선반영(대부분 nullable), cross-session 채우기는 Phase 2a.
-- =============================================================================
SET search_path TO app, public;
-- 01_extensions.sql 미적용 환경에서도 단독 실행 가능하도록 방어적 보강.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pgcrypto; -- gen_random_uuid()
CREATE SCHEMA IF NOT EXISTS app;
CREATE SCHEMA IF NOT EXISTS audit;
CREATE SCHEMA IF NOT EXISTS kb;
-- =============================================================================
-- 0. 인적 주체 (RBAC 앵커) — 학습자/교수자/관리자
-- visible_to / RLS 가 참조하는 최소 사용자 테이블. SSO 매핑은 BFF 소유.
-- =============================================================================
CREATE TABLE IF NOT EXISTS app.app_user (
user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
external_id TEXT UNIQUE, -- Google OIDC sub 등
email TEXT,
display_name TEXT,
role TEXT NOT NULL DEFAULT 'learner'
CHECK (role IN ('learner','instructor','admin')),
cohort TEXT, -- 교수자 담당 코호트 매칭
is_active BOOLEAN NOT NULL DEFAULT TRUE,
consent_at TIMESTAMPTZ, -- 사전동의 하드게이트(IRB)
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_app_user_cohort ON app.app_user(cohort);
-- =============================================================================
-- 1. 페르소나 — 불변 카드 (설계서 §3.1)
-- 람다 IDENTITY/SOUL 차용. 버전드, 교수만 편집. 진행 세션은 옛 version 핀.
-- =============================================================================
CREATE TABLE IF NOT EXISTS app.persona_card (
persona_id UUID NOT NULL DEFAULT gen_random_uuid(),
code TEXT NOT NULL, -- 'P1','P2','P3'
version INT NOT NULL DEFAULT 1, -- 편집 시 +1
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft','review','approved','archived')),
display_name TEXT NOT NULL,
difficulty TEXT NOT NULL CHECK (difficulty IN ('easy','moderate','hard')),
theory_target TEXT[], -- {'humanistic','cbt'}
demographics JSONB NOT NULL, -- 범주화(재식별 방지 F-25)
presenting JSONB NOT NULL, -- 표층 호소
history JSONB NOT NULL, -- 과거사
big5 JSONB NOT NULL, -- {O,C,E,A,N} 0~1
resistance JSONB NOT NULL, -- {base_resistance,unlock_rate,decay_floor,...}
speech_style JSONB NOT NULL, -- {register,avg_sentence_len,fillers,...}
affect_baseline JSONB NOT NULL, -- {...,suicide_ideation_stage} hard상한=3
ccd JSONB NOT NULL, -- Patient-Ψ 8요소 (★직접발화 금지 R4)
dsm5_dimensional JSONB NOT NULL, -- criteria_behavior_matrix (진단명 비노출)
source_provenance TEXT NOT NULL, -- '0615 합성변형'
is_synthetic BOOLEAN NOT NULL DEFAULT TRUE,
created_by UUID REFERENCES app.app_user(user_id),
approved_by UUID REFERENCES app.app_user(user_id),
approved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (persona_id, version),
UNIQUE (code, version)
);
-- 승인된 최신 카드 빠른 조회 (회기 시작 시 카드 핀).
CREATE INDEX IF NOT EXISTS idx_persona_card_status
ON app.persona_card(status) WHERE status = 'approved';
CREATE INDEX IF NOT EXISTS idx_persona_card_code ON app.persona_card(code, version DESC);
-- 음성 매핑 (provider-agnostic, 카드 버전 정합)
CREATE TABLE IF NOT EXISTS app.persona_voice_map (
persona_id UUID NOT NULL,
version INT NOT NULL,
voice_id TEXT NOT NULL, -- 'voice_p1_teen_m'
provider TEXT NOT NULL CHECK (provider IN ('openai','higgs','melotts')),
base_params JSONB NOT NULL,
prosody_map JSONB NOT NULL, -- 상태→prosody 함수
PRIMARY KEY (persona_id, version),
FOREIGN KEY (persona_id, version)
REFERENCES app.persona_card(persona_id, version) ON DELETE CASCADE
);
-- 상담사 AI 페르소나 (데모/self-play용, 이론 충실도 일관)
CREATE TABLE IF NOT EXISTS app.counselor_profile (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code TEXT,
theory TEXT CHECK (theory IN ('humanistic','cbt','integrative')),
skill_level TEXT,
reasoning_chain JSONB, -- CoE: PCT/CBT 추론체인
allowed_microskills TEXT[],
speech_style JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- 2. 라벨 코드테이블 (MASTERPLAN §3.2, taxonomy 3축 분리)
-- 버전드 코드테이블 + 발화 다대다. KB taxonomy 청크가 label_id 로 참조.
-- =============================================================================
CREATE TABLE IF NOT EXISTS app.stage_def (
stage_code TEXT PRIMARY KEY, -- '라포','탐색','개입','정리'
display_name TEXT NOT NULL,
seq INT NOT NULL,
base_openness REAL NOT NULL DEFAULT 0.15
);
CREATE TABLE IF NOT EXISTS app.technique_label_def (
label_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
code TEXT NOT NULL, -- '공감','반영','탐색','홀딩'...
display_name TEXT NOT NULL,
category TEXT, -- 마이크로스킬 분류
version INT NOT NULL DEFAULT 1,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
UNIQUE (code, version)
);
CREATE TABLE IF NOT EXISTS app.client_state_def (
label_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
code TEXT NOT NULL, -- '비자발','방어','자살사고인정'...
display_name TEXT NOT NULL,
version INT NOT NULL DEFAULT 1,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
UNIQUE (code, version)
);
CREATE TABLE IF NOT EXISTS app.scale_def (
scale_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
code TEXT NOT NULL UNIQUE, -- 'empathy','open_q','CTRS'...
min_score REAL NOT NULL DEFAULT 0,
max_score REAL NOT NULL DEFAULT 5, -- CTRS 는 0-6
description TEXT
);
-- =============================================================================
-- 3. 세션 (MASTERPLAN §3.2) — 케이스/회기 앵커
-- case_profile 보다 먼저 정의(FK 의존: session_state → sessions).
-- case_id 는 (persona_id, learner_id) 복합 인스턴스 식별(설계서 §3.5 핵심결정).
-- =============================================================================
CREATE TABLE IF NOT EXISTS app.sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
case_id UUID, -- 다회기 연속체(FK는 04에서 보강)
learner_id UUID NOT NULL REFERENCES app.app_user(user_id),
persona_id UUID, -- 시드 카드 persona_id
persona_version INT, -- 핀된 카드 버전
session_no INT, -- 케이스 내 N번째 회기
theory_mode TEXT, -- 'humanistic'|'cbt'|'integrative'
stage_path JSONB NOT NULL DEFAULT '[]', -- 단계 전이 이력
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ended_at TIMESTAMPTZ,
FOREIGN KEY (persona_id, persona_version)
REFERENCES app.persona_card(persona_id, version)
);
CREATE INDEX IF NOT EXISTS idx_sessions_case ON app.sessions(case_id, session_no);
CREATE INDEX IF NOT EXISTS idx_sessions_learner ON app.sessions(learner_id);
-- =============================================================================
-- 4. 발화 (MASTERPLAN §3.2 + 설계서 §3.3 연속성 컬럼) — ② EPISODIC 원자단위
-- append-only. PII 마스킹 후 적재. visible_to 는 표면이라 기본 전체 가시.
-- =============================================================================
CREATE TABLE IF NOT EXISTS app.turns (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE CASCADE,
seq INT NOT NULL,
speaker TEXT NOT NULL CHECK (speaker IN ('counselor','client')),
stage TEXT REFERENCES app.stage_def(stage_code),
text TEXT, -- 저장 전 Presidio PII 마스킹된 원문
text_masked TEXT, -- 외부 LLM 전송용(추가 마스킹)
actor_kind TEXT, -- 'client_ai'|'counselor_ai'|'evaluator_ai'|'human_learner'|'human_supervisor'
llm_provider TEXT, model TEXT,
tokens_in INT, tokens_out INT, cost_usd NUMERIC(12,6),
visible_to TEXT[] NOT NULL DEFAULT '{client,counselor,evaluator}',
-- 설계서 §3.3 연속성 컬럼
salience REAL NOT NULL DEFAULT 0.0, -- 망각/회상 우선순위
is_pinned BOOLEAN NOT NULL DEFAULT FALSE, -- pinned_fact 승격 발화
contradicts UUID[] NOT NULL DEFAULT '{}', -- 뒤집은 과거 turn_id[]
-- 음성 paralinguistic (MASTERPLAN_REVISIONS §3.5 선반영, nullable)
audio_ref TEXT,
silence_ms INT,
speech_rate REAL,
barge_in BOOLEAN,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (session_id, seq)
);
CREATE INDEX IF NOT EXISTS idx_turns_session ON app.turns(session_id, seq);
CREATE INDEX IF NOT EXISTS idx_turns_salient
ON app.turns(session_id) WHERE salience >= 0.5;
-- 발화 라벨 다대다 (복수 라벨)
CREATE TABLE IF NOT EXISTS app.turn_technique (
turn_id UUID NOT NULL REFERENCES app.turns(id) ON DELETE CASCADE,
label_id BIGINT NOT NULL REFERENCES app.technique_label_def(label_id),
PRIMARY KEY (turn_id, label_id)
);
CREATE TABLE IF NOT EXISTS app.turn_client_state (
turn_id UUID NOT NULL REFERENCES app.turns(id) ON DELETE CASCADE,
label_id BIGINT NOT NULL REFERENCES app.client_state_def(label_id),
PRIMARY KEY (turn_id, label_id)
);
-- 슈퍼바이저 논평 (MASTERPLAN §3.2, 윤찬 "의도와 다른 부분" 1급 시민)
CREATE TABLE IF NOT EXISTS app.supervisor_comment (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
turn_id UUID NOT NULL REFERENCES app.turns(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('rationale','critique')),
text TEXT NOT NULL,
intent_deviation JSONB, -- {expected,actual,severity,dimension}
visible_to TEXT[] NOT NULL DEFAULT '{evaluator}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_sup_comment_turn ON app.supervisor_comment(turn_id);
-- 안전 이벤트 (MASTERPLAN §3.2)
CREATE TABLE IF NOT EXISTS app.safety_events (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
session_id UUID REFERENCES app.sessions(id) ON DELETE CASCADE,
turn_id UUID REFERENCES app.turns(id) ON DELETE SET NULL,
trigger_type TEXT NOT NULL, -- 'suicide_means'|'real_crisis'|'ideation_cap'...
ko_risk_level SMALLINT, -- 한국어 자살분류 1~5
escalated BOOLEAN NOT NULL DEFAULT FALSE,
latency_ms INT,
detail JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_safety_session ON app.safety_events(session_id);
-- =============================================================================
-- 5. 메모리 ① WORKING (설계서 §3.2) — 체크포인트
-- 상태머신 결정론 수치 + 단기버퍼. 매 턴 UPSERT(프로세스 죽어도 복원).
-- =============================================================================
CREATE TABLE IF NOT EXISTS app.session_state (
session_id UUID PRIMARY KEY REFERENCES app.sessions(id) ON DELETE CASCADE,
stage TEXT NOT NULL DEFAULT '라포'
CHECK (stage IN ('라포','탐색','개입','정리')),
turn_seq INT NOT NULL DEFAULT 0,
effective_openness REAL NOT NULL DEFAULT 0.15
CHECK (effective_openness BETWEEN 0 AND 1),
rapport_credit REAL NOT NULL DEFAULT 0.0, -- 회기말 0.7 이월 대상
resistance REAL NOT NULL DEFAULT 0.65
CHECK (resistance BETWEEN 0 AND 1),
ideation_stage SMALLINT NOT NULL DEFAULT 1
CHECK (ideation_stage BETWEEN 1 AND 5),
affect_state JSONB NOT NULL DEFAULT '{}',
short_buffer JSONB NOT NULL DEFAULT '[]', -- 최근 K턴 rolling window
recall_context JSONB, -- 회기시작 1회 로드 캐시
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- 6. 메모리 ② EPISODIC 임베딩 (설계서 §3.3) — BGE-M3 멀티벡터 + HNSW
-- case_id/session_id 비정규화: 케이스 스코프 회상(cross-trainee 누수 차단).
-- =============================================================================
CREATE TABLE IF NOT EXISTS app.turn_embedding (
turn_id UUID PRIMARY KEY REFERENCES app.turns(id) ON DELETE CASCADE,
case_id UUID NOT NULL, -- denormalized 회상 스코프
session_id UUID NOT NULL,
seq INT NOT NULL,
dense vector(1024) NOT NULL, -- BGE-M3 dense
sparse JSONB, -- BGE-M3 sparse {token_id:weight}
context_prefix TEXT, -- Contextual Retrieval 1문장
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_turn_emb_hnsw ON app.turn_embedding
USING hnsw (dense vector_cosine_ops) WITH (m = 16, ef_construction = 64);
CREATE INDEX IF NOT EXISTS idx_turn_emb_case ON app.turn_embedding (case_id, session_id);
-- =============================================================================
-- 7. 메모리 ③ SUMMARY (설계서 §3.4) — 회기당 1행
-- (A) end_state 무손실 carry-over(코드복사) / (B) digest narrative(LLM) 분리.
-- =============================================================================
CREATE TABLE IF NOT EXISTS app.session_summary (
session_id UUID PRIMARY KEY REFERENCES app.sessions(id) ON DELETE CASCADE,
case_id UUID NOT NULL,
session_no INT NOT NULL,
-- (A) 무손실 carry-over [P2]
end_state JSONB NOT NULL, -- {stage,openness,rapport_credit,...}
rapport_delta REAL,
-- (B) narrative 압축 (LLM, 손실 허용)
digest TEXT NOT NULL, -- 6~10문장, 다음 회기 주입
open_threads JSONB NOT NULL DEFAULT '[]', -- [{topic,last_stance,raised_session_no}]
homework JSONB,
emotional_arc TEXT,
-- (C) 검색용
digest_embedding vector(1024),
token_count INT,
compressed_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (case_id, session_no)
);
CREATE INDEX IF NOT EXISTS idx_sess_sum_case
ON app.session_summary (case_id, session_no DESC);
CREATE INDEX IF NOT EXISTS idx_sess_sum_emb ON app.session_summary
USING hnsw (digest_embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);
-- =============================================================================
-- 8. 메모리 ④ SEMANTIC (설계서 §3.5) — case_profile(evolving) + pinned_fact
-- 케이스 = (persona_id 템플릿) × (learner_id 인스턴스). 학습자별 독립 연속체.
-- =============================================================================
CREATE TABLE IF NOT EXISTS app.case_profile (
case_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
persona_id UUID NOT NULL, -- 시드 페르소나(카드 persona_id)
learner_id UUID NOT NULL REFERENCES app.app_user(user_id),
last_session_no INT NOT NULL DEFAULT 0,
ccd_estimate JSONB NOT NULL DEFAULT '{}', -- CCD 8요소 현재추정 +confidence
presenting_arc JSONB NOT NULL DEFAULT '[]', -- 호소문제 궤적
rapport_trajectory JSONB NOT NULL DEFAULT '[]', -- [{session_no,end_rapport,end_openness}]
alliance_level REAL DEFAULT 0.2, -- 치료동맹 누적(EWMA)
case_digest TEXT NOT NULL DEFAULT '', -- 전체 궤적 8~12문장(큰그림)
digest_embedding vector(1024),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (persona_id, learner_id) -- 핵심 복합키(§3.5)
);
CREATE INDEX IF NOT EXISTS idx_case_learner ON app.case_profile(learner_id);
CREATE INDEX IF NOT EXISTS idx_case_emb ON app.case_profile
USING hnsw (digest_embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);
-- pinned facts (람다 MEMORY.md, 절대 흘리면 안 되는 핵심, 모순 기준점)
CREATE TABLE IF NOT EXISTS app.pinned_fact (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
case_id UUID NOT NULL REFERENCES app.case_profile(case_id) ON DELETE CASCADE,
key TEXT NOT NULL, -- 'name','family',...
value TEXT NOT NULL, -- 가명처리/마스킹된
fact_type TEXT NOT NULL
CHECK (fact_type IN ('identity','history','relationship','clinical','agreement')),
status TEXT NOT NULL DEFAULT 'stable'
CHECK (status IN ('stable','evolving','contradicted','locked')),
source_turn UUID REFERENCES app.turns(id) ON DELETE SET NULL,
confidence REAL NOT NULL DEFAULT 1.0,
version INT NOT NULL DEFAULT 1,
updated_session_no INT,
visible_to TEXT[] NOT NULL DEFAULT '{client,evaluator}', -- 모순처리 정답신호
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (case_id, key)
);
CREATE INDEX IF NOT EXISTS idx_pinned_case ON app.pinned_fact (case_id, status);
-- pinned 변경 이력 (append-only, 모순/갱신 audit + 평가AI 정답신호)
CREATE TABLE IF NOT EXISTS app.pinned_fact_history (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
fact_id UUID NOT NULL, -- pinned_fact 삭제돼도 이력 보존(FK 무)
case_id UUID NOT NULL,
old_value TEXT,
new_value TEXT,
reason TEXT, -- 'contradiction'|'clarification'|'progression'
session_no INT,
turn_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_pinned_hist_fact ON app.pinned_fact_history(fact_id);
-- =============================================================================
-- 9. 지연 FK 보강 — sessions.case_id, summary/embedding case 정합
-- case_profile 가 sessions 보다 뒤에 정의되므로 여기서 FK 연결.
-- =============================================================================
ALTER TABLE app.sessions
DROP CONSTRAINT IF EXISTS fk_sessions_case;
ALTER TABLE app.sessions
ADD CONSTRAINT fk_sessions_case
FOREIGN KEY (case_id) REFERENCES app.case_profile(case_id) ON DELETE SET NULL;

104
infra/db/init/03_kb.sql Normal file
View file

@ -0,0 +1,104 @@
-- =============================================================================
-- Vignette · 03_kb.sql
-- kb 스키마: 정적 지식 KB (DSM/이론/technique/taxonomy) — app 과 물리 분리
-- ⑤ KNOWLEDGE 계층. 3-AI 가 같은 물리 테이블을 다른 정책(visible_to+sensitivity)으로 검색.
-- 근거: MEMORY_KNOWLEDGE_PERSONA_DESIGN.md §3.6, §4.3 / MASTERPLAN §3.6
-- 전제: 02_schema.sql (app.technique_label_def 존재). 적용 순서: 02 → 03(본 파일).
-- 원칙: app 으로의 FK 없음(read-only 독립). license_class C/D = 외부 LLM 차단.
-- =============================================================================
CREATE SCHEMA IF NOT EXISTS kb;
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
SET search_path TO kb, public;
-- =============================================================================
-- 1. 출처 + 라이선스 (DSM-5 저작권 1차 통제, 설계서 §3.6)
-- =============================================================================
CREATE TABLE IF NOT EXISTS kb.source (
source_id TEXT PRIMARY KEY, -- 'dsm5','theory','taxonomy_0615'
title TEXT NOT NULL,
kb_kind TEXT NOT NULL CHECK (kb_kind IN
('diagnostic','theory','technique','taxonomy',
'supervisor_pattern','template','ko_context','microskill')),
license_class CHAR(1) NOT NULL CHECK (license_class IN ('A','B','C','D')),
-- C=저작권민감 D=미성년파생
origin_path TEXT,
citation TEXT,
external_llm_ok BOOLEAN NOT NULL DEFAULT FALSE, -- C/D면 false → 국내 라우팅
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- 2. 문서 + 버전 (graphify content_hash 증분 인덱싱 차용)
-- =============================================================================
CREATE TABLE IF NOT EXISTS kb.document (
doc_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
source_id TEXT NOT NULL REFERENCES kb.source(source_id),
doc_uri TEXT NOT NULL,
version INT NOT NULL DEFAULT 1,
content_hash TEXT NOT NULL, -- SHA256 → 변경감지(증분)
superseded_by BIGINT REFERENCES kb.document(doc_id),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
indexed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (source_id, doc_uri, version)
);
CREATE INDEX IF NOT EXISTS idx_kb_doc_active
ON kb.document(source_id) WHERE is_active;
-- =============================================================================
-- 3. 청크 + 멀티벡터 (검색 본체, 설계서 §3.6)
-- visible_to + sensitivity 가 3-AI 정보비대칭을 DB WHERE 로 강제.
-- =============================================================================
CREATE TABLE IF NOT EXISTS kb.chunk (
chunk_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
doc_id BIGINT NOT NULL REFERENCES kb.document(doc_id) ON DELETE CASCADE,
source_id TEXT NOT NULL, -- 비정규화(필터 가속)
kb_kind TEXT NOT NULL, -- 비정규화
seq INT NOT NULL,
heading_path TEXT,
chunk_text TEXT NOT NULL, -- 표시·LLM 주입(prefix 미포함)
context_prefix TEXT, -- Contextual Retrieval 주입문
embedding vector(1024), -- BGE-M3 dense
sparse_vec JSONB, -- BGE-M3 sparse
colbert_vecs JSONB, -- 멀티벡터(단일 호출 산출)
visible_to TEXT[] NOT NULL DEFAULT '{client,counselor,evaluator}',
sensitivity SMALLINT NOT NULL DEFAULT 0
CHECK (sensitivity BETWEEN 0 AND 3), -- 0공개 1내부 2평가전용 3원천격리
label_id BIGINT REFERENCES app.technique_label_def(label_id), -- taxonomy 정답라벨
meta JSONB DEFAULT '{}', -- {dsm_category,theory,bias_weight,...}
token_count INT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- dense ANN
CREATE INDEX IF NOT EXISTS idx_chunk_hnsw ON kb.chunk
USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);
-- sparse/BM25 (한국어: simple + pg_trgm 병용, M10 완화)
CREATE INDEX IF NOT EXISTS idx_chunk_fts ON kb.chunk
USING gin (to_tsvector('simple', chunk_text));
CREATE INDEX IF NOT EXISTS idx_chunk_trgm ON kb.chunk
USING gin (chunk_text gin_trgm_ops);
-- 정보비대칭 사전필터
CREATE INDEX IF NOT EXISTS idx_chunk_visible ON kb.chunk USING gin (visible_to);
CREATE INDEX IF NOT EXISTS idx_chunk_route ON kb.chunk (kb_kind, sensitivity);
-- =============================================================================
-- 4. KB 검색 로그 (환각측정·캐시검증, 람다 cost.json 차용, 설계서 §3.7)
-- =============================================================================
CREATE TABLE IF NOT EXISTS kb.retrieval_log (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
session_id UUID,
turn_id UUID,
ai_role TEXT NOT NULL, -- 'client'|'counselor'|'evaluator'
query_text TEXT,
policy TEXT NOT NULL, -- 4-튜플 정책명
hit_chunk_ids BIGINT[],
rerank_scores REAL[],
top1_score REAL, -- CRAG 게이트
used_in_answer BOOLEAN,
latency_ms INT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_kb_retr_session ON kb.retrieval_log(session_id);

View file

@ -0,0 +1,246 @@
-- =============================================================================
-- Vignette · 04_audit_eval_rls.sql
-- ① 평가 + 종단추적 (app) ② 드리프트 감사 (audit) ③ ds 재귀학습 ④ RLS 정책
-- 근거: MEMORY_KNOWLEDGE_PERSONA_DESIGN.md §3.7, §4 / MASTERPLAN §3.2~3.3, §6(RLS)
-- 전제: 02_schema.sql, 03_kb.sql. 적용 순서: 02 → 03 → 04(본 파일, 마지막).
-- 정보비대칭 2-레이어(설계서 §4.1):
-- 레이어1 = AI view (current_ai_view) → visible_to[] WHERE
-- 레이어2 = 인간 RBAC×cohort (current_role/current_uid) → RLS DB레벨
-- =============================================================================
CREATE SCHEMA IF NOT EXISTS audit;
SET search_path TO app, public;
-- =============================================================================
-- 1. 평가 — 발화별 점수 (설계서 §3.7, MASTERPLAN §3.2)
-- visible_to='{evaluator}' → 학습자 비노출(응답 가공단에서 풀림).
-- =============================================================================
CREATE TABLE IF NOT EXISTS app.feedback_scores (
turn_id UUID NOT NULL REFERENCES app.turns(id) ON DELETE CASCADE,
dimension TEXT NOT NULL, -- '공감'|'개방질문'|'검증'|'이론부합'
score REAL, -- 0-5 (CTRS 0-6)
rationale TEXT,
top1_score REAL, -- CRAG 게이트(임계미달→관찰프레이밍 F-06)
visible_to TEXT[] NOT NULL DEFAULT '{evaluator}',
loop TEXT CHECK (loop IN ('fast','deep')), -- 2-tier 출처
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (turn_id, dimension)
);
-- 대안발화 제안 (deep-loop, 대시보드용)
CREATE TABLE IF NOT EXISTS app.alternative_utterance (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
turn_id UUID NOT NULL REFERENCES app.turns(id) ON DELETE CASCADE,
suggestion TEXT NOT NULL,
rationale TEXT,
visible_to TEXT[] NOT NULL DEFAULT '{evaluator,learner}', -- 코칭은 학습자 노출
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- 2. 종단 학습자 프로파일 (설계서 §3.7) — 회기 거듭하며 나아지는지
-- EWMA(현재수준) + slope(성장속도) 동시. persistent_gaps 는 deep-loop만.
-- =============================================================================
CREATE TABLE IF NOT EXISTS app.learner_profile (
learner_id UUID PRIMARY KEY REFERENCES app.app_user(user_id) ON DELETE CASCADE,
dim_ewma JSONB NOT NULL DEFAULT '{}', -- {empathy:0.6, open_q:0.5,...}
dim_slope JSONB NOT NULL DEFAULT '{}', -- 성장속도
persistent_gaps JSONB NOT NULL DEFAULT '[]', -- deep-loop 코칭 전용(fast 미주입)
session_count INT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- 3. 감사 (audit 스키마, append-only)
-- =============================================================================
-- 페르소나 드리프트 감사 (KPI 임베딩일관성≥0.79 측정, 설계서 §3.7)
CREATE TABLE IF NOT EXISTS audit.persona_drift_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL,
turn_id UUID,
persona_id UUID NOT NULL,
drift_type TEXT NOT NULL, -- 'big5_violation'|'ccd_leak'|'fact_contradiction'|'style_drift'
embedding_sim REAL,
detail JSONB,
severity TEXT CHECK (severity IN ('warn','block')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_drift_session ON audit.persona_drift_log(session_id);
-- 인간 열람 추적 (RBAC audit, MASTERPLAN §3.3 "교수활동 감사")
CREATE TABLE IF NOT EXISTS audit.audit_log (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
actor_uid UUID NOT NULL,
action TEXT NOT NULL, -- 'read_session'|'override_label'|...
target_kind TEXT,
target_id TEXT,
detail JSONB,
inference_geo TEXT, -- 'kr'|'us'
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit.audit_log(actor_uid, created_at DESC);
-- LLM 호출 로그 (비용·드리프트·inference_geo, MASTERPLAN §3.2)
CREATE TABLE IF NOT EXISTS audit.llm_call_log (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
session_id UUID,
turn_id UUID,
provider TEXT, model TEXT,
tokens_in INT, tokens_out INT, cost_usd NUMERIC(12,6),
inference_geo TEXT,
latency_ms INT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- 4. ds 스키마 — 재귀학습 파이프라인 (MASTERPLAN §3.2)
-- AI자동(1R) → 인간검수(2R) → IAA 게이트(κ≥0.6,ICC≥0.75) → 골든셋 → JSONL
-- =============================================================================
CREATE SCHEMA IF NOT EXISTS ds;
CREATE TABLE IF NOT EXISTS ds.dataset (
dataset_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL,
purpose TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS ds.dataset_item (
item_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
dataset_id BIGINT NOT NULL REFERENCES ds.dataset(dataset_id) ON DELETE CASCADE,
turn_id UUID,
payload JSONB NOT NULL
);
CREATE TABLE IF NOT EXISTS ds.annotation_round (
round_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
dataset_id BIGINT NOT NULL REFERENCES ds.dataset(dataset_id) ON DELETE CASCADE,
round_kind TEXT NOT NULL CHECK (round_kind IN ('ai_auto','human_review')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS ds.annotation (
annotation_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
round_id BIGINT NOT NULL REFERENCES ds.annotation_round(round_id) ON DELETE CASCADE,
item_id BIGINT NOT NULL REFERENCES ds.dataset_item(item_id) ON DELETE CASCADE,
annotator TEXT,
labels JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS ds.export_manifest (
export_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
dataset_id BIGINT NOT NULL REFERENCES ds.dataset(dataset_id),
iaa_kappa REAL, iaa_icc REAL, -- 게이트 통과 기록
jsonl_path TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================================================
-- 5. RLS — 인간 RBAC×cohort 이중강제 (레이어2, MASTERPLAN §3.3)
-- 세션 GUC 로 컨텍스트 주입(BFF/FastAPI 미들웨어):
-- app.current_uid : 현재 인간 사용자 UUID
-- app.current_role : 'learner'|'instructor'|'admin'
-- app.current_cohort: 교수자 담당 코호트
-- AI 엔진(서비스롤)은 BYPASSRLS 또는 app.ai_context='1' 로 우회(레이어1 visible_to 가 방어).
-- =============================================================================
-- 헬퍼: 현재 컨텍스트 안전 추출(미설정 시 NULL/빈).
CREATE OR REPLACE FUNCTION app.current_uid() RETURNS UUID
LANGUAGE sql STABLE AS
$$ SELECT NULLIF(current_setting('app.current_uid', true), '')::uuid $$;
CREATE OR REPLACE FUNCTION app.current_role_name() RETURNS TEXT
LANGUAGE sql STABLE AS
$$ SELECT COALESCE(NULLIF(current_setting('app.current_role', true), ''), 'learner') $$;
CREATE OR REPLACE FUNCTION app.is_ai_context() RETURNS BOOLEAN
LANGUAGE sql STABLE AS
$$ SELECT current_setting('app.ai_context', true) = '1' $$;
-- ── sessions: 학습자=본인 / 교수자=담당 코호트 / 관리자=전부 ──
ALTER TABLE app.sessions ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_sessions_select ON app.sessions;
CREATE POLICY p_sessions_select ON app.sessions FOR SELECT USING (
app.is_ai_context()
OR app.current_role_name() = 'admin'
OR (app.current_role_name() = 'learner' AND learner_id = app.current_uid())
OR (app.current_role_name() = 'instructor' AND EXISTS (
SELECT 1 FROM app.app_user u
WHERE u.user_id = app.sessions.learner_id
AND u.cohort = current_setting('app.current_cohort', true)))
);
DROP POLICY IF EXISTS p_sessions_modify ON app.sessions;
CREATE POLICY p_sessions_modify ON app.sessions FOR ALL 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()
);
-- ── turns: 세션 소유 + visible_to(레이어1) 동시 ──
ALTER TABLE app.turns ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_turns_select ON app.turns;
CREATE POLICY p_turns_select ON app.turns FOR SELECT USING (
-- AI 경로: visible_to 가 ai_view 와 교집합일 때만(레이어1)
( app.is_ai_context()
AND current_setting('app.current_ai_view', true) = ANY(visible_to) )
OR
-- 인간 경로: 세션 RLS 통과 + (학습자면 evaluator-only 차단)
( NOT app.is_ai_context()
AND EXISTS (SELECT 1 FROM app.sessions s WHERE s.id = app.turns.session_id)
AND ( app.current_role_name() IN ('admin','instructor')
OR 'counselor' = ANY(visible_to) ) )
);
-- ── feedback_scores: 평가전용. 학습자 직접열람 차단(응답 가공단에서만 노출) ──
ALTER TABLE app.feedback_scores ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_feedback_select ON app.feedback_scores;
CREATE POLICY p_feedback_select ON app.feedback_scores FOR SELECT USING (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
);
-- ── case_profile / pinned_fact: 학습자=본인 케이스만(cross-trainee 누수 차단 T4) ──
ALTER TABLE app.case_profile ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_case_select ON app.case_profile;
CREATE POLICY p_case_select ON app.case_profile FOR SELECT USING (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
OR learner_id = app.current_uid()
);
ALTER TABLE app.pinned_fact ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_pinned_select ON app.pinned_fact;
CREATE POLICY p_pinned_select ON app.pinned_fact FOR SELECT USING (
( app.is_ai_context()
AND current_setting('app.current_ai_view', true) = ANY(visible_to) )
OR app.current_role_name() IN ('admin','instructor')
);
-- ── learner_profile: 학습자=본인, persistent_gaps 는 응답단 필터 ──
ALTER TABLE app.learner_profile ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_learner_select ON app.learner_profile;
CREATE POLICY p_learner_select ON app.learner_profile FOR SELECT USING (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
OR learner_id = app.current_uid()
);
-- ── kb.chunk: 정보비대칭(visible_to + sensitivity) DB 강제 ──
ALTER TABLE kb.chunk ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_kb_chunk_select ON kb.chunk;
CREATE POLICY p_kb_chunk_select ON kb.chunk FOR SELECT USING (
-- AI 경로: 자기 view 가 visible_to 에 포함 + sensitivity 상한(엔진이 GUC 로 주입)
( app.is_ai_context()
AND current_setting('app.current_ai_view', true) = ANY(visible_to)
AND sensitivity <= COALESCE(
NULLIF(current_setting('app.current_sens_max', true), '')::smallint, 0) )
-- 인간(교수/관리자) 검수: 전체 가시
OR app.current_role_name() IN ('admin','instructor')
);
-- 참고: AI 엔진 DB 롤은 ALTER ROLE engine_svc BYPASSRLS 또는 매 연결에서
-- SET app.ai_context='1'; SET app.current_ai_view='client';
-- SET app.current_sens_max='1'; (정책별 4-튜플) 로 컨텍스트를 명시 주입한다.