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
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue