from functools import lru_cache from pathlib import Path from pydantic import AliasChoices, Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict # One .env at the repo root serves every way of running the API; a backend/.env (or the # working directory's .env) overrides it. Real environment variables beat both. _ROOT_ENV = Path(__file__).resolve().parents[2] / ".env" class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=(_ROOT_ENV, ".env"), extra="ignore") env: str = "development" app_name: str = "Notestack" frontend_url: str = "http://localhost:5173" api_url: str = "http://localhost:8000" cors_origins: str = "http://localhost:5173" # Local default is a SQLite file (relative to the working directory, normally backend/). # Prod and docker-compose use Postgres: postgresql+psycopg://user:pass@host:5432/db # (plain postgres:// and postgresql:// URLs are rewritten to use psycopg, see below) database_url: str = "sqlite:///./notestack.db" # Worker (Postgres backed queue, see app/worker.py) worker_concurrency: int = 4 worker_poll_seconds: float = 1.0 job_stale_seconds: int = 900 # a running job with no heartbeat for this long is retried # Run the worker loop inside the API process (local dev: one command, no second terminal). # None = on in development, off elsewhere. run_worker_in_api: bool | None = None # Auth jwt_secret: str = "change-me" jwt_algorithm: str = "HS256" jwt_expiration_hours: int = 72 jwt_refresh_expiration_days: int = 30 google_client_id: str = "" # Setting the secret switches Google sign in from the GIS popup to the redirect flow # (server side code exchange). The redirect URI must be listed on the OAuth client. google_client_secret: str = "" google_redirect_uri: str = "" # defaults to {api_url}/api/auth/google/callback # Email (Resend) email_provider: str = "console" # resend | console resend_api_key: str = "" from_email: str = "Notestack " alerts_email: str = "arslan@firebird-technologies.com" unsubscribe_secret: str = "change-me-too" update_email_send_hour: int = 9 # Storage: auto = local disk when no R2 account or endpoint is set, else R2 (S3 compatible) storage_backend: str = "auto" # auto | r2 | local local_storage_dir: str = ".storage" r2_account_id: str = "" r2_access_key_id: str = "" r2_secret_access_key: str = "" r2_bucket: str = "notestack-assets" r2_endpoint_url: str = "" # override for MinIO locally; defaults to the R2 account endpoint r2_public_base_url: str = "" # optional custom domain for public objects # Folder inside the bucket, so Notestack can share a bucket with other apps. Empty = bucket root. r2_prefix: str = "" r2_presign_ttl_seconds: int = 3600 max_upload_bytes: int = 300 * 1024 * 1024 # Posts an infographic reads, and a report that is not built from themes (REPORT_PLANNER below). A quiz or flashcard set reads up to that many straight from the posts; with more (no limit # on how many are picked) it reads a small sample of the ideas already extracted from them, so its size does not grow. artifact_max_posts: int = 20 # Characters of post text a report, quiz or flashcard set may read in all (split evenly between the posts, 1,500 at least each). artifact_passage_budget: int = 120_000 # The same for a quiz: it needs less text than a report to ask good questions. artifact_quiz_passage_budget: int = 60_000 # Ideas (already extracted from each post) in the evidence of a flashcard set, or of a quiz above ARTIFACT_MAX_POSTS posts. artifact_idea_sample: int = 30 # Reports. "themes": the posts' stored ideas are grouped into weighted themes, a blueprint plans the report, and each section is # written from its own evidence (pipeline/report_themes.py). "legacy": the posts' text goes straight to the writer, at most # ARTIFACT_MAX_POSTS of them. Chats, small selections and posts without stored ideas always use the legacy way. report_planner: str = "themes" # Posts a themed report reads: 0 = every post picked (the plan's indexed_posts still applies). report_max_posts: int = 0 # Posts whose ideas are extracted while the report is made (in parallel); the rest are represented by their opening. report_inline_ideas: int = 25 # LLM (LiteLLM model strings; Z.ai GLM by default) llm_model: str = "openai/glm-5.3" llm_fast_model: str = "openai/glm-5.3-flash" llm_report_model: str = "" # optional: writes report prose (default: the fast model, with a bigger output cap) # Chat triage (small talk, off topic, follow up rewriting). Empty = LLM_FAST_MODEL. llm_triage_model: str = "" # TypeSafe Jev classifier for chat triage. When set, triage uses Jev instead of an LLM call. typesafe_api_key: str = "" typesafe_model: str = "jev-latest" typesafe_url: str = "https://api.typesafe.ai" # Firecrawl: crawls sites without an RSS feed and renders JavaScript pages. Empty = plain HTTP only. firecrawl_api_key: str = "" firecrawl_url: str = "https://api.firecrawl.dev" # Help bot (bottom right widget). Empty = LLM_FAST_MODEL. support_llm_model: str = "" support_enabled: bool = True support_messages_per_minute: int = 20 llm_api_base: str = "https://api.z.ai/api/paas/v4" llm_api_key: str = "" llm_temperature: float = 1.0 # Z.ai recommends 1.0 for GLM-5.x # Reasoning output counts against max_tokens, so leave headroom for the structured answer. llm_max_tokens: int = 10000 # The output cap of the fast model: quizzes, flashcards, infographics, idea extraction, template suggestions. A cap, not a target: # only the tokens written are charged, and the thinking counts too, so keep room for it. Measured answers are under 3,000. llm_fast_max_tokens: int = 6000 # GLM-5.x always reasons (it cannot be disabled); this sets how hard. low | high | max llm_reasoning_effort: str = "low" # Corpus: posts as markdown files. R2 is the source of truth; each process keeps a disk cache. corpus_cache_dir: str = ".corpus-cache" research_max_steps: int = 10 # After an answer, how long the chat stream waits for the memory update so it can say what was saved. memory_notice_wait_seconds: float = 25.0 # Chat memory: topic files built from a notebook's chats (docs/CHAT_RECALL_IMPLEMENTATION.md). # CHAT_MEMORY_ENABLED writes the files after each answer. CHAT_MEMORY_READ lets answers use them. chat_memory_enabled: bool = True chat_memory_read: bool = False chat_memory_delay_seconds: float = 10.0 chat_memory_max_rounds_per_job: int = 12 chat_memory_summary_chars: int = 1400 chat_memory_recent_rounds: int = 4 chat_memory_context_chars: int = 3000 chat_memory_topics_shown: int = 12 # Log what the research agent is given on every chat message (conversation, writer notes, chat memory). The logs # then hold writers' text, so it is on only in development unless CHAT_CONTEXT_LOG=true. None follows ENV. chat_context_log: bool | None = None # ElevenLabs (premade voices by default; a consented clone can replace host A) elevenlabs_api_key: str = "" elevenlabs_model: str = "eleven_multilingual_v2" elevenlabs_voice_a: str = "EXAVITQu4vr4xnSDxMaL" # Sarah (premade) elevenlabs_voice_b: str = "JBFqnCBsd6RMkjVDRZzb" # George (premade) # Social publishing (Launchpad). Redirect URIs: {API_URL}/api/social/{x,linkedin}/callback social_token_key: str = "" # Fernet key; derived from JWT_SECRET when empty x_enabled: bool = False # X posting is switched off for now (LinkedIn only); set X_ENABLED=true to bring it back x_client_id: str = "" x_client_secret: str = "" linkedin_client_id: str = "" linkedin_client_secret: str = "" # blog2video: makes the videos. We call its public API with our own account's key (b2v_live_...), so every # video is charged to that account. Backend only: the key must never reach the browser or the logs. b2v_api_base_url: str = Field("", validation_alias=AliasChoices("B2V_API_BASE_URL", "B2V_API_URL")) b2v_api_key: str = "" # blog2video's web app: serves the template poster images (/template-posters/.webp). b2v_app_url: str = "https://blog2video.app" b2v_timeout_seconds: float = 30 # creates, uploads and full-project reads; other calls also wait 30s (STATUS_TIMEOUT) b2v_capacity_alert_below: int = 20 # log an error when our blog2video account has fewer videos left # Renderer renderer_url: str = "http://localhost:3100" internal_token: str = "internal-change-me" # Billing (Stripe). With BILLING_ENABLED=false everyone gets Studio limits and checkout is off. billing_enabled: bool = False stripe_secret_key: str = Field( "", validation_alias=AliasChoices("STRIPE_SECRET_KEY", "STRIPE_API_KEY", "STRIPE_SECRET") ) stripe_webhook_secret: str = Field( "", validation_alias=AliasChoices( "STRIPE_WEBHOOK_SECRET", "STRIPE_WEBHOOK_SIGNING_SECRET", "STRIPE_SIGNING_SECRET" ), ) # Optional existing Price ids. Empty = Notestack finds or creates prices by lookup key # (notestack__) from the plan table in services/plans.py. stripe_price_writer_monthly: str = "" stripe_price_writer_annual: str = "" stripe_price_studio_monthly: str = "" stripe_price_studio_annual: str = "" @field_validator("run_worker_in_api", mode="before") @classmethod def _blank_is_default(cls, value): """RUN_WORKER_IN_API= (blank, as in .env.example) means "decide from ENV", not a parse error.""" return None if isinstance(value, str) and not value.strip() else value @field_validator("database_url", mode="before") @classmethod def _use_psycopg_driver(cls, value): """Accept plain postgres:// or postgresql:// URLs (as Neon, Heroku etc. hand out) and point them at psycopg 3, the only Postgres driver installed. Query params pass through.""" if isinstance(value, str): value = value.strip() for prefix in ("postgres://", "postgresql://"): if value.startswith(prefix): return "postgresql+psycopg://" + value[len(prefix):] return value @property def worker_in_api(self) -> bool: return self.env == "development" if self.run_worker_in_api is None else self.run_worker_in_api @property def use_local_storage(self) -> bool: if self.storage_backend != "auto": return self.storage_backend == "local" return not (self.r2_account_id or self.r2_endpoint_url) @property def r2_endpoint(self) -> str: if self.r2_endpoint_url: return self.r2_endpoint_url return f"https://{self.r2_account_id}.r2.cloudflarestorage.com" @property def google_redirect_url(self) -> str: return self.google_redirect_uri or f"{self.api_url.rstrip('/')}/api/auth/google/callback" @property def cors_origin_list(self) -> list[str]: return [o.strip() for o in self.cors_origins.split(",") if o.strip()] @lru_cache def get_settings() -> Settings: return Settings() settings = get_settings()