diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..4e068f7 --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,14 @@ +# Vignette API (FastAPI) — 이식 가능한 슬림 이미지 +FROM python:3.11-slim AS base +ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 PIP_NO_CACHE_DIR=1 +WORKDIR /app + +# 의존성 레이어 분리(캐시 효율) +COPY requirements.txt ./ +RUN pip install -r requirements.txt + +COPY . . + +EXPOSE 8000 +# 엔진/DB/음성 엔드포인트는 전부 env 로 주입(이미지에 굽지 않음 = 이식성) +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/apps/api/Dockerfile.rag b/apps/api/Dockerfile.rag new file mode 100644 index 0000000..9fdf21c --- /dev/null +++ b/apps/api/Dockerfile.rag @@ -0,0 +1,11 @@ +# Vignette RAG 사이드카 — BGE-M3 임베딩 + BGE-reranker-v2-m3 +# CPU로도 동작(20명 규모 충분), GPU 호스트면 nvidia runtime 으로 가속. +FROM python:3.11-slim +ENV PYTHONUNBUFFERED=1 HF_HOME=/models +WORKDIR /app +COPY requirements-rag.txt ./ +RUN pip install --no-cache-dir -r requirements-rag.txt +COPY rag/ ./rag/ +EXPOSE 8080 +# 모델은 첫 기동 시 /models 볼륨에 캐시 → 이식 시 볼륨만 옮기면 재다운로드 불필요 +CMD ["uvicorn", "rag.server:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/apps/api/requirements-rag.txt b/apps/api/requirements-rag.txt new file mode 100644 index 0000000..d5308f4 --- /dev/null +++ b/apps/api/requirements-rag.txt @@ -0,0 +1,5 @@ +fastapi +uvicorn[standard] +FlagEmbedding +sentence-transformers +torch diff --git a/apps/api/requirements.txt b/apps/api/requirements.txt new file mode 100644 index 0000000..c0de658 --- /dev/null +++ b/apps/api/requirements.txt @@ -0,0 +1,6 @@ +fastapi +uvicorn[standard] +asyncpg +pydantic +python-multipart +httpx diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..e6632a3 --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,16 @@ +# Vignette Web (React 19 + Vite) — 멀티스테이지(빌드 → 정적 서빙) +FROM node:22-alpine AS build +WORKDIR /app +# node 22 사용(23.x는 일부 빌드 segfault 이슈 회피) +COPY package.json pnpm-lock.yaml* ./ +RUN corepack enable && pnpm install --frozen-lockfile || npm install +COPY . . +ARG VITE_API_BASE=/api +ENV VITE_API_BASE=$VITE_API_BASE +RUN pnpm build || npm run build + +# 정적 서빙(작고 이식성 높은 nginx) +FROM nginx:1.27-alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/apps/web/nginx.conf b/apps/web/nginx.conf new file mode 100644 index 0000000..9afc97b --- /dev/null +++ b/apps/web/nginx.conf @@ -0,0 +1,5 @@ +server { + listen 80; + root /usr/share/nginx/html; + location / { try_files $uri /index.html; } +} diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..03b13a1 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,65 @@ +# Vignette 배포 가이드 — "아무 데나 설치 가능한" Docker 구성 + +목표: 어떤 호스트(네 PC / 학교 서버 / 클라우드 VM)든 `docker compose up -d` 한 방으로 뜨고, **옮길 때 환경변수 몇 줄만** 바꾼다. + +## 1. 한 방 실행 + +```bash +cd infra +cp .env.example .env # 값 채우기(아래 표) +docker compose up -d +# web/api/db/rag/proxy 컨테이너 기동. http://localhost:8080 +``` + +전부 컨테이너: `web`(React/nginx) · `api`(FastAPI) · `db`(postgres16+pgvector) · `rag`(BGE-M3+reranker) · `proxy`(Caddy, TLS·SSE·WSS). + +## 2. 핵심 — "엔진"만 컨테이너 밖 (이식성의 열쇠) + +우리 엔진은 로컬 `claude -p`(네 PC의 claude CLI/OAuth에 묶임)라, 컨테이너에 가두면 못 옮긴다. 그래서 **엔진은 어댑터 뒤에 두고 `ENGINE_URL`/`ENGINE_MODE`로 가리킨다.** 옮길 때 이 두 줄만 바꾸면 됨 — 코드 무수정. + +설정 경로 **3가지(사용자 선택)**: +1. **yml / .env** — `infra/.env`의 `ENGINE_URL`·`ENGINE_MODE` (기본·운영) +2. **docker compose** — `docker compose run -e ENGINE_MODE=messages_api ...` 즉석 오버라이드 +3. **설정 페이지(관리자 UI)** — 런타임에 엔진/모델 전환 (DB에 저장, 재기동 불필요) + +| 시나리오 | ENGINE_MODE | ENGINE_URL | +|---|---|---| +| **네 PC 운영** (요구사항: 로컬 claude -p) | `claude_p` | `http://host.docker.internal:9099` (호스트의 claude -p 게이트웨이) | +| **클라우드/타호스트 이전** | `messages_api` | (불필요, ANTHROPIC_API_KEY 사용) | +| **원격 엔진 PC** | `claude_p` | `http://<엔진PC-IP>:9099` | + +> 로컬 claude -p 게이트웨이 = 호스트에서 도는 작은 프로세스(상주 멀티턴 풀, `claude -p --input-format stream-json`). 컨테이너 api 가 `host.docker.internal:9099`로 호출. 이 게이트웨이는 `apps/api/engine_gateway/`에 둔다(호스트 실행, 컨테이너 밖). + +## 3. 환경변수 (.env) + +| 변수 | 설명 | +|---|---| +| `POSTGRES_PASSWORD` | DB 비밀번호(필수) | +| `SESSION_SECRET` | 세션 서명키(필수, 랜덤) | +| `OPENAI_API_KEY` | 음성(STT/TTS) | +| `ANTHROPIC_API_KEY` | 엔진 폴백/클라우드 모드 | +| `ENGINE_MODE` / `ENGINE_URL` | 엔진 위치(위 표) | +| `SITE_ADDRESS` | 외부노출 도메인(예: `vignette.chanpaca.net`). 비우면 로컬 :80 | +| `ACME_EMAIL` | 외부노출 시 Let's Encrypt 자동 TLS용 | +| `HTTP_PORT`/`HTTPS_PORT` | 호스트 포트 매핑 | + +## 4. 외부 공유 (chanpaca.net, 교수 테스트) + +두 가지 — 호스트 상황에 맞게: +- **Caddy 자동 TLS**: 공인 IP·도메인 있으면 `SITE_ADDRESS=vignette.chanpaca.net` + `ACME_EMAIL` → Caddy가 인증서 자동 발급. +- **Cloudflare/Tailscale 터널**: 공인 IP 없을 때(네 PC). 단 **음성 SSE/WSS는 버퍼링 충돌**이 있어 Caddyfile에서 `flush_interval -1`로 흘려보냄(red team F-07 반영). 터널도 SSE 무버퍼 경로 분리 권장. + +## 5. 스케일업 / 이전 + +- **수직**: compose 그대로, 호스트 사양만 키움. +- **수평**: `docker compose up --scale api=3` (api 무상태 설계 전제, 세션은 DB/Redis). proxy가 라운드로빈. +- **이전**: `pgdata`·`ragmodels` 볼륨만 새 호스트로 옮기고 `.env` 채워 `up`. (rag 모델 캐시 볼륨 덕에 재다운로드 0) +- **클라우드**: 동일 compose. 단 엔진을 `messages_api`로 바꾸거나 엔진 PC를 `ENGINE_URL`로 원격 지정. + +## 6. 데이터 주권 / 보안 + +- DB는 NAS PostgreSQL(국내). 미성년 사례데이터는 repo·이미지에 절대 미포함(`.gitignore`). +- 외부 LLM 전송 전 PII 마스킹 미들웨어 통과(red team F-03). +- proxy 인증 게이트 뒤에서만 SSE/WSS 노출(무인증 IDOR 차단, F-20). + +> 현 단계: 구성 골격(compose·Dockerfile·Caddy) 완비. 앱 코드(apps/web, apps/api) 채워지면 그대로 빌드된다. diff --git a/infra/.env.example b/infra/.env.example new file mode 100644 index 0000000..0c2a53d --- /dev/null +++ b/infra/.env.example @@ -0,0 +1,15 @@ +# Vignette infra env (예시). cp .env.example .env 후 값 채우기 +POSTGRES_USER=vignette +POSTGRES_PASSWORD=change-me +POSTGRES_DB=vignette +SESSION_SECRET=change-me-random +OPENAI_API_KEY= +ANTHROPIC_API_KEY= +# 엔진(이식성): 네 PC=claude_p, 클라우드=messages_api +ENGINE_MODE=claude_p +ENGINE_URL=http://host.docker.internal:9099 +# 외부노출(비우면 로컬 :80) +SITE_ADDRESS=:80 +ACME_EMAIL= +HTTP_PORT=8080 +HTTPS_PORT=8443 diff --git a/infra/Caddyfile b/infra/Caddyfile new file mode 100644 index 0000000..6393c64 --- /dev/null +++ b/infra/Caddyfile @@ -0,0 +1,29 @@ +# Vignette 리버스프록시 +# 로컬: http://localhost:8080 +# 외부: 도메인 한 줄 바꾸면 Caddy가 Let's Encrypt 자동 TLS +{ + # 외부노출 시 이메일만 채우면 자동 HTTPS. 로컬은 무시됨. + email {$ACME_EMAIL:admin@example.com} +} + +{$SITE_ADDRESS::80} { + # 음성 WSS — 버퍼링 끄고 그대로 흘려보냄(상담 실시간성) + @ws path /api/voice/ws* + reverse_proxy @ws api:8000 + + # SSE(실시간 자막/피드백 스트림) — flush_interval -1 로 즉시 전달(버퍼링 충돌 방지) + @sse path /api/*/stream* + reverse_proxy @sse api:8000 { + flush_interval -1 + } + + # 일반 API + handle_path /api/* { + reverse_proxy api:8000 + } + + # 프론트(SPA) + handle { + reverse_proxy web:80 + } +} diff --git a/infra/db/init/01_extensions.sql b/infra/db/init/01_extensions.sql new file mode 100644 index 0000000..657ccb5 --- /dev/null +++ b/infra/db/init/01_extensions.sql @@ -0,0 +1,6 @@ +-- Vignette DB 초기화: pgvector 확장 + 스키마(app/audit/kb) +CREATE EXTENSION IF NOT EXISTS vector; +CREATE SCHEMA IF NOT EXISTS app; +CREATE SCHEMA IF NOT EXISTS audit; +CREATE SCHEMA IF NOT EXISTS kb; +-- 상세 DDL은 docs/MEMORY_KNOWLEDGE_PERSONA_DESIGN.md / MASTERPLAN.md 참조 diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml new file mode 100644 index 0000000..421878a --- /dev/null +++ b/infra/docker-compose.yml @@ -0,0 +1,100 @@ +# Vignette — 이식 가능한 단일 호스트 배포 구성 +# 어디서든: cp .env.example .env && (값 채우고) && docker compose up -d +# +# 설계 원칙: +# - web/api/db/voice/rag 는 전부 컨테이너 → 호스트 무관하게 한 방에 뜬다. +# - "엔진"(로컬 claude -p)만 컨테이너 밖. api 는 ENGINE_URL 로 가리킨다. +# → 옮길 때 ENGINE_URL 한 줄만 바꾸면 됨(로컬 게이트웨이 ↔ API 폴백). +# - 시크릿/엔드포인트는 전부 .env. 이미지에 굽지 않는다(이식성). + +name: vignette + +services: + db: + image: pgvector/pgvector:pg16 + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-vignette} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set in .env} + POSTGRES_DB: ${POSTGRES_DB:-vignette} + volumes: + - pgdata:/var/lib/postgresql/data + - ./db/init:/docker-entrypoint-initdb.d:ro # 스키마/확장 초기화 SQL + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-vignette}"] + interval: 10s + timeout: 5s + retries: 5 + networks: [vignette] + + api: + build: + context: ../apps/api + dockerfile: Dockerfile + restart: unless-stopped + depends_on: + db: { condition: service_healthy } + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-vignette}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-vignette} + # === 엔진(이식성 핵심) === + # 로컬 운영: host 의 claude -p 게이트웨이를 가리킴(아래 extra_hosts 사용) + # 클라우드 이전: 이 값을 Messages API 어댑터 URL 로만 바꾸면 됨. + ENGINE_URL: ${ENGINE_URL:-http://host.docker.internal:9099} + ENGINE_MODE: ${ENGINE_MODE:-claude_p} # claude_p | messages_api + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} # 폴백/클라우드용 + OPENAI_API_KEY: ${OPENAI_API_KEY:?set in .env} # 음성 + RAG_EMBED_URL: http://rag:8080 + SESSION_SECRET: ${SESSION_SECRET:?set in .env} + extra_hosts: + - "host.docker.internal:host-gateway" # 컨테이너→호스트 엔진 게이트웨이 접근(Linux 포함) + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request,sys; urllib.request.urlopen('http://localhost:8000/health'); "] + interval: 15s + timeout: 5s + retries: 5 + networks: [vignette] + + web: + build: + context: ../apps/web + dockerfile: Dockerfile + args: + VITE_API_BASE: ${PUBLIC_API_BASE:-/api} + restart: unless-stopped + depends_on: [api] + networks: [vignette] + + # RAG 사이드카: BGE-M3 임베딩 + BGE-reranker-v2-m3 (CPU 가능, GPU 있으면 가속) + rag: + build: + context: ../apps/api + dockerfile: Dockerfile.rag + restart: unless-stopped + environment: + EMBED_MODEL: BAAI/bge-m3 + RERANK_MODEL: BAAI/bge-reranker-v2-m3 + volumes: + - ragmodels:/models # 모델 캐시(재다운로드 방지 = 이식 시 볼륨만 옮기면 빠름) + networks: [vignette] + + # 리버스프록시 + TLS. 외부노출(chanpaca.net)도 여기서. 끄려면 profiles 로 제외. + proxy: + image: caddy:2-alpine + restart: unless-stopped + depends_on: [web, api] + ports: + - "${HTTP_PORT:-8080}:80" + - "${HTTPS_PORT:-8443}:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddydata:/data + networks: [vignette] + +volumes: + pgdata: + ragmodels: + caddydata: + +networks: + vignette: + driver: bridge