Files
docqube_backend/app/core/settings.py
T
2026-09-08 15:10:59 +05:30

280 lines
9.4 KiB
Python

"""
Central Application Settings
Loads environment variables using Pydantic v2
"""
import os
from typing import Optional, List
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import ConfigDict, model_validator
class Settings(BaseSettings):
APP_ENV: str
HOST: str
PORT: int
DEBUG: bool
DB_HOST: str
ZOHO_CLIENT_ID: str = ""
ZOHO_CLIENT_SECRET: str = ""
ZOHO_REFRESH_TOKEN: str = ""
ZOHO_WEBHOOK_SECRET: str = ""
ZOHO_DOMAIN: str = "in"
DB_PORT: int
DB_NAME: str
DB_USER: str
DB_PASSWORD: str
DB_SSLMODE: str
PGBOUNCER_PORT: Optional[int] = None
@property
def DATABASE_URL(self) -> str:
import urllib.parse
user = urllib.parse.quote(self.DB_USER, safe='')
password = urllib.parse.quote(self.DB_PASSWORD, safe='')
return (
f"postgresql+psycopg2://{user}:"
f"{password}@"
f"{self.DB_HOST}:{self.DB_PORT}/"
f"{self.DB_NAME}?sslmode={self.DB_SSLMODE}"
)
@property
def PGBOUNCER_URL(self) -> Optional[str]:
if not self.PGBOUNCER_PORT:
return None
import urllib.parse
user = urllib.parse.quote(self.DB_USER, safe='')
password = urllib.parse.quote(self.DB_PASSWORD, safe='')
return (
f"postgresql+psycopg2://{user}:"
f"{password}@"
f"{self.DB_HOST}:{self.PGBOUNCER_PORT}/"
f"{self.DB_NAME}?sslmode={self.DB_SSLMODE}"
)
APP_SECRET: str
ALGORITHM: str
ACCESS_TOKEN_EXPIRE_MINUTES: int
MAX_ACTIVE_DEVICES: int = 3
REDIS_HOST: Optional[str]
REDIS_PORT: Optional[int]
REDIS_PASSWORD: Optional[str]
@property
def REDIS_URL(self) -> Optional[str]:
if not self.REDIS_HOST:
return None
import urllib.parse
encoded_password = (
urllib.parse.quote(
self.REDIS_PASSWORD) if self.REDIS_PASSWORD else None
)
if encoded_password:
return f"redis://:{encoded_password}@{self.REDIS_HOST}:{self.REDIS_PORT}/0"
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/0"
CHAT_REDIS_MAX_MESSAGES: int
CHAT_REDIS_TTL_SECONDS: int
STORAGE_DRIVE_DIR: str
CLAMAV_HOST: str
CLAMAV_PORT: int
CLAMAV_TIMEOUT_SECONDS: float = 20.0
CLAMAV_CHUNK_SIZE: int = 1024 * 1024
API_BASE_URL: str
GOOGLE_CLIENT_ID: Optional[str] = None
GOOGLE_CLIENT_SECRET: Optional[str] = None
SAAS_TRUST_SECRET: Optional[str] = None
SAAS_BASE_URL: Optional[str] = None
SAAS_PUBLIC_KEY: Optional[str] = None
DOCUSEAL_API_URL: str = "http://localhost:3000"
DOCUSEAL_API_KEY: Optional[str] = None
DOCUSEAL_ACCOUNT_EMAIL: Optional[str] = None
DEEPSEEK_API_KEY: Optional[str] = None
DEEPSEEK_BASE_URL: str = "https://api.deepseek.com/v1"
DEEPSEEK_MODEL: str = "deepseek-chat"
LLM_TEMPERATURE: float = 0.2
EMBEDDING_MODEL: str = "BAAI/bge-base-en-v1.5"
EXTRACTION_API_KEY: Optional[str] = None
EXTRACTION_BASE_URL: str = "https://integrate.api.nvidia.com/v1"
EXTRACTION_MODEL: str = "mistralai/mistral-medium-3-instruct"
EXTRACTION_VISION_MODEL: str = "meta/llama-3.2-90b-vision-instruct"
HF_HUB_OFFLINE: bool = False
TRANSFORMERS_OFFLINE: bool = False
CHUNK_SIZE: int = 850
CHUNK_OVERLAP: int = 180
TOP_K_QA: int = 3
TOP_K_RETRIEVAL: int = 12
TOP_K_RERANK: int = 6
MAX_CHAT_HISTORY: int = 5
TOP_K_SUMMARY: int = 4
MAX_CONTEXT_CHARS: int = 4000
MAX_RESPONSE_TOKENS: int = 1024
CHAT_DAILY_CREDITS_LIMIT: int
MIN_SIMILARITY_SCORE: float = 0.18
RAG_DOMAIN_PROFILE: str = "auto"
RAG_K_SCHEDULE: str = "8,16,24,36,48"
RAG_MAX_CANDIDATES: int = 180
RAG_STAGNATION_PATIENCE: int = 2
RAG_RERANK_MAX_DOCS: int = 48
RAG_SEMANTIC_WEIGHT: float = 1.0
RAG_KEYWORD_HIT_WEIGHT: float = 0.08
RAG_KEYWORD_COVERAGE_WEIGHT: float = 0.35
RAG_COVERAGE_TARGET_RATIO: float = 0.75
RAG_MAX_SELECTED_DOCS: int = 40
RAG_CONTEXT_BASE_BUDGET: int = 4200
RAG_CONTEXT_MAX_BUDGET: int = 9000
RAG_PER_SOURCE_CHAR_CAP: int = 680
RAG_ENABLE_EXHAUSTIVE_COVERAGE: bool = True
RAG_COVERAGE_HARD_FAIL: bool = False
RAG_MIN_COVERAGE_TERM_LEN: int = 4
RAG_COVERAGE_MAX_POINTS: int = 20000
RAG_COVERAGE_MAX_PASSES: int = 4
RAG_SCOPE_CACHE_TTL_SECONDS: int = 300
RAG_SCOPE_CACHE_MAX_DOCS: int = 24
RAG_MICRO_MAX_SENTENCES: int = 6
RAG_MICRO_CHUNK_CHAR_CAP: int = 1200
RAG_STRICT_CONTEXT_MAX_BUDGET: int = 18000
RAG_STRICT_PER_SOURCE_CHAR_CAP: int = 2000
RAG_STRICT_EVIDENCE_ONLY_CONTEXT: bool = True
RAG_SECTION_FULL_CHUNK_MAX_CHARS: int = 2600
RAG_SECTION_FOCUS_ONLY: bool = True
RAG_SECTION_FOCUS_CHUNK_WINDOW: int = 0
RAG_SECTION_PROBE_NEIGHBOR_WINDOW: int = 2
RAG_ENABLE_SECTION_FAST_PATH: bool = True
RAG_ENABLE_FAST_PATH_AUTO_DOC: bool = True
RAG_FAST_PATH_AUTO_DOC_TOP_K: int = 10
RAG_FAST_PATH_AUTO_DOC_MAX_TRY_DOCS: int = 4
RAG_ENABLE_EARLY_OOD_CHECK: bool = True
RAG_SECTION_FAST_MAX_CHARS: int = 7000
RAG_SECTION_FAST_MAX_BULLETS: int = 32
RAG_SECTION_FAST_PER_SUBSECTION_BULLETS: int = 8
RAG_SECTION_FAST_COMPACT_ROOT: bool = True
RAG_SECTION_FAST_SUBSECTION_CHAR_CAP: int = 900
RAG_SECTION_FAST_USE_LLM_FOR_ROOT: bool = False
RAG_SECTION_FAST_LLM_MAX_CHARS: int = 7000
RAG_REQUIRE_DOCUMENT_ID: bool = True
RAG_DOC_OVERVIEW_CONTEXT_CHARS: int = 16000
RAG_DOC_OVERVIEW_MAX_SECTIONS: int = 14
RAG_DOC_OVERVIEW_POINTS_PER_SECTION: int = 3
RAG_RESCUE_CONTEXT_CHAR_CAP: int = 2600
RAG_RESPONSE_TOKEN_SMALL: int = 180
RAG_RESPONSE_TOKEN_MEDIUM: int = 280
RAG_RESPONSE_TOKEN_LARGE: int = 420
RAG_RESPONSE_TOKEN_HARD_CAP: int = 520
VECTOR_DB: str = "qdrant"
QDRANT_HOST: str = "localhost"
QDRANT_PORT: int = 6333
QDRANT_COLLECTION: str = "documents"
RERANKER_MODEL: str = "cross-encoder/ms-marco-MiniLM-L-12-v2"
SMTP_HOST: Optional[str] = None
SMTP_PORT: Optional[int] = 587
SMTP_USER: Optional[str] = None
SMTP_PASSWORD: Optional[str] = None
SMTP_SECURE: bool = False
MAIL_FROM: Optional[str] = "noreply@docqube.com"
FRONTEND_URL: str = "http://localhost:5173"
@property
def EMAIL_ASSET_BASE_URL(self) -> str:
"""Origin serving the email images in docqube_frontend/public/email/.
These are frontend static assets, so they hang off FRONTEND_URL rather
than the API. FRONTEND_URL is a comma-separated list in some
environments (see .env.test), so take the first entry.
"""
base = self.FRONTEND_URL.split(",")[0].strip().rstrip("/")
return f"{base}/email"
LOG_LEVEL: str
CORS_ORIGINS: str
@property
def CORS_ORIGINS_LIST(self) -> List[str]:
return [origin.strip() for origin in self.CORS_ORIGINS.split(",") if origin]
model_config = SettingsConfigDict(
env_file=os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
f".env.{os.getenv('APP_ENV', 'development')}",
),
extra="ignore",
)
@model_validator(mode="after")
def validate_production_config(self) -> "Settings":
if self.MAX_ACTIVE_DEVICES < 1:
raise ValueError("MAX_ACTIVE_DEVICES must be at least 1")
if self.APP_ENV == "production":
if not self.REDIS_HOST:
raise ValueError(
"REDIS_HOST must be explicitly set in production environment "
"to prevent silent fallback to insecure defaults."
)
# Enforce PgBouncer in production: app/db/database.py silently
# falls back to a direct PostgreSQL connection (DATABASE_URL,
# DB_PORT) whenever PGBOUNCER_PORT is unset — with no error and
# no log warning at the point it matters. That silent fallback
# is what let the API run for a time connected straight to
# Postgres on 5432 instead of through PgBouncer on 6432 during
# a prior incident. Failing fast here, before the DB engine is
# ever created, converts that into a startup error instead.
if not self.PGBOUNCER_PORT:
raise ValueError(
"PGBOUNCER_PORT must be explicitly set in production environment "
"to prevent silently falling back to a direct PostgreSQL connection."
)
# CORS check
if "*" in self.CORS_ORIGINS_LIST:
raise ValueError(
"CORS_ORIGINS must not contain '*' in production environment "
"to prevent unauthorized cross-origin requests."
)
if self.ZOHO_CLIENT_ID and not self.ZOHO_WEBHOOK_SECRET:
import logging
logging.getLogger(__name__).warning(
"ZOHO_WEBHOOK_SECRET is empty in production. This silently disables webhook authentication "
"and leaves your Zoho Sign integration vulnerable to spoofed requests."
)
return self
COOKIE_DOMAIN: Optional[str] = None
DISABLE_CSRF: bool = False
TENANT_FILTER_ENABLED: bool = False
ORG_SCOPE_ENABLED: bool = False
SUBSCRIPTION_REQUIRED_FOR_MAPPED_TENANTS: bool = False
SUBSCRIPTION_ENFORCEMENT_ENABLED: bool = False
ACCESS_LOG_RETENTION_DAYS: int = 365
settings = Settings()
DATABASE_URL = settings.DATABASE_URL