Spaces:
Runtime error
Runtime error
File size: 11,436 Bytes
00a4363 e5d90fd 00a4363 bcef2c3 00a4363 e5d90fd 00a4363 e5d90fd 00a4363 34b0226 eb03313 34b0226 7299acc 34b0226 00a4363 013ff92 00a4363 34b0226 00a4363 23cef78 00a4363 bdd1640 00a4363 66de9c8 00a4363 0649f1b 1e09b5f 528cb2d 0c11338 b74c6ae 00a4363 b74c6ae 66de9c8 00a4363 94b5741 b74c6ae 00a4363 34b0226 00a4363 34b0226 f74c934 34b0226 bdd1640 34b0226 00a4363 070c931 00a4363 bcef2c3 00a4363 8e2a6eb bcef2c3 8e2a6eb bcef2c3 00a4363 528cb2d eb03313 34b0226 00a4363 013ff92 00a4363 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 | 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 <hello@notestack.ai>"
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 (<app>/template-posters/<id>.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_<plan>_<cycle>) 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()
|