DocDoeAI / app /core /config.py
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
fba198b
Raw
History Blame Contribute Delete
17.3 kB
from functools import lru_cache
from pathlib import Path
from pydantic import AliasChoices, Field
from pydantic_settings import BaseSettings, SettingsConfigDict
BACKEND_DIR = Path(__file__).resolve().parents[2]
PROJECT_ROOT = BACKEND_DIR.parent
class Settings(BaseSettings):
app_name: str = "AI Exam Success API"
app_version: str = "0.1.0"
environment: str = "development"
database_url: str = f"sqlite:///{BACKEND_DIR / 'exam_success_dev.db'}"
# Dev defaults stay small; production .env should raise these for 1k+ concurrent students.
database_pool_size: int = Field(
default=2,
validation_alias=AliasChoices("DATABASE_POOL_SIZE"),
)
database_max_overflow: int = Field(
default=3,
validation_alias=AliasChoices("DATABASE_MAX_OVERFLOW"),
)
database_pool_timeout_seconds: int = Field(
default=10,
validation_alias=AliasChoices("DATABASE_POOL_TIMEOUT_SECONDS"),
)
database_pool_recycle_seconds: int = Field(
default=1800,
validation_alias=AliasChoices("DATABASE_POOL_RECYCLE_SECONDS"),
)
cors_origins: str = "http://localhost:3000,http://127.0.0.1:3000,http://localhost:3001,http://127.0.0.1:3001,http://localhost:3003,http://127.0.0.1:3003"
upload_dir: str = "uploads"
ai_provider: str = "openai"
# The tuition brain uses its OWN text provider so it can run on a cheap
# text-adapter (Cloudflare Workers AI) without forcing the rest of the app
# off its main provider. Must be a text-adapter name
# (cloudflare_workers_ai | openrouter | nvidia_nim); anything else -> the
# brain falls back to its deterministic plan.
tuition_brain_provider: str = Field(
default="cloudflare_workers_ai",
validation_alias=AliasChoices("TUITION_BRAIN_PROVIDER"),
)
# --- OpenAI (fast + high-quality primary provider) ---
openai_api_key: str | None = Field(
default=None,
validation_alias=AliasChoices("OPENAI_API_KEY"),
)
openai_base_url: str = "https://api.openai.com/v1"
openai_model_main: str = "gpt-5-mini"
openai_model_heavy: str = "gpt-5.2"
openai_timeout_seconds: int = 60
openai_max_retries: int = 1
openai_reasoning_effort_fast: str = "minimal"
openai_reasoning_effort_heavy: str = "low"
# --- Sarvam AI (primary production provider) ---
sarvam_api_key: str | None = Field(
default=None,
validation_alias=AliasChoices("SARVAM_API_KEY"),
)
sarvam_base_url: str = "https://api.sarvam.ai/v1"
sarvam_model_main: str = "sarvam-30b" # sarvam-m deprecated; 30b is the fast reasoning model
sarvam_model_heavy: str = "sarvam-30b" # reasoning model for deep analysis
sarvam_model_notes: str = "sarvam-105b" # upgraded: more tokens for richer notes
sarvam_model_quiz: str = "sarvam-105b" # upgraded: more questions, better quality
sarvam_model_flashcards: str = "sarvam-105b" # upgraded: more cards, richer back-sides
sarvam_model_exam_mode: str = "sarvam-105b"
sarvam_model_pyq_analysis: str = "sarvam-105b"
sarvam_model_video_script: str = "sarvam-105b"
sarvam_timeout_seconds: int = 90
sarvam_max_retries: int = 2
# --- OpenRouter (legacy provider — kept for backward compat) ---
openrouter_api_key: str | None = Field(
default=None,
validation_alias=AliasChoices("OPENROUTER_API_KEY"),
)
openrouter_base_url: str = "https://openrouter.ai/api/v1"
# User-facing fallbacks must support structured outputs and avoid unstable
# free-tier routing. The old DeepSeek free slug now returns HTTP 404.
openrouter_model_main: str = "deepseek/deepseek-v4-flash"
openrouter_model_llama: str = "qwen/qwen3-next-80b-a3b-instruct"
openrouter_model_gpt_oss: str = "openai/gpt-oss-120b:free"
openrouter_model_nemotron: str = "nvidia/nemotron-3-nano-30b-a3b:free"
openrouter_site_url: str = "https://docdoe.ai"
openrouter_app_name: str = "DocDoe AI"
ai_max_retries: int = 2
ai_timeout_seconds: int = 120 # 60s is too short for llama-3.3-70b free tier (~90s p50)
ai_fallback_to_mock: bool = True
mock_ai_model_name: str = "mock-exam-tutor-v1"
# --- Cost-control provider router ---
ai_router_enabled: bool = False
ai_router_default_tier: str = "cheap"
ai_router_daily_budget_usd: float = 0
ai_router_allow_free_providers: bool = True
ai_router_provider_daily_units_default: int = 0
generation_cache_prompt_version: str = "v1"
# --- Legacy Gemini fields (kept so old .env files don't crash) ---
gemini_api_key: str | None = Field(
default=None,
validation_alias=AliasChoices("GEMINI_API_KEY", "GEMINI_API"),
)
gemini_model_flash: str = "gemini-1.5-flash"
gemini_model_pro: str = "gemini-1.5-pro"
# --- Additional router provider keys/config ---
groq_api_key: str | None = Field(default=None, validation_alias=AliasChoices("GROQ_API_KEY"))
groq_base_url: str = "https://api.groq.com/openai/v1"
groq_model_main: str = "llama-3.1-8b-instant"
# --- Z.AI / GLM (free text + vision Flash models) ---
# OpenAI-compatible: https://api.z.ai/api/paas/v4/
# Free: glm-4.7-flash (text), glm-4.6v-flash (vision / image understanding).
zai_api_key: str | None = Field(
default=None,
validation_alias=AliasChoices("ZAI_API_KEY", "Z_AI_API_KEY", "GLM_API_KEY"),
)
zai_base_url: str = Field(
default="https://api.z.ai/api/paas/v4",
validation_alias=AliasChoices("ZAI_BASE_URL", "Z_AI_BASE_URL"),
)
zai_model_text: str = Field(
default="glm-4.7-flash",
validation_alias=AliasChoices("ZAI_MODEL_TEXT", "GLM_MODEL_TEXT"),
)
zai_model_vision: str = Field(
default="glm-4.6v-flash",
validation_alias=AliasChoices("ZAI_MODEL_VISION", "GLM_MODEL_VISION"),
)
zai_timeout_seconds: int = Field(
default=90,
validation_alias=AliasChoices("ZAI_TIMEOUT_SECONDS"),
)
nvidia_nim_api_key: str | None = Field(default=None, validation_alias=AliasChoices("NVIDIA_NIM_API_KEY"))
nvidia_nim_base_url: str = "https://integrate.api.nvidia.com/v1"
nvidia_nim_model_main: str = "meta/llama-3.1-8b-instruct" # fast (~3-4s), good for quick answers
# When true, fast tasks (notes/simple/quiz/flashcards) try NVIDIA NIM first
# for ~3-4s answers; deep tasks (exam/pyq/video) still use Sarvam.
fast_provider_enabled: bool = Field(default=False, validation_alias=AliasChoices("FAST_PROVIDER_ENABLED"))
cloudflare_account_id: str | None = Field(default=None, validation_alias=AliasChoices("CLOUDFLARE_ACCOUNT_ID"))
cloudflare_api_token: str | None = Field(default=None, validation_alias=AliasChoices("CLOUDFLARE_API_TOKEN"))
cloudflare_workers_ai_text_model: str = "@cf/meta/llama-3.1-8b-instruct"
cloudflare_workers_ai_image_model: str = "@cf/black-forest-labs/flux-1-schnell"
huggingface_api_key: str | None = Field(default=None, validation_alias=AliasChoices("HUGGINGFACE_API_KEY"))
huggingface_text_model: str = "mistralai/Mistral-7B-Instruct-v0.3"
huggingface_image_model: str = "stabilityai/stable-diffusion-xl-base-1.0"
replicate_api_token: str | None = Field(default=None, validation_alias=AliasChoices("REPLICATE_API_TOKEN"))
# Deepgram powers Record Lecture (speech-to-text). Absent key => the
# transcription route returns an honest "not configured" result instead of
# failing, exactly like the other optional providers.
deepgram_api_key: str | None = Field(default=None, validation_alias=AliasChoices("DEEPGRAM_API_KEY"))
deepgram_model: str = "nova-2"
# AI4Bharat Indic Parler is the default teacher voice: it supports Indian
# English and Malayalam in one local, commercially permissive model.
tts_provider: str = "ai4bharat"
tts_default_provider: str = "ai4bharat"
video_tts_provider: str = "ai4bharat"
tts_default_voice: str = "nila"
tts_output_dir: str = "public/generated/audio"
audio_scene_timeout_seconds: int = 45
audio_total_timeout_seconds: int = 600
audio_max_retries: int = 2
audio_max_concurrency: int = 5
kokoro_voice: str = "af_heart"
kokoro_speed: float = 0.95
ai4bharat_tts_model: str = "ai4bharat/indic-parler-tts"
ai4bharat_tts_speaker: str = "Anjali"
ai4bharat_tts_english_speaker: str = "Mary"
ai4bharat_tts_device: str = "auto"
# Full precision is validated on the RTX 3060 12 GB and is materially
# clearer for Malayalam than half precision with Indic Parler-TTS.
ai4bharat_tts_precision: str = "float32"
ai4bharat_tts_description: str = (
"Mary speaks like a warm and patient Kerala secondary-school teacher at a "
"slightly slow conversational pace. Her voice is clear, close-sounding, "
"slightly expressive, and recorded with very clear audio and no background noise."
)
ai4bharat_tts_malayalam_description: str = (
"Anjali speaks with a warm, clear and neutral Kerala teacher voice at a "
"normal calm pace in a close-sounding environment. Her recording has "
"excellent, very clear audio with no background noise."
)
indic_tts_model: str | None = None
indic_tts_language: str = "ml"
# Default English voice tuned for natural teaching cadence.
# AvaMultilingualNeural is a warmer, slower-paced US neural voice that
# carries explanation content far better than the older Indian newsreader.
# Negative rate gives space between sentences so concepts land.
edge_tts_voice_en: str = "en-US-AvaMultilingualNeural"
edge_tts_voice_ml: str = "ml-IN-SobhanaNeural"
edge_tts_rate: str = "-4%"
elevenlabs_api_key: str | None = None
elevenlabs_voice_id: str | None = None
audio_normalize: bool = True
# Distributed state + observability (env-gated; in-memory/no-op when unset)
redis_url: str | None = None
sentry_dsn: str | None = None
sentry_traces_sample_rate: float = 0.1
email_provider: str = "disabled"
resend_api_key: str | None = Field(
default=None,
validation_alias=AliasChoices("RESEND_API_KEY"),
)
resend_base_url: str = "https://api.resend.com"
email_from: str = "DocDoe <support@docdoe.in>"
password_reset_token_minutes: int = 30
password_reset_request_cooldown_seconds: int = 60
generated_video_jobs_dir: str = "generated-video-jobs"
generated_video_output_dir: str = "generated-videos"
video_render_timeout_seconds: int = 3600
video_min_output_bytes: int = 4096
video_beta_max_scenes: int = 40
video_beta_max_duration_seconds: int = 1800
video_scene_text_max_chars: int = 700
video_max_concurrent_render_jobs_per_user: int = 1
video_stuck_job_timeout_minutes: int = 30
video_enforce_beta_limits_in_development: bool = False
storage_provider: str = "local"
storage_bucket: str | None = None
storage_region: str | None = None
storage_endpoint_url: str | None = None
storage_access_key_id: str | None = None
storage_secret_access_key: str | None = None
storage_public_base_url: str | None = None
storage_upload_prefix: str = "uploads"
storage_audio_prefix: str = "generated-audio"
storage_video_prefix: str = "generated-videos"
# Cloudinary (used when storage_provider == "cloudinary")
cloudinary_cloud_name: str | None = None
cloudinary_api_key: str | None = None
cloudinary_api_secret: str | None = None
auth_provider: str = "jwt"
# Local preview is an explicit opt-in so a non-production deployment cannot
# become a shared demo account by accident.
auth_enabled: bool = True
allow_insecure_dev_auth: bool = False
frontend_base_url: str = "http://127.0.0.1:3000"
google_client_id: str | None = None
google_client_secret: str | None = None
google_oauth_redirect_uri: str | None = None
supabase_url: str | None = None
supabase_jwt_secret: str | None = None
supabase_anon_key: str | None = None
# Backend-only key used for destructive Supabase Auth administration, such
# as honoring an authenticated account-deletion request. Never expose this
# value through a NEXT_PUBLIC_* variable or a frontend response.
supabase_service_role_key: str | None = Field(
default=None,
validation_alias=AliasChoices("SUPABASE_SERVICE_ROLE_KEY"),
)
jwt_secret_key: str | None = None
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 10080 # 7 days — keeps users logged in across sessions
# --- Beta invite gate ---
beta_access_enabled: bool = False
beta_invite_code: str | None = None
# --- Per-user AI rate limiting ---
rate_limit_enabled: bool = True
rate_limit_ai_requests_per_day: int = 50
# --- Admin access ---
admin_emails: str = "" # comma-separated list of admin email addresses
# --- PYQ discovery (official-source-first, safe-disabled by default) ---
pyq_discovery_enabled: bool = False
pyq_search_provider: str = "disabled" # disabled | google_cse | bing | serpapi | manual
pyq_search_api_key: str | None = Field(
default=None,
validation_alias=AliasChoices("PYQ_SEARCH_API_KEY"),
)
pyq_search_engine_id: str | None = Field(
default=None,
validation_alias=AliasChoices("PYQ_SEARCH_ENGINE_ID"),
)
pyq_discovery_max_candidates: int = 12
pyq_discovery_min_confidence_for_auto_import: float = 0.85
# --- Exa web search (explicit/current-information questions only) ---
# The key is read by the backend only. It must never be exposed through a
# NEXT_PUBLIC_* variable or returned by an API response.
exa_api_key: str | None = Field(
default=None,
validation_alias=AliasChoices("EXA_API_KEY"),
)
exa_base_url: str = "https://api.exa.ai"
exa_search_timeout_seconds: float = 15.0
exa_search_max_results: int = 6
# --- Stripe Billing (real SaaS upgrades) ---
stripe_secret_key: str | None = Field(
default=None, validation_alias=AliasChoices("STRIPE_SECRET_KEY")
)
stripe_webhook_secret: str | None = Field(
default=None, validation_alias=AliasChoices("STRIPE_WEBHOOK_SECRET")
)
stripe_api_version: str = "2026-06-24.dahlia"
# Stable integration label registered for this checkout flow. The suffix
# was generated once (rather than per request) so Stripe idempotency keys
# always see identical request parameters.
stripe_checkout_integration_identifier: str = "docdoe_web_psuqtgkx"
# Map the two student-facing subscription keys to recurring Stripe Prices.
stripe_price_ids: str = "" # e.g. popular_299=price_xxx,premium_599=price_yyy
model_config = SettingsConfigDict(
env_file=BACKEND_DIR / ".env",
env_file_encoding="utf-8",
extra="ignore",
)
@property
def is_production(self) -> bool:
"""True when ENVIRONMENT is production (case-insensitive)."""
return self.environment.strip().casefold() == "production"
@property
def cors_origin_list(self) -> list[str]:
origins = [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
# SECURITY: with allow_credentials=True a "*" makes Starlette reflect ANY
# origin (echoing the caller's Origin header), so any website could make
# authenticated requests. Never allow a wildcard in production — drop it
# and fall back to the known app domains if nothing valid remains.
if self.environment == "production":
origins = [o for o in origins if o != "*" and not o.startswith("http://")]
if not origins:
origins = ["https://www.docdoe.in", "https://docdoe.in"]
return origins
@property
def resolved_upload_dir(self) -> Path:
upload_path = Path(self.upload_dir)
if upload_path.is_absolute():
return upload_path
return BACKEND_DIR / upload_path
@property
def resolved_tts_output_dir(self) -> Path:
output_path = Path(self.tts_output_dir)
if output_path.is_absolute():
return output_path
return PROJECT_ROOT / output_path
@property
def stripe_enabled(self) -> bool:
return bool(self.stripe_secret_key)
def get_stripe_price_id(self, plan_key: str) -> str | None:
raw = (self.stripe_price_ids or "").strip()
if not raw:
return None
for pair in raw.split(","):
if "=" in pair:
k, v = pair.split("=", 1)
if k.strip().lower() == plan_key.lower():
return v.strip() or None
return None
@property
def resolved_generated_video_jobs_dir(self) -> Path:
jobs_path = Path(self.generated_video_jobs_dir)
if jobs_path.is_absolute():
return jobs_path
return PROJECT_ROOT / jobs_path
@property
def resolved_generated_video_output_dir(self) -> Path:
output_path = Path(self.generated_video_output_dir)
if output_path.is_absolute():
return output_path
return PROJECT_ROOT / output_path
@lru_cache
def get_settings() -> Settings:
return Settings()