Spaces:
Runtime error
Runtime error
Merge pull request #9 from FireBird-Technologies/develop
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +0 -131
- .github/workflows/ci.yml +2 -2
- .gitignore +4 -0
- Dockerfile +25 -0
- README.md +27 -7
- backend/.env.example +34 -0
- backend/0 +1 -0
- backend/Dockerfile +3 -0
- backend/alembic/versions/0005_video_quota.py +48 -0
- backend/alembic/versions/0006_plans.py +74 -0
- backend/alembic/versions/0007_subscription_plan_fk.py +36 -0
- backend/alembic/versions/0008_b2v_videos.py +40 -0
- backend/alembic/versions/0009_b2v_resources.py +111 -0
- backend/alembic/versions/0010_b2v_video_refs.py +96 -0
- backend/alembic/versions/0011_one_archive_notebook.py +48 -0
- backend/alembic/versions/0012_artifact_status_len.py +26 -0
- backend/alembic/versions/0013_workspace_memory.py +32 -0
- backend/alembic/versions/0014_chat_citations.py +30 -0
- backend/alembic/versions/0015_message_feedback.py +48 -0
- backend/alembic/versions/0016_support_chat.py +50 -0
- backend/alembic/versions/0017_reports_limit.py +26 -0
- backend/alembic/versions/0018_report_sharing.py +41 -0
- backend/alembic/versions/0019_infographics_limit.py +26 -0
- backend/alembic/versions/0020_calendar_kit.py +35 -0
- backend/alembic/versions/0021_artifact_feedback.py +40 -0
- backend/alembic/versions/0022_report_template_cache.py +34 -0
- backend/alembic/versions/0023_drop_brand_kit.py +31 -0
- backend/alembic/versions/0024_plan_limits.py +49 -0
- backend/alembic/versions/0025_drop_video_premium.py +24 -0
- backend/alembic/versions/0026_plan_copy.py +40 -0
- backend/app/chat_memory/__init__.py +5 -0
- backend/app/chat_memory/context.py +260 -0
- backend/app/chat_memory/files.py +237 -0
- backend/app/chat_memory/logs.py +19 -0
- backend/app/chat_memory/organize.py +528 -0
- backend/app/chat_memory/search.py +115 -0
- backend/app/chat_memory/tools.py +136 -0
- backend/app/config.py +68 -2
- backend/app/corpus.py +9 -5
- backend/app/db.py +12 -2
- backend/app/infographics/__init__.py +0 -0
- backend/app/infographics/content.py +108 -0
- backend/app/infographics/design.py +213 -0
- backend/app/infographics/image.py +111 -0
- backend/app/infographics/render.py +53 -0
- backend/app/infographics/templates/base.html.j2 +65 -0
- backend/app/infographics/templates/design.css +126 -0
- backend/app/infographics/templates/design.html.j2 +22 -0
- backend/app/infographics/templates/galaxy.html.j2 +46 -0
- 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
|
| 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
|
| 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 |
|
| 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
|
| 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:
|
| 87 |
-
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 96 |
-
self.
|
|
|
|
| 97 |
|
| 98 |
# Keys and paths
|
| 99 |
|
| 100 |
def _key(self, rel: str) -> str:
|
| 101 |
-
return f"ws/{self.workspace_id}/
|
| 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 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 %}
|