Arag / app /config.py
AuthorBot
P0 security: async backup, widget CORS, Redis auth.
937692a
Raw
History Blame Contribute Delete
10.1 kB
"""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
@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()