Spaces:
Runtime error
Runtime error
Commit ·
34b0226
1
Parent(s): 013ff92
added blogs
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +31 -1
- .gitignore +8 -0
- README.md +30 -5
- backend/alembic/env.py +3 -1
- backend/alembic/versions/0002_build_out.py +94 -0
- backend/app/config.py +30 -3
- backend/app/db.py +14 -2
- backend/app/llm/run.py +48 -0
- backend/app/llm/signatures.py +105 -2
- backend/app/main.py +31 -6
- backend/app/models/__init__.py +6 -0
- backend/app/models/content.py +60 -3
- backend/app/pipeline/generate.py +182 -0
- backend/app/pipeline/ingest.py +300 -30
- backend/app/pipeline/launchkit.py +108 -0
- backend/app/pipeline/media.py +260 -0
- backend/app/pipeline/passages.py +98 -0
- backend/app/routers/artifacts.py +171 -45
- backend/app/routers/jobs.py +22 -3
- backend/app/routers/launchpad.py +360 -0
- backend/app/routers/notebooks.py +141 -21
- backend/app/routers/resurface.py +53 -0
- backend/app/routers/settings.py +84 -0
- backend/app/routers/sources.py +158 -31
- backend/app/routers/storage.py +40 -3
- backend/app/routers/topics.py +71 -0
- backend/app/routers/voice.py +144 -0
- backend/app/services/artifacts.py +76 -0
- backend/app/services/crypto.py +31 -0
- backend/app/services/email.py +15 -0
- backend/app/services/jobs.py +1 -0
- backend/app/services/launchpad.py +156 -0
- backend/app/services/renderer.py +65 -0
- backend/app/services/social/__init__.py +55 -0
- backend/app/services/social/bluesky.py +67 -0
- backend/app/services/social/linkedin.py +87 -0
- backend/app/services/social/x.py +95 -0
- backend/app/services/storage.py +91 -3
- backend/app/services/tts.py +124 -0
- backend/app/services/usage.py +64 -0
- backend/app/worker.py +145 -35
- backend/requirements.txt +1 -0
- backend/tests/conftest.py +36 -3
- backend/tests/test_buildout.py +612 -0
- frontend/src/App.tsx +24 -11
- frontend/src/api/auth.ts +1 -0
- frontend/src/api/client.ts +44 -2
- frontend/src/api/endpoints.ts +161 -0
- frontend/src/api/types.ts +230 -0
- frontend/src/components/AppShell.tsx +45 -30
.env.example
CHANGED
|
@@ -7,6 +7,13 @@ JWT_SECRET=replace-with-64-random-chars
|
|
| 7 |
INTERNAL_TOKEN=replace-with-random-token
|
| 8 |
BILLING_ENABLED=false
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
# ---- Google sign in (OAuth client, type Web; add http://localhost:5173 to authorized JS origins)
|
| 11 |
# Client ID only: popup sign in (Google Identity Services), no redirect URIs needed.
|
| 12 |
# Client ID + secret: redirect flow with server side code exchange. Add the redirect URI
|
|
@@ -23,6 +30,13 @@ FROM_EMAIL=Notestack <hello@notestack.ai>
|
|
| 23 |
ALERTS_EMAIL=arslan@firebird-technologies.com
|
| 24 |
UNSUBSCRIBE_SECRET=replace-me
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
# ---- Storage: Cloudflare R2
|
| 27 |
# Prod: set account id + keys, leave R2_ENDPOINT_URL empty.
|
| 28 |
# Local: docker-compose points R2_ENDPOINT_URL at MinIO (keys minioadmin/minioadmin).
|
|
@@ -53,5 +67,21 @@ WORKER_CONCURRENCY=4
|
|
| 53 |
WORKER_POLL_SECONDS=1
|
| 54 |
JOB_STALE_SECONDS=900
|
| 55 |
|
| 56 |
-
# ---- ElevenLabs (
|
| 57 |
ELEVENLABS_API_KEY=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
INTERNAL_TOKEN=replace-with-random-token
|
| 8 |
BILLING_ENABLED=false
|
| 9 |
|
| 10 |
+
# ---- Database (also backs the job queue)
|
| 11 |
+
# Local: a SQLite file, created by `alembic upgrade head` run from backend/.
|
| 12 |
+
# Prod (Neon, Supabase, RDS...): Postgres with the psycopg driver, e.g.
|
| 13 |
+
# DATABASE_URL=postgresql+psycopg://user:pass@host:5432/dbname?sslmode=require
|
| 14 |
+
# docker-compose ignores this and points at its own Postgres container.
|
| 15 |
+
DATABASE_URL=sqlite:///./notestack.db
|
| 16 |
+
|
| 17 |
# ---- Google sign in (OAuth client, type Web; add http://localhost:5173 to authorized JS origins)
|
| 18 |
# Client ID only: popup sign in (Google Identity Services), no redirect URIs needed.
|
| 19 |
# Client ID + secret: redirect flow with server side code exchange. Add the redirect URI
|
|
|
|
| 30 |
ALERTS_EMAIL=arslan@firebird-technologies.com
|
| 31 |
UNSUBSCRIBE_SECRET=replace-me
|
| 32 |
|
| 33 |
+
# ---- Local dev conveniences
|
| 34 |
+
# The API runs the job worker in process when ENV=development (set false to use python -m app.worker).
|
| 35 |
+
RUN_WORKER_IN_API=
|
| 36 |
+
# auto = local disk (LOCAL_STORAGE_DIR) when no R2 account or endpoint is set, else R2
|
| 37 |
+
STORAGE_BACKEND=auto
|
| 38 |
+
LOCAL_STORAGE_DIR=.storage
|
| 39 |
+
|
| 40 |
# ---- Storage: Cloudflare R2
|
| 41 |
# Prod: set account id + keys, leave R2_ENDPOINT_URL empty.
|
| 42 |
# Local: docker-compose points R2_ENDPOINT_URL at MinIO (keys minioadmin/minioadmin).
|
|
|
|
| 67 |
WORKER_POLL_SECONDS=1
|
| 68 |
JOB_STALE_SECONDS=900
|
| 69 |
|
| 70 |
+
# ---- ElevenLabs (audio overviews, video narration, voice cloning)
|
| 71 |
ELEVENLABS_API_KEY=
|
| 72 |
+
ELEVENLABS_MODEL=eleven_multilingual_v2
|
| 73 |
+
ELEVENLABS_VOICE_A=21m00Tcm4TlvDq8ikWAM
|
| 74 |
+
ELEVENLABS_VOICE_B=pNInz6obpgDQGcFMaJgB
|
| 75 |
+
|
| 76 |
+
# ---- Launchpad social publishing
|
| 77 |
+
# Bluesky needs nothing here (users connect with an app password).
|
| 78 |
+
# X: developer app with OAuth 2.0, type Web App, scopes tweet.read tweet.write users.read offline.access.
|
| 79 |
+
# Callback URI: {API_URL}/api/social/x/callback e.g. http://localhost:8000/api/social/x/callback
|
| 80 |
+
# LinkedIn: app with the "Sign In with LinkedIn using OpenID Connect" and "Share on LinkedIn" products.
|
| 81 |
+
# Redirect URL: {API_URL}/api/social/linkedin/callback
|
| 82 |
+
X_CLIENT_ID=
|
| 83 |
+
X_CLIENT_SECRET=
|
| 84 |
+
LINKEDIN_CLIENT_ID=
|
| 85 |
+
LINKEDIN_CLIENT_SECRET=
|
| 86 |
+
# Fernet key for stored social tokens; derived from JWT_SECRET when empty.
|
| 87 |
+
SOCIAL_TOKEN_KEY=
|
.gitignore
CHANGED
|
@@ -12,3 +12,11 @@ __pycache__/
|
|
| 12 |
out/
|
| 13 |
renderer/build/
|
| 14 |
.corpus-cache/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
out/
|
| 13 |
renderer/build/
|
| 14 |
.corpus-cache/
|
| 15 |
+
|
| 16 |
+
# Local SQLite database
|
| 17 |
+
*.db
|
| 18 |
+
*.db-wal
|
| 19 |
+
*.db-shm
|
| 20 |
+
|
| 21 |
+
# Local object storage (used when no R2 is configured)
|
| 22 |
+
.storage/
|
README.md
CHANGED
|
@@ -4,7 +4,7 @@ Your archive, in orbit. Turn a Substack (or any feed) into a grounded research n
|
|
| 4 |
overviews, videos and launch kits, all traced back to what the writer actually wrote.
|
| 5 |
|
| 6 |
- Design doc: [docs/DESIGN.md](docs/DESIGN.md)
|
| 7 |
-
-
|
| 8 |
|
| 9 |
## Layout
|
| 10 |
|
|
@@ -27,16 +27,41 @@ docker compose up --build
|
|
| 27 |
`127.0.0.1 minio` to your hosts file for browser uploads to work locally.
|
| 28 |
- With `EMAIL_PROVIDER=console`, verification codes print in the `api` logs.
|
| 29 |
|
| 30 |
-
Without Docker:
|
| 31 |
|
| 32 |
```bash
|
| 33 |
cd backend && python -m venv .venv && .venv/Scripts/pip install -r requirements-dev.txt
|
| 34 |
-
alembic upgrade head && uvicorn app.main:app --reload #
|
| 35 |
-
python -m app.worker # second terminal
|
| 36 |
cd frontend && npm install && npm run dev
|
| 37 |
-
cd renderer && npm install && npm start #
|
| 38 |
```
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
## Key choices
|
| 41 |
|
| 42 |
| Concern | Where |
|
|
|
|
| 4 |
overviews, videos and launch kits, all traced back to what the writer actually wrote.
|
| 5 |
|
| 6 |
- Design doc: [docs/DESIGN.md](docs/DESIGN.md)
|
| 7 |
+
- Foundations task list: [docs/PHASE0.md](docs/PHASE0.md)
|
| 8 |
|
| 9 |
## Layout
|
| 10 |
|
|
|
|
| 27 |
`127.0.0.1 minio` to your hosts file for browser uploads to work locally.
|
| 28 |
- With `EMAIL_PROVIDER=console`, verification codes print in the `api` logs.
|
| 29 |
|
| 30 |
+
Without Docker (one API process does everything):
|
| 31 |
|
| 32 |
```bash
|
| 33 |
cd backend && python -m venv .venv && .venv/Scripts/pip install -r requirements-dev.txt
|
| 34 |
+
alembic upgrade head && uvicorn app.main:app --reload # SQLite at backend/notestack.db
|
|
|
|
| 35 |
cd frontend && npm install && npm run dev
|
| 36 |
+
cd renderer && npm install && npm start # only needed for videos, quote cards, carousels
|
| 37 |
```
|
| 38 |
|
| 39 |
+
- With `ENV=development` the API runs the job worker in process (`RUN_WORKER_IN_API`), so syncing,
|
| 40 |
+
topic mapping, audio and launch kits work without a second terminal. In production run
|
| 41 |
+
`python -m app.worker` separately and set `RUN_WORKER_IN_API=false`.
|
| 42 |
+
- With no R2 settings, files live on local disk under `backend/.storage` and are served through
|
| 43 |
+
signed `/api/storage/local/...` URLs (range requests supported for audio and video seeking).
|
| 44 |
+
- Audio needs `ELEVENLABS_API_KEY`; everything text based needs `LLM_API_KEY`.
|
| 45 |
+
- Launchpad: Bluesky connects with an app password. X and LinkedIn need developer apps; the
|
| 46 |
+
redirect URIs are listed in `.env.example` and on the Launchpad page. Substack Notes has no API,
|
| 47 |
+
so those posts (and any post without a connected account) arrive as an email reminder at send time.
|
| 48 |
+
|
| 49 |
+
## What is in the app
|
| 50 |
+
|
| 51 |
+
| Menu | Does |
|
| 52 |
+
| --- | --- |
|
| 53 |
+
| Mission Control | Pre-flight checklist, jobs in flight, sources, notebooks, recent launches, upcoming posts |
|
| 54 |
+
| Notebooks | Group posts, chat with cited answers (history kept), summaries, audio overviews, videos, quote cards |
|
| 55 |
+
| Sources | Connect feeds (Substack archive included), import one URL, upload md/txt/html/pdf, read posts by line |
|
| 56 |
+
| Topic map | LLM tagged topics per post, merged into a constellation; make a notebook from any topic |
|
| 57 |
+
| Voice profile | Writing voice built from your posts (editable), host voices, consented voice cloning |
|
| 58 |
+
| Video and audio | Audio overviews, 9:16 shorts, 16:9 explainers, 1:1 audiograms, quote cards |
|
| 59 |
+
| Launch Kit | Hooks, X thread, LinkedIn, Substack Notes, Bluesky, SEO pack, carousel, quote cards, all editable |
|
| 60 |
+
| Launchpad | Calendar and agenda, auto posting to X, LinkedIn and Bluesky, email reminders, tracked links, engagement |
|
| 61 |
+
| Archive | Every generated artifact with filters, players, downloads, retry and delete |
|
| 62 |
+
| Resurfacing | Evergreen scores and reshare angles for old posts, on this day, straight into a kit or the calendar |
|
| 63 |
+
| Settings | Profile, workspace, brand kit, privacy, plan usage meters, connections, sign out everywhere, delete |
|
| 64 |
+
|
| 65 |
## Key choices
|
| 66 |
|
| 67 |
| Concern | Where |
|
backend/alembic/env.py
CHANGED
|
@@ -24,7 +24,9 @@ def run_migrations_online() -> None:
|
|
| 24 |
connectable = engine_from_config(config.get_section(config.config_ini_section), prefix="sqlalchemy.",
|
| 25 |
poolclass=pool.NullPool)
|
| 26 |
with connectable.connect() as connection:
|
| 27 |
-
|
|
|
|
|
|
|
| 28 |
with context.begin_transaction():
|
| 29 |
context.run_migrations()
|
| 30 |
|
|
|
|
| 24 |
connectable = engine_from_config(config.get_section(config.config_ini_section), prefix="sqlalchemy.",
|
| 25 |
poolclass=pool.NullPool)
|
| 26 |
with connectable.connect() as connection:
|
| 27 |
+
# Batch mode rebuilds tables for ALTERs that SQLite cannot do in place (local dev only).
|
| 28 |
+
context.configure(connection=connection, target_metadata=target_metadata,
|
| 29 |
+
render_as_batch=connection.dialect.name == "sqlite")
|
| 30 |
with context.begin_transaction():
|
| 31 |
context.run_migrations()
|
| 32 |
|
backend/alembic/versions/0002_build_out.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""build out: social accounts, topics, launchpad fields, resurfacing, host voices
|
| 2 |
+
|
| 3 |
+
Revision ID: 0002_build_out
|
| 4 |
+
Revises: 0001_initial
|
| 5 |
+
Create Date: 2026-09-27
|
| 6 |
+
"""
|
| 7 |
+
import sqlalchemy as sa
|
| 8 |
+
from alembic import op
|
| 9 |
+
|
| 10 |
+
revision = "0002_build_out"
|
| 11 |
+
down_revision = "0001_initial"
|
| 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 already has everything below.
|
| 18 |
+
# Only apply what is missing, which makes this safe on both fresh and existing databases.
|
| 19 |
+
insp = sa.inspect(op.get_bind())
|
| 20 |
+
tables = set(insp.get_table_names())
|
| 21 |
+
if "social_accounts" in tables and "evergreen_score" in {c["name"] for c in insp.get_columns("documents")}:
|
| 22 |
+
return
|
| 23 |
+
op.create_table(
|
| 24 |
+
"social_accounts",
|
| 25 |
+
sa.Column("id", sa.Uuid(), primary_key=True),
|
| 26 |
+
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
| 27 |
+
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
| 28 |
+
sa.Column("workspace_id", sa.Uuid(), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True),
|
| 29 |
+
sa.Column("platform", sa.String(20), nullable=False),
|
| 30 |
+
sa.Column("handle", sa.String(200), nullable=False),
|
| 31 |
+
sa.Column("external_id", sa.String(200), nullable=False),
|
| 32 |
+
sa.Column("access_token", sa.Text(), nullable=False),
|
| 33 |
+
sa.Column("refresh_token", sa.Text()),
|
| 34 |
+
sa.Column("expires_at", sa.DateTime(timezone=True)),
|
| 35 |
+
sa.Column("scopes", sa.String(500)),
|
| 36 |
+
sa.Column("status", sa.String(20), nullable=False, server_default="active"),
|
| 37 |
+
sa.Column("avatar_url", sa.String(1000)),
|
| 38 |
+
sa.UniqueConstraint("workspace_id", "platform", "external_id"),
|
| 39 |
+
)
|
| 40 |
+
op.create_table(
|
| 41 |
+
"topics",
|
| 42 |
+
sa.Column("id", sa.Uuid(), primary_key=True),
|
| 43 |
+
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
| 44 |
+
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
| 45 |
+
sa.Column("workspace_id", sa.Uuid(), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True),
|
| 46 |
+
sa.Column("name", sa.String(200), nullable=False),
|
| 47 |
+
sa.Column("slug", sa.String(200), nullable=False),
|
| 48 |
+
sa.Column("summary", sa.Text()),
|
| 49 |
+
sa.Column("post_count", sa.Integer(), nullable=False, server_default="0"),
|
| 50 |
+
sa.UniqueConstraint("workspace_id", "slug"),
|
| 51 |
+
)
|
| 52 |
+
op.create_table(
|
| 53 |
+
"document_topics",
|
| 54 |
+
sa.Column("document_id", sa.Uuid(), sa.ForeignKey("documents.id", ondelete="CASCADE"), primary_key=True),
|
| 55 |
+
sa.Column("topic_id", sa.Uuid(), sa.ForeignKey("topics.id", ondelete="CASCADE"), primary_key=True),
|
| 56 |
+
sa.Column("weight", sa.Float(), nullable=False, server_default="1"),
|
| 57 |
+
)
|
| 58 |
+
with op.batch_alter_table("documents") as b:
|
| 59 |
+
b.add_column(sa.Column("evergreen_score", sa.Float()))
|
| 60 |
+
b.add_column(sa.Column("last_resurfaced_at", sa.DateTime(timezone=True)))
|
| 61 |
+
with op.batch_alter_table("voice_profiles") as b:
|
| 62 |
+
b.add_column(sa.Column("host_voices", sa.JSON(), nullable=False, server_default="{}"))
|
| 63 |
+
with op.batch_alter_table("calendar_items") as b:
|
| 64 |
+
b.alter_column("artifact_id", existing_type=sa.Uuid(), nullable=True)
|
| 65 |
+
b.add_column(sa.Column("document_id", sa.Uuid(), sa.ForeignKey(
|
| 66 |
+
"documents.id", name="fk_calendar_items_document_id", ondelete="SET NULL")))
|
| 67 |
+
b.add_column(sa.Column("content", sa.Text(), nullable=False, server_default=""))
|
| 68 |
+
b.add_column(sa.Column("thread", sa.JSON(), nullable=False, server_default="[]"))
|
| 69 |
+
b.add_column(sa.Column("social_account_id", sa.Uuid(), sa.ForeignKey(
|
| 70 |
+
"social_accounts.id", name="fk_calendar_items_social_account_id", ondelete="SET NULL")))
|
| 71 |
+
b.add_column(sa.Column("remind_by_email", sa.Boolean(), nullable=False, server_default=sa.false()))
|
| 72 |
+
b.add_column(sa.Column("external_id", sa.String(200)))
|
| 73 |
+
b.add_column(sa.Column("external_url", sa.String(1000)))
|
| 74 |
+
b.add_column(sa.Column("posted_at", sa.DateTime(timezone=True)))
|
| 75 |
+
b.add_column(sa.Column("error", sa.Text()))
|
| 76 |
+
b.add_column(sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"))
|
| 77 |
+
b.add_column(sa.Column("metrics", sa.JSON(), nullable=False, server_default="{}"))
|
| 78 |
+
b.create_index("ix_calendar_items_scheduled_at", ["scheduled_at"])
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def downgrade() -> None:
|
| 82 |
+
with op.batch_alter_table("calendar_items") as b:
|
| 83 |
+
b.drop_index("ix_calendar_items_scheduled_at")
|
| 84 |
+
for col in ("metrics", "attempts", "error", "posted_at", "external_url", "external_id", "remind_by_email",
|
| 85 |
+
"social_account_id", "thread", "content", "document_id"):
|
| 86 |
+
b.drop_column(col)
|
| 87 |
+
with op.batch_alter_table("voice_profiles") as b:
|
| 88 |
+
b.drop_column("host_voices")
|
| 89 |
+
with op.batch_alter_table("documents") as b:
|
| 90 |
+
b.drop_column("last_resurfaced_at")
|
| 91 |
+
b.drop_column("evergreen_score")
|
| 92 |
+
op.drop_table("document_topics")
|
| 93 |
+
op.drop_table("topics")
|
| 94 |
+
op.drop_table("social_accounts")
|
backend/app/config.py
CHANGED
|
@@ -17,12 +17,17 @@ class Settings(BaseSettings):
|
|
| 17 |
api_url: str = "http://localhost:8000"
|
| 18 |
cors_origins: str = "http://localhost:5173"
|
| 19 |
|
| 20 |
-
|
|
|
|
|
|
|
| 21 |
|
| 22 |
# Worker (Postgres backed queue, see app/worker.py)
|
| 23 |
worker_concurrency: int = 4
|
| 24 |
worker_poll_seconds: float = 1.0
|
| 25 |
job_stale_seconds: int = 900 # a running job with no heartbeat for this long is retried
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
# Auth
|
| 28 |
jwt_secret: str = "change-me"
|
|
@@ -43,7 +48,9 @@ class Settings(BaseSettings):
|
|
| 43 |
unsubscribe_secret: str = "change-me-too"
|
| 44 |
update_email_send_hour: int = 9
|
| 45 |
|
| 46 |
-
# Storage
|
|
|
|
|
|
|
| 47 |
r2_account_id: str = ""
|
| 48 |
r2_access_key_id: str = ""
|
| 49 |
r2_secret_access_key: str = ""
|
|
@@ -68,8 +75,18 @@ class Settings(BaseSettings):
|
|
| 68 |
corpus_cache_dir: str = ".corpus-cache"
|
| 69 |
research_max_steps: int = 10
|
| 70 |
|
| 71 |
-
# ElevenLabs
|
| 72 |
elevenlabs_api_key: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
# Renderer
|
| 75 |
renderer_url: str = "http://localhost:3100"
|
|
@@ -78,6 +95,16 @@ class Settings(BaseSettings):
|
|
| 78 |
# Billing
|
| 79 |
billing_enabled: bool = False
|
| 80 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
@property
|
| 82 |
def r2_endpoint(self) -> str:
|
| 83 |
if self.r2_endpoint_url:
|
|
|
|
| 17 |
api_url: str = "http://localhost:8000"
|
| 18 |
cors_origins: str = "http://localhost:5173"
|
| 19 |
|
| 20 |
+
# Local default is a SQLite file (relative to the working directory, normally backend/).
|
| 21 |
+
# Prod and docker-compose use Postgres: postgresql+psycopg://user:pass@host:5432/db
|
| 22 |
+
database_url: str = "sqlite:///./notestack.db"
|
| 23 |
|
| 24 |
# Worker (Postgres backed queue, see app/worker.py)
|
| 25 |
worker_concurrency: int = 4
|
| 26 |
worker_poll_seconds: float = 1.0
|
| 27 |
job_stale_seconds: int = 900 # a running job with no heartbeat for this long is retried
|
| 28 |
+
# Run the worker loop inside the API process (local dev: one command, no second terminal).
|
| 29 |
+
# None = on in development, off elsewhere.
|
| 30 |
+
run_worker_in_api: bool | None = None
|
| 31 |
|
| 32 |
# Auth
|
| 33 |
jwt_secret: str = "change-me"
|
|
|
|
| 48 |
unsubscribe_secret: str = "change-me-too"
|
| 49 |
update_email_send_hour: int = 9
|
| 50 |
|
| 51 |
+
# Storage: auto = local disk when no R2 account or endpoint is set, else R2 (S3 compatible)
|
| 52 |
+
storage_backend: str = "auto" # auto | r2 | local
|
| 53 |
+
local_storage_dir: str = ".storage"
|
| 54 |
r2_account_id: str = ""
|
| 55 |
r2_access_key_id: str = ""
|
| 56 |
r2_secret_access_key: str = ""
|
|
|
|
| 75 |
corpus_cache_dir: str = ".corpus-cache"
|
| 76 |
research_max_steps: int = 10
|
| 77 |
|
| 78 |
+
# ElevenLabs (premade voices by default; a consented clone can replace host A)
|
| 79 |
elevenlabs_api_key: str = ""
|
| 80 |
+
elevenlabs_model: str = "eleven_multilingual_v2"
|
| 81 |
+
elevenlabs_voice_a: str = "21m00Tcm4TlvDq8ikWAM" # Rachel
|
| 82 |
+
elevenlabs_voice_b: str = "pNInz6obpgDQGcFMaJgB" # Adam
|
| 83 |
+
|
| 84 |
+
# Social publishing (Launchpad). Redirect URIs: {API_URL}/api/social/{x,linkedin}/callback
|
| 85 |
+
social_token_key: str = "" # Fernet key; derived from JWT_SECRET when empty
|
| 86 |
+
x_client_id: str = ""
|
| 87 |
+
x_client_secret: str = ""
|
| 88 |
+
linkedin_client_id: str = ""
|
| 89 |
+
linkedin_client_secret: str = ""
|
| 90 |
|
| 91 |
# Renderer
|
| 92 |
renderer_url: str = "http://localhost:3100"
|
|
|
|
| 95 |
# Billing
|
| 96 |
billing_enabled: bool = False
|
| 97 |
|
| 98 |
+
@property
|
| 99 |
+
def worker_in_api(self) -> bool:
|
| 100 |
+
return self.env == "development" if self.run_worker_in_api is None else self.run_worker_in_api
|
| 101 |
+
|
| 102 |
+
@property
|
| 103 |
+
def use_local_storage(self) -> bool:
|
| 104 |
+
if self.storage_backend != "auto":
|
| 105 |
+
return self.storage_backend == "local"
|
| 106 |
+
return not (self.r2_account_id or self.r2_endpoint_url)
|
| 107 |
+
|
| 108 |
@property
|
| 109 |
def r2_endpoint(self) -> str:
|
| 110 |
if self.r2_endpoint_url:
|
backend/app/db.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
from collections.abc import Iterator
|
| 2 |
|
| 3 |
-
from sqlalchemy import create_engine
|
| 4 |
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
| 5 |
|
| 6 |
from app.config import settings
|
|
@@ -10,8 +10,20 @@ class Base(DeclarativeBase):
|
|
| 10 |
pass
|
| 11 |
|
| 12 |
|
| 13 |
-
|
|
|
|
| 14 |
engine = create_engine(settings.database_url, pool_pre_ping=True, connect_args=_connect_args)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
| 16 |
|
| 17 |
|
|
|
|
| 1 |
from collections.abc import Iterator
|
| 2 |
|
| 3 |
+
from sqlalchemy import create_engine, event
|
| 4 |
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
| 5 |
|
| 6 |
from app.config import settings
|
|
|
|
| 10 |
pass
|
| 11 |
|
| 12 |
|
| 13 |
+
_sqlite = settings.database_url.startswith("sqlite")
|
| 14 |
+
_connect_args = {"check_same_thread": False, "timeout": 30} if _sqlite else {}
|
| 15 |
engine = create_engine(settings.database_url, pool_pre_ping=True, connect_args=_connect_args)
|
| 16 |
+
|
| 17 |
+
if _sqlite:
|
| 18 |
+
# Local dev: the API and the worker share one file. WAL lets reads run during a write, the busy
|
| 19 |
+
# timeout makes a second writer wait instead of failing, and SQLite needs FKs switched on.
|
| 20 |
+
@event.listens_for(engine, "connect")
|
| 21 |
+
def _sqlite_pragmas(dbapi_conn, _record):
|
| 22 |
+
cur = dbapi_conn.cursor()
|
| 23 |
+
cur.execute("PRAGMA journal_mode=WAL")
|
| 24 |
+
cur.execute("PRAGMA busy_timeout=30000")
|
| 25 |
+
cur.execute("PRAGMA foreign_keys=ON")
|
| 26 |
+
cur.close()
|
| 27 |
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
|
| 28 |
|
| 29 |
|
backend/app/llm/run.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""One door for every generation call: runs the signature, meters tokens, writes a trace, and strips
|
| 2 |
+
em dashes from the outputs. Tests replace `predict` to return canned outputs."""
|
| 3 |
+
|
| 4 |
+
import uuid
|
| 5 |
+
|
| 6 |
+
import dspy
|
| 7 |
+
from sqlalchemy.orm import Session
|
| 8 |
+
|
| 9 |
+
from app.config import settings
|
| 10 |
+
from app.llm.postprocess import clean_output
|
| 11 |
+
from app.llm.provider import main_lm, provider_name, track_usage
|
| 12 |
+
from app.models import DspyTrace, Job, Workspace
|
| 13 |
+
from app.services.jobs import record_usage
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _dump(value):
|
| 17 |
+
if hasattr(value, "model_dump"):
|
| 18 |
+
return value.model_dump()
|
| 19 |
+
if isinstance(value, list):
|
| 20 |
+
return [_dump(v) for v in value]
|
| 21 |
+
return value
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def predict(
|
| 25 |
+
signature: type[dspy.Signature],
|
| 26 |
+
*,
|
| 27 |
+
db: Session,
|
| 28 |
+
workspace_id: uuid.UUID,
|
| 29 |
+
job: Job | None = None,
|
| 30 |
+
lm: dspy.LM | None = None,
|
| 31 |
+
**inputs,
|
| 32 |
+
) -> dict:
|
| 33 |
+
"""Run `signature` and return its outputs as plain dicts/lists (pydantic models dumped)."""
|
| 34 |
+
lm = lm or main_lm()
|
| 35 |
+
with track_usage(lm) as usage, dspy.context(lm=lm):
|
| 36 |
+
pred = dspy.Predict(signature)(**inputs)
|
| 37 |
+
outputs = {name: clean_output(_dump(pred[name])) for name in signature.output_fields}
|
| 38 |
+
if usage.total:
|
| 39 |
+
record_usage(db, workspace_id=workspace_id, kind="llm", provider=provider_name(), model=settings.llm_model,
|
| 40 |
+
quantity=usage.total, unit="tokens", cost_usd=usage.cost_usd, job=job)
|
| 41 |
+
workspace = db.get(Workspace, workspace_id)
|
| 42 |
+
db.add(DspyTrace(
|
| 43 |
+
workspace_id=workspace_id, module=signature.__name__, model=lm.model,
|
| 44 |
+
inputs={k: (v if len(str(v)) < 4000 else str(v)[:4000]) for k, v in inputs.items()},
|
| 45 |
+
outputs=outputs, training_allowed=bool(workspace and workspace.training_opt_in),
|
| 46 |
+
))
|
| 47 |
+
db.commit()
|
| 48 |
+
return outputs
|
backend/app/llm/signatures.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
-
"""Typed DSPy signatures with Pydantic outputs.
|
| 2 |
-
|
| 3 |
|
| 4 |
from typing import Literal
|
| 5 |
|
|
@@ -61,6 +61,41 @@ class Hook(BaseModel):
|
|
| 61 |
rationale: str
|
| 62 |
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
# Signatures
|
| 65 |
|
| 66 |
|
|
@@ -146,3 +181,71 @@ class PlatformAdapter(dspy.Signature):
|
|
| 146 |
platform: Literal["x_thread", "linkedin", "substack_notes", "bluesky"] = dspy.InputField()
|
| 147 |
platform_rules: str = dspy.InputField()
|
| 148 |
posts: list[str] = dspy.OutputField()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Typed DSPy signatures with Pydantic outputs. Every module that produces facts cites SourceRefs,
|
| 2 |
+
which are verified against the corpus before anything reaches the writer."""
|
| 3 |
|
| 4 |
from typing import Literal
|
| 5 |
|
|
|
|
| 61 |
rationale: str
|
| 62 |
|
| 63 |
|
| 64 |
+
class TopicTag(BaseModel):
|
| 65 |
+
name: str = Field(description="Short topic name, 1 to 4 words, Title Case")
|
| 66 |
+
weight: float = Field(ge=0, le=1, description="How central the topic is to the post")
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class TopicGroup(BaseModel):
|
| 70 |
+
canonical: str = Field(description="The best name for the merged topic")
|
| 71 |
+
members: list[str] = Field(description="Every input name that means the same topic")
|
| 72 |
+
summary: str = Field(description="One sentence on what the writer says about it")
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class InternalLink(BaseModel):
|
| 76 |
+
path: str
|
| 77 |
+
anchor_text: str
|
| 78 |
+
reason: str
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class SeoPackOut(BaseModel):
|
| 82 |
+
title_options: list[str]
|
| 83 |
+
meta_description: str = Field(description="At most 155 characters")
|
| 84 |
+
slug: str
|
| 85 |
+
keywords: list[str]
|
| 86 |
+
internal_links: list[InternalLink] = Field(default_factory=list)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class Slide(BaseModel):
|
| 90 |
+
heading: str
|
| 91 |
+
body: str
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
class QuotePick(BaseModel):
|
| 95 |
+
quote: str = Field(description="Exact words from the passage, at most 220 characters")
|
| 96 |
+
source: SourceRef
|
| 97 |
+
|
| 98 |
+
|
| 99 |
# Signatures
|
| 100 |
|
| 101 |
|
|
|
|
| 181 |
platform: Literal["x_thread", "linkedin", "substack_notes", "bluesky"] = dspy.InputField()
|
| 182 |
platform_rules: str = dspy.InputField()
|
| 183 |
posts: list[str] = dspy.OutputField()
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
class SummarizeNotebook(dspy.Signature):
|
| 187 |
+
"""Summarize what these posts say as a whole: the main arguments, how they connect and where the
|
| 188 |
+
writer changed their mind. Every factual sentence ends with a [n] marker and a citation to the
|
| 189 |
+
lines it came from. Plain, warm prose. Never use em dashes."""
|
| 190 |
+
|
| 191 |
+
title: str = dspy.InputField()
|
| 192 |
+
passages: list[str] = dspy.InputField(desc="Posts with path and numbered lines")
|
| 193 |
+
summary: str = dspy.OutputField(desc="3 to 6 paragraphs with [n] markers")
|
| 194 |
+
themes: list[str] = dspy.OutputField(desc="3 to 7 short theme names")
|
| 195 |
+
citations: list[FileCitation] = dspy.OutputField()
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
class ExtractTopics(dspy.Signature):
|
| 199 |
+
"""Name the topics this post is about, the way a reader would search for them."""
|
| 200 |
+
|
| 201 |
+
title: str = dspy.InputField()
|
| 202 |
+
text: str = dspy.InputField()
|
| 203 |
+
known_topics: list[str] = dspy.InputField(desc="Reuse one of these names when it fits")
|
| 204 |
+
topics: list[TopicTag] = dspy.OutputField(desc="3 to 8 topics")
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
class ConsolidateTopics(dspy.Signature):
|
| 208 |
+
"""Merge topic names that mean the same thing (plural/singular, synonyms, broader wording) and
|
| 209 |
+
write a one sentence summary per merged topic from the post titles given."""
|
| 210 |
+
|
| 211 |
+
topics: list[str] = dspy.InputField(desc="name (post count): sample post titles")
|
| 212 |
+
groups: list[TopicGroup] = dspy.OutputField()
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
class SeoPack(dspy.Signature):
|
| 216 |
+
"""Write search metadata for the post in the writer's voice and suggest links to their other
|
| 217 |
+
posts where a reader would want them. No em dashes."""
|
| 218 |
+
|
| 219 |
+
title: str = dspy.InputField()
|
| 220 |
+
passages: list[str] = dspy.InputField()
|
| 221 |
+
related_posts: str = dspy.InputField(desc="Other posts in the archive as date | path | title")
|
| 222 |
+
seo: SeoPackOut = dspy.OutputField()
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
class CarouselSlides(dspy.Signature):
|
| 226 |
+
"""Turn the post's key claims into a 6 to 9 slide carousel. Slide 1 is a hook, the last is a call to
|
| 227 |
+
read the post. Short, punchy, in the writer's voice. No em dashes."""
|
| 228 |
+
|
| 229 |
+
passages: list[str] = dspy.InputField()
|
| 230 |
+
voice_profile: str = dspy.InputField()
|
| 231 |
+
slides: list[Slide] = dspy.OutputField()
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
class PickQuotes(dspy.Signature):
|
| 235 |
+
"""Pick the most quotable lines, copied exactly from the passages."""
|
| 236 |
+
|
| 237 |
+
passages: list[str] = dspy.InputField()
|
| 238 |
+
n: int = dspy.InputField()
|
| 239 |
+
quotes: list[QuotePick] = dspy.OutputField()
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
class EvergreenScore(dspy.Signature):
|
| 243 |
+
"""Judge how well this post would land if reshared today. Evergreen means the ideas are not tied to
|
| 244 |
+
news, dates or a moment. Suggest a fresh angle for resharing it. No em dashes."""
|
| 245 |
+
|
| 246 |
+
title: str = dspy.InputField()
|
| 247 |
+
published: str = dspy.InputField()
|
| 248 |
+
excerpt: str = dspy.InputField()
|
| 249 |
+
score: float = dspy.OutputField(desc="0 (dated) to 1 (timeless)")
|
| 250 |
+
reason: str = dspy.OutputField()
|
| 251 |
+
angle: str = dspy.OutputField(desc="One line hook for resharing now")
|
backend/app/main.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import logging
|
|
|
|
| 2 |
import time
|
| 3 |
import uuid
|
| 4 |
from contextlib import asynccontextmanager
|
|
@@ -8,7 +9,22 @@ from fastapi.middleware.cors import CORSMiddleware
|
|
| 8 |
|
| 9 |
from app.config import settings
|
| 10 |
from app.llm.provider import configure_default
|
| 11 |
-
from app.routers import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
log = logging.getLogger("notestack")
|
| 14 |
|
|
@@ -17,14 +33,19 @@ log = logging.getLogger("notestack")
|
|
| 17 |
async def lifespan(app: FastAPI):
|
| 18 |
logging.basicConfig(level=logging.INFO)
|
| 19 |
configure_default()
|
| 20 |
-
if settings.env == "development" and settings.r2_endpoint_url:
|
| 21 |
try:
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
store.ensure_bucket() # MinIO locally
|
| 25 |
except Exception:
|
| 26 |
log.warning("could not ensure local bucket", exc_info=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
yield
|
|
|
|
| 28 |
|
| 29 |
|
| 30 |
app = FastAPI(title="Notestack API", version="0.1.0", lifespan=lifespan)
|
|
@@ -53,6 +74,10 @@ def health():
|
|
| 53 |
return {"ok": True, "llm": settings.llm_model, "billing_enabled": settings.billing_enabled}
|
| 54 |
|
| 55 |
|
| 56 |
-
for r in (auth, billing, storage, sources, notebooks, artifacts, jobs, unsubscribe
|
|
|
|
| 57 |
app.include_router(r.router)
|
| 58 |
app.include_router(sources.documents_router)
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import logging
|
| 2 |
+
import threading
|
| 3 |
import time
|
| 4 |
import uuid
|
| 5 |
from contextlib import asynccontextmanager
|
|
|
|
| 9 |
|
| 10 |
from app.config import settings
|
| 11 |
from app.llm.provider import configure_default
|
| 12 |
+
from app.routers import (
|
| 13 |
+
artifacts,
|
| 14 |
+
auth,
|
| 15 |
+
billing,
|
| 16 |
+
jobs,
|
| 17 |
+
launchpad,
|
| 18 |
+
notebooks,
|
| 19 |
+
resurface,
|
| 20 |
+
sources,
|
| 21 |
+
storage,
|
| 22 |
+
topics,
|
| 23 |
+
unsubscribe,
|
| 24 |
+
voice,
|
| 25 |
+
)
|
| 26 |
+
from app.routers import settings as settings_router
|
| 27 |
+
from app.services.storage import storage as store
|
| 28 |
|
| 29 |
log = logging.getLogger("notestack")
|
| 30 |
|
|
|
|
| 33 |
async def lifespan(app: FastAPI):
|
| 34 |
logging.basicConfig(level=logging.INFO)
|
| 35 |
configure_default()
|
| 36 |
+
if settings.env == "development" and (settings.r2_endpoint_url or settings.use_local_storage):
|
| 37 |
try:
|
| 38 |
+
store.ensure_bucket() # MinIO or the local storage folder
|
|
|
|
|
|
|
| 39 |
except Exception:
|
| 40 |
log.warning("could not ensure local bucket", exc_info=True)
|
| 41 |
+
stop = threading.Event()
|
| 42 |
+
if settings.worker_in_api:
|
| 43 |
+
from app.worker import start_background
|
| 44 |
+
|
| 45 |
+
start_background(stop)
|
| 46 |
+
log.info("worker running inside the API (RUN_WORKER_IN_API)")
|
| 47 |
yield
|
| 48 |
+
stop.set()
|
| 49 |
|
| 50 |
|
| 51 |
app = FastAPI(title="Notestack API", version="0.1.0", lifespan=lifespan)
|
|
|
|
| 74 |
return {"ok": True, "llm": settings.llm_model, "billing_enabled": settings.billing_enabled}
|
| 75 |
|
| 76 |
|
| 77 |
+
for r in (auth, billing, storage, sources, notebooks, artifacts, jobs, unsubscribe, topics, voice, resurface,
|
| 78 |
+
launchpad, settings_router):
|
| 79 |
app.include_router(r.router)
|
| 80 |
app.include_router(sources.documents_router)
|
| 81 |
+
app.include_router(launchpad.links_router)
|
| 82 |
+
if settings.use_local_storage:
|
| 83 |
+
app.include_router(storage.local_router)
|
backend/app/models/__init__.py
CHANGED
|
@@ -4,13 +4,16 @@ from app.models.content import (
|
|
| 4 |
Chat,
|
| 5 |
Citation,
|
| 6 |
Document,
|
|
|
|
| 7 |
DspyTrace,
|
| 8 |
EngagementEvent,
|
| 9 |
Job,
|
| 10 |
Message,
|
| 11 |
Notebook,
|
| 12 |
NotebookDocument,
|
|
|
|
| 13 |
Source,
|
|
|
|
| 14 |
TrackedLink,
|
| 15 |
Upload,
|
| 16 |
UsageEvent,
|
|
@@ -27,6 +30,7 @@ __all__ = [
|
|
| 27 |
"Chat",
|
| 28 |
"Citation",
|
| 29 |
"Document",
|
|
|
|
| 30 |
"DspyTrace",
|
| 31 |
"EmailVerificationCode",
|
| 32 |
"EngagementEvent",
|
|
@@ -34,8 +38,10 @@ __all__ = [
|
|
| 34 |
"Message",
|
| 35 |
"Notebook",
|
| 36 |
"NotebookDocument",
|
|
|
|
| 37 |
"Source",
|
| 38 |
"Subscription",
|
|
|
|
| 39 |
"TrackedLink",
|
| 40 |
"UpdateEmail",
|
| 41 |
"UpdateEmailSend",
|
|
|
|
| 4 |
Chat,
|
| 5 |
Citation,
|
| 6 |
Document,
|
| 7 |
+
DocumentTopic,
|
| 8 |
DspyTrace,
|
| 9 |
EngagementEvent,
|
| 10 |
Job,
|
| 11 |
Message,
|
| 12 |
Notebook,
|
| 13 |
NotebookDocument,
|
| 14 |
+
SocialAccount,
|
| 15 |
Source,
|
| 16 |
+
Topic,
|
| 17 |
TrackedLink,
|
| 18 |
Upload,
|
| 19 |
UsageEvent,
|
|
|
|
| 30 |
"Chat",
|
| 31 |
"Citation",
|
| 32 |
"Document",
|
| 33 |
+
"DocumentTopic",
|
| 34 |
"DspyTrace",
|
| 35 |
"EmailVerificationCode",
|
| 36 |
"EngagementEvent",
|
|
|
|
| 38 |
"Message",
|
| 39 |
"Notebook",
|
| 40 |
"NotebookDocument",
|
| 41 |
+
"SocialAccount",
|
| 42 |
"Source",
|
| 43 |
"Subscription",
|
| 44 |
+
"Topic",
|
| 45 |
"TrackedLink",
|
| 46 |
"UpdateEmail",
|
| 47 |
"UpdateEmailSend",
|
backend/app/models/content.py
CHANGED
|
@@ -53,6 +53,9 @@ class Document(IdMixin, TimestampMixin, Base):
|
|
| 53 |
clean_text: Mapped[str] = mapped_column(Text, default="")
|
| 54 |
content_hash: Mapped[str | None] = mapped_column(String(64))
|
| 55 |
metadata_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
|
| 58 |
class Notebook(IdMixin, TimestampMixin, Base):
|
|
@@ -83,6 +86,7 @@ class VoiceProfile(IdMixin, TimestampMixin, Base):
|
|
| 83 |
)
|
| 84 |
profile_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
| 85 |
sample_doc_ids: Mapped[list] = mapped_column(JSON, default=list)
|
|
|
|
| 86 |
|
| 87 |
|
| 88 |
class VoiceConsent(IdMixin, TimestampMixin, Base):
|
|
@@ -188,10 +192,63 @@ class CalendarItem(IdMixin, TimestampMixin, Base):
|
|
| 188 |
__tablename__ = "calendar_items"
|
| 189 |
|
| 190 |
workspace_id: Mapped[uuid.UUID] = _ws_fk()
|
| 191 |
-
artifact_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("artifacts.id", ondelete="CASCADE"))
|
| 192 |
-
|
| 193 |
-
|
|
|
|
|
|
|
| 194 |
status: Mapped[str] = mapped_column(String(20), default="scheduled")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
|
| 196 |
|
| 197 |
class TrackedLink(IdMixin, TimestampMixin, Base):
|
|
|
|
| 53 |
clean_text: Mapped[str] = mapped_column(Text, default="")
|
| 54 |
content_hash: Mapped[str | None] = mapped_column(String(64))
|
| 55 |
metadata_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
| 56 |
+
# Resurfacing: 0..1 from the EvergreenScore module (reason and angle live in metadata_json)
|
| 57 |
+
evergreen_score: Mapped[float | None] = mapped_column(Float)
|
| 58 |
+
last_resurfaced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
| 59 |
|
| 60 |
|
| 61 |
class Notebook(IdMixin, TimestampMixin, Base):
|
|
|
|
| 86 |
)
|
| 87 |
profile_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
| 88 |
sample_doc_ids: Mapped[list] = mapped_column(JSON, default=list)
|
| 89 |
+
host_voices: Mapped[dict] = mapped_column(JSON, default=dict) # {"host_a": voice_id, "host_b": voice_id}
|
| 90 |
|
| 91 |
|
| 92 |
class VoiceConsent(IdMixin, TimestampMixin, Base):
|
|
|
|
| 192 |
__tablename__ = "calendar_items"
|
| 193 |
|
| 194 |
workspace_id: Mapped[uuid.UUID] = _ws_fk()
|
| 195 |
+
artifact_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("artifacts.id", ondelete="CASCADE"))
|
| 196 |
+
document_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("documents.id", ondelete="SET NULL"))
|
| 197 |
+
platform: Mapped[str] = mapped_column(String(30)) # x | linkedin | bluesky | substack_notes
|
| 198 |
+
scheduled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
| 199 |
+
# scheduled | publishing | posted | reminded | failed | draft
|
| 200 |
status: Mapped[str] = mapped_column(String(20), default="scheduled")
|
| 201 |
+
content: Mapped[str] = mapped_column(Text, default="")
|
| 202 |
+
thread: Mapped[list] = mapped_column(JSON, default=list) # extra posts after the first, for threads
|
| 203 |
+
social_account_id: Mapped[uuid.UUID | None] = mapped_column(
|
| 204 |
+
Uuid, ForeignKey("social_accounts.id", ondelete="SET NULL")
|
| 205 |
+
)
|
| 206 |
+
remind_by_email: Mapped[bool] = mapped_column(Boolean, default=False)
|
| 207 |
+
external_id: Mapped[str | None] = mapped_column(String(200))
|
| 208 |
+
external_url: Mapped[str | None] = mapped_column(String(1000))
|
| 209 |
+
posted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
| 210 |
+
error: Mapped[str | None] = mapped_column(Text)
|
| 211 |
+
attempts: Mapped[int] = mapped_column(Integer, default=0)
|
| 212 |
+
metrics: Mapped[dict] = mapped_column(JSON, default=dict) # likes, reposts, replies, impressions
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
class SocialAccount(IdMixin, TimestampMixin, Base):
|
| 216 |
+
"""A connected X, LinkedIn or Bluesky account. Tokens are Fernet encrypted (app/services/crypto.py)."""
|
| 217 |
+
|
| 218 |
+
__tablename__ = "social_accounts"
|
| 219 |
+
__table_args__ = (UniqueConstraint("workspace_id", "platform", "external_id"),)
|
| 220 |
+
|
| 221 |
+
workspace_id: Mapped[uuid.UUID] = _ws_fk()
|
| 222 |
+
platform: Mapped[str] = mapped_column(String(20))
|
| 223 |
+
handle: Mapped[str] = mapped_column(String(200))
|
| 224 |
+
external_id: Mapped[str] = mapped_column(String(200))
|
| 225 |
+
access_token: Mapped[str] = mapped_column(Text)
|
| 226 |
+
refresh_token: Mapped[str | None] = mapped_column(Text)
|
| 227 |
+
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
| 228 |
+
scopes: Mapped[str | None] = mapped_column(String(500))
|
| 229 |
+
status: Mapped[str] = mapped_column(String(20), default="active") # active | expired | revoked
|
| 230 |
+
avatar_url: Mapped[str | None] = mapped_column(String(1000))
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
class Topic(IdMixin, TimestampMixin, Base):
|
| 234 |
+
__tablename__ = "topics"
|
| 235 |
+
__table_args__ = (UniqueConstraint("workspace_id", "slug"),)
|
| 236 |
+
|
| 237 |
+
workspace_id: Mapped[uuid.UUID] = _ws_fk()
|
| 238 |
+
name: Mapped[str] = mapped_column(String(200))
|
| 239 |
+
slug: Mapped[str] = mapped_column(String(200))
|
| 240 |
+
summary: Mapped[str | None] = mapped_column(Text)
|
| 241 |
+
post_count: Mapped[int] = mapped_column(Integer, default=0)
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
class DocumentTopic(Base):
|
| 245 |
+
__tablename__ = "document_topics"
|
| 246 |
+
|
| 247 |
+
document_id: Mapped[uuid.UUID] = mapped_column(
|
| 248 |
+
Uuid, ForeignKey("documents.id", ondelete="CASCADE"), primary_key=True
|
| 249 |
+
)
|
| 250 |
+
topic_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("topics.id", ondelete="CASCADE"), primary_key=True)
|
| 251 |
+
weight: Mapped[float] = mapped_column(Float, default=1.0)
|
| 252 |
|
| 253 |
|
| 254 |
class TrackedLink(IdMixin, TimestampMixin, Base):
|
backend/app/pipeline/generate.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Text generation jobs over the corpus: notebook summaries, topic map, voice profile, evergreen scores."""
|
| 2 |
+
|
| 3 |
+
import uuid
|
| 4 |
+
from collections import Counter, defaultdict
|
| 5 |
+
from datetime import UTC, datetime
|
| 6 |
+
|
| 7 |
+
from sqlalchemy import delete, select
|
| 8 |
+
from sqlalchemy.orm import Session
|
| 9 |
+
|
| 10 |
+
from app.corpus import Corpus, slugify
|
| 11 |
+
from app.llm import run
|
| 12 |
+
from app.llm.provider import fast_lm
|
| 13 |
+
from app.llm.signatures import BuildVoiceProfile, ConsolidateTopics, EvergreenScore, ExtractTopics, SummarizeNotebook
|
| 14 |
+
from app.models import Artifact, DocumentTopic, Job, Notebook, Topic, VoiceProfile
|
| 15 |
+
from app.pipeline.passages import as_citations, notebook_docs, passages_for, tools_for, workspace_docs
|
| 16 |
+
from app.pipeline.research import verify_citations
|
| 17 |
+
from app.services.jobs import update_job
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class NothingToDo(ValueError):
|
| 21 |
+
pass
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# Notebook summary
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def summarize_notebook(db: Session, job: Job, artifact: Artifact) -> dict:
|
| 28 |
+
nb = db.get(Notebook, artifact.notebook_id)
|
| 29 |
+
docs = notebook_docs(db, nb.id)
|
| 30 |
+
if not docs:
|
| 31 |
+
raise NothingToDo("This notebook has no posts yet.")
|
| 32 |
+
corpus = Corpus(nb.workspace_id)
|
| 33 |
+
update_job(db, job, progress=0.1, message=f"Reading {len(docs)} posts")
|
| 34 |
+
passages = passages_for(corpus, docs)
|
| 35 |
+
update_job(db, job, progress=0.3, message="Writing the summary")
|
| 36 |
+
out = run.predict(SummarizeNotebook, db=db, workspace_id=nb.workspace_id, job=job,
|
| 37 |
+
title=nb.title, passages=passages)
|
| 38 |
+
tools = tools_for(corpus, docs)
|
| 39 |
+
text, cites = verify_citations(tools, out.get("summary") or "", as_citations(out.get("citations")))
|
| 40 |
+
by_path = {d.path: d for d in docs}
|
| 41 |
+
content = {
|
| 42 |
+
"title": f"Summary: {nb.title}",
|
| 43 |
+
"summary": text,
|
| 44 |
+
"themes": out.get("themes") or [],
|
| 45 |
+
"citations": [
|
| 46 |
+
{"marker": c.marker, "path": c.path, "line_start": c.line_start, "line_end": c.line_end,
|
| 47 |
+
"span": c.quote, "title": by_path[c.path].title, "url": by_path[c.path].url,
|
| 48 |
+
"document_id": str(by_path[c.path].id)}
|
| 49 |
+
for c in cites if c.path in by_path
|
| 50 |
+
],
|
| 51 |
+
}
|
| 52 |
+
artifact.content_json = content
|
| 53 |
+
artifact.status = "ready"
|
| 54 |
+
nb.summary = text
|
| 55 |
+
db.commit()
|
| 56 |
+
return {"artifact_id": str(artifact.id)}
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# Topic map
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def extract_topics(db: Session, job: Job, workspace_id: uuid.UUID, doc_ids: list[str] | None = None) -> dict:
|
| 63 |
+
"""Tag posts that have no topics yet (or the given ones), then rebuild the workspace topic table."""
|
| 64 |
+
docs = workspace_docs(db, workspace_id, [uuid.UUID(i) for i in doc_ids] if doc_ids else None)
|
| 65 |
+
todo = [d for d in docs if doc_ids or "topics" not in (d.metadata_json or {})]
|
| 66 |
+
known = sorted({t["name"] for d in docs for t in (d.metadata_json or {}).get("topics", [])})[:200]
|
| 67 |
+
lm = fast_lm()
|
| 68 |
+
for i, d in enumerate(todo):
|
| 69 |
+
update_job(db, job, progress=0.05 + 0.75 * i / max(len(todo), 1), message=f"Mapping {d.title[:70]}")
|
| 70 |
+
try:
|
| 71 |
+
out = run.predict(ExtractTopics, db=db, workspace_id=workspace_id, job=job, lm=lm,
|
| 72 |
+
title=d.title, text=(d.clean_text or "")[:12000], known_topics=known)
|
| 73 |
+
except Exception:
|
| 74 |
+
continue
|
| 75 |
+
tags = [t for t in (out.get("topics") or []) if t.get("name")][:8]
|
| 76 |
+
d.metadata_json = {**(d.metadata_json or {}), "topics": tags}
|
| 77 |
+
known = sorted(set(known) | {t["name"] for t in tags})[:200]
|
| 78 |
+
db.commit()
|
| 79 |
+
update_job(db, job, progress=0.85, message="Drawing constellations")
|
| 80 |
+
count = rebuild_topics(db, job, workspace_id)
|
| 81 |
+
return {"tagged": len(todo), "topics": count}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def rebuild_topics(db: Session, job: Job | None, workspace_id: uuid.UUID) -> int:
|
| 85 |
+
docs = workspace_docs(db, workspace_id)
|
| 86 |
+
counts: Counter[str] = Counter()
|
| 87 |
+
titles: dict[str, list[str]] = defaultdict(list)
|
| 88 |
+
for d in docs:
|
| 89 |
+
for t in (d.metadata_json or {}).get("topics", []):
|
| 90 |
+
counts[t["name"]] += 1
|
| 91 |
+
if len(titles[t["name"]]) < 3:
|
| 92 |
+
titles[t["name"]].append(d.title)
|
| 93 |
+
if not counts:
|
| 94 |
+
return 0
|
| 95 |
+
canonical = {name: name for name in counts}
|
| 96 |
+
summaries: dict[str, str] = {}
|
| 97 |
+
listing = [f"{n} ({c}): {'; '.join(titles[n])}" for n, c in counts.most_common(150)]
|
| 98 |
+
try:
|
| 99 |
+
out = run.predict(ConsolidateTopics, db=db, workspace_id=workspace_id, job=job, lm=fast_lm(), topics=listing)
|
| 100 |
+
for g in out.get("groups") or []:
|
| 101 |
+
name = (g.get("canonical") or "").strip()
|
| 102 |
+
if not name:
|
| 103 |
+
continue
|
| 104 |
+
summaries[name] = g.get("summary") or ""
|
| 105 |
+
for m in g.get("members") or []:
|
| 106 |
+
if m in canonical:
|
| 107 |
+
canonical[m] = name
|
| 108 |
+
except Exception:
|
| 109 |
+
pass # keep raw names; the map still works
|
| 110 |
+
|
| 111 |
+
db.execute(delete(DocumentTopic).where(DocumentTopic.document_id.in_([d.id for d in docs])))
|
| 112 |
+
db.execute(delete(Topic).where(Topic.workspace_id == workspace_id))
|
| 113 |
+
db.flush()
|
| 114 |
+
topics: dict[str, Topic] = {}
|
| 115 |
+
for d in docs:
|
| 116 |
+
weights: dict[str, float] = {}
|
| 117 |
+
for t in (d.metadata_json or {}).get("topics", []):
|
| 118 |
+
name = canonical.get(t["name"], t["name"])
|
| 119 |
+
weights[name] = max(weights.get(name, 0), float(t.get("weight") or 0.5))
|
| 120 |
+
for name, weight in weights.items():
|
| 121 |
+
slug = slugify(name, 120)
|
| 122 |
+
topic = topics.get(slug)
|
| 123 |
+
if not topic:
|
| 124 |
+
topic = Topic(workspace_id=workspace_id, name=name, slug=slug, summary=summaries.get(name))
|
| 125 |
+
db.add(topic)
|
| 126 |
+
db.flush()
|
| 127 |
+
topics[slug] = topic
|
| 128 |
+
topic.post_count += 1
|
| 129 |
+
db.add(DocumentTopic(document_id=d.id, topic_id=topic.id, weight=weight))
|
| 130 |
+
db.commit()
|
| 131 |
+
return len(topics)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
# Voice profile
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def build_voice_profile(db: Session, job: Job, workspace_id: uuid.UUID, doc_ids: list[str]) -> dict:
|
| 138 |
+
docs = workspace_docs(db, workspace_id, [uuid.UUID(i) for i in doc_ids] if doc_ids else None)
|
| 139 |
+
if not doc_ids:
|
| 140 |
+
docs = sorted(docs, key=lambda d: len(d.clean_text or ""), reverse=True)[:8]
|
| 141 |
+
if not docs:
|
| 142 |
+
raise NothingToDo("Add some posts before building a voice profile.")
|
| 143 |
+
update_job(db, job, progress=0.2, message=f"Listening to {len(docs)} posts")
|
| 144 |
+
samples = [f"# {d.title}\n\n{(d.clean_text or '')[:6000]}" for d in docs[:10]]
|
| 145 |
+
out = run.predict(BuildVoiceProfile, db=db, workspace_id=workspace_id, job=job, samples=samples)
|
| 146 |
+
vp = db.scalar(select(VoiceProfile).where(VoiceProfile.workspace_id == workspace_id))
|
| 147 |
+
if not vp:
|
| 148 |
+
vp = VoiceProfile(workspace_id=workspace_id)
|
| 149 |
+
db.add(vp)
|
| 150 |
+
vp.profile_json = out.get("profile") or {}
|
| 151 |
+
vp.sample_doc_ids = [str(d.id) for d in docs[:10]]
|
| 152 |
+
db.commit()
|
| 153 |
+
return {"profile": vp.profile_json}
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
# Resurfacing
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def score_evergreen(db: Session, job: Job, workspace_id: uuid.UUID, limit: int = 60) -> dict:
|
| 160 |
+
docs = [d for d in workspace_docs(db, workspace_id) if d.evergreen_score is None][:limit]
|
| 161 |
+
lm = fast_lm()
|
| 162 |
+
scored = 0
|
| 163 |
+
for i, d in enumerate(docs):
|
| 164 |
+
update_job(db, job, progress=0.05 + 0.9 * i / max(len(docs), 1), message=f"Weighing {d.title[:70]}")
|
| 165 |
+
try:
|
| 166 |
+
out = run.predict(
|
| 167 |
+
EvergreenScore, db=db, workspace_id=workspace_id, job=job, lm=lm, title=d.title,
|
| 168 |
+
published=d.published_at.date().isoformat() if d.published_at else "unknown",
|
| 169 |
+
excerpt=(d.clean_text or "")[:2500],
|
| 170 |
+
)
|
| 171 |
+
except Exception:
|
| 172 |
+
continue
|
| 173 |
+
try:
|
| 174 |
+
d.evergreen_score = max(0.0, min(1.0, float(out.get("score") or 0)))
|
| 175 |
+
except (TypeError, ValueError):
|
| 176 |
+
continue
|
| 177 |
+
d.metadata_json = {**(d.metadata_json or {}), "evergreen_reason": out.get("reason") or "",
|
| 178 |
+
"reshare_angle": out.get("angle") or "",
|
| 179 |
+
"scored_at": datetime.now(UTC).isoformat()}
|
| 180 |
+
scored += 1
|
| 181 |
+
db.commit()
|
| 182 |
+
return {"scored": scored}
|
backend/app/pipeline/ingest.py
CHANGED
|
@@ -1,13 +1,22 @@
|
|
| 1 |
-
"""Ingestion: feed -> raw HTML to
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import hashlib
|
|
|
|
| 4 |
import logging
|
| 5 |
import re
|
|
|
|
| 6 |
import uuid
|
| 7 |
from dataclasses import dataclass
|
| 8 |
from datetime import UTC, datetime
|
| 9 |
from time import mktime
|
| 10 |
-
from urllib.parse import urlparse
|
| 11 |
|
| 12 |
import feedparser
|
| 13 |
import httpx
|
|
@@ -23,13 +32,25 @@ from app.services.storage import keys, storage
|
|
| 23 |
log = logging.getLogger(__name__)
|
| 24 |
|
| 25 |
_DROP = re.compile(r"(subscribe|share this post|leave a comment|thanks for reading)", re.I)
|
|
|
|
|
|
|
| 26 |
|
| 27 |
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
url = url.strip()
|
| 31 |
-
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
parsed = urlparse(url)
|
| 34 |
host = parsed.netloc.lower()
|
| 35 |
if host.endswith("substack.com") or "/p/" in parsed.path:
|
|
@@ -41,12 +62,82 @@ def normalize_feed_url(url: str) -> tuple[str, str]:
|
|
| 41 |
return f"{parsed.scheme}://{host}/feed", "rss"
|
| 42 |
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
def html_to_sections(html: str) -> list[tuple[str | None, str]]:
|
| 45 |
soup = BeautifulSoup(html, "html.parser")
|
| 46 |
-
for tag in soup(["script", "style", "nav", "footer", "form", "button", "iframe"]):
|
| 47 |
tag.decompose()
|
| 48 |
sections: list[tuple[str | None, list[str]]] = [(None, [])]
|
| 49 |
-
for el in soup.find_all(["h1", "h2", "h3", "p", "li", "blockquote"]):
|
| 50 |
text = el.get_text(" ", strip=True)
|
| 51 |
if not text or (len(text) < 120 and _DROP.search(text)):
|
| 52 |
continue
|
|
@@ -57,16 +148,94 @@ def html_to_sections(html: str) -> list[tuple[str | None, str]]:
|
|
| 57 |
return [(h, "\n\n".join(p)) for h, p in sections if p]
|
| 58 |
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
@dataclass
|
| 61 |
class FeedEntry:
|
| 62 |
title: str
|
| 63 |
url: str
|
| 64 |
published_at: datetime | None
|
| 65 |
html: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
|
| 68 |
def fetch_feed(feed_url: str, limit: int) -> tuple[str | None, list[FeedEntry]]:
|
| 69 |
-
resp = httpx.get(feed_url, follow_redirects=True, timeout=30, headers=
|
| 70 |
resp.raise_for_status()
|
| 71 |
parsed = feedparser.parse(resp.content)
|
| 72 |
entries = []
|
|
@@ -84,7 +253,45 @@ def fetch_feed(feed_url: str, limit: int) -> tuple[str | None, list[FeedEntry]]:
|
|
| 84 |
return parsed.feed.get("title"), entries
|
| 85 |
|
| 86 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
def source_slug(source: Source) -> str:
|
|
|
|
|
|
|
| 88 |
return slugify(urlparse(source.site_url or source.feed_url).netloc or str(source.id), 50)
|
| 89 |
|
| 90 |
|
|
@@ -93,34 +300,46 @@ def rebuild_index(db: Session, workspace_id: uuid.UUID) -> str:
|
|
| 93 |
select(Document).where(Document.workspace_id == workspace_id, Document.path.is_not(None))
|
| 94 |
).all()
|
| 95 |
rows = sorted(
|
| 96 |
-
(index_line(d.path, d.title, d.published_at, d.metadata_json.get("headings", [])) for d in docs),
|
| 97 |
reverse=True,
|
| 98 |
)
|
| 99 |
header = "# Archive index\n\ndate | path | title | section headings\n\n"
|
| 100 |
return header + "\n".join(rows) + "\n"
|
| 101 |
|
| 102 |
|
| 103 |
-
def
|
| 104 |
-
source
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
-
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
| 109 |
corpus = Corpus(source.workspace_id)
|
| 110 |
folder = source_slug(source)
|
| 111 |
-
total = max(len(entries), 1)
|
| 112 |
indexed = skipped = 0
|
|
|
|
| 113 |
files: dict[str, str] = {}
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
db.scalars(select(Document.path).where(Document.workspace_id == source.workspace_id)).all()
|
| 117 |
-
)
|
| 118 |
for i, entry in enumerate(entries):
|
| 119 |
-
|
|
|
|
| 120 |
doc = db.scalar(select(Document).where(Document.source_id == source.id, Document.url == entry.url))
|
| 121 |
if doc and doc.content_hash == content_hash and doc.path:
|
| 122 |
skipped += 1
|
| 123 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
if not doc:
|
| 125 |
doc = Document(id=uuid.uuid4(), workspace_id=source.workspace_id, source_id=source.id, url=entry.url)
|
| 126 |
db.add(doc)
|
|
@@ -135,25 +354,76 @@ def ingest_source(db: Session, source: Source, job: Job, max_posts: int) -> dict
|
|
| 135 |
doc.title = entry.title
|
| 136 |
doc.published_at = entry.published_at
|
| 137 |
doc.content_hash = content_hash
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
doc.clean_text = "\n\n".join(f"## {h}\n{t}" if h else t for h, t in sections)
|
| 143 |
-
doc.metadata_json = {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
files[doc.path] = render_post(
|
| 145 |
title=entry.title, url=entry.url, published_at=entry.published_at, source=folder, sections=sections
|
| 146 |
)
|
| 147 |
indexed += 1
|
|
|
|
| 148 |
db.commit()
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
files[INDEX] = rebuild_index(db, source.workspace_id)
|
| 153 |
corpus.write_files(files)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
|
| 155 |
source.sync_status = "ok"
|
| 156 |
source.sync_error = None
|
| 157 |
source.last_synced_at = datetime.now(UTC)
|
| 158 |
db.commit()
|
| 159 |
-
return {"indexed": indexed, "skipped": skipped, "found": len(entries)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ingestion: feed -> raw HTML to storage -> clean markdown files in the workspace corpus -> INDEX.md.
|
| 2 |
+
|
| 3 |
+
Three ways in, one pipeline out:
|
| 4 |
+
- feeds (Substack, Ghost, Medium, any RSS/Atom), with Substack's archive API for posts past the feed
|
| 5 |
+
- one article by URL
|
| 6 |
+
- an uploaded file (markdown, text, HTML or PDF)
|
| 7 |
+
All of them end in `store_entries`, which writes documents, raw HTML and corpus files.
|
| 8 |
+
"""
|
| 9 |
|
| 10 |
import hashlib
|
| 11 |
+
import io
|
| 12 |
import logging
|
| 13 |
import re
|
| 14 |
+
import time
|
| 15 |
import uuid
|
| 16 |
from dataclasses import dataclass
|
| 17 |
from datetime import UTC, datetime
|
| 18 |
from time import mktime
|
| 19 |
+
from urllib.parse import urljoin, urlparse
|
| 20 |
|
| 21 |
import feedparser
|
| 22 |
import httpx
|
|
|
|
| 32 |
log = logging.getLogger(__name__)
|
| 33 |
|
| 34 |
_DROP = re.compile(r"(subscribe|share this post|leave a comment|thanks for reading)", re.I)
|
| 35 |
+
UA = {"User-Agent": "Mozilla/5.0 (compatible; NotestackBot/0.2; +https://notestack.ai)"}
|
| 36 |
+
IMPORTS_FEED = "notestack:imports"
|
| 37 |
|
| 38 |
|
| 39 |
+
class FeedNotFound(ValueError):
|
| 40 |
+
pass
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# Feed discovery
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _with_scheme(url: str) -> str:
|
| 47 |
url = url.strip()
|
| 48 |
+
return url if url.startswith(("http://", "https://")) else f"https://{url}"
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def normalize_feed_url(url: str) -> tuple[str, str]:
|
| 52 |
+
"""Best guess without network: accept 'name.substack.com', a post URL, or any feed URL."""
|
| 53 |
+
url = _with_scheme(url)
|
| 54 |
parsed = urlparse(url)
|
| 55 |
host = parsed.netloc.lower()
|
| 56 |
if host.endswith("substack.com") or "/p/" in parsed.path:
|
|
|
|
| 62 |
return f"{parsed.scheme}://{host}/feed", "rss"
|
| 63 |
|
| 64 |
|
| 65 |
+
def _parse_feed(url: str, timeout: float = 15) -> tuple[feedparser.FeedParserDict | None, httpx.Response | None]:
|
| 66 |
+
try:
|
| 67 |
+
resp = httpx.get(url, follow_redirects=True, timeout=timeout, headers=UA)
|
| 68 |
+
except httpx.HTTPError:
|
| 69 |
+
return None, None
|
| 70 |
+
if resp.status_code >= 400:
|
| 71 |
+
return None, resp
|
| 72 |
+
parsed = feedparser.parse(resp.content)
|
| 73 |
+
if parsed.get("version") and (parsed.entries or parsed.feed.get("title")):
|
| 74 |
+
return parsed, resp
|
| 75 |
+
return None, resp
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@dataclass
|
| 79 |
+
class DiscoveredFeed:
|
| 80 |
+
feed_url: str
|
| 81 |
+
platform: str
|
| 82 |
+
title: str | None
|
| 83 |
+
site_url: str
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def discover_feed(url: str) -> DiscoveredFeed:
|
| 87 |
+
"""Find a working feed for whatever the writer pasted, or raise FeedNotFound with a clear reason."""
|
| 88 |
+
url = _with_scheme(url)
|
| 89 |
+
guess, platform = normalize_feed_url(url)
|
| 90 |
+
parsed_url = urlparse(url)
|
| 91 |
+
site = f"{parsed_url.scheme}://{parsed_url.netloc}"
|
| 92 |
+
candidates = [guess, url, f"{site}/feed", f"{site}/rss", f"{site}/rss.xml", f"{site}/feed.xml", f"{site}/atom.xml"]
|
| 93 |
+
seen: set[str] = set()
|
| 94 |
+
last: httpx.Response | None = None
|
| 95 |
+
for candidate in candidates:
|
| 96 |
+
if candidate in seen:
|
| 97 |
+
continue
|
| 98 |
+
seen.add(candidate)
|
| 99 |
+
parsed, resp = _parse_feed(candidate)
|
| 100 |
+
last = resp or last
|
| 101 |
+
if parsed:
|
| 102 |
+
return DiscoveredFeed(candidate, _platform(candidate, parsed, platform), parsed.feed.get("title"), site)
|
| 103 |
+
if resp is not None and resp.status_code < 400 and "html" in resp.headers.get("content-type", ""):
|
| 104 |
+
# A web page: look for <link rel="alternate" type="application/rss+xml">.
|
| 105 |
+
soup = BeautifulSoup(resp.text, "html.parser")
|
| 106 |
+
for link in soup.find_all("link", rel="alternate"):
|
| 107 |
+
if "rss" in (link.get("type") or "") or "atom" in (link.get("type") or ""):
|
| 108 |
+
href = urljoin(str(resp.url), link.get("href", ""))
|
| 109 |
+
if href and href not in seen:
|
| 110 |
+
seen.add(href)
|
| 111 |
+
found, _ = _parse_feed(href)
|
| 112 |
+
if found:
|
| 113 |
+
return DiscoveredFeed(href, _platform(href, found, platform), found.feed.get("title"), site)
|
| 114 |
+
if last is not None and last.status_code == 404:
|
| 115 |
+
raise FeedNotFound(f"{parsed_url.netloc} returned 404. Check the address in your browser.")
|
| 116 |
+
raise FeedNotFound("We couldn't find a feed at that address.")
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _platform(feed_url: str, parsed, guess: str) -> str:
|
| 120 |
+
generator = (parsed.feed.get("generator") or "").lower()
|
| 121 |
+
if "substack" in feed_url or "substack" in generator:
|
| 122 |
+
return "substack"
|
| 123 |
+
if "ghost" in generator:
|
| 124 |
+
return "ghost"
|
| 125 |
+
if "medium.com" in feed_url:
|
| 126 |
+
return "medium"
|
| 127 |
+
if "wordpress" in generator:
|
| 128 |
+
return "wordpress"
|
| 129 |
+
return guess if guess != "rss" else "rss"
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# Text extraction
|
| 133 |
+
|
| 134 |
+
|
| 135 |
def html_to_sections(html: str) -> list[tuple[str | None, str]]:
|
| 136 |
soup = BeautifulSoup(html, "html.parser")
|
| 137 |
+
for tag in soup(["script", "style", "nav", "footer", "form", "button", "iframe", "noscript", "svg"]):
|
| 138 |
tag.decompose()
|
| 139 |
sections: list[tuple[str | None, list[str]]] = [(None, [])]
|
| 140 |
+
for el in soup.find_all(["h1", "h2", "h3", "p", "li", "blockquote", "pre"]):
|
| 141 |
text = el.get_text(" ", strip=True)
|
| 142 |
if not text or (len(text) < 120 and _DROP.search(text)):
|
| 143 |
continue
|
|
|
|
| 148 |
return [(h, "\n\n".join(p)) for h, p in sections if p]
|
| 149 |
|
| 150 |
|
| 151 |
+
def markdown_to_sections(text: str) -> list[tuple[str | None, str]]:
|
| 152 |
+
sections: list[tuple[str | None, list[str]]] = [(None, [])]
|
| 153 |
+
para: list[str] = []
|
| 154 |
+
|
| 155 |
+
def flush() -> None:
|
| 156 |
+
if para:
|
| 157 |
+
sections[-1][1].append(" ".join(para).strip())
|
| 158 |
+
para.clear()
|
| 159 |
+
|
| 160 |
+
for line in text.splitlines():
|
| 161 |
+
stripped = line.strip()
|
| 162 |
+
heading = re.match(r"^#{1,3}\s+(.*)", stripped)
|
| 163 |
+
if heading:
|
| 164 |
+
flush()
|
| 165 |
+
sections.append((heading.group(1).strip(), []))
|
| 166 |
+
elif not stripped:
|
| 167 |
+
flush()
|
| 168 |
+
else:
|
| 169 |
+
para.append(stripped)
|
| 170 |
+
flush()
|
| 171 |
+
return [(h, "\n\n".join(p)) for h, p in sections if p]
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def pdf_to_sections(data: bytes) -> list[tuple[str | None, str]]:
|
| 175 |
+
from pypdf import PdfReader
|
| 176 |
+
|
| 177 |
+
reader = PdfReader(io.BytesIO(data))
|
| 178 |
+
text = "\n\n".join((page.extract_text() or "") for page in reader.pages)
|
| 179 |
+
# PDF text keeps hard line breaks inside paragraphs; blank lines separate paragraphs.
|
| 180 |
+
paragraphs = [re.sub(r"\s*\n\s*", " ", p).strip() for p in re.split(r"\n\s*\n", text)]
|
| 181 |
+
return [(None, "\n\n".join(p for p in paragraphs if p))] if any(paragraphs) else []
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def _meta(soup: BeautifulSoup, *names: str) -> str | None:
|
| 185 |
+
for name in names:
|
| 186 |
+
tag = soup.find("meta", attrs={"property": name}) or soup.find("meta", attrs={"name": name})
|
| 187 |
+
if tag and tag.get("content"):
|
| 188 |
+
return tag["content"].strip()
|
| 189 |
+
return None
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def _parse_date(value: str | None) -> datetime | None:
|
| 193 |
+
if not value:
|
| 194 |
+
return None
|
| 195 |
+
try:
|
| 196 |
+
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
| 197 |
+
return dt if dt.tzinfo else dt.replace(tzinfo=UTC)
|
| 198 |
+
except ValueError:
|
| 199 |
+
return None
|
| 200 |
+
|
| 201 |
+
|
| 202 |
@dataclass
|
| 203 |
class FeedEntry:
|
| 204 |
title: str
|
| 205 |
url: str
|
| 206 |
published_at: datetime | None
|
| 207 |
html: str
|
| 208 |
+
sections: list[tuple[str | None, str]] | None = None # set when the source is not HTML
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def extract_article(url: str) -> FeedEntry:
|
| 212 |
+
"""Fetch one web page and keep the article body."""
|
| 213 |
+
url = _with_scheme(url)
|
| 214 |
+
resp = httpx.get(url, follow_redirects=True, timeout=30, headers=UA)
|
| 215 |
+
if resp.status_code >= 400:
|
| 216 |
+
raise FeedNotFound(f"That page returned {resp.status_code}.")
|
| 217 |
+
soup = BeautifulSoup(resp.text, "html.parser")
|
| 218 |
+
title = _meta(soup, "og:title", "twitter:title") or (soup.title.get_text(strip=True) if soup.title else url)
|
| 219 |
+
published = _parse_date(_meta(soup, "article:published_time", "og:published_time", "date"))
|
| 220 |
+
if not published and (t := soup.find("time", datetime=True)):
|
| 221 |
+
published = _parse_date(t["datetime"])
|
| 222 |
+
body = soup.find("article") or soup.find("main") or soup.find(class_=re.compile("post-content|entry-content|body"))
|
| 223 |
+
if body is None:
|
| 224 |
+
# Fall back to the element holding the most paragraph text.
|
| 225 |
+
candidates = soup.find_all(["div", "section"])
|
| 226 |
+
body = max(candidates, key=lambda el: sum(len(p.get_text()) for p in el.find_all("p", recursive=False)),
|
| 227 |
+
default=soup.body or soup)
|
| 228 |
+
html = str(body)
|
| 229 |
+
if not html_to_sections(html):
|
| 230 |
+
raise FeedNotFound("We could not find article text on that page.")
|
| 231 |
+
return FeedEntry(title=title[:1000], url=str(resp.url), published_at=published, html=html)
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
# Feed fetching
|
| 235 |
|
| 236 |
|
| 237 |
def fetch_feed(feed_url: str, limit: int) -> tuple[str | None, list[FeedEntry]]:
|
| 238 |
+
resp = httpx.get(feed_url, follow_redirects=True, timeout=30, headers=UA)
|
| 239 |
resp.raise_for_status()
|
| 240 |
parsed = feedparser.parse(resp.content)
|
| 241 |
entries = []
|
|
|
|
| 253 |
return parsed.feed.get("title"), entries
|
| 254 |
|
| 255 |
|
| 256 |
+
def fetch_substack_archive(site: str, known_urls: set[str], limit: int, on_page=None) -> list[FeedEntry]:
|
| 257 |
+
"""Posts beyond the ~20 the RSS feed carries, through Substack's public archive API."""
|
| 258 |
+
out: list[FeedEntry] = []
|
| 259 |
+
offset = 0
|
| 260 |
+
with httpx.Client(follow_redirects=True, timeout=30, headers=UA) as client:
|
| 261 |
+
while len(out) + len(known_urls) < limit:
|
| 262 |
+
resp = client.get(f"{site}/api/v1/archive", params={"sort": "new", "offset": offset, "limit": 50})
|
| 263 |
+
if resp.status_code >= 400:
|
| 264 |
+
break
|
| 265 |
+
page = resp.json()
|
| 266 |
+
if not page:
|
| 267 |
+
break
|
| 268 |
+
offset += len(page)
|
| 269 |
+
for item in page:
|
| 270 |
+
url = item.get("canonical_url") or f"{site}/p/{item.get('slug')}"
|
| 271 |
+
if url in known_urls or item.get("type") not in (None, "newsletter", "thread"):
|
| 272 |
+
continue
|
| 273 |
+
if len(out) + len(known_urls) >= limit:
|
| 274 |
+
break
|
| 275 |
+
post = client.get(f"{site}/api/v1/posts/{item['slug']}")
|
| 276 |
+
if post.status_code >= 400:
|
| 277 |
+
continue
|
| 278 |
+
body = post.json().get("body_html") or item.get("truncated_body_text") or ""
|
| 279 |
+
if not body:
|
| 280 |
+
continue
|
| 281 |
+
out.append(FeedEntry(title=item.get("title") or "Untitled", url=url,
|
| 282 |
+
published_at=_parse_date(item.get("post_date")), html=body))
|
| 283 |
+
time.sleep(0.2) # be polite to Substack
|
| 284 |
+
if on_page:
|
| 285 |
+
on_page(len(out))
|
| 286 |
+
return out
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
# Storing
|
| 290 |
+
|
| 291 |
+
|
| 292 |
def source_slug(source: Source) -> str:
|
| 293 |
+
if source.feed_url == IMPORTS_FEED:
|
| 294 |
+
return "imports"
|
| 295 |
return slugify(urlparse(source.site_url or source.feed_url).netloc or str(source.id), 50)
|
| 296 |
|
| 297 |
|
|
|
|
| 300 |
select(Document).where(Document.workspace_id == workspace_id, Document.path.is_not(None))
|
| 301 |
).all()
|
| 302 |
rows = sorted(
|
| 303 |
+
(index_line(d.path, d.title, d.published_at, (d.metadata_json or {}).get("headings", [])) for d in docs),
|
| 304 |
reverse=True,
|
| 305 |
)
|
| 306 |
header = "# Archive index\n\ndate | path | title | section headings\n\n"
|
| 307 |
return header + "\n".join(rows) + "\n"
|
| 308 |
|
| 309 |
|
| 310 |
+
def imports_source(db: Session, workspace_id: uuid.UUID) -> Source:
|
| 311 |
+
"""Single articles and uploaded files live under one built in source that does not count to limits."""
|
| 312 |
+
source = db.scalar(select(Source).where(Source.workspace_id == workspace_id, Source.feed_url == IMPORTS_FEED))
|
| 313 |
+
if not source:
|
| 314 |
+
source = Source(workspace_id=workspace_id, feed_url=IMPORTS_FEED, platform="imports",
|
| 315 |
+
title="Imported posts and files", sync_status="ok")
|
| 316 |
+
db.add(source)
|
| 317 |
+
db.flush()
|
| 318 |
+
return source
|
| 319 |
|
| 320 |
+
|
| 321 |
+
def store_entries(
|
| 322 |
+
db: Session, source: Source, entries: list[FeedEntry], progress=None
|
| 323 |
+
) -> tuple[int, int, list[uuid.UUID]]:
|
| 324 |
+
"""Write documents, raw HTML and corpus files. Returns (indexed, skipped, changed document ids)."""
|
| 325 |
corpus = Corpus(source.workspace_id)
|
| 326 |
folder = source_slug(source)
|
|
|
|
| 327 |
indexed = skipped = 0
|
| 328 |
+
changed: list[uuid.UUID] = []
|
| 329 |
files: dict[str, str] = {}
|
| 330 |
+
taken = set(db.scalars(select(Document.path).where(Document.workspace_id == source.workspace_id)).all())
|
| 331 |
+
total = max(len(entries), 1)
|
|
|
|
|
|
|
| 332 |
for i, entry in enumerate(entries):
|
| 333 |
+
payload = entry.html or "\n\n".join(t for _, t in (entry.sections or []))
|
| 334 |
+
content_hash = hashlib.sha256(payload.encode()).hexdigest()
|
| 335 |
doc = db.scalar(select(Document).where(Document.source_id == source.id, Document.url == entry.url))
|
| 336 |
if doc and doc.content_hash == content_hash and doc.path:
|
| 337 |
skipped += 1
|
| 338 |
continue
|
| 339 |
+
sections = entry.sections if entry.sections is not None else html_to_sections(entry.html)
|
| 340 |
+
if not sections:
|
| 341 |
+
skipped += 1
|
| 342 |
+
continue
|
| 343 |
if not doc:
|
| 344 |
doc = Document(id=uuid.uuid4(), workspace_id=source.workspace_id, source_id=source.id, url=entry.url)
|
| 345 |
db.add(doc)
|
|
|
|
| 354 |
doc.title = entry.title
|
| 355 |
doc.published_at = entry.published_at
|
| 356 |
doc.content_hash = content_hash
|
| 357 |
+
if entry.html:
|
| 358 |
+
doc.raw_html_key = storage.put_text(
|
| 359 |
+
keys.raw_html(source.workspace_id, source.id, doc.id), entry.html, "text/html; charset=utf-8"
|
| 360 |
+
)
|
| 361 |
doc.clean_text = "\n\n".join(f"## {h}\n{t}" if h else t for h, t in sections)
|
| 362 |
+
doc.metadata_json = {
|
| 363 |
+
**(doc.metadata_json or {}),
|
| 364 |
+
"headings": [h for h, _ in sections if h],
|
| 365 |
+
"words": len(doc.clean_text.split()),
|
| 366 |
+
}
|
| 367 |
files[doc.path] = render_post(
|
| 368 |
title=entry.title, url=entry.url, published_at=entry.published_at, source=folder, sections=sections
|
| 369 |
)
|
| 370 |
indexed += 1
|
| 371 |
+
changed.append(doc.id)
|
| 372 |
db.commit()
|
| 373 |
+
if progress:
|
| 374 |
+
progress(i + 1, total, entry.title)
|
| 375 |
+
db.flush()
|
| 376 |
files[INDEX] = rebuild_index(db, source.workspace_id)
|
| 377 |
corpus.write_files(files)
|
| 378 |
+
db.commit()
|
| 379 |
+
return indexed, skipped, changed
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
def ingest_source(db: Session, source: Source, job: Job, max_posts: int) -> dict:
|
| 383 |
+
source.sync_status = "syncing"
|
| 384 |
+
update_job(db, job, status="running", progress=0.02, message="Contacting your feed")
|
| 385 |
+
|
| 386 |
+
title, entries = fetch_feed(source.feed_url, max_posts)
|
| 387 |
+
source.title = source.title or title
|
| 388 |
+
if source.platform == "substack" and len(entries) < max_posts:
|
| 389 |
+
site = f"{urlparse(source.feed_url).scheme}://{urlparse(source.feed_url).netloc}"
|
| 390 |
+
update_job(db, job, progress=0.05, message="Reading your Substack archive")
|
| 391 |
+
try:
|
| 392 |
+
entries += fetch_substack_archive(
|
| 393 |
+
site, {e.url for e in entries}, max_posts,
|
| 394 |
+
on_page=lambda n: update_job(db, job, message=f"Found {len(entries) + n} posts in the archive"),
|
| 395 |
+
)
|
| 396 |
+
except (httpx.HTTPError, ValueError):
|
| 397 |
+
log.warning("substack archive fetch failed for %s", site, exc_info=True)
|
| 398 |
+
|
| 399 |
+
def progress(done: int, total: int, post_title: str) -> None:
|
| 400 |
+
update_job(db, job, progress=0.1 + 0.8 * done / total, message=f"{post_title[:80]} is in orbit")
|
| 401 |
+
|
| 402 |
+
update_job(db, job, progress=0.1, message=f"Indexing {len(entries)} posts")
|
| 403 |
+
indexed, skipped, changed = store_entries(db, source, entries, progress)
|
| 404 |
|
| 405 |
source.sync_status = "ok"
|
| 406 |
source.sync_error = None
|
| 407 |
source.last_synced_at = datetime.now(UTC)
|
| 408 |
db.commit()
|
| 409 |
+
return {"indexed": indexed, "skipped": skipped, "found": len(entries), "changed": [str(i) for i in changed]}
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
def entry_from_upload(filename: str, content_type: str, data: bytes) -> FeedEntry:
|
| 413 |
+
name = filename.rsplit("/", 1)[-1]
|
| 414 |
+
stem = re.sub(r"\.[A-Za-z0-9]+$", "", name).replace("-", " ").replace("_", " ").strip() or "Untitled"
|
| 415 |
+
lower = name.lower()
|
| 416 |
+
url = f"upload://{name}"
|
| 417 |
+
if content_type == "application/pdf" or lower.endswith(".pdf"):
|
| 418 |
+
return FeedEntry(title=stem, url=url, published_at=None, html="", sections=pdf_to_sections(data))
|
| 419 |
+
text = data.decode("utf-8", errors="replace")
|
| 420 |
+
if content_type == "text/html" or lower.endswith((".html", ".htm")):
|
| 421 |
+
soup = BeautifulSoup(text, "html.parser")
|
| 422 |
+
title = soup.title.get_text(strip=True) if soup.title else stem
|
| 423 |
+
return FeedEntry(title=title, url=url, published_at=None, html=text)
|
| 424 |
+
sections = markdown_to_sections(text)
|
| 425 |
+
first_heading = re.search(r"^#\s+(.+)$", text, re.M)
|
| 426 |
+
title = first_heading.group(1).strip() if first_heading else stem
|
| 427 |
+
if sections and sections[0][0] == title:
|
| 428 |
+
sections[0] = (None, sections[0][1])
|
| 429 |
+
return FeedEntry(title=title, url=url, published_at=None, html="", sections=sections)
|
backend/app/pipeline/launchkit.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Launch Kit: one post in, everything needed to launch it out, in the writer's voice and grounded in
|
| 2 |
+
the post's own claims."""
|
| 3 |
+
|
| 4 |
+
from sqlalchemy.orm import Session
|
| 5 |
+
|
| 6 |
+
from app.corpus import INDEX, Corpus
|
| 7 |
+
from app.llm import run
|
| 8 |
+
from app.llm.signatures import CarouselSlides, ExtractClaims, HookGenerator, PickQuotes, PlatformAdapter, SeoPack
|
| 9 |
+
from app.models import Artifact, Document, Job
|
| 10 |
+
from app.pipeline.generate import NothingToDo
|
| 11 |
+
from app.pipeline.passages import passages_for, tools_for, verify_refs, voice_text
|
| 12 |
+
from app.services.jobs import create_job, update_job
|
| 13 |
+
|
| 14 |
+
PLATFORMS = {
|
| 15 |
+
"x_thread": ("X thread", "5 to 9 posts, each under 270 characters. Post 1 is the hook and stands alone. "
|
| 16 |
+
"No hashtags except at most one at the end. The last post links to the article with {url}."),
|
| 17 |
+
"linkedin": ("LinkedIn", "One post, 900 to 1,500 characters. Short paragraphs of 1 to 2 lines. Open with a "
|
| 18 |
+
"concrete line, not a question. End with the link {url} and one question for comments."),
|
| 19 |
+
"substack_notes": ("Substack Notes", "3 separate notes, each 1 to 4 sentences, conversational, each able "
|
| 20 |
+
"to stand alone. One of them links to {url}."),
|
| 21 |
+
"bluesky": ("Bluesky", "A thread of 3 to 6 posts, each under 290 characters. Plain, human, no hashtags. "
|
| 22 |
+
"The last post links to {url}."),
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def build_launch_kit(db: Session, job: Job, artifact: Artifact) -> dict:
|
| 27 |
+
doc = db.get(Document, artifact.document_id) if artifact.document_id else None
|
| 28 |
+
if not doc or not doc.path:
|
| 29 |
+
raise NothingToDo("Pick a post for the Launch Kit.")
|
| 30 |
+
ws = artifact.workspace_id
|
| 31 |
+
corpus = Corpus(ws)
|
| 32 |
+
passages = passages_for(corpus, [doc], budget_chars=50_000)
|
| 33 |
+
tools = tools_for(corpus, [doc])
|
| 34 |
+
voice = voice_text(db, ws)
|
| 35 |
+
url = doc.url if doc.url.startswith("http") else ""
|
| 36 |
+
|
| 37 |
+
def step(progress: float, message: str) -> None:
|
| 38 |
+
update_job(db, job, progress=progress, message=message)
|
| 39 |
+
|
| 40 |
+
step(0.08, "Pulling out the key claims")
|
| 41 |
+
claims_out = run.predict(ExtractClaims, db=db, workspace_id=ws, job=job, passages=passages)
|
| 42 |
+
claims = []
|
| 43 |
+
for c in claims_out.get("claims") or []:
|
| 44 |
+
refs = verify_refs(tools, c.get("sources") or [])
|
| 45 |
+
if refs and c.get("claim"):
|
| 46 |
+
claims.append({"claim": c["claim"], "sources": refs})
|
| 47 |
+
if not claims:
|
| 48 |
+
raise RuntimeError("No verifiable claims found in this post")
|
| 49 |
+
claim_inputs = [{"claim": c["claim"], "sources": [{k: s[k] for k in ("path", "line_start", "line_end")}
|
| 50 |
+
for s in c["sources"]]} for c in claims]
|
| 51 |
+
|
| 52 |
+
step(0.2, "Writing hooks")
|
| 53 |
+
hooks = run.predict(HookGenerator, db=db, workspace_id=ws, job=job, passages=passages,
|
| 54 |
+
voice_profile=voice, n=8).get("hooks") or []
|
| 55 |
+
hooks = sorted(hooks, key=lambda h: -(h.get("strength") or 0))
|
| 56 |
+
|
| 57 |
+
posts: dict[str, list[str]] = {}
|
| 58 |
+
for i, (platform, (label, rules)) in enumerate(PLATFORMS.items()):
|
| 59 |
+
step(0.3 + 0.1 * i, f"Adapting for {label}")
|
| 60 |
+
out = run.predict(PlatformAdapter, db=db, workspace_id=ws, job=job, claims=claim_inputs, voice_profile=voice,
|
| 61 |
+
platform=platform, platform_rules=rules.format(url=url or "the post"))
|
| 62 |
+
posts[platform] = [p for p in (out.get("posts") or []) if p.strip()]
|
| 63 |
+
|
| 64 |
+
step(0.72, "Building the SEO pack")
|
| 65 |
+
related = []
|
| 66 |
+
if corpus.exists(INDEX):
|
| 67 |
+
related = [ln for ln in corpus.read_lines(INDEX) if "|" in ln and doc.path not in ln][:150]
|
| 68 |
+
seo = run.predict(SeoPack, db=db, workspace_id=ws, job=job, title=doc.title, passages=passages,
|
| 69 |
+
related_posts="\n".join(related)).get("seo") or {}
|
| 70 |
+
|
| 71 |
+
step(0.82, "Designing the carousel")
|
| 72 |
+
slides = run.predict(CarouselSlides, db=db, workspace_id=ws, job=job, passages=passages,
|
| 73 |
+
voice_profile=voice).get("slides") or []
|
| 74 |
+
|
| 75 |
+
step(0.9, "Choosing quotes")
|
| 76 |
+
quotes = []
|
| 77 |
+
for pick in run.predict(PickQuotes, db=db, workspace_id=ws, job=job, passages=passages, n=3).get("quotes") or []:
|
| 78 |
+
refs = verify_refs(tools, [pick.get("source") or {}])
|
| 79 |
+
if refs and pick.get("quote"):
|
| 80 |
+
quotes.append({"quote": pick["quote"][:380], "source": refs[0]})
|
| 81 |
+
|
| 82 |
+
artifact.content_json = {
|
| 83 |
+
**(artifact.content_json or {}),
|
| 84 |
+
"title": f"Launch Kit: {doc.title}",
|
| 85 |
+
"post_title": doc.title,
|
| 86 |
+
"post_url": doc.url,
|
| 87 |
+
"claims": claims,
|
| 88 |
+
"hooks": hooks,
|
| 89 |
+
"posts": posts,
|
| 90 |
+
"seo": seo,
|
| 91 |
+
"carousel": slides,
|
| 92 |
+
"quotes": quotes,
|
| 93 |
+
}
|
| 94 |
+
artifact.status = "ready"
|
| 95 |
+
db.commit()
|
| 96 |
+
|
| 97 |
+
# Quote cards render as their own artifacts, so a missing renderer does not sink the kit.
|
| 98 |
+
card_ids = []
|
| 99 |
+
for q in quotes:
|
| 100 |
+
card = Artifact(workspace_id=ws, document_id=doc.id, type="quote_card", status="pending",
|
| 101 |
+
content_json={"title": f"Quote: {doc.title}", "parent_id": str(artifact.id), **q})
|
| 102 |
+
db.add(card)
|
| 103 |
+
db.flush()
|
| 104 |
+
create_job(db, ws, "quote_card", {"artifact_id": str(card.id)}, artifact_id=card.id, max_attempts=1)
|
| 105 |
+
card_ids.append(str(card.id))
|
| 106 |
+
artifact.content_json = {**artifact.content_json, "quote_card_ids": card_ids}
|
| 107 |
+
db.commit()
|
| 108 |
+
return {"artifact_id": str(artifact.id), "claims": len(claims)}
|
backend/app/pipeline/media.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Audio overviews (two hosts, ElevenLabs), videos and stills (Remotion renderer)."""
|
| 2 |
+
|
| 3 |
+
import uuid
|
| 4 |
+
|
| 5 |
+
from sqlalchemy import select
|
| 6 |
+
from sqlalchemy.orm import Session
|
| 7 |
+
|
| 8 |
+
from app.config import settings
|
| 9 |
+
from app.corpus import Corpus
|
| 10 |
+
from app.llm import run
|
| 11 |
+
from app.llm.signatures import PickQuotes, PodcastScript, VideoStoryboard
|
| 12 |
+
from app.models import Artifact, Document, Job, Source, User, VoiceProfile, Workspace
|
| 13 |
+
from app.pipeline.generate import NothingToDo
|
| 14 |
+
from app.pipeline.passages import notebook_docs, passages_for, tools_for, verify_refs
|
| 15 |
+
from app.services import tts
|
| 16 |
+
from app.services.jobs import record_usage, update_job
|
| 17 |
+
from app.services.renderer import PermanentJobError, brand_props, request_render
|
| 18 |
+
from app.services.storage import keys, storage
|
| 19 |
+
|
| 20 |
+
SCENE_TYPES = {"title", "section", "pull_quote", "number", "outro"}
|
| 21 |
+
VIDEO_STYLES = {
|
| 22 |
+
"short": ("ShortVertical", "9:16"),
|
| 23 |
+
"explainer": ("ExplainerLong", "16:9"),
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def artifact_docs(db: Session, artifact: Artifact) -> list[Document]:
|
| 28 |
+
if artifact.document_id:
|
| 29 |
+
doc = db.get(Document, artifact.document_id)
|
| 30 |
+
return [doc] if doc and doc.path else []
|
| 31 |
+
if artifact.notebook_id:
|
| 32 |
+
return notebook_docs(db, artifact.notebook_id)
|
| 33 |
+
return []
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def host_voices(db: Session, workspace_id: uuid.UUID) -> tuple[str, str]:
|
| 37 |
+
vp = db.scalar(select(VoiceProfile).where(VoiceProfile.workspace_id == workspace_id))
|
| 38 |
+
voices = (vp.host_voices if vp else None) or {}
|
| 39 |
+
return voices.get("host_a") or settings.elevenlabs_voice_a, voices.get("host_b") or settings.elevenlabs_voice_b
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _require_tts() -> None:
|
| 43 |
+
if not settings.elevenlabs_api_key:
|
| 44 |
+
raise PermanentJobError("Audio needs ELEVENLABS_API_KEY in .env")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# Audio overview
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def audio_overview(db: Session, job: Job, artifact: Artifact) -> dict:
|
| 51 |
+
_require_tts()
|
| 52 |
+
params = job.params
|
| 53 |
+
docs = artifact_docs(db, artifact)
|
| 54 |
+
if not docs:
|
| 55 |
+
raise NothingToDo("Add posts before making an audio overview.")
|
| 56 |
+
corpus = Corpus(artifact.workspace_id)
|
| 57 |
+
update_job(db, job, progress=0.05, message=f"Reading {len(docs)} posts")
|
| 58 |
+
passages = passages_for(corpus, docs, budget_chars=90_000)
|
| 59 |
+
update_job(db, job, progress=0.15, message="Writing the conversation")
|
| 60 |
+
out = run.predict(PodcastScript, db=db, workspace_id=artifact.workspace_id, job=job, passages=passages,
|
| 61 |
+
format=params.get("format", "deep_dive"), target_minutes=int(params.get("minutes", 6)))
|
| 62 |
+
lines = [ln for ln in (out.get("lines") or []) if (ln.get("text") or "").strip()]
|
| 63 |
+
if not lines:
|
| 64 |
+
raise RuntimeError("The script came back empty")
|
| 65 |
+
tools = tools_for(corpus, docs)
|
| 66 |
+
voice_a, voice_b = host_voices(db, artifact.workspace_id)
|
| 67 |
+
audio = bytearray()
|
| 68 |
+
segments = []
|
| 69 |
+
t = 0.0
|
| 70 |
+
for i, line in enumerate(lines):
|
| 71 |
+
voice = voice_a if line.get("speaker") == "host_a" else voice_b
|
| 72 |
+
clip = tts.synthesize(artifact.workspace_id, line["text"], voice)
|
| 73 |
+
seconds = tts.mp3_seconds(clip)
|
| 74 |
+
segments.append({"speaker": line.get("speaker", "host_a"), "text": line["text"],
|
| 75 |
+
"start": round(t, 2), "end": round(t + seconds, 2),
|
| 76 |
+
"sources": verify_refs(tools, line.get("sources") or [])})
|
| 77 |
+
audio += clip
|
| 78 |
+
t += seconds
|
| 79 |
+
update_job(db, job, progress=0.2 + 0.75 * (i + 1) / len(lines),
|
| 80 |
+
message=f"Recording line {i + 1} of {len(lines)}")
|
| 81 |
+
key = keys.artifact(artifact.workspace_id, artifact.id, "audio", "mp3")
|
| 82 |
+
storage.put_bytes(key, bytes(audio), "audio/mpeg")
|
| 83 |
+
artifact.storage_key = key
|
| 84 |
+
artifact.status = "ready"
|
| 85 |
+
artifact.content_json = {
|
| 86 |
+
**(artifact.content_json or {}),
|
| 87 |
+
"format": params.get("format", "deep_dive"),
|
| 88 |
+
"duration_s": round(t, 1),
|
| 89 |
+
"segments": segments,
|
| 90 |
+
"voices": {"host_a": voice_a, "host_b": voice_b},
|
| 91 |
+
}
|
| 92 |
+
db.commit()
|
| 93 |
+
record_usage(db, workspace_id=artifact.workspace_id, kind="tts", provider="elevenlabs",
|
| 94 |
+
quantity=round(t, 1), unit="seconds", job=job)
|
| 95 |
+
return {"artifact_id": str(artifact.id), "duration_s": round(t, 1)}
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
# Video
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _clamp(value, lo: float, hi: float) -> float:
|
| 102 |
+
try:
|
| 103 |
+
return max(lo, min(hi, float(value)))
|
| 104 |
+
except (TypeError, ValueError):
|
| 105 |
+
return lo
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def make_video(db: Session, job: Job, artifact: Artifact) -> None:
|
| 109 |
+
style = job.params.get("style", "short")
|
| 110 |
+
if style == "audiogram":
|
| 111 |
+
return make_audiogram(db, job, artifact)
|
| 112 |
+
composition, aspect = VIDEO_STYLES[style]
|
| 113 |
+
docs = artifact_docs(db, artifact)
|
| 114 |
+
if not docs:
|
| 115 |
+
raise NothingToDo("Add posts before making a video.")
|
| 116 |
+
corpus = Corpus(artifact.workspace_id)
|
| 117 |
+
workspace = db.get(Workspace, artifact.workspace_id)
|
| 118 |
+
update_job(db, job, progress=0.05, message="Storyboarding")
|
| 119 |
+
passages = passages_for(corpus, docs, budget_chars=60_000)
|
| 120 |
+
out = run.predict(VideoStoryboard, db=db, workspace_id=artifact.workspace_id, job=job,
|
| 121 |
+
passages=passages, aspect=aspect)
|
| 122 |
+
scenes = [s for s in (out.get("scenes") or []) if (s.get("on_screen_text") or "").strip()]
|
| 123 |
+
if not scenes:
|
| 124 |
+
raise RuntimeError("The storyboard came back empty")
|
| 125 |
+
max_scenes = 8 if style == "short" else 14
|
| 126 |
+
scenes = scenes[:max_scenes]
|
| 127 |
+
|
| 128 |
+
captions: list[dict] = []
|
| 129 |
+
audio = bytearray()
|
| 130 |
+
cursor = 0.0
|
| 131 |
+
voice_a, _ = host_voices(db, artifact.workspace_id)
|
| 132 |
+
out_scenes = []
|
| 133 |
+
for i, s in enumerate(scenes):
|
| 134 |
+
narration = (s.get("narration") or "").strip()
|
| 135 |
+
duration = _clamp(s.get("duration_hint_s"), 2.0, 20.0)
|
| 136 |
+
if narration and settings.elevenlabs_api_key:
|
| 137 |
+
update_job(db, job, progress=0.15 + 0.5 * i / len(scenes), message=f"Narrating scene {i + 1}")
|
| 138 |
+
clip, words = tts.synthesize_with_timestamps(artifact.workspace_id, narration, voice_a)
|
| 139 |
+
seconds = tts.mp3_seconds(clip)
|
| 140 |
+
# Pad with silence so each clip starts exactly when its scene does.
|
| 141 |
+
audio += _silence(cursor - tts.mp3_seconds(bytes(audio)))
|
| 142 |
+
offset = int(cursor * 1000)
|
| 143 |
+
captions += [{**w, "startMs": w["startMs"] + offset, "endMs": w["endMs"] + offset} for w in words]
|
| 144 |
+
audio += clip
|
| 145 |
+
duration = max(duration, seconds + 0.3)
|
| 146 |
+
cursor += duration
|
| 147 |
+
scene_type = s.get("type") if s.get("type") in SCENE_TYPES else "section"
|
| 148 |
+
out_scenes.append({"type": scene_type, "onScreenText": s["on_screen_text"][:300],
|
| 149 |
+
"narration": narration, "durationS": round(_clamp(duration, 0.5, 60), 2)})
|
| 150 |
+
|
| 151 |
+
props: dict = {"scenes": out_scenes, "captions": captions, "brand": brand_props(workspace)}
|
| 152 |
+
title = docs[0].title if len(docs) == 1 else (artifact.content_json or {}).get("title", "Notestack")
|
| 153 |
+
if composition == "ShortVertical":
|
| 154 |
+
hook = next((sc["onScreenText"] for sc in out_scenes if sc["type"] == "title"), title)
|
| 155 |
+
props["hook"] = hook[:120]
|
| 156 |
+
else:
|
| 157 |
+
props["title"] = title[:140]
|
| 158 |
+
if audio:
|
| 159 |
+
key = keys.artifact(artifact.workspace_id, artifact.id, "narration", "mp3")
|
| 160 |
+
storage.put_bytes(key, bytes(audio), "audio/mpeg")
|
| 161 |
+
props["audioUrl"] = storage.presign_get(key, ttl=6 * 3600)
|
| 162 |
+
artifact.content_json = {**(artifact.content_json or {}), "style": style, "composition": composition,
|
| 163 |
+
"scenes": out_scenes, "duration_s": round(cursor, 1)}
|
| 164 |
+
artifact.status = "rendering"
|
| 165 |
+
db.commit()
|
| 166 |
+
update_job(db, job, progress=0.7, message="Handing off to the renderer")
|
| 167 |
+
request_render(job, artifact, composition, props)
|
| 168 |
+
record_usage(db, workspace_id=artifact.workspace_id, kind="video", provider="remotion",
|
| 169 |
+
quantity=round(cursor, 1), unit="seconds", job=job)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
_SILENT_FRAME = None
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def _silence(seconds: float) -> bytes:
|
| 176 |
+
"""MPEG1 Layer III 128 kbps 44.1 kHz silent frames (417 bytes, ~26 ms each)."""
|
| 177 |
+
global _SILENT_FRAME
|
| 178 |
+
if _SILENT_FRAME is None:
|
| 179 |
+
_SILENT_FRAME = bytes([0xFF, 0xFB, 0x90, 0x64]) + bytes(413)
|
| 180 |
+
frames = int(seconds / 0.026122)
|
| 181 |
+
return _SILENT_FRAME * max(frames, 0)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def make_audiogram(db: Session, job: Job, artifact: Artifact) -> None:
|
| 185 |
+
source = db.get(Artifact, uuid.UUID(job.params["audio_artifact_id"]))
|
| 186 |
+
if not source or source.type != "audio_overview" or not source.storage_key:
|
| 187 |
+
raise PermanentJobError("Pick a finished audio overview for the audiogram.")
|
| 188 |
+
workspace = db.get(Workspace, artifact.workspace_id)
|
| 189 |
+
content = source.content_json or {}
|
| 190 |
+
duration = float(content.get("duration_s") or 30)
|
| 191 |
+
segments = [{"speaker": s["speaker"], "text": s["text"], "startMs": int(s["start"] * 1000),
|
| 192 |
+
"endMs": int(s["end"] * 1000)} for s in content.get("segments", [])]
|
| 193 |
+
props = {"title": (content.get("title") or "Audio overview")[:140],
|
| 194 |
+
"audioUrl": storage.presign_get(source.storage_key, ttl=6 * 3600),
|
| 195 |
+
"durationS": round(min(duration, 600), 2), "segments": segments, "brand": brand_props(workspace)}
|
| 196 |
+
artifact.content_json = {**(artifact.content_json or {}), "style": "audiogram", "composition": "AudiogramSquare",
|
| 197 |
+
"audio_artifact_id": str(source.id), "duration_s": props["durationS"]}
|
| 198 |
+
artifact.status = "rendering"
|
| 199 |
+
db.commit()
|
| 200 |
+
update_job(db, job, progress=0.1, message="Handing off to the renderer")
|
| 201 |
+
request_render(job, artifact, "AudiogramSquare", props)
|
| 202 |
+
record_usage(db, workspace_id=artifact.workspace_id, kind="video", provider="remotion",
|
| 203 |
+
quantity=props["durationS"], unit="seconds", job=job)
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
# Stills
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def author_line(db: Session, workspace_id: uuid.UUID) -> str:
|
| 210 |
+
ws = db.get(Workspace, workspace_id)
|
| 211 |
+
owner = db.get(User, ws.owner_id) if ws else None
|
| 212 |
+
return (ws.brand_json or {}).get("name") or (owner.name if owner and owner.name else "") or "Notestack"
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def make_quote_card(db: Session, job: Job, artifact: Artifact) -> None:
|
| 216 |
+
content = artifact.content_json or {}
|
| 217 |
+
workspace = db.get(Workspace, artifact.workspace_id)
|
| 218 |
+
if not content.get("quote"):
|
| 219 |
+
docs = artifact_docs(db, artifact)
|
| 220 |
+
if not docs:
|
| 221 |
+
raise NothingToDo("Pick a post or notebook for the quote card.")
|
| 222 |
+
corpus = Corpus(artifact.workspace_id)
|
| 223 |
+
out = run.predict(PickQuotes, db=db, workspace_id=artifact.workspace_id, job=job,
|
| 224 |
+
passages=passages_for(corpus, docs, budget_chars=40_000), n=3)
|
| 225 |
+
tools = tools_for(corpus, docs)
|
| 226 |
+
for pick in out.get("quotes") or []:
|
| 227 |
+
refs = verify_refs(tools, [pick.get("source") or {}])
|
| 228 |
+
if refs and pick.get("quote"):
|
| 229 |
+
content = {**content, "quote": pick["quote"][:380], "source": refs[0]}
|
| 230 |
+
break
|
| 231 |
+
else:
|
| 232 |
+
raise RuntimeError("No verifiable quote found")
|
| 233 |
+
source = content.get("source") or {}
|
| 234 |
+
doc = db.scalar(select(Document).where(Document.workspace_id == artifact.workspace_id,
|
| 235 |
+
Document.path == source.get("path")))
|
| 236 |
+
publication = None
|
| 237 |
+
if doc and doc.source_id:
|
| 238 |
+
src = db.get(Source, doc.source_id)
|
| 239 |
+
publication = src.title if src else None
|
| 240 |
+
props = {"quote": content["quote"][:380], "author": author_line(db, artifact.workspace_id),
|
| 241 |
+
"brand": brand_props(workspace)}
|
| 242 |
+
if publication or (doc and doc.title):
|
| 243 |
+
props["publication"] = (publication or doc.title)[:120]
|
| 244 |
+
artifact.content_json = {**content, "composition": "QuoteCard"}
|
| 245 |
+
artifact.status = "rendering"
|
| 246 |
+
db.commit()
|
| 247 |
+
request_render(job, artifact, "QuoteCard", props)
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def render_carousel(db: Session, job: Job, artifact: Artifact) -> None:
|
| 251 |
+
slides = (artifact.content_json or {}).get("slides") or []
|
| 252 |
+
if not slides:
|
| 253 |
+
raise PermanentJobError("This carousel has no slides.")
|
| 254 |
+
workspace = db.get(Workspace, artifact.workspace_id)
|
| 255 |
+
brand = brand_props(workspace)
|
| 256 |
+
stills = [{"heading": s.get("heading", "")[:120], "body": s.get("body", "")[:500], "index": i + 1,
|
| 257 |
+
"total": len(slides), "brand": brand} for i, s in enumerate(slides)]
|
| 258 |
+
artifact.status = "rendering"
|
| 259 |
+
db.commit()
|
| 260 |
+
request_render(job, artifact, "CarouselSlide", stills=stills)
|
backend/app/pipeline/passages.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Feeding posts to whole-document modules: numbered passages in, verified SourceRefs out."""
|
| 2 |
+
|
| 3 |
+
import uuid
|
| 4 |
+
from types import SimpleNamespace
|
| 5 |
+
|
| 6 |
+
from sqlalchemy import select
|
| 7 |
+
from sqlalchemy.orm import Session
|
| 8 |
+
|
| 9 |
+
from app.corpus import Corpus, CorpusError, ScopedTools
|
| 10 |
+
from app.models import Document, NotebookDocument, VoiceProfile
|
| 11 |
+
|
| 12 |
+
DEFAULT_BUDGET = 120_000 # characters across all passages; GLM-5.3 has room, this keeps cost sane
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def notebook_docs(db: Session, notebook_id: uuid.UUID) -> list[Document]:
|
| 16 |
+
return list(db.scalars(
|
| 17 |
+
select(Document)
|
| 18 |
+
.join(NotebookDocument, NotebookDocument.document_id == Document.id)
|
| 19 |
+
.where(NotebookDocument.notebook_id == notebook_id, Document.path.is_not(None))
|
| 20 |
+
.order_by(Document.published_at.desc())
|
| 21 |
+
).all())
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def workspace_docs(db: Session, workspace_id: uuid.UUID, ids: list[uuid.UUID] | None = None) -> list[Document]:
|
| 25 |
+
q = select(Document).where(Document.workspace_id == workspace_id, Document.path.is_not(None))
|
| 26 |
+
if ids is not None:
|
| 27 |
+
q = q.where(Document.id.in_(ids))
|
| 28 |
+
return list(db.scalars(q.order_by(Document.published_at.desc())).all())
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def passages_for(corpus: Corpus, docs: list[Document], budget_chars: int = DEFAULT_BUDGET) -> list[str]:
|
| 32 |
+
"""One string per post: a header line then `n| text` numbered lines (same numbering as read()).
|
| 33 |
+
The budget is split evenly, so one long post cannot crowd out the rest."""
|
| 34 |
+
corpus.sync()
|
| 35 |
+
per_doc = max(budget_chars // max(len(docs), 1), 1500)
|
| 36 |
+
out = []
|
| 37 |
+
for d in docs:
|
| 38 |
+
try:
|
| 39 |
+
lines = corpus.read_lines(d.path)
|
| 40 |
+
except CorpusError:
|
| 41 |
+
continue
|
| 42 |
+
body, used = [], 0
|
| 43 |
+
for n, line in enumerate(lines, start=1):
|
| 44 |
+
if not line.strip():
|
| 45 |
+
continue
|
| 46 |
+
entry = f"{n}| {line}"
|
| 47 |
+
if used + len(entry) > per_doc:
|
| 48 |
+
body.append("[... post continues]")
|
| 49 |
+
break
|
| 50 |
+
body.append(entry)
|
| 51 |
+
used += len(entry) + 1
|
| 52 |
+
out.append(f"FILE {d.path} ({d.title})\n" + "\n".join(body))
|
| 53 |
+
return out
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def tools_for(corpus: Corpus, docs: list[Document]) -> ScopedTools:
|
| 57 |
+
return ScopedTools(corpus, {d.path: d.title for d in docs})
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def verify_refs(tools: ScopedTools, refs: list[dict]) -> list[dict]:
|
| 61 |
+
"""Keep refs that point at real lines of allowed files; attach the quoted text."""
|
| 62 |
+
out = []
|
| 63 |
+
for r in refs or []:
|
| 64 |
+
try:
|
| 65 |
+
start, end = int(r["line_start"]), int(r["line_end"])
|
| 66 |
+
except (KeyError, TypeError, ValueError):
|
| 67 |
+
continue
|
| 68 |
+
quote = tools.quote(r.get("path", ""), start, end)
|
| 69 |
+
if quote:
|
| 70 |
+
out.append({"path": r["path"], "line_start": start, "line_end": max(start, end),
|
| 71 |
+
"title": tools.allowed.get(r["path"]), "quote": quote[:600]})
|
| 72 |
+
return out
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def as_citations(items: list[dict]) -> list[SimpleNamespace]:
|
| 76 |
+
"""Dicts from predict() in the shape research.verify_citations expects."""
|
| 77 |
+
out = []
|
| 78 |
+
for c in items or []:
|
| 79 |
+
try:
|
| 80 |
+
out.append(SimpleNamespace(marker=int(c["marker"]), path=c["path"],
|
| 81 |
+
line_start=int(c["line_start"]), line_end=int(c["line_end"])))
|
| 82 |
+
except (KeyError, TypeError, ValueError):
|
| 83 |
+
continue
|
| 84 |
+
return out
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def voice_text(db: Session, workspace_id: uuid.UUID) -> str:
|
| 88 |
+
vp = db.scalar(select(VoiceProfile).where(VoiceProfile.workspace_id == workspace_id))
|
| 89 |
+
if not vp or not vp.profile_json:
|
| 90 |
+
return "No voice profile yet: write clearly, warmly and concretely."
|
| 91 |
+
p = vp.profile_json
|
| 92 |
+
parts = [p.get("summary", "")]
|
| 93 |
+
for key in ("tone", "vocabulary", "structure_habits", "openings", "avoid"):
|
| 94 |
+
if p.get(key):
|
| 95 |
+
parts.append(f"{key.replace('_', ' ')}: {', '.join(p[key])}")
|
| 96 |
+
if p.get("sentence_length"):
|
| 97 |
+
parts.append(f"sentence length: {p['sentence_length']}")
|
| 98 |
+
return "\n".join(x for x in parts if x)
|
backend/app/routers/artifacts.py
CHANGED
|
@@ -2,79 +2,205 @@ import uuid
|
|
| 2 |
from typing import Literal
|
| 3 |
|
| 4 |
from fastapi import APIRouter, Depends, HTTPException
|
| 5 |
-
from pydantic import BaseModel
|
| 6 |
-
from sqlalchemy import select
|
| 7 |
|
| 8 |
from app.auth import Ctx, get_ctx
|
| 9 |
-
from app.models import Artifact
|
|
|
|
| 10 |
from app.services.jobs import create_job, serialize_job
|
|
|
|
| 11 |
from app.services.storage import storage
|
|
|
|
| 12 |
|
| 13 |
router = APIRouter(prefix="/api/artifacts", tags=["artifacts"])
|
| 14 |
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
class ArtifactIn(BaseModel):
|
| 19 |
-
type: Literal["summary", "audio_overview", "video", "thread", "linkedin", "notes", "quote_card", "carousel", "seo"]
|
| 20 |
-
notebook_id: uuid.UUID | None = None
|
| 21 |
-
document_id: uuid.UUID | None = None
|
| 22 |
-
content: dict = {}
|
| 23 |
|
| 24 |
|
| 25 |
class RenderIn(BaseModel):
|
| 26 |
-
composition: Literal["ShortVertical", "QuoteCard"]
|
| 27 |
props: dict
|
| 28 |
|
| 29 |
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
-
|
|
|
|
| 42 |
a = ctx.db.scalar(select(Artifact).where(Artifact.id == artifact_id, Artifact.workspace_id == ctx.workspace.id))
|
| 43 |
if not a:
|
| 44 |
raise HTTPException(404, "Artifact not found")
|
| 45 |
return a
|
| 46 |
|
| 47 |
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
|
| 63 |
@router.get("/{artifact_id}")
|
| 64 |
def get_artifact(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
|
| 68 |
@router.post("/{artifact_id}/render")
|
| 69 |
def render_artifact(artifact_id: uuid.UUID, body: RenderIn, ctx: Ctx = Depends(get_ctx)):
|
| 70 |
-
a =
|
|
|
|
|
|
|
| 71 |
a.status = "rendering"
|
| 72 |
-
job = create_job(
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
"render",
|
| 76 |
-
{"artifact_id": str(a.id), "composition": body.composition, "props": body.props,
|
| 77 |
-
"format": COMPOSITIONS[body.composition]},
|
| 78 |
-
artifact_id=a.id,
|
| 79 |
-
)
|
| 80 |
return serialize_job(job)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from typing import Literal
|
| 3 |
|
| 4 |
from fastapi import APIRouter, Depends, HTTPException
|
| 5 |
+
from pydantic import BaseModel, Field
|
| 6 |
+
from sqlalchemy import func, or_, select
|
| 7 |
|
| 8 |
from app.auth import Ctx, get_ctx
|
| 9 |
+
from app.models import Artifact, CalendarItem, Document, Job, Notebook
|
| 10 |
+
from app.services.artifacts import latest_jobs, serialize_artifact, start_artifact
|
| 11 |
from app.services.jobs import create_job, serialize_job
|
| 12 |
+
from app.services.renderer import COMPOSITIONS
|
| 13 |
from app.services.storage import storage
|
| 14 |
+
from app.services.usage import check_limit
|
| 15 |
|
| 16 |
router = APIRouter(prefix="/api/artifacts", tags=["artifacts"])
|
| 17 |
|
| 18 |
+
ArtifactType = Literal["summary", "audio_overview", "video", "quote_card", "carousel", "launch_kit"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
|
| 21 |
class RenderIn(BaseModel):
|
| 22 |
+
composition: Literal["ShortVertical", "ExplainerLong", "AudiogramSquare", "QuoteCard", "CarouselSlide"]
|
| 23 |
props: dict
|
| 24 |
|
| 25 |
|
| 26 |
+
class GenerateIn(BaseModel):
|
| 27 |
+
type: Literal["summary", "audio_overview", "video", "quote_card", "carousel", "launch_kit"]
|
| 28 |
+
notebook_id: uuid.UUID | None = None
|
| 29 |
+
document_id: uuid.UUID | None = None
|
| 30 |
+
# audio_overview
|
| 31 |
+
format: Literal["deep_dive", "brief", "debate"] = "deep_dive"
|
| 32 |
+
minutes: int = Field(6, ge=1, le=30)
|
| 33 |
+
# video
|
| 34 |
+
style: Literal["short", "explainer", "audiogram"] = "short"
|
| 35 |
+
audio_artifact_id: uuid.UUID | None = None
|
| 36 |
+
# carousel
|
| 37 |
+
slides: list[dict] | None = None
|
| 38 |
+
parent_id: uuid.UUID | None = None
|
| 39 |
+
|
| 40 |
|
| 41 |
+
class PatchIn(BaseModel):
|
| 42 |
+
title: str | None = Field(None, max_length=300)
|
| 43 |
+
content: dict | None = None
|
| 44 |
|
| 45 |
+
|
| 46 |
+
def get_artifact_or_404(ctx: Ctx, artifact_id: uuid.UUID) -> Artifact:
|
| 47 |
a = ctx.db.scalar(select(Artifact).where(Artifact.id == artifact_id, Artifact.workspace_id == ctx.workspace.id))
|
| 48 |
if not a:
|
| 49 |
raise HTTPException(404, "Artifact not found")
|
| 50 |
return a
|
| 51 |
|
| 52 |
|
| 53 |
+
def _target_title(ctx: Ctx, notebook_id: uuid.UUID | None, document_id: uuid.UUID | None) -> str:
|
| 54 |
+
if document_id:
|
| 55 |
+
doc = ctx.db.scalar(select(Document).where(Document.id == document_id,
|
| 56 |
+
Document.workspace_id == ctx.workspace.id))
|
| 57 |
+
if not doc:
|
| 58 |
+
raise HTTPException(404, "Post not found")
|
| 59 |
+
return doc.title
|
| 60 |
+
if notebook_id:
|
| 61 |
+
nb = ctx.db.scalar(select(Notebook).where(Notebook.id == notebook_id,
|
| 62 |
+
Notebook.workspace_id == ctx.workspace.id))
|
| 63 |
+
if not nb:
|
| 64 |
+
raise HTTPException(404, "Notebook not found")
|
| 65 |
+
return nb.title
|
| 66 |
+
raise HTTPException(400, "Pick a notebook or a post")
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@router.post("/generate")
|
| 70 |
+
def generate(body: GenerateIn, ctx: Ctx = Depends(get_ctx)):
|
| 71 |
+
"""One entry point for every generated artifact."""
|
| 72 |
+
params: dict = {}
|
| 73 |
+
content: dict = {}
|
| 74 |
+
if body.type == "video" and body.style == "audiogram":
|
| 75 |
+
if not body.audio_artifact_id:
|
| 76 |
+
raise HTTPException(400, "Pick an audio overview for the audiogram")
|
| 77 |
+
source = get_artifact_or_404(ctx, body.audio_artifact_id)
|
| 78 |
+
seconds = float((source.content_json or {}).get("duration_s", 60))
|
| 79 |
+
check_limit(ctx.db, ctx.workspace, "video_minutes", seconds / 60)
|
| 80 |
+
title = f"Audiogram: {(source.content_json or {}).get('title', 'Audio overview')}"
|
| 81 |
+
params = {"style": "audiogram", "audio_artifact_id": str(source.id)}
|
| 82 |
+
artifact, job = start_artifact(ctx.db, ctx.workspace.id, "video", title=title, notebook_id=source.notebook_id,
|
| 83 |
+
document_id=source.document_id, params=params, job_kind="video")
|
| 84 |
+
return serialize_artifact(artifact, job)
|
| 85 |
+
if body.type == "carousel":
|
| 86 |
+
if not body.slides:
|
| 87 |
+
raise HTTPException(400, "A carousel needs slides")
|
| 88 |
+
content = {"slides": body.slides[:12], "parent_id": str(body.parent_id) if body.parent_id else None}
|
| 89 |
+
target = _target_title(ctx, body.notebook_id, body.document_id)
|
| 90 |
+
if body.type == "audio_overview":
|
| 91 |
+
check_limit(ctx.db, ctx.workspace, "audio_minutes", body.minutes)
|
| 92 |
+
params = {"format": body.format, "minutes": body.minutes}
|
| 93 |
+
title = f"Audio overview: {target}"
|
| 94 |
+
elif body.type == "video":
|
| 95 |
+
check_limit(ctx.db, ctx.workspace, "video_minutes", 1 if body.style == "short" else 3)
|
| 96 |
+
params = {"style": body.style}
|
| 97 |
+
title = f"{'Short' if body.style == 'short' else 'Explainer'} video: {target}"
|
| 98 |
+
elif body.type == "launch_kit":
|
| 99 |
+
if not body.document_id:
|
| 100 |
+
raise HTTPException(400, "A Launch Kit is made from one post")
|
| 101 |
+
check_limit(ctx.db, ctx.workspace, "launch_kits", 1)
|
| 102 |
+
title = f"Launch Kit: {target}"
|
| 103 |
+
else:
|
| 104 |
+
title = {"summary": "Summary", "quote_card": "Quote", "carousel": "Carousel"}[body.type] + f": {target}"
|
| 105 |
+
artifact, job = start_artifact(ctx.db, ctx.workspace.id, body.type, title=title, notebook_id=body.notebook_id,
|
| 106 |
+
document_id=body.document_id, content=content, params=params,
|
| 107 |
+
max_attempts=1 if body.type in {"quote_card", "carousel"} else 2)
|
| 108 |
+
return serialize_artifact(artifact, job)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
@router.get("")
|
| 112 |
+
def list_artifacts(
|
| 113 |
+
ctx: Ctx = Depends(get_ctx),
|
| 114 |
+
type: str | None = None,
|
| 115 |
+
notebook_id: uuid.UUID | None = None,
|
| 116 |
+
document_id: uuid.UUID | None = None,
|
| 117 |
+
status: str | None = None,
|
| 118 |
+
q: str = "",
|
| 119 |
+
limit: int = 60,
|
| 120 |
+
offset: int = 0,
|
| 121 |
+
):
|
| 122 |
+
query = select(Artifact).where(Artifact.workspace_id == ctx.workspace.id)
|
| 123 |
+
if type:
|
| 124 |
+
query = query.where(Artifact.type.in_(type.split(",")))
|
| 125 |
+
if notebook_id:
|
| 126 |
+
query = query.where(Artifact.notebook_id == notebook_id)
|
| 127 |
+
if document_id:
|
| 128 |
+
query = query.where(Artifact.document_id == document_id)
|
| 129 |
+
if status:
|
| 130 |
+
query = query.where(Artifact.status.in_(status.split(",")))
|
| 131 |
+
if q.strip():
|
| 132 |
+
# Titles live in JSON; match on the linked post or notebook title instead of JSON operators.
|
| 133 |
+
like = f"%{q.strip()}%"
|
| 134 |
+
docs = select(Document.id).where(Document.workspace_id == ctx.workspace.id, Document.title.ilike(like))
|
| 135 |
+
nbs = select(Notebook.id).where(Notebook.workspace_id == ctx.workspace.id, Notebook.title.ilike(like))
|
| 136 |
+
query = query.where(or_(Artifact.document_id.in_(docs), Artifact.notebook_id.in_(nbs)))
|
| 137 |
+
total = ctx.db.scalar(select(func.count()).select_from(query.subquery()))
|
| 138 |
+
rows = ctx.db.scalars(query.order_by(Artifact.created_at.desc()).offset(max(offset, 0)).limit(min(limit, 200)))
|
| 139 |
+
rows = list(rows.all())
|
| 140 |
+
jobs = latest_jobs(ctx.db, [a.id for a in rows])
|
| 141 |
+
return {"total": total, "items": [serialize_artifact(a, jobs.get(a.id)) for a in rows]}
|
| 142 |
|
| 143 |
|
| 144 |
@router.get("/{artifact_id}")
|
| 145 |
def get_artifact(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 146 |
+
a = get_artifact_or_404(ctx, artifact_id)
|
| 147 |
+
return serialize_artifact(a, latest_jobs(ctx.db, [a.id]).get(a.id))
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
@router.patch("/{artifact_id}")
|
| 151 |
+
def patch_artifact(artifact_id: uuid.UUID, body: PatchIn, ctx: Ctx = Depends(get_ctx)):
|
| 152 |
+
a = get_artifact_or_404(ctx, artifact_id)
|
| 153 |
+
content = dict(a.content_json or {})
|
| 154 |
+
if body.content is not None:
|
| 155 |
+
protected = {"slide_keys", "quote_card_ids", "segments", "voices"}
|
| 156 |
+
content.update({k: v for k, v in body.content.items() if k not in protected})
|
| 157 |
+
if body.title is not None:
|
| 158 |
+
content["title"] = body.title
|
| 159 |
+
a.content_json = content
|
| 160 |
+
ctx.db.commit()
|
| 161 |
+
return serialize_artifact(a, latest_jobs(ctx.db, [a.id]).get(a.id))
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
@router.delete("/{artifact_id}")
|
| 165 |
+
def delete_artifact(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 166 |
+
a = get_artifact_or_404(ctx, artifact_id)
|
| 167 |
+
storage.delete_prefix(f"ws/{ctx.workspace.id}/artifacts/{a.id}/")
|
| 168 |
+
ctx.db.query(CalendarItem).filter(CalendarItem.artifact_id == a.id, CalendarItem.status == "scheduled").delete()
|
| 169 |
+
ctx.db.delete(a)
|
| 170 |
+
ctx.db.commit()
|
| 171 |
+
return {"ok": True}
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
@router.post("/{artifact_id}/retry")
|
| 175 |
+
def retry_artifact(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 176 |
+
a = get_artifact_or_404(ctx, artifact_id)
|
| 177 |
+
last = latest_jobs(ctx.db, [a.id]).get(a.id)
|
| 178 |
+
if not last:
|
| 179 |
+
raise HTTPException(400, "Nothing to retry")
|
| 180 |
+
if last.status not in {"failed", "done"}:
|
| 181 |
+
raise HTTPException(409, "Still running")
|
| 182 |
+
a.status = "pending"
|
| 183 |
+
content = dict(a.content_json or {})
|
| 184 |
+
content.pop("error", None)
|
| 185 |
+
a.content_json = content
|
| 186 |
+
job = create_job(ctx.db, ctx.workspace.id, last.kind, last.params, artifact_id=a.id, max_attempts=last.max_attempts)
|
| 187 |
+
return serialize_artifact(a, job)
|
| 188 |
|
| 189 |
|
| 190 |
@router.post("/{artifact_id}/render")
|
| 191 |
def render_artifact(artifact_id: uuid.UUID, body: RenderIn, ctx: Ctx = Depends(get_ctx)):
|
| 192 |
+
a = get_artifact_or_404(ctx, artifact_id)
|
| 193 |
+
if body.composition not in COMPOSITIONS:
|
| 194 |
+
raise HTTPException(400, "Unknown composition")
|
| 195 |
a.status = "rendering"
|
| 196 |
+
job = create_job(ctx.db, ctx.workspace.id, "render",
|
| 197 |
+
{"artifact_id": str(a.id), "composition": body.composition, "props": body.props},
|
| 198 |
+
artifact_id=a.id, max_attempts=1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
return serialize_job(job)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
@router.get("/{artifact_id}/jobs")
|
| 203 |
+
def artifact_jobs(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 204 |
+
a = get_artifact_or_404(ctx, artifact_id)
|
| 205 |
+
jobs = ctx.db.scalars(select(Job).where(Job.artifact_id == a.id).order_by(Job.created_at.desc())).all()
|
| 206 |
+
return [serialize_job(j) for j in jobs]
|
backend/app/routers/jobs.py
CHANGED
|
@@ -22,6 +22,17 @@ def _get(ctx: Ctx, job_id: uuid.UUID) -> Job:
|
|
| 22 |
return job
|
| 23 |
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
@router.get("/jobs/{job_id}")
|
| 26 |
def get_job(job_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 27 |
return serialize_job(_get(ctx, job_id))
|
|
@@ -45,6 +56,7 @@ class ProgressIn(BaseModel):
|
|
| 45 |
error: str | None = None
|
| 46 |
render_seconds: float | None = None
|
| 47 |
storage_key: str | None = None
|
|
|
|
| 48 |
|
| 49 |
|
| 50 |
@router.post("/internal/jobs/{job_id}/progress", include_in_schema=False)
|
|
@@ -59,9 +71,16 @@ def renderer_progress(
|
|
| 59 |
job = db.get(Job, job_id)
|
| 60 |
if not job:
|
| 61 |
raise HTTPException(404, "Job not found")
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
artifact.storage_key = body.storage_key
|
| 66 |
artifact.status = "ready"
|
| 67 |
if body.render_seconds:
|
|
|
|
| 22 |
return job
|
| 23 |
|
| 24 |
|
| 25 |
+
@router.get("/jobs")
|
| 26 |
+
def list_jobs(ctx: Ctx = Depends(get_ctx), active: bool = False, kind: str | None = None, limit: int = 20):
|
| 27 |
+
q = select(Job).where(Job.workspace_id == ctx.workspace.id)
|
| 28 |
+
if active:
|
| 29 |
+
q = q.where(Job.status.in_(["queued", "running"]))
|
| 30 |
+
if kind:
|
| 31 |
+
q = q.where(Job.kind.in_(kind.split(",")))
|
| 32 |
+
jobs = ctx.db.scalars(q.order_by(Job.created_at.desc()).limit(min(limit, 100))).all()
|
| 33 |
+
return [serialize_job(j) for j in jobs]
|
| 34 |
+
|
| 35 |
+
|
| 36 |
@router.get("/jobs/{job_id}")
|
| 37 |
def get_job(job_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 38 |
return serialize_job(_get(ctx, job_id))
|
|
|
|
| 56 |
error: str | None = None
|
| 57 |
render_seconds: float | None = None
|
| 58 |
storage_key: str | None = None
|
| 59 |
+
storage_keys: list[str] | None = None # carousels: one PNG per slide
|
| 60 |
|
| 61 |
|
| 62 |
@router.post("/internal/jobs/{job_id}/progress", include_in_schema=False)
|
|
|
|
| 71 |
job = db.get(Job, job_id)
|
| 72 |
if not job:
|
| 73 |
raise HTTPException(404, "Job not found")
|
| 74 |
+
artifact = db.get(Artifact, job.artifact_id) if job.artifact_id else None
|
| 75 |
+
if body.status == "failed" and artifact:
|
| 76 |
+
artifact.status = "failed"
|
| 77 |
+
artifact.content_json = {**(artifact.content_json or {}), "error": (body.error or "Render failed")[:500]}
|
| 78 |
+
if body.status == "done" and artifact:
|
| 79 |
+
if body.storage_keys:
|
| 80 |
+
artifact.content_json = {**(artifact.content_json or {}), "slide_keys": body.storage_keys}
|
| 81 |
+
artifact.storage_key = body.storage_keys[0]
|
| 82 |
+
artifact.status = "ready"
|
| 83 |
+
elif body.storage_key:
|
| 84 |
artifact.storage_key = body.storage_key
|
| 85 |
artifact.status = "ready"
|
| 86 |
if body.render_seconds:
|
backend/app/routers/launchpad.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Launchpad: the calendar, connected social accounts (OAuth / app password) and tracked short links."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import secrets
|
| 5 |
+
import uuid
|
| 6 |
+
from datetime import UTC, datetime, timedelta
|
| 7 |
+
from typing import Literal
|
| 8 |
+
from urllib.parse import urlencode
|
| 9 |
+
|
| 10 |
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 11 |
+
from fastapi.concurrency import run_in_threadpool
|
| 12 |
+
from fastapi.responses import RedirectResponse
|
| 13 |
+
from pydantic import BaseModel, Field
|
| 14 |
+
from sqlalchemy import select
|
| 15 |
+
from sqlalchemy.orm import Session
|
| 16 |
+
|
| 17 |
+
from app.auth import Ctx, decode_token_full, encode_signed, get_ctx
|
| 18 |
+
from app.config import settings
|
| 19 |
+
from app.db import get_db
|
| 20 |
+
from app.models import Artifact, CalendarItem, Document, EngagementEvent, SocialAccount, TrackedLink
|
| 21 |
+
from app.services import launchpad as lp
|
| 22 |
+
from app.services.crypto import decrypt, encrypt
|
| 23 |
+
from app.services.social import AUTO_POST, LIMITS, PLATFORM_LABELS, SocialError, bluesky, linkedin, x
|
| 24 |
+
|
| 25 |
+
router = APIRouter(prefix="/api", tags=["launchpad"])
|
| 26 |
+
|
| 27 |
+
Platform = Literal["x", "linkedin", "bluesky", "substack_notes"]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# Calendar
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class ItemIn(BaseModel):
|
| 34 |
+
platform: Platform
|
| 35 |
+
scheduled_at: datetime
|
| 36 |
+
content: str = Field(min_length=1, max_length=10000)
|
| 37 |
+
thread: list[str] = Field(default_factory=list, max_length=25)
|
| 38 |
+
artifact_id: uuid.UUID | None = None
|
| 39 |
+
document_id: uuid.UUID | None = None
|
| 40 |
+
social_account_id: uuid.UUID | None = None
|
| 41 |
+
remind_by_email: bool = False
|
| 42 |
+
draft: bool = False
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class ItemPatch(BaseModel):
|
| 46 |
+
scheduled_at: datetime | None = None
|
| 47 |
+
content: str | None = Field(None, min_length=1, max_length=10000)
|
| 48 |
+
thread: list[str] | None = None
|
| 49 |
+
social_account_id: uuid.UUID | None = None
|
| 50 |
+
remind_by_email: bool | None = None
|
| 51 |
+
status: Literal["scheduled", "draft"] | None = None
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _aware(dt: datetime) -> datetime:
|
| 55 |
+
return dt if dt.tzinfo else dt.replace(tzinfo=UTC)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def serialize_item(item: CalendarItem, account: SocialAccount | None = None, clicks: int = 0) -> dict:
|
| 59 |
+
return {
|
| 60 |
+
"id": str(item.id),
|
| 61 |
+
"platform": item.platform,
|
| 62 |
+
"platform_label": PLATFORM_LABELS.get(item.platform, item.platform),
|
| 63 |
+
"scheduled_at": _aware(item.scheduled_at).isoformat(),
|
| 64 |
+
"status": item.status,
|
| 65 |
+
"content": item.content,
|
| 66 |
+
"thread": item.thread or [],
|
| 67 |
+
"artifact_id": str(item.artifact_id) if item.artifact_id else None,
|
| 68 |
+
"document_id": str(item.document_id) if item.document_id else None,
|
| 69 |
+
"social_account_id": str(item.social_account_id) if item.social_account_id else None,
|
| 70 |
+
"account_handle": account.handle if account else None,
|
| 71 |
+
"remind_by_email": item.remind_by_email,
|
| 72 |
+
"auto_post": item.platform in AUTO_POST and bool(item.social_account_id) and not item.remind_by_email,
|
| 73 |
+
"external_url": item.external_url,
|
| 74 |
+
"posted_at": _aware(item.posted_at).isoformat() if item.posted_at else None,
|
| 75 |
+
"error": item.error,
|
| 76 |
+
"metrics": item.metrics or {},
|
| 77 |
+
"clicks": clicks,
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _item(ctx: Ctx, item_id: uuid.UUID) -> CalendarItem:
|
| 82 |
+
item = ctx.db.scalar(select(CalendarItem).where(CalendarItem.id == item_id,
|
| 83 |
+
CalendarItem.workspace_id == ctx.workspace.id))
|
| 84 |
+
if not item:
|
| 85 |
+
raise HTTPException(404, "Calendar item not found")
|
| 86 |
+
return item
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _account(ctx: Ctx, account_id: uuid.UUID | None, platform: str) -> SocialAccount | None:
|
| 90 |
+
if not account_id:
|
| 91 |
+
return None
|
| 92 |
+
account = ctx.db.scalar(select(SocialAccount).where(SocialAccount.id == account_id,
|
| 93 |
+
SocialAccount.workspace_id == ctx.workspace.id))
|
| 94 |
+
if not account or account.platform != platform:
|
| 95 |
+
raise HTTPException(400, "That account does not match the platform")
|
| 96 |
+
return account
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _validate_lengths(platform: str, posts: list[str]) -> None:
|
| 100 |
+
limit = LIMITS.get(platform)
|
| 101 |
+
if platform in ("x", "bluesky") and limit:
|
| 102 |
+
for i, p in enumerate(posts, start=1):
|
| 103 |
+
if len(p) > limit:
|
| 104 |
+
raise HTTPException(422, {"code": "too_long",
|
| 105 |
+
"message": f"Post {i} is {len(p)} characters; {PLATFORM_LABELS[platform]} "
|
| 106 |
+
f"allows {limit}."})
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _enrich(ctx: Ctx, items: list[CalendarItem]) -> list[dict]:
|
| 110 |
+
accounts = {a.id: a for a in ctx.db.scalars(
|
| 111 |
+
select(SocialAccount).where(SocialAccount.workspace_id == ctx.workspace.id))}
|
| 112 |
+
clicks = lp.clicks_by_item(ctx.db, [i.id for i in items])
|
| 113 |
+
return [serialize_item(i, accounts.get(i.social_account_id), clicks.get(i.id, 0)) for i in items]
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@router.get("/calendar")
|
| 117 |
+
def list_items(ctx: Ctx = Depends(get_ctx), start: datetime | None = None, end: datetime | None = None,
|
| 118 |
+
status: str | None = None, limit: int = 500):
|
| 119 |
+
q = select(CalendarItem).where(CalendarItem.workspace_id == ctx.workspace.id)
|
| 120 |
+
if start:
|
| 121 |
+
q = q.where(CalendarItem.scheduled_at >= start)
|
| 122 |
+
if end:
|
| 123 |
+
q = q.where(CalendarItem.scheduled_at < end)
|
| 124 |
+
if status:
|
| 125 |
+
q = q.where(CalendarItem.status.in_(status.split(",")))
|
| 126 |
+
items = list(ctx.db.scalars(q.order_by(CalendarItem.scheduled_at).limit(min(limit, 1000))).all())
|
| 127 |
+
return _enrich(ctx, items)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
@router.post("/calendar")
|
| 131 |
+
def create_item(body: ItemIn, ctx: Ctx = Depends(get_ctx)):
|
| 132 |
+
account = _account(ctx, body.social_account_id, body.platform)
|
| 133 |
+
_validate_lengths(body.platform, [body.content, *body.thread])
|
| 134 |
+
for model, value in ((Artifact, body.artifact_id), (Document, body.document_id)):
|
| 135 |
+
if value and not ctx.db.scalar(select(model.id).where(model.id == value,
|
| 136 |
+
model.workspace_id == ctx.workspace.id)):
|
| 137 |
+
raise HTTPException(404, f"{model.__name__} not found")
|
| 138 |
+
item = CalendarItem(
|
| 139 |
+
workspace_id=ctx.workspace.id, platform=body.platform, scheduled_at=_aware(body.scheduled_at),
|
| 140 |
+
content=body.content, thread=[t for t in body.thread if t.strip()], artifact_id=body.artifact_id,
|
| 141 |
+
document_id=body.document_id, social_account_id=account.id if account else None,
|
| 142 |
+
remind_by_email=body.remind_by_email or body.platform not in AUTO_POST or not account,
|
| 143 |
+
status="draft" if body.draft else "scheduled",
|
| 144 |
+
)
|
| 145 |
+
ctx.db.add(item)
|
| 146 |
+
ctx.db.commit()
|
| 147 |
+
return _enrich(ctx, [item])[0]
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
@router.patch("/calendar/{item_id}")
|
| 151 |
+
def update_item(item_id: uuid.UUID, body: ItemPatch, ctx: Ctx = Depends(get_ctx)):
|
| 152 |
+
item = _item(ctx, item_id)
|
| 153 |
+
if item.status in ("posted", "publishing"):
|
| 154 |
+
raise HTTPException(409, "This post already went out.")
|
| 155 |
+
if body.scheduled_at is not None:
|
| 156 |
+
item.scheduled_at = _aware(body.scheduled_at)
|
| 157 |
+
if body.content is not None:
|
| 158 |
+
item.content = body.content
|
| 159 |
+
if body.thread is not None:
|
| 160 |
+
item.thread = [t for t in body.thread if t.strip()]
|
| 161 |
+
if "social_account_id" in body.model_fields_set:
|
| 162 |
+
item.social_account_id = (_account(ctx, body.social_account_id, item.platform).id
|
| 163 |
+
if body.social_account_id else None)
|
| 164 |
+
if body.remind_by_email is not None:
|
| 165 |
+
item.remind_by_email = body.remind_by_email
|
| 166 |
+
if not item.social_account_id or item.platform not in AUTO_POST:
|
| 167 |
+
item.remind_by_email = True
|
| 168 |
+
if body.status is not None:
|
| 169 |
+
item.status = body.status
|
| 170 |
+
elif item.status in ("failed", "reminded"):
|
| 171 |
+
item.status = "scheduled"
|
| 172 |
+
item.error = None
|
| 173 |
+
item.attempts = 0
|
| 174 |
+
_validate_lengths(item.platform, [item.content, *(item.thread or [])])
|
| 175 |
+
ctx.db.commit()
|
| 176 |
+
return _enrich(ctx, [item])[0]
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
@router.delete("/calendar/{item_id}")
|
| 180 |
+
def delete_item(item_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 181 |
+
item = _item(ctx, item_id)
|
| 182 |
+
ctx.db.delete(item)
|
| 183 |
+
ctx.db.commit()
|
| 184 |
+
return {"ok": True}
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
@router.post("/calendar/{item_id}/publish")
|
| 188 |
+
async def publish_now(item_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 189 |
+
item = _item(ctx, item_id)
|
| 190 |
+
if item.status in ("posted", "publishing"):
|
| 191 |
+
raise HTTPException(409, "This post already went out.")
|
| 192 |
+
item.status = "publishing"
|
| 193 |
+
item.attempts = 0
|
| 194 |
+
ctx.db.commit()
|
| 195 |
+
await run_in_threadpool(lp.publish_item, ctx.db, item)
|
| 196 |
+
return _enrich(ctx, [item])[0]
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
# Social accounts
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def serialize_account(a: SocialAccount) -> dict:
|
| 203 |
+
return {"id": str(a.id), "platform": a.platform, "platform_label": PLATFORM_LABELS[a.platform],
|
| 204 |
+
"handle": a.handle, "status": a.status, "avatar_url": a.avatar_url,
|
| 205 |
+
"connected_at": a.created_at.isoformat() if a.created_at else None}
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
@router.get("/social/accounts")
|
| 209 |
+
def list_accounts(ctx: Ctx = Depends(get_ctx)):
|
| 210 |
+
accounts = ctx.db.scalars(select(SocialAccount).where(SocialAccount.workspace_id == ctx.workspace.id)
|
| 211 |
+
.order_by(SocialAccount.created_at)).all()
|
| 212 |
+
return {
|
| 213 |
+
"accounts": [serialize_account(a) for a in accounts],
|
| 214 |
+
"available": {"x": x.configured(), "linkedin": linkedin.configured(), "bluesky": True},
|
| 215 |
+
"redirect_uris": {"x": x.redirect_uri(), "linkedin": linkedin.redirect_uri()},
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
@router.delete("/social/accounts/{account_id}")
|
| 220 |
+
def disconnect(account_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 221 |
+
account = ctx.db.scalar(select(SocialAccount).where(SocialAccount.id == account_id,
|
| 222 |
+
SocialAccount.workspace_id == ctx.workspace.id))
|
| 223 |
+
if not account:
|
| 224 |
+
raise HTTPException(404, "Account not found")
|
| 225 |
+
for item in ctx.db.scalars(select(CalendarItem).where(CalendarItem.social_account_id == account.id,
|
| 226 |
+
CalendarItem.status == "scheduled")):
|
| 227 |
+
item.social_account_id = None
|
| 228 |
+
item.remind_by_email = True
|
| 229 |
+
ctx.db.delete(account)
|
| 230 |
+
ctx.db.commit()
|
| 231 |
+
return {"ok": True}
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def _upsert_account(db: Session, workspace_id: uuid.UUID, platform: str, info: dict, secret: str,
|
| 235 |
+
refresh: str | None = None, expires_in: int | None = None, scopes: str | None = None) -> None:
|
| 236 |
+
account = db.scalar(select(SocialAccount).where(SocialAccount.workspace_id == workspace_id,
|
| 237 |
+
SocialAccount.platform == platform,
|
| 238 |
+
SocialAccount.external_id == info["external_id"]))
|
| 239 |
+
if not account:
|
| 240 |
+
account = SocialAccount(workspace_id=workspace_id, platform=platform, external_id=info["external_id"],
|
| 241 |
+
handle=info["handle"], access_token="")
|
| 242 |
+
db.add(account)
|
| 243 |
+
account.handle = info["handle"]
|
| 244 |
+
account.avatar_url = info.get("avatar_url")
|
| 245 |
+
account.access_token = encrypt(secret)
|
| 246 |
+
account.refresh_token = encrypt(refresh) if refresh else account.refresh_token
|
| 247 |
+
account.expires_at = datetime.now(UTC) + timedelta(seconds=int(expires_in)) if expires_in else None
|
| 248 |
+
account.scopes = scopes
|
| 249 |
+
account.status = "active"
|
| 250 |
+
db.commit()
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
class BlueskyIn(BaseModel):
|
| 254 |
+
handle: str = Field(min_length=3, max_length=200)
|
| 255 |
+
app_password: str = Field(min_length=8, max_length=100)
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
@router.post("/social/bluesky")
|
| 259 |
+
async def connect_bluesky(body: BlueskyIn, ctx: Ctx = Depends(get_ctx)):
|
| 260 |
+
try:
|
| 261 |
+
session = await run_in_threadpool(bluesky.create_session, body.handle, body.app_password)
|
| 262 |
+
except SocialError as exc:
|
| 263 |
+
raise HTTPException(400, {"code": "bluesky_auth", "message": str(exc)}) from exc
|
| 264 |
+
_upsert_account(ctx.db, ctx.workspace.id, "bluesky",
|
| 265 |
+
{"external_id": session["did"], "handle": session.get("handle", body.handle)}, body.app_password)
|
| 266 |
+
return list_accounts(ctx)
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
# OAuth (X, LinkedIn). The signed state carries the workspace and PKCE verifier; the callback hands a
|
| 270 |
+
# short lived encrypted ticket back to the SPA, which completes the link as the signed in user.
|
| 271 |
+
|
| 272 |
+
STATE_TTL = timedelta(minutes=10)
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def _launchpad_redirect(**params: str) -> RedirectResponse:
|
| 276 |
+
return RedirectResponse(f"{settings.frontend_url.rstrip('/')}/app/launchpad?{urlencode(params)}", 302)
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
@router.post("/social/{platform}/start")
|
| 280 |
+
def oauth_start(platform: Literal["x", "linkedin"], ctx: Ctx = Depends(get_ctx)):
|
| 281 |
+
"""Returns the provider URL. The SPA calls this with its bearer token, then navigates to `url`."""
|
| 282 |
+
mod = x if platform == "x" else linkedin
|
| 283 |
+
if not mod.configured():
|
| 284 |
+
raise HTTPException(503, {"code": "not_configured",
|
| 285 |
+
"message": f"Set {platform.upper()}_CLIENT_ID and {platform.upper()}_CLIENT_SECRET."})
|
| 286 |
+
nonce = secrets.token_urlsafe(24)
|
| 287 |
+
payload = {"typ": "social_state", "nonce": nonce, "ws": str(ctx.workspace.id), "platform": platform}
|
| 288 |
+
if platform == "x":
|
| 289 |
+
verifier, challenge = x.pkce_pair()
|
| 290 |
+
payload["verifier"] = verifier
|
| 291 |
+
state = encode_signed(payload, STATE_TTL)
|
| 292 |
+
url = x.authorize_url(state, challenge)
|
| 293 |
+
else:
|
| 294 |
+
state = encode_signed(payload, STATE_TTL)
|
| 295 |
+
url = linkedin.authorize_url(state)
|
| 296 |
+
return {"url": url, "nonce": nonce}
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
@router.get("/social/{platform}/callback")
|
| 300 |
+
def oauth_callback(platform: Literal["x", "linkedin"], code: str | None = None, state: str | None = None,
|
| 301 |
+
error: str | None = None, db: Session = Depends(get_db)):
|
| 302 |
+
label = PLATFORM_LABELS[platform]
|
| 303 |
+
if error or not code or not state:
|
| 304 |
+
return _launchpad_redirect(error=f"{label} connection was cancelled.")
|
| 305 |
+
try:
|
| 306 |
+
saved = decode_token_full(state, expected_type="social_state")
|
| 307 |
+
except HTTPException:
|
| 308 |
+
return _launchpad_redirect(error=f"{label} connection expired. Try again.")
|
| 309 |
+
if saved.get("platform") != platform:
|
| 310 |
+
return _launchpad_redirect(error=f"{label} connection failed.")
|
| 311 |
+
try:
|
| 312 |
+
if platform == "x":
|
| 313 |
+
info = x.exchange_code(code, saved["verifier"])
|
| 314 |
+
else:
|
| 315 |
+
info = linkedin.exchange_code(code)
|
| 316 |
+
except Exception:
|
| 317 |
+
return _launchpad_redirect(error=f"{label} connection failed. Try again.")
|
| 318 |
+
# Do not link here: this request carries no session. The signed in SPA finishes the link, and only
|
| 319 |
+
# for the workspace that started it, so a forwarded authorize link cannot attach someone's account
|
| 320 |
+
# to another workspace.
|
| 321 |
+
ticket = encode_signed({"typ": "social_link", "ws": saved["ws"], "platform": platform,
|
| 322 |
+
"data": encrypt(json.dumps(info))}, timedelta(minutes=10))
|
| 323 |
+
return _launchpad_redirect(link=ticket, platform=platform)
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
class LinkIn(BaseModel):
|
| 327 |
+
ticket: str
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
@router.post("/social/complete")
|
| 331 |
+
def oauth_complete(body: LinkIn, ctx: Ctx = Depends(get_ctx)):
|
| 332 |
+
try:
|
| 333 |
+
saved = decode_token_full(body.ticket, expected_type="social_link")
|
| 334 |
+
except HTTPException as exc:
|
| 335 |
+
raise HTTPException(400, {"code": "expired", "message": "That connection expired. Try again."}) from exc
|
| 336 |
+
if saved.get("ws") != str(ctx.workspace.id):
|
| 337 |
+
raise HTTPException(403, {"code": "wrong_workspace", "message": "This connection was started elsewhere."})
|
| 338 |
+
info = json.loads(decrypt(saved["data"]) or "{}")
|
| 339 |
+
if not info.get("access_token"):
|
| 340 |
+
raise HTTPException(400, {"code": "expired", "message": "That connection expired. Try again."})
|
| 341 |
+
_upsert_account(ctx.db, ctx.workspace.id, saved["platform"], info, info["access_token"],
|
| 342 |
+
info.get("refresh_token"), info.get("expires_in"), info.get("scope"))
|
| 343 |
+
return list_accounts(ctx)
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
# Tracked links (public)
|
| 347 |
+
|
| 348 |
+
links_router = APIRouter(tags=["links"])
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
@links_router.get("/l/{slug}")
|
| 352 |
+
def follow_link(slug: str, request: Request, db: Session = Depends(get_db)):
|
| 353 |
+
link = db.scalar(select(TrackedLink).where(TrackedLink.slug == slug))
|
| 354 |
+
if not link:
|
| 355 |
+
raise HTTPException(404, "Link not found")
|
| 356 |
+
db.add(EngagementEvent(workspace_id=link.workspace_id, tracked_link_id=link.id, kind="click",
|
| 357 |
+
metadata_json={"referer": request.headers.get("referer", "")[:300],
|
| 358 |
+
"ua": request.headers.get("user-agent", "")[:200]}))
|
| 359 |
+
db.commit()
|
| 360 |
+
return RedirectResponse(link.target_url, status_code=302)
|
backend/app/routers/notebooks.py
CHANGED
|
@@ -1,18 +1,21 @@
|
|
| 1 |
import asyncio
|
| 2 |
import json
|
| 3 |
import uuid
|
|
|
|
| 4 |
|
| 5 |
from fastapi import APIRouter, Depends, HTTPException
|
| 6 |
from fastapi.responses import StreamingResponse
|
| 7 |
from pydantic import BaseModel, Field
|
| 8 |
-
from sqlalchemy import select
|
| 9 |
|
| 10 |
from app.auth import Ctx, get_ctx
|
| 11 |
from app.config import settings
|
| 12 |
from app.corpus import Corpus
|
| 13 |
from app.llm.provider import provider_name
|
| 14 |
-
from app.models import Chat, Citation, Document, Message, Notebook, NotebookDocument
|
| 15 |
from app.pipeline.research import research
|
|
|
|
|
|
|
| 16 |
from app.services.jobs import record_usage
|
| 17 |
|
| 18 |
router = APIRouter(prefix="/api/notebooks", tags=["notebooks"])
|
|
@@ -21,6 +24,12 @@ router = APIRouter(prefix="/api/notebooks", tags=["notebooks"])
|
|
| 21 |
class NotebookIn(BaseModel):
|
| 22 |
title: str = Field(min_length=1, max_length=300)
|
| 23 |
description: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
|
| 26 |
class DocumentsIn(BaseModel):
|
|
@@ -41,12 +50,38 @@ def _get(ctx: Ctx, notebook_id: uuid.UUID) -> Notebook:
|
|
| 41 |
return nb
|
| 42 |
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
@router.post("")
|
| 45 |
def create_notebook(body: NotebookIn, ctx: Ctx = Depends(get_ctx)):
|
| 46 |
-
nb = Notebook(workspace_id=ctx.workspace.id, title=body.title, description=body.description)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
ctx.db.add(nb)
|
|
|
|
|
|
|
| 48 |
ctx.db.commit()
|
| 49 |
-
return {"id": str(nb.id), "title": nb.title}
|
| 50 |
|
| 51 |
|
| 52 |
@router.get("")
|
|
@@ -54,7 +89,24 @@ def list_notebooks(ctx: Ctx = Depends(get_ctx)):
|
|
| 54 |
rows = ctx.db.scalars(
|
| 55 |
select(Notebook).where(Notebook.workspace_id == ctx.workspace.id).order_by(Notebook.updated_at.desc())
|
| 56 |
).all()
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
|
| 60 |
@router.get("/{notebook_id}")
|
|
@@ -71,29 +123,96 @@ def get_notebook(notebook_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
|
| 71 |
"title": nb.title,
|
| 72 |
"description": nb.description,
|
| 73 |
"summary": nb.summary,
|
| 74 |
-
"documents": [
|
| 75 |
-
{"id": str(d.id), "title": d.title, "url": d.url,
|
| 76 |
-
"published_at": d.published_at.isoformat() if d.published_at else None}
|
| 77 |
-
for d in docs
|
| 78 |
-
],
|
| 79 |
}
|
| 80 |
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
@router.post("/{notebook_id}/documents")
|
| 83 |
def add_documents(notebook_id: uuid.UUID, body: DocumentsIn, ctx: Ctx = Depends(get_ctx)):
|
| 84 |
nb = _get(ctx, notebook_id)
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
)
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
ctx.db.commit()
|
| 96 |
-
return {"
|
| 97 |
|
| 98 |
|
| 99 |
def _sse(event: str, data: dict) -> str:
|
|
@@ -112,6 +231,7 @@ async def chat(notebook_id: uuid.UUID, body: ChatIn, ctx: Ctx = Depends(get_ctx)
|
|
| 112 |
chat_row = Chat(workspace_id=ctx.workspace.id, notebook_id=nb.id, title=body.question[:120])
|
| 113 |
db.add(chat_row)
|
| 114 |
db.flush()
|
|
|
|
| 115 |
db.add(Message(chat_id=chat_row.id, role="user", content=body.question))
|
| 116 |
db.commit()
|
| 117 |
|
|
|
|
| 1 |
import asyncio
|
| 2 |
import json
|
| 3 |
import uuid
|
| 4 |
+
from datetime import UTC, datetime
|
| 5 |
|
| 6 |
from fastapi import APIRouter, Depends, HTTPException
|
| 7 |
from fastapi.responses import StreamingResponse
|
| 8 |
from pydantic import BaseModel, Field
|
| 9 |
+
from sqlalchemy import delete, func, select
|
| 10 |
|
| 11 |
from app.auth import Ctx, get_ctx
|
| 12 |
from app.config import settings
|
| 13 |
from app.corpus import Corpus
|
| 14 |
from app.llm.provider import provider_name
|
| 15 |
+
from app.models import Artifact, Chat, Citation, Document, DocumentTopic, Message, Notebook, NotebookDocument, Topic
|
| 16 |
from app.pipeline.research import research
|
| 17 |
+
from app.routers.sources import serialize_document
|
| 18 |
+
from app.services.artifacts import latest_jobs, serialize_artifact
|
| 19 |
from app.services.jobs import record_usage
|
| 20 |
|
| 21 |
router = APIRouter(prefix="/api/notebooks", tags=["notebooks"])
|
|
|
|
| 24 |
class NotebookIn(BaseModel):
|
| 25 |
title: str = Field(min_length=1, max_length=300)
|
| 26 |
description: str | None = None
|
| 27 |
+
document_ids: list[uuid.UUID] = []
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class NotebookPatch(BaseModel):
|
| 31 |
+
title: str | None = Field(None, min_length=1, max_length=300)
|
| 32 |
+
description: str | None = None
|
| 33 |
|
| 34 |
|
| 35 |
class DocumentsIn(BaseModel):
|
|
|
|
| 50 |
return nb
|
| 51 |
|
| 52 |
|
| 53 |
+
def _add_docs(ctx: Ctx, nb: Notebook, ids: list[uuid.UUID]) -> int:
|
| 54 |
+
valid = set(ctx.db.scalars(
|
| 55 |
+
select(Document.id).where(Document.id.in_(ids), Document.workspace_id == ctx.workspace.id)
|
| 56 |
+
).all())
|
| 57 |
+
existing = set(ctx.db.scalars(select(NotebookDocument.document_id).where(NotebookDocument.notebook_id == nb.id)))
|
| 58 |
+
for doc_id in valid - existing:
|
| 59 |
+
ctx.db.add(NotebookDocument(notebook_id=nb.id, document_id=doc_id))
|
| 60 |
+
return len(valid - existing)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
@router.post("")
|
| 64 |
def create_notebook(body: NotebookIn, ctx: Ctx = Depends(get_ctx)):
|
| 65 |
+
nb = Notebook(workspace_id=ctx.workspace.id, title=body.title.strip(), description=body.description)
|
| 66 |
+
ctx.db.add(nb)
|
| 67 |
+
ctx.db.flush()
|
| 68 |
+
added = _add_docs(ctx, nb, body.document_ids)
|
| 69 |
+
ctx.db.commit()
|
| 70 |
+
return {"id": str(nb.id), "title": nb.title, "added": added}
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@router.post("/from-topic/{topic_id}")
|
| 74 |
+
def notebook_from_topic(topic_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 75 |
+
topic = ctx.db.scalar(select(Topic).where(Topic.id == topic_id, Topic.workspace_id == ctx.workspace.id))
|
| 76 |
+
if not topic:
|
| 77 |
+
raise HTTPException(404, "Topic not found")
|
| 78 |
+
ids = list(ctx.db.scalars(select(DocumentTopic.document_id).where(DocumentTopic.topic_id == topic.id)))
|
| 79 |
+
nb = Notebook(workspace_id=ctx.workspace.id, title=topic.name, description=topic.summary)
|
| 80 |
ctx.db.add(nb)
|
| 81 |
+
ctx.db.flush()
|
| 82 |
+
added = _add_docs(ctx, nb, ids)
|
| 83 |
ctx.db.commit()
|
| 84 |
+
return {"id": str(nb.id), "title": nb.title, "added": added}
|
| 85 |
|
| 86 |
|
| 87 |
@router.get("")
|
|
|
|
| 89 |
rows = ctx.db.scalars(
|
| 90 |
select(Notebook).where(Notebook.workspace_id == ctx.workspace.id).order_by(Notebook.updated_at.desc())
|
| 91 |
).all()
|
| 92 |
+
ids = [n.id for n in rows]
|
| 93 |
+
doc_counts = dict(ctx.db.execute(
|
| 94 |
+
select(NotebookDocument.notebook_id, func.count()).where(NotebookDocument.notebook_id.in_(ids))
|
| 95 |
+
.group_by(NotebookDocument.notebook_id)
|
| 96 |
+
).all()) if ids else {}
|
| 97 |
+
chat_counts = dict(ctx.db.execute(
|
| 98 |
+
select(Chat.notebook_id, func.count()).where(Chat.notebook_id.in_(ids)).group_by(Chat.notebook_id)
|
| 99 |
+
).all()) if ids else {}
|
| 100 |
+
artifact_counts = dict(ctx.db.execute(
|
| 101 |
+
select(Artifact.notebook_id, func.count()).where(Artifact.notebook_id.in_(ids)).group_by(Artifact.notebook_id)
|
| 102 |
+
).all()) if ids else {}
|
| 103 |
+
return [
|
| 104 |
+
{"id": str(n.id), "title": n.title, "description": n.description, "summary": n.summary,
|
| 105 |
+
"document_count": doc_counts.get(n.id, 0), "chat_count": chat_counts.get(n.id, 0),
|
| 106 |
+
"artifact_count": artifact_counts.get(n.id, 0),
|
| 107 |
+
"updated_at": n.updated_at.isoformat() if n.updated_at else None}
|
| 108 |
+
for n in rows
|
| 109 |
+
]
|
| 110 |
|
| 111 |
|
| 112 |
@router.get("/{notebook_id}")
|
|
|
|
| 123 |
"title": nb.title,
|
| 124 |
"description": nb.description,
|
| 125 |
"summary": nb.summary,
|
| 126 |
+
"documents": [serialize_document(d) for d in docs],
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
}
|
| 128 |
|
| 129 |
|
| 130 |
+
@router.patch("/{notebook_id}")
|
| 131 |
+
def patch_notebook(notebook_id: uuid.UUID, body: NotebookPatch, ctx: Ctx = Depends(get_ctx)):
|
| 132 |
+
nb = _get(ctx, notebook_id)
|
| 133 |
+
if body.title is not None:
|
| 134 |
+
nb.title = body.title.strip()
|
| 135 |
+
if body.description is not None:
|
| 136 |
+
nb.description = body.description
|
| 137 |
+
ctx.db.commit()
|
| 138 |
+
return {"id": str(nb.id), "title": nb.title, "description": nb.description}
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
@router.delete("/{notebook_id}")
|
| 142 |
+
def delete_notebook(notebook_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 143 |
+
nb = _get(ctx, notebook_id)
|
| 144 |
+
ctx.db.execute(delete(NotebookDocument).where(NotebookDocument.notebook_id == nb.id))
|
| 145 |
+
ctx.db.delete(nb)
|
| 146 |
+
ctx.db.commit()
|
| 147 |
+
return {"ok": True}
|
| 148 |
+
|
| 149 |
+
|
| 150 |
@router.post("/{notebook_id}/documents")
|
| 151 |
def add_documents(notebook_id: uuid.UUID, body: DocumentsIn, ctx: Ctx = Depends(get_ctx)):
|
| 152 |
nb = _get(ctx, notebook_id)
|
| 153 |
+
added = _add_docs(ctx, nb, body.document_ids)
|
| 154 |
+
ctx.db.commit()
|
| 155 |
+
return {"added": added}
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
@router.delete("/{notebook_id}/documents/{document_id}")
|
| 159 |
+
def remove_document(notebook_id: uuid.UUID, document_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 160 |
+
nb = _get(ctx, notebook_id)
|
| 161 |
+
ctx.db.execute(delete(NotebookDocument).where(NotebookDocument.notebook_id == nb.id,
|
| 162 |
+
NotebookDocument.document_id == document_id))
|
| 163 |
+
ctx.db.commit()
|
| 164 |
+
return {"ok": True}
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
@router.get("/{notebook_id}/artifacts")
|
| 168 |
+
def notebook_artifacts(notebook_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 169 |
+
nb = _get(ctx, notebook_id)
|
| 170 |
+
rows = list(ctx.db.scalars(
|
| 171 |
+
select(Artifact).where(Artifact.notebook_id == nb.id).order_by(Artifact.created_at.desc())
|
| 172 |
+
).all())
|
| 173 |
+
jobs = latest_jobs(ctx.db, [a.id for a in rows])
|
| 174 |
+
return [serialize_artifact(a, jobs.get(a.id)) for a in rows]
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
@router.get("/{notebook_id}/chats")
|
| 178 |
+
def list_chats(notebook_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 179 |
+
nb = _get(ctx, notebook_id)
|
| 180 |
+
chats = ctx.db.scalars(select(Chat).where(Chat.notebook_id == nb.id).order_by(Chat.updated_at.desc())).all()
|
| 181 |
+
return [{"id": str(c.id), "title": c.title, "updated_at": c.updated_at.isoformat() if c.updated_at else None}
|
| 182 |
+
for c in chats]
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def _get_chat(ctx: Ctx, chat_id: uuid.UUID) -> Chat:
|
| 186 |
+
chat = ctx.db.scalar(select(Chat).where(Chat.id == chat_id, Chat.workspace_id == ctx.workspace.id))
|
| 187 |
+
if not chat:
|
| 188 |
+
raise HTTPException(404, "Chat not found")
|
| 189 |
+
return chat
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
@router.get("/chats/{chat_id}/messages")
|
| 193 |
+
def chat_messages(chat_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 194 |
+
chat = _get_chat(ctx, chat_id)
|
| 195 |
+
msgs = ctx.db.scalars(select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at)).all()
|
| 196 |
+
doc_ids = {c.document_id for m in msgs for c in m.citations}
|
| 197 |
+
docs = {d.id: d for d in ctx.db.scalars(select(Document).where(Document.id.in_(doc_ids)))} if doc_ids else {}
|
| 198 |
+
out = []
|
| 199 |
+
for m in msgs:
|
| 200 |
+
cites = []
|
| 201 |
+
for c in sorted(m.citations, key=lambda c: c.marker):
|
| 202 |
+
d = docs.get(c.document_id)
|
| 203 |
+
cites.append({"marker": c.marker, "document_id": str(c.document_id), "title": d.title if d else "",
|
| 204 |
+
"url": d.url if d else "", "path": c.path, "line_start": c.line_start,
|
| 205 |
+
"line_end": c.line_end, "span": c.span or ""})
|
| 206 |
+
out.append({"id": str(m.id), "role": m.role, "text": m.content, "citations": cites})
|
| 207 |
+
return out
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
@router.delete("/chats/{chat_id}")
|
| 211 |
+
def delete_chat(chat_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 212 |
+
chat = _get_chat(ctx, chat_id)
|
| 213 |
+
ctx.db.delete(chat)
|
| 214 |
ctx.db.commit()
|
| 215 |
+
return {"ok": True}
|
| 216 |
|
| 217 |
|
| 218 |
def _sse(event: str, data: dict) -> str:
|
|
|
|
| 231 |
chat_row = Chat(workspace_id=ctx.workspace.id, notebook_id=nb.id, title=body.question[:120])
|
| 232 |
db.add(chat_row)
|
| 233 |
db.flush()
|
| 234 |
+
chat_row.updated_at = datetime.now(UTC) # keeps the history list newest first
|
| 235 |
db.add(Message(chat_id=chat_row.id, role="user", content=body.question))
|
| 236 |
db.commit()
|
| 237 |
|
backend/app/routers/resurface.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import UTC, datetime, timedelta
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Depends
|
| 4 |
+
from sqlalchemy import select
|
| 5 |
+
|
| 6 |
+
from app.auth import Ctx, get_ctx
|
| 7 |
+
from app.models import Document, Source
|
| 8 |
+
from app.routers.sources import serialize_document
|
| 9 |
+
from app.services.jobs import create_job, serialize_job
|
| 10 |
+
|
| 11 |
+
router = APIRouter(prefix="/api/resurface", tags=["resurface"])
|
| 12 |
+
|
| 13 |
+
COOLDOWN = timedelta(days=90)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _aware(dt: datetime | None) -> datetime | None:
|
| 17 |
+
return dt if dt is None or dt.tzinfo else dt.replace(tzinfo=UTC)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@router.get("")
|
| 21 |
+
def suggestions(ctx: Ctx = Depends(get_ctx), limit: int = 20):
|
| 22 |
+
now = datetime.now(UTC)
|
| 23 |
+
rows = ctx.db.execute(
|
| 24 |
+
select(Document, Source).outerjoin(Source, Source.id == Document.source_id)
|
| 25 |
+
.where(Document.workspace_id == ctx.workspace.id, Document.path.is_not(None))
|
| 26 |
+
).all()
|
| 27 |
+
evergreen, on_this_day = [], []
|
| 28 |
+
unscored = 0
|
| 29 |
+
for d, s in rows:
|
| 30 |
+
published = _aware(d.published_at)
|
| 31 |
+
resurfaced = _aware(d.last_resurfaced_at)
|
| 32 |
+
meta = d.metadata_json or {}
|
| 33 |
+
item = {**serialize_document(d, s), "reason": meta.get("evergreen_reason"), "angle": meta.get("reshare_angle"),
|
| 34 |
+
"last_resurfaced_at": resurfaced.isoformat() if resurfaced else None}
|
| 35 |
+
if published and published.month == now.month and published.day == now.day and published.year < now.year:
|
| 36 |
+
on_this_day.append({**item, "years_ago": now.year - published.year})
|
| 37 |
+
if d.evergreen_score is None:
|
| 38 |
+
unscored += 1
|
| 39 |
+
continue
|
| 40 |
+
if resurfaced and now - resurfaced < COOLDOWN:
|
| 41 |
+
continue
|
| 42 |
+
age_days = (now - published).days if published else 365
|
| 43 |
+
# Old, timeless and not recently reshared ranks highest; brand new posts do not need resurfacing.
|
| 44 |
+
age_factor = min(age_days / 365, 2.0) if age_days > 30 else 0.1
|
| 45 |
+
evergreen.append({**item, "rank": round(d.evergreen_score * (0.5 + age_factor / 2), 3)})
|
| 46 |
+
evergreen.sort(key=lambda x: -x["rank"])
|
| 47 |
+
return {"evergreen": evergreen[: min(limit, 100)], "on_this_day": on_this_day, "unscored": unscored,
|
| 48 |
+
"total_posts": len(rows)}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@router.post("/scan")
|
| 52 |
+
def scan(ctx: Ctx = Depends(get_ctx)):
|
| 53 |
+
return serialize_job(create_job(ctx.db, ctx.workspace.id, "resurface_scan", {}, max_attempts=2))
|
backend/app/routers/settings.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 4 |
+
from pydantic import BaseModel, Field
|
| 5 |
+
from sqlalchemy import select
|
| 6 |
+
|
| 7 |
+
from app.auth import Ctx, get_ctx
|
| 8 |
+
from app.config import settings as app_settings
|
| 9 |
+
from app.models import Upload
|
| 10 |
+
from app.services.plans import effective_plan, plan_dict
|
| 11 |
+
from app.services.storage import storage
|
| 12 |
+
from app.services.usage import usage_report
|
| 13 |
+
|
| 14 |
+
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class SettingsIn(BaseModel):
|
| 18 |
+
name: str | None = Field(None, max_length=200)
|
| 19 |
+
workspace_name: str | None = Field(None, min_length=1, max_length=200)
|
| 20 |
+
brand_name: str | None = Field(None, max_length=120)
|
| 21 |
+
brand_accent: str | None = None
|
| 22 |
+
logo_upload_id: str | None = None # "" removes the logo
|
| 23 |
+
training_opt_in: bool | None = None
|
| 24 |
+
email_unsubscribed: bool | None = None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _serialize(ctx: Ctx) -> dict:
|
| 28 |
+
brand = ctx.workspace.brand_json or {}
|
| 29 |
+
return {
|
| 30 |
+
"user": {"name": ctx.user.name, "email": ctx.user.email, "auth_provider": ctx.user.auth_provider.value,
|
| 31 |
+
"email_unsubscribed": ctx.user.email_unsubscribed},
|
| 32 |
+
"workspace": {"id": str(ctx.workspace.id), "name": ctx.workspace.name,
|
| 33 |
+
"training_opt_in": ctx.workspace.training_opt_in},
|
| 34 |
+
"brand": {"name": brand.get("name"), "accent": brand.get("accent") or "#217cff",
|
| 35 |
+
"logo_url": storage.presign_get(brand["logo_key"]) if brand.get("logo_key") else None},
|
| 36 |
+
"plan": plan_dict(effective_plan(ctx.db, ctx.workspace)),
|
| 37 |
+
"billing_enabled": app_settings.billing_enabled,
|
| 38 |
+
"usage": usage_report(ctx.db, ctx.workspace),
|
| 39 |
+
"integrations": {"llm": bool(app_settings.llm_api_key), "elevenlabs": bool(app_settings.elevenlabs_api_key),
|
| 40 |
+
"x": bool(app_settings.x_client_id), "linkedin": bool(app_settings.linkedin_client_id),
|
| 41 |
+
"storage": "local" if app_settings.use_local_storage else "r2",
|
| 42 |
+
"renderer": app_settings.renderer_url},
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@router.get("")
|
| 47 |
+
def get_settings(ctx: Ctx = Depends(get_ctx)):
|
| 48 |
+
return _serialize(ctx)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@router.patch("")
|
| 52 |
+
def update_settings(body: SettingsIn, ctx: Ctx = Depends(get_ctx)):
|
| 53 |
+
if body.name is not None:
|
| 54 |
+
ctx.user.name = body.name.strip() or None
|
| 55 |
+
if body.workspace_name is not None:
|
| 56 |
+
ctx.workspace.name = body.workspace_name.strip()
|
| 57 |
+
if body.training_opt_in is not None:
|
| 58 |
+
ctx.workspace.training_opt_in = body.training_opt_in
|
| 59 |
+
if body.email_unsubscribed is not None:
|
| 60 |
+
ctx.user.email_unsubscribed = body.email_unsubscribed
|
| 61 |
+
brand = dict(ctx.workspace.brand_json or {})
|
| 62 |
+
if body.brand_name is not None:
|
| 63 |
+
brand["name"] = body.brand_name.strip() or None
|
| 64 |
+
if body.brand_accent is not None:
|
| 65 |
+
if not re.fullmatch(r"#[0-9a-fA-F]{6}", body.brand_accent):
|
| 66 |
+
raise HTTPException(422, "Use a hex color like #217cff")
|
| 67 |
+
brand["accent"] = body.brand_accent.lower()
|
| 68 |
+
if body.logo_upload_id is not None:
|
| 69 |
+
if body.logo_upload_id == "":
|
| 70 |
+
brand.pop("logo_key", None)
|
| 71 |
+
else:
|
| 72 |
+
upload = ctx.db.scalar(select(Upload).where(Upload.id == body.logo_upload_id,
|
| 73 |
+
Upload.workspace_id == ctx.workspace.id))
|
| 74 |
+
if not upload or upload.status != "complete" or not upload.content_type.startswith("image/"):
|
| 75 |
+
raise HTTPException(400, "Upload an image first")
|
| 76 |
+
brand["logo_key"] = upload.key
|
| 77 |
+
ctx.workspace.brand_json = brand
|
| 78 |
+
ctx.db.commit()
|
| 79 |
+
return _serialize(ctx)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@router.get("/usage")
|
| 83 |
+
def usage(ctx: Ctx = Depends(get_ctx)):
|
| 84 |
+
return usage_report(ctx.db, ctx.workspace)
|
backend/app/routers/sources.py
CHANGED
|
@@ -1,57 +1,85 @@
|
|
| 1 |
import uuid
|
| 2 |
|
| 3 |
from fastapi import APIRouter, Depends, HTTPException
|
| 4 |
-
from
|
| 5 |
-
from
|
|
|
|
| 6 |
|
| 7 |
from app.auth import Ctx, get_ctx
|
| 8 |
-
from app.
|
| 9 |
-
from app.
|
|
|
|
| 10 |
from app.services.jobs import create_job, serialize_job
|
| 11 |
from app.services.plans import effective_plan
|
|
|
|
| 12 |
|
| 13 |
router = APIRouter(prefix="/api/sources", tags=["sources"])
|
| 14 |
|
| 15 |
|
| 16 |
class SourceIn(BaseModel):
|
| 17 |
-
url: str
|
| 18 |
|
| 19 |
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
return {
|
| 22 |
"id": str(source.id),
|
| 23 |
"feed_url": source.feed_url,
|
|
|
|
| 24 |
"platform": source.platform,
|
| 25 |
"title": source.title,
|
| 26 |
"sync_status": source.sync_status,
|
| 27 |
"sync_error": source.sync_error,
|
| 28 |
"last_synced_at": source.last_synced_at.isoformat() if source.last_synced_at else None,
|
| 29 |
"document_count": doc_count,
|
|
|
|
| 30 |
}
|
| 31 |
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
def _enqueue_sync(ctx: Ctx, source: Source) -> Job:
|
|
|
|
| 34 |
return create_job(ctx.db, ctx.workspace.id, "ingest", {"source_id": str(source.id)})
|
| 35 |
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
@router.post("")
|
| 38 |
-
def create_source(body: SourceIn, ctx: Ctx = Depends(get_ctx)):
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
| 40 |
existing = ctx.db.scalar(
|
| 41 |
-
select(Source).where(Source.workspace_id == ctx.workspace.id, Source.feed_url == feed_url)
|
| 42 |
)
|
| 43 |
if existing:
|
| 44 |
job = _enqueue_sync(ctx, existing)
|
| 45 |
-
return {"source":
|
| 46 |
plan = effective_plan(ctx.db, ctx.workspace)
|
| 47 |
-
count = ctx.db.scalar(
|
|
|
|
|
|
|
|
|
|
| 48 |
if count >= plan.sources:
|
| 49 |
raise HTTPException(402, {"code": "plan_limit", "message": f"Your plan includes {plan.sources} sources."})
|
| 50 |
-
source = Source(workspace_id=ctx.workspace.id, feed_url=feed_url, platform=platform,
|
|
|
|
| 51 |
ctx.db.add(source)
|
| 52 |
ctx.db.commit()
|
| 53 |
job = _enqueue_sync(ctx, source)
|
| 54 |
-
return {"source":
|
| 55 |
|
| 56 |
|
| 57 |
@router.get("")
|
|
@@ -63,33 +91,132 @@ def list_sources(ctx: Ctx = Depends(get_ctx)):
|
|
| 63 |
.group_by(Source.id)
|
| 64 |
.order_by(Source.created_at)
|
| 65 |
).all()
|
| 66 |
-
return [
|
| 67 |
|
| 68 |
|
| 69 |
-
@router.
|
| 70 |
def sync_source(source_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 71 |
-
source =
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
if not source:
|
| 75 |
-
raise HTTPException(404, "Source not found")
|
| 76 |
job = _enqueue_sync(ctx, source)
|
| 77 |
-
return {"source":
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
|
| 80 |
documents_router = APIRouter(prefix="/api/documents", tags=["documents"])
|
| 81 |
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
@documents_router.get("")
|
| 84 |
-
def list_documents(
|
| 85 |
-
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
.where(Document.workspace_id == ctx.workspace.id)
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
).all()
|
| 91 |
-
return [
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import uuid
|
| 2 |
|
| 3 |
from fastapi import APIRouter, Depends, HTTPException
|
| 4 |
+
from fastapi.concurrency import run_in_threadpool
|
| 5 |
+
from pydantic import BaseModel, Field
|
| 6 |
+
from sqlalchemy import delete, func, or_, select
|
| 7 |
|
| 8 |
from app.auth import Ctx, get_ctx
|
| 9 |
+
from app.corpus import INDEX, Corpus, CorpusError
|
| 10 |
+
from app.models import Document, Job, NotebookDocument, Source, Upload
|
| 11 |
+
from app.pipeline.ingest import IMPORTS_FEED, FeedNotFound, discover_feed, imports_source, rebuild_index
|
| 12 |
from app.services.jobs import create_job, serialize_job
|
| 13 |
from app.services.plans import effective_plan
|
| 14 |
+
from app.services.storage import storage
|
| 15 |
|
| 16 |
router = APIRouter(prefix="/api/sources", tags=["sources"])
|
| 17 |
|
| 18 |
|
| 19 |
class SourceIn(BaseModel):
|
| 20 |
+
url: str = Field(min_length=3, max_length=1000)
|
| 21 |
|
| 22 |
|
| 23 |
+
class UploadImportIn(BaseModel):
|
| 24 |
+
upload_id: uuid.UUID
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def serialize_source(source: Source, doc_count: int = 0) -> dict:
|
| 28 |
return {
|
| 29 |
"id": str(source.id),
|
| 30 |
"feed_url": source.feed_url,
|
| 31 |
+
"site_url": source.site_url,
|
| 32 |
"platform": source.platform,
|
| 33 |
"title": source.title,
|
| 34 |
"sync_status": source.sync_status,
|
| 35 |
"sync_error": source.sync_error,
|
| 36 |
"last_synced_at": source.last_synced_at.isoformat() if source.last_synced_at else None,
|
| 37 |
"document_count": doc_count,
|
| 38 |
+
"is_imports": source.feed_url == IMPORTS_FEED,
|
| 39 |
}
|
| 40 |
|
| 41 |
|
| 42 |
+
def _get(ctx: Ctx, source_id: uuid.UUID) -> Source:
|
| 43 |
+
source = ctx.db.scalar(select(Source).where(Source.id == source_id, Source.workspace_id == ctx.workspace.id))
|
| 44 |
+
if not source:
|
| 45 |
+
raise HTTPException(404, "Source not found")
|
| 46 |
+
return source
|
| 47 |
+
|
| 48 |
+
|
| 49 |
def _enqueue_sync(ctx: Ctx, source: Source) -> Job:
|
| 50 |
+
source.sync_status = "pending"
|
| 51 |
return create_job(ctx.db, ctx.workspace.id, "ingest", {"source_id": str(source.id)})
|
| 52 |
|
| 53 |
|
| 54 |
+
def _doc_count(ctx: Ctx, source: Source) -> int:
|
| 55 |
+
return ctx.db.scalar(select(func.count()).select_from(Document).where(Document.source_id == source.id)) or 0
|
| 56 |
+
|
| 57 |
+
|
| 58 |
@router.post("")
|
| 59 |
+
async def create_source(body: SourceIn, ctx: Ctx = Depends(get_ctx)):
|
| 60 |
+
try:
|
| 61 |
+
found = await run_in_threadpool(discover_feed, body.url)
|
| 62 |
+
except FeedNotFound as exc:
|
| 63 |
+
raise HTTPException(422, {"code": "feed_not_found", "message": str(exc)}) from exc
|
| 64 |
existing = ctx.db.scalar(
|
| 65 |
+
select(Source).where(Source.workspace_id == ctx.workspace.id, Source.feed_url == found.feed_url)
|
| 66 |
)
|
| 67 |
if existing:
|
| 68 |
job = _enqueue_sync(ctx, existing)
|
| 69 |
+
return {"source": serialize_source(existing, _doc_count(ctx, existing)), "job": serialize_job(job)}
|
| 70 |
plan = effective_plan(ctx.db, ctx.workspace)
|
| 71 |
+
count = ctx.db.scalar(
|
| 72 |
+
select(func.count()).select_from(Source)
|
| 73 |
+
.where(Source.workspace_id == ctx.workspace.id, Source.feed_url != IMPORTS_FEED)
|
| 74 |
+
)
|
| 75 |
if count >= plan.sources:
|
| 76 |
raise HTTPException(402, {"code": "plan_limit", "message": f"Your plan includes {plan.sources} sources."})
|
| 77 |
+
source = Source(workspace_id=ctx.workspace.id, feed_url=found.feed_url, platform=found.platform,
|
| 78 |
+
site_url=found.site_url, title=found.title)
|
| 79 |
ctx.db.add(source)
|
| 80 |
ctx.db.commit()
|
| 81 |
job = _enqueue_sync(ctx, source)
|
| 82 |
+
return {"source": serialize_source(source), "job": serialize_job(job)}
|
| 83 |
|
| 84 |
|
| 85 |
@router.get("")
|
|
|
|
| 91 |
.group_by(Source.id)
|
| 92 |
.order_by(Source.created_at)
|
| 93 |
).all()
|
| 94 |
+
return [serialize_source(s, n) for s, n in rows]
|
| 95 |
|
| 96 |
|
| 97 |
+
@router.post("/{source_id}/sync")
|
| 98 |
def sync_source(source_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 99 |
+
source = _get(ctx, source_id)
|
| 100 |
+
if source.feed_url == IMPORTS_FEED:
|
| 101 |
+
raise HTTPException(400, "Imported posts have no feed to sync.")
|
|
|
|
|
|
|
| 102 |
job = _enqueue_sync(ctx, source)
|
| 103 |
+
return {"source": serialize_source(source, _doc_count(ctx, source)), "job": serialize_job(job)}
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def remove_documents(ctx: Ctx, docs: list[Document]) -> None:
|
| 107 |
+
"""Delete documents plus their corpus files and raw HTML, then rebuild INDEX.md."""
|
| 108 |
+
if not docs:
|
| 109 |
+
return
|
| 110 |
+
paths = {d.path for d in docs if d.path}
|
| 111 |
+
for d in docs:
|
| 112 |
+
if d.raw_html_key:
|
| 113 |
+
storage.delete_prefix(d.raw_html_key.rsplit("/", 1)[0])
|
| 114 |
+
ids = [d.id for d in docs]
|
| 115 |
+
ctx.db.execute(delete(NotebookDocument).where(NotebookDocument.document_id.in_(ids)))
|
| 116 |
+
ctx.db.execute(delete(Document).where(Document.id.in_(ids)))
|
| 117 |
+
ctx.db.flush()
|
| 118 |
+
Corpus(ctx.workspace.id).write_files({INDEX: rebuild_index(ctx.db, ctx.workspace.id)}, remove=paths)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
@router.delete("/{source_id}")
|
| 122 |
+
def delete_source(source_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 123 |
+
source = _get(ctx, source_id)
|
| 124 |
+
docs = ctx.db.scalars(select(Document).where(Document.source_id == source.id)).all()
|
| 125 |
+
remove_documents(ctx, list(docs))
|
| 126 |
+
ctx.db.delete(source)
|
| 127 |
+
ctx.db.commit()
|
| 128 |
+
return {"ok": True, "removed_posts": len(docs)}
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
@router.post("/url")
|
| 132 |
+
def import_url(body: SourceIn, ctx: Ctx = Depends(get_ctx)):
|
| 133 |
+
source = imports_source(ctx.db, ctx.workspace.id)
|
| 134 |
+
ctx.db.commit()
|
| 135 |
+
job = create_job(ctx.db, ctx.workspace.id, "import_url", {"source_id": str(source.id), "url": body.url})
|
| 136 |
+
return {"source": serialize_source(source, _doc_count(ctx, source)), "job": serialize_job(job)}
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
@router.post("/upload")
|
| 140 |
+
def import_upload(body: UploadImportIn, ctx: Ctx = Depends(get_ctx)):
|
| 141 |
+
upload = ctx.db.scalar(select(Upload).where(Upload.id == body.upload_id, Upload.workspace_id == ctx.workspace.id))
|
| 142 |
+
if not upload or upload.status != "complete":
|
| 143 |
+
raise HTTPException(404, "Upload not found or not finished")
|
| 144 |
+
source = imports_source(ctx.db, ctx.workspace.id)
|
| 145 |
+
ctx.db.commit()
|
| 146 |
+
job = create_job(ctx.db, ctx.workspace.id, "import_upload",
|
| 147 |
+
{"source_id": str(source.id), "upload_id": str(upload.id)})
|
| 148 |
+
return {"source": serialize_source(source, _doc_count(ctx, source)), "job": serialize_job(job)}
|
| 149 |
|
| 150 |
|
| 151 |
documents_router = APIRouter(prefix="/api/documents", tags=["documents"])
|
| 152 |
|
| 153 |
|
| 154 |
+
def serialize_document(d: Document, source: Source | None = None) -> dict:
|
| 155 |
+
meta = d.metadata_json or {}
|
| 156 |
+
return {
|
| 157 |
+
"id": str(d.id),
|
| 158 |
+
"title": d.title,
|
| 159 |
+
"url": d.url,
|
| 160 |
+
"path": d.path,
|
| 161 |
+
"source_id": str(d.source_id) if d.source_id else None,
|
| 162 |
+
"source_title": source.title if source else None,
|
| 163 |
+
"published_at": d.published_at.isoformat() if d.published_at else None,
|
| 164 |
+
"words": meta.get("words") or len((d.clean_text or "").split()),
|
| 165 |
+
"evergreen_score": d.evergreen_score,
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
|
| 169 |
@documents_router.get("")
|
| 170 |
+
def list_documents(
|
| 171 |
+
ctx: Ctx = Depends(get_ctx),
|
| 172 |
+
q: str = "",
|
| 173 |
+
source_id: uuid.UUID | None = None,
|
| 174 |
+
limit: int = 500,
|
| 175 |
+
offset: int = 0,
|
| 176 |
+
):
|
| 177 |
+
query = (
|
| 178 |
+
select(Document, Source)
|
| 179 |
+
.outerjoin(Source, Source.id == Document.source_id)
|
| 180 |
.where(Document.workspace_id == ctx.workspace.id)
|
| 181 |
+
)
|
| 182 |
+
if q.strip():
|
| 183 |
+
like = f"%{q.strip()}%"
|
| 184 |
+
query = query.where(or_(Document.title.ilike(like), Document.clean_text.ilike(like)))
|
| 185 |
+
if source_id:
|
| 186 |
+
query = query.where(Document.source_id == source_id)
|
| 187 |
+
total = ctx.db.scalar(select(func.count()).select_from(query.subquery()))
|
| 188 |
+
rows = ctx.db.execute(
|
| 189 |
+
query.order_by(Document.published_at.desc().nulls_last(), Document.created_at.desc())
|
| 190 |
+
.offset(max(offset, 0)).limit(min(limit, 5000))
|
| 191 |
).all()
|
| 192 |
+
return {"total": total, "items": [serialize_document(d, s) for d, s in rows]}
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
@documents_router.get("/{document_id}")
|
| 196 |
+
def get_document(document_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 197 |
+
doc = ctx.db.scalar(select(Document).where(Document.id == document_id, Document.workspace_id == ctx.workspace.id))
|
| 198 |
+
if not doc:
|
| 199 |
+
raise HTTPException(404, "Post not found")
|
| 200 |
+
source = ctx.db.get(Source, doc.source_id) if doc.source_id else None
|
| 201 |
+
lines: list[str] = []
|
| 202 |
+
if doc.path:
|
| 203 |
+
corpus = Corpus(ctx.workspace.id)
|
| 204 |
+
try:
|
| 205 |
+
if not corpus.exists(doc.path):
|
| 206 |
+
corpus.sync()
|
| 207 |
+
lines = corpus.read_lines(doc.path)
|
| 208 |
+
except CorpusError:
|
| 209 |
+
lines = []
|
| 210 |
+
if not lines:
|
| 211 |
+
lines = [f"# {doc.title}", "", *(doc.clean_text or "").splitlines()]
|
| 212 |
+
return {**serialize_document(doc, source), "lines": lines}
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
@documents_router.delete("/{document_id}")
|
| 216 |
+
def delete_document(document_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 217 |
+
doc = ctx.db.scalar(select(Document).where(Document.id == document_id, Document.workspace_id == ctx.workspace.id))
|
| 218 |
+
if not doc:
|
| 219 |
+
raise HTTPException(404, "Post not found")
|
| 220 |
+
remove_documents(ctx, [doc])
|
| 221 |
+
ctx.db.commit()
|
| 222 |
+
return {"ok": True}
|
backend/app/routers/storage.py
CHANGED
|
@@ -1,16 +1,17 @@
|
|
| 1 |
"""Browser uploads go straight to R2 with presigned PUTs; reads redirect to presigned GETs."""
|
| 2 |
|
|
|
|
| 3 |
import uuid
|
| 4 |
|
| 5 |
-
from fastapi import APIRouter, Depends, HTTPException
|
| 6 |
-
from fastapi.responses import RedirectResponse
|
| 7 |
from pydantic import BaseModel, Field
|
| 8 |
from sqlalchemy import select
|
| 9 |
|
| 10 |
from app.auth import Ctx, get_ctx
|
| 11 |
from app.config import settings
|
| 12 |
from app.models import Upload
|
| 13 |
-
from app.services.storage import keys, storage, workspace_owns_key
|
| 14 |
|
| 15 |
router = APIRouter(prefix="/api/storage", tags=["storage"])
|
| 16 |
|
|
@@ -23,6 +24,7 @@ ALLOWED_TYPES = {
|
|
| 23 |
"audio/wav",
|
| 24 |
"audio/webm",
|
| 25 |
"audio/mp4",
|
|
|
|
| 26 |
"video/mp4",
|
| 27 |
"application/pdf",
|
| 28 |
"text/markdown",
|
|
@@ -89,3 +91,38 @@ def read_object(key: str, download: bool = False, ctx: Ctx = Depends(get_ctx)):
|
|
| 89 |
raise HTTPException(404, "Not found")
|
| 90 |
name = key.rsplit("/", 1)[-1] if download else None
|
| 91 |
return RedirectResponse(storage.presign_get(key, download_name=name), status_code=302)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""Browser uploads go straight to R2 with presigned PUTs; reads redirect to presigned GETs."""
|
| 2 |
|
| 3 |
+
import mimetypes
|
| 4 |
import uuid
|
| 5 |
|
| 6 |
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 7 |
+
from fastapi.responses import FileResponse, RedirectResponse
|
| 8 |
from pydantic import BaseModel, Field
|
| 9 |
from sqlalchemy import select
|
| 10 |
|
| 11 |
from app.auth import Ctx, get_ctx
|
| 12 |
from app.config import settings
|
| 13 |
from app.models import Upload
|
| 14 |
+
from app.services.storage import LocalStorage, keys, safe_filename, storage, verify_local_sig, workspace_owns_key
|
| 15 |
|
| 16 |
router = APIRouter(prefix="/api/storage", tags=["storage"])
|
| 17 |
|
|
|
|
| 24 |
"audio/wav",
|
| 25 |
"audio/webm",
|
| 26 |
"audio/mp4",
|
| 27 |
+
"audio/ogg",
|
| 28 |
"video/mp4",
|
| 29 |
"application/pdf",
|
| 30 |
"text/markdown",
|
|
|
|
| 91 |
raise HTTPException(404, "Not found")
|
| 92 |
name = key.rsplit("/", 1)[-1] if download else None
|
| 93 |
return RedirectResponse(storage.presign_get(key, download_name=name), status_code=302)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
# Local disk backend: the "presigned" URLs LocalStorage hands out point here.
|
| 97 |
+
|
| 98 |
+
local_router = APIRouter(prefix="/api/storage/local", tags=["storage"], include_in_schema=False)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _local_path(method: str, key: str, exp: int, sig: str):
|
| 102 |
+
if not isinstance(storage, LocalStorage) or not verify_local_sig(method, key, exp, sig):
|
| 103 |
+
raise HTTPException(403, "Invalid or expired link")
|
| 104 |
+
try:
|
| 105 |
+
return storage.path(key)
|
| 106 |
+
except ValueError as exc:
|
| 107 |
+
raise HTTPException(404, "Not found") from exc
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@local_router.get("/{key:path}")
|
| 111 |
+
def local_get(key: str, exp: int, sig: str, download: str | None = None):
|
| 112 |
+
path = _local_path("GET", key, exp, sig)
|
| 113 |
+
if not path.is_file():
|
| 114 |
+
raise HTTPException(404, "Not found")
|
| 115 |
+
media_type = mimetypes.guess_type(key)[0] or "application/octet-stream"
|
| 116 |
+
# FileResponse answers Range requests, so audio and video can seek.
|
| 117 |
+
return FileResponse(path, media_type=media_type, filename=safe_filename(download) if download else None,
|
| 118 |
+
content_disposition_type="attachment" if download else "inline")
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
@local_router.put("/{key:path}")
|
| 122 |
+
async def local_put(key: str, exp: int, sig: str, request: Request):
|
| 123 |
+
_local_path("PUT", key, exp, sig)
|
| 124 |
+
body = await request.body()
|
| 125 |
+
if len(body) > settings.max_upload_bytes:
|
| 126 |
+
raise HTTPException(413, "File too large")
|
| 127 |
+
storage.put_bytes(key, body)
|
| 128 |
+
return {"ok": True, "size_bytes": len(body)}
|
backend/app/routers/topics.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
from collections import Counter
|
| 3 |
+
from itertools import combinations
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 6 |
+
from sqlalchemy import select
|
| 7 |
+
|
| 8 |
+
from app.auth import Ctx, get_ctx
|
| 9 |
+
from app.models import Document, DocumentTopic, Topic
|
| 10 |
+
from app.routers.sources import serialize_document
|
| 11 |
+
from app.services.jobs import create_job, serialize_job
|
| 12 |
+
|
| 13 |
+
router = APIRouter(prefix="/api/topics", tags=["topics"])
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@router.get("")
|
| 17 |
+
def topic_map(ctx: Ctx = Depends(get_ctx), min_posts: int = 1, limit: int = 80):
|
| 18 |
+
"""Nodes are topics sized by post count; edges join topics that share posts."""
|
| 19 |
+
topics = ctx.db.scalars(
|
| 20 |
+
select(Topic).where(Topic.workspace_id == ctx.workspace.id, Topic.post_count >= min_posts)
|
| 21 |
+
.order_by(Topic.post_count.desc()).limit(min(limit, 300))
|
| 22 |
+
).all()
|
| 23 |
+
ids = {t.id for t in topics}
|
| 24 |
+
links = ctx.db.execute(
|
| 25 |
+
select(DocumentTopic.document_id, DocumentTopic.topic_id).where(DocumentTopic.topic_id.in_(ids))
|
| 26 |
+
).all() if ids else []
|
| 27 |
+
by_doc: dict[uuid.UUID, list[uuid.UUID]] = {}
|
| 28 |
+
for doc_id, topic_id in links:
|
| 29 |
+
by_doc.setdefault(doc_id, []).append(topic_id)
|
| 30 |
+
edges: Counter[tuple[str, str]] = Counter()
|
| 31 |
+
for tids in by_doc.values():
|
| 32 |
+
for a, b in combinations(sorted(str(t) for t in tids), 2):
|
| 33 |
+
edges[(a, b)] += 1
|
| 34 |
+
untagged = ctx.db.scalar(
|
| 35 |
+
select(Document.id).where(Document.workspace_id == ctx.workspace.id, Document.path.is_not(None),
|
| 36 |
+
Document.id.not_in(select(DocumentTopic.document_id))).limit(1)
|
| 37 |
+
)
|
| 38 |
+
return {
|
| 39 |
+
"nodes": [{"id": str(t.id), "name": t.name, "summary": t.summary, "post_count": t.post_count}
|
| 40 |
+
for t in topics],
|
| 41 |
+
"edges": [{"source": a, "target": b, "weight": w} for (a, b), w in edges.most_common(400)],
|
| 42 |
+
"has_untagged_posts": untagged is not None,
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@router.get("/{topic_id}")
|
| 47 |
+
def topic_detail(topic_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
|
| 48 |
+
topic = ctx.db.scalar(select(Topic).where(Topic.id == topic_id, Topic.workspace_id == ctx.workspace.id))
|
| 49 |
+
if not topic:
|
| 50 |
+
raise HTTPException(404, "Topic not found")
|
| 51 |
+
rows = ctx.db.execute(
|
| 52 |
+
select(Document, DocumentTopic.weight)
|
| 53 |
+
.join(DocumentTopic, DocumentTopic.document_id == Document.id)
|
| 54 |
+
.where(DocumentTopic.topic_id == topic.id)
|
| 55 |
+
.order_by(DocumentTopic.weight.desc(), Document.published_at.desc())
|
| 56 |
+
).all()
|
| 57 |
+
return {"id": str(topic.id), "name": topic.name, "summary": topic.summary, "post_count": topic.post_count,
|
| 58 |
+
"posts": [{**serialize_document(d), "weight": w} for d, w in rows]}
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
@router.post("/rebuild")
|
| 62 |
+
def rebuild(ctx: Ctx = Depends(get_ctx), full: bool = False):
|
| 63 |
+
params = {}
|
| 64 |
+
if full:
|
| 65 |
+
# Clear per post tags so every post is mapped again.
|
| 66 |
+
for d in ctx.db.scalars(select(Document).where(Document.workspace_id == ctx.workspace.id)):
|
| 67 |
+
if d.metadata_json and "topics" in d.metadata_json:
|
| 68 |
+
d.metadata_json = {k: v for k, v in d.metadata_json.items() if k != "topics"}
|
| 69 |
+
ctx.db.commit()
|
| 70 |
+
job = create_job(ctx.db, ctx.workspace.id, "topics", params, max_attempts=2)
|
| 71 |
+
return serialize_job(job)
|
backend/app/routers/voice.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
from datetime import UTC, datetime
|
| 3 |
+
|
| 4 |
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 5 |
+
from fastapi.concurrency import run_in_threadpool
|
| 6 |
+
from pydantic import BaseModel, Field
|
| 7 |
+
from sqlalchemy import select
|
| 8 |
+
|
| 9 |
+
from app.auth import Ctx, get_ctx
|
| 10 |
+
from app.config import settings
|
| 11 |
+
from app.models import Upload, VoiceConsent, VoiceProfile
|
| 12 |
+
from app.services import tts
|
| 13 |
+
from app.services.jobs import create_job, serialize_job
|
| 14 |
+
from app.services.plans import effective_plan
|
| 15 |
+
from app.services.storage import storage
|
| 16 |
+
|
| 17 |
+
router = APIRouter(prefix="/api/voice", tags=["voice"])
|
| 18 |
+
|
| 19 |
+
CONSENT_TEXT = (
|
| 20 |
+
"I confirm this recording is my own voice. I allow Notestack to create a synthetic copy of it with "
|
| 21 |
+
"ElevenLabs, only for audio and video made in my workspace. I can revoke this at any time, which deletes "
|
| 22 |
+
"the synthetic voice."
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class BuildIn(BaseModel):
|
| 27 |
+
document_ids: list[uuid.UUID] = Field(default_factory=list, max_length=10)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class ProfileIn(BaseModel):
|
| 31 |
+
profile: dict | None = None
|
| 32 |
+
host_voices: dict[str, str] | None = None
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class ConsentIn(BaseModel):
|
| 36 |
+
upload_id: uuid.UUID
|
| 37 |
+
agreed: bool
|
| 38 |
+
consent_text: str
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _profile(ctx: Ctx) -> VoiceProfile | None:
|
| 42 |
+
return ctx.db.scalar(select(VoiceProfile).where(VoiceProfile.workspace_id == ctx.workspace.id))
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _consent(ctx: Ctx) -> VoiceConsent | None:
|
| 46 |
+
return ctx.db.scalar(
|
| 47 |
+
select(VoiceConsent).where(VoiceConsent.workspace_id == ctx.workspace.id, VoiceConsent.revoked_at.is_(None))
|
| 48 |
+
.order_by(VoiceConsent.created_at.desc())
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _serialize(ctx: Ctx) -> dict:
|
| 53 |
+
vp = _profile(ctx)
|
| 54 |
+
consent = _consent(ctx)
|
| 55 |
+
plan = effective_plan(ctx.db, ctx.workspace)
|
| 56 |
+
voices = (vp.host_voices if vp else None) or {}
|
| 57 |
+
return {
|
| 58 |
+
"profile": vp.profile_json if vp else None,
|
| 59 |
+
"sample_doc_ids": vp.sample_doc_ids if vp else [],
|
| 60 |
+
"updated_at": vp.updated_at.isoformat() if vp and vp.updated_at else None,
|
| 61 |
+
"host_voices": {"host_a": voices.get("host_a") or settings.elevenlabs_voice_a,
|
| 62 |
+
"host_b": voices.get("host_b") or settings.elevenlabs_voice_b},
|
| 63 |
+
"clone": {
|
| 64 |
+
"allowed": plan.voice_cloning,
|
| 65 |
+
"consent_text": CONSENT_TEXT,
|
| 66 |
+
"status": ("ready" if consent and consent.elevenlabs_voice_id else "processing" if consent else "none"),
|
| 67 |
+
"voice_id": consent.elevenlabs_voice_id if consent else None,
|
| 68 |
+
"created_at": consent.created_at.isoformat() if consent else None,
|
| 69 |
+
},
|
| 70 |
+
"tts_configured": bool(settings.elevenlabs_api_key),
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@router.get("")
|
| 75 |
+
def get_voice(ctx: Ctx = Depends(get_ctx)):
|
| 76 |
+
return _serialize(ctx)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@router.put("")
|
| 80 |
+
def update_voice(body: ProfileIn, ctx: Ctx = Depends(get_ctx)):
|
| 81 |
+
vp = _profile(ctx)
|
| 82 |
+
if not vp:
|
| 83 |
+
vp = VoiceProfile(workspace_id=ctx.workspace.id)
|
| 84 |
+
ctx.db.add(vp)
|
| 85 |
+
if body.profile is not None:
|
| 86 |
+
vp.profile_json = body.profile
|
| 87 |
+
if body.host_voices is not None:
|
| 88 |
+
vp.host_voices = {k: v for k, v in body.host_voices.items() if k in ("host_a", "host_b") and v}
|
| 89 |
+
ctx.db.commit()
|
| 90 |
+
return _serialize(ctx)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@router.post("/build")
|
| 94 |
+
def build(body: BuildIn, ctx: Ctx = Depends(get_ctx)):
|
| 95 |
+
job = create_job(ctx.db, ctx.workspace.id, "voice_profile",
|
| 96 |
+
{"document_ids": [str(i) for i in body.document_ids]}, max_attempts=2)
|
| 97 |
+
return serialize_job(job)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@router.get("/voices")
|
| 101 |
+
async def voices(ctx: Ctx = Depends(get_ctx)):
|
| 102 |
+
items = await run_in_threadpool(tts.list_voices)
|
| 103 |
+
consent = _consent(ctx)
|
| 104 |
+
if consent and consent.elevenlabs_voice_id and not any(v["voice_id"] == consent.elevenlabs_voice_id for v in items):
|
| 105 |
+
items = [{"voice_id": consent.elevenlabs_voice_id, "name": "My voice", "category": "cloned"}, *items]
|
| 106 |
+
return items
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
@router.post("/consent")
|
| 110 |
+
def give_consent(body: ConsentIn, request: Request, ctx: Ctx = Depends(get_ctx)):
|
| 111 |
+
if not effective_plan(ctx.db, ctx.workspace).voice_cloning:
|
| 112 |
+
raise HTTPException(402, {"code": "plan_limit", "message": "Voice cloning is on the Writer and Studio plans."})
|
| 113 |
+
if not body.agreed or body.consent_text.strip() != CONSENT_TEXT:
|
| 114 |
+
raise HTTPException(400, "Please read and accept the consent statement.")
|
| 115 |
+
if not settings.elevenlabs_api_key:
|
| 116 |
+
raise HTTPException(503, "Voice cloning needs ELEVENLABS_API_KEY in .env")
|
| 117 |
+
upload = ctx.db.scalar(select(Upload).where(Upload.id == body.upload_id, Upload.workspace_id == ctx.workspace.id))
|
| 118 |
+
if not upload or upload.status != "complete" or not upload.content_type.startswith("audio/"):
|
| 119 |
+
raise HTTPException(400, "Upload an audio recording first.")
|
| 120 |
+
consent = VoiceConsent(workspace_id=ctx.workspace.id, user_id=ctx.user.id, sample_key=upload.key,
|
| 121 |
+
consent_text=CONSENT_TEXT, ip_address=request.client.host if request.client else None)
|
| 122 |
+
ctx.db.add(consent)
|
| 123 |
+
ctx.db.commit()
|
| 124 |
+
job = create_job(ctx.db, ctx.workspace.id, "voice_clone", {"consent_id": str(consent.id)}, max_attempts=1)
|
| 125 |
+
return {"job": serialize_job(job), **_serialize(ctx)}
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@router.delete("/consent")
|
| 129 |
+
async def revoke_consent(ctx: Ctx = Depends(get_ctx)):
|
| 130 |
+
consent = _consent(ctx)
|
| 131 |
+
if not consent:
|
| 132 |
+
return _serialize(ctx)
|
| 133 |
+
if consent.elevenlabs_voice_id and settings.elevenlabs_api_key:
|
| 134 |
+
try:
|
| 135 |
+
await run_in_threadpool(tts.delete_voice, consent.elevenlabs_voice_id)
|
| 136 |
+
except Exception:
|
| 137 |
+
pass
|
| 138 |
+
consent.revoked_at = datetime.now(UTC)
|
| 139 |
+
storage.delete(consent.sample_key)
|
| 140 |
+
vp = _profile(ctx)
|
| 141 |
+
if vp and vp.host_voices:
|
| 142 |
+
vp.host_voices = {k: v for k, v in vp.host_voices.items() if v != consent.elevenlabs_voice_id}
|
| 143 |
+
ctx.db.commit()
|
| 144 |
+
return _serialize(ctx)
|
backend/app/services/artifacts.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Artifacts are every generated thing (summaries, audio, video, launch kits, stills). Creating one always
|
| 2 |
+
means: artifact row + queued job, returned together so the UI can stream progress."""
|
| 3 |
+
|
| 4 |
+
import uuid
|
| 5 |
+
|
| 6 |
+
from sqlalchemy import select
|
| 7 |
+
from sqlalchemy.orm import Session
|
| 8 |
+
|
| 9 |
+
from app.models import Artifact, Job
|
| 10 |
+
from app.services.jobs import create_job, serialize_job
|
| 11 |
+
from app.services.storage import storage
|
| 12 |
+
|
| 13 |
+
TYPE_LABELS = {
|
| 14 |
+
"summary": "Summary",
|
| 15 |
+
"audio_overview": "Audio overview",
|
| 16 |
+
"video": "Video",
|
| 17 |
+
"quote_card": "Quote card",
|
| 18 |
+
"carousel": "Carousel",
|
| 19 |
+
"launch_kit": "Launch Kit",
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def start_artifact(
|
| 24 |
+
db: Session,
|
| 25 |
+
workspace_id: uuid.UUID,
|
| 26 |
+
type_: str,
|
| 27 |
+
*,
|
| 28 |
+
title: str,
|
| 29 |
+
notebook_id: uuid.UUID | None = None,
|
| 30 |
+
document_id: uuid.UUID | None = None,
|
| 31 |
+
content: dict | None = None,
|
| 32 |
+
job_kind: str | None = None,
|
| 33 |
+
params: dict | None = None,
|
| 34 |
+
max_attempts: int = 2,
|
| 35 |
+
) -> tuple[Artifact, Job]:
|
| 36 |
+
artifact = Artifact(workspace_id=workspace_id, type=type_, notebook_id=notebook_id, document_id=document_id,
|
| 37 |
+
status="pending", content_json={"title": title, **(content or {})})
|
| 38 |
+
db.add(artifact)
|
| 39 |
+
db.commit()
|
| 40 |
+
job = create_job(db, workspace_id, job_kind or type_, {"artifact_id": str(artifact.id), **(params or {})},
|
| 41 |
+
artifact_id=artifact.id, max_attempts=max_attempts)
|
| 42 |
+
return artifact, job
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def latest_jobs(db: Session, artifact_ids: list[uuid.UUID]) -> dict[uuid.UUID, Job]:
|
| 46 |
+
if not artifact_ids:
|
| 47 |
+
return {}
|
| 48 |
+
jobs = db.scalars(select(Job).where(Job.artifact_id.in_(artifact_ids)).order_by(Job.created_at)).all()
|
| 49 |
+
return {j.artifact_id: j for j in jobs} # later rows win, so this is the newest job per artifact
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def serialize_artifact(a: Artifact, job: Job | None = None) -> dict:
|
| 53 |
+
content = a.content_json or {}
|
| 54 |
+
slides = content.get("slide_keys") or []
|
| 55 |
+
return {
|
| 56 |
+
"id": str(a.id),
|
| 57 |
+
"type": a.type,
|
| 58 |
+
"type_label": TYPE_LABELS.get(a.type, a.type),
|
| 59 |
+
"title": content.get("title") or TYPE_LABELS.get(a.type, a.type),
|
| 60 |
+
"status": a.status,
|
| 61 |
+
"notebook_id": str(a.notebook_id) if a.notebook_id else None,
|
| 62 |
+
"document_id": str(a.document_id) if a.document_id else None,
|
| 63 |
+
"content": content,
|
| 64 |
+
"storage_key": a.storage_key,
|
| 65 |
+
"url": storage.presign_get(a.storage_key) if a.storage_key else None,
|
| 66 |
+
"download_url": storage.presign_get(a.storage_key, download_name=_download_name(a)) if a.storage_key else None,
|
| 67 |
+
"slide_urls": [storage.presign_get(k) for k in slides],
|
| 68 |
+
"created_at": a.created_at.isoformat() if a.created_at else None,
|
| 69 |
+
"job": serialize_job(job) if job else None,
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _download_name(a: Artifact) -> str:
|
| 74 |
+
title = (a.content_json or {}).get("title") or a.type
|
| 75 |
+
ext = (a.storage_key or "").rsplit(".", 1)[-1]
|
| 76 |
+
return f"{title[:60]}.{ext}"
|
backend/app/services/crypto.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Encrypts third party tokens at rest (social accounts). Fernet key from SOCIAL_TOKEN_KEY, or derived
|
| 2 |
+
from JWT_SECRET so local dev needs no extra setting."""
|
| 3 |
+
|
| 4 |
+
import base64
|
| 5 |
+
import hashlib
|
| 6 |
+
from functools import lru_cache
|
| 7 |
+
|
| 8 |
+
from cryptography.fernet import Fernet, InvalidToken
|
| 9 |
+
|
| 10 |
+
from app.config import settings
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@lru_cache
|
| 14 |
+
def _fernet() -> Fernet:
|
| 15 |
+
key = settings.social_token_key
|
| 16 |
+
if not key:
|
| 17 |
+
key = base64.urlsafe_b64encode(hashlib.sha256(f"social:{settings.jwt_secret}".encode()).digest()).decode()
|
| 18 |
+
return Fernet(key.encode())
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def encrypt(value: str | None) -> str | None:
|
| 22 |
+
return _fernet().encrypt(value.encode()).decode() if value else None
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def decrypt(value: str | None) -> str | None:
|
| 26 |
+
if not value:
|
| 27 |
+
return None
|
| 28 |
+
try:
|
| 29 |
+
return _fernet().decrypt(value.encode()).decode()
|
| 30 |
+
except InvalidToken:
|
| 31 |
+
return None
|
backend/app/services/email.py
CHANGED
|
@@ -165,6 +165,21 @@ class EmailService:
|
|
| 165 |
)
|
| 166 |
)
|
| 167 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
# Internal alerts
|
| 169 |
|
| 170 |
def send_alert(self, subject: str, text: str) -> bool:
|
|
|
|
| 165 |
)
|
| 166 |
)
|
| 167 |
|
| 168 |
+
def send_post_reminder(self, to: str, platform: str, content: str, url: str) -> bool:
|
| 169 |
+
body = (
|
| 170 |
+
f"Your {html.escape(platform)} post is scheduled for now. Copy it below and post it."
|
| 171 |
+
f"<pre style='white-space:pre-wrap;background:rgba(255,255,255,0.06);padding:16px;border-radius:8px'>"
|
| 172 |
+
f"{html.escape(content)}</pre>" + _button("Open Launchpad", url)
|
| 173 |
+
)
|
| 174 |
+
return self._send(
|
| 175 |
+
OutgoingEmail(
|
| 176 |
+
to=to,
|
| 177 |
+
subject=f"Time to post on {platform}",
|
| 178 |
+
html=_layout("Launch window open", body),
|
| 179 |
+
text=f"Time to post on {platform}:\n\n{content}\n\nLaunchpad: {url}",
|
| 180 |
+
)
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
# Internal alerts
|
| 184 |
|
| 185 |
def send_alert(self, subject: str, text: str) -> bool:
|
backend/app/services/jobs.py
CHANGED
|
@@ -29,6 +29,7 @@ def serialize_job(job: Job) -> dict:
|
|
| 29 |
return {
|
| 30 |
"id": str(job.id),
|
| 31 |
"kind": job.kind,
|
|
|
|
| 32 |
"status": job.status,
|
| 33 |
"progress": job.progress,
|
| 34 |
"message": job.message,
|
|
|
|
| 29 |
return {
|
| 30 |
"id": str(job.id),
|
| 31 |
"kind": job.kind,
|
| 32 |
+
"params": job.params,
|
| 33 |
"status": job.status,
|
| 34 |
"progress": job.progress,
|
| 35 |
"message": job.message,
|
backend/app/services/launchpad.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Launchpad: publishes due calendar items, emails reminders for platforms without an API, rewrites links
|
| 2 |
+
to tracked short links and pulls engagement back in."""
|
| 3 |
+
|
| 4 |
+
import logging
|
| 5 |
+
import re
|
| 6 |
+
import secrets
|
| 7 |
+
from collections.abc import Callable
|
| 8 |
+
from datetime import UTC, datetime, timedelta
|
| 9 |
+
|
| 10 |
+
from sqlalchemy import func, select
|
| 11 |
+
from sqlalchemy.orm import Session
|
| 12 |
+
|
| 13 |
+
from app.config import settings
|
| 14 |
+
from app.db import SessionLocal
|
| 15 |
+
from app.models import CalendarItem, Document, EngagementEvent, SocialAccount, TrackedLink, User, Workspace
|
| 16 |
+
from app.services.email import email_service
|
| 17 |
+
from app.services.social import AUTO_POST, PLATFORM_LABELS, SocialError, module
|
| 18 |
+
|
| 19 |
+
log = logging.getLogger("notestack.launchpad")
|
| 20 |
+
_URL = re.compile(r"https?://[^\s)\]>]+")
|
| 21 |
+
MAX_ATTEMPTS = 3
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _public_api() -> bool:
|
| 25 |
+
return not re.search(r"//(localhost|127\.0\.0\.1|api)(:|/|$)", settings.api_url)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def track_links(db: Session, item: CalendarItem, text: str) -> str:
|
| 29 |
+
"""Swap outbound links for {API_URL}/l/{slug} so clicks are counted. Skipped when the API is not public."""
|
| 30 |
+
if not _public_api():
|
| 31 |
+
return text
|
| 32 |
+
base = f"{settings.api_url.rstrip('/')}/l/"
|
| 33 |
+
|
| 34 |
+
def swap(m: re.Match) -> str:
|
| 35 |
+
url = m.group(0)
|
| 36 |
+
if url.startswith(base):
|
| 37 |
+
return url
|
| 38 |
+
link = TrackedLink(workspace_id=item.workspace_id, slug=secrets.token_urlsafe(5)[:7], target_url=url,
|
| 39 |
+
calendar_item_id=item.id)
|
| 40 |
+
db.add(link)
|
| 41 |
+
return base + link.slug
|
| 42 |
+
|
| 43 |
+
return _URL.sub(swap, text)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def owner_email(db: Session, workspace_id) -> str | None:
|
| 47 |
+
ws = db.get(Workspace, workspace_id)
|
| 48 |
+
user = db.get(User, ws.owner_id) if ws else None
|
| 49 |
+
return user.email if user and user.is_active else None
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def publish_item(db: Session, item: CalendarItem) -> CalendarItem:
|
| 53 |
+
"""Publish or remind now. Leaves the item posted, reminded, failed, or scheduled again for a retry."""
|
| 54 |
+
posts = [p for p in [item.content, *(item.thread or [])] if p and p.strip()]
|
| 55 |
+
if not posts:
|
| 56 |
+
item.status, item.error = "failed", "Nothing to post."
|
| 57 |
+
db.commit()
|
| 58 |
+
return item
|
| 59 |
+
account = db.get(SocialAccount, item.social_account_id) if item.social_account_id else None
|
| 60 |
+
label = PLATFORM_LABELS.get(item.platform, item.platform)
|
| 61 |
+
if item.platform not in AUTO_POST or item.remind_by_email or not account:
|
| 62 |
+
to = owner_email(db, item.workspace_id)
|
| 63 |
+
sent = bool(to) and email_service.send_post_reminder(
|
| 64 |
+
to, label, "\n\n".join(posts), f"{settings.frontend_url}/app/launchpad?item={item.id}")
|
| 65 |
+
item.status = "reminded" if sent else "failed"
|
| 66 |
+
item.error = None if sent else "Could not send the reminder email."
|
| 67 |
+
item.posted_at = datetime.now(UTC)
|
| 68 |
+
db.commit()
|
| 69 |
+
return item
|
| 70 |
+
item.attempts += 1
|
| 71 |
+
try:
|
| 72 |
+
tracked = [track_links(db, item, p) for p in posts]
|
| 73 |
+
db.flush()
|
| 74 |
+
external_id, url = module(item.platform).publish(db, account, tracked)
|
| 75 |
+
except SocialError as exc:
|
| 76 |
+
_fail_or_retry(db, item, str(exc), exc.permanent)
|
| 77 |
+
return item
|
| 78 |
+
except Exception as exc: # network trouble: retry
|
| 79 |
+
log.exception("publish failed for %s", item.id)
|
| 80 |
+
_fail_or_retry(db, item, f"{type(exc).__name__}: {exc}", False)
|
| 81 |
+
return item
|
| 82 |
+
item.status, item.error = "posted", None
|
| 83 |
+
item.external_id, item.external_url, item.posted_at = external_id, url, datetime.now(UTC)
|
| 84 |
+
if item.document_id:
|
| 85 |
+
doc = db.get(Document, item.document_id)
|
| 86 |
+
if doc:
|
| 87 |
+
doc.last_resurfaced_at = item.posted_at
|
| 88 |
+
db.commit()
|
| 89 |
+
return item
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _fail_or_retry(db: Session, item: CalendarItem, error: str, permanent: bool) -> None:
|
| 93 |
+
item.error = error[:1000]
|
| 94 |
+
if permanent or item.attempts >= MAX_ATTEMPTS:
|
| 95 |
+
item.status = "failed"
|
| 96 |
+
else:
|
| 97 |
+
item.status = "scheduled"
|
| 98 |
+
item.scheduled_at = datetime.now(UTC) + timedelta(minutes=5 * item.attempts)
|
| 99 |
+
db.commit()
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def publish_due(session_factory: Callable[[], Session] = SessionLocal) -> int:
|
| 103 |
+
now = datetime.now(UTC)
|
| 104 |
+
count = 0
|
| 105 |
+
with session_factory() as db:
|
| 106 |
+
# A tick that died mid publish leaves items in "publishing"; put them back after 10 minutes.
|
| 107 |
+
for stuck in db.scalars(select(CalendarItem).where(
|
| 108 |
+
CalendarItem.status == "publishing", CalendarItem.updated_at < now - timedelta(minutes=10))):
|
| 109 |
+
stuck.status = "scheduled"
|
| 110 |
+
due = db.scalars(
|
| 111 |
+
select(CalendarItem).where(CalendarItem.status == "scheduled", CalendarItem.scheduled_at <= now)
|
| 112 |
+
.order_by(CalendarItem.scheduled_at).limit(50)
|
| 113 |
+
).all()
|
| 114 |
+
for item in due:
|
| 115 |
+
item.status = "publishing" # claim, so an overlapping tick skips it
|
| 116 |
+
db.commit()
|
| 117 |
+
for item in due:
|
| 118 |
+
publish_item(db, item)
|
| 119 |
+
count += 1
|
| 120 |
+
return count
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def clicks_by_item(db: Session, item_ids: list) -> dict:
|
| 124 |
+
if not item_ids:
|
| 125 |
+
return {}
|
| 126 |
+
rows = db.execute(
|
| 127 |
+
select(TrackedLink.calendar_item_id, func.count(EngagementEvent.id))
|
| 128 |
+
.join(EngagementEvent, EngagementEvent.tracked_link_id == TrackedLink.id)
|
| 129 |
+
.where(TrackedLink.calendar_item_id.in_(item_ids), EngagementEvent.kind == "click")
|
| 130 |
+
.group_by(TrackedLink.calendar_item_id)
|
| 131 |
+
).all()
|
| 132 |
+
return {item_id: n for item_id, n in rows}
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def sync_engagement(session_factory: Callable[[], Session] = SessionLocal) -> int:
|
| 136 |
+
since = datetime.now(UTC) - timedelta(days=30)
|
| 137 |
+
updated = 0
|
| 138 |
+
with session_factory() as db:
|
| 139 |
+
items = db.scalars(
|
| 140 |
+
select(CalendarItem).where(CalendarItem.status == "posted", CalendarItem.posted_at >= since,
|
| 141 |
+
CalendarItem.external_id.is_not(None))
|
| 142 |
+
).all()
|
| 143 |
+
for item in items:
|
| 144 |
+
account = db.get(SocialAccount, item.social_account_id) if item.social_account_id else None
|
| 145 |
+
if not account:
|
| 146 |
+
continue
|
| 147 |
+
try:
|
| 148 |
+
data = module(item.platform).metrics(db, account, item.external_id)
|
| 149 |
+
except Exception:
|
| 150 |
+
log.warning("metrics failed for %s", item.id, exc_info=True)
|
| 151 |
+
continue
|
| 152 |
+
if data:
|
| 153 |
+
item.metrics = {**(item.metrics or {}), **data, "synced_at": datetime.now(UTC).isoformat()}
|
| 154 |
+
updated += 1
|
| 155 |
+
db.commit()
|
| 156 |
+
return updated
|
backend/app/services/renderer.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hands a render to the Remotion service with presigned upload URLs (it never holds storage keys)."""
|
| 2 |
+
|
| 3 |
+
import httpx
|
| 4 |
+
|
| 5 |
+
from app.config import settings
|
| 6 |
+
from app.models import Artifact, Job
|
| 7 |
+
from app.services.storage import keys, storage
|
| 8 |
+
|
| 9 |
+
COMPOSITIONS = {
|
| 10 |
+
"ShortVertical": "mp4",
|
| 11 |
+
"ExplainerLong": "mp4",
|
| 12 |
+
"AudiogramSquare": "mp4",
|
| 13 |
+
"QuoteCard": "png",
|
| 14 |
+
"CarouselSlide": "png",
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class PermanentJobError(RuntimeError):
|
| 19 |
+
"""Fail the job now; retrying will not help."""
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def request_render(job: Job, artifact: Artifact, composition: str, props: dict | None = None,
|
| 23 |
+
stills: list[dict] | None = None) -> None:
|
| 24 |
+
"""Start a render. `stills` renders one PNG per props dict (carousels) instead of a single output."""
|
| 25 |
+
fmt = COMPOSITIONS[composition]
|
| 26 |
+
content_type = "image/png" if fmt == "png" else "video/mp4"
|
| 27 |
+
body: dict = {
|
| 28 |
+
"jobId": str(job.id),
|
| 29 |
+
"compositionId": composition,
|
| 30 |
+
"props": props or {},
|
| 31 |
+
"format": fmt,
|
| 32 |
+
"uploadContentType": content_type,
|
| 33 |
+
"callbackUrl": f"{settings.api_url}/api/internal/jobs/{job.id}/progress",
|
| 34 |
+
}
|
| 35 |
+
if stills:
|
| 36 |
+
outputs = []
|
| 37 |
+
for i, still_props in enumerate(stills, start=1):
|
| 38 |
+
key = keys.artifact(artifact.workspace_id, artifact.id, f"slide-{i:02d}", fmt)
|
| 39 |
+
outputs.append({"props": still_props, "uploadUrl": storage.presign_put(key, content_type, ttl=6 * 3600),
|
| 40 |
+
"storageKey": key})
|
| 41 |
+
body.update(stills=outputs, props=stills[0], uploadUrl=outputs[0]["uploadUrl"],
|
| 42 |
+
storageKey=outputs[0]["storageKey"])
|
| 43 |
+
else:
|
| 44 |
+
key = keys.artifact(artifact.workspace_id, artifact.id, composition.lower(), fmt)
|
| 45 |
+
body.update(uploadUrl=storage.presign_put(key, content_type, ttl=6 * 3600), storageKey=key)
|
| 46 |
+
try:
|
| 47 |
+
resp = httpx.post(f"{settings.renderer_url}/render", headers={"x-internal-token": settings.internal_token},
|
| 48 |
+
json=body, timeout=30)
|
| 49 |
+
except httpx.ConnectError as exc:
|
| 50 |
+
raise PermanentJobError("Renderer is not running. Start it with: cd renderer && npm start") from exc
|
| 51 |
+
if resp.status_code == 400:
|
| 52 |
+
raise PermanentJobError(f"Renderer rejected the props: {resp.text[:400]}")
|
| 53 |
+
resp.raise_for_status()
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def brand_props(workspace, *, ttl: int = 6 * 3600) -> dict:
|
| 57 |
+
brand = workspace.brand_json or {}
|
| 58 |
+
out: dict = {}
|
| 59 |
+
if brand.get("accent"):
|
| 60 |
+
out["accent"] = brand["accent"]
|
| 61 |
+
if brand.get("name"):
|
| 62 |
+
out["name"] = brand["name"]
|
| 63 |
+
if brand.get("logo_key"):
|
| 64 |
+
out["logoUrl"] = storage.presign_get(brand["logo_key"], ttl=ttl)
|
| 65 |
+
return out
|
backend/app/services/social/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Publishing to X, LinkedIn and Bluesky. Each platform module exposes:
|
| 2 |
+
|
| 3 |
+
publish(db, account, posts: list[str]) -> (external_id, external_url)
|
| 4 |
+
metrics(db, account, external_id) -> dict
|
| 5 |
+
|
| 6 |
+
A list with more than one post is published as a thread (reply chain) where the platform supports it.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from datetime import UTC, datetime
|
| 10 |
+
|
| 11 |
+
from sqlalchemy.orm import Session
|
| 12 |
+
|
| 13 |
+
from app.models import SocialAccount
|
| 14 |
+
from app.services.crypto import decrypt, encrypt
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class SocialError(RuntimeError):
|
| 18 |
+
def __init__(self, message: str, permanent: bool = False):
|
| 19 |
+
super().__init__(message)
|
| 20 |
+
self.permanent = permanent
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
PLATFORM_LABELS = {"x": "X", "linkedin": "LinkedIn", "bluesky": "Bluesky", "substack_notes": "Substack Notes"}
|
| 24 |
+
AUTO_POST = {"x", "linkedin", "bluesky"}
|
| 25 |
+
LIMITS = {"x": 280, "bluesky": 300, "linkedin": 3000, "substack_notes": 2000}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def token(account: SocialAccount) -> str:
|
| 29 |
+
value = decrypt(account.access_token)
|
| 30 |
+
if not value:
|
| 31 |
+
raise SocialError("Stored credentials could not be read. Reconnect the account.", permanent=True)
|
| 32 |
+
return value
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def save_tokens(db: Session, account: SocialAccount, access: str, refresh: str | None, expires_in: int | None) -> None:
|
| 36 |
+
account.access_token = encrypt(access)
|
| 37 |
+
if refresh:
|
| 38 |
+
account.refresh_token = encrypt(refresh)
|
| 39 |
+
if expires_in:
|
| 40 |
+
account.expires_at = datetime.fromtimestamp(datetime.now(UTC).timestamp() + int(expires_in), UTC)
|
| 41 |
+
account.status = "active"
|
| 42 |
+
db.commit()
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def expired(account: SocialAccount) -> bool:
|
| 46 |
+
if not account.expires_at:
|
| 47 |
+
return False
|
| 48 |
+
exp = account.expires_at if account.expires_at.tzinfo else account.expires_at.replace(tzinfo=UTC)
|
| 49 |
+
return exp.timestamp() - 120 < datetime.now(UTC).timestamp()
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def module(platform: str):
|
| 53 |
+
from app.services.social import bluesky, linkedin, x
|
| 54 |
+
|
| 55 |
+
return {"x": x, "linkedin": linkedin, "bluesky": bluesky}[platform]
|
backend/app/services/social/bluesky.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Bluesky over AT Protocol with an app password (bsky.app > Settings > App passwords)."""
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
from datetime import UTC, datetime
|
| 5 |
+
|
| 6 |
+
import httpx
|
| 7 |
+
from sqlalchemy.orm import Session
|
| 8 |
+
|
| 9 |
+
from app.models import SocialAccount
|
| 10 |
+
from app.services.social import SocialError, token
|
| 11 |
+
|
| 12 |
+
PDS = "https://bsky.social/xrpc"
|
| 13 |
+
PUBLIC = "https://public.api.bsky.app/xrpc"
|
| 14 |
+
_URL = re.compile(r"https?://[^\s)\]]+")
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def create_session(handle: str, app_password: str) -> dict:
|
| 18 |
+
resp = httpx.post(f"{PDS}/com.atproto.server.createSession",
|
| 19 |
+
json={"identifier": handle.lstrip("@"), "password": app_password}, timeout=20)
|
| 20 |
+
if resp.status_code in (400, 401):
|
| 21 |
+
raise SocialError("Bluesky did not accept that handle and app password.", permanent=True)
|
| 22 |
+
resp.raise_for_status()
|
| 23 |
+
return resp.json()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def link_facets(text: str) -> list[dict]:
|
| 27 |
+
"""Links must be declared as facets with UTF-8 byte offsets to be clickable."""
|
| 28 |
+
facets = []
|
| 29 |
+
for m in _URL.finditer(text):
|
| 30 |
+
start = len(text[: m.start()].encode())
|
| 31 |
+
end = start + len(m.group(0).encode())
|
| 32 |
+
facets.append({"index": {"byteStart": start, "byteEnd": end},
|
| 33 |
+
"features": [{"$type": "app.bsky.richtext.facet#link", "uri": m.group(0)}]})
|
| 34 |
+
return facets
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def publish(db: Session, account: SocialAccount, posts: list[str]) -> tuple[str, str]:
|
| 38 |
+
session = create_session(account.handle, token(account))
|
| 39 |
+
headers = {"Authorization": f"Bearer {session['accessJwt']}"}
|
| 40 |
+
root = parent = None
|
| 41 |
+
for text in posts:
|
| 42 |
+
record: dict = {"$type": "app.bsky.feed.post", "text": text,
|
| 43 |
+
"createdAt": datetime.now(UTC).isoformat().replace("+00:00", "Z")}
|
| 44 |
+
facets = link_facets(text)
|
| 45 |
+
if facets:
|
| 46 |
+
record["facets"] = facets
|
| 47 |
+
if parent:
|
| 48 |
+
record["reply"] = {"root": root, "parent": parent}
|
| 49 |
+
resp = httpx.post(f"{PDS}/com.atproto.repo.createRecord", headers=headers, timeout=20,
|
| 50 |
+
json={"repo": session["did"], "collection": "app.bsky.feed.post", "record": record})
|
| 51 |
+
if resp.status_code >= 400:
|
| 52 |
+
raise SocialError(f"Bluesky {resp.status_code}: {resp.text[:300]}", permanent=resp.status_code == 400)
|
| 53 |
+
ref = {"uri": resp.json()["uri"], "cid": resp.json()["cid"]}
|
| 54 |
+
root = root or ref
|
| 55 |
+
parent = ref
|
| 56 |
+
rkey = root["uri"].rsplit("/", 1)[-1]
|
| 57 |
+
return root["uri"], f"https://bsky.app/profile/{session.get('handle', account.handle)}/post/{rkey}"
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def metrics(db: Session, account: SocialAccount, external_id: str) -> dict:
|
| 61 |
+
resp = httpx.get(f"{PUBLIC}/app.bsky.feed.getPosts", params={"uris": external_id}, timeout=20)
|
| 62 |
+
if resp.status_code >= 400:
|
| 63 |
+
return {}
|
| 64 |
+
posts = resp.json().get("posts") or [{}]
|
| 65 |
+
p = posts[0]
|
| 66 |
+
return {"likes": p.get("likeCount", 0), "reposts": p.get("repostCount", 0),
|
| 67 |
+
"replies": p.get("replyCount", 0), "quotes": p.get("quoteCount", 0)}
|
backend/app/services/social/linkedin.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LinkedIn member posts (Share on LinkedIn + Sign In with LinkedIn using OpenID Connect products)."""
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
from urllib.parse import quote, urlencode
|
| 5 |
+
|
| 6 |
+
import httpx
|
| 7 |
+
from sqlalchemy.orm import Session
|
| 8 |
+
|
| 9 |
+
from app.config import settings
|
| 10 |
+
from app.models import SocialAccount
|
| 11 |
+
from app.services.social import SocialError, expired, token
|
| 12 |
+
|
| 13 |
+
AUTH_URL = "https://www.linkedin.com/oauth/v2/authorization"
|
| 14 |
+
TOKEN_URL = "https://www.linkedin.com/oauth/v2/accessToken"
|
| 15 |
+
SCOPES = "openid profile w_member_social"
|
| 16 |
+
VERSION = "202509" # LinkedIn-Version header, YYYYMM; LinkedIn supports each version for a year
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def redirect_uri() -> str:
|
| 20 |
+
return f"{settings.api_url.rstrip('/')}/api/social/linkedin/callback"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def configured() -> bool:
|
| 24 |
+
return bool(settings.linkedin_client_id and settings.linkedin_client_secret)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def authorize_url(state: str) -> str:
|
| 28 |
+
return AUTH_URL + "?" + urlencode({
|
| 29 |
+
"response_type": "code", "client_id": settings.linkedin_client_id, "redirect_uri": redirect_uri(),
|
| 30 |
+
"state": state, "scope": SCOPES,
|
| 31 |
+
})
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def exchange_code(code: str) -> dict:
|
| 35 |
+
resp = httpx.post(TOKEN_URL, data={
|
| 36 |
+
"grant_type": "authorization_code", "code": code, "redirect_uri": redirect_uri(),
|
| 37 |
+
"client_id": settings.linkedin_client_id, "client_secret": settings.linkedin_client_secret,
|
| 38 |
+
}, timeout=20)
|
| 39 |
+
if resp.status_code >= 400:
|
| 40 |
+
raise SocialError(f"LinkedIn token request failed: {resp.text[:300]}", permanent=True)
|
| 41 |
+
tokens = resp.json()
|
| 42 |
+
me = httpx.get("https://api.linkedin.com/v2/userinfo",
|
| 43 |
+
headers={"Authorization": f"Bearer {tokens['access_token']}"}, timeout=20)
|
| 44 |
+
me.raise_for_status()
|
| 45 |
+
info = me.json()
|
| 46 |
+
return {**tokens, "external_id": info["sub"], "handle": info.get("name") or info["sub"],
|
| 47 |
+
"avatar_url": info.get("picture")}
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
_RESERVED = re.compile(r"([\\|{}@\[\]()<>#*_~])")
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def little_text(text: str) -> str:
|
| 54 |
+
"""LinkedIn's commentary field uses 'little text' markup; reserved characters must be escaped."""
|
| 55 |
+
return _RESERVED.sub(r"\\\1", text)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def publish(db: Session, account: SocialAccount, posts: list[str]) -> tuple[str, str]:
|
| 59 |
+
if expired(account):
|
| 60 |
+
account.status = "expired"
|
| 61 |
+
db.commit()
|
| 62 |
+
raise SocialError("The LinkedIn connection expired. Reconnect the account.", permanent=True)
|
| 63 |
+
body = {
|
| 64 |
+
"author": f"urn:li:person:{account.external_id}",
|
| 65 |
+
"commentary": little_text("\n\n".join(posts)),
|
| 66 |
+
"visibility": "PUBLIC",
|
| 67 |
+
"distribution": {"feedDistribution": "MAIN_FEED", "targetEntities": [], "thirdPartyDistributionChannels": []},
|
| 68 |
+
"lifecycleState": "PUBLISHED",
|
| 69 |
+
"isReshareDisabledByAuthor": False,
|
| 70 |
+
}
|
| 71 |
+
resp = httpx.post("https://api.linkedin.com/rest/posts", json=body, timeout=20, headers={
|
| 72 |
+
"Authorization": f"Bearer {token(account)}", "LinkedIn-Version": VERSION,
|
| 73 |
+
"X-Restli-Protocol-Version": "2.0.0",
|
| 74 |
+
})
|
| 75 |
+
if resp.status_code == 401:
|
| 76 |
+
account.status = "expired"
|
| 77 |
+
db.commit()
|
| 78 |
+
raise SocialError("LinkedIn rejected the token. Reconnect the account.", permanent=True)
|
| 79 |
+
if resp.status_code >= 400:
|
| 80 |
+
raise SocialError(f"LinkedIn {resp.status_code}: {resp.text[:300]}",
|
| 81 |
+
permanent=resp.status_code in (400, 403, 422))
|
| 82 |
+
urn = resp.headers.get("x-restli-id") or resp.headers.get("x-linkedin-id") or ""
|
| 83 |
+
return urn, f"https://www.linkedin.com/feed/update/{quote(urn)}/" if urn else ""
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def metrics(db: Session, account: SocialAccount, external_id: str) -> dict:
|
| 87 |
+
return {} # member post analytics need the Community Management API, which personal apps rarely have
|
backend/app/services/social/x.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""X (Twitter) API v2 with OAuth 2.0 user context (PKCE). Threads are reply chains."""
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import hashlib
|
| 5 |
+
import secrets
|
| 6 |
+
from urllib.parse import urlencode
|
| 7 |
+
|
| 8 |
+
import httpx
|
| 9 |
+
from sqlalchemy.orm import Session
|
| 10 |
+
|
| 11 |
+
from app.config import settings
|
| 12 |
+
from app.models import SocialAccount
|
| 13 |
+
from app.services.crypto import decrypt
|
| 14 |
+
from app.services.social import SocialError, expired, save_tokens, token
|
| 15 |
+
|
| 16 |
+
AUTH_URL = "https://x.com/i/oauth2/authorize"
|
| 17 |
+
TOKEN_URL = "https://api.x.com/2/oauth2/token"
|
| 18 |
+
API = "https://api.x.com/2"
|
| 19 |
+
SCOPES = "tweet.read tweet.write users.read offline.access"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def redirect_uri() -> str:
|
| 23 |
+
return f"{settings.api_url.rstrip('/')}/api/social/x/callback"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def configured() -> bool:
|
| 27 |
+
return bool(settings.x_client_id and settings.x_client_secret)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def pkce_pair() -> tuple[str, str]:
|
| 31 |
+
verifier = secrets.token_urlsafe(64)[:96]
|
| 32 |
+
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")
|
| 33 |
+
return verifier, challenge
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def authorize_url(state: str, challenge: str) -> str:
|
| 37 |
+
return AUTH_URL + "?" + urlencode({
|
| 38 |
+
"response_type": "code", "client_id": settings.x_client_id, "redirect_uri": redirect_uri(),
|
| 39 |
+
"scope": SCOPES, "state": state, "code_challenge": challenge, "code_challenge_method": "S256",
|
| 40 |
+
})
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _token_request(data: dict) -> dict:
|
| 44 |
+
resp = httpx.post(TOKEN_URL, data={**data, "client_id": settings.x_client_id},
|
| 45 |
+
auth=(settings.x_client_id, settings.x_client_secret), timeout=20)
|
| 46 |
+
if resp.status_code >= 400:
|
| 47 |
+
raise SocialError(f"X token request failed: {resp.text[:300]}", permanent=resp.status_code in (400, 401))
|
| 48 |
+
return resp.json()
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def exchange_code(code: str, verifier: str) -> dict:
|
| 52 |
+
tokens = _token_request({"grant_type": "authorization_code", "code": code, "redirect_uri": redirect_uri(),
|
| 53 |
+
"code_verifier": verifier})
|
| 54 |
+
me = httpx.get(f"{API}/users/me", params={"user.fields": "profile_image_url"},
|
| 55 |
+
headers={"Authorization": f"Bearer {tokens['access_token']}"}, timeout=20)
|
| 56 |
+
me.raise_for_status()
|
| 57 |
+
user = me.json()["data"]
|
| 58 |
+
return {**tokens, "external_id": user["id"], "handle": user["username"],
|
| 59 |
+
"avatar_url": user.get("profile_image_url")}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _access(db: Session, account: SocialAccount) -> str:
|
| 63 |
+
if expired(account) and account.refresh_token:
|
| 64 |
+
tokens = _token_request({"grant_type": "refresh_token", "refresh_token": decrypt(account.refresh_token)})
|
| 65 |
+
save_tokens(db, account, tokens["access_token"], tokens.get("refresh_token"), tokens.get("expires_in"))
|
| 66 |
+
return token(account)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def publish(db: Session, account: SocialAccount, posts: list[str]) -> tuple[str, str]:
|
| 70 |
+
headers = {"Authorization": f"Bearer {_access(db, account)}"}
|
| 71 |
+
first_id, reply_to = None, None
|
| 72 |
+
for text in posts:
|
| 73 |
+
body: dict = {"text": text}
|
| 74 |
+
if reply_to:
|
| 75 |
+
body["reply"] = {"in_reply_to_tweet_id": reply_to}
|
| 76 |
+
resp = httpx.post(f"{API}/tweets", json=body, headers=headers, timeout=20)
|
| 77 |
+
if resp.status_code == 401:
|
| 78 |
+
account.status = "expired"
|
| 79 |
+
db.commit()
|
| 80 |
+
raise SocialError("X rejected the token. Reconnect the account.", permanent=True)
|
| 81 |
+
if resp.status_code >= 400:
|
| 82 |
+
raise SocialError(f"X {resp.status_code}: {resp.text[:300]}", permanent=resp.status_code in (400, 403))
|
| 83 |
+
reply_to = resp.json()["data"]["id"]
|
| 84 |
+
first_id = first_id or reply_to
|
| 85 |
+
return first_id, f"https://x.com/{account.handle}/status/{first_id}"
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def metrics(db: Session, account: SocialAccount, external_id: str) -> dict:
|
| 89 |
+
resp = httpx.get(f"{API}/tweets", params={"ids": external_id, "tweet.fields": "public_metrics"},
|
| 90 |
+
headers={"Authorization": f"Bearer {_access(db, account)}"}, timeout=20)
|
| 91 |
+
if resp.status_code >= 400:
|
| 92 |
+
return {}
|
| 93 |
+
data = (resp.json().get("data") or [{}])[0].get("public_metrics") or {}
|
| 94 |
+
return {"likes": data.get("like_count", 0), "reposts": data.get("retweet_count", 0),
|
| 95 |
+
"replies": data.get("reply_count", 0), "impressions": data.get("impression_count", 0)}
|
backend/app/services/storage.py
CHANGED
|
@@ -1,13 +1,20 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
|
|
|
|
|
|
|
| 4 |
"""
|
| 5 |
|
| 6 |
import hashlib
|
|
|
|
| 7 |
import mimetypes
|
| 8 |
import re
|
|
|
|
|
|
|
| 9 |
import uuid
|
| 10 |
from functools import cached_property
|
|
|
|
|
|
|
| 11 |
|
| 12 |
import boto3
|
| 13 |
from botocore.client import Config
|
|
@@ -127,4 +134,85 @@ class Storage:
|
|
| 127 |
return f"{settings.r2_public_base_url.rstrip('/')}/{key}"
|
| 128 |
|
| 129 |
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Object storage. Postgres stores keys; bytes live here.
|
| 2 |
|
| 3 |
+
Production: Cloudflare R2 (S3 API). docker-compose: MinIO via R2_ENDPOINT_URL. Plain local dev with
|
| 4 |
+
no R2 settings: LocalStorage, a folder on disk whose presigned URLs are HMAC signed API routes, so
|
| 5 |
+
every caller (browser uploads, the renderer, media players) works unchanged.
|
| 6 |
"""
|
| 7 |
|
| 8 |
import hashlib
|
| 9 |
+
import hmac
|
| 10 |
import mimetypes
|
| 11 |
import re
|
| 12 |
+
import shutil
|
| 13 |
+
import time
|
| 14 |
import uuid
|
| 15 |
from functools import cached_property
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from urllib.parse import quote, urlencode
|
| 18 |
|
| 19 |
import boto3
|
| 20 |
from botocore.client import Config
|
|
|
|
| 134 |
return f"{settings.r2_public_base_url.rstrip('/')}/{key}"
|
| 135 |
|
| 136 |
|
| 137 |
+
def _local_sig(method: str, key: str, exp: int) -> str:
|
| 138 |
+
msg = f"{method.upper()}:{key}:{exp}".encode()
|
| 139 |
+
return hmac.new(settings.jwt_secret.encode(), msg, hashlib.sha256).hexdigest()
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def verify_local_sig(method: str, key: str, exp: int, sig: str) -> bool:
|
| 143 |
+
return exp >= time.time() and hmac.compare_digest(_local_sig(method, key, exp), sig)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
class LocalStorage:
|
| 147 |
+
"""Disk backed stand in for R2 with the same interface."""
|
| 148 |
+
|
| 149 |
+
def __init__(self, root: str | None = None):
|
| 150 |
+
self.root = Path(root or settings.local_storage_dir).resolve()
|
| 151 |
+
self.bucket = "local"
|
| 152 |
+
|
| 153 |
+
def path(self, key: str) -> Path:
|
| 154 |
+
if ".." in key.split("/") or key.startswith("/"):
|
| 155 |
+
raise ValueError(f"Invalid key: {key!r}")
|
| 156 |
+
return self.root / key
|
| 157 |
+
|
| 158 |
+
def ensure_bucket(self) -> None:
|
| 159 |
+
self.root.mkdir(parents=True, exist_ok=True)
|
| 160 |
+
|
| 161 |
+
def put_bytes(self, key: str, data: bytes, content_type: str | None = None) -> str:
|
| 162 |
+
path = self.path(key)
|
| 163 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 164 |
+
path.write_bytes(data)
|
| 165 |
+
return key
|
| 166 |
+
|
| 167 |
+
def put_text(self, key: str, text: str, content_type: str = "text/plain; charset=utf-8") -> str:
|
| 168 |
+
return self.put_bytes(key, text.encode("utf-8"), content_type)
|
| 169 |
+
|
| 170 |
+
def get_bytes(self, key: str) -> bytes:
|
| 171 |
+
path = self.path(key)
|
| 172 |
+
if not path.is_file():
|
| 173 |
+
raise FileNotFoundError(key)
|
| 174 |
+
return path.read_bytes()
|
| 175 |
+
|
| 176 |
+
def head(self, key: str) -> dict | None:
|
| 177 |
+
path = self.path(key)
|
| 178 |
+
if not path.is_file():
|
| 179 |
+
return None
|
| 180 |
+
return {"ContentLength": path.stat().st_size, "ContentType": mimetypes.guess_type(key)[0]}
|
| 181 |
+
|
| 182 |
+
def exists(self, key: str) -> bool:
|
| 183 |
+
return self.path(key).is_file()
|
| 184 |
+
|
| 185 |
+
def delete(self, key: str) -> None:
|
| 186 |
+
self.path(key).unlink(missing_ok=True)
|
| 187 |
+
|
| 188 |
+
def delete_prefix(self, prefix: str) -> int:
|
| 189 |
+
base = self.path(prefix.rstrip("/"))
|
| 190 |
+
if base.is_file():
|
| 191 |
+
base.unlink()
|
| 192 |
+
return 1
|
| 193 |
+
if not base.is_dir():
|
| 194 |
+
return 0
|
| 195 |
+
count = sum(1 for f in base.rglob("*") if f.is_file())
|
| 196 |
+
shutil.rmtree(base, ignore_errors=True)
|
| 197 |
+
return count
|
| 198 |
+
|
| 199 |
+
def _signed(self, method: str, key: str, ttl: int | None, extra: dict | None = None) -> str:
|
| 200 |
+
exp = int(time.time()) + (ttl or settings.r2_presign_ttl_seconds)
|
| 201 |
+
params = {"exp": exp, "sig": _local_sig(method, key, exp), **(extra or {})}
|
| 202 |
+
return f"{settings.api_url.rstrip('/')}/api/storage/local/{quote(key)}?{urlencode(params)}"
|
| 203 |
+
|
| 204 |
+
def presign_get(self, key: str, ttl: int | None = None, download_name: str | None = None) -> str:
|
| 205 |
+
return self._signed("GET", key, ttl, {"download": safe_filename(download_name)} if download_name else None)
|
| 206 |
+
|
| 207 |
+
def presign_put(self, key: str, content_type: str, ttl: int | None = None) -> str:
|
| 208 |
+
return self._signed("PUT", key, ttl)
|
| 209 |
+
|
| 210 |
+
def public_url(self, key: str) -> str | None:
|
| 211 |
+
return None
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def make_storage() -> Storage | LocalStorage:
|
| 215 |
+
return LocalStorage() if settings.use_local_storage else Storage()
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
storage = make_storage()
|
backend/app/services/tts.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ElevenLabs text to speech, cached in storage by (script, voice, settings)."""
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import json
|
| 5 |
+
|
| 6 |
+
import httpx
|
| 7 |
+
|
| 8 |
+
from app.config import settings
|
| 9 |
+
from app.services.storage import keys, storage
|
| 10 |
+
|
| 11 |
+
API = "https://api.elevenlabs.io/v1"
|
| 12 |
+
OUTPUT = "mp3_44100_128"
|
| 13 |
+
BITRATE = 128_000
|
| 14 |
+
|
| 15 |
+
PREMADE = [
|
| 16 |
+
{"voice_id": "21m00Tcm4TlvDq8ikWAM", "name": "Rachel", "category": "premade"},
|
| 17 |
+
{"voice_id": "pNInz6obpgDQGcFMaJgB", "name": "Adam", "category": "premade"},
|
| 18 |
+
{"voice_id": "EXAVITQu4vr4xnSDxMaL", "name": "Bella", "category": "premade"},
|
| 19 |
+
{"voice_id": "ErXwobaYiN019PkySvjV", "name": "Antoni", "category": "premade"},
|
| 20 |
+
{"voice_id": "AZnzlk1XvdvUeBnXmlld", "name": "Domi", "category": "premade"},
|
| 21 |
+
{"voice_id": "TxGEqnHWrfWFTfGW9XjX", "name": "Josh", "category": "premade"},
|
| 22 |
+
]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class TTSError(RuntimeError):
|
| 26 |
+
pass
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _headers() -> dict:
|
| 30 |
+
if not settings.elevenlabs_api_key:
|
| 31 |
+
raise TTSError("ELEVENLABS_API_KEY is not set")
|
| 32 |
+
return {"xi-api-key": settings.elevenlabs_api_key}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def mp3_seconds(data: bytes) -> float:
|
| 36 |
+
return len(data) * 8 / BITRATE
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _voice_settings() -> dict:
|
| 40 |
+
return {"stability": 0.45, "similarity_boost": 0.8, "style": 0.2, "use_speaker_boost": True}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _cache_key(workspace_id, text: str, voice_id: str, variant: str) -> str:
|
| 44 |
+
return keys.tts_cache(workspace_id, text, voice_id,
|
| 45 |
+
json.dumps(_voice_settings(), sort_keys=True) + settings.elevenlabs_model + variant)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def synthesize(workspace_id, text: str, voice_id: str) -> bytes:
|
| 49 |
+
key = _cache_key(workspace_id, text, voice_id, OUTPUT)
|
| 50 |
+
if storage.exists(key):
|
| 51 |
+
return storage.get_bytes(key)
|
| 52 |
+
body = {"text": text, "model_id": settings.elevenlabs_model, "voice_settings": _voice_settings()}
|
| 53 |
+
resp = httpx.post(f"{API}/text-to-speech/{voice_id}", params={"output_format": OUTPUT},
|
| 54 |
+
headers=_headers(), json=body, timeout=120)
|
| 55 |
+
if resp.status_code >= 400:
|
| 56 |
+
raise TTSError(f"ElevenLabs {resp.status_code}: {resp.text[:300]}")
|
| 57 |
+
storage.put_bytes(key, resp.content, "audio/mpeg")
|
| 58 |
+
return resp.content
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def synthesize_with_timestamps(workspace_id, text: str, voice_id: str) -> tuple[bytes, list[dict]]:
|
| 62 |
+
"""Audio plus word timings [{text, startMs, endMs}] for captions."""
|
| 63 |
+
key = _cache_key(workspace_id, text, voice_id, "timestamps") + ".json"
|
| 64 |
+
if storage.exists(key):
|
| 65 |
+
data = json.loads(storage.get_bytes(key))
|
| 66 |
+
else:
|
| 67 |
+
body = {"text": text, "model_id": settings.elevenlabs_model, "voice_settings": _voice_settings()}
|
| 68 |
+
resp = httpx.post(f"{API}/text-to-speech/{voice_id}/with-timestamps", params={"output_format": OUTPUT},
|
| 69 |
+
headers=_headers(), json=body, timeout=120)
|
| 70 |
+
if resp.status_code >= 400:
|
| 71 |
+
raise TTSError(f"ElevenLabs {resp.status_code}: {resp.text[:300]}")
|
| 72 |
+
data = resp.json()
|
| 73 |
+
storage.put_bytes(key, json.dumps(data).encode(), "application/json")
|
| 74 |
+
audio = base64.b64decode(data["audio_base64"])
|
| 75 |
+
return audio, words_from_alignment(data.get("alignment") or {})
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def words_from_alignment(alignment: dict) -> list[dict]:
|
| 79 |
+
chars = alignment.get("characters") or []
|
| 80 |
+
starts = alignment.get("character_start_times_seconds") or []
|
| 81 |
+
ends = alignment.get("character_end_times_seconds") or []
|
| 82 |
+
words: list[dict] = []
|
| 83 |
+
current, start, last_end = "", 0.0, 0.0
|
| 84 |
+
for ch, s, e in zip(chars, starts, ends, strict=False):
|
| 85 |
+
if ch.isspace():
|
| 86 |
+
if current:
|
| 87 |
+
words.append({"text": current, "startMs": int(start * 1000), "endMs": int(last_end * 1000)})
|
| 88 |
+
current = ""
|
| 89 |
+
continue
|
| 90 |
+
if not current:
|
| 91 |
+
start = s
|
| 92 |
+
current += ch
|
| 93 |
+
last_end = e
|
| 94 |
+
if current:
|
| 95 |
+
words.append({"text": current, "startMs": int(start * 1000), "endMs": int(last_end * 1000)})
|
| 96 |
+
return words
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def list_voices() -> list[dict]:
|
| 100 |
+
if not settings.elevenlabs_api_key:
|
| 101 |
+
return PREMADE
|
| 102 |
+
try:
|
| 103 |
+
resp = httpx.get(f"{API}/voices", headers=_headers(), timeout=20)
|
| 104 |
+
resp.raise_for_status()
|
| 105 |
+
except httpx.HTTPError:
|
| 106 |
+
return PREMADE
|
| 107 |
+
return [
|
| 108 |
+
{"voice_id": v["voice_id"], "name": v["name"], "category": v.get("category"),
|
| 109 |
+
"preview_url": v.get("preview_url")}
|
| 110 |
+
for v in resp.json().get("voices", [])
|
| 111 |
+
]
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def clone_voice(name: str, sample: bytes, filename: str, content_type: str) -> str:
|
| 115 |
+
resp = httpx.post(f"{API}/voices/add", headers=_headers(), timeout=120,
|
| 116 |
+
data={"name": name, "description": "Notestack consented voice clone"},
|
| 117 |
+
files={"files": (filename, sample, content_type)})
|
| 118 |
+
if resp.status_code >= 400:
|
| 119 |
+
raise TTSError(f"ElevenLabs {resp.status_code}: {resp.text[:300]}")
|
| 120 |
+
return resp.json()["voice_id"]
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def delete_voice(voice_id: str) -> None:
|
| 124 |
+
httpx.delete(f"{API}/voices/{voice_id}", headers=_headers(), timeout=30)
|
backend/app/services/usage.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Monthly usage against plan limits, from usage_events."""
|
| 2 |
+
|
| 3 |
+
from datetime import UTC, datetime
|
| 4 |
+
|
| 5 |
+
from fastapi import HTTPException
|
| 6 |
+
from sqlalchemy import func, select
|
| 7 |
+
from sqlalchemy.orm import Session
|
| 8 |
+
|
| 9 |
+
from app.models import Artifact, UsageEvent, Workspace
|
| 10 |
+
from app.services.plans import Plan, effective_plan
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def month_start(now: datetime | None = None) -> datetime:
|
| 14 |
+
now = now or datetime.now(UTC)
|
| 15 |
+
return now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def month_usage(db: Session, workspace_id) -> dict:
|
| 19 |
+
since = month_start()
|
| 20 |
+
|
| 21 |
+
def total(kind: str, unit: str) -> float:
|
| 22 |
+
return float(db.scalar(
|
| 23 |
+
select(func.coalesce(func.sum(UsageEvent.quantity), 0)).where(
|
| 24 |
+
UsageEvent.workspace_id == workspace_id, UsageEvent.kind == kind,
|
| 25 |
+
UsageEvent.unit == unit, UsageEvent.created_at >= since,
|
| 26 |
+
)
|
| 27 |
+
) or 0)
|
| 28 |
+
|
| 29 |
+
kits = db.scalar(
|
| 30 |
+
select(func.count()).select_from(Artifact).where(
|
| 31 |
+
Artifact.workspace_id == workspace_id, Artifact.type == "launch_kit", Artifact.created_at >= since
|
| 32 |
+
)
|
| 33 |
+
) or 0
|
| 34 |
+
return {
|
| 35 |
+
"audio_minutes": round(total("tts", "seconds") / 60, 1),
|
| 36 |
+
"video_minutes": round(total("video", "seconds") / 60, 1),
|
| 37 |
+
"launch_kits": kits,
|
| 38 |
+
"llm_tokens": int(total("llm", "tokens")),
|
| 39 |
+
"since": since.isoformat(),
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def usage_report(db: Session, workspace: Workspace) -> dict:
|
| 44 |
+
plan = effective_plan(db, workspace)
|
| 45 |
+
used = month_usage(db, workspace.id)
|
| 46 |
+
return {
|
| 47 |
+
"plan": plan.id,
|
| 48 |
+
"used": used,
|
| 49 |
+
"limits": {"audio_minutes": plan.audio_minutes, "video_minutes": plan.video_minutes,
|
| 50 |
+
"launch_kits": plan.launch_kits, "sources": plan.sources, "indexed_posts": plan.indexed_posts},
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def check_limit(db: Session, workspace: Workspace, kind: str, amount: float = 1) -> Plan:
|
| 55 |
+
"""Raise 402 plan_limit when this request would go over the monthly allowance."""
|
| 56 |
+
plan = effective_plan(db, workspace)
|
| 57 |
+
used = month_usage(db, workspace.id)
|
| 58 |
+
limit = {"audio_minutes": plan.audio_minutes, "video_minutes": plan.video_minutes,
|
| 59 |
+
"launch_kits": plan.launch_kits}[kind]
|
| 60 |
+
if limit >= 0 and used[kind] + amount > limit:
|
| 61 |
+
label = kind.replace("_", " ")
|
| 62 |
+
raise HTTPException(402, {"code": "plan_limit",
|
| 63 |
+
"message": f"This would go over your {limit} {label} this month."})
|
| 64 |
+
return plan
|
backend/app/worker.py
CHANGED
|
@@ -34,16 +34,22 @@ from app.models import (
|
|
| 34 |
Subscription,
|
| 35 |
UpdateEmail,
|
| 36 |
UpdateEmailSend,
|
|
|
|
| 37 |
User,
|
|
|
|
| 38 |
Workspace,
|
| 39 |
WorkspaceMember,
|
| 40 |
)
|
| 41 |
-
from app.pipeline
|
|
|
|
|
|
|
|
|
|
| 42 |
from app.services.email import email_service
|
| 43 |
from app.services.email_verification import purge_old_codes
|
| 44 |
-
from app.services.jobs import claim_next, retry_or_fail, stale_jobs, update_job
|
| 45 |
from app.services.plans import effective_plan
|
| 46 |
-
from app.services.
|
|
|
|
| 47 |
|
| 48 |
log = logging.getLogger("notestack.worker")
|
| 49 |
|
|
@@ -60,55 +66,140 @@ HANDED_OFF = object() # the job continues elsewhere (renderer) and reports back
|
|
| 60 |
# Handlers: (db, job) -> Done | HANDED_OFF. Raise to trigger a retry.
|
| 61 |
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
def handle_ingest(db: Session, job: Job):
|
| 64 |
source = db.get(Source, uuid.UUID(job.params["source_id"]))
|
| 65 |
if not source:
|
| 66 |
return Done({"error": "source deleted"}, "Source no longer exists")
|
| 67 |
plan = effective_plan(db, db.get(Workspace, source.workspace_id))
|
| 68 |
try:
|
| 69 |
-
result = ingest_source(db, source, job, max_posts=
|
| 70 |
except Exception as exc:
|
| 71 |
db.rollback()
|
| 72 |
source.sync_status = "error"
|
| 73 |
source.sync_error = str(exc)[:500]
|
| 74 |
db.commit()
|
|
|
|
|
|
|
| 75 |
raise
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
|
| 79 |
def handle_render(db: Session, job: Job):
|
| 80 |
-
"""
|
| 81 |
-
|
| 82 |
-
artifact = db.get(Artifact, uuid.UUID(p["artifact_id"]))
|
| 83 |
-
ext = "png" if p["format"] == "png" else "mp4"
|
| 84 |
-
key = keys.artifact(artifact.workspace_id, artifact.id, p["composition"].lower(), ext)
|
| 85 |
-
content_type = "image/png" if ext == "png" else "video/mp4"
|
| 86 |
update_job(db, job, progress=0.01, message="T-minus: preparing render")
|
| 87 |
-
|
| 88 |
-
f"{settings.renderer_url}/render",
|
| 89 |
-
headers={"x-internal-token": settings.internal_token},
|
| 90 |
-
json={
|
| 91 |
-
"jobId": str(job.id),
|
| 92 |
-
"compositionId": p["composition"],
|
| 93 |
-
"props": p["props"],
|
| 94 |
-
"format": p["format"],
|
| 95 |
-
"uploadUrl": storage.presign_put(key, content_type, ttl=6 * 3600),
|
| 96 |
-
"uploadContentType": content_type,
|
| 97 |
-
"callbackUrl": f"{settings.api_url}/api/internal/jobs/{job.id}/progress",
|
| 98 |
-
"storageKey": key,
|
| 99 |
-
},
|
| 100 |
-
timeout=30,
|
| 101 |
-
)
|
| 102 |
-
resp.raise_for_status()
|
| 103 |
return HANDED_OFF
|
| 104 |
|
| 105 |
|
| 106 |
HANDLERS: dict[str, Callable[[Session, Job], object]] = {
|
| 107 |
"ingest": handle_ingest,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
"render": handle_render,
|
| 109 |
}
|
| 110 |
|
| 111 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
def run_job(
|
| 113 |
job_id: uuid.UUID,
|
| 114 |
session_factory: Callable[[], Session] = SessionLocal,
|
|
@@ -125,11 +216,18 @@ def run_job(
|
|
| 125 |
return
|
| 126 |
try:
|
| 127 |
outcome = handler(db, job)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
except Exception as exc:
|
| 129 |
log.exception("job %s (%s) attempt %s failed", job.id, job.kind, job.attempts)
|
| 130 |
db.rollback()
|
| 131 |
job = db.get(Job, job_id)
|
| 132 |
-
retry_or_fail(db, job, f"{type(exc).__name__}: {exc}")
|
|
|
|
| 133 |
return
|
| 134 |
if outcome is HANDED_OFF:
|
| 135 |
return
|
|
@@ -219,6 +317,8 @@ class Periodic:
|
|
| 219 |
|
| 220 |
PERIODIC = [
|
| 221 |
Periodic("recover_stale", 60, recover_stale),
|
|
|
|
|
|
|
| 222 |
Periodic("update_email_batch", 300, update_email_batch),
|
| 223 |
Periodic("daily_cleanup", 24 * 3600, daily_cleanup),
|
| 224 |
]
|
|
@@ -240,13 +340,9 @@ def run_periodic(now: float) -> None:
|
|
| 240 |
# Main loop
|
| 241 |
|
| 242 |
|
| 243 |
-
def
|
| 244 |
-
|
| 245 |
-
worker_id = f"{socket.gethostname()}:{os.getpid()}"
|
| 246 |
-
stop = threading.Event()
|
| 247 |
-
for sig in (signal.SIGINT, signal.SIGTERM):
|
| 248 |
-
signal.signal(sig, lambda *_: stop.set())
|
| 249 |
-
|
| 250 |
pool = ThreadPoolExecutor(max_workers=settings.worker_concurrency, thread_name_prefix="job")
|
| 251 |
running: set[Future] = set()
|
| 252 |
log.info("worker %s up, concurrency=%s", worker_id, settings.worker_concurrency)
|
|
@@ -273,5 +369,19 @@ def main() -> None:
|
|
| 273 |
pool.shutdown(wait=True)
|
| 274 |
|
| 275 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
if __name__ == "__main__":
|
| 277 |
main()
|
|
|
|
| 34 |
Subscription,
|
| 35 |
UpdateEmail,
|
| 36 |
UpdateEmailSend,
|
| 37 |
+
Upload,
|
| 38 |
User,
|
| 39 |
+
VoiceConsent,
|
| 40 |
Workspace,
|
| 41 |
WorkspaceMember,
|
| 42 |
)
|
| 43 |
+
from app.pipeline import generate, launchkit, media
|
| 44 |
+
from app.pipeline.generate import NothingToDo
|
| 45 |
+
from app.pipeline.ingest import FeedNotFound, entry_from_upload, extract_article, ingest_source, store_entries
|
| 46 |
+
from app.services import launchpad, tts
|
| 47 |
from app.services.email import email_service
|
| 48 |
from app.services.email_verification import purge_old_codes
|
| 49 |
+
from app.services.jobs import claim_next, create_job, retry_or_fail, stale_jobs, update_job
|
| 50 |
from app.services.plans import effective_plan
|
| 51 |
+
from app.services.renderer import PermanentJobError, request_render
|
| 52 |
+
from app.services.storage import storage
|
| 53 |
|
| 54 |
log = logging.getLogger("notestack.worker")
|
| 55 |
|
|
|
|
| 66 |
# Handlers: (db, job) -> Done | HANDED_OFF. Raise to trigger a retry.
|
| 67 |
|
| 68 |
|
| 69 |
+
def _after_import(db: Session, job: Job, workspace_id: uuid.UUID, changed: list[str]) -> None:
|
| 70 |
+
"""New or changed posts get mapped into the topic constellation automatically."""
|
| 71 |
+
if changed and settings.llm_api_key:
|
| 72 |
+
create_job(db, workspace_id, "topics", {"document_ids": changed}, max_attempts=2)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
def handle_ingest(db: Session, job: Job):
|
| 76 |
source = db.get(Source, uuid.UUID(job.params["source_id"]))
|
| 77 |
if not source:
|
| 78 |
return Done({"error": "source deleted"}, "Source no longer exists")
|
| 79 |
plan = effective_plan(db, db.get(Workspace, source.workspace_id))
|
| 80 |
try:
|
| 81 |
+
result = ingest_source(db, source, job, max_posts=plan.indexed_posts)
|
| 82 |
except Exception as exc:
|
| 83 |
db.rollback()
|
| 84 |
source.sync_status = "error"
|
| 85 |
source.sync_error = str(exc)[:500]
|
| 86 |
db.commit()
|
| 87 |
+
if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code in (401, 403, 404, 410):
|
| 88 |
+
raise PermanentJobError(f"The feed returned {exc.response.status_code}.") from exc
|
| 89 |
raise
|
| 90 |
+
_after_import(db, job, source.workspace_id, result.get("changed", []))
|
| 91 |
+
return Done(result, f"All posts in orbit: {result['indexed']} new or updated")
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def handle_import_url(db: Session, job: Job):
|
| 95 |
+
source = db.get(Source, uuid.UUID(job.params["source_id"]))
|
| 96 |
+
update_job(db, job, progress=0.1, message="Fetching the article")
|
| 97 |
+
try:
|
| 98 |
+
entry = extract_article(job.params["url"])
|
| 99 |
+
except FeedNotFound as exc:
|
| 100 |
+
raise PermanentJobError(str(exc)) from exc
|
| 101 |
+
indexed, _, changed = store_entries(db, source, [entry])
|
| 102 |
+
_after_import(db, job, source.workspace_id, [str(i) for i in changed])
|
| 103 |
+
return Done({"indexed": indexed, "document_ids": [str(i) for i in changed]}, f"{entry.title[:80]} is in orbit")
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def handle_import_upload(db: Session, job: Job):
|
| 107 |
+
source = db.get(Source, uuid.UUID(job.params["source_id"]))
|
| 108 |
+
upload = db.get(Upload, uuid.UUID(job.params["upload_id"]))
|
| 109 |
+
update_job(db, job, progress=0.1, message=f"Reading {upload.filename}")
|
| 110 |
+
entry = entry_from_upload(upload.filename, upload.content_type, storage.get_bytes(upload.key))
|
| 111 |
+
if not entry.sections and not entry.html:
|
| 112 |
+
raise PermanentJobError("We could not read any text in that file.")
|
| 113 |
+
indexed, _, changed = store_entries(db, source, [entry])
|
| 114 |
+
_after_import(db, job, source.workspace_id, [str(i) for i in changed])
|
| 115 |
+
return Done({"indexed": indexed, "document_ids": [str(i) for i in changed]}, f"{entry.title[:80]} is in orbit")
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def handle_topics(db: Session, job: Job):
|
| 119 |
+
return Done(generate.extract_topics(db, job, job.workspace_id, job.params.get("document_ids")), "Topic map ready")
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def handle_voice_profile(db: Session, job: Job):
|
| 123 |
+
try:
|
| 124 |
+
result = generate.build_voice_profile(db, job, job.workspace_id, job.params.get("document_ids") or [])
|
| 125 |
+
except NothingToDo as exc:
|
| 126 |
+
raise PermanentJobError(str(exc)) from exc
|
| 127 |
+
return Done(result, "Voice profile ready")
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def handle_voice_clone(db: Session, job: Job):
|
| 131 |
+
consent = db.get(VoiceConsent, uuid.UUID(job.params["consent_id"]))
|
| 132 |
+
if not consent or consent.revoked_at:
|
| 133 |
+
return Done({}, "Consent was revoked")
|
| 134 |
+
update_job(db, job, progress=0.2, message="Sending your sample to ElevenLabs")
|
| 135 |
+
head = storage.head(consent.sample_key) or {}
|
| 136 |
+
try:
|
| 137 |
+
voice_id = tts.clone_voice(f"Notestack {consent.workspace_id}", storage.get_bytes(consent.sample_key),
|
| 138 |
+
consent.sample_key.rsplit("/", 1)[-1], head.get("ContentType") or "audio/webm")
|
| 139 |
+
except tts.TTSError as exc:
|
| 140 |
+
raise PermanentJobError(str(exc)) from exc
|
| 141 |
+
consent.elevenlabs_voice_id = voice_id
|
| 142 |
+
db.commit()
|
| 143 |
+
return Done({"voice_id": voice_id}, "Your voice is ready")
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def handle_resurface(db: Session, job: Job):
|
| 147 |
+
return Done(generate.score_evergreen(db, job, job.workspace_id), "Evergreen scores updated")
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def _artifact_handler(fn: Callable[[Session, Job, Artifact], object], done_message: str):
|
| 151 |
+
def handler(db: Session, job: Job):
|
| 152 |
+
artifact = db.get(Artifact, uuid.UUID(job.params["artifact_id"]))
|
| 153 |
+
if not artifact:
|
| 154 |
+
return Done({}, "Artifact was deleted")
|
| 155 |
+
artifact.status = "generating"
|
| 156 |
+
db.commit()
|
| 157 |
+
try:
|
| 158 |
+
result = fn(db, job, artifact)
|
| 159 |
+
except (NothingToDo, tts.TTSError) as exc:
|
| 160 |
+
raise PermanentJobError(str(exc)) from exc
|
| 161 |
+
if result is None:
|
| 162 |
+
return HANDED_OFF # the renderer reports back through /api/internal/jobs/{id}/progress
|
| 163 |
+
return Done(result if isinstance(result, dict) else {}, done_message)
|
| 164 |
+
|
| 165 |
+
return handler
|
| 166 |
|
| 167 |
|
| 168 |
def handle_render(db: Session, job: Job):
|
| 169 |
+
"""Direct render of caller supplied props (POST /api/artifacts/{id}/render)."""
|
| 170 |
+
artifact = db.get(Artifact, uuid.UUID(job.params["artifact_id"]))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
update_job(db, job, progress=0.01, message="T-minus: preparing render")
|
| 172 |
+
request_render(job, artifact, job.params["composition"], job.params.get("props") or {})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
return HANDED_OFF
|
| 174 |
|
| 175 |
|
| 176 |
HANDLERS: dict[str, Callable[[Session, Job], object]] = {
|
| 177 |
"ingest": handle_ingest,
|
| 178 |
+
"import_url": handle_import_url,
|
| 179 |
+
"import_upload": handle_import_upload,
|
| 180 |
+
"topics": handle_topics,
|
| 181 |
+
"voice_profile": handle_voice_profile,
|
| 182 |
+
"voice_clone": handle_voice_clone,
|
| 183 |
+
"resurface_scan": handle_resurface,
|
| 184 |
+
"summary": _artifact_handler(generate.summarize_notebook, "Summary ready"),
|
| 185 |
+
"audio_overview": _artifact_handler(media.audio_overview, "Audio overview ready"),
|
| 186 |
+
"video": _artifact_handler(media.make_video, "Video ready"),
|
| 187 |
+
"quote_card": _artifact_handler(media.make_quote_card, "Quote card ready"),
|
| 188 |
+
"carousel": _artifact_handler(media.render_carousel, "Carousel ready"),
|
| 189 |
+
"launch_kit": _artifact_handler(launchkit.build_launch_kit, "Launch Kit ready"),
|
| 190 |
"render": handle_render,
|
| 191 |
}
|
| 192 |
|
| 193 |
|
| 194 |
+
def mark_artifact_failed(db: Session, job: Job, error: str) -> None:
|
| 195 |
+
if job.artifact_id:
|
| 196 |
+
artifact = db.get(Artifact, job.artifact_id)
|
| 197 |
+
if artifact:
|
| 198 |
+
artifact.status = "failed"
|
| 199 |
+
artifact.content_json = {**(artifact.content_json or {}), "error": error[:500]}
|
| 200 |
+
db.commit()
|
| 201 |
+
|
| 202 |
+
|
| 203 |
def run_job(
|
| 204 |
job_id: uuid.UUID,
|
| 205 |
session_factory: Callable[[], Session] = SessionLocal,
|
|
|
|
| 216 |
return
|
| 217 |
try:
|
| 218 |
outcome = handler(db, job)
|
| 219 |
+
except PermanentJobError as exc:
|
| 220 |
+
db.rollback()
|
| 221 |
+
job = db.get(Job, job_id)
|
| 222 |
+
update_job(db, job, status="failed", error=str(exc), message=str(exc)[:500])
|
| 223 |
+
mark_artifact_failed(db, job, str(exc))
|
| 224 |
+
return
|
| 225 |
except Exception as exc:
|
| 226 |
log.exception("job %s (%s) attempt %s failed", job.id, job.kind, job.attempts)
|
| 227 |
db.rollback()
|
| 228 |
job = db.get(Job, job_id)
|
| 229 |
+
if not retry_or_fail(db, job, f"{type(exc).__name__}: {exc}"):
|
| 230 |
+
mark_artifact_failed(db, job, f"{type(exc).__name__}: {exc}")
|
| 231 |
return
|
| 232 |
if outcome is HANDED_OFF:
|
| 233 |
return
|
|
|
|
| 317 |
|
| 318 |
PERIODIC = [
|
| 319 |
Periodic("recover_stale", 60, recover_stale),
|
| 320 |
+
Periodic("publish_due", 30, launchpad.publish_due),
|
| 321 |
+
Periodic("sync_engagement", 3600, launchpad.sync_engagement),
|
| 322 |
Periodic("update_email_batch", 300, update_email_batch),
|
| 323 |
Periodic("daily_cleanup", 24 * 3600, daily_cleanup),
|
| 324 |
]
|
|
|
|
| 340 |
# Main loop
|
| 341 |
|
| 342 |
|
| 343 |
+
def run_loop(stop: threading.Event, worker_id: str | None = None) -> None:
|
| 344 |
+
"""Claim and run jobs until `stop` is set. Used by the CLI and, in development, by the API."""
|
| 345 |
+
worker_id = worker_id or f"{socket.gethostname()}:{os.getpid()}"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 346 |
pool = ThreadPoolExecutor(max_workers=settings.worker_concurrency, thread_name_prefix="job")
|
| 347 |
running: set[Future] = set()
|
| 348 |
log.info("worker %s up, concurrency=%s", worker_id, settings.worker_concurrency)
|
|
|
|
| 369 |
pool.shutdown(wait=True)
|
| 370 |
|
| 371 |
|
| 372 |
+
def start_background(stop: threading.Event) -> threading.Thread:
|
| 373 |
+
thread = threading.Thread(target=run_loop, args=(stop, f"api:{os.getpid()}"), name="worker", daemon=True)
|
| 374 |
+
thread.start()
|
| 375 |
+
return thread
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
def main() -> None:
|
| 379 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
| 380 |
+
stop = threading.Event()
|
| 381 |
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
| 382 |
+
signal.signal(sig, lambda *_: stop.set())
|
| 383 |
+
run_loop(stop)
|
| 384 |
+
|
| 385 |
+
|
| 386 |
if __name__ == "__main__":
|
| 387 |
main()
|
backend/requirements.txt
CHANGED
|
@@ -16,3 +16,4 @@ httpx==0.28.*
|
|
| 16 |
dspy==2.6.*
|
| 17 |
feedparser==6.*
|
| 18 |
beautifulsoup4==4.*
|
|
|
|
|
|
| 16 |
dspy==2.6.*
|
| 17 |
feedparser==6.*
|
| 18 |
beautifulsoup4==4.*
|
| 19 |
+
pypdf==5.*
|
backend/tests/conftest.py
CHANGED
|
@@ -1,6 +1,13 @@
|
|
| 1 |
import os
|
|
|
|
| 2 |
|
| 3 |
os.environ.setdefault("DATABASE_URL", "sqlite://")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
os.environ.setdefault("EMAIL_PROVIDER", "console")
|
| 5 |
os.environ.setdefault("JWT_SECRET", "test-secret-that-is-at-least-32-bytes-long")
|
| 6 |
os.environ.setdefault("BILLING_ENABLED", "false")
|
|
@@ -18,15 +25,41 @@ from app.services.email import ConsoleEmailProvider # noqa: E402
|
|
| 18 |
|
| 19 |
|
| 20 |
@pytest.fixture()
|
| 21 |
-
def
|
| 22 |
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
| 23 |
Base.metadata.create_all(engine)
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
yield session
|
| 27 |
session.close()
|
| 28 |
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
@pytest.fixture()
|
| 31 |
def client(db_session):
|
| 32 |
app.dependency_overrides[get_db] = lambda: db_session
|
|
|
|
| 1 |
import os
|
| 2 |
+
import tempfile
|
| 3 |
|
| 4 |
os.environ.setdefault("DATABASE_URL", "sqlite://")
|
| 5 |
+
os.environ["STORAGE_BACKEND"] = "local"
|
| 6 |
+
os.environ["LOCAL_STORAGE_DIR"] = tempfile.mkdtemp(prefix="notestack-storage-")
|
| 7 |
+
os.environ["CORPUS_CACHE_DIR"] = tempfile.mkdtemp(prefix="notestack-corpus-")
|
| 8 |
+
os.environ["RUN_WORKER_IN_API"] = "false"
|
| 9 |
+
os.environ["LLM_API_KEY"] = ""
|
| 10 |
+
os.environ["ELEVENLABS_API_KEY"] = ""
|
| 11 |
os.environ.setdefault("EMAIL_PROVIDER", "console")
|
| 12 |
os.environ.setdefault("JWT_SECRET", "test-secret-that-is-at-least-32-bytes-long")
|
| 13 |
os.environ.setdefault("BILLING_ENABLED", "false")
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
@pytest.fixture()
|
| 28 |
+
def session_factory():
|
| 29 |
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
| 30 |
Base.metadata.create_all(engine)
|
| 31 |
+
return sessionmaker(bind=engine, expire_on_commit=False)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@pytest.fixture()
|
| 35 |
+
def db_session(session_factory):
|
| 36 |
+
session = session_factory()
|
| 37 |
yield session
|
| 38 |
session.close()
|
| 39 |
|
| 40 |
|
| 41 |
+
@pytest.fixture()
|
| 42 |
+
def run_jobs(session_factory, db_session):
|
| 43 |
+
"""Drain the queue the way the worker does, in process. Returns the jobs that ran."""
|
| 44 |
+
from app.models import Job
|
| 45 |
+
from app.services.jobs import claim_next
|
| 46 |
+
from app.worker import run_job
|
| 47 |
+
|
| 48 |
+
def drain(limit: int = 50) -> list:
|
| 49 |
+
ran = []
|
| 50 |
+
for _ in range(limit):
|
| 51 |
+
with session_factory() as db:
|
| 52 |
+
job_id = claim_next(db, "test")
|
| 53 |
+
if not job_id:
|
| 54 |
+
break
|
| 55 |
+
run_job(job_id, session_factory=session_factory)
|
| 56 |
+
ran.append(job_id)
|
| 57 |
+
db_session.expire_all()
|
| 58 |
+
return [db_session.get(Job, j) for j in ran]
|
| 59 |
+
|
| 60 |
+
return drain
|
| 61 |
+
|
| 62 |
+
|
| 63 |
@pytest.fixture()
|
| 64 |
def client(db_session):
|
| 65 |
app.dependency_overrides[get_db] = lambda: db_session
|
backend/tests/test_buildout.py
ADDED
|
@@ -0,0 +1,612 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""End to end coverage for sources, notebooks, generation, Launchpad and settings. External services
|
| 2 |
+
(feeds, LLM, ElevenLabs, renderer, social APIs) are stubbed at their module seams."""
|
| 3 |
+
|
| 4 |
+
import time
|
| 5 |
+
import uuid
|
| 6 |
+
from datetime import UTC, datetime, timedelta
|
| 7 |
+
|
| 8 |
+
import httpx
|
| 9 |
+
import pytest
|
| 10 |
+
|
| 11 |
+
from app.models import (
|
| 12 |
+
Artifact,
|
| 13 |
+
CalendarItem,
|
| 14 |
+
Document,
|
| 15 |
+
EngagementEvent,
|
| 16 |
+
Job,
|
| 17 |
+
SocialAccount,
|
| 18 |
+
Source,
|
| 19 |
+
TrackedLink,
|
| 20 |
+
UsageEvent,
|
| 21 |
+
Workspace,
|
| 22 |
+
)
|
| 23 |
+
from app.services.email import ConsoleEmailProvider
|
| 24 |
+
from tests.conftest import last_code
|
| 25 |
+
|
| 26 |
+
FEED = """<?xml version="1.0"?>
|
| 27 |
+
<rss version="2.0"><channel><title>Ada Writes</title><link>https://ada.example.com</link>
|
| 28 |
+
<item><title>On Pricing</title><link>https://ada.example.com/p/on-pricing</link>
|
| 29 |
+
<pubDate>Mon, 03 Mar 2025 10:00:00 GMT</pubDate>
|
| 30 |
+
<description><![CDATA[<h2>Why price high</h2><p>Charging more made my readers take the work seriously.</p>
|
| 31 |
+
<p>The paid tier doubled after I raised the price to ten dollars.</p>]]></description></item>
|
| 32 |
+
<item><title>Writing Every Day</title><link>https://ada.example.com/p/writing-every-day</link>
|
| 33 |
+
<pubDate>Tue, 04 Jun 2024 10:00:00 GMT</pubDate>
|
| 34 |
+
<description><![CDATA[<p>I write five hundred words before breakfast.</p>
|
| 35 |
+
<p>Habits beat inspiration.</p>]]></description>
|
| 36 |
+
</item></channel></rss>"""
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class FakeResponse:
|
| 40 |
+
def __init__(self, status: int = 200, text: str = "", content_type: str = "application/rss+xml",
|
| 41 |
+
url: str = "", json_data=None):
|
| 42 |
+
self.status_code = status
|
| 43 |
+
self.text = text
|
| 44 |
+
self.content = text.encode()
|
| 45 |
+
self.headers = {"content-type": content_type}
|
| 46 |
+
self.url = url
|
| 47 |
+
self._json = json_data
|
| 48 |
+
|
| 49 |
+
def json(self):
|
| 50 |
+
return self._json
|
| 51 |
+
|
| 52 |
+
def raise_for_status(self):
|
| 53 |
+
if self.status_code >= 400:
|
| 54 |
+
raise httpx.HTTPStatusError("bad", request=None, response=self)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def fake_web(routes: dict):
|
| 58 |
+
def get(url, *args, **kwargs):
|
| 59 |
+
for prefix, resp in routes.items():
|
| 60 |
+
if url.startswith(prefix):
|
| 61 |
+
resp.url = url
|
| 62 |
+
return resp
|
| 63 |
+
return FakeResponse(404, "not found", "text/html", url)
|
| 64 |
+
|
| 65 |
+
return get
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@pytest.fixture()
|
| 69 |
+
def auth(client):
|
| 70 |
+
client.post("/api/auth/email/register/start", json={"email": "ada@example.com", "password": "stardust-42",
|
| 71 |
+
"name": "Ada Lovelace"})
|
| 72 |
+
r = client.post("/api/auth/email/register/verify", json={"email": "ada@example.com", "code": last_code()})
|
| 73 |
+
return {"Authorization": f"Bearer {r.json()['access_token']}"}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@pytest.fixture()
|
| 77 |
+
def feed(monkeypatch):
|
| 78 |
+
monkeypatch.setattr(httpx, "get", fake_web({"https://ada.example.com/feed": FakeResponse(200, FEED)}))
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@pytest.fixture()
|
| 82 |
+
def llm(monkeypatch):
|
| 83 |
+
"""Canned outputs per signature. Tests override entries in the returned dict."""
|
| 84 |
+
from app.llm import run
|
| 85 |
+
|
| 86 |
+
outputs: dict = {}
|
| 87 |
+
calls: list[str] = []
|
| 88 |
+
|
| 89 |
+
def predict(signature, *, db, workspace_id, job=None, lm=None, **inputs):
|
| 90 |
+
name = signature.__name__
|
| 91 |
+
calls.append(name)
|
| 92 |
+
value = outputs[name]
|
| 93 |
+
return value(**inputs) if callable(value) else value
|
| 94 |
+
|
| 95 |
+
monkeypatch.setattr(run, "predict", predict)
|
| 96 |
+
outputs["_calls"] = calls
|
| 97 |
+
return outputs
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def connect(client, auth, run_jobs):
|
| 101 |
+
r = client.post("/api/sources", json={"url": "ada.example.com"}, headers=auth)
|
| 102 |
+
assert r.status_code == 200, r.text
|
| 103 |
+
jobs = run_jobs()
|
| 104 |
+
assert jobs[0].status == "done", jobs[0].error
|
| 105 |
+
return r.json()["source"]
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def docs(client, auth):
|
| 109 |
+
return client.get("/api/documents", headers=auth).json()["items"]
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# Storage
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def test_local_storage_signed_urls(client):
|
| 116 |
+
from app.services.storage import storage
|
| 117 |
+
|
| 118 |
+
storage.put_bytes("ws/x/file.txt", b"0123456789")
|
| 119 |
+
url = storage.presign_get("ws/x/file.txt").split("8000", 1)[1]
|
| 120 |
+
assert client.get(url).content == b"0123456789"
|
| 121 |
+
ranged = client.get(url, headers={"Range": "bytes=2-4"})
|
| 122 |
+
assert ranged.status_code == 206 and ranged.content == b"234"
|
| 123 |
+
assert client.get(url.replace("sig=", "sig=0")).status_code == 403
|
| 124 |
+
expired = storage._signed("GET", "ws/x/file.txt", -10)
|
| 125 |
+
assert client.get(expired.split("8000", 1)[1]).status_code == 403
|
| 126 |
+
put = storage.presign_put("ws/x/new.bin", "application/octet-stream").split("8000", 1)[1]
|
| 127 |
+
assert client.put(put, content=b"abc").status_code == 200
|
| 128 |
+
assert storage.get_bytes("ws/x/new.bin") == b"abc"
|
| 129 |
+
assert client.get(url.replace("file.txt", "new.bin")).status_code == 403 # signature is per key
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# Sources
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def test_connect_rejects_missing_feed(client, auth, monkeypatch):
|
| 136 |
+
monkeypatch.setattr(httpx, "get", fake_web({}))
|
| 137 |
+
r = client.post("/api/sources", json={"url": "nobody.substack.com"}, headers=auth)
|
| 138 |
+
assert r.status_code == 422
|
| 139 |
+
assert r.json()["detail"]["code"] == "feed_not_found"
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def test_connect_ingest_read_and_delete(client, auth, run_jobs, feed, db_session):
|
| 143 |
+
source = connect(client, auth, run_jobs)
|
| 144 |
+
assert source["title"] == "Ada Writes"
|
| 145 |
+
items = docs(client, auth)
|
| 146 |
+
assert {d["title"] for d in items} == {"On Pricing", "Writing Every Day"}
|
| 147 |
+
listed = client.get("/api/sources", headers=auth).json()
|
| 148 |
+
assert listed[0]["document_count"] == 2 and listed[0]["sync_status"] == "ok"
|
| 149 |
+
|
| 150 |
+
post = client.get(f"/api/documents/{items[0]['id']}", headers=auth).json()
|
| 151 |
+
assert post["lines"][0] == "---" and any("paid tier" in ln or "breakfast" in ln for ln in post["lines"])
|
| 152 |
+
assert client.get("/api/documents", params={"q": "breakfast"}, headers=auth).json()["total"] == 1
|
| 153 |
+
|
| 154 |
+
# Re-sync is a no-op for unchanged posts.
|
| 155 |
+
client.post(f"/api/sources/{source['id']}/sync", headers=auth)
|
| 156 |
+
assert run_jobs()[0].result["skipped"] == 2
|
| 157 |
+
|
| 158 |
+
r = client.delete(f"/api/sources/{source['id']}", headers=auth)
|
| 159 |
+
assert r.json()["removed_posts"] == 2
|
| 160 |
+
assert docs(client, auth) == []
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def test_import_url_and_markdown_upload(client, auth, run_jobs, monkeypatch):
|
| 164 |
+
article = FakeResponse(200, "<html><head><title>Loose Post</title>"
|
| 165 |
+
"<meta property='article:published_time' content='2024-01-02T00:00:00Z'></head>"
|
| 166 |
+
"<body><article><h1>Loose Post</h1><p>Standalone essay text lives here.</p>"
|
| 167 |
+
"</article></body></html>", "text/html")
|
| 168 |
+
monkeypatch.setattr(httpx, "get", fake_web({"https://elsewhere.example.com/essay": article}))
|
| 169 |
+
r = client.post("/api/sources/url", json={"url": "https://elsewhere.example.com/essay"}, headers=auth)
|
| 170 |
+
assert r.status_code == 200
|
| 171 |
+
assert run_jobs()[0].status == "done"
|
| 172 |
+
|
| 173 |
+
start = client.post("/api/storage/uploads", json={"filename": "notes.md", "content_type": "text/markdown",
|
| 174 |
+
"size_bytes": 60}, headers=auth).json()
|
| 175 |
+
client.put(start["upload_url"].split("8000", 1)[1], content=b"# Field Notes\n\n## Day one\nIt rained all day.\n")
|
| 176 |
+
client.post(f"/api/storage/uploads/{start['upload_id']}/complete", headers=auth)
|
| 177 |
+
r = client.post("/api/sources/upload", json={"upload_id": start["upload_id"]}, headers=auth)
|
| 178 |
+
assert run_jobs()[0].status == "done", r.text
|
| 179 |
+
titles = {d["title"] for d in docs(client, auth)}
|
| 180 |
+
assert titles == {"Loose Post", "Field Notes"}
|
| 181 |
+
sources = client.get("/api/sources", headers=auth).json()
|
| 182 |
+
assert len(sources) == 1 and sources[0]["is_imports"]
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
# Notebooks
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def test_notebook_crud_chat_history_and_summary(client, auth, run_jobs, feed, llm, db_session):
|
| 189 |
+
connect(client, auth, run_jobs)
|
| 190 |
+
items = docs(client, auth)
|
| 191 |
+
pricing = next(d for d in items if d["title"] == "On Pricing")
|
| 192 |
+
nb = client.post("/api/notebooks", json={"title": "Money", "document_ids": [pricing["id"]]}, headers=auth).json()
|
| 193 |
+
assert nb["added"] == 1
|
| 194 |
+
assert client.patch(f"/api/notebooks/{nb['id']}", json={"title": "Pricing"}, headers=auth).json()["title"] == \
|
| 195 |
+
"Pricing"
|
| 196 |
+
listed = client.get("/api/notebooks", headers=auth).json()
|
| 197 |
+
assert listed[0]["document_count"] == 1
|
| 198 |
+
|
| 199 |
+
path = pricing["path"]
|
| 200 |
+
llm["SummarizeNotebook"] = {
|
| 201 |
+
"summary": "Raising prices worked [1]. Also the moon is cheese [2].",
|
| 202 |
+
"themes": ["Pricing"],
|
| 203 |
+
"citations": [{"marker": 1, "path": path, "line_start": 12, "line_end": 14},
|
| 204 |
+
{"marker": 2, "path": "sources/fake.md", "line_start": 1, "line_end": 2}],
|
| 205 |
+
}
|
| 206 |
+
art = client.post("/api/artifacts/generate", json={"type": "summary", "notebook_id": nb["id"]},
|
| 207 |
+
headers=auth).json()
|
| 208 |
+
assert art["job"]["status"] == "queued"
|
| 209 |
+
run_jobs()
|
| 210 |
+
done = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()
|
| 211 |
+
assert done["status"] == "ready"
|
| 212 |
+
assert len(done["content"]["citations"]) == 1 # the invented citation was dropped
|
| 213 |
+
assert "[2]" not in done["content"]["summary"]
|
| 214 |
+
assert client.get(f"/api/notebooks/{nb['id']}", headers=auth).json()["summary"].startswith("Raising")
|
| 215 |
+
|
| 216 |
+
# Chat history endpoints
|
| 217 |
+
from app.models import Chat, Message
|
| 218 |
+
|
| 219 |
+
chat = Chat(workspace_id=db_session.get(Source, uuid.UUID(pricing["source_id"])).workspace_id,
|
| 220 |
+
notebook_id=uuid.UUID(nb["id"]), title="q")
|
| 221 |
+
db_session.add(chat)
|
| 222 |
+
db_session.flush()
|
| 223 |
+
db_session.add(Message(chat_id=chat.id, role="user", content="Why price high?"))
|
| 224 |
+
db_session.commit()
|
| 225 |
+
chats = client.get(f"/api/notebooks/{nb['id']}/chats", headers=auth).json()
|
| 226 |
+
assert chats[0]["id"] == str(chat.id)
|
| 227 |
+
assert client.get(f"/api/notebooks/chats/{chat.id}/messages", headers=auth).json()[0]["text"] == "Why price high?"
|
| 228 |
+
|
| 229 |
+
client.delete(f"/api/notebooks/{nb['id']}/documents/{pricing['id']}", headers=auth)
|
| 230 |
+
assert client.get(f"/api/notebooks/{nb['id']}", headers=auth).json()["documents"] == []
|
| 231 |
+
assert client.delete(f"/api/notebooks/{nb['id']}", headers=auth).status_code == 200
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def test_summary_of_empty_notebook_fails_cleanly(client, auth, run_jobs, llm):
|
| 235 |
+
nb = client.post("/api/notebooks", json={"title": "Empty"}, headers=auth).json()
|
| 236 |
+
art = client.post("/api/artifacts/generate", json={"type": "summary", "notebook_id": nb["id"]},
|
| 237 |
+
headers=auth).json()
|
| 238 |
+
job = run_jobs()[0]
|
| 239 |
+
assert job.status == "failed" and job.attempts == 1 # permanent, no retries
|
| 240 |
+
assert client.get(f"/api/artifacts/{art['id']}", headers=auth).json()["status"] == "failed"
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
# Topics, voice, resurfacing
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def test_topic_map_and_notebook_from_topic(client, auth, run_jobs, feed, llm):
|
| 247 |
+
connect(client, auth, run_jobs)
|
| 248 |
+
llm["ExtractTopics"] = lambda title, **_: {"topics": [
|
| 249 |
+
{"name": "Pricing" if "Pricing" in title else "Habits", "weight": 0.9},
|
| 250 |
+
{"name": "Writing Business", "weight": 0.5},
|
| 251 |
+
{"name": "Writing business", "weight": 0.4},
|
| 252 |
+
]}
|
| 253 |
+
llm["ConsolidateTopics"] = {"groups": [
|
| 254 |
+
{"canonical": "Writing Business", "members": ["Writing Business", "Writing business"],
|
| 255 |
+
"summary": "Making writing pay."},
|
| 256 |
+
]}
|
| 257 |
+
client.post("/api/topics/rebuild", headers=auth)
|
| 258 |
+
assert run_jobs()[0].status == "done"
|
| 259 |
+
graph = client.get("/api/topics", headers=auth).json()
|
| 260 |
+
names = {n["name"]: n for n in graph["nodes"]}
|
| 261 |
+
assert set(names) == {"Pricing", "Habits", "Writing Business"}
|
| 262 |
+
assert names["Writing Business"]["post_count"] == 2
|
| 263 |
+
assert len(graph["edges"]) == 2
|
| 264 |
+
detail = client.get(f"/api/topics/{names['Writing Business']['id']}", headers=auth).json()
|
| 265 |
+
assert len(detail["posts"]) == 2
|
| 266 |
+
nb = client.post(f"/api/notebooks/from-topic/{names['Pricing']['id']}", headers=auth).json()
|
| 267 |
+
assert nb["added"] == 1
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def test_voice_profile_build_and_edit(client, auth, run_jobs, feed, llm):
|
| 271 |
+
connect(client, auth, run_jobs)
|
| 272 |
+
profile = {"tone": ["warm"], "sentence_length": "short", "vocabulary": ["readers"], "structure_habits": [],
|
| 273 |
+
"openings": ["A number"], "avoid": ["jargon"], "summary": "Plain and direct."}
|
| 274 |
+
llm["BuildVoiceProfile"] = {"profile": profile}
|
| 275 |
+
client.post("/api/voice/build", json={"document_ids": []}, headers=auth)
|
| 276 |
+
assert run_jobs()[0].status == "done"
|
| 277 |
+
voice = client.get("/api/voice", headers=auth).json()
|
| 278 |
+
assert voice["profile"]["summary"] == "Plain and direct."
|
| 279 |
+
r = client.put("/api/voice", json={"host_voices": {"host_a": "abc", "host_b": "def"}}, headers=auth)
|
| 280 |
+
assert r.json()["host_voices"] == {"host_a": "abc", "host_b": "def"}
|
| 281 |
+
bad = client.post("/api/voice/consent", json={"upload_id": str(uuid.uuid4()), "agreed": True,
|
| 282 |
+
"consent_text": "nope"}, headers=auth)
|
| 283 |
+
assert bad.status_code == 400
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def test_resurface_scan_and_suggestions(client, auth, run_jobs, feed, llm):
|
| 287 |
+
connect(client, auth, run_jobs)
|
| 288 |
+
llm["EvergreenScore"] = lambda title, **_: {"score": 0.9 if "Writing" in title else 0.2,
|
| 289 |
+
"reason": "Timeless habit advice", "angle": "Still true"}
|
| 290 |
+
client.post("/api/resurface/scan", headers=auth)
|
| 291 |
+
assert run_jobs()[0].status == "done"
|
| 292 |
+
data = client.get("/api/resurface", headers=auth).json()
|
| 293 |
+
assert data["unscored"] == 0
|
| 294 |
+
assert data["evergreen"][0]["title"] == "Writing Every Day"
|
| 295 |
+
assert data["evergreen"][0]["angle"] == "Still true"
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
# Audio, video, launch kit
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def test_audio_overview_and_limits(client, auth, run_jobs, feed, llm, monkeypatch, db_session):
|
| 302 |
+
from app.config import settings
|
| 303 |
+
from app.services import tts
|
| 304 |
+
|
| 305 |
+
connect(client, auth, run_jobs)
|
| 306 |
+
doc = docs(client, auth)[0]
|
| 307 |
+
monkeypatch.setattr(settings, "elevenlabs_api_key", "test")
|
| 308 |
+
monkeypatch.setattr(tts, "synthesize", lambda ws, text, voice: b"\x00" * 16000) # 1 second at 128 kbps
|
| 309 |
+
llm["PodcastScript"] = {"lines": [
|
| 310 |
+
{"speaker": "host_a", "text": "Welcome in.", "sources": []},
|
| 311 |
+
{"speaker": "host_b", "text": "Prices went up.", "sources": [{"path": doc["path"], "line_start": 12,
|
| 312 |
+
"line_end": 13}]},
|
| 313 |
+
]}
|
| 314 |
+
art = client.post("/api/artifacts/generate", json={"type": "audio_overview", "document_id": doc["id"],
|
| 315 |
+
"minutes": 5}, headers=auth).json()
|
| 316 |
+
run_jobs()
|
| 317 |
+
done = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()
|
| 318 |
+
assert done["status"] == "ready", done["content"]
|
| 319 |
+
assert done["content"]["duration_s"] == 2.0
|
| 320 |
+
assert done["url"] and client.get(done["url"].split("8000", 1)[1]).content == b"\x00" * 32000
|
| 321 |
+
|
| 322 |
+
ws_id = db_session.get(Document, uuid.UUID(doc["id"])).workspace_id
|
| 323 |
+
db_session.add(UsageEvent(workspace_id=ws_id, kind="tts", provider="elevenlabs", quantity=240 * 60,
|
| 324 |
+
unit="seconds"))
|
| 325 |
+
db_session.commit()
|
| 326 |
+
r = client.post("/api/artifacts/generate", json={"type": "audio_overview", "document_id": doc["id"],
|
| 327 |
+
"minutes": 5}, headers=auth)
|
| 328 |
+
assert r.status_code == 402 and r.json()["detail"]["code"] == "plan_limit"
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
def test_video_hands_off_and_renderer_callback(client, auth, run_jobs, feed, llm, monkeypatch):
|
| 332 |
+
from app.pipeline import media
|
| 333 |
+
|
| 334 |
+
connect(client, auth, run_jobs)
|
| 335 |
+
doc = docs(client, auth)[0]
|
| 336 |
+
sent = {}
|
| 337 |
+
monkeypatch.setattr(media, "request_render", lambda job, artifact, comp, props=None, stills=None:
|
| 338 |
+
sent.update(comp=comp, props=props, job=str(job.id)))
|
| 339 |
+
llm["VideoStoryboard"] = {"scenes": [
|
| 340 |
+
{"type": "title", "on_screen_text": "Charge more", "narration": "", "duration_hint_s": 2.5, "visual": ""},
|
| 341 |
+
{"type": "section", "on_screen_text": "Readers valued it", "narration": "", "duration_hint_s": 3,
|
| 342 |
+
"visual": ""},
|
| 343 |
+
]}
|
| 344 |
+
art = client.post("/api/artifacts/generate", json={"type": "video", "document_id": doc["id"], "style": "short"},
|
| 345 |
+
headers=auth).json()
|
| 346 |
+
job = run_jobs()[0]
|
| 347 |
+
assert job.status == "running" # handed off to the renderer
|
| 348 |
+
assert sent["comp"] == "ShortVertical" and sent["props"]["hook"] == "Charge more"
|
| 349 |
+
from app.config import settings
|
| 350 |
+
|
| 351 |
+
r = client.post(f"/api/internal/jobs/{sent['job']}/progress", headers={"x-internal-token": settings.internal_token},
|
| 352 |
+
json={"status": "done", "progress": 1, "storage_key": "ws/x/video.mp4", "render_seconds": 12})
|
| 353 |
+
assert r.status_code == 200
|
| 354 |
+
assert client.get(f"/api/artifacts/{art['id']}", headers=auth).json()["status"] == "ready"
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
def test_renderer_down_fails_without_retry(client, auth, run_jobs, feed, llm, monkeypatch):
|
| 358 |
+
connect(client, auth, run_jobs)
|
| 359 |
+
doc = docs(client, auth)[0]
|
| 360 |
+
|
| 361 |
+
def refuse(*args, **kwargs):
|
| 362 |
+
raise httpx.ConnectError("refused")
|
| 363 |
+
|
| 364 |
+
monkeypatch.setattr(httpx, "post", refuse)
|
| 365 |
+
llm["PickQuotes"] = lambda passages, **_: {"quotes": [{"quote": "Charging more made my readers take the work "
|
| 366 |
+
"seriously.",
|
| 367 |
+
"source": {"path": doc["path"], "line_start": 1,
|
| 368 |
+
"line_end": 20}}]}
|
| 369 |
+
art = client.post("/api/artifacts/generate", json={"type": "quote_card", "document_id": doc["id"]},
|
| 370 |
+
headers=auth).json()
|
| 371 |
+
job = run_jobs()[0]
|
| 372 |
+
assert job.status == "failed" and "Renderer is not running" in job.error
|
| 373 |
+
assert client.get(f"/api/artifacts/{art['id']}", headers=auth).json()["status"] == "failed"
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
def test_launch_kit(client, auth, run_jobs, feed, llm, monkeypatch, db_session):
|
| 377 |
+
from app.pipeline import media
|
| 378 |
+
|
| 379 |
+
connect(client, auth, run_jobs)
|
| 380 |
+
doc = next(d for d in docs(client, auth) if d["title"] == "On Pricing")
|
| 381 |
+
ref = {"path": doc["path"], "line_start": 12, "line_end": 14}
|
| 382 |
+
llm.update({
|
| 383 |
+
"ExtractClaims": {"claims": [{"claim": "Higher prices signal value", "sources": [ref]},
|
| 384 |
+
{"claim": "Unsupported", "sources": [{**ref, "line_start": 999}]}]},
|
| 385 |
+
"HookGenerator": {"hooks": [{"text": "I doubled my price.", "strength": 0.9, "rationale": "number"}]},
|
| 386 |
+
"PlatformAdapter": lambda platform, **_: {"posts": [f"{platform} post one", f"{platform} post two"]},
|
| 387 |
+
"SeoPack": {"seo": {"title_options": ["On Pricing"], "meta_description": "Why I charge more.",
|
| 388 |
+
"slug": "on-pricing", "keywords": ["pricing"], "internal_links": []}},
|
| 389 |
+
"CarouselSlides": {"slides": [{"heading": "Charge more", "body": "It works."}]},
|
| 390 |
+
"PickQuotes": {"quotes": [{"quote": "Charging more made my readers take the work seriously.",
|
| 391 |
+
"source": ref}]},
|
| 392 |
+
})
|
| 393 |
+
monkeypatch.setattr(media, "request_render", lambda *a, **k: None)
|
| 394 |
+
art = client.post("/api/artifacts/generate", json={"type": "launch_kit", "document_id": doc["id"]},
|
| 395 |
+
headers=auth).json()
|
| 396 |
+
run_jobs()
|
| 397 |
+
kit = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()
|
| 398 |
+
assert kit["status"] == "ready", kit["content"]
|
| 399 |
+
c = kit["content"]
|
| 400 |
+
assert len(c["claims"]) == 1 # the unverifiable claim was dropped
|
| 401 |
+
assert set(c["posts"]) == {"x_thread", "linkedin", "substack_notes", "bluesky"}
|
| 402 |
+
assert len(c["quote_card_ids"]) == 1
|
| 403 |
+
card = client.get(f"/api/artifacts/{c['quote_card_ids'][0]}", headers=auth).json()
|
| 404 |
+
assert card["status"] == "rendering" # its own job ran and handed off
|
| 405 |
+
|
| 406 |
+
edited = client.patch(f"/api/artifacts/{art['id']}", json={"content": {"posts": {**c["posts"],
|
| 407 |
+
"linkedin": ["Edited"]}}},
|
| 408 |
+
headers=auth).json()
|
| 409 |
+
assert edited["content"]["posts"]["linkedin"] == ["Edited"]
|
| 410 |
+
assert edited["content"]["quote_card_ids"] == c["quote_card_ids"]
|
| 411 |
+
|
| 412 |
+
listing = client.get("/api/artifacts", params={"type": "launch_kit"}, headers=auth).json()
|
| 413 |
+
assert listing["total"] == 1
|
| 414 |
+
assert client.delete(f"/api/artifacts/{art['id']}", headers=auth).status_code == 200
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
# Launchpad
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
def _bluesky(client, auth, monkeypatch):
|
| 421 |
+
from app.services.social import bluesky
|
| 422 |
+
|
| 423 |
+
monkeypatch.setattr(bluesky, "create_session", lambda handle, pw: {"did": "did:plc:ada", "handle": handle,
|
| 424 |
+
"accessJwt": "jwt"})
|
| 425 |
+
r = client.post("/api/social/bluesky", json={"handle": "ada.bsky.social", "app_password": "abcd-efgh-ijkl"},
|
| 426 |
+
headers=auth)
|
| 427 |
+
assert r.status_code == 200, r.text
|
| 428 |
+
return r.json()["accounts"][0]
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
def test_schedule_and_publish_bluesky_thread(client, auth, monkeypatch, session_factory, db_session):
|
| 432 |
+
from app.services import launchpad
|
| 433 |
+
from app.services.social import bluesky
|
| 434 |
+
|
| 435 |
+
account = _bluesky(client, auth, monkeypatch)
|
| 436 |
+
published = {}
|
| 437 |
+
|
| 438 |
+
def publish(db, acct, posts):
|
| 439 |
+
published["posts"] = posts
|
| 440 |
+
return "at://did:plc:ada/app.bsky.feed.post/abc", "https://bsky.app/profile/ada.bsky.social/post/abc"
|
| 441 |
+
|
| 442 |
+
monkeypatch.setattr(bluesky, "publish", publish)
|
| 443 |
+
too_long = client.post("/api/calendar", json={"platform": "bluesky", "content": "x" * 400,
|
| 444 |
+
"scheduled_at": datetime.now(UTC).isoformat(),
|
| 445 |
+
"social_account_id": account["id"]}, headers=auth)
|
| 446 |
+
assert too_long.status_code == 422
|
| 447 |
+
item = client.post("/api/calendar", json={
|
| 448 |
+
"platform": "bluesky", "content": "First", "thread": ["Second", " "],
|
| 449 |
+
"scheduled_at": (datetime.now(UTC) - timedelta(minutes=1)).isoformat(), "social_account_id": account["id"],
|
| 450 |
+
}, headers=auth).json()
|
| 451 |
+
assert item["auto_post"] is True
|
| 452 |
+
|
| 453 |
+
assert launchpad.publish_due(session_factory) == 1
|
| 454 |
+
assert published["posts"] == ["First", "Second"]
|
| 455 |
+
got = client.get("/api/calendar", headers=auth).json()[0]
|
| 456 |
+
assert got["status"] == "posted" and got["external_url"].startswith("https://bsky.app/")
|
| 457 |
+
assert client.patch(f"/api/calendar/{item['id']}", json={"content": "late"}, headers=auth).status_code == 409
|
| 458 |
+
|
| 459 |
+
|
| 460 |
+
def test_substack_notes_send_reminder_email(client, auth, session_factory):
|
| 461 |
+
from app.services import launchpad
|
| 462 |
+
|
| 463 |
+
ConsoleEmailProvider.sent.clear()
|
| 464 |
+
item = client.post("/api/calendar", json={"platform": "substack_notes", "content": "A note",
|
| 465 |
+
"scheduled_at": (datetime.now(UTC) - timedelta(seconds=5)).isoformat()},
|
| 466 |
+
headers=auth).json()
|
| 467 |
+
assert item["remind_by_email"] is True
|
| 468 |
+
launchpad.publish_due(session_factory)
|
| 469 |
+
assert client.get("/api/calendar", headers=auth).json()[0]["status"] == "reminded"
|
| 470 |
+
assert ConsoleEmailProvider.sent[-1].subject == "Time to post on Substack Notes"
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
def test_publish_retries_then_fails(client, auth, monkeypatch, session_factory):
|
| 474 |
+
from app.services import launchpad
|
| 475 |
+
from app.services.social import SocialError, bluesky
|
| 476 |
+
|
| 477 |
+
account = _bluesky(client, auth, monkeypatch)
|
| 478 |
+
|
| 479 |
+
def boom(db, acct, posts):
|
| 480 |
+
raise SocialError("Bluesky 500: busy")
|
| 481 |
+
|
| 482 |
+
monkeypatch.setattr(bluesky, "publish", boom)
|
| 483 |
+
item = client.post("/api/calendar", json={"platform": "bluesky", "content": "Hi", "social_account_id":
|
| 484 |
+
account["id"], "scheduled_at": datetime.now(UTC).isoformat()},
|
| 485 |
+
headers=auth).json()
|
| 486 |
+
r = client.post(f"/api/calendar/{item['id']}/publish", headers=auth).json()
|
| 487 |
+
assert r["status"] == "scheduled" and "busy" in r["error"] # retry queued
|
| 488 |
+
for _ in range(2):
|
| 489 |
+
with session_factory() as db:
|
| 490 |
+
db.query(CalendarItem).update({"scheduled_at": datetime.now(UTC) - timedelta(seconds=1)})
|
| 491 |
+
db.commit()
|
| 492 |
+
launchpad.publish_due(session_factory)
|
| 493 |
+
assert client.get("/api/calendar", headers=auth).json()[0]["status"] == "failed"
|
| 494 |
+
|
| 495 |
+
|
| 496 |
+
def test_tracked_link_counts_clicks(client, auth, db_session):
|
| 497 |
+
ws = db_session.query(Workspace).first()
|
| 498 |
+
item = CalendarItem(workspace_id=ws.id, platform="x", scheduled_at=datetime.now(UTC), content="x",
|
| 499 |
+
status="posted")
|
| 500 |
+
db_session.add(item)
|
| 501 |
+
db_session.flush()
|
| 502 |
+
db_session.add(TrackedLink(workspace_id=ws.id, slug="abc1234", target_url="https://ada.example.com/p/x",
|
| 503 |
+
calendar_item_id=item.id))
|
| 504 |
+
db_session.commit()
|
| 505 |
+
r = client.get("/l/abc1234", follow_redirects=False)
|
| 506 |
+
assert r.status_code == 302 and r.headers["location"] == "https://ada.example.com/p/x"
|
| 507 |
+
assert db_session.query(EngagementEvent).count() == 1
|
| 508 |
+
assert client.get("/api/calendar", headers=auth).json()[0]["clicks"] == 1
|
| 509 |
+
assert client.get("/l/nope", follow_redirects=False).status_code == 404
|
| 510 |
+
|
| 511 |
+
|
| 512 |
+
def test_oauth_link_is_bound_to_the_starting_workspace(client, auth, monkeypatch, db_session):
|
| 513 |
+
import json
|
| 514 |
+
|
| 515 |
+
from app.auth import encode_signed
|
| 516 |
+
from app.services.crypto import encrypt
|
| 517 |
+
|
| 518 |
+
ws = db_session.query(Workspace).first()
|
| 519 |
+
info = {"access_token": "tok", "refresh_token": "ref", "expires_in": 7200, "external_id": "42",
|
| 520 |
+
"handle": "ada", "scope": "tweet.write"}
|
| 521 |
+
|
| 522 |
+
def ticket(ws_id):
|
| 523 |
+
return encode_signed({"typ": "social_link", "ws": str(ws_id), "platform": "x",
|
| 524 |
+
"data": encrypt(json.dumps(info))}, timedelta(minutes=5))
|
| 525 |
+
|
| 526 |
+
assert client.post("/api/social/complete", json={"ticket": ticket(uuid.uuid4())}, headers=auth).status_code == 403
|
| 527 |
+
r = client.post("/api/social/complete", json={"ticket": ticket(ws.id)}, headers=auth)
|
| 528 |
+
assert r.status_code == 200
|
| 529 |
+
acct = db_session.query(SocialAccount).one()
|
| 530 |
+
assert acct.platform == "x" and acct.access_token != "tok" # stored encrypted
|
| 531 |
+
assert client.post("/api/social/x/start", headers=auth).status_code == 503 # no client id configured
|
| 532 |
+
|
| 533 |
+
|
| 534 |
+
def test_oauth_callback_redirects_with_ticket(client, monkeypatch):
|
| 535 |
+
from app.auth import encode_signed
|
| 536 |
+
from app.services.social import linkedin
|
| 537 |
+
|
| 538 |
+
monkeypatch.setattr(linkedin, "exchange_code", lambda code: {"access_token": "t", "external_id": "p",
|
| 539 |
+
"handle": "Ada", "expires_in": 60})
|
| 540 |
+
state = encode_signed({"typ": "social_state", "nonce": "n", "ws": str(uuid.uuid4()), "platform": "linkedin"},
|
| 541 |
+
timedelta(minutes=5))
|
| 542 |
+
r = client.get("/api/social/linkedin/callback", params={"code": "c", "state": state}, follow_redirects=False)
|
| 543 |
+
assert r.status_code == 302 and "link=" in r.headers["location"] and "/app/launchpad" in r.headers["location"]
|
| 544 |
+
bad = client.get("/api/social/linkedin/callback", params={"code": "c", "state": "junk"}, follow_redirects=False)
|
| 545 |
+
assert "error=" in bad.headers["location"]
|
| 546 |
+
|
| 547 |
+
|
| 548 |
+
# Settings and activity
|
| 549 |
+
|
| 550 |
+
|
| 551 |
+
def test_settings_and_usage(client, auth):
|
| 552 |
+
s = client.get("/api/settings", headers=auth).json()
|
| 553 |
+
assert s["user"]["name"] == "Ada Lovelace" and s["usage"]["used"]["launch_kits"] == 0
|
| 554 |
+
r = client.patch("/api/settings", json={"name": "Ada L", "workspace_name": "Ada HQ", "brand_accent": "#FF0000",
|
| 555 |
+
"training_opt_in": True}, headers=auth).json()
|
| 556 |
+
assert r["user"]["name"] == "Ada L" and r["workspace"]["name"] == "Ada HQ"
|
| 557 |
+
assert r["brand"]["accent"] == "#ff0000" and r["workspace"]["training_opt_in"] is True
|
| 558 |
+
assert client.patch("/api/settings", json={"brand_accent": "red"}, headers=auth).status_code == 422
|
| 559 |
+
|
| 560 |
+
|
| 561 |
+
def test_active_jobs_listing(client, auth, feed):
|
| 562 |
+
client.post("/api/sources", json={"url": "ada.example.com"}, headers=auth)
|
| 563 |
+
active = client.get("/api/jobs", params={"active": 1}, headers=auth).json()
|
| 564 |
+
assert active[0]["kind"] == "ingest" and active[0]["status"] == "queued"
|
| 565 |
+
|
| 566 |
+
|
| 567 |
+
def test_worker_loop_runs_in_background(tmp_path, monkeypatch):
|
| 568 |
+
"""The embedded worker thread (RUN_WORKER_IN_API) drains jobs without a second process."""
|
| 569 |
+
import threading
|
| 570 |
+
|
| 571 |
+
from sqlalchemy import create_engine
|
| 572 |
+
from sqlalchemy.orm import sessionmaker
|
| 573 |
+
|
| 574 |
+
from app import worker
|
| 575 |
+
from app.db import Base
|
| 576 |
+
from app.services.jobs import create_job
|
| 577 |
+
|
| 578 |
+
# A real file database: the in-memory test engine shares one connection across threads.
|
| 579 |
+
engine = create_engine(f"sqlite:///{tmp_path / 'w.db'}", connect_args={"check_same_thread": False})
|
| 580 |
+
Base.metadata.create_all(engine)
|
| 581 |
+
session_factory = sessionmaker(bind=engine, expire_on_commit=False)
|
| 582 |
+
|
| 583 |
+
with session_factory() as db:
|
| 584 |
+
ws = Workspace(name="w", owner_id=uuid.uuid4())
|
| 585 |
+
db.add(ws)
|
| 586 |
+
db.commit()
|
| 587 |
+
job = create_job(db, ws.id, "noop")
|
| 588 |
+
monkeypatch.setattr(worker, "SessionLocal", session_factory)
|
| 589 |
+
monkeypatch.setitem(worker.HANDLERS, "noop", lambda db, j: worker.Done({"ok": True}))
|
| 590 |
+
monkeypatch.setattr(worker, "PERIODIC", [])
|
| 591 |
+
real_run_job = worker.run_job
|
| 592 |
+
monkeypatch.setattr(worker, "run_job", lambda job_id: real_run_job(job_id, session_factory=session_factory))
|
| 593 |
+
stop = threading.Event()
|
| 594 |
+
thread = worker.start_background(stop)
|
| 595 |
+
deadline = time.time() + 5
|
| 596 |
+
status = None
|
| 597 |
+
while time.time() < deadline:
|
| 598 |
+
with session_factory() as db:
|
| 599 |
+
status = db.get(Job, job.id).status
|
| 600 |
+
if status == "done":
|
| 601 |
+
break
|
| 602 |
+
time.sleep(0.05)
|
| 603 |
+
stop.set()
|
| 604 |
+
thread.join(timeout=5)
|
| 605 |
+
assert status == "done"
|
| 606 |
+
|
| 607 |
+
|
| 608 |
+
def test_artifact_ownership(client, auth, db_session):
|
| 609 |
+
other = Artifact(workspace_id=uuid.uuid4(), type="summary", status="ready", content_json={})
|
| 610 |
+
db_session.add(other)
|
| 611 |
+
db_session.commit()
|
| 612 |
+
assert client.get(f"/api/artifacts/{other.id}", headers=auth).status_code == 404
|
frontend/src/App.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import { Route, Routes } from "react-router-dom";
|
| 2 |
-
import AppShell
|
| 3 |
import { PublicFooter, PublicNav } from "./components/PublicChrome";
|
| 4 |
import PricingTiers from "./components/PricingTiers";
|
| 5 |
import SkyCanvas from "./components/SkyCanvas";
|
|
@@ -7,8 +7,19 @@ import AuthCallback from "./pages/AuthCallback";
|
|
| 7 |
import AuthPage from "./pages/AuthPage";
|
| 8 |
import { Blog, BlogPostPage } from "./pages/Blog";
|
| 9 |
import Landing from "./pages/Landing";
|
|
|
|
|
|
|
|
|
|
| 10 |
import MissionControl from "./pages/MissionControl";
|
|
|
|
| 11 |
import NotebookView from "./pages/NotebookView";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
function PricingPage() {
|
| 14 |
return (
|
|
@@ -34,19 +45,21 @@ export default function App() {
|
|
| 34 |
<Route path="/blogs/:slug" element={<BlogPostPage />} />
|
| 35 |
<Route path="/auth" element={<AuthPage />} />
|
| 36 |
<Route path="/auth/callback" element={<AuthCallback />} />
|
|
|
|
| 37 |
<Route path="/app" element={<AppShell />}>
|
| 38 |
<Route index element={<MissionControl />} />
|
| 39 |
-
<Route path="notebooks" element={<
|
| 40 |
<Route path="notebooks/:id" element={<NotebookView />} />
|
| 41 |
-
<Route path="sources" element={<
|
| 42 |
-
<Route path="map" element={<
|
| 43 |
-
<Route path="voice" element={<
|
| 44 |
-
<Route path="studio" element={<
|
| 45 |
-
<Route path="launch-kit" element={<
|
| 46 |
-
<Route path="launchpad" element={<
|
| 47 |
-
<Route path="archive" element={<
|
| 48 |
-
<Route path="resurface" element={<
|
| 49 |
-
<Route path="settings" element={<
|
|
|
|
| 50 |
</Route>
|
| 51 |
<Route path="*" element={<Landing />} />
|
| 52 |
</Routes>
|
|
|
|
| 1 |
import { Route, Routes } from "react-router-dom";
|
| 2 |
+
import AppShell from "./components/AppShell";
|
| 3 |
import { PublicFooter, PublicNav } from "./components/PublicChrome";
|
| 4 |
import PricingTiers from "./components/PricingTiers";
|
| 5 |
import SkyCanvas from "./components/SkyCanvas";
|
|
|
|
| 7 |
import AuthPage from "./pages/AuthPage";
|
| 8 |
import { Blog, BlogPostPage } from "./pages/Blog";
|
| 9 |
import Landing from "./pages/Landing";
|
| 10 |
+
import Archive from "./pages/Archive";
|
| 11 |
+
import Launchpad from "./pages/Launchpad";
|
| 12 |
+
import LaunchKit from "./pages/LaunchKit";
|
| 13 |
import MissionControl from "./pages/MissionControl";
|
| 14 |
+
import Notebooks from "./pages/Notebooks";
|
| 15 |
import NotebookView from "./pages/NotebookView";
|
| 16 |
+
import Resurface from "./pages/Resurface";
|
| 17 |
+
import Settings from "./pages/Settings";
|
| 18 |
+
import Sources from "./pages/Sources";
|
| 19 |
+
import Studio from "./pages/Studio";
|
| 20 |
+
import TopicMap from "./pages/TopicMap";
|
| 21 |
+
import VoiceProfile from "./pages/VoiceProfile";
|
| 22 |
+
import Welcome from "./pages/Welcome";
|
| 23 |
|
| 24 |
function PricingPage() {
|
| 25 |
return (
|
|
|
|
| 45 |
<Route path="/blogs/:slug" element={<BlogPostPage />} />
|
| 46 |
<Route path="/auth" element={<AuthPage />} />
|
| 47 |
<Route path="/auth/callback" element={<AuthCallback />} />
|
| 48 |
+
<Route path="/welcome" element={<Welcome />} />
|
| 49 |
<Route path="/app" element={<AppShell />}>
|
| 50 |
<Route index element={<MissionControl />} />
|
| 51 |
+
<Route path="notebooks" element={<Notebooks />} />
|
| 52 |
<Route path="notebooks/:id" element={<NotebookView />} />
|
| 53 |
+
<Route path="sources" element={<Sources />} />
|
| 54 |
+
<Route path="map" element={<TopicMap />} />
|
| 55 |
+
<Route path="voice" element={<VoiceProfile />} />
|
| 56 |
+
<Route path="studio" element={<Studio />} />
|
| 57 |
+
<Route path="launch-kit" element={<LaunchKit />} />
|
| 58 |
+
<Route path="launchpad" element={<Launchpad />} />
|
| 59 |
+
<Route path="archive" element={<Archive />} />
|
| 60 |
+
<Route path="resurface" element={<Resurface />} />
|
| 61 |
+
<Route path="settings" element={<Settings />} />
|
| 62 |
+
<Route path="*" element={<MissionControl />} />
|
| 63 |
</Route>
|
| 64 |
<Route path="*" element={<Landing />} />
|
| 65 |
</Routes>
|
frontend/src/api/auth.ts
CHANGED
|
@@ -35,4 +35,5 @@ export const authApi = {
|
|
| 35 |
forgotComplete: (email: string, code: string, password: string) =>
|
| 36 |
post<LoginResult>("/api/auth/password/forgot/complete", { email, code, password }),
|
| 37 |
logout: () => post<{ ok: true }>("/api/auth/logout"),
|
|
|
|
| 38 |
};
|
|
|
|
| 35 |
forgotComplete: (email: string, code: string, password: string) =>
|
| 36 |
post<LoginResult>("/api/auth/password/forgot/complete", { email, code, password }),
|
| 37 |
logout: () => post<{ ok: true }>("/api/auth/logout"),
|
| 38 |
+
deleteAccount: () => post<{ ok: true }>("/api/auth/delete-account"),
|
| 39 |
};
|
frontend/src/api/client.ts
CHANGED
|
@@ -88,11 +88,53 @@ export async function api<T>(path: string, init: RequestInit = {}, retry = true)
|
|
| 88 |
export const post = <T>(path: string, body?: unknown) =>
|
| 89 |
api<T>(path, { method: "POST", body: body === undefined ? undefined : JSON.stringify(body) });
|
| 90 |
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
export async function uploadFile(file: File): Promise<{ upload_id: string; key: string }> {
|
| 93 |
const start = await post<{ upload_id: string; upload_url: string; headers: Record<string, string> }>(
|
| 94 |
"/api/storage/uploads",
|
| 95 |
-
{ filename: file.name, content_type: file
|
| 96 |
);
|
| 97 |
const put = await fetch(start.upload_url, { method: "PUT", headers: start.headers, body: file });
|
| 98 |
if (!put.ok) throw new ApiError(put.status, "Upload to storage failed");
|
|
|
|
| 88 |
export const post = <T>(path: string, body?: unknown) =>
|
| 89 |
api<T>(path, { method: "POST", body: body === undefined ? undefined : JSON.stringify(body) });
|
| 90 |
|
| 91 |
+
export const patch = <T>(path: string, body: unknown) => api<T>(path, { method: "PATCH", body: JSON.stringify(body) });
|
| 92 |
+
|
| 93 |
+
export const put = <T>(path: string, body: unknown) => api<T>(path, { method: "PUT", body: JSON.stringify(body) });
|
| 94 |
+
|
| 95 |
+
export const del = <T = { ok: true }>(path: string) => api<T>(path, { method: "DELETE" });
|
| 96 |
+
|
| 97 |
+
/** Query string from an object, skipping empty values. */
|
| 98 |
+
export function qs(params: Record<string, string | number | boolean | null | undefined>): string {
|
| 99 |
+
const q = new URLSearchParams();
|
| 100 |
+
for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== "") q.set(k, String(v));
|
| 101 |
+
const out = q.toString();
|
| 102 |
+
return out ? `?${out}` : "";
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
/** Direct browser upload to storage (R2, or the API's local disk store) through a presigned PUT. */
|
| 106 |
+
const TYPES_BY_EXT: Record<string, string> = {
|
| 107 |
+
md: "text/markdown",
|
| 108 |
+
markdown: "text/markdown",
|
| 109 |
+
txt: "text/plain",
|
| 110 |
+
html: "text/html",
|
| 111 |
+
htm: "text/html",
|
| 112 |
+
pdf: "application/pdf",
|
| 113 |
+
mp3: "audio/mpeg",
|
| 114 |
+
m4a: "audio/mp4",
|
| 115 |
+
wav: "audio/wav",
|
| 116 |
+
webm: "audio/webm",
|
| 117 |
+
ogg: "audio/ogg",
|
| 118 |
+
png: "image/png",
|
| 119 |
+
jpg: "image/jpeg",
|
| 120 |
+
jpeg: "image/jpeg",
|
| 121 |
+
webp: "image/webp",
|
| 122 |
+
svg: "image/svg+xml",
|
| 123 |
+
};
|
| 124 |
+
|
| 125 |
+
/** Browsers leave File.type empty for some extensions (.md on Windows) and use vendor names for others. */
|
| 126 |
+
function contentType(file: File): string {
|
| 127 |
+
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
|
| 128 |
+
const base = file.type.split(";")[0];
|
| 129 |
+
if (base === "audio/x-m4a") return "audio/mp4";
|
| 130 |
+
if (base === "audio/x-wav") return "audio/wav";
|
| 131 |
+
return base || TYPES_BY_EXT[ext] || "application/octet-stream";
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
export async function uploadFile(file: File): Promise<{ upload_id: string; key: string }> {
|
| 135 |
const start = await post<{ upload_id: string; upload_url: string; headers: Record<string, string> }>(
|
| 136 |
"/api/storage/uploads",
|
| 137 |
+
{ filename: file.name, content_type: contentType(file), size_bytes: file.size },
|
| 138 |
);
|
| 139 |
const put = await fetch(start.upload_url, { method: "PUT", headers: start.headers, body: file });
|
| 140 |
if (!put.ok) throw new ApiError(put.status, "Upload to storage failed");
|
frontend/src/api/endpoints.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { api, del, patch, post, put, qs, uploadFile } from "./client";
|
| 2 |
+
import type {
|
| 3 |
+
Artifact,
|
| 4 |
+
ArtifactType,
|
| 5 |
+
CalendarItem,
|
| 6 |
+
ChatMessage,
|
| 7 |
+
ChatSummary,
|
| 8 |
+
Doc,
|
| 9 |
+
DocDetail,
|
| 10 |
+
Job,
|
| 11 |
+
Notebook,
|
| 12 |
+
NotebookSummary,
|
| 13 |
+
Page,
|
| 14 |
+
Platform,
|
| 15 |
+
ResurfaceItem,
|
| 16 |
+
Settings,
|
| 17 |
+
SocialAccounts,
|
| 18 |
+
Source,
|
| 19 |
+
TopicDetail,
|
| 20 |
+
TopicMap,
|
| 21 |
+
Usage,
|
| 22 |
+
Voice,
|
| 23 |
+
VoiceProfileData,
|
| 24 |
+
VoiceState,
|
| 25 |
+
} from "./types";
|
| 26 |
+
|
| 27 |
+
type SourceJob = { source: Source; job: Job };
|
| 28 |
+
|
| 29 |
+
export const sourcesApi = {
|
| 30 |
+
list: () => api<Source[]>("/api/sources"),
|
| 31 |
+
connect: (url: string) => post<SourceJob>("/api/sources", { url }),
|
| 32 |
+
importUrl: (url: string) => post<SourceJob>("/api/sources/url", { url }),
|
| 33 |
+
importFile: async (file: File) => {
|
| 34 |
+
const { upload_id } = await uploadFile(file);
|
| 35 |
+
return post<SourceJob>("/api/sources/upload", { upload_id });
|
| 36 |
+
},
|
| 37 |
+
sync: (id: string) => post<SourceJob>(`/api/sources/${id}/sync`),
|
| 38 |
+
remove: (id: string) => del<{ ok: true; removed_posts: number }>(`/api/sources/${id}`),
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
export const docsApi = {
|
| 42 |
+
list: (p: { q?: string; source_id?: string; limit?: number; offset?: number } = {}) =>
|
| 43 |
+
api<Page<Doc>>(`/api/documents${qs(p)}`),
|
| 44 |
+
get: (id: string) => api<DocDetail>(`/api/documents/${id}`),
|
| 45 |
+
remove: (id: string) => del(`/api/documents/${id}`),
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
export const notebooksApi = {
|
| 49 |
+
list: () => api<NotebookSummary[]>("/api/notebooks"),
|
| 50 |
+
get: (id: string) => api<Notebook>(`/api/notebooks/${id}`),
|
| 51 |
+
create: (title: string, document_ids: string[] = [], description?: string) =>
|
| 52 |
+
post<{ id: string; title: string; added: number }>("/api/notebooks", { title, document_ids, description }),
|
| 53 |
+
fromTopic: (topicId: string) => post<{ id: string; title: string }>(`/api/notebooks/from-topic/${topicId}`),
|
| 54 |
+
update: (id: string, body: { title?: string; description?: string }) => patch(`/api/notebooks/${id}`, body),
|
| 55 |
+
remove: (id: string) => del(`/api/notebooks/${id}`),
|
| 56 |
+
addDocs: (id: string, document_ids: string[]) =>
|
| 57 |
+
post<{ added: number }>(`/api/notebooks/${id}/documents`, { document_ids }),
|
| 58 |
+
removeDoc: (id: string, docId: string) => del(`/api/notebooks/${id}/documents/${docId}`),
|
| 59 |
+
artifacts: (id: string) => api<Artifact[]>(`/api/notebooks/${id}/artifacts`),
|
| 60 |
+
chats: (id: string) => api<ChatSummary[]>(`/api/notebooks/${id}/chats`),
|
| 61 |
+
messages: (chatId: string) => api<ChatMessage[]>(`/api/notebooks/chats/${chatId}/messages`),
|
| 62 |
+
removeChat: (chatId: string) => del(`/api/notebooks/chats/${chatId}`),
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
export type GenerateBody = {
|
| 66 |
+
type: ArtifactType;
|
| 67 |
+
notebook_id?: string;
|
| 68 |
+
document_id?: string;
|
| 69 |
+
format?: "deep_dive" | "brief" | "debate";
|
| 70 |
+
minutes?: number;
|
| 71 |
+
style?: "short" | "explainer" | "audiogram";
|
| 72 |
+
audio_artifact_id?: string;
|
| 73 |
+
slides?: { heading: string; body: string }[];
|
| 74 |
+
parent_id?: string;
|
| 75 |
+
};
|
| 76 |
+
|
| 77 |
+
export const artifactsApi = {
|
| 78 |
+
list: (p: { type?: string; notebook_id?: string; document_id?: string; status?: string; q?: string;
|
| 79 |
+
limit?: number; offset?: number } = {}) => api<Page<Artifact>>(`/api/artifacts${qs(p)}`),
|
| 80 |
+
get: (id: string) => api<Artifact>(`/api/artifacts/${id}`),
|
| 81 |
+
generate: (body: GenerateBody) => post<Artifact>("/api/artifacts/generate", body),
|
| 82 |
+
update: (id: string, body: { title?: string; content?: Record<string, unknown> }) =>
|
| 83 |
+
patch<Artifact>(`/api/artifacts/${id}`, body),
|
| 84 |
+
remove: (id: string) => del(`/api/artifacts/${id}`),
|
| 85 |
+
retry: (id: string) => post<Artifact>(`/api/artifacts/${id}/retry`),
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
export const jobsApi = {
|
| 89 |
+
active: () => api<Job[]>("/api/jobs?active=1"),
|
| 90 |
+
recent: (kind?: string) => api<Job[]>(`/api/jobs${qs({ kind, limit: 10 })}`),
|
| 91 |
+
get: (id: string) => api<Job>(`/api/jobs/${id}`),
|
| 92 |
+
};
|
| 93 |
+
|
| 94 |
+
export const topicsApi = {
|
| 95 |
+
map: () => api<TopicMap>("/api/topics"),
|
| 96 |
+
get: (id: string) => api<TopicDetail>(`/api/topics/${id}`),
|
| 97 |
+
rebuild: (full = false) => post<Job>(`/api/topics/rebuild${qs({ full })}`),
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
export const voiceApi = {
|
| 101 |
+
get: () => api<VoiceState>("/api/voice"),
|
| 102 |
+
update: (body: { profile?: VoiceProfileData; host_voices?: { host_a: string; host_b: string } }) =>
|
| 103 |
+
put<VoiceState>("/api/voice", body),
|
| 104 |
+
build: (document_ids: string[]) => post<Job>("/api/voice/build", { document_ids }),
|
| 105 |
+
voices: () => api<Voice[]>("/api/voice/voices"),
|
| 106 |
+
consent: async (file: File, consent_text: string) => {
|
| 107 |
+
const { upload_id } = await uploadFile(file);
|
| 108 |
+
return post<VoiceState & { job: Job }>("/api/voice/consent", { upload_id, agreed: true, consent_text });
|
| 109 |
+
},
|
| 110 |
+
revoke: () => del<VoiceState>("/api/voice/consent"),
|
| 111 |
+
};
|
| 112 |
+
|
| 113 |
+
export type ItemBody = {
|
| 114 |
+
platform: Platform;
|
| 115 |
+
scheduled_at: string;
|
| 116 |
+
content: string;
|
| 117 |
+
thread?: string[];
|
| 118 |
+
artifact_id?: string | null;
|
| 119 |
+
document_id?: string | null;
|
| 120 |
+
social_account_id?: string | null;
|
| 121 |
+
remind_by_email?: boolean;
|
| 122 |
+
draft?: boolean;
|
| 123 |
+
};
|
| 124 |
+
|
| 125 |
+
export const launchpadApi = {
|
| 126 |
+
items: (p: { start?: string; end?: string; status?: string } = {}) => api<CalendarItem[]>(`/api/calendar${qs(p)}`),
|
| 127 |
+
create: (body: ItemBody) => post<CalendarItem>("/api/calendar", body),
|
| 128 |
+
update: (id: string, body: Partial<ItemBody> & { status?: "scheduled" | "draft" }) =>
|
| 129 |
+
patch<CalendarItem>(`/api/calendar/${id}`, body),
|
| 130 |
+
remove: (id: string) => del(`/api/calendar/${id}`),
|
| 131 |
+
publishNow: (id: string) => post<CalendarItem>(`/api/calendar/${id}/publish`),
|
| 132 |
+
accounts: () => api<SocialAccounts>("/api/social/accounts"),
|
| 133 |
+
connectBluesky: (handle: string, app_password: string) =>
|
| 134 |
+
post<SocialAccounts>("/api/social/bluesky", { handle, app_password }),
|
| 135 |
+
startOAuth: (platform: "x" | "linkedin") => post<{ url: string }>(`/api/social/${platform}/start`),
|
| 136 |
+
completeOAuth: (ticket: string) => post<SocialAccounts>("/api/social/complete", { ticket }),
|
| 137 |
+
disconnect: (id: string) => del(`/api/social/accounts/${id}`),
|
| 138 |
+
};
|
| 139 |
+
|
| 140 |
+
export const resurfaceApi = {
|
| 141 |
+
get: () => api<{ evergreen: ResurfaceItem[]; on_this_day: ResurfaceItem[]; unscored: number; total_posts: number }>(
|
| 142 |
+
"/api/resurface",
|
| 143 |
+
),
|
| 144 |
+
scan: () => post<Job>("/api/resurface/scan"),
|
| 145 |
+
};
|
| 146 |
+
|
| 147 |
+
export type SettingsPatch = {
|
| 148 |
+
name?: string;
|
| 149 |
+
workspace_name?: string;
|
| 150 |
+
brand_name?: string;
|
| 151 |
+
brand_accent?: string;
|
| 152 |
+
logo_upload_id?: string;
|
| 153 |
+
training_opt_in?: boolean;
|
| 154 |
+
email_unsubscribed?: boolean;
|
| 155 |
+
};
|
| 156 |
+
|
| 157 |
+
export const settingsApi = {
|
| 158 |
+
get: () => api<Settings>("/api/settings"),
|
| 159 |
+
update: (body: SettingsPatch) => patch<Settings>("/api/settings", body),
|
| 160 |
+
usage: () => api<Usage>("/api/settings/usage"),
|
| 161 |
+
};
|
frontend/src/api/types.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type JobStatus = "queued" | "running" | "done" | "failed";
|
| 2 |
+
|
| 3 |
+
export type Job = {
|
| 4 |
+
id: string;
|
| 5 |
+
kind: string;
|
| 6 |
+
params: Record<string, unknown>;
|
| 7 |
+
status: JobStatus;
|
| 8 |
+
progress: number;
|
| 9 |
+
message: string | null;
|
| 10 |
+
error: string | null;
|
| 11 |
+
result: Record<string, unknown>;
|
| 12 |
+
artifact_id: string | null;
|
| 13 |
+
attempts: number;
|
| 14 |
+
created_at: string | null;
|
| 15 |
+
finished_at: string | null;
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
export type Source = {
|
| 19 |
+
id: string;
|
| 20 |
+
feed_url: string;
|
| 21 |
+
site_url: string | null;
|
| 22 |
+
platform: string;
|
| 23 |
+
title: string | null;
|
| 24 |
+
sync_status: "pending" | "syncing" | "ok" | "error";
|
| 25 |
+
sync_error: string | null;
|
| 26 |
+
last_synced_at: string | null;
|
| 27 |
+
document_count: number;
|
| 28 |
+
is_imports: boolean;
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
export type Doc = {
|
| 32 |
+
id: string;
|
| 33 |
+
title: string;
|
| 34 |
+
url: string;
|
| 35 |
+
path: string | null;
|
| 36 |
+
source_id: string | null;
|
| 37 |
+
source_title: string | null;
|
| 38 |
+
published_at: string | null;
|
| 39 |
+
words: number;
|
| 40 |
+
evergreen_score: number | null;
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
export type DocDetail = Doc & { lines: string[] };
|
| 44 |
+
|
| 45 |
+
export type Page<T> = { total: number; items: T[] };
|
| 46 |
+
|
| 47 |
+
export type NotebookSummary = {
|
| 48 |
+
id: string;
|
| 49 |
+
title: string;
|
| 50 |
+
description: string | null;
|
| 51 |
+
summary: string | null;
|
| 52 |
+
document_count: number;
|
| 53 |
+
chat_count: number;
|
| 54 |
+
artifact_count: number;
|
| 55 |
+
updated_at: string | null;
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
export type Notebook = {
|
| 59 |
+
id: string;
|
| 60 |
+
title: string;
|
| 61 |
+
description: string | null;
|
| 62 |
+
summary: string | null;
|
| 63 |
+
documents: Doc[];
|
| 64 |
+
};
|
| 65 |
+
|
| 66 |
+
export type Citation = {
|
| 67 |
+
marker: number;
|
| 68 |
+
document_id?: string;
|
| 69 |
+
title: string;
|
| 70 |
+
url: string;
|
| 71 |
+
path: string;
|
| 72 |
+
line_start: number;
|
| 73 |
+
line_end: number;
|
| 74 |
+
span: string;
|
| 75 |
+
};
|
| 76 |
+
|
| 77 |
+
export type ChatSummary = { id: string; title: string | null; updated_at: string | null };
|
| 78 |
+
export type ChatMessage = { id: string; role: "user" | "assistant"; text: string; citations: Citation[] };
|
| 79 |
+
|
| 80 |
+
export type ArtifactType = "summary" | "audio_overview" | "video" | "quote_card" | "carousel" | "launch_kit";
|
| 81 |
+
|
| 82 |
+
export type SourceRef = { path: string; line_start: number; line_end: number; title?: string | null; quote?: string };
|
| 83 |
+
|
| 84 |
+
export type Segment = { speaker: string; text: string; start: number; end: number; sources: SourceRef[] };
|
| 85 |
+
|
| 86 |
+
export type LaunchKitContent = {
|
| 87 |
+
title: string;
|
| 88 |
+
post_title: string;
|
| 89 |
+
post_url: string;
|
| 90 |
+
claims: { claim: string; sources: SourceRef[] }[];
|
| 91 |
+
hooks: { text: string; strength: number; rationale: string }[];
|
| 92 |
+
posts: Record<"x_thread" | "linkedin" | "substack_notes" | "bluesky", string[]>;
|
| 93 |
+
seo: {
|
| 94 |
+
title_options?: string[];
|
| 95 |
+
meta_description?: string;
|
| 96 |
+
slug?: string;
|
| 97 |
+
keywords?: string[];
|
| 98 |
+
internal_links?: { path: string; anchor_text: string; reason: string }[];
|
| 99 |
+
};
|
| 100 |
+
carousel: { heading: string; body: string }[];
|
| 101 |
+
quotes: { quote: string; source: SourceRef }[];
|
| 102 |
+
quote_card_ids?: string[];
|
| 103 |
+
};
|
| 104 |
+
|
| 105 |
+
export type Artifact = {
|
| 106 |
+
id: string;
|
| 107 |
+
type: ArtifactType;
|
| 108 |
+
type_label: string;
|
| 109 |
+
title: string;
|
| 110 |
+
status: "pending" | "generating" | "rendering" | "ready" | "failed" | "draft";
|
| 111 |
+
notebook_id: string | null;
|
| 112 |
+
document_id: string | null;
|
| 113 |
+
content: Record<string, any>;
|
| 114 |
+
storage_key: string | null;
|
| 115 |
+
url: string | null;
|
| 116 |
+
download_url: string | null;
|
| 117 |
+
slide_urls: string[];
|
| 118 |
+
created_at: string | null;
|
| 119 |
+
job: Job | null;
|
| 120 |
+
};
|
| 121 |
+
|
| 122 |
+
export type TopicNode = { id: string; name: string; summary: string | null; post_count: number };
|
| 123 |
+
export type TopicMap = {
|
| 124 |
+
nodes: TopicNode[];
|
| 125 |
+
edges: { source: string; target: string; weight: number }[];
|
| 126 |
+
has_untagged_posts: boolean;
|
| 127 |
+
};
|
| 128 |
+
export type TopicDetail = TopicNode & { posts: (Doc & { weight: number })[] };
|
| 129 |
+
|
| 130 |
+
export type VoiceProfileData = {
|
| 131 |
+
tone: string[];
|
| 132 |
+
sentence_length: "short" | "medium" | "long" | "varied";
|
| 133 |
+
vocabulary: string[];
|
| 134 |
+
structure_habits: string[];
|
| 135 |
+
openings: string[];
|
| 136 |
+
avoid: string[];
|
| 137 |
+
summary: string;
|
| 138 |
+
};
|
| 139 |
+
|
| 140 |
+
export type VoiceState = {
|
| 141 |
+
profile: VoiceProfileData | null;
|
| 142 |
+
sample_doc_ids: string[];
|
| 143 |
+
updated_at: string | null;
|
| 144 |
+
host_voices: { host_a: string; host_b: string };
|
| 145 |
+
clone: {
|
| 146 |
+
allowed: boolean;
|
| 147 |
+
consent_text: string;
|
| 148 |
+
status: "none" | "processing" | "ready";
|
| 149 |
+
voice_id: string | null;
|
| 150 |
+
created_at: string | null;
|
| 151 |
+
};
|
| 152 |
+
tts_configured: boolean;
|
| 153 |
+
};
|
| 154 |
+
|
| 155 |
+
export type Voice = { voice_id: string; name: string; category?: string | null; preview_url?: string | null };
|
| 156 |
+
|
| 157 |
+
export type Platform = "x" | "linkedin" | "bluesky" | "substack_notes";
|
| 158 |
+
|
| 159 |
+
export type CalendarItem = {
|
| 160 |
+
id: string;
|
| 161 |
+
platform: Platform;
|
| 162 |
+
platform_label: string;
|
| 163 |
+
scheduled_at: string;
|
| 164 |
+
status: "scheduled" | "publishing" | "posted" | "reminded" | "failed" | "draft";
|
| 165 |
+
content: string;
|
| 166 |
+
thread: string[];
|
| 167 |
+
artifact_id: string | null;
|
| 168 |
+
document_id: string | null;
|
| 169 |
+
social_account_id: string | null;
|
| 170 |
+
account_handle: string | null;
|
| 171 |
+
remind_by_email: boolean;
|
| 172 |
+
auto_post: boolean;
|
| 173 |
+
external_url: string | null;
|
| 174 |
+
posted_at: string | null;
|
| 175 |
+
error: string | null;
|
| 176 |
+
metrics: Record<string, number | string>;
|
| 177 |
+
clicks: number;
|
| 178 |
+
};
|
| 179 |
+
|
| 180 |
+
export type SocialAccount = {
|
| 181 |
+
id: string;
|
| 182 |
+
platform: "x" | "linkedin" | "bluesky";
|
| 183 |
+
platform_label: string;
|
| 184 |
+
handle: string;
|
| 185 |
+
status: string;
|
| 186 |
+
avatar_url: string | null;
|
| 187 |
+
connected_at: string | null;
|
| 188 |
+
};
|
| 189 |
+
|
| 190 |
+
export type SocialAccounts = {
|
| 191 |
+
accounts: SocialAccount[];
|
| 192 |
+
available: { x: boolean; linkedin: boolean; bluesky: boolean };
|
| 193 |
+
redirect_uris: { x: string; linkedin: string };
|
| 194 |
+
};
|
| 195 |
+
|
| 196 |
+
export type ResurfaceItem = Doc & {
|
| 197 |
+
reason: string | null;
|
| 198 |
+
angle: string | null;
|
| 199 |
+
last_resurfaced_at: string | null;
|
| 200 |
+
rank?: number;
|
| 201 |
+
years_ago?: number;
|
| 202 |
+
};
|
| 203 |
+
|
| 204 |
+
export type Plan = {
|
| 205 |
+
id: string;
|
| 206 |
+
name: string;
|
| 207 |
+
sources: number;
|
| 208 |
+
indexed_posts: number;
|
| 209 |
+
audio_minutes: number;
|
| 210 |
+
video_minutes: number;
|
| 211 |
+
launch_kits: number;
|
| 212 |
+
voice_cloning: boolean;
|
| 213 |
+
brand_kit: boolean;
|
| 214 |
+
};
|
| 215 |
+
|
| 216 |
+
export type Usage = {
|
| 217 |
+
plan: string;
|
| 218 |
+
used: { audio_minutes: number; video_minutes: number; launch_kits: number; llm_tokens: number; since: string };
|
| 219 |
+
limits: { audio_minutes: number; video_minutes: number; launch_kits: number; sources: number; indexed_posts: number };
|
| 220 |
+
};
|
| 221 |
+
|
| 222 |
+
export type Settings = {
|
| 223 |
+
user: { name: string | null; email: string; auth_provider: string; email_unsubscribed: boolean };
|
| 224 |
+
workspace: { id: string; name: string; training_opt_in: boolean };
|
| 225 |
+
brand: { name: string | null; accent: string; logo_url: string | null };
|
| 226 |
+
plan: Plan;
|
| 227 |
+
billing_enabled: boolean;
|
| 228 |
+
usage: Usage;
|
| 229 |
+
integrations: { llm: boolean; elevenlabs: boolean; x: boolean; linkedin: boolean; storage: string; renderer: string };
|
| 230 |
+
};
|
frontend/src/components/AppShell.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
import { NavLink, Outlet, Navigate, useLocation } from "react-router-dom";
|
| 2 |
import { useAuth } from "../hooks/useAuth";
|
| 3 |
import Logo from "./Logo";
|
|
@@ -31,9 +32,29 @@ const NAV = [
|
|
| 31 |
{ to: "/app/settings", label: "Settings", icon: OrbitIcon },
|
| 32 |
];
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
export default function AppShell() {
|
| 35 |
const { user, loading, logout } = useAuth();
|
| 36 |
const location = useLocation();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
if (loading) return <div className="boot"><div className="orbit-loader"><span /></div></div>;
|
| 38 |
if (!user) {
|
| 39 |
const next = new URLSearchParams({ next: location.pathname + location.search });
|
|
@@ -41,23 +62,39 @@ export default function AppShell() {
|
|
| 41 |
}
|
| 42 |
|
| 43 |
return (
|
| 44 |
-
<div className=
|
| 45 |
<SkyCanvas intensity={0.35} />
|
| 46 |
<aside className="sidebar">
|
| 47 |
-
<
|
| 48 |
-
<
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
<nav aria-label="App">
|
| 51 |
{NAV.map(({ to, label, icon: Icon, end }) => (
|
| 52 |
-
<NavLink key={to} to={to} end={end} className={({ isActive }) => `side-link${isActive ? " active" : ""}`}>
|
| 53 |
<Icon />
|
| 54 |
-
<span>{label}</span>
|
| 55 |
</NavLink>
|
| 56 |
))}
|
| 57 |
</nav>
|
| 58 |
<div className="sidebar-user">
|
| 59 |
-
<span className="mono muted">{user.email}</span>
|
| 60 |
-
<button className="link-btn" onClick={logout}>
|
| 61 |
Sign out
|
| 62 |
</button>
|
| 63 |
</div>
|
|
@@ -68,25 +105,3 @@ export default function AppShell() {
|
|
| 68 |
</div>
|
| 69 |
);
|
| 70 |
}
|
| 71 |
-
|
| 72 |
-
export function ComingSoon({ title, phase }: { title: string; phase: string }) {
|
| 73 |
-
return (
|
| 74 |
-
<div className="empty-state">
|
| 75 |
-
<svg width="120" height="90" viewBox="0 0 120 90" fill="none" aria-hidden="true" className="empty-art">
|
| 76 |
-
<circle cx="84" cy="30" r="16" stroke="currentColor" strokeWidth="1.5" />
|
| 77 |
-
<circle cx="78" cy="26" r="3" stroke="currentColor" strokeWidth="1.5" opacity="0.5" />
|
| 78 |
-
<circle cx="90" cy="36" r="2" stroke="currentColor" strokeWidth="1.5" opacity="0.5" />
|
| 79 |
-
<path d="M20 70l14-8 6 10-14 8z" stroke="currentColor" strokeWidth="1.5" />
|
| 80 |
-
<path d="M34 62l10-6M26 80l-6 6" stroke="currentColor" strokeWidth="1.5" />
|
| 81 |
-
<circle cx="12" cy="20" r="1" fill="currentColor" />
|
| 82 |
-
<circle cx="50" cy="12" r="1" fill="currentColor" />
|
| 83 |
-
<circle cx="108" cy="72" r="1" fill="currentColor" />
|
| 84 |
-
</svg>
|
| 85 |
-
<h1>{title}</h1>
|
| 86 |
-
<p className="muted">This part of the station comes online in {phase}.</p>
|
| 87 |
-
<NavLink to="/app" className="btn">
|
| 88 |
-
Back to Mission Control
|
| 89 |
-
</NavLink>
|
| 90 |
-
</div>
|
| 91 |
-
);
|
| 92 |
-
}
|
|
|
|
| 1 |
+
import { useState } from "react";
|
| 2 |
import { NavLink, Outlet, Navigate, useLocation } from "react-router-dom";
|
| 3 |
import { useAuth } from "../hooks/useAuth";
|
| 4 |
import Logo from "./Logo";
|
|
|
|
| 32 |
{ to: "/app/settings", label: "Settings", icon: OrbitIcon },
|
| 33 |
];
|
| 34 |
|
| 35 |
+
const COLLAPSED_KEY = "ns_sidebar_collapsed";
|
| 36 |
+
|
| 37 |
+
function readCollapsed(): boolean {
|
| 38 |
+
try {
|
| 39 |
+
return localStorage.getItem(COLLAPSED_KEY) === "1";
|
| 40 |
+
} catch {
|
| 41 |
+
return false;
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
export default function AppShell() {
|
| 46 |
const { user, loading, logout } = useAuth();
|
| 47 |
const location = useLocation();
|
| 48 |
+
const [collapsed, setCollapsed] = useState(readCollapsed);
|
| 49 |
+
const toggle = () =>
|
| 50 |
+
setCollapsed((c) => {
|
| 51 |
+
try {
|
| 52 |
+
localStorage.setItem(COLLAPSED_KEY, c ? "0" : "1");
|
| 53 |
+
} catch {
|
| 54 |
+
/* private mode: remember for this visit only */
|
| 55 |
+
}
|
| 56 |
+
return !c;
|
| 57 |
+
});
|
| 58 |
if (loading) return <div className="boot"><div className="orbit-loader"><span /></div></div>;
|
| 59 |
if (!user) {
|
| 60 |
const next = new URLSearchParams({ next: location.pathname + location.search });
|
|
|
|
| 62 |
}
|
| 63 |
|
| 64 |
return (
|
| 65 |
+
<div className={`shell${collapsed ? " collapsed" : ""}`}>
|
| 66 |
<SkyCanvas intensity={0.35} />
|
| 67 |
<aside className="sidebar">
|
| 68 |
+
<div className="sidebar-top">
|
| 69 |
+
<NavLink to="/" className="sidebar-brand">
|
| 70 |
+
<Logo size={28} />
|
| 71 |
+
</NavLink>
|
| 72 |
+
<button
|
| 73 |
+
type="button"
|
| 74 |
+
className="icon-btn sidebar-toggle"
|
| 75 |
+
onClick={toggle}
|
| 76 |
+
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
| 77 |
+
aria-expanded={!collapsed}
|
| 78 |
+
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
| 79 |
+
>
|
| 80 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
| 81 |
+
<rect x="3.5" y="4.5" width="17" height="15" rx="2.5" />
|
| 82 |
+
<path d="M9 4.5v15" />
|
| 83 |
+
<path d={collapsed ? "M13 10l2 2-2 2" : "M15 10l-2 2 2 2"} />
|
| 84 |
+
</svg>
|
| 85 |
+
</button>
|
| 86 |
+
</div>
|
| 87 |
<nav aria-label="App">
|
| 88 |
{NAV.map(({ to, label, icon: Icon, end }) => (
|
| 89 |
+
<NavLink key={to} to={to} end={end} title={collapsed ? label : undefined} className={({ isActive }) => `side-link${isActive ? " active" : ""}`}>
|
| 90 |
<Icon />
|
| 91 |
+
<span className="side-label">{label}</span>
|
| 92 |
</NavLink>
|
| 93 |
))}
|
| 94 |
</nav>
|
| 95 |
<div className="sidebar-user">
|
| 96 |
+
<span className="mono muted side-label">{user.email}</span>
|
| 97 |
+
<button className="link-btn" onClick={logout} title="Sign out">
|
| 98 |
Sign out
|
| 99 |
</button>
|
| 100 |
</div>
|
|
|
|
| 105 |
</div>
|
| 106 |
);
|
| 107 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|