Spaces:
Running
Running
| """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 | |
| # βββ Frontend / Widget ββββββββββββββββββββββββββββββββ | |
| SAAS_CDN_URL: str = "http://localhost:3000" # Widget script served from here | |
| ALLOWED_ORIGINS: list[str] = ["http://localhost:3000"] | |
| # βββ 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 | |
| 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 | |
| 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 | |
| def get_settings() -> Settings: | |
| """Return cached settings instance. Use this everywhere.""" | |
| return Settings() | |