Spaces:
Runtime error
Runtime error
Commit ·
00a4363
0
Parent(s):
added elevenlabs grant
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +49 -0
- .github/workflows/ci.yml +35 -0
- .gitignore +10 -0
- README.md +70 -0
- backend/Dockerfile +7 -0
- backend/alembic.ini +28 -0
- backend/alembic/env.py +35 -0
- backend/alembic/script.py.mako +22 -0
- backend/alembic/versions/0001_initial.py +35 -0
- backend/app/__init__.py +0 -0
- backend/app/auth.py +87 -0
- backend/app/config.py +87 -0
- backend/app/db.py +23 -0
- backend/app/llm/__init__.py +0 -0
- backend/app/llm/embeddings.py +46 -0
- backend/app/llm/postprocess.py +23 -0
- backend/app/llm/provider.py +87 -0
- backend/app/llm/signatures.py +141 -0
- backend/app/main.py +62 -0
- backend/app/models/__init__.py +52 -0
- backend/app/models/base.py +18 -0
- backend/app/models/content.py +249 -0
- backend/app/models/email.py +58 -0
- backend/app/models/user.py +72 -0
- backend/app/pipeline/__init__.py +0 -0
- backend/app/pipeline/ingest.py +168 -0
- backend/app/pipeline/retrieval.py +62 -0
- backend/app/routers/__init__.py +0 -0
- backend/app/routers/artifacts.py +77 -0
- backend/app/routers/auth.py +244 -0
- backend/app/routers/billing.py +32 -0
- backend/app/routers/jobs.py +83 -0
- backend/app/routers/notebooks.py +153 -0
- backend/app/routers/sources.py +97 -0
- backend/app/routers/storage.py +91 -0
- backend/app/routers/unsubscribe.py +44 -0
- backend/app/services/__init__.py +0 -0
- backend/app/services/auth_identity.py +122 -0
- backend/app/services/email.py +214 -0
- backend/app/services/email_verification.py +117 -0
- backend/app/services/jobs.py +119 -0
- backend/app/services/plans.py +104 -0
- backend/app/services/storage.py +130 -0
- backend/app/worker.py +139 -0
- backend/pyproject.toml +10 -0
- backend/requirements-dev.txt +3 -0
- backend/requirements.txt +22 -0
- backend/tests/__init__.py +0 -0
- backend/tests/conftest.py +41 -0
- 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
|