Arslan1997 commited on
Commit
00a4363
·
0 Parent(s):

added elevenlabs grant

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +49 -0
  2. .github/workflows/ci.yml +35 -0
  3. .gitignore +10 -0
  4. README.md +70 -0
  5. backend/Dockerfile +7 -0
  6. backend/alembic.ini +28 -0
  7. backend/alembic/env.py +35 -0
  8. backend/alembic/script.py.mako +22 -0
  9. backend/alembic/versions/0001_initial.py +35 -0
  10. backend/app/__init__.py +0 -0
  11. backend/app/auth.py +87 -0
  12. backend/app/config.py +87 -0
  13. backend/app/db.py +23 -0
  14. backend/app/llm/__init__.py +0 -0
  15. backend/app/llm/embeddings.py +46 -0
  16. backend/app/llm/postprocess.py +23 -0
  17. backend/app/llm/provider.py +87 -0
  18. backend/app/llm/signatures.py +141 -0
  19. backend/app/main.py +62 -0
  20. backend/app/models/__init__.py +52 -0
  21. backend/app/models/base.py +18 -0
  22. backend/app/models/content.py +249 -0
  23. backend/app/models/email.py +58 -0
  24. backend/app/models/user.py +72 -0
  25. backend/app/pipeline/__init__.py +0 -0
  26. backend/app/pipeline/ingest.py +168 -0
  27. backend/app/pipeline/retrieval.py +62 -0
  28. backend/app/routers/__init__.py +0 -0
  29. backend/app/routers/artifacts.py +77 -0
  30. backend/app/routers/auth.py +244 -0
  31. backend/app/routers/billing.py +32 -0
  32. backend/app/routers/jobs.py +83 -0
  33. backend/app/routers/notebooks.py +153 -0
  34. backend/app/routers/sources.py +97 -0
  35. backend/app/routers/storage.py +91 -0
  36. backend/app/routers/unsubscribe.py +44 -0
  37. backend/app/services/__init__.py +0 -0
  38. backend/app/services/auth_identity.py +122 -0
  39. backend/app/services/email.py +214 -0
  40. backend/app/services/email_verification.py +117 -0
  41. backend/app/services/jobs.py +119 -0
  42. backend/app/services/plans.py +104 -0
  43. backend/app/services/storage.py +130 -0
  44. backend/app/worker.py +139 -0
  45. backend/pyproject.toml +10 -0
  46. backend/requirements-dev.txt +3 -0
  47. backend/requirements.txt +22 -0
  48. backend/tests/__init__.py +0 -0
  49. backend/tests/conftest.py +41 -0
  50. backend/tests/test_auth.py +80 -0
.env.example ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ---- App
2
+ ENV=development
3
+ FRONTEND_URL=http://localhost:5173
4
+ API_URL=http://localhost:8000
5
+ CORS_ORIGINS=http://localhost:5173
6
+ JWT_SECRET=replace-with-64-random-chars
7
+ INTERNAL_TOKEN=replace-with-random-token
8
+ BILLING_ENABLED=false
9
+
10
+ # ---- Google sign in (OAuth client, type Web; add http://localhost:5173 to authorized JS origins)
11
+ GOOGLE_CLIENT_ID=
12
+
13
+ # ---- Email (Resend). EMAIL_PROVIDER=console prints emails to the API log instead.
14
+ EMAIL_PROVIDER=console
15
+ RESEND_API_KEY=
16
+ FROM_EMAIL=Notestack <hello@notestack.ai>
17
+ ALERTS_EMAIL=arslan@firebird-technologies.com
18
+ UNSUBSCRIBE_SECRET=replace-me
19
+
20
+ # ---- Storage: Cloudflare R2
21
+ # Prod: set account id + keys, leave R2_ENDPOINT_URL empty.
22
+ # Local: docker-compose points R2_ENDPOINT_URL at MinIO (keys minioadmin/minioadmin).
23
+ R2_ACCOUNT_ID=
24
+ R2_ACCESS_KEY_ID=minioadmin
25
+ R2_SECRET_ACCESS_KEY=minioadmin
26
+ R2_BUCKET=notestack-assets
27
+ R2_ENDPOINT_URL=
28
+ R2_PUBLIC_BASE_URL=
29
+
30
+ # ---- LLM (any LiteLLM model string). Defaults target Z.ai GLM-5.3 via its OpenAI compatible API.
31
+ # GLM-5.x always reasons; LLM_REASONING_EFFORT (low | high | max) trades cost for depth.
32
+ # Cheaper: LLM_MODEL=openai/glm-5.3-flash. Free for local dev: openai/glm-4.7-flash
33
+ LLM_MODEL=openai/glm-5.3
34
+ LLM_FAST_MODEL=openai/glm-5.3-flash
35
+ LLM_API_BASE=https://api.z.ai/api/paas/v4
36
+ LLM_API_KEY=
37
+ LLM_TEMPERATURE=1.0
38
+ LLM_MAX_TOKENS=16000
39
+ LLM_REASONING_EFFORT=low
40
+
41
+ # ---- Embeddings (separate provider: Z.ai's international API has no embedding model)
42
+ # Must support a `dimensions` parameter so the pgvector column stays EMBEDDING_DIM wide.
43
+ EMBEDDING_MODEL=openai/text-embedding-3-small
44
+ EMBEDDING_DIM=1024
45
+ EMBEDDING_API_BASE=
46
+ EMBEDDING_API_KEY=
47
+
48
+ # ---- ElevenLabs (Phase 1 audio)
49
+ ELEVENLABS_API_KEY=
.github/workflows/ci.yml ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: CI
2
+ on: [push, pull_request]
3
+
4
+ jobs:
5
+ backend:
6
+ runs-on: ubuntu-latest
7
+ defaults: { run: { working-directory: backend } }
8
+ steps:
9
+ - uses: actions/checkout@v4
10
+ - uses: actions/setup-python@v5
11
+ with: { python-version: "3.12", cache: pip }
12
+ - run: pip install -r requirements-dev.txt
13
+ - run: ruff check app tests
14
+ - run: pytest -q
15
+
16
+ frontend:
17
+ runs-on: ubuntu-latest
18
+ defaults: { run: { working-directory: frontend } }
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ - uses: actions/setup-node@v4
22
+ with: { node-version: 22, cache: npm, cache-dependency-path: frontend/package-lock.json }
23
+ - run: npm ci
24
+ - run: npm run check:copy
25
+ - run: npm run build
26
+
27
+ renderer:
28
+ runs-on: ubuntu-latest
29
+ defaults: { run: { working-directory: renderer } }
30
+ steps:
31
+ - uses: actions/checkout@v4
32
+ - uses: actions/setup-node@v4
33
+ with: { node-version: 22, cache: npm, cache-dependency-path: renderer/package-lock.json }
34
+ - run: npm ci
35
+ - run: npm run typecheck
.gitignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ .env
2
+ node_modules/
3
+ dist/
4
+ .venv/
5
+ __pycache__/
6
+ *.pyc
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ out/
10
+ renderer/build/
README.md ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Notestack
2
+
3
+ Your archive, in orbit. Turn a Substack (or any feed) into a grounded research notebook, audio
4
+ overviews, videos and launch kits, all traced back to what the writer actually wrote.
5
+
6
+ - Design doc: [docs/DESIGN.md](docs/DESIGN.md)
7
+ - Phase 0 task list: [docs/PHASE0.md](docs/PHASE0.md)
8
+
9
+ ## Layout
10
+
11
+ ```
12
+ backend/ FastAPI + SQLAlchemy + Alembic, arq worker, DSPy (LLM agnostic, Z.ai first)
13
+ frontend/ React + Vite + TS: night sky landing, pricing, blog, auth, Mission Control
14
+ renderer/ Remotion compositions + render service that uploads to R2 via presigned PUT
15
+ docs/ Design doc and task lists
16
+ ```
17
+
18
+ ## Run locally
19
+
20
+ ```bash
21
+ cp .env.example .env # fill LLM_API_KEY (Z.ai), EMBEDDING_API_KEY, GOOGLE_CLIENT_ID, RESEND_API_KEY
22
+ docker compose up --build
23
+ ```
24
+
25
+ - App: http://localhost:5173, API: http://localhost:8000/docs, MinIO console: http://localhost:9001
26
+ - Local storage is MinIO standing in for R2. Presigned URLs point at `http://minio:9000`, so add
27
+ `127.0.0.1 minio` to your hosts file for browser uploads to work locally.
28
+ - With `EMAIL_PROVIDER=console`, verification codes print in the `api` logs.
29
+
30
+ Without Docker:
31
+
32
+ ```bash
33
+ cd backend && python -m venv .venv && .venv/Scripts/pip install -r requirements-dev.txt
34
+ alembic upgrade head && uvicorn app.main:app --reload # needs Postgres (pgvector) + Redis
35
+ arq app.worker.WorkerSettings # second terminal
36
+ cd frontend && npm install && npm run dev
37
+ cd renderer && npm install && npm start # or: npm run studio
38
+ ```
39
+
40
+ ## Key choices
41
+
42
+ | Concern | Where |
43
+ | --- | --- |
44
+ | Cloudflare R2 storage (keys, presign, uploads) | `backend/app/services/storage.py`, `backend/app/routers/storage.py` |
45
+ | Resend email + unsubscribe + campaigns | `backend/app/services/email.py`, `backend/app/worker.py` |
46
+ | Google + email code auth, JWT revocation | `backend/app/auth.py`, `backend/app/routers/auth.py` |
47
+ | LLM provider (swap by env) | `backend/app/llm/provider.py`, `.env` `LLM_*` |
48
+ | Three plans, billing off | `backend/app/services/plans.py`, `BILLING_ENABLED` |
49
+ | Blog posts | `frontend/src/content/blogPosts.ts` (`/blogs`, `/blogs/:slug`) |
50
+ | Logo | replace `frontend/public/logo.svg` (white tile, black silhouettes) |
51
+ | Sky background | `frontend/src/components/SkyCanvas.tsx` |
52
+
53
+ ## Switching LLM providers
54
+
55
+ ```bash
56
+ # Z.ai (default). Cheaper: glm-5.3-flash. Free for dev: glm-4.7-flash
57
+ LLM_MODEL=openai/glm-5.3 LLM_API_BASE=https://api.z.ai/api/paas/v4 LLM_REASONING_EFFORT=low
58
+ # Anthropic
59
+ LLM_MODEL=anthropic/claude-sonnet-5 LLM_API_BASE=
60
+ # OpenAI
61
+ LLM_MODEL=openai/gpt-5 LLM_API_BASE=
62
+ ```
63
+
64
+ Changing `EMBEDDING_DIM` needs a new migration and a re-embed.
65
+
66
+ ## Rules
67
+
68
+ - No em dashes in any UI, email or generated copy. `npm run check:copy` and
69
+ `app/llm/postprocess.py` enforce it.
70
+ - Palette is black, `#217cff` and white only.
backend/Dockerfile ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+ WORKDIR /app
3
+ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
4
+ COPY requirements.txt .
5
+ RUN pip install --no-cache-dir -r requirements.txt
6
+ COPY . .
7
+ CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"]
backend/alembic.ini ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [alembic]
2
+ script_location = alembic
3
+ prepend_sys_path = .
4
+
5
+ [loggers]
6
+ keys = root,sqlalchemy,alembic
7
+ [handlers]
8
+ keys = console
9
+ [formatters]
10
+ keys = generic
11
+ [logger_root]
12
+ level = WARN
13
+ handlers = console
14
+ [logger_sqlalchemy]
15
+ level = WARN
16
+ handlers =
17
+ qualname = sqlalchemy.engine
18
+ [logger_alembic]
19
+ level = INFO
20
+ handlers =
21
+ qualname = alembic
22
+ [handler_console]
23
+ class = StreamHandler
24
+ args = (sys.stderr,)
25
+ level = NOTSET
26
+ formatter = generic
27
+ [formatter_generic]
28
+ format = %(levelname)-5.5s [%(name)s] %(message)s
backend/alembic/env.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from logging.config import fileConfig
2
+
3
+ from alembic import context
4
+ from sqlalchemy import engine_from_config, pool
5
+
6
+ import app.models # noqa: F401 (register tables)
7
+ from app.config import settings
8
+ from app.db import Base
9
+
10
+ config = context.config
11
+ config.set_main_option("sqlalchemy.url", settings.database_url)
12
+ if config.config_file_name:
13
+ fileConfig(config.config_file_name)
14
+ target_metadata = Base.metadata
15
+
16
+
17
+ def run_migrations_offline() -> None:
18
+ context.configure(url=settings.database_url, target_metadata=target_metadata, literal_binds=True)
19
+ with context.begin_transaction():
20
+ context.run_migrations()
21
+
22
+
23
+ def run_migrations_online() -> None:
24
+ connectable = engine_from_config(config.get_section(config.config_ini_section), prefix="sqlalchemy.",
25
+ poolclass=pool.NullPool)
26
+ with connectable.connect() as connection:
27
+ context.configure(connection=connection, target_metadata=target_metadata)
28
+ with context.begin_transaction():
29
+ context.run_migrations()
30
+
31
+
32
+ if context.is_offline_mode():
33
+ run_migrations_offline()
34
+ else:
35
+ run_migrations_online()
backend/alembic/script.py.mako ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+ """
7
+ from alembic import op
8
+ import sqlalchemy as sa
9
+ ${imports if imports else ""}
10
+
11
+ revision = ${repr(up_revision)}
12
+ down_revision = ${repr(down_revision)}
13
+ branch_labels = ${repr(branch_labels)}
14
+ depends_on = ${repr(depends_on)}
15
+
16
+
17
+ def upgrade() -> None:
18
+ ${upgrades if upgrades else "pass"}
19
+
20
+
21
+ def downgrade() -> None:
22
+ ${downgrades if downgrades else "pass"}
backend/alembic/versions/0001_initial.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """initial schema: pgvector + all Phase 0 tables
2
+
3
+ Initial revision builds from the models so schema and code start identical. Every later change
4
+ must be a normal autogenerated revision (alembic revision --autogenerate -m "...").
5
+
6
+ Revision ID: 0001_initial
7
+ Revises:
8
+ Create Date: 2026-09-22
9
+ """
10
+ from alembic import op
11
+
12
+ import app.models # noqa: F401
13
+ from app.config import settings
14
+ from app.db import Base
15
+
16
+ revision = "0001_initial"
17
+ down_revision = None
18
+ branch_labels = None
19
+ depends_on = None
20
+
21
+
22
+ def upgrade() -> None:
23
+ op.execute("CREATE EXTENSION IF NOT EXISTS vector")
24
+ bind = op.get_bind()
25
+ Base.metadata.create_all(bind=bind)
26
+ # HNSW index for cosine search over chunk embeddings (pgvector supports HNSW up to 2000 dims).
27
+ if settings.embedding_dim <= 2000:
28
+ op.execute(
29
+ "CREATE INDEX IF NOT EXISTS ix_chunks_embedding_hnsw ON chunks "
30
+ "USING hnsw (embedding vector_cosine_ops)"
31
+ )
32
+
33
+
34
+ def downgrade() -> None:
35
+ Base.metadata.drop_all(bind=op.get_bind())
backend/app/__init__.py ADDED
File without changes
backend/app/auth.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """JWT helpers. Revocation = bump User.token_version; stale tokens get the same 401 as expired ones."""
2
+
3
+ import uuid
4
+ from dataclasses import dataclass
5
+ from datetime import UTC, datetime, timedelta
6
+
7
+ import jwt
8
+ from fastapi import Depends, HTTPException, status
9
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
10
+ from sqlalchemy import select
11
+ from sqlalchemy.orm import Session
12
+
13
+ from app.config import settings
14
+ from app.db import get_db
15
+ from app.models import User, Workspace, WorkspaceMember
16
+
17
+ _bearer = HTTPBearer(auto_error=False)
18
+ _UNAUTHORIZED = HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid or expired token")
19
+
20
+
21
+ def _encode(user_id: uuid.UUID, token_version: int, kind: str, ttl: timedelta) -> str:
22
+ now = datetime.now(UTC)
23
+ payload = {"sub": str(user_id), "tv": token_version, "typ": kind, "iat": now, "exp": now + ttl}
24
+ return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
25
+
26
+
27
+ def create_access_token(user_id: uuid.UUID, token_version: int) -> str:
28
+ return _encode(user_id, token_version, "access", timedelta(hours=settings.jwt_expiration_hours))
29
+
30
+
31
+ def create_refresh_token(user_id: uuid.UUID, token_version: int) -> str:
32
+ return _encode(user_id, token_version, "refresh", timedelta(days=settings.jwt_refresh_expiration_days))
33
+
34
+
35
+ def decode_token_full(token: str, expected_type: str = "access") -> dict:
36
+ try:
37
+ payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
38
+ except jwt.PyJWTError as exc:
39
+ raise _UNAUTHORIZED from exc
40
+ if payload.get("typ") != expected_type:
41
+ raise _UNAUTHORIZED
42
+ return payload
43
+
44
+
45
+ def token_version_is_current(user: User, payload: dict) -> bool:
46
+ return payload.get("tv") == user.token_version
47
+
48
+
49
+ def load_user_from_token(db: Session, token: str, expected_type: str = "access") -> User:
50
+ payload = decode_token_full(token, expected_type)
51
+ try:
52
+ user_id = uuid.UUID(payload["sub"])
53
+ except (KeyError, ValueError) as exc:
54
+ raise _UNAUTHORIZED from exc
55
+ user = db.get(User, user_id)
56
+ if not user or not user.is_active or user.deleted_at or not token_version_is_current(user, payload):
57
+ raise _UNAUTHORIZED
58
+ return user
59
+
60
+
61
+ def get_current_user(
62
+ creds: HTTPAuthorizationCredentials | None = Depends(_bearer), db: Session = Depends(get_db)
63
+ ) -> User:
64
+ if not creds:
65
+ raise _UNAUTHORIZED
66
+ return load_user_from_token(db, creds.credentials)
67
+
68
+
69
+ @dataclass
70
+ class Ctx:
71
+ user: User
72
+ workspace: Workspace
73
+ db: Session
74
+
75
+
76
+ def get_ctx(user: User = Depends(get_current_user), db: Session = Depends(get_db)) -> Ctx:
77
+ """Current user plus their active workspace (first membership for now; team switching in Phase 4)."""
78
+ workspace = db.scalar(
79
+ select(Workspace)
80
+ .join(WorkspaceMember, WorkspaceMember.workspace_id == Workspace.id)
81
+ .where(WorkspaceMember.user_id == user.id)
82
+ .order_by(WorkspaceMember.created_at)
83
+ .limit(1)
84
+ )
85
+ if not workspace:
86
+ raise HTTPException(status.HTTP_409_CONFLICT, "No workspace for user")
87
+ return Ctx(user=user, workspace=workspace, db=db)
backend/app/config.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from functools import lru_cache
2
+
3
+ from pydantic_settings import BaseSettings, SettingsConfigDict
4
+
5
+
6
+ class Settings(BaseSettings):
7
+ model_config = SettingsConfigDict(env_file=".env", extra="ignore")
8
+
9
+ env: str = "development"
10
+ app_name: str = "Notestack"
11
+ frontend_url: str = "http://localhost:5173"
12
+ api_url: str = "http://localhost:8000"
13
+ cors_origins: str = "http://localhost:5173"
14
+
15
+ database_url: str = "postgresql+psycopg://notestack:notestack@localhost:5432/notestack"
16
+ redis_url: str = "redis://localhost:6379/0"
17
+
18
+ # Auth
19
+ jwt_secret: str = "change-me"
20
+ jwt_algorithm: str = "HS256"
21
+ jwt_expiration_hours: int = 72
22
+ jwt_refresh_expiration_days: int = 30
23
+ google_client_id: str = ""
24
+
25
+ # Email (Resend)
26
+ email_provider: str = "console" # resend | console
27
+ resend_api_key: str = ""
28
+ from_email: str = "Notestack <hello@notestack.ai>"
29
+ alerts_email: str = "arslan@firebird-technologies.com"
30
+ unsubscribe_secret: str = "change-me-too"
31
+ update_email_send_hour: int = 9
32
+
33
+ # Storage (Cloudflare R2, S3 compatible)
34
+ r2_account_id: str = ""
35
+ r2_access_key_id: str = ""
36
+ r2_secret_access_key: str = ""
37
+ r2_bucket: str = "notestack-assets"
38
+ r2_endpoint_url: str = "" # override for MinIO locally; defaults to the R2 account endpoint
39
+ r2_public_base_url: str = "" # optional custom domain for public objects
40
+ r2_presign_ttl_seconds: int = 3600
41
+ max_upload_bytes: int = 200 * 1024 * 1024
42
+
43
+ # LLM (LiteLLM model strings; Z.ai GLM by default)
44
+ llm_model: str = "openai/glm-5.3"
45
+ llm_fast_model: str = "openai/glm-5.3-flash"
46
+ llm_api_base: str = "https://api.z.ai/api/paas/v4"
47
+ llm_api_key: str = ""
48
+ llm_temperature: float = 1.0 # Z.ai recommends 1.0 for GLM-5.x
49
+ # Reasoning output counts against max_tokens, so leave headroom for the structured answer.
50
+ llm_max_tokens: int = 16000
51
+ # GLM-5.x always reasons (it cannot be disabled); this sets how hard. low | high | max
52
+ llm_reasoning_effort: str = "low"
53
+
54
+ # Embeddings are a separate provider: Z.ai's international API has no embedding model.
55
+ # LiteLLM picks up OPENAI_API_KEY etc. from env when embedding_api_key is empty.
56
+ embedding_model: str = "openai/text-embedding-3-small"
57
+ embedding_api_base: str = ""
58
+ embedding_api_key: str = ""
59
+ embedding_dim: int = 1024
60
+
61
+ # ElevenLabs
62
+ elevenlabs_api_key: str = ""
63
+
64
+ # Renderer
65
+ renderer_url: str = "http://localhost:3100"
66
+ internal_token: str = "internal-change-me"
67
+
68
+ # Billing
69
+ billing_enabled: bool = False
70
+
71
+ @property
72
+ def r2_endpoint(self) -> str:
73
+ if self.r2_endpoint_url:
74
+ return self.r2_endpoint_url
75
+ return f"https://{self.r2_account_id}.r2.cloudflarestorage.com"
76
+
77
+ @property
78
+ def cors_origin_list(self) -> list[str]:
79
+ return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
80
+
81
+
82
+ @lru_cache
83
+ def get_settings() -> Settings:
84
+ return Settings()
85
+
86
+
87
+ settings = get_settings()
backend/app/db.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from collections.abc import Iterator
2
+
3
+ from sqlalchemy import create_engine
4
+ from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
5
+
6
+ from app.config import settings
7
+
8
+
9
+ class Base(DeclarativeBase):
10
+ pass
11
+
12
+
13
+ _connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
14
+ engine = create_engine(settings.database_url, pool_pre_ping=True, connect_args=_connect_args)
15
+ SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
16
+
17
+
18
+ def get_db() -> Iterator[Session]:
19
+ db = SessionLocal()
20
+ try:
21
+ yield db
22
+ finally:
23
+ db.close()
backend/app/llm/__init__.py ADDED
File without changes
backend/app/llm/embeddings.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Embeddings through LiteLLM. Always requests EMBEDDING_DIM so the pgvector column stays fixed."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import litellm
6
+
7
+ from app.config import settings
8
+
9
+ BATCH = 64
10
+
11
+
12
+ @dataclass
13
+ class EmbeddingResult:
14
+ vectors: list[list[float]]
15
+ tokens: int
16
+
17
+
18
+ def embed_texts(texts: list[str]) -> EmbeddingResult:
19
+ vectors: list[list[float]] = []
20
+ tokens = 0
21
+ for i in range(0, len(texts), BATCH):
22
+ batch = [t[:8000] for t in texts[i : i + BATCH]]
23
+ resp = litellm.embedding(
24
+ model=settings.embedding_model,
25
+ input=batch,
26
+ api_base=settings.embedding_api_base or None,
27
+ api_key=settings.embedding_api_key or None,
28
+ dimensions=settings.embedding_dim,
29
+ )
30
+ vectors.extend(item["embedding"] for item in resp.data)
31
+ tokens += int(getattr(resp, "usage", None) and resp.usage.prompt_tokens or 0)
32
+ for v in vectors:
33
+ if len(v) != settings.embedding_dim:
34
+ raise ValueError(
35
+ f"Embedding model returned {len(v)} dims, expected {settings.embedding_dim}. "
36
+ "Check EMBEDDING_MODEL supports the dimensions parameter."
37
+ )
38
+ return EmbeddingResult(vectors=vectors, tokens=tokens)
39
+
40
+
41
+ def embedding_provider() -> str:
42
+ return settings.embedding_model.split("/", 1)[0]
43
+
44
+
45
+ def embed_query(text: str) -> list[float]:
46
+ return embed_texts([text]).vectors[0]
backend/app/llm/postprocess.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Last pass on every generated string. Non-negotiable: no em dashes in generated copy."""
2
+
3
+ import re
4
+
5
+ _SPACED_DASH = re.compile(r"\s*[—―]\s*")
6
+ _EN_DASH_SPACED = re.compile(r"\s+–\s+")
7
+
8
+
9
+ def strip_em_dashes(text: str) -> str:
10
+ text = _SPACED_DASH.sub(", ", text)
11
+ text = _EN_DASH_SPACED.sub(", ", text)
12
+ return text.replace(",,", ",").replace(" ,", ",")
13
+
14
+
15
+ def clean_output(value):
16
+ """Recursively clean strings inside dicts/lists/pydantic dumps."""
17
+ if isinstance(value, str):
18
+ return strip_em_dashes(value)
19
+ if isinstance(value, list):
20
+ return [clean_output(v) for v in value]
21
+ if isinstance(value, dict):
22
+ return {k: clean_output(v) for k, v in value.items()}
23
+ return value
backend/app/llm/provider.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Provider agnostic LLM factory. Any LiteLLM model string works; Z.ai GLM is the default.
2
+
3
+ Examples:
4
+ Z.ai: LLM_MODEL=openai/glm-5.3 LLM_API_BASE=https://api.z.ai/api/paas/v4
5
+ Anthropic: LLM_MODEL=anthropic/claude-sonnet-5 LLM_API_BASE=
6
+ OpenAI: LLM_MODEL=openai/gpt-5 LLM_API_BASE=
7
+ Local: LLM_MODEL=ollama_chat/qwen3 LLM_API_BASE=http://localhost:11434
8
+ """
9
+
10
+ from contextlib import contextmanager
11
+ from dataclasses import dataclass
12
+ from functools import lru_cache
13
+
14
+ import dspy
15
+
16
+ from app.config import settings
17
+
18
+
19
+ def is_zai() -> bool:
20
+ return "z.ai" in settings.llm_api_base or "bigmodel.cn" in settings.llm_api_base
21
+
22
+
23
+ def _extra_kwargs(effort: str | None) -> dict:
24
+ if not is_zai():
25
+ return {}
26
+ # GLM-5.x always reasons; thinking.type only accepts "enabled". Cost and latency are tuned
27
+ # with reasoning_effort (low | high | max) instead. extra_body fields land top level in the request.
28
+ effort = effort or settings.llm_reasoning_effort
29
+ return {"extra_body": {"thinking": {"type": "enabled"}, "reasoning_effort": effort}}
30
+
31
+
32
+ def _build(model: str, *, effort: str | None, temperature: float | None, max_tokens: int | None) -> dspy.LM:
33
+ return dspy.LM(
34
+ model,
35
+ api_base=settings.llm_api_base or None,
36
+ api_key=settings.llm_api_key or None,
37
+ temperature=settings.llm_temperature if temperature is None else temperature,
38
+ max_tokens=max_tokens or settings.llm_max_tokens,
39
+ cache=True,
40
+ **_extra_kwargs(effort),
41
+ )
42
+
43
+
44
+ @lru_cache
45
+ def main_lm(effort: str | None = None) -> dspy.LM:
46
+ """effort overrides LLM_REASONING_EFFORT per module, e.g. main_lm("high") for the groundedness judge."""
47
+ return _build(settings.llm_model, effort=effort, temperature=None, max_tokens=None)
48
+
49
+
50
+ @lru_cache
51
+ def fast_lm() -> dspy.LM:
52
+ return _build(settings.llm_fast_model, effort="low", temperature=None, max_tokens=8000)
53
+
54
+
55
+ def configure_default() -> None:
56
+ dspy.configure(lm=main_lm(), adapter=dspy.JSONAdapter())
57
+
58
+
59
+ @dataclass
60
+ class Usage:
61
+ prompt_tokens: int = 0
62
+ completion_tokens: int = 0
63
+ cost_usd: float = 0.0
64
+
65
+ @property
66
+ def total(self) -> int:
67
+ return self.prompt_tokens + self.completion_tokens
68
+
69
+
70
+ @contextmanager
71
+ def track_usage(lm: dspy.LM):
72
+ """Collect token usage for calls made inside the block (for usage_events metering)."""
73
+ usage = Usage()
74
+ start = len(lm.history)
75
+ try:
76
+ yield usage
77
+ finally:
78
+ for entry in lm.history[start:]:
79
+ u = entry.get("usage") or {}
80
+ usage.prompt_tokens += int(u.get("prompt_tokens") or 0)
81
+ usage.completion_tokens += int(u.get("completion_tokens") or 0)
82
+ usage.cost_usd += float(entry.get("cost") or 0.0)
83
+
84
+
85
+ def provider_name(model: str | None = None) -> str:
86
+ model = model or settings.llm_model
87
+ return "zai" if is_zai() and model.startswith("openai/") else model.split("/", 1)[0]
backend/app/llm/signatures.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Typed DSPy signatures with Pydantic outputs. Phase 1 modules are implemented; later ones are
2
+ declared now so the output contract is fixed early."""
3
+
4
+ from typing import Literal
5
+
6
+ import dspy
7
+ from pydantic import BaseModel, Field
8
+
9
+ # Shared output models
10
+
11
+
12
+ class Section(BaseModel):
13
+ heading: str | None = None
14
+ text: str
15
+
16
+
17
+ class CitedSentence(BaseModel):
18
+ sentence: str
19
+ chunk_ids: list[int] = Field(description="Indices of supporting passages from the context list")
20
+
21
+
22
+ class GroundedAnswerOut(BaseModel):
23
+ answer: str = Field(description="Answer text with [n] markers that refer to passage indices")
24
+ sentences: list[CitedSentence]
25
+ unsupported: bool = Field(description="True if the passages do not contain enough to answer")
26
+
27
+
28
+ class VoiceProfileOut(BaseModel):
29
+ tone: list[str]
30
+ sentence_length: Literal["short", "medium", "long", "varied"]
31
+ vocabulary: list[str] = Field(description="Signature words and phrases the writer uses")
32
+ structure_habits: list[str]
33
+ openings: list[str] = Field(description="How the writer typically opens a piece")
34
+ avoid: list[str] = Field(description="Things this writer never does")
35
+ summary: str
36
+
37
+
38
+ class Claim(BaseModel):
39
+ claim: str
40
+ chunk_ids: list[int]
41
+
42
+
43
+ class ScriptLine(BaseModel):
44
+ speaker: Literal["host_a", "host_b"]
45
+ text: str
46
+ chunk_ids: list[int] = Field(default_factory=list)
47
+
48
+
49
+ class Scene(BaseModel):
50
+ type: Literal["title", "section", "pull_quote", "number", "outro"]
51
+ on_screen_text: str
52
+ narration: str
53
+ duration_hint_s: float
54
+ visual: str = ""
55
+
56
+
57
+ class Hook(BaseModel):
58
+ text: str
59
+ strength: float = Field(ge=0, le=1)
60
+ rationale: str
61
+
62
+
63
+ # Signatures
64
+
65
+
66
+ class CleanAndSegment(dspy.Signature):
67
+ """Turn a raw blog post into clean sections with headings. Keep the writer's words; drop
68
+ navigation, subscribe prompts, footers and share buttons."""
69
+
70
+ title: str = dspy.InputField()
71
+ raw_text: str = dspy.InputField()
72
+ sections: list[Section] = dspy.OutputField()
73
+
74
+
75
+ class GroundedAnswer(dspy.Signature):
76
+ """Answer the question using ONLY the numbered passages. Every factual sentence must cite at least
77
+ one passage with [n]. If the passages do not support an answer, say so and set unsupported=true.
78
+ Never use em dashes."""
79
+
80
+ question: str = dspy.InputField()
81
+ passages: list[str] = dspy.InputField(desc="Numbered passages from the writer's own posts")
82
+ result: GroundedAnswerOut = dspy.OutputField()
83
+
84
+
85
+ class GroundednessJudge(dspy.Signature):
86
+ """Decide whether the sentence is fully supported by the cited passages."""
87
+
88
+ sentence: str = dspy.InputField()
89
+ passages: list[str] = dspy.InputField()
90
+ supported: bool = dspy.OutputField()
91
+
92
+
93
+ class BuildVoiceProfile(dspy.Signature):
94
+ """Describe the writer's style from their sample posts so other modules can write like them."""
95
+
96
+ samples: list[str] = dspy.InputField()
97
+ profile: VoiceProfileOut = dspy.OutputField()
98
+
99
+
100
+ class ExtractClaims(dspy.Signature):
101
+ """List the key claims of the post, each with the passages that support it."""
102
+
103
+ passages: list[str] = dspy.InputField()
104
+ claims: list[Claim] = dspy.OutputField()
105
+
106
+
107
+ class PodcastScript(dspy.Signature):
108
+ """Write a two host conversation about the material. Hosts stay grounded in the passages and cite
109
+ them. Natural, warm, curious. No em dashes."""
110
+
111
+ passages: list[str] = dspy.InputField()
112
+ format: Literal["deep_dive", "brief", "debate"] = dspy.InputField()
113
+ target_minutes: int = dspy.InputField()
114
+ lines: list[ScriptLine] = dspy.OutputField()
115
+
116
+
117
+ class VideoStoryboard(dspy.Signature):
118
+ """Turn the post into a scene list for a Remotion composition."""
119
+
120
+ passages: list[str] = dspy.InputField()
121
+ aspect: Literal["16:9", "9:16", "1:1"] = dspy.InputField()
122
+ scenes: list[Scene] = dspy.OutputField()
123
+
124
+
125
+ class HookGenerator(dspy.Signature):
126
+ """Write scroll stopping hooks for the post in the writer's voice. No em dashes."""
127
+
128
+ passages: list[str] = dspy.InputField()
129
+ voice_profile: str = dspy.InputField()
130
+ n: int = dspy.InputField()
131
+ hooks: list[Hook] = dspy.OutputField()
132
+
133
+
134
+ class PlatformAdapter(dspy.Signature):
135
+ """Adapt claims into a native post for the platform, in the writer's voice, following the rules."""
136
+
137
+ claims: list[Claim] = dspy.InputField()
138
+ voice_profile: str = dspy.InputField()
139
+ platform: Literal["x_thread", "linkedin", "substack_notes", "bluesky"] = dspy.InputField()
140
+ platform_rules: str = dspy.InputField()
141
+ posts: list[str] = dspy.OutputField()
backend/app/main.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import time
3
+ import uuid
4
+ from contextlib import asynccontextmanager
5
+
6
+ from arq import create_pool
7
+ from arq.connections import RedisSettings
8
+ from fastapi import FastAPI, Request
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+
11
+ from app.config import settings
12
+ from app.llm.provider import configure_default
13
+ from app.routers import artifacts, auth, billing, jobs, notebooks, sources, storage, unsubscribe
14
+
15
+ log = logging.getLogger("notestack")
16
+
17
+
18
+ @asynccontextmanager
19
+ async def lifespan(app: FastAPI):
20
+ logging.basicConfig(level=logging.INFO)
21
+ configure_default()
22
+ app.state.arq = await create_pool(RedisSettings.from_dsn(settings.redis_url))
23
+ if settings.env == "development" and settings.r2_endpoint_url:
24
+ try:
25
+ from app.services.storage import storage as store
26
+
27
+ store.ensure_bucket() # MinIO locally
28
+ except Exception:
29
+ log.warning("could not ensure local bucket", exc_info=True)
30
+ yield
31
+ await app.state.arq.aclose()
32
+
33
+
34
+ app = FastAPI(title="Notestack API", version="0.1.0", lifespan=lifespan)
35
+ app.add_middleware(
36
+ CORSMiddleware,
37
+ allow_origins=settings.cors_origin_list,
38
+ allow_credentials=True,
39
+ allow_methods=["*"],
40
+ allow_headers=["*"],
41
+ )
42
+
43
+
44
+ @app.middleware("http")
45
+ async def request_id(request: Request, call_next):
46
+ rid = request.headers.get("x-request-id") or uuid.uuid4().hex[:12]
47
+ start = time.perf_counter()
48
+ response = await call_next(request)
49
+ response.headers["x-request-id"] = rid
50
+ log.info("%s %s %s %.0fms rid=%s", request.method, request.url.path, response.status_code,
51
+ (time.perf_counter() - start) * 1000, rid)
52
+ return response
53
+
54
+
55
+ @app.get("/api/health")
56
+ def health():
57
+ return {"ok": True, "llm": settings.llm_model, "billing_enabled": settings.billing_enabled}
58
+
59
+
60
+ for r in (auth, billing, storage, sources, notebooks, artifacts, jobs, unsubscribe):
61
+ app.include_router(r.router)
62
+ app.include_router(sources.documents_router)
backend/app/models/__init__.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.models.content import (
2
+ Artifact,
3
+ CalendarItem,
4
+ Chat,
5
+ Chunk,
6
+ Citation,
7
+ Document,
8
+ DspyTrace,
9
+ EngagementEvent,
10
+ Job,
11
+ Message,
12
+ Notebook,
13
+ NotebookDocument,
14
+ Source,
15
+ TrackedLink,
16
+ Upload,
17
+ UsageEvent,
18
+ VoiceConsent,
19
+ VoiceProfile,
20
+ )
21
+ from app.models.email import EmailVerificationCode, UpdateEmail, UpdateEmailSend, VerificationPurpose
22
+ from app.models.user import AuthProvider, Subscription, User, Workspace, WorkspaceMember
23
+
24
+ __all__ = [
25
+ "Artifact",
26
+ "AuthProvider",
27
+ "CalendarItem",
28
+ "Chat",
29
+ "Chunk",
30
+ "Citation",
31
+ "Document",
32
+ "DspyTrace",
33
+ "EmailVerificationCode",
34
+ "EngagementEvent",
35
+ "Job",
36
+ "Message",
37
+ "Notebook",
38
+ "NotebookDocument",
39
+ "Source",
40
+ "Subscription",
41
+ "TrackedLink",
42
+ "UpdateEmail",
43
+ "UpdateEmailSend",
44
+ "Upload",
45
+ "UsageEvent",
46
+ "User",
47
+ "VerificationPurpose",
48
+ "VoiceConsent",
49
+ "VoiceProfile",
50
+ "Workspace",
51
+ "WorkspaceMember",
52
+ ]
backend/app/models/base.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ from datetime import UTC, datetime
3
+
4
+ from sqlalchemy import DateTime, Uuid
5
+ from sqlalchemy.orm import Mapped, mapped_column
6
+
7
+
8
+ def utcnow() -> datetime:
9
+ return datetime.now(UTC)
10
+
11
+
12
+ class IdMixin:
13
+ id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
14
+
15
+
16
+ class TimestampMixin:
17
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
18
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
backend/app/models/content.py ADDED
@@ -0,0 +1,249 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ from datetime import datetime
3
+
4
+ from pgvector.sqlalchemy import Vector
5
+ from sqlalchemy import (
6
+ JSON,
7
+ BigInteger,
8
+ Boolean,
9
+ DateTime,
10
+ Float,
11
+ ForeignKey,
12
+ Integer,
13
+ String,
14
+ Text,
15
+ UniqueConstraint,
16
+ Uuid,
17
+ )
18
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
19
+
20
+ from app.config import settings
21
+ from app.db import Base
22
+ from app.models.base import IdMixin, TimestampMixin
23
+
24
+
25
+ def _ws_fk() -> Mapped[uuid.UUID]:
26
+ return mapped_column(Uuid, ForeignKey("workspaces.id", ondelete="CASCADE"), index=True)
27
+
28
+
29
+ class Source(IdMixin, TimestampMixin, Base):
30
+ __tablename__ = "sources"
31
+
32
+ workspace_id: Mapped[uuid.UUID] = _ws_fk()
33
+ feed_url: Mapped[str] = mapped_column(String(1000))
34
+ site_url: Mapped[str | None] = mapped_column(String(1000))
35
+ platform: Mapped[str] = mapped_column(String(20), default="rss") # substack | ghost | medium | rss | url
36
+ title: Mapped[str | None] = mapped_column(String(500))
37
+ sync_status: Mapped[str] = mapped_column(String(20), default="pending") # pending | syncing | ok | error
38
+ sync_error: Mapped[str | None] = mapped_column(Text)
39
+ last_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
40
+
41
+
42
+ class Document(IdMixin, TimestampMixin, Base):
43
+ __tablename__ = "documents"
44
+ __table_args__ = (UniqueConstraint("source_id", "url"),)
45
+
46
+ workspace_id: Mapped[uuid.UUID] = _ws_fk()
47
+ source_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("sources.id", ondelete="CASCADE"))
48
+ title: Mapped[str] = mapped_column(String(1000))
49
+ url: Mapped[str] = mapped_column(String(1000))
50
+ published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
51
+ raw_html_key: Mapped[str | None] = mapped_column(String(500)) # R2 key
52
+ clean_text: Mapped[str] = mapped_column(Text, default="")
53
+ content_hash: Mapped[str | None] = mapped_column(String(64))
54
+ metadata_json: Mapped[dict] = mapped_column(JSON, default=dict)
55
+
56
+ chunks: Mapped[list["Chunk"]] = relationship(back_populates="document", cascade="all, delete-orphan")
57
+
58
+
59
+ class Chunk(IdMixin, TimestampMixin, Base):
60
+ __tablename__ = "chunks"
61
+
62
+ workspace_id: Mapped[uuid.UUID] = _ws_fk()
63
+ document_id: Mapped[uuid.UUID] = mapped_column(
64
+ Uuid, ForeignKey("documents.id", ondelete="CASCADE"), index=True
65
+ )
66
+ position: Mapped[int] = mapped_column(Integer)
67
+ heading: Mapped[str | None] = mapped_column(String(500))
68
+ text: Mapped[str] = mapped_column(Text)
69
+ token_count: Mapped[int] = mapped_column(Integer, default=0)
70
+ embedding: Mapped[list[float] | None] = mapped_column(Vector(settings.embedding_dim))
71
+ entities: Mapped[list] = mapped_column(JSON, default=list)
72
+
73
+ document: Mapped[Document] = relationship(back_populates="chunks")
74
+
75
+
76
+ class Notebook(IdMixin, TimestampMixin, Base):
77
+ __tablename__ = "notebooks"
78
+
79
+ workspace_id: Mapped[uuid.UUID] = _ws_fk()
80
+ title: Mapped[str] = mapped_column(String(300))
81
+ description: Mapped[str | None] = mapped_column(Text)
82
+ summary: Mapped[str | None] = mapped_column(Text)
83
+
84
+
85
+ class NotebookDocument(Base):
86
+ __tablename__ = "notebook_documents"
87
+
88
+ notebook_id: Mapped[uuid.UUID] = mapped_column(
89
+ Uuid, ForeignKey("notebooks.id", ondelete="CASCADE"), primary_key=True
90
+ )
91
+ document_id: Mapped[uuid.UUID] = mapped_column(
92
+ Uuid, ForeignKey("documents.id", ondelete="CASCADE"), primary_key=True
93
+ )
94
+
95
+
96
+ class VoiceProfile(IdMixin, TimestampMixin, Base):
97
+ __tablename__ = "voice_profiles"
98
+
99
+ workspace_id: Mapped[uuid.UUID] = mapped_column(
100
+ Uuid, ForeignKey("workspaces.id", ondelete="CASCADE"), unique=True
101
+ )
102
+ profile_json: Mapped[dict] = mapped_column(JSON, default=dict)
103
+ sample_doc_ids: Mapped[list] = mapped_column(JSON, default=list)
104
+
105
+
106
+ class VoiceConsent(IdMixin, TimestampMixin, Base):
107
+ """Required before any voice cloning. The recorded sample lives in R2."""
108
+
109
+ __tablename__ = "voice_consents"
110
+
111
+ workspace_id: Mapped[uuid.UUID] = _ws_fk()
112
+ user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"))
113
+ sample_key: Mapped[str] = mapped_column(String(500))
114
+ consent_text: Mapped[str] = mapped_column(Text)
115
+ ip_address: Mapped[str | None] = mapped_column(String(64))
116
+ elevenlabs_voice_id: Mapped[str | None] = mapped_column(String(100))
117
+ revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
118
+
119
+
120
+ class Chat(IdMixin, TimestampMixin, Base):
121
+ __tablename__ = "chats"
122
+
123
+ workspace_id: Mapped[uuid.UUID] = _ws_fk()
124
+ notebook_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("notebooks.id", ondelete="CASCADE"))
125
+ title: Mapped[str | None] = mapped_column(String(300))
126
+
127
+
128
+ class Message(IdMixin, TimestampMixin, Base):
129
+ __tablename__ = "messages"
130
+
131
+ chat_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("chats.id", ondelete="CASCADE"), index=True)
132
+ role: Mapped[str] = mapped_column(String(20)) # user | assistant
133
+ content: Mapped[str] = mapped_column(Text)
134
+
135
+ citations: Mapped[list["Citation"]] = relationship(cascade="all, delete-orphan")
136
+
137
+
138
+ class Citation(IdMixin, Base):
139
+ __tablename__ = "citations"
140
+
141
+ message_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("messages.id", ondelete="CASCADE"))
142
+ chunk_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("chunks.id", ondelete="CASCADE"))
143
+ marker: Mapped[int] = mapped_column(Integer) # [1], [2] in the answer text
144
+ span: Mapped[str | None] = mapped_column(Text) # quoted supporting text
145
+
146
+
147
+ class Artifact(IdMixin, TimestampMixin, Base):
148
+ __tablename__ = "artifacts"
149
+
150
+ workspace_id: Mapped[uuid.UUID] = _ws_fk()
151
+ notebook_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("notebooks.id", ondelete="SET NULL"))
152
+ document_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("documents.id", ondelete="SET NULL"))
153
+ # summary | audio_overview | video | thread | linkedin | notes | quote_card | carousel | seo
154
+ type: Mapped[str] = mapped_column(String(30))
155
+ status: Mapped[str] = mapped_column(String(20), default="pending")
156
+ content_json: Mapped[dict] = mapped_column(JSON, default=dict)
157
+ storage_key: Mapped[str | None] = mapped_column(String(500))
158
+
159
+
160
+ class Job(IdMixin, TimestampMixin, Base):
161
+ """Covers ingest, tts and render jobs. kind decides which fields matter."""
162
+
163
+ __tablename__ = "jobs"
164
+
165
+ workspace_id: Mapped[uuid.UUID] = _ws_fk()
166
+ kind: Mapped[str] = mapped_column(String(30)) # ingest | tts | render | generate
167
+ status: Mapped[str] = mapped_column(String(20), default="queued") # queued | running | done | failed
168
+ progress: Mapped[float] = mapped_column(Float, default=0.0)
169
+ message: Mapped[str | None] = mapped_column(String(500))
170
+ error: Mapped[str | None] = mapped_column(Text)
171
+ params: Mapped[dict] = mapped_column(JSON, default=dict)
172
+ result: Mapped[dict] = mapped_column(JSON, default=dict)
173
+ artifact_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("artifacts.id", ondelete="SET NULL"))
174
+ cost_usd: Mapped[float] = mapped_column(Float, default=0.0)
175
+ finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
176
+
177
+
178
+ class Upload(IdMixin, TimestampMixin, Base):
179
+ __tablename__ = "uploads"
180
+
181
+ workspace_id: Mapped[uuid.UUID] = _ws_fk()
182
+ user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"))
183
+ key: Mapped[str] = mapped_column(String(500), unique=True)
184
+ filename: Mapped[str] = mapped_column(String(300))
185
+ content_type: Mapped[str] = mapped_column(String(100))
186
+ size_bytes: Mapped[int | None] = mapped_column(BigInteger)
187
+ status: Mapped[str] = mapped_column(String(20), default="pending") # pending | complete
188
+
189
+
190
+ class CalendarItem(IdMixin, TimestampMixin, Base):
191
+ __tablename__ = "calendar_items"
192
+
193
+ workspace_id: Mapped[uuid.UUID] = _ws_fk()
194
+ artifact_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("artifacts.id", ondelete="CASCADE"))
195
+ platform: Mapped[str] = mapped_column(String(30))
196
+ scheduled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
197
+ status: Mapped[str] = mapped_column(String(20), default="scheduled")
198
+
199
+
200
+ class TrackedLink(IdMixin, TimestampMixin, Base):
201
+ __tablename__ = "tracked_links"
202
+
203
+ workspace_id: Mapped[uuid.UUID] = _ws_fk()
204
+ slug: Mapped[str] = mapped_column(String(32), unique=True)
205
+ target_url: Mapped[str] = mapped_column(String(2000))
206
+ calendar_item_id: Mapped[uuid.UUID | None] = mapped_column(
207
+ Uuid, ForeignKey("calendar_items.id", ondelete="SET NULL")
208
+ )
209
+
210
+
211
+ class EngagementEvent(IdMixin, TimestampMixin, Base):
212
+ __tablename__ = "engagement_events"
213
+
214
+ workspace_id: Mapped[uuid.UUID] = _ws_fk()
215
+ tracked_link_id: Mapped[uuid.UUID | None] = mapped_column(
216
+ Uuid, ForeignKey("tracked_links.id", ondelete="CASCADE")
217
+ )
218
+ kind: Mapped[str] = mapped_column(String(30)) # click | like | repost | reply | subscribe
219
+ value: Mapped[float] = mapped_column(Float, default=1.0)
220
+ metadata_json: Mapped[dict] = mapped_column(JSON, default=dict)
221
+
222
+
223
+ class DspyTrace(IdMixin, TimestampMixin, Base):
224
+ __tablename__ = "dspy_traces"
225
+
226
+ workspace_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("workspaces.id", ondelete="SET NULL"))
227
+ module: Mapped[str] = mapped_column(String(60), index=True)
228
+ model: Mapped[str] = mapped_column(String(100))
229
+ inputs: Mapped[dict] = mapped_column(JSON, default=dict)
230
+ outputs: Mapped[dict] = mapped_column(JSON, default=dict)
231
+ score: Mapped[float | None] = mapped_column(Float)
232
+ feedback: Mapped[str | None] = mapped_column(Text)
233
+ # Only usable for optimization when the workspace opted in.
234
+ training_allowed: Mapped[bool] = mapped_column(Boolean, default=False)
235
+
236
+
237
+ class UsageEvent(IdMixin, TimestampMixin, Base):
238
+ """Metering: LLM tokens, TTS characters, render seconds. One row per billable call."""
239
+
240
+ __tablename__ = "usage_events"
241
+
242
+ workspace_id: Mapped[uuid.UUID] = _ws_fk()
243
+ job_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("jobs.id", ondelete="SET NULL"))
244
+ kind: Mapped[str] = mapped_column(String(20)) # llm | embedding | tts | render
245
+ provider: Mapped[str] = mapped_column(String(40))
246
+ model: Mapped[str | None] = mapped_column(String(100))
247
+ quantity: Mapped[float] = mapped_column(Float) # tokens, characters or seconds
248
+ unit: Mapped[str] = mapped_column(String(20))
249
+ cost_usd: Mapped[float] = mapped_column(Float, default=0.0)
backend/app/models/email.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import enum
2
+ import uuid
3
+ from datetime import datetime
4
+
5
+ from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, Text, UniqueConstraint, Uuid
6
+ from sqlalchemy.orm import Mapped, mapped_column
7
+
8
+ from app.db import Base
9
+ from app.models.base import IdMixin, TimestampMixin
10
+
11
+
12
+ class VerificationPurpose(str, enum.Enum):
13
+ SIGNUP = "signup"
14
+ PASSWORD_RESET = "password_reset"
15
+
16
+
17
+ class EmailVerificationCode(IdMixin, TimestampMixin, Base):
18
+ __tablename__ = "email_verification_codes"
19
+
20
+ email: Mapped[str] = mapped_column(String(320), index=True)
21
+ purpose: Mapped[VerificationPurpose] = mapped_column(
22
+ Enum(VerificationPurpose, native_enum=False, length=20)
23
+ )
24
+ code_hash: Mapped[str] = mapped_column(String(128))
25
+ # Signup stores the pending name + password hash until the code is verified.
26
+ pending_name: Mapped[str | None] = mapped_column(String(200))
27
+ pending_password_hash: Mapped[str | None] = mapped_column(String(200))
28
+ attempts: Mapped[int] = mapped_column(Integer, default=0)
29
+ expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
30
+ consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
31
+
32
+
33
+ class UpdateEmail(IdMixin, TimestampMixin, Base):
34
+ """Broadcast campaign. Body is plain text (it gets HTML escaped)."""
35
+
36
+ __tablename__ = "update_emails"
37
+
38
+ subject: Mapped[str] = mapped_column(String(300))
39
+ body: Mapped[str] = mapped_column(Text)
40
+ user_filter: Mapped[str] = mapped_column(String(20), default="all") # all | free | paid | writer | studio
41
+ batch_size: Mapped[int] = mapped_column(Integer, default=50)
42
+ send_hour: Mapped[int] = mapped_column(Integer, default=-1) # -1 = config default, UTC
43
+ total_users: Mapped[int] = mapped_column(Integer, default=0)
44
+ sent_count: Mapped[int] = mapped_column(Integer, default=0)
45
+ failed_count: Mapped[int] = mapped_column(Integer, default=0)
46
+ status: Mapped[str] = mapped_column(String(20), default="scheduled") # scheduled | running | completed
47
+ last_batch_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
48
+
49
+
50
+ class UpdateEmailSend(IdMixin, TimestampMixin, Base):
51
+ __tablename__ = "update_email_sends"
52
+ __table_args__ = (UniqueConstraint("update_email_id", "user_id"),)
53
+
54
+ update_email_id: Mapped[uuid.UUID] = mapped_column(
55
+ Uuid, ForeignKey("update_emails.id", ondelete="CASCADE")
56
+ )
57
+ user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id", ondelete="CASCADE"))
58
+ status: Mapped[str] = mapped_column(String(10)) # sent | failed
backend/app/models/user.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import enum
2
+ import uuid
3
+ from datetime import datetime
4
+
5
+ from sqlalchemy import JSON, Boolean, DateTime, Enum, ForeignKey, Integer, String, UniqueConstraint, Uuid
6
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
7
+
8
+ from app.db import Base
9
+ from app.models.base import IdMixin, TimestampMixin
10
+
11
+
12
+ class AuthProvider(str, enum.Enum):
13
+ GOOGLE = "google"
14
+ EMAIL = "email"
15
+
16
+
17
+ class User(IdMixin, TimestampMixin, Base):
18
+ """One auth provider per account for life, no linking (same rule as blog2video)."""
19
+
20
+ __tablename__ = "users"
21
+
22
+ email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
23
+ name: Mapped[str | None] = mapped_column(String(200))
24
+ avatar_url: Mapped[str | None] = mapped_column(String(1000))
25
+ auth_provider: Mapped[AuthProvider] = mapped_column(Enum(AuthProvider, native_enum=False, length=16))
26
+ google_id: Mapped[str | None] = mapped_column(String(64), unique=True)
27
+ password_hash: Mapped[str | None] = mapped_column(String(200))
28
+ token_version: Mapped[int] = mapped_column(Integer, default=0)
29
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True)
30
+ deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
31
+ email_unsubscribed: Mapped[bool] = mapped_column(Boolean, default=False)
32
+ welcome_email_sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
33
+ last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
34
+
35
+ memberships: Mapped[list["WorkspaceMember"]] = relationship(back_populates="user")
36
+
37
+
38
+ class Workspace(IdMixin, TimestampMixin, Base):
39
+ __tablename__ = "workspaces"
40
+
41
+ name: Mapped[str] = mapped_column(String(200))
42
+ owner_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"))
43
+ training_opt_in: Mapped[bool] = mapped_column(Boolean, default=False)
44
+ brand_json: Mapped[dict] = mapped_column(JSON, default=dict)
45
+
46
+ members: Mapped[list["WorkspaceMember"]] = relationship(back_populates="workspace")
47
+
48
+
49
+ class WorkspaceMember(IdMixin, TimestampMixin, Base):
50
+ __tablename__ = "workspace_members"
51
+ __table_args__ = (UniqueConstraint("workspace_id", "user_id"),)
52
+
53
+ workspace_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("workspaces.id", ondelete="CASCADE"))
54
+ user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id", ondelete="CASCADE"))
55
+ role: Mapped[str] = mapped_column(String(20), default="owner")
56
+
57
+ workspace: Mapped[Workspace] = relationship(back_populates="members")
58
+ user: Mapped[User] = relationship(back_populates="memberships")
59
+
60
+
61
+ class Subscription(IdMixin, TimestampMixin, Base):
62
+ __tablename__ = "subscriptions"
63
+
64
+ workspace_id: Mapped[uuid.UUID] = mapped_column(
65
+ Uuid, ForeignKey("workspaces.id", ondelete="CASCADE"), unique=True
66
+ )
67
+ plan: Mapped[str] = mapped_column(String(20), default="free") # free | writer | studio
68
+ status: Mapped[str] = mapped_column(String(20), default="active")
69
+ provider: Mapped[str | None] = mapped_column(String(20))
70
+ provider_customer_id: Mapped[str | None] = mapped_column(String(100))
71
+ provider_subscription_id: Mapped[str | None] = mapped_column(String(100))
72
+ current_period_end: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
backend/app/pipeline/__init__.py ADDED
File without changes
backend/app/pipeline/ingest.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ingestion: feed -> documents (raw HTML to R2) -> clean text -> chunks -> embeddings."""
2
+
3
+ import hashlib
4
+ import logging
5
+ import re
6
+ import uuid
7
+ from dataclasses import dataclass
8
+ from datetime import UTC, datetime
9
+ from time import mktime
10
+ from urllib.parse import urlparse
11
+
12
+ import feedparser
13
+ import httpx
14
+ import tiktoken
15
+ from bs4 import BeautifulSoup
16
+ from sqlalchemy import select
17
+ from sqlalchemy.orm import Session
18
+
19
+ from app.config import settings
20
+ from app.llm.embeddings import embed_texts, embedding_provider
21
+ from app.models import Chunk, Document, Job, Source
22
+ from app.services.jobs import record_usage, update_job
23
+ from app.services.storage import keys, storage
24
+
25
+ log = logging.getLogger(__name__)
26
+
27
+ CHUNK_TOKENS = 350
28
+ CHUNK_OVERLAP = 60
29
+ _enc = tiktoken.get_encoding("cl100k_base")
30
+ _DROP = re.compile(r"(subscribe|share this post|leave a comment|thanks for reading)", re.I)
31
+
32
+
33
+ def normalize_feed_url(url: str) -> tuple[str, str]:
34
+ """Accept 'name.substack.com', a Substack post URL, or any feed URL. Returns (feed_url, platform)."""
35
+ url = url.strip()
36
+ if not url.startswith(("http://", "https://")):
37
+ url = f"https://{url}"
38
+ parsed = urlparse(url)
39
+ host = parsed.netloc.lower()
40
+ if host.endswith("substack.com") or "/p/" in parsed.path:
41
+ return f"{parsed.scheme}://{host}/feed", "substack"
42
+ if parsed.path.rstrip("/").endswith(("/feed", "/rss", ".xml", "/atom")):
43
+ return url, "rss"
44
+ if host.endswith("medium.com"):
45
+ return f"https://medium.com/feed{parsed.path.rstrip('/')}", "medium"
46
+ return f"{parsed.scheme}://{host}/feed", "rss"
47
+
48
+
49
+ def html_to_sections(html: str) -> list[tuple[str | None, str]]:
50
+ soup = BeautifulSoup(html, "html.parser")
51
+ for tag in soup(["script", "style", "nav", "footer", "form", "button", "iframe"]):
52
+ tag.decompose()
53
+ sections: list[tuple[str | None, list[str]]] = [(None, [])]
54
+ for el in soup.find_all(["h1", "h2", "h3", "p", "li", "blockquote"]):
55
+ text = el.get_text(" ", strip=True)
56
+ if not text or (len(text) < 120 and _DROP.search(text)):
57
+ continue
58
+ if el.name in {"h1", "h2", "h3"}:
59
+ sections.append((text, []))
60
+ else:
61
+ sections[-1][1].append(text)
62
+ return [(h, "\n\n".join(p)) for h, p in sections if p]
63
+
64
+
65
+ def chunk_sections(sections: list[tuple[str | None, str]]) -> list[tuple[str | None, str, int]]:
66
+ out: list[tuple[str | None, str, int]] = []
67
+ for heading, text in sections:
68
+ tokens = _enc.encode(text)
69
+ start = 0
70
+ while start < len(tokens):
71
+ window = tokens[start : start + CHUNK_TOKENS]
72
+ out.append((heading, _enc.decode(window), len(window)))
73
+ if start + CHUNK_TOKENS >= len(tokens):
74
+ break
75
+ start += CHUNK_TOKENS - CHUNK_OVERLAP
76
+ return out
77
+
78
+
79
+ @dataclass
80
+ class FeedEntry:
81
+ title: str
82
+ url: str
83
+ published_at: datetime | None
84
+ html: str
85
+
86
+
87
+ def fetch_feed(feed_url: str, limit: int) -> tuple[str | None, list[FeedEntry]]:
88
+ resp = httpx.get(feed_url, follow_redirects=True, timeout=30, headers={"User-Agent": "NotestackBot/0.1"})
89
+ resp.raise_for_status()
90
+ parsed = feedparser.parse(resp.content)
91
+ entries = []
92
+ for e in parsed.entries[:limit]:
93
+ content = (e.get("content") or [{}])[0].get("value") or e.get("summary") or ""
94
+ published = e.get("published_parsed") or e.get("updated_parsed")
95
+ entries.append(
96
+ FeedEntry(
97
+ title=e.get("title") or "Untitled",
98
+ url=e.get("link") or "",
99
+ published_at=datetime.fromtimestamp(mktime(published), UTC) if published else None,
100
+ html=content,
101
+ )
102
+ )
103
+ return parsed.feed.get("title"), entries
104
+
105
+
106
+ def ingest_source(db: Session, source: Source, job: Job, max_posts: int) -> dict:
107
+ source.sync_status = "syncing"
108
+ update_job(db, job, status="running", progress=0.02, message="Contacting your feed")
109
+
110
+ title, entries = fetch_feed(source.feed_url, max_posts)
111
+ source.title = source.title or title
112
+ total = max(len(entries), 1)
113
+ indexed = skipped = 0
114
+ embed_tokens = 0
115
+
116
+ for i, entry in enumerate(entries):
117
+ content_hash = hashlib.sha256(entry.html.encode()).hexdigest()
118
+ doc = db.scalar(select(Document).where(Document.source_id == source.id, Document.url == entry.url))
119
+ if doc and doc.content_hash == content_hash:
120
+ skipped += 1
121
+ continue
122
+ if not doc:
123
+ doc = Document(id=uuid.uuid4(), workspace_id=source.workspace_id, source_id=source.id, url=entry.url)
124
+ db.add(doc)
125
+ doc.title = entry.title
126
+ doc.published_at = entry.published_at
127
+ doc.content_hash = content_hash
128
+ doc.raw_html_key = storage.put_text(
129
+ keys.raw_html(source.workspace_id, source.id, doc.id), entry.html, "text/html; charset=utf-8"
130
+ )
131
+ sections = html_to_sections(entry.html)
132
+ doc.clean_text = "\n\n".join(f"## {h}\n{t}" if h else t for h, t in sections)
133
+ doc.chunks.clear()
134
+ pieces = chunk_sections(sections)
135
+ if pieces:
136
+ result = embed_texts([f"{entry.title}\n{h or ''}\n{t}" for h, t, _ in pieces])
137
+ embed_tokens += result.tokens
138
+ for pos, ((heading, text, n_tokens), vec) in enumerate(zip(pieces, result.vectors, strict=True)):
139
+ doc.chunks.append(
140
+ Chunk(
141
+ workspace_id=source.workspace_id,
142
+ position=pos,
143
+ heading=heading,
144
+ text=text,
145
+ token_count=n_tokens,
146
+ embedding=vec,
147
+ )
148
+ )
149
+ indexed += 1
150
+ db.commit()
151
+ update_job(db, job, progress=0.05 + 0.9 * (i + 1) / total, message=f"{entry.title[:80]} is in orbit")
152
+
153
+ if embed_tokens:
154
+ record_usage(
155
+ db,
156
+ workspace_id=source.workspace_id,
157
+ kind="embedding",
158
+ provider=embedding_provider(),
159
+ model=settings.embedding_model,
160
+ quantity=embed_tokens,
161
+ unit="tokens",
162
+ job=job,
163
+ )
164
+ source.sync_status = "ok"
165
+ source.sync_error = None
166
+ source.last_synced_at = datetime.now(UTC)
167
+ db.commit()
168
+ return {"indexed": indexed, "skipped": skipped, "found": len(entries)}
backend/app/pipeline/retrieval.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Vector retrieval + grounded answering with citations."""
2
+
3
+ import uuid
4
+ from dataclasses import dataclass
5
+
6
+ import dspy
7
+ from sqlalchemy import select
8
+ from sqlalchemy.orm import Session
9
+
10
+ from app.llm.embeddings import embed_query
11
+ from app.llm.postprocess import strip_em_dashes
12
+ from app.llm.provider import main_lm, track_usage
13
+ from app.llm.signatures import GroundedAnswer
14
+ from app.models import Chunk, Document, NotebookDocument
15
+
16
+
17
+ @dataclass
18
+ class Retrieved:
19
+ chunk: Chunk
20
+ document: Document
21
+
22
+
23
+ def retrieve(db: Session, workspace_id: uuid.UUID, query: str, notebook_id: uuid.UUID | None, k: int = 8):
24
+ vec = embed_query(query)
25
+ stmt = (
26
+ select(Chunk, Document)
27
+ .join(Document, Document.id == Chunk.document_id)
28
+ .where(Chunk.workspace_id == workspace_id, Chunk.embedding.is_not(None))
29
+ )
30
+ if notebook_id:
31
+ stmt = stmt.join(NotebookDocument, NotebookDocument.document_id == Document.id).where(
32
+ NotebookDocument.notebook_id == notebook_id
33
+ )
34
+ stmt = stmt.order_by(Chunk.embedding.cosine_distance(vec)).limit(k)
35
+ return [Retrieved(c, d) for c, d in db.execute(stmt).all()]
36
+
37
+
38
+ @dataclass
39
+ class Answer:
40
+ text: str
41
+ unsupported: bool
42
+ citations: list[tuple[int, Retrieved]] # (marker, retrieved)
43
+ prompt_tokens: int
44
+ completion_tokens: int
45
+
46
+
47
+ def grounded_answer(question: str, hits: list[Retrieved]) -> Answer:
48
+ if not hits:
49
+ return Answer("I could not find anything about that in your posts yet.", True, [], 0, 0)
50
+ passages = [f"[{i + 1}] {h.document.title}: {h.chunk.text}" for i, h in enumerate(hits)]
51
+ lm = main_lm()
52
+ with track_usage(lm) as usage, dspy.context(lm=lm, adapter=dspy.JSONAdapter()):
53
+ pred = dspy.Predict(GroundedAnswer)(question=question, passages=passages)
54
+ result = pred.result
55
+ used = sorted({i for s in result.sentences for i in s.chunk_ids if 1 <= i <= len(hits)})
56
+ return Answer(
57
+ text=strip_em_dashes(result.answer),
58
+ unsupported=result.unsupported,
59
+ citations=[(i, hits[i - 1]) for i in used],
60
+ prompt_tokens=usage.prompt_tokens,
61
+ completion_tokens=usage.completion_tokens,
62
+ )
backend/app/routers/__init__.py ADDED
File without changes
backend/app/routers/artifacts.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ from typing import Literal
3
+
4
+ from fastapi import APIRouter, Depends, HTTPException, Request
5
+ from pydantic import BaseModel
6
+ from sqlalchemy import select
7
+
8
+ from app.auth import Ctx, get_ctx
9
+ from app.models import Artifact
10
+ from app.services.jobs import create_job, serialize_job
11
+ from app.services.storage import storage
12
+
13
+ router = APIRouter(prefix="/api/artifacts", tags=["artifacts"])
14
+
15
+ COMPOSITIONS = {"ShortVertical": "mp4", "QuoteCard": "png"}
16
+
17
+
18
+ class ArtifactIn(BaseModel):
19
+ type: Literal["summary", "audio_overview", "video", "thread", "linkedin", "notes", "quote_card", "carousel", "seo"]
20
+ notebook_id: uuid.UUID | None = None
21
+ document_id: uuid.UUID | None = None
22
+ content: dict = {}
23
+
24
+
25
+ class RenderIn(BaseModel):
26
+ composition: Literal["ShortVertical", "QuoteCard"]
27
+ props: dict
28
+
29
+
30
+ def _serialize(a: Artifact) -> dict:
31
+ return {
32
+ "id": str(a.id),
33
+ "type": a.type,
34
+ "status": a.status,
35
+ "content": a.content_json,
36
+ "storage_key": a.storage_key,
37
+ "url": storage.presign_get(a.storage_key) if a.storage_key else None,
38
+ }
39
+
40
+
41
+ def _get(ctx: Ctx, artifact_id: uuid.UUID) -> Artifact:
42
+ a = ctx.db.scalar(select(Artifact).where(Artifact.id == artifact_id, Artifact.workspace_id == ctx.workspace.id))
43
+ if not a:
44
+ raise HTTPException(404, "Artifact not found")
45
+ return a
46
+
47
+
48
+ @router.post("")
49
+ def create_artifact(body: ArtifactIn, ctx: Ctx = Depends(get_ctx)):
50
+ a = Artifact(
51
+ workspace_id=ctx.workspace.id,
52
+ type=body.type,
53
+ notebook_id=body.notebook_id,
54
+ document_id=body.document_id,
55
+ content_json=body.content,
56
+ status="draft",
57
+ )
58
+ ctx.db.add(a)
59
+ ctx.db.commit()
60
+ return _serialize(a)
61
+
62
+
63
+ @router.get("/{artifact_id}")
64
+ def get_artifact(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
65
+ return _serialize(_get(ctx, artifact_id))
66
+
67
+
68
+ @router.post("/{artifact_id}/render")
69
+ async def render_artifact(artifact_id: uuid.UUID, body: RenderIn, request: Request, ctx: Ctx = Depends(get_ctx)):
70
+ a = _get(ctx, artifact_id)
71
+ fmt = COMPOSITIONS[body.composition]
72
+ job = create_job(ctx.db, ctx.workspace.id, "render", {"composition": body.composition})
73
+ job.artifact_id = a.id
74
+ a.status = "rendering"
75
+ ctx.db.commit()
76
+ await request.app.state.arq.enqueue_job("render_task", str(a.id), str(job.id), body.composition, body.props, fmt)
77
+ return serialize_job(job)
backend/app/routers/auth.py ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import UTC, datetime
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException
4
+ from google.auth.transport import requests as google_requests
5
+ from google.oauth2 import id_token as google_id_token
6
+ from pydantic import BaseModel, EmailStr
7
+ from sqlalchemy.orm import Session
8
+
9
+ from app.auth import create_access_token, create_refresh_token, get_current_user, load_user_from_token
10
+ from app.config import settings
11
+ from app.db import get_db
12
+ from app.models import AuthProvider, User, VerificationPurpose
13
+ from app.services import auth_identity as ident
14
+ from app.services import email_verification as ev
15
+ from app.services.email import email_service
16
+
17
+ router = APIRouter(prefix="/api/auth", tags=["auth"])
18
+
19
+
20
+ def _err(exc: ident.IdentityError | ev.VerificationError, status: int = 400) -> HTTPException:
21
+ return HTTPException(getattr(exc, "status", status), {"code": exc.code, "message": exc.message})
22
+
23
+
24
+ def _serialize_user(user: User) -> dict:
25
+ return {
26
+ "id": str(user.id),
27
+ "email": user.email,
28
+ "name": user.name,
29
+ "avatar_url": user.avatar_url,
30
+ "auth_provider": user.auth_provider.value,
31
+ "email_unsubscribed": user.email_unsubscribed,
32
+ }
33
+
34
+
35
+ def _finalize_login(db: Session, user: User, created: bool) -> dict:
36
+ user.last_login_at = datetime.now(UTC)
37
+ if created and not user.welcome_email_sent_at:
38
+ if email_service.send_welcome(user.email, user.name, str(user.id)):
39
+ user.welcome_email_sent_at = datetime.now(UTC)
40
+ db.commit()
41
+ return {
42
+ "access_token": create_access_token(user.id, user.token_version),
43
+ "refresh_token": create_refresh_token(user.id, user.token_version),
44
+ "user": _serialize_user(user),
45
+ "created": created,
46
+ }
47
+
48
+
49
+ # Google
50
+
51
+
52
+ class GoogleIn(BaseModel):
53
+ credential: str # Google Identity Services ID token
54
+
55
+
56
+ @router.post("/google")
57
+ def google_login(body: GoogleIn, db: Session = Depends(get_db)):
58
+ if not settings.google_client_id:
59
+ raise HTTPException(503, "Google sign in is not configured")
60
+ try:
61
+ info = google_id_token.verify_oauth2_token(
62
+ body.credential, google_requests.Request(), settings.google_client_id
63
+ )
64
+ except ValueError as exc:
65
+ raise HTTPException(401, "Invalid Google credential") from exc
66
+ if not info.get("email_verified"):
67
+ raise HTTPException(401, "Google email is not verified")
68
+ try:
69
+ resolved = ident.resolve_or_create_google_user(
70
+ db, email=info["email"], google_id=info["sub"], name=info.get("name"), avatar_url=info.get("picture")
71
+ )
72
+ except ident.IdentityError as exc:
73
+ raise _err(exc) from exc
74
+ return _finalize_login(db, resolved.user, resolved.created)
75
+
76
+
77
+ # Email signup (6 digit code)
78
+
79
+
80
+ class RegisterStartIn(BaseModel):
81
+ email: EmailStr
82
+ password: str
83
+ name: str | None = None
84
+
85
+
86
+ class CodeIn(BaseModel):
87
+ email: EmailStr
88
+ code: str
89
+
90
+
91
+ class EmailIn(BaseModel):
92
+ email: EmailStr
93
+
94
+
95
+ @router.post("/email/register/start")
96
+ def register_start(body: RegisterStartIn, db: Session = Depends(get_db)):
97
+ email = ident.normalize_email(body.email)
98
+ try:
99
+ ident.validate_password(body.password)
100
+ existing = ident.find_user(db, email)
101
+ if existing:
102
+ if existing.auth_provider != AuthProvider.EMAIL:
103
+ raise ident.wrong_provider_error(existing)
104
+ raise ident.IdentityError("exists", "An account with this email already exists. Sign in instead.", 409)
105
+ issued = ev.issue_code(
106
+ db,
107
+ email,
108
+ VerificationPurpose.SIGNUP,
109
+ pending_name=(body.name or "").strip() or None,
110
+ pending_password_hash=ident.hash_password(body.password),
111
+ )
112
+ except (ident.IdentityError, ev.VerificationError) as exc:
113
+ raise _err(exc, 429 if getattr(exc, "code", "") == "cooldown" else 400) from exc
114
+ db.commit()
115
+ email_service.send_verification_code(email, issued.code)
116
+ return {"ok": True}
117
+
118
+
119
+ @router.post("/email/register/verify")
120
+ def register_verify(body: CodeIn, db: Session = Depends(get_db)):
121
+ email = ident.normalize_email(body.email)
122
+ try:
123
+ record = ev.consume_code(db, email, VerificationPurpose.SIGNUP, body.code)
124
+ user = ident.create_password_user(
125
+ db, email=email, name=record.pending_name, password_hash=record.pending_password_hash or ""
126
+ )
127
+ except (ident.IdentityError, ev.VerificationError) as exc:
128
+ raise _err(exc) from exc
129
+ ev.clear_codes(db, email, VerificationPurpose.SIGNUP)
130
+ return _finalize_login(db, user, True)
131
+
132
+
133
+ @router.post("/email/register/resend")
134
+ def register_resend(body: EmailIn, db: Session = Depends(get_db)):
135
+ email = ident.normalize_email(body.email)
136
+ pending = ev._latest(db, email, VerificationPurpose.SIGNUP)
137
+ if not pending:
138
+ raise HTTPException(400, {"code": "not_found", "message": "Start sign up again."})
139
+ try:
140
+ issued = ev.issue_code(
141
+ db,
142
+ email,
143
+ VerificationPurpose.SIGNUP,
144
+ pending_name=pending.pending_name,
145
+ pending_password_hash=pending.pending_password_hash,
146
+ )
147
+ except ev.VerificationError as exc:
148
+ raise _err(exc, 429) from exc
149
+ db.commit()
150
+ email_service.send_verification_code(email, issued.code)
151
+ return {"ok": True}
152
+
153
+
154
+ # Password login + reset
155
+
156
+
157
+ class LoginIn(BaseModel):
158
+ email: EmailStr
159
+ password: str
160
+
161
+
162
+ @router.post("/email/login")
163
+ def email_login(body: LoginIn, db: Session = Depends(get_db)):
164
+ try:
165
+ user = ident.resolve_password_user(db, email=body.email, password=body.password)
166
+ except ident.IdentityError as exc:
167
+ raise _err(exc) from exc
168
+ return _finalize_login(db, user, False)
169
+
170
+
171
+ @router.post("/password/forgot/start")
172
+ def forgot_start(body: EmailIn, db: Session = Depends(get_db)):
173
+ user = ident.find_user(db, body.email)
174
+ # Always 200 so the endpoint does not reveal which emails exist.
175
+ if user and user.auth_provider == AuthProvider.EMAIL and user.is_active:
176
+ try:
177
+ issued = ev.issue_code(db, user.email, VerificationPurpose.PASSWORD_RESET)
178
+ db.commit()
179
+ email_service.send_password_reset_code(user.email, issued.code)
180
+ except ev.VerificationError:
181
+ pass
182
+ return {"ok": True}
183
+
184
+
185
+ @router.post("/password/forgot/check")
186
+ def forgot_check(body: CodeIn, db: Session = Depends(get_db)):
187
+ try:
188
+ ev.check_code(db, ident.normalize_email(body.email), VerificationPurpose.PASSWORD_RESET, body.code)
189
+ except ev.VerificationError as exc:
190
+ raise _err(exc) from exc
191
+ return {"ok": True}
192
+
193
+
194
+ class ResetIn(CodeIn):
195
+ password: str
196
+
197
+
198
+ @router.post("/password/forgot/complete")
199
+ def forgot_complete(body: ResetIn, db: Session = Depends(get_db)):
200
+ email = ident.normalize_email(body.email)
201
+ try:
202
+ ident.validate_password(body.password)
203
+ ev.consume_code(db, email, VerificationPurpose.PASSWORD_RESET, body.code)
204
+ except (ident.IdentityError, ev.VerificationError) as exc:
205
+ raise _err(exc) from exc
206
+ user = ident.find_user(db, email)
207
+ if not user:
208
+ raise HTTPException(400, {"code": "not_found", "message": "Account not found."})
209
+ user.password_hash = ident.hash_password(body.password)
210
+ user.token_version += 1 # sign out everywhere
211
+ ev.clear_codes(db, email, VerificationPurpose.PASSWORD_RESET)
212
+ return _finalize_login(db, user, False)
213
+
214
+
215
+ # Session
216
+
217
+
218
+ class RefreshIn(BaseModel):
219
+ refresh_token: str
220
+
221
+
222
+ @router.post("/refresh")
223
+ def refresh(body: RefreshIn, db: Session = Depends(get_db)):
224
+ user = load_user_from_token(db, body.refresh_token, expected_type="refresh")
225
+ return {"access_token": create_access_token(user.id, user.token_version)}
226
+
227
+
228
+ @router.get("/me")
229
+ def me(user: User = Depends(get_current_user)):
230
+ return _serialize_user(user)
231
+
232
+
233
+ @router.post("/logout")
234
+ def logout(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
235
+ user.token_version += 1
236
+ db.commit()
237
+ return {"ok": True}
238
+
239
+
240
+ @router.post("/delete-account")
241
+ def delete_account(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
242
+ ident.soft_delete(db, user)
243
+ db.commit()
244
+ return {"ok": True}
backend/app/routers/billing.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException
2
+
3
+ from app.auth import Ctx, get_ctx
4
+ from app.config import settings
5
+ from app.services.plans import PLANS, effective_plan, plan_dict
6
+
7
+ router = APIRouter(prefix="/api/billing", tags=["billing"])
8
+
9
+
10
+ @router.get("/plans")
11
+ def list_plans():
12
+ return {"billing_enabled": settings.billing_enabled, "plans": [plan_dict(p) for p in PLANS.values()]}
13
+
14
+
15
+ @router.get("/me")
16
+ def my_plan(ctx: Ctx = Depends(get_ctx)):
17
+ plan = effective_plan(ctx.db, ctx.workspace)
18
+ return {"billing_enabled": settings.billing_enabled, "plan": plan_dict(plan)}
19
+
20
+
21
+ @router.post("/checkout")
22
+ def checkout(ctx: Ctx = Depends(get_ctx)):
23
+ if not settings.billing_enabled:
24
+ raise HTTPException(409, {"code": "billing_disabled", "message": "Notestack is free during early access."})
25
+ raise HTTPException(501, "Checkout provider not wired yet")
26
+
27
+
28
+ @router.post("/webhook")
29
+ def webhook():
30
+ if not settings.billing_enabled:
31
+ return {"ignored": True}
32
+ raise HTTPException(501, "Webhook not wired yet")
backend/app/routers/jobs.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import uuid
3
+
4
+ from fastapi import APIRouter, Depends, Header, HTTPException
5
+ from fastapi.responses import StreamingResponse
6
+ from pydantic import BaseModel
7
+ from sqlalchemy import select
8
+ from sqlalchemy.orm import Session
9
+
10
+ from app.auth import Ctx, get_ctx
11
+ from app.config import settings
12
+ from app.db import get_db
13
+ from app.models import Artifact, Job
14
+ from app.services.jobs import record_usage, serialize_job, subscribe, update_job
15
+
16
+ router = APIRouter(prefix="/api", tags=["jobs"])
17
+
18
+
19
+ def _get(ctx: Ctx, job_id: uuid.UUID) -> Job:
20
+ job = ctx.db.scalar(select(Job).where(Job.id == job_id, Job.workspace_id == ctx.workspace.id))
21
+ if not job:
22
+ raise HTTPException(404, "Job not found")
23
+ return job
24
+
25
+
26
+ @router.get("/jobs/{job_id}")
27
+ def get_job(job_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
28
+ return serialize_job(_get(ctx, job_id))
29
+
30
+
31
+ @router.get("/jobs/{job_id}/events")
32
+ async def job_events(job_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
33
+ job = _get(ctx, job_id)
34
+ snapshot = serialize_job(job)
35
+
36
+ async def stream():
37
+ yield f"data: {json.dumps(snapshot)}\n\n"
38
+ if snapshot["status"] in {"done", "failed"}:
39
+ return
40
+ async for data in subscribe(job_id):
41
+ yield f"data: {data}\n\n"
42
+ if json.loads(data).get("status") in {"done", "failed"}:
43
+ return
44
+
45
+ return StreamingResponse(stream(), media_type="text/event-stream", headers={"Cache-Control": "no-cache"})
46
+
47
+
48
+ class ProgressIn(BaseModel):
49
+ status: str | None = None
50
+ progress: float | None = None
51
+ message: str | None = None
52
+ error: str | None = None
53
+ render_seconds: float | None = None
54
+ storage_key: str | None = None
55
+
56
+
57
+ @router.post("/internal/jobs/{job_id}/progress", include_in_schema=False)
58
+ def renderer_progress(
59
+ job_id: uuid.UUID,
60
+ body: ProgressIn,
61
+ x_internal_token: str = Header(...),
62
+ db: Session = Depends(get_db),
63
+ ):
64
+ if x_internal_token != settings.internal_token:
65
+ raise HTTPException(403, "Forbidden")
66
+ job = db.get(Job, job_id)
67
+ if not job:
68
+ raise HTTPException(404, "Job not found")
69
+ if body.status == "done" and job.artifact_id:
70
+ artifact = db.get(Artifact, job.artifact_id)
71
+ if artifact and body.storage_key:
72
+ artifact.storage_key = body.storage_key
73
+ artifact.status = "ready"
74
+ if body.render_seconds:
75
+ record_usage(
76
+ db, workspace_id=job.workspace_id, kind="render", provider="remotion",
77
+ quantity=body.render_seconds, unit="seconds", job=job,
78
+ )
79
+ update_job(
80
+ db, job, status=body.status, progress=body.progress, message=body.message, error=body.error,
81
+ result={"storage_key": body.storage_key} if body.storage_key else None,
82
+ )
83
+ return {"ok": True}
backend/app/routers/notebooks.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import uuid
3
+
4
+ from fastapi import APIRouter, Depends, HTTPException
5
+ from fastapi.concurrency import run_in_threadpool
6
+ from fastapi.responses import StreamingResponse
7
+ from pydantic import BaseModel, Field
8
+ from sqlalchemy import select
9
+
10
+ from app.auth import Ctx, get_ctx
11
+ from app.config import settings
12
+ from app.llm.provider import provider_name
13
+ from app.models import Chat, Citation, Document, Message, Notebook, NotebookDocument
14
+ from app.pipeline.retrieval import grounded_answer, retrieve
15
+ from app.services.jobs import record_usage
16
+
17
+ router = APIRouter(prefix="/api/notebooks", tags=["notebooks"])
18
+
19
+
20
+ class NotebookIn(BaseModel):
21
+ title: str = Field(min_length=1, max_length=300)
22
+ description: str | None = None
23
+
24
+
25
+ class DocumentsIn(BaseModel):
26
+ document_ids: list[uuid.UUID]
27
+
28
+
29
+ class ChatIn(BaseModel):
30
+ question: str = Field(min_length=1, max_length=4000)
31
+ chat_id: uuid.UUID | None = None
32
+
33
+
34
+ def _get(ctx: Ctx, notebook_id: uuid.UUID) -> Notebook:
35
+ nb = ctx.db.scalar(
36
+ select(Notebook).where(Notebook.id == notebook_id, Notebook.workspace_id == ctx.workspace.id)
37
+ )
38
+ if not nb:
39
+ raise HTTPException(404, "Notebook not found")
40
+ return nb
41
+
42
+
43
+ @router.post("")
44
+ def create_notebook(body: NotebookIn, ctx: Ctx = Depends(get_ctx)):
45
+ nb = Notebook(workspace_id=ctx.workspace.id, title=body.title, description=body.description)
46
+ ctx.db.add(nb)
47
+ ctx.db.commit()
48
+ return {"id": str(nb.id), "title": nb.title}
49
+
50
+
51
+ @router.get("")
52
+ def list_notebooks(ctx: Ctx = Depends(get_ctx)):
53
+ rows = ctx.db.scalars(
54
+ select(Notebook).where(Notebook.workspace_id == ctx.workspace.id).order_by(Notebook.updated_at.desc())
55
+ ).all()
56
+ return [{"id": str(n.id), "title": n.title, "description": n.description} for n in rows]
57
+
58
+
59
+ @router.get("/{notebook_id}")
60
+ def get_notebook(notebook_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
61
+ nb = _get(ctx, notebook_id)
62
+ docs = ctx.db.scalars(
63
+ select(Document)
64
+ .join(NotebookDocument, NotebookDocument.document_id == Document.id)
65
+ .where(NotebookDocument.notebook_id == nb.id)
66
+ .order_by(Document.published_at.desc())
67
+ ).all()
68
+ return {
69
+ "id": str(nb.id),
70
+ "title": nb.title,
71
+ "description": nb.description,
72
+ "summary": nb.summary,
73
+ "documents": [
74
+ {"id": str(d.id), "title": d.title, "url": d.url,
75
+ "published_at": d.published_at.isoformat() if d.published_at else None}
76
+ for d in docs
77
+ ],
78
+ }
79
+
80
+
81
+ @router.post("/{notebook_id}/documents")
82
+ def add_documents(notebook_id: uuid.UUID, body: DocumentsIn, ctx: Ctx = Depends(get_ctx)):
83
+ nb = _get(ctx, notebook_id)
84
+ valid = set(
85
+ ctx.db.scalars(
86
+ select(Document.id).where(Document.id.in_(body.document_ids), Document.workspace_id == ctx.workspace.id)
87
+ ).all()
88
+ )
89
+ existing = set(
90
+ ctx.db.scalars(select(NotebookDocument.document_id).where(NotebookDocument.notebook_id == nb.id)).all()
91
+ )
92
+ for doc_id in valid - existing:
93
+ ctx.db.add(NotebookDocument(notebook_id=nb.id, document_id=doc_id))
94
+ ctx.db.commit()
95
+ return {"added": len(valid - existing)}
96
+
97
+
98
+ def _sse(event: str, data: dict) -> str:
99
+ return f"event: {event}\ndata: {json.dumps(data)}\n\n"
100
+
101
+
102
+ @router.post("/{notebook_id}/chat")
103
+ async def chat(notebook_id: uuid.UUID, body: ChatIn, ctx: Ctx = Depends(get_ctx)):
104
+ nb = _get(ctx, notebook_id)
105
+ db = ctx.db
106
+ chat_row = db.get(Chat, body.chat_id) if body.chat_id else None
107
+ if not chat_row or chat_row.notebook_id != nb.id:
108
+ chat_row = Chat(workspace_id=ctx.workspace.id, notebook_id=nb.id, title=body.question[:120])
109
+ db.add(chat_row)
110
+ db.flush()
111
+ db.add(Message(chat_id=chat_row.id, role="user", content=body.question))
112
+ db.commit()
113
+
114
+ async def stream():
115
+ yield _sse("status", {"chat_id": str(chat_row.id), "message": "Scanning your archive"})
116
+ hits = await run_in_threadpool(retrieve, db, ctx.workspace.id, body.question, nb.id)
117
+ yield _sse("status", {"message": f"Found {len(hits)} relevant passages"})
118
+ answer = await run_in_threadpool(grounded_answer, body.question, hits)
119
+ msg = Message(chat_id=chat_row.id, role="assistant", content=answer.text)
120
+ db.add(msg)
121
+ db.flush()
122
+ citations = []
123
+ for marker, hit in answer.citations:
124
+ db.add(Citation(message_id=msg.id, chunk_id=hit.chunk.id, marker=marker, span=hit.chunk.text[:500]))
125
+ citations.append({
126
+ "marker": marker,
127
+ "chunk_id": str(hit.chunk.id),
128
+ "document_id": str(hit.document.id),
129
+ "title": hit.document.title,
130
+ "url": hit.document.url,
131
+ "heading": hit.chunk.heading,
132
+ "span": hit.chunk.text[:500],
133
+ })
134
+ db.commit()
135
+ if answer.prompt_tokens or answer.completion_tokens:
136
+ record_usage(
137
+ db,
138
+ workspace_id=ctx.workspace.id,
139
+ kind="llm",
140
+ provider=provider_name(),
141
+ model=settings.llm_model,
142
+ quantity=answer.prompt_tokens + answer.completion_tokens,
143
+ unit="tokens",
144
+ )
145
+ yield _sse("answer", {
146
+ "message_id": str(msg.id),
147
+ "text": answer.text,
148
+ "unsupported": answer.unsupported,
149
+ "citations": citations,
150
+ })
151
+ yield _sse("done", {})
152
+
153
+ return StreamingResponse(stream(), media_type="text/event-stream")
backend/app/routers/sources.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException, Request
4
+ from pydantic import BaseModel
5
+ from sqlalchemy import func, select
6
+
7
+ from app.auth import Ctx, get_ctx
8
+ from app.models import Document, Job, Source
9
+ from app.pipeline.ingest import normalize_feed_url
10
+ from app.services.jobs import create_job, serialize_job
11
+ from app.services.plans import effective_plan
12
+
13
+ router = APIRouter(prefix="/api/sources", tags=["sources"])
14
+
15
+
16
+ class SourceIn(BaseModel):
17
+ url: str
18
+
19
+
20
+ def _serialize(source: Source, doc_count: int = 0) -> dict:
21
+ return {
22
+ "id": str(source.id),
23
+ "feed_url": source.feed_url,
24
+ "platform": source.platform,
25
+ "title": source.title,
26
+ "sync_status": source.sync_status,
27
+ "sync_error": source.sync_error,
28
+ "last_synced_at": source.last_synced_at.isoformat() if source.last_synced_at else None,
29
+ "document_count": doc_count,
30
+ }
31
+
32
+
33
+ async def _enqueue_sync(request: Request, ctx: Ctx, source: Source) -> Job:
34
+ job = create_job(ctx.db, ctx.workspace.id, "ingest", {"source_id": str(source.id)})
35
+ await request.app.state.arq.enqueue_job("ingest_source_task", str(source.id), str(job.id))
36
+ return job
37
+
38
+
39
+ @router.post("")
40
+ async def create_source(body: SourceIn, request: Request, ctx: Ctx = Depends(get_ctx)):
41
+ feed_url, platform = normalize_feed_url(body.url)
42
+ existing = ctx.db.scalar(
43
+ select(Source).where(Source.workspace_id == ctx.workspace.id, Source.feed_url == feed_url)
44
+ )
45
+ if existing:
46
+ job = await _enqueue_sync(request, ctx, existing)
47
+ return {"source": _serialize(existing), "job": serialize_job(job)}
48
+ plan = effective_plan(ctx.db, ctx.workspace)
49
+ count = ctx.db.scalar(select(func.count()).select_from(Source).where(Source.workspace_id == ctx.workspace.id))
50
+ if count >= plan.sources:
51
+ raise HTTPException(402, {"code": "plan_limit", "message": f"Your plan includes {plan.sources} sources."})
52
+ source = Source(workspace_id=ctx.workspace.id, feed_url=feed_url, platform=platform, site_url=body.url)
53
+ ctx.db.add(source)
54
+ ctx.db.commit()
55
+ job = await _enqueue_sync(request, ctx, source)
56
+ return {"source": _serialize(source), "job": serialize_job(job)}
57
+
58
+
59
+ @router.get("")
60
+ def list_sources(ctx: Ctx = Depends(get_ctx)):
61
+ rows = ctx.db.execute(
62
+ select(Source, func.count(Document.id))
63
+ .outerjoin(Document, Document.source_id == Source.id)
64
+ .where(Source.workspace_id == ctx.workspace.id)
65
+ .group_by(Source.id)
66
+ .order_by(Source.created_at)
67
+ ).all()
68
+ return [_serialize(s, n) for s, n in rows]
69
+
70
+
71
+ @router.get("/{source_id}/sync")
72
+ async def sync_source(source_id: uuid.UUID, request: Request, ctx: Ctx = Depends(get_ctx)):
73
+ source = ctx.db.scalar(
74
+ select(Source).where(Source.id == source_id, Source.workspace_id == ctx.workspace.id)
75
+ )
76
+ if not source:
77
+ raise HTTPException(404, "Source not found")
78
+ job = await _enqueue_sync(request, ctx, source)
79
+ return {"source": _serialize(source), "job": serialize_job(job)}
80
+
81
+
82
+ documents_router = APIRouter(prefix="/api/documents", tags=["documents"])
83
+
84
+
85
+ @documents_router.get("")
86
+ def list_documents(ctx: Ctx = Depends(get_ctx), limit: int = 500):
87
+ docs = ctx.db.scalars(
88
+ select(Document)
89
+ .where(Document.workspace_id == ctx.workspace.id)
90
+ .order_by(Document.published_at.desc())
91
+ .limit(min(limit, 5000))
92
+ ).all()
93
+ return [
94
+ {"id": str(d.id), "title": d.title, "url": d.url,
95
+ "published_at": d.published_at.isoformat() if d.published_at else None}
96
+ for d in docs
97
+ ]
backend/app/routers/storage.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Browser uploads go straight to R2 with presigned PUTs; reads redirect to presigned GETs."""
2
+
3
+ import uuid
4
+
5
+ from fastapi import APIRouter, Depends, HTTPException
6
+ from fastapi.responses import RedirectResponse
7
+ from pydantic import BaseModel, Field
8
+ from sqlalchemy import select
9
+
10
+ from app.auth import Ctx, get_ctx
11
+ from app.config import settings
12
+ from app.models import Upload
13
+ from app.services.storage import keys, storage, workspace_owns_key
14
+
15
+ router = APIRouter(prefix="/api/storage", tags=["storage"])
16
+
17
+ ALLOWED_TYPES = {
18
+ "image/png",
19
+ "image/jpeg",
20
+ "image/webp",
21
+ "image/svg+xml",
22
+ "audio/mpeg",
23
+ "audio/wav",
24
+ "audio/webm",
25
+ "audio/mp4",
26
+ "video/mp4",
27
+ "application/pdf",
28
+ "text/markdown",
29
+ "text/plain",
30
+ "text/html",
31
+ }
32
+
33
+
34
+ class UploadStartIn(BaseModel):
35
+ filename: str = Field(max_length=300)
36
+ content_type: str
37
+ size_bytes: int = Field(gt=0)
38
+
39
+
40
+ @router.post("/uploads")
41
+ def start_upload(body: UploadStartIn, ctx: Ctx = Depends(get_ctx)):
42
+ if body.content_type not in ALLOWED_TYPES:
43
+ raise HTTPException(415, "File type not supported")
44
+ if body.size_bytes > settings.max_upload_bytes:
45
+ raise HTTPException(413, "File too large")
46
+ upload_id = uuid.uuid4()
47
+ key = keys.upload(ctx.workspace.id, upload_id, body.filename)
48
+ upload = Upload(
49
+ id=upload_id,
50
+ workspace_id=ctx.workspace.id,
51
+ user_id=ctx.user.id,
52
+ key=key,
53
+ filename=body.filename,
54
+ content_type=body.content_type,
55
+ )
56
+ ctx.db.add(upload)
57
+ ctx.db.commit()
58
+ return {
59
+ "upload_id": str(upload_id),
60
+ "key": key,
61
+ "upload_url": storage.presign_put(key, body.content_type, ttl=900),
62
+ "headers": {"Content-Type": body.content_type},
63
+ }
64
+
65
+
66
+ @router.post("/uploads/{upload_id}/complete")
67
+ def complete_upload(upload_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
68
+ upload = ctx.db.scalar(
69
+ select(Upload).where(Upload.id == upload_id, Upload.workspace_id == ctx.workspace.id)
70
+ )
71
+ if not upload:
72
+ raise HTTPException(404, "Upload not found")
73
+ head = storage.head(upload.key)
74
+ if not head:
75
+ raise HTTPException(409, "Object not found in storage yet")
76
+ size = int(head.get("ContentLength") or 0)
77
+ if size > settings.max_upload_bytes:
78
+ storage.delete(upload.key)
79
+ raise HTTPException(413, "File too large")
80
+ upload.size_bytes = size
81
+ upload.status = "complete"
82
+ ctx.db.commit()
83
+ return {"upload_id": str(upload.id), "key": upload.key, "size_bytes": size}
84
+
85
+
86
+ @router.get("/objects/{key:path}")
87
+ def read_object(key: str, download: bool = False, ctx: Ctx = Depends(get_ctx)):
88
+ if not workspace_owns_key(ctx.workspace.id, key):
89
+ raise HTTPException(404, "Not found")
90
+ name = key.rsplit("/", 1)[-1] if download else None
91
+ return RedirectResponse(storage.presign_get(key, download_name=name), status_code=302)
backend/app/routers/unsubscribe.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+
3
+ from fastapi import APIRouter, Depends
4
+ from fastapi.responses import HTMLResponse
5
+ from sqlalchemy.orm import Session
6
+
7
+ from app.db import get_db
8
+ from app.models import User
9
+ from app.services.email import verify_unsubscribe_token
10
+
11
+ router = APIRouter(prefix="/api", tags=["email"])
12
+
13
+ _PAGE = """<!doctype html><html><body style="background:#000;color:#fff;font-family:Inter,Arial,sans-serif;
14
+ display:grid;place-items:center;min-height:100vh;margin:0"><div style="text-align:center;padding:16px">
15
+ <p style="color:#217cff;letter-spacing:.2em;text-transform:uppercase;font-size:13px">Notestack</p>
16
+ <h1 style="font-weight:600">{title}</h1><p style="color:rgba(255,255,255,.7)">{body}</p></div></body></html>"""
17
+
18
+
19
+ def _apply(token: str, db: Session) -> bool:
20
+ user_id = verify_unsubscribe_token(token)
21
+ if not user_id:
22
+ return False
23
+ try:
24
+ user = db.get(User, uuid.UUID(user_id))
25
+ except ValueError:
26
+ return False
27
+ if not user:
28
+ return False
29
+ user.email_unsubscribed = True
30
+ db.commit()
31
+ return True
32
+
33
+
34
+ @router.get("/unsubscribe", response_class=HTMLResponse)
35
+ def unsubscribe(token: str, db: Session = Depends(get_db)):
36
+ if _apply(token, db):
37
+ return _PAGE.format(title="You are unsubscribed", body="You will still get account and security emails.")
38
+ return HTMLResponse(_PAGE.format(title="Link not valid", body="This unsubscribe link is invalid."), 400)
39
+
40
+
41
+ @router.post("/unsubscribe")
42
+ def unsubscribe_one_click(token: str, db: Session = Depends(get_db)):
43
+ """RFC 8058 one click (List-Unsubscribe-Post)."""
44
+ return {"ok": _apply(token, db)}
backend/app/services/__init__.py ADDED
File without changes
backend/app/services/auth_identity.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Account resolution. One provider per account for life: Google accounts never get a password and
2
+ email accounts never get linked to Google."""
3
+
4
+ from dataclasses import dataclass
5
+ from datetime import UTC, datetime
6
+
7
+ import bcrypt
8
+ from sqlalchemy import select
9
+ from sqlalchemy.orm import Session
10
+
11
+ from app.models import AuthProvider, Subscription, User, Workspace, WorkspaceMember
12
+
13
+
14
+ class IdentityError(Exception):
15
+ def __init__(self, code: str, message: str, status: int = 400):
16
+ super().__init__(message)
17
+ self.code = code
18
+ self.message = message
19
+ self.status = status
20
+
21
+
22
+ @dataclass
23
+ class Resolved:
24
+ user: User
25
+ created: bool
26
+
27
+
28
+ def normalize_email(email: str) -> str:
29
+ return email.strip().lower()
30
+
31
+
32
+ def hash_password(password: str) -> str:
33
+ return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
34
+
35
+
36
+ def verify_password(password: str, password_hash: str | None) -> bool:
37
+ if not password_hash:
38
+ return False
39
+ return bcrypt.checkpw(password.encode(), password_hash.encode())
40
+
41
+
42
+ def validate_password(password: str) -> None:
43
+ if len(password) < 8:
44
+ raise IdentityError("weak_password", "Use at least 8 characters.")
45
+
46
+
47
+ def wrong_provider_error(user: User) -> IdentityError:
48
+ if user.auth_provider == AuthProvider.GOOGLE:
49
+ return IdentityError("wrong_provider", "This email uses Google sign in. Continue with Google.", 409)
50
+ return IdentityError("wrong_provider", "This email uses a password. Sign in with email instead.", 409)
51
+
52
+
53
+ def find_user(db: Session, email: str) -> User | None:
54
+ return db.scalar(select(User).where(User.email == normalize_email(email)))
55
+
56
+
57
+ def _ensure_active(user: User) -> None:
58
+ if user.deleted_at or not user.is_active:
59
+ raise IdentityError("account_deleted", "This account was deleted. Contact support to restore it.", 403)
60
+
61
+
62
+ def _bootstrap_workspace(db: Session, user: User) -> None:
63
+ first = (user.name or user.email.split("@")[0]).split(" ")[0]
64
+ workspace = Workspace(name=f"{first}'s workspace", owner_id=user.id)
65
+ db.add(workspace)
66
+ db.flush()
67
+ db.add(WorkspaceMember(workspace_id=workspace.id, user_id=user.id, role="owner"))
68
+ db.add(Subscription(workspace_id=workspace.id, plan="free"))
69
+
70
+
71
+ def resolve_or_create_google_user(
72
+ db: Session, *, email: str, google_id: str, name: str | None, avatar_url: str | None
73
+ ) -> Resolved:
74
+ email = normalize_email(email)
75
+ user = db.scalar(select(User).where(User.google_id == google_id)) or find_user(db, email)
76
+ if user:
77
+ _ensure_active(user)
78
+ if user.auth_provider != AuthProvider.GOOGLE:
79
+ raise wrong_provider_error(user)
80
+ user.name = user.name or name
81
+ user.avatar_url = avatar_url or user.avatar_url
82
+ return Resolved(user, False)
83
+ user = User(
84
+ email=email, name=name, avatar_url=avatar_url, auth_provider=AuthProvider.GOOGLE, google_id=google_id
85
+ )
86
+ db.add(user)
87
+ db.flush()
88
+ _bootstrap_workspace(db, user)
89
+ return Resolved(user, True)
90
+
91
+
92
+ def create_password_user(db: Session, *, email: str, name: str | None, password_hash: str) -> User:
93
+ email = normalize_email(email)
94
+ existing = find_user(db, email)
95
+ if existing:
96
+ _ensure_active(existing)
97
+ if existing.auth_provider != AuthProvider.EMAIL:
98
+ raise wrong_provider_error(existing)
99
+ raise IdentityError("exists", "An account with this email already exists. Sign in instead.", 409)
100
+ user = User(email=email, name=name, auth_provider=AuthProvider.EMAIL, password_hash=password_hash)
101
+ db.add(user)
102
+ db.flush()
103
+ _bootstrap_workspace(db, user)
104
+ return user
105
+
106
+
107
+ def resolve_password_user(db: Session, *, email: str, password: str) -> User:
108
+ user = find_user(db, email)
109
+ if not user:
110
+ raise IdentityError("invalid_credentials", "Email or password is incorrect.", 401)
111
+ _ensure_active(user)
112
+ if user.auth_provider != AuthProvider.EMAIL:
113
+ raise wrong_provider_error(user)
114
+ if not verify_password(password, user.password_hash):
115
+ raise IdentityError("invalid_credentials", "Email or password is incorrect.", 401)
116
+ return user
117
+
118
+
119
+ def soft_delete(db: Session, user: User) -> None:
120
+ user.deleted_at = datetime.now(UTC)
121
+ user.is_active = False
122
+ user.token_version += 1
backend/app/services/email.py ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """EmailService over a provider interface (Resend in prod, console locally).
2
+
3
+ Rule: no em dashes in any copy. Broadcast bodies are plain text and get HTML escaped.
4
+ """
5
+
6
+ import hashlib
7
+ import hmac
8
+ import html
9
+ import logging
10
+ from abc import ABC, abstractmethod
11
+ from dataclasses import dataclass
12
+
13
+ from app.config import settings
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+
18
+ @dataclass
19
+ class OutgoingEmail:
20
+ to: str
21
+ subject: str
22
+ html: str
23
+ text: str
24
+ headers: dict[str, str] | None = None
25
+
26
+
27
+ class BaseEmailProvider(ABC):
28
+ @abstractmethod
29
+ def send(self, email: OutgoingEmail) -> str | None: ...
30
+
31
+
32
+ class ResendEmailProvider(BaseEmailProvider):
33
+ def __init__(self, api_key: str):
34
+ import resend
35
+
36
+ resend.api_key = api_key
37
+ self._resend = resend
38
+
39
+ def send(self, email: OutgoingEmail) -> str | None:
40
+ params = {
41
+ "from": settings.from_email,
42
+ "to": [email.to],
43
+ "subject": email.subject,
44
+ "html": email.html,
45
+ "text": email.text,
46
+ }
47
+ if email.headers:
48
+ params["headers"] = email.headers
49
+ result = self._resend.Emails.send(params)
50
+ return result.get("id") if isinstance(result, dict) else None
51
+
52
+
53
+ class ConsoleEmailProvider(BaseEmailProvider):
54
+ sent: list[OutgoingEmail] = []
55
+
56
+ def send(self, email: OutgoingEmail) -> str | None:
57
+ ConsoleEmailProvider.sent.append(email)
58
+ log.info("[email] to=%s subject=%s\n%s", email.to, email.subject, email.text)
59
+ return None
60
+
61
+
62
+ def make_unsubscribe_token(user_id: str) -> str:
63
+ sig = hmac.new(settings.unsubscribe_secret.encode(), user_id.encode(), hashlib.sha256).hexdigest()[:32]
64
+ return f"{user_id}.{sig}"
65
+
66
+
67
+ def verify_unsubscribe_token(token: str) -> str | None:
68
+ user_id, _, _sig = token.partition(".")
69
+ if user_id and hmac.compare_digest(make_unsubscribe_token(user_id), token):
70
+ return user_id
71
+ return None
72
+
73
+
74
+ def _layout(title: str, body_html: str, footer_html: str = "") -> str:
75
+ # Inline styles only; email clients ignore <style>. Black, #217cff, white.
76
+ return f"""<!doctype html><html><body style="margin:0;background:#000000;padding:32px 16px;
77
+ font-family:Inter,Arial,sans-serif;color:#ffffff">
78
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td align="center">
79
+ <table role="presentation" width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%;
80
+ border:1px solid rgba(33,124,255,0.35);border-radius:16px;padding:32px;background:#000000">
81
+ <tr><td style="font-size:13px;letter-spacing:0.2em;text-transform:uppercase;color:#217cff">Notestack</td></tr>
82
+ <tr><td style="padding-top:16px;font-size:22px;font-weight:600;color:#ffffff">{html.escape(title)}</td></tr>
83
+ <tr><td style="padding-top:16px;font-size:15px;line-height:1.6;color:rgba(255,255,255,0.85)">{body_html}</td></tr>
84
+ <tr><td style="padding-top:28px;font-size:12px;color:rgba(255,255,255,0.5)">{footer_html}</td></tr>
85
+ </table></td></tr></table></body></html>"""
86
+
87
+
88
+ def _button(label: str, url: str) -> str:
89
+ return (
90
+ f'<p style="padding-top:12px"><a href="{html.escape(url)}" style="display:inline-block;'
91
+ f"background:#217cff;color:#ffffff;text-decoration:none;padding:12px 20px;border-radius:999px;"
92
+ f'font-weight:600">{html.escape(label)}</a></p>'
93
+ )
94
+
95
+
96
+ def _code_block(code: str) -> str:
97
+ return (
98
+ f'<p style="font-family:JetBrains Mono,monospace;font-size:32px;letter-spacing:0.3em;'
99
+ f'color:#ffffff;padding:8px 0">{html.escape(code)}</p>'
100
+ )
101
+
102
+
103
+ class EmailService:
104
+ def __init__(self, provider: BaseEmailProvider):
105
+ self.provider = provider
106
+
107
+ def _send(self, email: OutgoingEmail) -> bool:
108
+ try:
109
+ self.provider.send(email)
110
+ return True
111
+ except Exception: # never let email failures break the request
112
+ log.exception("email send failed to=%s subject=%s", email.to, email.subject)
113
+ return False
114
+
115
+ # Transactional
116
+
117
+ def send_verification_code(self, to: str, code: str) -> bool:
118
+ body = f"Enter this code to finish creating your account:{_code_block(code)}It expires in 10 minutes."
119
+ return self._send(
120
+ OutgoingEmail(
121
+ to=to,
122
+ subject=f"{code} is your Notestack code",
123
+ html=_layout("Confirm your email", body, "If you did not sign up, ignore this email."),
124
+ text=f"Your Notestack code is {code}. It expires in 10 minutes.",
125
+ )
126
+ )
127
+
128
+ def send_password_reset_code(self, to: str, code: str) -> bool:
129
+ body = f"Use this code to reset your password:{_code_block(code)}It expires in 10 minutes."
130
+ return self._send(
131
+ OutgoingEmail(
132
+ to=to,
133
+ subject="Reset your Notestack password",
134
+ html=_layout("Password reset", body, "If you did not ask for this, you can ignore it."),
135
+ text=f"Your Notestack password reset code is {code}. It expires in 10 minutes.",
136
+ )
137
+ )
138
+
139
+ def send_welcome(self, to: str, name: str | None, user_id: str) -> bool:
140
+ first = (name or "there").split(" ")[0]
141
+ url = f"{settings.frontend_url}/app"
142
+ body = (
143
+ f"Hi {html.escape(first)},<br><br>Your archive is about to come into orbit. Paste your Substack "
144
+ "URL and Notestack will index your posts, learn your voice and get your first audio overview ready."
145
+ + _button("Open Mission Control", url)
146
+ )
147
+ return self._send(
148
+ OutgoingEmail(
149
+ to=to,
150
+ subject="Welcome to Notestack",
151
+ html=_layout("Welcome aboard", body, self._unsubscribe_footer(user_id)),
152
+ text=f"Hi {first}, welcome to Notestack. Open Mission Control: {url}",
153
+ headers=self._list_unsubscribe(user_id),
154
+ )
155
+ )
156
+
157
+ def send_job_ready(self, to: str, what: str, url: str) -> bool:
158
+ body = f"Your {html.escape(what)} is ready." + _button("View it", url)
159
+ return self._send(
160
+ OutgoingEmail(
161
+ to=to,
162
+ subject=f"Your {what} is ready",
163
+ html=_layout("Launch complete", body),
164
+ text=f"Your {what} is ready: {url}",
165
+ )
166
+ )
167
+
168
+ # Internal alerts
169
+
170
+ def send_alert(self, subject: str, text: str) -> bool:
171
+ return self._send(
172
+ OutgoingEmail(
173
+ to=settings.alerts_email,
174
+ subject=f"[Notestack alert] {subject}",
175
+ html=_layout(subject, f"<pre style='white-space:pre-wrap'>{html.escape(text)}</pre>"),
176
+ text=text,
177
+ )
178
+ )
179
+
180
+ # Broadcast
181
+
182
+ def send_blast_email(self, user_id: str, user_email: str, user_name: str | None, subject: str, body: str) -> bool:
183
+ first = (user_name or "there").split(" ")[0]
184
+ body_html = f"Hi {html.escape(first)},<br><br>" + html.escape(body).replace("\n", "<br>")
185
+ return self._send(
186
+ OutgoingEmail(
187
+ to=user_email,
188
+ subject=subject,
189
+ html=_layout(subject, body_html, self._unsubscribe_footer(user_id)),
190
+ text=f"Hi {first},\n\n{body}\n\nUnsubscribe: {self._unsubscribe_url(user_id)}",
191
+ headers=self._list_unsubscribe(user_id),
192
+ )
193
+ )
194
+
195
+ def _unsubscribe_url(self, user_id: str) -> str:
196
+ return f"{settings.api_url}/api/unsubscribe?token={make_unsubscribe_token(user_id)}"
197
+
198
+ def _unsubscribe_footer(self, user_id: str) -> str:
199
+ return f'<a href="{self._unsubscribe_url(user_id)}" style="color:rgba(255,255,255,0.5)">Unsubscribe</a>'
200
+
201
+ def _list_unsubscribe(self, user_id: str) -> dict[str, str]:
202
+ return {
203
+ "List-Unsubscribe": f"<{self._unsubscribe_url(user_id)}>",
204
+ "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
205
+ }
206
+
207
+
208
+ def _build() -> EmailService:
209
+ if settings.email_provider == "resend" and settings.resend_api_key:
210
+ return EmailService(ResendEmailProvider(settings.resend_api_key))
211
+ return EmailService(ConsoleEmailProvider())
212
+
213
+
214
+ email_service = _build()
backend/app/services/email_verification.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """6 digit codes: hashed, 10 minute TTL, 5 attempts, 60s resend cooldown, purged after 1 day."""
2
+
3
+ import hashlib
4
+ import hmac
5
+ import secrets
6
+ from dataclasses import dataclass
7
+ from datetime import UTC, datetime, timedelta
8
+
9
+ from sqlalchemy import delete, select
10
+ from sqlalchemy.orm import Session
11
+
12
+ from app.config import settings
13
+ from app.models import EmailVerificationCode, VerificationPurpose
14
+
15
+ CODE_TTL = timedelta(minutes=10)
16
+ MAX_ATTEMPTS = 5
17
+ RESEND_COOLDOWN = timedelta(seconds=60)
18
+ PURGE_AFTER = timedelta(days=1)
19
+
20
+
21
+ class VerificationError(Exception):
22
+ def __init__(self, code: str, message: str):
23
+ super().__init__(message)
24
+ self.code = code
25
+ self.message = message
26
+
27
+
28
+ @dataclass
29
+ class IssuedCode:
30
+ code: str
31
+ record: EmailVerificationCode
32
+
33
+
34
+ def _hash(email: str, code: str) -> str:
35
+ return hmac.new(settings.jwt_secret.encode(), f"{email}:{code}".encode(), hashlib.sha256).hexdigest()
36
+
37
+
38
+ def _aware(dt: datetime) -> datetime:
39
+ return dt if dt.tzinfo else dt.replace(tzinfo=UTC)
40
+
41
+
42
+ def _latest(db: Session, email: str, purpose: VerificationPurpose) -> EmailVerificationCode | None:
43
+ return db.scalar(
44
+ select(EmailVerificationCode)
45
+ .where(
46
+ EmailVerificationCode.email == email,
47
+ EmailVerificationCode.purpose == purpose,
48
+ EmailVerificationCode.consumed_at.is_(None),
49
+ )
50
+ .order_by(EmailVerificationCode.created_at.desc())
51
+ .limit(1)
52
+ )
53
+
54
+
55
+ def issue_code(
56
+ db: Session,
57
+ email: str,
58
+ purpose: VerificationPurpose,
59
+ pending_name: str | None = None,
60
+ pending_password_hash: str | None = None,
61
+ ) -> IssuedCode:
62
+ now = datetime.now(UTC)
63
+ latest = _latest(db, email, purpose)
64
+ if latest and now - _aware(latest.created_at) < RESEND_COOLDOWN:
65
+ raise VerificationError("cooldown", "Please wait a minute before requesting another code.")
66
+ clear_codes(db, email, purpose)
67
+ code = f"{secrets.randbelow(1_000_000):06d}"
68
+ record = EmailVerificationCode(
69
+ email=email,
70
+ purpose=purpose,
71
+ code_hash=_hash(email, code),
72
+ pending_name=pending_name,
73
+ pending_password_hash=pending_password_hash,
74
+ expires_at=now + CODE_TTL,
75
+ )
76
+ db.add(record)
77
+ db.flush()
78
+ return IssuedCode(code=code, record=record)
79
+
80
+
81
+ def check_code(db: Session, email: str, purpose: VerificationPurpose, code: str) -> EmailVerificationCode:
82
+ """Validate without consuming (used by the password reset 'check' step)."""
83
+ record = _latest(db, email, purpose)
84
+ if not record:
85
+ raise VerificationError("not_found", "No active code. Request a new one.")
86
+ if datetime.now(UTC) > _aware(record.expires_at):
87
+ raise VerificationError("expired", "That code expired. Request a new one.")
88
+ if record.attempts >= MAX_ATTEMPTS:
89
+ raise VerificationError("too_many_attempts", "Too many attempts. Request a new code.")
90
+ if not hmac.compare_digest(record.code_hash, _hash(email, code.strip())):
91
+ record.attempts += 1
92
+ db.commit()
93
+ raise VerificationError("invalid", "That code is not right.")
94
+ return record
95
+
96
+
97
+ def consume_code(db: Session, email: str, purpose: VerificationPurpose, code: str) -> EmailVerificationCode:
98
+ record = check_code(db, email, purpose, code)
99
+ record.consumed_at = datetime.now(UTC)
100
+ return record
101
+
102
+
103
+ def clear_codes(db: Session, email: str, purpose: VerificationPurpose) -> None:
104
+ db.execute(
105
+ delete(EmailVerificationCode).where(
106
+ EmailVerificationCode.email == email, EmailVerificationCode.purpose == purpose
107
+ )
108
+ )
109
+
110
+
111
+ def purge_old_codes(db: Session) -> None:
112
+ db.execute(
113
+ delete(EmailVerificationCode).where(
114
+ EmailVerificationCode.created_at < datetime.now(UTC) - PURGE_AFTER
115
+ )
116
+ )
117
+ db.commit()
backend/app/services/jobs.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Job rows + progress fan-out over Redis pub/sub (consumed by the SSE endpoint)."""
2
+
3
+ import json
4
+ import uuid
5
+ from datetime import UTC, datetime
6
+
7
+ import redis
8
+ import redis.asyncio as aredis
9
+ from sqlalchemy.orm import Session
10
+
11
+ from app.config import settings
12
+ from app.models import Job, UsageEvent
13
+
14
+ _sync_redis: redis.Redis | None = None
15
+
16
+
17
+ def _redis() -> redis.Redis:
18
+ global _sync_redis
19
+ if _sync_redis is None:
20
+ _sync_redis = redis.Redis.from_url(settings.redis_url)
21
+ return _sync_redis
22
+
23
+
24
+ def channel(job_id: uuid.UUID | str) -> str:
25
+ return f"job:{job_id}"
26
+
27
+
28
+ def serialize_job(job: Job) -> dict:
29
+ return {
30
+ "id": str(job.id),
31
+ "kind": job.kind,
32
+ "status": job.status,
33
+ "progress": job.progress,
34
+ "message": job.message,
35
+ "error": job.error,
36
+ "result": job.result,
37
+ "artifact_id": str(job.artifact_id) if job.artifact_id else None,
38
+ "cost_usd": job.cost_usd,
39
+ "created_at": job.created_at.isoformat() if job.created_at else None,
40
+ "finished_at": job.finished_at.isoformat() if job.finished_at else None,
41
+ }
42
+
43
+
44
+ def create_job(db: Session, workspace_id: uuid.UUID, kind: str, params: dict | None = None) -> Job:
45
+ job = Job(workspace_id=workspace_id, kind=kind, params=params or {})
46
+ db.add(job)
47
+ db.commit()
48
+ return job
49
+
50
+
51
+ def update_job(
52
+ db: Session,
53
+ job: Job,
54
+ *,
55
+ status: str | None = None,
56
+ progress: float | None = None,
57
+ message: str | None = None,
58
+ error: str | None = None,
59
+ result: dict | None = None,
60
+ ) -> None:
61
+ if status:
62
+ job.status = status
63
+ if status in {"done", "failed"}:
64
+ job.finished_at = datetime.now(UTC)
65
+ if progress is not None:
66
+ job.progress = max(0.0, min(1.0, progress))
67
+ if message is not None:
68
+ job.message = message
69
+ if error is not None:
70
+ job.error = error
71
+ if result is not None:
72
+ job.result = result
73
+ db.commit()
74
+ try:
75
+ _redis().publish(channel(job.id), json.dumps(serialize_job(job)))
76
+ except redis.RedisError:
77
+ pass # progress is also readable from GET /jobs/{id}
78
+
79
+
80
+ async def subscribe(job_id: uuid.UUID):
81
+ client = aredis.Redis.from_url(settings.redis_url)
82
+ pubsub = client.pubsub()
83
+ await pubsub.subscribe(channel(job_id))
84
+ try:
85
+ async for msg in pubsub.listen():
86
+ if msg["type"] == "message":
87
+ yield msg["data"].decode()
88
+ finally:
89
+ await pubsub.unsubscribe(channel(job_id))
90
+ await client.aclose()
91
+
92
+
93
+ def record_usage(
94
+ db: Session,
95
+ *,
96
+ workspace_id: uuid.UUID,
97
+ kind: str,
98
+ provider: str,
99
+ quantity: float,
100
+ unit: str,
101
+ model: str | None = None,
102
+ cost_usd: float = 0.0,
103
+ job: Job | None = None,
104
+ ) -> None:
105
+ db.add(
106
+ UsageEvent(
107
+ workspace_id=workspace_id,
108
+ job_id=job.id if job else None,
109
+ kind=kind,
110
+ provider=provider,
111
+ model=model,
112
+ quantity=quantity,
113
+ unit=unit,
114
+ cost_usd=cost_usd,
115
+ )
116
+ )
117
+ if job:
118
+ job.cost_usd = (job.cost_usd or 0.0) + cost_usd
119
+ db.commit()
backend/app/services/plans.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Three tiers. While BILLING_ENABLED is false every workspace gets Studio limits for free."""
2
+
3
+ from dataclasses import asdict, dataclass
4
+
5
+ from sqlalchemy.orm import Session
6
+
7
+ from app.config import settings
8
+ from app.models import Subscription, Workspace
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Plan:
13
+ id: str
14
+ name: str
15
+ tagline: str
16
+ price_monthly_usd: int
17
+ sources: int
18
+ indexed_posts: int
19
+ audio_minutes: int
20
+ video_minutes: int
21
+ launch_kits: int # -1 = unlimited
22
+ voice_cloning: bool
23
+ brand_kit: bool
24
+ features: tuple[str, ...]
25
+
26
+
27
+ PLANS: dict[str, Plan] = {
28
+ "free": Plan(
29
+ id="free",
30
+ name="Free",
31
+ tagline="For trying Notestack on your archive",
32
+ price_monthly_usd=0,
33
+ sources=1,
34
+ indexed_posts=50,
35
+ audio_minutes=10,
36
+ video_minutes=3,
37
+ launch_kits=5,
38
+ voice_cloning=False,
39
+ brand_kit=False,
40
+ features=(
41
+ "1 source, 50 indexed posts",
42
+ "Grounded research chat with citations",
43
+ "10 min of audio overviews a month",
44
+ "5 Launch Kits a month",
45
+ ),
46
+ ),
47
+ "writer": Plan(
48
+ id="writer",
49
+ name="Writer",
50
+ tagline="For writers publishing every week",
51
+ price_monthly_usd=19,
52
+ sources=3,
53
+ indexed_posts=500,
54
+ audio_minutes=60,
55
+ video_minutes=30,
56
+ launch_kits=50,
57
+ voice_cloning=True,
58
+ brand_kit=True,
59
+ features=(
60
+ "3 sources, 500 indexed posts",
61
+ "60 min of audio overviews a month",
62
+ "30 min of video renders a month",
63
+ "50 Launch Kits a month",
64
+ "Voice cloning with consent",
65
+ "Your brand colors and logo",
66
+ ),
67
+ ),
68
+ "studio": Plan(
69
+ id="studio",
70
+ name="Studio",
71
+ tagline="For publications and power users",
72
+ price_monthly_usd=49,
73
+ sources=10,
74
+ indexed_posts=5000,
75
+ audio_minutes=240,
76
+ video_minutes=120,
77
+ launch_kits=-1,
78
+ voice_cloning=True,
79
+ brand_kit=True,
80
+ features=(
81
+ "10 sources, 5,000 indexed posts",
82
+ "240 min of audio overviews a month",
83
+ "120 min of video renders a month",
84
+ "Unlimited Launch Kits",
85
+ "Launchpad calendar and resurfacing",
86
+ "Priority rendering",
87
+ ),
88
+ ),
89
+ }
90
+
91
+
92
+ def plan_dict(plan: Plan) -> dict:
93
+ data = asdict(plan)
94
+ data["features"] = list(plan.features)
95
+ return data
96
+
97
+
98
+ def effective_plan(db: Session, workspace: Workspace) -> Plan:
99
+ if not settings.billing_enabled:
100
+ return PLANS["studio"]
101
+ sub = db.query(Subscription).filter_by(workspace_id=workspace.id).one_or_none()
102
+ if not sub or sub.status not in {"active", "trialing"}:
103
+ return PLANS["free"]
104
+ return PLANS.get(sub.plan, PLANS["free"])
backend/app/services/storage.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cloudflare R2 storage (S3 API). Postgres stores keys; bytes live here.
2
+
3
+ Locally, point R2_ENDPOINT_URL at MinIO and nothing else changes.
4
+ """
5
+
6
+ import hashlib
7
+ import mimetypes
8
+ import re
9
+ import uuid
10
+ from functools import cached_property
11
+
12
+ import boto3
13
+ from botocore.client import Config
14
+ from botocore.exceptions import ClientError
15
+
16
+ from app.config import settings
17
+
18
+
19
+ class keys: # noqa: N801 (namespace for key builders)
20
+ @staticmethod
21
+ def raw_html(workspace_id: uuid.UUID, source_id: uuid.UUID, document_id: uuid.UUID) -> str:
22
+ return f"ws/{workspace_id}/sources/{source_id}/documents/{document_id}/raw.html"
23
+
24
+ @staticmethod
25
+ def upload(workspace_id: uuid.UUID, upload_id: uuid.UUID, filename: str) -> str:
26
+ return f"ws/{workspace_id}/uploads/{upload_id}/{safe_filename(filename)}"
27
+
28
+ @staticmethod
29
+ def artifact(workspace_id: uuid.UUID, artifact_id: uuid.UUID, variant: str, ext: str) -> str:
30
+ return f"ws/{workspace_id}/artifacts/{artifact_id}/{variant}.{ext.lstrip('.')}"
31
+
32
+ @staticmethod
33
+ def tts_cache(workspace_id: uuid.UUID, script: str, voice_id: str, settings_json: str) -> str:
34
+ digest = hashlib.sha256(f"{script}|{voice_id}|{settings_json}".encode()).hexdigest()
35
+ return f"ws/{workspace_id}/tts-cache/{digest}.mp3"
36
+
37
+ @staticmethod
38
+ def dspy_program(module: str, version: str) -> str:
39
+ return f"system/dspy/{module}/{version}.json"
40
+
41
+
42
+ def safe_filename(name: str) -> str:
43
+ name = name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
44
+ name = re.sub(r"[^A-Za-z0-9._-]+", "-", name).strip("-.")
45
+ return name[:120] or "file"
46
+
47
+
48
+ def workspace_owns_key(workspace_id: uuid.UUID, key: str) -> bool:
49
+ return key.startswith(f"ws/{workspace_id}/") and ".." not in key
50
+
51
+
52
+ class Storage:
53
+ def __init__(self, bucket: str | None = None):
54
+ self.bucket = bucket or settings.r2_bucket
55
+
56
+ @cached_property
57
+ def client(self):
58
+ return boto3.client(
59
+ "s3",
60
+ endpoint_url=settings.r2_endpoint,
61
+ aws_access_key_id=settings.r2_access_key_id,
62
+ aws_secret_access_key=settings.r2_secret_access_key,
63
+ region_name="auto",
64
+ config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
65
+ )
66
+
67
+ def ensure_bucket(self) -> None:
68
+ """Local dev convenience (MinIO). On R2 create the bucket in the dashboard."""
69
+ try:
70
+ self.client.head_bucket(Bucket=self.bucket)
71
+ except ClientError:
72
+ self.client.create_bucket(Bucket=self.bucket)
73
+
74
+ def put_bytes(self, key: str, data: bytes, content_type: str | None = None) -> str:
75
+ content_type = content_type or mimetypes.guess_type(key)[0] or "application/octet-stream"
76
+ self.client.put_object(Bucket=self.bucket, Key=key, Body=data, ContentType=content_type)
77
+ return key
78
+
79
+ def put_text(self, key: str, text: str, content_type: str = "text/plain; charset=utf-8") -> str:
80
+ return self.put_bytes(key, text.encode("utf-8"), content_type)
81
+
82
+ def get_bytes(self, key: str) -> bytes:
83
+ return self.client.get_object(Bucket=self.bucket, Key=key)["Body"].read()
84
+
85
+ def head(self, key: str) -> dict | None:
86
+ try:
87
+ return self.client.head_object(Bucket=self.bucket, Key=key)
88
+ except ClientError as exc:
89
+ if exc.response.get("Error", {}).get("Code") in {"404", "NoSuchKey", "NotFound"}:
90
+ return None
91
+ raise
92
+
93
+ def exists(self, key: str) -> bool:
94
+ return self.head(key) is not None
95
+
96
+ def delete(self, key: str) -> None:
97
+ self.client.delete_object(Bucket=self.bucket, Key=key)
98
+
99
+ def delete_prefix(self, prefix: str) -> int:
100
+ deleted = 0
101
+ paginator = self.client.get_paginator("list_objects_v2")
102
+ for page in paginator.paginate(Bucket=self.bucket, Prefix=prefix):
103
+ objects = [{"Key": o["Key"]} for o in page.get("Contents", [])]
104
+ if objects:
105
+ self.client.delete_objects(Bucket=self.bucket, Delete={"Objects": objects})
106
+ deleted += len(objects)
107
+ return deleted
108
+
109
+ def presign_get(self, key: str, ttl: int | None = None, download_name: str | None = None) -> str:
110
+ params = {"Bucket": self.bucket, "Key": key}
111
+ if download_name:
112
+ params["ResponseContentDisposition"] = f'attachment; filename="{safe_filename(download_name)}"'
113
+ return self.client.generate_presigned_url(
114
+ "get_object", Params=params, ExpiresIn=ttl or settings.r2_presign_ttl_seconds
115
+ )
116
+
117
+ def presign_put(self, key: str, content_type: str, ttl: int | None = None) -> str:
118
+ return self.client.generate_presigned_url(
119
+ "put_object",
120
+ Params={"Bucket": self.bucket, "Key": key, "ContentType": content_type},
121
+ ExpiresIn=ttl or settings.r2_presign_ttl_seconds,
122
+ )
123
+
124
+ def public_url(self, key: str) -> str | None:
125
+ if not settings.r2_public_base_url:
126
+ return None
127
+ return f"{settings.r2_public_base_url.rstrip('/')}/{key}"
128
+
129
+
130
+ storage = Storage()
backend/app/worker.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """arq worker: long running jobs + scheduled email batches.
2
+
3
+ Run: arq app.worker.WorkerSettings
4
+ """
5
+
6
+ import logging
7
+ import uuid
8
+ from datetime import UTC, datetime
9
+
10
+ import httpx
11
+ from arq import cron
12
+ from arq.connections import RedisSettings
13
+ from sqlalchemy import select
14
+
15
+ from app.config import settings
16
+ from app.db import SessionLocal
17
+ from app.models import (
18
+ Artifact,
19
+ Job,
20
+ Source,
21
+ Subscription,
22
+ UpdateEmail,
23
+ UpdateEmailSend,
24
+ User,
25
+ Workspace,
26
+ WorkspaceMember,
27
+ )
28
+ from app.pipeline.ingest import ingest_source
29
+ from app.services.email import email_service
30
+ from app.services.email_verification import purge_old_codes
31
+ from app.services.jobs import update_job
32
+ from app.services.plans import effective_plan
33
+ from app.services.storage import keys, storage
34
+
35
+ log = logging.getLogger(__name__)
36
+
37
+
38
+ async def ingest_source_task(ctx, source_id: str, job_id: str) -> dict:
39
+ with SessionLocal() as db:
40
+ job = db.get(Job, uuid.UUID(job_id))
41
+ source = db.get(Source, uuid.UUID(source_id))
42
+ if not job or not source:
43
+ return {"error": "missing"}
44
+ try:
45
+ plan = effective_plan(db, db.get(Workspace, source.workspace_id))
46
+ result = ingest_source(db, source, job, max_posts=min(plan.indexed_posts, 200))
47
+ update_job(db, job, status="done", progress=1.0, message="All posts in orbit", result=result)
48
+ return result
49
+ except Exception as exc:
50
+ log.exception("ingest failed source=%s", source_id)
51
+ db.rollback()
52
+ source.sync_status = "error"
53
+ source.sync_error = str(exc)[:500]
54
+ update_job(db, job, status="failed", error=str(exc)[:1000], message="Ingestion failed")
55
+ return {"error": str(exc)}
56
+
57
+
58
+ async def render_task(ctx, artifact_id: str, job_id: str, composition: str, props: dict, fmt: str) -> dict:
59
+ """Hand off to the Remotion renderer with a presigned PUT so it never holds R2 credentials."""
60
+ with SessionLocal() as db:
61
+ job = db.get(Job, uuid.UUID(job_id))
62
+ artifact = db.get(Artifact, uuid.UUID(artifact_id))
63
+ ext = "png" if fmt == "png" else "mp4"
64
+ key = keys.artifact(artifact.workspace_id, artifact.id, composition.lower(), ext)
65
+ content_type = "image/png" if ext == "png" else "video/mp4"
66
+ update_job(db, job, status="running", progress=0.01, message="T-minus: preparing render")
67
+ async with httpx.AsyncClient(timeout=30) as client:
68
+ resp = await client.post(
69
+ f"{settings.renderer_url}/render",
70
+ headers={"x-internal-token": settings.internal_token},
71
+ json={
72
+ "jobId": job_id,
73
+ "compositionId": composition,
74
+ "props": props,
75
+ "format": fmt,
76
+ "uploadUrl": storage.presign_put(key, content_type, ttl=6 * 3600),
77
+ "uploadContentType": content_type,
78
+ "callbackUrl": f"{settings.api_url}/api/internal/jobs/{job_id}/progress",
79
+ "storageKey": key,
80
+ },
81
+ )
82
+ resp.raise_for_status()
83
+ return {"accepted": True, "key": key}
84
+
85
+
86
+ async def update_email_batch(ctx) -> None:
87
+ """Hourly check; sends one daily batch per scheduled campaign at its send hour (UTC)."""
88
+ now = datetime.now(UTC)
89
+ with SessionLocal() as db:
90
+ campaigns = db.scalars(select(UpdateEmail).where(UpdateEmail.status.in_(["scheduled", "running"]))).all()
91
+ for campaign in campaigns:
92
+ hour = campaign.send_hour if campaign.send_hour >= 0 else settings.update_email_send_hour
93
+ if now.hour != hour:
94
+ continue
95
+ if campaign.last_batch_at and campaign.last_batch_at.date() == now.date():
96
+ continue
97
+ already = select(UpdateEmailSend.user_id).where(UpdateEmailSend.update_email_id == campaign.id)
98
+ q = select(User).where(
99
+ User.is_active.is_(True), User.email_unsubscribed.is_(False), User.id.not_in(already)
100
+ )
101
+ if campaign.user_filter != "all":
102
+ plans = {"paid": ["writer", "studio"]}.get(campaign.user_filter, [campaign.user_filter])
103
+ q = (
104
+ q.join(WorkspaceMember, WorkspaceMember.user_id == User.id)
105
+ .join(Subscription, Subscription.workspace_id == WorkspaceMember.workspace_id)
106
+ .where(Subscription.plan.in_(plans))
107
+ )
108
+ users = db.scalars(q.limit(campaign.batch_size)).unique().all()
109
+ campaign.status = "running"
110
+ for user in users:
111
+ ok = email_service.send_blast_email(
112
+ str(user.id), user.email, user.name, campaign.subject, campaign.body
113
+ )
114
+ db.add(UpdateEmailSend(update_email_id=campaign.id, user_id=user.id, status="sent" if ok else "failed"))
115
+ campaign.sent_count += int(ok)
116
+ campaign.failed_count += int(not ok)
117
+ campaign.total_users = campaign.sent_count + campaign.failed_count
118
+ campaign.last_batch_at = now
119
+ if len(users) < campaign.batch_size:
120
+ campaign.status = "completed"
121
+ db.commit()
122
+
123
+
124
+ async def daily_cleanup(ctx) -> None:
125
+ with SessionLocal() as db:
126
+ purge_old_codes(db)
127
+
128
+
129
+ async def startup(ctx) -> None:
130
+ logging.basicConfig(level=logging.INFO)
131
+
132
+
133
+ class WorkerSettings:
134
+ functions = [ingest_source_task, render_task]
135
+ cron_jobs = [cron(update_email_batch, minute=5), cron(daily_cleanup, hour=3, minute=17)]
136
+ redis_settings = RedisSettings.from_dsn(settings.redis_url)
137
+ on_startup = startup
138
+ max_jobs = 10
139
+ job_timeout = 60 * 30
backend/pyproject.toml ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ [tool.ruff]
2
+ line-length = 120
3
+ target-version = "py312"
4
+
5
+ [tool.ruff.lint]
6
+ select = ["E", "F", "I", "B", "UP"]
7
+ ignore = ["B008"]
8
+
9
+ [tool.pytest.ini_options]
10
+ testpaths = ["tests"]
backend/requirements-dev.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ -r requirements.txt
2
+ pytest==8.*
3
+ ruff==0.8.*
backend/requirements.txt ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.115.*
2
+ uvicorn[standard]==0.32.*
3
+ sqlalchemy==2.0.*
4
+ psycopg[binary]==3.2.*
5
+ alembic==1.14.*
6
+ pgvector==0.3.*
7
+ pydantic==2.*
8
+ pydantic-settings==2.*
9
+ email-validator==2.*
10
+ pyjwt==2.*
11
+ bcrypt==4.*
12
+ google-auth==2.*
13
+ requests==2.*
14
+ boto3==1.35.*
15
+ resend==2.*
16
+ httpx==0.28.*
17
+ arq==0.26.*
18
+ redis==5.*
19
+ dspy==2.6.*
20
+ feedparser==6.*
21
+ beautifulsoup4==4.*
22
+ tiktoken==0.8.*
backend/tests/__init__.py ADDED
File without changes
backend/tests/conftest.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ os.environ.setdefault("DATABASE_URL", "sqlite://")
4
+ os.environ.setdefault("EMAIL_PROVIDER", "console")
5
+ os.environ.setdefault("JWT_SECRET", "test-secret-that-is-at-least-32-bytes-long")
6
+ os.environ.setdefault("BILLING_ENABLED", "false")
7
+
8
+ import pytest # noqa: E402
9
+ from fastapi.testclient import TestClient # noqa: E402
10
+ from sqlalchemy import create_engine # noqa: E402
11
+ from sqlalchemy.orm import sessionmaker # noqa: E402
12
+ from sqlalchemy.pool import StaticPool # noqa: E402
13
+
14
+ import app.models # noqa: E402,F401
15
+ from app.db import Base, get_db # noqa: E402
16
+ from app.main import app # noqa: E402
17
+ from app.services.email import ConsoleEmailProvider # noqa: E402
18
+
19
+
20
+ @pytest.fixture()
21
+ def db_session():
22
+ engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
23
+ Base.metadata.create_all(engine)
24
+ Session = sessionmaker(bind=engine, expire_on_commit=False)
25
+ session = Session()
26
+ yield session
27
+ session.close()
28
+
29
+
30
+ @pytest.fixture()
31
+ def client(db_session):
32
+ app.dependency_overrides[get_db] = lambda: db_session
33
+ ConsoleEmailProvider.sent.clear()
34
+ yield TestClient(app) # no context manager: skips lifespan (no Redis needed)
35
+ app.dependency_overrides.clear()
36
+
37
+
38
+ def last_code() -> str:
39
+ import re
40
+
41
+ return re.search(r"\b(\d{6})\b", ConsoleEmailProvider.sent[-1].text).group(1)
backend/tests/test_auth.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.services.email import ConsoleEmailProvider
2
+ from tests.conftest import last_code
3
+
4
+
5
+ def _signup(client, email="writer@example.com", password="stardust-42"):
6
+ r = client.post("/api/auth/email/register/start", json={"email": email, "password": password, "name": "Ada L"})
7
+ assert r.status_code == 200, r.text
8
+ r = client.post("/api/auth/email/register/verify", json={"email": email, "code": last_code()})
9
+ assert r.status_code == 200, r.text
10
+ return r.json()
11
+
12
+
13
+ def test_signup_login_me_logout(client):
14
+ data = _signup(client)
15
+ assert data["created"] is True
16
+ assert any(e.subject == "Welcome to Notestack" for e in ConsoleEmailProvider.sent)
17
+ headers = {"Authorization": f"Bearer {data['access_token']}"}
18
+ assert client.get("/api/auth/me", headers=headers).json()["email"] == "writer@example.com"
19
+
20
+ r = client.post("/api/auth/email/login", json={"email": "WRITER@example.com", "password": "stardust-42"})
21
+ assert r.status_code == 200
22
+
23
+ assert client.post("/api/auth/logout", headers=headers).status_code == 200
24
+ assert client.get("/api/auth/me", headers=headers).status_code == 401 # token_version bumped
25
+
26
+
27
+ def test_wrong_code_and_attempt_limit(client):
28
+ client.post("/api/auth/email/register/start", json={"email": "a@b.co", "password": "longenough"})
29
+ for _ in range(5):
30
+ r = client.post("/api/auth/email/register/verify", json={"email": "a@b.co", "code": "000000"})
31
+ if last_code() == "000000":
32
+ return # astronomically unlikely collision
33
+ assert r.status_code == 400
34
+ r = client.post("/api/auth/email/register/verify", json={"email": "a@b.co", "code": last_code()})
35
+ assert r.json()["detail"]["code"] == "too_many_attempts"
36
+
37
+
38
+ def test_resend_cooldown(client):
39
+ client.post("/api/auth/email/register/start", json={"email": "c@d.co", "password": "longenough"})
40
+ r = client.post("/api/auth/email/register/resend", json={"email": "c@d.co"})
41
+ assert r.status_code == 429
42
+
43
+
44
+ def test_bad_password_and_weak_password(client):
45
+ _signup(client)
46
+ r = client.post("/api/auth/email/login", json={"email": "writer@example.com", "password": "nope-nope"})
47
+ assert r.status_code == 401
48
+ r = client.post("/api/auth/email/register/start", json={"email": "x@y.co", "password": "short"})
49
+ assert r.status_code == 400
50
+
51
+
52
+ def test_password_reset_revokes_old_tokens(client):
53
+ data = _signup(client)
54
+ old = {"Authorization": f"Bearer {data['access_token']}"}
55
+ client.post("/api/auth/password/forgot/start", json={"email": "writer@example.com"})
56
+ code = last_code()
57
+ r = client.post("/api/auth/password/forgot/check", json={"email": "writer@example.com", "code": code})
58
+ assert r.status_code == 200
59
+ r = client.post("/api/auth/password/forgot/complete",
60
+ json={"email": "writer@example.com", "code": code, "password": "new-orbit-99"})
61
+ assert r.status_code == 200
62
+ assert client.get("/api/auth/me", headers=old).status_code == 401
63
+
64
+
65
+ def test_google_user_cannot_use_password(client, db_session):
66
+ from app.services.auth_identity import resolve_or_create_google_user
67
+
68
+ resolve_or_create_google_user(db_session, email="g@example.com", google_id="123", name="G", avatar_url=None)
69
+ db_session.commit()
70
+ r = client.post("/api/auth/email/register/start", json={"email": "g@example.com", "password": "longenough"})
71
+ assert r.status_code == 409
72
+ assert r.json()["detail"]["code"] == "wrong_provider"
73
+
74
+
75
+ def test_delete_account(client):
76
+ data = _signup(client)
77
+ headers = {"Authorization": f"Bearer {data['access_token']}"}
78
+ assert client.post("/api/auth/delete-account", headers=headers).status_code == 200
79
+ r = client.post("/api/auth/email/login", json={"email": "writer@example.com", "password": "stardust-42"})
80
+ assert r.status_code == 403