Humera Raheel commited on
Commit
055e18c
·
2 Parent(s): 4c105cb393f0ea

Merge pull request #9 from FireBird-Technologies/develop

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +0 -131
  2. .github/workflows/ci.yml +2 -2
  3. .gitignore +4 -0
  4. Dockerfile +25 -0
  5. README.md +27 -7
  6. backend/.env.example +34 -0
  7. backend/0 +1 -0
  8. backend/Dockerfile +3 -0
  9. backend/alembic/versions/0005_video_quota.py +48 -0
  10. backend/alembic/versions/0006_plans.py +74 -0
  11. backend/alembic/versions/0007_subscription_plan_fk.py +36 -0
  12. backend/alembic/versions/0008_b2v_videos.py +40 -0
  13. backend/alembic/versions/0009_b2v_resources.py +111 -0
  14. backend/alembic/versions/0010_b2v_video_refs.py +96 -0
  15. backend/alembic/versions/0011_one_archive_notebook.py +48 -0
  16. backend/alembic/versions/0012_artifact_status_len.py +26 -0
  17. backend/alembic/versions/0013_workspace_memory.py +32 -0
  18. backend/alembic/versions/0014_chat_citations.py +30 -0
  19. backend/alembic/versions/0015_message_feedback.py +48 -0
  20. backend/alembic/versions/0016_support_chat.py +50 -0
  21. backend/alembic/versions/0017_reports_limit.py +26 -0
  22. backend/alembic/versions/0018_report_sharing.py +41 -0
  23. backend/alembic/versions/0019_infographics_limit.py +26 -0
  24. backend/alembic/versions/0020_calendar_kit.py +35 -0
  25. backend/alembic/versions/0021_artifact_feedback.py +40 -0
  26. backend/alembic/versions/0022_report_template_cache.py +34 -0
  27. backend/alembic/versions/0023_drop_brand_kit.py +31 -0
  28. backend/alembic/versions/0024_plan_limits.py +49 -0
  29. backend/alembic/versions/0025_drop_video_premium.py +24 -0
  30. backend/alembic/versions/0026_plan_copy.py +40 -0
  31. backend/app/chat_memory/__init__.py +5 -0
  32. backend/app/chat_memory/context.py +260 -0
  33. backend/app/chat_memory/files.py +237 -0
  34. backend/app/chat_memory/logs.py +19 -0
  35. backend/app/chat_memory/organize.py +528 -0
  36. backend/app/chat_memory/search.py +115 -0
  37. backend/app/chat_memory/tools.py +136 -0
  38. backend/app/config.py +68 -2
  39. backend/app/corpus.py +9 -5
  40. backend/app/db.py +12 -2
  41. backend/app/infographics/__init__.py +0 -0
  42. backend/app/infographics/content.py +108 -0
  43. backend/app/infographics/design.py +213 -0
  44. backend/app/infographics/image.py +111 -0
  45. backend/app/infographics/render.py +53 -0
  46. backend/app/infographics/templates/base.html.j2 +65 -0
  47. backend/app/infographics/templates/design.css +126 -0
  48. backend/app/infographics/templates/design.html.j2 +22 -0
  49. backend/app/infographics/templates/galaxy.html.j2 +46 -0
  50. backend/app/infographics/templates/launch.html.j2 +58 -0
.env.example DELETED
@@ -1,131 +0,0 @@
1
- # ---- App
2
- ENV=development
3
- APP_NAME=Notestack
4
- FRONTEND_URL=http://localhost:5173
5
- API_URL=http://localhost:8000
6
- CORS_ORIGINS=http://localhost:5173
7
- INTERNAL_TOKEN=replace-with-random-token
8
- BILLING_ENABLED=false
9
- # Stripe. Checkout, the customer portal and webhooks. Prices are found or created by lookup key
10
- # (notestack_<plan>_<cycle>) from backend/app/services/plans.py; set STRIPE_PRICE_* to pin existing ones.
11
- # Webhook endpoint: {API_URL}/api/billing/webhook with events checkout.session.completed,
12
- # checkout.session.async_payment_succeeded, checkout.session.async_payment_failed,
13
- # customer.subscription.created, customer.subscription.updated, customer.subscription.deleted,
14
- # customer.subscription.paused, customer.subscription.resumed, invoice.paid, invoice.payment_failed,
15
- # invoice.payment_action_required, charge.dispute.created.
16
- STRIPE_SECRET_KEY=
17
- STRIPE_WEBHOOK_SECRET=
18
- STRIPE_PRICE_WRITER_MONTHLY=
19
- STRIPE_PRICE_WRITER_ANNUAL=
20
- STRIPE_PRICE_STUDIO_MONTHLY=
21
- STRIPE_PRICE_STUDIO_ANNUAL=
22
-
23
- # ---- Auth (JWT). Revocation bumps users.token_version, so tokens can be long lived.
24
- JWT_SECRET=replace-with-64-random-chars
25
- JWT_ALGORITHM=HS256
26
- JWT_EXPIRATION_HOURS=72
27
- JWT_REFRESH_EXPIRATION_DAYS=30
28
-
29
- # ---- Database (also backs the job queue)
30
- # Local: a SQLite file, created by `alembic upgrade head` run from backend/.
31
- # Prod (Neon, Supabase, RDS...): Postgres with the psycopg driver, e.g.
32
- # DATABASE_URL=postgresql+psycopg://user:pass@host:5432/dbname?sslmode=require
33
- # docker-compose ignores this and points at its own Postgres container.
34
- DATABASE_URL=sqlite:///./notestack.db
35
-
36
- # ---- Google sign in (OAuth client, type Web; add http://localhost:5173 to authorized JS origins)
37
- # Client ID only: popup sign in (Google Identity Services), no redirect URIs needed.
38
- # Client ID + secret: redirect flow with server side code exchange. Add the redirect URI
39
- # (default {API_URL}/api/auth/google/callback, e.g. http://localhost:8000/api/auth/google/callback)
40
- # to "Authorized redirect URIs" on the OAuth client. Never prefix the secret with VITE_.
41
- GOOGLE_CLIENT_ID=
42
- GOOGLE_CLIENT_SECRET=
43
- GOOGLE_REDIRECT_URI=
44
-
45
- # ---- Email (Resend). EMAIL_PROVIDER=console prints emails to the API log instead.
46
- EMAIL_PROVIDER=console
47
- RESEND_API_KEY=
48
- FROM_EMAIL=Notestack <hello@notestack.ai>
49
- ALERTS_EMAIL=arslan@firebird-technologies.com
50
- UNSUBSCRIBE_SECRET=replace-me
51
- # Hour (UTC, 0 to 23) at which scheduled update email campaigns send their daily batch.
52
- UPDATE_EMAIL_SEND_HOUR=9
53
-
54
- # ---- Local dev conveniences
55
- # The API runs the job worker in process when ENV=development (set false to use python -m app.worker).
56
- RUN_WORKER_IN_API=
57
- # auto = local disk (LOCAL_STORAGE_DIR) when no R2 account or endpoint is set, else R2
58
- STORAGE_BACKEND=auto
59
- LOCAL_STORAGE_DIR=.storage
60
-
61
- # ---- Storage: Cloudflare R2
62
- # Prod: set account id + keys, leave R2_ENDPOINT_URL empty.
63
- # Local: docker-compose points R2_ENDPOINT_URL at MinIO (keys minioadmin/minioadmin).
64
- R2_ACCOUNT_ID=
65
- R2_ACCESS_KEY_ID=minioadmin
66
- R2_SECRET_ACCESS_KEY=minioadmin
67
- R2_BUCKET=notestack-assets
68
- R2_ENDPOINT_URL=
69
- R2_PUBLIC_BASE_URL=
70
- # Folder inside the bucket when it is shared with other apps, e.g. notestack. Empty = bucket root.
71
- R2_PREFIX=
72
- # How long presigned download links stay valid, in seconds.
73
- R2_PRESIGN_TTL_SECONDS=3600
74
- # Largest file a browser may upload (bytes). 209715200 = 200 MB.
75
- MAX_UPLOAD_BYTES=209715200
76
-
77
- # ---- LLM (any LiteLLM model string). Defaults target Z.ai GLM-5.3 via its OpenAI compatible API.
78
- # GLM-5.x always reasons; LLM_REASONING_EFFORT (low | high | max) trades cost for depth.
79
- # Cheaper: LLM_MODEL=openai/glm-5.3-flash. Free for local dev: openai/glm-4.7-flash
80
- LLM_MODEL=openai/glm-5.3
81
- LLM_FAST_MODEL=openai/glm-5.3-flash
82
- LLM_API_BASE=https://api.z.ai/api/paas/v4
83
- LLM_API_KEY=
84
- LLM_TEMPERATURE=1.0
85
- LLM_MAX_TOKENS=16000
86
- LLM_REASONING_EFFORT=low
87
- # Fallback chat triage model when Jev is not configured. Empty = LLM_FAST_MODEL.
88
- LLM_TRIAGE_MODEL=
89
-
90
- # ---- Chat triage: TypeSafe Jev (https://typesafe.ai)
91
- # A fast classifier (typed choices, no text generation) that decides what each chat message needs
92
- # before any research runs. With a key set, greetings, off topic and app questions never reach the
93
- # LLM, and archive questions get a quick or deep research budget. Empty = LLM triage above.
94
- TYPESAFE_API_KEY=
95
- TYPESAFE_MODEL=jev-latest
96
- TYPESAFE_URL=https://api.typesafe.ai
97
-
98
- # Firecrawl (firecrawl.dev): lets Connect read sites with no RSS feed, including JavaScript sites
99
- # (Next.js, Framer, Webflow), and makes single URL imports reliable. Empty = plain HTTP fetching only.
100
- FIRECRAWL_API_KEY=
101
-
102
- # ---- Research corpus (posts as markdown files; the agent greps and reads them, no vector index)
103
- CORPUS_CACHE_DIR=.corpus-cache
104
- RESEARCH_MAX_STEPS=10
105
-
106
- # ---- Worker (jobs queue lives in the database; no Redis)
107
- WORKER_CONCURRENCY=4
108
- WORKER_POLL_SECONDS=1
109
- JOB_STALE_SECONDS=900
110
-
111
- # ---- ElevenLabs (audio overviews, video narration, voice cloning)
112
- ELEVENLABS_API_KEY=
113
- ELEVENLABS_MODEL=eleven_multilingual_v2
114
- ELEVENLABS_VOICE_A=EXAVITQu4vr4xnSDxMaL
115
- ELEVENLABS_VOICE_B=JBFqnCBsd6RMkjVDRZzb
116
-
117
- # ---- Renderer (Remotion service for videos, quote cards and carousels: cd renderer && npm start)
118
- RENDERER_URL=http://localhost:3100
119
-
120
- # ---- Launchpad social publishing
121
- # Bluesky needs nothing here (users connect with an app password).
122
- # X: developer app with OAuth 2.0, type Web App, scopes tweet.read tweet.write users.read offline.access.
123
- # Callback URI: {API_URL}/api/social/x/callback e.g. http://localhost:8000/api/social/x/callback
124
- # LinkedIn: app with the "Sign In with LinkedIn using OpenID Connect" and "Share on LinkedIn" products.
125
- # Redirect URL: {API_URL}/api/social/linkedin/callback
126
- X_CLIENT_ID=
127
- X_CLIENT_SECRET=
128
- LINKEDIN_CLIENT_ID=
129
- LINKEDIN_CLIENT_SECRET=
130
- # Fernet key for stored social tokens; derived from JWT_SECRET when empty.
131
- SOCIAL_TOKEN_KEY=
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.github/workflows/ci.yml CHANGED
@@ -17,9 +17,9 @@ jobs:
17
  - uses: actions/checkout@v4
18
  - uses: actions/setup-python@v5
19
  with: { python-version: "3.12", cache: pip }
20
- - run: pip install -r requirements-dev.txt
21
  - run: ruff check app tests
22
- - run: pytest -q
23
 
24
  frontend:
25
  runs-on: ubuntu-latest
 
17
  - uses: actions/checkout@v4
18
  - uses: actions/setup-python@v5
19
  with: { python-version: "3.12", cache: pip }
20
+ - run: pip install -r requirements.txt
21
  - run: ruff check app tests
22
+ # - run: pytest -q
23
 
24
  frontend:
25
  runs-on: ubuntu-latest
.gitignore CHANGED
@@ -20,3 +20,7 @@ renderer/build/
20
 
21
  # Local object storage (used when no R2 is configured)
22
  .storage/
 
 
 
 
 
20
 
21
  # Local object storage (used when no R2 is configured)
22
  .storage/
23
+
24
+ # Local-only notes, not for the repo
25
+ docs-local/
26
+ docs/
Dockerfile ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face Spaces image (the Space builds this root Dockerfile from the whole repo).
2
+ # One container, non-root uid 1000, port 7860; the worker runs inside the API process.
3
+ # Local docker-compose builds backend/Dockerfile instead.
4
+ FROM python:3.12-slim
5
+
6
+ RUN useradd -m -u 1000 user
7
+ WORKDIR /app
8
+ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 \
9
+ ENV=production \
10
+ RUN_WORKER_IN_API=true \
11
+ CORPUS_CACHE_DIR=/app/.corpus-cache \
12
+ LOCAL_STORAGE_DIR=/app/.storage
13
+
14
+ # Chromium draws an infographic's PNG for its Download button (and the fonts the pages use)
15
+ RUN apt-get update && apt-get install -y --no-install-recommends chromium fonts-liberation \
16
+ && rm -rf /var/lib/apt/lists/*
17
+
18
+ COPY backend/requirements.txt .
19
+ RUN pip install --no-cache-dir -r requirements.txt
20
+ COPY --chown=user backend/ .
21
+ RUN rm -f .env
22
+ USER user
23
+
24
+ EXPOSE 7860
25
+ CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 7860 --proxy-headers --forwarded-allow-ips='*'"]
README.md CHANGED
@@ -1,3 +1,13 @@
 
 
 
 
 
 
 
 
 
 
1
  <p align="center">
2
  <picture>
3
  <source media="(prefers-color-scheme: dark)" srcset="marketing/logo/notestack-logo.svg">
@@ -39,10 +49,10 @@ archive, stays in sync with it, cites the exact lines behind every answer, and t
39
  | Citations | Exact line ranges, verified before you see them | Passage level citations |
40
  | Not in your sources | Says so; unverifiable claims are dropped | Grounded, passage citations |
41
  | Audio | Two hosts on ElevenLabs voices, or your consented voice clone | Two host overviews, Google voices |
42
- | Video | 9:16 shorts, 16:9 explainers, 1:1 audiograms, quote cards, in your brand | Video overviews |
43
  | Publishing | Launch Kits (threads, LinkedIn, Notes, Bluesky, SEO, carousels) and auto posting | Not a publishing tool |
44
  | Your voice | Voice profile learned from your posts, used in every draft | General purpose writing |
45
- | Seeing your archive | Topic constellation with rising and dormant topics, evergreen resurfacing | Mind maps per notebook |
46
 
47
  The full comparison lives at [notestack.ai/notebooklm-alternative](https://notestack.ai/notebooklm-alternative).
48
 
@@ -71,7 +81,7 @@ docker compose up --build
71
  Without Docker (one API process does everything):
72
 
73
  ```bash
74
- cd backend && python -m venv .venv && .venv/Scripts/pip install -r requirements-dev.txt
75
  alembic upgrade head && uvicorn app.main:app --reload # SQLite at backend/notestack.db
76
  cd frontend && npm install && npm run dev
77
  cd renderer && npm install && npm start # only needed for videos, quote cards, carousels
@@ -83,9 +93,19 @@ cd renderer && npm install && npm start # only needed for vid
83
  - With no R2 settings, files live on local disk under `backend/.storage` and are served through
84
  signed `/api/storage/local/...` URLs (range requests supported for audio and video seeking).
85
  - Audio needs `ELEVENLABS_API_KEY`; everything text based needs `LLM_API_KEY`.
86
- - Launchpad: Bluesky connects with an app password. X and LinkedIn need developer apps; the
87
- redirect URIs are listed in `.env.example` and on the Launchpad page. Substack Notes has no API,
88
- so those posts (and any post without a connected account) arrive as an email reminder at send time.
 
 
 
 
 
 
 
 
 
 
89
 
90
  ## What is in the app
91
 
@@ -101,7 +121,7 @@ cd renderer && npm install && npm start # only needed for vid
101
  | Launchpad | Calendar and agenda, auto posting to X, LinkedIn and Bluesky, email reminders, tracked links, engagement |
102
  | Archive | Every generated artifact with filters, players, downloads, retry and delete |
103
  | Resurfacing | Evergreen scores and reshare angles for old posts, on this day, straight into a kit or the calendar |
104
- | Settings | Profile, workspace, brand kit, privacy, plan usage meters, connections, sign out everywhere, delete |
105
 
106
  ## Key choices
107
 
 
1
+ ---
2
+ title: NoteStack Test
3
+ emoji: 📚
4
+ colorFrom: indigo
5
+ colorTo: purple
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
  <p align="center">
12
  <picture>
13
  <source media="(prefers-color-scheme: dark)" srcset="marketing/logo/notestack-logo.svg">
 
49
  | Citations | Exact line ranges, verified before you see them | Passage level citations |
50
  | Not in your sources | Says so; unverifiable claims are dropped | Grounded, passage citations |
51
  | Audio | Two hosts on ElevenLabs voices, or your consented voice clone | Two host overviews, Google voices |
52
+ | Video | 9:16 shorts, 16:9 explainers, 1:1 audiograms, quote cards | Video overviews |
53
  | Publishing | Launch Kits (threads, LinkedIn, Notes, Bluesky, SEO, carousels) and auto posting | Not a publishing tool |
54
  | Your voice | Voice profile learned from your posts, used in every draft | General purpose writing |
55
+ | Seeing your archive | Topic constellation with rising and dormant topics, evergreen resurfacing | Idea Constellations per notebook |
56
 
57
  The full comparison lives at [notestack.ai/notebooklm-alternative](https://notestack.ai/notebooklm-alternative).
58
 
 
81
  Without Docker (one API process does everything):
82
 
83
  ```bash
84
+ cd backend && python -m venv .venv && .venv/Scripts/pip install -r requirements.txt
85
  alembic upgrade head && uvicorn app.main:app --reload # SQLite at backend/notestack.db
86
  cd frontend && npm install && npm run dev
87
  cd renderer && npm install && npm start # only needed for videos, quote cards, carousels
 
93
  - With no R2 settings, files live on local disk under `backend/.storage` and are served through
94
  signed `/api/storage/local/...` URLs (range requests supported for audio and video seeking).
95
  - Audio needs `ELEVENLABS_API_KEY`; everything text based needs `LLM_API_KEY`.
96
+ - Launchpad: posts (text, images, videos from your Library; not audio) go to X and LinkedIn at their
97
+ scheduled time through a `publish_post` job. Set up the developer apps and keys as described in
98
+ `backend/.env.example`:
99
+ - `X_CLIENT_ID` / `X_CLIENT_SECRET`: the X app's **OAuth 2.0 Client ID and Client Secret** (Read and write,
100
+ Web App, callback `{API_URL}/api/social/x/callback`, scopes incl. `media.write`).
101
+ - `LINKEDIN_CLIENT_ID` / `LINKEDIN_CLIENT_SECRET`: products "Sign In with LinkedIn using OpenID Connect" and
102
+ "Share on LinkedIn", redirect `{API_URL}/api/social/linkedin/callback`.
103
+ - `API_URL` must be reachable from the internet for the callbacks (an https tunnel locally).
104
+ X and LinkedIn posts need a connected account. X tokens are refreshed automatically; LinkedIn connections
105
+ last about 60 days. If a connection goes down (expired, revoked on the platform, or disconnected here) its
106
+ scheduled posts are **paused** and never attempted; reconnecting the same account resumes the ones still
107
+ ahead, and missed ones wait for a new time. Bluesky connects with an app password; Substack Notes has no API,
108
+ so those posts arrive as an email reminder at send time.
109
 
110
  ## What is in the app
111
 
 
121
  | Launchpad | Calendar and agenda, auto posting to X, LinkedIn and Bluesky, email reminders, tracked links, engagement |
122
  | Archive | Every generated artifact with filters, players, downloads, retry and delete |
123
  | Resurfacing | Evergreen scores and reshare angles for old posts, on this day, straight into a kit or the calendar |
124
+ | Settings | Profile, workspace, privacy, plan usage meters, connections, sign out everywhere, delete |
125
 
126
  ## Key choices
127
 
backend/.env.example ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Launchpad: posting to X and LinkedIn (copy what you need into .env)
2
+
3
+ # Where the app and the API are reached. Both platforms send people back to
4
+ # {API_URL}/api/social/{x,linkedin}/callback after they connect an account, so API_URL must be reachable from the
5
+ # internet (when running locally, an https tunnel such as ngrok: API_URL=https://<id>.ngrok-free.app).
6
+ FRONTEND_URL=http://localhost:5173
7
+ API_URL=http://localhost:8000
8
+
9
+ # X: developer.x.com > your app > Keys and tokens > "OAuth 2.0 Client ID and Client Secret"
10
+ # (not the API Key / Bearer Token). Then User authentication settings:
11
+ # App permissions: Read and write. Type of app: Web App (confidential client).
12
+ # Callback URI: {API_URL}/api/social/x/callback
13
+ # Scopes asked for: tweet.read tweet.write users.read offline.access media.write
14
+ # (media.write posts images and videos; accounts connected before it was added are asked to reconnect)
15
+ # X is switched off for now (LinkedIn only). Set to true to bring it back.
16
+ X_ENABLED=false
17
+ X_CLIENT_ID=
18
+ X_CLIENT_SECRET=
19
+
20
+ # LinkedIn: linkedin.com/developers > your app > Auth > "Client ID" and "Primary Client Secret"
21
+ # Products: "Sign In with LinkedIn using OpenID Connect" and "Share on LinkedIn".
22
+ # Auth > Authorized redirect URLs: {API_URL}/api/social/linkedin/callback
23
+ # Scopes asked for: openid profile w_member_social
24
+ # LinkedIn tokens last about 60 days; then the account shows Reconnect.
25
+ LINKEDIN_CLIENT_ID=
26
+ LINKEDIN_CLIENT_SECRET=
27
+
28
+ # Connections: X tokens are refreshed automatically (every 10 minutes the worker checks them, and every 6 hours asks
29
+ # each platform whether the connection still works). When a connection goes down, its scheduled posts are paused and
30
+ # never attempted; reconnecting the same account resumes the ones still ahead.
31
+
32
+ # Encrypts the stored social tokens (a Fernet key). Empty: derived from JWT_SECRET.
33
+ # Make one with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
34
+ SOCIAL_TOKEN_KEY=
backend/0 ADDED
@@ -0,0 +1 @@
 
 
1
+ key set: 49
backend/Dockerfile CHANGED
@@ -1,6 +1,9 @@
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 . .
 
1
  FROM python:3.12-slim
2
  WORKDIR /app
3
  ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
4
+ # Chromium draws an infographic's PNG for its Download button
5
+ RUN apt-get update && apt-get install -y --no-install-recommends chromium fonts-liberation \
6
+ && rm -rf /var/lib/apt/lists/*
7
  COPY requirements.txt .
8
  RUN pip install --no-cache-dir -r requirements.txt
9
  COPY . .
backend/alembic/versions/0005_video_quota.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """subscriptions.video_plan / videos_used / video_limit / videos_period_start: the video allowance
2
+
3
+ Revision ID: 0005_video_quota
4
+ Revises: 0004_archive_notebook
5
+ Create Date: 2026-09-29
6
+ """
7
+ import sqlalchemy as sa
8
+ from alembic import op
9
+
10
+ revision = "0005_video_quota"
11
+ down_revision = "0004_archive_notebook"
12
+ branch_labels = None
13
+ depends_on = None
14
+
15
+ COLUMNS = ("video_plan", "videos_used", "video_limit", "videos_period_start")
16
+
17
+
18
+ def upgrade() -> None:
19
+ # 0001 builds from the live models, so a fresh database may already have the columns.
20
+ existing = {c["name"] for c in sa.inspect(op.get_bind()).get_columns("subscriptions")}
21
+ new = {
22
+ "video_plan": sa.Column("video_plan", sa.String(20), nullable=False, server_default="free"),
23
+ "videos_used": sa.Column("videos_used", sa.Integer(), nullable=False, server_default="0"),
24
+ "video_limit": sa.Column("video_limit", sa.Integer(), nullable=False, server_default="0"),
25
+ "videos_period_start": sa.Column("videos_period_start", sa.DateTime(timezone=True), nullable=True),
26
+ }
27
+ missing = [new[name] for name in COLUMNS if name not in existing]
28
+ if missing:
29
+ with op.batch_alter_table("subscriptions") as batch:
30
+ for column in missing:
31
+ batch.add_column(column)
32
+ # Same limits as app/services/plans.py; the app re-syncs on first use (billing disabled -> studio).
33
+ op.execute("""
34
+ UPDATE subscriptions SET
35
+ video_plan = CASE WHEN status IN ('active', 'trialing', 'past_due') AND plan IN ('writer', 'studio')
36
+ THEN plan ELSE 'free' END
37
+ """)
38
+ op.execute("""
39
+ UPDATE subscriptions SET
40
+ video_limit = CASE video_plan WHEN 'studio' THEN 30 WHEN 'writer' THEN 10 ELSE 1 END,
41
+ videos_period_start = COALESCE(videos_period_start, CURRENT_TIMESTAMP)
42
+ """)
43
+
44
+
45
+ def downgrade() -> None:
46
+ with op.batch_alter_table("subscriptions") as batch:
47
+ for name in reversed(COLUMNS):
48
+ batch.drop_column(name)
backend/alembic/versions/0006_plans.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """plans: one row per plan (limits, price, pricing-page copy), editable without a deploy
2
+
3
+ Seeded with the plans that were hard-coded in app/services/plans.py. Rows that already exist are left alone.
4
+
5
+ Revision ID: 0006_plans
6
+ Revises: 0005_video_quota
7
+ Create Date: 2026-09-29
8
+ """
9
+ from datetime import UTC, datetime
10
+
11
+ import sqlalchemy as sa
12
+ from alembic import op
13
+
14
+ revision = "0006_plans"
15
+ down_revision = "0005_video_quota"
16
+ branch_labels = None
17
+ depends_on = None
18
+
19
+ SEED = [
20
+ {"id": "free", "sort_order": 0, "name": "Free", "tagline": "For trying Notestack on your archive",
21
+ "price_monthly_usd": 0, "sources": 1, "indexed_posts": 5, "audio_minutes": 3, "videos": 1, "launch_kits": 2,
22
+ "voice_cloning": False, "brand_kit": False,
23
+ "features": ["1 source, your latest 5 posts indexed", "Grounded research chat with citations",
24
+ "3 min of audio overviews a month", "1 video a month", "2 Launch Kits a month"]},
25
+ {"id": "writer", "sort_order": 1, "name": "Writer", "tagline": "For writers publishing every week",
26
+ "price_monthly_usd": 24.99, "sources": 3, "indexed_posts": 500, "audio_minutes": 60, "videos": 10,
27
+ "launch_kits": 50, "voice_cloning": True, "brand_kit": True,
28
+ "features": ["3 sources, 500 indexed posts", "60 min of audio overviews a month", "10 videos a month",
29
+ "50 Launch Kits a month", "Voice cloning with consent", "Your brand colors and logo"]},
30
+ {"id": "studio", "sort_order": 2, "name": "Studio", "tagline": "For publications and power users",
31
+ "price_monthly_usd": 48.99, "sources": 10, "indexed_posts": 5000, "audio_minutes": 240, "videos": 30,
32
+ "launch_kits": -1, "voice_cloning": True, "brand_kit": True,
33
+ "features": ["10 sources, 5,000 indexed posts", "240 min of audio overviews a month", "30 videos a month",
34
+ "Unlimited Launch Kits", "Launchpad calendar and resurfacing", "Priority rendering"]},
35
+ ]
36
+
37
+
38
+ def upgrade() -> None:
39
+ bind = op.get_bind()
40
+ # 0001 builds from the live models, so a fresh database may already have the table (empty).
41
+ if "plans" not in sa.inspect(bind).get_table_names():
42
+ op.create_table(
43
+ "plans",
44
+ sa.Column("id", sa.String(20), primary_key=True),
45
+ sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
46
+ sa.Column("name", sa.String(50), nullable=False),
47
+ sa.Column("tagline", sa.String(200), nullable=False, server_default=""),
48
+ sa.Column("price_monthly_usd", sa.Float(), nullable=False, server_default="0"),
49
+ sa.Column("sources", sa.Integer(), nullable=False, server_default="1"),
50
+ sa.Column("indexed_posts", sa.Integer(), nullable=False, server_default="5"),
51
+ sa.Column("audio_minutes", sa.Integer(), nullable=False, server_default="0"),
52
+ sa.Column("videos", sa.Integer(), nullable=False, server_default="0"),
53
+ sa.Column("launch_kits", sa.Integer(), nullable=False, server_default="0"),
54
+ sa.Column("voice_cloning", sa.Boolean(), nullable=False, server_default=sa.false()),
55
+ sa.Column("brand_kit", sa.Boolean(), nullable=False, server_default=sa.false()),
56
+ sa.Column("features", sa.JSON(), nullable=False),
57
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
58
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
59
+ )
60
+ # Columns a later migration drops (brand_kit) are not in a table 0001 built from today's models: seed what exists.
61
+ have = {c["name"] for c in sa.inspect(bind).get_columns("plans")}
62
+ seed = [{k: v for k, v in p.items() if k in have} for p in SEED]
63
+ types = {"features": sa.JSON(), "voice_cloning": sa.Boolean(), "brand_kit": sa.Boolean(),
64
+ "created_at": sa.DateTime(timezone=True), "updated_at": sa.DateTime(timezone=True)}
65
+ plans = sa.table("plans", *(sa.column(k, types.get(k)) for k in [*seed[0], "created_at", "updated_at"]))
66
+ existing = {row[0] for row in bind.execute(sa.text("SELECT id FROM plans"))}
67
+ now = datetime.now(UTC)
68
+ missing = [{**p, "created_at": now, "updated_at": now} for p in seed if p["id"] not in existing]
69
+ if missing:
70
+ op.bulk_insert(plans, missing)
71
+
72
+
73
+ def downgrade() -> None:
74
+ op.drop_table("plans")
backend/alembic/versions/0007_subscription_plan_fk.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """subscriptions.plan -> plans.id: the plan is picked from the plans table, not typed as free text
2
+
3
+ Unknown plan values (typos, removed plans) become 'free' first so the constraint can be added.
4
+
5
+ Revision ID: 0007_subscription_plan_fk
6
+ Revises: 0006_plans
7
+ Create Date: 2026-09-29
8
+ """
9
+ import sqlalchemy as sa
10
+ from alembic import op
11
+
12
+ revision = "0007_subscription_plan_fk"
13
+ down_revision = "0006_plans"
14
+ branch_labels = None
15
+ depends_on = None
16
+
17
+ NAME = "fk_subscriptions_plan_plans"
18
+
19
+
20
+ def upgrade() -> None:
21
+ bind = op.get_bind()
22
+ # 0001 builds from the live models, so a fresh database may already have the constraint.
23
+ if any(fk["referred_table"] == "plans" for fk in sa.inspect(bind).get_foreign_keys("subscriptions")):
24
+ return
25
+ op.execute("UPDATE subscriptions SET plan = 'free' WHERE plan IS NULL OR plan NOT IN (SELECT id FROM plans)")
26
+ with op.batch_alter_table("subscriptions") as batch:
27
+ batch.create_foreign_key(NAME, "plans", ["plan"], ["id"], onupdate="CASCADE")
28
+
29
+
30
+ def downgrade() -> None:
31
+ fks = sa.inspect(op.get_bind()).get_foreign_keys("subscriptions")
32
+ name = next((fk["name"] for fk in fks if fk["referred_table"] == "plans"), None)
33
+ if not name:
34
+ return # unnamed (built by 0001 on SQLite): nothing we can drop by name
35
+ with op.batch_alter_table("subscriptions") as batch:
36
+ batch.drop_constraint(name, type_="foreignkey")
backend/alembic/versions/0008_b2v_videos.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """b2v_videos: which workspace owns each blog2video video, and whether its allowance is still charged
2
+
3
+ blog2video now only takes our account's API key, so this table is our access control and refund ledger.
4
+ blog2video no longer connects to this database: drop the role it used (see docs/BLOG2VIDEO.md).
5
+
6
+ Revision ID: 0008_b2v_videos
7
+ Revises: 0007_subscription_plan_fk
8
+ Create Date: 2026-09-30
9
+ """
10
+ import sqlalchemy as sa
11
+ from alembic import op
12
+
13
+ revision = "0008_b2v_videos"
14
+ down_revision = "0007_subscription_plan_fk"
15
+ branch_labels = None
16
+ depends_on = None
17
+
18
+
19
+ def upgrade() -> None:
20
+ # 0001 builds from the live models, so a fresh database may already have the table.
21
+ if sa.inspect(op.get_bind()).has_table("b2v_videos"):
22
+ return
23
+ op.create_table(
24
+ "b2v_videos",
25
+ sa.Column("id", sa.Uuid(), primary_key=True),
26
+ sa.Column("workspace_id", sa.Uuid(), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False),
27
+ sa.Column("artifact_id", sa.Uuid(), sa.ForeignKey("artifacts.id", ondelete="SET NULL"), nullable=True),
28
+ sa.Column("b2v_video_id", sa.Integer(), nullable=True, unique=True),
29
+ sa.Column("idempotency_key", sa.String(100), nullable=False, unique=True),
30
+ sa.Column("quota_state", sa.String(20), nullable=False, server_default="charged"),
31
+ sa.Column("status", sa.String(40), nullable=False, server_default="queued"),
32
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
33
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
34
+ )
35
+ op.create_index("ix_b2v_videos_ws_created", "b2v_videos", ["workspace_id", "created_at"])
36
+ op.create_index("ix_b2v_videos_artifact_id", "b2v_videos", ["artifact_id"])
37
+
38
+
39
+ def downgrade() -> None:
40
+ op.drop_table("b2v_videos")
backend/alembic/versions/0009_b2v_resources.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """blog2video resources per workspace: templates, custom voices, saved voices, styles, usage counters;
2
+ b2v_videos.created_via / template_ref; plans.video_premium / video_limits
3
+
4
+ Everything made with our blog2video key belongs to one account, so these tables are what keeps each workspace's
5
+ templates, voices and styles its own (app/services/b2v_access.py).
6
+
7
+ Revision ID: 0009_b2v_resources
8
+ Revises: 0008_b2v_videos
9
+ Create Date: 2026-09-30
10
+ """
11
+ import json
12
+
13
+ import sqlalchemy as sa
14
+ from alembic import op
15
+
16
+ revision = "0009_b2v_resources"
17
+ down_revision = "0008_b2v_videos"
18
+ branch_labels = None
19
+ depends_on = None
20
+
21
+ # Same as app/services/plans.py
22
+ VIDEO_LIMITS = {
23
+ "free": {"ai_edits": 20, "templates": 0, "template_ai_daily": 0, "voice_designs_daily": 0,
24
+ "voice_samples_daily": 0, "custom_voices": 0},
25
+ "writer": {"ai_edits": 300, "templates": 2, "template_ai_daily": 3, "voice_designs_daily": 5,
26
+ "voice_samples_daily": 20, "custom_voices": 3},
27
+ "studio": {"ai_edits": 1000, "templates": 5, "template_ai_daily": 5, "voice_designs_daily": 10,
28
+ "voice_samples_daily": 40, "custom_voices": 10},
29
+ }
30
+
31
+
32
+ def _ws() -> sa.Column:
33
+ return sa.Column("workspace_id", sa.Uuid(), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False,
34
+ index=True)
35
+
36
+
37
+ def _times() -> list[sa.Column]:
38
+ return [sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
39
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now())]
40
+
41
+
42
+ def upgrade() -> None:
43
+ bind = op.get_bind()
44
+ inspector = sa.inspect(bind)
45
+
46
+ # 0001 builds from the live models, so a fresh database may already have all of this.
47
+ video_cols = {c["name"] for c in inspector.get_columns("b2v_videos")}
48
+ with op.batch_alter_table("b2v_videos") as batch:
49
+ if "created_via" not in video_cols:
50
+ batch.add_column(sa.Column("created_via", sa.String(10), nullable=False, server_default="v1"))
51
+ if "template_ref" not in video_cols:
52
+ batch.add_column(sa.Column("template_ref", sa.String(100), nullable=True))
53
+ batch.alter_column("idempotency_key", existing_type=sa.String(100), nullable=True)
54
+
55
+ if not inspector.has_table("b2v_templates"):
56
+ op.create_table("b2v_templates",
57
+ sa.Column("b2v_template_id", sa.Integer(), primary_key=True, autoincrement=False), _ws(),
58
+ sa.Column("name", sa.String(255), nullable=False),
59
+ sa.Column("ready", sa.Boolean(), nullable=False, server_default=sa.false()), *_times())
60
+ if not inspector.has_table("b2v_custom_voices"):
61
+ op.create_table("b2v_custom_voices",
62
+ sa.Column("b2v_custom_voice_id", sa.Integer(), primary_key=True, autoincrement=False), _ws(),
63
+ sa.Column("voice_id", sa.String(100), nullable=False, unique=True),
64
+ sa.Column("name", sa.String(255), nullable=False),
65
+ sa.Column("source", sa.String(20), nullable=False),
66
+ sa.Column("preview_url", sa.String(1000), nullable=True), *_times())
67
+ if not inspector.has_table("user_saved_voices"):
68
+ op.create_table("user_saved_voices",
69
+ sa.Column("workspace_id", sa.Uuid(), sa.ForeignKey("workspaces.id", ondelete="CASCADE"),
70
+ primary_key=True),
71
+ sa.Column("voice_id", sa.String(100), primary_key=True),
72
+ sa.Column("name", sa.String(255), nullable=False),
73
+ sa.Column("preview_url", sa.String(1000), nullable=True),
74
+ sa.Column("gender", sa.String(20), nullable=True),
75
+ sa.Column("accent", sa.String(50), nullable=True),
76
+ sa.Column("premium", sa.Boolean(), nullable=False, server_default=sa.false()),
77
+ sa.Column("is_custom", sa.Boolean(), nullable=False, server_default=sa.false()), *_times())
78
+ if not inspector.has_table("b2v_styles"):
79
+ op.create_table("b2v_styles",
80
+ sa.Column("b2v_style_id", sa.Integer(), primary_key=True, autoincrement=False), _ws(),
81
+ sa.Column("name", sa.String(80), nullable=False), *_times())
82
+ if not inspector.has_table("usage_counters"):
83
+ op.create_table("usage_counters",
84
+ sa.Column("workspace_id", sa.Uuid(), sa.ForeignKey("workspaces.id", ondelete="CASCADE"),
85
+ primary_key=True),
86
+ sa.Column("period", sa.String(10), primary_key=True),
87
+ sa.Column("metric", sa.String(30), primary_key=True),
88
+ sa.Column("used", sa.Integer(), nullable=False, server_default="0"))
89
+
90
+ plan_cols = {c["name"] for c in inspector.get_columns("plans")}
91
+ with op.batch_alter_table("plans") as batch:
92
+ if "video_premium" not in plan_cols:
93
+ batch.add_column(sa.Column("video_premium", sa.Boolean(), nullable=False, server_default=sa.false()))
94
+ if "video_limits" not in plan_cols:
95
+ batch.add_column(sa.Column("video_limits", sa.JSON(), nullable=True))
96
+ plans = sa.table("plans", sa.column("id", sa.String), sa.column("video_premium", sa.Boolean),
97
+ sa.column("video_limits", sa.JSON))
98
+ for plan_id, limits in VIDEO_LIMITS.items():
99
+ op.execute(plans.update().where(plans.c.id == plan_id)
100
+ .values(video_premium=plan_id != "free", video_limits=json.loads(json.dumps(limits))))
101
+
102
+
103
+ def downgrade() -> None:
104
+ with op.batch_alter_table("plans") as batch:
105
+ batch.drop_column("video_limits")
106
+ batch.drop_column("video_premium")
107
+ for table in ("usage_counters", "b2v_styles", "user_saved_voices", "b2v_custom_voices", "b2v_templates"):
108
+ op.drop_table(table)
109
+ with op.batch_alter_table("b2v_videos") as batch:
110
+ batch.drop_column("template_ref")
111
+ batch.drop_column("created_via")
backend/alembic/versions/0010_b2v_video_refs.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """b2v_videos holds each video's full reference (who made it, title, source, links); video limits 1 total / 10 / 20
2
+
3
+ - b2v_videos: user_id, title, source_url, aspect_ratio, preview_url, video_url, backfilled from the workspace owner
4
+ and the linked artifact.
5
+ - plans: videos_monthly (False on Free: 1 video in total, never refilled); Studio 20 videos a month.
6
+
7
+ Revision ID: 0010_b2v_video_refs
8
+ Revises: 0009_b2v_resources
9
+ Create Date: 2026-09-30
10
+ """
11
+ import json
12
+
13
+ import sqlalchemy as sa
14
+ from alembic import op
15
+
16
+ revision = "0010_b2v_video_refs"
17
+ down_revision = "0009_b2v_resources"
18
+ branch_labels = None
19
+ depends_on = None
20
+
21
+ VIDEO_COLUMNS = {
22
+ "user_id": lambda: sa.Column("user_id", sa.Uuid(), nullable=True),
23
+ "title": lambda: sa.Column("title", sa.String(300), nullable=True),
24
+ "source_url": lambda: sa.Column("source_url", sa.String(2000), nullable=True),
25
+ "aspect_ratio": lambda: sa.Column("aspect_ratio", sa.String(20), nullable=True),
26
+ "preview_url": lambda: sa.Column("preview_url", sa.String(1000), nullable=True),
27
+ "video_url": lambda: sa.Column("video_url", sa.String(1000), nullable=True),
28
+ }
29
+
30
+ # Same as app/services/plans.py
31
+ PLAN_VIDEOS = {"free": (1, False, "1 video a month", "1 video to try it"),
32
+ "writer": (10, True, None, None),
33
+ "studio": (20, True, "30 videos a month", "20 videos a month")}
34
+
35
+
36
+ def upgrade() -> None:
37
+ bind = op.get_bind()
38
+ inspector = sa.inspect(bind)
39
+
40
+ # 0001 builds from the live models, so a fresh database may already have these columns.
41
+ existing = {c["name"] for c in inspector.get_columns("b2v_videos")}
42
+ missing = [make() for name, make in VIDEO_COLUMNS.items() if name not in existing]
43
+ if missing:
44
+ with op.batch_alter_table("b2v_videos") as batch:
45
+ for column in missing:
46
+ batch.add_column(column)
47
+ if "user_id" not in existing:
48
+ batch.create_foreign_key("fk_b2v_videos_user_id_users", "users", ["user_id"], ["id"],
49
+ ondelete="SET NULL")
50
+ batch.create_index("ix_b2v_videos_user_id", ["user_id"])
51
+
52
+ # Backfill: the workspace owner made it; title and links come from the artifact.
53
+ rows = bind.execute(sa.text("""
54
+ SELECT v.id, w.owner_id, a.content_json FROM b2v_videos v
55
+ JOIN workspaces w ON w.id = v.workspace_id
56
+ LEFT JOIN artifacts a ON a.id = v.artifact_id
57
+ WHERE v.user_id IS NULL
58
+ """)).fetchall()
59
+ for vid, owner_id, content in rows:
60
+ c = content if isinstance(content, dict) else json.loads(content or "{}")
61
+ bind.execute(sa.text("""
62
+ UPDATE b2v_videos SET user_id = :u, title = COALESCE(title, :t), aspect_ratio = COALESCE(aspect_ratio, :ar),
63
+ preview_url = COALESCE(preview_url, :p), video_url = COALESCE(video_url, :v) WHERE id = :id
64
+ """), {"u": owner_id, "t": (c.get("title") or None), "ar": c.get("aspect_ratio"), "p": c.get("preview_url"),
65
+ "v": c.get("video_url"), "id": vid})
66
+
67
+ plan_cols = {c["name"] for c in inspector.get_columns("plans")}
68
+ if "videos_monthly" not in plan_cols:
69
+ with op.batch_alter_table("plans") as batch:
70
+ batch.add_column(sa.Column("videos_monthly", sa.Boolean(), nullable=False, server_default=sa.true()))
71
+ for plan_id, (videos, monthly, old, new) in PLAN_VIDEOS.items():
72
+ row = bind.execute(sa.text("SELECT features FROM plans WHERE id = :id"), {"id": plan_id}).fetchone()
73
+ if row is None:
74
+ continue
75
+ features = row[0] if isinstance(row[0], list) else json.loads(row[0] or "[]")
76
+ if old:
77
+ features = [new if f == old else f for f in features]
78
+ bind.execute(sa.text("UPDATE plans SET videos = :v, videos_monthly = :m, features = :f WHERE id = :id"),
79
+ {"v": videos, "m": monthly, "f": json.dumps(features), "id": plan_id})
80
+
81
+
82
+ def downgrade() -> None:
83
+ with op.batch_alter_table("plans") as batch:
84
+ batch.drop_column("videos_monthly")
85
+ # A database built from the models (0001) names these differently, so look them up.
86
+ inspector = sa.inspect(op.get_bind())
87
+ fks = [fk["name"] for fk in inspector.get_foreign_keys("b2v_videos")
88
+ if fk["constrained_columns"] == ["user_id"] and fk.get("name")]
89
+ indexes = [ix["name"] for ix in inspector.get_indexes("b2v_videos") if ix["column_names"] == ["user_id"]]
90
+ with op.batch_alter_table("b2v_videos") as batch:
91
+ for name in indexes:
92
+ batch.drop_index(name)
93
+ for name in fks:
94
+ batch.drop_constraint(name, type_="foreignkey")
95
+ for name in reversed(list(VIDEO_COLUMNS)):
96
+ batch.drop_column(name)
backend/alembic/versions/0011_one_archive_notebook.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """One "All posts" notebook per workspace
2
+
3
+ Two requests opening the Notebooks page at once could each create the archive notebook. This merges the copies into
4
+ the oldest one (its posts, chats and artifacts move over) and adds a unique index so it cannot happen again.
5
+
6
+ Revision ID: 0011_one_archive_notebook
7
+ Revises: 0010_b2v_video_refs
8
+ Create Date: 2026-10-01
9
+ """
10
+ import sqlalchemy as sa
11
+ from alembic import op
12
+
13
+ revision = "0011_one_archive_notebook"
14
+ down_revision = "0010_b2v_video_refs"
15
+ branch_labels = None
16
+ depends_on = None
17
+
18
+ INDEX = "uq_notebooks_one_archive"
19
+
20
+
21
+ def upgrade() -> None:
22
+ conn = op.get_bind()
23
+ rows = conn.execute(sa.text(
24
+ "SELECT workspace_id, id FROM notebooks WHERE is_archive ORDER BY workspace_id, created_at, id"
25
+ )).all()
26
+ keep: dict = {}
27
+ for ws, nb in rows:
28
+ if ws not in keep:
29
+ keep[ws] = nb
30
+ continue
31
+ ids = {"keep": keep[ws], "dup": nb}
32
+ conn.execute(sa.text(
33
+ "INSERT INTO notebook_documents (notebook_id, document_id) "
34
+ "SELECT :keep, document_id FROM notebook_documents WHERE notebook_id = :dup AND document_id NOT IN "
35
+ "(SELECT document_id FROM notebook_documents WHERE notebook_id = :keep)"
36
+ ), ids)
37
+ conn.execute(sa.text("UPDATE chats SET notebook_id = :keep WHERE notebook_id = :dup"), ids)
38
+ conn.execute(sa.text("UPDATE artifacts SET notebook_id = :keep WHERE notebook_id = :dup"), ids)
39
+ conn.execute(sa.text("DELETE FROM notebooks WHERE id = :dup"), ids)
40
+
41
+ # 0001 builds from the live models, so a fresh database may already have the index.
42
+ if INDEX not in {i["name"] for i in sa.inspect(conn).get_indexes("notebooks")}:
43
+ op.create_index(INDEX, "notebooks", ["workspace_id"], unique=True,
44
+ postgresql_where=sa.text("is_archive"), sqlite_where=sa.text("is_archive"))
45
+
46
+
47
+ def downgrade() -> None:
48
+ op.drop_index(INDEX, table_name="notebooks")
backend/alembic/versions/0012_artifact_status_len.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Longer artifact status
2
+
3
+ A video's artifact now takes blog2video's job statuses as they are (e.g. "language_regenerating", 21 characters),
4
+ which do not fit the old 20.
5
+
6
+ Revision ID: 0012_artifact_status_len
7
+ Revises: 0011_one_archive_notebook
8
+ Create Date: 2026-10-02
9
+ """
10
+ import sqlalchemy as sa
11
+ from alembic import op
12
+
13
+ revision = "0012_artifact_status_len"
14
+ down_revision = "0011_one_archive_notebook"
15
+ branch_labels = None
16
+ depends_on = None
17
+
18
+
19
+ def upgrade() -> None:
20
+ with op.batch_alter_table("artifacts") as batch:
21
+ batch.alter_column("status", existing_type=sa.String(20), type_=sa.String(40), existing_nullable=False)
22
+
23
+
24
+ def downgrade() -> None:
25
+ with op.batch_alter_table("artifacts") as batch:
26
+ batch.alter_column("status", existing_type=sa.String(40), type_=sa.String(20), existing_nullable=False)
backend/alembic/versions/0013_workspace_memory.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """workspace_memory: one row of standing notes per workspace, stored as JSON
2
+
3
+ Revision ID: 0013_workspace_memory
4
+ Revises: 0012_artifact_status_len
5
+ Create Date: 2026-09-30
6
+ """
7
+ import sqlalchemy as sa
8
+ from alembic import op
9
+
10
+ revision = "0013_workspace_memory"
11
+ down_revision = "0012_artifact_status_len"
12
+ branch_labels = None
13
+ depends_on = None
14
+
15
+
16
+ def upgrade() -> None:
17
+ # 0001 builds from the live models, so a fresh database may already have the table.
18
+ if "workspace_memory" in sa.inspect(op.get_bind()).get_table_names():
19
+ return
20
+ op.create_table(
21
+ "workspace_memory",
22
+ sa.Column("id", sa.Uuid(), primary_key=True),
23
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
24
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
25
+ sa.Column("workspace_id", sa.Uuid(), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False),
26
+ sa.Column("facts", sa.JSON(), nullable=False, server_default="{}"),
27
+ )
28
+ op.create_index("ix_workspace_memory_workspace_id", "workspace_memory", ["workspace_id"], unique=True)
29
+
30
+
31
+ def downgrade() -> None:
32
+ op.drop_table("workspace_memory")
backend/alembic/versions/0014_chat_citations.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """citations.kind: a citation can point at an earlier chat (no document) as well as a post
2
+
3
+ Revision ID: 0014_chat_citations
4
+ Revises: 0013_workspace_memory
5
+ Create Date: 2026-10-01
6
+ """
7
+ import sqlalchemy as sa
8
+ from alembic import op
9
+
10
+ revision = "0014_chat_citations"
11
+ down_revision = "0013_workspace_memory"
12
+ branch_labels = None
13
+ depends_on = None
14
+
15
+
16
+ def upgrade() -> None:
17
+ # 0001 builds from the live models, so a fresh database may already have the column.
18
+ columns = {c["name"]: c for c in sa.inspect(op.get_bind()).get_columns("citations")}
19
+ with op.batch_alter_table("citations") as b:
20
+ if "kind" not in columns:
21
+ b.add_column(sa.Column("kind", sa.String(10), nullable=False, server_default="post"))
22
+ if not columns["document_id"]["nullable"]:
23
+ b.alter_column("document_id", existing_type=sa.Uuid(), nullable=True)
24
+
25
+
26
+ def downgrade() -> None:
27
+ op.execute("DELETE FROM citations WHERE document_id IS NULL") # a chat citation has no document to point at
28
+ with op.batch_alter_table("citations") as b:
29
+ b.alter_column("document_id", existing_type=sa.Uuid(), nullable=False)
30
+ b.drop_column("kind")
backend/alembic/versions/0015_message_feedback.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """message_feedback: thumbs up/down on chat answers, plus messages.recall_json (how the answer was produced)
2
+
3
+ Revision ID: 0015_message_feedback
4
+ Revises: 0014_chat_citations
5
+ Create Date: 2026-10-02
6
+ """
7
+ import sqlalchemy as sa
8
+ from alembic import op
9
+
10
+ revision = "0015_message_feedback"
11
+ down_revision = "0014_chat_citations"
12
+ branch_labels = None
13
+ depends_on = None
14
+
15
+
16
+ def upgrade() -> None:
17
+ # 0001 builds from the live models, so a fresh database may already have these.
18
+ insp = sa.inspect(op.get_bind())
19
+ if "recall_json" not in {c["name"] for c in insp.get_columns("messages")}:
20
+ with op.batch_alter_table("messages") as b:
21
+ b.add_column(sa.Column("recall_json", sa.JSON(), nullable=True))
22
+ if "message_feedback" in insp.get_table_names():
23
+ return
24
+ op.create_table(
25
+ "message_feedback",
26
+ sa.Column("id", sa.Uuid(), primary_key=True),
27
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
28
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
29
+ sa.Column("workspace_id", sa.Uuid(), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False),
30
+ sa.Column("notebook_id", sa.Uuid(), sa.ForeignKey("notebooks.id", ondelete="SET NULL")),
31
+ sa.Column("chat_id", sa.Uuid(), sa.ForeignKey("chats.id", ondelete="SET NULL")),
32
+ sa.Column("message_id", sa.Uuid(), sa.ForeignKey("messages.id", ondelete="SET NULL")),
33
+ sa.Column("rating", sa.Integer(), nullable=False),
34
+ sa.Column("reasons", sa.JSON(), nullable=False),
35
+ sa.Column("comment", sa.Text()),
36
+ sa.Column("question", sa.Text(), nullable=False),
37
+ sa.Column("answer", sa.Text(), nullable=False),
38
+ sa.Column("sources", sa.JSON(), nullable=False),
39
+ sa.Column("recall", sa.JSON(), nullable=False),
40
+ )
41
+ op.create_index("ix_message_feedback_workspace_id", "message_feedback", ["workspace_id"])
42
+ op.create_index("ix_message_feedback_message_id", "message_feedback", ["message_id"], unique=True)
43
+
44
+
45
+ def downgrade() -> None:
46
+ op.drop_table("message_feedback")
47
+ with op.batch_alter_table("messages") as b:
48
+ b.drop_column("recall_json")
backend/alembic/versions/0016_support_chat.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """support_chat: conversations and messages for the in app help bot
2
+
3
+ Revision ID: 0016_support_chat
4
+ Revises: 0015_message_feedback
5
+ Create Date: 2026-10-06
6
+ """
7
+ import sqlalchemy as sa
8
+ from alembic import op
9
+
10
+ revision = "0016_support_chat"
11
+ down_revision = "0015_message_feedback"
12
+ branch_labels = None
13
+ depends_on = None
14
+
15
+
16
+ def upgrade() -> None:
17
+ # 0001 builds from the live models, so a fresh database may already have these.
18
+ if "support_conversations" in sa.inspect(op.get_bind()).get_table_names():
19
+ return
20
+ op.create_table(
21
+ "support_conversations",
22
+ sa.Column("id", sa.Uuid(), primary_key=True),
23
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
24
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
25
+ sa.Column("user_id", sa.Uuid(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
26
+ sa.Column("title", sa.String(200)),
27
+ sa.Column("summary", sa.Text(), nullable=False, server_default=""),
28
+ sa.Column("session_state", sa.JSON(), nullable=False),
29
+ sa.Column("escalation_count", sa.Integer(), nullable=False, server_default="0"),
30
+ )
31
+ op.create_index("ix_support_conversations_user_id", "support_conversations", ["user_id"])
32
+ op.create_table(
33
+ "support_messages",
34
+ sa.Column("id", sa.Uuid(), primary_key=True),
35
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
36
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
37
+ sa.Column(
38
+ "conversation_id", sa.Uuid(), sa.ForeignKey("support_conversations.id", ondelete="CASCADE"), nullable=False
39
+ ),
40
+ sa.Column("role", sa.String(20), nullable=False),
41
+ sa.Column("content", sa.Text(), nullable=False),
42
+ sa.Column("page_path", sa.String(300)),
43
+ sa.Column("cited_docs", sa.JSON()),
44
+ )
45
+ op.create_index("ix_support_messages_conversation_id", "support_messages", ["conversation_id"])
46
+
47
+
48
+ def downgrade() -> None:
49
+ op.drop_table("support_messages")
50
+ op.drop_table("support_conversations")
backend/alembic/versions/0017_reports_limit.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """reports_limit: a monthly report allowance on each plan
2
+
3
+ Revision ID: 0017_reports_limit
4
+ Revises: 0016_support_chat
5
+ Create Date: 2026-10-07
6
+ """
7
+ import sqlalchemy as sa
8
+ from alembic import op
9
+
10
+ revision = "0017_reports_limit"
11
+ down_revision = "0016_support_chat"
12
+ branch_labels = None
13
+ depends_on = None
14
+
15
+
16
+ def upgrade() -> None:
17
+ # 0001 builds from the live models, so a fresh database may already have the column.
18
+ columns = {c["name"] for c in sa.inspect(op.get_bind()).get_columns("plans")}
19
+ if "reports" not in columns:
20
+ op.add_column("plans", sa.Column("reports", sa.Integer(), nullable=False, server_default="0"))
21
+ op.execute("UPDATE plans SET reports = CASE id WHEN 'studio' THEN -1 WHEN 'writer' THEN 30 ELSE 2 END")
22
+
23
+
24
+ def downgrade() -> None:
25
+ with op.batch_alter_table("plans") as batch:
26
+ batch.drop_column("reports")
backend/alembic/versions/0018_report_sharing.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """report_sharing: public links to reports, and a workspace switch that turns them off
2
+
3
+ Revision ID: 0018_report_sharing
4
+ Revises: 0017_reports_limit
5
+ Create Date: 2026-10-07
6
+ """
7
+ import sqlalchemy as sa
8
+ from alembic import op
9
+
10
+ revision = "0018_report_sharing"
11
+ down_revision = "0017_reports_limit"
12
+ branch_labels = None
13
+ depends_on = None
14
+
15
+
16
+ def upgrade() -> None:
17
+ # 0001 builds from the live models, so a fresh database may already have these.
18
+ inspector = sa.inspect(op.get_bind())
19
+ if "artifact_shares" not in inspector.get_table_names():
20
+ op.create_table(
21
+ "artifact_shares",
22
+ sa.Column("id", sa.Uuid(), primary_key=True),
23
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
24
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
25
+ sa.Column("workspace_id", sa.Uuid(), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False),
26
+ sa.Column("artifact_id", sa.Uuid(), sa.ForeignKey("artifacts.id", ondelete="CASCADE"), nullable=False,
27
+ unique=True),
28
+ sa.Column("token", sa.String(64), nullable=False),
29
+ sa.Column("show_sources", sa.Boolean(), nullable=False, server_default=sa.true()),
30
+ )
31
+ op.create_index("ix_artifact_shares_workspace_id", "artifact_shares", ["workspace_id"])
32
+ op.create_index("ix_artifact_shares_token", "artifact_shares", ["token"], unique=True)
33
+ if "allow_public_links" not in {c["name"] for c in inspector.get_columns("workspaces")}:
34
+ op.add_column("workspaces", sa.Column("allow_public_links", sa.Boolean(), nullable=False,
35
+ server_default=sa.true()))
36
+
37
+
38
+ def downgrade() -> None:
39
+ with op.batch_alter_table("workspaces") as batch:
40
+ batch.drop_column("allow_public_links")
41
+ op.drop_table("artifact_shares")
backend/alembic/versions/0019_infographics_limit.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """infographics_limit: a monthly infographic allowance on each plan
2
+
3
+ Revision ID: 0019_infographics_limit
4
+ Revises: 0018_report_sharing
5
+ Create Date: 2026-10-08
6
+ """
7
+ import sqlalchemy as sa
8
+ from alembic import op
9
+
10
+ revision = "0019_infographics_limit"
11
+ down_revision = "0018_report_sharing"
12
+ branch_labels = None
13
+ depends_on = None
14
+
15
+
16
+ def upgrade() -> None:
17
+ # 0001 builds from the live models, so a fresh database may already have the column.
18
+ columns = {c["name"] for c in sa.inspect(op.get_bind()).get_columns("plans")}
19
+ if "infographics" not in columns:
20
+ op.add_column("plans", sa.Column("infographics", sa.Integer(), nullable=False, server_default="0"))
21
+ op.execute("UPDATE plans SET infographics = CASE id WHEN 'studio' THEN -1 WHEN 'writer' THEN 30 ELSE 2 END")
22
+
23
+
24
+ def downgrade() -> None:
25
+ with op.batch_alter_table("plans") as batch:
26
+ batch.drop_column("infographics")
backend/alembic/versions/0020_calendar_kit.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """calendar_items.kit_id: the Launch Kit a scheduled post was written from, kept apart from its attachment
2
+
3
+ Revision ID: 0020_calendar_kit
4
+ Revises: 0019_infographics_limit
5
+ Create Date: 2026-10-06
6
+ """
7
+ import sqlalchemy as sa
8
+ from alembic import op
9
+
10
+ revision = "0020_calendar_kit"
11
+ down_revision = "0019_infographics_limit"
12
+ branch_labels = None
13
+ depends_on = None
14
+
15
+
16
+ def upgrade() -> None:
17
+ # 0001 builds from the live models, so a fresh database may already have the column.
18
+ if "kit_id" not in {c["name"] for c in sa.inspect(op.get_bind()).get_columns("calendar_items")}:
19
+ with op.batch_alter_table("calendar_items") as batch:
20
+ batch.add_column(sa.Column("kit_id", sa.Uuid(), nullable=True))
21
+ batch.create_foreign_key("fk_calendar_items_kit_id", "artifacts", ["kit_id"], ["id"], ondelete="SET NULL")
22
+ # Posts that carried a Launch Kit as their "attachment": the kit is where they came from, not an attachment.
23
+ op.execute("UPDATE calendar_items SET kit_id = artifact_id, artifact_id = NULL "
24
+ "WHERE artifact_id IN (SELECT id FROM artifacts WHERE type = 'launch_kit')")
25
+
26
+
27
+ def downgrade() -> None:
28
+ op.execute("UPDATE calendar_items SET artifact_id = kit_id WHERE artifact_id IS NULL AND kit_id IS NOT NULL")
29
+ # The foreign key is named here, or by the database when 0001 made the column from the models.
30
+ fks = [fk["name"] for fk in sa.inspect(op.get_bind()).get_foreign_keys("calendar_items")
31
+ if fk["constrained_columns"] == ["kit_id"] and fk.get("name")]
32
+ with op.batch_alter_table("calendar_items") as batch:
33
+ for name in fks:
34
+ batch.drop_constraint(name, type_="foreignkey")
35
+ batch.drop_column("kit_id")
backend/alembic/versions/0021_artifact_feedback.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """artifact_feedback: thumbs up/down on generated reports, quizzes, flashcards and infographics
2
+
3
+ Revision ID: 0021_artifact_feedback
4
+ Revises: 0020_calendar_kit
5
+ Create Date: 2026-10-08
6
+ """
7
+ import sqlalchemy as sa
8
+ from alembic import op
9
+
10
+ revision = "0021_artifact_feedback"
11
+ down_revision = "0020_calendar_kit"
12
+ branch_labels = None
13
+ depends_on = None
14
+
15
+
16
+ def upgrade() -> None:
17
+ # 0001 builds from the live models, so a fresh database may already have the table.
18
+ if "artifact_feedback" in sa.inspect(op.get_bind()).get_table_names():
19
+ return
20
+ op.create_table(
21
+ "artifact_feedback",
22
+ sa.Column("id", sa.Uuid(), primary_key=True),
23
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
24
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
25
+ sa.Column("workspace_id", sa.Uuid(), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False),
26
+ sa.Column("artifact_id", sa.Uuid(), sa.ForeignKey("artifacts.id", ondelete="SET NULL")),
27
+ sa.Column("user_id", sa.Uuid(), sa.ForeignKey("users.id", ondelete="SET NULL")),
28
+ sa.Column("artifact_type", sa.String(30), nullable=False),
29
+ sa.Column("rating", sa.Integer(), nullable=False),
30
+ sa.Column("reasons", sa.JSON(), nullable=False),
31
+ sa.Column("comment", sa.Text()),
32
+ sa.Column("snapshot", sa.JSON(), nullable=False),
33
+ sa.UniqueConstraint("artifact_id", "user_id"),
34
+ )
35
+ op.create_index("ix_artifact_feedback_workspace_id", "artifact_feedback", ["workspace_id"])
36
+ op.create_index("ix_artifact_feedback_artifact_id", "artifact_feedback", ["artifact_id"])
37
+
38
+
39
+ def downgrade() -> None:
40
+ op.drop_table("artifact_feedback")
backend/alembic/versions/0022_report_template_cache.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """report_template_cache: suggested report templates kept, so the Create report dialog is instant
2
+
3
+ Revision ID: 0022_report_template_cache
4
+ Revises: 0021_artifact_feedback
5
+ Create Date: 2026-10-08
6
+ """
7
+ import sqlalchemy as sa
8
+ from alembic import op
9
+
10
+ revision = "0022_report_template_cache"
11
+ down_revision = "0021_artifact_feedback"
12
+ branch_labels = None
13
+ depends_on = None
14
+
15
+
16
+ def upgrade() -> None:
17
+ # 0001 builds from the live models, so a fresh database may already have the table.
18
+ if "report_template_cache" in sa.inspect(op.get_bind()).get_table_names():
19
+ return
20
+ op.create_table(
21
+ "report_template_cache",
22
+ sa.Column("id", sa.Uuid(), primary_key=True),
23
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
24
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
25
+ sa.Column("workspace_id", sa.Uuid(), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False),
26
+ sa.Column("key", sa.String(64), nullable=False),
27
+ sa.Column("templates", sa.JSON(), nullable=False),
28
+ sa.UniqueConstraint("workspace_id", "key"),
29
+ )
30
+ op.create_index("ix_report_template_cache_workspace_id", "report_template_cache", ["workspace_id"])
31
+
32
+
33
+ def downgrade() -> None:
34
+ op.drop_table("report_template_cache")
backend/alembic/versions/0023_drop_brand_kit.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """drop_brand_kit: the brand kit (workspace colors and logo, and the plan flag for it) is gone
2
+
3
+ Revision ID: 0023_drop_brand_kit
4
+ Revises: 0022_report_template_cache
5
+ Create Date: 2026-10-08
6
+ """
7
+ import sqlalchemy as sa
8
+ from alembic import op
9
+
10
+ revision = "0023_drop_brand_kit"
11
+ down_revision = "0022_report_template_cache"
12
+ branch_labels = None
13
+ depends_on = None
14
+
15
+
16
+ def _has(table: str, column: str) -> bool:
17
+ return column in {c["name"] for c in sa.inspect(op.get_bind()).get_columns(table)}
18
+
19
+
20
+ def upgrade() -> None:
21
+ for table, column in (("plans", "brand_kit"), ("workspaces", "brand_json")):
22
+ if _has(table, column):
23
+ with op.batch_alter_table(table) as batch:
24
+ batch.drop_column(column)
25
+
26
+
27
+ def downgrade() -> None:
28
+ if not _has("plans", "brand_kit"):
29
+ op.add_column("plans", sa.Column("brand_kit", sa.Boolean(), nullable=False, server_default=sa.false()))
30
+ if not _has("workspaces", "brand_json"):
31
+ op.add_column("workspaces", sa.Column("brand_json", sa.JSON(), nullable=True))
backend/alembic/versions/0024_plan_limits.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """plan_limits: posts indexed across every source, Free allowances that are totals, new Writer and Studio numbers
2
+
3
+ Revision ID: 0024_plan_limits
4
+ Revises: 0023_drop_brand_kit
5
+ Create Date: 2026-10-08
6
+ """
7
+ import sqlalchemy as sa
8
+ from alembic import op
9
+
10
+ revision = "0024_plan_limits"
11
+ down_revision = "0023_drop_brand_kit"
12
+ branch_labels = None
13
+ depends_on = None
14
+
15
+ PLANS = {
16
+ "free": dict(sources=-1, indexed_posts=5, audio_minutes=-1, audio_overviews=1, videos=2, launch_kits=2, reports=2,
17
+ infographics=2, videos_monthly=False, features=[
18
+ "Up to 5 posts indexed, from any source", "Grounded research chat with citations",
19
+ "1 audio overview, any length", "2 videos", "2 Launch Kits", "2 infographics", "2 reports a month"]),
20
+ "writer": dict(sources=-1, indexed_posts=100, audio_minutes=60, audio_overviews=-1, videos=10, launch_kits=20,
21
+ reports=30, infographics=30, features=[
22
+ "Up to 100 posts indexed, from any source", "60 min of audio overviews a month", "10 videos a month",
23
+ "20 Launch Kits a month", "30 reports a month", "30 infographics a month",
24
+ "Voice cloning with consent"]),
25
+ "studio": dict(sources=-1, indexed_posts=250, audio_minutes=240, audio_overviews=-1, videos=20, launch_kits=50,
26
+ reports=-1, infographics=-1, features=[
27
+ "Up to 250 posts indexed, from any source", "240 min of audio overviews a month",
28
+ "20 videos a month", "50 Launch Kits a month", "Unlimited reports", "Unlimited infographics",
29
+ "Launchpad calendar and resurfacing", "Priority rendering"]),
30
+ }
31
+
32
+
33
+ def upgrade() -> None:
34
+ columns = {c["name"] for c in sa.inspect(op.get_bind()).get_columns("plans")}
35
+ if "audio_overviews" not in columns:
36
+ op.add_column("plans", sa.Column("audio_overviews", sa.Integer(), nullable=False, server_default="-1"))
37
+ plans = sa.table("plans", sa.column("id", sa.String), sa.column("sources", sa.Integer),
38
+ sa.column("indexed_posts", sa.Integer), sa.column("audio_minutes", sa.Integer),
39
+ sa.column("audio_overviews", sa.Integer), sa.column("videos", sa.Integer),
40
+ sa.column("launch_kits", sa.Integer), sa.column("reports", sa.Integer),
41
+ sa.column("infographics", sa.Integer), sa.column("videos_monthly", sa.Boolean),
42
+ sa.column("features", sa.JSON))
43
+ for plan_id, values in PLANS.items():
44
+ op.execute(plans.update().where(plans.c.id == plan_id).values(**values))
45
+
46
+
47
+ def downgrade() -> None:
48
+ with op.batch_alter_table("plans") as batch:
49
+ batch.drop_column("audio_overviews")
backend/alembic/versions/0025_drop_video_premium.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """drop_video_premium: the premium video options are gone, so is the plan flag for them
2
+
3
+ Revision ID: 0025_drop_video_premium
4
+ Revises: 0024_plan_limits
5
+ Create Date: 2026-10-08
6
+ """
7
+ import sqlalchemy as sa
8
+ from alembic import op
9
+
10
+ revision = "0025_drop_video_premium"
11
+ down_revision = "0024_plan_limits"
12
+ branch_labels = None
13
+ depends_on = None
14
+
15
+
16
+ def upgrade() -> None:
17
+ if "video_premium" in {c["name"] for c in sa.inspect(op.get_bind()).get_columns("plans")}:
18
+ with op.batch_alter_table("plans") as batch:
19
+ batch.drop_column("video_premium")
20
+
21
+
22
+ def downgrade() -> None:
23
+ if "video_premium" not in {c["name"] for c in sa.inspect(op.get_bind()).get_columns("plans")}:
24
+ op.add_column("plans", sa.Column("video_premium", sa.Boolean(), nullable=False, server_default=sa.false()))
backend/alembic/versions/0026_plan_copy.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """plan_copy: Free gets 1 video; Writer and Studio audio copy is relative to Free
2
+
3
+ Revision ID: 0026_plan_copy
4
+ Revises: 0025_drop_video_premium
5
+ Create Date: 2026-10-08
6
+ """
7
+ import sqlalchemy as sa
8
+ from alembic import op
9
+
10
+ revision = "0026_plan_copy"
11
+ down_revision = "0025_drop_video_premium"
12
+ branch_labels = None
13
+ depends_on = None
14
+
15
+ SWAPS = {
16
+ "free": {"2 videos": "1 video"},
17
+ "writer": {"60 min of audio overviews a month": "10x more audio overviews than Free (60 min a month)"},
18
+ "studio": {"240 min of audio overviews a month": "40x more audio overviews than Free (240 min a month)"},
19
+ }
20
+
21
+
22
+ def _swap(rows: dict) -> None:
23
+ plans = sa.table("plans", sa.column("id", sa.String), sa.column("videos", sa.Integer), sa.column("features", sa.JSON))
24
+ bind = op.get_bind()
25
+ for plan_id, swaps in rows.items():
26
+ features = bind.execute(sa.select(plans.c.features).where(plans.c.id == plan_id)).scalar()
27
+ if features is None:
28
+ continue
29
+ values = {"features": [swaps.get(f, f) for f in features]}
30
+ if plan_id == "free":
31
+ values["videos"] = 1 if "1 video" in swaps.values() else 2
32
+ op.execute(plans.update().where(plans.c.id == plan_id).values(**values))
33
+
34
+
35
+ def upgrade() -> None:
36
+ _swap(SWAPS)
37
+
38
+
39
+ def downgrade() -> None:
40
+ _swap({k: {v: o for o, v in d.items()} for k, d in SWAPS.items()})
backend/app/chat_memory/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Chat memory: a notebook's chats kept as topic files in R2, so a chat can pick up where it left off and a writer can
2
+ ask about something said earlier. See docs/CHAT_RECALL_IMPLEMENTATION.md.
3
+
4
+ The database keeps every message. This package keeps what was learned from them: a summary, keywords and a few
5
+ exact quotes per topic. Only the background job writes these files (organize.py)."""
backend/app/chat_memory/context.py ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The read path of chat memory: before an answer, decide how much of the chat's memory the research agent needs and
2
+ build it as one small text block.
3
+
4
+ Everything the answer step needs from the database is loaded up front by `load_chat_memory` (in the request, with its
5
+ session), so the research agent runs in its thread with plain data and no database access.
6
+
7
+ How much memory a message needs is the `memory` decision made by triage:
8
+ none a fresh question about the posts: nothing extra
9
+ lookup a follow up on a topic: that topic's summary and its last few rounds word for word
10
+ replay "what exactly did you say": the topic's saved exact words, and the chat tools to go further
11
+ compose a reference to earlier conversations in general: the notebook's topic list, and the chat tools"""
12
+
13
+ import logging
14
+ import uuid
15
+ from dataclasses import dataclass, field
16
+
17
+ from sqlalchemy import select
18
+ from sqlalchemy.orm import Session
19
+
20
+ from app.chat_memory import files
21
+ from app.chat_memory.files import Topic, TopicsFile
22
+ from app.chat_memory.organize import after_through, chat_corpus, ordered_messages, read_topic, read_topics_file
23
+ from app.config import settings
24
+ from app.corpus import Corpus
25
+ from app.models import Message
26
+
27
+ log = logging.getLogger(__name__)
28
+
29
+ MEMORY_LEVELS = ("none", "lookup", "replay", "compose")
30
+ BACKLOG_MESSAGES = 6 # unprocessed messages older than the history window that are added back
31
+ INDEX_PREVIEW_LINES = 20
32
+ TURN_CHARS = 500
33
+
34
+ # Jev choices (see pipeline/research.py triage_with_jev)
35
+ JEV_MEMORY = {
36
+ "none": {
37
+ "what": "A new question about the writer's posts that makes sense on its own, with no reference to anything "
38
+ "said earlier in the conversation",
39
+ "examples": ["What have I written about pricing?", "Summarize my post on habits"],
40
+ },
41
+ "lookup": {
42
+ "what": "A follow up that continues a topic from earlier in this chat and needs to know what was discussed",
43
+ "examples": ["Which of those benchmarks is best?", "Say more about the second point", "Go on"],
44
+ },
45
+ "replay": {
46
+ "what": "Asks what the assistant or the writer said earlier in this chat, or points at a specific earlier "
47
+ "answer or instruction",
48
+ "examples": ["What exactly did you say about MMLU?", "You listed three options earlier, what were they?",
49
+ "Use the format I asked for before"],
50
+ },
51
+ "compose": {
52
+ "what": "Refers to an earlier conversation or an earlier time, not just the current topic",
53
+ "examples": ["What did I ask about pricing last week?", "In another chat we talked about churn",
54
+ "Did I ever ask about Obama?"],
55
+ },
56
+ }
57
+ JEV_MEMORY_QUESTION = "How much memory of earlier conversation does the latest message need?"
58
+ JEV_TOPIC_NONE = {"what": "A new subject that is none of the topics above, or a plain question about the posts"}
59
+
60
+
61
+ def topic_question(tf: TopicsFile) -> dict | None:
62
+ """The Jev question that picks which topic of this chat a message belongs to."""
63
+ shown = sorted(tf.entries, key=lambda e: e.last, reverse=True)[:settings.chat_memory_topics_shown]
64
+ if not shown:
65
+ return None
66
+ criteria = {e.slug: {"what": f"{e.label}: {e.gist}. Words used: {e.keywords}"} for e in shown}
67
+ criteria["none"] = JEV_TOPIC_NONE
68
+ return {"instructions": "Which earlier topic of this conversation does the latest message belong to?",
69
+ "criteria": criteria}
70
+
71
+
72
+ def topics_digest(tf: TopicsFile) -> str:
73
+ """A few lines for the triage model: the topics of this chat and when each was last active."""
74
+ shown = sorted(tf.entries, key=lambda e: e.last, reverse=True)[:settings.chat_memory_topics_shown]
75
+ return "\n".join(f"{e.slug} | {e.label} | last active {e.last} | {e.gist}" for e in shown) or "(none)"
76
+
77
+
78
+ @dataclass
79
+ class Said:
80
+ role: str # writer | assistant
81
+ text: str
82
+ date: str
83
+
84
+
85
+ @dataclass
86
+ class ChatMemory:
87
+ """Everything the read path needs, loaded in advance."""
88
+
89
+ corpus: Corpus
90
+ chat_id: str
91
+ tf: TopicsFile
92
+ topics: dict[str, Topic] = field(default_factory=dict)
93
+ messages: dict[str, Said] = field(default_factory=dict) # by message id
94
+ backlog: list[Said] = field(default_factory=list) # processed late: older than the history window, oldest first
95
+ index_preview: str = ""
96
+
97
+ @property
98
+ def has_anything(self) -> bool:
99
+ return bool(self.tf.entries or self.index_preview)
100
+
101
+ def latest(self) -> str | None:
102
+ return max(self.tf.entries, key=lambda e: e.last).slug if self.tf.entries else None
103
+
104
+
105
+ def load_chat_memory(db: Session, workspace_id: uuid.UUID, notebook_id: uuid.UUID, chat_id: uuid.UUID,
106
+ history_ids: set[str], corpus: Corpus | None = None) -> ChatMemory | None:
107
+ """None when the read path is off, there is nothing remembered yet, or anything goes wrong: the chat then works as
108
+ it always has, from the last few messages."""
109
+ if not settings.chat_memory_read:
110
+ return None
111
+ try:
112
+ corpus = corpus or chat_corpus(workspace_id, notebook_id)
113
+ corpus.sync()
114
+ mem = ChatMemory(corpus=corpus, chat_id=str(chat_id), tf=read_topics_file(corpus, chat_id))
115
+ if corpus.exists(files.INDEX):
116
+ mem.index_preview = "\n".join(corpus.read_lines(files.INDEX)[:INDEX_PREVIEW_LINES])
117
+ if not mem.has_anything:
118
+ return None
119
+ shown = sorted(mem.tf.entries, key=lambda e: e.last, reverse=True)[:settings.chat_memory_topics_shown]
120
+ for e in shown:
121
+ topic = read_topic(corpus, chat_id, e.slug)
122
+ if topic:
123
+ mem.topics[e.slug] = topic
124
+ ids = {uuid.UUID(i) for t in mem.topics.values() for i in t.recent if _is_uuid(i)}
125
+ if ids:
126
+ for m in db.scalars(select(Message).where(Message.id.in_(ids), Message.chat_id == chat_id)):
127
+ mem.messages[str(m.id)] = _said(m)
128
+ # Messages the job has not filed yet that the history window no longer covers (the job is far behind).
129
+ pending = [m for m in after_through(ordered_messages(db, chat_id), mem.tf.through)
130
+ if str(m.id) not in history_ids]
131
+ mem.backlog = [_said(m) for m in pending[-BACKLOG_MESSAGES:]]
132
+ return mem
133
+ except Exception:
134
+ log.warning("Chat memory could not be loaded, continuing without it", exc_info=True)
135
+ return None
136
+
137
+
138
+ def _is_uuid(value: str) -> bool:
139
+ try:
140
+ uuid.UUID(value)
141
+ return True
142
+ except ValueError:
143
+ return False
144
+
145
+
146
+ def _said(m: Message) -> Said:
147
+ text = m.content if m.role == "user" else files.strip_markers(m.content)
148
+ return Said("writer" if m.role == "user" else "assistant", text, m.created_at.date().isoformat())
149
+
150
+
151
+ def _turns(items: list[Said]) -> str:
152
+ return "\n".join(f"{s.role.capitalize()} ({s.date}): {files.squeeze(s.text, TURN_CHARS)}" for s in items)
153
+
154
+
155
+ def line_refs(mem: ChatMemory, t: Topic) -> tuple[str, dict[str, int], int]:
156
+ """(the topic file's path as the agent sees it, line of each saved quote's first text line, line of the summary
157
+ text), so the block can say where every line lives and the agent can cite it."""
158
+ path = f"chats/{mem.chat_id}/{t.slug}.md"
159
+ quotes: dict[str, int] = {}
160
+ summary = 0
161
+ try:
162
+ for n, line in enumerate(mem.corpus.read_lines(files.topic_path(mem.chat_id, t.slug)), start=1):
163
+ if line.strip() == "## Summary":
164
+ summary = n + 2
165
+ for q in t.verbatim:
166
+ if line.startswith(f"[{q.message_id} "):
167
+ quotes[q.message_id] = n + 1
168
+ except Exception:
169
+ log.warning("Could not read line numbers of %s", path, exc_info=True)
170
+ return path, quotes, summary
171
+
172
+
173
+ def build_context(mem: ChatMemory, topic: str, memory: str) -> tuple[str, bool]:
174
+ """(the text block for the research agent, whether to give it the chat tools). An empty block means nothing."""
175
+ if memory not in ("lookup", "replay", "compose"):
176
+ return "", False
177
+ tools = memory in ("replay", "compose")
178
+ t = mem.topics.get(topic) or mem.topics.get(mem.latest() or "")
179
+ parts: list[str] = []
180
+ if memory in ("lookup", "replay") and t is not None:
181
+ path, quote_lines, summary_line = line_refs(mem, t)
182
+ parts.append(f"Topic: {t.label} (first {t.first}, last discussed {t.last}, {t.rounds} rounds). "
183
+ "Context only, never evidence about the posts. To cite what was said earlier, use the file and "
184
+ "line given in brackets.")
185
+ if memory == "replay" and t.verbatim:
186
+ parts.append("Exact words kept from this topic:\n" + "\n".join(
187
+ f"[{q.role}, {q.date}, {path}:{quote_lines.get(q.message_id, 1)}] "
188
+ f"{files.squeeze(q.text, files.MAX_VERBATIM_CHARS)}" for q in t.verbatim))
189
+ parts.append(f"Summary [{path}:{summary_line or 1}]: {t.summary}")
190
+ recent = [mem.messages[i] for i in t.recent if i in mem.messages][-settings.chat_memory_recent_rounds * 2:]
191
+ if memory == "lookup" and recent:
192
+ parts.append("Latest messages in this topic, word for word:\n" + _turns(recent))
193
+ elif memory in ("lookup", "replay") and not mem.backlog:
194
+ return "", tools # no topic to load: replay still gets the tools to look around
195
+ if memory == "compose" or (tools and t is None):
196
+ parts.append("Earlier conversations in this notebook (use the chat tools to read them):\n"
197
+ + (mem.index_preview or "(none yet)"))
198
+ if mem.backlog and memory in ("lookup", "replay"):
199
+ parts.append("Messages not yet filed under a topic:\n" + _turns(mem.backlog))
200
+ block = "\n\n".join(p for p in parts if p.strip())
201
+ if len(block) > settings.chat_memory_context_chars:
202
+ block = files.squeeze(block, settings.chat_memory_context_chars)
203
+ return block, tools
204
+
205
+
206
+ def memory_decision(mem: ChatMemory | None, topic: str, memory: str, notes: str, with_tools: bool) -> str:
207
+ """A plain account, for the server log, of what chat memory did for one message: whether it was read, which topic
208
+ file the summary came from, and what of it went to the agent. Answers "which summary was passed, and from where"."""
209
+ lines = [context_banner(mem, topic, memory, notes),
210
+ f"CHAT_MEMORY_READ: {'on' if settings.chat_memory_read else 'OFF'}"]
211
+ if not settings.chat_memory_read:
212
+ lines.append("result: chat memory is not read. Only the last 8 messages are passed. Summaries are still being "
213
+ "written to R2, but nothing is loaded from them. Set CHAT_MEMORY_READ=true and restart.")
214
+ return "\n".join(lines)
215
+ if mem is None:
216
+ lines.append("result: nothing loaded. This notebook has no chat memory yet, or loading it failed (look for a "
217
+ "'Chat memory could not be loaded' warning above).")
218
+ return "\n".join(lines)
219
+ lines.append(f"loaded for this notebook: {len(mem.tf.entries)} topic(s) in this chat, "
220
+ f"{len(mem.topics)} topic file(s) read, notebook index {'present' if mem.index_preview else 'empty'}")
221
+ lines.append(f"triage chose: topic={topic or '-'} memory={memory}")
222
+ t = mem.topics.get(topic) or mem.topics.get(mem.latest() or "")
223
+ if memory == "none" or (not notes and not with_tools):
224
+ lines.append("result: no summary passed (triage decided this message needs no memory of earlier chat).")
225
+ return "\n".join(lines)
226
+ if t is not None and memory in ("lookup", "replay"):
227
+ rel = files.topic_path(mem.chat_id, t.slug)
228
+ lines.append(f"summary source: {mem.corpus._key(rel)} (R2, read through the local cache)")
229
+ lines.append(f" topic '{t.label}': {t.rounds} round(s), last discussed {t.last}, "
230
+ f"summary {len(t.summary)} chars, {len(t.verbatim)} saved exact quote(s)")
231
+ passed = ["summary"]
232
+ recent = min(len(t.recent), settings.chat_memory_recent_rounds * 2)
233
+ passed.append(f"{len(t.verbatim)} exact quote(s)" if memory == "replay"
234
+ else f"last {recent} messages of the topic (database)")
235
+ if mem.backlog:
236
+ passed.append(f"{len(mem.backlog)} message(s) not yet filed (database)")
237
+ lines.append("passed to the agent: " + " + ".join(passed))
238
+ elif memory == "compose":
239
+ lines.append("passed to the agent: the notebook's list of earlier topics (INDEX.md from R2) and the chat tools")
240
+ lines.append(f"chat tools given to the agent: {'yes' if with_tools else 'no'}")
241
+ return "\n".join(lines)
242
+
243
+
244
+ def context_banner(mem: ChatMemory | None, topic: str, memory: str, notes: str) -> str:
245
+ """One line, for the server log, that says which summary was passed as context and whether the chat switched topic.
246
+ `mem.tf.current` is the topic the chat was just on, so a different topic now means the writer switched."""
247
+ if not settings.chat_memory_read:
248
+ return "=== NO SUMMARY PASSED as context: CHAT_MEMORY_READ is off ==="
249
+ if mem is None:
250
+ return "=== NO SUMMARY PASSED as context: this notebook has no chat memory yet (or it could not be loaded) ==="
251
+ if memory == "compose":
252
+ return "=== NO SINGLE SUMMARY: the notebook's list of earlier topics was passed as context ==="
253
+ t = mem.topics.get(topic) or mem.topics.get(mem.latest() or "")
254
+ if memory == "none" or t is None or not notes:
255
+ return "=== NO SUMMARY PASSED as context: this message needs no memory of earlier chat ==="
256
+ before = mem.topics.get(mem.tf.current)
257
+ if before is not None and before.slug != t.slug:
258
+ return (f"=== SUMMARY SWITCHED from '{before.label}' to '{t.label}': "
259
+ f"the summary of '{t.label}' was passed as context ({memory}) ===")
260
+ return f"=== SUMMARY CONTINUES on '{t.label}': the summary of '{t.label}' was passed as context ({memory}) ==="
backend/app/chat_memory/files.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The file formats of chat memory, as pure functions: render and parse.
2
+
3
+ Layout, relative to the notebook's chat area (R2 `ws/{workspace}/chats/{notebook}/`):
4
+
5
+ INDEX.md one line per topic across every chat of the notebook
6
+ {chat_id}/TOPICS.md this chat's topic list and the `through` pointer
7
+ {chat_id}/{slug}.md one file per topic: summary, then exact quotes
8
+
9
+ Every parser tolerates a missing or hand edited file by returning something empty, so one bad file never breaks a
10
+ chat."""
11
+
12
+ import re
13
+ import uuid
14
+ from dataclasses import dataclass, field
15
+
16
+ from app.corpus import slugify
17
+
18
+ TOPICS = "TOPICS.md"
19
+ INDEX = "INDEX.md"
20
+ MAX_TOPICS = 30 # lines kept in TOPICS.md; the least recently active are dropped from the list, never from the database
21
+ MAX_VERBATIM = 10
22
+ MAX_VERBATIM_CHARS = 1200
23
+ MAX_RECENT = 8 # message ids kept per topic: the last four rounds
24
+ MAX_KEYWORDS = 20
25
+
26
+ SMALL_TALK = re.compile(
27
+ r"^\s*(hi|hey|hello|yo|hiya|good (morning|afternoon|evening)|thanks|thank you|thx|ty|cheers|ok(ay)?|"
28
+ r"cool|great|nice|awesome|perfect|got it|sounds good|bye|goodbye|see you|lol|haha)[\s!.,:)]*$",
29
+ re.IGNORECASE,
30
+ )
31
+ _MARKER = re.compile(r"\[(\d+)\]")
32
+ _VERBATIM_HEAD = re.compile(r"^\[([0-9a-fA-F-]{36}) (writer|assistant) (\d{4}-\d{2}-\d{2})\]$")
33
+
34
+
35
+ def chat_path(chat_id: uuid.UUID | str, name: str) -> str:
36
+ return f"{chat_id}/{name}"
37
+
38
+
39
+ def topic_path(chat_id: uuid.UUID | str, slug: str) -> str:
40
+ return chat_path(chat_id, f"{slug}.md")
41
+
42
+
43
+ def topic_slug(label: str) -> str:
44
+ return slugify(label, max_len=48)
45
+
46
+
47
+ def one_line(text: str, limit: int = 200) -> str:
48
+ """A value that sits on one `a | b | c` line: no newlines, no pipes."""
49
+ return re.sub(r"\s+", " ", text.replace("|", "/")).strip()[:limit]
50
+
51
+
52
+ def squeeze(text: str, limit: int) -> str:
53
+ """Shorten a long message by keeping its start and end, cut on word boundaries."""
54
+ text = text.strip()
55
+ if len(text) <= limit:
56
+ return text
57
+ half = limit // 2
58
+ head, tail = text[:half], text[-half:]
59
+ head = head.rsplit(" ", 1)[0] if " " in head else head
60
+ tail = tail.split(" ", 1)[1] if " " in tail else tail
61
+ return f"{head.rstrip()} ... {tail.lstrip()}"
62
+
63
+
64
+ def strip_markers(text: str) -> str:
65
+ return re.sub(r"[ \t]+([.,;:!?])", r"\1", _MARKER.sub("", text))
66
+
67
+
68
+ def clean_keywords(words: list[str]) -> list[str]:
69
+ seen: dict[str, None] = {}
70
+ for w in words:
71
+ w = one_line(str(w), 40).lower().replace(",", " ").strip()
72
+ if w and w not in seen:
73
+ seen[w] = None
74
+ return list(seen)[:MAX_KEYWORDS]
75
+
76
+
77
+ def _front_matter(text: str) -> tuple[dict[str, str], str]:
78
+ """(key: value pairs, the rest) of a file that starts with a --- block."""
79
+ lines = text.splitlines()
80
+ if not lines or lines[0].strip() != "---":
81
+ return {}, text
82
+ meta: dict[str, str] = {}
83
+ for i, line in enumerate(lines[1:], start=1):
84
+ if line.strip() == "---":
85
+ return meta, "\n".join(lines[i + 1:])
86
+ if ":" in line:
87
+ key, _, value = line.partition(":")
88
+ meta[key.strip()] = value.strip()
89
+ return {}, text # never closed: treat the file as unreadable front matter
90
+
91
+
92
+ # TOPICS.md
93
+
94
+
95
+ @dataclass
96
+ class TopicEntry:
97
+ slug: str
98
+ label: str
99
+ last: str # YYYY-MM-DD of the last round
100
+ rounds: int
101
+ keywords: str # comma separated
102
+ gist: str
103
+
104
+
105
+ @dataclass
106
+ class TopicsFile:
107
+ chat_id: str = ""
108
+ through: str = "" # id of the last message processed, "" before the first run
109
+ current: str = "" # slug of the topic the newest filed round went to: what the chat was just talking about
110
+ updated: str = ""
111
+ entries: list[TopicEntry] = field(default_factory=list)
112
+
113
+ def get(self, slug: str) -> TopicEntry | None:
114
+ return next((e for e in self.entries if e.slug == slug), None)
115
+
116
+ @property
117
+ def slugs(self) -> set[str]:
118
+ return {e.slug for e in self.entries}
119
+
120
+
121
+ def render_topics(tf: TopicsFile) -> str:
122
+ entries = sorted(tf.entries, key=lambda e: e.last, reverse=True)[:MAX_TOPICS]
123
+ lines = ["---", f"chat: {tf.chat_id}", f"through: {tf.through or 'none'}", f"current: {tf.current or 'none'}",
124
+ f"updated: {tf.updated}", "---"]
125
+ for e in entries:
126
+ lines.append(f"- {e.slug} | {one_line(e.label, 80)} | {e.last} | {e.rounds} rounds | "
127
+ f"{one_line(e.keywords, 200)} | {one_line(e.gist, 200)}")
128
+ return "\n".join(lines) + "\n"
129
+
130
+
131
+ def parse_topics(text: str) -> TopicsFile:
132
+ meta, body = _front_matter(text or "")
133
+ through = meta.get("through", "")
134
+ current = meta.get("current", "")
135
+ tf = TopicsFile(chat_id=meta.get("chat", ""), through="" if through in ("", "none") else through,
136
+ current="" if current in ("", "none") else current, updated=meta.get("updated", ""))
137
+ for line in body.splitlines():
138
+ if not line.startswith("- "):
139
+ continue
140
+ parts = [p.strip() for p in line[2:].split("|")]
141
+ if len(parts) < 6:
142
+ continue
143
+ rounds = re.match(r"\d+", parts[3])
144
+ tf.entries.append(TopicEntry(slug=parts[0], label=parts[1], last=parts[2],
145
+ rounds=int(rounds.group()) if rounds else 0,
146
+ keywords=parts[4], gist="|".join(parts[5:])))
147
+ return tf
148
+
149
+
150
+ # {slug}.md
151
+
152
+
153
+ @dataclass
154
+ class Quote:
155
+ message_id: str
156
+ role: str # writer | assistant
157
+ date: str
158
+ text: str
159
+
160
+
161
+ @dataclass
162
+ class Topic:
163
+ label: str
164
+ slug: str
165
+ chat_id: str
166
+ first: str = ""
167
+ last: str = ""
168
+ rounds: int = 0
169
+ keywords: list[str] = field(default_factory=list)
170
+ gist: str = ""
171
+ recent: list[str] = field(default_factory=list) # message ids, oldest first
172
+ last_message: str = "" # the newest message filed here: a rerun after a crash skips rounds up to it
173
+ summary: str = ""
174
+ verbatim: list[Quote] = field(default_factory=list)
175
+
176
+
177
+ def clean_summary(text: str, limit: int) -> str:
178
+ """The summary sits under a `## Summary` heading, so a heading inside it would break the file's sections."""
179
+ text = "\n".join(re.sub(r"^#+\s*", "", ln) for ln in (text or "").strip().splitlines())
180
+ return squeeze(text, limit) if len(text) > limit else text
181
+
182
+
183
+ def render_topic(t: Topic) -> str:
184
+ lines = ["---", f"topic: {one_line(t.label, 80)}", f"slug: {t.slug}", f"chat: {t.chat_id}", f"first: {t.first}",
185
+ f"last: {t.last}", f"rounds: {t.rounds}", f"keywords: {', '.join(t.keywords)}",
186
+ f"gist: {one_line(t.gist, 200)}", f"recent: {', '.join(t.recent)}", f"last_message: {t.last_message}",
187
+ "---", "", "## Summary", "",
188
+ t.summary.strip(), "", "## Verbatim", ""]
189
+ for q in t.verbatim:
190
+ lines.append(f"[{q.message_id} {q.role} {q.date}]")
191
+ lines += [f"> {ln}" if ln else ">" for ln in q.text.splitlines()]
192
+ lines.append("")
193
+ return "\n".join(lines).rstrip() + "\n"
194
+
195
+
196
+ def parse_topic(text: str) -> Topic | None:
197
+ meta, body = _front_matter(text or "")
198
+ if not meta.get("slug"):
199
+ return None
200
+ t = Topic(label=meta.get("topic", meta["slug"]), slug=meta["slug"], chat_id=meta.get("chat", ""),
201
+ first=meta.get("first", ""), last=meta.get("last", ""),
202
+ keywords=[k.strip() for k in meta.get("keywords", "").split(",") if k.strip()],
203
+ gist=meta.get("gist", ""), recent=[r.strip() for r in meta.get("recent", "").split(",") if r.strip()],
204
+ last_message=meta.get("last_message", ""))
205
+ rounds = re.match(r"\d+", meta.get("rounds", ""))
206
+ t.rounds = int(rounds.group()) if rounds else 0
207
+ section, summary, current = "", [], None
208
+ for line in body.splitlines():
209
+ if line.startswith("## "):
210
+ section = line[3:].strip().lower()
211
+ current = None
212
+ continue
213
+ if section == "summary":
214
+ summary.append(line)
215
+ elif section == "verbatim":
216
+ head = _VERBATIM_HEAD.match(line)
217
+ if head:
218
+ current = Quote(head.group(1), head.group(2), head.group(3), "")
219
+ t.verbatim.append(current)
220
+ elif current is not None and line.startswith(">"):
221
+ current.text += ("\n" if current.text else "") + line[1:].removeprefix(" ")
222
+ t.summary = "\n".join(summary).strip()
223
+ return t
224
+
225
+
226
+ # INDEX.md (notebook level)
227
+
228
+
229
+ def index_line(chat_id: str, entry: TopicEntry) -> str:
230
+ return (f"{entry.last} | {topic_path(chat_id, entry.slug)} | {one_line(entry.label, 80)} | "
231
+ f"{one_line(entry.keywords, 200)} | {one_line(entry.gist, 200)}")
232
+
233
+
234
+ def render_index(per_chat: dict[str, list[TopicEntry]]) -> str:
235
+ """Newest first, one line per topic. Rebuilt from every chat's TOPICS.md, never edited in place."""
236
+ rows = [(e.last, index_line(chat, e)) for chat, entries in per_chat.items() for e in entries]
237
+ return "\n".join(line for _, line in sorted(rows, key=lambda r: r[0], reverse=True)) + ("\n" if rows else "")
backend/app/chat_memory/logs.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Server log switches for chat memory.
2
+
3
+ Two loggers, because they hold different things:
4
+ notestack.chat_memory what the summary job did: which chat, how many rounds, which R2 keys it wrote and how big. No
5
+ writer text, so it is always on.
6
+ notestack.chat_context what the research agent is given on each message, and the text of new summaries. That is
7
+ writers' text, so it is on in development, or when CHAT_CONTEXT_LOG=true."""
8
+
9
+ import logging
10
+
11
+ from app.config import settings
12
+
13
+ job_log = logging.getLogger("notestack.chat_memory")
14
+ context_log = logging.getLogger("notestack.chat_context")
15
+
16
+
17
+ def text_enabled() -> bool:
18
+ """Whether logs may contain writers' text: CHAT_CONTEXT_LOG if set, else on only in development."""
19
+ return settings.chat_context_log if settings.chat_context_log is not None else settings.env == "development"
backend/app/chat_memory/organize.py ADDED
@@ -0,0 +1,528 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The write path of chat memory: after an answer, a background job files the new rounds of a chat under topics and
2
+ updates each topic's summary, keywords and exact quotes.
3
+
4
+ The database stays the source of truth for what was said. The model only chooses: which topic a round belongs to,
5
+ what to write in a summary, and which messages (by short labels like m4) are worth keeping. Code does the rest: it
6
+ maps labels back to real messages, copies the quotes out of the database word for word, validates everything the
7
+ model returned, and writes the files in an order that makes a crash harmless (topic files, then the notebook index,
8
+ then TOPICS.md with the `through` pointer last)."""
9
+
10
+ import logging
11
+ import re
12
+ import uuid
13
+ from dataclasses import dataclass, field
14
+ from datetime import timedelta
15
+
16
+ from sqlalchemy import select
17
+ from sqlalchemy.orm import Session
18
+
19
+ from app.chat_memory import files
20
+ from app.chat_memory.files import INDEX, TOPICS, Quote, Topic, TopicEntry, TopicsFile
21
+ from app.chat_memory.logs import context_log, job_log, text_enabled
22
+ from app.config import settings
23
+ from app.corpus import Corpus, CorpusError
24
+ from app.llm import run
25
+ from app.llm.provider import fast_lm
26
+ from app.llm.signatures import OrganizeChat
27
+ from app.models import Chat, Job, Message, Notebook
28
+ from app.services.jobs import create_job, utcnow
29
+
30
+ log = logging.getLogger(__name__)
31
+
32
+ WRITER_CHARS = 600 # per message sent to the model
33
+ ASSISTANT_CHARS = 900
34
+ SUMMARIES_SHOWN = 12 # topics whose current summary is shown to the model
35
+ SUMMARY_SHOWN_CHARS = 700
36
+ FALLBACK_TOPIC = "General chat"
37
+ # A new topic whose words overlap an existing topic's this much is the same topic, so its rounds join that one.
38
+ MERGE_OVERLAP = 0.5 # shared words as a share of the smaller of the two word sets
39
+ MERGE_MIN_SHARED = 4 # and at least this many shared words, so two short lists cannot match by luck
40
+ REFUSAL_OPENING = "I only answer from the posts in this notebook" # canned off-topic reply, rows saved before kinds
41
+
42
+
43
+ def chat_corpus(workspace_id: uuid.UUID, notebook_id: uuid.UUID, store=None, cache_dir: str | None = None) -> Corpus:
44
+ """The notebook's chat area: its own manifest, disk cache and R2 prefix `ws/{workspace}/chats/{notebook}/`."""
45
+ return Corpus(workspace_id, store=store, cache_dir=cache_dir, area=f"chats/{notebook_id}")
46
+
47
+
48
+ # Rounds
49
+
50
+
51
+ @dataclass
52
+ class Round:
53
+ label: str # r1, r2, ...
54
+ writer: Message | None
55
+ assistant: Message | None
56
+
57
+ @property
58
+ def messages(self) -> list[Message]:
59
+ return [m for m in (self.writer, self.assistant) if m is not None]
60
+
61
+
62
+ def ordered_messages(db: Session, chat_id: uuid.UUID) -> list[Message]:
63
+ """Oldest first. On a timestamp tie the writer's message comes before the reply ("user" sorts after "assistant")."""
64
+ return list(db.scalars(select(Message).where(Message.chat_id == chat_id)
65
+ .order_by(Message.created_at, Message.role.desc())))
66
+
67
+
68
+ def after_through(messages: list[Message], through: str) -> list[Message]:
69
+ if not through:
70
+ return messages
71
+ for i, m in enumerate(messages):
72
+ if str(m.id) == through:
73
+ return messages[i + 1:]
74
+ return messages # the pointer names a message that is gone: start over rather than lose the chat
75
+
76
+
77
+ def pair_rounds(messages: list[Message]) -> list[Round]:
78
+ """A writer message with the assistant message after it. A trailing writer message with no reply yet is left
79
+ for the next run, so its answer is filed together with it."""
80
+ rounds: list[Round] = []
81
+ i = 0
82
+ while i < len(messages):
83
+ m = messages[i]
84
+ if m.role == "user":
85
+ nxt = messages[i + 1] if i + 1 < len(messages) else None
86
+ if nxt is not None and nxt.role == "assistant":
87
+ rounds.append(Round("", m, nxt))
88
+ i += 2
89
+ continue
90
+ if nxt is None:
91
+ break # waiting for the answer
92
+ rounds.append(Round("", m, None))
93
+ else:
94
+ rounds.append(Round("", None, m))
95
+ i += 1
96
+ for n, r in enumerate(rounds, start=1):
97
+ r.label = f"r{n}"
98
+ return rounds
99
+
100
+
101
+ def _post_paths(m: Message) -> list[str]:
102
+ return sorted({c.path for c in m.citations if getattr(c, "kind", "post") == "post"})
103
+
104
+
105
+ def render_rounds(rounds: list[Round]) -> tuple[str, dict[str, Message]]:
106
+ """The prompt text for the rounds, and the table from short labels (m1, m2) back to real messages. The model
107
+ never sees or writes a real id."""
108
+ labels: dict[str, Message] = {}
109
+ out: list[str] = []
110
+ n = 0
111
+ for r in rounds:
112
+ out.append(r.label)
113
+ for m in r.messages:
114
+ n += 1
115
+ label = f"m{n}"
116
+ labels[label] = m
117
+ if m.role == "user":
118
+ out.append(f"{label} Writer: {files.squeeze(m.content, WRITER_CHARS)}")
119
+ else:
120
+ out.append(f"{label} Assistant: {files.squeeze(files.strip_markers(m.content), ASSISTANT_CHARS)}")
121
+ posts = _post_paths(m)
122
+ if posts:
123
+ out.append(f" (posts used: {', '.join(posts)})")
124
+ out.append("")
125
+ return "\n".join(out).strip(), labels
126
+
127
+
128
+ # Reading the current state
129
+
130
+
131
+ def read_topics_file(corpus: Corpus, chat_id: uuid.UUID | str) -> TopicsFile:
132
+ path = files.chat_path(chat_id, TOPICS)
133
+ try:
134
+ if corpus.exists(path):
135
+ tf = files.parse_topics("\n".join(corpus.read_lines(path)))
136
+ tf.chat_id = tf.chat_id or str(chat_id)
137
+ return tf
138
+ except (CorpusError, OSError):
139
+ log.warning("Unreadable %s, starting that chat's topics over", path, exc_info=True)
140
+ return TopicsFile(chat_id=str(chat_id))
141
+
142
+
143
+ def read_topic(corpus: Corpus, chat_id: uuid.UUID | str, slug: str) -> Topic | None:
144
+ path = files.topic_path(chat_id, slug)
145
+ try:
146
+ return files.parse_topic("\n".join(corpus.read_lines(path))) if corpus.exists(path) else None
147
+ except (CorpusError, OSError):
148
+ return None
149
+
150
+
151
+ def topics_prompt(corpus: Corpus, chat_id: uuid.UUID | str, tf: TopicsFile) -> tuple[str, set[str]]:
152
+ """The topics so far for the model, and which of them had their summary shown."""
153
+ if not tf.entries:
154
+ return "(none)", set()
155
+ entries = sorted(tf.entries, key=lambda e: e.last, reverse=True)
156
+ lines = [f"{e.slug} | {e.label} | {e.gist} | {e.keywords}" for e in entries]
157
+ shown: set[str] = set()
158
+ blocks: list[str] = []
159
+ for e in entries[:SUMMARIES_SHOWN]:
160
+ topic = read_topic(corpus, chat_id, e.slug)
161
+ if topic and topic.summary:
162
+ shown.add(e.slug)
163
+ blocks.append(f"Summary of {e.slug}: {files.squeeze(topic.summary, SUMMARY_SHOWN_CHARS)}")
164
+ return "\n".join(lines + [""] + blocks), shown
165
+
166
+
167
+ # Reading the model's answer
168
+
169
+
170
+ @dataclass
171
+ class Plan:
172
+ """What the model decided, checked against reality: slug -> rounds, plus the update for each slug."""
173
+
174
+ rounds: dict[str, list[Round]] = field(default_factory=dict)
175
+ labels: dict[str, str] = field(default_factory=dict) # slug -> label for topics that are new
176
+ updates: dict[str, dict] = field(default_factory=dict)
177
+ extend: set[str] = field(default_factory=set) # existing topics a new topic was merged into: extend, not replace
178
+ merged: dict[str, str] = field(default_factory=dict) # new slug -> the existing slug it was folded into
179
+
180
+
181
+ def _resolve(raw: str, known: dict[str, str], plan: Plan) -> str | None:
182
+ """A slug for the model's topic string, or None for SKIP. `known` maps slug -> label for existing topics."""
183
+ raw = (raw or "").strip()
184
+ if not raw or raw.upper() == "SKIP":
185
+ return None
186
+ if raw.upper().startswith("NEW:"):
187
+ label = raw[4:].strip() or FALLBACK_TOPIC
188
+ elif raw in known or raw in plan.labels:
189
+ return raw
190
+ else:
191
+ label = raw.replace("-", " ").strip() or FALLBACK_TOPIC
192
+ slug = files.topic_slug(label)
193
+ if slug not in known:
194
+ plan.labels.setdefault(slug, label[:80])
195
+ return slug
196
+
197
+
198
+ # Answers that were not about the writer's posts: the question was turned down, answered from the app's help text, or
199
+ # was small talk. They say nothing about the posts, and filing them adds a topic every later message has to be weighed
200
+ # against. `kind` is saved with every answer (recall_json) by the chat endpoint.
201
+ NOT_ABOUT_THE_POSTS = {"off_topic", "about_app", "chitchat", "error"} # error: the "please send that again" reply
202
+
203
+
204
+ def is_off_topic(r: Round) -> bool:
205
+ """True when the assistant's answer was not about the posts (see NOT_ABOUT_THE_POSTS), so the round is not filed."""
206
+ a = r.assistant
207
+ if a is None:
208
+ return False
209
+ kind = (a.recall_json or {}).get("kind")
210
+ if kind:
211
+ return kind in NOT_ABOUT_THE_POSTS
212
+ return not a.citations and a.content.strip().startswith(REFUSAL_OPENING) # an answer saved before kinds existed
213
+
214
+
215
+ _STOPWORDS = {"the", "and", "for", "with", "from", "that", "this", "what", "how", "about", "post", "posts", "chat",
216
+ "writer", "assistant", "question", "answer", "per", "its", "are", "was", "has", "have"}
217
+
218
+
219
+ def _tokens(text: str) -> set[str]:
220
+ """Lower case words, with a plural s dropped, so "agents" and "agent" count as one word."""
221
+ words = re.findall(r"[a-z0-9]+", text.lower())
222
+ return {w[:-1] if len(w) > 4 and w.endswith("s") else w for w in words if len(w) >= 3 and w not in _STOPWORDS}
223
+
224
+
225
+ def merge_close_topics(plan: Plan, tf: TopicsFile) -> None:
226
+ """Fold a brand new topic into an existing one when their keywords mostly agree. The model reads a short topic
227
+ list and tends to open a fresh topic for a follow up that really continues an old one, which splits one subject
228
+ across several files. Different questions on a related theme (few shared words) stay apart."""
229
+ for slug in [s for s in plan.labels if s in plan.rounds and not tf.get(s)]:
230
+ update = plan.updates.get(slug) or {}
231
+ mine = _tokens(f"{plan.labels[slug]} {' '.join(str(k) for k in update.get('keywords') or [])}")
232
+ best: tuple[float, int, str] | None = None
233
+ for e in tf.entries:
234
+ theirs = _tokens(f"{e.label} {e.keywords}")
235
+ shared = len(mine & theirs)
236
+ score = shared / max(min(len(mine), len(theirs)), 1)
237
+ if shared >= MERGE_MIN_SHARED and score >= MERGE_OVERLAP and (best is None or score > best[0]):
238
+ best = (score, shared, e.slug)
239
+ if best is None:
240
+ continue
241
+ target = best[2]
242
+ plan.rounds.setdefault(target, []).extend(plan.rounds.pop(slug))
243
+ plan.rounds[target].sort(key=lambda r: int(r.label[1:]))
244
+ plan.labels.pop(slug, None)
245
+ plan.updates.pop(slug, None)
246
+ plan.merged[slug] = target
247
+ there = plan.updates.get(target)
248
+ if there is None:
249
+ # The model wrote this summary for the new topic alone: it is added to the old one, and the old gist stays.
250
+ plan.updates[target] = {k: v for k, v in update.items() if k not in {"topic", "gist"}} | {"topic": target}
251
+ plan.extend.add(target)
252
+ else: # the model also updated the old topic in this run: join the two updates
253
+ there["summary"] = f"{there.get('summary', '')} {update.get('summary', '')}".strip()
254
+ there["keywords"] = list(there.get("keywords") or []) + list(update.get("keywords") or [])
255
+ there["keep"] = list(there.get("keep") or []) + list(update.get("keep") or [])
256
+ job_log.info("chat memory: new topic '%s' overlaps '%s' (%d shared words, %.0f%%), so its rounds were filed "
257
+ "there instead", slug, target, best[1], best[0] * 100)
258
+
259
+
260
+ def _is_small_talk(r: Round) -> bool:
261
+ """SKIP is for greetings and thanks only. The model is asked that, but a real follow up it skipped is not lost."""
262
+ return r.writer is not None and bool(files.SMALL_TALK.match(r.writer.content))
263
+
264
+
265
+ def build_plan(out: dict, batch: list[Round], labels: dict[str, Message], tf: TopicsFile) -> Plan:
266
+ plan = Plan()
267
+ known = {e.slug: e.label for e in tf.entries}
268
+ by_label = {r.label: r for r in batch}
269
+ chosen: dict[str, str | None] = {} # round label -> slug, or None for SKIP
270
+ for a in out.get("assignments") or []:
271
+ label = str(a.get("round", "")).strip()
272
+ if label in by_label and label not in chosen:
273
+ chosen[label] = _resolve(a.get("topic", ""), known, plan)
274
+ previous = max(tf.entries, key=lambda e: e.last).slug if tf.entries else None
275
+ for r in batch: # a round the model forgot, or skipped though it is a real question, is filed with the one before
276
+ if r.label not in chosen or (chosen[r.label] is None and not _is_small_talk(r)):
277
+ chosen[r.label] = previous or _resolve(f"NEW:{FALLBACK_TOPIC}", known, plan)
278
+ if chosen[r.label] is not None:
279
+ previous = chosen[r.label]
280
+ plan.rounds.setdefault(chosen[r.label], []).append(r)
281
+ for u in out.get("updates") or []:
282
+ slug = _resolve(u.get("topic", ""), known, plan)
283
+ if slug is not None and slug in plan.rounds: # an update for a topic that got no rounds is ignored
284
+ plan.updates[slug] = u
285
+ for rs in plan.rounds.values():
286
+ rs.sort(key=lambda r: int(r.label[1:]))
287
+ merge_close_topics(plan, tf)
288
+ return plan
289
+
290
+
291
+ def _date(m: Message) -> str:
292
+ return m.created_at.date().isoformat() if m.created_at else utcnow().date().isoformat()
293
+
294
+
295
+ def _quote(m: Message) -> Quote:
296
+ text = m.content if m.role == "user" else files.strip_markers(m.content)
297
+ text = text.strip()
298
+ if len(text) > files.MAX_VERBATIM_CHARS:
299
+ text = text[:files.MAX_VERBATIM_CHARS].rstrip() + " [...]"
300
+ return Quote(str(m.id), "writer" if m.role == "user" else "assistant", _date(m), text)
301
+
302
+
303
+ def apply_plan(plan: Plan, existing: dict[str, Topic], shown: set[str], chat_id: str,
304
+ labels: dict[str, Message], position: dict[str, int]) -> dict[str, Topic]:
305
+ """The new state of every topic that got rounds. Quotes are copied from the database here, by code.
306
+
307
+ `position` maps a message id to its place in the chat. A topic file remembers the newest message filed in it, so
308
+ after a crash that left topic files written but the pointer unmoved, the rerun skips the rounds already counted
309
+ instead of counting them twice."""
310
+ result: dict[str, Topic] = {}
311
+ for slug, rounds in plan.rounds.items():
312
+ topic = existing.get(slug) or Topic(label=plan.labels.get(slug, slug.replace("-", " ")), slug=slug,
313
+ chat_id=chat_id)
314
+ done_to = position.get(topic.last_message, -1)
315
+ fresh = [r for r in rounds if position[str(r.messages[-1].id)] > done_to]
316
+ msgs = [m for r in rounds for m in r.messages]
317
+ dates = sorted(_date(m) for m in msgs)
318
+ topic.first = min(filter(None, [topic.first, dates[0]]))
319
+ topic.last = max(filter(None, [topic.last, dates[-1]]))
320
+ topic.rounds += len(fresh)
321
+ topic.recent = (topic.recent + [str(m.id) for r in fresh for m in r.messages])[-files.MAX_RECENT:]
322
+ topic.last_message = str(max((r.messages[-1] for r in rounds), key=lambda m: position[str(m.id)]).id)
323
+ update = plan.updates.get(slug) or {}
324
+ new_summary = files.clean_summary(update.get("summary", ""), settings.chat_memory_summary_chars)
325
+ if not new_summary:
326
+ if not topic.summary: # a new topic the model gave no summary for: say what was asked
327
+ first = next((m.content for m in msgs if m.role == "user"), msgs[0].content)
328
+ new_summary = f"The writer asked: {files.squeeze(first, 240)}"
329
+ elif topic.summary and slug not in shown: # the model never saw the old summary, so extend instead of replace
330
+ new_summary = files.clean_summary(f"{topic.summary} {new_summary}", settings.chat_memory_summary_chars)
331
+ topic.summary = new_summary or topic.summary
332
+ topic.gist = files.one_line(update.get("gist", "") or topic.gist or topic.label)
333
+ topic.keywords = files.clean_keywords(list(update.get("keywords") or []) + topic.keywords)
334
+ allowed = {id(m): m for m in msgs}
335
+ wanted: list[Message] = []
336
+ for label in update.get("keep") or []:
337
+ m = labels.get(str(label).strip())
338
+ if m is not None and id(m) in allowed and m not in wanted: # only messages from this topic's rounds
339
+ wanted.append(m)
340
+ topic.verbatim = _merge_quotes(topic.verbatim, [_quote(m) for m in wanted])
341
+ result[slug] = topic
342
+ return result
343
+
344
+
345
+ def _merge_quotes(old: list[Quote], new: list[Quote]) -> list[Quote]:
346
+ new_ids = {q.message_id for q in new}
347
+ merged = [q for q in old if q.message_id not in new_ids] + new
348
+ while len(merged) > files.MAX_VERBATIM:
349
+ drop = next((i for i, q in enumerate(merged) if q.message_id not in new_ids), 0)
350
+ merged.pop(drop)
351
+ return merged
352
+
353
+
354
+ def entry_for(t: Topic) -> TopicEntry:
355
+ return TopicEntry(slug=t.slug, label=t.label, last=t.last, rounds=t.rounds, keywords=", ".join(t.keywords),
356
+ gist=t.gist)
357
+
358
+
359
+ # The job
360
+
361
+
362
+ def _lock_notebook(db: Session, notebook_id: uuid.UUID) -> None:
363
+ """Hold the notebook row (FOR UPDATE on Postgres) while writing, so two chats of one notebook cannot overwrite
364
+ each other's manifest or the notebook index. Released by the next commit. Held only for the file writes, never
365
+ for the model call."""
366
+ db.scalar(select(Notebook.id).where(Notebook.id == notebook_id).with_for_update())
367
+
368
+
369
+ def other_chats(corpus: Corpus, manifest: dict[str, str], chat_id: str) -> dict[str, list[TopicEntry]]:
370
+ out: dict[str, list[TopicEntry]] = {}
371
+ for rel in manifest:
372
+ chat, _, name = rel.partition("/")
373
+ if name == TOPICS and chat != chat_id:
374
+ out[chat] = read_topics_file(corpus, chat).entries
375
+ return out
376
+
377
+
378
+ def organize_chat(db: Session, job: Job | None, chat: Chat, corpus: Corpus | None = None,
379
+ requeue: bool = True) -> dict:
380
+ """File the chat's new rounds under topics. Safe to run twice: the result depends only on the database and the
381
+ `through` pointer, and the pointer moves last."""
382
+ corpus = corpus or chat_corpus(chat.workspace_id, chat.notebook_id)
383
+ chat_id = str(chat.id)
384
+ corpus.sync()
385
+ tf = read_topics_file(corpus, chat_id)
386
+ start_through = tf.through
387
+ ordered = ordered_messages(db, chat.id)
388
+ rounds = pair_rounds(after_through(ordered, start_through))
389
+ if not rounds:
390
+ job_log.info("chat memory: chat %s has nothing new to file (through=%s)", chat_id[:8], start_through[:8] or "-")
391
+ return {"rounds": 0, "topics": 0, "more": False}
392
+ batch = rounds[:max(settings.chat_memory_max_rounds_per_job, 1)]
393
+ more = len(rounds) > len(batch)
394
+ job_log.info("chat memory: filing %d of %d waiting round(s) of chat %s (notebook %s), through=%s, "
395
+ "%d topic(s) so far", len(batch), len(rounds), chat_id[:8], str(chat.notebook_id)[:8],
396
+ start_through[:8] or "-", len(tf.entries))
397
+
398
+ # Rounds that were not about the posts (turned down, app help, small talk) are never filed, so they reach neither
399
+ # the model nor R2. They still count as handled: the pointer moves past them below.
400
+ work = [r for r in batch if not is_off_topic(r)]
401
+ if len(work) < len(batch):
402
+ job_log.info("chat memory: %d round(s) of chat %s were not about the posts (kind %s) and are not filed",
403
+ len(batch) - len(work), chat_id[:8], "/".join(sorted(NOT_ABOUT_THE_POSTS)))
404
+ topics_text, shown = topics_prompt(corpus, chat_id, tf)
405
+ rounds_text, labels = render_rounds(work)
406
+ out = run.predict(OrganizeChat, db=db, workspace_id=chat.workspace_id, job=job, lm=fast_lm(),
407
+ topics=topics_text, rounds=rounds_text) if work else {}
408
+ plan = build_plan(out, work, labels, tf)
409
+ shown -= plan.extend # a merged-in summary covers only the new rounds, so it is added to the old one
410
+ # Only topics listed in TOPICS.md are real. A topic file that is not listed was left by a run that crashed before
411
+ # the pointer moved, so it is rebuilt from scratch instead of being extended.
412
+ existing = {slug: t for slug in plan.rounds if tf.get(slug) and (t := read_topic(corpus, chat_id, slug))}
413
+ position = {str(m.id): i for i, m in enumerate(ordered)}
414
+ old_chars = {slug: len(t.summary) for slug, t in existing.items()} # apply_plan edits these topics in place
415
+ topics = apply_plan(plan, existing, shown, chat_id, labels, position)
416
+ previous = tf.current
417
+ for r in batch: # in order: a change of topic between one round and the next is a switch
418
+ slug = next((s for s, rs in plan.rounds.items() if r in rs), None)
419
+ if slug is None:
420
+ continue
421
+ if previous and slug != previous:
422
+ known = topics.get(previous) or tf.get(previous) # filed in this batch, or listed from an earlier one
423
+ job_log.info("=== TOPIC SWITCHED while filing chat %s: '%s' -> '%s' (round %s) ===", chat_id[:8],
424
+ known.label if known else previous, topics[slug].label, r.label)
425
+ previous = slug
426
+ tf.current = previous or tf.current
427
+ job_log.info("chat memory: model %s filed the rounds as %s", fast_lm().model,
428
+ {slug: [r.label for r in rs] for slug, rs in plan.rounds.items()} or "no topic (small talk only)")
429
+ for slug, topic in topics.items():
430
+ job_log.info("=== SUMMARY %s: topic '%s' (%s) %s -> %d chars ===", "CHANGED" if slug in existing else "CREATED",
431
+ topic.label, slug, f"{old_chars[slug]}" if slug in existing else "new", len(topic.summary))
432
+ job_log.info("chat memory: summary %s for topic '%s' (%s): %d round(s), summary %d chars, %d exact quote(s), "
433
+ "%d keyword(s)", "UPDATED" if slug in existing else "CREATED", topic.label, slug, topic.rounds,
434
+ len(topic.summary), len(topic.verbatim), len(topic.keywords))
435
+ if text_enabled():
436
+ context_log.info("chat memory: new summary of '%s':\n%s\nkeywords: %s", topic.label, topic.summary,
437
+ ", ".join(topic.keywords))
438
+
439
+ for slug, topic in topics.items():
440
+ tf.entries = [e for e in tf.entries if e.slug != slug] + [entry_for(topic)]
441
+ tf.through = str(batch[-1].messages[-1].id)
442
+ tf.updated = utcnow().isoformat()
443
+
444
+ _lock_notebook(db, chat.notebook_id)
445
+ manifest = corpus.sync() # inside the lock: other chats' latest files, and a compare and set on `through`
446
+ if read_topics_file(corpus, chat_id).through != start_through:
447
+ db.rollback()
448
+ job_log.warning("chat memory: chat %s skipped: another run moved the pointer while this one was working",
449
+ chat_id[:8])
450
+ return {"rounds": 0, "topics": 0, "more": False, "skipped": "another run moved the pointer"}
451
+ per_chat = other_chats(corpus, manifest, chat_id)
452
+ per_chat[chat_id] = tf.entries
453
+ # Order matters: topic files and the index first, the pointer last. A crash before the last write leaves the old
454
+ # pointer, so the next run files the same rounds again and writes the same files.
455
+ topic_files = {files.topic_path(chat_id, s): files.render_topic(t) for s, t in topics.items()}
456
+ index_text, topics_text = files.render_index(per_chat), files.render_topics(tf)
457
+ corpus.write_files({**topic_files, INDEX: index_text})
458
+ corpus.write_files({files.chat_path(chat_id, TOPICS): topics_text})
459
+ for rel, body in [*topic_files.items(), (INDEX, index_text), (files.chat_path(chat_id, TOPICS), topics_text)]:
460
+ job_log.info("chat memory: wrote to R2: %s (%d bytes)", corpus._key(rel), len(body.encode()))
461
+ job_log.info("chat memory: chat %s through pointer moved %s -> %s", chat_id[:8], start_through[:8] or "-",
462
+ tf.through[:8])
463
+ db.commit()
464
+ if more and requeue:
465
+ create_job(db, chat.workspace_id, "chat_memory", {"chat_id": chat_id}, max_attempts=2,
466
+ run_after=utcnow() + timedelta(seconds=1))
467
+ return {"rounds": len(batch), "topics": len(topics), "more": more}
468
+
469
+
470
+ def forget_chat(workspace_id: uuid.UUID, notebook_id: uuid.UUID, chat_id: uuid.UUID, db: Session | None = None,
471
+ corpus: Corpus | None = None) -> int:
472
+ """Remove a deleted chat's files and its lines in the notebook index. Returns the number of files removed."""
473
+ corpus = corpus or chat_corpus(workspace_id, notebook_id)
474
+ if db is not None:
475
+ _lock_notebook(db, notebook_id)
476
+ manifest = corpus.sync()
477
+ gone = {rel for rel in manifest if rel.startswith(f"{chat_id}/")}
478
+ if not gone:
479
+ return 0
480
+ per_chat = other_chats(corpus, manifest, str(chat_id))
481
+ corpus.write_files({INDEX: files.render_index(per_chat)}, remove=gone)
482
+ job_log.info("chat memory: removed %d file(s) of chat %s from R2 and rebuilt %s", len(gone), str(chat_id)[:8],
483
+ corpus._key(INDEX))
484
+ if db is not None:
485
+ db.commit()
486
+ return len(gone)
487
+
488
+
489
+ def queue_chat_memory(db: Session, workspace_id: uuid.UUID, chat_id: uuid.UUID) -> Job | None:
490
+ """Queue one memory job for the chat unless one is already waiting. A job that is already running does not count:
491
+ it may have read the messages before the newest answer was saved."""
492
+ if not (settings.chat_memory_enabled and settings.llm_api_key):
493
+ return None
494
+ waiting = db.scalars(select(Job).where(Job.workspace_id == workspace_id, Job.kind == "chat_memory",
495
+ Job.status == "queued")).all()
496
+ if any((j.params or {}).get("chat_id") == str(chat_id) for j in waiting):
497
+ return None
498
+ return create_job(db, workspace_id, "chat_memory", {"chat_id": str(chat_id)}, max_attempts=2,
499
+ run_after=utcnow() + timedelta(seconds=settings.chat_memory_delay_seconds))
500
+
501
+
502
+ def rebuild_chat(db: Session, job: Job | None, chat: Chat, corpus: Corpus | None = None) -> dict:
503
+ """Throw away a chat's memory and build it again from its messages: for a lost or damaged R2 area, or after the
504
+ prompt changes. Costs one model call per batch of rounds."""
505
+ corpus = corpus or chat_corpus(chat.workspace_id, chat.notebook_id)
506
+ forget_chat(chat.workspace_id, chat.notebook_id, chat.id, db, corpus)
507
+ total = {"rounds": 0, "topics": 0}
508
+ while True:
509
+ done = organize_chat(db, job, chat, corpus, requeue=False)
510
+ total["rounds"] += done["rounds"]
511
+ total["topics"] = max(total["topics"], done["topics"])
512
+ if not done["more"]:
513
+ return total
514
+
515
+
516
+ def forget_orphans(db: Session, workspace_id: uuid.UUID, notebook_id: uuid.UUID, corpus: Corpus | None = None) -> int:
517
+ """Remove memory for chats that no longer exist (a deletion whose cleanup failed, or a database restore). Returns
518
+ the number of chat folders removed."""
519
+ corpus = corpus or chat_corpus(workspace_id, notebook_id)
520
+ folders = {rel.partition("/")[0] for rel in corpus.sync() if "/" in rel}
521
+ live = {str(i) for i in db.scalars(select(Chat.id).where(Chat.notebook_id == notebook_id))}
522
+ orphans = sorted(folders - live)
523
+ for folder in orphans:
524
+ try:
525
+ forget_chat(workspace_id, notebook_id, uuid.UUID(folder), db, corpus)
526
+ except ValueError: # not a chat folder: leave it alone
527
+ continue
528
+ return len(orphans)
backend/app/chat_memory/search.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ranked search over a notebook's chat memory: BM25, written in place.
2
+
3
+ grep finds exact strings but cannot rank and misses synonyms. Each topic file carries keywords with synonyms, and
4
+ every searchable unit (a saved quote or the summary) is scored together with its topic's label, gist and keywords, so
5
+ a search for "fee" can find a chat that only said "pricing". A notebook's chats are small, so this runs in
6
+ milliseconds from the files already on disk, with no index service."""
7
+
8
+ import math
9
+ import re
10
+ from collections import Counter
11
+ from dataclasses import dataclass
12
+
13
+ from app.chat_memory import files
14
+
15
+ K1 = 1.5
16
+ B = 0.75
17
+ MAX_RESULTS = 8
18
+ CUTOFF = 0.5 # drop results scoring under this share of the best one
19
+ SNIPPET_CHARS = 240
20
+
21
+ _WORD = re.compile(r"[a-z0-9]+")
22
+ _STOP = frozenset("a an and are as at be by for from has have i in is it its me my of on or that the this to was "
23
+ "we were what when which who with you your about did do does said say".split())
24
+
25
+
26
+ def tokenize(text: str) -> list[str]:
27
+ out = []
28
+ for w in _WORD.findall(text.lower()):
29
+ if w in _STOP:
30
+ continue
31
+ out.append(w[:-1] if len(w) > 3 and w.endswith("s") and not w.endswith("ss") else w)
32
+ return out
33
+
34
+
35
+ @dataclass
36
+ class Unit:
37
+ path: str # chat area relative: {chat}/{slug}.md
38
+ line: int # 1 based line in that file
39
+ text: str # what is shown
40
+ topic: str
41
+ last: str # the topic's last active date
42
+ tokens: list[str]
43
+
44
+
45
+ def units_for(path: str, text: str) -> list[Unit]:
46
+ topic = files.parse_topic(text)
47
+ if topic is None:
48
+ return []
49
+ lines = text.splitlines()
50
+ context = tokenize(f"{topic.label} {topic.gist} {' '.join(topic.keywords)}") # document expansion
51
+ units: list[Unit] = []
52
+
53
+ def add(line: int, shown: str) -> None:
54
+ units.append(Unit(path, line, shown, topic.label, topic.last, tokenize(shown) + context))
55
+
56
+ summary_line = next((i for i, ln in enumerate(lines, start=1) if ln.strip() == "## Summary"), 0)
57
+ if topic.summary:
58
+ add(summary_line + 2 if summary_line else 1, topic.summary)
59
+ head = {q.message_id: 0 for q in topic.verbatim}
60
+ for i, ln in enumerate(lines, start=1):
61
+ for mid in head:
62
+ if ln.startswith(f"[{mid} "):
63
+ head[mid] = i
64
+ for q in topic.verbatim:
65
+ add(head[q.message_id] + 1 if head[q.message_id] else 1, q.text)
66
+ return units
67
+
68
+
69
+ class Bm25:
70
+ def __init__(self, units: list[Unit]):
71
+ self.units = units
72
+ self.tf = [Counter(u.tokens) for u in units]
73
+ n = max(len(units), 1)
74
+ self.avg = sum(len(u.tokens) for u in units) / n if units else 0.0
75
+ df: Counter[str] = Counter()
76
+ for c in self.tf:
77
+ df.update(c.keys())
78
+ self.idf = {t: math.log(1 + (n - d + 0.5) / (d + 0.5)) for t, d in df.items()}
79
+
80
+ def search(self, query: str, since: str = "") -> list[tuple[float, Unit]]:
81
+ terms = list(dict.fromkeys(tokenize(query)))
82
+ scored = []
83
+ for unit, tf in zip(self.units, self.tf, strict=True):
84
+ if since and unit.last < since:
85
+ continue
86
+ length = len(unit.tokens) or 1
87
+ score = 0.0
88
+ for t in terms:
89
+ f = tf.get(t, 0)
90
+ if f:
91
+ score += self.idf[t] * f * (K1 + 1) / (f + K1 * (1 - B + B * length / (self.avg or 1)))
92
+ if score > 0:
93
+ scored.append((score, unit))
94
+ scored.sort(key=lambda s: s[0], reverse=True)
95
+ return scored
96
+
97
+
98
+ def cut(scored: list[tuple[float, Unit]]) -> list[tuple[float, Unit]]:
99
+ """Keep the clear winners: results within CUTOFF of the best score, at most MAX_RESULTS, at least one."""
100
+ if not scored:
101
+ return []
102
+ top = scored[0][0]
103
+ return [s for s in scored if s[0] >= top * CUTOFF][:MAX_RESULTS]
104
+
105
+
106
+ _cache: dict[tuple, Bm25] = {}
107
+
108
+
109
+ def index_for(key: tuple, load_units) -> Bm25:
110
+ """The BM25 index for one notebook, rebuilt only when its files change (the key holds the manifest hashes)."""
111
+ if key not in _cache:
112
+ if len(_cache) >= 8:
113
+ _cache.pop(next(iter(_cache)))
114
+ _cache[key] = Bm25(load_units())
115
+ return _cache[key]
backend/app/chat_memory/tools.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The agent's tools for earlier chats, scoped to one notebook's chat memory.
2
+
3
+ Paths are shown as `chats/{chat_id}/{topic}.md` so a citation tells a chat file from a post (`sources/...`). Only
4
+ topic files can be searched or read: the corpus root is the notebook's own area, and `clean_rel` rejects anything that
5
+ climbs out of it."""
6
+
7
+ import re
8
+
9
+ from app.chat_memory import files, search
10
+ from app.corpus import MAX_LINE_CHARS, MAX_READ_LINES, MAX_SEARCH_HITS, Corpus, CorpusError
11
+
12
+ PREFIX = "chats/"
13
+
14
+
15
+ def is_chat_path(path: str) -> bool:
16
+ return path.strip().lstrip("/").startswith(PREFIX)
17
+
18
+
19
+ class ChatTools:
20
+ def __init__(self, corpus: Corpus, on_step=None):
21
+ self.corpus = corpus
22
+ self.on_step = on_step or (lambda kind, detail: None)
23
+
24
+ # Paths
25
+
26
+ def _rel(self, path: str) -> str:
27
+ rel = Corpus.clean_rel(path)
28
+ rel = rel.removeprefix(PREFIX)
29
+ name = rel.rsplit("/", 1)[-1]
30
+ if not rel.endswith(".md") or name in (files.TOPICS, files.INDEX):
31
+ raise CorpusError(f"{path} is not an earlier chat topic. Use list_chat_topics to see them.")
32
+ if not self.corpus.exists(rel):
33
+ raise CorpusError(f"No such earlier chat topic: {path}. Use list_chat_topics to see them.")
34
+ return rel
35
+
36
+ def topic_files(self) -> list[str]:
37
+ manifest = self.corpus._read_manifest(remote=False)
38
+ skip = (files.TOPICS, files.INDEX)
39
+ return sorted(r for r in manifest if "/" in r and r.endswith(".md") and r.rsplit("/", 1)[-1] not in skip)
40
+
41
+ @staticmethod
42
+ def shown(rel: str) -> str:
43
+ return PREFIX + rel
44
+
45
+ # Tools
46
+
47
+ def list_chat_topics(self, contains: str = "", since: str = "") -> str:
48
+ """List the topics of earlier chats in this notebook, newest first, one per line as
49
+ `date | path | topic | keywords | gist`. Filter to lines containing `contains` (case insensitive) and to
50
+ topics last active on or after `since` (a date like 2026-09-24)."""
51
+ self.on_step("recall", f"topics of earlier chats{f' about {contains}' if contains else ''}")
52
+ if not self.corpus.exists(files.INDEX):
53
+ return "No earlier chat topics yet."
54
+ rows = []
55
+ for line in self.corpus.read_lines(files.INDEX):
56
+ parts = [p.strip() for p in line.split("|")]
57
+ if len(parts) < 3:
58
+ continue
59
+ if since and parts[0] < since:
60
+ continue
61
+ if contains and contains.lower() not in line.lower():
62
+ continue
63
+ rows.append(" | ".join([parts[0], self.shown(parts[1]), *parts[2:]]))
64
+ return "\n".join(rows[:60]) or "No matching topics."
65
+
66
+ def search_chats(self, query: str, since: str = "", exact: bool = False) -> str:
67
+ """Ranked search of earlier chats in this notebook (best matches first). Matches the words used and
68
+ the topic's keywords, so a related word can find a chat that used another word. Optionally only topics last
69
+ active on or after `since` (a date like 2026-09-24). Set exact=true to match `query` as a regular expression
70
+ instead, for a literal phrase or a name. Returns `path:line: [topic, date] text`."""
71
+ if exact:
72
+ return self.grep_chats(query)
73
+ self.on_step("recall", f"earlier chats for {query}")
74
+ manifest = self.corpus._read_manifest(remote=False)
75
+ key = (str(self.corpus.root), tuple(sorted(manifest.items())))
76
+
77
+ def load():
78
+ units = []
79
+ for rel in self.topic_files():
80
+ units += search.units_for(rel, "\n".join(self.corpus.read_lines(rel)))
81
+ return units
82
+
83
+ hits = search.cut(search.index_for(key, load).search(query, since))
84
+ if not hits:
85
+ return "No matches in earlier chats. Try other words, list_chat_topics, or exact=true for a phrase."
86
+ return "\n".join(f"{self.shown(u.path)}:{u.line}: [{u.topic}, {u.last}] "
87
+ f"{u.text.strip().replace(chr(10), ' ')[:search.SNIPPET_CHARS]}" for _, u in hits)
88
+
89
+ def grep_chats(self, pattern: str, path: str = "") -> str:
90
+ """Search the text of earlier chat topics with a case insensitive regular expression (like grep), for an
91
+ exact word or phrase. Optionally limit to one `path`. Returns `path:line: text` matches. The agent reaches
92
+ this through search_chats(exact=true): this model stops answering when it is given more than six tools."""
93
+ self.on_step("recall", f"earlier chats for {pattern}")
94
+ try:
95
+ rx = re.compile(pattern, re.IGNORECASE)
96
+ except re.error:
97
+ rx = re.compile(re.escape(pattern), re.IGNORECASE)
98
+ targets = [self._rel(path)] if path else self.topic_files()
99
+ hits: list[str] = []
100
+ total = 0
101
+ for rel in targets:
102
+ for n, line in enumerate(self.corpus.read_lines(rel), start=1):
103
+ if rx.search(line):
104
+ total += 1
105
+ if len(hits) < MAX_SEARCH_HITS:
106
+ hits.append(f"{self.shown(rel)}:{n}: {line.strip()[:MAX_LINE_CHARS]}")
107
+ if not hits:
108
+ return "No matches in earlier chats."
109
+ out = "\n".join(hits)
110
+ return out + (f"\n... {total - len(hits)} more matches. Narrow the pattern." if total > len(hits) else "")
111
+
112
+ def read_chat(self, path: str, start_line: int = 1, end_line: int = 0) -> str:
113
+ """Read an earlier chat topic with line numbers (its summary, then the exact words that were kept). Reads at
114
+ most 160 lines per call. Cite with these line numbers."""
115
+ rel = self._rel(path)
116
+ lines = self.corpus.read_lines(rel)
117
+ start = max(1, start_line)
118
+ end = min(len(lines), end_line if end_line >= start else start + MAX_READ_LINES - 1)
119
+ end = min(end, start + MAX_READ_LINES - 1)
120
+ self.on_step("recall", f"{rel} (lines {start} to {end})")
121
+ body = "\n".join(f"{n}| {lines[n - 1]}" for n in range(start, end + 1))
122
+ more = f"\n[{len(lines) - end} more lines]" if end < len(lines) else ""
123
+ return f"{self.shown(rel)} ({len(lines)} lines)\n{body}{more}"
124
+
125
+ def quote(self, path: str, start: int, end: int) -> str | None:
126
+ """Text of a cited line range, or None if the citation does not point at real content."""
127
+ try:
128
+ rel = self._rel(path)
129
+ lines = self.corpus.read_lines(rel)
130
+ except CorpusError:
131
+ return None
132
+ if start < 1 or start > len(lines):
133
+ return None
134
+ end = max(start, min(end, len(lines), start + 40))
135
+ text = "\n".join(lines[start - 1:end]).strip()
136
+ return text or None
backend/app/config.py CHANGED
@@ -20,6 +20,7 @@ class Settings(BaseSettings):
20
 
21
  # Local default is a SQLite file (relative to the working directory, normally backend/).
22
  # Prod and docker-compose use Postgres: postgresql+psycopg://user:pass@host:5432/db
 
23
  database_url: str = "sqlite:///./notestack.db"
24
 
25
  # Worker (Postgres backed queue, see app/worker.py)
@@ -61,11 +62,31 @@ class Settings(BaseSettings):
61
  # Folder inside the bucket, so Notestack can share a bucket with other apps. Empty = bucket root.
62
  r2_prefix: str = ""
63
  r2_presign_ttl_seconds: int = 3600
64
- max_upload_bytes: int = 200 * 1024 * 1024
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
 
66
  # LLM (LiteLLM model strings; Z.ai GLM by default)
67
  llm_model: str = "openai/glm-5.3"
68
  llm_fast_model: str = "openai/glm-5.3-flash"
 
69
  # Chat triage (small talk, off topic, follow up rewriting). Empty = LLM_FAST_MODEL.
70
  llm_triage_model: str = ""
71
  # TypeSafe Jev classifier for chat triage. When set, triage uses Jev instead of an LLM call.
@@ -75,17 +96,40 @@ class Settings(BaseSettings):
75
  # Firecrawl: crawls sites without an RSS feed and renders JavaScript pages. Empty = plain HTTP only.
76
  firecrawl_api_key: str = ""
77
  firecrawl_url: str = "https://api.firecrawl.dev"
 
 
 
 
78
  llm_api_base: str = "https://api.z.ai/api/paas/v4"
79
  llm_api_key: str = ""
80
  llm_temperature: float = 1.0 # Z.ai recommends 1.0 for GLM-5.x
81
  # Reasoning output counts against max_tokens, so leave headroom for the structured answer.
82
- llm_max_tokens: int = 16000
 
 
 
83
  # GLM-5.x always reasons (it cannot be disabled); this sets how hard. low | high | max
84
  llm_reasoning_effort: str = "low"
85
 
86
  # Corpus: posts as markdown files. R2 is the source of truth; each process keeps a disk cache.
87
  corpus_cache_dir: str = ".corpus-cache"
88
  research_max_steps: int = 10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
 
90
  # ElevenLabs (premade voices by default; a consented clone can replace host A)
91
  elevenlabs_api_key: str = ""
@@ -95,11 +139,21 @@ class Settings(BaseSettings):
95
 
96
  # Social publishing (Launchpad). Redirect URIs: {API_URL}/api/social/{x,linkedin}/callback
97
  social_token_key: str = "" # Fernet key; derived from JWT_SECRET when empty
 
98
  x_client_id: str = ""
99
  x_client_secret: str = ""
100
  linkedin_client_id: str = ""
101
  linkedin_client_secret: str = ""
102
 
 
 
 
 
 
 
 
 
 
103
  # Renderer
104
  renderer_url: str = "http://localhost:3100"
105
  internal_token: str = "internal-change-me"
@@ -128,6 +182,18 @@ class Settings(BaseSettings):
128
  """RUN_WORKER_IN_API= (blank, as in .env.example) means "decide from ENV", not a parse error."""
129
  return None if isinstance(value, str) and not value.strip() else value
130
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  @property
132
  def worker_in_api(self) -> bool:
133
  return self.env == "development" if self.run_worker_in_api is None else self.run_worker_in_api
 
20
 
21
  # Local default is a SQLite file (relative to the working directory, normally backend/).
22
  # Prod and docker-compose use Postgres: postgresql+psycopg://user:pass@host:5432/db
23
+ # (plain postgres:// and postgresql:// URLs are rewritten to use psycopg, see below)
24
  database_url: str = "sqlite:///./notestack.db"
25
 
26
  # Worker (Postgres backed queue, see app/worker.py)
 
62
  # Folder inside the bucket, so Notestack can share a bucket with other apps. Empty = bucket root.
63
  r2_prefix: str = ""
64
  r2_presign_ttl_seconds: int = 3600
65
+ max_upload_bytes: int = 300 * 1024 * 1024
66
+
67
+ # Posts an infographic reads, and a report that is not built from themes (REPORT_PLANNER below). A quiz or flashcard set reads up to that many straight from the posts; with more (no limit
68
+ # on how many are picked) it reads a small sample of the ideas already extracted from them, so its size does not grow.
69
+ artifact_max_posts: int = 20
70
+ # Characters of post text a report, quiz or flashcard set may read in all (split evenly between the posts, 1,500 at least each).
71
+ artifact_passage_budget: int = 120_000
72
+ # The same for a quiz: it needs less text than a report to ask good questions.
73
+ artifact_quiz_passage_budget: int = 60_000
74
+ # Ideas (already extracted from each post) in the evidence of a flashcard set, or of a quiz above ARTIFACT_MAX_POSTS posts.
75
+ artifact_idea_sample: int = 30
76
+
77
+ # Reports. "themes": the posts' stored ideas are grouped into weighted themes, a blueprint plans the report, and each section is
78
+ # written from its own evidence (pipeline/report_themes.py). "legacy": the posts' text goes straight to the writer, at most
79
+ # ARTIFACT_MAX_POSTS of them. Chats, small selections and posts without stored ideas always use the legacy way.
80
+ report_planner: str = "themes"
81
+ # Posts a themed report reads: 0 = every post picked (the plan's indexed_posts still applies).
82
+ report_max_posts: int = 0
83
+ # Posts whose ideas are extracted while the report is made (in parallel); the rest are represented by their opening.
84
+ report_inline_ideas: int = 25
85
 
86
  # LLM (LiteLLM model strings; Z.ai GLM by default)
87
  llm_model: str = "openai/glm-5.3"
88
  llm_fast_model: str = "openai/glm-5.3-flash"
89
+ llm_report_model: str = "" # optional: writes report prose (default: the fast model, with a bigger output cap)
90
  # Chat triage (small talk, off topic, follow up rewriting). Empty = LLM_FAST_MODEL.
91
  llm_triage_model: str = ""
92
  # TypeSafe Jev classifier for chat triage. When set, triage uses Jev instead of an LLM call.
 
96
  # Firecrawl: crawls sites without an RSS feed and renders JavaScript pages. Empty = plain HTTP only.
97
  firecrawl_api_key: str = ""
98
  firecrawl_url: str = "https://api.firecrawl.dev"
99
+ # Help bot (bottom right widget). Empty = LLM_FAST_MODEL.
100
+ support_llm_model: str = ""
101
+ support_enabled: bool = True
102
+ support_messages_per_minute: int = 20
103
  llm_api_base: str = "https://api.z.ai/api/paas/v4"
104
  llm_api_key: str = ""
105
  llm_temperature: float = 1.0 # Z.ai recommends 1.0 for GLM-5.x
106
  # Reasoning output counts against max_tokens, so leave headroom for the structured answer.
107
+ llm_max_tokens: int = 10000
108
+ # The output cap of the fast model: quizzes, flashcards, infographics, idea extraction, template suggestions. A cap, not a target:
109
+ # only the tokens written are charged, and the thinking counts too, so keep room for it. Measured answers are under 3,000.
110
+ llm_fast_max_tokens: int = 6000
111
  # GLM-5.x always reasons (it cannot be disabled); this sets how hard. low | high | max
112
  llm_reasoning_effort: str = "low"
113
 
114
  # Corpus: posts as markdown files. R2 is the source of truth; each process keeps a disk cache.
115
  corpus_cache_dir: str = ".corpus-cache"
116
  research_max_steps: int = 10
117
+ # After an answer, how long the chat stream waits for the memory update so it can say what was saved.
118
+ memory_notice_wait_seconds: float = 25.0
119
+
120
+ # Chat memory: topic files built from a notebook's chats (docs/CHAT_RECALL_IMPLEMENTATION.md).
121
+ # CHAT_MEMORY_ENABLED writes the files after each answer. CHAT_MEMORY_READ lets answers use them.
122
+ chat_memory_enabled: bool = True
123
+ chat_memory_read: bool = False
124
+ chat_memory_delay_seconds: float = 10.0
125
+ chat_memory_max_rounds_per_job: int = 12
126
+ chat_memory_summary_chars: int = 1400
127
+ chat_memory_recent_rounds: int = 4
128
+ chat_memory_context_chars: int = 3000
129
+ chat_memory_topics_shown: int = 12
130
+ # Log what the research agent is given on every chat message (conversation, writer notes, chat memory). The logs
131
+ # then hold writers' text, so it is on only in development unless CHAT_CONTEXT_LOG=true. None follows ENV.
132
+ chat_context_log: bool | None = None
133
 
134
  # ElevenLabs (premade voices by default; a consented clone can replace host A)
135
  elevenlabs_api_key: str = ""
 
139
 
140
  # Social publishing (Launchpad). Redirect URIs: {API_URL}/api/social/{x,linkedin}/callback
141
  social_token_key: str = "" # Fernet key; derived from JWT_SECRET when empty
142
+ x_enabled: bool = False # X posting is switched off for now (LinkedIn only); set X_ENABLED=true to bring it back
143
  x_client_id: str = ""
144
  x_client_secret: str = ""
145
  linkedin_client_id: str = ""
146
  linkedin_client_secret: str = ""
147
 
148
+ # blog2video: makes the videos. We call its public API with our own account's key (b2v_live_...), so every
149
+ # video is charged to that account. Backend only: the key must never reach the browser or the logs.
150
+ b2v_api_base_url: str = Field("", validation_alias=AliasChoices("B2V_API_BASE_URL", "B2V_API_URL"))
151
+ b2v_api_key: str = ""
152
+ # blog2video's web app: serves the template poster images (<app>/template-posters/<id>.webp).
153
+ b2v_app_url: str = "https://blog2video.app"
154
+ b2v_timeout_seconds: float = 30 # creates, uploads and full-project reads; other calls also wait 30s (STATUS_TIMEOUT)
155
+ b2v_capacity_alert_below: int = 20 # log an error when our blog2video account has fewer videos left
156
+
157
  # Renderer
158
  renderer_url: str = "http://localhost:3100"
159
  internal_token: str = "internal-change-me"
 
182
  """RUN_WORKER_IN_API= (blank, as in .env.example) means "decide from ENV", not a parse error."""
183
  return None if isinstance(value, str) and not value.strip() else value
184
 
185
+ @field_validator("database_url", mode="before")
186
+ @classmethod
187
+ def _use_psycopg_driver(cls, value):
188
+ """Accept plain postgres:// or postgresql:// URLs (as Neon, Heroku etc. hand out) and
189
+ point them at psycopg 3, the only Postgres driver installed. Query params pass through."""
190
+ if isinstance(value, str):
191
+ value = value.strip()
192
+ for prefix in ("postgres://", "postgresql://"):
193
+ if value.startswith(prefix):
194
+ return "postgresql+psycopg://" + value[len(prefix):]
195
+ return value
196
+
197
  @property
198
  def worker_in_api(self) -> bool:
199
  return self.env == "development" if self.run_worker_in_api is None else self.run_worker_in_api
backend/app/corpus.py CHANGED
@@ -32,7 +32,7 @@ MAX_READ_LINES = 160
32
  MAX_SEARCH_HITS = 40
33
  MAX_LINE_CHARS = 240
34
 
35
- _locks: dict[uuid.UUID, threading.Lock] = {}
36
 
37
 
38
  def slugify(text: str, max_len: int = 60) -> str:
@@ -89,16 +89,20 @@ class Hit:
89
 
90
 
91
  class Corpus:
92
- def __init__(self, workspace_id: uuid.UUID, store=None, cache_dir: str | None = None):
 
 
93
  self.workspace_id = workspace_id
 
94
  self.store = store or default_storage
95
- self.root = Path(cache_dir or settings.corpus_cache_dir) / str(workspace_id)
96
- self._lock = _locks.setdefault(workspace_id, threading.Lock())
 
97
 
98
  # Keys and paths
99
 
100
  def _key(self, rel: str) -> str:
101
- return f"ws/{self.workspace_id}/corpus/{rel}"
102
 
103
  @staticmethod
104
  def clean_rel(rel: str) -> str:
 
32
  MAX_SEARCH_HITS = 40
33
  MAX_LINE_CHARS = 240
34
 
35
+ _locks: dict[tuple[uuid.UUID, str], threading.Lock] = {}
36
 
37
 
38
  def slugify(text: str, max_len: int = 60) -> str:
 
89
 
90
 
91
  class Corpus:
92
+ def __init__(self, workspace_id: uuid.UUID, store=None, cache_dir: str | None = None, area: str = "corpus"):
93
+ """`area` picks the folder inside the workspace: "corpus" for posts, "chats/{notebook_id}" for chat memory.
94
+ Each area has its own manifest, disk cache and lock."""
95
  self.workspace_id = workspace_id
96
+ self.area = area.strip("/")
97
  self.store = store or default_storage
98
+ base = Path(cache_dir or settings.corpus_cache_dir) / str(workspace_id)
99
+ self.root = base if self.area == "corpus" else base / self.area
100
+ self._lock = _locks.setdefault((workspace_id, self.area), threading.Lock())
101
 
102
  # Keys and paths
103
 
104
  def _key(self, rel: str) -> str:
105
+ return f"ws/{self.workspace_id}/{self.area}/{rel}"
106
 
107
  @staticmethod
108
  def clean_rel(rel: str) -> str:
backend/app/db.py CHANGED
@@ -11,8 +11,18 @@ class Base(DeclarativeBase):
11
 
12
 
13
  _sqlite = settings.database_url.startswith("sqlite")
14
- _connect_args = {"check_same_thread": False, "timeout": 30} if _sqlite else {}
15
- engine = create_engine(settings.database_url, pool_pre_ping=True, connect_args=_connect_args)
 
 
 
 
 
 
 
 
 
 
16
 
17
  if _sqlite:
18
  # Local dev: the API and the worker share one file. WAL lets reads run during a write, the busy
 
11
 
12
 
13
  _sqlite = settings.database_url.startswith("sqlite")
14
+ # A hosted Postgres (Neon) closes idle connections and drops the odd one on a network blip, and a connection that dies silently
15
+ # (laptop sleep, network change) otherwise leaves a query waiting on the socket forever, and the worker loop with it. TCP
16
+ # keepalives notice a dead peer within about a minute, so the query fails instead and a dead socket is noticed while it sits in
17
+ # the pool; connect_timeout does the same for a connect that never completes. A pooled connection is also replaced before it
18
+ # is handed out (pool_recycle, below Neon's idle timeout), since pool_pre_ping alone can fail on a dead SSL socket with a
19
+ # ProgrammingError ("can't change 'autocommit' now") that SQLAlchemy does not treat as a lost connection, and the request
20
+ # ends in a 500.
21
+ _pg_connect_args = {"connect_timeout": 10, "keepalives": 1, "keepalives_idle": 30, "keepalives_interval": 10,
22
+ "keepalives_count": 3}
23
+ _connect_args = {"check_same_thread": False, "timeout": 30} if _sqlite else _pg_connect_args
24
+ engine = create_engine(settings.database_url, pool_pre_ping=True, connect_args=_connect_args,
25
+ **({} if _sqlite else {"pool_recycle": 240, "pool_use_lifo": True}))
26
 
27
  if _sqlite:
28
  # Local dev: the API and the worker share one file. WAL lets reads run during a write, the busy
backend/app/infographics/__init__.py ADDED
File without changes
backend/app/infographics/content.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """What an infographic says: a short, length-limited block of text the premade themes pour into their layout.
2
+ Model output is never trusted for layout, so every field is stripped of markup and clamped here."""
3
+
4
+ import re
5
+
6
+ from pydantic import BaseModel
7
+
8
+ WIDTH, HEIGHT = 1600, 2260
9
+
10
+ # Characters per field: the themes are drawn so the longest allowed text still fits its box.
11
+ LIMITS = {"title": 60, "subtitle": 110, "label": 34, "item_name": 22, "item_text": 120, "step_name": 14,
12
+ "step_text": 78, "rule": 80, "note": 90}
13
+ ITEMS = (3, 5)
14
+ STEPS = (4, 8)
15
+ NOTES = 3
16
+
17
+
18
+ class Entry(BaseModel):
19
+ name: str
20
+ text: str
21
+
22
+
23
+ class InfographicContent(BaseModel):
24
+ title: str
25
+ subtitle: str
26
+ items_label: str
27
+ items: list[Entry]
28
+ steps_label: str
29
+ steps: list[Entry]
30
+ rule: str
31
+ notes: list[str]
32
+
33
+
34
+ _TAGS = re.compile(r"<[^>]*>")
35
+ _SPACE = re.compile(r"\s+")
36
+
37
+
38
+ def _clip(text: object, limit: int) -> str:
39
+ """Plain text of at most `limit` characters, cut at a word with an ellipsis when it was longer."""
40
+ s = _SPACE.sub(" ", _TAGS.sub(" ", str(text or ""))).replace("—", ",").replace("–", "-").strip()
41
+ if len(s) <= limit:
42
+ return s
43
+ cut = s[: limit - 1].rsplit(" ", 1)[0].rstrip(" ,;:.-") or s[: limit - 1]
44
+ return cut + "…"
45
+
46
+
47
+ def _entries(raw: object, name_limit: int, text_limit: int, count: tuple[int, int]) -> list[dict]:
48
+ out = []
49
+ for e in (raw if isinstance(raw, list) else []):
50
+ e = e if isinstance(e, dict) else getattr(e, "__dict__", {})
51
+ name, text = _clip(e.get("name"), name_limit), _clip(e.get("text"), text_limit)
52
+ if name and text:
53
+ out.append({"name": name, "text": text})
54
+ return out[: count[1]] if len(out) >= count[0] else []
55
+
56
+
57
+ def clamp(raw: dict) -> dict | None:
58
+ """The content in the shape the themes draw, or None when there is too little to draw (too few items or steps)."""
59
+ if hasattr(raw, "model_dump"):
60
+ raw = raw.model_dump()
61
+ items = _entries(raw.get("items"), LIMITS["item_name"], LIMITS["item_text"], ITEMS)
62
+ steps = _entries(raw.get("steps"), LIMITS["step_name"], LIMITS["step_text"], STEPS)
63
+ title = _clip(raw.get("title"), LIMITS["title"])
64
+ if not items or not steps or not title:
65
+ return None
66
+ notes = [n for n in (_clip(n, LIMITS["note"]) for n in raw.get("notes") or []) if n][:NOTES]
67
+ return {"title": title, "subtitle": _clip(raw.get("subtitle"), LIMITS["subtitle"]),
68
+ "items_label": _clip(raw.get("items_label"), LIMITS["label"]) or "Key ideas",
69
+ "items": items,
70
+ "steps_label": _clip(raw.get("steps_label"), LIMITS["label"]) or "How it works",
71
+ "steps": steps, "rule": _clip(raw.get("rule"), LIMITS["rule"]), "notes": notes}
72
+
73
+
74
+ SAMPLE = {
75
+ "title": "From Prompt to Production Agent",
76
+ "subtitle": "Five engineering layers plus an eight step production request flow",
77
+ "items_label": "The five layers",
78
+ "items": [
79
+ {"name": "Prompt", "text": "Optimizes wording, examples, and format in a single request."},
80
+ {"name": "Context", "text": "Curates docs, history, tools, and memory before each model call."},
81
+ {"name": "Harness", "text": "Scaffolding around the model: tools, verification, and guardrails."},
82
+ {"name": "Loop", "text": "Runs reason, act and observe cycles until a stop condition is met."},
83
+ {"name": "Graph", "text": "Maps multi-agent systems as explicit nodes and edges."},
84
+ ],
85
+ "steps_label": "Every request goes through",
86
+ "steps": [
87
+ {"name": "Auth", "text": "Middleware checks identity, rate limits, and input guards."},
88
+ {"name": "Cache", "text": "Semantic cache returns answers for equivalent questions."},
89
+ {"name": "Safety", "text": "Scrub PII and detect prompt injection attacks."},
90
+ {"name": "Memory", "text": "Load session history with summarized context."},
91
+ {"name": "Retrieve", "text": "Fetch relevant docs and split complex queries."},
92
+ {"name": "Answer", "text": "Execute sub-queries in parallel with branching models."},
93
+ {"name": "Validate", "text": "Check output quality metrics before responding."},
94
+ {"name": "Save", "text": "Store results and log one full trace."},
95
+ ],
96
+ "rule": "Most tasks only need the bottom layers; avoid complex graphs without cause.",
97
+ "notes": ["Each layer builds on the one below it.", "Cache and middleware run outside the graph.",
98
+ "Every request should produce a single end-to-end trace."],
99
+ }
100
+
101
+ # The longest text each field allows, with the most items and steps: what every theme must fit.
102
+ STRESS = {
103
+ "title": "W" * 4 + " Quarterly Review of Everything We Learned Here", "subtitle": "Long words here " * 7,
104
+ "items_label": "The five very long layers", "steps_label": "Every request goes through",
105
+ "items": [{"name": "Wide Name Here Now", "text": "Words that go on and on " * 6}] * 5,
106
+ "steps": [{"name": "Validators", "text": "Words that go on and on " * 5}] * 8,
107
+ "rule": "A very long rule that keeps going and going " * 3, "notes": ["A long note that keeps on going " * 4] * 3,
108
+ }
backend/app/infographics/design.py ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The AI-composed infographic: a page frame of ours (head, takeaway, background, mark) around a body the model writes in
2
+ HTML from a small vocabulary of components (flow charts, timelines, hierarchies, comparisons, cards, numbers). The
3
+ model's markup is never trusted: it is parsed and rebuilt from an allowlist of tags and class names, with no
4
+ attributes but `class`, so it can carry no script, style, link, image or address."""
5
+
6
+ import hashlib
7
+ import random
8
+ import re
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ from bs4 import BeautifulSoup, Comment, NavigableString, Tag
13
+ from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape
14
+ from markupsafe import Markup
15
+
16
+ from app.infographics.render import LOGO_SVG, SIZES
17
+
18
+ _DIR = Path(__file__).parent / "templates"
19
+ CSS = (_DIR / "design.css").read_text(encoding="utf-8")
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Look:
24
+ id: str
25
+ name: str
26
+ blurb: str
27
+ vars: dict # CSS variables of the palette
28
+
29
+
30
+ LOOKS: dict[str, Look] = {l.id: l for l in (
31
+ Look("midnight", "Midnight", "Black and blue, white type", {
32
+ "bg": "#03050a", "bg2": "#0b1a3a", "ink": "#ffffff", "mute": "#9fb0cc", "acc": "#2f7bff", "acc2": "#8db8ff",
33
+ "line": "rgba(130,170,255,.26)", "card": "rgba(255,255,255,.04)", "hl": "rgba(47,123,255,.18)", "paper": "#ffffff",
34
+ "paperink": "#03050a", "star": "#ffffff", "markbg": "rgba(3,5,10,.75)", "markline": "rgba(141,184,255,.4)", "markink": "#ffffff"}),
35
+ Look("paper", "Orbit", "White, black type, blue orbits and stars", {
36
+ "bg": "#ffffff", "bg2": "#eaf1ff", "ink": "#05070d", "mute": "#4b5668", "acc": "#1f5fe6", "acc2": "#1f5fe6",
37
+ "line": "rgba(5,7,13,.18)", "card": "#ffffff", "hl": "rgba(31,95,230,.09)", "paper": "#05070d",
38
+ "paperink": "#ffffff", "star": "#1f5fe6", "markbg": "#05070d", "markline": "#05070d", "markink": "#ffffff"}),
39
+ )}
40
+ DEFAULT_LOOK = "midnight"
41
+
42
+ # What the person can ask the layout to lean towards; "auto" lets the AI choose from the content.
43
+ STYLES = {
44
+ "auto": "Choose the layout that suits the content",
45
+ "flowchart": "A flow chart: boxes joined by arrows, steps in order",
46
+ "timeline": "A timeline: what happened or happens, in order",
47
+ "compare": "A comparison: two sides set against each other",
48
+ "hierarchy": "A hierarchy: one idea and the parts it breaks into",
49
+ "overview": "An overview: cards of the key ideas and the numbers that matter",
50
+ }
51
+
52
+ # Every class the model's markup may use (the components in design.css); anything else is dropped.
53
+ ALLOWED_CLASSES = {
54
+ "sec", "sec-t", "lead", "mono", "small", "hl", "node", "n-no", "n-t", "n-d", "flow", "col", "grid", "c2", "c3", "c4",
55
+ "card", "c-k", "c-t", "c-d", "timeline", "t-item", "t-when", "t-t", "t-d", "tree", "t-root", "t-kids", "compare",
56
+ "side", "s-h", "stats", "stat", "s-n", "s-l", "cols", "pills", "pill", "callout",
57
+ }
58
+ ALLOWED_TAGS = {"div", "section", "span", "p", "ul", "ol", "li", "strong", "b", "em", "i", "small", "br", "h3", "h4"}
59
+ DROP_WITH_CONTENT = {"script", "style", "iframe", "object", "embed", "link", "meta", "svg", "img", "video", "audio", "form",
60
+ "input", "button", "textarea", "select", "canvas", "math", "head", "title", "noscript", "template"}
61
+ MAX_BODY_CHARS = 14_000
62
+ MAX_ELEMENTS = 320
63
+
64
+ _SPACE = re.compile(r"\s+")
65
+
66
+
67
+ def _clean_text(text: str) -> str:
68
+ return text.replace("—", ",").replace("–", "-")
69
+
70
+
71
+ def sanitize_body(raw: object) -> str | None:
72
+ """The model's body markup rebuilt from the allowlist, or None when nothing readable is left."""
73
+ if not isinstance(raw, str) or not raw.strip():
74
+ return None
75
+ soup = BeautifulSoup(raw[: MAX_BODY_CHARS * 2], "html.parser")
76
+ for c in soup.find_all(string=lambda t: isinstance(t, Comment)):
77
+ c.extract()
78
+ for tag in soup.find_all(DROP_WITH_CONTENT):
79
+ tag.decompose()
80
+ count = 0
81
+ for tag in list(soup.find_all(True)):
82
+ count += 1
83
+ if tag.name not in ALLOWED_TAGS or count > MAX_ELEMENTS:
84
+ tag.unwrap() if count <= MAX_ELEMENTS else tag.decompose()
85
+ continue
86
+ classes = [c for c in (tag.get("class") or []) if c in ALLOWED_CLASSES]
87
+ tag.attrs = {"class": classes} if classes else {}
88
+ for node in soup.find_all(string=True):
89
+ if isinstance(node, NavigableString):
90
+ node.replace_with(NavigableString(_clean_text(str(node))))
91
+ html = _SPACE.sub(" ", str(soup)).strip()
92
+ text = soup.get_text(strip=True)
93
+ if len(html) > MAX_BODY_CHARS or len(text) < 40:
94
+ return None
95
+ return html
96
+
97
+
98
+ def _clip(text: object, limit: int) -> str:
99
+ s = _SPACE.sub(" ", re.sub(r"<[^>]*>", " ", str(text or ""))).strip()
100
+ s = _clean_text(s)
101
+ if len(s) <= limit:
102
+ return s
103
+ cut = s[: limit - 1].rsplit(" ", 1)[0].rstrip(" ,;:.-") or s[: limit - 1]
104
+ return cut + "…"
105
+
106
+
107
+ def build_content(raw: dict) -> dict | None:
108
+ """The stored content for a model answer (title, subtitle, eyebrow, key_figures, body_html, rule, notes), or None when it is
109
+ too thin to draw."""
110
+ if hasattr(raw, "model_dump"):
111
+ raw = raw.model_dump()
112
+ title = _clip(raw.get("title"), 70)
113
+ body = sanitize_body(raw.get("body_html"))
114
+ if not title or not body:
115
+ return None
116
+ figures = []
117
+ for f in raw.get("key_figures") or []:
118
+ f = f if isinstance(f, dict) else getattr(f, "__dict__", {})
119
+ value, label = _clip(f.get("value"), 14), _clip(f.get("label"), 44)
120
+ if value and label:
121
+ figures.append({"value": value, "label": label})
122
+ notes = [n for n in (_clip(n, 100) for n in raw.get("notes") or []) if n][:3]
123
+ return {"title": title, "subtitle": _clip(raw.get("subtitle"), 130), "eyebrow": _clip(raw.get("eyebrow"), 30) or "Key ideas",
124
+ "key_figures": figures[:3], "body_html": body, "rule": _clip(raw.get("rule"), 90), "notes": notes}
125
+
126
+
127
+ def _stars(seed: str, w: int, h: int, n: int = 90) -> str:
128
+ rnd = random.Random(int(hashlib.sha1(seed.encode()).hexdigest()[:8], 16))
129
+ out = []
130
+ for _ in range(n):
131
+ r = rnd.choice((1.2, 1.5, 1.5, 2, 2.6))
132
+ out.append(f'<circle cx="{rnd.randint(0, w)}" cy="{rnd.randint(0, h)}" r="{r}" fill="var(--star)" opacity="{round(rnd.uniform(.12, .5), 2)}"/>')
133
+ return "".join(out)
134
+
135
+
136
+ def _orbits(w: int, h: int) -> str:
137
+ """The Orbit look's scenery: a ringed planet like the app's icon, orbit rings with small moons, and shooting stars."""
138
+ big = round(w * 0.2)
139
+ cx, cy = round(w * 0.86), round(h * 0.085)
140
+ pr = round(w * 0.05)
141
+
142
+ def ring(x, y, rx, ry, op, dash=""):
143
+ return (f'<ellipse cx="{x}" cy="{y}" rx="{rx}" ry="{ry}" transform="rotate(-20 {x} {y})" fill="none" stroke="var(--acc)" '
144
+ f'stroke-width="2" opacity="{op}"{dash}/>')
145
+
146
+ def planet_ring(front: bool):
147
+ sweep = 0 if front else 1
148
+ return (f'<g transform="rotate(-22 {cx} {cy})"><path d="M{cx - pr * 2} {cy} A{pr * 2} {round(pr * .6)} 0 0 {sweep} {cx + pr * 2} {cy}" '
149
+ f'fill="none" stroke="var(--acc)" stroke-width="5" opacity="{1 if front else .5}"/></g>')
150
+
151
+ out = [
152
+ '<defs><linearGradient id="shoot" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="var(--acc)" stop-opacity="0"/>'
153
+ '<stop offset="1" stop-color="var(--acc)" stop-opacity=".85"/></linearGradient></defs>',
154
+ ring(cx, cy, big, round(big * .3), .35, ' stroke-dasharray="3 12"'),
155
+ ring(cx, cy, round(big * 1.55), round(big * .46), .22),
156
+ ring(round(w * .06), round(h * .96), round(w * .5), round(w * .14), .2, ' stroke-dasharray="3 12"'),
157
+ ring(round(w * .06), round(h * .96), round(w * .34), round(w * .09), .28),
158
+ planet_ring(False),
159
+ f'<circle cx="{cx}" cy="{cy}" r="{pr}" fill="var(--bg)" stroke="var(--acc)" stroke-width="5"/>',
160
+ f'<circle cx="{cx}" cy="{cy}" r="{pr - 12}" fill="var(--acc)" opacity=".12"/>',
161
+ planet_ring(True),
162
+ f'<circle cx="{round(w * .6)}" cy="{round(h * .036)}" r="9" fill="var(--acc)" opacity=".7"/>',
163
+ f'<circle cx="{round(w * .31)}" cy="{round(h * .93)}" r="8" fill="var(--acc)" opacity=".6"/>',
164
+ ]
165
+ for fx, fy, length, op in ((.46, .2, 190, .8), (.7, .4, 150, .6), (.12, .55, 120, .5)):
166
+ x, y = round(w * fx), round(h * fy)
167
+ out.append(f'<g transform="rotate(28 {x} {y})" opacity="{op}"><rect x="{x - length}" y="{y - 2}" width="{length}" height="4" rx="2" fill="url(#shoot)"/>'
168
+ f'<circle cx="{x}" cy="{y}" r="5" fill="var(--acc)"/></g>')
169
+ return "".join(out)
170
+
171
+
172
+ _ENV = Environment(loader=FileSystemLoader(_DIR), autoescape=select_autoescape(["j2", "html"], default=True), undefined=StrictUndefined,
173
+ trim_blocks=True, lstrip_blocks=True)
174
+
175
+
176
+ def render_design(look: str, content: dict, layout: str = "portrait", fit: float | None = None) -> str:
177
+ """The page for an AI-composed infographic. `fit` is the scale that makes its body fit (stored with the content)."""
178
+ look = look if look in LOOKS else DEFAULT_LOOK
179
+ layout = layout if layout in SIZES else "portrait"
180
+ w, h = SIZES[layout]
181
+ if fit is None:
182
+ fit = float(((content.get("fit") or {}) if isinstance(content.get("fit"), dict) else {}).get(layout) or 1)
183
+ fit = max(0.5, min(1.0, fit))
184
+ vars_css = ";".join(f"--{k}:{v}" for k, v in LOOKS[look].vars.items())
185
+ return _ENV.get_template("design.html.j2").render(
186
+ c=content, look=look, layout=layout, w=w, h=h, fit=fit, vars_css=vars_css, css=Markup(CSS), logo=Markup(LOGO_SVG),
187
+ body=Markup(content["body_html"]),
188
+ stars=Markup(_stars(content["title"] + look, w, h) + (_orbits(w, h) if look == "paper" else "")),
189
+ fonts="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500;700&display=swap")
190
+
191
+
192
+ SAMPLE_BODY = (
193
+ '<section class="sec"><div class="sec-t">The five layers</div><div class="flow">'
194
+ '<div class="node"><span class="n-no">01</span><span class="n-t">Prompt</span><span class="n-d">Wording, examples and format of one request.</span></div>'
195
+ '<div class="node"><span class="n-no">02</span><span class="n-t">Context</span><span class="n-d">Docs, history, tools and memory for each call.</span></div>'
196
+ '<div class="node hl"><span class="n-no">03</span><span class="n-t">Harness</span><span class="n-d">Tools, checks and guardrails around the model.</span></div>'
197
+ '<div class="node"><span class="n-no">04</span><span class="n-t">Loop</span><span class="n-d">Reason, act and observe until done.</span></div></div></section>'
198
+ '<section class="sec"><div class="sec-t">Every request goes through</div><div class="grid c4">'
199
+ + "".join(f'<div class="card{" hl" if i == 3 else ""}"><span class="c-k">Step {i + 1}</span><span class="c-t">{t}</span><span class="c-d">{d}</span></div>'
200
+ for i, (t, d) in enumerate([("Auth", "Identity, limits and input guards."), ("Cache", "Answers to repeat questions."),
201
+ ("Safety", "Scrub PII, catch injection."), ("Memory", "Session history, summarised."),
202
+ ("Retrieve", "Fetch docs, split queries."), ("Answer", "Sub-queries in parallel."),
203
+ ("Validate", "Check quality first."), ("Save", "Store and log one trace.")]))
204
+ + '</div></section>'
205
+ '<section class="sec"><div class="sec-t">Why it matters</div><div class="callout">Each layer builds on the one below it, so start at the bottom and add a layer only when the task needs it.</div></section>'
206
+ )
207
+ SAMPLE = {"title": "From Prompt to Production Agent", "subtitle": "Five engineering layers and the steps every request takes",
208
+ "eyebrow": "Agent engineering", "body_html": SAMPLE_BODY,
209
+ "key_figures": [{"value": "5", "label": "engineering layers"}, {"value": "8", "label": "steps on every request"},
210
+ {"value": "30%", "label": "of traffic skips the pipeline"}],
211
+ "rule": "Most tasks only need the bottom layers; avoid complex graphs without cause.",
212
+ "notes": ["Each layer builds on the one below it.", "Cache and middleware run outside the graph.",
213
+ "Every request produces one end-to-end trace."]}
backend/app/infographics/image.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """An infographic's PNG: its stored HTML page drawn by a headless Chrome at the page's own size. No Remotion, no
2
+ browser library: one `chrome --headless --screenshot` run per download (the result is kept for the next one)."""
3
+
4
+ import functools
5
+ import glob
6
+ import hashlib
7
+ import os
8
+ import re
9
+ import shutil
10
+ import subprocess
11
+ import tempfile
12
+ from collections import OrderedDict
13
+ from pathlib import Path
14
+ from threading import Lock
15
+
16
+ from app.infographics.render import SIZES
17
+
18
+ TIMEOUT = 90 # seconds for one drawing
19
+ SCALE = 2 # pixels per page pixel: a 1600 x 2260 page is drawn as 3200 x 4520, sharp on any screen and in print
20
+ KEEP = 6 # drawings kept in memory
21
+
22
+ _cache: OrderedDict[str, bytes] = OrderedDict()
23
+ _lock = Lock()
24
+
25
+
26
+ class ImageUnavailable(Exception):
27
+ """No Chrome to draw with, or it failed."""
28
+
29
+
30
+ @functools.cache
31
+ def _runs(path: str) -> bool:
32
+ """Whether the binary actually starts (a package manager's wrapper can outlive the browser it points at)."""
33
+ try:
34
+ return subprocess.run([path, "--version"], capture_output=True, timeout=20).returncode == 0
35
+ except (subprocess.SubprocessError, OSError):
36
+ return False
37
+
38
+
39
+ def chrome() -> str | None:
40
+ """CHROME, or a working Chrome / Chromium found on the machine."""
41
+ if env := os.environ.get("CHROME"):
42
+ return env if Path(env).exists() else None
43
+ found = [p for name in ("chromium", "chromium-browser", "google-chrome", "google-chrome-stable", "chrome")
44
+ if (p := shutil.which(name))]
45
+ # Playwright's headless shell first: desktop Chrome on macOS can hang taking a headless screenshot.
46
+ shell = "chromium_headless_shell-*/chrome-headless-shell-*/chrome-headless-shell"
47
+ patterns = [os.path.expanduser(f"~/Library/Caches/ms-playwright/{shell}"), os.path.expanduser(f"~/.cache/ms-playwright/{shell}"),
48
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
49
+ os.path.expanduser("~/.cache/puppeteer/chrome/*/chrome-mac-*/"
50
+ "Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing")]
51
+ found += [p for pat in patterns for p in glob.glob(pat)]
52
+ return next((p for p in found if _runs(p)), None)
53
+
54
+
55
+ def render_png(html: str, layout: str = "portrait") -> bytes:
56
+ """The page as a PNG, drawn at SCALE times its size: tall (1600 x 2260) or wide (2260 x 1600) before scaling."""
57
+ width, height = SIZES.get(layout, SIZES["portrait"])
58
+ key = hashlib.sha256(f"{width}x{height}@{SCALE}{html}".encode()).hexdigest()
59
+ with _lock:
60
+ if key in _cache:
61
+ _cache.move_to_end(key)
62
+ return _cache[key]
63
+ binary = chrome()
64
+ if not binary:
65
+ raise ImageUnavailable("No Chrome is installed here to draw the image.")
66
+ page = f'<!doctype html><meta charset="utf-8"><body style="margin:0">{html}</body>'
67
+ with tempfile.TemporaryDirectory() as tmp:
68
+ src, out = Path(tmp) / "page.html", Path(tmp) / "out.png"
69
+ src.write_text(page, encoding="utf-8")
70
+ cmd = [binary, "--headless=new", "--disable-gpu", "--no-sandbox", "--hide-scrollbars", f"--force-device-scale-factor={SCALE}",
71
+ f"--user-data-dir={tmp}/profile", f"--window-size={width},{height}", "--virtual-time-budget=8000",
72
+ f"--screenshot={out}", f"file://{src}"]
73
+ try:
74
+ subprocess.run(cmd, check=True, capture_output=True, timeout=TIMEOUT)
75
+ except (subprocess.SubprocessError, OSError) as exc:
76
+ raise ImageUnavailable("The image could not be drawn.") from exc
77
+ if not out.exists():
78
+ raise ImageUnavailable("The image could not be drawn.")
79
+ data = out.read_bytes()
80
+ with _lock:
81
+ _cache[key] = data
82
+ while len(_cache) > KEEP:
83
+ _cache.popitem(last=False)
84
+ return data
85
+
86
+
87
+ _FIT = re.compile(r"FIT=([0-9.]+)")
88
+ _MEASURE = ("<script>document.fonts.ready.then(function(){var b=document.querySelector('.ig-body'),i=document.querySelector('.ig-inner');"
89
+ "var s=1;if(b&&i){var inner=i.scrollHeight*parseFloat(getComputedStyle(i).getPropertyValue('--fit')||1);s=Math.min(1,b.clientHeight/Math.max(inner,1));}"
90
+ "document.title='FIT='+s.toFixed(3);});</script>")
91
+
92
+
93
+ def measure_fit(html: str, layout: str) -> float:
94
+ """The scale (1 or less) that makes an AI-composed page's body fit its space: the page drawn once, in Chrome, with our own
95
+ small script measuring it (the page's markup is sanitised, so no other script is in it). 1 when Chrome is missing."""
96
+ width, height = SIZES.get(layout, SIZES["portrait"])
97
+ binary = chrome()
98
+ if not binary:
99
+ return 1.0
100
+ page = f'<!doctype html><meta charset="utf-8"><body style="margin:0">{html}{_MEASURE}</body>'
101
+ with tempfile.TemporaryDirectory() as tmp:
102
+ src = Path(tmp) / "page.html"
103
+ src.write_text(page, encoding="utf-8")
104
+ cmd = [binary, "--headless=new", "--disable-gpu", "--no-sandbox", "--hide-scrollbars", f"--user-data-dir={tmp}/profile",
105
+ f"--window-size={width},{height}", "--virtual-time-budget=10000", "--dump-dom", f"file://{src}"]
106
+ try:
107
+ out = subprocess.run(cmd, check=True, capture_output=True, timeout=TIMEOUT, text=True).stdout
108
+ except (subprocess.SubprocessError, OSError):
109
+ return 1.0
110
+ found = _FIT.search(out)
111
+ return max(0.55, min(1.0, float(found.group(1)))) if found else 1.0
backend/app/infographics/render.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pours an infographic's content into a theme's premade HTML page. Autoescape is on and nothing is marked safe, so text
2
+ from the model can never add markup or scripts to the page."""
3
+
4
+ import hashlib
5
+ import random
6
+ from pathlib import Path
7
+
8
+ from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape
9
+ from markupsafe import Markup
10
+
11
+ from app.infographics.content import HEIGHT, WIDTH
12
+ from app.infographics.themes import DEFAULT_THEME, THEMES, theme_id
13
+
14
+ _ENV = Environment(loader=FileSystemLoader(Path(__file__).parent / "templates"), autoescape=select_autoescape(["j2"], default=True),
15
+ undefined=StrictUndefined, trim_blocks=True, lstrip_blocks=True)
16
+
17
+ # name -> (heading, body, mono, heading weight, heading case, label case)
18
+ _FONT = {"jost": "Jost", "outfit": "Outfit", "playfair": "Playfair Display", "cormorant": "Cormorant Garamond",
19
+ "mono": "Space Mono"}
20
+
21
+
22
+ # The Notestack mark (the same icon as frontend/public/logo.svg), drawn beside the name on every page. Our own constant, never
23
+ # model text, so it is the one thing marked safe.
24
+ LOGO_SVG = ('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#fff"/>'
25
+ '<circle cx="26" cy="36" r="13" fill="#000"/><ellipse cx="26" cy="36" rx="21" ry="5" fill="none" stroke="#000" '
26
+ 'stroke-width="3" transform="rotate(-18 26 36)"/><path d="M46 10l2.2 5.8 5.8 2.2-5.8 2.2-2.2 5.8-2.2-5.8-5.8-2.2 '
27
+ '5.8-2.2z" fill="#000"/></svg>')
28
+
29
+ SIZES = {"portrait": (WIDTH, HEIGHT), "landscape": (HEIGHT, WIDTH)} # the page is drawn at either shape
30
+
31
+
32
+ def _stars(seed: str, w: int, h: int, n: int = 70) -> list[tuple[int, int, float, float]]:
33
+ rnd = random.Random(int(hashlib.sha1(seed.encode()).hexdigest()[:8], 16))
34
+ return [(rnd.randint(0, w), rnd.randint(0, h), round(rnd.choice((1.5, 2, 2, 3, 4)), 1), round(rnd.uniform(.15, .6), 2))
35
+ for _ in range(n)]
36
+
37
+
38
+ def build_html(theme: str, content: dict, layout: str = "portrait") -> str:
39
+ """The page for `content` (as returned by content.clamp) in the theme (the default when unknown), tall (portrait)
40
+ or wide (landscape: the same words, the title and takeaway in a column beside the theme's drawing)."""
41
+ tid = theme_id(theme)
42
+ layout = layout if layout in SIZES else "portrait"
43
+ w, h = SIZES[layout]
44
+ tpl = _ENV.get_template(f"{tid}.html.j2")
45
+ return tpl.render(c=content, width=w, height=h, layout=layout, logo=Markup(LOGO_SVG), stars=_stars(content["title"] + tid, w, h))
46
+
47
+
48
+ def thumbnail_html(theme: str, content: dict, layout: str = "portrait") -> str:
49
+ """The page as a standalone document, for rendering a preview outside the app."""
50
+ return f'<!doctype html><meta charset="utf-8"><body style="margin:0">{build_html(theme, content, layout)}</body>'
51
+
52
+
53
+ __all__ = ["build_html", "LOGO_SVG", "thumbnail_html", "SIZES", "THEMES", "DEFAULT_THEME"]
backend/app/infographics/templates/base.html.j2 ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {#- Shared layout of every infographic theme. A theme extends this and fills the style, the background, the hero drawing and a few
2
+ words. All text is autoescaped; nothing from the model is ever marked safe. The notestack.ai mark is added by the renderer. -#}
3
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family={{ fonts.h|replace(' ', '+') }}:wght@400;500;600;700&family={{ fonts.b|replace(' ', '+') }}:wght@300;400;500;600&family={{ fonts.m|replace(' ', '+') }}:wght@400;700&display=swap">
4
+ <style>
5
+ .ig,.ig *{box-sizing:border-box;margin:0;padding:0}
6
+ .ig{position:relative;width:{{ width }}px;height:{{ height }}px;overflow:hidden;color:var(--ink);font-family:var(--fb);
7
+ background:radial-gradient(1300px 800px at 62% 14%,var(--bg2),var(--bg1) 72%)}
8
+ .ig .bg{position:absolute;inset:0;pointer-events:none}
9
+ .ig .main{position:absolute;left:110px;right:110px;top:84px;bottom:116px;display:flex;flex-direction:column}
10
+ .ig .head{display:flex;justify-content:space-between;align-items:flex-start;gap:30px;height:var(--hh,400px);flex:none}
11
+ .ig .head .txt{width:900px}
12
+ .ig .eyebrow{font-family:var(--fm);font-size:21px;letter-spacing:.24em;text-transform:uppercase;color:var(--a);margin-bottom:18px}
13
+ .ig h1{font-family:var(--fh);font-weight:var(--hw,700);font-size:72px;line-height:1.05;letter-spacing:var(--hl,0);text-transform:var(--ht,none);margin-bottom:20px}
14
+ .ig .sub{font-size:31px;line-height:1.35;color:var(--mute)}
15
+ .ig .hero{flex:none;width:480px;height:400px}
16
+ .ig .lab{font-family:var(--fm);font-size:21px;letter-spacing:.22em;text-transform:uppercase;color:var(--a);margin:12px 0 16px;flex:none;display:flex;align-items:center;gap:18px}
17
+ .ig .lab.gap{margin-top:12px}
18
+ .ig .lab:after{content:"";flex:1;height:2px;background:var(--line)}
19
+ .ig .fill{flex:1;min-height:0;display:flex;flex-direction:column;justify-content:space-evenly}
20
+ .ig .band{margin-top:0;display:grid;grid-template-columns:1.05fr 1fr;gap:48px;align-items:center;padding:34px 50px;border-radius:var(--r);background:var(--paper);color:var(--paperink);flex:none}
21
+ .ig .band .q{font-family:var(--fh);font-weight:700;font-size:40px;line-height:1.14;text-transform:var(--ht,none)}
22
+ .ig .band ul{list-style:none;font-size:24px;line-height:1.35}
23
+ .ig .band li{position:relative;padding-left:26px;margin:7px 0}
24
+ .ig .band li:before{content:"";position:absolute;left:0;top:11px;width:12px;height:12px;border-radius:50%;background:var(--b)}
25
+ .ig .mark{position:absolute;right:44px;bottom:34px;display:flex;align-items:center;gap:16px;font-family:'Space Mono',monospace;font-size:30px;font-weight:700;letter-spacing:.06em;color:#fff;padding:8px 26px 8px 10px;border-radius:999px;background:rgba(10,14,20,.7);border:1px solid rgba(255,255,255,.3)}
26
+ .ig .mark svg{width:52px;height:52px;display:block}
27
+ .ig .left{display:contents}
28
+ .ig .head{order:1}.ig .fill{order:2}.ig .band{order:3}
29
+ .ig.landscape .main{top:70px;bottom:90px;flex-direction:row;gap:60px}
30
+ .ig.landscape .left{display:flex;flex-direction:column;justify-content:space-between;gap:28px;width:600px;flex:none;min-height:0}
31
+ .ig.landscape .head{display:block;height:auto}
32
+ .ig.landscape .head .txt{width:100%}
33
+ .ig.landscape h1{font-size:60px}
34
+ .ig.landscape .sub{font-size:28px}
35
+ .ig.landscape .hero{display:block;width:360px;height:300px;margin:22px 0 0 auto}
36
+ .ig.landscape .fill{order:0;width:1380px;flex:none}
37
+ .ig.landscape .band{grid-template-columns:1fr;gap:18px;padding:30px 34px}
38
+ .ig.landscape .band .q{font-size:36px}
39
+ .ig.landscape .band ul{font-size:22px}
40
+ {% block css %}{% endblock %}
41
+ </style>
42
+ <div class="ig {{ layout }}" style="--bg1:{{ pal.bg1 }};--bg2:{{ pal.bg2 }};--ink:{{ pal.ink }};--mute:{{ pal.mute }};--a:{{ pal.a }};--b:{{ pal.b }};--card:{{ pal.card }};--line:{{ pal.line }};--paper:{{ pal.paper }};--paperink:{{ pal.paperink }};--r:{{ pal.r }}px;--fh:'{{ fonts.h }}',serif;--fb:'{{ fonts.b }}',sans-serif;--fm:'{{ fonts.m }}',monospace;--hw:{{ fonts.hw }};--ht:{{ fonts.ht }};--bt:{{ fonts.bt }}">
43
+ <svg class="bg" width="{{ width }}" height="{{ height }}" viewBox="0 0 {{ width }} {{ height }}">
44
+ {% for s in stars %}<circle cx="{{ s[0] }}" cy="{{ s[1] }}" r="{{ s[2] }}" fill="{{ pal.ink }}" opacity="{{ s[3] }}"/>{% endfor %}
45
+ <g{% if layout == "landscape" %} transform="translate(660 0)"{% endif %}>{% block bg %}{% endblock %}</g>
46
+ </svg>
47
+ <div class="main">
48
+ <div class="left">
49
+ <div class="head">
50
+ <div class="txt">
51
+ <div class="eyebrow">{{ eyebrow }}</div>
52
+ <h1>{{ c.title }}</h1>
53
+ <p class="sub">{{ c.subtitle }}</p>
54
+ </div>
55
+ <svg class="hero" viewBox="0 0 480 400">{% block hero %}{% endblock %}</svg>
56
+ </div>
57
+ <div class="band">
58
+ <div class="q">{{ c.rule }}</div>
59
+ <ul>{% for n in c.notes %}<li>{{ n }}</li>{% endfor %}</ul>
60
+ </div>
61
+ </div>
62
+ <div class="fill">{% block body %}{% endblock %}</div>
63
+ </div>
64
+ <div class="mark">{{ logo }}<span>notestack.ai</span></div>
65
+ </div>
backend/app/infographics/templates/design.css ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* The infographic design system: black, blue and white, thin lines, flow charts. The page frame (head, takeaway, mark, the
2
+ background) is ours; the body is composed by the AI from the components below, and only these class names survive the
3
+ sanitiser (design.ALLOWED_CLASSES). */
4
+ .ig2,.ig2 *{box-sizing:border-box;margin:0;padding:0}
5
+ .ig2{position:relative;width:var(--w);height:var(--h);overflow:hidden;color:var(--ink);font-family:'Inter',sans-serif;
6
+ background:radial-gradient(1300px 900px at 82% 6%,var(--bg2),var(--bg) 68%)}
7
+ .ig2 .ig-bg{position:absolute;inset:0;pointer-events:none}
8
+ .ig2 .ig-main{position:absolute;left:110px;right:110px;top:84px;bottom:132px;display:flex;flex-direction:column;gap:34px}
9
+ .ig2 .ig-left{display:contents}
10
+ .ig2 .ig-head{order:1;position:relative;flex:none;min-height:286px}
11
+ .ig2 .ig-body{order:2;flex:1;min-height:0;overflow:hidden;display:flex}
12
+ .ig2 .ig-take{order:3;flex:none}
13
+
14
+ .ig2 .ig-eyebrow{display:flex;align-items:center;gap:16px;font:500 20px 'JetBrains Mono',monospace;letter-spacing:.24em;text-transform:uppercase;color:var(--acc2);margin-bottom:20px}
15
+ .ig2 .ig-eyebrow:before{content:"";width:44px;height:2px;background:var(--acc)}
16
+ .ig2 h1{font:600 74px/1.04 'Space Grotesk',sans-serif;letter-spacing:-.02em;max-width:850px;margin-bottom:20px}
17
+ .ig2 .ig-sub{font-size:30px;line-height:1.38;color:var(--mute);max-width:850px}
18
+ /* The key figures: the few numbers or short facts that matter most, beside the title (or under it when wide) */
19
+ .ig2 .ig-figs{position:absolute;right:0;top:0;width:430px;display:flex;flex-direction:column;gap:16px}
20
+ .ig2 .fig{padding:6px 0 6px 24px;border-left:3px solid var(--acc)}
21
+ .ig2 .fig-v{display:block;font:600 56px/1.02 'Space Grotesk',sans-serif;letter-spacing:-.02em;overflow-wrap:anywhere}
22
+ .ig2 .fig-l{display:block;margin-top:6px;font:500 19px 'JetBrains Mono',monospace;letter-spacing:.1em;text-transform:uppercase;color:var(--mute)}
23
+
24
+ .ig2 .ig-take{display:grid;grid-template-columns:1.05fr 1fr;gap:48px;align-items:center;padding:32px 46px;border-radius:16px;background:var(--paper);color:var(--paperink)}
25
+ .ig2 .ig-take .q{font:600 38px/1.16 'Space Grotesk',sans-serif;letter-spacing:-.01em}
26
+ .ig2 .ig-take ul{list-style:none;font-size:24px;line-height:1.38}
27
+ .ig2 .ig-take li{position:relative;padding-left:28px;margin:8px 0}
28
+ .ig2 .ig-take li:before{content:"";position:absolute;left:0;top:12px;width:11px;height:11px;border-radius:50%;background:var(--acc)}
29
+
30
+ .ig2 .ig-mark{position:absolute;right:44px;bottom:34px;display:flex;align-items:center;gap:16px;padding:8px 26px 8px 10px;border-radius:999px;
31
+ background:var(--markbg);border:1px solid var(--markline);font:700 30px 'JetBrains Mono',monospace;letter-spacing:.04em;color:var(--markink)}
32
+ .ig2 .ig-mark svg{width:52px;height:52px;display:block}
33
+
34
+ /* The body: fitted by scaling when the AI wrote more than a poster holds (the scale is measured once, when it is made) */
35
+ .ig2 .ig-inner{display:flex;flex-direction:column;justify-content:space-evenly;gap:34px;min-height:calc(100% / var(--fit,1));flex:none;width:calc(1380px / var(--fit,1));transform:scale(var(--fit,1));transform-origin:0 0}
36
+ .ig2 .sec{display:flex;flex-direction:column;gap:20px}
37
+ .ig2 .sec-t{display:flex;align-items:center;gap:18px;font:500 20px 'JetBrains Mono',monospace;letter-spacing:.22em;text-transform:uppercase;color:var(--acc2)}
38
+ .ig2 .sec-t:after{content:"";flex:1;height:1.5px;background:var(--line)}
39
+ .ig2 .lead{font-size:28px;line-height:1.4;color:var(--ink)}
40
+ .ig2 .ig-inner p{font-size:24px;line-height:1.4;color:var(--mute)}
41
+ .ig2 .ig-inner strong,.ig2 .ig-inner b{color:var(--ink);font-weight:600}
42
+ .ig2 .ig-inner em,.ig2 .ig-inner i{color:var(--acc2);font-style:normal}
43
+ .ig2 .mono{font-family:'JetBrains Mono',monospace}
44
+ .ig2 .small{font-size:20px}
45
+
46
+ /* A box in a flow: a small number, a title, a line of text */
47
+ .ig2 .node{position:relative;padding:24px 26px;border:1.5px solid var(--line);border-radius:14px;background:linear-gradient(var(--card),var(--card)),linear-gradient(var(--bg),var(--bg))}
48
+ .ig2 .node.hl{background:linear-gradient(var(--hl),var(--hl)),linear-gradient(var(--bg),var(--bg));border-color:var(--acc)}
49
+ .ig2 .n-no{display:block;font:500 19px 'JetBrains Mono',monospace;letter-spacing:.14em;color:var(--acc2);margin-bottom:10px}
50
+ .ig2 .n-t{display:block;font:600 31px/1.15 'Space Grotesk',sans-serif;margin-bottom:8px}
51
+ .ig2 .n-d{display:block;font-size:23px;line-height:1.38;color:var(--mute)}
52
+
53
+ /* Flow chart: boxes in a row (or a column) joined by arrows */
54
+ .ig2 .flow{display:flex;gap:58px;align-items:stretch}
55
+ .ig2 .flow>.node{flex:1;min-width:0}
56
+ .ig2 .flow>.node+.node:before{content:"";position:absolute;left:-48px;top:50%;width:34px;height:2px;background:var(--acc)}
57
+ .ig2 .flow>.node+.node:after{content:"";position:absolute;left:-15px;top:50%;margin-top:-7px;border-style:solid;border-width:7px 0 7px 11px;border-color:transparent transparent transparent var(--acc)}
58
+ .ig2 .flow.col{flex-direction:column;gap:50px}
59
+ .ig2 .flow.col>.node+.node:before{left:50%;top:-40px;width:2px;height:28px;margin-left:-1px}
60
+ .ig2 .flow.col>.node+.node:after{left:50%;top:-13px;margin:0 0 0 -7px;border-width:11px 7px 0 7px;border-color:var(--acc) transparent transparent transparent}
61
+
62
+ /* Cards in a grid */
63
+ .ig2 .grid{display:grid;gap:22px}
64
+ .ig2 .c2{grid-template-columns:repeat(2,minmax(0,1fr))}
65
+ .ig2 .c3{grid-template-columns:repeat(3,minmax(0,1fr))}
66
+ .ig2 .c4{grid-template-columns:repeat(4,minmax(0,1fr))}
67
+ .ig2 .card{padding:24px 26px 26px;border:1.5px solid var(--line);border-top:3px solid var(--acc);border-radius:14px;background:linear-gradient(var(--card),var(--card)),linear-gradient(var(--bg),var(--bg))}
68
+ .ig2 .card.hl{background:linear-gradient(var(--hl),var(--hl)),linear-gradient(var(--bg),var(--bg));border-color:var(--acc)}
69
+ .ig2 .c-k{display:block;font:500 19px 'JetBrains Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:var(--acc2);margin-bottom:10px}
70
+ .ig2 .c-t{display:block;font:600 29px/1.15 'Space Grotesk',sans-serif;margin-bottom:8px}
71
+ .ig2 .c-d{display:block;font-size:22px;line-height:1.38;color:var(--mute)}
72
+
73
+ /* A timeline: a line with a dot for each moment */
74
+ .ig2 .timeline{position:relative;display:flex;flex-direction:column;gap:28px;padding-left:46px}
75
+ .ig2 .timeline:before{content:"";position:absolute;left:10px;top:8px;bottom:8px;width:2px;background:var(--line)}
76
+ .ig2 .t-item{position:relative}
77
+ .ig2 .t-item:before{content:"";position:absolute;left:-46px;top:6px;width:16px;height:16px;border-radius:50%;background:var(--bg);border:3px solid var(--acc)}
78
+ .ig2 .t-when{display:block;font:500 19px 'JetBrains Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:var(--acc2);margin-bottom:6px}
79
+ .ig2 .t-t{display:block;font:600 30px/1.15 'Space Grotesk',sans-serif;margin-bottom:6px}
80
+ .ig2 .t-d{display:block;font-size:23px;line-height:1.38;color:var(--mute)}
81
+
82
+ /* A hierarchy: one box over the boxes it leads to, joined by lines */
83
+ .ig2 .tree{display:flex;flex-direction:column;align-items:center}
84
+ .ig2 .t-root{width:560px;max-width:100%;text-align:center;position:relative}
85
+ .ig2 .t-root:after{content:"";position:absolute;left:50%;top:100%;width:2px;height:28px;margin-left:-1px;background:var(--acc)}
86
+ .ig2 .t-kids{display:flex;gap:24px;width:100%;margin-top:28px}
87
+ .ig2 .t-kids>*{flex:1;min-width:0;position:relative;margin-top:28px}
88
+ .ig2 .t-kids>*:before{content:"";position:absolute;left:50%;top:-28px;width:2px;height:28px;margin-left:-1px;background:var(--acc)}
89
+ .ig2 .t-kids>*:after{content:"";position:absolute;top:-28px;left:-12px;right:-12px;height:2px;background:var(--acc)}
90
+ .ig2 .t-kids>*:first-child:after{left:50%}
91
+ .ig2 .t-kids>*:last-child:after{right:50%}
92
+ .ig2 .t-kids>*:only-child:after{display:none}
93
+
94
+ /* Two sides compared */
95
+ .ig2 .compare{display:grid;grid-template-columns:1fr 1fr;border:1.5px solid var(--line);border-radius:14px;overflow:hidden}
96
+ .ig2 .side{padding:26px 32px 22px;background:linear-gradient(var(--card),var(--card)),linear-gradient(var(--bg),var(--bg))}
97
+ .ig2 .side+.side{border-left:1.5px solid var(--line)}
98
+ .ig2 .side.hl{background:linear-gradient(var(--hl),var(--hl)),linear-gradient(var(--bg),var(--bg))}
99
+ .ig2 .s-h{display:block;font:600 32px/1.15 'Space Grotesk',sans-serif;margin-bottom:14px}
100
+ .ig2 .side ul{list-style:none}
101
+ .ig2 .side li{position:relative;padding:13px 0 13px 30px;border-top:1px solid var(--line);font-size:23px;line-height:1.35;color:var(--mute)}
102
+ .ig2 .side li:before{content:"";position:absolute;left:2px;top:23px;width:10px;height:10px;border-radius:50%;background:var(--acc)}
103
+
104
+ /* Big numbers */
105
+ .ig2 .stats{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(0,1fr);gap:22px}
106
+ .ig2 .stat{padding:20px 26px 22px;border-left:3px solid var(--acc);background:linear-gradient(var(--card),var(--card)),linear-gradient(var(--bg),var(--bg));border-radius:0 12px 12px 0}
107
+ .ig2 .s-n{display:block;font:600 62px/1 'Space Grotesk',sans-serif;letter-spacing:-.02em}
108
+ .ig2 .s-l{display:block;font-size:22px;line-height:1.35;color:var(--mute);margin-top:8px}
109
+
110
+ .ig2 .cols{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:40px}
111
+ .ig2 .pills{display:flex;flex-wrap:wrap;gap:12px}
112
+ .ig2 .pill{padding:10px 22px;border:1.5px solid var(--line);border-radius:999px;font-size:22px;background:linear-gradient(var(--card),var(--card)),linear-gradient(var(--bg),var(--bg))}
113
+ .ig2 .callout{padding:24px 30px;border-left:4px solid var(--acc);background:linear-gradient(var(--hl),var(--hl)),linear-gradient(var(--bg),var(--bg));border-radius:0 12px 12px 0;font-size:27px;line-height:1.38}
114
+
115
+ /* Wide: the title and the takeaway in a column, the body beside them */
116
+ .ig2.landscape .ig-main{top:70px;bottom:104px;flex-direction:row;gap:60px}
117
+ .ig2.landscape .ig-left{display:flex;flex-direction:column;justify-content:space-between;gap:28px;width:600px;flex:none;min-height:0}
118
+ .ig2.landscape .ig-head{min-height:0}
119
+ .ig2.landscape h1{font-size:58px;max-width:none}
120
+ .ig2.landscape .ig-sub{font-size:27px;max-width:none}
121
+ .ig2.landscape .ig-figs{position:static;width:100%;margin-top:34px;gap:14px}
122
+ .ig2.landscape .fig-v{font-size:50px}
123
+ .ig2.landscape .ig-body{order:0;width:1380px;flex:none}
124
+ .ig2.landscape .ig-take{grid-template-columns:1fr;gap:18px;padding:30px 34px}
125
+ .ig2.landscape .ig-take .q{font-size:35px}
126
+ .ig2.landscape .ig-take ul{font-size:22px}
backend/app/infographics/templates/design.html.j2 ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {#- The frame of an AI-composed infographic. `body` is sanitised markup (design.sanitize_body); every other value is autoescaped. -#}
2
+ <link rel="stylesheet" href="{{ fonts }}">
3
+ <style>{{ css }}</style>
4
+ <div class="ig2 look-{{ look }} {{ layout }}" style="--w:{{ w }}px;--h:{{ h }}px;--fit:{{ fit }};{{ vars_css }}">
5
+ <svg class="ig-bg" width="{{ w }}" height="{{ h }}" viewBox="0 0 {{ w }} {{ h }}">{{ stars }}</svg>
6
+ <div class="ig-main">
7
+ <div class="ig-left">
8
+ <header class="ig-head">
9
+ <div class="ig-eyebrow">{{ c.eyebrow }}</div>
10
+ <h1>{{ c.title }}</h1>
11
+ <p class="ig-sub">{{ c.subtitle }}</p>
12
+ {% if c.key_figures %}<div class="ig-figs">{% for f in c.key_figures %}<div class="fig"><span class="fig-v">{{ f.value }}</span><span class="fig-l">{{ f.label }}</span></div>{% endfor %}</div>{% endif %}
13
+ </header>
14
+ <div class="ig-take">
15
+ <div class="q">{{ c.rule }}</div>
16
+ <ul>{% for n in c.notes %}<li>{{ n }}</li>{% endfor %}</ul>
17
+ </div>
18
+ </div>
19
+ <div class="ig-body"><div class="ig-inner">{{ body }}</div></div>
20
+ </div>
21
+ <div class="ig-mark">{{ logo }}<span>notestack.ai</span></div>
22
+ </div>
backend/app/infographics/templates/galaxy.html.j2 ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html.j2" %}
2
+ {% set eyebrow = "Field notes from the galaxy" %}
3
+ {% set pal = {"bg1":"#1c1830","bg2":"#3a2f57","ink":"#ece7f3","mute":"#c1b9d0","a":"#b79ad6","b":"#7fa3b8","card":"rgba(236,231,243,.06)","line":"rgba(183,154,214,.28)","paper":"#e9e2f2","paperink":"#241f33","r":26} %}
4
+ {% set fonts = {"h":"Cormorant Garamond","b":"Outfit","m":"Space Mono","hw":700,"ht":"none","bt":"none"} %}
5
+ {% set cycle = ["#b79ad6","#7fa3b8","#d9a7b8","#8fb8a8","#c9b27a"] %}
6
+ {% block bg %}
7
+ <defs><radialGradient id="neb" cx=".5" cy=".5"><stop offset="0" stop-color="#b79ad6" stop-opacity=".45"/><stop offset="1" stop-color="#b79ad6" stop-opacity="0"/></radialGradient>
8
+ <radialGradient id="neb2" cx=".5" cy=".5"><stop offset="0" stop-color="#7fa3b8" stop-opacity=".35"/><stop offset="1" stop-color="#7fa3b8" stop-opacity="0"/></radialGradient></defs>
9
+ <circle cx="1300" cy="300" r="520" fill="url(#neb)"/><circle cx="250" cy="1800" r="560" fill="url(#neb2)"/>
10
+ <g fill="none" stroke="#b79ad6" stroke-opacity=".22" stroke-width="2"><ellipse cx="1360" cy="1250" rx="620" ry="190" transform="rotate(-24 1360 1250)"/><ellipse cx="1360" cy="1250" rx="440" ry="130" transform="rotate(-24 1360 1250)"/><ellipse cx="200" cy="2020" rx="540" ry="150" transform="rotate(18 200 2020)"/></g>
11
+ {% endblock %}
12
+ {% block hero %}
13
+ <defs><radialGradient id="core" cx=".5" cy=".5"><stop offset="0" stop-color="#f4ecff"/><stop offset=".25" stop-color="#b79ad6"/><stop offset="1" stop-color="#b79ad6" stop-opacity="0"/></radialGradient></defs>
14
+ <g transform="translate(240 200)" fill="none" stroke-linecap="round">
15
+ <ellipse rx="210" ry="70" transform="rotate(-20)" stroke="#b79ad6" stroke-width="3" opacity=".9"/><ellipse rx="160" ry="52" transform="rotate(-20)" stroke="#7fa3b8" stroke-width="3"/>
16
+ <ellipse rx="110" ry="36" transform="rotate(-20)" stroke="#d9a7b8" stroke-width="3"/><ellipse rx="60" ry="20" transform="rotate(-20)" stroke="#c9b27a" stroke-width="3"/>
17
+ <path d="M-200 60C-120 -150 120 -190 210 -40" stroke="#b79ad6" stroke-width="2" stroke-dasharray="2 10" opacity=".8"/>
18
+ <circle r="86" fill="url(#core)" stroke="none"/>
19
+ <g fill="#ece7f3" stroke="none"><circle cx="-190" cy="64" r="9" fill="#7fa3b8"/><circle cx="150" cy="-70" r="7" fill="#d9a7b8"/><circle cx="196" cy="42" r="10" fill="#c9b27a"/><circle cx="-60" cy="-110" r="5"/><circle cx="90" cy="120" r="6"/></g>
20
+ </g>
21
+ {% endblock %}
22
+ {% block css %}
23
+ .ig h1{font-size:84px;line-height:1}
24
+ .gi{display:grid;grid-template-columns:76px 300px 1fr;gap:24px;align-items:center;width:1090px;min-height:104px;padding:14px 30px 14px 22px;border-radius:60px;background:var(--card);border:1px solid var(--line);margin-bottom:14px;position:relative}
25
+ .gi:before{content:"";position:absolute;left:-40px;top:50%;width:40px;height:2px;background:linear-gradient(90deg,transparent,var(--c))}
26
+ .gi .mk{width:64px;height:64px;border-radius:50%;display:grid;place-items:center;font-family:var(--fh);font-weight:700;font-size:32px;color:var(--bg1);background:radial-gradient(circle at 35% 30%,#fff8,var(--c) 60%);box-shadow:0 0 0 7px rgba(183,154,214,.16),0 0 30px var(--c)}
27
+ .gi b{font-family:var(--fh);font-weight:700;font-size:36px;line-height:1.05}
28
+ .gi p{font-size:24px;line-height:1.3;color:var(--mute)}
29
+ .gc{display:grid;grid-template-columns:repeat(4,1fr);gap:0 28px;row-gap:20px;flex:none}
30
+ .gs{position:relative;padding-top:34px}
31
+ .gs:before{content:"";position:absolute;left:0;right:-28px;top:9px;height:1px;background:var(--line)}
32
+ .gs:nth-child(4n):before{right:0}
33
+ .gs:after{content:"";position:absolute;left:0;top:2px;width:15px;height:15px;border-radius:50%;background:var(--c);box-shadow:0 0 0 5px rgba(183,154,214,.15),0 0 18px var(--c)}
34
+ .gs small{display:block;font-family:var(--fm);font-size:19px;letter-spacing:.14em;color:var(--c);margin-bottom:4px}
35
+ .gs b{display:block;font-family:var(--fh);font-weight:700;font-size:34px;margin-bottom:6px}
36
+ .gs p{font-size:22px;line-height:1.32;color:var(--mute)}
37
+ {% endblock %}
38
+ {% block body %}<section><div class="lab">{{ c.items_label }}</div>
39
+ <div class="gitems" style="flex:none">
40
+ {% for e in c["items"] %}<div class="gi" style="--c:{{ cycle[loop.index0 % cycle|length] }};margin-left:{{ loop.index0 * 56 + 30 }}px"><div class="mk">{{ loop.index }}</div><b>{{ e.name }}</b><p>{{ e.text }}</p></div>{% endfor %}
41
+ </div></section>
42
+ <section><div class="lab">{{ c.steps_label }}</div>
43
+ <div class="gc">
44
+ {% for e in c.steps %}<div class="gs" style="--c:{{ cycle[loop.index0 % cycle|length] }}"><small>{{ '%02d'|format(loop.index) }}</small><b>{{ e.name }}</b><p>{{ e.text }}</p></div>{% endfor %}
45
+ </div></section>
46
+ {% endblock %}
backend/app/infographics/templates/launch.html.j2 ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html.j2" %}
2
+ {% set eyebrow = "Mission briefing" %}
3
+ {% set pal = {"bg1":"#1d2a34","bg2":"#34495a","ink":"#efe7d8","mute":"#c3ccd1","a":"#cfa24d","b":"#c8745f","card":"rgba(239,231,216,.07)","line":"rgba(239,231,216,.18)","paper":"#efe7d8","paperink":"#1d2a34","r":18} %}
4
+ {% set fonts = {"h":"Jost","b":"Outfit","m":"Space Mono","hw":700,"ht":"uppercase","bt":"uppercase"} %}
5
+ {% set cycle = ["#cfa24d","#c8745f","#8fb0a0","#8fa6c4","#cfa24d"] %}
6
+ {% block bg %}
7
+ <circle cx="1440" cy="150" r="88" fill="#e9dfc6" opacity=".9"/><circle cx="1408" cy="122" r="15" fill="#b9ad90" opacity=".7"/><circle cx="1470" cy="178" r="21" fill="#b9ad90" opacity=".7"/>
8
+ <g fill="#efe7d8" opacity=".07"><circle cx="300" cy="2200" r="150"/><circle cx="520" cy="2230" r="120"/><circle cx="1200" cy="2210" r="170"/></g>
9
+ {% endblock %}
10
+ {% block hero %}
11
+ <g fill="none" stroke="#cfa24d" stroke-width="3" stroke-dasharray="3 10" stroke-linecap="round"><ellipse cx="250" cy="215" rx="205" ry="92" transform="rotate(-16 250 215)"/><ellipse cx="250" cy="215" rx="140" ry="58" transform="rotate(-16 250 215)"/></g>
12
+ <circle cx="250" cy="215" r="64" fill="#e9dfc6"/><circle cx="228" cy="195" r="12" fill="#b9ad90" opacity=".7"/><circle cx="276" cy="238" r="16" fill="#b9ad90" opacity=".7"/><circle cx="262" cy="180" r="7" fill="#b9ad90" opacity=".7"/>
13
+ <g transform="translate(404 122) rotate(-16)"><rect x="-17" y="-13" width="34" height="26" rx="5" fill="#cfa24d"/><rect x="-66" y="-9" width="42" height="18" fill="#8fa6c4"/><rect x="24" y="-9" width="42" height="18" fill="#8fa6c4"/><path d="M-66 -9V9M-52 -9V9M-38 -9V9M38 -9V9M52 -9V9" stroke="#1d2a34" stroke-width="2"/></g>
14
+ <g fill="#efe7d8"><path d="M70 90l5 -14 5 14 14 5 -14 5 -5 14 -5 -14 -14 -5z" opacity=".8"/><path d="M60 320l4 -10 4 10 10 4 -10 4 -4 10 -4 -10 -10 -4z" opacity=".6"/></g>
15
+ {% endblock %}
16
+ {% block css %}
17
+ .ig{--hh:380px}.ig h1{font-size:68px}
18
+ .lr{position:relative;flex:none}.lr svg{position:absolute;left:0;top:0}
19
+ .lr .co{position:absolute;width:470px;padding:16px 22px 18px;border-radius:18px;background:rgba(239,231,216,.07);border:1px solid rgba(239,231,216,.2)}
20
+ .lr .co b{display:flex;align-items:center;gap:12px;font-family:var(--fh);font-size:29px;letter-spacing:.04em;text-transform:uppercase;margin-bottom:6px}
21
+ .lr .co b i{font-style:normal;flex:none;width:38px;height:38px;border-radius:50%;display:grid;place-items:center;font-size:21px;color:#1d2a34;background:var(--c)}
22
+ .lr .co p{font-size:23px;line-height:1.34;color:var(--mute)}
23
+ .cd{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;flex:none}
24
+ .cd .t{position:relative;padding:16px 20px 18px;border-radius:16px;background:var(--card);border-top:6px solid var(--c)}
25
+ .cd .t:after{content:"";position:absolute;right:18px;top:18px;width:13px;height:13px;border-radius:50%;background:var(--c);box-shadow:0 0 0 4px rgba(239,231,216,.1),0 0 14px var(--c)}
26
+ .cd small{display:block;font-family:var(--fm);font-size:19px;letter-spacing:.1em;color:var(--c)}
27
+ .cd b{display:block;font-family:var(--fh);font-size:30px;text-transform:uppercase;letter-spacing:.04em;margin:2px 0 6px}
28
+ .cd p{font-size:21px;line-height:1.32;color:var(--mute)}
29
+ {% endblock %}
30
+ {% block body %}
31
+ {% set S = 110 %}{% set n = c["items"]|length %}{% set H = 120 + n * S + 90 %}
32
+ <section><div class="lab">{{ c.items_label }}</div>
33
+ <div class="lr" style="height:{{ H }}px">
34
+ <svg width="1380" height="{{ H }}" viewBox="0 0 1380 {{ H }}">
35
+ <defs><linearGradient id="gr" x1="0" x2="1"><stop offset="0" stop-color="#e8dfcf"/><stop offset="1" stop-color="#b9b3a6"/></linearGradient></defs>
36
+ <path d="M690 0C738 36 760 76 760 120H620C620 76 642 36 690 0Z" fill="#c8745f"/>
37
+ {% for e in c["items"] %}{% set y = 120 + loop.index0 * S %}{% set cc = cycle[loop.index0 % cycle|length] %}
38
+ <rect x="620" y="{{ y }}" width="140" height="{{ S }}" fill="url(#gr)" stroke="#1d2a34" stroke-width="3"/>
39
+ <circle cx="690" cy="{{ y + S // 2 }}" r="30" fill="{{ cc }}" stroke="#1d2a34" stroke-width="3"/>
40
+ <text x="690" y="{{ y + S // 2 + 11 }}" text-anchor="middle" font-family="Jost" font-weight="700" font-size="32" fill="#1d2a34">{{ loop.index }}</text>
41
+ {% if loop.index0 % 2 == 0 %}<path d="M620 {{ y + S // 2 }}H480" stroke="{{ cc }}" stroke-width="3" stroke-dasharray="2 9" stroke-linecap="round"/>
42
+ {% else %}<path d="M760 {{ y + S // 2 }}H900" stroke="{{ cc }}" stroke-width="3" stroke-dasharray="2 9" stroke-linecap="round"/>{% endif %}
43
+ {% endfor %}
44
+ {% set yb = 120 + n * S %}
45
+ <path d="M620 {{ yb - S }}L552 {{ yb }}H620ZM760 {{ yb - S }}L828 {{ yb }}H760Z" fill="#c8745f"/>
46
+ <path d="M644 {{ yb }}h92l-10 26h-72Z" fill="#8a8578"/>
47
+ <path d="M656 {{ yb + 26 }}C664 {{ yb + 56 }} 690 {{ yb + 84 }} 690 {{ yb + 84 }}C690 {{ yb + 84 }} 716 {{ yb + 56 }} 724 {{ yb + 26 }}Z" fill="#cfa24d"/>
48
+ <path d="M672 {{ yb + 26 }}C678 {{ yb + 46 }} 690 {{ yb + 62 }} 690 {{ yb + 62 }}C690 {{ yb + 62 }} 702 {{ yb + 46 }} 708 {{ yb + 26 }}Z" fill="#f4ead3"/>
49
+ </svg>
50
+ {% for e in c["items"] %}{% set cc = cycle[loop.index0 % cycle|length] %}
51
+ <div class="co" style="--c:{{ cc }};top:{{ 120 + loop.index0 * S + S // 2 - 46 }}px;{{ 'left:0' if loop.index0 % 2 == 0 else 'right:0' }}"><b><i>{{ loop.index }}</i>{{ e.name }}</b><p>{{ e.text }}</p></div>
52
+ {% endfor %}
53
+ </div></section>
54
+ <section><div class="lab">{{ c.steps_label }}</div>
55
+ <div class="cd">
56
+ {% for e in c.steps %}<div class="t" style="--c:{{ cycle[loop.index0 % cycle|length] }}"><small>T-{{ c.steps|length - loop.index0 }}</small><b>{{ e.name }}</b><p>{{ e.text }}</p></div>{% endfor %}
57
+ </div></section>
58
+ {% endblock %}