vignette/apps/api/app/config.py
2026-08-09 18:22:03 +09:00

642 lines
25 KiB
Python

"""환경설정 (env -> 타입드 Settings).
마스터플랜 §1.1 원칙: 엔진/DB/음성 엔드포인트는 전부 env 주입(이미지에 굽지 않음 = 이식성).
Docker Compose secrets/.env 로 주입, 코드에 하드코딩 금지.
"""
from __future__ import annotations
from functools import lru_cache
from typing import Literal
from urllib.parse import urlsplit
from pydantic import Field, SecretStr, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from .contracts.engine_gateway import EngineProvider
# 엔진 어댑터 provider 플래그 (마스터플랜 §0, R1: claude -p 과금누수 회피)
# claude_api = Anthropic Messages API 직결 (기본)
# claude_cli = 로컬 claude -p 상주풀 (stream-json, 옵션/시연용)
# codex_cli = 로컬 Codex CLI (app-server 모델 탐색 + exec 생성)
# agy_cli = 로컬 Agy/Google Antigravity CLI
# openai = OpenAI 호환 (폴백/평가 보조)
# solar = 국내 모델 라우팅 (PII 민감구간 inference_geo:kr)
EngineMode = EngineProvider
def _is_local_url(value: str) -> bool:
parsed = urlsplit(value)
host = (parsed.hostname or "").lower()
return host in {"localhost", "127.0.0.1", "::1"}
def _is_allowed_local_dev_cors_origin(value: str) -> bool:
parsed = urlsplit(value)
host = (parsed.hostname or "").lower()
return (
parsed.scheme == "http"
and host in {"localhost", "127.0.0.1"}
and parsed.port in range(5170, 5181)
and not parsed.path
and not parsed.query
and not parsed.fragment
)
def _is_local_or_forbidden_non_dev_origin(value: str) -> bool:
return _is_local_url(value) and not _is_allowed_local_dev_cors_origin(value)
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
case_sensitive=False,
populate_by_name=True,
)
# ── 앱 ───────────────────────────────────────────────
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_gateway_shared_secret: SecretStr = Field(
default=SecretStr(""),
validation_alias="ENGINE_GATEWAY_SHARED_SECRET",
)
engine_mode: EngineMode = Field(
default="claude_api",
validation_alias="ENGINE_MODE",
)
# 실시간 내담자 응답은 평가/저작 provider와 분리할 수 있다. Agy/Codex 같은
# 범용 agent CLI의 도구 prompt와 프로세스 기동 지연을 상담 왕복에 강제하지 않는다.
live_client_provider: EngineMode | None = Field(
default=None,
validation_alias="VIGNETTE_LIVE_CLIENT_PROVIDER",
)
engine_timeout: float = 120.0 # SSE 롱리브드 (50분 상담 대비, 스트림은 무제한 별도)
engine_connect_timeout: float = 10.0
admin_usage_budget_usd: float = Field(
default=0.0,
validation_alias="ADMIN_USAGE_BUDGET_USD",
)
evaluator_fast_model: str = Field(
default="",
validation_alias="EVALUATOR_FAST_MODEL",
)
evaluator_deep_model: str = Field(
default="",
validation_alias="EVALUATOR_DEEP_MODEL",
)
evaluator_semantic_cache_enabled: bool = Field(
default=True,
validation_alias="EVALUATOR_SEMANTIC_CACHE_ENABLED",
)
evaluator_semantic_cache_ttl_seconds: int = Field(
default=900,
validation_alias="EVALUATOR_SEMANTIC_CACHE_TTL_SECONDS",
)
evaluator_semantic_cache_max_entries: int = Field(
default=256,
validation_alias="EVALUATOR_SEMANTIC_CACHE_MAX_ENTRIES",
)
session_evaluation_timeout: float = Field(
default=0.0,
validation_alias="SESSION_EVALUATION_TIMEOUT",
)
session_evaluation_recovery_limit: int = Field(
default=5,
validation_alias="SESSION_EVALUATION_RECOVERY_LIMIT",
)
session_digest_worker_enabled: bool = Field(
default=False,
validation_alias="SESSION_DIGEST_WORKER_ENABLED",
)
session_digest_worker_model: str = Field(
default="",
validation_alias="SESSION_DIGEST_WORKER_MODEL",
)
# ── 외부 LLM 키 (게이트웨이가 못 받을 때 직접 폴백, PII 마스킹 후만) ──
anthropic_api_key: str = Field(default="", validation_alias="ANTHROPIC_API_KEY")
openai_api_key: str = Field(default="", validation_alias="OPENAI_API_KEY")
openai_base_url: str = Field(
default="https://api.openai.com/v1",
validation_alias="OPENAI_BASE_URL",
)
# `local_whisper`는 노트북 상주 faster-whisper 사이드카(scripts/local-whisper-stt-server.py)를
# 쓴다. 오디오가 호스트를 벗어나지 않고 외부 STT 키가 필요 없다.
voice_stt_provider: Literal["openai", "deepgram", "local_whisper"] = Field(
default="openai",
validation_alias="VIGNETTE_VOICE_STT_PROVIDER",
)
local_whisper_stt_url: str = Field(
default="ws://127.0.0.1:9882/v1/listen",
validation_alias="VIGNETTE_LOCAL_WHISPER_STT_URL",
)
local_whisper_stt_model: str = Field(
default="small",
validation_alias="VIGNETTE_LOCAL_WHISPER_STT_MODEL",
)
local_whisper_stt_language: str = Field(
default="ko",
validation_alias="VIGNETTE_LOCAL_WHISPER_STT_LANGUAGE",
)
local_whisper_endpointing_ms: int = Field(
default=300,
ge=10,
le=5000,
validation_alias="VIGNETTE_LOCAL_WHISPER_ENDPOINTING_MS",
)
local_whisper_utterance_end_ms: int = Field(
default=1200,
ge=1000,
le=10000,
validation_alias="VIGNETTE_LOCAL_WHISPER_UTTERANCE_END_MS",
)
local_whisper_finalize_timeout_seconds: float = Field(
default=20.0,
ge=1.0,
le=120.0,
validation_alias="VIGNETTE_LOCAL_WHISPER_FINALIZE_TIMEOUT_SECONDS",
)
deepgram_api_key: SecretStr = Field(
default=SecretStr(""),
validation_alias="DEEPGRAM_API_KEY",
)
deepgram_stt_url: str = Field(
default="wss://api.deepgram.com/v1/listen",
validation_alias="DEEPGRAM_STT_URL",
)
deepgram_stt_model: str = Field(
default="nova-3",
validation_alias="DEEPGRAM_STT_MODEL",
)
deepgram_stt_language: str = Field(
default="ko",
validation_alias="DEEPGRAM_STT_LANGUAGE",
)
deepgram_endpointing_ms: int = Field(
default=300,
ge=10,
le=5000,
validation_alias="DEEPGRAM_ENDPOINTING_MS",
)
deepgram_utterance_end_ms: int = Field(
default=1200,
ge=1000,
le=10000,
validation_alias="DEEPGRAM_UTTERANCE_END_MS",
)
deepgram_keepalive_seconds: float = Field(
default=4.0,
ge=1.0,
le=8.0,
validation_alias="DEEPGRAM_KEEPALIVE_SECONDS",
)
deepgram_finalize_timeout_seconds: float = Field(
default=15.0,
ge=1.0,
le=60.0,
validation_alias="DEEPGRAM_FINALIZE_TIMEOUT_SECONDS",
)
deepgram_mip_opt_out: bool = Field(
default=True,
validation_alias="DEEPGRAM_MIP_OPT_OUT",
)
voice_poc_sample_tts_enabled: bool = Field(
default=False,
validation_alias="VIGNETTE_VOICE_POC_SAMPLE_TTS",
)
voice_poc_sample_tts_dir: str = Field(
default="",
validation_alias="VIGNETTE_VOICE_POC_SAMPLE_TTS_DIR",
)
# `melotts`는 MIT 라이선스 사전학습 한국어 다화자 모델이라 상업 사용 제약도,
# 실존 인물 reference 문제도 없다. Higgs 와 달리 환경·프리셋 제한이 필요 없다.
melotts_tts_url: str = Field(
default="http://127.0.0.1:9883",
validation_alias="VIGNETTE_MELOTTS_TTS_URL",
)
melotts_tts_timeout_seconds: float = Field(
default=120.0,
ge=1.0,
le=600.0,
validation_alias="VIGNETTE_MELOTTS_TTS_TIMEOUT_SECONDS",
)
voice_tts_provider: Literal["openai", "higgs", "melotts"] = Field(
default="openai",
validation_alias="VIGNETTE_VOICE_TTS_PROVIDER",
)
higgs_tts_url: str = Field(
default="http://127.0.0.1:9881",
validation_alias="VIGNETTE_HIGGS_TTS_URL",
)
higgs_tts_timeout_seconds: float = Field(
default=300.0,
validation_alias="VIGNETTE_HIGGS_TTS_TIMEOUT_SECONDS",
)
# ── 세션/인증 (BFF OAuth 2.1, 토큰 서버 보관) ────────
session_secret: str = Field(
default="dev-insecure-change-me",
validation_alias="SESSION_SECRET",
)
# G3 evaluator ingestion is a machine-to-machine boundary. An empty value
# does not fall back to a development secret; the endpoints fail closed.
rupture_internal_token: SecretStr = Field(
default=SecretStr(""),
validation_alias="VIGNETTE_RUPTURE_INTERNAL_TOKEN",
)
# G4 evaluator prescription ingestion is independently revocable from G3.
# Missing or short configuration disables the endpoint instead of falling
# back to a shared development secret.
practice_internal_token: SecretStr = Field(
default=SecretStr(""),
validation_alias="VIGNETTE_PRACTICE_INTERNAL_TOKEN",
)
# G5 evaluator calibration/transfer ingestion has its own independently
# revocable credential and never receives an implicit development fallback.
calibration_transfer_internal_token: SecretStr = Field(
default=SecretStr(""),
validation_alias="VIGNETTE_CALIBRATION_TRANSFER_INTERNAL_TOKEN",
)
# G6 supervision/research ingestion separates supervisor and research AI
# views behind one independently revocable machine credential.
supervision_research_internal_token: SecretStr = Field(
default=SecretStr(""),
validation_alias="VIGNETTE_SUPERVISION_RESEARCH_INTERNAL_TOKEN",
)
supervision_research_producer_enabled: bool = Field(
default=True,
validation_alias="VIGNETTE_SUPERVISION_RESEARCH_PRODUCER_ENABLED",
)
supervision_research_producer_interval_seconds: float = Field(
default=3600.0,
ge=60.0,
validation_alias="VIGNETTE_SUPERVISION_RESEARCH_PRODUCER_INTERVAL_SECONDS",
)
supervision_research_producer_startup_delay_seconds: float = Field(
default=15.0,
ge=0.0,
le=300.0,
validation_alias="VIGNETTE_SUPERVISION_RESEARCH_PRODUCER_STARTUP_DELAY_SECONDS",
)
# G8 automation qualification is research-only and independently revocable;
# no development token is synthesized when the setting is absent.
continuous_improvement_internal_token: SecretStr = Field(
default=SecretStr(""),
validation_alias="VIGNETTE_CONTINUOUS_IMPROVEMENT_INTERNAL_TOKEN",
)
# G8 scheduled generation is opt-in because each queued item performs
# multiple model calls. Schema readiness is checked separately in lifespan.
continuous_improvement_producer_enabled: bool = Field(
default=False,
validation_alias="VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_ENABLED",
)
continuous_improvement_producer_interval_seconds: float = Field(
default=3600.0,
ge=30.0,
validation_alias="VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_INTERVAL_SECONDS",
)
continuous_improvement_producer_startup_delay_seconds: float = Field(
default=30.0,
ge=0.0,
le=300.0,
validation_alias="VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_STARTUP_DELAY_SECONDS",
)
continuous_improvement_producer_retry_delay_seconds: float = Field(
default=300.0,
ge=30.0,
validation_alias="VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_RETRY_DELAY_SECONDS",
)
continuous_improvement_producer_lease_timeout_seconds: float = Field(
default=1800.0,
ge=60.0,
validation_alias="VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_LEASE_TIMEOUT_SECONDS",
)
continuous_improvement_producer_engine_timeout_seconds: float = Field(
default=300.0,
ge=60.0,
le=600.0,
validation_alias="VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_ENGINE_TIMEOUT_SECONDS",
)
continuous_improvement_producer_batch_size: int = Field(
default=1,
ge=1,
le=10,
validation_alias="VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_BATCH_SIZE",
)
# Operational measurement drift is a separate fail-closed ingestion gate.
# Enabling the producer alone must not start consuming benchmark ledgers.
continuous_improvement_drift_trigger_enabled: bool = Field(
default=False,
validation_alias="VIGNETTE_CONTINUOUS_IMPROVEMENT_DRIFT_TRIGGER_ENABLED",
)
# Human authorization and rollback execution are separate boundaries. NAS
# deployments must opt in explicitly and point at an authenticated control plane.
continuous_improvement_rollback_executor_enabled: bool = Field(
default=False,
validation_alias="VIGNETTE_CONTINUOUS_IMPROVEMENT_ROLLBACK_EXECUTOR_ENABLED",
)
continuous_improvement_rollback_executor_endpoint: str = Field(
default="",
validation_alias="VIGNETTE_CONTINUOUS_IMPROVEMENT_ROLLBACK_EXECUTOR_ENDPOINT",
)
continuous_improvement_rollback_executor_token: SecretStr = Field(
default=SecretStr(""),
validation_alias="VIGNETTE_CONTINUOUS_IMPROVEMENT_ROLLBACK_EXECUTOR_TOKEN",
)
continuous_improvement_rollback_executor_timeout_seconds: float = Field(
default=30.0,
ge=2.0,
le=120.0,
validation_alias=(
"VIGNETTE_CONTINUOUS_IMPROVEMENT_ROLLBACK_EXECUTOR_TIMEOUT_SECONDS"
),
)
# G7 consent-bound evaluator ingestion is isolated from every other
# machine credential and fails closed when unconfigured.
multimodal_alliance_internal_token: SecretStr = Field(
default=SecretStr(""),
validation_alias="VIGNETTE_MULTIMODAL_ALLIANCE_INTERNAL_TOKEN",
)
# __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://api-vignette.chanpaca.net/auth/callback",
validation_alias="OAUTH_REDIRECT_URI",
)
auth_allowed_email_domains: list[str] = Field(
default=["hs.ac.kr", "twentyoz.kr"],
validation_alias="AUTH_ALLOWED_EMAIL_DOMAINS",
)
auth_teacher_emails: list[str] = Field(
default=[],
validation_alias="AUTH_TEACHER_EMAILS",
)
auth_admin_emails: list[str] = Field(
default=[],
validation_alias="AUTH_ADMIN_EMAILS",
)
auth_super_admin_emails: list[str] = Field(
default=["yunchan@twentyoz.kr", "hoonjungkoo@hs.ac.kr"],
validation_alias="AUTH_SUPER_ADMIN_EMAILS",
)
auth_approved_emails: list[str] = Field(
default=[],
validation_alias="AUTH_APPROVED_EMAILS",
)
auth_new_user_default_status: Literal["pending", "approved"] = Field(
default="pending",
validation_alias="AUTH_NEW_USER_DEFAULT_STATUS",
)
auth_email_cohort_map: dict[str, str] = Field(
default_factory=dict,
validation_alias="AUTH_EMAIL_COHORT_MAP",
)
auth_domain_cohort_map: dict[str, str] = Field(
default_factory=dict,
validation_alias="AUTH_DOMAIN_COHORT_MAP",
)
auth_dev_login_enabled: bool = Field(
default=False,
validation_alias="AUTH_DEV_LOGIN_ENABLED",
)
auth_saml_enabled: bool = Field(
default=False,
validation_alias="AUTH_SAML_ENABLED",
)
saml_sp_entity_id: str = Field(
default="",
validation_alias="SAML_SP_ENTITY_ID",
)
saml_sso_url: str = Field(
default="",
validation_alias="SAML_SSO_URL",
)
saml_x509_cert_fingerprint: str = Field(
default="",
validation_alias="SAML_X509_CERT_FINGERPRINT",
)
default_affiliation: str = Field(
default="",
validation_alias="DEFAULT_AFFILIATION",
)
frontend_base_url: str = Field(
default="http://localhost:5173",
validation_alias="FRONTEND_BASE_URL",
)
frontend_origin_map: dict[str, str] = Field(
default_factory=lambda: {
"api-vignette.chanpaca.net": "https://vignette.chanpaca.net",
"api-vnet.18ka.net": "https://vnet.18ka.net",
},
validation_alias="FRONTEND_ORIGIN_MAP",
)
user_upload_dir: str = Field(
default="uploads",
validation_alias="USER_UPLOAD_DIR",
)
# ── 운영 메일 알림 ─────────────────────────────────────
notification_email_provider: Literal["disabled", "smtp"] = Field(
default="disabled",
validation_alias="NOTIFICATION_EMAIL_PROVIDER",
)
notification_email_max_attempts: int = Field(
default=3,
validation_alias="NOTIFICATION_EMAIL_MAX_ATTEMPTS",
)
notification_email_retry_seconds: int = Field(
default=900,
validation_alias="NOTIFICATION_EMAIL_RETRY_SECONDS",
)
smtp_host: str = Field(default="", validation_alias="SMTP_HOST")
smtp_port: int = Field(default=587, validation_alias="SMTP_PORT")
smtp_username: str = Field(default="", validation_alias="SMTP_USERNAME")
smtp_password: str = Field(default="", validation_alias="SMTP_PASSWORD")
smtp_from_email: str = Field(default="", validation_alias="SMTP_FROM_EMAIL")
smtp_from_name: str = Field(default="Vignette", validation_alias="SMTP_FROM_NAME")
smtp_starttls: bool = Field(default=True, validation_alias="SMTP_STARTTLS")
smtp_ssl: bool = Field(default=False, validation_alias="SMTP_SSL")
# ── CORS (정적 프론트 + SSE 분리경로) ────────────────
cors_origins: list[str] = Field(
default=[
"https://vignette.chanpaca.net",
"https://vnet.18ka.net",
"https://vignette-b1q.pages.dev",
],
validation_alias="CORS_ORIGINS",
)
auth_dev_login_extra_origins: list[str] = Field(
default=[],
validation_alias="AUTH_DEV_LOGIN_EXTRA_ORIGINS",
)
# Built-in personas are developer/bootstrap fixtures, not runtime truth.
# Production should use approved rows from app.persona_card only.
auto_seed_personas: bool = Field(
default=False,
validation_alias="AUTO_SEED_PERSONAS",
)
allow_seed_persona_fallback: bool = Field(
default=False,
validation_alias="ALLOW_SEED_PERSONA_FALLBACK",
)
# ── 회기 시간 제한 (2026-07-13 한신대 회의: 시간 기반 종료 전환) ──
# 임상 근거: 한 회기에 4단계가 모두 이뤄지지 않는 게 정상 — 종료는 단계 완수가 아니라
# 시간이 결정한다. 목표 달성 후에도 시간 내에는 계속 진행할 수 있다.
session_duration_minutes: int = Field(
default=60,
validation_alias="SESSION_DURATION_MINUTES",
)
session_warning_minutes: int = Field(
default=10,
validation_alias="SESSION_WARNING_MINUTES",
)
# 시간 만료 후에도 마무리 인사를 나눌 수 있는 유예. 유예까지 지나면 새 턴을 거부한다.
session_overtime_grace_minutes: int = Field(
default=10,
validation_alias="SESSION_OVERTIME_GRACE_MINUTES",
)
# ── SSE 스트리밍 ─────────────────────────────────────
sse_heartbeat_seconds: int = 30 # Cloudflare 100초 timeout 회피 (R2)
@property
def is_prod(self) -> bool:
return self.environment == "prod"
@model_validator(mode="after")
def validate_non_dev_runtime_flags(self) -> "Settings":
gateway_secret = self.engine_gateway_shared_secret.get_secret_value().strip()
if gateway_secret and (
len(gateway_secret) < 32
or gateway_secret.lower().startswith(
("change-me", "replace-with", "dummy", "example")
)
):
raise ValueError(
"ENGINE_GATEWAY_SHARED_SECRET must be a non-placeholder value "
"containing at least 32 characters"
)
if self.continuous_improvement_rollback_executor_enabled:
endpoint = self.continuous_improvement_rollback_executor_endpoint.strip()
parsed_endpoint = urlsplit(endpoint)
rollback_token = (
self.continuous_improvement_rollback_executor_token.get_secret_value().strip()
)
local_dev_http = (
self.environment == "dev"
and parsed_endpoint.scheme == "http"
and _is_local_url(endpoint)
)
if not endpoint or not parsed_endpoint.hostname:
raise ValueError(
"rollback executor endpoint is required when executor is enabled"
)
if parsed_endpoint.scheme != "https" and not local_dev_http:
raise ValueError(
"rollback executor endpoint must use HTTPS outside local development"
)
if (
parsed_endpoint.username
or parsed_endpoint.password
or parsed_endpoint.query
or parsed_endpoint.fragment
):
raise ValueError(
"rollback executor endpoint cannot contain credentials, query, or fragment"
)
if len(rollback_token) < 32 or rollback_token.lower().startswith(
("change-me", "replace-with", "dummy", "example")
):
raise ValueError(
"rollback executor token must be a non-placeholder value "
"containing at least 32 characters"
)
if self.environment != "dev":
forbidden: list[str] = []
if self.auth_dev_login_enabled:
forbidden.append("AUTH_DEV_LOGIN_ENABLED")
if self.auto_seed_personas:
forbidden.append("AUTO_SEED_PERSONAS")
if self.allow_seed_persona_fallback:
forbidden.append("ALLOW_SEED_PERSONA_FALLBACK")
if self.voice_poc_sample_tts_enabled:
forbidden.append("VIGNETTE_VOICE_POC_SAMPLE_TTS")
if self.voice_tts_provider == "higgs":
# Higgs Audio v3 TTS 4B는 연구/비상업 라이선스이므로 로컬 dev에서만 쓴다.
forbidden.append("VIGNETTE_VOICE_TTS_PROVIDER=higgs")
if not self.oauth_google_client_id.strip():
forbidden.append("OAUTH_GOOGLE_CLIENT_ID")
if not self.oauth_google_client_secret.strip():
forbidden.append("OAUTH_GOOGLE_CLIENT_SECRET")
if self.session_secret == "dev-insecure-change-me":
forbidden.append("SESSION_SECRET")
if _is_local_url(self.frontend_base_url):
forbidden.append("FRONTEND_BASE_URL")
if any(_is_local_or_forbidden_non_dev_origin(origin) for origin in self.cors_origins):
forbidden.append("CORS_ORIGINS")
if any(
_is_local_or_forbidden_non_dev_origin(origin)
for origin in self.frontend_origin_map.values()
):
forbidden.append("FRONTEND_ORIGIN_MAP")
if self.notification_email_provider == "smtp":
if not self.smtp_host.strip():
forbidden.append("SMTP_HOST")
if not self.smtp_from_email.strip():
forbidden.append("SMTP_FROM_EMAIL")
if forbidden:
joined = ", ".join(forbidden)
raise ValueError(f"{joined} must be production-safe when ENVIRONMENT={self.environment}")
if self.smtp_ssl and self.smtp_starttls:
raise ValueError("SMTP_SSL and SMTP_STARTTLS cannot both be true")
if self.auth_saml_enabled:
missing_saml: list[str] = []
if not self.saml_sp_entity_id.strip():
missing_saml.append("SAML_SP_ENTITY_ID")
if not self.saml_sso_url.strip():
missing_saml.append("SAML_SSO_URL")
if missing_saml:
joined = ", ".join(missing_saml)
raise ValueError(f"{joined} must be configured when AUTH_SAML_ENABLED=true")
return self
@lru_cache
def get_settings() -> Settings:
"""프로세스 1회 로드 (lru_cache). 의존성 주입은 deps.get_settings_dep 사용."""
return Settings()
settings = get_settings()