Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 39 additions & 25 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,36 +20,50 @@ MINIO_CONSOLE_PORT=38061
MINIO_BUCKET=stackup

# AI server
#
# 여기에 `KEY=값` 으로 적은 줄은 deploy-app.yml 이 운영 .env 에 **영구 복사**하고,
# 그 값이 ai/src/ai_server/config/settings.py 의 기본값을 이긴다. 그래서 코드 기본값과
# 같은 값을 여기 적으면 나중에 코드를 고쳐도 운영에 도달하지 못한다 —
# 실제로 LLM_PRO_TIMEOUT_SEC 를 30→60 으로 올린 변경이 이렇게 무효가 됐다.
#
# 규칙: **환경마다 달라야 하는 것만** 적는다(시크릿·운영 전용 선택). 나머지는
# 주석으로 남겨 "이런 노브가 있다"만 알리고, 값의 주인은 settings.py 로 둔다.
AI_PORT=38030
STORAGE_BACKEND=s3
STORAGE_LOCAL_ROOT=/var/lib/stackup-ai/storage
S3_REGION=us-east-1
LLM_API_KEY=
LLM_BASE_URL=https://factchat-cloud.mindlogic.ai/v1/gateway
LLM_PRO_MODEL=gemini-3.1-pro-preview
LLM_PRO_TIMEOUT_SEC=60.0
LLM_FLASH_MODEL=gemini-3.5-flash-lite
LLM_FLASH_TEMPERATURE=0.4
LLM_FLASH_MAX_TOKENS=512
LLM_FLASH_TIMEOUT_SEC=10.0
DEEPGRAM_API_KEY=
# 코드 기본값은 mock(로컬에서 키 없이 돌리기 위함) — 운영은 반드시 gemini 여야 한다.
# 이 줄이 없으면 신규 배포가 가짜 임베딩으로 조용히 뜨고 RAG 검색이 무의미해진다.
EMBEDDING_PROVIDER=gemini
STORAGE_LOCAL_ROOT=/var/lib/stackup-ai/storage

# ── 아래는 전부 settings.py 가 기본값을 가진다. 바꿀 때만 주석을 풀어 적는다. ──
# STORAGE_BACKEND=s3
# S3_REGION=us-east-1
# LLM_BASE_URL=https://factchat-cloud.mindlogic.ai/v1/gateway
# LLM_PRO_MODEL=gemini-3.1-pro-preview
# LLM_PRO_TIMEOUT_SEC=60.0
# LLM_FLASH_MODEL=gemini-3.5-flash-lite
# LLM_FLASH_TEMPERATURE=0.4
# LLM_FLASH_MAX_TOKENS=512
# LLM_FLASH_TIMEOUT_SEC=10.0
# QUESTIONS_RAG_TIMEOUT_SEC=1.5
# 티어별 엔드포인트 오버라이드(OpenAI 호환). 비우면 위 게이트웨이 공유.
# 예) 꼬리질문만 로컬 Ollama: LLM_FLASH_BASE_URL=http://ollama:11434/v1 + LLM_FLASH_MODEL=exaone3.5:7.8b
# 오버라이드한 티어는 LLM_API_KEY(학교 키)를 물려받지 않는다.
LLM_PRO_BASE_URL=
LLM_PRO_API_KEY=
LLM_FLASH_BASE_URL=
LLM_FLASH_API_KEY=
QUESTIONS_RAG_TIMEOUT_SEC=1.5

# 음성 STT (Phase 2). auto=DEEPGRAM_API_KEY 있으면 deepgram, 없으면 mock.
STT_PROVIDER=auto
DEEPGRAM_API_KEY=
DEEPGRAM_MODEL=whisper-large
DEEPGRAM_LANGUAGE=ko
DEEPGRAM_TIMEOUT_SEC=20
DEEPGRAM_CONNECT_TIMEOUT_SEC=5
STT_MAX_ATTEMPTS=3
STT_RETRY_BACKOFF_SEC=0.5
# LLM_PRO_BASE_URL=
# LLM_PRO_API_KEY=
# LLM_FLASH_BASE_URL=
# LLM_FLASH_API_KEY=
# 음성 STT. auto=DEEPGRAM_API_KEY 있으면 deepgram, 없으면 mock.
# STT_PROVIDER=auto
# DEEPGRAM_MODEL=whisper-large
# DEEPGRAM_LANGUAGE=ko
# DEEPGRAM_TIMEOUT_SEC=20
# DEEPGRAM_CONNECT_TIMEOUT_SEC=5
# STT_MAX_ATTEMPTS=3
# STT_RETRY_BACKOFF_SEC=0.5
# EMBEDDING_MODEL=gemini-embedding-001
# EMBEDDING_DIM=1536

# RealTime server
REALTIME_PORT=38020
Expand Down
100 changes: 100 additions & 0 deletions ai/tests/test_compose_defaults.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
from __future__ import annotations

import re
from pathlib import Path

from ai_server.config.settings import Settings

COMPOSE = Path(__file__).resolve().parents[2] / "docker-compose.yml"

# compose 가 기본값을 가져야 하는 키 — settings.py 가 알 수 없는 값들.
# · 컨테이너 토폴로지: 서비스 호스트명(minio/backend/rabbitmq)은 compose 네트워크의 사실이고
# settings.py 기본값(localhost)은 로컬 실행용이라 서로 달라야 정상이다.
# · 이름 변환: MINIO_ROOT_USER → S3_ACCESS_KEY 처럼 키 이름 자체가 바뀌는 것.
# · 로컬 개발 편의 기본값: 비어 있으면 로컬 기동이 막히는 것(CORE_INTERNAL_API_KEY).
COMPOSE_OWNED = {
"RABBITMQ_URL",
"S3_ENDPOINT_URL",
"S3_ACCESS_KEY",
"S3_SECRET_KEY",
"S3_BUCKET_NAME",
"STORAGE_LOCAL_ROOT",
"CORE_INTERNAL_BASE_URL",
"CORE_INTERNAL_API_KEY",
}


def _ai_service_block() -> str:
text = COMPOSE.read_text(encoding="utf-8")
start = text.index("\n ai:")
rest = text[start + 1 :]
# 첫 줄(` ai:`) 자신이 매칭되지 않도록 다음 줄부터 형제 서비스를 찾는다.
body_at = rest.index("\n") + 1
nxt = re.search(r"^ [a-z][a-z0-9_-]*:$", rest[body_at:], re.M)
return rest[: body_at + nxt.start()] if nxt else rest


def _env_entries() -> list[str]:
"""ai 서비스의 `environment:` 리스트 항목만. ports/extra_hosts 같은 다른 리스트는 제외."""
block = _ai_service_block()
env_at = block.index(" environment:")
after = block[env_at + len(" environment:") :]
end = re.search(r"^ [a-z_]+:", after, re.M)
body = after[: end.start()] if end else after
return [
line.strip()[2:] for line in body.splitlines() if line.strip().startswith("- ")
]


def _declared_defaults() -> dict[str, str]:
"""compose 가 값을 직접 박아 둔 환경변수 → 그 값."""
out: dict[str, str] = {}
for entry in _env_entries():
if "=" in entry:
key, value = entry.split("=", 1)
out[key.strip()] = value.strip()
return out


def _passthrough_keys() -> list[str]:
"""이름만 적어 전달만 하는 환경변수."""
return [e for e in _env_entries() if "=" not in e]


def test_compose_does_not_shadow_settings_defaults() -> None:
"""compose 가 settings.py 의 기본값을 다시 적으면 코드 수정이 운영에 도달하지 못한다.

실제로 `LLM_PRO_TIMEOUT_SEC: ${LLM_PRO_TIMEOUT_SEC:-30.0}` 한 줄이 settings.py 의
30→60 변경을 통째로 삼켰고, 웹 이력서 분석이 계속 타임아웃으로 죽었다(실제 소요 37.4초).
조용히 무효가 되는 종류의 사고라 사람 눈으로는 못 잡는다 — 여기서 막는다.
"""
settings_fields = {name.upper() for name in Settings.model_fields}
offenders = sorted(
key
for key in _declared_defaults()
if key in settings_fields and key not in COMPOSE_OWNED
)
assert not offenders, (
"docker-compose.yml 이 settings.py 가 소유한 기본값을 덮어쓴다: "
f"{offenders}. 값을 지우고 이름만 남겨 전달만 하거나"
"(`- KEY`), 정말 compose 가 소유해야 하면 COMPOSE_OWNED 에 추가하고 이유를 적는다."
)


def test_passthrough_keys_are_known_settings() -> None:
"""이름만 적힌 전달용 키가 settings.py 에 없으면 오타이거나 죽은 설정이다."""
passthrough = _passthrough_keys()
settings_fields = {name.upper() for name in Settings.model_fields}
unknown = sorted(k for k in passthrough if k not in settings_fields)
assert not unknown, f"settings.py 에 없는 환경변수를 전달하고 있다: {unknown}"


def test_no_empty_string_fallbacks_for_settings_keys() -> None:
"""`${VAR:-}` 는 미설정을 빈 문자열로 바꿔 코드 기본값을 덮는다 — 전달만 하게 둔다."""
settings_fields = {name.upper() for name in Settings.model_fields}
bad = sorted(
key
for key, value in _declared_defaults().items()
if key in settings_fields and re.fullmatch(r"\$\{[A-Z_0-9]+:-\}", value)
)
assert not bad, f"빈 문자열 fallback 이 코드 기본값을 덮는다: {bad}"
80 changes: 47 additions & 33 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,41 +75,55 @@ services:
container_name: stackup-ai
ports:
- "${AI_PORT:-38030}:38030"
# 값이 붙은 항목만 compose 가 소유한다 — 컨테이너 토폴로지(서비스 호스트명)와
# MINIO_* → S3_* 이름 변환처럼 settings.py 가 알 수 없는 것들.
#
# 이름만 적은 항목은 **전달만** 한다: .env 에 있으면 그 값이, 없으면 아무것도 넘어가지
# 않아 settings.py 의 기본값이 그대로 산다. 여기에 `${VAR:-기본값}` 으로 기본값을 또
# 적으면 settings.py 를 고쳐도 운영에 영원히 도달하지 못한다 —
# 실제로 LLM_PRO_TIMEOUT_SEC 를 30→60 으로 올린 변경이 이 줄에 막혀 무효가 됐고,
# 웹 이력서 분석이 계속 타임아웃으로 죽었다(실제 소요 37.4초).
# `${VAR:-}` 도 금지다 — 미설정이 빈 문자열로 주입돼 역시 코드 기본값을 덮는다.
# 중복 방지는 ai/tests/test_compose_defaults.py 가 CI 에서 강제한다.
environment:
RABBITMQ_URL: amqp://${RABBITMQ_USER:-stackup}:${RABBITMQ_PASSWORD:-stackup}@rabbitmq:38050/
STORAGE_BACKEND: ${STORAGE_BACKEND:-s3}
STORAGE_LOCAL_ROOT: ${STORAGE_LOCAL_ROOT:-/var/lib/stackup-ai/storage}
S3_ENDPOINT_URL: http://minio:38060
S3_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin}
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin}
S3_BUCKET_NAME: ${MINIO_BUCKET:-stackup}
S3_REGION: ${S3_REGION:-us-east-1}
LLM_API_KEY: ${LLM_API_KEY:-}
LLM_BASE_URL: ${LLM_BASE_URL:-https://factchat-cloud.mindlogic.ai/v1/gateway}
LLM_PRO_MODEL: ${LLM_PRO_MODEL:-gemini-3.1-pro-preview}
LLM_FLASH_MODEL: ${LLM_FLASH_MODEL:-gemini-3.5-flash-lite}
LLM_FLASH_TEMPERATURE: ${LLM_FLASH_TEMPERATURE:-0.4}
LLM_FLASH_MAX_TOKENS: ${LLM_FLASH_MAX_TOKENS:-512}
LLM_PRO_TIMEOUT_SEC: ${LLM_PRO_TIMEOUT_SEC:-30.0}
LLM_PRO_BASE_URL: ${LLM_PRO_BASE_URL:-}
LLM_PRO_API_KEY: ${LLM_PRO_API_KEY:-}
LLM_FLASH_BASE_URL: ${LLM_FLASH_BASE_URL:-}
LLM_FLASH_API_KEY: ${LLM_FLASH_API_KEY:-}
LLM_FLASH_TIMEOUT_SEC: ${LLM_FLASH_TIMEOUT_SEC:-10.0}
QUESTIONS_RAG_TIMEOUT_SEC: ${QUESTIONS_RAG_TIMEOUT_SEC:-1.5}
STT_PROVIDER: ${STT_PROVIDER:-auto}
DEEPGRAM_API_KEY: ${DEEPGRAM_API_KEY:-}
DEEPGRAM_BASE_URL: ${DEEPGRAM_BASE_URL:-https://api.deepgram.com/v1}
DEEPGRAM_MODEL: ${DEEPGRAM_MODEL:-whisper-large}
DEEPGRAM_LANGUAGE: ${DEEPGRAM_LANGUAGE:-ko}
- RABBITMQ_URL=amqp://${RABBITMQ_USER:-stackup}:${RABBITMQ_PASSWORD:-stackup}@rabbitmq:38050/
- S3_ENDPOINT_URL=http://minio:38060
- S3_ACCESS_KEY=${MINIO_ROOT_USER:-minioadmin}
- S3_SECRET_KEY=${MINIO_ROOT_PASSWORD:-minioadmin}
- S3_BUCKET_NAME=${MINIO_BUCKET:-stackup}
- STORAGE_LOCAL_ROOT=${STORAGE_LOCAL_ROOT:-/var/lib/stackup-ai/storage}
# AI → Core 내부 API. 같은 compose 네트워크의 backend 서비스로 직접 라우팅.
CORE_INTERNAL_BASE_URL: ${CORE_INTERNAL_BASE_URL:-http://backend:38010}
CORE_INTERNAL_API_KEY: ${CORE_INTERNAL_API_KEY:-local-development-internal-api-key}
# 임베딩
EMBEDDING_PROVIDER: ${EMBEDDING_PROVIDER:-mock}
EMBEDDING_MODEL: ${EMBEDDING_MODEL:-gemini-embedding-001}
EMBEDDING_DIM: ${EMBEDDING_DIM:-1536}
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
- CORE_INTERNAL_BASE_URL=${CORE_INTERNAL_BASE_URL:-http://backend:38010}
- CORE_INTERNAL_API_KEY=${CORE_INTERNAL_API_KEY:-local-development-internal-api-key}
# ── 아래는 전달만. 기본값의 주인은 ai/src/ai_server/config/settings.py ──
- S3_REGION
- STORAGE_BACKEND
- LLM_API_KEY
- LLM_BASE_URL
- LLM_PRO_MODEL
- LLM_FLASH_MODEL
- LLM_FLASH_TEMPERATURE
- LLM_FLASH_MAX_TOKENS
- LLM_PRO_TIMEOUT_SEC
- LLM_PRO_BASE_URL
- LLM_PRO_API_KEY
- LLM_FLASH_BASE_URL
- LLM_FLASH_API_KEY
- LLM_FLASH_TIMEOUT_SEC
- QUESTIONS_RAG_TIMEOUT_SEC
- STT_PROVIDER
- STT_MAX_ATTEMPTS
- STT_RETRY_BACKOFF_SEC
- DEEPGRAM_API_KEY
- DEEPGRAM_BASE_URL
- DEEPGRAM_MODEL
- DEEPGRAM_LANGUAGE
- DEEPGRAM_TIMEOUT_SEC
- DEEPGRAM_CONNECT_TIMEOUT_SEC
- EMBEDDING_PROVIDER
- EMBEDDING_MODEL
- EMBEDDING_DIM
- GEMINI_API_KEY
extra_hosts:
- "host.docker.internal:host-gateway"
depends_on:
Expand Down
31 changes: 31 additions & 0 deletions docs/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,37 @@

---

## 0. 설정값의 주인 — 우선순위

AI 서버 설정은 세 군데에 있을 수 있고, **아래로 갈수록 세다**:

```
ai/src/ai_server/config/settings.py 기본값의 주인 (single source of truth)
↑ 덮어씀
docker-compose.yml 컨테이너 토폴로지·이름 변환만 (`- KEY=값`)
↑ 덮어씀
운영 .env 환경마다 달라야 하는 것만 (시크릿·운영 전용 선택)
```

**같은 기본값을 두 군데 적지 않는다.** compose 나 `.env` 에 코드와 같은 값을 적어 두면
`settings.py` 를 고쳐도 운영에 영원히 도달하지 못하고, 조용히 무효가 돼서 알아챌 방법도 없다.
실제로 `LLM_PRO_TIMEOUT_SEC` 를 30→60 으로 올린 변경이 compose 의
`${LLM_PRO_TIMEOUT_SEC:-30.0}` 한 줄에 막혀 무효가 됐고, 웹 이력서 분석이 계속
타임아웃으로 죽었다(실제 소요 37.4초 — 30초로는 불가능, 60초면 성공).

- compose 는 값을 지우고 **이름만** 적어 전달만 한다(`- LLM_PRO_TIMEOUT_SEC`).
`.env` 에 있으면 그 값이 가고, 없으면 아무것도 안 가서 코드 기본값이 산다.
`${VAR:-}` 도 금지 — 미설정이 빈 문자열로 주입돼 역시 코드 기본값을 덮는다.
- `.env.example` 에 `KEY=값` 으로 적은 줄은 `deploy-app.yml` 이 운영 `.env` 에 **영구 복사**한다.
그러므로 환경마다 달라야 하는 것만 적고, 나머지는 주석으로 남긴다.
- 이 규약은 `ai/tests/test_compose_defaults.py` 가 CI 에서 강제한다.

**예외** — compose 가 기본값을 가져야 하는 것: 서비스 호스트명(`http://backend:38010`,
`http://minio:38060`)처럼 `settings.py` 가 알 수 없는 컨테이너 토폴로지, `MINIO_ROOT_USER`
→ `S3_ACCESS_KEY` 같은 이름 변환, 비면 로컬 기동이 막히는 개발용 기본값.

---

## 1. 환경 분리

| 환경 | 용도 | 호스트 |
Expand Down
Loading