"""Author RAG Chatbot SaaS — Application Configuration. All environment variables are defined here via pydantic BaseSettings. NEVER hardcode any value anywhere else in the codebase. Import `get_settings()` wherever configuration is needed. """ from functools import lru_cache from typing import Literal from pydantic import AliasChoices, EmailStr, Field, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): """Central configuration loaded from environment variables.""" model_config = SettingsConfigDict( env_file=(".env", "email_credentials.env"), env_file_encoding="utf-8", case_sensitive=False, extra="ignore", ) # ─── App ────────────────────────────────────────────── APP_NAME: str = "AuthorBot" APP_ENV: Literal["development", "staging", "production"] = "development" DEBUG: bool = False SECRET_KEY: str = Field( default="change-me-set-a-real-secret-key-in-hf-secrets-32chars", min_length=32, ) JWT_PRIVATE_KEY: str | None = None # RS256 private key (PEM) JWT_PUBLIC_KEY: str | None = None # RS256 public key (PEM) JWT_ALGORITHM: str = "HS256" ACCESS_TOKEN_EXPIRE_MINUTES: int = 480 # 8 hours — short-lived, auto-refreshed REFRESH_TOKEN_EXPIRE_DAYS: int = 30 # 30 days — stay logged in until explicit logout MAX_CONCURRENT_SESSIONS: int = 3 # ─── Database ───────────────────────────────────────── # HF Spaces persistent bucket: sqlite+aiosqlite:////data/db.sqlite DATABASE_URL: str = Field(default="sqlite+aiosqlite:////data/db.sqlite") DB_POOL_SIZE: int = 10 DB_MAX_OVERFLOW: int = 20 # ─── Redis ──────────────────────────────────────────── REDIS_URL: str = Field(default="redis://localhost:6379/0") REDIS_PASSWORD: str = Field(default="", exclude=True, description="Redis AUTH (required in production)") REDIS_DECODE_RESPONSES: bool = True # ─── ChromaDB ───────────────────────────────────────── CHROMA_CLIENT_MODE: Literal["http", "persistent"] = "persistent" CHROMA_HOST: str = "localhost" CHROMA_PORT: int = 8000 CHROMA_PERSIST_DIR: str = "/data/chroma" # ─── OpenAI ─────────────────────────────────────────── OPENAI_API_KEY: str = Field(default="") OPENAI_CHAT_MODEL: str = "gpt-4o" # Never change without approval OPENAI_EMBEDDING_MODEL: str = "text-embedding-3-small" OPENAI_MAX_RETRIES: int = 3 OPENAI_RETRY_DELAY_BASE_SECONDS: float = 1.0 # Exponential backoff base # ─── Google Books (catalog / platform presence) ─────── GOOGLE_BOOKS_API_KEY: str = Field(default="") # ─── Retailer APIs (optional env fallback — SuperAdmin DB overrides) ── RAINFOREST_API_KEY: str = Field(default="", description="Amazon product API (Rainforest)") # ─── RAG Pipeline ───────────────────────────────────── RAG_MAX_CONTEXT_TOKENS: int = 4096 # Hard limit, never exceed RAG_MAX_RESPONSE_TOKENS: int = 200 RAG_RETRIEVAL_TOP_K: int = 10 RAG_RERANK_TOP_N: int = 5 RAG_RERANK_MIN_SCORE: float = 0.3 RAG_FAITHFULNESS_THRESHOLD: float = 0.55 RAG_BOOK_CONFIDENCE_THRESHOLD: float = 0.75 # Below → show book selector RAG_SESSION_HISTORY_TURNS: int = 10 RAG_SESSION_TTL_MINUTES: int = 30 RAG_TEMPERATURE: float = 0.7 RAG_REWRITER_MAX_TOKENS: int = 300 RAG_MAX_INPUT_CHARS: int = 800 # Hard input cap (B2 fix — was 2000 silent truncation) # ─── Chunking ───────────────────────────────────────── CHUNK_SIZE: int = 512 CHUNK_OVERLAP: int = 64 EMBEDDING_BATCH_SIZE: int = 100 # ─── File Upload ────────────────────────────────────── UPLOAD_MAX_FILE_SIZE_MB: int = 50 UPLOAD_CHUNK_SIZE_MB: int = 5 UPLOAD_STORAGE_PATH: str = "/data/uploads" SUPPORTED_FILE_EXTENSIONS: list[str] = ["pdf", "epub", "docx", "txt"] # ─── Email (Gmail SMTP) ─────────────────────────────── SMTP_HOST: str = "smtp.gmail.com" SMTP_PORT: int = 465 SMTP_USE_SSL: bool = True SMTP_USERNAME: EmailStr | None = Field( default=None, validation_alias=AliasChoices("SMTP_USERNAME", "GMAIL_USER"), ) SMTP_PASSWORD: str | None = Field( default=None, validation_alias=AliasChoices("SMTP_PASSWORD", "GMAIL_APP_PASSWORD"), ) EMAIL_FROM_NAME: str = "AuthorBot" EMAIL_FROM_ADDRESS: EmailStr | None = Field( default=None, validation_alias=AliasChoices("EMAIL_FROM_ADDRESS", "GMAIL_USER"), ) SUPPORT_EMAIL: EmailStr | None = Field( default=None, description="Destination for publish-help requests; falls back to SMTP_USERNAME", ) # ─── Rate Limiting ──────────────────────────────────── RATE_LIMIT_CHAT_PER_MINUTE: int = 60 RATE_LIMIT_AUTH_PER_MINUTE: int = 10 RATE_LIMIT_VISITOR_PER_HOUR: int = 60 # Per visitor fingerprint (engaged sales chats) MAX_LOGIN_ATTEMPTS: int = 5 LOCKOUT_DURATION_MINUTES: int = 15 # ─── Analytics ──────────────────────────────────────── GEO_DB_PATH: str = "./geoip/GeoLite2-City.mmdb" ANALYTICS_RETENTION_DAYS: int = 365 BOT_USER_AGENTS_BLOCKLIST: list[str] = [ "Googlebot", "bingbot", "Slurp", "DuckDuckBot", "Baiduspider", "YandexBot", "facebookexternalhit", ] # ─── SuperAdmin ─────────────────────────────────────── SUPERADMIN_2FA_ISSUER: str = "AuthorBot SuperAdmin" SUBSCRIPTION_TOKEN_ALGORITHM: str = "HS256" SUBSCRIPTION_SECRET: str | None = Field( default=None, validation_alias=AliasChoices("SUBSCRIPTION_SECRET", "SUBSCRIPTION_TOKEN_SECRET"), ) DEMO_SUBSCRIPTION_TOKEN: str | None = Field( default=None, validation_alias=AliasChoices("DEMO_SUBSCRIPTION_TOKEN", "SUBSCRIPTION_TOKEN"), ) SUPERADMIN_TOTP_SECRET: str | None = Field(default=None, exclude=True) # Seed superadmin account on first startup (set via HF Secrets) SUPERADMIN_EMAIL: str | None = Field( default=None, validation_alias=AliasChoices("SUPERADMIN_EMAIL", "SUPER_ADMIN_USER"), ) SUPERADMIN_PASSWORD: str | None = Field( default=None, validation_alias=AliasChoices("SUPERADMIN_PASSWORD", "SUPER_ADMIN_PASS"), exclude=True, ) # ─── Token Budgets (defaults, overridden per plan) ──── PLAN_MONTHLY_TOKENS: int = 500_000 PLAN_QUARTERLY_TOKENS: int = 1_500_000 PLAN_SEMI_ANNUAL_TOKENS: int = 3_000_000 PLAN_ANNUAL_TOKENS: int = 7_000_000 TOKEN_WARNING_THRESHOLD_PCT: float = 0.80 TOKEN_ALERT_THRESHOLD_PCT: float = 0.95 # ─── Stripe Billing ─────────────────────────────────── # Test mode: sk_test_... (default — safe to commit .env.example with placeholder) # Live mode: sk_live_... (set in production .env — zero code changes needed) STRIPE_SECRET_KEY: str = Field(default="", description="Stripe secret key (sk_test_... or sk_live_...)") STRIPE_PUBLISHABLE_KEY: str = Field(default="", description="Stripe publishable key (pk_test_... or pk_live_...)") STRIPE_WEBHOOK_SECRET: str = Field(default="", description="Stripe webhook signing secret (whsec_...)") # ─── Frontend / Widget ──────────────────────────────── SAAS_CDN_URL: str = "http://localhost:3000" # Widget script served from here ALLOWED_ORIGINS: list[str] = ["http://localhost:3000"] FRONTEND_URL: str = Field( default="http://localhost:3000", description="Base URL of the Next.js marketing website (for Stripe redirect URLs)", ) # ─── Auth / Password Reset ──────────────────────────── PASSWORD_RESET_EXPIRE_MINUTES: int = 60 # Reset tokens expire after 1 hour # ─── Celery ─────────────────────────────────────────── CELERY_BROKER_URL: str = Field(default="redis://localhost:6379/1") CELERY_RESULT_BACKEND: str = Field(default="redis://localhost:6379/2") # ─── Observability ──────────────────────────────────── SENTRY_DSN: str | None = None # R-100: Set via env to enable error tracking @field_validator("SECRET_KEY") @classmethod def secret_key_must_be_strong(cls, v: str) -> str: """Enforce minimum secret key entropy.""" if len(v) < 32: raise ValueError("SECRET_KEY must be at least 32 characters long") return v @model_validator(mode="after") def apply_hf_secret_defaults(self) -> "Settings": """Fill derived settings from legacy Hugging Face secret names.""" from app.core.redis_url import redis_url_has_auth, redis_url_with_auth if self.EMAIL_FROM_ADDRESS is None and self.SMTP_USERNAME is not None: self.EMAIL_FROM_ADDRESS = self.SMTP_USERNAME if self.SUPPORT_EMAIL is None and self.SMTP_USERNAME is not None: self.SUPPORT_EMAIL = self.SMTP_USERNAME if self.REDIS_PASSWORD: self.REDIS_URL = redis_url_with_auth(self.REDIS_URL, self.REDIS_PASSWORD) self.CELERY_BROKER_URL = redis_url_with_auth(self.CELERY_BROKER_URL, self.REDIS_PASSWORD) self.CELERY_RESULT_BACKEND = redis_url_with_auth( self.CELERY_RESULT_BACKEND, self.REDIS_PASSWORD, ) if self.APP_ENV == "production" and not redis_url_has_auth(self.REDIS_URL): raise ValueError( "REDIS_PASSWORD or password in REDIS_URL is required when APP_ENV=production", ) return self @lru_cache def get_settings() -> Settings: """Return cached settings instance. Use this everywhere.""" return Settings()