280 lines
9.4 KiB
Python
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
|