diff --git a/.env.example b/.env.example index 54fc4856..dabf182c 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/ai/tests/test_compose_defaults.py b/ai/tests/test_compose_defaults.py new file mode 100644 index 00000000..4d74adb1 --- /dev/null +++ b/ai/tests/test_compose_defaults.py @@ -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}" diff --git a/docker-compose.yml b/docker-compose.yml index a1b1975b..cfad621d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docs/environment.md b/docs/environment.md index 1df64847..0ce88620 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -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. 환경 분리 | 환경 | 용도 | 호스트 |