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:
parent
d5b86c5f89
commit
84eb6e2173
20 changed files with 2038 additions and 1 deletions
17
.env.example
17
.env.example
|
|
@ -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
10
apps/api/app/__init__.py
Normal 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
102
apps/api/app/config.py
Normal 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
126
apps/api/app/db.py
Normal 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
119
apps/api/app/deps.py
Normal 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)]
|
||||
137
apps/api/app/engine_client.py
Normal file
137
apps/api/app/engine_client.py
Normal 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
69
apps/api/app/main.py
Normal 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,
|
||||
}
|
||||
1
apps/api/app/routes/__init__.py
Normal file
1
apps/api/app/routes/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""API 라우터 모음."""
|
||||
84
apps/api/app/routes/auth.py
Normal file
84
apps/api/app/routes/auth.py
Normal 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,
|
||||
)
|
||||
188
apps/api/app/routes/sessions.py
Normal file
188
apps/api/app/routes/sessions.py
Normal 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
285
apps/api/app/taxonomy.py
Normal 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",
|
||||
]
|
||||
19
apps/api/engine_gateway/README.md
Normal file
19
apps/api/engine_gateway/README.md
Normal 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`.
|
||||
0
apps/api/engine_gateway/__init__.py
Normal file
0
apps/api/engine_gateway/__init__.py
Normal file
153
apps/api/engine_gateway/gateway.py
Normal file
153
apps/api/engine_gateway/gateway.py
Normal 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)}
|
||||
1
apps/api/engine_gateway/requirements.txt
Normal file
1
apps/api/engine_gateway/requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
fastapi\nuvicorn[standard]
|
||||
|
|
@ -2,5 +2,7 @@ fastapi
|
|||
uvicorn[standard]
|
||||
asyncpg
|
||||
pydantic
|
||||
pydantic-settings
|
||||
python-multipart
|
||||
httpx
|
||||
sse-starlette
|
||||
|
|
|
|||
5
docs/golden_schema.jsonl
Normal file
5
docs/golden_schema.jsonl
Normal 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
371
infra/db/init/02_schema.sql
Normal 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
104
infra/db/init/03_kb.sql
Normal 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);
|
||||
246
infra/db/init/04_audit_eval_rls.sql
Normal file
246
infra/db/init/04_audit_eval_rls.sql
Normal 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-튜플) 로 컨텍스트를 명시 주입한다.
|
||||
Loading…
Add table
Add a link
Reference in a new issue