HumeraR commited on
Commit
0649f1b
·
1 Parent(s): cd4f836

Refactor code structure for improved readability and maintainability; removed redundant code blocks and optimized function calls.

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. Dockerfile +4 -0
  2. backend/0 +1 -0
  3. backend/Dockerfile +3 -0
  4. backend/alembic/versions/0017_reports_limit.py +26 -0
  5. backend/alembic/versions/0018_report_sharing.py +41 -0
  6. backend/alembic/versions/0019_infographics_limit.py +26 -0
  7. backend/app/config.py +1 -0
  8. backend/app/infographics/__init__.py +0 -0
  9. backend/app/infographics/content.py +108 -0
  10. backend/app/infographics/image.py +68 -0
  11. backend/app/infographics/render.py +53 -0
  12. backend/app/infographics/templates/base.html.j2 +65 -0
  13. backend/app/infographics/templates/galaxy.html.j2 +46 -0
  14. backend/app/infographics/templates/launch.html.j2 +58 -0
  15. backend/app/infographics/templates/moonbase.html.j2 +51 -0
  16. backend/app/infographics/templates/observatory.html.j2 +45 -0
  17. backend/app/infographics/templates/solar.html.j2 +47 -0
  18. backend/app/infographics/templates/station.html.j2 +47 -0
  19. backend/app/infographics/themes.py +26 -0
  20. backend/app/llm/provider.py +10 -0
  21. backend/app/llm/signatures.py +202 -0
  22. backend/app/main.py +3 -1
  23. backend/app/models/__init__.py +2 -0
  24. backend/app/models/content.py +13 -0
  25. backend/app/models/user.py +4 -0
  26. backend/app/pipeline/flashcards.py +54 -0
  27. backend/app/pipeline/generate.py +24 -16
  28. backend/app/pipeline/infographic.py +116 -0
  29. backend/app/pipeline/ingest.py +30 -0
  30. backend/app/pipeline/material.py +113 -0
  31. backend/app/pipeline/quiz.py +82 -0
  32. backend/app/pipeline/report.py +395 -0
  33. backend/app/routers/artifacts.py +84 -5
  34. backend/app/routers/reports.py +113 -0
  35. backend/app/routers/settings.py +5 -1
  36. backend/app/routers/share.py +107 -0
  37. backend/app/routers/sources.py +3 -0
  38. backend/app/routers/storage.py +1 -0
  39. backend/app/services/artifacts.py +34 -1
  40. backend/app/services/plans.py +16 -1
  41. backend/app/services/report_templates.py +74 -0
  42. backend/app/services/share.py +97 -0
  43. backend/app/services/usage.py +15 -2
  44. backend/app/support/corpus.py +5 -2
  45. backend/app/support/prompts.py +4 -2
  46. backend/app/support/scope.py +5 -4
  47. backend/app/worker.py +8 -2
  48. backend/requirements.txt +1 -0
  49. backend/scripts/render_theme_thumbnails.py +45 -0
  50. backend/tests/conftest.py +16 -2
Dockerfile CHANGED
@@ -11,6 +11,10 @@ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 \
11
  CORPUS_CACHE_DIR=/app/.corpus-cache \
12
  LOCAL_STORAGE_DIR=/app/.storage
13
 
 
 
 
 
14
  COPY backend/requirements.txt .
15
  RUN pip install --no-cache-dir -r requirements.txt
16
  COPY --chown=user backend/ .
 
11
  CORPUS_CACHE_DIR=/app/.corpus-cache \
12
  LOCAL_STORAGE_DIR=/app/.storage
13
 
14
+ # Chromium draws an infographic's PNG for its Download button (and the fonts the pages use)
15
+ RUN apt-get update && apt-get install -y --no-install-recommends chromium fonts-liberation \
16
+ && rm -rf /var/lib/apt/lists/*
17
+
18
  COPY backend/requirements.txt .
19
  RUN pip install --no-cache-dir -r requirements.txt
20
  COPY --chown=user backend/ .
backend/0 ADDED
@@ -0,0 +1 @@
 
 
1
+ key set: 49
backend/Dockerfile CHANGED
@@ -1,6 +1,9 @@
1
  FROM python:3.12-slim
2
  WORKDIR /app
3
  ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
 
 
 
4
  COPY requirements.txt .
5
  RUN pip install --no-cache-dir -r requirements.txt
6
  COPY . .
 
1
  FROM python:3.12-slim
2
  WORKDIR /app
3
  ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
4
+ # Chromium draws an infographic's PNG for its Download button
5
+ RUN apt-get update && apt-get install -y --no-install-recommends chromium fonts-liberation \
6
+ && rm -rf /var/lib/apt/lists/*
7
  COPY requirements.txt .
8
  RUN pip install --no-cache-dir -r requirements.txt
9
  COPY . .
backend/alembic/versions/0017_reports_limit.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """reports_limit: a monthly report allowance on each plan
2
+
3
+ Revision ID: 0017_reports_limit
4
+ Revises: 0016_support_chat
5
+ Create Date: 2026-10-07
6
+ """
7
+ import sqlalchemy as sa
8
+ from alembic import op
9
+
10
+ revision = "0017_reports_limit"
11
+ down_revision = "0016_support_chat"
12
+ branch_labels = None
13
+ depends_on = None
14
+
15
+
16
+ def upgrade() -> None:
17
+ # 0001 builds from the live models, so a fresh database may already have the column.
18
+ columns = {c["name"] for c in sa.inspect(op.get_bind()).get_columns("plans")}
19
+ if "reports" not in columns:
20
+ op.add_column("plans", sa.Column("reports", sa.Integer(), nullable=False, server_default="0"))
21
+ op.execute("UPDATE plans SET reports = CASE id WHEN 'studio' THEN -1 WHEN 'writer' THEN 30 ELSE 2 END")
22
+
23
+
24
+ def downgrade() -> None:
25
+ with op.batch_alter_table("plans") as batch:
26
+ batch.drop_column("reports")
backend/alembic/versions/0018_report_sharing.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """report_sharing: public links to reports, and a workspace switch that turns them off
2
+
3
+ Revision ID: 0018_report_sharing
4
+ Revises: 0017_reports_limit
5
+ Create Date: 2026-10-07
6
+ """
7
+ import sqlalchemy as sa
8
+ from alembic import op
9
+
10
+ revision = "0018_report_sharing"
11
+ down_revision = "0017_reports_limit"
12
+ branch_labels = None
13
+ depends_on = None
14
+
15
+
16
+ def upgrade() -> None:
17
+ # 0001 builds from the live models, so a fresh database may already have these.
18
+ inspector = sa.inspect(op.get_bind())
19
+ if "artifact_shares" not in inspector.get_table_names():
20
+ op.create_table(
21
+ "artifact_shares",
22
+ sa.Column("id", sa.Uuid(), primary_key=True),
23
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
24
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
25
+ sa.Column("workspace_id", sa.Uuid(), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False),
26
+ sa.Column("artifact_id", sa.Uuid(), sa.ForeignKey("artifacts.id", ondelete="CASCADE"), nullable=False,
27
+ unique=True),
28
+ sa.Column("token", sa.String(64), nullable=False),
29
+ sa.Column("show_sources", sa.Boolean(), nullable=False, server_default=sa.true()),
30
+ )
31
+ op.create_index("ix_artifact_shares_workspace_id", "artifact_shares", ["workspace_id"])
32
+ op.create_index("ix_artifact_shares_token", "artifact_shares", ["token"], unique=True)
33
+ if "allow_public_links" not in {c["name"] for c in inspector.get_columns("workspaces")}:
34
+ op.add_column("workspaces", sa.Column("allow_public_links", sa.Boolean(), nullable=False,
35
+ server_default=sa.true()))
36
+
37
+
38
+ def downgrade() -> None:
39
+ with op.batch_alter_table("workspaces") as batch:
40
+ batch.drop_column("allow_public_links")
41
+ op.drop_table("artifact_shares")
backend/alembic/versions/0019_infographics_limit.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """infographics_limit: a monthly infographic allowance on each plan
2
+
3
+ Revision ID: 0019_infographics_limit
4
+ Revises: 0018_report_sharing
5
+ Create Date: 2026-10-08
6
+ """
7
+ import sqlalchemy as sa
8
+ from alembic import op
9
+
10
+ revision = "0019_infographics_limit"
11
+ down_revision = "0018_report_sharing"
12
+ branch_labels = None
13
+ depends_on = None
14
+
15
+
16
+ def upgrade() -> None:
17
+ # 0001 builds from the live models, so a fresh database may already have the column.
18
+ columns = {c["name"] for c in sa.inspect(op.get_bind()).get_columns("plans")}
19
+ if "infographics" not in columns:
20
+ op.add_column("plans", sa.Column("infographics", sa.Integer(), nullable=False, server_default="0"))
21
+ op.execute("UPDATE plans SET infographics = CASE id WHEN 'studio' THEN -1 WHEN 'writer' THEN 30 ELSE 2 END")
22
+
23
+
24
+ def downgrade() -> None:
25
+ with op.batch_alter_table("plans") as batch:
26
+ batch.drop_column("infographics")
backend/app/config.py CHANGED
@@ -67,6 +67,7 @@ class Settings(BaseSettings):
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"
 
70
  # Chat triage (small talk, off topic, follow up rewriting). Empty = LLM_FAST_MODEL.
71
  llm_triage_model: str = ""
72
  # TypeSafe Jev classifier for chat triage. When set, triage uses Jev instead of an LLM call.
 
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"
70
+ llm_report_model: str = "" # optional: writes report prose (default: the fast model, with a bigger output cap)
71
  # Chat triage (small talk, off topic, follow up rewriting). Empty = LLM_FAST_MODEL.
72
  llm_triage_model: str = ""
73
  # TypeSafe Jev classifier for chat triage. When set, triage uses Jev instead of an LLM call.
backend/app/infographics/__init__.py ADDED
File without changes
backend/app/infographics/content.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """What an infographic says: a short, length-limited block of text the premade themes pour into their layout.
2
+ Model output is never trusted for layout, so every field is stripped of markup and clamped here."""
3
+
4
+ import re
5
+
6
+ from pydantic import BaseModel
7
+
8
+ WIDTH, HEIGHT = 1600, 2260
9
+
10
+ # Characters per field: the themes are drawn so the longest allowed text still fits its box.
11
+ LIMITS = {"title": 60, "subtitle": 110, "label": 34, "item_name": 22, "item_text": 120, "step_name": 14,
12
+ "step_text": 78, "rule": 80, "note": 90}
13
+ ITEMS = (3, 5)
14
+ STEPS = (4, 8)
15
+ NOTES = 3
16
+
17
+
18
+ class Entry(BaseModel):
19
+ name: str
20
+ text: str
21
+
22
+
23
+ class InfographicContent(BaseModel):
24
+ title: str
25
+ subtitle: str
26
+ items_label: str
27
+ items: list[Entry]
28
+ steps_label: str
29
+ steps: list[Entry]
30
+ rule: str
31
+ notes: list[str]
32
+
33
+
34
+ _TAGS = re.compile(r"<[^>]*>")
35
+ _SPACE = re.compile(r"\s+")
36
+
37
+
38
+ def _clip(text: object, limit: int) -> str:
39
+ """Plain text of at most `limit` characters, cut at a word with an ellipsis when it was longer."""
40
+ s = _SPACE.sub(" ", _TAGS.sub(" ", str(text or ""))).replace("—", ",").replace("–", "-").strip()
41
+ if len(s) <= limit:
42
+ return s
43
+ cut = s[: limit - 1].rsplit(" ", 1)[0].rstrip(" ,;:.-") or s[: limit - 1]
44
+ return cut + "…"
45
+
46
+
47
+ def _entries(raw: object, name_limit: int, text_limit: int, count: tuple[int, int]) -> list[dict]:
48
+ out = []
49
+ for e in (raw if isinstance(raw, list) else []):
50
+ e = e if isinstance(e, dict) else getattr(e, "__dict__", {})
51
+ name, text = _clip(e.get("name"), name_limit), _clip(e.get("text"), text_limit)
52
+ if name and text:
53
+ out.append({"name": name, "text": text})
54
+ return out[: count[1]] if len(out) >= count[0] else []
55
+
56
+
57
+ def clamp(raw: dict) -> dict | None:
58
+ """The content in the shape the themes draw, or None when there is too little to draw (too few items or steps)."""
59
+ if hasattr(raw, "model_dump"):
60
+ raw = raw.model_dump()
61
+ items = _entries(raw.get("items"), LIMITS["item_name"], LIMITS["item_text"], ITEMS)
62
+ steps = _entries(raw.get("steps"), LIMITS["step_name"], LIMITS["step_text"], STEPS)
63
+ title = _clip(raw.get("title"), LIMITS["title"])
64
+ if not items or not steps or not title:
65
+ return None
66
+ notes = [n for n in (_clip(n, LIMITS["note"]) for n in raw.get("notes") or []) if n][:NOTES]
67
+ return {"title": title, "subtitle": _clip(raw.get("subtitle"), LIMITS["subtitle"]),
68
+ "items_label": _clip(raw.get("items_label"), LIMITS["label"]) or "Key ideas",
69
+ "items": items,
70
+ "steps_label": _clip(raw.get("steps_label"), LIMITS["label"]) or "How it works",
71
+ "steps": steps, "rule": _clip(raw.get("rule"), LIMITS["rule"]), "notes": notes}
72
+
73
+
74
+ SAMPLE = {
75
+ "title": "From Prompt to Production Agent",
76
+ "subtitle": "Five engineering layers plus an eight step production request flow",
77
+ "items_label": "The five layers",
78
+ "items": [
79
+ {"name": "Prompt", "text": "Optimizes wording, examples, and format in a single request."},
80
+ {"name": "Context", "text": "Curates docs, history, tools, and memory before each model call."},
81
+ {"name": "Harness", "text": "Scaffolding around the model: tools, verification, and guardrails."},
82
+ {"name": "Loop", "text": "Runs reason, act and observe cycles until a stop condition is met."},
83
+ {"name": "Graph", "text": "Maps multi-agent systems as explicit nodes and edges."},
84
+ ],
85
+ "steps_label": "Every request goes through",
86
+ "steps": [
87
+ {"name": "Auth", "text": "Middleware checks identity, rate limits, and input guards."},
88
+ {"name": "Cache", "text": "Semantic cache returns answers for equivalent questions."},
89
+ {"name": "Safety", "text": "Scrub PII and detect prompt injection attacks."},
90
+ {"name": "Memory", "text": "Load session history with summarized context."},
91
+ {"name": "Retrieve", "text": "Fetch relevant docs and split complex queries."},
92
+ {"name": "Answer", "text": "Execute sub-queries in parallel with branching models."},
93
+ {"name": "Validate", "text": "Check output quality metrics before responding."},
94
+ {"name": "Save", "text": "Store results and log one full trace."},
95
+ ],
96
+ "rule": "Most tasks only need the bottom layers; avoid complex graphs without cause.",
97
+ "notes": ["Each layer builds on the one below it.", "Cache and middleware run outside the graph.",
98
+ "Every request should produce a single end-to-end trace."],
99
+ }
100
+
101
+ # The longest text each field allows, with the most items and steps: what every theme must fit.
102
+ STRESS = {
103
+ "title": "W" * 4 + " Quarterly Review of Everything We Learned Here", "subtitle": "Long words here " * 7,
104
+ "items_label": "The five very long layers", "steps_label": "Every request goes through",
105
+ "items": [{"name": "Wide Name Here Now", "text": "Words that go on and on " * 6}] * 5,
106
+ "steps": [{"name": "Validators", "text": "Words that go on and on " * 5}] * 8,
107
+ "rule": "A very long rule that keeps going and going " * 3, "notes": ["A long note that keeps on going " * 4] * 3,
108
+ }
backend/app/infographics/image.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """An infographic's PNG: its stored HTML page drawn by a headless Chrome at the page's own size. No Remotion, no
2
+ browser library: one `chrome --headless --screenshot` run per download (the result is kept for the next one)."""
3
+
4
+ import glob
5
+ import hashlib
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import tempfile
10
+ from collections import OrderedDict
11
+ from pathlib import Path
12
+ from threading import Lock
13
+
14
+ from app.infographics.render import SIZES
15
+
16
+ TIMEOUT = 60 # seconds for one drawing
17
+ KEEP = 8 # drawings kept in memory
18
+
19
+ _cache: OrderedDict[str, bytes] = OrderedDict()
20
+ _lock = Lock()
21
+
22
+
23
+ class ImageUnavailable(Exception):
24
+ """No Chrome to draw with, or it failed."""
25
+
26
+
27
+ def chrome() -> str | None:
28
+ """CHROME, or a Chrome / Chromium found on the machine."""
29
+ if env := os.environ.get("CHROME"):
30
+ return env if Path(env).exists() else None
31
+ for name in ("chromium", "chromium-browser", "google-chrome", "google-chrome-stable", "chrome"):
32
+ if found := shutil.which(name):
33
+ return found
34
+ patterns = ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
35
+ os.path.expanduser("~/.cache/puppeteer/chrome/*/chrome-mac-*/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing")]
36
+ return next((p for pat in patterns for p in glob.glob(pat)), None)
37
+
38
+
39
+ def render_png(html: str, layout: str = "portrait") -> bytes:
40
+ """The page as a PNG at its own size: tall (1600 x 2260) or wide (2260 x 1600)."""
41
+ width, height = SIZES.get(layout, SIZES["portrait"])
42
+ key = hashlib.sha256(f"{width}x{height}{html}".encode()).hexdigest()
43
+ with _lock:
44
+ if key in _cache:
45
+ _cache.move_to_end(key)
46
+ return _cache[key]
47
+ binary = chrome()
48
+ if not binary:
49
+ raise ImageUnavailable("No Chrome is installed here to draw the image.")
50
+ page = f'<!doctype html><meta charset="utf-8"><body style="margin:0">{html}</body>'
51
+ with tempfile.TemporaryDirectory() as tmp:
52
+ src, out = Path(tmp) / "page.html", Path(tmp) / "out.png"
53
+ src.write_text(page, encoding="utf-8")
54
+ cmd = [binary, "--headless=new", "--disable-gpu", "--no-sandbox", "--hide-scrollbars", "--force-device-scale-factor=1",
55
+ f"--user-data-dir={tmp}/profile", f"--window-size={width},{height}", "--virtual-time-budget=8000",
56
+ f"--screenshot={out}", f"file://{src}"]
57
+ try:
58
+ subprocess.run(cmd, check=True, capture_output=True, timeout=TIMEOUT)
59
+ except (subprocess.SubprocessError, OSError) as exc:
60
+ raise ImageUnavailable("The image could not be drawn.") from exc
61
+ if not out.exists():
62
+ raise ImageUnavailable("The image could not be drawn.")
63
+ data = out.read_bytes()
64
+ with _lock:
65
+ _cache[key] = data
66
+ while len(_cache) > KEEP:
67
+ _cache.popitem(last=False)
68
+ return data
backend/app/infographics/render.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pours an infographic's content into a theme's premade HTML page. Autoescape is on and nothing is marked safe, so text
2
+ from the model can never add markup or scripts to the page."""
3
+
4
+ import hashlib
5
+ import random
6
+ from pathlib import Path
7
+
8
+ from markupsafe import Markup
9
+ from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape
10
+
11
+ from app.infographics.content import HEIGHT, WIDTH
12
+ from app.infographics.themes import DEFAULT_THEME, THEMES, theme_id
13
+
14
+ _ENV = Environment(loader=FileSystemLoader(Path(__file__).parent / "templates"), autoescape=select_autoescape(["j2"], default=True),
15
+ undefined=StrictUndefined, trim_blocks=True, lstrip_blocks=True)
16
+
17
+ # name -> (heading, body, mono, heading weight, heading case, label case)
18
+ _FONT = {"jost": "Jost", "outfit": "Outfit", "playfair": "Playfair Display", "cormorant": "Cormorant Garamond",
19
+ "mono": "Space Mono"}
20
+
21
+
22
+ # The Notestack mark (the same icon as frontend/public/logo.svg), drawn beside the name on every page. Our own constant, never
23
+ # model text, so it is the one thing marked safe.
24
+ LOGO_SVG = ('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#fff"/>'
25
+ '<circle cx="26" cy="36" r="13" fill="#000"/><ellipse cx="26" cy="36" rx="21" ry="5" fill="none" stroke="#000" '
26
+ 'stroke-width="3" transform="rotate(-18 26 36)"/><path d="M46 10l2.2 5.8 5.8 2.2-5.8 2.2-2.2 5.8-2.2-5.8-5.8-2.2 '
27
+ '5.8-2.2z" fill="#000"/></svg>')
28
+
29
+ SIZES = {"portrait": (WIDTH, HEIGHT), "landscape": (HEIGHT, WIDTH)} # the page is drawn at either shape
30
+
31
+
32
+ def _stars(seed: str, w: int, h: int, n: int = 70) -> list[tuple[int, int, float, float]]:
33
+ rnd = random.Random(int(hashlib.sha1(seed.encode()).hexdigest()[:8], 16))
34
+ return [(rnd.randint(0, w), rnd.randint(0, h), round(rnd.choice((1.5, 2, 2, 3, 4)), 1), round(rnd.uniform(.15, .6), 2))
35
+ for _ in range(n)]
36
+
37
+
38
+ def build_html(theme: str, content: dict, layout: str = "portrait") -> str:
39
+ """The page for `content` (as returned by content.clamp) in the theme (the default when unknown), tall (portrait)
40
+ or wide (landscape: the same words, the title and takeaway in a column beside the theme's drawing)."""
41
+ tid = theme_id(theme)
42
+ layout = layout if layout in SIZES else "portrait"
43
+ w, h = SIZES[layout]
44
+ tpl = _ENV.get_template(f"{tid}.html.j2")
45
+ return tpl.render(c=content, width=w, height=h, layout=layout, logo=Markup(LOGO_SVG), stars=_stars(content["title"] + tid, w, h))
46
+
47
+
48
+ def thumbnail_html(theme: str, content: dict, layout: str = "portrait") -> str:
49
+ """The page as a standalone document, for rendering a preview outside the app."""
50
+ return f'<!doctype html><meta charset="utf-8"><body style="margin:0">{build_html(theme, content, layout)}</body>'
51
+
52
+
53
+ __all__ = ["build_html", "LOGO_SVG", "thumbnail_html", "SIZES", "THEMES", "DEFAULT_THEME"]
backend/app/infographics/templates/base.html.j2 ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {#- Shared layout of every infographic theme. A theme extends this and fills the style, the background, the hero drawing and a few
2
+ words. All text is autoescaped; nothing from the model is ever marked safe. The notestack.ai mark is added by the renderer. -#}
3
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family={{ fonts.h|replace(' ', '+') }}:wght@400;500;600;700&family={{ fonts.b|replace(' ', '+') }}:wght@300;400;500;600&family={{ fonts.m|replace(' ', '+') }}:wght@400;700&display=swap">
4
+ <style>
5
+ .ig,.ig *{box-sizing:border-box;margin:0;padding:0}
6
+ .ig{position:relative;width:{{ width }}px;height:{{ height }}px;overflow:hidden;color:var(--ink);font-family:var(--fb);
7
+ background:radial-gradient(1300px 800px at 62% 14%,var(--bg2),var(--bg1) 72%)}
8
+ .ig .bg{position:absolute;inset:0;pointer-events:none}
9
+ .ig .main{position:absolute;left:110px;right:110px;top:84px;bottom:116px;display:flex;flex-direction:column}
10
+ .ig .head{display:flex;justify-content:space-between;align-items:flex-start;gap:30px;height:var(--hh,400px);flex:none}
11
+ .ig .head .txt{width:900px}
12
+ .ig .eyebrow{font-family:var(--fm);font-size:21px;letter-spacing:.24em;text-transform:uppercase;color:var(--a);margin-bottom:18px}
13
+ .ig h1{font-family:var(--fh);font-weight:var(--hw,700);font-size:72px;line-height:1.05;letter-spacing:var(--hl,0);text-transform:var(--ht,none);margin-bottom:20px}
14
+ .ig .sub{font-size:31px;line-height:1.35;color:var(--mute)}
15
+ .ig .hero{flex:none;width:480px;height:400px}
16
+ .ig .lab{font-family:var(--fm);font-size:21px;letter-spacing:.22em;text-transform:uppercase;color:var(--a);margin:12px 0 16px;flex:none;display:flex;align-items:center;gap:18px}
17
+ .ig .lab.gap{margin-top:12px}
18
+ .ig .lab:after{content:"";flex:1;height:2px;background:var(--line)}
19
+ .ig .fill{flex:1;min-height:0;display:flex;flex-direction:column;justify-content:space-evenly}
20
+ .ig .band{margin-top:0;display:grid;grid-template-columns:1.05fr 1fr;gap:48px;align-items:center;padding:34px 50px;border-radius:var(--r);background:var(--paper);color:var(--paperink);flex:none}
21
+ .ig .band .q{font-family:var(--fh);font-weight:700;font-size:40px;line-height:1.14;text-transform:var(--ht,none)}
22
+ .ig .band ul{list-style:none;font-size:24px;line-height:1.35}
23
+ .ig .band li{position:relative;padding-left:26px;margin:7px 0}
24
+ .ig .band li:before{content:"";position:absolute;left:0;top:11px;width:12px;height:12px;border-radius:50%;background:var(--b)}
25
+ .ig .mark{position:absolute;right:44px;bottom:34px;display:flex;align-items:center;gap:16px;font-family:'Space Mono',monospace;font-size:30px;font-weight:700;letter-spacing:.06em;color:#fff;padding:8px 26px 8px 10px;border-radius:999px;background:rgba(10,14,20,.7);border:1px solid rgba(255,255,255,.3)}
26
+ .ig .mark svg{width:52px;height:52px;display:block}
27
+ .ig .left{display:contents}
28
+ .ig .head{order:1}.ig .fill{order:2}.ig .band{order:3}
29
+ .ig.landscape .main{top:70px;bottom:90px;flex-direction:row;gap:60px}
30
+ .ig.landscape .left{display:flex;flex-direction:column;justify-content:space-between;gap:28px;width:600px;flex:none;min-height:0}
31
+ .ig.landscape .head{display:block;height:auto}
32
+ .ig.landscape .head .txt{width:100%}
33
+ .ig.landscape h1{font-size:60px}
34
+ .ig.landscape .sub{font-size:28px}
35
+ .ig.landscape .hero{display:block;width:360px;height:300px;margin:22px 0 0 auto}
36
+ .ig.landscape .fill{order:0;width:1380px;flex:none}
37
+ .ig.landscape .band{grid-template-columns:1fr;gap:18px;padding:30px 34px}
38
+ .ig.landscape .band .q{font-size:36px}
39
+ .ig.landscape .band ul{font-size:22px}
40
+ {% block css %}{% endblock %}
41
+ </style>
42
+ <div class="ig {{ layout }}" style="--bg1:{{ pal.bg1 }};--bg2:{{ pal.bg2 }};--ink:{{ pal.ink }};--mute:{{ pal.mute }};--a:{{ pal.a }};--b:{{ pal.b }};--card:{{ pal.card }};--line:{{ pal.line }};--paper:{{ pal.paper }};--paperink:{{ pal.paperink }};--r:{{ pal.r }}px;--fh:'{{ fonts.h }}',serif;--fb:'{{ fonts.b }}',sans-serif;--fm:'{{ fonts.m }}',monospace;--hw:{{ fonts.hw }};--ht:{{ fonts.ht }};--bt:{{ fonts.bt }}">
43
+ <svg class="bg" width="{{ width }}" height="{{ height }}" viewBox="0 0 {{ width }} {{ height }}">
44
+ {% for s in stars %}<circle cx="{{ s[0] }}" cy="{{ s[1] }}" r="{{ s[2] }}" fill="{{ pal.ink }}" opacity="{{ s[3] }}"/>{% endfor %}
45
+ <g{% if layout == "landscape" %} transform="translate(660 0)"{% endif %}>{% block bg %}{% endblock %}</g>
46
+ </svg>
47
+ <div class="main">
48
+ <div class="left">
49
+ <div class="head">
50
+ <div class="txt">
51
+ <div class="eyebrow">{{ eyebrow }}</div>
52
+ <h1>{{ c.title }}</h1>
53
+ <p class="sub">{{ c.subtitle }}</p>
54
+ </div>
55
+ <svg class="hero" viewBox="0 0 480 400">{% block hero %}{% endblock %}</svg>
56
+ </div>
57
+ <div class="band">
58
+ <div class="q">{{ c.rule }}</div>
59
+ <ul>{% for n in c.notes %}<li>{{ n }}</li>{% endfor %}</ul>
60
+ </div>
61
+ </div>
62
+ <div class="fill">{% block body %}{% endblock %}</div>
63
+ </div>
64
+ <div class="mark">{{ logo }}<span>notestack.ai</span></div>
65
+ </div>
backend/app/infographics/templates/galaxy.html.j2 ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html.j2" %}
2
+ {% set eyebrow = "Field notes from the galaxy" %}
3
+ {% set pal = {"bg1":"#1c1830","bg2":"#3a2f57","ink":"#ece7f3","mute":"#c1b9d0","a":"#b79ad6","b":"#7fa3b8","card":"rgba(236,231,243,.06)","line":"rgba(183,154,214,.28)","paper":"#e9e2f2","paperink":"#241f33","r":26} %}
4
+ {% set fonts = {"h":"Cormorant Garamond","b":"Outfit","m":"Space Mono","hw":700,"ht":"none","bt":"none"} %}
5
+ {% set cycle = ["#b79ad6","#7fa3b8","#d9a7b8","#8fb8a8","#c9b27a"] %}
6
+ {% block bg %}
7
+ <defs><radialGradient id="neb" cx=".5" cy=".5"><stop offset="0" stop-color="#b79ad6" stop-opacity=".45"/><stop offset="1" stop-color="#b79ad6" stop-opacity="0"/></radialGradient>
8
+ <radialGradient id="neb2" cx=".5" cy=".5"><stop offset="0" stop-color="#7fa3b8" stop-opacity=".35"/><stop offset="1" stop-color="#7fa3b8" stop-opacity="0"/></radialGradient></defs>
9
+ <circle cx="1300" cy="300" r="520" fill="url(#neb)"/><circle cx="250" cy="1800" r="560" fill="url(#neb2)"/>
10
+ <g fill="none" stroke="#b79ad6" stroke-opacity=".22" stroke-width="2"><ellipse cx="1360" cy="1250" rx="620" ry="190" transform="rotate(-24 1360 1250)"/><ellipse cx="1360" cy="1250" rx="440" ry="130" transform="rotate(-24 1360 1250)"/><ellipse cx="200" cy="2020" rx="540" ry="150" transform="rotate(18 200 2020)"/></g>
11
+ {% endblock %}
12
+ {% block hero %}
13
+ <defs><radialGradient id="core" cx=".5" cy=".5"><stop offset="0" stop-color="#f4ecff"/><stop offset=".25" stop-color="#b79ad6"/><stop offset="1" stop-color="#b79ad6" stop-opacity="0"/></radialGradient></defs>
14
+ <g transform="translate(240 200)" fill="none" stroke-linecap="round">
15
+ <ellipse rx="210" ry="70" transform="rotate(-20)" stroke="#b79ad6" stroke-width="3" opacity=".9"/><ellipse rx="160" ry="52" transform="rotate(-20)" stroke="#7fa3b8" stroke-width="3"/>
16
+ <ellipse rx="110" ry="36" transform="rotate(-20)" stroke="#d9a7b8" stroke-width="3"/><ellipse rx="60" ry="20" transform="rotate(-20)" stroke="#c9b27a" stroke-width="3"/>
17
+ <path d="M-200 60C-120 -150 120 -190 210 -40" stroke="#b79ad6" stroke-width="2" stroke-dasharray="2 10" opacity=".8"/>
18
+ <circle r="86" fill="url(#core)" stroke="none"/>
19
+ <g fill="#ece7f3" stroke="none"><circle cx="-190" cy="64" r="9" fill="#7fa3b8"/><circle cx="150" cy="-70" r="7" fill="#d9a7b8"/><circle cx="196" cy="42" r="10" fill="#c9b27a"/><circle cx="-60" cy="-110" r="5"/><circle cx="90" cy="120" r="6"/></g>
20
+ </g>
21
+ {% endblock %}
22
+ {% block css %}
23
+ .ig h1{font-size:84px;line-height:1}
24
+ .gi{display:grid;grid-template-columns:76px 300px 1fr;gap:24px;align-items:center;width:1090px;min-height:104px;padding:14px 30px 14px 22px;border-radius:60px;background:var(--card);border:1px solid var(--line);margin-bottom:14px;position:relative}
25
+ .gi:before{content:"";position:absolute;left:-40px;top:50%;width:40px;height:2px;background:linear-gradient(90deg,transparent,var(--c))}
26
+ .gi .mk{width:64px;height:64px;border-radius:50%;display:grid;place-items:center;font-family:var(--fh);font-weight:700;font-size:32px;color:var(--bg1);background:radial-gradient(circle at 35% 30%,#fff8,var(--c) 60%);box-shadow:0 0 0 7px rgba(183,154,214,.16),0 0 30px var(--c)}
27
+ .gi b{font-family:var(--fh);font-weight:700;font-size:36px;line-height:1.05}
28
+ .gi p{font-size:24px;line-height:1.3;color:var(--mute)}
29
+ .gc{display:grid;grid-template-columns:repeat(4,1fr);gap:0 28px;row-gap:20px;flex:none}
30
+ .gs{position:relative;padding-top:34px}
31
+ .gs:before{content:"";position:absolute;left:0;right:-28px;top:9px;height:1px;background:var(--line)}
32
+ .gs:nth-child(4n):before{right:0}
33
+ .gs:after{content:"";position:absolute;left:0;top:2px;width:15px;height:15px;border-radius:50%;background:var(--c);box-shadow:0 0 0 5px rgba(183,154,214,.15),0 0 18px var(--c)}
34
+ .gs small{display:block;font-family:var(--fm);font-size:19px;letter-spacing:.14em;color:var(--c);margin-bottom:4px}
35
+ .gs b{display:block;font-family:var(--fh);font-weight:700;font-size:34px;margin-bottom:6px}
36
+ .gs p{font-size:22px;line-height:1.32;color:var(--mute)}
37
+ {% endblock %}
38
+ {% block body %}<section><div class="lab">{{ c.items_label }}</div>
39
+ <div class="gitems" style="flex:none">
40
+ {% for e in c["items"] %}<div class="gi" style="--c:{{ cycle[loop.index0 % cycle|length] }};margin-left:{{ loop.index0 * 56 + 30 }}px"><div class="mk">{{ loop.index }}</div><b>{{ e.name }}</b><p>{{ e.text }}</p></div>{% endfor %}
41
+ </div></section>
42
+ <section><div class="lab">{{ c.steps_label }}</div>
43
+ <div class="gc">
44
+ {% for e in c.steps %}<div class="gs" style="--c:{{ cycle[loop.index0 % cycle|length] }}"><small>{{ '%02d'|format(loop.index) }}</small><b>{{ e.name }}</b><p>{{ e.text }}</p></div>{% endfor %}
45
+ </div></section>
46
+ {% endblock %}
backend/app/infographics/templates/launch.html.j2 ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html.j2" %}
2
+ {% set eyebrow = "Mission briefing" %}
3
+ {% set pal = {"bg1":"#1d2a34","bg2":"#34495a","ink":"#efe7d8","mute":"#c3ccd1","a":"#cfa24d","b":"#c8745f","card":"rgba(239,231,216,.07)","line":"rgba(239,231,216,.18)","paper":"#efe7d8","paperink":"#1d2a34","r":18} %}
4
+ {% set fonts = {"h":"Jost","b":"Outfit","m":"Space Mono","hw":700,"ht":"uppercase","bt":"uppercase"} %}
5
+ {% set cycle = ["#cfa24d","#c8745f","#8fb0a0","#8fa6c4","#cfa24d"] %}
6
+ {% block bg %}
7
+ <circle cx="1440" cy="150" r="88" fill="#e9dfc6" opacity=".9"/><circle cx="1408" cy="122" r="15" fill="#b9ad90" opacity=".7"/><circle cx="1470" cy="178" r="21" fill="#b9ad90" opacity=".7"/>
8
+ <g fill="#efe7d8" opacity=".07"><circle cx="300" cy="2200" r="150"/><circle cx="520" cy="2230" r="120"/><circle cx="1200" cy="2210" r="170"/></g>
9
+ {% endblock %}
10
+ {% block hero %}
11
+ <g fill="none" stroke="#cfa24d" stroke-width="3" stroke-dasharray="3 10" stroke-linecap="round"><ellipse cx="250" cy="215" rx="205" ry="92" transform="rotate(-16 250 215)"/><ellipse cx="250" cy="215" rx="140" ry="58" transform="rotate(-16 250 215)"/></g>
12
+ <circle cx="250" cy="215" r="64" fill="#e9dfc6"/><circle cx="228" cy="195" r="12" fill="#b9ad90" opacity=".7"/><circle cx="276" cy="238" r="16" fill="#b9ad90" opacity=".7"/><circle cx="262" cy="180" r="7" fill="#b9ad90" opacity=".7"/>
13
+ <g transform="translate(404 122) rotate(-16)"><rect x="-17" y="-13" width="34" height="26" rx="5" fill="#cfa24d"/><rect x="-66" y="-9" width="42" height="18" fill="#8fa6c4"/><rect x="24" y="-9" width="42" height="18" fill="#8fa6c4"/><path d="M-66 -9V9M-52 -9V9M-38 -9V9M38 -9V9M52 -9V9" stroke="#1d2a34" stroke-width="2"/></g>
14
+ <g fill="#efe7d8"><path d="M70 90l5 -14 5 14 14 5 -14 5 -5 14 -5 -14 -14 -5z" opacity=".8"/><path d="M60 320l4 -10 4 10 10 4 -10 4 -4 10 -4 -10 -10 -4z" opacity=".6"/></g>
15
+ {% endblock %}
16
+ {% block css %}
17
+ .ig{--hh:380px}.ig h1{font-size:68px}
18
+ .lr{position:relative;flex:none}.lr svg{position:absolute;left:0;top:0}
19
+ .lr .co{position:absolute;width:470px;padding:16px 22px 18px;border-radius:18px;background:rgba(239,231,216,.07);border:1px solid rgba(239,231,216,.2)}
20
+ .lr .co b{display:flex;align-items:center;gap:12px;font-family:var(--fh);font-size:29px;letter-spacing:.04em;text-transform:uppercase;margin-bottom:6px}
21
+ .lr .co b i{font-style:normal;flex:none;width:38px;height:38px;border-radius:50%;display:grid;place-items:center;font-size:21px;color:#1d2a34;background:var(--c)}
22
+ .lr .co p{font-size:23px;line-height:1.34;color:var(--mute)}
23
+ .cd{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;flex:none}
24
+ .cd .t{position:relative;padding:16px 20px 18px;border-radius:16px;background:var(--card);border-top:6px solid var(--c)}
25
+ .cd .t:after{content:"";position:absolute;right:18px;top:18px;width:13px;height:13px;border-radius:50%;background:var(--c);box-shadow:0 0 0 4px rgba(239,231,216,.1),0 0 14px var(--c)}
26
+ .cd small{display:block;font-family:var(--fm);font-size:19px;letter-spacing:.1em;color:var(--c)}
27
+ .cd b{display:block;font-family:var(--fh);font-size:30px;text-transform:uppercase;letter-spacing:.04em;margin:2px 0 6px}
28
+ .cd p{font-size:21px;line-height:1.32;color:var(--mute)}
29
+ {% endblock %}
30
+ {% block body %}
31
+ {% set S = 110 %}{% set n = c["items"]|length %}{% set H = 120 + n * S + 90 %}
32
+ <section><div class="lab">{{ c.items_label }}</div>
33
+ <div class="lr" style="height:{{ H }}px">
34
+ <svg width="1380" height="{{ H }}" viewBox="0 0 1380 {{ H }}">
35
+ <defs><linearGradient id="gr" x1="0" x2="1"><stop offset="0" stop-color="#e8dfcf"/><stop offset="1" stop-color="#b9b3a6"/></linearGradient></defs>
36
+ <path d="M690 0C738 36 760 76 760 120H620C620 76 642 36 690 0Z" fill="#c8745f"/>
37
+ {% for e in c["items"] %}{% set y = 120 + loop.index0 * S %}{% set cc = cycle[loop.index0 % cycle|length] %}
38
+ <rect x="620" y="{{ y }}" width="140" height="{{ S }}" fill="url(#gr)" stroke="#1d2a34" stroke-width="3"/>
39
+ <circle cx="690" cy="{{ y + S // 2 }}" r="30" fill="{{ cc }}" stroke="#1d2a34" stroke-width="3"/>
40
+ <text x="690" y="{{ y + S // 2 + 11 }}" text-anchor="middle" font-family="Jost" font-weight="700" font-size="32" fill="#1d2a34">{{ loop.index }}</text>
41
+ {% if loop.index0 % 2 == 0 %}<path d="M620 {{ y + S // 2 }}H480" stroke="{{ cc }}" stroke-width="3" stroke-dasharray="2 9" stroke-linecap="round"/>
42
+ {% else %}<path d="M760 {{ y + S // 2 }}H900" stroke="{{ cc }}" stroke-width="3" stroke-dasharray="2 9" stroke-linecap="round"/>{% endif %}
43
+ {% endfor %}
44
+ {% set yb = 120 + n * S %}
45
+ <path d="M620 {{ yb - S }}L552 {{ yb }}H620ZM760 {{ yb - S }}L828 {{ yb }}H760Z" fill="#c8745f"/>
46
+ <path d="M644 {{ yb }}h92l-10 26h-72Z" fill="#8a8578"/>
47
+ <path d="M656 {{ yb + 26 }}C664 {{ yb + 56 }} 690 {{ yb + 84 }} 690 {{ yb + 84 }}C690 {{ yb + 84 }} 716 {{ yb + 56 }} 724 {{ yb + 26 }}Z" fill="#cfa24d"/>
48
+ <path d="M672 {{ yb + 26 }}C678 {{ yb + 46 }} 690 {{ yb + 62 }} 690 {{ yb + 62 }}C690 {{ yb + 62 }} 702 {{ yb + 46 }} 708 {{ yb + 26 }}Z" fill="#f4ead3"/>
49
+ </svg>
50
+ {% for e in c["items"] %}{% set cc = cycle[loop.index0 % cycle|length] %}
51
+ <div class="co" style="--c:{{ cc }};top:{{ 120 + loop.index0 * S + S // 2 - 46 }}px;{{ 'left:0' if loop.index0 % 2 == 0 else 'right:0' }}"><b><i>{{ loop.index }}</i>{{ e.name }}</b><p>{{ e.text }}</p></div>
52
+ {% endfor %}
53
+ </div></section>
54
+ <section><div class="lab">{{ c.steps_label }}</div>
55
+ <div class="cd">
56
+ {% for e in c.steps %}<div class="t" style="--c:{{ cycle[loop.index0 % cycle|length] }}"><small>T-{{ c.steps|length - loop.index0 }}</small><b>{{ e.name }}</b><p>{{ e.text }}</p></div>{% endfor %}
57
+ </div></section>
58
+ {% endblock %}
backend/app/infographics/templates/moonbase.html.j2 ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html.j2" %}
2
+ {% set eyebrow = "Lunar surface log" %}
3
+ {% set pal = {"bg1":"#222226","bg2":"#3b3b42","ink":"#efeae0","mute":"#c7c3b9","a":"#d8d2c4","b":"#c8745f","card":"rgba(239,234,224,.07)","line":"rgba(239,234,224,.2)","paper":"#d8d2c4","paperink":"#222226","r":20} %}
4
+ {% set fonts = {"h":"Outfit","b":"Outfit","m":"Space Mono","hw":600,"ht":"none","bt":"none"} %}
5
+ {% set cycle = ["#d8d2c4","#c8745f","#9fb4a4","#8fa6c4","#d6b66a"] %}
6
+ {% block bg %}
7
+ <circle cx="1360" cy="190" r="110" fill="#5c7ea6" opacity=".55"/><circle cx="1360" cy="190" r="110" fill="none" stroke="#8fb0c9" stroke-opacity=".5" stroke-width="3"/>
8
+ <path d="M0 2210Q300 2150 600 2190T1200 2180T1600 2170V2260H0Z" fill="#d8d2c4" opacity=".1"/>
9
+ <g fill="#d8d2c4" opacity=".08"><ellipse cx="260" cy="2225" rx="120" ry="22"/><ellipse cx="900" cy="2235" rx="170" ry="24"/><ellipse cx="1400" cy="2220" rx="90" ry="18"/></g>
10
+ {% endblock %}
11
+ {% block hero %}
12
+ <path d="M0 392Q120 340 240 372T480 350V400H0Z" fill="#d8d2c4" opacity=".85"/>
13
+ <ellipse cx="90" cy="372" rx="46" ry="10" fill="#9b968a"/><ellipse cx="420" cy="380" rx="34" ry="8" fill="#9b968a"/>
14
+ <path d="M130 365C130 270 190 220 250 220S370 270 370 360Z" fill="#8fa6c4" fill-opacity=".35" stroke="#d8d2c4" stroke-width="4"/>
15
+ <path d="M190 366V318H310V366" fill="#c7c3b9" stroke="#222226" stroke-width="3"/><rect x="236" y="330" width="28" height="36" fill="#222226"/>
16
+ <path d="M250 220V130" stroke="#d8d2c4" stroke-width="4"/><path d="M250 130H310L292 148L310 166H250Z" fill="#c8745f"/>
17
+ <circle cx="70" cy="90" r="6" fill="#efeae0"/><circle cx="410" cy="60" r="8" fill="#efeae0" opacity=".7"/>
18
+ {% endblock %}
19
+ {% block css %}
20
+ .ig h1{font-size:74px;letter-spacing:-.01em}
21
+ .mc{display:flex;flex-direction:column;gap:14px;flex:none}
22
+ .mc .cap{display:grid;grid-template-columns:84px 270px 1fr;gap:22px;align-items:center;width:1210px;min-height:104px;padding:12px 34px 12px 14px;border-radius:70px;background:var(--card);border:1px solid var(--line)}
23
+ .mc .cap:nth-child(even){margin-left:auto}
24
+ .mc .mk{width:76px;height:76px;border-radius:50%;display:grid;place-items:center;font-family:var(--fh);font-weight:600;font-size:36px;color:#222226;background:var(--c);border:6px solid rgba(34,34,38,.55);outline:3px solid var(--c)}
25
+ .mc b{font-family:var(--fh);font-weight:600;font-size:31px;line-height:1.08}
26
+ .mc p{font-size:23px;line-height:1.32;color:var(--mute)}
27
+ .mt{display:grid;grid-template-columns:1fr 1fr;gap:0 56px;flex:none}
28
+ .mt .col{position:relative;padding-left:46px}
29
+ .mt .col:before{content:"";position:absolute;left:11px;top:8px;bottom:8px;width:3px;background:repeating-linear-gradient(180deg,var(--a) 0 10px,transparent 10px 18px);opacity:.55}
30
+ .mt .ev{position:relative;padding:4px 0 18px}
31
+ .mt .ev:before{content:"";position:absolute;left:-46px;top:8px;width:26px;height:26px;border-radius:7px;background:var(--c);box-shadow:0 0 0 6px rgba(216,210,196,.12)}
32
+ .mt small{font-family:var(--fm);font-size:19px;letter-spacing:.14em;color:var(--c)}
33
+ .mt b{display:block;font-family:var(--fh);font-weight:600;font-size:30px;margin:2px 0 3px}
34
+ .mt p{font-size:22px;line-height:1.32;color:var(--mute)}
35
+ .ig .band{border-top:10px solid transparent;border-image:repeating-linear-gradient(135deg,#c8745f 0 18px,#222226 18px 36px) 10}
36
+ {% endblock %}
37
+ {% block body %}{% set half = (c.steps|length + 1) // 2 %}
38
+ <section><div class="lab">{{ c.items_label }}</div>
39
+ <div class="mc">
40
+ {% for e in c["items"] %}<div class="cap" style="--c:{{ cycle[loop.index0 % cycle|length] }}"><div class="mk">{{ loop.index }}</div><b>{{ e.name }}</b><p>{{ e.text }}</p></div>{% endfor %}
41
+ </div></section>
42
+ <section><div class="lab">{{ c.steps_label }}</div>
43
+ <div class="mt">
44
+ <div class="col">
45
+ {% for e in c.steps[:half] %}<div class="ev" style="--c:{{ cycle[loop.index0 % cycle|length] }}"><small>SOL {{ loop.index }}</small><b>{{ e.name }}</b><p>{{ e.text }}</p></div>{% endfor %}
46
+ </div>
47
+ <div class="col">
48
+ {% for e in c.steps[half:] %}<div class="ev" style="--c:{{ cycle[(loop.index0 + half) % cycle|length] }}"><small>SOL {{ loop.index + half }}</small><b>{{ e.name }}</b><p>{{ e.text }}</p></div>{% endfor %}
49
+ </div>
50
+ </div></section>
51
+ {% endblock %}
backend/app/infographics/templates/observatory.html.j2 ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html.j2" %}
2
+ {% set eyebrow = "Observation log" %}
3
+ {% set pal = {"bg1":"#102427","bg2":"#1d4144","ink":"#ece8da","mute":"#b9c9c4","a":"#8fc2b4","b":"#d6b66a","card":"rgba(236,232,218,.06)","line":"rgba(143,194,180,.3)","paper":"#ece8da","paperink":"#102427","r":14} %}
4
+ {% set fonts = {"h":"Playfair Display","b":"Outfit","m":"Space Mono","hw":700,"ht":"none","bt":"none"} %}
5
+ {% set cycle = ["#8fc2b4","#d6b66a","#a9b9d6","#d68f7a","#8fc2b4"] %}
6
+ {% block bg %}
7
+ <g fill="none" stroke="#8fc2b4" stroke-opacity=".14" stroke-width="2"><circle cx="1500" cy="300" r="260"/><circle cx="1500" cy="300" r="380"/><circle cx="1500" cy="300" r="500"/><path d="M1500 -200V800M1000 300H2000"/></g>
8
+ <g stroke="#d6b66a" stroke-opacity=".35" stroke-width="2" fill="#d6b66a"><path d="M1090 190L1180 260L1260 220L1330 330" fill="none"/><circle cx="1090" cy="190" r="6"/><circle cx="1180" cy="260" r="8"/><circle cx="1260" cy="220" r="6"/><circle cx="1330" cy="330" r="7"/></g>
9
+ {% endblock %}
10
+ {% block hero %}
11
+ <g transform="translate(240 240) rotate(-32)"><rect x="-130" y="-34" width="250" height="68" rx="8" fill="#d6b66a"/><rect x="-170" y="-26" width="46" height="52" rx="6" fill="#8fc2b4"/><rect x="100" y="-46" width="40" height="92" rx="6" fill="#ece8da"/><path d="M-60 -34V34M0 -34V34M60 -34V34" stroke="#102427" stroke-width="3"/></g>
12
+ <path d="M240 250L170 390M240 250L320 390M240 250V392" stroke="#ece8da" stroke-width="7" stroke-linecap="round"/>
13
+ <circle cx="380" cy="70" r="30" fill="#ece8da" opacity=".9"/><circle cx="392" cy="62" r="26" fill="#102427"/>
14
+ <g fill="#d6b66a"><path d="M70 90l6 -16 6 16 16 6 -16 6 -6 16 -6 -16 -16 -6z"/><path d="M430 200l4 -11 4 11 11 4 -11 4 -4 11 -4 -11 -11 -4z"/></g>
15
+ {% endblock %}
16
+ {% block css %}
17
+ .ig h1{font-size:68px}
18
+ .ob{display:grid;grid-template-columns:1fr 1fr;gap:14px;flex:none}
19
+ .ob .pl{position:relative;padding:20px 26px 22px;border-radius:var(--r);background:var(--card);border:1px solid var(--line)}
20
+ .ob .pl:last-child:nth-child(even){grid-column:span 2}
21
+ .ob .pl small{font-family:var(--fm);font-size:18px;letter-spacing:.2em;color:var(--c);text-transform:uppercase}
22
+ .ob .pl b{display:block;font-family:var(--fh);font-weight:700;font-style:italic;font-size:32px;margin:4px 0 6px}
23
+ .ob .pl p{font-size:23px;line-height:1.34;color:var(--mute)}
24
+ .ob .feat{grid-column:span 2;display:grid;grid-template-columns:130px 1fr;gap:6px 28px;align-items:center;padding:26px 34px;background:linear-gradient(100deg,rgba(143,194,180,.18),rgba(143,194,180,.04));border-color:var(--c)}
25
+ .ob .feat .big{grid-row:span 3;font-family:var(--fh);font-weight:700;font-size:118px;line-height:1;color:var(--c);opacity:.9}
26
+ .ob .feat b{font-size:42px;margin:0}.ob .feat p{font-size:26px}
27
+ .ed{display:grid;grid-template-columns:1fr 1fr;gap:0 56px;flex:none}
28
+ .ed .li{display:grid;grid-template-columns:80px 1fr;gap:0 16px;padding:12px 0 14px;border-bottom:1px solid var(--line)}
29
+ .ed .li .n{grid-row:span 2;font-family:var(--fh);font-weight:700;font-size:58px;line-height:.95;color:var(--c)}
30
+ .ed .li b{font-family:var(--fh);font-weight:700;font-size:29px}
31
+ .ed .li p{font-size:22px;line-height:1.3;color:var(--mute)}
32
+ {% endblock %}
33
+ {% block body %}<section><div class="lab">{{ c.items_label }}</div>
34
+ <div class="ob">
35
+ {% for e in c["items"] %}{% if loop.first %}
36
+ <div class="pl feat" style="--c:{{ cycle[0] }}"><div class="big">1</div><small>The main idea</small><b>{{ e.name }}</b><p>{{ e.text }}</p></div>
37
+ {% else %}
38
+ <div class="pl" style="--c:{{ cycle[loop.index0 % cycle|length] }}"><small>Plate {{ loop.index }}</small><b>{{ e.name }}</b><p>{{ e.text }}</p></div>
39
+ {% endif %}{% endfor %}
40
+ </div></section>
41
+ <section><div class="lab">{{ c.steps_label }}</div>
42
+ <div class="ed">
43
+ {% for e in c.steps %}<div class="li" style="--c:{{ cycle[loop.index0 % cycle|length] }}"><div class="n">{{ loop.index }}</div><b>{{ e.name }}</b><p>{{ e.text }}</p></div>{% endfor %}
44
+ </div></section>
45
+ {% endblock %}
backend/app/infographics/templates/solar.html.j2 ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html.j2" %}
2
+ {% set eyebrow = "A tour of the solar system" %}
3
+ {% set pal = {"bg1":"#1a2230","bg2":"#2e3d52","ink":"#f0ead9","mute":"#c2c9cf","a":"#d9a441","b":"#7fa3b8","card":"rgba(240,234,217,.07)","line":"rgba(240,234,217,.2)","paper":"#f0ead9","paperink":"#1a2230","r":16} %}
4
+ {% set fonts = {"h":"Playfair Display","b":"Outfit","m":"Space Mono","hw":700,"ht":"none","bt":"none"} %}
5
+ {% set cycle = ["#d9a441","#c8745f","#7fa3b8","#8fb0a0","#b79ad6"] %}
6
+ {% block bg %}
7
+ <g fill="none" stroke="#f0ead9" stroke-opacity=".12" stroke-width="2"><circle cx="-120" cy="1100" r="620"/><circle cx="-120" cy="1100" r="900"/><circle cx="-120" cy="1100" r="1180"/><circle cx="-120" cy="1100" r="1460"/></g>
8
+ <circle cx="-120" cy="1100" r="190" fill="#d9a441" opacity=".13"/>
9
+ <circle cx="1556" cy="860" r="18" fill="#c8745f" opacity=".6"/><circle cx="1560" cy="1500" r="26" fill="#7fa3b8" opacity=".5"/>
10
+ {% endblock %}
11
+ {% block hero %}
12
+ <circle cx="-40" cy="200" r="120" fill="#d9a441"/><circle cx="-40" cy="200" r="150" fill="#d9a441" opacity=".25"/>
13
+ <path d="M0 340Q240 360 480 300" stroke="#f0ead9" stroke-opacity=".5" stroke-width="2" stroke-dasharray="3 9" fill="none"/>
14
+ <circle cx="150" cy="120" r="22" fill="#b9ad90"/><circle cx="220" cy="270" r="34" fill="#c8745f"/><circle cx="300" cy="150" r="42" fill="#7fa3b8"/><circle cx="305" cy="145" r="42" fill="none" stroke="#f0ead9" stroke-opacity=".0"/>
15
+ <g transform="translate(400 250)"><ellipse rx="70" ry="18" fill="none" stroke="#e6d3a1" stroke-width="7" transform="rotate(-18)"/><circle r="40" fill="#d9a441"/><path d="M-72 10Q0 34 72 -6" stroke="#e6d3a1" stroke-width="7" fill="none" transform="rotate(-18)"/></g>
16
+ <circle cx="420" cy="80" r="26" fill="#8fb0a0"/>
17
+ {% endblock %}
18
+ {% block css %}
19
+ .ig h1{font-size:70px}
20
+ .sp{display:flex;gap:18px;flex:none;align-items:stretch}
21
+ .sp .pl{flex:1;display:flex;flex-direction:column;align-items:center;text-align:center;padding:0 6px}
22
+ .sp .og{height:140px;display:flex;align-items:flex-end;margin-bottom:-34px;z-index:1}
23
+ .sp .orb{position:relative;display:grid;place-items:center;border-radius:50%;font-family:var(--fh);font-weight:700;font-size:42px;color:#1a2230;background:radial-gradient(circle at 32% 28%,#fff9,var(--c) 62%);box-shadow:inset -14px -12px 0 rgba(0,0,0,.2)}
24
+ .sp .card{width:100%;padding:48px 18px 20px;border-radius:var(--r);background:var(--card);border:1px solid var(--line);flex:1}
25
+ .sp b{display:block;font-family:var(--fh);font-weight:700;font-size:31px;margin-bottom:8px;line-height:1.1}
26
+ .sp p{font-size:22px;line-height:1.34;color:var(--mute)}
27
+ .sm{display:grid;grid-template-columns:repeat(4,1fr);gap:0 24px;row-gap:22px;flex:none}
28
+ .sm .stop{position:relative;padding-top:44px}
29
+ .sm .stop:before{content:"";position:absolute;left:0;right:-24px;top:14px;height:8px;border-radius:4px;background:var(--c);opacity:.85}
30
+ .sm .stop:nth-child(4n):before{right:0}
31
+ .sm .stop:after{content:"";position:absolute;left:6px;top:7px;width:22px;height:22px;border-radius:50%;background:#f0ead9;border:6px solid var(--c);box-sizing:border-box;width:22px;height:22px}
32
+ .sm small{display:block;font-family:var(--fm);font-size:19px;letter-spacing:.1em;color:var(--c);margin-bottom:3px}
33
+ .sm b{display:block;font-family:var(--fh);font-weight:700;font-size:31px;margin-bottom:5px}
34
+ .sm p{font-size:22px;line-height:1.32;color:var(--mute)}
35
+ {% endblock %}
36
+ {% block body %}{% set n = c["items"]|length %}{% set sizes = [124, 100, 138, 92, 116] %}
37
+ <section><div class="lab">{{ c.items_label }}</div>
38
+ <div class="sp">
39
+ {% for e in c["items"] %}{% set d = sizes[loop.index0] %}
40
+ <div class="pl" style="--c:{{ cycle[loop.index0 % cycle|length] }}"><div class="og"><div class="orb" style="width:{{ d }}px;height:{{ d }}px">{{ loop.index }}</div></div><div class="card"><b>{{ e.name }}</b><p>{{ e.text }}</p></div></div>
41
+ {% endfor %}
42
+ </div></section>
43
+ <section><div class="lab">{{ c.steps_label }}</div>
44
+ <div class="sm">
45
+ {% for e in c.steps %}<div class="stop" style="--c:{{ cycle[loop.index0 % cycle|length] }}"><small>Stop {{ loop.index }}</small><b>{{ e.name }}</b><p>{{ e.text }}</p></div>{% endfor %}
46
+ </div></section>
47
+ {% endblock %}
backend/app/infographics/templates/station.html.j2 ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html.j2" %}
2
+ {% set eyebrow = "Station blueprint" %}
3
+ {% set pal = {"bg1":"#13263a","bg2":"#1f3d57","ink":"#e6eef2","mute":"#b3c5d0","a":"#8fb8c9","b":"#d9a441","card":"rgba(143,184,201,.08)","line":"rgba(143,184,201,.35)","paper":"#e6eef2","paperink":"#13263a","r":4} %}
4
+ {% set fonts = {"h":"Jost","b":"Outfit","m":"Space Mono","hw":700,"ht":"uppercase","bt":"uppercase"} %}
5
+ {% set cycle = ["#8fb8c9","#d9a441","#9cc4a6","#d68f7a","#8fb8c9"] %}
6
+ {% block bg %}
7
+ <g stroke="#8fb8c9" stroke-opacity=".1" stroke-width="1">{% for x in range(0, 1601, 80) %}<line x1="{{ x }}" y1="0" x2="{{ x }}" y2="2260"/>{% endfor %}{% for y in range(0, 2261, 80) %}<line x1="0" y1="{{ y }}" x2="1600" y2="{{ y }}"/>{% endfor %}</g>
8
+ <rect x="60" y="60" width="1480" height="2140" fill="none" stroke="#8fb8c9" stroke-opacity=".5" stroke-width="2"/>
9
+ {% endblock %}
10
+ {% block hero %}
11
+ <g fill="none" stroke="#8fb8c9" stroke-width="3">
12
+ <rect x="190" y="130" width="100" height="140" rx="10" fill="#1f3d57"/><rect x="215" y="60" width="50" height="70"/><rect x="215" y="270" width="50" height="70"/>
13
+ <circle cx="240" cy="200" r="30" stroke="#d9a441"/>
14
+ <rect x="20" y="170" width="150" height="60" fill="#1f3d57"/><rect x="310" y="170" width="150" height="60" fill="#1f3d57"/>
15
+ <path d="M60 170V230M100 170V230M140 170V230M350 170V230M390 170V230M430 170V230" stroke-width="2"/>
16
+ <path d="M170 200H190M290 200H310" stroke="#d9a441"/>
17
+ <path d="M10 360H470M10 350V370M470 350V370" stroke-dasharray="6 6" stroke-width="2"/>
18
+ </g>
19
+ {% endblock %}
20
+ {% block css %}
21
+ .ig h1{font-size:66px}
22
+ .sg{display:grid;grid-template-columns:1fr 1fr;gap:16px;flex:none}
23
+ .sg .mod{position:relative;padding:44px 26px 22px;background:var(--card);border:1px dashed var(--line)}
24
+ .sg .mod:last-child:nth-child(odd){grid-column:span 2}
25
+ .sg .mod:before,.sg .mod:after{content:"";position:absolute;width:26px;height:26px;border:3px solid var(--c)}
26
+ .sg .mod:before{left:-2px;top:-2px;border-right:0;border-bottom:0}
27
+ .sg .mod:after{right:-2px;bottom:-2px;border-left:0;border-top:0}
28
+ .sg .tag{position:absolute;left:26px;top:12px;font-family:var(--fm);font-size:18px;letter-spacing:.2em;color:var(--c);text-transform:uppercase}
29
+ .sg .tag em{font-style:normal;color:var(--mute)}
30
+ .sg b{display:block;font-family:var(--fh);font-weight:700;font-size:32px;letter-spacing:.03em;text-transform:uppercase;margin-bottom:6px}
31
+ .sg p{font-size:24px;line-height:1.34;color:var(--mute)}
32
+ .lg{display:grid;grid-template-columns:1fr 1fr;gap:0 40px;flex:none;border-top:2px solid var(--line)}
33
+ .lg .row{display:grid;grid-template-columns:96px 1fr;grid-template-rows:auto 1fr;align-content:start;gap:6px 18px;padding:12px 0;border-bottom:1px dashed var(--line)}
34
+ .lg .row small{grid-row:span 2;font-family:var(--fm);font-size:20px;color:var(--c);letter-spacing:.08em;padding-top:5px}
35
+ .lg .row b{font-family:var(--fh);font-weight:700;font-size:28px;letter-spacing:.04em;text-transform:uppercase}
36
+ .lg .row p{font-size:22px;line-height:1.3;color:var(--mute)}
37
+ .ig .band{border:3px double var(--a)}
38
+ {% endblock %}
39
+ {% block body %}<section><div class="lab">{{ c.items_label }}</div>
40
+ <div class="sg">
41
+ {% for e in c["items"] %}<div class="mod" style="--c:{{ cycle[loop.index0 % cycle|length] }}"><span class="tag">Module <em>{{ 'ABCDE'[loop.index0] }}-{{ '%02d'|format(loop.index) }}</em></span><b>{{ e.name }}</b><p>{{ e.text }}</p></div>{% endfor %}
42
+ </div></section>
43
+ <section><div class="lab">{{ c.steps_label }}</div>
44
+ <div class="lg">
45
+ {% for e in c.steps %}<div class="row" style="--c:{{ cycle[loop.index0 % cycle|length] }}"><small>SEQ {{ '%02d'|format(loop.index) }}</small><b>{{ e.name }}</b><p>{{ e.text }}</p></div>{% endfor %}
46
+ </div></section>
47
+ {% endblock %}
backend/app/infographics/themes.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The premade themes: one HTML template each (templates/<id>.html.j2), all space themed with muted palettes."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class Theme:
8
+ id: str
9
+ name: str
10
+ blurb: str
11
+ swatch: tuple[str, str, str] # background, accent, second accent: for the picker card
12
+
13
+
14
+ THEMES: dict[str, Theme] = {t.id: t for t in (
15
+ Theme("launch", "Launch", "A rocket, stage by stage, with a countdown", ("#1d2a34", "#cfa24d", "#c8745f")),
16
+ Theme("galaxy", "Galaxy", "Spiral arms and glowing orbit rings", ("#241f33", "#b79ad6", "#7fa3b8")),
17
+ Theme("solar", "Solar System", "Planets in a row and a route between them", ("#1b2433", "#d9a441", "#7fa3b8")),
18
+ Theme("station", "Space Station", "A blueprint of modules and solar wings", ("#16283a", "#8fb8c9", "#d9a441")),
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
backend/app/llm/provider.py CHANGED
@@ -52,6 +52,16 @@ def fast_lm() -> dspy.LM:
52
  return _build(settings.llm_fast_model, effort="low", temperature=None, max_tokens=8000)
53
 
54
 
 
 
 
 
 
 
 
 
 
 
55
  @lru_cache
56
  def triage_lm() -> dspy.LM:
57
  """Cheap model that decides whether a chat message needs research at all."""
 
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
56
+
57
+
58
+ @lru_cache
59
+ def report_lm() -> dspy.LM:
60
+ """Writes reports: the fast model unless LLM_REPORT_MODEL names another, with room for a long answer."""
61
+ return _build(settings.llm_report_model or settings.llm_fast_model, effort="low", temperature=None,
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."""
backend/app/llm/signatures.py CHANGED
@@ -353,6 +353,208 @@ class ArrangeMindMap(dspy.Signature):
353
  branches: list[MindArrangedBranch] = dspy.OutputField(desc="3 to 10 branches")
354
 
355
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
356
  class ExtractTopics(dspy.Signature):
357
  """Name the topics this post is about, the way a reader would search for them."""
358
 
 
353
  branches: list[MindArrangedBranch] = dspy.OutputField(desc="3 to 10 branches")
354
 
355
 
356
+ class QuizQuestion(BaseModel):
357
+ type: Literal["multiple_choice", "multiple_select", "fill_blank", "short_answer"]
358
+ question: str = Field(description="The question. A fill_blank question has exactly one ____ where the answer goes")
359
+ options: list[str] = Field(default_factory=list, description="3 to 5 choices for multiple_choice and "
360
+ "multiple_select, otherwise empty")
361
+ correct: list[int] = Field(default_factory=list, description="Zero based indexes into options: one for "
362
+ "multiple_choice, two or more for multiple_select")
363
+ answer: str = Field(default="", description="fill_blank: the word or phrase for the blank. short_answer: a model "
364
+ "answer in one or two sentences. Empty for the choice types")
365
+ accepted: list[str] = Field(default_factory=list, description="fill_blank only: other wordings that are also right")
366
+ explanation: str = Field(description="One or two sentences on why the answer is right")
367
+ sources: list[SourceRef] = Field(default_factory=list, description="Lines the answer comes from, when the "
368
+ "material has numbered lines")
369
+
370
+
371
+ class GenerateQuiz(dspy.Signature):
372
+ """Write a quiz that tests whether the reader understood the material. Ask only about what the material says;
373
+ never use outside facts. Every question has one clear answer the material supports. Use only the question types
374
+ allowed, mixing them when several are allowed, and write exactly the number of questions asked for. Wrong
375
+ options must be plausible, not silly, and options must not give the answer away by length or wording. When a
376
+ topic is given, ask only about it, as far as the material covers it. Match the difficulty: easy asks for recall
377
+ of stated facts, medium asks for understanding, hard asks for applying ideas or telling similar ideas apart.
378
+ Write in the language requested. Never use em dashes."""
379
+
380
+ title: str = dspy.InputField()
381
+ material: list[str] = dspy.InputField(desc="Posts with path and numbered lines, or chat transcripts")
382
+ topic: str = dspy.InputField(desc="What the quiz should be about, or (none)")
383
+ difficulty: str = dspy.InputField(desc="easy, medium or hard")
384
+ question_types: list[str] = dspy.InputField(desc="The only question types allowed")
385
+ count: int = dspy.InputField(desc="How many questions to write")
386
+ language: str = dspy.InputField(desc="The language to write in")
387
+ questions: list[QuizQuestion] = dspy.OutputField()
388
+
389
+
390
+ class Flashcard(BaseModel):
391
+ front: str = Field(description="A term, a question or a prompt, short")
392
+ back: str = Field(description="The answer or definition in one to three plain sentences")
393
+ sources: list[SourceRef] = Field(default_factory=list, description="Lines the card comes from, when the material "
394
+ "has numbered lines")
395
+
396
+
397
+ class GenerateFlashcards(dspy.Signature):
398
+ """Write flashcards for studying the material: each card has a short front (a term, a question or a prompt) and a
399
+ back that answers it from the material. Cover the main ideas, not trivia, and do not repeat a card. Never use
400
+ outside facts. When a topic is given, make cards only about it, as far as the material covers it. Match the
401
+ difficulty: easy cards test recall of stated terms and facts, medium cards test understanding, hard cards ask the
402
+ reader to apply an idea or tell similar ideas apart. Write in the language requested. Never use em dashes."""
403
+
404
+ title: str = dspy.InputField()
405
+ material: list[str] = dspy.InputField(desc="Posts with path and numbered lines, or chat transcripts")
406
+ topic: str = dspy.InputField(desc="What the cards should be about, or (none)")
407
+ difficulty: str = dspy.InputField(desc="easy, medium or hard")
408
+ count: int = dspy.InputField(desc="How many cards to write")
409
+ language: str = dspy.InputField(desc="The language to write in")
410
+ cards: list[Flashcard] = dspy.OutputField()
411
+
412
+
413
+ class InfographicEntry(BaseModel):
414
+ name: str = Field(description="1 to 3 words")
415
+ text: str = Field(description="One short plain sentence")
416
+
417
+
418
+ class InfographicOut(BaseModel):
419
+ title: str = Field(description="At most 8 words")
420
+ subtitle: str = Field(description="One line, at most 16 words")
421
+ items_label: str = Field(description="A 2 to 4 word heading for the items, e.g. The five layers")
422
+ items: list[InfographicEntry] = Field(description="3 to 5 main ideas, text at most 18 words each")
423
+ steps_label: str = Field(description="A 2 to 5 word heading for the steps, e.g. How a request flows")
424
+ steps: list[InfographicEntry] = Field(description="4 to 8 steps in order, name 1 or 2 words, text at most 12 words")
425
+ rule: str = Field(description="The one takeaway, at most 12 words")
426
+ notes: list[str] = Field(description="Exactly 3 supporting notes, at most 14 words each")
427
+
428
+
429
+ class PlanInfographic(dspy.Signature):
430
+ """Turn the material into the text of a one page infographic: a title, the main ideas, the ordered steps or stages
431
+ that connect them, one takeaway and three notes. Use only what the material says. When a focus is given, make the
432
+ infographic about it, as far as the material covers it. The material is a list of ideas from posts, or chat
433
+ transcripts (then summarise what was discussed and concluded). Keep every line short and plain: it is printed on a
434
+ poster, so no markdown, no quotes around names, no lists inside a line. Write in the language of the material.
435
+ Never use em dashes."""
436
+
437
+ title: str = dspy.InputField(desc="What the material is from")
438
+ material: list[str] = dspy.InputField(desc="Ideas from posts, or chat transcripts")
439
+ focus: str = dspy.InputField(desc="What the infographic should be about, or (none)")
440
+ infographic: InfographicOut = dspy.OutputField()
441
+
442
+
443
+ class WriteReport(dspy.Signature):
444
+ """Write the report the reader describes, using only the material given: never add facts from outside it. Follow
445
+ the reader's instructions on structure, style, tone and length. Write in Markdown that starts straight at the
446
+ first "## " heading (the title goes in report_title, not in the text). Structure it like a well organised
447
+ article: open with a "## Introduction" that says what the report covers (and, for a study or how-to report, what the
448
+ reader will be able to do by the end), then 3 to 7 sections, then close with a "## Summary" of the key takeaways,
449
+ unless the reader's instructions ask for a different structure. Every "## " heading is specific and descriptive of
450
+ what is in that section (for example "The Context Window Bottleneck: Moving Beyond Basic Summarization", never
451
+ "Section 2" or "Details"), and they read in a logical order. Use "### " inside sections, lists, and tables where
452
+ they help. When the material has numbered lines (FILE blocks), put a [n] marker after each
453
+ claim it supports and list each n in citations with the path and lines it came from; when it is a chat transcript,
454
+ write no markers and no citations. Do not write a quiz, flashcards, practice or review questions into the report unless the reader's instructions
455
+ ask for one: those are offered to the reader separately as visuals they can add. Never write about visuals, mind maps, flashcards, quizzes, tables or timelines in the text, and never suggest or describe one, even if the reader's instructions mention them: the app offers those to the reader separately, so write only the report itself. Write in the language requested.
456
+ Never use em dashes."""
457
+
458
+ title: str = dspy.InputField(desc="The notebook, post or chat the material comes from")
459
+ material: list[str] = dspy.InputField(desc="Posts with path and numbered lines, or chat transcripts")
460
+ request: str = dspy.InputField(desc="What the reader wants the report to be")
461
+ language: str = dspy.InputField(desc="The language to write in")
462
+ report_title: str = dspy.OutputField(desc="A specific title, 3 to 10 words")
463
+ markdown: str = dspy.OutputField(desc="The report body in Markdown, starting at the first ## heading")
464
+ citations: list[FileCitation] = dspy.OutputField()
465
+
466
+
467
+ class PlannedEmbed(BaseModel):
468
+ kind: Literal["mind_map", "flashcards", "quiz", "infographic"]
469
+ title: str = Field(description="A short heading for the visual, 2 to 6 words")
470
+ brief: str = Field(description="What the visual covers, one sentence, drawn from this section")
471
+ why: str = Field(description="Why it helps the reader at this point, one short sentence")
472
+
473
+
474
+ class PlannedSection(BaseModel):
475
+ heading: str = Field(description="2 to 8 words")
476
+ brief: str = Field(description="What this section says and which part of the material it draws on, 1 to 2 sentences")
477
+ embed: PlannedEmbed | None = Field(None, description="A visual to suggest right after this section, only where it "
478
+ "really helps")
479
+
480
+
481
+ class PlanInteractiveReport(dspy.Signature):
482
+ """Plan an interactive report the reader describes, from the material given. Every section is written text: never plan a
483
+ section that is itself a quiz, flashcards, practice questions or a mind map, because those are visuals, and are
484
+ suggested through a section's visual instead. Choose 4 to 8 sections in a sensible
485
+ order, starting with an Introduction and ending with a Summary unless the reader asks for something else. Each
486
+ heading is specific and descriptive of what the section covers, never generic like "Details" or "Section 2". Then decide, section by section, where a visual would help the reader and which one. These are only
487
+ suggestions: nothing is built until the reader clicks Add, so suggest the spots where each really fits: a mind_map to show how
488
+ many linked ideas fit together (at most one, usually near the start or the end), flashcards to learn a set of
489
+ terms or facts, a quiz to check understanding after a dense section, an infographic for a one page picture of the
490
+ main ideas. Use at most four visuals in all, each kind
491
+ at most once, and none where plain prose reads better. Only the kinds allowed may be used. Use only what the
492
+ material covers. Never use em dashes."""
493
+
494
+ title: str = dspy.InputField()
495
+ material: list[str] = dspy.InputField(desc="Posts with path and numbered lines, or chat transcripts")
496
+ request: str = dspy.InputField(desc="What the reader wants the report to be")
497
+ allowed_kinds: list[str] = dspy.InputField(desc="The visuals that may be used")
498
+ language: str = dspy.InputField()
499
+ report_title: str = dspy.OutputField(desc="A specific title, 3 to 10 words")
500
+ sections: list[PlannedSection] = dspy.OutputField(desc="4 to 8 sections")
501
+
502
+
503
+ class WriteReportSection(dspy.Signature):
504
+ """Write one section of a report, using only the material given. Follow the reader's instructions on style, tone
505
+ and length; a section is two to five short paragraphs, or a list or table where that reads better. Do not write quiz questions, flashcards or
506
+ review questions in the section (those are offered to the reader separately). Do not repeat
507
+ the heading, and do not use "#" or "##" headings; "###" subheadings are fine. When the material has numbered lines (FILE
508
+ blocks), put a [n] marker after each claim it supports and list each n in citations with the path and lines; when it
509
+ is a chat transcript, write no markers and no citations. Never write about visuals, mind maps, flashcards, quizzes, tables or timelines in the text, and never suggest or describe one, even if the reader's instructions mention them: the app offers those to the reader separately, so write only the report itself. Write in the language requested. Never use em dashes."""
510
+
511
+ title: str = dspy.InputField()
512
+ report_title: str = dspy.InputField()
513
+ heading: str = dspy.InputField()
514
+ brief: str = dspy.InputField(desc="What this section should say")
515
+ request: str = dspy.InputField(desc="What the reader wants the report to be")
516
+ material: list[str] = dspy.InputField(desc="Posts with path and numbered lines, or chat transcripts")
517
+ language: str = dspy.InputField()
518
+ markdown: str = dspy.OutputField(desc="The section body in Markdown, without its heading")
519
+ citations: list[FileCitation] = dspy.OutputField()
520
+
521
+
522
+ class ReportTemplateIdea(BaseModel):
523
+ name: str = Field(description="2 to 4 words, a kind of report, like a field guide or a decision memo")
524
+ description: str = Field(description="One short sentence on what the reader gets")
525
+ prompt: str = Field(description="The instructions for the writer, two to four sentences, specific to this material: "
526
+ "what the report covers, how it is organised, and its tone")
527
+
528
+
529
+ class SuggestReportTemplates(dspy.Signature):
530
+ """Suggest four different kinds of report a reader could make from this material. Each is specific to what the
531
+ material is actually about, not a generic format, and the four are clearly different from each other (for example
532
+ a practical how-to, a comparison, a beginner primer and an in-depth analysis, when the material supports them).
533
+ Never use em dashes."""
534
+
535
+ items: list[str] = dspy.InputField(desc="The title and the start of each source")
536
+ topic: str = dspy.InputField(desc="What the reader wants the report to be about, or (none)")
537
+ templates: list[ReportTemplateIdea] = dspy.OutputField(desc="Exactly 4")
538
+
539
+
540
+ class EmbedIdea(BaseModel):
541
+ after_block_id: str = Field(description="Id of the section the visual goes right after, exactly as listed")
542
+ kind: Literal["mind_map", "flashcards", "quiz", "infographic"]
543
+ brief: str = Field(description="What the visual should cover, one sentence, about that section")
544
+ why: str = Field(description="Why it helps the reader at that point, one short sentence")
545
+
546
+
547
+ class SuggestEmbeds(dspy.Signature):
548
+ """Look at a finished report's sections and suggest up to four places where a visual would help the reader:
549
+ a mind_map to show how many linked ideas fit together, flashcards to learn a set of terms or facts, a quiz to
550
+ check understanding after a dense section. Pick the sections where each really fits, use only the kinds allowed,
551
+ and do not suggest the same kind twice in a row. Never use em dashes."""
552
+
553
+ sections: list[str] = dspy.InputField(desc="One per line: id | heading: the start of the section")
554
+ allowed_kinds: list[str] = dspy.InputField()
555
+ suggestions: list[EmbedIdea] = dspy.OutputField(desc="0 to 4")
556
+
557
+
558
  class ExtractTopics(dspy.Signature):
559
  """Name the topics this post is about, the way a reader would search for them."""
560
 
backend/app/main.py CHANGED
@@ -17,7 +17,9 @@ from app.routers import (
17
  launchpad,
18
  memory,
19
  notebooks,
 
20
  resurface,
 
21
  sources,
22
  storage,
23
  support,
@@ -94,7 +96,7 @@ def health():
94
 
95
  # video_templates (custom templates) is switched off: users pick only from the built-in templates.
96
  for r in (auth, billing, storage, sources, notebooks, artifacts, jobs, unsubscribe, topics, voice, resurface,
97
- launchpad, videos, video_edit, video_voices, video_styles, settings_router, memory, support):
98
  app.include_router(r.router)
99
  app.include_router(sources.documents_router)
100
  app.include_router(launchpad.links_router)
 
17
  launchpad,
18
  memory,
19
  notebooks,
20
+ reports,
21
  resurface,
22
+ share,
23
  sources,
24
  storage,
25
  support,
 
96
 
97
  # video_templates (custom templates) is switched off: users pick only from the built-in templates.
98
  for r in (auth, billing, storage, sources, notebooks, artifacts, jobs, unsubscribe, topics, voice, resurface,
99
+ launchpad, videos, video_edit, video_voices, video_styles, settings_router, memory, support, reports, share):
100
  app.include_router(r.router)
101
  app.include_router(sources.documents_router)
102
  app.include_router(launchpad.links_router)
backend/app/models/__init__.py CHANGED
@@ -1,5 +1,6 @@
1
  from app.models.content import (
2
  Artifact,
 
3
  B2VCustomVoice,
4
  B2VStyle,
5
  B2VTemplate,
@@ -36,6 +37,7 @@ __all__ = [
36
  "SupportConversation",
37
  "SupportMessage",
38
  "Artifact",
 
39
  "AuthProvider",
40
  "B2VCustomVoice",
41
  "B2VStyle",
 
1
  from app.models.content import (
2
  Artifact,
3
+ ArtifactShare,
4
  B2VCustomVoice,
5
  B2VStyle,
6
  B2VTemplate,
 
37
  "SupportConversation",
38
  "SupportMessage",
39
  "Artifact",
40
+ "ArtifactShare",
41
  "AuthProvider",
42
  "B2VCustomVoice",
43
  "B2VStyle",
backend/app/models/content.py CHANGED
@@ -15,6 +15,7 @@ from sqlalchemy import (
15
  UniqueConstraint,
16
  Uuid,
17
  text,
 
18
  )
19
  from sqlalchemy.orm import Mapped, mapped_column, relationship
20
 
@@ -195,6 +196,18 @@ class Artifact(IdMixin, TimestampMixin, Base):
195
  storage_key: Mapped[str | None] = mapped_column(String(500))
196
 
197
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  class B2VVideo(IdMixin, TimestampMixin, Base):
199
  """One blog2video video we asked for: the only place its blog2video id lives. Every read, edit and delete on
200
  blog2video goes through this row (b2v_video_id, and created_via for the right endpoints). Also records who made it
 
15
  UniqueConstraint,
16
  Uuid,
17
  text,
18
+ true,
19
  )
20
  from sqlalchemy.orm import Mapped, mapped_column, relationship
21
 
 
196
  storage_key: Mapped[str | None] = mapped_column(String(500))
197
 
198
 
199
+ class ArtifactShare(IdMixin, TimestampMixin, Base):
200
+ """A public link to one artifact (a report): anyone holding the token can read the shared view, nothing else.
201
+ Revoking deletes the row, so a new link is a new token."""
202
+
203
+ __tablename__ = "artifact_shares"
204
+
205
+ workspace_id: Mapped[uuid.UUID] = _ws_fk()
206
+ artifact_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("artifacts.id", ondelete="CASCADE"), unique=True)
207
+ token: Mapped[str] = mapped_column(String(64), unique=True, index=True)
208
+ show_sources: Mapped[bool] = mapped_column(Boolean, default=True, server_default=true())
209
+
210
+
211
  class B2VVideo(IdMixin, TimestampMixin, Base):
212
  """One blog2video video we asked for: the only place its blog2video id lives. Every read, edit and delete on
213
  blog2video goes through this row (b2v_video_id, and created_via for the right endpoints). Also records who made it
backend/app/models/user.py CHANGED
@@ -54,6 +54,8 @@ class Workspace(IdMixin, TimestampMixin, Base):
54
  name: Mapped[str] = mapped_column(String(200))
55
  owner_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"))
56
  training_opt_in: Mapped[bool] = mapped_column(Boolean, default=False)
 
 
57
  brand_json: Mapped[dict] = mapped_column(JSON, default=dict)
58
 
59
  members: Mapped[list["WorkspaceMember"]] = relationship(back_populates="workspace")
@@ -108,6 +110,8 @@ class PlanRecord(TimestampMixin, Base):
108
  audio_minutes: Mapped[int] = mapped_column(Integer, default=0)
109
  videos: Mapped[int] = mapped_column(Integer, default=0)
110
  launch_kits: Mapped[int] = mapped_column(Integer, default=0) # -1 = unlimited
 
 
111
  voice_cloning: Mapped[bool] = mapped_column(Boolean, default=False)
112
  brand_kit: Mapped[bool] = mapped_column(Boolean, default=False)
113
  features: Mapped[list] = mapped_column(JSON, default=list) # bullet points on the pricing page
 
54
  name: Mapped[str] = mapped_column(String(200))
55
  owner_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id"))
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")
 
110
  audio_minutes: Mapped[int] = mapped_column(Integer, default=0)
111
  videos: Mapped[int] = mapped_column(Integer, default=0)
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
backend/app/pipeline/flashcards.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Flashcards: front and back cards written from a notebook's posts or from notebook chats, flipped in the browser."""
2
+
3
+ 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 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
14
+
15
+ SAME_LANGUAGE = "the same language as the sources" # no language picked: write as the material is written
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
+
36
+ def build_flashcards(db: Session, job: Job, artifact: Artifact) -> dict:
37
+ params = job.params
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)
45
+ if not cards:
46
+ raise NothingToDo("The material did not give enough to make cards from. Try other posts or another topic.")
47
+ artifact.content_json = {
48
+ "title": f"Flashcards: {topic[:60] or material.title}", "topic": topic, "difficulty": difficulty,
49
+ "language": params.get("language") or SAME_LANGUAGE, "source": material.source, "cards": cards,
50
+ "card_count": len(cards),
51
+ }
52
+ artifact.status = "ready"
53
+ db.commit()
54
+ return {"artifact_id": str(artifact.id)}
backend/app/pipeline/generate.py CHANGED
@@ -152,9 +152,6 @@ def extract_ideas(db: Session, job: Job | None, workspace_id: uuid.UUID, doc_ids
152
 
153
 
154
  def build_mind_map(db: Session, job: Job, artifact: Artifact) -> dict:
155
- """A tree (centre, branches, sub-themes, points) over the picked posts, arranged from the ideas already stored
156
- on each post. A focus decides how they are grouped and weighted, never which posts are on the map: every idea
157
- the model leaves out is filed next to a sibling from the same post, or under a catch-all branch."""
158
  nb = db.get(Notebook, artifact.notebook_id)
159
  docs = notebook_docs(db, nb.id)
160
  picked = {str(i) for i in job.params.get("document_ids") or []}
@@ -162,12 +159,25 @@ def build_mind_map(db: Session, job: Job, artifact: Artifact) -> dict:
162
  docs = [d for d in docs if str(d.id) in picked]
163
  if not docs:
164
  raise NothingToDo("Pick at least one post for the Mind Constellation.")
165
- focus = (job.params.get("focus") or "").strip()
166
- corpus = Corpus(nb.workspace_id)
 
 
 
 
 
 
 
 
 
 
 
 
167
  stale = [d for d in docs if not ideas_fresh(d)]
168
  if stale: # background extraction has not reached these yet, so do it now
169
- update_job(db, job, progress=0.1, message=f"Reading {len(stale)} posts")
170
- extract_ideas(db, None, nb.workspace_id, [str(d.id) for d in stale])
 
171
  tools = tools_for(corpus, docs)
172
 
173
  def refs(items) -> list[dict]:
@@ -196,9 +206,10 @@ def build_mind_map(db: Session, job: Job, artifact: Artifact) -> dict:
196
  # A very large notebook lists labels only, and the leftover rule below files whatever is not listed.
197
  brief = len(pool) > BRIEF_ABOVE
198
  listing = [f"{i} | {n['label']}" if brief else f"{i} | {n['label']}: {n['note'][:140]}" for i, n in pool.items()]
199
- update_job(db, job, progress=0.4, message="Charting the constellation")
200
- out = run.predict(ArrangeMindMap, db=db, workspace_id=nb.workspace_id, job=job,
201
- title=nb.title, focus=focus or "(none)", ideas=listing)
 
202
 
203
  placed: set[str] = set()
204
  branches: list[dict] = []
@@ -228,21 +239,18 @@ def build_mind_map(db: Session, job: Job, artifact: Artifact) -> dict:
228
 
229
  for i, b in enumerate(branches):
230
  _number(b, str(i))
231
- centre = (out.get("centre") or focus or nb.title).strip()[:80]
232
  root = {"id": "root", "label": centre, "note": (out.get("overview") or "").strip()[:500], "sources": [],
233
  "children": branches}
234
 
235
  def count(n: dict) -> int:
236
  return 1 + sum(count(c) for c in n["children"])
237
 
238
- artifact.content_json = {
239
- "title": f"Mind Constellation: {focus[:60] or nb.title}", "focus": focus, "root": root,
240
  "node_count": count(root), "document_ids": [str(d.id) for d in docs], "post_count": len(docs),
241
  "covered_count": len(_cited(branches) & {str(d.id) for d in docs}),
242
  }
243
- artifact.status = "ready"
244
- db.commit()
245
- return {"artifact_id": str(artifact.id)}
246
 
247
 
248
  # Topic map
 
152
 
153
 
154
  def build_mind_map(db: Session, job: Job, artifact: Artifact) -> dict:
 
 
 
155
  nb = db.get(Notebook, artifact.notebook_id)
156
  docs = notebook_docs(db, nb.id)
157
  picked = {str(i) for i in job.params.get("document_ids") or []}
 
159
  docs = [d for d in docs if str(d.id) in picked]
160
  if not docs:
161
  raise NothingToDo("Pick at least one post for the Mind Constellation.")
162
+ artifact.content_json = mind_map_content(db, job, nb.workspace_id, nb.title, docs,
163
+ (job.params.get("focus") or "").strip())
164
+ artifact.status = "ready"
165
+ db.commit()
166
+ return {"artifact_id": str(artifact.id)}
167
+
168
+
169
+ def mind_map_content(db: Session, job: Job | None, workspace_id: uuid.UUID, title: str, docs: list[Document],
170
+ focus: str = "") -> dict:
171
+ """A tree (centre, branches, sub-themes, points) over the given posts, arranged from the ideas already stored
172
+ on each post. A focus decides how they are grouped and weighted, never which posts are on the map: every idea
173
+ the model leaves out is filed next to a sibling from the same post, or under a catch-all branch. Used for a
174
+ Mind Constellation artifact and for the map inside a report."""
175
+ corpus = Corpus(workspace_id)
176
  stale = [d for d in docs if not ideas_fresh(d)]
177
  if stale: # background extraction has not reached these yet, so do it now
178
+ if job:
179
+ update_job(db, job, progress=0.1, message=f"Reading {len(stale)} posts")
180
+ extract_ideas(db, None, workspace_id, [str(d.id) for d in stale])
181
  tools = tools_for(corpus, docs)
182
 
183
  def refs(items) -> list[dict]:
 
206
  # A very large notebook lists labels only, and the leftover rule below files whatever is not listed.
207
  brief = len(pool) > BRIEF_ABOVE
208
  listing = [f"{i} | {n['label']}" if brief else f"{i} | {n['label']}: {n['note'][:140]}" for i, n in pool.items()]
209
+ if job:
210
+ update_job(db, job, progress=0.4, message="Charting the constellation")
211
+ out = run.predict(ArrangeMindMap, db=db, workspace_id=workspace_id, job=job,
212
+ title=title, focus=focus or "(none)", ideas=listing)
213
 
214
  placed: set[str] = set()
215
  branches: list[dict] = []
 
239
 
240
  for i, b in enumerate(branches):
241
  _number(b, str(i))
242
+ centre = (out.get("centre") or focus or title).strip()[:80]
243
  root = {"id": "root", "label": centre, "note": (out.get("overview") or "").strip()[:500], "sources": [],
244
  "children": branches}
245
 
246
  def count(n: dict) -> int:
247
  return 1 + sum(count(c) for c in n["children"])
248
 
249
+ return {
250
+ "title": f"Mind Constellation: {focus[:60] or title}", "focus": focus, "root": root,
251
  "node_count": count(root), "document_ids": [str(d.id) for d in docs], "post_count": len(docs),
252
  "covered_count": len(_cited(branches) & {str(d.id) for d in docs}),
253
  }
 
 
 
254
 
255
 
256
  # Topic map
backend/app/pipeline/infographic.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Infographics: a short block of text written from posts' stored ideas and/or chats (and the user's own prompt),
2
+ poured into a premade space theme. The model writes content only (a few hundred tokens), never the page, which is
3
+ what keeps it to seconds and keeps every word spelled as written. The page is shown as HTML; the PNG is drawn by the
4
+ Remotion service only when someone downloads it."""
5
+
6
+ import uuid
7
+
8
+ 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
+
24
+ IDEAS_BUDGET = 7_000 # characters of stored ideas the model reads
25
+ CHAT_BUDGET = 9_000 # characters of chat transcript the model reads
26
+
27
+
28
+ def _ideas_text(db: Session, workspace_id: uuid.UUID, docs: list[Document], job: Job | None) -> list[str]:
29
+ """One block per post from the ideas already stored on it (extracted now for a post that has none)."""
30
+ stale = [str(d.id) for d in docs if not ideas_fresh(d)]
31
+ if stale:
32
+ extract_ideas(db, job, workspace_id, stale)
33
+ for d in docs:
34
+ db.refresh(d)
35
+ share = IDEAS_BUDGET // max(len(docs), 1)
36
+ corpus = Corpus(workspace_id)
37
+ out = []
38
+ for d in docs:
39
+ ideas = (d.metadata_json or {}).get("ideas") if ideas_fresh(d) else None
40
+ if ideas:
41
+ lines = []
42
+ for i in ideas:
43
+ points = "; ".join(str(p) for p in (i.get("details") or [])[:3] if isinstance(p, str))
44
+ lines.append(f"- {i.get('label', '')}: {i.get('note', '')}" + (f" ({points})" if points else ""))
45
+ body = "\n".join(lines)
46
+ else: # extraction failed for this post: its opening lines
47
+ body = "\n".join(passages_for(corpus, [d], budget_chars=share))
48
+ out.append(f"POST {d.title}\n{body}"[:share])
49
+ return out
50
+
51
+
52
+ def _chat_text(db: Session, workspace_id: uuid.UUID, chat_ids: list[uuid.UUID]) -> tuple[list[Chat], list[str]]:
53
+ chats = list(db.scalars(select(Chat).where(Chat.id.in_(chat_ids), Chat.workspace_id == workspace_id)
54
+ .order_by(Chat.created_at)))
55
+ share = CHAT_BUDGET // max(len(chats), 1)
56
+ return chats, [t[-share:] for t in chat_material(db, chats)]
57
+
58
+
59
+ def write_content(db: Session, job: Job, artifact: Artifact, params: dict) -> tuple[dict, dict, str]:
60
+ """The clamped content, the source it came from and a title. Raises NothingToDo when there is nothing to draw."""
61
+ ws = artifact.workspace_id
62
+ chat_ids = [uuid.UUID(str(i)) for i in params.get("chat_ids") or []]
63
+ picked = [uuid.UUID(str(i)) for i in params.get("document_ids") or []]
64
+ nb = db.get(Notebook, artifact.notebook_id) if artifact.notebook_id else None
65
+ docs: list[Document] = []
66
+ if picked:
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.")
74
+
75
+ update_job(db, job, progress=0.1, message="Reading the key ideas")
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:
88
+ raise NothingToDo("The material did not give enough to draw. Try other posts or add a prompt.")
89
+ source: dict = {"kind": "mixed" if docs and chat_blocks else "posts" if docs else "chats"}
90
+ if docs:
91
+ source["document_ids"] = [str(d.id) for d in docs]
92
+ if chat_blocks:
93
+ source["chat_ids"] = [str(c.id) for c in chats]
94
+ source["count"] = len(docs) + len(chats if chat_blocks else [])
95
+ return content, source, title
96
+
97
+
98
+ def build_infographic(db: Session, job: Job, artifact: Artifact) -> dict:
99
+ """Write the text and keep it with the theme. The page is built from them whenever it is shown (so it is ready at
100
+ once) and shared by link."""
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}
107
+ artifact.storage_key = None
108
+ artifact.status = "ready"
109
+ db.commit()
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
backend/app/pipeline/ingest.py CHANGED
@@ -681,6 +681,34 @@ def ingest_source(db: Session, source: Source, job: Job, max_posts: int) -> dict
681
  "changed": [str(i) for i in changed]}
682
 
683
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
684
  def entry_from_upload(filename: str, content_type: str, data: bytes) -> FeedEntry:
685
  name = filename.rsplit("/", 1)[-1]
686
  stem = re.sub(r"\.[A-Za-z0-9]+$", "", name).replace("-", " ").replace("_", " ").strip() or "Untitled"
@@ -693,6 +721,8 @@ def entry_from_upload(filename: str, content_type: str, data: bytes) -> FeedEntr
693
  soup = BeautifulSoup(text, "html.parser")
694
  title = soup.title.get_text(strip=True) if soup.title else stem
695
  return FeedEntry(title=title, url=url, published_at=None, html=text)
 
 
696
  sections = markdown_to_sections(text)
697
  first_heading = re.search(r"^#\s+(.+)$", text, re.M)
698
  title = first_heading.group(1).strip() if first_heading else stem
 
681
  "changed": [str(i) for i in changed]}
682
 
683
 
684
+ _VTT_TIME = re.compile(r"^(\d+:)?\d{1,2}:\d{2}[.,]\d{3}\s+-->\s+")
685
+
686
+
687
+ def vtt_to_text(text: str) -> str:
688
+ """The spoken words of a WebVTT transcript: the header, notes, cue ids, timestamps and markup dropped, a line
689
+ repeated from the cue before (rolling captions) kept once, the rest joined into paragraphs."""
690
+ lines: list[str] = []
691
+ in_cue = False
692
+ skipping = False # inside a NOTE / STYLE / REGION block, until the blank line
693
+ for raw in text.lstrip("\ufeff").splitlines():
694
+ line = raw.strip()
695
+ if not line:
696
+ in_cue = skipping = False
697
+ continue
698
+ if skipping or line.startswith(("WEBVTT", "NOTE", "STYLE", "REGION")) and not in_cue:
699
+ skipping = True
700
+ continue
701
+ if _VTT_TIME.match(line):
702
+ in_cue = True
703
+ continue
704
+ if not in_cue:
705
+ continue # a cue id
706
+ line = re.sub(r"<[^>]+>", "", line).strip()
707
+ if line and (not lines or lines[-1] != line):
708
+ lines.append(line)
709
+ return "\n\n".join(" ".join(lines[i:i + 8]) for i in range(0, len(lines), 8))
710
+
711
+
712
  def entry_from_upload(filename: str, content_type: str, data: bytes) -> FeedEntry:
713
  name = filename.rsplit("/", 1)[-1]
714
  stem = re.sub(r"\.[A-Za-z0-9]+$", "", name).replace("-", " ").replace("_", " ").strip() or "Untitled"
 
721
  soup = BeautifulSoup(text, "html.parser")
722
  title = soup.title.get_text(strip=True) if soup.title else stem
723
  return FeedEntry(title=title, url=url, published_at=None, html=text)
724
+ if lower.endswith(".vtt") or text.lstrip("\ufeff").startswith("WEBVTT"):
725
+ text = vtt_to_text(text)
726
  sections = markdown_to_sections(text)
727
  first_heading = re.search(r"^#\s+(.+)$", text, re.M)
728
  title = first_heading.group(1).strip() if first_heading else stem
backend/app/pipeline/material.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """What a generated quiz, flashcard set or report reads: a notebook's posts (with numbered lines to cite) or the
2
+ transcripts of notebook chats. One loader for all of them, so every artifact picks its source the same way."""
3
+
4
+ import re
5
+ import uuid
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass, field
8
+
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
25
+ text: list[str] # passages (posts) or transcripts (chats), ready for a prompt
26
+ source: dict # {"kind": "posts"|"chats"|"mixed", "document_ids"?, "chat_ids"?, "count": n}
27
+ docs: list[Document] = field(default_factory=list) # empty for chats only
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:
34
+ """Chats only: there are no posts to draw a mind map from or to cite lines of."""
35
+ return not self.docs
36
+
37
+
38
+ def chat_material(db: Session, chats: list[Chat]) -> list[str]:
39
+ """One transcript per chat, citation markers removed. A chat over its share loses its oldest turns first."""
40
+ share = CHAT_BUDGET // max(len(chats), 1)
41
+ out = []
42
+ for chat in chats:
43
+ msgs = db.scalars(select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at)).all()
44
+ turns = [f"{'User' if m.role == 'user' else 'Assistant'}: {t}" for m in msgs
45
+ if (t := re.sub(r"\s*\[\d+\]", "", m.content).strip())]
46
+ size = sum(len(t) + 2 for t in turns)
47
+ while len(turns) > 1 and size > share:
48
+ size -= len(turns.pop(0)) + 2
49
+ if any(m.role == "assistant" for m in msgs):
50
+ out.append(f"CHAT {chat.title or 'Untitled chat'}\n" + "\n\n".join(turns))
51
+ return out
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."""
59
+ chat_ids = [uuid.UUID(str(i)) for i in params.get("chat_ids") or []]
60
+ picked = [uuid.UUID(str(i)) for i in params.get("document_ids") or []]
61
+ nb = db.get(Notebook, notebook_id) if notebook_id else None
62
+
63
+ docs: list[Document] = []
64
+ if picked:
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] = []
72
+ if chat_ids:
73
+ chats = list(db.scalars(select(Chat).where(Chat.id.in_(chat_ids), Chat.workspace_id == workspace_id)
74
+ .order_by(Chat.created_at)))
75
+ chat_text = chat_material(db, chats)
76
+ if not chat_text and not docs:
77
+ raise NothingToDo(f"These chats have no answers to make a {what} from yet.")
78
+ if not docs and not chat_text:
79
+ raise NothingToDo(f"Pick at least one post or chat for the {what}.")
80
+
81
+ text: list[str] = []
82
+ refs: Callable[[list | None], list[dict]] = lambda _items: [] # noqa: E731 (chats have no lines to point at)
83
+ tools: ScopedTools | None = None
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
+
92
+ def refs(items) -> list[dict]: # noqa: F811
93
+ return [{"document_id": str(by_path[r["path"]].id), "path": r["path"], "title": r["title"],
94
+ "line_start": r["line_start"], "line_end": r["line_end"], "quote": r["quote"]}
95
+ for r in verify_refs(tools, items or []) if r["path"] in by_path]
96
+
97
+ if docs and nb and not picked:
98
+ title = nb.title
99
+ elif docs:
100
+ title = docs[0].title + (f" + {len(docs) - 1} more" if len(docs) > 1 else "")
101
+ else:
102
+ first = chats[0].title or "Chat"
103
+ title = first if len(chats) == 1 else f"{first} + {len(chats) - 1} more"
104
+ if docs and chat_text:
105
+ title += f" + {len(chats)} chat{'s' if len(chats) != 1 else ''}"
106
+
107
+ source: dict = {"kind": "mixed" if docs and chat_text else "posts" if docs else "chats"}
108
+ if docs:
109
+ source["document_ids"] = [str(d.id) for d in docs]
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)
backend/app/pipeline/quiz.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Quizzes: questions written from a notebook's posts or from notebook chats, graded in the browser."""
2
+
3
+ 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
14
+
15
+ SAME_LANGUAGE = "the same language as the sources" # no language picked: write as the material is written
16
+ QUESTION_COUNTS = {"fewer": 5, "standard": 10, "more": 15}
17
+ DIFFICULTIES = ("easy", "medium", "hard")
18
+ QUESTION_TYPES = ("multiple_choice", "multiple_select", "fill_blank", "short_answer")
19
+
20
+
21
+ def _clean(q: dict, refs) -> dict | None:
22
+ """One generated question in the shape the page plays, or None when it cannot be played."""
23
+ kind, text = q.get("type"), (q.get("question") or "").strip()
24
+ if kind not in QUESTION_TYPES or not text:
25
+ return None
26
+ base = {"type": kind, "question": text, "explanation": (q.get("explanation") or "").strip(),
27
+ "sources": refs(q.get("sources"))}
28
+ if kind in ("multiple_choice", "multiple_select"):
29
+ options = [str(o).strip() for o in q.get("options") or [] if str(o).strip()]
30
+ correct = sorted({i for i in q.get("correct") or [] if isinstance(i, int) and 0 <= i < len(options)})
31
+ if len(options) < 2 or not correct:
32
+ return None
33
+ if kind == "multiple_choice":
34
+ correct = correct[:1]
35
+ elif len(correct) < 2:
36
+ kind = base["type"] = "multiple_choice"
37
+ return {**base, "options": options, "correct": correct}
38
+ answer = (q.get("answer") or "").strip()
39
+ if not answer:
40
+ return None
41
+ if kind == "fill_blank":
42
+ accepted = [a.strip() for a in q.get("accepted") or [] if a and a.strip()]
43
+ if "____" not in text:
44
+ base["question"] = f"{text} ____"
45
+ return {**base, "answer": answer, "accepted": accepted}
46
+ return {**base, "answer": answer}
47
+
48
+
49
+ def make_questions(db: Session, workspace_id: uuid.UUID, material: Material, *, topic: str = "",
50
+ difficulty: str = "medium", types: list[str] | None = None, count: int = 10,
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
+
63
+ def build_quiz(db: Session, job: Job, artifact: Artifact) -> dict:
64
+ params = job.params
65
+ types = [t for t in params.get("question_types") or [] if t in QUESTION_TYPES] or list(QUESTION_TYPES[:2])
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)
73
+ if not questions:
74
+ raise NothingToDo("The material did not give enough to ask about. Try other posts or another topic.")
75
+ artifact.content_json = {
76
+ "title": f"Quiz: {(topic[:60] or material.title)}", "topic": topic, "difficulty": difficulty,
77
+ "language": params.get("language") or SAME_LANGUAGE, "question_types": types, "source": material.source,
78
+ "questions": questions, "question_count": len(questions),
79
+ }
80
+ artifact.status = "ready"
81
+ db.commit()
82
+ return {"artifact_id": str(artifact.id)}
backend/app/pipeline/report.py ADDED
@@ -0,0 +1,395 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.llm import run
14
+ from app.llm.provider import fast_lm, report_lm
15
+ from app.llm.signatures import (
16
+ PlanInteractiveReport,
17
+ SuggestEmbeds,
18
+ WriteReport,
19
+ WriteReportSection,
20
+ )
21
+ from app.models import Artifact, Job
22
+ from app.pipeline.flashcards import make_cards
23
+ from app.pipeline.generate import NothingToDo, mind_map_content
24
+ from app.pipeline.material import Material, load_material
25
+ from app.pipeline.quiz import make_questions
26
+ from app.pipeline.passages import as_citations
27
+ from app.pipeline.research import verify_citations
28
+ from app.infographics.themes import theme_id
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__)
43
+
44
+ MARKER = re.compile(r"\s*\[\d+\](?:\[\d+\])*")
45
+
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:
53
+ """The visual a section heading really is ("Quick quiz" is a quiz), or None for an ordinary section."""
54
+ if QUIZ_HEADING.search(heading):
55
+ return "quiz"
56
+ if FLASHCARD_HEADING.search(heading):
57
+ return "flashcards"
58
+ return None
59
+
60
+
61
+ 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(), []
86
+ for g in groups:
87
+ for s in g:
88
+ key = (s["after_block_id"], s["kind"])
89
+ if key not in seen:
90
+ seen.add(key)
91
+ out.append({**s, "id": f"s{len(out) + 1}"})
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())
98
+ blocks = []
99
+ for part in parts:
100
+ if part.strip():
101
+ blocks.append({"type": "prose", "text": part.strip()})
102
+ return blocks
103
+
104
+
105
+ def number_blocks(blocks: list[dict]) -> list[dict]:
106
+ for i, b in enumerate(blocks, start=1):
107
+ b["id"] = f"b{i}"
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]
189
+ update_job(db, job, progress=0.25, message="Planning the report")
190
+ try:
191
+ plan = run.predict(PlanInteractiveReport, db=db, workspace_id=workspace_id, job=job, lm=report_lm(),
192
+ title=material.title, material=material.text, request=instructions,
193
+ allowed_kinds=kinds, language=language)
194
+ except Exception:
195
+ log.warning("report plan failed", exc_info=True)
196
+ return None
197
+ sections = [x for x in plan.get("sections") or [] if (x.get("heading") or "").strip()][:MAX_SECTIONS]
198
+ if len(sections) < 2:
199
+ return None
200
+ report_title = (plan.get("report_title") or material.title).strip()
201
+
202
+ # A planned section that is really a quiz or flashcards is not written as text: it becomes a suggestion.
203
+ kept_secs: list[dict] = []
204
+ moved: list[tuple[int, str, dict]] = []
205
+ for x in sections:
206
+ kind = visual_kind(x["heading"])
207
+ if kind and kind in kinds and kept_secs:
208
+ moved.append((len(kept_secs) - 1, kind, x))
209
+ else:
210
+ kept_secs.append(x)
211
+ sections = kept_secs
212
+
213
+ update_job(db, job, progress=0.4, message=f"Writing {len(sections)} sections")
214
+ written = run.predict_many(
215
+ WriteReportSection,
216
+ [{"title": material.title, "report_title": report_title, "heading": x["heading"].strip(),
217
+ "brief": (x.get("brief") or "").strip(), "request": instructions, "material": material.text,
218
+ "language": language} for x in sections],
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)
240
+ anchor[n] = blocks[-1]["id"]
241
+ embed = sec.get("embed")
242
+ if embed and len(suggestions) < MAX_PLANNED_VISUALS and embed.get("kind") in kinds \
243
+ and embed["kind"] not in used_kinds and (embed.get("brief") or "").strip():
244
+ used_kinds.add(embed["kind"])
245
+ suggestions.append({"id": f"s{len(suggestions) + 1}", "after_block_id": blocks[-1]["id"],
246
+ "kind": embed["kind"], "brief": embed["brief"].strip()[:300],
247
+ "why": (embed.get("why") or "").strip()[:300]})
248
+ if not any(b["type"] == "prose" for b in blocks):
249
+ return None
250
+ for idx, kind, sec in moved:
251
+ after = anchor.get(idx) or blocks[0]["id"]
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:
259
+ params = job.params
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()
290
+ return {"artifact_id": str(artifact.id)}
291
+
292
+
293
+ # Adding a visual to a finished report
294
+
295
+
296
+ def _source_params(source: dict) -> dict:
297
+ """The job params that load a report's own source again."""
298
+ return {"chat_ids": source.get("chat_ids") or [], "document_ids": source.get("document_ids") or []}
299
+
300
+
301
+ def copy_block(kind: str, existing: Artifact) -> dict:
302
+ """A visual's content taken from an artifact the user already made (a Mind Constellation, a quiz or flashcards)."""
303
+ c = existing.content_json or {}
304
+ if kind == "mind_map" and c.get("root"):
305
+ return {"title": c.get("title"), "root": c["root"], "node_count": c.get("node_count"),
306
+ "post_count": c.get("post_count")}
307
+ if kind == "quiz" and c.get("questions"):
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
+
315
+
316
+ def make_block(db: Session, job: Job, report: Artifact, kind: str, brief: str, theme: str | None = None) -> dict:
317
+ """A new visual about `brief`, built from the report's own sources."""
318
+ source = (report.content_json or {})["source"]
319
+ if kind == "infographic":
320
+ # A picture is drawn by the renderer, so it is its own infographic (listed in the Library, counted in the
321
+ # plan) that the block points at; the report page shows it once it is ready.
322
+ child, _ = start_artifact(db, report.workspace_id, "infographic", title=f"Infographic: {brief[:60] or report.content_json.get('title', '')}",
323
+ notebook_id=report.notebook_id,
324
+ params={**_source_params(source), "theme": theme_id(theme), "instructions": brief[:600]})
325
+ return {"title": f"Infographic: {brief[:60]}", "artifact_id": str(child.id), "theme": theme_id(theme)}
326
+ material = load_material(db, report.workspace_id, report.notebook_id, _source_params(source), job=job, what="report")
327
+ language = (report.content_json or {}).get("language") or SAME_LANGUAGE
328
+ update_job(db, job, progress=0.4, message="Building it")
329
+ if kind == "mind_map":
330
+ if material.is_chat:
331
+ raise NothingToDo("A Mind Constellation needs posts, not chats.")
332
+ c = mind_map_content(db, job, report.workspace_id, material.title, material.docs, brief)
333
+ return {"title": c["title"], "root": c["root"], "node_count": c["node_count"], "post_count": c["post_count"]}
334
+ if kind == "flashcards":
335
+ cards = make_cards(db, report.workspace_id, material, topic=brief, count=BLOCK_CARDS, language=language, job=job)
336
+ if not cards:
337
+ raise NothingToDo("The sources did not give enough to make cards from.")
338
+ return {"title": f"Flashcards: {brief[:60]}", "cards": cards}
339
+ questions = make_questions(db, report.workspace_id, material, topic=brief, difficulty="medium",
340
+ types=["multiple_choice", "multiple_select", "fill_blank"], count=BLOCK_QUESTIONS,
341
+ language=language, job=job)
342
+ if not questions:
343
+ raise NothingToDo("The sources did not give enough to ask about.")
344
+ return {"title": f"Test yourself: {brief[:60]}", "questions": questions}
345
+
346
+
347
+ def insert_block(content: dict, block: dict, after: str | None, suggestion_id: str | None) -> dict:
348
+ """The report's content with the block placed after `after` (or last), the suggestion it answers removed, and
349
+ nothing else changed. Block ids stay unique."""
350
+ blocks = list(content.get("blocks") or [])
351
+ used = {b["id"] for b in blocks}
352
+ n = len(blocks) + 1
353
+ while f"b{n}" in used:
354
+ n += 1
355
+ block = {**block, "id": f"b{n}", "after": after}
356
+ at = next((i + 1 for i, b in enumerate(blocks) if b["id"] == after), len(blocks))
357
+ # Keep the block with the visuals already hanging off the same section, in the order they were added.
358
+ while at < len(blocks) and blocks[at]["type"] != "prose" and blocks[at].get("after") == after:
359
+ at += 1
360
+ blocks.insert(at, block)
361
+ suggestions = [s for s in content.get("suggestions") or [] if s["id"] != suggestion_id]
362
+ return {**content, "blocks": blocks, "suggestions": suggestions}
363
+
364
+
365
+ def handle_report_block(db: Session, job: Job) -> dict:
366
+ """Worker job: add one visual (new, or copied from an existing artifact) to a report."""
367
+ report = db.get(Artifact, uuid.UUID(job.params["artifact_id"]))
368
+ if not report or report.type != "report":
369
+ return {}
370
+ p = job.params
371
+ kind = p.get("kind")
372
+ if kind not in EMBED_KINDS:
373
+ raise PermanentJobError("Unknown kind of visual.")
374
+ try:
375
+ if p.get("existing_artifact_id"):
376
+ existing = db.get(Artifact, uuid.UUID(p["existing_artifact_id"]))
377
+ if not existing or existing.workspace_id != report.workspace_id or existing.type != kind \
378
+ or existing.status != "ready":
379
+ raise NothingToDo("That one is not available.")
380
+ block = copy_block(kind, existing)
381
+ else:
382
+ block = make_block(db, job, report, kind, (p.get("brief") or "").strip(), p.get("theme"))
383
+ except NothingToDo as exc:
384
+ raise PermanentJobError(str(exc)) from exc
385
+ report.content_json = insert_block(report.content_json or {}, {"type": kind, **block}, p.get("after_block_id"),
386
+ p.get("suggestion_id"))
387
+ db.commit()
388
+ return {"artifact_id": str(report.id)}
389
+
390
+
391
+ def remove_block(content: dict, block_id: str) -> dict | None:
392
+ """The content without that visual (prose cannot be removed), or None when there is no such visual."""
393
+ blocks = list(content.get("blocks") or [])
394
+ keep = [b for b in blocks if not (b["id"] == block_id and b["type"] != "prose")]
395
+ return None if len(keep) == len(blocks) else {**content, "blocks": keep}
backend/app/routers/artifacts.py CHANGED
@@ -1,24 +1,30 @@
 
1
  import uuid
2
  from typing import Literal
3
 
4
- from fastapi import APIRouter, Depends, HTTPException
5
  from pydantic import BaseModel, Field
6
  from sqlalchemy import func, or_, select
7
 
8
  from app.auth import Ctx, get_ctx
9
  from app.config import settings
10
- from app.models import Artifact, CalendarItem, Document, Job, Notebook, UserSavedVoice
11
  from app.routers.notebooks import ensure_archive_notebook
 
12
  from app.services.artifacts import latest_jobs, serialize_artifact, start_artifact
13
  from app.services.jobs import create_job, serialize_job
14
  from app.services.plans import effective_plan, plan_limit_error
 
15
  from app.services.renderer import COMPOSITIONS
16
  from app.services.storage import storage
17
  from app.services.usage import check_limit
18
 
19
  router = APIRouter(prefix="/api/artifacts", tags=["artifacts"])
20
 
21
- ArtifactType = Literal["summary", "audio_overview", "video", "quote_card", "carousel", "launch_kit", "mind_map"]
 
 
 
22
 
23
 
24
  class RenderIn(BaseModel):
@@ -27,7 +33,7 @@ class RenderIn(BaseModel):
27
 
28
 
29
  class GenerateIn(BaseModel):
30
- type: Literal["summary", "audio_overview", "video", "quote_card", "carousel", "launch_kit", "mind_map"]
31
  notebook_id: uuid.UUID | None = None
32
  document_id: uuid.UUID | None = None
33
  archive: bool = False # no notebook or post picked: use the "All posts" notebook
@@ -46,6 +52,20 @@ class GenerateIn(BaseModel):
46
  # mind_map: the posts to chart (none picked means every post in the notebook) and what to centre on
47
  document_ids: list[uuid.UUID] | None = None
48
  focus: str | None = Field(None, max_length=500)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
 
50
 
51
  def _hosts(ctx: Ctx, body: GenerateIn) -> dict:
@@ -115,7 +135,21 @@ def generate(body: GenerateIn, ctx: Ctx = Depends(get_ctx)):
115
  if not body.slides:
116
  raise HTTPException(400, "A carousel needs slides")
117
  content = {"slides": body.slides[:12], "parent_id": str(body.parent_id) if body.parent_id else None}
118
- target = _target_title(ctx, body.notebook_id, body.document_id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  if body.type == "audio_overview":
120
  check_limit(ctx.db, ctx.workspace, "audio_minutes", body.minutes)
121
  params = {"format": body.format, "minutes": body.minutes, **_hosts(ctx, body)}
@@ -125,6 +159,33 @@ def generate(body: GenerateIn, ctx: Ctx = Depends(get_ctx)):
125
  raise HTTPException(400, "A Mind Constellation is made from a notebook")
126
  params = {"document_ids": [str(i) for i in body.document_ids or []], "focus": (body.focus or "").strip()}
127
  title = f"Mind Constellation: {params['focus'][:60] or target}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  elif body.type == "launch_kit":
129
  if not body.document_id:
130
  raise HTTPException(400, "A Launch Kit is made from one post")
@@ -177,6 +238,22 @@ def get_artifact(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
177
  return serialize_artifact(a, latest_jobs(ctx.db, [a.id]).get(a.id))
178
 
179
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  @router.patch("/{artifact_id}")
181
  def patch_artifact(artifact_id: uuid.UUID, body: PatchIn, ctx: Ctx = Depends(get_ctx)):
182
  a = get_artifact_or_404(ctx, artifact_id)
@@ -214,6 +291,8 @@ def retry_artifact(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
214
  raise HTTPException(400, "Nothing to retry")
215
  if last.status not in {"failed", "done"}:
216
  raise HTTPException(409, "Still running")
 
 
217
  a.status = "pending"
218
  content = dict(a.content_json or {})
219
  content.pop("error", None)
 
1
+ import re
2
  import uuid
3
  from typing import Literal
4
 
5
+ from fastapi import APIRouter, Depends, HTTPException, Response
6
  from pydantic import BaseModel, Field
7
  from sqlalchemy import func, or_, select
8
 
9
  from app.auth import Ctx, get_ctx
10
  from app.config import settings
11
+ from app.models import Artifact, CalendarItem, Chat, Document, Job, Notebook, UserSavedVoice
12
  from app.routers.notebooks import ensure_archive_notebook
13
+ from app.infographics.image import ImageUnavailable, render_png
14
  from app.services.artifacts import latest_jobs, serialize_artifact, start_artifact
15
  from app.services.jobs import create_job, serialize_job
16
  from app.services.plans import effective_plan, plan_limit_error
17
+ from app.infographics.themes import theme_id
18
  from app.services.renderer import COMPOSITIONS
19
  from app.services.storage import storage
20
  from app.services.usage import check_limit
21
 
22
  router = APIRouter(prefix="/api/artifacts", tags=["artifacts"])
23
 
24
+ ArtifactType = Literal["summary", "audio_overview", "video", "quote_card", "carousel", "launch_kit", "mind_map", "quiz", "flashcards", "report", "infographic"]
25
+
26
+
27
+ SOURCE_TYPES = ("quiz", "flashcards", "report", "infographic") # made from posts or chats picked in the dialog
28
 
29
 
30
  class RenderIn(BaseModel):
 
33
 
34
 
35
  class GenerateIn(BaseModel):
36
+ type: Literal["summary", "audio_overview", "video", "quote_card", "carousel", "launch_kit", "mind_map", "quiz", "flashcards", "report", "infographic"]
37
  notebook_id: uuid.UUID | None = None
38
  document_id: uuid.UUID | None = None
39
  archive: bool = False # no notebook or post picked: use the "All posts" notebook
 
52
  # mind_map: the posts to chart (none picked means every post in the notebook) and what to centre on
53
  document_ids: list[uuid.UUID] | None = None
54
  focus: str | None = Field(None, max_length=500)
55
+ topic: str | None = Field(None, max_length=500) # quiz: what to ask about (none: the material as a whole)
56
+ # quiz: from a notebook's posts (notebook_id and document_ids) or from notebook chats (chat_ids)
57
+ chat_ids: list[uuid.UUID] | None = Field(None, min_length=1, max_length=5)
58
+ count: Literal["fewer", "standard", "more"] = "standard"
59
+ difficulty: Literal["easy", "medium", "hard"] = "medium"
60
+ question_types: list[Literal["multiple_choice", "multiple_select", "fill_blank", "short_answer"]] = Field(
61
+ default_factory=lambda: ["multiple_choice", "multiple_select"], min_length=1)
62
+ language: str | None = Field(None, max_length=40)
63
+ theme: str | None = Field(None, max_length=30) # infographic: one of app.infographics.themes
64
+ # report: a written "document" or an "interactive" one, the template it started from, and the instructions the
65
+ # writer is given (the dialog's editable text; empty means the template's own)
66
+ report_format: Literal["document", "interactive"] = "document"
67
+ template_id: str | None = Field(None, max_length=40)
68
+ instructions: str | None = Field(None, max_length=4000)
69
 
70
 
71
  def _hosts(ctx: Ctx, body: GenerateIn) -> dict:
 
135
  if not body.slides:
136
  raise HTTPException(400, "A carousel needs slides")
137
  content = {"slides": body.slides[:12], "parent_id": str(body.parent_id) if body.parent_id else None}
138
+ if body.type in SOURCE_TYPES and body.chat_ids:
139
+ chats = list(ctx.db.scalars(select(Chat).where(Chat.id.in_(set(body.chat_ids)),
140
+ Chat.workspace_id == ctx.workspace.id)
141
+ .order_by(Chat.created_at)))
142
+ if len(chats) != len(set(body.chat_ids)):
143
+ raise HTTPException(404, "Chat not found")
144
+ if not body.notebook_id and not body.document_id:
145
+ body.notebook_id = chats[0].notebook_id # listed with its chats' notebook
146
+ target = chats[0].title or "Chat"
147
+ else:
148
+ if body.type in SOURCE_TYPES and not (body.notebook_id or body.document_id):
149
+ raise HTTPException(400, "Pick posts or chats to make this from")
150
+ if body.type in SOURCE_TYPES and body.notebook_id and not body.document_id and body.document_ids == []:
151
+ raise HTTPException(400, "Pick at least one post or chat to make this from")
152
+ target = _target_title(ctx, body.notebook_id, body.document_id)
153
  if body.type == "audio_overview":
154
  check_limit(ctx.db, ctx.workspace, "audio_minutes", body.minutes)
155
  params = {"format": body.format, "minutes": body.minutes, **_hosts(ctx, body)}
 
159
  raise HTTPException(400, "A Mind Constellation is made from a notebook")
160
  params = {"document_ids": [str(i) for i in body.document_ids or []], "focus": (body.focus or "").strip()}
161
  title = f"Mind Constellation: {params['focus'][:60] or target}"
162
+ elif body.type == "flashcards":
163
+ ids = body.document_ids or ([body.document_id] if body.document_id else [])
164
+ params = {"document_ids": [str(i) for i in ids], "chat_ids": [str(i) for i in body.chat_ids or []],
165
+ "topic": (body.topic or "").strip(), "count": body.count, "difficulty": body.difficulty,
166
+ "language": body.language}
167
+ title = f"Flashcards: {params['topic'][:60] or target}"
168
+ elif body.type == "report":
169
+ check_limit(ctx.db, ctx.workspace, "reports", 1)
170
+ ids = body.document_ids or ([body.document_id] if body.document_id else [])
171
+ params = {"document_ids": [str(i) for i in ids], "chat_ids": [str(i) for i in body.chat_ids or []],
172
+ "topic": (body.topic or "").strip(), "language": body.language,
173
+ "report_format": body.report_format, "template_id": body.template_id,
174
+ "instructions": (body.instructions or "").strip()}
175
+ title = f"Report: {params['topic'][:60] or target}"
176
+ elif body.type == "infographic":
177
+ check_limit(ctx.db, ctx.workspace, "infographics", 1)
178
+ ids = body.document_ids or ([body.document_id] if body.document_id else [])
179
+ params = {"document_ids": [str(i) for i in ids], "chat_ids": [str(i) for i in body.chat_ids or []],
180
+ "theme": theme_id(body.theme), "instructions": (body.instructions or "").strip()[:600]}
181
+ title = f"Infographic: {params['instructions'][:60] or target}"
182
+ elif body.type == "quiz":
183
+ ids = body.document_ids or ([body.document_id] if body.document_id else [])
184
+ params = {"document_ids": [str(i) for i in ids],
185
+ "chat_ids": [str(i) for i in body.chat_ids or []], "topic": (body.topic or "").strip(),
186
+ "count": body.count, "difficulty": body.difficulty,
187
+ "question_types": list(dict.fromkeys(body.question_types)), "language": body.language}
188
+ title = f"Quiz: {params['topic'][:60] or target}"
189
  elif body.type == "launch_kit":
190
  if not body.document_id:
191
  raise HTTPException(400, "A Launch Kit is made from one post")
 
238
  return serialize_artifact(a, latest_jobs(ctx.db, [a.id]).get(a.id))
239
 
240
 
241
+ @router.get("/{artifact_id}/image")
242
+ def infographic_image(artifact_id: uuid.UUID, layout: Literal["landscape", "portrait"] = "landscape", ctx: Ctx = Depends(get_ctx)):
243
+ """A finished infographic as a PNG, wide (the default) or tall, for its Download button."""
244
+ a = get_artifact_or_404(ctx, artifact_id)
245
+ shown = serialize_artifact(a) if a.type == "infographic" and a.status == "ready" else {}
246
+ html = (shown.get("content") or {}).get("html_landscape" if layout == "landscape" else "html")
247
+ if not html:
248
+ raise HTTPException(404, "This infographic has no image to download.")
249
+ try:
250
+ png = render_png(html, layout)
251
+ except ImageUnavailable as exc:
252
+ raise HTTPException(503, str(exc)) from exc
253
+ name = re.sub(r"[^\w\- ]+", "", shown.get("title") or "infographic").strip()[:80] or "infographic"
254
+ return Response(png, media_type="image/png", headers={"Content-Disposition": f'attachment; filename="{name} ({layout}).png"'})
255
+
256
+
257
  @router.patch("/{artifact_id}")
258
  def patch_artifact(artifact_id: uuid.UUID, body: PatchIn, ctx: Ctx = Depends(get_ctx)):
259
  a = get_artifact_or_404(ctx, artifact_id)
 
291
  raise HTTPException(400, "Nothing to retry")
292
  if last.status not in {"failed", "done"}:
293
  raise HTTPException(409, "Still running")
294
+ if last.kind == "report_block":
295
+ raise HTTPException(409, "Add it again from the report")
296
  a.status = "pending"
297
  content = dict(a.content_json or {})
298
  content.pop("error", None)
backend/app/routers/reports.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ from typing import Literal
3
+
4
+ from fastapi import APIRouter, Depends, HTTPException
5
+ from pydantic import BaseModel, Field
6
+ 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.usage import check_limit
19
+ from app.services.report_templates import INTERACTIVE, TEMPLATES
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")
27
+ def templates(_ctx: Ctx = Depends(get_ctx)):
28
+ """The fixed templates (each with its instruction text, {about} to be filled in) and the interactive one."""
29
+ return {"templates": TEMPLATES, "interactive": INTERACTIVE}
30
+
31
+
32
+ class SuggestIn(FocusIn):
33
+ topic: str | None = Field(None, max_length=500)
34
+
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):
60
+ kind: Literal["mind_map", "flashcards", "quiz", "infographic"]
61
+ theme: str | None = Field(None, max_length=30) # infographic: the premade theme
62
+ after_block_id: str | None = Field(None, max_length=20)
63
+ suggestion_id: str | None = Field(None, max_length=20)
64
+ brief: str = Field("", max_length=500)
65
+ existing_artifact_id: uuid.UUID | None = None # copy this Mind Constellation, quiz or flashcard set instead
66
+
67
+
68
+ def _report_or_404(ctx: Ctx, artifact_id: uuid.UUID) -> Artifact:
69
+ a = get_artifact_or_404(ctx, artifact_id)
70
+ if a.type != "report" or a.status != "ready":
71
+ raise HTTPException(404, "Report not found")
72
+ return a
73
+
74
+
75
+ @router.post("/{artifact_id}/blocks")
76
+ def add_block(artifact_id: uuid.UUID, body: AddBlockIn, ctx: Ctx = Depends(get_ctx)):
77
+ """Add a visual after a section: made new from the report's sources, or copied from one you already made. Runs as a
78
+ job; the report page shows its progress and reloads when it is done."""
79
+ a = _report_or_404(ctx, artifact_id)
80
+ content = a.content_json or {}
81
+ if body.kind not in EMBED_KINDS:
82
+ raise HTTPException(400, "Unknown kind of visual")
83
+ if body.kind == "mind_map" and not content.get("source", {}).get("document_ids"):
84
+ raise HTTPException(400, "A Mind Constellation is made from posts, not chats")
85
+ if body.after_block_id and body.after_block_id not in {b["id"] for b in content.get("blocks") or []}:
86
+ raise HTTPException(404, "Section not found")
87
+ last = latest_jobs(ctx.db, [a.id]).get(a.id)
88
+ if last and last.status in {"queued", "running"}:
89
+ raise HTTPException(409, "Something is already being added to this report")
90
+ if body.existing_artifact_id:
91
+ existing = ctx.db.scalar(select(Artifact).where(Artifact.id == body.existing_artifact_id,
92
+ Artifact.workspace_id == ctx.workspace.id,
93
+ Artifact.type == body.kind, Artifact.status == "ready"))
94
+ if not existing:
95
+ raise HTTPException(404, "That one was not found")
96
+ if body.kind == "infographic" and not body.existing_artifact_id:
97
+ check_limit(ctx.db, ctx.workspace, "infographics", 1)
98
+ params = {"artifact_id": str(a.id), "kind": body.kind, "theme": body.theme, "after_block_id": body.after_block_id,
99
+ "suggestion_id": body.suggestion_id, "brief": body.brief.strip(),
100
+ "existing_artifact_id": str(body.existing_artifact_id) if body.existing_artifact_id else None}
101
+ job = create_job(ctx.db, ctx.workspace.id, "report_block", params, artifact_id=a.id, max_attempts=1)
102
+ return serialize_artifact(a, job)
103
+
104
+
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))
backend/app/routers/settings.py CHANGED
@@ -21,6 +21,7 @@ class SettingsIn(BaseModel):
21
  brand_accent: str | None = None
22
  logo_upload_id: str | None = None # "" removes the logo
23
  training_opt_in: bool | None = None
 
24
  email_unsubscribed: bool | None = None
25
 
26
 
@@ -30,7 +31,8 @@ def _serialize(ctx: Ctx) -> dict:
30
  "user": {"name": ctx.user.name, "email": ctx.user.email, "auth_provider": ctx.user.auth_provider.value,
31
  "email_unsubscribed": ctx.user.email_unsubscribed},
32
  "workspace": {"id": str(ctx.workspace.id), "name": ctx.workspace.name,
33
- "training_opt_in": ctx.workspace.training_opt_in},
 
34
  "brand": {"name": brand.get("name"), "accent": brand.get("accent") or "#217cff",
35
  "logo_url": storage.presign_get(brand["logo_key"]) if brand.get("logo_key") else None},
36
  "plan": plan_dict(effective_plan(ctx.db, ctx.workspace)),
@@ -56,6 +58,8 @@ def update_settings(body: SettingsIn, ctx: Ctx = Depends(get_ctx)):
56
  ctx.workspace.name = body.workspace_name.strip()
57
  if body.training_opt_in is not None:
58
  ctx.workspace.training_opt_in = body.training_opt_in
 
 
59
  if body.email_unsubscribed is not None:
60
  ctx.user.email_unsubscribed = body.email_unsubscribed
61
  brand = dict(ctx.workspace.brand_json or {})
 
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
 
 
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)),
 
58
  ctx.workspace.name = body.workspace_name.strip()
59
  if body.training_opt_in is not None:
60
  ctx.workspace.training_opt_in = body.training_opt_in
61
+ if body.allow_public_links is not None:
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 {})
backend/app/routers/share.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import uuid
3
+ from collections import defaultdict, deque
4
+
5
+ from fastapi import APIRouter, Depends, HTTPException, Request, Response
6
+ from pydantic import BaseModel
7
+ from sqlalchemy import select
8
+ from sqlalchemy.orm import Session
9
+
10
+ from app.auth import Ctx, get_ctx
11
+ from app.db import get_db
12
+ from app.models import Artifact, ArtifactShare, Workspace
13
+ from app.routers.artifacts import get_artifact_or_404
14
+ from app.services.share import new_token, public_infographic, public_report, share_url
15
+
16
+ router = APIRouter(tags=["share"])
17
+
18
+ PUBLIC_PER_MINUTE = 60 # reads of shared pages from one address
19
+ _hits: dict[str, deque] = defaultdict(deque)
20
+
21
+
22
+ def _limit(request: Request) -> None:
23
+ ip = (request.headers.get("x-forwarded-for") or "").split(",")[0].strip() or (request.client.host if request.client else "?")
24
+ now, window = time.monotonic(), _hits[ip]
25
+ while window and now - window[0] > 60:
26
+ window.popleft()
27
+ if len(window) >= PUBLIC_PER_MINUTE:
28
+ raise HTTPException(429, "Too many requests. Try again in a minute.")
29
+ window.append(now)
30
+
31
+
32
+ SHAREABLE = ("report", "infographic")
33
+
34
+
35
+ def _report(ctx: Ctx, artifact_id: uuid.UUID) -> Artifact:
36
+ """A finished report or infographic of this workspace."""
37
+ a = get_artifact_or_404(ctx, artifact_id)
38
+ if a.type not in SHAREABLE or a.status != "ready":
39
+ raise HTTPException(404, "Not found")
40
+ return a
41
+
42
+
43
+ def _share(ctx: Ctx, a: Artifact) -> ArtifactShare | None:
44
+ return ctx.db.scalar(select(ArtifactShare).where(ArtifactShare.artifact_id == a.id))
45
+
46
+
47
+ def _out(share: ArtifactShare | None) -> dict:
48
+ return {"shared": bool(share), "url": share_url(share.token) if share else None,
49
+ "show_sources": share.show_sources if share else True}
50
+
51
+
52
+ @router.get("/api/artifacts/{artifact_id}/share")
53
+ def get_share(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
54
+ return _out(_share(ctx, _report(ctx, artifact_id)))
55
+
56
+
57
+ @router.post("/api/artifacts/{artifact_id}/share")
58
+ def create_share(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
59
+ """Turn the link on (the same link if it already is)."""
60
+ a = _report(ctx, artifact_id)
61
+ if not ctx.workspace.allow_public_links:
62
+ raise HTTPException(403, "Public links are turned off for this workspace. Turn them on in Settings, Privacy.")
63
+ share = _share(ctx, a)
64
+ if not share:
65
+ share = ArtifactShare(workspace_id=ctx.workspace.id, artifact_id=a.id, token=new_token(), show_sources=True)
66
+ ctx.db.add(share)
67
+ ctx.db.commit()
68
+ return _out(share)
69
+
70
+
71
+ class ShareIn(BaseModel):
72
+ show_sources: bool
73
+
74
+
75
+ @router.patch("/api/artifacts/{artifact_id}/share")
76
+ def update_share(artifact_id: uuid.UUID, body: ShareIn, ctx: Ctx = Depends(get_ctx)):
77
+ share = _share(ctx, _report(ctx, artifact_id))
78
+ if not share:
79
+ raise HTTPException(404, "This is not shared")
80
+ share.show_sources = body.show_sources
81
+ ctx.db.commit()
82
+ return _out(share)
83
+
84
+
85
+ @router.delete("/api/artifacts/{artifact_id}/share")
86
+ def delete_share(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
87
+ """Turn the link off for good: the old address stops working, and sharing again makes a new one."""
88
+ share = _share(ctx, _report(ctx, artifact_id))
89
+ if share:
90
+ ctx.db.delete(share)
91
+ ctx.db.commit()
92
+ return _out(None)
93
+
94
+
95
+ @router.get("/api/public/reports/{token}")
96
+ def public(token: str, request: Request, response: Response, db: Session = Depends(get_db)):
97
+ """The shared view of a report or an infographic. No sign in. A wrong, revoked or switched-off link is a plain 404."""
98
+ _limit(request)
99
+ response.headers["X-Robots-Tag"] = "noindex, nofollow"
100
+ response.headers["Cache-Control"] = "no-store"
101
+ share = db.scalar(select(ArtifactShare).where(ArtifactShare.token == token[:64]))
102
+ artifact = db.get(Artifact, share.artifact_id) if share else None
103
+ workspace = db.get(Workspace, share.workspace_id) if share else None
104
+ if not share or not artifact or artifact.type not in SHAREABLE or artifact.status != "ready" \
105
+ or not workspace or not workspace.allow_public_links:
106
+ raise HTTPException(404, "This link does not work")
107
+ return public_infographic(artifact) if artifact.type == "infographic" else public_report(artifact, share)
backend/app/routers/sources.py CHANGED
@@ -184,6 +184,7 @@ def list_documents(
184
  ctx: Ctx = Depends(get_ctx),
185
  q: str = "",
186
  source_id: uuid.UUID | None = None,
 
187
  limit: int = 500,
188
  offset: int = 0,
189
  ):
@@ -197,6 +198,8 @@ def list_documents(
197
  query = query.where(or_(Document.title.ilike(like), Document.clean_text.ilike(like)))
198
  if source_id:
199
  query = query.where(Document.source_id == source_id)
 
 
200
  total = ctx.db.scalar(select(func.count()).select_from(query.subquery()))
201
  rows = ctx.db.execute(
202
  query.order_by(Document.published_at.desc().nulls_last(), Document.created_at.desc())
 
184
  ctx: Ctx = Depends(get_ctx),
185
  q: str = "",
186
  source_id: uuid.UUID | None = None,
187
+ indexed_only: bool = False,
188
  limit: int = 500,
189
  offset: int = 0,
190
  ):
 
198
  query = query.where(or_(Document.title.ilike(like), Document.clean_text.ilike(like)))
199
  if source_id:
200
  query = query.where(Document.source_id == source_id)
201
+ if indexed_only:
202
+ query = query.where(Document.path.is_not(None))
203
  total = ctx.db.scalar(select(func.count()).select_from(query.subquery()))
204
  rows = ctx.db.execute(
205
  query.order_by(Document.published_at.desc().nulls_last(), Document.created_at.desc())
backend/app/routers/storage.py CHANGED
@@ -30,6 +30,7 @@ ALLOWED_TYPES = {
30
  "text/markdown",
31
  "text/plain",
32
  "text/html",
 
33
  }
34
 
35
 
 
30
  "text/markdown",
31
  "text/plain",
32
  "text/html",
33
+ "text/vtt",
34
  }
35
 
36
 
backend/app/services/artifacts.py CHANGED
@@ -4,7 +4,7 @@ means: artifact row + queued job, returned together so the UI can stream progres
4
  import uuid
5
 
6
  from sqlalchemy import select
7
- from sqlalchemy.orm import Session
8
 
9
  from app.models import Artifact, Job
10
  from app.services.jobs import create_job, serialize_job
@@ -18,6 +18,10 @@ TYPE_LABELS = {
18
  "carousel": "Carousel",
19
  "launch_kit": "Launch Kit",
20
  "mind_map": "Mind Constellation",
 
 
 
 
21
  }
22
 
23
 
@@ -50,8 +54,37 @@ def latest_jobs(db: Session, artifact_ids: list[uuid.UUID]) -> dict[uuid.UUID, J
50
  return {j.artifact_id: j for j in jobs} # later rows win, so this is the newest job per artifact
51
 
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  def serialize_artifact(a: Artifact, job: Job | None = None) -> dict:
54
  content = a.content_json or {}
 
 
 
 
 
 
55
  slides = content.get("slide_keys") or []
56
  return {
57
  "id": str(a.id),
 
4
  import uuid
5
 
6
  from sqlalchemy import select
7
+ from sqlalchemy.orm import Session, object_session
8
 
9
  from app.models import Artifact, Job
10
  from app.services.jobs import create_job, serialize_job
 
18
  "carousel": "Carousel",
19
  "launch_kit": "Launch Kit",
20
  "mind_map": "Mind Constellation",
21
+ "quiz": "Quiz",
22
+ "flashcards": "Flashcards",
23
+ "report": "Report",
24
+ "infographic": "Infographic",
25
  }
26
 
27
 
 
54
  return {j.artifact_id: j for j in jobs} # later rows win, so this is the newest job per artifact
55
 
56
 
57
+ def report_blocks(a: Artifact) -> list[dict]:
58
+ """A report's blocks, each infographic block with its page: the child artifact's html and status (or "missing"
59
+ once it was deleted). Read when the report is read, so one still being written shows up when it is ready."""
60
+ from app.pipeline.infographic import infographic_html # not at import: pipeline imports this module
61
+
62
+ blocks = list((a.content_json or {}).get("blocks") or [])
63
+ ids = [uuid.UUID(b["artifact_id"]) for b in blocks if b.get("type") == "infographic" and b.get("artifact_id")]
64
+ db = object_session(a)
65
+ kids = {k.id: k for k in db.scalars(select(Artifact).where(Artifact.id.in_(ids), Artifact.workspace_id == a.workspace_id))} \
66
+ if ids and db else {}
67
+ out = []
68
+ for b in blocks:
69
+ if b.get("type") != "infographic":
70
+ out.append(b)
71
+ continue
72
+ kid = kids.get(uuid.UUID(b["artifact_id"])) if b.get("artifact_id") else None
73
+ ready = bool(kid and kid.status == "ready" and (kid.content_json or {}).get("content"))
74
+ out.append({**b, "status": kid.status if kid else "missing",
75
+ "html": infographic_html(kid.content_json) if ready else None,
76
+ "html_landscape": infographic_html(kid.content_json, "landscape") if ready else None})
77
+ return out
78
+
79
+
80
  def serialize_artifact(a: Artifact, job: Job | None = None) -> dict:
81
  content = a.content_json or {}
82
+ if a.type == "infographic" and a.status == "ready":
83
+ from app.pipeline.infographic import infographic_html # not at import: pipeline imports this module
84
+
85
+ content = {**content, "html": infographic_html(content), "html_landscape": infographic_html(content, "landscape")}
86
+ if a.type == "report" and any(b.get("type") == "infographic" for b in content.get("blocks") or []):
87
+ content = {**content, "blocks": report_blocks(a)}
88
  slides = content.get("slide_keys") or []
89
  return {
90
  "id": str(a.id),
backend/app/services/plans.py CHANGED
@@ -41,6 +41,8 @@ class Plan:
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
 
45
 
46
  # metric -> period it counts over (app/services/video_limits.py)
@@ -72,6 +74,8 @@ DEFAULT_PLANS: dict[str, Plan] = {
72
  audio_minutes=3,
73
  videos=1,
74
  launch_kits=2,
 
 
75
  voice_cloning=False,
76
  brand_kit=False,
77
  features=(
@@ -80,6 +84,8 @@ DEFAULT_PLANS: dict[str, Plan] = {
80
  "3 min of audio overviews a month",
81
  "1 video to try it",
82
  "2 Launch Kits a month",
 
 
83
  ),
84
  video_limits=FREE_VIDEO_LIMITS,
85
  videos_monthly=False,
@@ -94,6 +100,8 @@ DEFAULT_PLANS: dict[str, Plan] = {
94
  audio_minutes=60,
95
  videos=10,
96
  launch_kits=50,
 
 
97
  voice_cloning=True,
98
  brand_kit=True,
99
  features=(
@@ -101,6 +109,8 @@ DEFAULT_PLANS: dict[str, Plan] = {
101
  "60 min of audio overviews a month",
102
  "10 videos a month",
103
  "50 Launch Kits a month",
 
 
104
  "Voice cloning with consent",
105
  "Your brand colors and logo",
106
  ),
@@ -117,6 +127,8 @@ DEFAULT_PLANS: dict[str, Plan] = {
117
  audio_minutes=240,
118
  videos=20,
119
  launch_kits=-1,
 
 
120
  voice_cloning=True,
121
  brand_kit=True,
122
  features=(
@@ -124,6 +136,8 @@ DEFAULT_PLANS: dict[str, Plan] = {
124
  "240 min of audio overviews a month",
125
  "20 videos a month",
126
  "Unlimited Launch Kits",
 
 
127
  "Launchpad calendar and resurfacing",
128
  "Priority rendering",
129
  ),
@@ -140,7 +154,8 @@ def _plan_from_row(row: PlanRecord) -> Plan:
140
  return Plan(id=row.id, name=row.name, tagline=row.tagline or "",
141
  price_monthly_usd=float(row.price_monthly_usd or 0), sources=row.sources,
142
  indexed_posts=row.indexed_posts, audio_minutes=row.audio_minutes,
143
- videos=row.videos, launch_kits=row.launch_kits, voice_cloning=bool(row.voice_cloning),
 
144
  brand_kit=bool(row.brand_kit), features=tuple(row.features or ()),
145
  video_premium=bool(row.video_premium), videos_monthly=bool(row.videos_monthly),
146
  video_limits={**{k: 0 for k in VIDEO_LIMITS}, **(row.video_limits or {})})
 
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)
 
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=(
 
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,
 
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=(
 
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
  ),
 
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=(
 
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
  ),
 
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 {})})
backend/app/services/report_templates.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The report templates offered in the Create report dialog. Each one is the instruction text the writer is given; the
2
+ dialog fills {about} in with the report's topic (or the chosen sources) and lets the user edit the whole text before it
3
+ is sent. Kept on the server so the dialog and the generator share one source of truth."""
4
+
5
+ INTERACTIVE_ID = "interactive"
6
+ CUSTOM_ID = "custom"
7
+
8
+ TEMPLATES: list[dict] = [
9
+ {
10
+ "id": CUSTOM_ID,
11
+ "name": "Create Your Own",
12
+ "description": "Craft reports your way by specifying structure, style, tone, and more",
13
+ "prompt": "",
14
+ },
15
+ {
16
+ "id": "briefing",
17
+ "name": "Briefing Doc",
18
+ "description": "Overview of your sources featuring key insights and quotes",
19
+ "prompt": ("You are a highly capable research assistant. Write a briefing document on {about}. Open with a "
20
+ "short Introduction, then give the key insights under clear, descriptive headings, and include the best "
21
+ "short quotes from the sources that support them. End with a Summary of the main takeaways."),
22
+ },
23
+ {
24
+ "id": "study_guide",
25
+ "name": "Study Guide",
26
+ "description": "Short-answer quiz, suggested essay questions, and glossary of key terms",
27
+ "prompt": ("You are a highly capable research assistant and tutor. Create a detailed study guide on {about} "
28
+ "designed to review understanding of the sources. Create a quiz with ten short-answer questions "
29
+ "(2-3 sentences each) and include a separate answer key. Suggest five essay format questions, "
30
+ "but do not supply answers. Also conclude with a comprehensive glossary of key terms with "
31
+ "definitions. Start with an Introduction that says what the reader will be able to do by the end, and give "
32
+ "every section a relevant, specific heading."),
33
+ },
34
+ {
35
+ "id": "blog_post",
36
+ "name": "Blog Post",
37
+ "description": "Insightful takeaways distilled into a highly readable article",
38
+ "prompt": ("Write an engaging blog post on {about}. Distill the most insightful takeaways from the sources "
39
+ "into a highly readable article with a strong opening, short sections with clear subheadings, "
40
+ "concrete examples from the sources, and a closing thought. Give every section a relevant, specific heading. Keep "
41
+ "the tone warm and conversational."),
42
+ },
43
+ ]
44
+
45
+ INTERACTIVE = {
46
+ "id": INTERACTIVE_ID,
47
+ "name": "Interactive report",
48
+ "description": "An interactive report with embedded studio content",
49
+ "prompt": ("Create an interactive report on {about}. Explain it clearly in well organised sections, starting "
50
+ "with an Introduction and ending with a Summary, and give every section a relevant, specific heading. "
51
+ "Suggest where a visual would help the reader, such as a mind map of how the ideas connect, flashcards "
52
+ "for the key terms, a short quiz, a comparison table or a timeline."),
53
+ }
54
+
55
+ DEFAULT_ABOUT = "the selected sources"
56
+
57
+
58
+ def get_template(template_id: str | None) -> dict | None:
59
+ if template_id == INTERACTIVE_ID:
60
+ return INTERACTIVE
61
+ return next((t for t in TEMPLATES if t["id"] == template_id), None)
62
+
63
+
64
+ def fill_prompt(template: dict, about: str | None = None) -> str:
65
+ """The template's instruction text for a report about `about` (the topic, or the sources when there is none)."""
66
+ return template["prompt"].replace("{about}", (about or "").strip() or DEFAULT_ABOUT)
67
+
68
+
69
+ def default_instructions(report_format: str, template_id: str | None, about: str | None = None) -> str:
70
+ """What the writer is told when the dialog sent no instructions of its own."""
71
+ template = get_template(INTERACTIVE_ID if report_format == "interactive" else template_id)
72
+ if template and template["prompt"]:
73
+ return fill_prompt(template, about)
74
+ return f"Write a clear, well organised report on {(about or '').strip() or DEFAULT_ABOUT}."
backend/app/services/share.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """What a shared report shows to someone with the link.
2
+
3
+ The public view is built from a whitelist, field by field, never by filtering the stored content: a field added to a
4
+ block later stays private until it is added here on purpose. It carries no post text, no quoted lines, no ids, no
5
+ paths, no instructions, no suggestions, and no name of the author or workspace."""
6
+
7
+ import re
8
+ import secrets
9
+
10
+ from app.config import settings
11
+ from app.models import Artifact, ArtifactShare
12
+ from app.pipeline.infographic import infographic_html
13
+ from app.services.artifacts import report_blocks
14
+
15
+ MARKER = re.compile(r"\s*\[\d+\](?:\[\d+\])*") # [n] points into citations the public view does not carry
16
+ MAX_TEXT = 20_000
17
+
18
+
19
+ def new_token() -> str:
20
+ return secrets.token_urlsafe(16)
21
+
22
+
23
+ def share_url(token: str) -> str:
24
+ return f"{settings.frontend_url.rstrip('/')}/r/{token}"
25
+
26
+
27
+ def _s(value, limit: int = MAX_TEXT) -> str:
28
+ return str(value or "")[:limit]
29
+
30
+
31
+ def _node(n: dict, depth: int = 0) -> dict:
32
+ """A mind map node as label, note and children only (no sources, no quotes)."""
33
+ kids = [] if depth > 6 else [_node(c, depth + 1) for c in (n.get("children") or []) if isinstance(c, dict)]
34
+ return {"id": _s(n.get("id"), 40), "label": _s(n.get("label"), 120), "note": _s(n.get("note"), 500),
35
+ "sources": [], "children": kids}
36
+
37
+
38
+ def _block(b: dict) -> dict | None:
39
+ kind, base = b.get("type"), {"id": _s(b.get("id"), 20), "type": b.get("type"), "title": _s(b.get("title"), 200)}
40
+ if kind == "prose":
41
+ return {**base, "title": "", "text": MARKER.sub("", _s(b.get("text")))}
42
+ if kind == "callout":
43
+ return {**base, "text": MARKER.sub("", _s(b.get("text"), 2000))}
44
+ if kind == "key_terms":
45
+ return {**base, "terms": [{"term": _s(t.get("term"), 200), "definition": _s(t.get("definition"), 800)}
46
+ for t in b.get("terms") or [] if isinstance(t, dict)][:60]}
47
+ if kind == "table":
48
+ return {**base, "headers": [_s(h, 120) for h in b.get("headers") or []][:12],
49
+ "rows": [[_s(c, 400) for c in r][:12] for r in b.get("rows") or [] if isinstance(r, list)][:60]}
50
+ if kind == "timeline":
51
+ return {**base, "events": [{"when": _s(e.get("when"), 80), "title": _s(e.get("title"), 200),
52
+ "detail": _s(e.get("detail"), 800)}
53
+ for e in b.get("events") or [] if isinstance(e, dict)][:40]}
54
+ if kind == "mind_map" and isinstance(b.get("root"), dict):
55
+ return {**base, "root": _node(b["root"])}
56
+ if kind == "infographic": # only a finished one, as the page our own template built (never the model's markup)
57
+ html, wide = b.get("html"), b.get("html_landscape")
58
+ return {**base, "html": html, "html_landscape": wide if isinstance(wide, str) else None} if isinstance(html, str) and html else None
59
+ if kind == "flashcards":
60
+ return {**base, "cards": [{"front": _s(c.get("front"), 300), "back": _s(c.get("back"), 800)}
61
+ for c in b.get("cards") or [] if isinstance(c, dict)][:60]}
62
+ if kind == "quiz":
63
+ return {**base, "questions": [{
64
+ "type": q.get("type"), "question": _s(q.get("question"), 600),
65
+ "options": [_s(o, 300) for o in q.get("options") or []][:8],
66
+ "correct": [i for i in q.get("correct") or [] if isinstance(i, int)][:8],
67
+ "answer": _s(q.get("answer"), 600), "accepted": [_s(a, 200) for a in q.get("accepted") or []][:8],
68
+ "explanation": _s(q.get("explanation"), 800)}
69
+ for q in b.get("questions") or [] if isinstance(q, dict)][:30]}
70
+ return None
71
+
72
+
73
+ def public_report(artifact: Artifact, share: ArtifactShare) -> dict:
74
+ """The shared page's data for a report."""
75
+ c = artifact.content_json or {}
76
+ blocks = [x for b in report_blocks(artifact) if isinstance(b, dict) and (x := _block(b))]
77
+ out = {"kind": "report", "title": _s(c.get("title"), 300), "format": "interactive" if c.get("format") == "interactive" else "document",
78
+ "language": _s(c.get("language"), 40),
79
+ "created_at": artifact.created_at.isoformat() if artifact.created_at else None, "blocks": blocks,
80
+ "show_sources": bool(share.show_sources)}
81
+ if share.show_sources:
82
+ source = c.get("source") or {}
83
+ # Only a post's own public web address is shown; an upload or anything else is named by its title alone.
84
+ out["sources"] = {"chats": len(source.get("chat_ids") or []), "posts": [
85
+ {"title": _s(s.get("title"), 300),
86
+ "url": s["url"] if isinstance(s.get("url"), str) and re.match(r"https?://", s["url"]) else None}
87
+ for s in c.get("sources") or [] if isinstance(s, dict)][:60]}
88
+ return out
89
+
90
+
91
+ def public_infographic(artifact: Artifact) -> dict:
92
+ """The shared page's data for an infographic: its title and the page our own template builds from the stored text
93
+ (every word escaped there). No source, no prompt, no ids."""
94
+ c = artifact.content_json or {}
95
+ return {"kind": "infographic", "title": _s(c.get("title"), 300), "html": infographic_html(c) or "",
96
+ "html_landscape": infographic_html(c, "landscape") or "",
97
+ "created_at": artifact.created_at.isoformat() if artifact.created_at else None}
backend/app/services/usage.py CHANGED
@@ -31,9 +31,21 @@ def month_usage(db: Session, workspace_id) -> dict:
31
  Artifact.workspace_id == workspace_id, Artifact.type == "launch_kit", Artifact.created_at >= since
32
  )
33
  ) or 0
 
 
 
 
 
 
 
 
 
 
34
  return {
35
  "audio_minutes": round(total("tts", "seconds") / 60, 1),
36
  "launch_kits": kits,
 
 
37
  "llm_tokens": int(total("llm", "tokens")),
38
  "since": since.isoformat(),
39
  }
@@ -49,7 +61,7 @@ def usage_report(db: Session, workspace: Workspace) -> dict:
49
  "used": used,
50
  "videos_resets_at": videos["resets_at"],
51
  "limits": {"audio_minutes": plan.audio_minutes, "videos": videos["limit"],
52
- "launch_kits": plan.launch_kits, "sources": plan.sources, "indexed_posts": plan.indexed_posts},
53
  }
54
 
55
 
@@ -57,7 +69,8 @@ def check_limit(db: Session, workspace: Workspace, kind: str, amount: float = 1)
57
  """Raise 402 plan_limit when this request would go over the monthly allowance."""
58
  plan = effective_plan(db, workspace)
59
  used = month_usage(db, workspace.id)
60
- limit = {"audio_minutes": plan.audio_minutes, "launch_kits": plan.launch_kits}[kind]
 
61
  if limit >= 0 and used[kind] + amount > limit:
62
  label = kind.replace("_", " ")
63
  raise plan_limit_error(plan, kind, f"This would go over your {limit} {label} this month.")
 
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
  }
 
61
  "used": used,
62
  "videos_resets_at": videos["resets_at"],
63
  "limits": {"audio_minutes": plan.audio_minutes, "videos": videos["limit"],
64
+ "launch_kits": plan.launch_kits, "reports": plan.reports, "infographics": plan.infographics, "sources": plan.sources, "indexed_posts": plan.indexed_posts},
65
  }
66
 
67
 
 
69
  """Raise 402 plan_limit when this request would go over the monthly allowance."""
70
  plan = effective_plan(db, workspace)
71
  used = month_usage(db, workspace.id)
72
+ limit = {"audio_minutes": plan.audio_minutes, "launch_kits": plan.launch_kits, "reports": plan.reports,
73
+ "infographics": plan.infographics}[kind]
74
  if limit >= 0 and used[kind] + amount > limit:
75
  label = kind.replace("_", " ")
76
  raise plan_limit_error(plan, kind, f"This would go over your {limit} {label} this month.")
backend/app/support/corpus.py CHANGED
@@ -74,12 +74,15 @@ def _plans_doc() -> Doc:
74
  monthly, yearly = annual_prices(p)
75
  price += f", or ${monthly:g} a month billed yearly (${yearly:g} a year)"
76
  kits = "unlimited" if p.launch_kits < 0 else str(p.launch_kits)
 
 
77
  lines += [
78
  f"## {p.name}",
79
  f"{p.tagline}. Price: {price}.",
80
  f"Limits: {p.sources} sources, {p.indexed_posts} indexed posts, "
81
  f"{p.audio_minutes} minutes of audio a month, "
82
- f"{p.videos} videos {'a month' if p.videos_monthly else 'in total'}, {kits} Launch Kits a month. "
 
83
  f"Voice cloning: {'yes' if p.voice_cloning else 'no'}. Brand kit: {'yes' if p.brand_kit else 'no'}.",
84
  "Includes: " + "; ".join(p.features) + ".",
85
  ]
@@ -94,7 +97,7 @@ def _plans_doc() -> Doc:
94
  return Doc(
95
  id="plans-and-billing", title="Plans, pricing and billing", route="/pricing",
96
  keywords=("price", "pricing", "plan", "plans", "free", "writer", "studio", "cost", "billing", "upgrade",
97
- "limits", "annual", "monthly", "subscription", "refund", "cancel", "usage"),
98
  questions=("How much does Notestack cost?", "What is in the free plan?", "What are the plan limits?",
99
  "How do I upgrade?", "Is there annual billing?"),
100
  related_paths=("/app/settings",), headings=tuple(re.findall(r"^#{1,3}\s+(.+)$", body, re.M)), body=body,
 
74
  monthly, yearly = annual_prices(p)
75
  price += f", or ${monthly:g} a month billed yearly (${yearly:g} a year)"
76
  kits = "unlimited" if p.launch_kits < 0 else str(p.launch_kits)
77
+ reports = "unlimited" if p.reports < 0 else str(p.reports)
78
+ infographics = "unlimited" if p.infographics < 0 else str(p.infographics)
79
  lines += [
80
  f"## {p.name}",
81
  f"{p.tagline}. Price: {price}.",
82
  f"Limits: {p.sources} sources, {p.indexed_posts} indexed posts, "
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
  ]
 
97
  return Doc(
98
  id="plans-and-billing", title="Plans, pricing and billing", route="/pricing",
99
  keywords=("price", "pricing", "plan", "plans", "free", "writer", "studio", "cost", "billing", "upgrade",
100
+ "limits", "annual", "monthly", "subscription", "refund", "cancel", "usage", "reports", "infographics"),
101
  questions=("How much does Notestack cost?", "What is in the free plan?", "What are the plan limits?",
102
  "How do I upgrade?", "Is there annual billing?"),
103
  related_paths=("/app/settings",), headings=tuple(re.findall(r"^#{1,3}\s+(.+)$", body, re.M)), body=body,
backend/app/support/prompts.py CHANGED
@@ -27,8 +27,10 @@ steering back to Notestack. Do not answer the off topic question.
27
  COMPETITORS: represent Notestack only. Never recommend or name another product to use, even if a document \
28
  mentions one. If Notestack cannot do something, say so plainly and point to the closest thing it does.
29
 
30
- HOW TO: you only give written instructions. Say where to go using the real menu names: Notebooks, Sources and \
31
- Launchpad are in the left sidebar, Settings is at the bottom of the sidebar, Pricing is on the website menu. When \
 
 
32
  the answer has more than one step, use a numbered markdown list, one step per line ("1.", "2."), with **bold** \
33
  for button and menu names. One short intro sentence first. Never describe a button or page that the documents do \
34
  not mention.
 
27
  COMPETITORS: represent Notestack only. Never recommend or name another product to use, even if a document \
28
  mentions one. If Notestack cannot do something, say so plainly and point to the closest thing it does.
29
 
30
+ HOW TO: you only give written instructions. Say where to go using the real menu names: Notebooks, Sources, \
31
+ Library and Launchpad are in the left sidebar, Settings is at the bottom of the sidebar, Pricing is on the website menu. \
32
+ Inside a notebook the Create panel on the right makes audio, video, reports, summaries, infographics, quizzes, \
33
+ flashcards and Mind Constellations. When \
34
  the answer has more than one step, use a numbered markdown list, one step per line ("1.", "2."), with **bold** \
35
  for button and menu names. One short intro sentence first. Never describe a button or page that the documents do \
36
  not mention.
backend/app/support/scope.py CHANGED
@@ -22,13 +22,14 @@ def scrub_path(path: str | None) -> str | None:
22
 
23
  # "How do I ..." style questions are product help even when they mention "my notebook".
24
  _HOWTO = re.compile(
25
- r"^\s*(?:hi|hello|hey|please|ok|okay)?[\s,!.]*(?:how\s+(?:do|can|could|should|to|does|would)\b|where\s+(?:do|can|is|are)\b|"
26
- r"can\s+(?:i|you|notestack)\b|could\s+(?:i|you)\b|is\s+it\s+possible\b|which\s+plan\b|what\s+(?:does|is\s+a|is\s+the)\b)",
 
27
  re.I,
28
  )
29
  _MINE = (
30
  r"(?:notebooks?|posts?|articles?|sources?|archive|documents?|newsletters?|blog|videos?|audio|podcasts?|"
31
- r"launch\s*kits?|drafts?|files?|chats?|conversations?|topics?|quotes?|summar(?:y|ies)|writing|content|stuff|data)"
32
  )
33
  _ABOUT_MY_DATA = re.compile(
34
  rf"""(?ix)
@@ -46,7 +47,7 @@ _ABOUT_MY_DATA = re.compile(
46
  """
47
  )
48
  # Words that mean something went wrong with their work; the reply also offers the human form.
49
- _BROKEN = re.compile(r"(?i)\b(?:fail\w*|error|broken|stuck|crash\w*|not\s+working|won'?t|doesn'?t\s+work|missing|lost)\b")
50
 
51
  OUT_OF_SCOPE_REPLY = (
52
  "I can help with how Notestack works, but I can't see your notebooks, posts, files or account, so I can't answer "
 
22
 
23
  # "How do I ..." style questions are product help even when they mention "my notebook".
24
  _HOWTO = re.compile(
25
+ r"^\s*(?:hi|hello|hey|please|ok|okay)?[\s,!.]*(?:how\s+(?:do|can|could|should|to|does|would)\b|i(?:\s+(?:really|just))?\s+(?:want|need|wanna|would\s+like|'d\s+like)\s+to\b|make\s+me\b|where\s+(?:do|can|is|are)\b|"
26
+ r"can\s+(?:i|you|notestack)\b|could\s+(?:i|you)\b|is\s+it\s+possible\b|which\s+plan\b|what\s+(?:does|is\s+a|is\s+the)\b|"
27
+ r"(?:can|could|will|would|do|does|are|is)\s+(?:people|anyone|anybody|others|someone|they|readers?|visitors?)\b)",
28
  re.I,
29
  )
30
  _MINE = (
31
  r"(?:notebooks?|posts?|articles?|sources?|archive|documents?|newsletters?|blog|videos?|audio|podcasts?|"
32
+ r"reports?|quiz(?:zes)?|flashcards?|infographics?|mind\s*(?:map|constellation)s?|launch\s*kits?|drafts?|files?|chats?|conversations?|topics?|quotes?|summar(?:y|ies)|writing|content|stuff|data)"
33
  )
34
  _ABOUT_MY_DATA = re.compile(
35
  rf"""(?ix)
 
47
  """
48
  )
49
  # Words that mean something went wrong with their work; the reply also offers the human form.
50
+ _BROKEN = re.compile(r"(?i)\b(?:fail\w*|slow|forever|too\s+long|taking\s+(?:so\s+|way\s+)?long|hang\w*|never\s+(?:finish|load|appear)\w*|error|broken|stuck|crash\w*|not\s+working|won'?t|doesn'?t\s+work|missing|lost)\b")
51
 
52
  OUT_OF_SCOPE_REPLY = (
53
  "I can help with how Notestack works, but I can't see your notebooks, posts, files or account, so I can't answer "
backend/app/worker.py CHANGED
@@ -45,7 +45,7 @@ from app.models import (
45
  Workspace,
46
  WorkspaceMember,
47
  )
48
- from app.pipeline import generate, launchkit, media
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
@@ -282,6 +282,11 @@ HANDLERS: dict[str, Callable[[Session, Job], object]] = {
282
  "resurface_scan": handle_resurface,
283
  "summary": _artifact_handler(generate.summarize_notebook, "Summary ready"),
284
  "mind_map": _artifact_handler(generate.build_mind_map, "Mind Constellation ready"),
 
 
 
 
 
285
  "audio_overview": _artifact_handler(media.audio_overview, "Audio overview ready"),
286
  "video": _artifact_handler(media.make_video, "Audiogram ready"), # audiograms; videos come from blog2video
287
  "quote_card": _artifact_handler(media.make_quote_card, "Quote card ready"),
@@ -292,7 +297,8 @@ HANDLERS: dict[str, Callable[[Session, Job], object]] = {
292
 
293
 
294
  def mark_artifact_failed(db: Session, job: Job, error: str) -> None:
295
- if job.artifact_id:
 
296
  artifact = db.get(Artifact, job.artifact_id)
297
  if artifact:
298
  artifact.status = "failed"
 
45
  Workspace,
46
  WorkspaceMember,
47
  )
48
+ from app.pipeline import flashcards, generate, infographic, launchkit, media, quiz, report
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
 
282
  "resurface_scan": handle_resurface,
283
  "summary": _artifact_handler(generate.summarize_notebook, "Summary ready"),
284
  "mind_map": _artifact_handler(generate.build_mind_map, "Mind Constellation ready"),
285
+ "quiz": _artifact_handler(quiz.build_quiz, "Quiz ready"),
286
+ "flashcards": _artifact_handler(flashcards.build_flashcards, "Flashcards ready"),
287
+ "report": _artifact_handler(report.build_report, "Report ready"),
288
+ "infographic": _artifact_handler(infographic.build_infographic, "Infographic ready"),
289
+ "report_block": report.handle_report_block,
290
  "audio_overview": _artifact_handler(media.audio_overview, "Audio overview ready"),
291
  "video": _artifact_handler(media.make_video, "Audiogram ready"), # audiograms; videos come from blog2video
292
  "quote_card": _artifact_handler(media.make_quote_card, "Quote card ready"),
 
297
 
298
 
299
  def mark_artifact_failed(db: Session, job: Job, error: str) -> None:
300
+ # A failed "add a visual" job leaves the report as it was; the job's own error tells the page what went wrong.
301
+ if job.artifact_id and job.kind != "report_block":
302
  artifact = db.get(Artifact, job.artifact_id)
303
  if artifact:
304
  artifact.status = "failed"
backend/requirements.txt CHANGED
@@ -20,6 +20,7 @@ feedparser==6.*
20
  beautifulsoup4==4.*
21
  pypdf==5.*
22
  stripe==13.*
 
23
 
24
  # Dev tools (tests, lint)
25
  pytest==8.*
 
20
  beautifulsoup4==4.*
21
  pypdf==5.*
22
  stripe==13.*
23
+ jinja2==3.*
24
 
25
  # Dev tools (tests, lint)
26
  pytest==8.*
backend/scripts/render_theme_thumbnails.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
13
+ 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)
backend/tests/conftest.py CHANGED
@@ -11,8 +11,10 @@ os.environ["ELEVENLABS_API_KEY"] = ""
11
  os.environ["FIRECRAWL_API_KEY"] = ""
12
  os.environ["R2_PREFIX"] = ""
13
  os.environ["TYPESAFE_API_KEY"] = ""
14
- os.environ["STRIPE_SECRET_KEY"] = ""
15
- os.environ["STRIPE_WEBHOOK_SECRET"] = ""
 
 
16
  os.environ["STRIPE_PRICE_WRITER_MONTHLY"] = ""
17
  os.environ["STRIPE_PRICE_WRITER_ANNUAL"] = ""
18
  os.environ["STRIPE_PRICE_STUDIO_MONTHLY"] = ""
@@ -71,6 +73,18 @@ def run_jobs(session_factory, db_session):
71
  return drain
72
 
73
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  @pytest.fixture()
75
  def client(db_session):
76
  app.dependency_overrides[get_db] = lambda: db_session
 
11
  os.environ["FIRECRAWL_API_KEY"] = ""
12
  os.environ["R2_PREFIX"] = ""
13
  os.environ["TYPESAFE_API_KEY"] = ""
14
+ # Every name the settings accept for these, so a key exported in the shell under another name cannot get through.
15
+ for _name in ("STRIPE_SECRET_KEY", "STRIPE_API_KEY", "STRIPE_SECRET", "STRIPE_WEBHOOK_SECRET",
16
+ "STRIPE_WEBHOOK_SIGNING_SECRET", "STRIPE_SIGNING_SECRET"):
17
+ os.environ[_name] = ""
18
  os.environ["STRIPE_PRICE_WRITER_MONTHLY"] = ""
19
  os.environ["STRIPE_PRICE_WRITER_ANNUAL"] = ""
20
  os.environ["STRIPE_PRICE_STUDIO_MONTHLY"] = ""
 
73
  return drain
74
 
75
 
76
+ @pytest.fixture(autouse=True)
77
+ def never_reach_stripe(monkeypatch):
78
+ """No test can talk to the real Stripe account: building a real client fails loudly. A test that needs Stripe swaps in
79
+ a fake (`monkeypatch.setattr(stripe_billing, "client", lambda: fake)`), which never builds one."""
80
+ import stripe
81
+
82
+ def blocked(*_args, **_kwargs):
83
+ raise RuntimeError("A test tried to create a real Stripe client. Use a fake one (see tests/test_stripe_billing.py).")
84
+
85
+ monkeypatch.setattr(stripe, "StripeClient", blocked)
86
+
87
+
88
  @pytest.fixture()
89
  def client(db_session):
90
  app.dependency_overrides[get_db] = lambda: db_session