HumeraR commited on
Commit
66de9c8
·
1 Parent(s): 55d0eca

Refactor VideoTemplateEditor component: remove AI editing features and related state management, streamline template loading, and update styles for improved layout. Enhance CSS for responsive design and visual consistency across various components, including dropdowns, sync stats, and quiz elements. Remove deprecated premium badge styles and adjust layout for chat components.

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. README.md +2 -2
  2. backend/alembic/versions/0006_plans.py +5 -2
  3. backend/app/config.py +22 -0
  4. backend/app/db.py +9 -4
  5. backend/app/infographics/image.py +34 -5
  6. backend/app/infographics/themes.py +5 -2
  7. backend/app/llm/provider.py +32 -1
  8. backend/app/llm/run.py +2 -1
  9. backend/app/llm/signatures.py +204 -10
  10. backend/app/models/__init__.py +6 -0
  11. backend/app/models/content.py +46 -0
  12. backend/app/models/user.py +1 -4
  13. backend/app/pipeline/flashcards.py +21 -10
  14. backend/app/pipeline/generate.py +26 -9
  15. backend/app/pipeline/infographic.py +32 -10
  16. backend/app/pipeline/ingest.py +19 -9
  17. backend/app/pipeline/material.py +37 -7
  18. backend/app/pipeline/media.py +5 -10
  19. backend/app/pipeline/passages.py +21 -3
  20. backend/app/pipeline/quiz.py +10 -5
  21. backend/app/pipeline/report.py +395 -125
  22. backend/app/routers/artifacts.py +115 -5
  23. backend/app/routers/notebooks.py +6 -1
  24. backend/app/routers/reports.py +11 -21
  25. backend/app/routers/settings.py +10 -29
  26. backend/app/routers/sources.py +12 -2
  27. backend/app/routers/video_edit.py +2 -28
  28. backend/app/routers/video_templates.py +0 -7
  29. backend/app/routers/video_voices.py +27 -197
  30. backend/app/routers/videos.py +4 -34
  31. backend/app/services/b2v_access.py +12 -50
  32. backend/app/services/billing.py +23 -10
  33. backend/app/services/notestack_voices.py +3 -4
  34. backend/app/services/plans.py +51 -44
  35. backend/app/services/renderer.py +0 -12
  36. backend/app/services/usage.py +27 -26
  37. backend/app/services/video_limits.py +4 -15
  38. backend/app/support/corpus.py +1 -1
  39. backend/app/worker.py +25 -3
  40. backend/scripts/render_theme_thumbnails.py +15 -27
  41. backend/tests/test_billing.py +74 -5
  42. backend/tests/test_buildout.py +128 -40
  43. backend/tests/test_support.py +1 -2
  44. backend/tests/test_videos.py +42 -90
  45. docs/BLOG2VIDEO.md +7 -8
  46. frontend/src/api/endpoints.ts +12 -41
  47. frontend/src/api/types.ts +14 -31
  48. frontend/src/components/AnswerFeedback.tsx +1 -1
  49. frontend/src/components/AppShell.tsx +1 -1
  50. frontend/src/components/Flashcards.tsx +15 -25
README.md CHANGED
@@ -49,7 +49,7 @@ archive, stays in sync with it, cites the exact lines behind every answer, and t
49
  | Citations | Exact line ranges, verified before you see them | Passage level citations |
50
  | Not in your sources | Says so; unverifiable claims are dropped | Grounded, passage citations |
51
  | Audio | Two hosts on ElevenLabs voices, or your consented voice clone | Two host overviews, Google voices |
52
- | Video | 9:16 shorts, 16:9 explainers, 1:1 audiograms, quote cards, in your brand | Video overviews |
53
  | Publishing | Launch Kits (threads, LinkedIn, Notes, Bluesky, SEO, carousels) and auto posting | Not a publishing tool |
54
  | Your voice | Voice profile learned from your posts, used in every draft | General purpose writing |
55
  | Seeing your archive | Topic constellation with rising and dormant topics, evergreen resurfacing | Idea Constellations per notebook |
@@ -121,7 +121,7 @@ cd renderer && npm install && npm start # only needed for vid
121
  | Launchpad | Calendar and agenda, auto posting to X, LinkedIn and Bluesky, email reminders, tracked links, engagement |
122
  | Archive | Every generated artifact with filters, players, downloads, retry and delete |
123
  | Resurfacing | Evergreen scores and reshare angles for old posts, on this day, straight into a kit or the calendar |
124
- | Settings | Profile, workspace, brand kit, privacy, plan usage meters, connections, sign out everywhere, delete |
125
 
126
  ## Key choices
127
 
 
49
  | Citations | Exact line ranges, verified before you see them | Passage level citations |
50
  | Not in your sources | Says so; unverifiable claims are dropped | Grounded, passage citations |
51
  | Audio | Two hosts on ElevenLabs voices, or your consented voice clone | Two host overviews, Google voices |
52
+ | Video | 9:16 shorts, 16:9 explainers, 1:1 audiograms, quote cards | Video overviews |
53
  | Publishing | Launch Kits (threads, LinkedIn, Notes, Bluesky, SEO, carousels) and auto posting | Not a publishing tool |
54
  | Your voice | Voice profile learned from your posts, used in every draft | General purpose writing |
55
  | Seeing your archive | Topic constellation with rising and dormant topics, evergreen resurfacing | Idea Constellations per notebook |
 
121
  | Launchpad | Calendar and agenda, auto posting to X, LinkedIn and Bluesky, email reminders, tracked links, engagement |
122
  | Archive | Every generated artifact with filters, players, downloads, retry and delete |
123
  | Resurfacing | Evergreen scores and reshare angles for old posts, on this day, straight into a kit or the calendar |
124
+ | Settings | Profile, workspace, privacy, plan usage meters, connections, sign out everywhere, delete |
125
 
126
  ## Key choices
127
 
backend/alembic/versions/0006_plans.py CHANGED
@@ -57,12 +57,15 @@ def upgrade() -> None:
57
  sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
58
  sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
59
  )
 
 
 
60
  types = {"features": sa.JSON(), "voice_cloning": sa.Boolean(), "brand_kit": sa.Boolean(),
61
  "created_at": sa.DateTime(timezone=True), "updated_at": sa.DateTime(timezone=True)}
62
- plans = sa.table("plans", *(sa.column(k, types.get(k)) for k in [*SEED[0], "created_at", "updated_at"]))
63
  existing = {row[0] for row in bind.execute(sa.text("SELECT id FROM plans"))}
64
  now = datetime.now(UTC)
65
- missing = [{**p, "created_at": now, "updated_at": now} for p in SEED if p["id"] not in existing]
66
  if missing:
67
  op.bulk_insert(plans, missing)
68
 
 
57
  sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
58
  sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
59
  )
60
+ # Columns a later migration drops (brand_kit) are not in a table 0001 built from today's models: seed what exists.
61
+ have = {c["name"] for c in sa.inspect(bind).get_columns("plans")}
62
+ seed = [{k: v for k, v in p.items() if k in have} for p in SEED]
63
  types = {"features": sa.JSON(), "voice_cloning": sa.Boolean(), "brand_kit": sa.Boolean(),
64
  "created_at": sa.DateTime(timezone=True), "updated_at": sa.DateTime(timezone=True)}
65
+ plans = sa.table("plans", *(sa.column(k, types.get(k)) for k in [*seed[0], "created_at", "updated_at"]))
66
  existing = {row[0] for row in bind.execute(sa.text("SELECT id FROM plans"))}
67
  now = datetime.now(UTC)
68
+ missing = [{**p, "created_at": now, "updated_at": now} for p in seed if p["id"] not in existing]
69
  if missing:
70
  op.bulk_insert(plans, missing)
71
 
backend/app/config.py CHANGED
@@ -64,6 +64,25 @@ class Settings(BaseSettings):
64
  r2_presign_ttl_seconds: int = 3600
65
  max_upload_bytes: int = 300 * 1024 * 1024
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  # LLM (LiteLLM model strings; Z.ai GLM by default)
68
  llm_model: str = "openai/glm-5.3"
69
  llm_fast_model: str = "openai/glm-5.3-flash"
@@ -86,6 +105,9 @@ class Settings(BaseSettings):
86
  llm_temperature: float = 1.0 # Z.ai recommends 1.0 for GLM-5.x
87
  # Reasoning output counts against max_tokens, so leave headroom for the structured answer.
88
  llm_max_tokens: int = 10000
 
 
 
89
  # GLM-5.x always reasons (it cannot be disabled); this sets how hard. low | high | max
90
  llm_reasoning_effort: str = "low"
91
 
 
64
  r2_presign_ttl_seconds: int = 3600
65
  max_upload_bytes: int = 300 * 1024 * 1024
66
 
67
+ # Posts an infographic reads, and a report that is not built from themes (REPORT_PLANNER below). A quiz or flashcard set reads up to that many straight from the posts; with more (no limit
68
+ # on how many are picked) it reads a small sample of the ideas already extracted from them, so its size does not grow.
69
+ artifact_max_posts: int = 20
70
+ # Characters of post text a report, quiz or flashcard set may read in all (split evenly between the posts, 1,500 at least each).
71
+ artifact_passage_budget: int = 120_000
72
+ # The same for a quiz: it needs less text than a report to ask good questions.
73
+ artifact_quiz_passage_budget: int = 60_000
74
+ # Ideas (already extracted from each post) in the evidence of a flashcard set, or of a quiz above ARTIFACT_MAX_POSTS posts.
75
+ artifact_idea_sample: int = 30
76
+
77
+ # Reports. "themes": the posts' stored ideas are grouped into weighted themes, a blueprint plans the report, and each section is
78
+ # written from its own evidence (pipeline/report_themes.py). "legacy": the posts' text goes straight to the writer, at most
79
+ # ARTIFACT_MAX_POSTS of them. Chats, small selections and posts without stored ideas always use the legacy way.
80
+ report_planner: str = "themes"
81
+ # Posts a themed report reads: 0 = every post picked (the plan's indexed_posts still applies).
82
+ report_max_posts: int = 0
83
+ # Posts whose ideas are extracted while the report is made (in parallel); the rest are represented by their opening.
84
+ report_inline_ideas: int = 25
85
+
86
  # LLM (LiteLLM model strings; Z.ai GLM by default)
87
  llm_model: str = "openai/glm-5.3"
88
  llm_fast_model: str = "openai/glm-5.3-flash"
 
105
  llm_temperature: float = 1.0 # Z.ai recommends 1.0 for GLM-5.x
106
  # Reasoning output counts against max_tokens, so leave headroom for the structured answer.
107
  llm_max_tokens: int = 10000
108
+ # The output cap of the fast model: quizzes, flashcards, infographics, idea extraction, template suggestions. A cap, not a target:
109
+ # only the tokens written are charged, and the thinking counts too, so keep room for it. Measured answers are under 3,000.
110
+ llm_fast_max_tokens: int = 6000
111
  # GLM-5.x always reasons (it cannot be disabled); this sets how hard. low | high | max
112
  llm_reasoning_effort: str = "low"
113
 
backend/app/db.py CHANGED
@@ -11,13 +11,18 @@ class Base(DeclarativeBase):
11
 
12
 
13
  _sqlite = settings.database_url.startswith("sqlite")
14
- # Postgres: a connection that dies silently (laptop sleep, network change, Neon dropping it) otherwise leaves a query
15
- # waiting on the socket forever, and the worker loop with it. Keepalives notice a dead peer within about a minute
16
- # and the query fails instead; connect_timeout does the same for a connect that never completes.
 
 
 
 
17
  _pg_connect_args = {"connect_timeout": 10, "keepalives": 1, "keepalives_idle": 30, "keepalives_interval": 10,
18
  "keepalives_count": 3}
19
  _connect_args = {"check_same_thread": False, "timeout": 30} if _sqlite else _pg_connect_args
20
- engine = create_engine(settings.database_url, pool_pre_ping=True, pool_recycle=300, connect_args=_connect_args)
 
21
 
22
  if _sqlite:
23
  # Local dev: the API and the worker share one file. WAL lets reads run during a write, the busy
 
11
 
12
 
13
  _sqlite = settings.database_url.startswith("sqlite")
14
+ # A hosted Postgres (Neon) closes idle connections and drops the odd one on a network blip, and a connection that dies silently
15
+ # (laptop sleep, network change) otherwise leaves a query waiting on the socket forever, and the worker loop with it. TCP
16
+ # keepalives notice a dead peer within about a minute, so the query fails instead and a dead socket is noticed while it sits in
17
+ # the pool; connect_timeout does the same for a connect that never completes. A pooled connection is also replaced before it
18
+ # is handed out (pool_recycle, below Neon's idle timeout), since pool_pre_ping alone can fail on a dead SSL socket with a
19
+ # ProgrammingError ("can't change 'autocommit' now") that SQLAlchemy does not treat as a lost connection, and the request
20
+ # ends in a 500.
21
  _pg_connect_args = {"connect_timeout": 10, "keepalives": 1, "keepalives_idle": 30, "keepalives_interval": 10,
22
  "keepalives_count": 3}
23
  _connect_args = {"check_same_thread": False, "timeout": 30} if _sqlite else _pg_connect_args
24
+ engine = create_engine(settings.database_url, pool_pre_ping=True, connect_args=_connect_args,
25
+ **({} if _sqlite else {"pool_recycle": 240, "pool_use_lifo": True}))
26
 
27
  if _sqlite:
28
  # Local dev: the API and the worker share one file. WAL lets reads run during a write, the busy
backend/app/infographics/image.py CHANGED
@@ -5,6 +5,7 @@ import functools
5
  import glob
6
  import hashlib
7
  import os
 
8
  import shutil
9
  import subprocess
10
  import tempfile
@@ -14,8 +15,9 @@ from threading import Lock
14
 
15
  from app.infographics.render import SIZES
16
 
17
- TIMEOUT = 60 # seconds for one drawing
18
- KEEP = 8 # drawings kept in memory
 
19
 
20
  _cache: OrderedDict[str, bytes] = OrderedDict()
21
  _lock = Lock()
@@ -51,9 +53,9 @@ def chrome() -> str | None:
51
 
52
 
53
  def render_png(html: str, layout: str = "portrait") -> bytes:
54
- """The page as a PNG at its own size: tall (1600 x 2260) or wide (2260 x 1600)."""
55
  width, height = SIZES.get(layout, SIZES["portrait"])
56
- key = hashlib.sha256(f"{width}x{height}{html}".encode()).hexdigest()
57
  with _lock:
58
  if key in _cache:
59
  _cache.move_to_end(key)
@@ -65,7 +67,7 @@ def render_png(html: str, layout: str = "portrait") -> bytes:
65
  with tempfile.TemporaryDirectory() as tmp:
66
  src, out = Path(tmp) / "page.html", Path(tmp) / "out.png"
67
  src.write_text(page, encoding="utf-8")
68
- cmd = [binary, "--headless=new", "--disable-gpu", "--no-sandbox", "--hide-scrollbars", "--force-device-scale-factor=1",
69
  f"--user-data-dir={tmp}/profile", f"--window-size={width},{height}", "--virtual-time-budget=8000",
70
  f"--screenshot={out}", f"file://{src}"]
71
  try:
@@ -80,3 +82,30 @@ def render_png(html: str, layout: str = "portrait") -> bytes:
80
  while len(_cache) > KEEP:
81
  _cache.popitem(last=False)
82
  return data
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  import glob
6
  import hashlib
7
  import os
8
+ import re
9
  import shutil
10
  import subprocess
11
  import tempfile
 
15
 
16
  from app.infographics.render import SIZES
17
 
18
+ TIMEOUT = 90 # seconds for one drawing
19
+ SCALE = 2 # pixels per page pixel: a 1600 x 2260 page is drawn as 3200 x 4520, sharp on any screen and in print
20
+ KEEP = 6 # drawings kept in memory
21
 
22
  _cache: OrderedDict[str, bytes] = OrderedDict()
23
  _lock = Lock()
 
53
 
54
 
55
  def render_png(html: str, layout: str = "portrait") -> bytes:
56
+ """The page as a PNG, drawn at SCALE times its size: tall (1600 x 2260) or wide (2260 x 1600) before scaling."""
57
  width, height = SIZES.get(layout, SIZES["portrait"])
58
+ key = hashlib.sha256(f"{width}x{height}@{SCALE}{html}".encode()).hexdigest()
59
  with _lock:
60
  if key in _cache:
61
  _cache.move_to_end(key)
 
67
  with tempfile.TemporaryDirectory() as tmp:
68
  src, out = Path(tmp) / "page.html", Path(tmp) / "out.png"
69
  src.write_text(page, encoding="utf-8")
70
+ cmd = [binary, "--headless=new", "--disable-gpu", "--no-sandbox", "--hide-scrollbars", f"--force-device-scale-factor={SCALE}",
71
  f"--user-data-dir={tmp}/profile", f"--window-size={width},{height}", "--virtual-time-budget=8000",
72
  f"--screenshot={out}", f"file://{src}"]
73
  try:
 
82
  while len(_cache) > KEEP:
83
  _cache.popitem(last=False)
84
  return data
85
+
86
+
87
+ _FIT = re.compile(r"FIT=([0-9.]+)")
88
+ _MEASURE = ("<script>document.fonts.ready.then(function(){var b=document.querySelector('.ig-body'),i=document.querySelector('.ig-inner');"
89
+ "var s=1;if(b&&i){var inner=i.scrollHeight*parseFloat(getComputedStyle(i).getPropertyValue('--fit')||1);s=Math.min(1,b.clientHeight/Math.max(inner,1));}"
90
+ "document.title='FIT='+s.toFixed(3);});</script>")
91
+
92
+
93
+ def measure_fit(html: str, layout: str) -> float:
94
+ """The scale (1 or less) that makes an AI-composed page's body fit its space: the page drawn once, in Chrome, with our own
95
+ small script measuring it (the page's markup is sanitised, so no other script is in it). 1 when Chrome is missing."""
96
+ width, height = SIZES.get(layout, SIZES["portrait"])
97
+ binary = chrome()
98
+ if not binary:
99
+ return 1.0
100
+ page = f'<!doctype html><meta charset="utf-8"><body style="margin:0">{html}{_MEASURE}</body>'
101
+ with tempfile.TemporaryDirectory() as tmp:
102
+ src = Path(tmp) / "page.html"
103
+ src.write_text(page, encoding="utf-8")
104
+ cmd = [binary, "--headless=new", "--disable-gpu", "--no-sandbox", "--hide-scrollbars", f"--user-data-dir={tmp}/profile",
105
+ f"--window-size={width},{height}", "--virtual-time-budget=10000", "--dump-dom", f"file://{src}"]
106
+ try:
107
+ out = subprocess.run(cmd, check=True, capture_output=True, timeout=TIMEOUT, text=True).stdout
108
+ except (subprocess.SubprocessError, OSError):
109
+ return 1.0
110
+ found = _FIT.search(out)
111
+ return max(0.55, min(1.0, float(found.group(1)))) if found else 1.0
backend/app/infographics/themes.py CHANGED
@@ -19,8 +19,11 @@ THEMES: dict[str, Theme] = {t.id: t for t in (
19
  Theme("moonbase", "Moon Base", "A lunar surface, a dome and a flag", ("#2b2b30", "#d8d2c4", "#c8745f")),
20
  Theme("observatory", "Observatory", "A telescope under a chart of stars", ("#14282b", "#8fc2b4", "#d6b66a")),
21
  )}
22
- DEFAULT_THEME = "launch"
23
 
24
 
25
  def theme_id(value: object) -> str:
26
- return value if isinstance(value, str) and value in THEMES else DEFAULT_THEME
 
 
 
 
19
  Theme("moonbase", "Moon Base", "A lunar surface, a dome and a flag", ("#2b2b30", "#d8d2c4", "#c8745f")),
20
  Theme("observatory", "Observatory", "A telescope under a chart of stars", ("#14282b", "#8fc2b4", "#d6b66a")),
21
  )}
22
+ DEFAULT_THEME = "midnight" # the look new infographics start with (the six above are the older fixed themes)
23
 
24
 
25
  def theme_id(value: object) -> str:
26
+ """A look (design.LOOKS) or one of the older themes; the default when unknown."""
27
+ from app.infographics.design import LOOKS
28
+
29
+ return value if isinstance(value, str) and (value in LOOKS or value in THEMES) else DEFAULT_THEME
backend/app/llm/provider.py CHANGED
@@ -49,7 +49,7 @@ def main_lm(effort: str | None = None) -> dspy.LM:
49
 
50
  @lru_cache
51
  def fast_lm() -> dspy.LM:
52
- return _build(settings.llm_fast_model, effort="low", temperature=None, max_tokens=8000)
53
 
54
 
55
  REPORT_MAX_TOKENS = 16000 # a few pages of prose plus GLM's low-effort reasoning
@@ -62,6 +62,27 @@ def report_lm() -> dspy.LM:
62
  max_tokens=REPORT_MAX_TOKENS)
63
 
64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  @lru_cache
66
  def triage_lm() -> dspy.LM:
67
  """Cheap model that decides whether a chat message needs research at all."""
@@ -82,6 +103,16 @@ def focus_lm(retry: bool = False) -> dspy.LM:
82
  max_tokens=FOCUS_MAX_TOKENS)
83
 
84
 
 
 
 
 
 
 
 
 
 
 
85
  def configure_default() -> None:
86
  dspy.configure(lm=main_lm(), adapter=dspy.JSONAdapter())
87
 
 
49
 
50
  @lru_cache
51
  def fast_lm() -> dspy.LM:
52
+ return _build(settings.llm_fast_model, effort="low", temperature=None, max_tokens=settings.llm_fast_max_tokens)
53
 
54
 
55
  REPORT_MAX_TOKENS = 16000 # a few pages of prose plus GLM's low-effort reasoning
 
62
  max_tokens=REPORT_MAX_TOKENS)
63
 
64
 
65
+ REPORT_PLAN_MAX_TOKENS = 6000 # the blueprint: a storyline and up to eight short section plans plus reasoning
66
+ REPORT_SECTION_MAX_TOKENS = 4000 # one section (two to five paragraphs) plus reasoning, a quarter of a whole report's cap
67
+
68
+
69
+ def _report_model() -> str:
70
+ return settings.llm_report_model or settings.llm_fast_model
71
+
72
+
73
+ @lru_cache
74
+ def report_plan_lm() -> dspy.LM:
75
+ """Plans a themed report (the blueprint). The report model, with a small cap: the answer is a plan, not prose."""
76
+ return _build(_report_model(), effort="low", temperature=None, max_tokens=REPORT_PLAN_MAX_TOKENS)
77
+
78
+
79
+ @lru_cache
80
+ def report_section_lm(retry: bool = False) -> dspy.LM:
81
+ """Writes one section of a themed report. retry=True is the second try after a failed one: a different temperature,
82
+ so it is a fresh call and not the failed one again from the cache."""
83
+ return _build(_report_model(), effort="low", temperature=0.7 if retry else None, max_tokens=REPORT_SECTION_MAX_TOKENS)
84
+
85
+
86
  @lru_cache
87
  def triage_lm() -> dspy.LM:
88
  """Cheap model that decides whether a chat message needs research at all."""
 
103
  max_tokens=FOCUS_MAX_TOKENS)
104
 
105
 
106
+ SUGGEST_TEMPLATES_MAX_TOKENS = 2000 # four short template ideas plus GLM's low-effort reasoning
107
+
108
+
109
+ @lru_cache
110
+ def suggest_templates_lm() -> dspy.LM:
111
+ """The Create report dialog's suggested templates: four short template ideas out, so a tighter cap than the
112
+ general fast_lm keeps this quick without cutting into GLM's reasoning headroom."""
113
+ return _build(settings.llm_fast_model, effort="low", temperature=None, max_tokens=SUGGEST_TEMPLATES_MAX_TOKENS)
114
+
115
+
116
  def configure_default() -> None:
117
  dspy.configure(lm=main_lm(), adapter=dspy.JSONAdapter())
118
 
backend/app/llm/run.py CHANGED
@@ -77,7 +77,8 @@ def predict_many(
77
  def _record(db: Session, workspace_id: uuid.UUID, job: Job | None, signature: type[dspy.Signature], lm: dspy.LM,
78
  usage, calls: list[tuple[dict, dict]]) -> None:
79
  if usage.total:
80
- record_usage(db, workspace_id=workspace_id, kind="llm", provider=provider_name(), model=settings.llm_model,
 
81
  quantity=usage.total, unit="tokens", cost_usd=usage.cost_usd, job=job)
82
  workspace = db.get(Workspace, workspace_id)
83
  for inputs, outputs in calls:
 
77
  def _record(db: Session, workspace_id: uuid.UUID, job: Job | None, signature: type[dspy.Signature], lm: dspy.LM,
78
  usage, calls: list[tuple[dict, dict]]) -> None:
79
  if usage.total:
80
+ model = getattr(lm, "model", None) or settings.llm_model # the model that answered, not always the main one
81
+ record_usage(db, workspace_id=workspace_id, kind="llm", provider=provider_name(model), model=model,
82
  quantity=usage.total, unit="tokens", cost_usd=usage.cost_usd, job=job)
83
  workspace = db.get(Workspace, workspace_id)
84
  for inputs, outputs in calls:
backend/app/llm/signatures.py CHANGED
@@ -451,6 +451,58 @@ class PlanInfographic(dspy.Signature):
451
  infographic: InfographicOut = dspy.OutputField()
452
 
453
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
454
  class WriteReport(dspy.Signature):
455
  """Write the report the reader describes, using only the material given: never add facts from outside it. Follow
456
  the reader's instructions on structure, style, tone and length. Write in Markdown that starts straight at the
@@ -460,9 +512,8 @@ class WriteReport(dspy.Signature):
460
  unless the reader's instructions ask for a different structure. Every "## " heading is specific and descriptive of
461
  what is in that section (for example "The Context Window Bottleneck: Moving Beyond Basic Summarization", never
462
  "Section 2" or "Details"), and they read in a logical order. Use "### " inside sections, lists, and tables where
463
- they help. When the material has numbered lines (FILE blocks), put a [n] marker after each
464
- claim it supports and list each n in citations with the path and lines it came from; when it is a chat transcript,
465
- write no markers and no citations. Do not write a quiz, flashcards, practice or review questions into the report
466
  unless the reader's instructions
467
  ask for one: those are offered to the reader separately as visuals they can add. Never write about visuals, mind
468
  maps, flashcards, quizzes, tables or timelines in the text, and never suggest or describe one, even if the
@@ -476,7 +527,6 @@ class WriteReport(dspy.Signature):
476
  language: str = dspy.InputField(desc="The language to write in")
477
  report_title: str = dspy.OutputField(desc="A specific title, 3 to 10 words")
478
  markdown: str = dspy.OutputField(desc="The report body in Markdown, starting at the first ## heading")
479
- citations: list[FileCitation] = dspy.OutputField()
480
 
481
 
482
  class PlannedEmbed(BaseModel):
@@ -521,22 +571,166 @@ class WriteReportSection(dspy.Signature):
521
  and length; a section is two to five short paragraphs, or a list or table where that reads better. Do not write
522
  quiz questions, flashcards or
523
  review questions in the section (those are offered to the reader separately). Do not repeat
524
- the heading, and do not use "#" or "##" headings; "###" subheadings are fine. When the material has numbered lines (FILE
525
- blocks), put a [n] marker after each claim it supports and list each n in citations with the path and lines; when it
526
- is a chat transcript, write no markers and no citations. Never write about visuals, mind maps, flashcards,
527
  quizzes, tables or timelines in the text, and never suggest or describe one, even if the reader's instructions
528
  mention them: the app offers those to the reader separately, so write only the report itself. Write in the
529
  language requested. Never use em dashes."""
530
 
 
531
  title: str = dspy.InputField()
532
  report_title: str = dspy.InputField()
 
 
 
533
  heading: str = dspy.InputField()
534
  brief: str = dspy.InputField(desc="What this section should say")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
535
  request: str = dspy.InputField(desc="What the reader wants the report to be")
536
- material: list[str] = dspy.InputField(desc="Posts with path and numbered lines, or chat transcripts")
537
  language: str = dspy.InputField()
 
 
 
 
 
 
 
 
 
538
  markdown: str = dspy.OutputField(desc="The section body in Markdown, without its heading")
539
- citations: list[FileCitation] = dspy.OutputField()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
540
 
541
 
542
  class ReportTemplateIdea(BaseModel):
@@ -552,7 +746,7 @@ class SuggestReportTemplates(dspy.Signature):
552
  a practical how-to, a comparison, a beginner primer and an in-depth analysis, when the material supports them).
553
  Never use em dashes."""
554
 
555
- items: list[str] = dspy.InputField(desc="The title and the start of each source")
556
  topic: str = dspy.InputField(desc="What the reader wants the report to be about, or (none)")
557
  templates: list[ReportTemplateIdea] = dspy.OutputField(desc="Exactly 4")
558
 
 
451
  infographic: InfographicOut = dspy.OutputField()
452
 
453
 
454
+ class KeyFigure(BaseModel):
455
+ value: str = Field(description="A number or a very short fact, at most 12 characters, e.g. 30%, 8 steps, Layer 1")
456
+ label: str = Field(description="What it counts or means, at most 6 words")
457
+
458
+
459
+ class DesignOut(BaseModel):
460
+ title: str = Field(description="At most 8 words")
461
+ subtitle: str = Field(description="One line, at most 16 words")
462
+ eyebrow: str = Field(description="A 1 to 3 word topic label, e.g. Agent engineering")
463
+ key_figures: list[KeyFigure] = Field(description="The 2 or 3 most important numbers or short facts in the material, shown beside the title")
464
+ body_html: str = Field(description="The body of the page: 2 to 4 sections of HTML built only from the allowed components")
465
+ rule: str = Field(description="The one takeaway, at most 14 words")
466
+ notes: list[str] = Field(description="Exactly 3 supporting notes, at most 14 words each")
467
+
468
+
469
+ class DesignInfographic(dspy.Signature):
470
+ """Design a one page infographic from the material. You write the body of the page as HTML, composed ONLY from the
471
+ components below, choosing the ones that make the content clearest (a flow chart for steps, a timeline for events,
472
+ a hierarchy for a whole and its parts, a comparison for two options, cards for the key ideas, stats for numbers that
473
+ matter). Use only what the material says. When a layout style is asked for, build the page around it. When a focus is
474
+ given, make it about that, as far as the material covers it. The page is black, blue and white and printed on a
475
+ poster: plain text only, short lines, no markdown, no emoji, no quotes around names, no em dashes. Write in the
476
+ language of the material.
477
+
478
+ HTML rules: tags allowed are div, section, span, p, ul, ol, li, strong, em, small, br. Use no style, id, href, src,
479
+ script, svg, img or any attribute except class. Only these class names exist: sec, sec-t, lead, hl, node, n-no, n-t,
480
+ n-d, flow, col, grid, c2, c3, c4, card, c-k, c-t, c-d, timeline, t-item, t-when, t-t, t-d, tree, t-root, t-kids,
481
+ compare, side, s-h, stats, stat, s-n, s-l, cols, pills, pill, callout.
482
+
483
+ Every part sits in a section: <section class="sec"><div class="sec-t">Short heading</div> COMPONENT </section>.
484
+ Components:
485
+ - Flow chart, left to right, at most 4 boxes, arrows are drawn between them for you:
486
+ <div class="flow"><div class="node"><span class="n-no">01</span><span class="n-t">Title</span><span class="n-d">One short line</span></div> ...</div>
487
+ Add class hl to the box that matters most (class="node hl"). For more than 4 steps use two flows, or a vertical one: class="flow col" (at most 5 boxes).
488
+ - Cards in a grid of 2, 3 or 4 columns (class grid c2, grid c3 or grid c4, at most 8 cards):
489
+ <div class="grid c3"><div class="card"><span class="c-k">Kicker</span><span class="c-t">Title</span><span class="c-d">One short line</span></div> ...</div>
490
+ - Timeline, at most 5 moments: <div class="timeline"><div class="t-item"><span class="t-when">When</span><span class="t-t">Title</span><span class="t-d">One short line</span></div> ...</div>
491
+ - Hierarchy, one root and 2 to 4 children: <div class="tree"><div class="node hl t-root"><span class="n-t">Whole</span><span class="n-d">Line</span></div><div class="t-kids"><div class="node"><span class="n-t">Part</span><span class="n-d">Line</span></div> ...</div></div>
492
+ - Comparison of two sides, 2 to 4 short lines each: <div class="compare"><div class="side"><span class="s-h">Side A</span><ul><li>Line</li></ul></div><div class="side hl"><span class="s-h">Side B</span><ul><li>Line</li></ul></div></div>
493
+ - Numbers, 2 to 4: <div class="stats"><div class="stat"><span class="s-n">30%</span><span class="s-l">What it counts</span></div> ...</div>
494
+ - A callout sentence: <div class="callout">One sentence.</div>
495
+ The key_figures are separate from the body and are shown beside the title, so do not repeat them in the body.
496
+ Lengths: a box or card title at most 4 words, its line at most 14 words, a heading at most 4 words. Use two to four
497
+ sections, and mix components (never one grid of 8 cards alone). Keep the whole page short enough for one poster."""
498
+
499
+ title: str = dspy.InputField(desc="What the material is from")
500
+ material: list[str] = dspy.InputField(desc="Ideas from posts, or chat transcripts")
501
+ focus: str = dspy.InputField(desc="What the infographic should be about, or (none)")
502
+ layout_style: str = dspy.InputField(desc="The kind of layout asked for, or auto to choose")
503
+ design: DesignOut = dspy.OutputField()
504
+
505
+
506
  class WriteReport(dspy.Signature):
507
  """Write the report the reader describes, using only the material given: never add facts from outside it. Follow
508
  the reader's instructions on structure, style, tone and length. Write in Markdown that starts straight at the
 
512
  unless the reader's instructions ask for a different structure. Every "## " heading is specific and descriptive of
513
  what is in that section (for example "The Context Window Bottleneck: Moving Beyond Basic Summarization", never
514
  "Section 2" or "Details"), and they read in a logical order. Use "### " inside sections, lists, and tables where
515
+ they help. Do not cite: no [n] markers, footnotes, references or lists of sources in the text; the app shows the reader
516
+ the sources separately. Do not write a quiz, flashcards, practice or review questions into the report
 
517
  unless the reader's instructions
518
  ask for one: those are offered to the reader separately as visuals they can add. Never write about visuals, mind
519
  maps, flashcards, quizzes, tables or timelines in the text, and never suggest or describe one, even if the
 
527
  language: str = dspy.InputField(desc="The language to write in")
528
  report_title: str = dspy.OutputField(desc="A specific title, 3 to 10 words")
529
  markdown: str = dspy.OutputField(desc="The report body in Markdown, starting at the first ## heading")
 
530
 
531
 
532
  class PlannedEmbed(BaseModel):
 
571
  and length; a section is two to five short paragraphs, or a list or table where that reads better. Do not write
572
  quiz questions, flashcards or
573
  review questions in the section (those are offered to the reader separately). Do not repeat
574
+ the heading, and do not use "#" or "##" headings; "###" subheadings are fine. Do not cite: no [n] markers, footnotes,
575
+ references or lists of sources in the text; the app shows the reader the sources separately. Never write about visuals, mind maps, flashcards,
 
576
  quizzes, tables or timelines in the text, and never suggest or describe one, even if the reader's instructions
577
  mention them: the app offers those to the reader separately, so write only the report itself. Write in the
578
  language requested. Never use em dashes."""
579
 
580
+ # The parts every section shares come first, so a provider can reuse them across the section calls.
581
  title: str = dspy.InputField()
582
  report_title: str = dspy.InputField()
583
+ request: str = dspy.InputField(desc="What the reader wants the report to be")
584
+ material: list[str] = dspy.InputField(desc="Posts with path and numbered lines, or chat transcripts")
585
+ language: str = dspy.InputField()
586
  heading: str = dspy.InputField()
587
  brief: str = dspy.InputField(desc="What this section should say")
588
+ markdown: str = dspy.OutputField(desc="The section body in Markdown, without its heading")
589
+
590
+
591
+ # Themed reports: the posts' stored ideas are grouped into themes, a blueprint plans the whole report, and each section is
592
+ # written from its own evidence (pipeline/report_themes.py, report_blueprint.py, report_evidence.py).
593
+
594
+
595
+ class ReportTheme(BaseModel):
596
+ name: str = Field(description="2 to 5 words, a noun phrase a reader would recognise")
597
+ summary: str = Field(description="One or two plain sentences on what the posts say about it")
598
+ idea_ids: list[str] = Field(description="Ids of the ideas that belong to this theme, exactly as listed")
599
+ matches_request: bool = Field(False, description="True when the reader's request asks for this theme to be focused on, "
600
+ "included or given more room")
601
+
602
+
603
+ class ThemeRelation(BaseModel):
604
+ a: str = Field(description="A theme name, exactly as you wrote it in themes")
605
+ b: str = Field(description="Another theme name, exactly as you wrote it in themes")
606
+ relation: str = Field(description="How they connect, one short sentence")
607
+
608
+
609
+ class GroupThemes(dspy.Signature):
610
+ """Group ideas taken from a writer's posts into themes. Every idea belongs to exactly one theme, listed by its id as
611
+ given. A theme is something several ideas are about, named the way a reader would name it: not a post title, not a
612
+ single idea, not 'Other'. Choose 4 to 12 themes, fewer when the ideas really are about few things. Also note how themes
613
+ connect (one cause of another, one the context of another, two sides of a choice), only where the ideas show it.
614
+ Where the reader's request asks to focus on, include or give more room to something, set matches_request on the theme
615
+ that is about it. Never use em dashes."""
616
+
617
+ title: str = dspy.InputField(desc="The notebook or posts the ideas come from")
618
+ request: str = dspy.InputField(desc="What the reader wants the report to be")
619
+ ideas: list[str] = dspy.InputField(desc="One per line: id | label: note")
620
+ themes: list[ReportTheme] = dspy.OutputField(desc="4 to 12 themes")
621
+ relations: list[ThemeRelation] = dspy.OutputField(desc="How themes connect, at most 8, only where the ideas show it")
622
+
623
+
624
+ class BlueprintSection(BaseModel):
625
+ heading: str = Field(description="2 to 8 words, specific to what the section says, never generic")
626
+ role: Literal["intro", "body", "synthesis", "summary"] = Field(
627
+ "body", description="intro opens the report, summary closes it, synthesis ties two or more themes together, "
628
+ "body covers its themes")
629
+ brief: str = Field(description="What this section says, 1 to 2 sentences")
630
+ theme_ids: list[str] = Field(description="Ids of the themes this section is about, as listed. A body section's "
631
+ "themes belong to it alone")
632
+ depth: Literal["deep", "standard", "brief"] = Field("standard", description="deep for a theme with a large weight "
633
+ "or one the reader asked for, brief for a small one")
634
+ must_cover: list[str] = Field(description="2 to 4 specific points this section has to make")
635
+ embed: PlannedEmbed | None = Field(None, description="A visual to suggest right after this section, only where it "
636
+ "really helps")
637
+
638
+
639
+ class ThemeConnection(BaseModel):
640
+ theme_ids: list[str] = Field(description="Two or more theme ids that are connected")
641
+ relation: str = Field(description="How they connect, one short sentence")
642
+ section: str = Field(description="Heading of the section that should bridge them")
643
+
644
+
645
+ class PlanReportBlueprint(dspy.Signature):
646
+ """Plan a whole report before any of it is written. The themes come with their weight (their share of what the posts say), a
647
+ tier (major, medium, minor) and whether the reader asked for them. Give every major theme and every theme the reader asked
648
+ for its own section with depth deep, in an order that builds an argument; put the ones the reader asked for early. Put medium
649
+ themes in standard sections and merge the minor ones into one brief section. A theme is the subject of exactly one section.
650
+ Write a storyline of three to five sentences: what the report as a whole says, so every section can be written toward it.
651
+ Start with an intro section and end with a summary section unless the reader asks for something else; the summary must
652
+ restate the major themes. Where themes connect, say which section bridges them (connections), and add a synthesis section
653
+ only if the connection deserves its own. Each section's must_cover is specific to it and shares no point with another.
654
+ Choose at most max_sections sections. Never plan a section that is itself a quiz, flashcards, practice questions or a
655
+ mind map: when allowed_kinds is not empty those are visuals, suggested through a section's embed (at most four in all, each
656
+ kind once, only the kinds allowed): a mind_map to show how many linked themes fit together (at most one, near the start or
657
+ the end), flashcards to learn a set of terms or facts, a quiz to check understanding after a dense section, and an
658
+ infographic, a one page picture of the main ideas, which every report with that kind allowed should suggest once, on the
659
+ last section before the summary. Suggest the others only where they really help; none where plain prose reads better. When
660
+ allowed_kinds is empty, plan no embeds, and a quiz or practice section is allowed only if the reader's request asks for
661
+ one. Use only what the themes cover. Never use em dashes."""
662
+
663
+ title: str = dspy.InputField()
664
+ request: str = dspy.InputField(desc="What the reader wants the report to be")
665
+ themes: list[str] = dspy.InputField(desc="One per line: id | name | tier | weight | posts | asked for: summary")
666
+ relations: list[str] = dspy.InputField(desc="One per line: id <-> id: how they connect, or empty")
667
+ allowed_kinds: list[str] = dspy.InputField(desc="The visuals that may be suggested, or empty")
668
+ language: str = dspy.InputField()
669
+ max_sections: int = dspy.InputField(desc="The most sections to plan")
670
+ report_title: str = dspy.OutputField(desc="A specific title, 3 to 10 words")
671
+ storyline: str = dspy.OutputField(desc="Three to five sentences on what the whole report says")
672
+ sections: list[BlueprintSection] = dspy.OutputField(desc="2 to max_sections sections, in reading order")
673
+ connections: list[ThemeConnection] = dspy.OutputField(desc="At most 6")
674
+
675
+
676
+ class WriteThemedSection(dspy.Signature):
677
+ """Write one section of a report that has already been planned. You are given the storyline of the whole report and its
678
+ outline: write toward that storyline, and do not make points that another section owns (covered_elsewhere). Make the
679
+ points in must_cover. Use only the evidence given; never add facts from outside it. The evidence is grouped by post and
680
+ comes in two forms: text as the writer wrote it, and short summaries of other posts. Combine what
681
+ several posts say about the same thing: say where they agree, where they differ or where the writer changed their mind,
682
+ and never go through the posts one at a time. Where a connection is listed, bridge the themes in a sentence or two.
683
+ Follow the reader's instructions on style, tone and length; a section is two to five short paragraphs, or a list or table
684
+ where that reads better; a deep section is longer than a brief one. For role intro, say what the report covers and why it
685
+ matters; for role summary, restate the main points of the sections as written, in the digests given. Do not repeat the
686
+ heading, and do not use "#" or "##" headings; "###" subheadings are fine. Do not cite: no [n] markers, footnotes,
687
+ references or lists of sources in the text, and do not name the posts or their paths; the app shows the reader the
688
+ sources separately. Do not write quiz questions, flashcards or review questions, and never write about visuals, mind maps,
689
+ flashcards, quizzes, tables or timelines in the text: the app offers those separately. Write in the language requested.
690
+ Never use em dashes."""
691
+
692
+ # What every section shares comes first, so a provider can reuse it across the section calls.
693
+ storyline: str = dspy.InputField(desc="What the whole report says")
694
+ outline: list[str] = dspy.InputField(desc="Every section: number, heading, role, what it says")
695
+ connections: list[str] = dspy.InputField(desc="Themes the report connects, with the section that bridges them")
696
  request: str = dspy.InputField(desc="What the reader wants the report to be")
 
697
  language: str = dspy.InputField()
698
+ heading: str = dspy.InputField(desc="The section to write")
699
+ role: str = dspy.InputField(desc="intro, body, synthesis or summary")
700
+ depth: str = dspy.InputField(desc="deep, standard or brief")
701
+ brief: str = dspy.InputField(desc="What this section should say")
702
+ must_cover: list[str] = dspy.InputField(desc="Points this section has to make")
703
+ covered_elsewhere: list[str] = dspy.InputField(desc="Points other sections own: do not make them here")
704
+ bridges: list[str] = dspy.InputField(desc="Connections this section should bridge, or empty")
705
+ evidence: list[str] = dspy.InputField(desc="Posts as written, short summaries of other posts, or digests of the written "
706
+ "sections")
707
  markdown: str = dspy.OutputField(desc="The section body in Markdown, without its heading")
708
+
709
+
710
+ class WriteThemedReport(dspy.Signature):
711
+ """Write a whole report that has already been planned, in one piece. You are given the storyline and the planned sections
712
+ in order, each with what it must cover and the evidence for it. Write in Markdown that starts straight at the first "## "
713
+ heading (the title goes in report_title, not in the text), with one "## " heading per planned section, in that order and
714
+ with those headings, and write toward the storyline. Make each section's must_cover points, and do not make a point
715
+ another section owns. Use only the evidence given; never add facts from outside it. The evidence is grouped by post and
716
+ comes in two forms: text as the writer wrote it, and short summaries of other posts. Combine what
717
+ several posts say about the same thing: say where they agree, where they differ or where the writer changed their mind,
718
+ and never go through the posts one at a time. Where a connection is listed, bridge the themes. A deep section is longer
719
+ than a brief one. Follow the reader's instructions on style, tone and length. Use "### " inside sections, lists, and tables
720
+ where they help. Do not cite: no [n] markers, footnotes, references or lists of sources in the text, and do not name the
721
+ posts or their paths; the app shows the reader the sources separately. Do not write a quiz, flashcards, practice or
722
+ review questions unless the reader's instructions ask for one, and never write about visuals, mind maps, flashcards or
723
+ timelines in the text. Write in the language requested. Never use em dashes."""
724
+
725
+ title: str = dspy.InputField(desc="The notebook or posts the evidence comes from")
726
+ storyline: str = dspy.InputField(desc="What the whole report says")
727
+ request: str = dspy.InputField(desc="What the reader wants the report to be")
728
+ language: str = dspy.InputField(desc="The language to write in")
729
+ connections: list[str] = dspy.InputField(desc="Themes the report connects, with the section that bridges them")
730
+ sections: list[str] = dspy.InputField(desc="The planned sections in order: heading, role, depth, what it says, must cover, "
731
+ "followed by its evidence")
732
+ report_title: str = dspy.OutputField(desc="A specific title, 3 to 10 words")
733
+ markdown: str = dspy.OutputField(desc="The report body in Markdown, starting at the first ## heading")
734
 
735
 
736
  class ReportTemplateIdea(BaseModel):
 
746
  a practical how-to, a comparison, a beginner primer and an in-depth analysis, when the material supports them).
747
  Never use em dashes."""
748
 
749
+ items: list[str] = dspy.InputField(desc="Each source: its title, topics and key ideas, or the start of it")
750
  topic: str = dspy.InputField(desc="What the reader wants the report to be about, or (none)")
751
  templates: list[ReportTemplateIdea] = dspy.OutputField(desc="Exactly 4")
752
 
backend/app/models/__init__.py CHANGED
@@ -1,5 +1,6 @@
1
  from app.models.content import (
2
  Artifact,
 
3
  ArtifactShare,
4
  B2VCustomVoice,
5
  B2VStyle,
@@ -17,6 +18,8 @@ from app.models.content import (
17
  MessageFeedback,
18
  Notebook,
19
  NotebookDocument,
 
 
20
  SocialAccount,
21
  Source,
22
  Topic,
@@ -34,9 +37,11 @@ from app.models.support import SupportConversation, SupportMessage
34
  from app.models.user import AuthProvider, BillingEvent, PlanRecord, Subscription, User, Workspace, WorkspaceMember
35
 
36
  __all__ = [
 
37
  "SupportConversation",
38
  "SupportMessage",
39
  "Artifact",
 
40
  "ArtifactShare",
41
  "AuthProvider",
42
  "B2VCustomVoice",
@@ -58,6 +63,7 @@ __all__ = [
58
  "Notebook",
59
  "PlanRecord",
60
  "NotebookDocument",
 
61
  "SocialAccount",
62
  "Source",
63
  "Subscription",
 
1
  from app.models.content import (
2
  Artifact,
3
+ ArtifactFeedback,
4
  ArtifactShare,
5
  B2VCustomVoice,
6
  B2VStyle,
 
18
  MessageFeedback,
19
  Notebook,
20
  NotebookDocument,
21
+ QuizAttempt,
22
+ ReportTemplateCache,
23
  SocialAccount,
24
  Source,
25
  Topic,
 
37
  from app.models.user import AuthProvider, BillingEvent, PlanRecord, Subscription, User, Workspace, WorkspaceMember
38
 
39
  __all__ = [
40
+ "QuizAttempt",
41
  "SupportConversation",
42
  "SupportMessage",
43
  "Artifact",
44
+ "ArtifactFeedback",
45
  "ArtifactShare",
46
  "AuthProvider",
47
  "B2VCustomVoice",
 
63
  "Notebook",
64
  "PlanRecord",
65
  "NotebookDocument",
66
+ "ReportTemplateCache",
67
  "SocialAccount",
68
  "Source",
69
  "Subscription",
backend/app/models/content.py CHANGED
@@ -155,6 +155,52 @@ class MessageFeedback(IdMixin, TimestampMixin, Base):
155
  recall: Mapped[dict] = mapped_column(JSON, default=dict) # copy of Message.recall_json
156
 
157
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  class WorkspaceMemory(IdMixin, TimestampMixin, Base):
159
  """The writer's standing notes, one row per workspace, shared by every notebook and chat.
160
 
 
155
  recall: Mapped[dict] = mapped_column(JSON, default=dict) # copy of Message.recall_json
156
 
157
 
158
+ class ArtifactFeedback(IdMixin, TimestampMixin, Base):
159
+ """A thumbs up or down on one generated report, quiz, flashcard set or infographic, with a snapshot of what it was
160
+ (title, prompt, sources, options) taken when it was rated, so the ratings stay useful after it is deleted (the link
161
+ then becomes null). One row per artifact and person: rating again replaces it."""
162
+
163
+ __tablename__ = "artifact_feedback"
164
+ __table_args__ = (UniqueConstraint("artifact_id", "user_id"),)
165
+
166
+ workspace_id: Mapped[uuid.UUID] = _ws_fk()
167
+ artifact_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("artifacts.id", ondelete="SET NULL"), index=True)
168
+ user_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("users.id", ondelete="SET NULL"))
169
+ artifact_type: Mapped[str] = mapped_column(String(30))
170
+ rating: Mapped[int] = mapped_column(Integer) # 1 up, -1 down
171
+ reasons: Mapped[list] = mapped_column(JSON, default=list)
172
+ comment: Mapped[str | None] = mapped_column(Text)
173
+ snapshot: Mapped[dict] = mapped_column(JSON, default=dict)
174
+
175
+
176
+ class QuizAttempt(IdMixin, TimestampMixin, Base):
177
+ """A person's last finished run of a quiz: what they answered and the score, so opening the quiz again shows the result.
178
+ One row per quiz and person; finishing it again replaces it."""
179
+
180
+ __tablename__ = "quiz_attempts"
181
+ __table_args__ = (UniqueConstraint("artifact_id", "user_id"),)
182
+
183
+ workspace_id: Mapped[uuid.UUID] = _ws_fk()
184
+ artifact_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("artifacts.id", ondelete="CASCADE"), index=True)
185
+ user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id", ondelete="CASCADE"))
186
+ score: Mapped[int] = mapped_column(Integer)
187
+ total: Mapped[int] = mapped_column(Integer)
188
+ answers: Mapped[list] = mapped_column(JSON, default=list) # per question: choice (indexes), text, correct
189
+
190
+
191
+ class ReportTemplateCache(IdMixin, TimestampMixin, Base):
192
+ """The suggested report templates already written for some sources, found again by a hash of exactly what the AI read (each
193
+ post's stored ideas and topics, and the topic). The same input gives the same templates at once, so the Create report dialog
194
+ is instant, and a changed post (its ideas change) gets new ones."""
195
+
196
+ __tablename__ = "report_template_cache"
197
+ __table_args__ = (UniqueConstraint("workspace_id", "key"),)
198
+
199
+ workspace_id: Mapped[uuid.UUID] = _ws_fk()
200
+ key: Mapped[str] = mapped_column(String(64))
201
+ templates: Mapped[list] = mapped_column(JSON, default=list)
202
+
203
+
204
  class WorkspaceMemory(IdMixin, TimestampMixin, Base):
205
  """The writer's standing notes, one row per workspace, shared by every notebook and chat.
206
 
backend/app/models/user.py CHANGED
@@ -13,7 +13,6 @@ from sqlalchemy import (
13
  String,
14
  UniqueConstraint,
15
  Uuid,
16
- false,
17
  true,
18
  )
19
  from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -56,7 +55,6 @@ class Workspace(IdMixin, TimestampMixin, Base):
56
  training_opt_in: Mapped[bool] = mapped_column(Boolean, default=False)
57
  # Off: no report of this workspace can be shared by link, and existing links stop working.
58
  allow_public_links: Mapped[bool] = mapped_column(Boolean, default=True, server_default=true())
59
- brand_json: Mapped[dict] = mapped_column(JSON, default=dict)
60
 
61
  members: Mapped[list["WorkspaceMember"]] = relationship(back_populates="workspace")
62
 
@@ -112,11 +110,10 @@ class PlanRecord(TimestampMixin, Base):
112
  launch_kits: Mapped[int] = mapped_column(Integer, default=0) # -1 = unlimited
113
  reports: Mapped[int] = mapped_column(Integer, default=0, server_default="0") # per month, -1 = unlimited
114
  infographics: Mapped[int] = mapped_column(Integer, default=0, server_default="0") # per month, -1 = unlimited
 
115
  voice_cloning: Mapped[bool] = mapped_column(Boolean, default=False)
116
- brand_kit: Mapped[bool] = mapped_column(Boolean, default=False)
117
  features: Mapped[list] = mapped_column(JSON, default=list) # bullet points on the pricing page
118
  # Server defaults: 0006 seeds this table without these columns on a database 0001 built from the models.
119
- video_premium: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false()) # premium options
120
  # False: `videos` is a lifetime total that never refills (Free). True: per month.
121
  videos_monthly: Mapped[bool] = mapped_column(Boolean, default=True, server_default=true())
122
  video_limits: Mapped[dict | None] = mapped_column(JSON, default=dict) # see plans.VIDEO_LIMITS
 
13
  String,
14
  UniqueConstraint,
15
  Uuid,
 
16
  true,
17
  )
18
  from sqlalchemy.orm import Mapped, mapped_column, relationship
 
55
  training_opt_in: Mapped[bool] = mapped_column(Boolean, default=False)
56
  # Off: no report of this workspace can be shared by link, and existing links stop working.
57
  allow_public_links: Mapped[bool] = mapped_column(Boolean, default=True, server_default=true())
 
58
 
59
  members: Mapped[list["WorkspaceMember"]] = relationship(back_populates="workspace")
60
 
 
110
  launch_kits: Mapped[int] = mapped_column(Integer, default=0) # -1 = unlimited
111
  reports: Mapped[int] = mapped_column(Integer, default=0, server_default="0") # per month, -1 = unlimited
112
  infographics: Mapped[int] = mapped_column(Integer, default=0, server_default="0") # per month, -1 = unlimited
113
+ audio_overviews: Mapped[int] = mapped_column(Integer, default=-1, server_default="-1") # allowed (in total on Free); -1 = no cap
114
  voice_cloning: Mapped[bool] = mapped_column(Boolean, default=False)
 
115
  features: Mapped[list] = mapped_column(JSON, default=list) # bullet points on the pricing page
116
  # Server defaults: 0006 seeds this table without these columns on a database 0001 built from the models.
 
117
  # False: `videos` is a lifetime total that never refills (Free). True: per month.
118
  videos_monthly: Mapped[bool] = mapped_column(Boolean, default=True, server_default=true())
119
  video_limits: Mapped[dict | None] = mapped_column(JSON, default=dict) # see plans.VIDEO_LIMITS
backend/app/pipeline/flashcards.py CHANGED
@@ -8,6 +8,7 @@ from app.llm import run
8
  from app.llm.provider import fast_lm
9
  from app.llm.signatures import GenerateFlashcards
10
  from app.models import Artifact, Job
 
11
  from app.pipeline.generate import NothingToDo
12
  from app.pipeline.material import Material, load_material
13
  from app.services.jobs import update_job
@@ -16,20 +17,29 @@ SAME_LANGUAGE = "the same language as the sources" # no language picked: write
16
  CARD_COUNTS = {"fewer": 8, "standard": 16, "more": 24}
17
 
18
 
 
 
 
 
 
 
 
 
19
  def make_cards(db: Session, workspace_id: uuid.UUID, material: Material, *, topic: str = "", count: int = 16,
20
  difficulty: str = "medium", language: str = SAME_LANGUAGE, job: Job | None = None) -> list[dict]:
21
  """Cards about the material: a front, a back and verified source lines. Repeats and empty cards are dropped."""
22
- out = run.predict(GenerateFlashcards, db=db, workspace_id=workspace_id, job=job, title=material.title,
23
- material=material.text, topic=topic or "(none)", difficulty=difficulty, count=count,
24
- language=language.strip() or SAME_LANGUAGE, lm=fast_lm())
 
 
25
  cards, seen = [], set()
26
- for c in out.get("cards") or []:
27
- front, back = (c.get("front") or "").strip(), (c.get("back") or "").strip()
28
- key = front.lower()
29
- if not front or not back or key in seen:
30
  continue
31
- seen.add(key)
32
- cards.append({"front": front[:300], "back": back[:800], "sources": material.refs(c.get("sources"))})
33
  return cards[:count]
34
 
35
 
@@ -38,7 +48,8 @@ def build_flashcards(db: Session, job: Job, artifact: Artifact) -> dict:
38
  count = CARD_COUNTS.get(params.get("count") or "standard", CARD_COUNTS["standard"])
39
  topic = (params.get("topic") or "").strip()
40
  difficulty = params.get("difficulty") if params.get("difficulty") in ("easy", "medium", "hard") else "medium"
41
- material = load_material(db, artifact.workspace_id, artifact.notebook_id, params, job=job, what="flashcard set")
 
42
  update_job(db, job, progress=0.4, message="Writing the cards")
43
  cards = make_cards(db, artifact.workspace_id, material, topic=topic, count=count, difficulty=difficulty,
44
  language=params.get("language") or SAME_LANGUAGE, job=job)
 
8
  from app.llm.provider import fast_lm
9
  from app.llm.signatures import GenerateFlashcards
10
  from app.models import Artifact, Job
11
+ from app.pipeline import idea_pool
12
  from app.pipeline.generate import NothingToDo
13
  from app.pipeline.material import Material, load_material
14
  from app.services.jobs import update_job
 
17
  CARD_COUNTS = {"fewer": 8, "standard": 16, "more": 24}
18
 
19
 
20
+ def _card(c: dict, refs) -> dict | None:
21
+ """One generated card as the deck plays it, or None when it has no front or no back."""
22
+ front, back = (c.get("front") or "").strip(), (c.get("back") or "").strip()
23
+ if not front or not back:
24
+ return None
25
+ return {"front": front[:300], "back": back[:800], "sources": refs(c.get("sources"))}
26
+
27
+
28
  def make_cards(db: Session, workspace_id: uuid.UUID, material: Material, *, topic: str = "", count: int = 16,
29
  difficulty: str = "medium", language: str = SAME_LANGUAGE, job: Job | None = None) -> list[dict]:
30
  """Cards about the material: a front, a back and verified source lines. Repeats and empty cards are dropped."""
31
+ inputs = dict(title=material.title, topic=topic or "(none)", difficulty=difficulty, language=language.strip() or SAME_LANGUAGE)
32
+ # Posts with their ideas stored (the Mind Constellation's): one call over a random sample of those ideas, not the posts' text.
33
+ text = idea_pool.sample(db, workspace_id, material.docs, topic, chats=material.text) if material.mode == "ideas" else material.text
34
+ out = run.predict(GenerateFlashcards, db=db, workspace_id=workspace_id, job=job, material=text, count=count,
35
+ lm=fast_lm(), **inputs)
36
  cards, seen = [], set()
37
+ for raw in out.get("cards") or []:
38
+ card = _card(raw, material.refs)
39
+ if not card or card["front"].lower() in seen:
 
40
  continue
41
+ seen.add(card["front"].lower())
42
+ cards.append(card)
43
  return cards[:count]
44
 
45
 
 
48
  count = CARD_COUNTS.get(params.get("count") or "standard", CARD_COUNTS["standard"])
49
  topic = (params.get("topic") or "").strip()
50
  difficulty = params.get("difficulty") if params.get("difficulty") in ("easy", "medium", "hard") else "medium"
51
+ material = load_material(db, artifact.workspace_id, artifact.notebook_id, params, job=job, what="flashcard set",
52
+ large_ok=True, ideas_always=True)
53
  update_job(db, job, progress=0.4, message="Writing the cards")
54
  cards = make_cards(db, artifact.workspace_id, material, topic=topic, count=count, difficulty=difficulty,
55
  language=params.get("language") or SAME_LANGUAGE, job=job)
backend/app/pipeline/generate.py CHANGED
@@ -112,22 +112,39 @@ def ideas_fresh(d) -> bool:
112
  return bool(md.get("ideas")) and md.get("ideas_hash") == (d.content_hash or "")
113
 
114
 
115
- def extract_ideas(db: Session, job: Job | None, workspace_id: uuid.UUID, doc_ids: list[str] | None = None) -> dict:
 
116
  """Read each post that has no current ideas once and keep what it says on the post (label, note, points and
117
- the lines they came from). Maps are arranged from these, so they never re-read the posts."""
 
118
  docs = workspace_docs(db, workspace_id, [uuid.UUID(str(i)) for i in doc_ids] if doc_ids is not None else None)
119
  todo = [d for d in docs if not ideas_fresh(d)]
 
 
120
  corpus = Corpus(workspace_id)
121
  lm = fast_lm()
122
  done = 0
123
- for i, d in enumerate(todo):
 
124
  if job:
125
- update_job(db, job, progress=0.05 + 0.85 * i / max(len(todo), 1), message=f"Reading {d.title[:70]}")
126
- try:
127
- out = run.predict(ExtractIdeas, db=db, workspace_id=workspace_id, job=job, lm=lm, title=d.title,
128
- passage="\n\n".join(passages_for(corpus, [d], budget_chars=IDEAS_BUDGET)))
129
- except Exception:
130
- continue
 
 
 
 
 
 
 
 
 
 
 
 
131
  tools = tools_for(corpus, [d])
132
 
133
  def keep(items, tools=tools, d=d) -> list[dict]:
 
112
  return bool(md.get("ideas")) and md.get("ideas_hash") == (d.content_hash or "")
113
 
114
 
115
+ def extract_ideas(db: Session, job: Job | None, workspace_id: uuid.UUID, doc_ids: list[str] | None = None, *,
116
+ parallel: bool = False, limit: int | None = None) -> dict:
117
  """Read each post that has no current ideas once and keep what it says on the post (label, note, points and
118
+ the lines they came from). Maps are arranged from these, so they never re-read the posts. `parallel` reads the
119
+ posts together instead of one after the other (a report that needs many at once); `limit` reads at most that many."""
120
  docs = workspace_docs(db, workspace_id, [uuid.UUID(str(i)) for i in doc_ids] if doc_ids is not None else None)
121
  todo = [d for d in docs if not ideas_fresh(d)]
122
+ if limit is not None:
123
+ todo = todo[:limit]
124
  corpus = Corpus(workspace_id)
125
  lm = fast_lm()
126
  done = 0
127
+ answers: list[dict | None] | None = None
128
+ if parallel and todo:
129
  if job:
130
+ update_job(db, job, progress=0.05, message=f"Reading {len(todo)} posts for their key ideas")
131
+ answers = run.predict_many(
132
+ ExtractIdeas,
133
+ [dict(title=d.title, passage="\n\n".join(passages_for(corpus, [d], budget_chars=IDEAS_BUDGET))) for d in todo],
134
+ db=db, workspace_id=workspace_id, lm=lm)
135
+ for i, d in enumerate(todo):
136
+ if answers is not None:
137
+ out = answers[i]
138
+ if out is None:
139
+ continue
140
+ else:
141
+ if job:
142
+ update_job(db, job, progress=0.05 + 0.85 * i / max(len(todo), 1), message=f"Reading {d.title[:70]}")
143
+ try:
144
+ out = run.predict(ExtractIdeas, db=db, workspace_id=workspace_id, job=job, lm=lm, title=d.title,
145
+ passage="\n\n".join(passages_for(corpus, [d], budget_chars=IDEAS_BUDGET)))
146
+ except Exception:
147
+ continue
148
  tools = tools_for(corpus, [d])
149
 
150
  def keep(items, tools=tools, d=d) -> list[dict]:
backend/app/pipeline/infographic.py CHANGED
@@ -9,15 +9,18 @@ from sqlalchemy import select
9
  from sqlalchemy.orm import Session
10
 
11
  from app.corpus import Corpus
12
- from app.infographics.content import clamp
 
 
 
13
  from app.infographics.render import build_html
14
  from app.infographics.themes import theme_id
15
  from app.llm import run
16
  from app.llm.provider import fast_lm
17
- from app.llm.signatures import PlanInfographic
18
  from app.models import Artifact, Chat, Document, Job, Notebook
19
  from app.pipeline.generate import NothingToDo, extract_ideas, ideas_fresh
20
- from app.pipeline.material import MAX_POSTS, chat_material
21
  from app.pipeline.passages import notebook_docs, passages_for, workspace_docs
22
  from app.services.jobs import update_job
23
 
@@ -67,7 +70,7 @@ def write_content(db: Session, job: Job, artifact: Artifact, params: dict) -> tu
67
  docs = workspace_docs(db, ws, picked)
68
  elif not chat_ids and nb:
69
  docs = notebook_docs(db, nb.id)
70
- docs = docs[:MAX_POSTS]
71
  chats, chat_blocks = _chat_text(db, ws, chat_ids) if chat_ids else ([], [])
72
  if not docs and not chat_blocks:
73
  raise NothingToDo("Pick at least one post or chat for the infographic.")
@@ -76,12 +79,14 @@ def write_content(db: Session, job: Job, artifact: Artifact, params: dict) -> tu
76
  material = (_ideas_text(db, ws, docs, job) if docs else []) + chat_blocks
77
  prompt = (params.get("instructions") or "").strip()
78
  title = docs[0].title if docs else (chats[0].title or "Chat")
79
- update_job(db, job, progress=0.35, message="Writing the infographic")
 
80
  content = None
81
  for _ in range(2): # one retry when the model gives too little to draw
82
- out = run.predict(PlanInfographic, db=db, workspace_id=ws, job=job, title=title, material=material,
83
- focus=prompt or "(none)", lm=fast_lm())
84
- content = clamp(out.get("infographic") or {})
 
85
  if content:
86
  break
87
  if not content:
@@ -101,6 +106,8 @@ def build_infographic(db: Session, job: Job, artifact: Artifact) -> dict:
101
  params = job.params
102
  theme = theme_id(params.get("theme"))
103
  content, source, _title = write_content(db, job, artifact, params)
 
 
104
  artifact.content_json = {**(artifact.content_json or {}), "title": f"Infographic: {content['title']}"[:140],
105
  "theme": theme, "prompt": (params.get("instructions") or "").strip(), "source": source,
106
  "content": content}
@@ -110,7 +117,22 @@ def build_infographic(db: Session, job: Job, artifact: Artifact) -> dict:
110
  return {"artifact_id": str(artifact.id)}
111
 
112
 
 
 
 
 
 
 
 
 
 
113
  def infographic_html(content: dict, layout: str = "portrait") -> str | None:
114
- """The page for a finished infographic's stored text and theme, tall or wide (None when it has none yet)."""
 
115
  text = content.get("content")
116
- return build_html(theme_id(content.get("theme")), text, layout) if text else None
 
 
 
 
 
 
9
  from sqlalchemy.orm import Session
10
 
11
  from app.corpus import Corpus
12
+ from concurrent.futures import ThreadPoolExecutor
13
+
14
+ from app.infographics.design import LOOKS, STYLES, build_content, render_design
15
+ from app.infographics.image import measure_fit
16
  from app.infographics.render import build_html
17
  from app.infographics.themes import theme_id
18
  from app.llm import run
19
  from app.llm.provider import fast_lm
20
+ from app.llm.signatures import DesignInfographic
21
  from app.models import Artifact, Chat, Document, Job, Notebook
22
  from app.pipeline.generate import NothingToDo, extract_ideas, ideas_fresh
23
+ from app.pipeline.material import chat_material, max_posts
24
  from app.pipeline.passages import notebook_docs, passages_for, workspace_docs
25
  from app.services.jobs import update_job
26
 
 
70
  docs = workspace_docs(db, ws, picked)
71
  elif not chat_ids and nb:
72
  docs = notebook_docs(db, nb.id)
73
+ docs = docs[:max_posts()]
74
  chats, chat_blocks = _chat_text(db, ws, chat_ids) if chat_ids else ([], [])
75
  if not docs and not chat_blocks:
76
  raise NothingToDo("Pick at least one post or chat for the infographic.")
 
79
  material = (_ideas_text(db, ws, docs, job) if docs else []) + chat_blocks
80
  prompt = (params.get("instructions") or "").strip()
81
  title = docs[0].title if docs else (chats[0].title or "Chat")
82
+ update_job(db, job, progress=0.35, message="Designing the infographic")
83
+ style = params.get("style") if params.get("style") in STYLES else "auto"
84
  content = None
85
  for _ in range(2): # one retry when the model gives too little to draw
86
+ out = run.predict(DesignInfographic, db=db, workspace_id=ws, job=job, title=title, material=material,
87
+ focus=prompt or "(none)", layout_style=f"{style}: {STYLES[style]}" if style != "auto" else "auto",
88
+ lm=fast_lm())
89
+ content = build_content(out.get("design") or {})
90
  if content:
91
  break
92
  if not content:
 
106
  params = job.params
107
  theme = theme_id(params.get("theme"))
108
  content, source, _title = write_content(db, job, artifact, params)
109
+ update_job(db, job, progress=0.8, message="Fitting it to the page")
110
+ content["fit"] = fit_scales(theme, content)
111
  artifact.content_json = {**(artifact.content_json or {}), "title": f"Infographic: {content['title']}"[:140],
112
  "theme": theme, "prompt": (params.get("instructions") or "").strip(), "source": source,
113
  "content": content}
 
117
  return {"artifact_id": str(artifact.id)}
118
 
119
 
120
+ def fit_scales(theme: str, content: dict) -> dict:
121
+ """The scale at which the body fits the page, tall and wide (measured in Chrome once, kept with the content)."""
122
+ look = theme if theme in LOOKS else "midnight"
123
+ with ThreadPoolExecutor(max_workers=2) as pool:
124
+ wide, tall = (pool.submit(measure_fit, render_design(look, content, layout, fit=1.0), layout)
125
+ for layout in ("landscape", "portrait"))
126
+ return {"landscape": wide.result(), "portrait": tall.result()}
127
+
128
+
129
  def infographic_html(content: dict, layout: str = "portrait") -> str | None:
130
+ """The page for a finished infographic's stored content and look, tall or wide (None when it has none yet). Older
131
+ infographics (fixed text in a premade theme) still draw in that theme."""
132
  text = content.get("content")
133
+ if not text:
134
+ return None
135
+ theme = theme_id(content.get("theme"))
136
+ if "body_html" in text:
137
+ return render_design(theme if theme in LOOKS else "midnight", text, layout)
138
+ return build_html(theme, text, layout) if "items" in text else None
backend/app/pipeline/ingest.py CHANGED
@@ -630,29 +630,39 @@ def store_locked(db: Session, source: Source, entries: list[FeedEntry]) -> tuple
630
 
631
  def ingest_source(db: Session, source: Source, job: Job, max_posts: int) -> dict:
632
  source.sync_status = "syncing"
633
- update_job(db, job, status="running", progress=0.02, message="Contacting your feed")
634
  fetch_limit = max(SCAN_CAP, max_posts)
 
 
 
 
 
 
 
 
635
 
636
  if source.feed_url.startswith(SITE_PREFIX):
637
  update_job(db, job, message="Mapping your site")
638
  entries = crawl_site(
639
  source.feed_url.removeprefix(SITE_PREFIX), fetch_limit,
640
- on_found=lambda n: update_job(db, job, progress=0.05, message=f"Found {n} pages, reading them"),
641
- on_post=lambda done, total: update_job(db, job, progress=0.05 + 0.05 * done / total,
642
- message=f"Read {done} of {total} pages"),
643
- read_limit=max_posts,
644
  )
645
  title = None
646
  else:
647
  title, entries = fetch_feed(source.feed_url, fetch_limit)
 
648
  source.title = source.title or title
649
  if source.platform == "substack" and len(entries) < fetch_limit:
650
  site = f"{urlparse(source.feed_url).scheme}://{urlparse(source.feed_url).netloc}"
651
- update_job(db, job, progress=0.05, message="Reading your Substack archive")
652
  try:
653
  entries += fetch_substack_archive(
654
  site, {e.url for e in entries}, fetch_limit,
655
- on_page=lambda n: update_job(db, job, message=f"Found {len(entries) + n} posts in the archive"),
 
 
656
  body_budget=max(0, max_posts - len(entries)),
657
  )
658
  except (httpx.HTTPError, ValueError):
@@ -662,9 +672,9 @@ def ingest_source(db: Session, source: Source, job: Job, max_posts: int) -> dict
662
  available, beyond = entries[:max_posts], entries[max_posts:]
663
 
664
  def progress(done: int, total: int, post_title: str) -> None:
665
- update_job(db, job, progress=0.1 + 0.7 * done / total, message=f"{post_title[:80]} is in orbit")
666
 
667
- update_job(db, job, progress=0.1, message=f"Indexing {len(available)} posts")
668
  indexed, skipped, changed = store_entries(db, source, available, progress)
669
 
670
  locked = 0
 
630
 
631
  def ingest_source(db: Session, source: Source, job: Job, max_posts: int) -> dict:
632
  source.sync_status = "syncing"
 
633
  fetch_limit = max(SCAN_CAP, max_posts)
634
+ # Live counts the Sources page shows while this runs: found on the site, fetched (read), indexed.
635
+ live = {"stage": "fetching", "found": 0, "fetched": 0, "fetch_total": 0, "indexed": 0, "index_total": 0}
636
+
637
+ def report(progress: float | None = None, message: str | None = None, **counts) -> None:
638
+ live.update(counts)
639
+ update_job(db, job, progress=progress, message=message, result=dict(live))
640
+
641
+ update_job(db, job, status="running", progress=0.02, message="Contacting your feed", result=dict(live))
642
 
643
  if source.feed_url.startswith(SITE_PREFIX):
644
  update_job(db, job, message="Mapping your site")
645
  entries = crawl_site(
646
  source.feed_url.removeprefix(SITE_PREFIX), fetch_limit,
647
+ on_found=lambda n: report(0.05, f"Found {n} pages, reading them", found=n),
648
+ on_post=lambda done, total: report(0.05 + 0.05 * done / total, f"Read {done} of {total} pages",
649
+ fetched=done, fetch_total=total),
650
+ read_limit=max(max_posts, 1),
651
  )
652
  title = None
653
  else:
654
  title, entries = fetch_feed(source.feed_url, fetch_limit)
655
+ report(found=len(entries), fetched=len(entries), fetch_total=len(entries))
656
  source.title = source.title or title
657
  if source.platform == "substack" and len(entries) < fetch_limit:
658
  site = f"{urlparse(source.feed_url).scheme}://{urlparse(source.feed_url).netloc}"
659
+ report(0.05, "Reading your Substack archive")
660
  try:
661
  entries += fetch_substack_archive(
662
  site, {e.url for e in entries}, fetch_limit,
663
+ on_page=lambda n: report(message=f"Found {len(entries) + n} posts in the archive",
664
+ found=len(entries) + n, fetched=len(entries) + n,
665
+ fetch_total=len(entries) + n),
666
  body_budget=max(0, max_posts - len(entries)),
667
  )
668
  except (httpx.HTTPError, ValueError):
 
672
  available, beyond = entries[:max_posts], entries[max_posts:]
673
 
674
  def progress(done: int, total: int, post_title: str) -> None:
675
+ report(0.1 + 0.7 * done / total, f"{post_title[:80]} is in orbit", stage="indexing", indexed=done)
676
 
677
+ report(0.1, f"Indexing {len(available)} posts", stage="indexing", found=len(entries), index_total=len(available))
678
  indexed, skipped, changed = store_entries(db, source, available, progress)
679
 
680
  locked = 0
backend/app/pipeline/material.py CHANGED
@@ -9,16 +9,36 @@ from dataclasses import dataclass, field
9
  from sqlalchemy import select
10
  from sqlalchemy.orm import Session
11
 
 
12
  from app.corpus import Corpus
13
- from app.models import Chat, Document, Job, Message, Notebook
14
  from app.pipeline.generate import NothingToDo
15
  from app.pipeline.passages import ScopedTools, notebook_docs, passages_for, tools_for, verify_refs, workspace_docs
16
  from app.services.jobs import update_job
 
17
 
18
- MAX_POSTS = 20 # posts one artifact reads; the passage budget is shared between them
19
  CHAT_BUDGET = 60_000 # characters of chat transcripts
20
 
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  @dataclass
23
  class Material:
24
  title: str # the notebook, the post, or the first chat's title
@@ -28,6 +48,9 @@ class Material:
28
  # Turns the model's {path, line_start, line_end} items into verified refs with their quote. Chats give none.
29
  refs: Callable[[list | None], list[dict]] = lambda _items: []
30
  tools: ScopedTools | None = None # reads back cited lines (posts only)
 
 
 
31
 
32
  @property
33
  def is_chat(self) -> bool:
@@ -52,7 +75,8 @@ def chat_material(db: Session, chats: list[Chat]) -> list[str]:
52
 
53
 
54
  def load_material(db: Session, workspace_id: uuid.UUID, notebook_id: uuid.UUID | None, params: dict, *,
55
- job: Job | None = None, what: str = "quiz") -> Material:
 
56
  """The source a job's params name: posts (`document_ids`, a notebook's, the archive's or a single post), chats
57
  (`chat_ids`), or both together. With `chat_ids` and no posts it reads only the chats; with neither it reads the whole
58
  notebook. Raises NothingToDo when there is nothing to read."""
@@ -65,7 +89,11 @@ def load_material(db: Session, workspace_id: uuid.UUID, notebook_id: uuid.UUID |
65
  docs = workspace_docs(db, workspace_id, picked)
66
  elif not chat_ids and nb:
67
  docs = notebook_docs(db, nb.id)
68
- docs = docs[:MAX_POSTS]
 
 
 
 
69
 
70
  chats: list[Chat] = []
71
  chat_text: list[str] = []
@@ -84,8 +112,9 @@ def load_material(db: Session, workspace_id: uuid.UUID, notebook_id: uuid.UUID |
84
  if docs:
85
  corpus = Corpus(workspace_id)
86
  if job:
87
- update_job(db, job, progress=0.1, message=f"Reading {len(docs)} posts")
88
- text = passages_for(corpus, docs)
 
89
  tools = tools_for(corpus, docs)
90
  by_path = {d.path: d for d in docs}
91
 
@@ -110,4 +139,5 @@ def load_material(db: Session, workspace_id: uuid.UUID, notebook_id: uuid.UUID |
110
  if chat_text:
111
  source["chat_ids"] = [str(c.id) for c in chats]
112
  source["count"] = len(docs) + (len(chats) if chat_text else 0)
113
- return Material(title=title, text=text + chat_text, docs=docs, refs=refs, tools=tools, source=source)
 
 
9
  from sqlalchemy import select
10
  from sqlalchemy.orm import Session
11
 
12
+ from app.config import settings
13
  from app.corpus import Corpus
14
+ from app.models import Chat, Document, Job, Message, Notebook, Workspace
15
  from app.pipeline.generate import NothingToDo
16
  from app.pipeline.passages import ScopedTools, notebook_docs, passages_for, tools_for, verify_refs, workspace_docs
17
  from app.services.jobs import update_job
18
+ from app.services.plans import effective_plan
19
 
 
20
  CHAT_BUDGET = 60_000 # characters of chat transcripts
21
 
22
 
23
+ def max_posts() -> int:
24
+ """Posts a report or infographic reads, and the most a quiz or flashcard set reads straight from the posts (ARTIFACT_MAX_POSTS);
25
+ the passage budget is shared between them."""
26
+ return max(settings.artifact_max_posts, 1)
27
+
28
+
29
+ def use_ideas(docs: list[Document], *, large_ok: bool, ideas_always: bool) -> bool:
30
+ """Read the posts through their stored ideas instead of their text? Only when most of them have ideas stored (the rest are
31
+ represented by a short opening). A selection above max_posts() (quiz and flashcards) always, when it can; a flashcard set
32
+ (`ideas_always`) whatever the size. Otherwise, or with too few ideas stored, the posts' text is read as it always was."""
33
+ from app.pipeline.idea_pool import has_ideas
34
+
35
+ if not docs or not (large_ok or ideas_always):
36
+ return False
37
+ if len(docs) > max_posts() or ideas_always:
38
+ return has_ideas(docs) * 2 >= len(docs)
39
+ return False
40
+
41
+
42
  @dataclass
43
  class Material:
44
  title: str # the notebook, the post, or the first chat's title
 
48
  # Turns the model's {path, line_start, line_end} items into verified refs with their quote. Chats give none.
49
  refs: Callable[[list | None], list[dict]] = lambda _items: []
50
  tools: ScopedTools | None = None # reads back cited lines (posts only)
51
+ # "passages": `text` holds the posts' passages. "ideas": more posts than max_posts(), so `text` holds only chat transcripts and
52
+ # the posts are read through their stored ideas (pipeline/idea_pool.py).
53
+ mode: str = "passages"
54
 
55
  @property
56
  def is_chat(self) -> bool:
 
75
 
76
 
77
  def load_material(db: Session, workspace_id: uuid.UUID, notebook_id: uuid.UUID | None, params: dict, *,
78
+ job: Job | None = None, what: str = "quiz", large_ok: bool = False,
79
+ ideas_always: bool = False, passage_budget: int | None = None) -> Material:
80
  """The source a job's params name: posts (`document_ids`, a notebook's, the archive's or a single post), chats
81
  (`chat_ids`), or both together. With `chat_ids` and no posts it reads only the chats; with neither it reads the whole
82
  notebook. Raises NothingToDo when there is nothing to read."""
 
89
  docs = workspace_docs(db, workspace_id, picked)
90
  elif not chat_ids and nb:
91
  docs = notebook_docs(db, nb.id)
92
+ # Quiz and flashcards take as many posts as the plan indexes: their ideas are sampled. The rest read max_posts().
93
+ docs = docs[:effective_plan(db, db.get(Workspace, workspace_id)).indexed_posts] if large_ok else docs[:max_posts()]
94
+ ideas_mode = use_ideas(docs, large_ok=large_ok, ideas_always=ideas_always)
95
+ if large_ok and not ideas_mode:
96
+ docs = docs[:max_posts()] # their ideas are not stored yet: read the first posts' text, as a small selection is
97
 
98
  chats: list[Chat] = []
99
  chat_text: list[str] = []
 
112
  if docs:
113
  corpus = Corpus(workspace_id)
114
  if job:
115
+ update_job(db, job, progress=0.1, message=f"Gathering the key ideas of {len(docs)} posts" if ideas_mode
116
+ else f"Reading {len(docs)} posts")
117
+ text = [] if ideas_mode else passages_for(corpus, docs, budget_chars=passage_budget)
118
  tools = tools_for(corpus, docs)
119
  by_path = {d.path: d for d in docs}
120
 
 
139
  if chat_text:
140
  source["chat_ids"] = [str(c.id) for c in chats]
141
  source["count"] = len(docs) + (len(chats) if chat_text else 0)
142
+ return Material(title=title, text=text + chat_text, docs=docs, refs=refs, tools=tools, source=source,
143
+ mode="ideas" if ideas_mode else "passages")
backend/app/pipeline/media.py CHANGED
@@ -17,7 +17,7 @@ from app.pipeline.passages import notebook_docs, passages_for, tools_for, verify
17
  from app.pipeline.report import SAME_LANGUAGE
18
  from app.services import tts
19
  from app.services.jobs import record_usage, update_job
20
- from app.services.renderer import PermanentJobError, brand_props, request_render
21
  from app.services.storage import keys, storage
22
 
23
 
@@ -160,14 +160,13 @@ def make_audiogram(db: Session, job: Job, artifact: Artifact) -> None:
160
  source = db.get(Artifact, uuid.UUID(job.params["audio_artifact_id"]))
161
  if not source or source.type != "audio_overview" or not source.storage_key:
162
  raise PermanentJobError("Pick a finished audio overview for the audiogram.")
163
- workspace = db.get(Workspace, artifact.workspace_id)
164
  content = source.content_json or {}
165
  duration = float(content.get("duration_s") or 30)
166
  segments = [{"speaker": s["speaker"], "text": s["text"], "startMs": int(s["start"] * 1000),
167
  "endMs": int(s["end"] * 1000)} for s in content.get("segments", [])]
168
  props = {"title": (content.get("title") or "Audio overview")[:140],
169
  "audioUrl": storage.presign_get(source.storage_key, ttl=6 * 3600),
170
- "durationS": round(min(duration, 600), 2), "segments": segments, "brand": brand_props(workspace)}
171
  artifact.content_json = {**(artifact.content_json or {}), "style": "audiogram", "composition": "AudiogramSquare",
172
  "audio_artifact_id": str(source.id), "duration_s": props["durationS"]}
173
  artifact.status = "rendering"
@@ -184,12 +183,11 @@ def make_audiogram(db: Session, job: Job, artifact: Artifact) -> None:
184
  def author_line(db: Session, workspace_id: uuid.UUID) -> str:
185
  ws = db.get(Workspace, workspace_id)
186
  owner = db.get(User, ws.owner_id) if ws else None
187
- return (ws.brand_json or {}).get("name") or (owner.name if owner and owner.name else "") or "Notestack"
188
 
189
 
190
  def make_quote_card(db: Session, job: Job, artifact: Artifact) -> None:
191
  content = artifact.content_json or {}
192
- workspace = db.get(Workspace, artifact.workspace_id)
193
  if not content.get("quote"):
194
  docs = artifact_docs(db, artifact)
195
  if not docs:
@@ -212,8 +210,7 @@ def make_quote_card(db: Session, job: Job, artifact: Artifact) -> None:
212
  if doc and doc.source_id:
213
  src = db.get(Source, doc.source_id)
214
  publication = src.title if src else None
215
- props = {"quote": content["quote"][:380], "author": author_line(db, artifact.workspace_id),
216
- "brand": brand_props(workspace)}
217
  if publication or (doc and doc.title):
218
  props["publication"] = (publication or doc.title)[:120]
219
  artifact.content_json = {**content, "composition": "QuoteCard"}
@@ -226,10 +223,8 @@ def render_carousel(db: Session, job: Job, artifact: Artifact) -> None:
226
  slides = (artifact.content_json or {}).get("slides") or []
227
  if not slides:
228
  raise PermanentJobError("This carousel has no slides.")
229
- workspace = db.get(Workspace, artifact.workspace_id)
230
- brand = brand_props(workspace)
231
  stills = [{"heading": s.get("heading", "")[:120], "body": s.get("body", "")[:500], "index": i + 1,
232
- "total": len(slides), "brand": brand} for i, s in enumerate(slides)]
233
  artifact.status = "rendering"
234
  db.commit()
235
  request_render(job, artifact, "CarouselSlide", stills=stills)
 
17
  from app.pipeline.report import SAME_LANGUAGE
18
  from app.services import tts
19
  from app.services.jobs import record_usage, update_job
20
+ from app.services.renderer import PermanentJobError, request_render
21
  from app.services.storage import keys, storage
22
 
23
 
 
160
  source = db.get(Artifact, uuid.UUID(job.params["audio_artifact_id"]))
161
  if not source or source.type != "audio_overview" or not source.storage_key:
162
  raise PermanentJobError("Pick a finished audio overview for the audiogram.")
 
163
  content = source.content_json or {}
164
  duration = float(content.get("duration_s") or 30)
165
  segments = [{"speaker": s["speaker"], "text": s["text"], "startMs": int(s["start"] * 1000),
166
  "endMs": int(s["end"] * 1000)} for s in content.get("segments", [])]
167
  props = {"title": (content.get("title") or "Audio overview")[:140],
168
  "audioUrl": storage.presign_get(source.storage_key, ttl=6 * 3600),
169
+ "durationS": round(min(duration, 600), 2), "segments": segments }
170
  artifact.content_json = {**(artifact.content_json or {}), "style": "audiogram", "composition": "AudiogramSquare",
171
  "audio_artifact_id": str(source.id), "duration_s": props["durationS"]}
172
  artifact.status = "rendering"
 
183
  def author_line(db: Session, workspace_id: uuid.UUID) -> str:
184
  ws = db.get(Workspace, workspace_id)
185
  owner = db.get(User, ws.owner_id) if ws else None
186
+ return (owner.name if owner and owner.name else "") or "Notestack"
187
 
188
 
189
  def make_quote_card(db: Session, job: Job, artifact: Artifact) -> None:
190
  content = artifact.content_json or {}
 
191
  if not content.get("quote"):
192
  docs = artifact_docs(db, artifact)
193
  if not docs:
 
210
  if doc and doc.source_id:
211
  src = db.get(Source, doc.source_id)
212
  publication = src.title if src else None
213
+ props = {"quote": content["quote"][:380], "author": author_line(db, artifact.workspace_id)}
 
214
  if publication or (doc and doc.title):
215
  props["publication"] = (publication or doc.title)[:120]
216
  artifact.content_json = {**content, "composition": "QuoteCard"}
 
223
  slides = (artifact.content_json or {}).get("slides") or []
224
  if not slides:
225
  raise PermanentJobError("This carousel has no slides.")
 
 
226
  stills = [{"heading": s.get("heading", "")[:120], "body": s.get("body", "")[:500], "index": i + 1,
227
+ "total": len(slides)} for i, s in enumerate(slides)]
228
  artifact.status = "rendering"
229
  db.commit()
230
  request_render(job, artifact, "CarouselSlide", stills=stills)
backend/app/pipeline/passages.py CHANGED
@@ -6,10 +6,11 @@ from types import SimpleNamespace
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]:
@@ -28,10 +29,26 @@ def workspace_docs(db: Session, workspace_id: uuid.UUID, ids: list[uuid.UUID] |
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:
@@ -40,8 +57,9 @@ def passages_for(corpus: Corpus, docs: list[Document], budget_chars: int = DEFAU
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:
 
6
  from sqlalchemy import select
7
  from sqlalchemy.orm import Session
8
 
9
+ from app.config import settings
10
  from app.corpus import Corpus, CorpusError, ScopedTools
11
  from app.models import Document, NotebookDocument, VoiceProfile
12
 
13
+ DEFAULT_BUDGET = 120_000 # the default of ARTIFACT_PASSAGE_BUDGET: characters across all passages; this keeps cost sane
14
 
15
 
16
  def notebook_docs(db: Session, notebook_id: uuid.UUID) -> list[Document]:
 
29
  return list(db.scalars(q.order_by(Document.published_at.desc())).all())
30
 
31
 
32
+ def _body_start(lines: list[str]) -> int:
33
+ """The index of the first line after the post file's header (the `---` block of title, url, date and source, and the repeated
34
+ `# Title`): none of it is worth sending, the `FILE path (title)` line already names the post."""
35
+ i = 0
36
+ if lines and lines[0].strip() == "---":
37
+ end = next((k for k in range(1, min(len(lines), 12)) if lines[k].strip() == "---"), None)
38
+ if end is not None:
39
+ i = end + 1
40
+ while i < len(lines) and not lines[i].strip():
41
+ i += 1
42
+ if i < len(lines) and lines[i].startswith("# "):
43
+ i += 1
44
+ return i
45
+
46
+
47
+ def passages_for(corpus: Corpus, docs: list[Document], budget_chars: int | None = None) -> list[str]:
48
  """One string per post: a header line then `n| text` numbered lines (same numbering as read()).
49
  The budget is split evenly, so one long post cannot crowd out the rest."""
50
  corpus.sync()
51
+ budget_chars = budget_chars or settings.artifact_passage_budget
52
  per_doc = max(budget_chars // max(len(docs), 1), 1500)
53
  out = []
54
  for d in docs:
 
57
  except CorpusError:
58
  continue
59
  body, used = [], 0
60
+ first = _body_start(lines) # lines keep their number in the file, so a cited line is still the right one
61
  for n, line in enumerate(lines, start=1):
62
+ if n <= first or not line.strip():
63
  continue
64
  entry = f"{n}| {line}"
65
  if used + len(entry) > per_doc:
backend/app/pipeline/quiz.py CHANGED
@@ -4,10 +4,12 @@ import uuid
4
 
5
  from sqlalchemy.orm import Session
6
 
 
7
  from app.llm import run
8
  from app.llm.provider import fast_lm
9
  from app.llm.signatures import GenerateQuiz
10
  from app.models import Artifact, Job
 
11
  from app.pipeline.generate import NothingToDo
12
  from app.pipeline.material import Material, load_material
13
  from app.services.jobs import update_job
@@ -51,12 +53,14 @@ def make_questions(db: Session, workspace_id: uuid.UUID, material: Material, *,
51
  language: str = SAME_LANGUAGE, job: Job | None = None) -> list[dict]:
52
  """Playable questions about the material (fewer than `count` when the model gave unusable ones)."""
53
  types = [t for t in types or [] if t in QUESTION_TYPES] or list(QUESTION_TYPES[:2])
54
- out = run.predict(GenerateQuiz, db=db, workspace_id=workspace_id, job=job, title=material.title,
55
- material=material.text, topic=topic or "(none)", difficulty=difficulty, question_types=types,
56
- count=count, language=language.strip() or SAME_LANGUAGE, lm=fast_lm())
57
- cleaned = [c for q in out.get("questions") or [] if (c := _clean(q, material.refs))]
58
  # A multiple select with a single right answer was turned into a multiple choice: fine when that was asked for too.
59
  allowed = set(types) | ({"multiple_choice"} if "multiple_select" in types else set())
 
 
 
 
 
 
60
  return [c for c in cleaned if c["type"] in allowed][:count]
61
 
62
 
@@ -66,7 +70,8 @@ def build_quiz(db: Session, job: Job, artifact: Artifact) -> dict:
66
  count = QUESTION_COUNTS.get(params.get("count") or "standard", QUESTION_COUNTS["standard"])
67
  difficulty = params.get("difficulty") if params.get("difficulty") in DIFFICULTIES else "medium"
68
  topic = (params.get("topic") or "").strip()
69
- material = load_material(db, artifact.workspace_id, artifact.notebook_id, params, job=job, what="quiz")
 
70
  update_job(db, job, progress=0.4, message="Writing the questions")
71
  questions = make_questions(db, artifact.workspace_id, material, topic=topic, difficulty=difficulty, types=types,
72
  count=count, language=params.get("language") or SAME_LANGUAGE, job=job)
 
4
 
5
  from sqlalchemy.orm import Session
6
 
7
+ from app.config import settings
8
  from app.llm import run
9
  from app.llm.provider import fast_lm
10
  from app.llm.signatures import GenerateQuiz
11
  from app.models import Artifact, Job
12
+ from app.pipeline import idea_pool
13
  from app.pipeline.generate import NothingToDo
14
  from app.pipeline.material import Material, load_material
15
  from app.services.jobs import update_job
 
53
  language: str = SAME_LANGUAGE, job: Job | None = None) -> list[dict]:
54
  """Playable questions about the material (fewer than `count` when the model gave unusable ones)."""
55
  types = [t for t in types or [] if t in QUESTION_TYPES] or list(QUESTION_TYPES[:2])
 
 
 
 
56
  # A multiple select with a single right answer was turned into a multiple choice: fine when that was asked for too.
57
  allowed = set(types) | ({"multiple_choice"} if "multiple_select" in types else set())
58
+ inputs = dict(title=material.title, topic=topic or "(none)", difficulty=difficulty, question_types=types,
59
+ language=language.strip() or SAME_LANGUAGE)
60
+ # Many posts: one call over a random sample of the ideas already extracted from them (idea_pool), not the posts' text.
61
+ text = idea_pool.sample(db, workspace_id, material.docs, topic, chats=material.text) if material.mode == "ideas" else material.text
62
+ out = run.predict(GenerateQuiz, db=db, workspace_id=workspace_id, job=job, material=text, count=count, lm=fast_lm(), **inputs)
63
+ cleaned = [c for q in out.get("questions") or [] if (c := _clean(q, material.refs))]
64
  return [c for c in cleaned if c["type"] in allowed][:count]
65
 
66
 
 
70
  count = QUESTION_COUNTS.get(params.get("count") or "standard", QUESTION_COUNTS["standard"])
71
  difficulty = params.get("difficulty") if params.get("difficulty") in DIFFICULTIES else "medium"
72
  topic = (params.get("topic") or "").strip()
73
+ material = load_material(db, artifact.workspace_id, artifact.notebook_id, params, job=job, what="quiz", large_ok=True,
74
+ passage_budget=settings.artifact_quiz_passage_budget)
75
  update_job(db, job, progress=0.4, message="Writing the questions")
76
  questions = make_questions(db, artifact.workspace_id, material, topic=topic, difficulty=difficulty, types=types,
77
  count=count, language=params.get("language") or SAME_LANGUAGE, job=job)
backend/app/pipeline/report.py CHANGED
@@ -1,42 +1,46 @@
1
  """Reports: a written document (or an interactive one, with visuals) made from a notebook's posts or chats.
2
 
3
  Every report is stored as `blocks`, even a plain document (a few prose blocks), so the same page can show, extend and
4
- share either kind. Each block is {"id", "type", ...}: `prose` has Markdown `text` whose [n] markers point into the
5
- report's `citations`."""
 
6
 
7
  import logging
8
  import re
 
9
  import uuid
 
10
 
 
11
  from sqlalchemy.orm import Session
12
 
 
 
13
  from app.infographics.themes import theme_id
14
  from app.llm import run
15
- from app.llm.provider import fast_lm, report_lm
16
  from app.llm.signatures import (
17
  PlanInteractiveReport,
18
- SuggestEmbeds,
19
  WriteReport,
20
  WriteReportSection,
 
 
21
  )
22
- from app.models import Artifact, Job
 
23
  from app.pipeline.flashcards import make_cards
24
  from app.pipeline.generate import NothingToDo, mind_map_content
25
  from app.pipeline.material import Material, load_material
26
- from app.pipeline.passages import as_citations
27
  from app.pipeline.quiz import make_questions
28
- from app.pipeline.research import verify_citations
29
  from app.services.artifacts import start_artifact
30
  from app.services.jobs import update_job
31
  from app.services.renderer import PermanentJobError
32
  from app.services.report_templates import default_instructions
33
 
34
- SUGGEST_KINDS = ("mind_map", "flashcards", "quiz", "infographic") # what the AI suggests for a document report
35
- EMBED_KINDS = SUGGEST_KINDS # every visual a reader can add with one click
36
  MAX_PLANNED_VISUALS = 4 # suggestions an interactive report's plan can make
37
  MAX_SECTIONS = 8
38
  SAME_LANGUAGE = "the same language as the sources" # no language picked: write as the material is written
39
- MAX_SUGGESTIONS = 4
40
  BLOCK_CARDS, BLOCK_QUESTIONS = 8, 5 # a visual inside a report is a taste of the full thing
41
 
42
  log = logging.getLogger(__name__)
@@ -46,7 +50,6 @@ MARKER = re.compile(r"\s*\[\d+\](?:\[\d+\])*")
46
 
47
  QUIZ_HEADING = re.compile(r"\b(quiz|test yourself|check your understanding|practice questions|review questions|self[- ]?check)\b", re.I)
48
  FLASHCARD_HEADING = re.compile(r"\bflash ?cards?\b", re.I)
49
- ASKED_FOR_VISUAL = re.compile(r"\b(quiz|flash ?cards?|practice questions)\b", re.I)
50
 
51
 
52
  def visual_kind(heading: str) -> str | None:
@@ -62,24 +65,6 @@ MOVED_BRIEF = {"quiz": "Check understanding of the key ideas in this report.",
62
  "flashcards": "Learn the key terms and facts from this report."}
63
 
64
 
65
- def pull_visual_sections(blocks: list[dict], kinds: list[str]) -> tuple[list[dict], list[dict]]:
66
- """A section written as a quiz or flashcards is not part of the text: it is taken out and offered as a suggestion
67
- after the section before it, for the reader to add. Never takes out every section."""
68
- kept: list[dict] = []
69
- moved: list[tuple[int, str]] = []
70
- for b in blocks:
71
- first = b["text"].split("\n", 1)[0] if b["type"] == "prose" else ""
72
- kind = visual_kind(first.lstrip("# ").strip()) if first.startswith("#") else None
73
- if kind and kind in kinds and kept:
74
- moved.append((len(kept) - 1, kind))
75
- else:
76
- kept.append(b)
77
- number_blocks(kept)
78
- return kept, [{"id": f"s{i}", "after_block_id": kept[idx]["id"], "kind": kind, "brief": MOVED_BRIEF[kind],
79
- "why": "Offered here for you to add, not written into the text."}
80
- for i, (idx, kind) in enumerate(moved, start=1)]
81
-
82
-
83
  def merge_suggestions(*groups: list[dict]) -> list[dict]:
84
  """Suggestions from several places, one per section and kind, renumbered."""
85
  seen, out = set(), []
@@ -92,6 +77,36 @@ def merge_suggestions(*groups: list[dict]) -> list[dict]:
92
  return out[:MAX_PLANNED_VISUALS + 2]
93
 
94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  def split_prose(markdown: str) -> list[dict]:
96
  """Markdown cut into prose blocks at each "## " heading, so a block is one section and can be followed by a visual."""
97
  parts = re.split(r"(?m)^(?=## )", markdown.strip())
@@ -108,81 +123,29 @@ def number_blocks(blocks: list[dict]) -> list[dict]:
108
  return blocks
109
 
110
 
111
- def citation_rows(cites, material: Material) -> list[dict]:
112
- by_path = {d.path: d for d in material.docs}
113
- return [{"marker": c.marker, "path": c.path, "line_start": c.line_start, "line_end": c.line_end,
114
- "span": c.quote, "title": by_path[c.path].title, "url": by_path[c.path].url,
115
- "document_id": str(by_path[c.path].id)}
116
- for c in cites if c.path in by_path]
117
 
118
 
119
- def source_rows(material: Material) -> list[dict]:
120
- """Where the report came from, for the public page's Sources button: titles and the posts' own web addresses."""
121
- return [{"title": d.title, "url": d.url, "document_id": str(d.id)} for d in material.docs]
122
 
123
 
124
  def write_document(db: Session, workspace_id: uuid.UUID, job: Job | None, material: Material, instructions: str,
125
- language: str) -> tuple[str, str, list[dict]]:
126
- """(title, Markdown, citation rows) for a written report. Citations are checked against the posts' lines and
127
- renumbered 1..n; a chat has none, so any marker the model wrote is removed."""
128
  out = run.predict(WriteReport, db=db, workspace_id=workspace_id, job=job, lm=report_lm(), title=material.title,
129
  material=material.text, request=instructions, language=language)
130
  markdown = (out.get("markdown") or "").strip()
131
  if not markdown:
132
  raise NothingToDo("The report came back empty. Try again, or describe it differently.")
133
- if material.tools is None:
134
- return (out.get("report_title") or material.title).strip(), MARKER.sub("", markdown), []
135
- text, cites = verify_citations(material.tools, markdown, as_citations(out.get("citations")))
136
- return (out.get("report_title") or material.title).strip(), text, citation_rows(cites, material)
137
-
138
-
139
- def allowed_kinds(material_or_source: Material | dict) -> list[str]:
140
- """Which visuals fit this source: a mind map is drawn from posts' stored ideas, so a chat gets cards and a quiz."""
141
- has_posts = bool(material_or_source.docs) if isinstance(material_or_source, Material) \
142
- else bool(material_or_source.get("document_ids"))
143
- return [k for k in SUGGEST_KINDS if k != "mind_map" or has_posts]
144
-
145
-
146
- def suggest_embeds(db: Session, workspace_id: uuid.UUID, job: Job | None, blocks: list[dict],
147
- kinds: list[str]) -> list[dict]:
148
- """Up to MAX_SUGGESTIONS places for a visual: after which section, what kind, what it covers, why. Never fails
149
- the report: no suggestions is a fine answer."""
150
- sections = []
151
- for b in blocks:
152
- if b["type"] != "prose":
153
- continue
154
- first, _, rest = b["text"].partition("\n")
155
- heading = first.lstrip("# ").strip() if first.startswith("#") else ""
156
- start = re.sub(r"\s+", " ", rest or first)[:240]
157
- sections.append(f"{b['id']} | {heading or 'Introduction'}: {start}")
158
- if len(sections) < 2:
159
- return []
160
- try:
161
- out = run.predict(SuggestEmbeds, db=db, workspace_id=workspace_id, job=job, lm=fast_lm(), sections=sections,
162
- allowed_kinds=kinds)
163
- except Exception:
164
- return []
165
- ids = {b["id"] for b in blocks}
166
- taken: set[tuple[str, str]] = {(b.get("after") or "", b["type"]) for b in blocks if b["type"] != "prose"}
167
- found = []
168
- for s in out.get("suggestions") or []:
169
- key = (s.get("after_block_id"), s.get("kind"))
170
- if s.get("after_block_id") not in ids or s.get("kind") not in kinds or key in taken or not (s.get("brief") or "").strip():
171
- continue
172
- taken.add(key)
173
- found.append({"id": f"s{len(found) + 1}", "after_block_id": s["after_block_id"], "kind": s["kind"],
174
- "brief": s["brief"].strip()[:300], "why": (s.get("why") or "").strip()[:300]})
175
- return found[:MAX_SUGGESTIONS]
176
-
177
-
178
- def shift_markers(text: str, offset: int) -> str:
179
- """[n] markers renumbered to [n + offset], so sections written apart share one citation list."""
180
- return re.sub(r"\[(\d+)\]", lambda m: f"[{int(m.group(1)) + offset}]", text) if offset else text
181
 
182
 
183
  def write_interactive(db: Session, workspace_id: uuid.UUID, job: Job, material: Material, instructions: str,
184
- language: str) -> tuple[str, list[dict], list[dict], list[dict]] | None:
185
- """(title, blocks, citation rows, suggestions) for an interactive report: a plan of sections, each written on its
186
  own (in parallel). The plan also says where a visual would fit; those spots come back as suggestions, and nothing
187
  is built until the reader clicks Add. None when planning fails, so the caller can write a plain document."""
188
  kinds = [k for k in EMBED_KINDS if k != "mind_map" or not material.is_chat]
@@ -219,21 +182,13 @@ def write_interactive(db: Session, workspace_id: uuid.UUID, job: Job, material:
219
  db=db, workspace_id=workspace_id, lm=report_lm())
220
 
221
  blocks: list[dict] = []
222
- citations: list[dict] = []
223
  suggestions: list[dict] = []
224
  used_kinds: set[str] = set()
225
  anchor: dict[int, str] = {} # planned section number -> the block it became
226
  for n, (sec, out) in enumerate(zip(sections, written, strict=True)):
227
- body = ((out or {}).get("markdown") or "").strip()
228
  if not body:
229
  continue
230
- if material.tools is None:
231
- body = MARKER.sub("", body)
232
- else:
233
- body, cites = verify_citations(material.tools, body, as_citations((out or {}).get("citations")))
234
- offset = len(citations)
235
- body = shift_markers(body, offset)
236
- citations += [{**c, "marker": c["marker"] + offset} for c in citation_rows(cites, material)]
237
  block = {"type": "prose", "text": f"## {sec['heading'].strip()}\n\n{body}"}
238
  blocks.append(block)
239
  number_blocks(blocks)
@@ -252,7 +207,275 @@ def write_interactive(db: Session, workspace_id: uuid.UUID, job: Job, material:
252
  suggestions.append({"id": "", "after_block_id": after, "kind": kind,
253
  "brief": (sec.get("brief") or MOVED_BRIEF[kind]).strip()[:300],
254
  "why": "Offered here for you to add, not written into the text."})
255
- return report_title, blocks, citations, merge_suggestions(suggestions)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
 
257
 
258
  def build_report(db: Session, job: Job, artifact: Artifact) -> dict:
@@ -260,30 +483,40 @@ def build_report(db: Session, job: Job, artifact: Artifact) -> dict:
260
  topic = (params.get("topic") or "").strip()
261
  language = (params.get("language") or "").strip() or SAME_LANGUAGE
262
  report_format = "interactive" if params.get("report_format") == "interactive" else "document"
263
- material = load_material(db, artifact.workspace_id, artifact.notebook_id, params, job=job, what="report")
 
 
 
264
  instructions = (params.get("instructions") or "").strip() or default_instructions(
265
- report_format, params.get("template_id"), topic or material.title)
266
-
267
- made = write_interactive(db, artifact.workspace_id, job, material, instructions, language) \
268
- if report_format == "interactive" else None
269
- if made:
270
- title, blocks, citations, suggestions = made
 
 
 
 
271
  else:
272
- # A document, or an interactive report whose plan came back unusable: write it as one piece. Never a failure.
273
- report_format = "document"
274
- update_job(db, job, progress=0.3, message="Writing the report")
275
- title, markdown, citations = write_document(db, artifact.workspace_id, job, material, instructions, language)
276
- blocks = number_blocks(split_prose(markdown))
277
- kinds = allowed_kinds(material)
278
- pulled: list[dict] = []
279
- if not ASKED_FOR_VISUAL.search(instructions): # a quiz section the reader did not ask for becomes a suggestion
280
- blocks, pulled = pull_visual_sections(blocks, kinds)
281
- update_job(db, job, progress=0.9, message="Finding places for visuals")
282
- suggestions = merge_suggestions(pulled, suggest_embeds(db, artifact.workspace_id, job, blocks, kinds))
 
 
 
283
  artifact.content_json = {
284
  "title": title, "format": report_format, "template_id": params.get("template_id"),
285
- "instructions": instructions, "language": language, "topic": topic, "source": material.source,
286
- "blocks": blocks, "suggestions": suggestions, "citations": citations, "sources": source_rows(material),
287
  }
288
  artifact.status = "ready"
289
  db.commit()
@@ -308,7 +541,7 @@ def copy_block(kind: str, existing: Artifact) -> dict:
308
  return {"title": c.get("title"), "questions": c["questions"]}
309
  if kind == "flashcards" and c.get("cards"):
310
  return {"title": c.get("title"), "cards": c["cards"]}
311
- if kind == "infographic" and existing.storage_key: # the picture itself is shared, not copied
312
  return {"title": c.get("title"), "artifact_id": str(existing.id), "theme": c.get("theme")}
313
  raise NothingToDo("That one has nothing to add.")
314
 
@@ -381,6 +614,7 @@ def handle_report_block(db: Session, job: Job) -> dict:
381
  block = copy_block(kind, existing)
382
  else:
383
  block = make_block(db, job, report, kind, (p.get("brief") or "").strip(), p.get("theme"))
 
384
  except NothingToDo as exc:
385
  raise PermanentJobError(str(exc)) from exc
386
  report.content_json = insert_block(report.content_json or {}, {"type": kind, **block}, p.get("after_block_id"),
@@ -389,8 +623,44 @@ def handle_report_block(db: Session, job: Job) -> dict:
389
  return {"artifact_id": str(report.id)}
390
 
391
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
392
  def remove_block(content: dict, block_id: str) -> dict | None:
393
- """The content without that visual (prose cannot be removed), or None when there is no such visual."""
 
394
  blocks = list(content.get("blocks") or [])
395
- keep = [b for b in blocks if not (b["id"] == block_id and b["type"] != "prose")]
396
- return None if len(keep) == len(blocks) else {**content, "blocks": keep}
 
 
 
 
 
 
 
 
 
1
  """Reports: a written document (or an interactive one, with visuals) made from a notebook's posts or chats.
2
 
3
  Every report is stored as `blocks`, even a plain document (a few prose blocks), so the same page can show, extend and
4
+ share either kind. Each block is {"id", "type", ...}: `prose` has Markdown `text`. A report cites nothing: it carries the
5
+ list of the posts it was made from (`sources`) and the instructions it was written to (`instructions`), which the page shows
6
+ together in a drop-down. (`citations` stays in the content, empty, for the older reports that have them.)"""
7
 
8
  import logging
9
  import re
10
+ import time
11
  import uuid
12
+ from dataclasses import dataclass, field
13
 
14
+ from sqlalchemy import select
15
  from sqlalchemy.orm import Session
16
 
17
+ from app.config import settings
18
+ from app.corpus import Corpus
19
  from app.infographics.themes import theme_id
20
  from app.llm import run
21
+ from app.llm.provider import report_lm, report_section_lm
22
  from app.llm.signatures import (
23
  PlanInteractiveReport,
 
24
  WriteReport,
25
  WriteReportSection,
26
+ WriteThemedReport,
27
+ WriteThemedSection,
28
  )
29
+ from app.models import Artifact, Document, Job, Notebook
30
+ from app.pipeline import report_blueprint, report_evidence, report_themes
31
  from app.pipeline.flashcards import make_cards
32
  from app.pipeline.generate import NothingToDo, mind_map_content
33
  from app.pipeline.material import Material, load_material
 
34
  from app.pipeline.quiz import make_questions
 
35
  from app.services.artifacts import start_artifact
36
  from app.services.jobs import update_job
37
  from app.services.renderer import PermanentJobError
38
  from app.services.report_templates import default_instructions
39
 
40
+ EMBED_KINDS = ("mind_map", "flashcards", "quiz", "infographic") # every visual a reader can add with one click
 
41
  MAX_PLANNED_VISUALS = 4 # suggestions an interactive report's plan can make
42
  MAX_SECTIONS = 8
43
  SAME_LANGUAGE = "the same language as the sources" # no language picked: write as the material is written
 
44
  BLOCK_CARDS, BLOCK_QUESTIONS = 8, 5 # a visual inside a report is a taste of the full thing
45
 
46
  log = logging.getLogger(__name__)
 
50
 
51
  QUIZ_HEADING = re.compile(r"\b(quiz|test yourself|check your understanding|practice questions|review questions|self[- ]?check)\b", re.I)
52
  FLASHCARD_HEADING = re.compile(r"\bflash ?cards?\b", re.I)
 
53
 
54
 
55
  def visual_kind(heading: str) -> str | None:
 
65
  "flashcards": "Learn the key terms and facts from this report."}
66
 
67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  def merge_suggestions(*groups: list[dict]) -> list[dict]:
69
  """Suggestions from several places, one per section and kind, renumbered."""
70
  seen, out = set(), []
 
77
  return out[:MAX_PLANNED_VISUALS + 2]
78
 
79
 
80
+ FALLBACK_BRIEFS = {
81
+ "infographic": ("A one page picture of the main ideas of this report.", "A quick picture of the whole report."),
82
+ "flashcards": (MOVED_BRIEF["flashcards"], "Cards to learn the key terms and facts."),
83
+ "quiz": (MOVED_BRIEF["quiz"], "A short quiz to check understanding."),
84
+ "mind_map": ("How the main ideas of this report connect.", "See how the ideas fit together."),
85
+ }
86
+ MIN_SUGGESTIONS = 3 # a report always offers at least this many visuals (fewer only when fewer kinds are allowed)
87
+
88
+
89
+ def fill_suggestions(suggestions: list[dict], blocks: list[dict], kinds: list[str]) -> list[dict]:
90
+ """An interactive report always offers something to add (a document never does). Whatever the plan or the writer suggested is
91
+ kept; then an infographic is added if there is none, and flashcards, a quiz and a mind map in turn until there are
92
+ MIN_SUGGESTIONS, each after a different section. Plain code, so it never depends on the model remembering to suggest."""
93
+ prose = [b for b in blocks if b.get("type") == "prose"]
94
+ if not prose:
95
+ return suggestions
96
+ inner = prose[1:-1] or prose # not the introduction or the summary, when there are others
97
+ place = {"infographic": inner[-1]["id"], "flashcards": inner[0]["id"], "quiz": inner[len(inner) // 2]["id"],
98
+ "mind_map": prose[0]["id"]}
99
+ out = list(suggestions)
100
+ have = {s["kind"] for s in out}
101
+ for kind in ("infographic", "flashcards", "quiz", "mind_map"):
102
+ if kind in kinds and kind not in have and (kind == "infographic" or len(out) < MIN_SUGGESTIONS):
103
+ brief, why = FALLBACK_BRIEFS[kind]
104
+ out.append({"id": "", "after_block_id": place[kind], "kind": kind, "brief": brief, "why": why})
105
+ have.add(kind)
106
+ log.info("report: no %s suggested by the plan, offering one after %s", kind, place[kind])
107
+ return out
108
+
109
+
110
  def split_prose(markdown: str) -> list[dict]:
111
  """Markdown cut into prose blocks at each "## " heading, so a block is one section and can be followed by a visual."""
112
  parts = re.split(r"(?m)^(?=## )", markdown.strip())
 
123
  return blocks
124
 
125
 
126
+ def source_rows(material: Material) -> list[dict]:
127
+ """The posts a report was made from, for its Sources list: titles and the posts' own web addresses."""
128
+ return source_rows_for(material.docs)
 
 
 
129
 
130
 
131
+ def source_rows_for(docs: list[Document]) -> list[dict]:
132
+ return [{"title": d.title, "url": d.url, "document_id": str(d.id)} for d in docs]
 
133
 
134
 
135
  def write_document(db: Session, workspace_id: uuid.UUID, job: Job | None, material: Material, instructions: str,
136
+ language: str) -> tuple[str, str]:
137
+ """(title, Markdown) for a written report. Nothing is cited; a marker the model wrote anyway is removed."""
 
138
  out = run.predict(WriteReport, db=db, workspace_id=workspace_id, job=job, lm=report_lm(), title=material.title,
139
  material=material.text, request=instructions, language=language)
140
  markdown = (out.get("markdown") or "").strip()
141
  if not markdown:
142
  raise NothingToDo("The report came back empty. Try again, or describe it differently.")
143
+ return (out.get("report_title") or material.title).strip(), MARKER.sub("", markdown)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
 
145
 
146
  def write_interactive(db: Session, workspace_id: uuid.UUID, job: Job, material: Material, instructions: str,
147
+ language: str) -> tuple[str, list[dict], list[dict]] | None:
148
+ """(title, blocks, suggestions) for an interactive report: a plan of sections, each written on its
149
  own (in parallel). The plan also says where a visual would fit; those spots come back as suggestions, and nothing
150
  is built until the reader clicks Add. None when planning fails, so the caller can write a plain document."""
151
  kinds = [k for k in EMBED_KINDS if k != "mind_map" or not material.is_chat]
 
182
  db=db, workspace_id=workspace_id, lm=report_lm())
183
 
184
  blocks: list[dict] = []
 
185
  suggestions: list[dict] = []
186
  used_kinds: set[str] = set()
187
  anchor: dict[int, str] = {} # planned section number -> the block it became
188
  for n, (sec, out) in enumerate(zip(sections, written, strict=True)):
189
+ body = MARKER.sub("", ((out or {}).get("markdown") or "").strip())
190
  if not body:
191
  continue
 
 
 
 
 
 
 
192
  block = {"type": "prose", "text": f"## {sec['heading'].strip()}\n\n{body}"}
193
  blocks.append(block)
194
  number_blocks(blocks)
 
207
  suggestions.append({"id": "", "after_block_id": after, "kind": kind,
208
  "brief": (sec.get("brief") or MOVED_BRIEF[kind]).strip()[:300],
209
  "why": "Offered here for you to add, not written into the text."})
210
+ return report_title, blocks, merge_suggestions(fill_suggestions(suggestions, blocks, kinds))
211
+
212
+
213
+ # A report built around themes (report_themes.py, report_blueprint.py, report_evidence.py)
214
+
215
+ EVIDENCE_TOTAL = 100_000 # characters of evidence in all for a report written in one piece (about 25k tokens)
216
+ DIGEST_CHARS = 300 # of each written section, for the introduction and the summary that are written after them
217
+
218
+
219
+ @dataclass
220
+ class Themed:
221
+ title: str
222
+ blocks: list[dict]
223
+ suggestions: list[dict]
224
+ source: dict
225
+ sources: list[dict]
226
+ plan: dict
227
+ failed: list[str] = field(default_factory=list)
228
+
229
+
230
+ @dataclass
231
+ class ThemedRun:
232
+ """What the steps of a themed report share."""
233
+ db: Session
234
+ workspace_id: uuid.UUID
235
+ job: Job
236
+ docs: list[Document]
237
+ corpus: Corpus
238
+ bp: report_blueprint.Blueprint
239
+ themes: list[report_themes.Theme]
240
+ instructions: str
241
+ language: str
242
+ title: str
243
+
244
+ @property
245
+ def by_id(self) -> dict[str, report_themes.Theme]:
246
+ return {t.id: t for t in self.themes}
247
+
248
+ def names(self, s: report_blueprint.Section) -> list[str]:
249
+ by_id = self.by_id
250
+ return [by_id[i].name for i in s.theme_ids if i in by_id]
251
+
252
+ def sections_info(self) -> list[dict]:
253
+ return [{"heading": s.heading, "role": s.role, "depth": s.depth, "theme_ids": s.theme_ids, "must_cover": s.must_cover}
254
+ for s in self.bp.sections]
255
+
256
+ def finish(self, title: str, blocks: list[dict], suggestions: list[dict], used_posts: list[str],
257
+ failed: list[str]) -> Themed:
258
+ """The Sources list is every post the report was made from: the ones read as written and the ones summarised."""
259
+ used = [d for d in self.docs if d.path in set(used_posts)] or self.docs
260
+ log.info("report sources: job=%s posts_total=%d posts_used=%d", self.job.id, len(self.docs), len(used))
261
+ plan = {"planner": "themes", "storyline": self.bp.storyline, "posts_total": len(self.docs), "posts_used": len(used),
262
+ "themes": [report_themes.theme_summary(t) for t in self.themes], "sections": self.sections_info(),
263
+ "connections": self.bp.connections}
264
+ return Themed(title=title, blocks=blocks, suggestions=suggestions, plan=plan, failed=failed,
265
+ source={"kind": "posts", "document_ids": [str(d.id) for d in self.docs], "count": len(self.docs),
266
+ "read": len(used), "total": len(self.docs)},
267
+ sources=source_rows_for(used))
268
+
269
+
270
+ def themed_docs(db: Session, artifact: Artifact, params: dict) -> list[Document]:
271
+ """The posts a themed report would read, or [] when the report is made the usual way: chats, a small selection, or the
272
+ legacy planner."""
273
+ if settings.report_planner != "themes" or params.get("chat_ids"):
274
+ log.info("report route: legacy artifact=%s reason=%s", artifact.id,
275
+ "chats picked" if params.get("chat_ids") else f"REPORT_PLANNER={settings.report_planner}")
276
+ return []
277
+ docs = report_themes.select_docs(db, artifact.workspace_id, artifact.notebook_id, params)
278
+ if len(docs) <= report_themes.SMALL_SELECTION:
279
+ log.info("report route: legacy artifact=%s reason=small selection (%d posts, themes need more than %d)", artifact.id,
280
+ len(docs), report_themes.SMALL_SELECTION)
281
+ return []
282
+ return docs
283
+
284
+
285
+ def themed_title(db: Session, artifact: Artifact, params: dict, docs: list[Document]) -> str:
286
+ """What the report is about, named the way load_material names its source."""
287
+ nb = db.get(Notebook, artifact.notebook_id) if artifact.notebook_id else None
288
+ if nb and not params.get("document_ids"):
289
+ return nb.title
290
+ return docs[0].title + (f" + {len(docs) - 1} more" if len(docs) > 1 else "")
291
+
292
+
293
+ def _digest(markdown: str) -> str:
294
+ """The start of a written section, for the sections written after it to know what it says."""
295
+ text = MARKER.sub("", re.sub(r"(?m)^#+\s*", "", markdown))
296
+ return re.sub(r"\s+", " ", text).strip()[:DIGEST_CHARS]
297
+
298
+
299
+ def _section_header(n: int, s: report_blueprint.Section, names: list[str]) -> str:
300
+ return (f"SECTION {n}: {s.heading} (role: {s.role}, depth: {s.depth})\nSays: {s.brief}\n"
301
+ f"Must cover: {'; '.join(s.must_cover) or '(see what it says)'}\nThemes: {', '.join(names) or '(the whole report)'}")
302
+
303
+
304
+ def _section_input(ctx: ThemedRun, s: report_blueprint.Section, evidence: list[str]) -> dict:
305
+ """Everything WriteThemedSection is given. What every section shares comes first in the signature."""
306
+ must = list(s.must_cover)
307
+ if s.role == "summary" and ctx.names(s):
308
+ must.append(f"Restate the main points about: {', '.join(ctx.names(s))}")
309
+ return {"storyline": ctx.bp.storyline, "outline": report_blueprint.outline(ctx.bp),
310
+ "connections": report_blueprint.connection_lines(ctx.bp, ctx.themes), "request": ctx.instructions,
311
+ "language": ctx.language, "heading": s.heading, "role": s.role, "depth": s.depth, "brief": s.brief,
312
+ "must_cover": must, "covered_elsewhere": s.covered_elsewhere,
313
+ "bridges": report_blueprint.connection_lines(ctx.bp, ctx.themes, s.heading), "evidence": evidence}
314
+
315
+
316
+ def _write_sections(ctx: ThemedRun, inputs: list[dict]) -> list[dict | None]:
317
+ """WriteThemedSection for each input, together; one that failed or came back empty gets one more try (another temperature)."""
318
+ kw = {"db": ctx.db, "workspace_id": ctx.workspace_id}
319
+ started = time.monotonic()
320
+ outs = run.predict_many(WriteThemedSection, inputs, lm=report_section_lm(), **kw)
321
+ again = [k for k, o in enumerate(outs) if not ((o or {}).get("markdown") or "").strip()]
322
+ log.info("report sections written: asked=%d ok=%d failed=%d seconds=%.1f", len(inputs), len(inputs) - len(again), len(again),
323
+ time.monotonic() - started)
324
+ if again:
325
+ log.warning("report sections failed, trying again: %s", [inputs[k]["heading"] for k in again])
326
+ redo = run.predict_many(WriteThemedSection, [inputs[k] for k in again], lm=report_section_lm(retry=True), **kw)
327
+ for k, o in zip(again, redo, strict=True):
328
+ outs[k] = o
329
+ gone = [inputs[k]["heading"] for k in again if not ((outs[k] or {}).get("markdown") or "").strip()]
330
+ log.info("report sections retried: asked=%d recovered=%d still_failed=%s", len(again), len(again) - len(gone), gone)
331
+ return outs
332
+
333
+
334
+ def _themed_document(ctx: ThemedRun) -> Themed | None:
335
+ """The whole report in one piece: the plan and every section's evidence go to one writer, so one voice writes it all."""
336
+ budgets = report_evidence.scale_budgets(ctx.bp.sections, EVIDENCE_TOTAL)
337
+ parts: list[str] = []
338
+ used: list[str] = []
339
+ for n, s in enumerate(ctx.bp.sections, start=1):
340
+ head = _section_header(n, s, ctx.names(s))
341
+ if s.role in ("body", "synthesis"):
342
+ ev = report_evidence.build(ctx.corpus, s, ctx.by_id, budgets.get(s.heading))
343
+ used += ev.posts
344
+ if ev.blocks:
345
+ head += "\nEvidence:\n" + "\n\n".join(ev.blocks)
346
+ parts.append(head)
347
+ report_themes.progress(ctx.db, ctx.job, 0.5, "Writing the report")
348
+ log.info("report document: writing in one piece sections=%d evidence_chars=%d (budget %d) posts_used=%d", len(parts),
349
+ sum(len(p) for p in parts), EVIDENCE_TOTAL, len(set(used)))
350
+ started = time.monotonic()
351
+ out = run.predict(WriteThemedReport, db=ctx.db, workspace_id=ctx.workspace_id, job=ctx.job, lm=report_lm(),
352
+ title=ctx.title, storyline=ctx.bp.storyline, request=ctx.instructions, language=ctx.language,
353
+ connections=report_blueprint.connection_lines(ctx.bp, ctx.themes), sections=parts)
354
+ markdown = MARKER.sub("", (out.get("markdown") or "").strip())
355
+ if not markdown:
356
+ log.warning("report document came back empty, reading the posts instead")
357
+ return None
358
+ log.info("report document written: chars=%d seconds=%.1f", len(markdown), time.monotonic() - started)
359
+ return ctx.finish((out.get("report_title") or ctx.bp.title).strip(), number_blocks(split_prose(markdown)), [], used, [])
360
+
361
+
362
+ def _themed_interactive(ctx: ThemedRun, moved: list[tuple[report_blueprint.Section, str, report_blueprint.Section]]) -> Themed | None:
363
+ """Wave 1: the body sections together, each given the storyline, the outline, what the others cover and only its own
364
+ evidence. Wave 2: the introduction and the summary, written from what wave 1 produced."""
365
+ sections = ctx.bp.sections
366
+ body = [(n, s) for n, s in enumerate(sections) if s.role in ("body", "synthesis")]
367
+ frame = [(n, s) for n, s in enumerate(sections) if s.role in ("intro", "summary")]
368
+ used: list[str] = []
369
+ inputs = []
370
+ for _, s in body:
371
+ ev = report_evidence.build(ctx.corpus, s, ctx.by_id)
372
+ used += ev.posts
373
+ inputs.append(_section_input(ctx, s, ev.blocks))
374
+ report_themes.progress(ctx.db, ctx.job, 0.45, f"Writing {len(body)} sections")
375
+ log.info("report wave 1: body sections=%d evidence_chars=%s", len(body), [sum(len(b) for b in i["evidence"]) for i in inputs])
376
+ written: dict[int, dict] = {}
377
+ for (n, _), out in zip(body, _write_sections(ctx, inputs), strict=True):
378
+ if ((out or {}).get("markdown") or "").strip():
379
+ written[n] = out
380
+ if not written:
381
+ log.warning("report wave 1 wrote nothing, reading the posts instead")
382
+ return None
383
+ digests = [f"{s.heading}: {_digest(written[n]['markdown'])}" for n, s in body if n in written]
384
+ report_themes.progress(ctx.db, ctx.job, 0.8, "Writing the introduction and summary")
385
+ log.info("report wave 2: intro/summary sections=%d digests=%d", len(frame), len(digests))
386
+ for (n, _), out in zip(frame, _write_sections(ctx, [_section_input(ctx, s, digests) for _, s in frame]), strict=True):
387
+ if ((out or {}).get("markdown") or "").strip():
388
+ written[n] = out
389
+
390
+ blocks: list[dict] = []
391
+ suggestions: list[dict] = []
392
+ anchor: dict[int, str] = {}
393
+ used_kinds: set[str] = set()
394
+ for n, s in enumerate(sections):
395
+ out = written.get(n)
396
+ if not out:
397
+ continue
398
+ blocks.append({"type": "prose", "text": f"## {s.heading}\n\n{MARKER.sub('', out['markdown'].strip())}"})
399
+ number_blocks(blocks)
400
+ anchor[id(s)] = blocks[-1]["id"]
401
+ embed = s.embed
402
+ if embed and len(suggestions) < MAX_PLANNED_VISUALS and embed.get("kind") in EMBED_KINDS \
403
+ and embed["kind"] not in used_kinds and (embed.get("brief") or "").strip():
404
+ used_kinds.add(embed["kind"])
405
+ suggestions.append({"id": f"s{len(suggestions) + 1}", "after_block_id": blocks[-1]["id"], "kind": embed["kind"],
406
+ "brief": embed["brief"].strip()[:300], "why": (embed.get("why") or "").strip()[:300]})
407
+ for after, kind, sec in moved:
408
+ suggestions.append({"id": "", "after_block_id": anchor.get(id(after)) or blocks[0]["id"], "kind": kind,
409
+ "brief": (sec.brief or MOVED_BRIEF[kind]).strip()[:300],
410
+ "why": "Offered here for you to add, not written into the text."})
411
+ failed = [s.heading for n, s in enumerate(sections) if n not in written]
412
+ if failed:
413
+ log.warning("report sections left out after a retry: %s", failed)
414
+ log.info("report interactive written: blocks=%d suggestions=%d moved_to_suggestions=%d failed=%d", len(blocks),
415
+ len(suggestions), len(moved), len(failed))
416
+ suggestions = fill_suggestions(suggestions, blocks, list(EMBED_KINDS))
417
+ return ctx.finish(ctx.bp.title, blocks, merge_suggestions(suggestions), used, failed)
418
+
419
+
420
+ def write_themed(db: Session, workspace_id: uuid.UUID, job: Job, docs: list[Document], title: str, instructions: str,
421
+ language: str, interactive: bool) -> Themed | None:
422
+ """The report from the posts' themes: group their stored ideas into weighted themes, plan the whole report, build each
423
+ section's evidence from several posts, then write. A document is written in one piece from the plan; an interactive report
424
+ section by section, each knowing the storyline, the outline and what the others cover, with the introduction and the
425
+ summary written last from what the sections say. None when any step cannot be done (too few ideas, no usable plan, nothing
426
+ written), so the caller reads the posts as a report always did. Never makes a report fail."""
427
+ started = time.monotonic()
428
+ log.info("themed report starting: job=%s workspace=%s format=%s posts=%d language=%r", job.id, workspace_id,
429
+ "interactive" if interactive else "document", len(docs), language)
430
+ try:
431
+ report_themes.progress(db, job, 0.05, f"Reading {len(docs)} posts")
432
+ report_themes.ensure_ideas(db, job, workspace_id, docs)
433
+ share = report_themes.fresh_share(docs)
434
+ if share < report_themes.MIN_FRESH_SHARE:
435
+ log.warning("themed report not possible, reading the posts instead: job=%s only %.0f%% of %d posts have ideas "
436
+ "(need %.0f%%)", job.id, share * 100, len(docs), report_themes.MIN_FRESH_SHARE * 100)
437
+ return None
438
+ corpus = Corpus(workspace_id)
439
+ corpus.sync()
440
+ ideas = report_themes.collect_ideas(docs, corpus)
441
+ report_themes.progress(db, job, 0.2, f"Finding the themes in {len(docs)} posts")
442
+ themes, relations = report_themes.group_themes(db, job, workspace_id, title, instructions, ideas)
443
+ report_themes.progress(db, job, 0.3, "Planning the report")
444
+ kinds = list(EMBED_KINDS) if interactive else []
445
+ bp = report_blueprint.plan_blueprint(db, job, workspace_id, title, instructions, themes, relations, kinds, language)
446
+ log.info("themed report planned: job=%s themes=%d sections=%d seconds=%.1f", job.id, len(themes), len(bp.sections),
447
+ time.monotonic() - started)
448
+ moved: list[tuple[report_blueprint.Section, str, report_blueprint.Section]] = []
449
+ if interactive: # a planned section that is really a quiz or flashcards is a suggestion, not text
450
+ kept: list[report_blueprint.Section] = []
451
+ for s in bp.sections:
452
+ kind = visual_kind(s.heading)
453
+ if kind and kept:
454
+ log.info("report section %r is a %s, so it becomes a suggestion after %r", s.heading, kind, kept[-1].heading)
455
+ moved.append((kept[-1], kind, s))
456
+ if kept[-1].role == "body": # what the section was about is still said, in the section before it
457
+ kept[-1].theme_ids += [i for i in s.theme_ids if i not in kept[-1].theme_ids]
458
+ else:
459
+ kept.append(s)
460
+ bp.sections = kept
461
+ if len(bp.sections) < 2:
462
+ log.warning("themed report not possible, reading the posts instead: job=%s fewer than two sections left", job.id)
463
+ return None
464
+ ctx = ThemedRun(db, workspace_id, job, docs, corpus, bp, themes, instructions, language, title)
465
+ themed = _themed_interactive(ctx, moved) if interactive else _themed_document(ctx)
466
+ if themed:
467
+ log.info("themed report done: job=%s posts_total=%d posts_used=%d blocks=%d suggestions=%d failed=%d seconds=%.1f",
468
+ job.id, len(docs), themed.plan["posts_used"], len(themed.blocks), len(themed.suggestions),
469
+ len(themed.failed), time.monotonic() - started)
470
+ else:
471
+ log.warning("themed report wrote nothing, reading the posts instead: job=%s", job.id)
472
+ return themed
473
+ except (report_themes.ThemesUnavailable, report_blueprint.BlueprintUnavailable) as exc:
474
+ log.warning("themed report not possible, reading the posts instead: job=%s reason=%s", job.id, exc)
475
+ except Exception:
476
+ db.rollback()
477
+ log.warning("themed report failed, reading the posts instead: job=%s", job.id, exc_info=True)
478
+ return None
479
 
480
 
481
  def build_report(db: Session, job: Job, artifact: Artifact) -> dict:
 
483
  topic = (params.get("topic") or "").strip()
484
  language = (params.get("language") or "").strip() or SAME_LANGUAGE
485
  report_format = "interactive" if params.get("report_format") == "interactive" else "document"
486
+ # Posts with ideas stored are read through their themes, however many are picked; chats and small selections as always.
487
+ docs = themed_docs(db, artifact, params)
488
+ material = None if docs else load_material(db, artifact.workspace_id, artifact.notebook_id, params, job=job, what="report")
489
+ about = topic or (themed_title(db, artifact, params, docs) if docs else material.title)
490
  instructions = (params.get("instructions") or "").strip() or default_instructions(
491
+ report_format, params.get("template_id"), about)
492
+
493
+ themed = write_themed(db, artifact.workspace_id, job, docs, themed_title(db, artifact, params, docs), instructions, language,
494
+ report_format == "interactive") if docs else None
495
+ log.info("report: job=%s artifact=%s format=%s way=%s", job.id, artifact.id, report_format,
496
+ "themes" if themed else "legacy (posts read as text)")
497
+ if themed:
498
+ extra = {"plan": themed.plan, **({"failed_sections": themed.failed} if themed.failed else {})}
499
+ title, blocks, suggestions = themed.title, themed.blocks, themed.suggestions
500
+ source, sources = themed.source, themed.sources
501
  else:
502
+ material = material or load_material(db, artifact.workspace_id, artifact.notebook_id, params, job=job, what="report")
503
+ extra = {}
504
+ made = write_interactive(db, artifact.workspace_id, job, material, instructions, language) \
505
+ if report_format == "interactive" else None
506
+ if made:
507
+ title, blocks, suggestions = made
508
+ else:
509
+ # A document, or an interactive report whose plan came back unusable: write it as one piece. Never a failure.
510
+ report_format = "document"
511
+ update_job(db, job, progress=0.3, message="Writing the report")
512
+ title, markdown = write_document(db, artifact.workspace_id, job, material, instructions, language)
513
+ blocks = number_blocks(split_prose(markdown))
514
+ suggestions = [] # visuals are offered in interactive reports only, never in a document
515
+ source, sources = material.source, source_rows(material)
516
  artifact.content_json = {
517
  "title": title, "format": report_format, "template_id": params.get("template_id"),
518
+ "instructions": instructions, "language": language, "topic": topic, "source": source,
519
+ "blocks": blocks, "suggestions": suggestions, "suggestions_offered": True, "citations": [], "sources": sources, **extra,
520
  }
521
  artifact.status = "ready"
522
  db.commit()
 
541
  return {"title": c.get("title"), "questions": c["questions"]}
542
  if kind == "flashcards" and c.get("cards"):
543
  return {"title": c.get("title"), "cards": c["cards"]}
544
+ if kind == "infographic" and c.get("content"): # the picture itself is shared, not copied
545
  return {"title": c.get("title"), "artifact_id": str(existing.id), "theme": c.get("theme")}
546
  raise NothingToDo("That one has nothing to add.")
547
 
 
614
  block = copy_block(kind, existing)
615
  else:
616
  block = make_block(db, job, report, kind, (p.get("brief") or "").strip(), p.get("theme"))
617
+ keep_visual(db, report, {"type": kind, **block}) # a new one also lands in the notebook's Studio list
618
  except NothingToDo as exc:
619
  raise PermanentJobError(str(exc)) from exc
620
  report.content_json = insert_block(report.content_json or {}, {"type": kind, **block}, p.get("after_block_id"),
 
623
  return {"artifact_id": str(report.id)}
624
 
625
 
626
+ def keep_visual(db: Session, report: Artifact, block: dict) -> Artifact | None:
627
+ """A mind map, flashcards or quiz made inside a report would exist only as that block. It is also saved as an
628
+ artifact of its own in the notebook, so it shows in the Studio list and "use one you already made" offers it
629
+ again after the block is removed. Content that is already saved is not saved twice."""
630
+ kind = block.get("type")
631
+ source = (report.content_json or {}).get("source") or {}
632
+ if kind == "flashcards" and block.get("cards"):
633
+ key, content = "cards", {"cards": block["cards"], "card_count": len(block["cards"])}
634
+ elif kind == "quiz" and block.get("questions"):
635
+ key, content = "questions", {"questions": block["questions"], "question_count": len(block["questions"])}
636
+ elif kind == "mind_map" and block.get("root"):
637
+ key, content = "root", {"root": block["root"], "node_count": block.get("node_count"),
638
+ "post_count": block.get("post_count"),
639
+ "document_ids": source.get("document_ids") or []}
640
+ else:
641
+ return None # an infographic already is its own artifact
642
+ same = db.scalars(select(Artifact).where(Artifact.workspace_id == report.workspace_id, Artifact.type == kind,
643
+ Artifact.notebook_id == report.notebook_id))
644
+ if any((a.content_json or {}).get(key) == content[key] for a in same):
645
+ return None
646
+ saved = Artifact(workspace_id=report.workspace_id, notebook_id=report.notebook_id, type=kind, status="ready",
647
+ content_json={"title": block.get("title") or kind, "source": source, **content})
648
+ db.add(saved)
649
+ db.commit()
650
+ return saved
651
+
652
+
653
  def remove_block(content: dict, block_id: str) -> dict | None:
654
+ """The content without that visual (prose cannot be removed), with the offer to add it again restored, or None
655
+ when there is no such visual."""
656
  blocks = list(content.get("blocks") or [])
657
+ removed = next((b for b in blocks if b["id"] == block_id and b["type"] != "prose"), None)
658
+ if not removed:
659
+ return None
660
+ keep = [b for b in blocks if b["id"] != block_id]
661
+ suggestions = list(content.get("suggestions") or [])
662
+ kind = removed["type"]
663
+ if kind in FALLBACK_BRIEFS and not any(s["kind"] == kind for s in suggestions):
664
+ brief, why = FALLBACK_BRIEFS[kind]
665
+ suggestions.append({"id": "", "after_block_id": removed.get("after"), "kind": kind, "brief": brief, "why": why})
666
+ return {**content, "blocks": keep, "suggestions": suggestions}
backend/app/routers/artifacts.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import re
2
  import uuid
3
  from typing import Literal
@@ -10,8 +11,9 @@ from app.auth import Ctx, get_ctx
10
  from app.config import settings
11
  from app.infographics.image import ImageUnavailable, render_png
12
  from app.infographics.themes import theme_id
13
- from app.models import Artifact, CalendarItem, Chat, Document, Job, Notebook, UserSavedVoice
14
  from app.routers.notebooks import ensure_archive_notebook
 
15
  from app.services.artifacts import latest_jobs, serialize_artifact, start_artifact
16
  from app.services.jobs import create_job, serialize_job
17
  from app.services.plans import effective_plan, plan_limit_error
@@ -25,6 +27,8 @@ from app.slides.build import stored as stored_deck
25
  from app.slides.build import view as slide_pages
26
  from app.slides.themes import theme_id as slide_theme_id
27
 
 
 
28
  router = APIRouter(prefix="/api/artifacts", tags=["artifacts"])
29
 
30
  ArtifactType = Literal["summary", "audio_overview", "video", "quote_card", "carousel", "launch_kit", "mind_map", "quiz",
@@ -69,7 +73,8 @@ class GenerateIn(BaseModel):
69
  question_types: list[Literal["multiple_choice", "multiple_select", "fill_blank", "short_answer"]] = Field(
70
  default_factory=lambda: ["multiple_choice", "multiple_select"], min_length=1)
71
  language: str | None = Field(None, max_length=40)
72
- theme: str | None = Field(None, max_length=30) # infographic: app.infographics.themes; slide_deck: app.slides.themes
 
73
  # slide_deck: read on its own ("detailed") or shown behind a speaker ("presenter"), and how many slides
74
  deck_format: Literal["detailed", "presenter"] = "detailed"
75
  deck_length: Literal["short", "default", "long"] = "default"
@@ -166,6 +171,7 @@ def generate(body: GenerateIn, ctx: Ctx = Depends(get_ctx)):
166
  if body.format == "debate" and body.hosts == 1:
167
  raise HTTPException(400, "A debate needs two hosts")
168
  check_limit(ctx.db, ctx.workspace, "audio_minutes", body.minutes)
 
169
  ids = body.document_ids or ([body.document_id] if body.document_id else [])
170
  hosts = _hosts(ctx, body)
171
  if body.hosts == 1:
@@ -197,7 +203,8 @@ def generate(body: GenerateIn, ctx: Ctx = Depends(get_ctx)):
197
  check_limit(ctx.db, ctx.workspace, "infographics", 1)
198
  ids = body.document_ids or ([body.document_id] if body.document_id else [])
199
  params = {"document_ids": [str(i) for i in ids], "chat_ids": [str(i) for i in body.chat_ids or []],
200
- "theme": theme_id(body.theme), "instructions": (body.instructions or "").strip()[:600]}
 
201
  title = f"Infographic: {params['instructions'][:60] or target}"
202
  elif body.type == "slide_deck":
203
  ids = body.document_ids or ([body.document_id] if body.document_id else [])
@@ -257,13 +264,116 @@ def list_artifacts(
257
  rows = ctx.db.scalars(query.order_by(Artifact.created_at.desc()).offset(max(offset, 0)).limit(min(limit, 200)))
258
  rows = list(rows.all())
259
  jobs = latest_jobs(ctx.db, [a.id for a in rows])
260
- return {"total": total, "items": [serialize_artifact(a, jobs.get(a.id)) for a in rows]}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
 
262
 
263
  @router.get("/{artifact_id}")
264
  def get_artifact(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
265
  a = get_artifact_or_404(ctx, artifact_id)
266
- return serialize_artifact(a, latest_jobs(ctx.db, [a.id]).get(a.id))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
 
268
 
269
  @router.get("/{artifact_id}/image")
 
1
+ import logging
2
  import re
3
  import uuid
4
  from typing import Literal
 
11
  from app.config import settings
12
  from app.infographics.image import ImageUnavailable, render_png
13
  from app.infographics.themes import theme_id
14
+ from app.models import Artifact, ArtifactFeedback, CalendarItem, Chat, Document, Job, Notebook, QuizAttempt, UserSavedVoice
15
  from app.routers.notebooks import ensure_archive_notebook
16
+ from app.services import artifact_feedback
17
  from app.services.artifacts import latest_jobs, serialize_artifact, start_artifact
18
  from app.services.jobs import create_job, serialize_job
19
  from app.services.plans import effective_plan, plan_limit_error
 
27
  from app.slides.build import view as slide_pages
28
  from app.slides.themes import theme_id as slide_theme_id
29
 
30
+ log = logging.getLogger(__name__)
31
+
32
  router = APIRouter(prefix="/api/artifacts", tags=["artifacts"])
33
 
34
  ArtifactType = Literal["summary", "audio_overview", "video", "quote_card", "carousel", "launch_kit", "mind_map", "quiz",
 
73
  question_types: list[Literal["multiple_choice", "multiple_select", "fill_blank", "short_answer"]] = Field(
74
  default_factory=lambda: ["multiple_choice", "multiple_select"], min_length=1)
75
  language: str | None = Field(None, max_length=40)
76
+ theme: str | None = Field(None, max_length=30) # infographic: one of the looks (app.infographics.design.LOOKS); slide_deck: app.slides.themes
77
+ ig_style: str | None = Field(None, max_length=20) # infographic: the layout to lean towards (design.STYLES), auto when empty
78
  # slide_deck: read on its own ("detailed") or shown behind a speaker ("presenter"), and how many slides
79
  deck_format: Literal["detailed", "presenter"] = "detailed"
80
  deck_length: Literal["short", "default", "long"] = "default"
 
171
  if body.format == "debate" and body.hosts == 1:
172
  raise HTTPException(400, "A debate needs two hosts")
173
  check_limit(ctx.db, ctx.workspace, "audio_minutes", body.minutes)
174
+ check_limit(ctx.db, ctx.workspace, "audio_overviews", 1)
175
  ids = body.document_ids or ([body.document_id] if body.document_id else [])
176
  hosts = _hosts(ctx, body)
177
  if body.hosts == 1:
 
203
  check_limit(ctx.db, ctx.workspace, "infographics", 1)
204
  ids = body.document_ids or ([body.document_id] if body.document_id else [])
205
  params = {"document_ids": [str(i) for i in ids], "chat_ids": [str(i) for i in body.chat_ids or []],
206
+ "theme": theme_id(body.theme), "style": body.ig_style or "auto",
207
+ "instructions": (body.instructions or "").strip()[:600]}
208
  title = f"Infographic: {params['instructions'][:60] or target}"
209
  elif body.type == "slide_deck":
210
  ids = body.document_ids or ([body.document_id] if body.document_id else [])
 
264
  rows = ctx.db.scalars(query.order_by(Artifact.created_at.desc()).offset(max(offset, 0)).limit(min(limit, 200)))
265
  rows = list(rows.all())
266
  jobs = latest_jobs(ctx.db, [a.id for a in rows])
267
+ items = [serialize_artifact(a, jobs.get(a.id)) for a in rows]
268
+ return {"total": total, "items": artifact_feedback.attach(ctx.db, ctx.user.id, items)}
269
+
270
+
271
+ @router.get("/feedback/export")
272
+ def export_artifact_feedback(ctx: Ctx = Depends(get_ctx), rating: Literal["up", "down"] | None = None, type: str | None = None,
273
+ limit: int = 200):
274
+ """The ratings of generated reports, quizzes, flashcards and infographics, newest first, with what each one was, to see
275
+ which kinds do well and which do not."""
276
+ q = select(ArtifactFeedback).where(ArtifactFeedback.workspace_id == ctx.workspace.id)
277
+ if rating:
278
+ q = q.where(ArtifactFeedback.rating == (1 if rating == "up" else -1))
279
+ if type:
280
+ q = q.where(ArtifactFeedback.artifact_type == type)
281
+ rows = ctx.db.scalars(q.order_by(ArtifactFeedback.updated_at.desc()).limit(min(limit, 1000))).all()
282
+ log.info("feedback exported: rows=%d rating=%s type=%s user=%s workspace=%s", len(rows), rating, type, ctx.user.id,
283
+ ctx.workspace.id)
284
+ return [{"id": str(f.id), "artifact_id": str(f.artifact_id) if f.artifact_id else None, "type": f.artifact_type,
285
+ "rating": "up" if f.rating > 0 else "down", "reasons": f.reasons, "comment": f.comment, "snapshot": f.snapshot,
286
+ "rated_at": f.updated_at.isoformat() if f.updated_at else None} for f in rows]
287
 
288
 
289
  @router.get("/{artifact_id}")
290
  def get_artifact(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
291
  a = get_artifact_or_404(ctx, artifact_id)
292
+ _offer_visuals_once(ctx, a)
293
+ return artifact_feedback.attach(ctx.db, ctx.user.id, [serialize_artifact(a, latest_jobs(ctx.db, [a.id]).get(a.id))])[0]
294
+
295
+
296
+ def _offer_visuals_once(ctx: Ctx, a: Artifact) -> None:
297
+ """An interactive report made before interactive reports always offered visuals gets them the first time it is opened (once: after
298
+ that, the ones the reader has added or taken away stay as they are). A document report never offers any."""
299
+ c = a.content_json or {}
300
+ if a.type != "report" or a.status != "ready" or c.get("format") != "interactive" or c.get("suggestions_offered") \
301
+ or not c.get("blocks"):
302
+ return
303
+ from app.pipeline.report import EMBED_KINDS, fill_suggestions, merge_suggestions
304
+
305
+ has_posts = bool((c.get("source") or {}).get("document_ids"))
306
+ kinds = [k for k in EMBED_KINDS if k != "mind_map" or has_posts]
307
+ a.content_json = {**c, "suggestions": merge_suggestions(fill_suggestions(list(c.get("suggestions") or []), c["blocks"], kinds)),
308
+ "suggestions_offered": True}
309
+ ctx.db.commit()
310
+
311
+
312
+ class FeedbackIn(BaseModel):
313
+ rating: Literal["up", "down"] | None # null takes the rating back
314
+ reasons: list[str] = []
315
+ comment: str | None = Field(None, max_length=1000)
316
+
317
+
318
+ @router.put("/{artifact_id}/feedback")
319
+ def rate_artifact(artifact_id: uuid.UUID, body: FeedbackIn, ctx: Ctx = Depends(get_ctx)):
320
+ """Thumbs up or down on a finished report, quiz, flashcard set or infographic. Rating again replaces it; null removes it."""
321
+ a = get_artifact_or_404(ctx, artifact_id)
322
+ allowed = artifact_feedback.REASONS.get(a.type)
323
+ if allowed is None or a.status != "ready":
324
+ log.warning("feedback refused: artifact=%s type=%s status=%s user=%s", a.id, a.type, a.status, ctx.user.id)
325
+ raise HTTPException(400, "Only a finished report, quiz, flashcard set or infographic can be rated")
326
+ row = ctx.db.scalar(select(ArtifactFeedback).where(ArtifactFeedback.artifact_id == a.id, ArtifactFeedback.user_id == ctx.user.id))
327
+ if body.rating is None:
328
+ if row:
329
+ ctx.db.delete(row)
330
+ ctx.db.commit()
331
+ log.info("feedback removed: artifact=%s type=%s user=%s workspace=%s", a.id, a.type, ctx.user.id, ctx.workspace.id)
332
+ return {"feedback": None}
333
+ if not row:
334
+ row = ArtifactFeedback(workspace_id=ctx.workspace.id, artifact_id=a.id, user_id=ctx.user.id)
335
+ ctx.db.add(row)
336
+ row.artifact_type = a.type
337
+ row.rating = 1 if body.rating == "up" else -1
338
+ row.reasons = [r for r in dict.fromkeys(body.reasons) if r in allowed] if body.rating == "down" else []
339
+ row.comment = (body.comment or "").strip() or None
340
+ row.snapshot = artifact_feedback.snapshot(a)
341
+ ctx.db.commit()
342
+ # The comment's text stays out of the logs (it is the user's own words); its length and the reasons are enough.
343
+ log.info("feedback saved: artifact=%s type=%s rating=%s reasons=%s comment_chars=%d user=%s workspace=%s",
344
+ a.id, a.type, body.rating, row.reasons, len(row.comment or ""), ctx.user.id, ctx.workspace.id)
345
+ return {"feedback": artifact_feedback.feedback_out(row)}
346
+
347
+
348
+ class AttemptAnswer(BaseModel):
349
+ choice: list[int] = Field(default_factory=list, max_length=20)
350
+ text: str = Field("", max_length=2000)
351
+ correct: bool | None = None
352
+
353
+
354
+ class AttemptIn(BaseModel):
355
+ answers: list[AttemptAnswer] = Field(max_length=200)
356
+ score: int = Field(ge=0)
357
+
358
+
359
+ @router.put("/{artifact_id}/attempt")
360
+ def save_quiz_attempt(artifact_id: uuid.UUID, body: AttemptIn, ctx: Ctx = Depends(get_ctx)):
361
+ """The person's finished run of a quiz, kept so the result shows when they open it again. Finishing again replaces it."""
362
+ a = get_artifact_or_404(ctx, artifact_id)
363
+ if a.type != "quiz" or a.status != "ready":
364
+ raise HTTPException(400, "Only a finished quiz keeps a result")
365
+ total = len((a.content_json or {}).get("questions") or [])
366
+ if len(body.answers) != total:
367
+ raise HTTPException(400, "The answers do not match the quiz")
368
+ row = ctx.db.scalar(select(QuizAttempt).where(QuizAttempt.artifact_id == a.id, QuizAttempt.user_id == ctx.user.id))
369
+ if not row:
370
+ row = QuizAttempt(workspace_id=ctx.workspace.id, artifact_id=a.id, user_id=ctx.user.id)
371
+ ctx.db.add(row)
372
+ row.total = total
373
+ row.score = min(body.score, total)
374
+ row.answers = [x.model_dump() for x in body.answers]
375
+ ctx.db.commit()
376
+ return {"attempt": artifact_feedback.attempt_out(row)}
377
 
378
 
379
  @router.get("/{artifact_id}/image")
backend/app/routers/notebooks.py CHANGED
@@ -36,6 +36,7 @@ from app.pipeline.research import Turn, research
36
  from app.pipeline.trace import ChatTrace, use_trace
37
  from app.routers.sources import serialize_document
38
  from app.services.artifacts import latest_jobs, serialize_artifact
 
39
  from app.services.jobs import create_job, record_usage
40
  from app.services.memory import list_facts, profile_text
41
  from app.services.storage import storage
@@ -86,6 +87,8 @@ def _add_docs(ctx: Ctx, nb: Notebook, ids: list[uuid.UUID]) -> int:
86
  fresh = [d for d in ctx.db.scalars(select(Document).where(Document.id.in_(valid - existing))) if not ideas_fresh(d)]
87
  if fresh and settings.llm_api_key:
88
  create_job(ctx.db, ctx.workspace.id, "ideas", {"document_ids": [str(d.id) for d in fresh]}, max_attempts=2)
 
 
89
  return len(valid - existing)
90
 
91
 
@@ -254,7 +257,9 @@ def notebook_artifacts(notebook_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
254
  select(Artifact).where(Artifact.notebook_id == nb.id).order_by(Artifact.created_at.desc())
255
  ).all())
256
  jobs = latest_jobs(ctx.db, [a.id for a in rows])
257
- return [serialize_artifact(a, jobs.get(a.id)) for a in rows]
 
 
258
 
259
 
260
  @router.get("/{notebook_id}/chats")
 
36
  from app.pipeline.trace import ChatTrace, use_trace
37
  from app.routers.sources import serialize_document
38
  from app.services.artifacts import latest_jobs, serialize_artifact
39
+ from app.services import report_suggestions
40
  from app.services.jobs import create_job, record_usage
41
  from app.services.memory import list_facts, profile_text
42
  from app.services.storage import storage
 
87
  fresh = [d for d in ctx.db.scalars(select(Document).where(Document.id.in_(valid - existing))) if not ideas_fresh(d)]
88
  if fresh and settings.llm_api_key:
89
  create_job(ctx.db, ctx.workspace.id, "ideas", {"document_ids": [str(d.id) for d in fresh]}, max_attempts=2)
90
+ if valid - existing:
91
+ report_suggestions.queue_warmup(ctx.db, ctx.workspace.id) # the Create report dialog's templates, ready before it is opened
92
  return len(valid - existing)
93
 
94
 
 
257
  select(Artifact).where(Artifact.notebook_id == nb.id).order_by(Artifact.created_at.desc())
258
  ).all())
259
  jobs = latest_jobs(ctx.db, [a.id for a in rows])
260
+ from app.services import artifact_feedback # the ratings ride along on each artifact the panel lists
261
+
262
+ return artifact_feedback.attach(ctx.db, ctx.user.id, [serialize_artifact(a, jobs.get(a.id)) for a in rows])
263
 
264
 
265
  @router.get("/{notebook_id}/chats")
backend/app/routers/reports.py CHANGED
@@ -7,12 +7,11 @@ from sqlalchemy import select
7
 
8
  from app.auth import Ctx, get_ctx
9
  from app.llm import run
10
- from app.llm.provider import fast_lm
11
- from app.llm.signatures import SuggestReportTemplates
12
  from app.models import Artifact
13
- from app.pipeline.report import EMBED_KINDS, remove_block
14
  from app.routers.artifacts import get_artifact_or_404
15
  from app.routers.videos import FocusIn, _focus_material
 
16
  from app.services.artifacts import latest_jobs, serialize_artifact
17
  from app.services.jobs import create_job
18
  from app.services.report_templates import INTERACTIVE, TEMPLATES
@@ -20,7 +19,6 @@ from app.services.usage import check_limit
20
 
21
  router = APIRouter(prefix="/api/reports", tags=["reports"])
22
 
23
- SUGGEST_ITEMS = 5 # sources the AI reads to suggest templates
24
 
25
 
26
  @router.get("/templates")
@@ -35,25 +33,15 @@ class SuggestIn(FocusIn):
35
 
36
  @router.post("/suggest")
37
  def suggest_templates(body: SuggestIn, ctx: Ctx = Depends(get_ctx)):
38
- """Four kinds of report these sources could make, each with its own ready-written instructions. Never an error:
39
- when the AI has nothing usable the list is empty and the dialog shows only the fixed templates."""
40
- material, _docs = _focus_material(ctx, body)
 
 
41
  if not material:
42
  return {"templates": []}
43
- try:
44
- out = run.predict(SuggestReportTemplates, db=ctx.db, workspace_id=ctx.workspace.id, lm=fast_lm(),
45
- items=list(material.values())[:SUGGEST_ITEMS], topic=(body.topic or "").strip() or "(none)")
46
- except Exception:
47
- return {"templates": []}
48
- seen, found = set(), []
49
- for t in out.get("templates") or []:
50
- name, prompt = (t.get("name") or "").strip(), (t.get("prompt") or "").strip()
51
- if not name or not prompt or name.lower() in seen:
52
- continue
53
- seen.add(name.lower())
54
- found.append({"id": f"suggested-{len(found) + 1}", "name": name[:60],
55
- "description": (t.get("description") or "").strip()[:200], "prompt": prompt[:1500]})
56
- return {"templates": found[:4]}
57
 
58
 
59
  class AddBlockIn(BaseModel):
@@ -105,9 +93,11 @@ def add_block(artifact_id: uuid.UUID, body: AddBlockIn, ctx: Ctx = Depends(get_c
105
  @router.delete("/{artifact_id}/blocks/{block_id}")
106
  def delete_block(artifact_id: uuid.UUID, block_id: str, ctx: Ctx = Depends(get_ctx)):
107
  a = _report_or_404(ctx, artifact_id)
 
108
  content = remove_block(a.content_json or {}, block_id)
109
  if content is None:
110
  raise HTTPException(404, "Visual not found")
 
111
  a.content_json = content
112
  ctx.db.commit()
113
  return serialize_artifact(a, latest_jobs(ctx.db, [a.id]).get(a.id))
 
7
 
8
  from app.auth import Ctx, get_ctx
9
  from app.llm import run
 
 
10
  from app.models import Artifact
11
+ from app.pipeline.report import EMBED_KINDS, keep_visual, remove_block
12
  from app.routers.artifacts import get_artifact_or_404
13
  from app.routers.videos import FocusIn, _focus_material
14
+ from app.services import report_suggestions
15
  from app.services.artifacts import latest_jobs, serialize_artifact
16
  from app.services.jobs import create_job
17
  from app.services.report_templates import INTERACTIVE, TEMPLATES
 
19
 
20
  router = APIRouter(prefix="/api/reports", tags=["reports"])
21
 
 
22
 
23
 
24
  @router.get("/templates")
 
33
 
34
  @router.post("/suggest")
35
  def suggest_templates(body: SuggestIn, ctx: Ctx = Depends(get_ctx)):
36
+ """Four kinds of report these sources could make, each with its own ready-written instructions. The AI reads each post's stored
37
+ ideas and topics (not its text); the answer is kept, so the same sources get it again at once (and it is prepared in the
38
+ background as soon as ideas are extracted). Never an error: when there is nothing usable the list is empty and the dialog shows
39
+ only the fixed templates."""
40
+ material, docs = _focus_material(ctx, body)
41
  if not material:
42
  return {"templates": []}
43
+ items = report_suggestions.suggestion_items(material, docs)
44
+ return {"templates": report_suggestions.suggest(ctx.db, ctx.workspace.id, items, (body.topic or "").strip())}
 
 
 
 
 
 
 
 
 
 
 
 
45
 
46
 
47
  class AddBlockIn(BaseModel):
 
93
  @router.delete("/{artifact_id}/blocks/{block_id}")
94
  def delete_block(artifact_id: uuid.UUID, block_id: str, ctx: Ctx = Depends(get_ctx)):
95
  a = _report_or_404(ctx, artifact_id)
96
+ removed = next((b for b in (a.content_json or {}).get("blocks") or [] if b.get("id") == block_id), None)
97
  content = remove_block(a.content_json or {}, block_id)
98
  if content is None:
99
  raise HTTPException(404, "Visual not found")
100
+ keep_visual(ctx.db, a, removed)
101
  a.content_json = content
102
  ctx.db.commit()
103
  return serialize_artifact(a, latest_jobs(ctx.db, [a.id]).get(a.id))
backend/app/routers/settings.py CHANGED
@@ -1,14 +1,9 @@
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"])
@@ -17,24 +12,18 @@ router = APIRouter(prefix="/api/settings", tags=["settings"])
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
  allow_public_links: bool | None = None
25
  email_unsubscribed: bool | None = None
26
 
27
 
28
  def _serialize(ctx: Ctx) -> dict:
29
- brand = ctx.workspace.brand_json or {}
30
  return {
31
  "user": {"name": ctx.user.name, "email": ctx.user.email, "auth_provider": ctx.user.auth_provider.value,
32
  "email_unsubscribed": ctx.user.email_unsubscribed},
33
  "workspace": {"id": str(ctx.workspace.id), "name": ctx.workspace.name,
34
  "training_opt_in": ctx.workspace.training_opt_in,
35
  "allow_public_links": ctx.workspace.allow_public_links},
36
- "brand": {"name": brand.get("name"), "accent": brand.get("accent") or "#217cff",
37
- "logo_url": storage.presign_get(brand["logo_key"]) if brand.get("logo_key") else None},
38
  "plan": plan_dict(effective_plan(ctx.db, ctx.workspace)),
39
  "billing_enabled": app_settings.billing_enabled,
40
  "usage": usage_report(ctx.db, ctx.workspace),
@@ -45,6 +34,15 @@ def _serialize(ctx: Ctx) -> dict:
45
  }
46
 
47
 
 
 
 
 
 
 
 
 
 
48
  @router.get("")
49
  def get_settings(ctx: Ctx = Depends(get_ctx)):
50
  return _serialize(ctx)
@@ -62,23 +60,6 @@ def update_settings(body: SettingsIn, ctx: Ctx = Depends(get_ctx)):
62
  ctx.workspace.allow_public_links = body.allow_public_links
63
  if body.email_unsubscribed is not None:
64
  ctx.user.email_unsubscribed = body.email_unsubscribed
65
- brand = dict(ctx.workspace.brand_json or {})
66
- if body.brand_name is not None:
67
- brand["name"] = body.brand_name.strip() or None
68
- if body.brand_accent is not None:
69
- if not re.fullmatch(r"#[0-9a-fA-F]{6}", body.brand_accent):
70
- raise HTTPException(422, "Use a hex color like #217cff")
71
- brand["accent"] = body.brand_accent.lower()
72
- if body.logo_upload_id is not None:
73
- if body.logo_upload_id == "":
74
- brand.pop("logo_key", None)
75
- else:
76
- upload = ctx.db.scalar(select(Upload).where(Upload.id == body.logo_upload_id,
77
- Upload.workspace_id == ctx.workspace.id))
78
- if not upload or upload.status != "complete" or not upload.content_type.startswith("image/"):
79
- raise HTTPException(400, "Upload an image first")
80
- brand["logo_key"] = upload.key
81
- ctx.workspace.brand_json = brand
82
  ctx.db.commit()
83
  return _serialize(ctx)
84
 
 
1
+ from fastapi import APIRouter, Depends
 
 
2
  from pydantic import BaseModel, Field
 
3
 
4
  from app.auth import Ctx, get_ctx
5
  from app.config import settings as app_settings
 
6
  from app.services.plans import effective_plan, plan_dict
 
7
  from app.services.usage import usage_report
8
 
9
  router = APIRouter(prefix="/api/settings", tags=["settings"])
 
12
  class SettingsIn(BaseModel):
13
  name: str | None = Field(None, max_length=200)
14
  workspace_name: str | None = Field(None, min_length=1, max_length=200)
 
 
 
15
  training_opt_in: bool | None = None
16
  allow_public_links: bool | None = None
17
  email_unsubscribed: bool | None = None
18
 
19
 
20
  def _serialize(ctx: Ctx) -> dict:
 
21
  return {
22
  "user": {"name": ctx.user.name, "email": ctx.user.email, "auth_provider": ctx.user.auth_provider.value,
23
  "email_unsubscribed": ctx.user.email_unsubscribed},
24
  "workspace": {"id": str(ctx.workspace.id), "name": ctx.workspace.name,
25
  "training_opt_in": ctx.workspace.training_opt_in,
26
  "allow_public_links": ctx.workspace.allow_public_links},
 
 
27
  "plan": plan_dict(effective_plan(ctx.db, ctx.workspace)),
28
  "billing_enabled": app_settings.billing_enabled,
29
  "usage": usage_report(ctx.db, ctx.workspace),
 
34
  }
35
 
36
 
37
+ @router.get("/limits")
38
+ def artifact_limits(_ctx: Ctx = Depends(get_ctx)):
39
+ """How many posts a report or infographic reads, and a dialog ticks to start with (ARTIFACT_MAX_POSTS), so the pickers match the
40
+ server. A quiz or flashcard set can read as many posts as the plan indexes (selectable_posts)."""
41
+ from app.pipeline.material import max_posts
42
+
43
+ return {"posts": max_posts(), "selectable_posts": effective_plan(_ctx.db, _ctx.workspace).indexed_posts}
44
+
45
+
46
  @router.get("")
47
  def get_settings(ctx: Ctx = Depends(get_ctx)):
48
  return _serialize(ctx)
 
60
  ctx.workspace.allow_public_links = body.allow_public_links
61
  if body.email_unsubscribed is not None:
62
  ctx.user.email_unsubscribed = body.email_unsubscribed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  ctx.db.commit()
64
  return _serialize(ctx)
65
 
backend/app/routers/sources.py CHANGED
@@ -10,7 +10,7 @@ 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, plan_limit_error
14
  from app.services.storage import storage
15
 
16
  router = APIRouter(prefix="/api/sources", tags=["sources"])
@@ -74,7 +74,7 @@ async def create_source(body: SourceIn, ctx: Ctx = Depends(get_ctx)):
74
  select(func.count()).select_from(Source)
75
  .where(Source.workspace_id == ctx.workspace.id, Source.feed_url != IMPORTS_FEED)
76
  )
77
- if count >= plan.sources:
78
  raise plan_limit_error(plan, "sources", f"Your plan includes {plan.sources} sources.")
79
  source = Source(workspace_id=ctx.workspace.id, feed_url=found.feed_url, platform=found.platform,
80
  site_url=found.site_url, title=found.title)
@@ -135,8 +135,17 @@ def delete_source(source_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
135
  return {"ok": True, "removed_posts": len(docs)}
136
 
137
 
 
 
 
 
 
 
 
 
138
  @router.post("/url")
139
  def import_url(body: SourceIn, ctx: Ctx = Depends(get_ctx)):
 
140
  source = imports_source(ctx.db, ctx.workspace.id)
141
  ctx.db.commit()
142
  job = create_job(ctx.db, ctx.workspace.id, "import_url", {"source_id": str(source.id), "url": body.url})
@@ -148,6 +157,7 @@ def import_upload(body: UploadImportIn, ctx: Ctx = Depends(get_ctx)):
148
  upload = ctx.db.scalar(select(Upload).where(Upload.id == body.upload_id, Upload.workspace_id == ctx.workspace.id))
149
  if not upload or upload.status != "complete":
150
  raise HTTPException(404, "Upload not found or not finished")
 
151
  source = imports_source(ctx.db, ctx.workspace.id)
152
  ctx.db.commit()
153
  job = create_job(ctx.db, ctx.workspace.id, "import_upload",
 
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, plan_limit_error, post_room
14
  from app.services.storage import storage
15
 
16
  router = APIRouter(prefix="/api/sources", tags=["sources"])
 
74
  select(func.count()).select_from(Source)
75
  .where(Source.workspace_id == ctx.workspace.id, Source.feed_url != IMPORTS_FEED)
76
  )
77
+ if 0 <= plan.sources <= count:
78
  raise plan_limit_error(plan, "sources", f"Your plan includes {plan.sources} sources.")
79
  source = Source(workspace_id=ctx.workspace.id, feed_url=found.feed_url, platform=found.platform,
80
  site_url=found.site_url, title=found.title)
 
135
  return {"ok": True, "removed_posts": len(docs)}
136
 
137
 
138
+ def _require_room(ctx: Ctx) -> None:
139
+ """A link or a file adds a post: refused (with the upgrade prompt) once the plan's posts are all indexed."""
140
+ plan = effective_plan(ctx.db, ctx.workspace)
141
+ if post_room(ctx.db, ctx.workspace.id, plan) <= 0:
142
+ raise plan_limit_error(plan, "indexed_posts", f"Your plan indexes up to {plan.indexed_posts} posts, and they are all "
143
+ "used. Remove a post or upgrade to add more.")
144
+
145
+
146
  @router.post("/url")
147
  def import_url(body: SourceIn, ctx: Ctx = Depends(get_ctx)):
148
+ _require_room(ctx)
149
  source = imports_source(ctx.db, ctx.workspace.id)
150
  ctx.db.commit()
151
  job = create_job(ctx.db, ctx.workspace.id, "import_url", {"source_id": str(source.id), "url": body.url})
 
157
  upload = ctx.db.scalar(select(Upload).where(Upload.id == body.upload_id, Upload.workspace_id == ctx.workspace.id))
158
  if not upload or upload.status != "complete":
159
  raise HTTPException(404, "Upload not found or not finished")
160
+ _require_room(ctx)
161
  source = imports_source(ctx.db, ctx.workspace.id)
162
  ctx.db.commit()
163
  job = create_job(ctx.db, ctx.workspace.id, "import_upload",
backend/app/routers/video_edit.py CHANGED
@@ -4,7 +4,7 @@
4
 
5
  Only paths in RULES pass (anything else is 404). Before forwarding, the video must be this workspace's
6
  (b2v_access.video_or_404), every template / voice / style id in a JSON body must be this workspace's (check_refs),
7
- premium (★) features need a paid plan, and AI edits come out of the workspace's own counter (given back if
8
  blog2video refuses). A template switch also uses one video, as it does on blog2video.
9
 
10
  JSON bodies, multipart uploads (images, voiceovers, portraits) and binary answers (downloads, stills) pass through.
@@ -35,8 +35,6 @@ S = r"\d+" # a scene id
35
  @dataclass(frozen=True)
36
  class Rule:
37
  ai_edits: int = 0 # AI edits this call uses (per workspace, video_limits "ai_edits")
38
- per_scene: bool = False # ai_edits is per scene_ids entry (avatar batches)
39
- premium: str | None = None # ★: what to call it in the upgrade message
40
  uses_video: bool = False # a template switch: one video from the allowance
41
  raw: bool = False # binary answer (download, still)
42
  timeout: float = 30
@@ -94,29 +92,12 @@ RULES: list[tuple[str, re.Pattern, Rule]] = [(m, re.compile(f"^{p}$"), r) for m,
94
  ("GET", "regenerate-script/preview", Rule()),
95
  ("POST", "regenerate-script/verify", Rule()),
96
  ("POST", "regenerate-script/regenerate", Rule()),
97
- # AI chat editing ★
98
- ("POST", "chat", Rule(ai_edits=1, premium="AI chat editing", timeout=120)),
99
- ("GET", "chat/history", Rule(premium="AI chat editing")),
100
- # Avatars ★ (blog2video charges the whole batch at authorize: 10 AI edits per scene)
101
- ("POST", "avatar-batch/authorize", Rule(ai_edits=10, per_scene=True, premium="Avatars")),
102
- ("POST", rf"scenes/{S}/avatar", Rule(premium="Avatars")),
103
- ("GET", rf"scenes/{S}/avatar-status", Rule()),
104
- ("DELETE", rf"scenes/{S}/avatar", Rule()),
105
- ("POST", rf"scenes/{S}/avatar-matte", Rule(premium="Avatars")),
106
- ("POST", "avatar-matte-all", Rule(premium="Avatars")),
107
- ("POST", "avatar-retry-failed", Rule(premium="Avatars")),
108
- ("GET", "avatar-progress", Rule()),
109
- ("POST", "avatar-portrait", Rule(premium="Avatars", timeout=60)),
110
- ("DELETE", "avatar-portrait", Rule()),
111
- ("PATCH", rf"scenes/{S}/avatar-focus", Rule()),
112
- ("PATCH", rf"scenes/{S}/avatar-appearance", Rule()),
113
  # Render, download, frames
114
  ("POST", "render", Rule(status="rendering")),
115
  ("GET", "render-status", Rule()),
116
  ("POST", "cancel-render", Rule()),
117
  ("GET", "download-url", Rule()),
118
  ("GET", "download", Rule(raw=True, timeout=120)),
119
- ("GET", "download-studio", Rule(raw=True, premium="The Remotion source download", timeout=120)),
120
  ("GET", "render-still", Rule(raw=True, timeout=60)),
121
  ("POST", "render-stills", Rule(timeout=120)),
122
  ]]
@@ -157,19 +138,12 @@ async def _read_body(request: Request) -> tuple[dict | None, dict | None, list |
157
 
158
 
159
  def _charge(ctx: Ctx, rule: Rule, body: dict | None, form: dict | None) -> int:
160
- """Premium, references and counters, all before blog2video is called. Returns AI edits taken."""
161
  plan = effective_plan(ctx.db, ctx.workspace)
162
- if rule.premium:
163
- video_limits.require_premium(ctx.db, ctx.workspace, rule.premium, plan)
164
  refs = body if isinstance(body, dict) else (form or {})
165
  with upstream(): # checking a template or voice may need blog2video's catalog
166
  b2v_access.check_refs(ctx, refs, plan)
167
  edits = rule.ai_edits
168
- if rule.per_scene:
169
- scenes = (body or {}).get("scene_ids") if isinstance(body, dict) else None
170
- if not isinstance(scenes, list) or not scenes:
171
- raise HTTPException(400, "Pick at least one scene")
172
- edits *= len(scenes)
173
  if edits:
174
  video_limits.take(ctx.db, ctx.workspace, "ai_edits", edits, plan=plan)
175
  if rule.uses_video and not video_quota.reserve(ctx.db, ctx.workspace.id):
 
4
 
5
  Only paths in RULES pass (anything else is 404). Before forwarding, the video must be this workspace's
6
  (b2v_access.video_or_404), every template / voice / style id in a JSON body must be this workspace's (check_refs),
7
+ AI edits come out of the workspace's own counter (given back if
8
  blog2video refuses). A template switch also uses one video, as it does on blog2video.
9
 
10
  JSON bodies, multipart uploads (images, voiceovers, portraits) and binary answers (downloads, stills) pass through.
 
35
  @dataclass(frozen=True)
36
  class Rule:
37
  ai_edits: int = 0 # AI edits this call uses (per workspace, video_limits "ai_edits")
 
 
38
  uses_video: bool = False # a template switch: one video from the allowance
39
  raw: bool = False # binary answer (download, still)
40
  timeout: float = 30
 
92
  ("GET", "regenerate-script/preview", Rule()),
93
  ("POST", "regenerate-script/verify", Rule()),
94
  ("POST", "regenerate-script/regenerate", Rule()),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  # Render, download, frames
96
  ("POST", "render", Rule(status="rendering")),
97
  ("GET", "render-status", Rule()),
98
  ("POST", "cancel-render", Rule()),
99
  ("GET", "download-url", Rule()),
100
  ("GET", "download", Rule(raw=True, timeout=120)),
 
101
  ("GET", "render-still", Rule(raw=True, timeout=60)),
102
  ("POST", "render-stills", Rule(timeout=120)),
103
  ]]
 
138
 
139
 
140
  def _charge(ctx: Ctx, rule: Rule, body: dict | None, form: dict | None) -> int:
141
+ """References and counters, all before blog2video is called. Returns AI edits taken."""
142
  plan = effective_plan(ctx.db, ctx.workspace)
 
 
143
  refs = body if isinstance(body, dict) else (form or {})
144
  with upstream(): # checking a template or voice may need blog2video's catalog
145
  b2v_access.check_refs(ctx, refs, plan)
146
  edits = rule.ai_edits
 
 
 
 
 
147
  if edits:
148
  video_limits.take(ctx.db, ctx.workspace, "ai_edits", edits, plan=plan)
149
  if rule.uses_video and not video_quota.reserve(ctx.db, ctx.workspace.id):
backend/app/routers/video_templates.py CHANGED
@@ -179,7 +179,6 @@ def delete_template(template_id: int, ctx: Ctx = Depends(get_ctx), _: None = Dep
179
 
180
  @dataclass(frozen=True)
181
  class Rule:
182
- ai_edit: bool = False # ★ one AI edit
183
  metric: str | None = None # another counter this uses (a lifetime slot, a daily AI step)
184
  raw: bool = False
185
 
@@ -190,8 +189,6 @@ RULES: list[tuple[str, re.Pattern, Rule]] = [(m, re.compile(f"^{p}$"), r) for m,
190
  ("PUT", "", Rule()),
191
  ("GET", "code", Rule()),
192
  ("POST", "upload-logo", Rule()),
193
- ("POST", f"scenes/{K}/ai-edit", Rule(ai_edit=True)),
194
- ("GET", f"scenes/{K}/ai-edit/status", Rule()),
195
  ("GET", "scene-drafts", Rule()),
196
  ("GET", f"scenes/{K}/draft", Rule()),
197
  ("POST", f"scenes/{K}/draft/apply", Rule()),
@@ -214,10 +211,6 @@ def _rule(method: str, path: str) -> Rule | None:
214
  def _take(ctx: Ctx, rule: Rule) -> list[str]:
215
  taken: list[str] = []
216
  try:
217
- if rule.ai_edit:
218
- plan = video_limits.require_premium(ctx.db, ctx.workspace, "AI template editing")
219
- video_limits.take(ctx.db, ctx.workspace, "ai_edits", plan=plan)
220
- taken.append("ai_edits")
221
  if rule.metric:
222
  video_limits.take(ctx.db, ctx.workspace, rule.metric)
223
  taken.append(rule.metric)
 
179
 
180
  @dataclass(frozen=True)
181
  class Rule:
 
182
  metric: str | None = None # another counter this uses (a lifetime slot, a daily AI step)
183
  raw: bool = False
184
 
 
189
  ("PUT", "", Rule()),
190
  ("GET", "code", Rule()),
191
  ("POST", "upload-logo", Rule()),
 
 
192
  ("GET", "scene-drafts", Rule()),
193
  ("GET", f"scenes/{K}/draft", Rule()),
194
  ("POST", f"scenes/{K}/draft/apply", Rule()),
 
211
  def _take(ctx: Ctx, rule: Rule) -> list[str]:
212
  taken: list[str] = []
213
  try:
 
 
 
 
214
  if rule.metric:
215
  video_limits.take(ctx.db, ctx.workspace, rule.metric)
216
  taken.append(rule.metric)
backend/app/routers/video_voices.py CHANGED
@@ -1,24 +1,19 @@
1
- """Voices for videos: the workspace's saved voices, the built-in library, custom voices (design, clone), and its
2
- Notestack voices (made on the Voice page; blog2video speaks with the same ElevenLabs account).
3
 
4
  "Your voices" (user_saved_voices, as many as wanted) is the one list for audio overviews and videos, and lives only
5
  here: blog2video's saved-voice list is one list for the whole account.
6
- Custom voices are made on our blog2video account, so b2v_custom_voices records which workspace made each one;
7
- only that workspace can use, play or delete it. Designing, keeping, cloning and samples are premium (★) and come out
8
- of the workspace's own daily and total counters (video_limits).
9
  """
10
 
11
  import logging
12
- from typing import Literal
13
 
14
- from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile
15
  from pydantic import BaseModel, Field
16
  from sqlalchemy import select
17
 
18
  from app.auth import Ctx, get_ctx
19
- from app.models import B2VCustomVoice, UserSavedVoice
20
  from app.routers.videos import b2v_ready, upstream
21
- from app.services import b2v_access, video_limits
22
  from app.services import blog2video as b2v
23
  from app.services.notestack_voices import notestack_voices, save_notestack_voice
24
 
@@ -26,7 +21,6 @@ log = logging.getLogger(__name__)
26
 
27
  router = APIRouter(prefix="/api/video-voices", tags=["videos"])
28
 
29
- CLONE_MAX_BYTES = 50 * 1024 * 1024
30
  STARTER_VOICES = 4 # a new workspace starts with this many free built-in voices saved
31
 
32
 
@@ -36,7 +30,7 @@ def _labels(v: dict) -> dict:
36
 
37
  def _voice_out(v: UserSavedVoice, ours: set[str] = frozenset()) -> dict:
38
  return {"voice_id": v.voice_id, "name": v.name, "preview_url": v.preview_url, "gender": v.gender,
39
- "accent": v.accent, "premium": v.premium, "is_custom": v.is_custom,
40
  "source": "notestack" if v.voice_id in ours else "blog2video"}
41
 
42
 
@@ -45,7 +39,7 @@ def _library_out(v: dict, saved: set[str]) -> dict:
45
  return {"voice_id": v["voice_id"], "name": (v.get("name") or "Voice").split(" - ")[0].strip(),
46
  "description": v.get("description") or labels.get("description") or "", "preview_url": v.get("preview_url"),
47
  "gender": labels.get("gender"), "accent": labels.get("accent"), "age": labels.get("age"),
48
- "premium": v.get("plan") == "paid", "saved": v["voice_id"] in saved}
49
 
50
 
51
  def _save_builtin(ctx: Ctx, v: dict) -> UserSavedVoice:
@@ -53,37 +47,36 @@ def _save_builtin(ctx: Ctx, v: dict) -> UserSavedVoice:
53
  row = UserSavedVoice(workspace_id=ctx.workspace.id, voice_id=v["voice_id"],
54
  name=(v.get("name") or "Voice").split(" - ")[0].strip()[:255],
55
  preview_url=v.get("preview_url"),
56
- gender=labels.get("gender"), accent=labels.get("accent"), premium=v.get("plan") == "paid")
57
  ctx.db.merge(row)
58
  return row
59
 
60
 
61
- def _saved(ctx: Ctx) -> list[UserSavedVoice]:
62
- return list(ctx.db.scalars(select(UserSavedVoice).where(UserSavedVoice.workspace_id == ctx.workspace.id)
63
- .order_by(UserSavedVoice.created_at)))
 
 
 
64
 
65
 
66
  @router.get("")
67
  def voices(ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
68
- """Your voices (audio overviews and videos), the built-in library (to save from) and my custom voices."""
69
  with upstream():
70
- library = b2v.prebuilt_voices()
71
- saved = _saved(ctx)
 
 
72
  if not saved:
73
- for v in [v for v in library if v.get("plan") != "paid" and v.get("voice_id")][:STARTER_VOICES]:
74
  _save_builtin(ctx, v)
75
  ctx.db.commit()
76
- saved = _saved(ctx)
77
  ids = {v.voice_id for v in saved}
78
- custom = ctx.db.scalars(select(B2VCustomVoice).where(B2VCustomVoice.workspace_id == ctx.workspace.id)
79
- .order_by(B2VCustomVoice.created_at.desc()))
80
- mine = notestack_voices(ctx.db, ctx.workspace.id)
81
- ours = {v["voice_id"] for v in mine}
82
  return {"saved": [_voice_out(v, ours) for v in saved],
83
  "notestack": [{**v, "saved": v["voice_id"] in ids} for v in mine],
84
- "library": [_library_out(v, ids) for v in library if v.get("voice_id")],
85
- "custom": [{"id": c.b2v_custom_voice_id, "voice_id": c.voice_id, "name": c.name, "source": c.source,
86
- "preview_url": c.preview_url, "saved": c.voice_id in ids} for c in custom]}
87
 
88
 
89
  class SaveIn(BaseModel):
@@ -94,25 +87,17 @@ class SaveIn(BaseModel):
94
  def save_voice(body: SaveIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
95
  if ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": body.voice_id}):
96
  return _voice_out(ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": body.voice_id}))
97
- custom = ctx.db.scalar(select(B2VCustomVoice).where(B2VCustomVoice.voice_id == body.voice_id,
98
- B2VCustomVoice.workspace_id == ctx.workspace.id))
99
  mine = next((v for v in notestack_voices(ctx.db, ctx.workspace.id) if v["voice_id"] == body.voice_id), None)
100
  if mine:
101
- # A Notestack voice: saved on any plan, premium to use in a video. No preview link (the Voice page plays it).
102
  row = save_notestack_voice(ctx.db, ctx.workspace.id, mine["voice_id"], mine["name"])
103
  ctx.db.commit()
104
  return _voice_out(row, {row.voice_id})
105
- if custom:
106
- row = ctx.db.merge(UserSavedVoice(workspace_id=ctx.workspace.id, voice_id=custom.voice_id, name=custom.name,
107
- preview_url=custom.preview_url, premium=True, is_custom=True))
108
- else:
109
- with upstream():
110
- v = next((v for v in b2v.prebuilt_voices() if v.get("voice_id") == body.voice_id), None)
111
- if not v:
112
- raise HTTPException(404, "Voice not found")
113
- if v.get("plan") == "paid":
114
- video_limits.require_premium(ctx.db, ctx.workspace, "This voice")
115
- row = _save_builtin(ctx, v)
116
  ctx.db.commit()
117
  return _voice_out(row)
118
 
@@ -125,158 +110,3 @@ def unsave_voice(voice_id: str, ctx: Ctx = Depends(get_ctx)):
125
  ctx.db.delete(row)
126
  ctx.db.commit()
127
  return {"ok": True}
128
-
129
-
130
- # Design a voice ★: a description or options -> a few previews; keep one to make it a custom voice
131
-
132
-
133
- class DesignPromptIn(BaseModel):
134
- prompt: str = Field(min_length=20, max_length=1000)
135
-
136
-
137
- class DesignPresetIn(BaseModel):
138
- gender: str = Field("", max_length=30)
139
- age: str = Field("", max_length=30)
140
- persona: str = Field("", max_length=100)
141
- speed: str = Field("", max_length=30)
142
- accent: str = Field("", max_length=60)
143
-
144
-
145
- def _design(ctx: Ctx, path: str, body: dict) -> dict:
146
- plan = video_limits.require_premium(ctx.db, ctx.workspace, "Designing a voice")
147
- video_limits.take(ctx.db, ctx.workspace, "voice_designs_daily", plan=plan)
148
- try:
149
- with upstream():
150
- data = b2v.request("POST", path, json=body, timeout=90) or {}
151
- except HTTPException:
152
- video_limits.give_back(ctx.db, ctx.workspace.id, "voice_designs_daily")
153
- raise
154
- return {"previews": [{k: p.get(k) for k in ("generated_voice_id", "audio_base_64", "media_type", "duration_secs")}
155
- for p in data.get("previews") or []]}
156
-
157
-
158
- @router.post("/design/prompt")
159
- def design_from_prompt(body: DesignPromptIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
160
- return _design(ctx, "/api/voices/design-from-prompt", body.model_dump())
161
-
162
-
163
- @router.post("/design/preset")
164
- def design_from_preset(body: DesignPresetIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
165
- return _design(ctx, "/api/voices/design-from-preset", body.model_dump())
166
-
167
-
168
- class KeepIn(BaseModel):
169
- generated_voice_id: str = Field(min_length=1, max_length=200)
170
- source: Literal["prompt", "preset"]
171
- name: str = Field(min_length=1, max_length=100)
172
- prompt_text: str | None = Field(None, max_length=1000)
173
- preview_url: str | None = Field(None, max_length=1000)
174
-
175
-
176
- def _record_custom(ctx: Ctx, made: dict, source: str) -> dict:
177
- row = B2VCustomVoice(b2v_custom_voice_id=int(made["id"]), workspace_id=ctx.workspace.id,
178
- voice_id=made["voice_id"], name=(made.get("name") or "My voice")[:255], source=source,
179
- preview_url=made.get("preview_url"))
180
- ctx.db.add(row)
181
- ctx.db.merge(UserSavedVoice(workspace_id=ctx.workspace.id, voice_id=row.voice_id, name=row.name,
182
- preview_url=row.preview_url, premium=True, is_custom=True)) # a new voice is saved
183
- ctx.db.commit()
184
- return {"id": row.b2v_custom_voice_id, "voice_id": row.voice_id, "name": row.name, "source": source,
185
- "preview_url": row.preview_url, "saved": True}
186
-
187
-
188
- def _make_custom(ctx: Ctx, source: str, send) -> dict:
189
- plan = video_limits.require_premium(ctx.db, ctx.workspace, "Custom voices")
190
- video_limits.take(ctx.db, ctx.workspace, "custom_voices", plan=plan)
191
- try:
192
- with upstream():
193
- made = send()
194
- except HTTPException:
195
- video_limits.give_back(ctx.db, ctx.workspace.id, "custom_voices")
196
- raise
197
- return _record_custom(ctx, made, source)
198
-
199
-
200
- @router.post("/custom", status_code=201)
201
- def keep_designed_voice(body: KeepIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
202
- """Keep one designed preview as a permanent custom voice."""
203
- payload = {"voice_id": body.generated_voice_id, "source": "prompt" if body.source == "prompt" else "form",
204
- "name": body.name, "prompt_text": body.prompt_text, "preview_url": body.preview_url}
205
- return _make_custom(ctx, body.source,
206
- lambda: b2v.request("POST", "/api/voices/custom", json=payload, timeout=60))
207
-
208
-
209
- @router.post("/clone", status_code=201)
210
- async def clone_voice(name: str = Form(..., min_length=1, max_length=100), remove_background_noise: bool = Form(True),
211
- file: UploadFile = File(...), ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
212
- """Clone a voice from one audio or video file (up to 50 MB)."""
213
- data = await file.read(CLONE_MAX_BYTES + 1)
214
- if not data:
215
- raise HTTPException(400, "The file is empty.")
216
- if len(data) > CLONE_MAX_BYTES:
217
- raise HTTPException(400, "The file must be under 50 MB.")
218
- form = {"name": name, "remove_background_noise": "true" if remove_background_noise else "false"}
219
- files = {"file": (file.filename or "voice.mp3", data, file.content_type or "application/octet-stream")}
220
- return _make_custom(ctx, "clone",
221
- lambda: b2v.request("POST", "/api/voices/clone", data=form, files=files, timeout=180))
222
-
223
-
224
- @router.get("/custom/{custom_id}/preview")
225
- def custom_preview(custom_id: int, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
226
- row = b2v_access.owned_custom_voice(ctx, custom_id)
227
- if row.preview_url:
228
- return {"preview_url": row.preview_url, "ready": True}
229
- with upstream():
230
- result = b2v.request("GET", f"/api/voices/custom/{custom_id}/preview") or {}
231
- if result.get("preview_url"):
232
- row.preview_url = result["preview_url"]
233
- saved = ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": row.voice_id})
234
- if saved:
235
- saved.preview_url = row.preview_url
236
- ctx.db.commit()
237
- return {"preview_url": result.get("preview_url"), "ready": bool(result.get("ready"))}
238
-
239
-
240
- @router.delete("/custom/{custom_id}")
241
- def delete_custom(custom_id: int, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
242
- row = b2v_access.owned_custom_voice(ctx, custom_id)
243
- try:
244
- with upstream():
245
- b2v.request("DELETE", f"/api/voices/custom/{custom_id}")
246
- except HTTPException as e:
247
- if e.status_code != 404:
248
- raise
249
- saved = ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": row.voice_id})
250
- if saved:
251
- ctx.db.delete(saved)
252
- ctx.db.delete(row)
253
- ctx.db.commit()
254
- video_limits.give_back(ctx.db, ctx.workspace.id, "custom_voices")
255
- return {"ok": True}
256
-
257
-
258
- # Voice sample ★: a few seconds of the chosen voice with the chosen tuning
259
-
260
-
261
- class SampleIn(BaseModel):
262
- voice_gender: Literal["female", "male"] = "female"
263
- voice_accent: str = Field("american", max_length=30)
264
- custom_voice_id: str | None = Field(None, max_length=100)
265
- voice_emotion: str | None = Field(None, max_length=200)
266
- video_style: str | None = Field(None, max_length=40)
267
-
268
-
269
- @router.post("/sample")
270
- def voice_sample(body: SampleIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
271
- plan = video_limits.require_premium(ctx.db, ctx.workspace, "Voice samples")
272
- payload = body.model_dump(exclude_none=True)
273
- with upstream(): # checking the voice may need blog2video's catalog
274
- b2v_access.check_refs(ctx, {k: v for k, v in payload.items() if k != "voice_emotion"}, plan)
275
- video_limits.take(ctx.db, ctx.workspace, "voice_samples_daily", plan=plan)
276
- try:
277
- with upstream():
278
- audio, ctype = b2v.request("POST", "/api/voice/preview", json=payload, timeout=60, raw=True)
279
- except HTTPException:
280
- video_limits.give_back(ctx.db, ctx.workspace.id, "voice_samples_daily")
281
- raise
282
- return Response(audio, media_type=ctype or "audio/mpeg", headers={"Cache-Control": "no-store"})
 
1
+ """Voices for videos: the workspace's saved voices, the free built-in library, and its Notestack voices (made on the Voice
2
+ page; they speak audio overviews, not videos).
3
 
4
  "Your voices" (user_saved_voices, as many as wanted) is the one list for audio overviews and videos, and lives only
5
  here: blog2video's saved-voice list is one list for the whole account.
 
 
 
6
  """
7
 
8
  import logging
 
9
 
10
+ from fastapi import APIRouter, Depends, HTTPException
11
  from pydantic import BaseModel, Field
12
  from sqlalchemy import select
13
 
14
  from app.auth import Ctx, get_ctx
15
+ from app.models import UserSavedVoice
16
  from app.routers.videos import b2v_ready, upstream
 
17
  from app.services import blog2video as b2v
18
  from app.services.notestack_voices import notestack_voices, save_notestack_voice
19
 
 
21
 
22
  router = APIRouter(prefix="/api/video-voices", tags=["videos"])
23
 
 
24
  STARTER_VOICES = 4 # a new workspace starts with this many free built-in voices saved
25
 
26
 
 
30
 
31
  def _voice_out(v: UserSavedVoice, ours: set[str] = frozenset()) -> dict:
32
  return {"voice_id": v.voice_id, "name": v.name, "preview_url": v.preview_url, "gender": v.gender,
33
+ "accent": v.accent,
34
  "source": "notestack" if v.voice_id in ours else "blog2video"}
35
 
36
 
 
39
  return {"voice_id": v["voice_id"], "name": (v.get("name") or "Voice").split(" - ")[0].strip(),
40
  "description": v.get("description") or labels.get("description") or "", "preview_url": v.get("preview_url"),
41
  "gender": labels.get("gender"), "accent": labels.get("accent"), "age": labels.get("age"),
42
+ "saved": v["voice_id"] in saved}
43
 
44
 
45
  def _save_builtin(ctx: Ctx, v: dict) -> UserSavedVoice:
 
47
  row = UserSavedVoice(workspace_id=ctx.workspace.id, voice_id=v["voice_id"],
48
  name=(v.get("name") or "Voice").split(" - ")[0].strip()[:255],
49
  preview_url=v.get("preview_url"),
50
+ gender=labels.get("gender"), accent=labels.get("accent"))
51
  ctx.db.merge(row)
52
  return row
53
 
54
 
55
+ def _saved(ctx: Ctx, ours: set[str] = frozenset()) -> list[UserSavedVoice]:
56
+ """The saved voices that can still be used: free built-in voices and the workspace's Notestack voices. Voices that
57
+ were designed or cloned for videos, and paid built-in ones, are gone."""
58
+ rows = ctx.db.scalars(select(UserSavedVoice).where(UserSavedVoice.workspace_id == ctx.workspace.id)
59
+ .order_by(UserSavedVoice.created_at))
60
+ return [v for v in rows if v.voice_id in ours or not (v.is_custom or v.premium)]
61
 
62
 
63
  @router.get("")
64
  def voices(ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
65
+ """Your voices (audio overviews and videos), the free built-in library (to save from) and your Notestack voices."""
66
  with upstream():
67
+ library = [v for v in b2v.prebuilt_voices() if v.get("plan") != "paid"]
68
+ mine = notestack_voices(ctx.db, ctx.workspace.id)
69
+ ours = {v["voice_id"] for v in mine}
70
+ saved = _saved(ctx, ours)
71
  if not saved:
72
+ for v in [v for v in library if v.get("voice_id")][:STARTER_VOICES]:
73
  _save_builtin(ctx, v)
74
  ctx.db.commit()
75
+ saved = _saved(ctx, ours)
76
  ids = {v.voice_id for v in saved}
 
 
 
 
77
  return {"saved": [_voice_out(v, ours) for v in saved],
78
  "notestack": [{**v, "saved": v["voice_id"] in ids} for v in mine],
79
+ "library": [_library_out(v, ids) for v in library if v.get("voice_id")]}
 
 
80
 
81
 
82
  class SaveIn(BaseModel):
 
87
  def save_voice(body: SaveIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
88
  if ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": body.voice_id}):
89
  return _voice_out(ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": body.voice_id}))
 
 
90
  mine = next((v for v in notestack_voices(ctx.db, ctx.workspace.id) if v["voice_id"] == body.voice_id), None)
91
  if mine:
92
+ # A Notestack voice: saved on any plan, for audio overviews. No preview link (the Voice page plays it).
93
  row = save_notestack_voice(ctx.db, ctx.workspace.id, mine["voice_id"], mine["name"])
94
  ctx.db.commit()
95
  return _voice_out(row, {row.voice_id})
96
+ with upstream():
97
+ v = next((v for v in b2v.prebuilt_voices() if v.get("voice_id") == body.voice_id), None)
98
+ if not v or v.get("plan") == "paid":
99
+ raise HTTPException(404, "Voice not found")
100
+ row = _save_builtin(ctx, v)
 
 
 
 
 
 
101
  ctx.db.commit()
102
  return _voice_out(row)
103
 
 
110
  ctx.db.delete(row)
111
  ctx.db.commit()
112
  return {"ok": True}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/routers/videos.py CHANGED
@@ -140,7 +140,7 @@ class VideoOptions(BaseModel):
140
  # Step 1: Project (script review is always off: blog2video goes straight to the video)
141
  stock_footage_enabled: bool = True
142
  aspect_ratio: Literal["landscape", "portrait"] = "landscape"
143
- video_length: Literal["short", "medium", "detailed", "more_detailed"] = "short"
144
  logo_upload_id: uuid.UUID | None = None # a file uploaded through /api/storage/uploads
145
  logo_position: Literal["bottom_right", "bottom_left", "top_left", "top_right"] = "bottom_right"
146
  logo_opacity: float = Field(0.9, ge=0.1, le=1.0)
@@ -155,7 +155,6 @@ class VideoOptions(BaseModel):
155
  voice_gender: Literal["female", "male", "none"] = "female"
156
  voice_accent: str = Field("american", max_length=30)
157
  custom_voice_id: str | None = Field(None, max_length=100)
158
- voice_emotion: str | None = Field(None, max_length=200) # ["<expressiveness>","<speed>","<emotion>",...]
159
  bgm_track_id: str | None = Field(None, max_length=100)
160
  bgm_volume: float = Field(0.10, ge=0, le=1)
161
 
@@ -167,7 +166,6 @@ class VideoOptions(BaseModel):
167
  data["script_review_enabled"] = False
168
  if self.voice_gender == "none":
169
  data.pop("custom_voice_id", None)
170
- data.pop("voice_emotion", None)
171
  if not self.bgm_track_id:
172
  data.pop("bgm_volume", None)
173
  return data
@@ -369,9 +367,6 @@ def _check(ctx: Ctx, options: VideoOptions) -> tuple[dict, Plan]:
369
  if length := b2v_access.house_length(payload.get("video_style")):
370
  payload["video_length"] = length
371
  plan = b2v_access.check_refs(ctx, payload)
372
- accent = (ctx.workspace.brand_json or {}).get("accent")
373
- if plan.brand_kit and accent and "accent_color" not in payload:
374
- payload["accent_color"] = accent
375
  return payload, plan
376
 
377
 
@@ -626,10 +621,10 @@ def quota(ctx: Ctx = Depends(get_ctx)):
626
 
627
  def _with_posters(t: dict) -> dict:
628
  """Poster images for the template picker. Built-in templates have baked posters on blog2video's web app (a few
629
- have none; the picker then shows the template's colors); designer templates carry their own image."""
630
  if t.get("preview_image_url") and not t.get("preview_url"):
631
  return {**t, "preview_url": t["preview_image_url"]}
632
- if t.get("preview_url") or not t.get("id") or str(t["id"]).startswith("crafted_"):
633
  return t
634
  base = f"{settings.b2v_app_url.rstrip('/')}/template-posters/{t['id']}"
635
  return {**t, "preview_url": f"{base}.webp", "preview_portrait_url": f"{base}-portrait.webp"}
@@ -637,20 +632,15 @@ def _with_posters(t: dict) -> dict:
637
 
638
  @router.get("/catalog")
639
  def catalog(ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
640
- """What the wizard offers this workspace: built-in and designer templates, its own ready custom templates,
641
  built-in and its own styles, music, languages. Nothing another workspace made."""
642
  with upstream():
643
  builtin = b2v.builtin_templates()
644
- try:
645
- crafted = b2v.crafted_templates()
646
- except b2v.B2VError:
647
- crafted = []
648
  mine = [{"id": f"custom_{t.b2v_template_id}", "name": t.name, "custom": True}
649
  for t in b2v_access.my_templates(ctx, ready_only=True)]
650
  styles = b2v_access.visible_styles(ctx)
651
  music = b2v.music_tracks()
652
  return {"templates": [_with_posters(t) for t in builtin if isinstance(t, dict)],
653
- "crafted_templates": [_with_posters(t) for t in crafted if isinstance(t, dict)],
654
  "my_templates": mine, "video_styles": styles,
655
  "music": music, "languages": [{"code": c, "name": n} for c, n in LANGUAGES]}
656
 
@@ -888,10 +878,6 @@ class ReviewPreviewIn(BaseModel):
888
  revision: int = Field(0, ge=0)
889
 
890
 
891
- class ReviewAiIn(ReviewPreviewIn):
892
- instruction: str = Field(min_length=2, max_length=1000)
893
-
894
-
895
  class ReviewApproveScene(DraftScene):
896
  preferred_layout: str | None = Field(None, max_length=100)
897
  source_fingerprint: str | None = Field(None, max_length=200)
@@ -923,22 +909,6 @@ def narration_preview(artifact_id: uuid.UUID, scene_id: int, body: ReviewPreview
923
  json=body.model_dump(), timeout=60)
924
 
925
 
926
- @router.post("/{artifact_id}/script/scenes/{scene_id}/ai-preview")
927
- def ai_preview(artifact_id: uuid.UUID, scene_id: int, body: ReviewAiIn, ctx: Ctx = Depends(get_ctx),
928
- _: None = Depends(b2v_ready)):
929
- """★ Rewrite one scene from an instruction. Uses one AI edit."""
930
- _, vid, _ = b2v_access.video_or_404(ctx, artifact_id)
931
- plan = video_limits.require_premium(ctx.db, ctx.workspace, "AI rewrites")
932
- video_limits.take(ctx.db, ctx.workspace, "ai_edits", plan=plan)
933
- try:
934
- with upstream():
935
- return b2v.project_call("POST", vid, f"script-review/scenes/{scene_id}/ai-preview",
936
- json=body.model_dump(), timeout=60)
937
- except HTTPException:
938
- video_limits.give_back(ctx.db, ctx.workspace.id, "ai_edits")
939
- raise
940
-
941
-
942
  @router.post("/{artifact_id}/script/approve")
943
  def approve_script(artifact_id: uuid.UUID, body: ReviewApproveIn, ctx: Ctx = Depends(get_ctx),
944
  _: None = Depends(b2v_ready)):
 
140
  # Step 1: Project (script review is always off: blog2video goes straight to the video)
141
  stock_footage_enabled: bool = True
142
  aspect_ratio: Literal["landscape", "portrait"] = "landscape"
143
+ video_length: Literal["short", "medium"] = "short"
144
  logo_upload_id: uuid.UUID | None = None # a file uploaded through /api/storage/uploads
145
  logo_position: Literal["bottom_right", "bottom_left", "top_left", "top_right"] = "bottom_right"
146
  logo_opacity: float = Field(0.9, ge=0.1, le=1.0)
 
155
  voice_gender: Literal["female", "male", "none"] = "female"
156
  voice_accent: str = Field("american", max_length=30)
157
  custom_voice_id: str | None = Field(None, max_length=100)
 
158
  bgm_track_id: str | None = Field(None, max_length=100)
159
  bgm_volume: float = Field(0.10, ge=0, le=1)
160
 
 
166
  data["script_review_enabled"] = False
167
  if self.voice_gender == "none":
168
  data.pop("custom_voice_id", None)
 
169
  if not self.bgm_track_id:
170
  data.pop("bgm_volume", None)
171
  return data
 
367
  if length := b2v_access.house_length(payload.get("video_style")):
368
  payload["video_length"] = length
369
  plan = b2v_access.check_refs(ctx, payload)
 
 
 
370
  return payload, plan
371
 
372
 
 
621
 
622
  def _with_posters(t: dict) -> dict:
623
  """Poster images for the template picker. Built-in templates have baked posters on blog2video's web app (a few
624
+ have none; the picker then shows the template's colors)."""
625
  if t.get("preview_image_url") and not t.get("preview_url"):
626
  return {**t, "preview_url": t["preview_image_url"]}
627
+ if t.get("preview_url") or not t.get("id"):
628
  return t
629
  base = f"{settings.b2v_app_url.rstrip('/')}/template-posters/{t['id']}"
630
  return {**t, "preview_url": f"{base}.webp", "preview_portrait_url": f"{base}-portrait.webp"}
 
632
 
633
  @router.get("/catalog")
634
  def catalog(ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
635
+ """What the wizard offers this workspace: built-in templates, its own ready custom templates,
636
  built-in and its own styles, music, languages. Nothing another workspace made."""
637
  with upstream():
638
  builtin = b2v.builtin_templates()
 
 
 
 
639
  mine = [{"id": f"custom_{t.b2v_template_id}", "name": t.name, "custom": True}
640
  for t in b2v_access.my_templates(ctx, ready_only=True)]
641
  styles = b2v_access.visible_styles(ctx)
642
  music = b2v.music_tracks()
643
  return {"templates": [_with_posters(t) for t in builtin if isinstance(t, dict)],
 
644
  "my_templates": mine, "video_styles": styles,
645
  "music": music, "languages": [{"code": c, "name": n} for c, n in LANGUAGES]}
646
 
 
878
  revision: int = Field(0, ge=0)
879
 
880
 
 
 
 
 
881
  class ReviewApproveScene(DraftScene):
882
  preferred_layout: str | None = Field(None, max_length=100)
883
  source_fingerprint: str | None = Field(None, max_length=200)
 
909
  json=body.model_dump(), timeout=60)
910
 
911
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
912
  @router.post("/{artifact_id}/script/approve")
913
  def approve_script(artifact_id: uuid.UUID, body: ReviewApproveIn, ctx: Ctx = Depends(get_ctx),
914
  _: None = Depends(b2v_ready)):
backend/app/services/b2v_access.py CHANGED
@@ -1,6 +1,6 @@
1
  """Who owns what on our one blog2video account.
2
 
3
- blog2video's ownership checks stop at the account: any video, custom template, custom voice or style made with our
4
  key can be used, listed and edited with it. So every route that takes a blog2video id checks here first that it
5
  belongs to the current workspace (404 otherwise, so another workspace's ids are never confirmed), and every create or
6
  edit body has its references checked before it is forwarded (check_refs).
@@ -12,11 +12,9 @@ from fastapi import HTTPException
12
  from sqlalchemy import select
13
 
14
  from app.auth import Ctx
15
- from app.models import Artifact, B2VCustomVoice, B2VStyle, B2VTemplate, B2VVideo
16
  from app.services import blog2video as b2v
17
- from app.services.notestack_voices import is_notestack_voice
18
  from app.services.plans import Plan, effective_plan
19
- from app.services.video_limits import require_premium
20
 
21
  BUILTIN_STYLES = {"auto", "explainer", "storytelling", "promotional"}
22
  # Custom styles made on our blog2video account itself, offered to every workspace in place of the others when they
@@ -29,7 +27,6 @@ HOUSE_STYLES = [ # (name on blog2video, video length, what the wizard says it i
29
  "scene building on the last. Best for YouTube, tutorials and full explainers. Usually 1 to "
30
  "2 minutes long."),
31
  ]
32
- PREMIUM_LENGTHS = {"detailed", "more_detailed", "mdetailed"}
33
 
34
 
35
  def not_found(what: str = "Not found") -> HTTPException:
@@ -79,14 +76,6 @@ def owned_template(ctx: Ctx, template_id: int) -> B2VTemplate:
79
  return row
80
 
81
 
82
- def owned_custom_voice(ctx: Ctx, custom_voice_id: int) -> B2VCustomVoice:
83
- row = ctx.db.scalar(select(B2VCustomVoice).where(B2VCustomVoice.b2v_custom_voice_id == custom_voice_id,
84
- B2VCustomVoice.workspace_id == ctx.workspace.id))
85
- if not row:
86
- raise not_found("Voice not found")
87
- return row
88
-
89
-
90
  def owned_style(ctx: Ctx, style_id: int) -> B2VStyle:
91
  row = ctx.db.scalar(select(B2VStyle).where(B2VStyle.b2v_style_id == style_id,
92
  B2VStyle.workspace_id == ctx.workspace.id))
@@ -100,15 +89,6 @@ def _custom_id(ref: str, prefix: str) -> int | None:
100
  return int(raw) if ref.startswith(prefix) and raw.isdigit() else None
101
 
102
 
103
- def crafted_ids() -> set[str]:
104
- out = set()
105
- for t in b2v.crafted_templates():
106
- tid = t.get("id") if isinstance(t, dict) else t
107
- if isinstance(tid, str) and tid.startswith("crafted_"):
108
- out.add(tid)
109
- return out
110
-
111
-
112
  def check_template(ctx: Ctx, template: str, plan: Plan) -> None:
113
  if template == "default":
114
  return
@@ -117,12 +97,6 @@ def check_template(ctx: Ctx, template: str, plan: Plan) -> None:
117
  if not row.ready:
118
  raise HTTPException(400, "That template is still being made.")
119
  return
120
- if template.startswith("crafted_"):
121
- # blog2video silently falls back to "default" for an unknown id, so check it exists.
122
- if template not in crafted_ids():
123
- raise not_found("Template not found")
124
- require_premium(ctx.db, ctx.workspace, "Designer templates", plan)
125
- return
126
  if template not in {t.get("id") for t in b2v.builtin_templates() if isinstance(t, dict)}:
127
  raise not_found("Template not found")
128
 
@@ -157,38 +131,26 @@ def check_style(ctx: Ctx, style: str) -> None:
157
  raise not_found("Video style not found") # includes your_style, which learns from every workspace's edits
158
 
159
 
160
- def check_voice(ctx: Ctx, voice_id: str, plan: Plan) -> None:
161
- """A built-in voice (premium if blog2video marks it paid), or one of this workspace's custom or Notestack
162
- voices."""
163
- prebuilt = {v.get("voice_id"): v for v in b2v.prebuilt_voices()}
164
- if voice_id in prebuilt:
165
- if prebuilt[voice_id].get("plan") == "paid":
166
- require_premium(ctx.db, ctx.workspace, "This voice", plan)
167
- return
168
- if ctx.db.scalar(select(B2VCustomVoice).where(B2VCustomVoice.voice_id == voice_id,
169
- B2VCustomVoice.workspace_id == ctx.workspace.id)):
170
- require_premium(ctx.db, ctx.workspace, "Custom voices", plan)
171
- return
172
- # One of this workspace's Notestack voices (clone, designed, added): same ElevenLabs account, premium like custom.
173
- if is_notestack_voice(ctx.db, ctx.workspace.id, voice_id):
174
- require_premium(ctx.db, ctx.workspace, "Custom voices", plan)
175
- return
176
- raise not_found("Voice not found")
177
 
178
 
179
  def check_refs(ctx: Ctx, body: dict, plan: Plan | None = None) -> Plan:
180
- """Every id in a create or edit body must be this workspace's, and premium options need a paid plan."""
181
  plan = plan or effective_plan(ctx.db, ctx.workspace)
182
  if body.get("template"):
183
  check_template(ctx, str(body["template"]), plan)
184
  if body.get("video_style"):
185
  check_style(ctx, str(body["video_style"]))
186
  if body.get("custom_voice_id") and body.get("voice_gender") != "none":
187
- check_voice(ctx, str(body["custom_voice_id"]), plan)
188
- if str(body.get("video_length") or "") in PREMIUM_LENGTHS:
189
- require_premium(ctx.db, ctx.workspace, "Longer videos", plan)
190
  if body.get("voice_emotion"):
191
- require_premium(ctx.db, ctx.workspace, "Advanced voice options", plan)
192
  return plan
193
 
194
 
 
1
  """Who owns what on our one blog2video account.
2
 
3
+ blog2video's ownership checks stop at the account: any video, custom template or style made with our
4
  key can be used, listed and edited with it. So every route that takes a blog2video id checks here first that it
5
  belongs to the current workspace (404 otherwise, so another workspace's ids are never confirmed), and every create or
6
  edit body has its references checked before it is forwarded (check_refs).
 
12
  from sqlalchemy import select
13
 
14
  from app.auth import Ctx
15
+ from app.models import Artifact, B2VStyle, B2VTemplate, B2VVideo
16
  from app.services import blog2video as b2v
 
17
  from app.services.plans import Plan, effective_plan
 
18
 
19
  BUILTIN_STYLES = {"auto", "explainer", "storytelling", "promotional"}
20
  # Custom styles made on our blog2video account itself, offered to every workspace in place of the others when they
 
27
  "scene building on the last. Best for YouTube, tutorials and full explainers. Usually 1 to "
28
  "2 minutes long."),
29
  ]
 
30
 
31
 
32
  def not_found(what: str = "Not found") -> HTTPException:
 
76
  return row
77
 
78
 
 
 
 
 
 
 
 
 
79
  def owned_style(ctx: Ctx, style_id: int) -> B2VStyle:
80
  row = ctx.db.scalar(select(B2VStyle).where(B2VStyle.b2v_style_id == style_id,
81
  B2VStyle.workspace_id == ctx.workspace.id))
 
89
  return int(raw) if ref.startswith(prefix) and raw.isdigit() else None
90
 
91
 
 
 
 
 
 
 
 
 
 
92
  def check_template(ctx: Ctx, template: str, plan: Plan) -> None:
93
  if template == "default":
94
  return
 
97
  if not row.ready:
98
  raise HTTPException(400, "That template is still being made.")
99
  return
 
 
 
 
 
 
100
  if template not in {t.get("id") for t in b2v.builtin_templates() if isinstance(t, dict)}:
101
  raise not_found("Template not found")
102
 
 
131
  raise not_found("Video style not found") # includes your_style, which learns from every workspace's edits
132
 
133
 
134
+ def check_voice(voice_id: str) -> None:
135
+ """A free built-in voice (blog2video marks the paid ones)."""
136
+ voice = next((v for v in b2v.prebuilt_voices() if v.get("voice_id") == voice_id), None)
137
+ if not voice or voice.get("plan") == "paid":
138
+ raise not_found("Voice not found")
 
 
 
 
 
 
 
 
 
 
 
 
139
 
140
 
141
  def check_refs(ctx: Ctx, body: dict, plan: Plan | None = None) -> Plan:
142
+ """Every id in a create or edit body must be this workspace's, and only the standard options are accepted."""
143
  plan = plan or effective_plan(ctx.db, ctx.workspace)
144
  if body.get("template"):
145
  check_template(ctx, str(body["template"]), plan)
146
  if body.get("video_style"):
147
  check_style(ctx, str(body["video_style"]))
148
  if body.get("custom_voice_id") and body.get("voice_gender") != "none":
149
+ check_voice(str(body["custom_voice_id"]))
150
+ if str(body.get("video_length") or "") not in {"", "short", "medium"}:
151
+ raise HTTPException(400, "That video length is not available.")
152
  if body.get("voice_emotion"):
153
+ raise HTTPException(400, "Voice tuning is not available.")
154
  return plan
155
 
156
 
backend/app/services/billing.py CHANGED
@@ -10,7 +10,7 @@ from sqlalchemy import func, select
10
  from sqlalchemy.orm import Session
11
 
12
  from app.config import settings
13
- from app.models import Artifact, CalendarItem, Notebook, Source, Subscription, User, VoiceProfile, Workspace
14
  from app.services.plans import PLANS, Plan, effective_plan, next_plan, plan_dict
15
  from app.services.usage import month_usage
16
  from app.services.video_quota import sync_video_quota, video_usage
@@ -27,15 +27,30 @@ METERS = {
27
 
28
  def _meters(plan: Plan, used: dict, sources: int) -> list[dict]:
29
  rows = []
30
- for key, (label, unit) in METERS.items():
 
 
 
 
31
  limit = getattr(plan, key)
32
  rows.append({"key": key, "label": label, "unit": unit, "used": used[key], "limit": limit,
33
  "pct": 0 if limit < 0 else round(min(1.0, used[key] / max(limit, 1)), 3)})
34
  rows.append({"key": "sources", "label": "Sources", "unit": "", "used": sources, "limit": plan.sources,
35
- "pct": round(min(1.0, sources / max(plan.sources, 1)), 3)})
36
  return rows
37
 
38
 
 
 
 
 
 
 
 
 
 
 
 
39
  def _count(db: Session, model, workspace_id, *where) -> int:
40
  return db.scalar(select(func.count()).select_from(model).where(model.workspace_id == workspace_id, *where)) or 0
41
 
@@ -57,23 +72,20 @@ def build_nudges(db: Session, workspace: Workspace, plan: Plan, meters: list[dic
57
  continue
58
  label = m["label"].lower()
59
  amount = f"{m['limit']:g} min of {label}" if m["unit"] else f"{m['limit']:g} {label}"
 
60
  if m["pct"] >= 1:
61
- out.append(_nudge(f"empty-{m['key']}-{month}", "limit", f"Out of {label} this month",
62
  f"You have used all {amount}. Upgrade to keep launching.", "Refuel now",
63
  priority=100))
64
  elif m["pct"] >= LOW_FUEL:
65
  left = f"{m['limit'] - m['used']:g}" + (" min" if m["unit"] else "")
66
  out.append(_nudge(f"low-{m['key']}-{month}", "limit", f"Running low on {label}",
67
- f"Only {left} left this month. Upgrade before you stall mid launch.",
68
  "See plans", priority=90))
69
  if not plan.voice_cloning:
70
  out.append(_nudge("perk-voice", "upgrade", "Narrate in your own voice",
71
  "Writer unlocks voice cloning, so every audio overview sounds like you.",
72
  "Unlock voice", priority=40))
73
- if not plan.brand_kit:
74
- out.append(_nudge("perk-brand", "upgrade", "Put your brand on every render",
75
- "Your colors and logo on videos, quote cards and carousels.", "Unlock brand kit",
76
- priority=30))
77
 
78
  sources = _count(db, Source, ws)
79
  if sources == 0:
@@ -119,7 +131,7 @@ def build_nudges(db: Session, workspace: Workspace, plan: Plan, meters: list[dic
119
 
120
  def billing_status(db: Session, workspace: Workspace) -> dict:
121
  plan = effective_plan(db, workspace)
122
- used = month_usage(db, workspace.id)
123
  videos = video_usage(db, workspace)
124
  used["videos"] = videos["used"]
125
  sources = _count(db, Source, workspace.id)
@@ -136,6 +148,7 @@ def billing_status(db: Session, workspace: Workspace) -> dict:
136
  "has_billing_account": bool(sub and sub.provider_customer_id),
137
  "period_end": sub.current_period_end.isoformat() if sub and sub.current_period_end else None,
138
  "meters": meters,
 
139
  "since": used["since"],
140
  "videos_resets_at": videos["resets_at"],
141
  "nudges": build_nudges(db, workspace, plan, meters, can_upgrade),
 
10
  from sqlalchemy.orm import Session
11
 
12
  from app.config import settings
13
+ from app.models import Artifact, CalendarItem, Document, Notebook, Source, Subscription, User, VoiceProfile, Workspace
14
  from app.services.plans import PLANS, Plan, effective_plan, next_plan, plan_dict
15
  from app.services.usage import month_usage
16
  from app.services.video_quota import sync_video_quota, video_usage
 
27
 
28
  def _meters(plan: Plan, used: dict, sources: int) -> list[dict]:
29
  rows = []
30
+ meters = dict(METERS)
31
+ if plan.audio_overviews >= 0: # a count of audio overviews (Free), not minutes
32
+ meters = {("audio_overviews" if k == "audio_minutes" else k): (("Audio overviews", "") if k == "audio_minutes" else v)
33
+ for k, v in METERS.items()}
34
+ for key, (label, unit) in meters.items():
35
  limit = getattr(plan, key)
36
  rows.append({"key": key, "label": label, "unit": unit, "used": used[key], "limit": limit,
37
  "pct": 0 if limit < 0 else round(min(1.0, used[key] / max(limit, 1)), 3)})
38
  rows.append({"key": "sources", "label": "Sources", "unit": "", "used": sources, "limit": plan.sources,
39
+ "pct": 0 if plan.sources < 0 else round(min(1.0, sources / max(plan.sources, 1)), 3)})
40
  return rows
41
 
42
 
43
+ def _more_meters(db: Session, workspace: Workspace, plan: Plan, used: dict) -> list[dict]:
44
+ """The rest of the plan's limits, shown when the sidebar card is opened: indexed posts, reports and infographics."""
45
+ posts = db.scalar(select(func.count()).select_from(Document)
46
+ .where(Document.workspace_id == workspace.id, Document.path.is_not(None))) or 0
47
+ rows = [("indexed_posts", "Posts indexed", posts, plan.indexed_posts),
48
+ ("reports", "Reports", used["reports"], plan.reports),
49
+ ("infographics", "Infographics", used["infographics"], plan.infographics)]
50
+ return [{"key": k, "label": label, "unit": "", "used": u, "limit": limit,
51
+ "pct": 0 if limit < 0 else round(min(1.0, u / max(limit, 1)), 3)} for k, label, u, limit in rows]
52
+
53
+
54
  def _count(db: Session, model, workspace_id, *where) -> int:
55
  return db.scalar(select(func.count()).select_from(model).where(model.workspace_id == workspace_id, *where)) or 0
56
 
 
72
  continue
73
  label = m["label"].lower()
74
  amount = f"{m['limit']:g} min of {label}" if m["unit"] else f"{m['limit']:g} {label}"
75
+ when = "" if plan.lifetime else " this month"
76
  if m["pct"] >= 1:
77
+ out.append(_nudge(f"empty-{m['key']}-{month}", "limit", f"Out of {label}{when}",
78
  f"You have used all {amount}. Upgrade to keep launching.", "Refuel now",
79
  priority=100))
80
  elif m["pct"] >= LOW_FUEL:
81
  left = f"{m['limit'] - m['used']:g}" + (" min" if m["unit"] else "")
82
  out.append(_nudge(f"low-{m['key']}-{month}", "limit", f"Running low on {label}",
83
+ f"Only {left} left{when}. Upgrade before you stall mid launch.",
84
  "See plans", priority=90))
85
  if not plan.voice_cloning:
86
  out.append(_nudge("perk-voice", "upgrade", "Narrate in your own voice",
87
  "Writer unlocks voice cloning, so every audio overview sounds like you.",
88
  "Unlock voice", priority=40))
 
 
 
 
89
 
90
  sources = _count(db, Source, ws)
91
  if sources == 0:
 
131
 
132
  def billing_status(db: Session, workspace: Workspace) -> dict:
133
  plan = effective_plan(db, workspace)
134
+ used = month_usage(db, workspace.id, plan.lifetime)
135
  videos = video_usage(db, workspace)
136
  used["videos"] = videos["used"]
137
  sources = _count(db, Source, workspace.id)
 
148
  "has_billing_account": bool(sub and sub.provider_customer_id),
149
  "period_end": sub.current_period_end.isoformat() if sub and sub.current_period_end else None,
150
  "meters": meters,
151
+ "more_meters": _more_meters(db, workspace, plan, used),
152
  "since": used["since"],
153
  "videos_resets_at": videos["resets_at"],
154
  "nudges": build_nudges(db, workspace, plan, meters, can_upgrade),
backend/app/services/notestack_voices.py CHANGED
@@ -1,6 +1,5 @@
1
  """The workspace's own ElevenLabs voices, made or added on the Voice page: its clone, designed voices, and voices added
2
- from the ElevenLabs library. blog2video speaks with the same ElevenLabs account, so these can be used in videos too
3
- (as `custom_voice_id`); video_voices lists them and b2v_access accepts them."""
4
 
5
  import uuid
6
 
@@ -32,8 +31,8 @@ def is_notestack_voice(db: Session, workspace_id: uuid.UUID, voice_id: str) -> b
32
 
33
 
34
  def save_notestack_voice(db: Session, workspace_id: uuid.UUID, voice_id: str, name: str) -> UserSavedVoice:
35
- """Put a Notestack voice in the workspace's voices (the one list for audio and video), once. Saving needs no plan:
36
- using it in a video is premium (b2v_access.check_voice). No preview link: it is played through /api/voice/preview.
37
  The caller commits."""
38
  row = db.get(UserSavedVoice, {"workspace_id": workspace_id, "voice_id": voice_id})
39
  if row is None:
 
1
  """The workspace's own ElevenLabs voices, made or added on the Voice page: its clone, designed voices, and voices added
2
+ from the ElevenLabs library. They speak audio overviews; video_voices lists them with the workspace's saved voices."""
 
3
 
4
  import uuid
5
 
 
31
 
32
 
33
  def save_notestack_voice(db: Session, workspace_id: uuid.UUID, voice_id: str, name: str) -> UserSavedVoice:
34
+ """Put a Notestack voice in the workspace's voices (the one list for audio and video), once. Saving needs no plan; it speaks audio
35
+ overviews (a video uses the free built-in voices). No preview link: it is played through /api/voice/preview.
36
  The caller commits."""
37
  row = db.get(UserSavedVoice, {"workspace_id": workspace_id, "voice_id": voice_id})
38
  if row is None:
backend/app/services/plans.py CHANGED
@@ -31,36 +31,32 @@ class Plan:
31
  indexed_posts: int
32
  audio_minutes: int
33
  videos: int # blog2video videos per month, or in total when videos_monthly is False
34
- launch_kits: int # -1 = unlimited
35
  voice_cloning: bool
36
- brand_kit: bool
37
  features: tuple[str, ...]
38
- # Premium (★) video options: long durations, paid and custom voices, voice tuning and samples, AI script
39
- # rewrites, AI chat editing, avatars, the Remotion source download.
40
- video_premium: bool = False
41
  # Per-workspace shares of what blog2video only limits account-wide (keys in VIDEO_LIMITS; 0 = none).
42
  video_limits: dict = field(default_factory=dict)
43
  videos_monthly: bool = True
44
  reports: int = 0 # reports a month; -1 = unlimited
45
- infographics: int = 0 # infographics a month; -1 = unlimited
 
 
 
 
 
 
46
 
47
 
48
  # metric -> period it counts over (app/services/video_limits.py)
49
  VIDEO_LIMITS = {
50
- "ai_edits": "month", # scene regenerate/add, AI images, stock clips, AI chat, template AI edits, avatars
51
  "templates": "all", # custom templates: blog2video's slots are lifetime, never given back
52
  "template_ai_daily": "day", # template theme extraction from a prompt or document, code generation
53
- "voice_designs_daily": "day",
54
- "voice_samples_daily": "day",
55
- "custom_voices": "all", # kept voices; deleting one frees its place
56
  }
57
 
58
- FREE_VIDEO_LIMITS = {"ai_edits": 20, "templates": 0, "template_ai_daily": 0, "voice_designs_daily": 0,
59
- "voice_samples_daily": 0, "custom_voices": 0}
60
- WRITER_VIDEO_LIMITS = {"ai_edits": 300, "templates": 2, "template_ai_daily": 3, "voice_designs_daily": 5,
61
- "voice_samples_daily": 20, "custom_voices": 3}
62
- STUDIO_VIDEO_LIMITS = {"ai_edits": 1000, "templates": 5, "template_ai_daily": 5, "voice_designs_daily": 10,
63
- "voice_samples_daily": 40, "custom_voices": 10}
64
 
65
 
66
  DEFAULT_PLANS: dict[str, Plan] = {
@@ -69,23 +65,23 @@ DEFAULT_PLANS: dict[str, Plan] = {
69
  name="Free",
70
  tagline="For trying Notestack on your archive",
71
  price_monthly_usd=0,
72
- sources=1,
73
  indexed_posts=5,
74
- audio_minutes=3,
 
75
  videos=1,
76
  launch_kits=2,
77
  reports=2,
78
  infographics=2,
79
  voice_cloning=False,
80
- brand_kit=False,
81
  features=(
82
- "1 source, your latest 5 posts indexed",
83
  "Grounded research chat with citations",
84
- "3 min of audio overviews a month",
85
- "1 video to try it",
86
- "2 Launch Kits a month",
 
87
  "2 reports a month",
88
- "2 infographics a month",
89
  ),
90
  video_limits=FREE_VIDEO_LIMITS,
91
  videos_monthly=False,
@@ -95,26 +91,23 @@ DEFAULT_PLANS: dict[str, Plan] = {
95
  name="Writer",
96
  tagline="For writers publishing every week",
97
  price_monthly_usd=24.99,
98
- sources=3,
99
- indexed_posts=500,
100
  audio_minutes=60,
101
  videos=10,
102
- launch_kits=50,
103
  reports=30,
104
  infographics=30,
105
  voice_cloning=True,
106
- brand_kit=True,
107
  features=(
108
- "3 sources, 500 indexed posts",
109
- "60 min of audio overviews a month",
110
  "10 videos a month",
111
- "50 Launch Kits a month",
112
  "30 reports a month",
113
  "30 infographics a month",
114
  "Voice cloning with consent",
115
- "Your brand colors and logo",
116
  ),
117
- video_premium=True,
118
  video_limits=WRITER_VIDEO_LIMITS,
119
  ),
120
  "studio": Plan(
@@ -122,26 +115,24 @@ DEFAULT_PLANS: dict[str, Plan] = {
122
  name="Studio",
123
  tagline="For publications and power users",
124
  price_monthly_usd=48.99,
125
- sources=10,
126
- indexed_posts=5000,
127
  audio_minutes=240,
128
  videos=20,
129
- launch_kits=-1,
130
  reports=-1,
131
  infographics=-1,
132
  voice_cloning=True,
133
- brand_kit=True,
134
  features=(
135
- "10 sources, 5,000 indexed posts",
136
- "240 min of audio overviews a month",
137
  "20 videos a month",
138
- "Unlimited Launch Kits",
139
  "Unlimited reports",
140
  "Unlimited infographics",
141
  "Launchpad calendar and resurfacing",
142
  "Priority rendering",
143
  ),
144
- video_premium=True,
145
  video_limits=STUDIO_VIDEO_LIMITS,
146
  ),
147
  }
@@ -154,10 +145,10 @@ def _plan_from_row(row: PlanRecord) -> Plan:
154
  return Plan(id=row.id, name=row.name, tagline=row.tagline or "",
155
  price_monthly_usd=float(row.price_monthly_usd or 0), sources=row.sources,
156
  indexed_posts=row.indexed_posts, audio_minutes=row.audio_minutes,
157
- videos=row.videos, launch_kits=row.launch_kits, reports=row.reports, infographics=row.infographics,
158
  voice_cloning=bool(row.voice_cloning),
159
- brand_kit=bool(row.brand_kit), features=tuple(row.features or ()),
160
- video_premium=bool(row.video_premium), videos_monthly=bool(row.videos_monthly),
161
  video_limits={**{k: 0 for k in VIDEO_LIMITS}, **(row.video_limits or {})})
162
 
163
 
@@ -251,6 +242,22 @@ def effective_plan(db: Session, workspace: Workspace) -> Plan:
251
  return PLANS.get(sub.plan, PLANS["free"])
252
 
253
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  def plan_order() -> list[str]:
255
  return PLANS.order()
256
 
 
31
  indexed_posts: int
32
  audio_minutes: int
33
  videos: int # blog2video videos per month, or in total when videos_monthly is False
34
+ launch_kits: int # a month (or in total on a lifetime plan); -1 = unlimited
35
  voice_cloning: bool
 
36
  features: tuple[str, ...]
 
 
 
37
  # Per-workspace shares of what blog2video only limits account-wide (keys in VIDEO_LIMITS; 0 = none).
38
  video_limits: dict = field(default_factory=dict)
39
  videos_monthly: bool = True
40
  reports: int = 0 # reports a month; -1 = unlimited
41
+ infographics: int = 0 # infographics a month (in total on a lifetime plan); -1 = unlimited
42
+ audio_overviews: int = -1 # audio overviews allowed (in total on a lifetime plan), whatever their length; -1 = no cap
43
+
44
+ @property
45
+ def lifetime(self) -> bool:
46
+ """The allowances for videos, audio overviews, Launch Kits and infographics are totals that never reset (Free)."""
47
+ return not self.videos_monthly
48
 
49
 
50
  # metric -> period it counts over (app/services/video_limits.py)
51
  VIDEO_LIMITS = {
52
+ "ai_edits": "month", # scene regenerate/add, AI images, stock clips
53
  "templates": "all", # custom templates: blog2video's slots are lifetime, never given back
54
  "template_ai_daily": "day", # template theme extraction from a prompt or document, code generation
 
 
 
55
  }
56
 
57
+ FREE_VIDEO_LIMITS = {"ai_edits": 20, "templates": 0, "template_ai_daily": 0}
58
+ WRITER_VIDEO_LIMITS = {"ai_edits": 300, "templates": 2, "template_ai_daily": 3}
59
+ STUDIO_VIDEO_LIMITS = {"ai_edits": 1000, "templates": 5, "template_ai_daily": 5}
 
 
 
60
 
61
 
62
  DEFAULT_PLANS: dict[str, Plan] = {
 
65
  name="Free",
66
  tagline="For trying Notestack on your archive",
67
  price_monthly_usd=0,
68
+ sources=-1,
69
  indexed_posts=5,
70
+ audio_minutes=-1,
71
+ audio_overviews=1,
72
  videos=1,
73
  launch_kits=2,
74
  reports=2,
75
  infographics=2,
76
  voice_cloning=False,
 
77
  features=(
78
+ "Up to 5 posts indexed, from any source",
79
  "Grounded research chat with citations",
80
+ "1 audio overview, any length",
81
+ "1 video",
82
+ "2 Launch Kits",
83
+ "2 infographics",
84
  "2 reports a month",
 
85
  ),
86
  video_limits=FREE_VIDEO_LIMITS,
87
  videos_monthly=False,
 
91
  name="Writer",
92
  tagline="For writers publishing every week",
93
  price_monthly_usd=24.99,
94
+ sources=-1,
95
+ indexed_posts=100,
96
  audio_minutes=60,
97
  videos=10,
98
+ launch_kits=20,
99
  reports=30,
100
  infographics=30,
101
  voice_cloning=True,
 
102
  features=(
103
+ "Up to 100 posts indexed, from any source",
104
+ "10x more audio overviews than Free (60 min a month)",
105
  "10 videos a month",
106
+ "20 Launch Kits a month",
107
  "30 reports a month",
108
  "30 infographics a month",
109
  "Voice cloning with consent",
 
110
  ),
 
111
  video_limits=WRITER_VIDEO_LIMITS,
112
  ),
113
  "studio": Plan(
 
115
  name="Studio",
116
  tagline="For publications and power users",
117
  price_monthly_usd=48.99,
118
+ sources=-1,
119
+ indexed_posts=250,
120
  audio_minutes=240,
121
  videos=20,
122
+ launch_kits=50,
123
  reports=-1,
124
  infographics=-1,
125
  voice_cloning=True,
 
126
  features=(
127
+ "Up to 250 posts indexed, from any source",
128
+ "40x more audio overviews than Free (240 min a month)",
129
  "20 videos a month",
130
+ "50 Launch Kits a month",
131
  "Unlimited reports",
132
  "Unlimited infographics",
133
  "Launchpad calendar and resurfacing",
134
  "Priority rendering",
135
  ),
 
136
  video_limits=STUDIO_VIDEO_LIMITS,
137
  ),
138
  }
 
145
  return Plan(id=row.id, name=row.name, tagline=row.tagline or "",
146
  price_monthly_usd=float(row.price_monthly_usd or 0), sources=row.sources,
147
  indexed_posts=row.indexed_posts, audio_minutes=row.audio_minutes,
148
+ videos=row.videos, launch_kits=row.launch_kits, reports=row.reports, infographics=row.infographics, audio_overviews=row.audio_overviews,
149
  voice_cloning=bool(row.voice_cloning),
150
+ features=tuple(row.features or ()),
151
+ videos_monthly=bool(row.videos_monthly),
152
  video_limits={**{k: 0 for k in VIDEO_LIMITS}, **(row.video_limits or {})})
153
 
154
 
 
242
  return PLANS.get(sub.plan, PLANS["free"])
243
 
244
 
245
+ def post_room(db: Session, workspace_id, plan: Plan, source_id=None) -> int:
246
+ """How many more posts the plan lets this workspace index: its limit across every source, link and upload, less the
247
+ posts indexed already. With `source_id`, that source's own posts are left out of the count, and a source never loses
248
+ posts it already has indexed (a plan that went down keeps what is stored; it only stops new ones)."""
249
+ from sqlalchemy import func
250
+
251
+ from app.models import Document
252
+
253
+ base = select(func.count()).select_from(Document).where(Document.workspace_id == workspace_id, Document.path.is_not(None))
254
+ total = db.scalar(base) or 0
255
+ if source_id is None:
256
+ return max(plan.indexed_posts - total, 0)
257
+ own = db.scalar(base.where(Document.source_id == source_id)) or 0
258
+ return max(plan.indexed_posts - (total - own), own)
259
+
260
+
261
  def plan_order() -> list[str]:
262
  return PLANS.order()
263
 
backend/app/services/renderer.py CHANGED
@@ -57,15 +57,3 @@ def request_render(job: Job, artifact: Artifact, composition: str, props: dict |
57
  if resp.status_code == 400:
58
  raise PermanentJobError(f"Renderer rejected the props: {resp.text[:400]}")
59
  resp.raise_for_status()
60
-
61
-
62
- def brand_props(workspace, *, ttl: int = 6 * 3600) -> dict:
63
- brand = workspace.brand_json or {}
64
- out: dict = {}
65
- if brand.get("accent"):
66
- out["accent"] = brand["accent"]
67
- if brand.get("name"):
68
- out["name"] = brand["name"]
69
- if brand.get("logo_key"):
70
- out["logoUrl"] = storage.presign_get(brand["logo_key"], ttl=ttl)
71
- return out
 
57
  if resp.status_code == 400:
58
  raise PermanentJobError(f"Renderer rejected the props: {resp.text[:400]}")
59
  resp.raise_for_status()
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/services/usage.py CHANGED
@@ -5,7 +5,7 @@ from datetime import UTC, datetime
5
  from sqlalchemy import func, select
6
  from sqlalchemy.orm import Session
7
 
8
- from app.models import Artifact, UsageEvent, Workspace
9
  from app.services.plans import Plan, effective_plan, plan_limit_error
10
  from app.services.video_quota import video_usage
11
 
@@ -15,7 +15,9 @@ def month_start(now: datetime | None = None) -> datetime:
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:
@@ -26,26 +28,20 @@ def month_usage(db: Session, workspace_id) -> dict:
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
- reports = db.scalar(
35
- select(func.count()).select_from(Artifact).where(
36
- Artifact.workspace_id == workspace_id, Artifact.type == "report", Artifact.created_at >= since
37
- )
38
- ) or 0
39
- infographics = db.scalar(
40
- select(func.count()).select_from(Artifact).where(
41
- Artifact.workspace_id == workspace_id, Artifact.type == "infographic", Artifact.created_at >= since
42
- )
43
- ) or 0
44
  return {
45
  "audio_minutes": round(total("tts", "seconds") / 60, 1),
46
- "launch_kits": kits,
47
- "reports": reports,
48
- "infographics": infographics,
 
49
  "llm_tokens": int(total("llm", "tokens")),
50
  "since": since.isoformat(),
51
  }
@@ -53,15 +49,19 @@ def month_usage(db: Session, workspace_id) -> dict:
53
 
54
  def usage_report(db: Session, workspace: Workspace) -> dict:
55
  plan = effective_plan(db, workspace)
56
- used = month_usage(db, workspace.id)
57
  videos = video_usage(db, workspace)
58
  used["videos"] = videos["used"]
 
 
 
59
  return {
60
  "plan": plan.id,
61
  "used": used,
62
  "videos_resets_at": videos["resets_at"],
63
  "limits": {
64
  "audio_minutes": plan.audio_minutes,
 
65
  "videos": videos["limit"],
66
  "launch_kits": plan.launch_kits,
67
  "reports": plan.reports,
@@ -73,12 +73,13 @@ def usage_report(db: Session, workspace: Workspace) -> dict:
73
 
74
 
75
  def check_limit(db: Session, workspace: Workspace, kind: str, amount: float = 1) -> Plan:
76
- """Raise 402 plan_limit when this request would go over the monthly allowance."""
77
  plan = effective_plan(db, workspace)
78
- used = month_usage(db, workspace.id)
79
- limit = {"audio_minutes": plan.audio_minutes, "launch_kits": plan.launch_kits, "reports": plan.reports,
80
- "infographics": plan.infographics}[kind]
81
  if limit >= 0 and used[kind] + amount > limit:
82
  label = kind.replace("_", " ")
83
- raise plan_limit_error(plan, kind, f"This would go over your {limit} {label} this month.")
 
84
  return plan
 
5
  from sqlalchemy import func, select
6
  from sqlalchemy.orm import Session
7
 
8
+ from app.models import Artifact, Document, UsageEvent, Workspace
9
  from app.services.plans import Plan, effective_plan, plan_limit_error
10
  from app.services.video_quota import video_usage
11
 
 
15
  return now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
16
 
17
 
18
+ def month_usage(db: Session, workspace_id, lifetime: bool = False) -> dict:
19
+ """What this month has used. With `lifetime` (the Free plan) Launch Kits, infographics and audio overviews are
20
+ counted over all time instead, not counting the ones that failed (a failed one never uses up an allowance)."""
21
  since = month_start()
22
 
23
  def total(kind: str, unit: str) -> float:
 
28
  )
29
  ) or 0)
30
 
31
+ def made(type_: str, *, always_month: bool = False) -> int:
32
+ q = select(func.count()).select_from(Artifact).where(Artifact.workspace_id == workspace_id, Artifact.type == type_)
33
+ if lifetime and not always_month:
34
+ q = q.where(Artifact.status != "failed")
35
+ else:
36
+ q = q.where(Artifact.created_at >= since)
37
+ return db.scalar(q) or 0
38
+
 
 
 
 
 
 
 
39
  return {
40
  "audio_minutes": round(total("tts", "seconds") / 60, 1),
41
+ "audio_overviews": made("audio_overview"),
42
+ "launch_kits": made("launch_kit"),
43
+ "reports": made("report", always_month=True), # reports stay monthly on every plan
44
+ "infographics": made("infographic"),
45
  "llm_tokens": int(total("llm", "tokens")),
46
  "since": since.isoformat(),
47
  }
 
49
 
50
  def usage_report(db: Session, workspace: Workspace) -> dict:
51
  plan = effective_plan(db, workspace)
52
+ used = month_usage(db, workspace.id, plan.lifetime)
53
  videos = video_usage(db, workspace)
54
  used["videos"] = videos["used"]
55
+ used["indexed_posts"] = db.scalar(
56
+ select(func.count()).select_from(Document).where(Document.workspace_id == workspace.id, Document.path.is_not(None))
57
+ ) or 0
58
  return {
59
  "plan": plan.id,
60
  "used": used,
61
  "videos_resets_at": videos["resets_at"],
62
  "limits": {
63
  "audio_minutes": plan.audio_minutes,
64
+ "audio_overviews": plan.audio_overviews,
65
  "videos": videos["limit"],
66
  "launch_kits": plan.launch_kits,
67
  "reports": plan.reports,
 
73
 
74
 
75
  def check_limit(db: Session, workspace: Workspace, kind: str, amount: float = 1) -> Plan:
76
+ """Raise 402 plan_limit when this request would go over the allowance (monthly, or in total on Free)."""
77
  plan = effective_plan(db, workspace)
78
+ used = month_usage(db, workspace.id, plan.lifetime)
79
+ limit = {"audio_minutes": plan.audio_minutes, "audio_overviews": plan.audio_overviews,
80
+ "launch_kits": plan.launch_kits, "reports": plan.reports, "infographics": plan.infographics}[kind]
81
  if limit >= 0 and used[kind] + amount > limit:
82
  label = kind.replace("_", " ")
83
+ total = plan.lifetime and kind != "reports" # Free's allowances are totals, its reports are monthly
84
+ raise plan_limit_error(plan, kind, f"This would go over your {limit} {label}{' in total' if total else ' this month'}.")
85
  return plan
backend/app/services/video_limits.py CHANGED
@@ -1,5 +1,5 @@
1
  """Per-workspace shares of what blog2video only limits account-wide: AI edits, custom templates, template AI
2
- operations, voice designs, voice samples, custom voices (plans.VIDEO_LIMITS), plus premium (★) video options.
3
 
4
  blog2video checks every limit against our own (Pro) account, so every workspace passes its checks. These counters
5
  stop a workspace before it uses up everyone's share. Videos themselves are counted in video_quota.py.
@@ -21,9 +21,6 @@ LABELS = {
21
  "ai_edits": ("AI edits", "this month"),
22
  "templates": ("custom templates", "in total"),
23
  "template_ai_daily": ("AI template steps", "today"),
24
- "voice_designs_daily": ("voice designs", "today"),
25
- "voice_samples_daily": ("voice samples", "today"),
26
- "custom_voices": ("custom voices", "at a time"),
27
  }
28
 
29
 
@@ -68,7 +65,7 @@ def take(db: Session, workspace: Workspace, metric: str, n: int = 1, plan: Plan
68
 
69
 
70
  def give_back(db: Session, workspace_id: uuid.UUID, metric: str, n: int = 1) -> None:
71
- """Undo a take whose blog2video call failed (or a custom voice that was deleted)."""
72
  db.execute(update(UsageCounter)
73
  .where(UsageCounter.workspace_id == workspace_id, UsageCounter.period == period_key(metric),
74
  UsageCounter.metric == metric)
@@ -76,18 +73,10 @@ def give_back(db: Session, workspace_id: uuid.UUID, metric: str, n: int = 1) ->
76
  db.commit()
77
 
78
 
79
- def require_premium(db: Session, workspace: Workspace, what: str, plan: Plan | None = None) -> Plan:
80
- plan = plan or effective_plan(db, workspace)
81
- if not plan.video_premium:
82
- raise plan_limit_error(plan, "video_premium", f"{what} comes with a paid plan.")
83
- return plan
84
-
85
-
86
  def report(db: Session, workspace: Workspace) -> dict:
87
- """What the wizard shows: premium or not, and each counter's used / limit."""
88
  plan = effective_plan(db, workspace)
89
  rows = {(r.period, r.metric): r.used for r in db.scalars(
90
  select(UsageCounter).where(UsageCounter.workspace_id == workspace.id))}
91
- return {"premium": plan.video_premium,
92
- "limits": {m: {"used": rows.get((period_key(m), m), 0), "limit": int(plan.video_limits.get(m, 0))}
93
  for m in VIDEO_LIMITS}}
 
1
  """Per-workspace shares of what blog2video only limits account-wide: AI edits, custom templates, template AI
2
+ operations (plans.VIDEO_LIMITS).
3
 
4
  blog2video checks every limit against our own (Pro) account, so every workspace passes its checks. These counters
5
  stop a workspace before it uses up everyone's share. Videos themselves are counted in video_quota.py.
 
21
  "ai_edits": ("AI edits", "this month"),
22
  "templates": ("custom templates", "in total"),
23
  "template_ai_daily": ("AI template steps", "today"),
 
 
 
24
  }
25
 
26
 
 
65
 
66
 
67
  def give_back(db: Session, workspace_id: uuid.UUID, metric: str, n: int = 1) -> None:
68
+ """Undo a take whose blog2video call failed."""
69
  db.execute(update(UsageCounter)
70
  .where(UsageCounter.workspace_id == workspace_id, UsageCounter.period == period_key(metric),
71
  UsageCounter.metric == metric)
 
73
  db.commit()
74
 
75
 
 
 
 
 
 
 
 
76
  def report(db: Session, workspace: Workspace) -> dict:
77
+ """What the wizard shows: each counter's used / limit."""
78
  plan = effective_plan(db, workspace)
79
  rows = {(r.period, r.metric): r.used for r in db.scalars(
80
  select(UsageCounter).where(UsageCounter.workspace_id == workspace.id))}
81
+ return {"limits": {m: {"used": rows.get((period_key(m), m), 0), "limit": int(plan.video_limits.get(m, 0))}
 
82
  for m in VIDEO_LIMITS}}
backend/app/support/corpus.py CHANGED
@@ -83,7 +83,7 @@ def _plans_doc() -> Doc:
83
  f"{p.audio_minutes} minutes of audio a month, "
84
  f"{p.videos} videos {'a month' if p.videos_monthly else 'in total'}, {kits} Launch Kits a month, "
85
  f"{reports} reports a month, {infographics} infographics a month. "
86
- f"Voice cloning: {'yes' if p.voice_cloning else 'no'}. Brand kit: {'yes' if p.brand_kit else 'no'}.",
87
  "Includes: " + "; ".join(p.features) + ".",
88
  ]
89
  lines += [
 
83
  f"{p.audio_minutes} minutes of audio a month, "
84
  f"{p.videos} videos {'a month' if p.videos_monthly else 'in total'}, {kits} Launch Kits a month, "
85
  f"{reports} reports a month, {infographics} infographics a month. "
86
+ f"Voice cloning: {'yes' if p.voice_cloning else 'no'}.",
87
  "Includes: " + "; ".join(p.features) + ".",
88
  ]
89
  lines += [
backend/app/worker.py CHANGED
@@ -49,7 +49,7 @@ from app.pipeline import flashcards, generate, infographic, launchkit, media, qu
49
  from app.pipeline.generate import NothingToDo
50
  from app.pipeline.ingest import FeedNotFound, entry_from_upload, extract_article, ingest_source, store_entries
51
  from app.pipeline.memory import learn_from_message
52
- from app.services import launchpad, tts, video_quota
53
  from app.services.email import email_service
54
  from app.services.email_verification import purge_old_codes
55
  from app.services.jobs import (
@@ -62,7 +62,7 @@ from app.services.jobs import (
62
  update_job,
63
  )
64
  from app.services.notestack_voices import save_notestack_voice
65
- from app.services.plans import effective_plan
66
  from app.services.renderer import PermanentJobError, request_render
67
  from app.services.social.health import social_health
68
  from app.services.storage import keys, storage
@@ -102,8 +102,10 @@ def handle_ingest(db: Session, job: Job):
102
  if not source:
103
  return Done({"error": "source deleted"}, "Source no longer exists")
104
  plan = effective_plan(db, db.get(Workspace, source.workspace_id))
 
 
105
  try:
106
- result = ingest_source(db, source, job, max_posts=plan.indexed_posts)
107
  except Exception as exc:
108
  db.rollback()
109
  source.sync_status = "error"
@@ -155,6 +157,16 @@ def handle_chat_memory_cleanup(db: Session, job: Job):
155
  return Done({"removed": removed}, f"Removed {removed} orphaned chats")
156
 
157
 
 
 
 
 
 
 
 
 
 
 
158
  def handle_import_url(db: Session, job: Job):
159
  source = db.get(Source, uuid.UUID(job.params["source_id"]))
160
  update_job(db, job, progress=0.1, message="Fetching the article")
@@ -162,6 +174,7 @@ def handle_import_url(db: Session, job: Job):
162
  entry = extract_article(job.params["url"])
163
  except FeedNotFound as exc:
164
  raise PermanentJobError(str(exc)) from exc
 
165
  indexed, _, changed = store_entries(db, source, [entry])
166
  _after_import(db, job, source.workspace_id, [str(i) for i in changed])
167
  return Done({"indexed": indexed, "document_ids": [str(i) for i in changed]}, f"{entry.title[:80]} is in orbit")
@@ -174,6 +187,7 @@ def handle_import_upload(db: Session, job: Job):
174
  entry = entry_from_upload(upload.filename, upload.content_type, storage.get_bytes(upload.key))
175
  if not entry.sections and not entry.html:
176
  raise PermanentJobError("We could not read any text in that file.")
 
177
  indexed, _, changed = store_entries(db, source, [entry])
178
  _after_import(db, job, source.workspace_id, [str(i) for i in changed])
179
  return Done({"indexed": indexed, "document_ids": [str(i) for i in changed]}, f"{entry.title[:80]} is in orbit")
@@ -185,9 +199,16 @@ def handle_topics(db: Session, job: Job):
185
 
186
  def handle_ideas(db: Session, job: Job):
187
  result = generate.extract_ideas(db, job, job.workspace_id, job.params.get("document_ids"))
 
 
188
  return Done(result, f"Read {result['extracted']} posts for their ideas")
189
 
190
 
 
 
 
 
 
191
  def handle_voice_profile(db: Session, job: Job):
192
  try:
193
  result = generate.build_voice_profile(db, job, job.workspace_id, job.params.get("document_ids") or [])
@@ -282,6 +303,7 @@ HANDLERS: dict[str, Callable[[Session, Job], object]] = {
282
  "topics": handle_topics,
283
  "publish_post": handle_publish_post,
284
  "ideas": handle_ideas,
 
285
  "memory_update": handle_memory,
286
  "chat_memory": handle_chat_memory,
287
  "chat_memory_rebuild": handle_chat_memory_rebuild,
 
49
  from app.pipeline.generate import NothingToDo
50
  from app.pipeline.ingest import FeedNotFound, entry_from_upload, extract_article, ingest_source, store_entries
51
  from app.pipeline.memory import learn_from_message
52
+ from app.services import launchpad, report_suggestions, tts, video_quota
53
  from app.services.email import email_service
54
  from app.services.email_verification import purge_old_codes
55
  from app.services.jobs import (
 
62
  update_job,
63
  )
64
  from app.services.notestack_voices import save_notestack_voice
65
+ from app.services.plans import effective_plan, post_room
66
  from app.services.renderer import PermanentJobError, request_render
67
  from app.services.social.health import social_health
68
  from app.services.storage import keys, storage
 
102
  if not source:
103
  return Done({"error": "source deleted"}, "Source no longer exists")
104
  plan = effective_plan(db, db.get(Workspace, source.workspace_id))
105
+ # The plan limit is one total across every source, link and upload: this source gets what the others leave.
106
+ room = post_room(db, source.workspace_id, plan, source.id)
107
  try:
108
+ result = ingest_source(db, source, job, max_posts=room)
109
  except Exception as exc:
110
  db.rollback()
111
  source.sync_status = "error"
 
157
  return Done({"removed": removed}, f"Removed {removed} orphaned chats")
158
 
159
 
160
+ def _require_room(db: Session, source: Source, url: str) -> None:
161
+ """Re-reading a post that is already indexed is fine; a new one needs room under the plan's post limit."""
162
+ known = db.scalar(select(Document.id).where(Document.source_id == source.id, Document.url == url,
163
+ Document.path.is_not(None)))
164
+ plan = effective_plan(db, db.get(Workspace, source.workspace_id))
165
+ if not known and post_room(db, source.workspace_id, plan) <= 0:
166
+ raise PermanentJobError(f"Your plan indexes up to {plan.indexed_posts} posts, and they are all used. "
167
+ "Remove a post or upgrade to add more.")
168
+
169
+
170
  def handle_import_url(db: Session, job: Job):
171
  source = db.get(Source, uuid.UUID(job.params["source_id"]))
172
  update_job(db, job, progress=0.1, message="Fetching the article")
 
174
  entry = extract_article(job.params["url"])
175
  except FeedNotFound as exc:
176
  raise PermanentJobError(str(exc)) from exc
177
+ _require_room(db, source, entry.url)
178
  indexed, _, changed = store_entries(db, source, [entry])
179
  _after_import(db, job, source.workspace_id, [str(i) for i in changed])
180
  return Done({"indexed": indexed, "document_ids": [str(i) for i in changed]}, f"{entry.title[:80]} is in orbit")
 
187
  entry = entry_from_upload(upload.filename, upload.content_type, storage.get_bytes(upload.key))
188
  if not entry.sections and not entry.html:
189
  raise PermanentJobError("We could not read any text in that file.")
190
+ _require_room(db, source, entry.url)
191
  indexed, _, changed = store_entries(db, source, [entry])
192
  _after_import(db, job, source.workspace_id, [str(i) for i in changed])
193
  return Done({"indexed": indexed, "document_ids": [str(i) for i in changed]}, f"{entry.title[:80]} is in orbit")
 
199
 
200
  def handle_ideas(db: Session, job: Job):
201
  result = generate.extract_ideas(db, job, job.workspace_id, job.params.get("document_ids"))
202
+ if result["extracted"]:
203
+ report_suggestions.queue_warmup(db, job.workspace_id) # their notebooks' suggested report templates are written now
204
  return Done(result, f"Read {result['extracted']} posts for their ideas")
205
 
206
 
207
+ def handle_report_templates(db: Session, job: Job):
208
+ written = report_suggestions.warm_workspace(db, job.workspace_id)
209
+ return Done({"written": written}, f"Prepared report templates for {written} notebooks")
210
+
211
+
212
  def handle_voice_profile(db: Session, job: Job):
213
  try:
214
  result = generate.build_voice_profile(db, job, job.workspace_id, job.params.get("document_ids") or [])
 
303
  "topics": handle_topics,
304
  "publish_post": handle_publish_post,
305
  "ideas": handle_ideas,
306
+ "report_templates": handle_report_templates,
307
  "memory_update": handle_memory,
308
  "chat_memory": handle_chat_memory,
309
  "chat_memory_rebuild": handle_chat_memory_rebuild,
backend/scripts/render_theme_thumbnails.py CHANGED
@@ -1,12 +1,8 @@
1
- """Draws every infographic theme with the sample content (and, with --stress, the longest text allowed) using a local
2
- Chrome, so the themes can be checked by eye and the picker thumbnails regenerated.
3
 
4
- python scripts/render_theme_thumbnails.py # writes ../frontend/public/infographics/<theme>.png (small)
5
- python scripts/render_theme_thumbnails.py --stress # writes full-size stress renders to a temp folder
6
  Set CHROME to the browser binary if it is not found."""
7
 
8
- import glob
9
- import os
10
  import subprocess
11
  import sys
12
  import tempfile
@@ -14,32 +10,24 @@ from pathlib import Path
14
 
15
  sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
16
 
17
- from app.infographics.content import HEIGHT, SAMPLE, STRESS, WIDTH, clamp # noqa: E402
18
- from app.infographics.render import thumbnail_html # noqa: E402
19
- from app.infographics.themes import THEMES # noqa: E402
20
 
21
- def chrome() -> str:
22
- found = os.environ.get("CHROME") or next(iter(glob.glob(os.path.expanduser("~/.cache/puppeteer/chrome/*/chrome-mac-*/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"))), "") \
23
- or "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
24
- return found
25
 
26
-
27
- def shoot(html: str, out: Path) -> None:
28
  with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False) as f:
29
- f.write(html)
30
- subprocess.run([chrome(), "--headless=new", "--disable-gpu", "--hide-scrollbars", f"--window-size={WIDTH},{HEIGHT}",
31
- "--virtual-time-budget=8000", f"--screenshot={out}", f"file://{f.name}"], check=True, capture_output=True)
32
- os.unlink(f.name)
33
 
34
 
35
  if __name__ == "__main__":
36
- stress = "--stress" in sys.argv
37
- folder = Path(tempfile.gettempdir()) / "ig-stress" if stress else Path(__file__).resolve().parents[2] / "frontend/public/infographics"
38
  folder.mkdir(parents=True, exist_ok=True)
39
- content = clamp(STRESS if stress else SAMPLE)
40
- for tid in THEMES:
41
- out = folder / f"{tid}.png"
42
- shoot(thumbnail_html(tid, content), out)
43
- if not stress: # picker thumbnails: a third of the size is plenty
44
- subprocess.run(["sips", "--resampleWidth", "560", str(out)], check=False, capture_output=True)
45
  print(out)
 
1
+ """Draws each infographic look with the sample content using a local Chrome, so the picker thumbnails can be regenerated.
 
2
 
3
+ python scripts/render_theme_thumbnails.py # writes ../frontend/public/infographics/<look>.png (small)
 
4
  Set CHROME to the browser binary if it is not found."""
5
 
 
 
6
  import subprocess
7
  import sys
8
  import tempfile
 
10
 
11
  sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
12
 
13
+ from app.infographics.design import LOOKS, SAMPLE, render_design # noqa: E402
14
+ from app.infographics.image import chrome # noqa: E402
15
+ from app.infographics.render import SIZES # noqa: E402
16
 
 
 
 
 
17
 
18
+ def shoot(html: str, out: Path, layout: str) -> None:
19
+ width, height = SIZES[layout]
20
  with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False) as f:
21
+ f.write(f'<!doctype html><meta charset="utf-8"><body style="margin:0">{html}</body>')
22
+ subprocess.run([chrome(), "--headless=new", "--disable-gpu", "--no-sandbox", "--hide-scrollbars", f"--window-size={width},{height}",
23
+ "--force-device-scale-factor=2", "--virtual-time-budget=8000", f"--screenshot={out}", f"file://{f.name}"], check=True, capture_output=True)
 
24
 
25
 
26
  if __name__ == "__main__":
27
+ folder = Path(__file__).resolve().parents[2] / "frontend/public/infographics"
 
28
  folder.mkdir(parents=True, exist_ok=True)
29
+ for look in LOOKS:
30
+ out = folder / f"{look}.png"
31
+ shoot(render_design(look, SAMPLE, "landscape", fit=1.0), out, "landscape")
32
+ subprocess.run(["sips", "--resampleWidth", "1400", str(out)], check=False, capture_output=True) # drawn at 2x, kept at 1400 wide: sharp when shown large
 
 
33
  print(out)
backend/tests/test_billing.py CHANGED
@@ -3,7 +3,7 @@
3
  import pytest
4
 
5
  from app.config import settings
6
- from app.models import Artifact, Notebook, Workspace
7
  from app.services.billing import apply_subscription
8
  from tests.conftest import last_code
9
 
@@ -38,6 +38,8 @@ def test_free_plan_nudges_and_out_of_fuel_402(client, auth, billing_on, db_sessi
38
  assert body["plan"]["id"] == "free" and body["can_upgrade"] and body["next_plan"] == "writer"
39
  kits = next(m for m in body["meters"] if m["key"] == "launch_kits")
40
  assert kits["used"] == 2 and kits["pct"] == 1
 
 
41
  ids = [n["id"] for n in body["nudges"]]
42
  assert ids[0].startswith("empty-launch_kits") and "perk-voice" in ids
43
  assert "—" not in str(body)
@@ -45,10 +47,77 @@ def test_free_plan_nudges_and_out_of_fuel_402(client, auth, billing_on, db_sessi
45
  nb = Notebook(workspace_id=ws.id, title="Pricing")
46
  db_session.add(nb)
47
  db_session.commit()
48
- r = client.post("/api/artifacts/generate", headers=auth,
49
- json={"type": "audio_overview", "notebook_id": str(nb.id), "minutes": 6})
 
 
50
  detail = r.json()["detail"]
51
- assert r.status_code == 402 and detail["kind"] == "audio_minutes" and detail["upgrade_to"] == "writer"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
 
53
 
54
  def test_checkout_guards(client, auth, billing_on, db_session):
@@ -100,7 +169,7 @@ def plans_table(monkeypatch):
100
  db.add(PlanRecord(id=p.id, sort_order=i, name=p.name, tagline=p.tagline,
101
  price_monthly_usd=p.price_monthly_usd, sources=p.sources, indexed_posts=p.indexed_posts,
102
  audio_minutes=p.audio_minutes, videos=p.videos, launch_kits=p.launch_kits,
103
- voice_cloning=p.voice_cloning, brand_kit=p.brand_kit, features=list(p.features)))
104
  db.commit()
105
  PLANS.reload()
106
  yield session_factory
 
3
  import pytest
4
 
5
  from app.config import settings
6
+ from app.models import Artifact, Document, Notebook, Source, Workspace
7
  from app.services.billing import apply_subscription
8
  from tests.conftest import last_code
9
 
 
38
  assert body["plan"]["id"] == "free" and body["can_upgrade"] and body["next_plan"] == "writer"
39
  kits = next(m for m in body["meters"] if m["key"] == "launch_kits")
40
  assert kits["used"] == 2 and kits["pct"] == 1
41
+ more = {m["key"]: m for m in body["more_meters"]} # the rest of the plan, behind the sidebar card's chevron
42
+ assert set(more) == {"indexed_posts", "reports", "infographics"} and more["indexed_posts"]["limit"] == 5
43
  ids = [n["id"] for n in body["nudges"]]
44
  assert ids[0].startswith("empty-launch_kits") and "perk-voice" in ids
45
  assert "—" not in str(body)
 
47
  nb = Notebook(workspace_id=ws.id, title="Pricing")
48
  db_session.add(nb)
49
  db_session.commit()
50
+ # Free gets one audio overview in total, whatever its length (here 6 minutes), then it is used up for good.
51
+ body_in = {"type": "audio_overview", "notebook_id": str(nb.id), "minutes": 6}
52
+ assert client.post("/api/artifacts/generate", headers=auth, json=body_in).status_code == 200
53
+ r = client.post("/api/artifacts/generate", headers=auth, json=body_in)
54
  detail = r.json()["detail"]
55
+ assert r.status_code == 402 and detail["kind"] == "audio_overviews" and detail["upgrade_to"] == "writer"
56
+ assert "in total" in detail["message"]
57
+ audio = next(m for m in client.get("/api/billing/status", headers=auth).json()["meters"] if m["key"] == "audio_overviews")
58
+ assert audio["used"] == 1 and audio["limit"] == 1
59
+
60
+
61
+ def _index_posts(db, ws, n, source):
62
+ for i in range(n):
63
+ db.add(Document(workspace_id=ws.id, source_id=source.id, title=f"Post {i}", url=f"https://x.test/{source.title}/{i}",
64
+ path=f"sources/{source.title}/post-{i}.md"))
65
+ db.commit()
66
+
67
+
68
+ def test_post_limit_is_one_total_across_sources_links_and_uploads(client, auth, billing_on, db_session):
69
+ from app.services.plans import PLANS, post_room
70
+
71
+ ws = db_session.query(Workspace).one()
72
+ feed_a = Source(workspace_id=ws.id, feed_url="https://a.test/feed", title="a")
73
+ feed_b = Source(workspace_id=ws.id, feed_url="https://b.test/feed", title="b")
74
+ db_session.add_all([feed_a, feed_b])
75
+ db_session.commit()
76
+ free = PLANS["free"]
77
+ assert free.indexed_posts == 5 and free.sources == -1 # any number of sources, five posts in all
78
+ _index_posts(db_session, ws, 3, feed_a)
79
+ assert post_room(db_session, ws.id, free) == 2
80
+ assert post_room(db_session, ws.id, free, feed_b.id) == 2 # a second source gets what the first leaves
81
+ _index_posts(db_session, ws, 2, feed_b)
82
+ assert post_room(db_session, ws.id, free) == 0
83
+ assert post_room(db_session, ws.id, free, feed_a.id) == 3 # a source keeps the posts it already has
84
+
85
+ # A link or a file is refused with the upgrade prompt once all five are used.
86
+ r = client.post("/api/sources/url", headers=auth, json={"url": "https://example.com/a-post"})
87
+ assert r.status_code == 402 and r.json()["detail"]["kind"] == "indexed_posts" and r.json()["detail"]["upgrade_to"] == "writer"
88
+ assert client.post("/api/sources/upload", headers=auth, json={"upload_id": "00000000-0000-0000-0000-000000000000"}).status_code == 404
89
+ assert (PLANS["writer"].indexed_posts, PLANS["studio"].indexed_posts) == (100, 250)
90
+
91
+
92
+ def test_free_allowances_are_totals_and_paid_ones_are_monthly(client, auth, billing_on, db_session):
93
+ from app.services.plans import PLANS
94
+ from app.services.usage import check_limit, month_usage
95
+
96
+ ws = db_session.query(Workspace).one()
97
+ free, writer, studio = PLANS["free"], PLANS["writer"], PLANS["studio"]
98
+ assert (free.videos, free.audio_overviews, free.launch_kits, free.infographics) == (2, 1, 2, 2) and free.lifetime
99
+ assert (writer.launch_kits, writer.audio_minutes, writer.reports) == (20, 60, 30) and not writer.lifetime
100
+ assert (studio.launch_kits, studio.audio_minutes, studio.reports) == (50, 240, -1)
101
+
102
+ old = Artifact(workspace_id=ws.id, type="infographic", status="ready")
103
+ db_session.add_all([old, Artifact(workspace_id=ws.id, type="infographic", status="failed")])
104
+ db_session.commit()
105
+ # An infographic made in an earlier month still counts on Free, a failed one never does.
106
+ from datetime import UTC, datetime, timedelta
107
+
108
+ old.created_at = datetime.now(UTC) - timedelta(days=90)
109
+ db_session.commit()
110
+ assert month_usage(db_session, ws.id, lifetime=True)["infographics"] == 1
111
+ assert month_usage(db_session, ws.id, lifetime=False)["infographics"] == 1 # (the failed one is this month's)
112
+ db_session.add(Artifact(workspace_id=ws.id, type="infographic", status="ready"))
113
+ db_session.commit()
114
+ with pytest.raises(Exception) as err:
115
+ check_limit(db_session, ws, "infographics", 1)
116
+ assert err.value.status_code == 402 and "in total" in err.value.detail["message"]
117
+ # Deleting one gives its place back.
118
+ db_session.delete(old)
119
+ db_session.commit()
120
+ check_limit(db_session, ws, "infographics", 1)
121
 
122
 
123
  def test_checkout_guards(client, auth, billing_on, db_session):
 
169
  db.add(PlanRecord(id=p.id, sort_order=i, name=p.name, tagline=p.tagline,
170
  price_monthly_usd=p.price_monthly_usd, sources=p.sources, indexed_posts=p.indexed_posts,
171
  audio_minutes=p.audio_minutes, videos=p.videos, launch_kits=p.launch_kits,
172
+ voice_cloning=p.voice_cloning, features=list(p.features)))
173
  db.commit()
174
  PLANS.reload()
175
  yield session_factory
backend/tests/test_buildout.py CHANGED
@@ -939,11 +939,10 @@ def test_publish_now_with_media_goes_through_a_job(client, auth, db_session, mon
939
  def test_settings_and_usage(client, auth):
940
  s = client.get("/api/settings", headers=auth).json()
941
  assert s["user"]["name"] == "Ada Lovelace" and s["usage"]["used"]["launch_kits"] == 0
942
- r = client.patch("/api/settings", json={"name": "Ada L", "workspace_name": "Ada HQ", "brand_accent": "#FF0000",
943
  "training_opt_in": True}, headers=auth).json()
944
  assert r["user"]["name"] == "Ada L" and r["workspace"]["name"] == "Ada HQ"
945
- assert r["brand"]["accent"] == "#ff0000" and r["workspace"]["training_opt_in"] is True
946
- assert client.patch("/api/settings", json={"brand_accent": "red"}, headers=auth).status_code == 422
947
 
948
 
949
  def test_active_jobs_listing(client, auth, feed):
@@ -1657,14 +1656,7 @@ def test_flashcards_drop_repeats_empties_and_invented_sources(client, auth, run_
1657
 
1658
 
1659
  def _make_report(client, auth, run_jobs, llm, pricing, nb):
1660
- good = {"path": pricing["path"], "line_start": 1, "line_end": 3}
1661
- fake = {"path": "sources/fake.md", "line_start": 1, "line_end": 2}
1662
- llm["WriteReport"] = {"report_title": "Pricing Brief", "markdown": "## One\nFirst point [1].\n\n## Two\nSecond [2].",
1663
- "citations": [{"marker": 1, **good}, {"marker": 2, **fake}]}
1664
- llm["SuggestEmbeds"] = {"suggestions": [
1665
- {"after_block_id": "b1", "kind": "flashcards", "brief": "Terms", "why": "Learn them"},
1666
- {"after_block_id": "nope", "kind": "quiz", "brief": "x", "why": "y"},
1667
- {"after_block_id": "b2", "kind": "mind_map", "brief": "Links", "why": "See them"}]}
1668
  art = client.post("/api/artifacts/generate", json={
1669
  "type": "report", "notebook_id": nb["id"], "document_ids": [pricing["id"]], "report_format": "document",
1670
  "template_id": "briefing", "instructions": "Write a brief."}, headers=auth).json()
@@ -1672,15 +1664,15 @@ def _make_report(client, auth, run_jobs, llm, pricing, nb):
1672
  return client.get(f"/api/artifacts/{art['id']}", headers=auth).json()
1673
 
1674
 
1675
- def test_document_report_is_blocks_with_checked_citations_and_suggestions(client, auth, run_jobs, feed, llm):
1676
  pricing, nb = _notebook_with_pricing(client, auth, run_jobs)
1677
  done = _make_report(client, auth, run_jobs, llm, pricing, nb)
1678
  c = done["content"]
1679
  assert done["status"] == "ready" and done["type_label"] == "Report" and c["format"] == "document"
1680
  assert [b["type"] for b in c["blocks"]] == ["prose", "prose"] and c["blocks"][0]["id"] == "b1"
1681
- assert len(c["citations"]) == 1 and "[2]" not in c["blocks"][1]["text"] # the invented source was dropped
1682
- assert [s["after_block_id"] for s in c["suggestions"]] == ["b1", "b2"] # the unknown section was dropped
1683
- assert c["sources"][0]["title"] == "On Pricing"
1684
 
1685
 
1686
  def test_add_and_remove_a_suggested_visual(client, auth, run_jobs, feed, llm):
@@ -1688,15 +1680,22 @@ def test_add_and_remove_a_suggested_visual(client, auth, run_jobs, feed, llm):
1688
  done = _make_report(client, auth, run_jobs, llm, pricing, nb)
1689
  llm["GenerateFlashcards"] = {"cards": [{"front": "Term", "back": "Meaning", "sources": []}]}
1690
  r = client.post(f"/api/reports/{done['id']}/blocks", json={
1691
- "kind": "flashcards", "after_block_id": "b1", "suggestion_id": "s1", "brief": "Terms"}, headers=auth)
1692
  assert r.status_code == 200, r.text
1693
  run_jobs()
1694
  c = client.get(f"/api/artifacts/{done['id']}", headers=auth).json()["content"]
1695
  assert [b["type"] for b in c["blocks"]] == ["prose", "flashcards", "prose"]
1696
- assert c["blocks"][1]["after"] == "b1" and [s["id"] for s in c["suggestions"]] == ["s2"]
1697
  added = c["blocks"][1]["id"]
 
 
 
 
 
 
1698
  assert client.delete(f"/api/reports/{done['id']}/blocks/b1", headers=auth).status_code == 404 # prose stays
1699
  assert client.delete(f"/api/reports/{done['id']}/blocks/{added}", headers=auth).status_code == 200
 
1700
  # a kind of visual the report does not know is refused
1701
  assert client.post(f"/api/reports/{done['id']}/blocks", json={"kind": "poem"}, headers=auth).status_code == 422
1702
 
@@ -1743,7 +1742,7 @@ def test_interactive_report_only_suggests_visuals_until_the_reader_adds_them(cli
1743
  {"heading": "Compare", "brief": "b",
1744
  "embed": {"kind": "table", "title": "Options", "brief": "options", "why": "Not a kind we offer"}}]}
1745
  monkeypatch.setattr(run, "predict_many", lambda sig, inputs, **kw: [
1746
- {"markdown": f"Body {i} [1].", "citations": [{"marker": 1, **good}]} for i, _ in enumerate(inputs)])
1747
  art = client.post("/api/artifacts/generate", json={
1748
  "type": "report", "notebook_id": nb["id"], "document_ids": [pricing["id"]], "report_format": "interactive",
1749
  "instructions": "Make it interactive."}, headers=auth).json()
@@ -1751,9 +1750,11 @@ def test_interactive_report_only_suggests_visuals_until_the_reader_adds_them(cli
1751
  c = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()["content"]
1752
  assert c["format"] == "interactive"
1753
  assert [b["type"] for b in c["blocks"]] == ["prose", "prose", "prose"] # nothing is built until the reader asks
1754
- assert [(s["after_block_id"], s["kind"]) for s in c["suggestions"]] == [("b1", "flashcards")] # a table is not a visual we offer
 
 
1755
  assert "GenerateFlashcards" not in llm["_calls"]
1756
- assert [x["marker"] for x in c["citations"]] == [1, 2, 3] and "[3]" in c["blocks"][2]["text"] # renumbered
1757
 
1758
  r = client.post(f"/api/reports/{art['id']}/blocks", json={
1759
  "kind": "table", "after_block_id": "b3", "brief": "options"}, headers=auth)
@@ -1799,11 +1800,10 @@ def test_quiz_can_be_made_from_posts_and_chats_together(client, auth, run_jobs,
1799
  assert any(m.startswith("FILE ") for m in seen["material"]) and any(m.startswith("CHAT ") for m in seen["material"])
1800
 
1801
 
1802
- def test_a_quiz_section_the_ai_wrote_becomes_a_suggestion_unless_it_was_asked_for(client, auth, run_jobs, feed, llm):
1803
  pricing, nb = _notebook_with_pricing(client, auth, run_jobs)
1804
  llm["WriteReport"] = {"report_title": "Pricing", "citations": [], "markdown":
1805
  "## Why price\nText.\n\n## How to raise\nMore.\n\n## Quick quiz\n1. What is a price? (Answer: x)"}
1806
- llm["SuggestEmbeds"] = {"suggestions": []}
1807
 
1808
  def make(instructions):
1809
  art = client.post("/api/artifacts/generate", json={
@@ -1813,21 +1813,25 @@ def test_a_quiz_section_the_ai_wrote_becomes_a_suggestion_unless_it_was_asked_fo
1813
  return client.get(f"/api/artifacts/{art['id']}", headers=auth).json()["content"]
1814
 
1815
  c = make("Write a short brief.")
1816
- assert [b["text"].split("\n")[0] for b in c["blocks"]] == ["## Why price", "## How to raise"] # no quiz in the text
1817
- assert [(s["after_block_id"], s["kind"]) for s in c["suggestions"]] == [("b2", "quiz")] # offered, not added
1818
-
1819
- c = make("Write a study guide with a quiz at the end.") # the reader asked for it: it stays
1820
  assert [b["text"].split("\n")[0] for b in c["blocks"]][-1] == "## Quick quiz" and c["suggestions"] == []
1821
 
1822
 
1823
  # ---- infographics ----------------------------------------------------------------------------------------------
1824
 
1825
  def _fake_infographic(**over):
1826
- out = {"title": "Pricing <script>x</script> Plan", "subtitle": "How to raise prices", "items_label": "Ideas",
1827
- "items": [{"name": f"Idea {i}", "text": "A short plain sentence about it."} for i in range(4)],
1828
- "steps_label": "Steps", "steps": [{"name": f"Step {i}", "text": "Do the thing."} for i in range(5)],
 
 
1829
  "rule": "Raise slowly.", "notes": ["One.", "Two.", "Three."]}
1830
- return {"infographic": {**out, **over}}
 
 
 
 
 
 
1831
 
1832
 
1833
  def test_every_theme_escapes_and_clamps_model_text():
@@ -1844,22 +1848,26 @@ def test_every_theme_escapes_and_clamps_model_text():
1844
  html = build_html(tid, evil)
1845
  assert "<script" not in html and "onerror" not in html and "Hello" in html, tid
1846
  for tid in THEMES:
1847
- assert '<div class="mark">notestack.ai</div>' in build_html(tid, evil) # every theme carries the mark
 
1848
 
1849
 
1850
  def test_infographic_is_a_page_at_once_and_can_be_shared_by_link(client, auth, run_jobs, feed, llm):
1851
  pricing, nb = _notebook_with_pricing(client, auth, run_jobs)
1852
  llm["ExtractIdeas"] = {"ideas": []}
1853
- llm["PlanInfographic"] = _fake_infographic()
1854
  art = client.post("/api/artifacts/generate", json={"type": "infographic", "notebook_id": nb["id"],
1855
- "document_ids": [pricing["id"]], "theme": "galaxy", "instructions": "About raising prices"},
1856
  headers=auth).json()
1857
  assert art["type_label"] == "Infographic"
1858
  run_jobs()
1859
  done = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()
1860
- assert done["status"] == "ready" and done["content"]["theme"] == "galaxy" and not done["download_url"]
1861
- assert "Idea 0" in done["content"]["html"] and "<script" not in done["content"]["html"]
1862
- assert 'class="mark">notestack.ai' in done["content"]["html"]
 
 
 
1863
 
1864
  share = client.post(f"/api/artifacts/{art['id']}/share", headers=auth).json()
1865
  assert share["shared"] and "/r/" in share["url"]
@@ -1879,7 +1887,7 @@ def test_infographic_needs_a_source_and_enough_text_and_obeys_its_limit(client,
1879
 
1880
  pricing, nb = _notebook_with_pricing(client, auth, run_jobs)
1881
  llm["ExtractIdeas"] = {"ideas": []}
1882
- llm["PlanInfographic"] = _fake_infographic(items=[])
1883
  art = client.post("/api/artifacts/generate", json={"type": "infographic", "notebook_id": nb["id"],
1884
  "document_ids": [pricing["id"]]}, headers=auth).json()
1885
  run_jobs()
@@ -1903,15 +1911,95 @@ def test_a_report_can_add_an_infographic_that_shows_on_the_shared_page(client, a
1903
  pricing, nb = _notebook_with_pricing(client, auth, run_jobs)
1904
  done = _make_report(client, auth, run_jobs, llm, pricing, nb)
1905
  llm["ExtractIdeas"] = {"ideas": []}
1906
- llm["PlanInfographic"] = _fake_infographic()
1907
  r = client.post(f"/api/reports/{done['id']}/blocks", json={"kind": "infographic", "after_block_id": "b1",
1908
- "brief": "The steps to raise prices", "theme": "moonbase"}, headers=auth)
1909
  assert r.status_code == 200, r.text
1910
  run_jobs() # adds the block, and writes the infographic it points at
1911
  block = next(b for b in client.get(f"/api/artifacts/{done['id']}", headers=auth).json()["content"]["blocks"]
1912
  if b["type"] == "infographic")
1913
- assert block["theme"] == "moonbase" and block["status"] == "ready" and "Idea 0" in block["html"]
1914
  token = client.post(f"/api/artifacts/{done['id']}/share", headers=auth).json()["url"].rsplit("/", 1)[1]
1915
  public = client.get(f"/api/public/reports/{token}").json()
1916
  shared = next(b for b in public["blocks"] if b["type"] == "infographic")
1917
  assert "Idea 0" in shared["html"] and "artifact_id" not in shared and "theme" not in shared
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
939
  def test_settings_and_usage(client, auth):
940
  s = client.get("/api/settings", headers=auth).json()
941
  assert s["user"]["name"] == "Ada Lovelace" and s["usage"]["used"]["launch_kits"] == 0
942
+ r = client.patch("/api/settings", json={"name": "Ada L", "workspace_name": "Ada HQ",
943
  "training_opt_in": True}, headers=auth).json()
944
  assert r["user"]["name"] == "Ada L" and r["workspace"]["name"] == "Ada HQ"
945
+ assert r["workspace"]["training_opt_in"] is True and "brand" not in r
 
946
 
947
 
948
  def test_active_jobs_listing(client, auth, feed):
 
1656
 
1657
 
1658
  def _make_report(client, auth, run_jobs, llm, pricing, nb):
1659
+ llm["WriteReport"] = {"report_title": "Pricing Brief", "markdown": "## One\nFirst point [1].\n\n## Two\nSecond [2]."}
 
 
 
 
 
 
 
1660
  art = client.post("/api/artifacts/generate", json={
1661
  "type": "report", "notebook_id": nb["id"], "document_ids": [pricing["id"]], "report_format": "document",
1662
  "template_id": "briefing", "instructions": "Write a brief."}, headers=auth).json()
 
1664
  return client.get(f"/api/artifacts/{art['id']}", headers=auth).json()
1665
 
1666
 
1667
+ def test_document_report_is_blocks_without_citations_and_with_the_posts_it_was_made_from(client, auth, run_jobs, feed, llm):
1668
  pricing, nb = _notebook_with_pricing(client, auth, run_jobs)
1669
  done = _make_report(client, auth, run_jobs, llm, pricing, nb)
1670
  c = done["content"]
1671
  assert done["status"] == "ready" and done["type_label"] == "Report" and c["format"] == "document"
1672
  assert [b["type"] for b in c["blocks"]] == ["prose", "prose"] and c["blocks"][0]["id"] == "b1"
1673
+ assert c["citations"] == [] and not any("[" in b["text"] for b in c["blocks"]) # a report cites nothing: markers are removed
1674
+ assert c["suggestions"] == [] # a document never offers visuals; only an interactive report does
1675
+ assert c["sources"][0]["title"] == "On Pricing" and c["instructions"] == "Write a brief." # what the drop-down shows
1676
 
1677
 
1678
  def test_add_and_remove_a_suggested_visual(client, auth, run_jobs, feed, llm):
 
1680
  done = _make_report(client, auth, run_jobs, llm, pricing, nb)
1681
  llm["GenerateFlashcards"] = {"cards": [{"front": "Term", "back": "Meaning", "sources": []}]}
1682
  r = client.post(f"/api/reports/{done['id']}/blocks", json={
1683
+ "kind": "flashcards", "after_block_id": "b1", "brief": "Terms"}, headers=auth)
1684
  assert r.status_code == 200, r.text
1685
  run_jobs()
1686
  c = client.get(f"/api/artifacts/{done['id']}", headers=auth).json()["content"]
1687
  assert [b["type"] for b in c["blocks"]] == ["prose", "flashcards", "prose"]
1688
+ assert c["blocks"][1]["after"] == "b1" and c["suggestions"] == []
1689
  added = c["blocks"][1]["id"]
1690
+
1691
+ def saved_sets():
1692
+ items = client.get("/api/artifacts", params={"type": "flashcards", "notebook_id": nb["id"]}, headers=auth).json()
1693
+ return [a for a in (items["items"] if isinstance(items, dict) else items) if a["status"] == "ready"]
1694
+
1695
+ assert len(saved_sets()) == 1 # a set made in the report is also in the notebook's Studio list
1696
  assert client.delete(f"/api/reports/{done['id']}/blocks/b1", headers=auth).status_code == 404 # prose stays
1697
  assert client.delete(f"/api/reports/{done['id']}/blocks/{added}", headers=auth).status_code == 200
1698
+ assert len(saved_sets()) == 1 # still there to use again, and not saved twice
1699
  # a kind of visual the report does not know is refused
1700
  assert client.post(f"/api/reports/{done['id']}/blocks", json={"kind": "poem"}, headers=auth).status_code == 422
1701
 
 
1742
  {"heading": "Compare", "brief": "b",
1743
  "embed": {"kind": "table", "title": "Options", "brief": "options", "why": "Not a kind we offer"}}]}
1744
  monkeypatch.setattr(run, "predict_many", lambda sig, inputs, **kw: [
1745
+ {"markdown": f"Body {i} [1]."} for i, _ in enumerate(inputs)])
1746
  art = client.post("/api/artifacts/generate", json={
1747
  "type": "report", "notebook_id": nb["id"], "document_ids": [pricing["id"]], "report_format": "interactive",
1748
  "instructions": "Make it interactive."}, headers=auth).json()
 
1750
  c = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()["content"]
1751
  assert c["format"] == "interactive"
1752
  assert [b["type"] for b in c["blocks"]] == ["prose", "prose", "prose"] # nothing is built until the reader asks
1753
+ # a table is not a visual we offer; the plan suggested no infographic, so one is offered before the last section
1754
+ assert [(s["after_block_id"], s["kind"]) for s in c["suggestions"]] == [
1755
+ ("b1", "flashcards"), ("b2", "infographic"), ("b2", "quiz")] # filled up to three
1756
  assert "GenerateFlashcards" not in llm["_calls"]
1757
+ assert c["citations"] == [] and not any("[" in b["text"] for b in c["blocks"]) # nothing is cited
1758
 
1759
  r = client.post(f"/api/reports/{art['id']}/blocks", json={
1760
  "kind": "table", "after_block_id": "b3", "brief": "options"}, headers=auth)
 
1800
  assert any(m.startswith("FILE ") for m in seen["material"]) and any(m.startswith("CHAT ") for m in seen["material"])
1801
 
1802
 
1803
+ def test_a_document_report_keeps_whatever_sections_the_ai_wrote_and_suggests_nothing(client, auth, run_jobs, feed, llm):
1804
  pricing, nb = _notebook_with_pricing(client, auth, run_jobs)
1805
  llm["WriteReport"] = {"report_title": "Pricing", "citations": [], "markdown":
1806
  "## Why price\nText.\n\n## How to raise\nMore.\n\n## Quick quiz\n1. What is a price? (Answer: x)"}
 
1807
 
1808
  def make(instructions):
1809
  art = client.post("/api/artifacts/generate", json={
 
1813
  return client.get(f"/api/artifacts/{art['id']}", headers=auth).json()["content"]
1814
 
1815
  c = make("Write a short brief.")
 
 
 
 
1816
  assert [b["text"].split("\n")[0] for b in c["blocks"]][-1] == "## Quick quiz" and c["suggestions"] == []
1817
 
1818
 
1819
  # ---- infographics ----------------------------------------------------------------------------------------------
1820
 
1821
  def _fake_infographic(**over):
1822
+ nodes = "".join(f'<div class="node"><span class="n-no">0{i}</span><span class="n-t">Idea {i}</span>'
1823
+ '<span class="n-d">A short plain sentence about it.</span></div>' for i in range(4))
1824
+ out = {"title": "Pricing <script>x</script> Plan", "subtitle": "How to raise prices", "eyebrow": "Pricing", "key_figures": [{"value": "30%", "label": "Raise carefully"}, {"value": "x" * 40, "label": "<b>Long</b> one"}],
1825
+ "body_html": f'<section class="sec"><div class="sec-t">Ideas</div><div class="flow">{nodes}</div></section>'
1826
+ '<script>bad()</script><img src=x onerror=alert(1)><a href="http://evil.test">a link that stays as plain text</a>',
1827
  "rule": "Raise slowly.", "notes": ["One.", "Two.", "Three."]}
1828
+ return {"design": {**out, **over}}
1829
+
1830
+
1831
+ @pytest.fixture(autouse=True)
1832
+ def _no_chrome_fit(monkeypatch):
1833
+ """The fit of an infographic is measured in Chrome when it is made; tests do not need a browser."""
1834
+ monkeypatch.setattr("app.pipeline.infographic.measure_fit", lambda html, layout: 1.0)
1835
 
1836
 
1837
  def test_every_theme_escapes_and_clamps_model_text():
 
1848
  html = build_html(tid, evil)
1849
  assert "<script" not in html and "onerror" not in html and "Hello" in html, tid
1850
  for tid in THEMES:
1851
+ html = build_html(tid, evil)
1852
+ assert 'class="mark"' in html and "<span>notestack.ai</span>" in html # every theme carries the mark (logo and name)
1853
 
1854
 
1855
  def test_infographic_is_a_page_at_once_and_can_be_shared_by_link(client, auth, run_jobs, feed, llm):
1856
  pricing, nb = _notebook_with_pricing(client, auth, run_jobs)
1857
  llm["ExtractIdeas"] = {"ideas": []}
1858
+ llm["DesignInfographic"] = _fake_infographic()
1859
  art = client.post("/api/artifacts/generate", json={"type": "infographic", "notebook_id": nb["id"],
1860
+ "document_ids": [pricing["id"]], "theme": "paper", "ig_style": "flowchart", "instructions": "About raising prices"},
1861
  headers=auth).json()
1862
  assert art["type_label"] == "Infographic"
1863
  run_jobs()
1864
  done = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()
1865
+ assert done["status"] == "ready" and done["content"]["theme"] == "paper" and not done["download_url"]
1866
+ html = done["content"]["html"]
1867
+ assert "Idea 0" in html and "<script" not in html and "<img" not in html and "evil.test" not in html and "onerror" not in html
1868
+ assert 'class="fig-v">30%<' in html
1869
+ assert "notestack.ai" in html and "html_landscape" in done["content"] and "ig2 look-paper landscape" in done["content"]["html_landscape"]
1870
+ assert done["content"]["content"]["fit"] == {"landscape": 1.0, "portrait": 1.0}
1871
 
1872
  share = client.post(f"/api/artifacts/{art['id']}/share", headers=auth).json()
1873
  assert share["shared"] and "/r/" in share["url"]
 
1887
 
1888
  pricing, nb = _notebook_with_pricing(client, auth, run_jobs)
1889
  llm["ExtractIdeas"] = {"ideas": []}
1890
+ llm["DesignInfographic"] = _fake_infographic(body_html="")
1891
  art = client.post("/api/artifacts/generate", json={"type": "infographic", "notebook_id": nb["id"],
1892
  "document_ids": [pricing["id"]]}, headers=auth).json()
1893
  run_jobs()
 
1911
  pricing, nb = _notebook_with_pricing(client, auth, run_jobs)
1912
  done = _make_report(client, auth, run_jobs, llm, pricing, nb)
1913
  llm["ExtractIdeas"] = {"ideas": []}
1914
+ llm["DesignInfographic"] = _fake_infographic()
1915
  r = client.post(f"/api/reports/{done['id']}/blocks", json={"kind": "infographic", "after_block_id": "b1",
1916
+ "brief": "The steps to raise prices", "theme": "paper"}, headers=auth)
1917
  assert r.status_code == 200, r.text
1918
  run_jobs() # adds the block, and writes the infographic it points at
1919
  block = next(b for b in client.get(f"/api/artifacts/{done['id']}", headers=auth).json()["content"]["blocks"]
1920
  if b["type"] == "infographic")
1921
+ assert block["theme"] == "paper" and block["status"] == "ready" and "Idea 0" in block["html"]
1922
  token = client.post(f"/api/artifacts/{done['id']}/share", headers=auth).json()["url"].rsplit("/", 1)[1]
1923
  public = client.get(f"/api/public/reports/{token}").json()
1924
  shared = next(b for b in public["blocks"] if b["type"] == "infographic")
1925
  assert "Idea 0" in shared["html"] and "artifact_id" not in shared and "theme" not in shared
1926
+
1927
+
1928
+ def test_a_composed_infographic_keeps_only_allowed_markup_and_draws_in_every_look():
1929
+ from app.infographics.design import LOOKS, SAMPLE, build_content, render_design, sanitize_body
1930
+ from app.infographics.render import SIZES
1931
+
1932
+ dirty = ('<div class="node evil" style="x:y" onclick="a()" id="k"><span class="n-t">A node title long enough to count</span>'
1933
+ '<script>bad()</script><iframe src="http://x"></iframe><svg><circle/></svg><a href="http://e.test">words that stay</a>'
1934
+ '<span class="n-d">Another line of readable text here — with a dash</span></div>')
1935
+ clean = sanitize_body(dirty)
1936
+ assert clean and 'class="node"' in clean
1937
+ for bad in ("style", "onclick", "id=", "<script", "<iframe", "<svg", "<a ", "href", "evil.test", "—"):
1938
+ assert bad not in clean
1939
+ assert "words that stay" in clean
1940
+ assert sanitize_body("") is None and sanitize_body("<div></div>") is None and sanitize_body("<script>x()</script>") is None
1941
+ assert sanitize_body('<div class="node">' + "word " * 5000 + "</div>") is None # more than a poster holds
1942
+ figs = build_content({**SAMPLE, "title": "<b>Hi</b> there", "key_figures": [{"value": "9" * 40, "label": "<i>Up</i> a lot"}, {"value": "", "label": "none"}]})["key_figures"]
1943
+ assert figs == [{"value": "9" * 13 + "…", "label": "Up a lot"}]
1944
+ assert build_content({**SAMPLE, "body_html": "<p></p>"}) is None
1945
+ for look in LOOKS:
1946
+ for layout in SIZES:
1947
+ html = render_design(look, SAMPLE, layout)
1948
+ assert f"look-{look} {layout}" in html and "notestack.ai" in html and "<script" not in html
1949
+ assert "--fit:0.7" in render_design("mono", {**SAMPLE, "fit": {"portrait": 0.7}}, "portrait")
1950
+
1951
+
1952
+ def test_artifacts_can_be_rated_with_a_thumb_and_reasons(client, auth, run_jobs, feed, llm):
1953
+ pricing, nb = _notebook_with_pricing(client, auth, run_jobs)
1954
+ llm["ExtractIdeas"] = {"ideas": []}
1955
+ llm["DesignInfographic"] = _fake_infographic()
1956
+ art = client.post("/api/artifacts/generate", json={"type": "infographic", "notebook_id": nb["id"],
1957
+ "document_ids": [pricing["id"]]}, headers=auth).json()
1958
+ # Not finished yet: nothing to rate
1959
+ assert client.put(f"/api/artifacts/{art['id']}/feedback", json={"rating": "up"}, headers=auth).status_code == 400
1960
+ run_jobs()
1961
+ url = f"/api/artifacts/{art['id']}/feedback"
1962
+ assert client.get(f"/api/artifacts/{art['id']}", headers=auth).json()["feedback"] is None
1963
+
1964
+ up = client.put(url, json={"rating": "up"}, headers=auth).json()["feedback"]
1965
+ assert up == {"rating": "up", "reasons": [], "comment": None}
1966
+ listed = client.get(f"/api/notebooks/{nb['id']}/artifacts", headers=auth).json()
1967
+ assert next(a for a in listed if a["id"] == art["id"])["feedback"]["rating"] == "up"
1968
+
1969
+ # Rating again replaces it; unknown reasons are dropped, known ones for this kind kept; a thumbs up carries none
1970
+ down = client.put(url, json={"rating": "down", "reasons": ["messy_layout", "nonsense", "wrong_answers"],
1971
+ "comment": " Boxes overlap "}, headers=auth).json()["feedback"]
1972
+ assert down == {"rating": "down", "reasons": ["messy_layout"], "comment": "Boxes overlap"}
1973
+ assert client.get("/api/artifacts?type=infographic", headers=auth).json()["items"][0]["feedback"]["rating"] == "down"
1974
+ rows = client.get("/api/artifacts/feedback/export?rating=down", headers=auth).json()
1975
+ assert len(rows) == 1 and rows[0]["type"] == "infographic" and rows[0]["snapshot"]["layout"] == "composed"
1976
+ assert client.get("/api/artifacts/feedback/export?rating=up", headers=auth).json() == []
1977
+
1978
+ assert client.put(url, json={"rating": None}, headers=auth).json() == {"feedback": None}
1979
+ assert client.get(f"/api/artifacts/{art['id']}", headers=auth).json()["feedback"] is None
1980
+
1981
+ # Only reports, quizzes, flashcards and infographics are rated
1982
+ summary = client.post("/api/artifacts/generate", json={"type": "summary", "notebook_id": nb["id"]}, headers=auth).json()
1983
+ assert "feedback" not in summary
1984
+ assert client.put(f"/api/artifacts/{summary['id']}/feedback", json={"rating": "up"}, headers=auth).status_code == 400
1985
+
1986
+
1987
+ def test_rating_an_artifact_is_logged_without_the_comment_text(client, auth, run_jobs, feed, llm, caplog):
1988
+ import logging
1989
+
1990
+ pricing, nb = _notebook_with_pricing(client, auth, run_jobs)
1991
+ llm["ExtractIdeas"] = {"ideas": []}
1992
+ llm["DesignInfographic"] = _fake_infographic()
1993
+ art = client.post("/api/artifacts/generate", json={"type": "infographic", "notebook_id": nb["id"],
1994
+ "document_ids": [pricing["id"]]}, headers=auth).json()
1995
+ url = f"/api/artifacts/{art['id']}/feedback"
1996
+ with caplog.at_level(logging.INFO, logger="app.routers.artifacts"):
1997
+ client.put(url, json={"rating": "up"}, headers=auth) # not finished: refused, and said so
1998
+ run_jobs()
1999
+ client.put(url, json={"rating": "down", "reasons": ["messy_layout"], "comment": "private words here"}, headers=auth)
2000
+ client.get("/api/artifacts/feedback/export", headers=auth)
2001
+ client.put(url, json={"rating": None}, headers=auth)
2002
+ text = "\n".join(r.getMessage() for r in caplog.records)
2003
+ assert "feedback refused" in text and "feedback saved" in text and "feedback exported" in text and "feedback removed" in text
2004
+ assert art["id"] in text and "rating=down" in text and "messy_layout" in text and "comment_chars=18" in text
2005
+ assert "private words here" not in text
backend/tests/test_support.py CHANGED
@@ -232,7 +232,7 @@ def test_create_panel_buttons_in_the_docs_exist_in_the_app():
232
  """The docs name the Create panel's buttons; each must still be a real button in NotebookView."""
233
  view = (FRONTEND / "pages" / "NotebookView.tsx").read_text(encoding="utf-8")
234
  docs = " ".join(d.body for d in get_corpus())
235
- for label in ("Report", "Summary", "Infographic", "Quiz", "Flashcards", "Mind Constellation", "Video"):
236
  assert re.search(rf">\s*{label}\s*<", view) or re.search(rf"\n\s*{label}\n", view), f"{label} button is gone"
237
  assert f"**{label}**" in docs, f"{label} is not documented"
238
 
@@ -272,7 +272,6 @@ def test_plan_doc_matches_the_plan_table():
272
  ("why did I get an email reminder instead of an auto post", "launchpad"),
273
  ("how does voice cloning work", "voice-profile"),
274
  ("what is the topic map", "topic-map-and-resurfacing"),
275
- ("how do I add my logo and brand colors", "settings-and-account"),
276
  ("how do I delete my account", "settings-and-account"),
277
  ("how much does the writer plan cost", "plans-and-billing"),
278
  ("what is in the free plan", "plans-and-billing"),
 
232
  """The docs name the Create panel's buttons; each must still be a real button in NotebookView."""
233
  view = (FRONTEND / "pages" / "NotebookView.tsx").read_text(encoding="utf-8")
234
  docs = " ".join(d.body for d in get_corpus())
235
+ for label in ("Report", "Infographic", "Quiz", "Flashcards", "Mind Constellation", "Video"):
236
  assert re.search(rf">\s*{label}\s*<", view) or re.search(rf"\n\s*{label}\n", view), f"{label} button is gone"
237
  assert f"**{label}**" in docs, f"{label} is not documented"
238
 
 
272
  ("why did I get an email reminder instead of an auto post", "launchpad"),
273
  ("how does voice cloning work", "voice-profile"),
274
  ("what is the topic map", "topic-map-and-resurfacing"),
 
275
  ("how do I delete my account", "settings-and-account"),
276
  ("how much does the writer plan cost", "plans-and-billing"),
277
  ("what is in the free plan", "plans-and-billing"),
backend/tests/test_videos.py CHANGED
@@ -237,7 +237,7 @@ def test_link_create_forwards_every_wizard_field(client, b2v, owner, db_session)
237
  "video_length": "medium", "logo_position": "top_left", "logo_opacity": 0.5, "template": "geometric",
238
  "video_style": "storytelling", "accent_color": "#FF5A1F", "bg_color": "#0B0B0F", "text_color": "#FFFFFF",
239
  "content_language": "es", "voice_gender": "female", "voice_accent": "british",
240
- "custom_voice_id": SARAH, "voice_emotion": '["0.5","1.0","calm","0","1"]',
241
  "bgm_track_id": "corporate_upbeat", "bgm_volume": 0.2}
242
  art = make_video(client, headers, **body)
243
  call = creates(b2v)[0]
@@ -508,12 +508,9 @@ def test_foreign_references_are_refused_before_calling_out(client, b2v, owner, t
508
  def test_own_references_pass(client, b2v, owner, db_session):
509
  headers, ws = owner
510
  db_session.add_all([B2VTemplate(b2v_template_id=58, workspace_id=ws.id, name="Mine", ready=True),
511
- B2VStyle(b2v_style_id=7, workspace_id=ws.id, name="Mine"),
512
- B2VCustomVoice(b2v_custom_voice_id=31, workspace_id=ws.id, voice_id="vc_mine", name="M",
513
- source="prompt")])
514
  db_session.commit()
515
- make_video(client, headers, template="custom_58", video_style="custom:7", custom_voice_id="vc_mine")
516
- make_video(client, headers, template="crafted_neon")
517
 
518
 
519
  def test_template_still_generating_is_refused(client, b2v, owner, db_session):
@@ -566,7 +563,7 @@ def test_catalog_offers_only_the_house_styles(client, b2v, owner, house, db_sess
566
 
567
  @pytest.mark.parametrize("style,length", [("custom:20", "short"), ("custom:21", "medium")])
568
  def test_house_style_sets_the_length(client, b2v, owner, house, free_plan, style, length):
569
- make_video(client, owner[0], video_style=style, video_length="detailed")
570
  sent = creates(b2v)[-1]["json"]
571
  assert sent["video_style"] == style and sent["video_length"] == length
572
 
@@ -910,17 +907,15 @@ def test_chat_with_a_focus_keeps_the_chat_context_first(client, b2v, owner, hous
910
  assert content.startswith(CHAT_CONTEXT) and content.index('"Annual raises"') < content.index("User:")
911
 
912
 
913
- # Premium (★) and per-workspace counters
914
 
915
 
916
  @pytest.mark.parametrize("body", [{"video_length": "detailed"}, {"video_length": "more_detailed"},
917
- {"custom_voice_id": BELLA}, {"voice_emotion": '["0.5","1.0","calm","0","1"]'},
918
- {"template": "crafted_neon"}])
919
- def test_premium_options_refused_on_free(client, b2v, owner, free_plan, db_session, body):
920
  headers, ws = owner
921
  r = client.post("/api/videos", json={"url": "https://a.test/x", **body}, headers=headers)
922
- assert r.status_code == 402 and r.json()["detail"]["kind"] == "video_premium" and not creates(b2v)
923
- assert r.json()["detail"]["upgrade_to"] == "writer"
924
 
925
 
926
  def test_free_plan_can_make_a_plain_video(client, b2v, owner, free_plan):
@@ -944,18 +939,10 @@ def test_ai_edits_stop_at_the_workspace_limit(client, b2v, owner, db_session):
944
  used=1000))
945
  db_session.commit()
946
  b2v.calls.clear()
947
- r = client.post(f"/api/videos/{art['id']}/p/chat", json={"message": "shorter"}, headers=headers)
948
  assert r.status_code == 402 and r.json()["detail"]["kind"] == "video_limits.ai_edits" and b2v.calls == []
949
 
950
 
951
- def test_avatar_batch_charges_ten_per_scene(client, b2v, owner, db_session):
952
- headers, ws = owner
953
- art = make_video(client, headers)
954
- r = client.post(f"/api/videos/{art['id']}/p/avatar-batch/authorize", json={"scene_ids": [1, 2, 3]},
955
- headers=headers)
956
- assert r.status_code == 200 and video_limits.used(db_session, ws.id, "ai_edits") == 30
957
-
958
-
959
  def test_template_switch_uses_a_video_and_checks_the_template(client, b2v, owner, theirs, db_session):
960
  headers, ws = owner
961
  art = make_video(client, headers)
@@ -1019,12 +1006,14 @@ def test_editor_passes_downloads_through(client, b2v, owner):
1019
  assert r.status_code == 200 and r.content == b"ID3audio" and r.headers["content-type"] == "audio/mpeg"
1020
 
1021
 
1022
- def test_editor_premium_features_refused_on_free(client, b2v, owner, free_plan):
1023
  headers, _ = owner
1024
  art = make_video(client, headers)
1025
- for method, path in [("POST", "chat"), ("GET", "download-studio"), ("POST", "avatar-portrait")]:
1026
- r = client.request(method, f"/api/videos/{art['id']}/p/{path}", json={}, headers=headers)
1027
- assert r.status_code == 402, path
 
 
1028
 
1029
 
1030
  # Script review
@@ -1036,9 +1025,9 @@ def test_script_review_flow(client, b2v, owner, db_session):
1036
  scenes = client.get(f"/api/videos/{art['id']}/script", headers=headers).json()["scenes"]
1037
  assert [s["id"] for s in scenes] == [1, 2]
1038
  draft = {"title": "A", "display_text": "Text", "narration_text": "", "draft_scenes": [], "revision": 1}
1039
- client.post(f"/api/videos/{art['id']}/script/scenes/1/ai-preview", json={**draft, "instruction": "punchier"},
1040
- headers=headers)
1041
- assert video_limits.used(db_session, ws.id, "ai_edits") == 1
1042
  r = client.post(f"/api/videos/{art['id']}/script/approve",
1043
  json={"scenes": [{"id": 1, "title": "A", "narration_text": "Hi"}]}, headers=headers)
1044
  assert r.status_code == 200 and "/api/projects/812/script-review/approve" in b2v.paths("POST")
@@ -1136,12 +1125,14 @@ def test_capacity_alert(b2v, caplog):
1136
  def test_new_workspace_gets_starter_voices_and_library(client, b2v, owner):
1137
  got = client.get("/api/video-voices", headers=owner[0]).json()
1138
  assert [v["voice_id"] for v in got["saved"]] == [SARAH] # free voices only
1139
- assert {v["voice_id"]: v["premium"] for v in got["library"]} == {SARAH: False, BELLA: True}
 
1140
 
1141
 
1142
- def test_saving_a_paid_voice_needs_premium(client, b2v, owner, free_plan):
1143
- r = client.post("/api/video-voices/saved", json={"voice_id": BELLA}, headers=owner[0])
1144
- assert r.status_code == 402
 
1145
 
1146
 
1147
  def test_saved_voices_never_touch_blog2video_saved_list(client, b2v, owner, db_session):
@@ -1163,36 +1154,22 @@ def test_any_number_of_voices_can_be_saved(client, b2v, owner, db_session):
1163
  for i in range(7):
1164
  db_session.add(UserSavedVoice(workspace_id=ws.id, voice_id=f"v{i}", name=f"V{i}"))
1165
  db_session.commit()
1166
- assert client.post("/api/video-voices/saved", json={"voice_id": BELLA}, headers=headers).status_code == 201
1167
  got = client.get("/api/video-voices", headers=headers).json()
1168
- assert len(got["saved"]) == 9 and "max_saved" not in got
1169
 
1170
 
1171
- def test_notestack_voices_can_be_saved_and_used_in_a_video(client, b2v, owner, other, db_session):
1172
  headers, ws = owner
1173
  notestack_voice(db_session, ws)
1174
  got = client.get("/api/video-voices", headers=headers).json()
1175
  assert got["notestack"] == [{"voice_id": "nv_narrator", "name": "Narrator", "kind": "designed", "saved": False}]
1176
  r = client.post("/api/video-voices/saved", json={"voice_id": "nv_narrator"}, headers=headers)
1177
- assert r.status_code == 201 and r.json()["source"] == "notestack" and r.json()["premium"]
1178
  saved = client.get("/api/video-voices", headers=headers).json()["saved"]
1179
- assert [v["source"] for v in saved if v["voice_id"] == "nv_narrator"] == ["notestack"]
1180
- make_video(client, headers, custom_voice_id="nv_narrator")
1181
- assert creates(b2v)[-1]["json"]["custom_voice_id"] == "nv_narrator"
1182
- # Another workspace cannot use it.
1183
- r = client.post("/api/videos", json={"url": "https://a.test/x", "custom_voice_id": "nv_narrator"},
1184
- headers=other[0])
1185
- assert r.status_code == 404
1186
-
1187
-
1188
- def test_notestack_voices_are_saved_on_any_plan_but_need_premium_in_videos(client, b2v, owner, db_session,
1189
- free_plan):
1190
- headers, ws = owner
1191
- notestack_voice(db_session, ws)
1192
- assert client.post("/api/video-voices/saved", json={"voice_id": "nv_narrator"},
1193
- headers=headers).status_code == 201 # in the list, for audio overviews
1194
  r = client.post("/api/videos", json={"url": "https://a.test/x", "custom_voice_id": "nv_narrator"}, headers=headers)
1195
- assert r.status_code == 402 and not creates(b2v)
1196
 
1197
 
1198
  def test_revoking_the_clone_drops_it_from_video_voices(client, b2v, owner, db_session, monkeypatch):
@@ -1207,37 +1184,11 @@ def test_revoking_the_clone_drops_it_from_video_voices(client, b2v, owner, db_se
1207
  assert "nv_clone" not in [v["voice_id"] for v in client.get("/api/video-voices", headers=headers).json()["saved"]]
1208
 
1209
 
1210
- def test_design_keep_and_delete_a_custom_voice(client, b2v, owner, other, db_session):
1211
- headers, ws = owner
1212
- previews = client.post("/api/video-voices/design/prompt", json={"prompt": "A calm, warm narrator voice please"},
1213
- headers=headers).json()["previews"]
1214
- kept = client.post("/api/video-voices/custom", json={"generated_voice_id": previews[0]["generated_voice_id"],
1215
- "source": "prompt", "name": "Narrator"}, headers=headers)
1216
- assert kept.status_code == 201
1217
- assert db_session.get(UserSavedVoice, {"workspace_id": ws.id, "voice_id": "vc_designed"}).is_custom
1218
- assert video_limits.used(db_session, ws.id, "voice_designs_daily") == 1
1219
- assert video_limits.used(db_session, ws.id, "custom_voices") == 1
1220
- # Another workspace can not play or delete it, nor use it on a video.
1221
- assert client.get("/api/video-voices/custom/31/preview", headers=other[0]).status_code == 404
1222
- assert client.delete("/api/video-voices/custom/31", headers=other[0]).status_code == 404
1223
- r = client.post("/api/videos", json={"url": "https://a.test/x", "custom_voice_id": "vc_designed"},
1224
- headers=other[0])
1225
- assert r.status_code == 404
1226
- assert client.delete("/api/video-voices/custom/31", headers=headers).status_code == 200
1227
- assert video_limits.used(db_session, ws.id, "custom_voices") == 0
1228
-
1229
-
1230
- def test_clone_voice(client, b2v, owner):
1231
- r = client.post("/api/video-voices/clone", data={"name": "Me"}, files={"file": ("me.mp3", b"ID3", "audio/mpeg")},
1232
- headers=owner[0])
1233
- assert r.status_code == 201 and r.json()["voice_id"] == "vc_clone"
1234
-
1235
-
1236
- def test_voice_sample_returns_audio_and_counts(client, b2v, owner, db_session):
1237
- headers, ws = owner
1238
- r = client.post("/api/video-voices/sample", json={"custom_voice_id": SARAH}, headers=headers)
1239
- assert r.status_code == 200 and r.content == b"ID3audio"
1240
- assert video_limits.used(db_session, ws.id, "voice_samples_daily") == 1
1241
 
1242
 
1243
  # Styles
@@ -1324,7 +1275,7 @@ def test_sync_follows_effective_plan(client, owner, db_session, monkeypatch):
1324
  sub.status = "canceled"
1325
  db_session.commit()
1326
  sub = video_quota.sync_video_quota(db_session, ws)
1327
- assert (sub.video_plan, sub.video_limit) == ("free", 1)
1328
 
1329
 
1330
  def test_monthly_fallback_reset(client, owner, db_session, session_factory):
@@ -1405,15 +1356,16 @@ def test_plan_video_limits(client, owner, db_session, monkeypatch):
1405
  _, ws = owner
1406
  monkeypatch.setattr(settings, "billing_enabled", True)
1407
  video_quota.sync_video_quota(db_session, ws)
1408
- for plan, limit in (("free", 1), ("writer", 10), ("studio", 20)):
1409
  _on_plan(db_session, ws, plan)
1410
  assert video_quota.sync_video_quota(db_session, ws).video_limit == limit
1411
 
1412
 
1413
- def test_free_is_one_video_in_total(client, b2v, owner, free_plan, db_session, session_factory):
1414
  headers, ws = owner
1415
  make_video(client, headers)
1416
- r = client.post("/api/videos", json={"url": "https://a.test/2"}, headers=headers)
 
1417
  assert r.status_code == 402
1418
  usage = client.get("/api/videos/quota", headers=headers).json()
1419
  assert usage["resets_at"] is None
@@ -1424,7 +1376,7 @@ def test_free_is_one_video_in_total(client, b2v, owner, free_plan, db_session, s
1424
  # Deleting the video does not give it back.
1425
  art_id = db_session.scalar(select(B2VVideo)).artifact_id
1426
  client.delete(f"/api/videos/{art_id}", headers=headers)
1427
- assert client.post("/api/videos", json={"url": "https://a.test/3"}, headers=headers).status_code == 402
1428
 
1429
 
1430
  def test_free_video_that_failed_is_given_back(client, b2v, owner, free_plan, db_session):
@@ -1443,7 +1395,7 @@ def test_dropping_to_free_counts_what_was_made(client, b2v, owner, db_session, m
1443
  make_video(client, headers, url=f"https://a.test/{i}")
1444
  _on_plan(db_session, ws, "free")
1445
  usage = client.get("/api/videos/quota", headers=headers).json()
1446
- assert (usage["used"], usage["limit"]) == (3, 1)
1447
  assert client.post("/api/videos", json={"url": "https://a.test/x"}, headers=headers).status_code == 402
1448
 
1449
 
 
237
  "video_length": "medium", "logo_position": "top_left", "logo_opacity": 0.5, "template": "geometric",
238
  "video_style": "storytelling", "accent_color": "#FF5A1F", "bg_color": "#0B0B0F", "text_color": "#FFFFFF",
239
  "content_language": "es", "voice_gender": "female", "voice_accent": "british",
240
+ "custom_voice_id": SARAH,
241
  "bgm_track_id": "corporate_upbeat", "bgm_volume": 0.2}
242
  art = make_video(client, headers, **body)
243
  call = creates(b2v)[0]
 
508
  def test_own_references_pass(client, b2v, owner, db_session):
509
  headers, ws = owner
510
  db_session.add_all([B2VTemplate(b2v_template_id=58, workspace_id=ws.id, name="Mine", ready=True),
511
+ B2VStyle(b2v_style_id=7, workspace_id=ws.id, name="Mine")])
 
 
512
  db_session.commit()
513
+ make_video(client, headers, template="custom_58", video_style="custom:7", custom_voice_id=SARAH)
 
514
 
515
 
516
  def test_template_still_generating_is_refused(client, b2v, owner, db_session):
 
563
 
564
  @pytest.mark.parametrize("style,length", [("custom:20", "short"), ("custom:21", "medium")])
565
  def test_house_style_sets_the_length(client, b2v, owner, house, free_plan, style, length):
566
+ make_video(client, owner[0], video_style=style, video_length="short" if style == "custom:21" else "medium")
567
  sent = creates(b2v)[-1]["json"]
568
  assert sent["video_style"] == style and sent["video_length"] == length
569
 
 
907
  assert content.startswith(CHAT_CONTEXT) and content.index('"Annual raises"') < content.index("User:")
908
 
909
 
910
+ # The standard options only, and per-workspace counters
911
 
912
 
913
  @pytest.mark.parametrize("body", [{"video_length": "detailed"}, {"video_length": "more_detailed"},
914
+ {"custom_voice_id": BELLA}, {"template": "crafted_neon"}])
915
+ def test_options_that_are_not_offered_are_refused_on_every_plan(client, b2v, owner, body):
 
916
  headers, ws = owner
917
  r = client.post("/api/videos", json={"url": "https://a.test/x", **body}, headers=headers)
918
+ assert r.status_code in (400, 404, 422) and not creates(b2v)
 
919
 
920
 
921
  def test_free_plan_can_make_a_plain_video(client, b2v, owner, free_plan):
 
939
  used=1000))
940
  db_session.commit()
941
  b2v.calls.clear()
942
+ r = client.post(f"/api/videos/{art['id']}/p/scenes/1/regenerate", json={"description": "shorter"}, headers=headers)
943
  assert r.status_code == 402 and r.json()["detail"]["kind"] == "video_limits.ai_edits" and b2v.calls == []
944
 
945
 
 
 
 
 
 
 
 
 
946
  def test_template_switch_uses_a_video_and_checks_the_template(client, b2v, owner, theirs, db_session):
947
  headers, ws = owner
948
  art = make_video(client, headers)
 
1006
  assert r.status_code == 200 and r.content == b"ID3audio" and r.headers["content-type"] == "audio/mpeg"
1007
 
1008
 
1009
+ def test_removed_editor_features_are_not_proxied(client, b2v, owner):
1010
  headers, _ = owner
1011
  art = make_video(client, headers)
1012
+ b2v.calls.clear()
1013
+ for method, path in [("POST", "chat"), ("GET", "chat/history"), ("GET", "download-studio"),
1014
+ ("POST", "avatar-portrait"), ("POST", "avatar-batch/authorize")]:
1015
+ assert client.request(method, f"/api/videos/{art['id']}/p/{path}", json={}, headers=headers).status_code == 404
1016
+ assert b2v.calls == []
1017
 
1018
 
1019
  # Script review
 
1025
  scenes = client.get(f"/api/videos/{art['id']}/script", headers=headers).json()["scenes"]
1026
  assert [s["id"] for s in scenes] == [1, 2]
1027
  draft = {"title": "A", "display_text": "Text", "narration_text": "", "draft_scenes": [], "revision": 1}
1028
+ r = client.post(f"/api/videos/{art['id']}/script/scenes/1/ai-preview", json={**draft, "instruction": "punchier"},
1029
+ headers=headers)
1030
+ assert r.status_code in (404, 405) # rewriting a scene with AI is not offered
1031
  r = client.post(f"/api/videos/{art['id']}/script/approve",
1032
  json={"scenes": [{"id": 1, "title": "A", "narration_text": "Hi"}]}, headers=headers)
1033
  assert r.status_code == 200 and "/api/projects/812/script-review/approve" in b2v.paths("POST")
 
1125
  def test_new_workspace_gets_starter_voices_and_library(client, b2v, owner):
1126
  got = client.get("/api/video-voices", headers=owner[0]).json()
1127
  assert [v["voice_id"] for v in got["saved"]] == [SARAH] # free voices only
1128
+ assert [v["voice_id"] for v in got["library"]] == [SARAH] # the paid voice is not offered
1129
+ assert "custom" not in got and all("premium" not in v for v in got["saved"] + got["library"])
1130
 
1131
 
1132
+ def test_a_paid_voice_cannot_be_saved_or_used_on_any_plan(client, b2v, owner):
1133
+ assert client.post("/api/video-voices/saved", json={"voice_id": BELLA}, headers=owner[0]).status_code == 404
1134
+ r = client.post("/api/videos", json={"url": "https://a.test/x", "custom_voice_id": BELLA}, headers=owner[0])
1135
+ assert r.status_code == 404 and not creates(b2v)
1136
 
1137
 
1138
  def test_saved_voices_never_touch_blog2video_saved_list(client, b2v, owner, db_session):
 
1154
  for i in range(7):
1155
  db_session.add(UserSavedVoice(workspace_id=ws.id, voice_id=f"v{i}", name=f"V{i}"))
1156
  db_session.commit()
1157
+ assert client.post("/api/video-voices/saved", json={"voice_id": SARAH}, headers=headers).status_code in (200, 201)
1158
  got = client.get("/api/video-voices", headers=headers).json()
1159
+ assert len(got["saved"]) == 8 and "max_saved" not in got
1160
 
1161
 
1162
+ def test_notestack_voices_are_saved_for_audio_but_not_used_in_videos(client, b2v, owner, other, db_session):
1163
  headers, ws = owner
1164
  notestack_voice(db_session, ws)
1165
  got = client.get("/api/video-voices", headers=headers).json()
1166
  assert got["notestack"] == [{"voice_id": "nv_narrator", "name": "Narrator", "kind": "designed", "saved": False}]
1167
  r = client.post("/api/video-voices/saved", json={"voice_id": "nv_narrator"}, headers=headers)
1168
+ assert r.status_code == 201 and r.json()["source"] == "notestack"
1169
  saved = client.get("/api/video-voices", headers=headers).json()["saved"]
1170
+ assert [v["source"] for v in saved if v["voice_id"] == "nv_narrator"] == ["notestack"] # kept, for audio overviews
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1171
  r = client.post("/api/videos", json={"url": "https://a.test/x", "custom_voice_id": "nv_narrator"}, headers=headers)
1172
+ assert r.status_code == 404 and not creates(b2v) # a video uses the free built-in voices
1173
 
1174
 
1175
  def test_revoking_the_clone_drops_it_from_video_voices(client, b2v, owner, db_session, monkeypatch):
 
1184
  assert "nv_clone" not in [v["voice_id"] for v in client.get("/api/video-voices", headers=headers).json()["saved"]]
1185
 
1186
 
1187
+ def test_designing_cloning_and_sampling_voices_for_videos_are_gone(client, b2v, owner):
1188
+ headers, _ = owner
1189
+ for method, path in [("POST", "design/prompt"), ("POST", "design/preset"), ("POST", "custom"), ("POST", "clone"),
1190
+ ("POST", "sample"), ("GET", "custom/31/preview"), ("DELETE", "custom/31")]:
1191
+ assert client.request(method, f"/api/video-voices/{path}", json={}, headers=headers).status_code in (404, 405), path
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1192
 
1193
 
1194
  # Styles
 
1275
  sub.status = "canceled"
1276
  db_session.commit()
1277
  sub = video_quota.sync_video_quota(db_session, ws)
1278
+ assert (sub.video_plan, sub.video_limit) == ("free", 2)
1279
 
1280
 
1281
  def test_monthly_fallback_reset(client, owner, db_session, session_factory):
 
1356
  _, ws = owner
1357
  monkeypatch.setattr(settings, "billing_enabled", True)
1358
  video_quota.sync_video_quota(db_session, ws)
1359
+ for plan, limit in (("free", 2), ("writer", 10), ("studio", 20)):
1360
  _on_plan(db_session, ws, plan)
1361
  assert video_quota.sync_video_quota(db_session, ws).video_limit == limit
1362
 
1363
 
1364
+ def test_free_is_two_videos_in_total(client, b2v, owner, free_plan, db_session, session_factory):
1365
  headers, ws = owner
1366
  make_video(client, headers)
1367
+ make_video(client, headers, url="https://a.test/2")
1368
+ r = client.post("/api/videos", json={"url": "https://a.test/3"}, headers=headers)
1369
  assert r.status_code == 402
1370
  usage = client.get("/api/videos/quota", headers=headers).json()
1371
  assert usage["resets_at"] is None
 
1376
  # Deleting the video does not give it back.
1377
  art_id = db_session.scalar(select(B2VVideo)).artifact_id
1378
  client.delete(f"/api/videos/{art_id}", headers=headers)
1379
+ assert client.post("/api/videos", json={"url": "https://a.test/4"}, headers=headers).status_code == 402
1380
 
1381
 
1382
  def test_free_video_that_failed_is_given_back(client, b2v, owner, free_plan, db_session):
 
1395
  make_video(client, headers, url=f"https://a.test/{i}")
1396
  _on_plan(db_session, ws, "free")
1397
  usage = client.get("/api/videos/quota", headers=headers).json()
1398
+ assert (usage["used"], usage["limit"]) == (3, 2)
1399
  assert client.post("/api/videos", json={"url": "https://a.test/x"}, headers=headers).status_code == 402
1400
 
1401
 
docs/BLOG2VIDEO.md CHANGED
@@ -29,7 +29,7 @@ log an `ALERT` line and users see "temporarily unavailable".
29
 
30
  ## Ownership
31
 
32
- Everything made with the key (videos, custom templates, custom voices, styles) belongs to one blog2video account,
33
  and blog2video's ownership checks stop at the account. Notestack is the only place each workspace's things are kept
34
  apart (`backend/app/services/b2v_access.py`):
35
 
@@ -64,20 +64,19 @@ built-in style overrides, style pin/selection, "Your Style", script preferences)
64
 
65
  The live preview link comes from `POST /api/embed/token/{id}` and is safe for the browser.
66
 
67
- ## Premium and per-workspace limits
68
 
69
  blog2video checks every limit against our Pro account, so every workspace passes its checks. Notestack divides them:
70
- each plan row has `video_premium` (★: detailed lengths, paid and custom voices, advanced voice tuning, voice samples,
71
- AI script rewrites, AI chat, avatars, designer templates, the source download) and `video_limits`:
 
 
72
 
73
  | Metric | Period | Free / Writer / Studio | Counts |
74
  |---|---|---|---|
75
- | `ai_edits` | month | 20 / 300 / 1000 | scene regenerate/add, AI image, stock clip, AI chat, AI rewrites, template AI edits, avatars (10 per scene) |
76
  | `templates` | lifetime | 0 / 2 / 5 | custom templates made (blog2video slots are never given back) |
77
  | `template_ai_daily` | day | 0 / 3 / 5 | theme from a document or prompt, code generation |
78
- | `voice_designs_daily` | day | 0 / 5 / 10 | voice designs |
79
- | `voice_samples_daily` | day | 0 / 20 / 40 | voice samples |
80
- | `custom_voices` | total | 0 / 3 / 10 | kept or cloned voices (a delete frees one) |
81
 
82
  Edit a plan row to change them. A counter is given back when blog2video refuses the call. A template switch also
83
  uses a video.
 
29
 
30
  ## Ownership
31
 
32
+ Everything made with the key (videos, custom templates, styles) belongs to one blog2video account,
33
  and blog2video's ownership checks stop at the account. Notestack is the only place each workspace's things are kept
34
  apart (`backend/app/services/b2v_access.py`):
35
 
 
64
 
65
  The live preview link comes from `POST /api/embed/token/{id}` and is safe for the browser.
66
 
67
+ ## Per-workspace limits
68
 
69
  blog2video checks every limit against our Pro account, so every workspace passes its checks. Notestack divides them:
70
+ each plan row has `video_limits`. There are no premium (★) video options: videos use the standard lengths (short,
71
+ medium), the built-in templates and styles, and the free built-in voices. Notestack does not proxy AI chat editing,
72
+ avatars, AI script or scene rewrites, AI template editing, designer templates, paid or custom voices, voice tuning,
73
+ voice design or cloning for videos, or the source download.
74
 
75
  | Metric | Period | Free / Writer / Studio | Counts |
76
  |---|---|---|---|
77
+ | `ai_edits` | month | 20 / 300 / 1000 | scene regenerate/add, AI image, stock clip |
78
  | `templates` | lifetime | 0 / 2 / 5 | custom templates made (blog2video slots are never given back) |
79
  | `template_ai_daily` | day | 0 / 3 / 5 | theme from a document or prompt, code generation |
 
 
 
80
 
81
  Edit a plan row to change them. A counter is given back when blog2video refuses the call. A template switch also
82
  uses a video.
frontend/src/api/endpoints.ts CHANGED
@@ -1,6 +1,7 @@
1
  import { api, apiBlob, del, patch, post, postForm, put, qs, uploadFile } from "./client";
2
  import type {
3
  AnswerFeedback,
 
4
  Artifact,
5
  ArtifactType,
6
  BillingStatus,
@@ -50,8 +51,6 @@ import type {
50
  VideoCreateBody,
51
  VideoFocusSource,
52
  VideoFocusTopic,
53
- VideoCustomVoice,
54
- VideoDesignedVoice,
55
  VideoJobState,
56
  VideoListItem,
57
  VideoOptions,
@@ -141,8 +140,9 @@ export type GenerateBody = {
141
  report_format?: "document" | "interactive";
142
  template_id?: string;
143
  instructions?: string;
144
- /** infographic: one of the premade themes (see components/Infographic.tsx); slide_deck: dark-space or light-space. */
145
  theme?: string;
 
146
  /** slide_deck: read on its own ("detailed") or shown behind a speaker ("presenter"), and how many slides. */
147
  deck_format?: "detailed" | "presenter";
148
  deck_length?: "short" | "default" | "long";
@@ -158,6 +158,12 @@ export const artifactsApi = {
158
  remove: (id: string) => del(`/api/artifacts/${id}`),
159
  /** An infographic drawn as a PNG. */
160
  image: (id: string, layout: "landscape" | "portrait" = "landscape") => apiBlob(`/api/artifacts/${id}/image${qs({ layout })}`),
 
 
 
 
 
 
161
  retry: (id: string) => post<Artifact>(`/api/artifacts/${id}/retry`),
162
  /** A slide deck as a PDF (exactly as shown) or an editable PowerPoint. */
163
  slidesFile: (id: string, ext: "pdf" | "pptx") => apiBlob(`/api/artifacts/${id}/slides.${ext}`),
@@ -285,9 +291,6 @@ export const resurfaceApi = {
285
  export type SettingsPatch = {
286
  name?: string;
287
  workspace_name?: string;
288
- brand_name?: string;
289
- brand_accent?: string;
290
- logo_upload_id?: string;
291
  training_opt_in?: boolean;
292
  allow_public_links?: boolean;
293
  email_unsubscribed?: boolean;
@@ -297,6 +300,8 @@ export const settingsApi = {
297
  get: () => api<Settings>("/api/settings"),
298
  update: (body: SettingsPatch) => patch<Settings>("/api/settings", body),
299
  usage: () => api<Usage>("/api/settings/usage"),
 
 
300
  };
301
 
302
  export const memoryApi = {
@@ -374,8 +379,6 @@ export const videosApi = {
374
  script: (id: string) => api<{ status: string; scenes: ScriptScene[] }>(`/api/videos/${id}/script`),
375
  narrationPreview: (id: string, sceneId: number, body: ScriptDraft) =>
376
  post<ScriptPreview>(`/api/videos/${id}/script/scenes/${sceneId}/narration-preview`, body),
377
- aiPreview: (id: string, sceneId: number, body: ScriptDraft & { instruction: string }) =>
378
- post<ScriptPreview>(`/api/videos/${id}/script/scenes/${sceneId}/ai-preview`, body),
379
  approveScript: (id: string, scenes: (ScriptScene & { source_fingerprint?: string; accepted_ai_instructions?: string[] })[]) =>
380
  post<unknown>(`/api/videos/${id}/script/approve`, { scenes }),
381
  };
@@ -448,8 +451,7 @@ export const videoEditApi = {
448
  uploadVoiceover: (id: string, sceneId: number, audio: File) =>
449
  postForm<VideoScene>(P(id, `scenes/${sceneId}/voiceover`), form({ audio })),
450
  deleteVoiceover: (id: string) => post<unknown>(P(id, "delete-voiceover")),
451
- changeVoice: (id: string, body: { voice_gender?: string; voice_accent?: string; custom_voice_id?: string | null;
452
- voice_emotion?: string | null }) => post<VideoJobState>(P(id, "change-voice"), body),
453
  voiceStatus: (id: string) => api<VideoJobState>(P(id, "voice-change-status")),
454
  changeLanguage: (id: string, content_language: string) => post<VideoJobState>(P(id, "change-language"), { content_language }),
455
  languageStatus: (id: string) => api<VideoJobState>(P(id, "language-change-status")),
@@ -463,21 +465,6 @@ export const videoEditApi = {
463
  refreshVerify: (id: string) => post<unknown>(P(id, "regenerate-script/verify")),
464
  refreshRetry: (id: string, user_instruction?: string) =>
465
  post<VideoJobState>(P(id, "regenerate-script/regenerate"), { user_instruction }),
466
- // AI chat editing ★
467
- chat: (id: string, message: string, conversation_id?: number) =>
468
- post<{ reply?: string; message?: string; conversation_id?: number; [k: string]: unknown }>(P(id, "chat"), { message, conversation_id }),
469
- chatHistory: (id: string) => api<unknown>(P(id, "chat/history")),
470
- // Avatars ★
471
- authorizeAvatars: (id: string, scene_ids: number[], avatar_preset?: string, avatar_motion_style?: string) =>
472
- post<unknown>(P(id, "avatar-batch/authorize"), { scene_ids, avatar_preset, avatar_motion_style }),
473
- makeAvatar: (id: string, sceneId: number, avatar_preset?: string) =>
474
- postForm<unknown>(P(id, `scenes/${sceneId}/avatar`), form({ avatar_preset })),
475
- avatarProgress: (id: string) => api<Record<string, unknown>>(P(id, "avatar-progress")),
476
- deleteAvatar: (id: string, sceneId: number) => del<unknown>(P(id, `scenes/${sceneId}/avatar`)),
477
- uploadPortrait: (id: string, file: File) => postForm<unknown>(P(id, "avatar-portrait"), form({ file })),
478
- deletePortrait: (id: string) => del<unknown>(P(id, "avatar-portrait")),
479
- avatarAppearance: (id: string, sceneId: number, body: { avatar_shape?: string; avatar_size?: number;
480
- avatar_position?: string; avatar_opacity?: number }) => patch<unknown>(P(id, `scenes/${sceneId}/avatar-appearance`), body),
481
  // Render, download, frames
482
  /** force renders again even when an MP4 is already on R2 (blog2video otherwise just returns the old one). */
483
  render: (id: string, { force = false, resolution = "1080p" }: { force?: boolean; resolution?: string } = {}) =>
@@ -485,7 +472,6 @@ export const videoEditApi = {
485
  renderStatus: (id: string) => api<VideoJobState>(P(id, "render-status")),
486
  cancelRender: (id: string) => post<unknown>(P(id, "cancel-render")),
487
  downloadUrl: (id: string) => api<{ url: string }>(P(id, "download-url")),
488
- downloadStudio: (id: string) => apiBlob(P(id, "download-studio")),
489
  still: (id: string, frame: number) => apiBlob(P(id, `render-still${qs({ frame })}`)),
490
  };
491
 
@@ -493,19 +479,6 @@ export const videoVoicesApi = {
493
  list: () => api<VideoVoicesResponse>("/api/video-voices"),
494
  save: (voice_id: string) => post<VideoSavedVoice>("/api/video-voices/saved", { voice_id }),
495
  unsave: (voice_id: string) => del(`/api/video-voices/saved/${encodeURIComponent(voice_id)}`),
496
- designFromPrompt: (prompt: string) => post<{ previews: VideoDesignedVoice[] }>("/api/video-voices/design/prompt", { prompt }),
497
- designFromPreset: (body: { gender: string; age: string; persona: string; speed: string; accent: string }) =>
498
- post<{ previews: VideoDesignedVoice[] }>("/api/video-voices/design/preset", body),
499
- keep: (body: { generated_voice_id: string; source: "prompt" | "preset"; name: string; prompt_text?: string }) =>
500
- post<VideoCustomVoice>("/api/video-voices/custom", body),
501
- clone: (name: string, file: File, remove_background_noise = true) =>
502
- postForm<VideoCustomVoice>("/api/video-voices/clone", form({ name, file, remove_background_noise })),
503
- customPreview: (customId: number) =>
504
- api<{ preview_url: string | null; ready: boolean }>(`/api/video-voices/custom/${customId}/preview`),
505
- removeCustom: (customId: number) => del(`/api/video-voices/custom/${customId}`),
506
- /** ★ A few seconds of the voice with the chosen tuning (audio/mpeg). */
507
- sample: (body: { voice_gender: string; voice_accent: string; custom_voice_id?: string; voice_emotion?: string;
508
- video_style?: string }) => apiBlob("/api/video-voices/sample", { method: "POST", body: JSON.stringify(body) }),
509
  };
510
 
511
  export const videoStylesApi = {
@@ -535,8 +508,6 @@ export const videoTemplatesApi = {
535
  get: (id: number) => api<Record<string, unknown>>(T(id, "")),
536
  update: (id: number, body: { name?: string; theme?: Record<string, unknown> }) => put<Record<string, unknown>>(T(id, ""), body),
537
  uploadLogo: (id: number, file: File) => postForm<unknown>(T(id, "upload-logo"), form({ file })),
538
- aiEdit: (id: number, sceneKey: string, prompt: string) => post<unknown>(T(id, `scenes/${sceneKey}/ai-edit`), { prompt }),
539
- aiEditStatus: (id: number, sceneKey: string) => api<VideoJobState>(T(id, `scenes/${sceneKey}/ai-edit/status`)),
540
  drafts: (id: number) => api<unknown>(T(id, "scene-drafts")),
541
  applyDraft: (id: number, sceneKey: string) => post<unknown>(T(id, `scenes/${sceneKey}/draft/apply`)),
542
  discardDraft: (id: number, sceneKey: string) => post<unknown>(T(id, `scenes/${sceneKey}/draft/discard`)),
 
1
  import { api, apiBlob, del, patch, post, postForm, put, qs, uploadFile } from "./client";
2
  import type {
3
  AnswerFeedback,
4
+ QuizAttempt,
5
  Artifact,
6
  ArtifactType,
7
  BillingStatus,
 
51
  VideoCreateBody,
52
  VideoFocusSource,
53
  VideoFocusTopic,
 
 
54
  VideoJobState,
55
  VideoListItem,
56
  VideoOptions,
 
140
  report_format?: "document" | "interactive";
141
  template_id?: string;
142
  instructions?: string;
143
+ /** infographic: one of the looks, and the kind of layout to lean towards (see components/Infographic.tsx); slide_deck: dark-space or light-space. */
144
  theme?: string;
145
+ ig_style?: string;
146
  /** slide_deck: read on its own ("detailed") or shown behind a speaker ("presenter"), and how many slides. */
147
  deck_format?: "detailed" | "presenter";
148
  deck_length?: "short" | "default" | "long";
 
158
  remove: (id: string) => del(`/api/artifacts/${id}`),
159
  /** An infographic drawn as a PNG. */
160
  image: (id: string, layout: "landscape" | "portrait" = "landscape") => apiBlob(`/api/artifacts/${id}/image${qs({ layout })}`),
161
+ /** Thumbs up or down (null takes it back) on a finished report, quiz, flashcard set or infographic. */
162
+ rate: (id: string, body: { rating: "up" | "down" | null; reasons?: string[]; comment?: string }) =>
163
+ put<{ feedback: AnswerFeedback | null }>(`/api/artifacts/${id}/feedback`, body),
164
+ /** A quiz's finished run, kept so the result shows when it is opened again. */
165
+ saveAttempt: (id: string, body: { answers: QuizAttempt["answers"]; score: number }) =>
166
+ put<{ attempt: QuizAttempt }>(`/api/artifacts/${id}/attempt`, body),
167
  retry: (id: string) => post<Artifact>(`/api/artifacts/${id}/retry`),
168
  /** A slide deck as a PDF (exactly as shown) or an editable PowerPoint. */
169
  slidesFile: (id: string, ext: "pdf" | "pptx") => apiBlob(`/api/artifacts/${id}/slides.${ext}`),
 
291
  export type SettingsPatch = {
292
  name?: string;
293
  workspace_name?: string;
 
 
 
294
  training_opt_in?: boolean;
295
  allow_public_links?: boolean;
296
  email_unsubscribed?: boolean;
 
300
  get: () => api<Settings>("/api/settings"),
301
  update: (body: SettingsPatch) => patch<Settings>("/api/settings", body),
302
  usage: () => api<Usage>("/api/settings/usage"),
303
+ /** How many posts a generated thing can read (set by the server's ARTIFACT_MAX_POSTS settings). */
304
+ limits: () => api<{ posts: number; selectable_posts: number }>("/api/settings/limits"),
305
  };
306
 
307
  export const memoryApi = {
 
379
  script: (id: string) => api<{ status: string; scenes: ScriptScene[] }>(`/api/videos/${id}/script`),
380
  narrationPreview: (id: string, sceneId: number, body: ScriptDraft) =>
381
  post<ScriptPreview>(`/api/videos/${id}/script/scenes/${sceneId}/narration-preview`, body),
 
 
382
  approveScript: (id: string, scenes: (ScriptScene & { source_fingerprint?: string; accepted_ai_instructions?: string[] })[]) =>
383
  post<unknown>(`/api/videos/${id}/script/approve`, { scenes }),
384
  };
 
451
  uploadVoiceover: (id: string, sceneId: number, audio: File) =>
452
  postForm<VideoScene>(P(id, `scenes/${sceneId}/voiceover`), form({ audio })),
453
  deleteVoiceover: (id: string) => post<unknown>(P(id, "delete-voiceover")),
454
+ changeVoice: (id: string, body: { voice_gender?: string; voice_accent?: string; custom_voice_id?: string | null }) => post<VideoJobState>(P(id, "change-voice"), body),
 
455
  voiceStatus: (id: string) => api<VideoJobState>(P(id, "voice-change-status")),
456
  changeLanguage: (id: string, content_language: string) => post<VideoJobState>(P(id, "change-language"), { content_language }),
457
  languageStatus: (id: string) => api<VideoJobState>(P(id, "language-change-status")),
 
465
  refreshVerify: (id: string) => post<unknown>(P(id, "regenerate-script/verify")),
466
  refreshRetry: (id: string, user_instruction?: string) =>
467
  post<VideoJobState>(P(id, "regenerate-script/regenerate"), { user_instruction }),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
468
  // Render, download, frames
469
  /** force renders again even when an MP4 is already on R2 (blog2video otherwise just returns the old one). */
470
  render: (id: string, { force = false, resolution = "1080p" }: { force?: boolean; resolution?: string } = {}) =>
 
472
  renderStatus: (id: string) => api<VideoJobState>(P(id, "render-status")),
473
  cancelRender: (id: string) => post<unknown>(P(id, "cancel-render")),
474
  downloadUrl: (id: string) => api<{ url: string }>(P(id, "download-url")),
 
475
  still: (id: string, frame: number) => apiBlob(P(id, `render-still${qs({ frame })}`)),
476
  };
477
 
 
479
  list: () => api<VideoVoicesResponse>("/api/video-voices"),
480
  save: (voice_id: string) => post<VideoSavedVoice>("/api/video-voices/saved", { voice_id }),
481
  unsave: (voice_id: string) => del(`/api/video-voices/saved/${encodeURIComponent(voice_id)}`),
 
 
 
 
 
 
 
 
 
 
 
 
 
482
  };
483
 
484
  export const videoStylesApi = {
 
508
  get: (id: number) => api<Record<string, unknown>>(T(id, "")),
509
  update: (id: number, body: { name?: string; theme?: Record<string, unknown> }) => put<Record<string, unknown>>(T(id, ""), body),
510
  uploadLogo: (id: number, file: File) => postForm<unknown>(T(id, "upload-logo"), form({ file })),
 
 
511
  drafts: (id: number) => api<unknown>(T(id, "scene-drafts")),
512
  applyDraft: (id: number, sceneKey: string) => post<unknown>(T(id, `scenes/${sceneKey}/draft/apply`)),
513
  discardDraft: (id: number, sceneKey: string) => post<unknown>(T(id, `scenes/${sceneKey}/draft/discard`)),
frontend/src/api/types.ts CHANGED
@@ -188,8 +188,14 @@ export type Artifact = {
188
  job: Job | null;
189
  /** "blog2video" for videos made in the Videos editor. */
190
  provider?: string | null;
 
 
 
 
191
  };
192
 
 
 
193
  export type TopicStatus = "rising" | "steady" | "dormant";
194
 
195
  export type TopicNode = {
@@ -411,20 +417,20 @@ export type Plan = {
411
  reports: number;
412
  infographics: number;
413
  voice_cloning: boolean;
414
- brand_kit: boolean;
 
415
  };
416
 
417
  export type Usage = {
418
  plan: string;
419
- used: { audio_minutes: number; videos: number; launch_kits: number; reports: number; infographics: number; llm_tokens: number; since: string };
420
- limits: { audio_minutes: number; videos: number; launch_kits: number; reports: number; infographics: number; sources: number; indexed_posts: number };
421
  videos_resets_at: string | null;
422
  };
423
 
424
  export type Settings = {
425
  user: { name: string | null; email: string; auth_provider: string; email_unsubscribed: boolean };
426
  workspace: { id: string; name: string; training_opt_in: boolean; allow_public_links: boolean };
427
- brand: { name: string | null; accent: string; logo_url: string | null };
428
  plan: Plan;
429
  billing_enabled: boolean;
430
  usage: Usage;
@@ -467,6 +473,8 @@ export type BillingStatus = {
467
  has_billing_account: boolean;
468
  period_end: string | null;
469
  meters: Meter[];
 
 
470
  since: string;
471
  /** Videos reset on renewal (or monthly without one), not on the 1st like the other meters. */
472
  videos_resets_at: string | null;
@@ -483,13 +491,10 @@ export type MindNode = {
483
 
484
  // Videos (blog2video). Everything goes through our API; ids are our artifact ids unless named b2v/custom.
485
 
486
- export type VideoLimitMetric =
487
- | "ai_edits" | "templates" | "template_ai_daily" | "voice_designs_daily" | "voice_samples_daily" | "custom_voices";
488
 
489
  export type VideoConfig = {
490
  configured: boolean;
491
- /** Premium (★) video options are included in this workspace's plan. */
492
- premium: boolean;
493
  limits: Record<VideoLimitMetric, { used: number; limit: number }>;
494
  };
495
 
@@ -535,7 +540,6 @@ export type MusicTrack = { track_id: string; display_name: string; mood?: string
535
 
536
  export type VideoCatalog = {
537
  templates: VideoTemplate[];
538
- crafted_templates: VideoTemplate[];
539
  my_templates: VideoTemplate[];
540
  video_styles: VideoStyleItem[];
541
  music: MusicTrack[];
@@ -549,8 +553,6 @@ export type VideoSavedVoice = {
549
  preview_url: string | null;
550
  gender: string | null;
551
  accent: string | null;
552
- premium: boolean;
553
- is_custom: boolean;
554
  /** notestack: made or added on the Voice page (played through the Voice page's preview, not a link). */
555
  source?: "notestack" | "blog2video";
556
  };
@@ -563,16 +565,6 @@ export type VideoLibraryVoice = {
563
  gender: string | null;
564
  accent: string | null;
565
  age?: string | null;
566
- premium: boolean;
567
- saved: boolean;
568
- };
569
-
570
- export type VideoCustomVoice = {
571
- id: number;
572
- voice_id: string;
573
- name: string;
574
- source: "prompt" | "preset" | "clone";
575
- preview_url: string | null;
576
  saved: boolean;
577
  };
578
 
@@ -582,18 +574,10 @@ export type VideoNotestackVoice = { voice_id: string; name: string; kind: "clone
582
  export type VideoVoicesResponse = {
583
  saved: VideoSavedVoice[];
584
  library: VideoLibraryVoice[];
585
- custom: VideoCustomVoice[];
586
  notestack: VideoNotestackVoice[];
587
  };
588
 
589
- export type VideoDesignedVoice = {
590
- generated_voice_id: string;
591
- audio_base_64: string;
592
- media_type: string;
593
- duration_secs: number;
594
- };
595
-
596
- export type VideoLength = "short" | "medium" | "detailed" | "more_detailed";
597
  export type LogoPosition = "bottom_right" | "bottom_left" | "top_left" | "top_right";
598
 
599
  /** Every field of the 3-step wizard, named as blog2video names them. */
@@ -616,7 +600,6 @@ export type VideoOptions = {
616
  voice_gender: "female" | "male" | "none";
617
  voice_accent: string;
618
  custom_voice_id?: string;
619
- voice_emotion?: string;
620
  bgm_track_id?: string;
621
  bgm_volume: number;
622
  };
 
188
  job: Job | null;
189
  /** "blog2video" for videos made in the Videos editor. */
190
  provider?: string | null;
191
+ /** This person's thumbs up or down on a finished report, quiz, flashcard set or infographic. */
192
+ feedback?: AnswerFeedback | null;
193
+ /** A quiz: this person's last finished run (what they answered, the score), shown again when it is reopened. */
194
+ attempt?: QuizAttempt | null;
195
  };
196
 
197
+ export type QuizAttempt = { score: number; total: number; answers: { choice: number[]; text: string; correct: boolean | null }[] };
198
+
199
  export type TopicStatus = "rising" | "steady" | "dormant";
200
 
201
  export type TopicNode = {
 
417
  reports: number;
418
  infographics: number;
419
  voice_cloning: boolean;
420
+ /** Audio overviews allowed (in total on Free), whatever their length; -1 = no cap. */
421
+ audio_overviews: number;
422
  };
423
 
424
  export type Usage = {
425
  plan: string;
426
+ used: { audio_minutes: number; audio_overviews: number; videos: number; launch_kits: number; reports: number; infographics: number; indexed_posts: number; llm_tokens: number; since: string };
427
+ limits: { audio_minutes: number; audio_overviews: number; videos: number; launch_kits: number; reports: number; infographics: number; sources: number; indexed_posts: number };
428
  videos_resets_at: string | null;
429
  };
430
 
431
  export type Settings = {
432
  user: { name: string | null; email: string; auth_provider: string; email_unsubscribed: boolean };
433
  workspace: { id: string; name: string; training_opt_in: boolean; allow_public_links: boolean };
 
434
  plan: Plan;
435
  billing_enabled: boolean;
436
  usage: Usage;
 
473
  has_billing_account: boolean;
474
  period_end: string | null;
475
  meters: Meter[];
476
+ /** Posts indexed, reports and infographics: shown when the sidebar plan card is opened. */
477
+ more_meters: Meter[];
478
  since: string;
479
  /** Videos reset on renewal (or monthly without one), not on the 1st like the other meters. */
480
  videos_resets_at: string | null;
 
491
 
492
  // Videos (blog2video). Everything goes through our API; ids are our artifact ids unless named b2v/custom.
493
 
494
+ export type VideoLimitMetric = "ai_edits" | "templates" | "template_ai_daily";
 
495
 
496
  export type VideoConfig = {
497
  configured: boolean;
 
 
498
  limits: Record<VideoLimitMetric, { used: number; limit: number }>;
499
  };
500
 
 
540
 
541
  export type VideoCatalog = {
542
  templates: VideoTemplate[];
 
543
  my_templates: VideoTemplate[];
544
  video_styles: VideoStyleItem[];
545
  music: MusicTrack[];
 
553
  preview_url: string | null;
554
  gender: string | null;
555
  accent: string | null;
 
 
556
  /** notestack: made or added on the Voice page (played through the Voice page's preview, not a link). */
557
  source?: "notestack" | "blog2video";
558
  };
 
565
  gender: string | null;
566
  accent: string | null;
567
  age?: string | null;
 
 
 
 
 
 
 
 
 
 
568
  saved: boolean;
569
  };
570
 
 
574
  export type VideoVoicesResponse = {
575
  saved: VideoSavedVoice[];
576
  library: VideoLibraryVoice[];
 
577
  notestack: VideoNotestackVoice[];
578
  };
579
 
580
+ export type VideoLength = "short" | "medium";
 
 
 
 
 
 
 
581
  export type LogoPosition = "bottom_right" | "bottom_left" | "top_left" | "top_right";
582
 
583
  /** Every field of the 3-step wizard, named as blog2video names them. */
 
600
  voice_gender: "female" | "male" | "none";
601
  voice_accent: string;
602
  custom_voice_id?: string;
 
603
  bgm_track_id?: string;
604
  bgm_volume: number;
605
  };
frontend/src/components/AnswerFeedback.tsx CHANGED
@@ -11,7 +11,7 @@ const REASONS: { id: string; label: string }[] = [
11
  { id: "other", label: "Something else" },
12
  ];
13
 
14
- const Thumb = ({ down }: { down?: boolean }) => (
15
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" style={down ? { transform: "rotate(180deg)" } : undefined}>
16
  <path d="M7 11v9H4a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1h3Zm0 0 4-8a2 2 0 0 1 2 2v4h5.2a2 2 0 0 1 2 2.3l-1 6A2 2 0 0 1 17.2 20H7" />
17
  </svg>
 
11
  { id: "other", label: "Something else" },
12
  ];
13
 
14
+ export const Thumb = ({ down }: { down?: boolean }) => (
15
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" style={down ? { transform: "rotate(180deg)" } : undefined}>
16
  <path d="M7 11v9H4a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1h3Zm0 0 4-8a2 2 0 0 1 2 2v4h5.2a2 2 0 0 1 2 2.3l-1 6A2 2 0 0 1 17.2 20H7" />
17
  </svg>
frontend/src/components/AppShell.tsx CHANGED
@@ -63,7 +63,7 @@ export default function AppShell() {
63
  </button>
64
  ) : (
65
  <NavLink to="/" className="sidebar-brand">
66
- <Logo size={28} withWordmark={false} />
67
  </NavLink>
68
  )}
69
  <button
 
63
  </button>
64
  ) : (
65
  <NavLink to="/" className="sidebar-brand">
66
+ <Logo size={28} />
67
  </NavLink>
68
  )}
69
  <button
frontend/src/components/Flashcards.tsx CHANGED
@@ -2,15 +2,10 @@ import { useCallback, useEffect, useMemo, useState } from "react";
2
  import type { GenerateBody } from "../api/endpoints";
3
  import type { Artifact, ChatSummary, FlashcardData } from "../api/types";
4
  import { Pills, SourceFocusFields, sourceSummary, type SourceSelection } from "./SourceFocusFields";
 
5
  import { StudyOverlay } from "./StudyOverlay";
6
  import { Modal } from "./ui";
7
 
8
- const COUNTS = [
9
- { id: "fewer", label: "Fewer" },
10
- { id: "standard", label: "Standard (Default)" },
11
- { id: "more", label: "More" },
12
- ] as const;
13
-
14
  const LEVELS = [
15
  { id: "easy", label: "Easy" },
16
  { id: "medium", label: "Medium (Default)" },
@@ -31,31 +26,25 @@ export function FlashcardsDialog({ notebookId, notebookTitle, chats, currentChat
31
  onCreate: (body: FlashcardsRequest) => void;
32
  }) {
33
  const [sel, setSel] = useState<SourceSelection | null>(null);
34
- const [count, setCount] = useState<(typeof COUNTS)[number]["id"]>("standard");
35
  const [level, setLevel] = useState<(typeof LEVELS)[number]["id"]>("medium");
36
 
37
  return (
38
  <Modal title="Flashcards" onClose={onClose} wide>
39
  <div className="stack vw-in-modal">
40
- <section className="stack vw">
41
- <SourceFocusFields notebookId={notebookId} notebookTitle={notebookTitle} chats={chats} currentChatId={currentChatId} what="flashcard set" columns onChange={setSel}
42
- below={(
43
- <>
44
- <div className="field">
45
- <span className="vw-label">Number of cards</span>
46
- <Pills label="Number of cards" value={count} options={COUNTS} onChange={setCount} />
47
- </div>
48
- <div className="field">
49
- <span className="vw-label">Level of difficulty</span>
50
- <Pills label="Level of difficulty" value={level} options={LEVELS} onChange={setLevel} />
51
- </div>
52
- </>
53
- )} />
54
- {!sel?.ready && <p className="muted small">Select at least one post or chat to generate flashcards.</p>}
55
- {error && <p className="error-text">{error}</p>}
56
- <div className="vw-nav">
57
  <button type="button" className="btn btn-primary vw-next" disabled={busy || !sel?.ready || sel.loading}
58
- onClick={() => sel && onCreate({ ...sel.request, count, difficulty: level })}>
59
  {busy ? "Starting..." : "Generate Flashcards"}
60
  </button>
61
  </div>
@@ -141,6 +130,7 @@ export function FlashcardsPlayer({ artifact, onClose }: { artifact: Artifact; on
141
  return (
142
  <StudyOverlay title={artifact.title} onClose={onClose}>
143
  <FlashcardDeck cards={(artifact.content.cards ?? []) as FlashcardData[]} />
 
144
  </StudyOverlay>
145
  );
146
  }
 
2
  import type { GenerateBody } from "../api/endpoints";
3
  import type { Artifact, ChatSummary, FlashcardData } from "../api/types";
4
  import { Pills, SourceFocusFields, sourceSummary, type SourceSelection } from "./SourceFocusFields";
5
+ import { ArtifactFeedback } from "./ArtifactFeedback";
6
  import { StudyOverlay } from "./StudyOverlay";
7
  import { Modal } from "./ui";
8
 
 
 
 
 
 
 
9
  const LEVELS = [
10
  { id: "easy", label: "Easy" },
11
  { id: "medium", label: "Medium (Default)" },
 
26
  onCreate: (body: FlashcardsRequest) => void;
27
  }) {
28
  const [sel, setSel] = useState<SourceSelection | null>(null);
 
29
  const [level, setLevel] = useState<(typeof LEVELS)[number]["id"]>("medium");
30
 
31
  return (
32
  <Modal title="Flashcards" onClose={onClose} wide>
33
  <div className="stack vw-in-modal">
34
+ <section className="stack vw qz-setup">
35
+ <div className="field">
36
+ <span className="vw-label">Level of difficulty</span>
37
+ <Pills label="Level of difficulty" value={level} options={LEVELS} onChange={setLevel} />
38
+ </div>
39
+ <SourceFocusFields notebookId={notebookId} notebookTitle={notebookTitle} chats={chats} currentChatId={currentChatId}
40
+ preselectChat what="flashcard set" largeSelection onChange={setSel} />
41
+ <div className="qz-foot">
42
+ <div>
43
+ {!sel?.ready && <p className="muted small">Select at least one post or chat to generate flashcards.</p>}
44
+ {error && <p className="error-text">{error}</p>}
45
+ </div>
 
 
 
 
 
46
  <button type="button" className="btn btn-primary vw-next" disabled={busy || !sel?.ready || sel.loading}
47
+ onClick={() => sel && onCreate({ ...sel.request, difficulty: level })}>
48
  {busy ? "Starting..." : "Generate Flashcards"}
49
  </button>
50
  </div>
 
130
  return (
131
  <StudyOverlay title={artifact.title} onClose={onClose}>
132
  <FlashcardDeck cards={(artifact.content.cards ?? []) as FlashcardData[]} />
133
+ <ArtifactFeedback artifact={artifact} label="How are these cards?" />
134
  </StudyOverlay>
135
  );
136
  }