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()