Spaces:
Runtime error
Runtime error
Refactor code structure for improved readability and maintainability; removed redundant code blocks and optimized function calls.
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- Dockerfile +4 -0
- backend/0 +1 -0
- backend/Dockerfile +3 -0
- backend/alembic/versions/0017_reports_limit.py +26 -0
- backend/alembic/versions/0018_report_sharing.py +41 -0
- backend/alembic/versions/0019_infographics_limit.py +26 -0
- backend/app/config.py +1 -0
- backend/app/infographics/__init__.py +0 -0
- backend/app/infographics/content.py +108 -0
- backend/app/infographics/image.py +68 -0
- backend/app/infographics/render.py +53 -0
- backend/app/infographics/templates/base.html.j2 +65 -0
- backend/app/infographics/templates/galaxy.html.j2 +46 -0
- backend/app/infographics/templates/launch.html.j2 +58 -0
- backend/app/infographics/templates/moonbase.html.j2 +51 -0
- backend/app/infographics/templates/observatory.html.j2 +45 -0
- backend/app/infographics/templates/solar.html.j2 +47 -0
- backend/app/infographics/templates/station.html.j2 +47 -0
- backend/app/infographics/themes.py +26 -0
- backend/app/llm/provider.py +10 -0
- backend/app/llm/signatures.py +202 -0
- backend/app/main.py +3 -1
- backend/app/models/__init__.py +2 -0
- backend/app/models/content.py +13 -0
- backend/app/models/user.py +4 -0
- backend/app/pipeline/flashcards.py +54 -0
- backend/app/pipeline/generate.py +24 -16
- backend/app/pipeline/infographic.py +116 -0
- backend/app/pipeline/ingest.py +30 -0
- backend/app/pipeline/material.py +113 -0
- backend/app/pipeline/quiz.py +82 -0
- backend/app/pipeline/report.py +395 -0
- backend/app/routers/artifacts.py +84 -5
- backend/app/routers/reports.py +113 -0
- backend/app/routers/settings.py +5 -1
- backend/app/routers/share.py +107 -0
- backend/app/routers/sources.py +3 -0
- backend/app/routers/storage.py +1 -0
- backend/app/services/artifacts.py +34 -1
- backend/app/services/plans.py +16 -1
- backend/app/services/report_templates.py +74 -0
- backend/app/services/share.py +97 -0
- backend/app/services/usage.py +15 -2
- backend/app/support/corpus.py +5 -2
- backend/app/support/prompts.py +4 -2
- backend/app/support/scope.py +5 -4
- backend/app/worker.py +8 -2
- backend/requirements.txt +1 -0
- backend/scripts/render_theme_thumbnails.py +45 -0
- 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 |
-
|
| 166 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 170 |
-
|
|
|
|
| 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 |
-
|
| 200 |
-
|
| 201 |
-
|
|
|
|
| 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
|
| 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 |
-
|
| 239 |
-
"title": f"Mind Constellation: {focus[:60] or
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
| 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
|
|
|
|
| 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
|
| 31 |
-
Launchpad are in the left sidebar, Settings is at the bottom of the sidebar, Pricing is on the website menu.
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 15 |
-
|
|
|
|
|
|
|
| 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
|