Arslan1997 commited on
Commit
34b0226
·
1 Parent(s): 013ff92

added blogs

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +31 -1
  2. .gitignore +8 -0
  3. README.md +30 -5
  4. backend/alembic/env.py +3 -1
  5. backend/alembic/versions/0002_build_out.py +94 -0
  6. backend/app/config.py +30 -3
  7. backend/app/db.py +14 -2
  8. backend/app/llm/run.py +48 -0
  9. backend/app/llm/signatures.py +105 -2
  10. backend/app/main.py +31 -6
  11. backend/app/models/__init__.py +6 -0
  12. backend/app/models/content.py +60 -3
  13. backend/app/pipeline/generate.py +182 -0
  14. backend/app/pipeline/ingest.py +300 -30
  15. backend/app/pipeline/launchkit.py +108 -0
  16. backend/app/pipeline/media.py +260 -0
  17. backend/app/pipeline/passages.py +98 -0
  18. backend/app/routers/artifacts.py +171 -45
  19. backend/app/routers/jobs.py +22 -3
  20. backend/app/routers/launchpad.py +360 -0
  21. backend/app/routers/notebooks.py +141 -21
  22. backend/app/routers/resurface.py +53 -0
  23. backend/app/routers/settings.py +84 -0
  24. backend/app/routers/sources.py +158 -31
  25. backend/app/routers/storage.py +40 -3
  26. backend/app/routers/topics.py +71 -0
  27. backend/app/routers/voice.py +144 -0
  28. backend/app/services/artifacts.py +76 -0
  29. backend/app/services/crypto.py +31 -0
  30. backend/app/services/email.py +15 -0
  31. backend/app/services/jobs.py +1 -0
  32. backend/app/services/launchpad.py +156 -0
  33. backend/app/services/renderer.py +65 -0
  34. backend/app/services/social/__init__.py +55 -0
  35. backend/app/services/social/bluesky.py +67 -0
  36. backend/app/services/social/linkedin.py +87 -0
  37. backend/app/services/social/x.py +95 -0
  38. backend/app/services/storage.py +91 -3
  39. backend/app/services/tts.py +124 -0
  40. backend/app/services/usage.py +64 -0
  41. backend/app/worker.py +145 -35
  42. backend/requirements.txt +1 -0
  43. backend/tests/conftest.py +36 -3
  44. backend/tests/test_buildout.py +612 -0
  45. frontend/src/App.tsx +24 -11
  46. frontend/src/api/auth.ts +1 -0
  47. frontend/src/api/client.ts +44 -2
  48. frontend/src/api/endpoints.ts +161 -0
  49. frontend/src/api/types.ts +230 -0
  50. 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 (Phase 1 audio)
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
- - Phase 0 task list: [docs/PHASE0.md](docs/PHASE0.md)
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 # needs Postgres only
35
- python -m app.worker # second terminal
36
  cd frontend && npm install && npm run dev
37
- cd renderer && npm install && npm start # or: npm run studio
38
  ```
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  ## Key choices
41
 
42
  | Concern | Where |
 
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
- context.configure(connection=connection, target_metadata=target_metadata)
 
 
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
- database_url: str = "postgresql+psycopg://notestack:notestack@localhost:5432/notestack"
 
 
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 (Cloudflare R2, S3 compatible)
 
 
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
- _connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
 
14
  engine = create_engine(settings.database_url, pool_pre_ping=True, connect_args=_connect_args)
 
 
 
 
 
 
 
 
 
 
 
15
  SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
16
 
17
 
 
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. Phase 1 modules are implemented; later ones are
2
- declared now so the output contract is fixed early."""
3
 
4
  from typing import Literal
5
 
@@ -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 artifacts, auth, billing, jobs, notebooks, sources, storage, unsubscribe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- from app.services.storage import storage as store
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
- platform: Mapped[str] = mapped_column(String(30))
193
- scheduled_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
 
 
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 R2 -> clean markdown files in the workspace corpus -> INDEX.md."""
 
 
 
 
 
 
 
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
- def normalize_feed_url(url: str) -> tuple[str, str]:
29
- """Accept 'name.substack.com', a Substack post URL, or any feed URL. Returns (feed_url, platform)."""
 
 
 
 
 
 
30
  url = url.strip()
31
- if not url.startswith(("http://", "https://")):
32
- url = f"https://{url}"
 
 
 
 
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={"User-Agent": "NotestackBot/0.1"})
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 ingest_source(db: Session, source: Source, job: Job, max_posts: int) -> dict:
104
- source.sync_status = "syncing"
105
- update_job(db, job, status="running", progress=0.02, message="Contacting your feed")
 
 
 
 
 
 
106
 
107
- title, entries = fetch_feed(source.feed_url, max_posts)
108
- source.title = source.title or title
 
 
 
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
- taken = set(
116
- db.scalars(select(Document.path).where(Document.workspace_id == source.workspace_id)).all()
117
- )
118
  for i, entry in enumerate(entries):
119
- content_hash = hashlib.sha256(entry.html.encode()).hexdigest()
 
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
- doc.raw_html_key = storage.put_text(
139
- keys.raw_html(source.workspace_id, source.id, doc.id), entry.html, "text/html; charset=utf-8"
140
- )
141
- sections = html_to_sections(entry.html)
142
  doc.clean_text = "\n\n".join(f"## {h}\n{t}" if h else t for h, t in sections)
143
- doc.metadata_json = {**(doc.metadata_json or {}), "headings": [h for h, _ in sections if h]}
 
 
 
 
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
- update_job(db, job, progress=0.05 + 0.85 * (i + 1) / total, message=f"{entry.title[:80]} is in orbit")
150
-
151
- update_job(db, job, progress=0.93, message="Writing your archive index")
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
- COMPOSITIONS = {"ShortVertical": "mp4", "QuoteCard": "png"}
16
-
17
-
18
- class ArtifactIn(BaseModel):
19
- type: Literal["summary", "audio_overview", "video", "thread", "linkedin", "notes", "quote_card", "carousel", "seo"]
20
- notebook_id: uuid.UUID | None = None
21
- document_id: uuid.UUID | None = None
22
- content: dict = {}
23
 
24
 
25
  class RenderIn(BaseModel):
26
- composition: Literal["ShortVertical", "QuoteCard"]
27
  props: dict
28
 
29
 
30
- def _serialize(a: Artifact) -> dict:
31
- return {
32
- "id": str(a.id),
33
- "type": a.type,
34
- "status": a.status,
35
- "content": a.content_json,
36
- "storage_key": a.storage_key,
37
- "url": storage.presign_get(a.storage_key) if a.storage_key else None,
38
- }
 
 
 
 
 
39
 
 
 
 
40
 
41
- def _get(ctx: Ctx, artifact_id: uuid.UUID) -> Artifact:
 
42
  a = ctx.db.scalar(select(Artifact).where(Artifact.id == artifact_id, Artifact.workspace_id == ctx.workspace.id))
43
  if not a:
44
  raise HTTPException(404, "Artifact not found")
45
  return a
46
 
47
 
48
- @router.post("")
49
- def create_artifact(body: ArtifactIn, ctx: Ctx = Depends(get_ctx)):
50
- a = Artifact(
51
- workspace_id=ctx.workspace.id,
52
- type=body.type,
53
- notebook_id=body.notebook_id,
54
- document_id=body.document_id,
55
- content_json=body.content,
56
- status="draft",
57
- )
58
- ctx.db.add(a)
59
- ctx.db.commit()
60
- return _serialize(a)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
 
62
 
63
  @router.get("/{artifact_id}")
64
  def get_artifact(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
65
- return _serialize(_get(ctx, artifact_id))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
 
67
 
68
  @router.post("/{artifact_id}/render")
69
  def render_artifact(artifact_id: uuid.UUID, body: RenderIn, ctx: Ctx = Depends(get_ctx)):
70
- a = _get(ctx, artifact_id)
 
 
71
  a.status = "rendering"
72
- job = create_job(
73
- ctx.db,
74
- ctx.workspace.id,
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
- if body.status == "done" and job.artifact_id:
63
- artifact = db.get(Artifact, job.artifact_id)
64
- if artifact and body.storage_key:
 
 
 
 
 
 
 
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
- return [{"id": str(n.id), "title": n.title, "description": n.description} for n in rows]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- valid = set(
86
- ctx.db.scalars(
87
- select(Document.id).where(Document.id.in_(body.document_ids), Document.workspace_id == ctx.workspace.id)
88
- ).all()
89
- )
90
- existing = set(
91
- ctx.db.scalars(select(NotebookDocument.document_id).where(NotebookDocument.notebook_id == nb.id)).all()
92
- )
93
- for doc_id in valid - existing:
94
- ctx.db.add(NotebookDocument(notebook_id=nb.id, document_id=doc_id))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  ctx.db.commit()
96
- return {"added": len(valid - existing)}
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 pydantic import BaseModel
5
- from sqlalchemy import func, select
 
6
 
7
  from app.auth import Ctx, get_ctx
8
- from app.models import Document, Job, Source
9
- from app.pipeline.ingest import normalize_feed_url
 
10
  from app.services.jobs import create_job, serialize_job
11
  from app.services.plans import effective_plan
 
12
 
13
  router = APIRouter(prefix="/api/sources", tags=["sources"])
14
 
15
 
16
  class SourceIn(BaseModel):
17
- url: str
18
 
19
 
20
- def _serialize(source: Source, doc_count: int = 0) -> dict:
 
 
 
 
21
  return {
22
  "id": str(source.id),
23
  "feed_url": source.feed_url,
 
24
  "platform": source.platform,
25
  "title": source.title,
26
  "sync_status": source.sync_status,
27
  "sync_error": source.sync_error,
28
  "last_synced_at": source.last_synced_at.isoformat() if source.last_synced_at else None,
29
  "document_count": doc_count,
 
30
  }
31
 
32
 
 
 
 
 
 
 
 
33
  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
- feed_url, platform = normalize_feed_url(body.url)
 
 
 
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": _serialize(existing), "job": serialize_job(job)}
46
  plan = effective_plan(ctx.db, ctx.workspace)
47
- count = ctx.db.scalar(select(func.count()).select_from(Source).where(Source.workspace_id == ctx.workspace.id))
 
 
 
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, site_url=body.url)
 
51
  ctx.db.add(source)
52
  ctx.db.commit()
53
  job = _enqueue_sync(ctx, source)
54
- return {"source": _serialize(source), "job": serialize_job(job)}
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 [_serialize(s, n) for s, n in rows]
67
 
68
 
69
- @router.get("/{source_id}/sync")
70
  def sync_source(source_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
71
- source = ctx.db.scalar(
72
- select(Source).where(Source.id == source_id, Source.workspace_id == ctx.workspace.id)
73
- )
74
- if not source:
75
- raise HTTPException(404, "Source not found")
76
  job = _enqueue_sync(ctx, source)
77
- return {"source": _serialize(source), "job": serialize_job(job)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
 
79
 
80
  documents_router = APIRouter(prefix="/api/documents", tags=["documents"])
81
 
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  @documents_router.get("")
84
- def list_documents(ctx: Ctx = Depends(get_ctx), limit: int = 500):
85
- docs = ctx.db.scalars(
86
- select(Document)
 
 
 
 
 
 
 
87
  .where(Document.workspace_id == ctx.workspace.id)
88
- .order_by(Document.published_at.desc())
89
- .limit(min(limit, 5000))
 
 
 
 
 
 
 
 
90
  ).all()
91
- return [
92
- {"id": str(d.id), "title": d.title, "url": d.url,
93
- "published_at": d.published_at.isoformat() if d.published_at else None}
94
- for d in docs
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
- """Cloudflare R2 storage (S3 API). Postgres stores keys; bytes live here.
2
 
3
- Locally, point R2_ENDPOINT_URL at MinIO and nothing else changes.
 
 
4
  """
5
 
6
  import hashlib
 
7
  import mimetypes
8
  import re
 
 
9
  import uuid
10
  from functools import cached_property
 
 
11
 
12
  import boto3
13
  from botocore.client import Config
@@ -127,4 +134,85 @@ class Storage:
127
  return f"{settings.r2_public_base_url.rstrip('/')}/{key}"
128
 
129
 
130
- storage = Storage()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.ingest import ingest_source
 
 
 
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.storage import keys, storage
 
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=min(plan.indexed_posts, 200))
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
- return Done(result, "All posts in orbit")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
 
78
 
79
  def handle_render(db: Session, job: Job):
80
- """Hand off to the Remotion renderer with a presigned PUT so it never holds R2 credentials."""
81
- p = job.params
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
- resp = httpx.post(
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 main() -> None:
244
- logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
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 db_session():
22
  engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
23
  Base.metadata.create_all(engine)
24
- Session = sessionmaker(bind=engine, expire_on_commit=False)
25
- session = Session()
 
 
 
 
26
  yield session
27
  session.close()
28
 
29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  @pytest.fixture()
31
  def client(db_session):
32
  app.dependency_overrides[get_db] = lambda: db_session
 
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, { ComingSoon } from "./components/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={<MissionControl />} />
40
  <Route path="notebooks/:id" element={<NotebookView />} />
41
- <Route path="sources" element={<MissionControl />} />
42
- <Route path="map" element={<ComingSoon title="Topic map" phase="Phase 2" />} />
43
- <Route path="voice" element={<ComingSoon title="Voice profile" phase="Phase 1" />} />
44
- <Route path="studio" element={<ComingSoon title="Video and audio studio" phase="Phase 2" />} />
45
- <Route path="launch-kit" element={<ComingSoon title="Launch Kit" phase="Phase 2" />} />
46
- <Route path="launchpad" element={<ComingSoon title="Launchpad" phase="Phase 3" />} />
47
- <Route path="archive" element={<ComingSoon title="Archive" phase="Phase 2" />} />
48
- <Route path="resurface" element={<ComingSoon title="Resurfacing" phase="Phase 3" />} />
49
- <Route path="settings" element={<ComingSoon title="Settings and billing" phase="Phase 1" />} />
 
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
- /** Direct browser upload to Cloudflare R2 through a presigned PUT. */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.type, size_bytes: file.size },
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="shell">
45
  <SkyCanvas intensity={0.35} />
46
  <aside className="sidebar">
47
- <NavLink to="/" className="sidebar-brand">
48
- <Logo size={28} />
49
- </NavLink>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
  }