diff --git a/README.md b/README.md index f8008014b85acb5e232f8047336d2eee00f603d3..e3e3883ce23c092a47fdfed689d203722af67d4e 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ archive, stays in sync with it, cites the exact lines behind every answer, and t | Citations | Exact line ranges, verified before you see them | Passage level citations | | Not in your sources | Says so; unverifiable claims are dropped | Grounded, passage citations | | Audio | Two hosts on ElevenLabs voices, or your consented voice clone | Two host overviews, Google voices | -| Video | 9:16 shorts, 16:9 explainers, 1:1 audiograms, quote cards, in your brand | Video overviews | +| Video | 9:16 shorts, 16:9 explainers, 1:1 audiograms, quote cards | Video overviews | | Publishing | Launch Kits (threads, LinkedIn, Notes, Bluesky, SEO, carousels) and auto posting | Not a publishing tool | | Your voice | Voice profile learned from your posts, used in every draft | General purpose writing | | Seeing your archive | Topic constellation with rising and dormant topics, evergreen resurfacing | Idea Constellations per notebook | @@ -121,7 +121,7 @@ cd renderer && npm install && npm start # only needed for vid | Launchpad | Calendar and agenda, auto posting to X, LinkedIn and Bluesky, email reminders, tracked links, engagement | | Archive | Every generated artifact with filters, players, downloads, retry and delete | | Resurfacing | Evergreen scores and reshare angles for old posts, on this day, straight into a kit or the calendar | -| Settings | Profile, workspace, brand kit, privacy, plan usage meters, connections, sign out everywhere, delete | +| Settings | Profile, workspace, privacy, plan usage meters, connections, sign out everywhere, delete | ## Key choices diff --git a/backend/alembic/versions/0006_plans.py b/backend/alembic/versions/0006_plans.py index 96db0917611d2ce7c0a5286346dd26c06a25367a..39fe40dff3718f8918568485098c463a4d3bc75b 100644 --- a/backend/alembic/versions/0006_plans.py +++ b/backend/alembic/versions/0006_plans.py @@ -57,12 +57,15 @@ def upgrade() -> None: sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), ) + # Columns a later migration drops (brand_kit) are not in a table 0001 built from today's models: seed what exists. + have = {c["name"] for c in sa.inspect(bind).get_columns("plans")} + seed = [{k: v for k, v in p.items() if k in have} for p in SEED] types = {"features": sa.JSON(), "voice_cloning": sa.Boolean(), "brand_kit": sa.Boolean(), "created_at": sa.DateTime(timezone=True), "updated_at": sa.DateTime(timezone=True)} - plans = sa.table("plans", *(sa.column(k, types.get(k)) for k in [*SEED[0], "created_at", "updated_at"])) + plans = sa.table("plans", *(sa.column(k, types.get(k)) for k in [*seed[0], "created_at", "updated_at"])) existing = {row[0] for row in bind.execute(sa.text("SELECT id FROM plans"))} now = datetime.now(UTC) - missing = [{**p, "created_at": now, "updated_at": now} for p in SEED if p["id"] not in existing] + missing = [{**p, "created_at": now, "updated_at": now} for p in seed if p["id"] not in existing] if missing: op.bulk_insert(plans, missing) diff --git a/backend/alembic/versions/0021_artifact_feedback.py b/backend/alembic/versions/0021_artifact_feedback.py new file mode 100644 index 0000000000000000000000000000000000000000..5f23225391e6b7c89417b461eeeb6357e804629e --- /dev/null +++ b/backend/alembic/versions/0021_artifact_feedback.py @@ -0,0 +1,40 @@ +"""artifact_feedback: thumbs up/down on generated reports, quizzes, flashcards and infographics + +Revision ID: 0021_artifact_feedback +Revises: 0020_calendar_kit +Create Date: 2026-10-08 +""" +import sqlalchemy as sa +from alembic import op + +revision = "0021_artifact_feedback" +down_revision = "0020_calendar_kit" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 0001 builds from the live models, so a fresh database may already have the table. + if "artifact_feedback" in sa.inspect(op.get_bind()).get_table_names(): + return + op.create_table( + "artifact_feedback", + sa.Column("id", sa.Uuid(), primary_key=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("workspace_id", sa.Uuid(), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False), + sa.Column("artifact_id", sa.Uuid(), sa.ForeignKey("artifacts.id", ondelete="SET NULL")), + sa.Column("user_id", sa.Uuid(), sa.ForeignKey("users.id", ondelete="SET NULL")), + sa.Column("artifact_type", sa.String(30), nullable=False), + sa.Column("rating", sa.Integer(), nullable=False), + sa.Column("reasons", sa.JSON(), nullable=False), + sa.Column("comment", sa.Text()), + sa.Column("snapshot", sa.JSON(), nullable=False), + sa.UniqueConstraint("artifact_id", "user_id"), + ) + op.create_index("ix_artifact_feedback_workspace_id", "artifact_feedback", ["workspace_id"]) + op.create_index("ix_artifact_feedback_artifact_id", "artifact_feedback", ["artifact_id"]) + + +def downgrade() -> None: + op.drop_table("artifact_feedback") diff --git a/backend/alembic/versions/0022_report_template_cache.py b/backend/alembic/versions/0022_report_template_cache.py new file mode 100644 index 0000000000000000000000000000000000000000..8d8426af79e320a25252d622b9d86622ab256de0 --- /dev/null +++ b/backend/alembic/versions/0022_report_template_cache.py @@ -0,0 +1,34 @@ +"""report_template_cache: suggested report templates kept, so the Create report dialog is instant + +Revision ID: 0022_report_template_cache +Revises: 0021_artifact_feedback +Create Date: 2026-10-08 +""" +import sqlalchemy as sa +from alembic import op + +revision = "0022_report_template_cache" +down_revision = "0021_artifact_feedback" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 0001 builds from the live models, so a fresh database may already have the table. + if "report_template_cache" in sa.inspect(op.get_bind()).get_table_names(): + return + op.create_table( + "report_template_cache", + sa.Column("id", sa.Uuid(), primary_key=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("workspace_id", sa.Uuid(), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False), + sa.Column("key", sa.String(64), nullable=False), + sa.Column("templates", sa.JSON(), nullable=False), + sa.UniqueConstraint("workspace_id", "key"), + ) + op.create_index("ix_report_template_cache_workspace_id", "report_template_cache", ["workspace_id"]) + + +def downgrade() -> None: + op.drop_table("report_template_cache") diff --git a/backend/alembic/versions/0023_drop_brand_kit.py b/backend/alembic/versions/0023_drop_brand_kit.py new file mode 100644 index 0000000000000000000000000000000000000000..68bcaa4ec85fadc0dbf46392d83ea12eaa1acfd2 --- /dev/null +++ b/backend/alembic/versions/0023_drop_brand_kit.py @@ -0,0 +1,31 @@ +"""drop_brand_kit: the brand kit (workspace colors and logo, and the plan flag for it) is gone + +Revision ID: 0023_drop_brand_kit +Revises: 0022_report_template_cache +Create Date: 2026-10-08 +""" +import sqlalchemy as sa +from alembic import op + +revision = "0023_drop_brand_kit" +down_revision = "0022_report_template_cache" +branch_labels = None +depends_on = None + + +def _has(table: str, column: str) -> bool: + return column in {c["name"] for c in sa.inspect(op.get_bind()).get_columns(table)} + + +def upgrade() -> None: + for table, column in (("plans", "brand_kit"), ("workspaces", "brand_json")): + if _has(table, column): + with op.batch_alter_table(table) as batch: + batch.drop_column(column) + + +def downgrade() -> None: + if not _has("plans", "brand_kit"): + op.add_column("plans", sa.Column("brand_kit", sa.Boolean(), nullable=False, server_default=sa.false())) + if not _has("workspaces", "brand_json"): + op.add_column("workspaces", sa.Column("brand_json", sa.JSON(), nullable=True)) diff --git a/backend/alembic/versions/0024_plan_limits.py b/backend/alembic/versions/0024_plan_limits.py new file mode 100644 index 0000000000000000000000000000000000000000..ec30136931b82c9d4e1989a44be19457131b95e5 --- /dev/null +++ b/backend/alembic/versions/0024_plan_limits.py @@ -0,0 +1,49 @@ +"""plan_limits: posts indexed across every source, Free allowances that are totals, new Writer and Studio numbers + +Revision ID: 0024_plan_limits +Revises: 0023_drop_brand_kit +Create Date: 2026-10-08 +""" +import sqlalchemy as sa +from alembic import op + +revision = "0024_plan_limits" +down_revision = "0023_drop_brand_kit" +branch_labels = None +depends_on = None + +PLANS = { + "free": dict(sources=-1, indexed_posts=5, audio_minutes=-1, audio_overviews=1, videos=2, launch_kits=2, reports=2, + infographics=2, videos_monthly=False, features=[ + "Up to 5 posts indexed, from any source", "Grounded research chat with citations", + "1 audio overview, any length", "2 videos", "2 Launch Kits", "2 infographics", "2 reports a month"]), + "writer": dict(sources=-1, indexed_posts=100, audio_minutes=60, audio_overviews=-1, videos=10, launch_kits=20, + reports=30, infographics=30, features=[ + "Up to 100 posts indexed, from any source", "60 min of audio overviews a month", "10 videos a month", + "20 Launch Kits a month", "30 reports a month", "30 infographics a month", + "Voice cloning with consent"]), + "studio": dict(sources=-1, indexed_posts=250, audio_minutes=240, audio_overviews=-1, videos=20, launch_kits=50, + reports=-1, infographics=-1, features=[ + "Up to 250 posts indexed, from any source", "240 min of audio overviews a month", + "20 videos a month", "50 Launch Kits a month", "Unlimited reports", "Unlimited infographics", + "Launchpad calendar and resurfacing", "Priority rendering"]), +} + + +def upgrade() -> None: + columns = {c["name"] for c in sa.inspect(op.get_bind()).get_columns("plans")} + if "audio_overviews" not in columns: + op.add_column("plans", sa.Column("audio_overviews", sa.Integer(), nullable=False, server_default="-1")) + plans = sa.table("plans", sa.column("id", sa.String), sa.column("sources", sa.Integer), + sa.column("indexed_posts", sa.Integer), sa.column("audio_minutes", sa.Integer), + sa.column("audio_overviews", sa.Integer), sa.column("videos", sa.Integer), + sa.column("launch_kits", sa.Integer), sa.column("reports", sa.Integer), + sa.column("infographics", sa.Integer), sa.column("videos_monthly", sa.Boolean), + sa.column("features", sa.JSON)) + for plan_id, values in PLANS.items(): + op.execute(plans.update().where(plans.c.id == plan_id).values(**values)) + + +def downgrade() -> None: + with op.batch_alter_table("plans") as batch: + batch.drop_column("audio_overviews") diff --git a/backend/alembic/versions/0025_drop_video_premium.py b/backend/alembic/versions/0025_drop_video_premium.py new file mode 100644 index 0000000000000000000000000000000000000000..d4906d857443d182bb53ce1976bb53ff24f23c82 --- /dev/null +++ b/backend/alembic/versions/0025_drop_video_premium.py @@ -0,0 +1,24 @@ +"""drop_video_premium: the premium video options are gone, so is the plan flag for them + +Revision ID: 0025_drop_video_premium +Revises: 0024_plan_limits +Create Date: 2026-10-08 +""" +import sqlalchemy as sa +from alembic import op + +revision = "0025_drop_video_premium" +down_revision = "0024_plan_limits" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + if "video_premium" in {c["name"] for c in sa.inspect(op.get_bind()).get_columns("plans")}: + with op.batch_alter_table("plans") as batch: + batch.drop_column("video_premium") + + +def downgrade() -> None: + if "video_premium" not in {c["name"] for c in sa.inspect(op.get_bind()).get_columns("plans")}: + op.add_column("plans", sa.Column("video_premium", sa.Boolean(), nullable=False, server_default=sa.false())) diff --git a/backend/alembic/versions/0026_plan_copy.py b/backend/alembic/versions/0026_plan_copy.py new file mode 100644 index 0000000000000000000000000000000000000000..44aa55493ae471ed91ff61444b70c408feafd7fa --- /dev/null +++ b/backend/alembic/versions/0026_plan_copy.py @@ -0,0 +1,40 @@ +"""plan_copy: Free gets 1 video; Writer and Studio audio copy is relative to Free + +Revision ID: 0026_plan_copy +Revises: 0025_drop_video_premium +Create Date: 2026-10-08 +""" +import sqlalchemy as sa +from alembic import op + +revision = "0026_plan_copy" +down_revision = "0025_drop_video_premium" +branch_labels = None +depends_on = None + +SWAPS = { + "free": {"2 videos": "1 video"}, + "writer": {"60 min of audio overviews a month": "10x more audio overviews than Free (60 min a month)"}, + "studio": {"240 min of audio overviews a month": "40x more audio overviews than Free (240 min a month)"}, +} + + +def _swap(rows: dict) -> None: + plans = sa.table("plans", sa.column("id", sa.String), sa.column("videos", sa.Integer), sa.column("features", sa.JSON)) + bind = op.get_bind() + for plan_id, swaps in rows.items(): + features = bind.execute(sa.select(plans.c.features).where(plans.c.id == plan_id)).scalar() + if features is None: + continue + values = {"features": [swaps.get(f, f) for f in features]} + if plan_id == "free": + values["videos"] = 1 if "1 video" in swaps.values() else 2 + op.execute(plans.update().where(plans.c.id == plan_id).values(**values)) + + +def upgrade() -> None: + _swap(SWAPS) + + +def downgrade() -> None: + _swap({k: {v: o for o, v in d.items()} for k, d in SWAPS.items()}) diff --git a/backend/app/config.py b/backend/app/config.py index 7a6efdadb21e4511840402e354a0450b8e79ace7..51e74474a6c5c9e8d6ef18d22ea4abb7e82b64d4 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -64,6 +64,25 @@ class Settings(BaseSettings): r2_presign_ttl_seconds: int = 3600 max_upload_bytes: int = 300 * 1024 * 1024 + # Posts an infographic reads, and a report that is not built from themes (REPORT_PLANNER below). A quiz or flashcard set reads up to that many straight from the posts; with more (no limit + # on how many are picked) it reads a small sample of the ideas already extracted from them, so its size does not grow. + artifact_max_posts: int = 20 + # Characters of post text a report, quiz or flashcard set may read in all (split evenly between the posts, 1,500 at least each). + artifact_passage_budget: int = 120_000 + # The same for a quiz: it needs less text than a report to ask good questions. + artifact_quiz_passage_budget: int = 60_000 + # Ideas (already extracted from each post) in the evidence of a flashcard set, or of a quiz above ARTIFACT_MAX_POSTS posts. + artifact_idea_sample: int = 30 + + # Reports. "themes": the posts' stored ideas are grouped into weighted themes, a blueprint plans the report, and each section is + # written from its own evidence (pipeline/report_themes.py). "legacy": the posts' text goes straight to the writer, at most + # ARTIFACT_MAX_POSTS of them. Chats, small selections and posts without stored ideas always use the legacy way. + report_planner: str = "themes" + # Posts a themed report reads: 0 = every post picked (the plan's indexed_posts still applies). + report_max_posts: int = 0 + # Posts whose ideas are extracted while the report is made (in parallel); the rest are represented by their opening. + report_inline_ideas: int = 25 + # LLM (LiteLLM model strings; Z.ai GLM by default) llm_model: str = "openai/glm-5.3" llm_fast_model: str = "openai/glm-5.3-flash" @@ -86,6 +105,9 @@ class Settings(BaseSettings): llm_temperature: float = 1.0 # Z.ai recommends 1.0 for GLM-5.x # Reasoning output counts against max_tokens, so leave headroom for the structured answer. llm_max_tokens: int = 10000 + # The output cap of the fast model: quizzes, flashcards, infographics, idea extraction, template suggestions. A cap, not a target: + # only the tokens written are charged, and the thinking counts too, so keep room for it. Measured answers are under 3,000. + llm_fast_max_tokens: int = 6000 # GLM-5.x always reasons (it cannot be disabled); this sets how hard. low | high | max llm_reasoning_effort: str = "low" diff --git a/backend/app/db.py b/backend/app/db.py index f77c0fb203161cd6251ace33549d943751bbda8e..8f54c1f0f1bc1e238a50301d7aee695c2c5f5723 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -11,13 +11,18 @@ class Base(DeclarativeBase): _sqlite = settings.database_url.startswith("sqlite") -# Postgres: a connection that dies silently (laptop sleep, network change, Neon dropping it) otherwise leaves a query -# waiting on the socket forever, and the worker loop with it. Keepalives notice a dead peer within about a minute -# and the query fails instead; connect_timeout does the same for a connect that never completes. +# A hosted Postgres (Neon) closes idle connections and drops the odd one on a network blip, and a connection that dies silently +# (laptop sleep, network change) otherwise leaves a query waiting on the socket forever, and the worker loop with it. TCP +# keepalives notice a dead peer within about a minute, so the query fails instead and a dead socket is noticed while it sits in +# the pool; connect_timeout does the same for a connect that never completes. A pooled connection is also replaced before it +# is handed out (pool_recycle, below Neon's idle timeout), since pool_pre_ping alone can fail on a dead SSL socket with a +# ProgrammingError ("can't change 'autocommit' now") that SQLAlchemy does not treat as a lost connection, and the request +# ends in a 500. _pg_connect_args = {"connect_timeout": 10, "keepalives": 1, "keepalives_idle": 30, "keepalives_interval": 10, "keepalives_count": 3} _connect_args = {"check_same_thread": False, "timeout": 30} if _sqlite else _pg_connect_args -engine = create_engine(settings.database_url, pool_pre_ping=True, pool_recycle=300, connect_args=_connect_args) +engine = create_engine(settings.database_url, pool_pre_ping=True, connect_args=_connect_args, + **({} if _sqlite else {"pool_recycle": 240, "pool_use_lifo": True})) if _sqlite: # Local dev: the API and the worker share one file. WAL lets reads run during a write, the busy diff --git a/backend/app/infographics/design.py b/backend/app/infographics/design.py new file mode 100644 index 0000000000000000000000000000000000000000..794a007702ca6f519250b5f9a4ad7aff7c9fd609 --- /dev/null +++ b/backend/app/infographics/design.py @@ -0,0 +1,213 @@ +"""The AI-composed infographic: a page frame of ours (head, takeaway, background, mark) around a body the model writes in +HTML from a small vocabulary of components (flow charts, timelines, hierarchies, comparisons, cards, numbers). The +model's markup is never trusted: it is parsed and rebuilt from an allowlist of tags and class names, with no +attributes but `class`, so it can carry no script, style, link, image or address.""" + +import hashlib +import random +import re +from dataclasses import dataclass +from pathlib import Path + +from bs4 import BeautifulSoup, Comment, NavigableString, Tag +from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape +from markupsafe import Markup + +from app.infographics.render import LOGO_SVG, SIZES + +_DIR = Path(__file__).parent / "templates" +CSS = (_DIR / "design.css").read_text(encoding="utf-8") + + +@dataclass(frozen=True) +class Look: + id: str + name: str + blurb: str + vars: dict # CSS variables of the palette + + +LOOKS: dict[str, Look] = {l.id: l for l in ( + Look("midnight", "Midnight", "Black and blue, white type", { + "bg": "#03050a", "bg2": "#0b1a3a", "ink": "#ffffff", "mute": "#9fb0cc", "acc": "#2f7bff", "acc2": "#8db8ff", + "line": "rgba(130,170,255,.26)", "card": "rgba(255,255,255,.04)", "hl": "rgba(47,123,255,.18)", "paper": "#ffffff", + "paperink": "#03050a", "star": "#ffffff", "markbg": "rgba(3,5,10,.75)", "markline": "rgba(141,184,255,.4)", "markink": "#ffffff"}), + Look("paper", "Orbit", "White, black type, blue orbits and stars", { + "bg": "#ffffff", "bg2": "#eaf1ff", "ink": "#05070d", "mute": "#4b5668", "acc": "#1f5fe6", "acc2": "#1f5fe6", + "line": "rgba(5,7,13,.18)", "card": "#ffffff", "hl": "rgba(31,95,230,.09)", "paper": "#05070d", + "paperink": "#ffffff", "star": "#1f5fe6", "markbg": "#05070d", "markline": "#05070d", "markink": "#ffffff"}), +)} +DEFAULT_LOOK = "midnight" + +# What the person can ask the layout to lean towards; "auto" lets the AI choose from the content. +STYLES = { + "auto": "Choose the layout that suits the content", + "flowchart": "A flow chart: boxes joined by arrows, steps in order", + "timeline": "A timeline: what happened or happens, in order", + "compare": "A comparison: two sides set against each other", + "hierarchy": "A hierarchy: one idea and the parts it breaks into", + "overview": "An overview: cards of the key ideas and the numbers that matter", +} + +# Every class the model's markup may use (the components in design.css); anything else is dropped. +ALLOWED_CLASSES = { + "sec", "sec-t", "lead", "mono", "small", "hl", "node", "n-no", "n-t", "n-d", "flow", "col", "grid", "c2", "c3", "c4", + "card", "c-k", "c-t", "c-d", "timeline", "t-item", "t-when", "t-t", "t-d", "tree", "t-root", "t-kids", "compare", + "side", "s-h", "stats", "stat", "s-n", "s-l", "cols", "pills", "pill", "callout", +} +ALLOWED_TAGS = {"div", "section", "span", "p", "ul", "ol", "li", "strong", "b", "em", "i", "small", "br", "h3", "h4"} +DROP_WITH_CONTENT = {"script", "style", "iframe", "object", "embed", "link", "meta", "svg", "img", "video", "audio", "form", + "input", "button", "textarea", "select", "canvas", "math", "head", "title", "noscript", "template"} +MAX_BODY_CHARS = 14_000 +MAX_ELEMENTS = 320 + +_SPACE = re.compile(r"\s+") + + +def _clean_text(text: str) -> str: + return text.replace("—", ",").replace("–", "-") + + +def sanitize_body(raw: object) -> str | None: + """The model's body markup rebuilt from the allowlist, or None when nothing readable is left.""" + if not isinstance(raw, str) or not raw.strip(): + return None + soup = BeautifulSoup(raw[: MAX_BODY_CHARS * 2], "html.parser") + for c in soup.find_all(string=lambda t: isinstance(t, Comment)): + c.extract() + for tag in soup.find_all(DROP_WITH_CONTENT): + tag.decompose() + count = 0 + for tag in list(soup.find_all(True)): + count += 1 + if tag.name not in ALLOWED_TAGS or count > MAX_ELEMENTS: + tag.unwrap() if count <= MAX_ELEMENTS else tag.decompose() + continue + classes = [c for c in (tag.get("class") or []) if c in ALLOWED_CLASSES] + tag.attrs = {"class": classes} if classes else {} + for node in soup.find_all(string=True): + if isinstance(node, NavigableString): + node.replace_with(NavigableString(_clean_text(str(node)))) + html = _SPACE.sub(" ", str(soup)).strip() + text = soup.get_text(strip=True) + if len(html) > MAX_BODY_CHARS or len(text) < 40: + return None + return html + + +def _clip(text: object, limit: int) -> str: + s = _SPACE.sub(" ", re.sub(r"<[^>]*>", " ", str(text or ""))).strip() + s = _clean_text(s) + if len(s) <= limit: + return s + cut = s[: limit - 1].rsplit(" ", 1)[0].rstrip(" ,;:.-") or s[: limit - 1] + return cut + "…" + + +def build_content(raw: dict) -> dict | None: + """The stored content for a model answer (title, subtitle, eyebrow, key_figures, body_html, rule, notes), or None when it is + too thin to draw.""" + if hasattr(raw, "model_dump"): + raw = raw.model_dump() + title = _clip(raw.get("title"), 70) + body = sanitize_body(raw.get("body_html")) + if not title or not body: + return None + figures = [] + for f in raw.get("key_figures") or []: + f = f if isinstance(f, dict) else getattr(f, "__dict__", {}) + value, label = _clip(f.get("value"), 14), _clip(f.get("label"), 44) + if value and label: + figures.append({"value": value, "label": label}) + notes = [n for n in (_clip(n, 100) for n in raw.get("notes") or []) if n][:3] + return {"title": title, "subtitle": _clip(raw.get("subtitle"), 130), "eyebrow": _clip(raw.get("eyebrow"), 30) or "Key ideas", + "key_figures": figures[:3], "body_html": body, "rule": _clip(raw.get("rule"), 90), "notes": notes} + + +def _stars(seed: str, w: int, h: int, n: int = 90) -> str: + rnd = random.Random(int(hashlib.sha1(seed.encode()).hexdigest()[:8], 16)) + out = [] + for _ in range(n): + r = rnd.choice((1.2, 1.5, 1.5, 2, 2.6)) + out.append(f'') + return "".join(out) + + +def _orbits(w: int, h: int) -> str: + """The Orbit look's scenery: a ringed planet like the app's icon, orbit rings with small moons, and shooting stars.""" + big = round(w * 0.2) + cx, cy = round(w * 0.86), round(h * 0.085) + pr = round(w * 0.05) + + def ring(x, y, rx, ry, op, dash=""): + return (f'') + + def planet_ring(front: bool): + sweep = 0 if front else 1 + return (f'') + + out = [ + '' + '', + ring(cx, cy, big, round(big * .3), .35, ' stroke-dasharray="3 12"'), + ring(cx, cy, round(big * 1.55), round(big * .46), .22), + ring(round(w * .06), round(h * .96), round(w * .5), round(w * .14), .2, ' stroke-dasharray="3 12"'), + ring(round(w * .06), round(h * .96), round(w * .34), round(w * .09), .28), + planet_ring(False), + f'', + f'', + planet_ring(True), + f'', + f'', + ] + for fx, fy, length, op in ((.46, .2, 190, .8), (.7, .4, 150, .6), (.12, .55, 120, .5)): + x, y = round(w * fx), round(h * fy) + out.append(f'' + f'') + return "".join(out) + + +_ENV = Environment(loader=FileSystemLoader(_DIR), autoescape=select_autoescape(["j2", "html"], default=True), undefined=StrictUndefined, + trim_blocks=True, lstrip_blocks=True) + + +def render_design(look: str, content: dict, layout: str = "portrait", fit: float | None = None) -> str: + """The page for an AI-composed infographic. `fit` is the scale that makes its body fit (stored with the content).""" + look = look if look in LOOKS else DEFAULT_LOOK + layout = layout if layout in SIZES else "portrait" + w, h = SIZES[layout] + if fit is None: + fit = float(((content.get("fit") or {}) if isinstance(content.get("fit"), dict) else {}).get(layout) or 1) + fit = max(0.5, min(1.0, fit)) + vars_css = ";".join(f"--{k}:{v}" for k, v in LOOKS[look].vars.items()) + return _ENV.get_template("design.html.j2").render( + c=content, look=look, layout=layout, w=w, h=h, fit=fit, vars_css=vars_css, css=Markup(CSS), logo=Markup(LOGO_SVG), + body=Markup(content["body_html"]), + stars=Markup(_stars(content["title"] + look, w, h) + (_orbits(w, h) if look == "paper" else "")), + fonts="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500;700&display=swap") + + +SAMPLE_BODY = ( + '
The five layers
' + '
01PromptWording, examples and format of one request.
' + '
02ContextDocs, history, tools and memory for each call.
' + '
03HarnessTools, checks and guardrails around the model.
' + '
04LoopReason, act and observe until done.
' + '
Every request goes through
' + + "".join(f'
Step {i + 1}{t}{d}
' + for i, (t, d) in enumerate([("Auth", "Identity, limits and input guards."), ("Cache", "Answers to repeat questions."), + ("Safety", "Scrub PII, catch injection."), ("Memory", "Session history, summarised."), + ("Retrieve", "Fetch docs, split queries."), ("Answer", "Sub-queries in parallel."), + ("Validate", "Check quality first."), ("Save", "Store and log one trace.")])) + + '
' + '
Why it matters
Each layer builds on the one below it, so start at the bottom and add a layer only when the task needs it.
' +) +SAMPLE = {"title": "From Prompt to Production Agent", "subtitle": "Five engineering layers and the steps every request takes", + "eyebrow": "Agent engineering", "body_html": SAMPLE_BODY, + "key_figures": [{"value": "5", "label": "engineering layers"}, {"value": "8", "label": "steps on every request"}, + {"value": "30%", "label": "of traffic skips the pipeline"}], + "rule": "Most tasks only need the bottom layers; avoid complex graphs without cause.", + "notes": ["Each layer builds on the one below it.", "Cache and middleware run outside the graph.", + "Every request produces one end-to-end trace."]} diff --git a/backend/app/infographics/image.py b/backend/app/infographics/image.py index dc010d647ac3617f045be4662d8b51fb73acd0c6..22176b7f356141c809fc2471f40cadb063b6dd31 100644 --- a/backend/app/infographics/image.py +++ b/backend/app/infographics/image.py @@ -5,6 +5,7 @@ import functools import glob import hashlib import os +import re import shutil import subprocess import tempfile @@ -14,8 +15,9 @@ from threading import Lock from app.infographics.render import SIZES -TIMEOUT = 60 # seconds for one drawing -KEEP = 8 # drawings kept in memory +TIMEOUT = 90 # seconds for one drawing +SCALE = 2 # pixels per page pixel: a 1600 x 2260 page is drawn as 3200 x 4520, sharp on any screen and in print +KEEP = 6 # drawings kept in memory _cache: OrderedDict[str, bytes] = OrderedDict() _lock = Lock() @@ -51,9 +53,9 @@ def chrome() -> str | None: def render_png(html: str, layout: str = "portrait") -> bytes: - """The page as a PNG at its own size: tall (1600 x 2260) or wide (2260 x 1600).""" + """The page as a PNG, drawn at SCALE times its size: tall (1600 x 2260) or wide (2260 x 1600) before scaling.""" width, height = SIZES.get(layout, SIZES["portrait"]) - key = hashlib.sha256(f"{width}x{height}{html}".encode()).hexdigest() + key = hashlib.sha256(f"{width}x{height}@{SCALE}{html}".encode()).hexdigest() with _lock: if key in _cache: _cache.move_to_end(key) @@ -65,7 +67,7 @@ def render_png(html: str, layout: str = "portrait") -> bytes: with tempfile.TemporaryDirectory() as tmp: src, out = Path(tmp) / "page.html", Path(tmp) / "out.png" src.write_text(page, encoding="utf-8") - cmd = [binary, "--headless=new", "--disable-gpu", "--no-sandbox", "--hide-scrollbars", "--force-device-scale-factor=1", + cmd = [binary, "--headless=new", "--disable-gpu", "--no-sandbox", "--hide-scrollbars", f"--force-device-scale-factor={SCALE}", f"--user-data-dir={tmp}/profile", f"--window-size={width},{height}", "--virtual-time-budget=8000", f"--screenshot={out}", f"file://{src}"] try: @@ -80,3 +82,30 @@ def render_png(html: str, layout: str = "portrait") -> bytes: while len(_cache) > KEEP: _cache.popitem(last=False) return data + + +_FIT = re.compile(r"FIT=([0-9.]+)") +_MEASURE = ("") + + +def measure_fit(html: str, layout: str) -> float: + """The scale (1 or less) that makes an AI-composed page's body fit its space: the page drawn once, in Chrome, with our own + small script measuring it (the page's markup is sanitised, so no other script is in it). 1 when Chrome is missing.""" + width, height = SIZES.get(layout, SIZES["portrait"]) + binary = chrome() + if not binary: + return 1.0 + page = f'{html}{_MEASURE}' + with tempfile.TemporaryDirectory() as tmp: + src = Path(tmp) / "page.html" + src.write_text(page, encoding="utf-8") + cmd = [binary, "--headless=new", "--disable-gpu", "--no-sandbox", "--hide-scrollbars", f"--user-data-dir={tmp}/profile", + f"--window-size={width},{height}", "--virtual-time-budget=10000", "--dump-dom", f"file://{src}"] + try: + out = subprocess.run(cmd, check=True, capture_output=True, timeout=TIMEOUT, text=True).stdout + except (subprocess.SubprocessError, OSError): + return 1.0 + found = _FIT.search(out) + return max(0.55, min(1.0, float(found.group(1)))) if found else 1.0 diff --git a/backend/app/infographics/templates/design.css b/backend/app/infographics/templates/design.css new file mode 100644 index 0000000000000000000000000000000000000000..921a04e14a41c5d411761dc134c3b1d45b7da768 --- /dev/null +++ b/backend/app/infographics/templates/design.css @@ -0,0 +1,126 @@ +/* The infographic design system: black, blue and white, thin lines, flow charts. The page frame (head, takeaway, mark, the + background) is ours; the body is composed by the AI from the components below, and only these class names survive the + sanitiser (design.ALLOWED_CLASSES). */ +.ig2,.ig2 *{box-sizing:border-box;margin:0;padding:0} +.ig2{position:relative;width:var(--w);height:var(--h);overflow:hidden;color:var(--ink);font-family:'Inter',sans-serif; + background:radial-gradient(1300px 900px at 82% 6%,var(--bg2),var(--bg) 68%)} +.ig2 .ig-bg{position:absolute;inset:0;pointer-events:none} +.ig2 .ig-main{position:absolute;left:110px;right:110px;top:84px;bottom:132px;display:flex;flex-direction:column;gap:34px} +.ig2 .ig-left{display:contents} +.ig2 .ig-head{order:1;position:relative;flex:none;min-height:286px} +.ig2 .ig-body{order:2;flex:1;min-height:0;overflow:hidden;display:flex} +.ig2 .ig-take{order:3;flex:none} + +.ig2 .ig-eyebrow{display:flex;align-items:center;gap:16px;font:500 20px 'JetBrains Mono',monospace;letter-spacing:.24em;text-transform:uppercase;color:var(--acc2);margin-bottom:20px} +.ig2 .ig-eyebrow:before{content:"";width:44px;height:2px;background:var(--acc)} +.ig2 h1{font:600 74px/1.04 'Space Grotesk',sans-serif;letter-spacing:-.02em;max-width:850px;margin-bottom:20px} +.ig2 .ig-sub{font-size:30px;line-height:1.38;color:var(--mute);max-width:850px} +/* The key figures: the few numbers or short facts that matter most, beside the title (or under it when wide) */ +.ig2 .ig-figs{position:absolute;right:0;top:0;width:430px;display:flex;flex-direction:column;gap:16px} +.ig2 .fig{padding:6px 0 6px 24px;border-left:3px solid var(--acc)} +.ig2 .fig-v{display:block;font:600 56px/1.02 'Space Grotesk',sans-serif;letter-spacing:-.02em;overflow-wrap:anywhere} +.ig2 .fig-l{display:block;margin-top:6px;font:500 19px 'JetBrains Mono',monospace;letter-spacing:.1em;text-transform:uppercase;color:var(--mute)} + +.ig2 .ig-take{display:grid;grid-template-columns:1.05fr 1fr;gap:48px;align-items:center;padding:32px 46px;border-radius:16px;background:var(--paper);color:var(--paperink)} +.ig2 .ig-take .q{font:600 38px/1.16 'Space Grotesk',sans-serif;letter-spacing:-.01em} +.ig2 .ig-take ul{list-style:none;font-size:24px;line-height:1.38} +.ig2 .ig-take li{position:relative;padding-left:28px;margin:8px 0} +.ig2 .ig-take li:before{content:"";position:absolute;left:0;top:12px;width:11px;height:11px;border-radius:50%;background:var(--acc)} + +.ig2 .ig-mark{position:absolute;right:44px;bottom:34px;display:flex;align-items:center;gap:16px;padding:8px 26px 8px 10px;border-radius:999px; + background:var(--markbg);border:1px solid var(--markline);font:700 30px 'JetBrains Mono',monospace;letter-spacing:.04em;color:var(--markink)} +.ig2 .ig-mark svg{width:52px;height:52px;display:block} + +/* The body: fitted by scaling when the AI wrote more than a poster holds (the scale is measured once, when it is made) */ +.ig2 .ig-inner{display:flex;flex-direction:column;justify-content:space-evenly;gap:34px;min-height:calc(100% / var(--fit,1));flex:none;width:calc(1380px / var(--fit,1));transform:scale(var(--fit,1));transform-origin:0 0} +.ig2 .sec{display:flex;flex-direction:column;gap:20px} +.ig2 .sec-t{display:flex;align-items:center;gap:18px;font:500 20px 'JetBrains Mono',monospace;letter-spacing:.22em;text-transform:uppercase;color:var(--acc2)} +.ig2 .sec-t:after{content:"";flex:1;height:1.5px;background:var(--line)} +.ig2 .lead{font-size:28px;line-height:1.4;color:var(--ink)} +.ig2 .ig-inner p{font-size:24px;line-height:1.4;color:var(--mute)} +.ig2 .ig-inner strong,.ig2 .ig-inner b{color:var(--ink);font-weight:600} +.ig2 .ig-inner em,.ig2 .ig-inner i{color:var(--acc2);font-style:normal} +.ig2 .mono{font-family:'JetBrains Mono',monospace} +.ig2 .small{font-size:20px} + +/* A box in a flow: a small number, a title, a line of text */ +.ig2 .node{position:relative;padding:24px 26px;border:1.5px solid var(--line);border-radius:14px;background:linear-gradient(var(--card),var(--card)),linear-gradient(var(--bg),var(--bg))} +.ig2 .node.hl{background:linear-gradient(var(--hl),var(--hl)),linear-gradient(var(--bg),var(--bg));border-color:var(--acc)} +.ig2 .n-no{display:block;font:500 19px 'JetBrains Mono',monospace;letter-spacing:.14em;color:var(--acc2);margin-bottom:10px} +.ig2 .n-t{display:block;font:600 31px/1.15 'Space Grotesk',sans-serif;margin-bottom:8px} +.ig2 .n-d{display:block;font-size:23px;line-height:1.38;color:var(--mute)} + +/* Flow chart: boxes in a row (or a column) joined by arrows */ +.ig2 .flow{display:flex;gap:58px;align-items:stretch} +.ig2 .flow>.node{flex:1;min-width:0} +.ig2 .flow>.node+.node:before{content:"";position:absolute;left:-48px;top:50%;width:34px;height:2px;background:var(--acc)} +.ig2 .flow>.node+.node:after{content:"";position:absolute;left:-15px;top:50%;margin-top:-7px;border-style:solid;border-width:7px 0 7px 11px;border-color:transparent transparent transparent var(--acc)} +.ig2 .flow.col{flex-direction:column;gap:50px} +.ig2 .flow.col>.node+.node:before{left:50%;top:-40px;width:2px;height:28px;margin-left:-1px} +.ig2 .flow.col>.node+.node:after{left:50%;top:-13px;margin:0 0 0 -7px;border-width:11px 7px 0 7px;border-color:var(--acc) transparent transparent transparent} + +/* Cards in a grid */ +.ig2 .grid{display:grid;gap:22px} +.ig2 .c2{grid-template-columns:repeat(2,minmax(0,1fr))} +.ig2 .c3{grid-template-columns:repeat(3,minmax(0,1fr))} +.ig2 .c4{grid-template-columns:repeat(4,minmax(0,1fr))} +.ig2 .card{padding:24px 26px 26px;border:1.5px solid var(--line);border-top:3px solid var(--acc);border-radius:14px;background:linear-gradient(var(--card),var(--card)),linear-gradient(var(--bg),var(--bg))} +.ig2 .card.hl{background:linear-gradient(var(--hl),var(--hl)),linear-gradient(var(--bg),var(--bg));border-color:var(--acc)} +.ig2 .c-k{display:block;font:500 19px 'JetBrains Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:var(--acc2);margin-bottom:10px} +.ig2 .c-t{display:block;font:600 29px/1.15 'Space Grotesk',sans-serif;margin-bottom:8px} +.ig2 .c-d{display:block;font-size:22px;line-height:1.38;color:var(--mute)} + +/* A timeline: a line with a dot for each moment */ +.ig2 .timeline{position:relative;display:flex;flex-direction:column;gap:28px;padding-left:46px} +.ig2 .timeline:before{content:"";position:absolute;left:10px;top:8px;bottom:8px;width:2px;background:var(--line)} +.ig2 .t-item{position:relative} +.ig2 .t-item:before{content:"";position:absolute;left:-46px;top:6px;width:16px;height:16px;border-radius:50%;background:var(--bg);border:3px solid var(--acc)} +.ig2 .t-when{display:block;font:500 19px 'JetBrains Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:var(--acc2);margin-bottom:6px} +.ig2 .t-t{display:block;font:600 30px/1.15 'Space Grotesk',sans-serif;margin-bottom:6px} +.ig2 .t-d{display:block;font-size:23px;line-height:1.38;color:var(--mute)} + +/* A hierarchy: one box over the boxes it leads to, joined by lines */ +.ig2 .tree{display:flex;flex-direction:column;align-items:center} +.ig2 .t-root{width:560px;max-width:100%;text-align:center;position:relative} +.ig2 .t-root:after{content:"";position:absolute;left:50%;top:100%;width:2px;height:28px;margin-left:-1px;background:var(--acc)} +.ig2 .t-kids{display:flex;gap:24px;width:100%;margin-top:28px} +.ig2 .t-kids>*{flex:1;min-width:0;position:relative;margin-top:28px} +.ig2 .t-kids>*:before{content:"";position:absolute;left:50%;top:-28px;width:2px;height:28px;margin-left:-1px;background:var(--acc)} +.ig2 .t-kids>*:after{content:"";position:absolute;top:-28px;left:-12px;right:-12px;height:2px;background:var(--acc)} +.ig2 .t-kids>*:first-child:after{left:50%} +.ig2 .t-kids>*:last-child:after{right:50%} +.ig2 .t-kids>*:only-child:after{display:none} + +/* Two sides compared */ +.ig2 .compare{display:grid;grid-template-columns:1fr 1fr;border:1.5px solid var(--line);border-radius:14px;overflow:hidden} +.ig2 .side{padding:26px 32px 22px;background:linear-gradient(var(--card),var(--card)),linear-gradient(var(--bg),var(--bg))} +.ig2 .side+.side{border-left:1.5px solid var(--line)} +.ig2 .side.hl{background:linear-gradient(var(--hl),var(--hl)),linear-gradient(var(--bg),var(--bg))} +.ig2 .s-h{display:block;font:600 32px/1.15 'Space Grotesk',sans-serif;margin-bottom:14px} +.ig2 .side ul{list-style:none} +.ig2 .side li{position:relative;padding:13px 0 13px 30px;border-top:1px solid var(--line);font-size:23px;line-height:1.35;color:var(--mute)} +.ig2 .side li:before{content:"";position:absolute;left:2px;top:23px;width:10px;height:10px;border-radius:50%;background:var(--acc)} + +/* Big numbers */ +.ig2 .stats{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(0,1fr);gap:22px} +.ig2 .stat{padding:20px 26px 22px;border-left:3px solid var(--acc);background:linear-gradient(var(--card),var(--card)),linear-gradient(var(--bg),var(--bg));border-radius:0 12px 12px 0} +.ig2 .s-n{display:block;font:600 62px/1 'Space Grotesk',sans-serif;letter-spacing:-.02em} +.ig2 .s-l{display:block;font-size:22px;line-height:1.35;color:var(--mute);margin-top:8px} + +.ig2 .cols{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:40px} +.ig2 .pills{display:flex;flex-wrap:wrap;gap:12px} +.ig2 .pill{padding:10px 22px;border:1.5px solid var(--line);border-radius:999px;font-size:22px;background:linear-gradient(var(--card),var(--card)),linear-gradient(var(--bg),var(--bg))} +.ig2 .callout{padding:24px 30px;border-left:4px solid var(--acc);background:linear-gradient(var(--hl),var(--hl)),linear-gradient(var(--bg),var(--bg));border-radius:0 12px 12px 0;font-size:27px;line-height:1.38} + +/* Wide: the title and the takeaway in a column, the body beside them */ +.ig2.landscape .ig-main{top:70px;bottom:104px;flex-direction:row;gap:60px} +.ig2.landscape .ig-left{display:flex;flex-direction:column;justify-content:space-between;gap:28px;width:600px;flex:none;min-height:0} +.ig2.landscape .ig-head{min-height:0} +.ig2.landscape h1{font-size:58px;max-width:none} +.ig2.landscape .ig-sub{font-size:27px;max-width:none} +.ig2.landscape .ig-figs{position:static;width:100%;margin-top:34px;gap:14px} +.ig2.landscape .fig-v{font-size:50px} +.ig2.landscape .ig-body{order:0;width:1380px;flex:none} +.ig2.landscape .ig-take{grid-template-columns:1fr;gap:18px;padding:30px 34px} +.ig2.landscape .ig-take .q{font-size:35px} +.ig2.landscape .ig-take ul{font-size:22px} diff --git a/backend/app/infographics/templates/design.html.j2 b/backend/app/infographics/templates/design.html.j2 new file mode 100644 index 0000000000000000000000000000000000000000..c19dab2ac0cc9500208c0e84fb51c78a39608ebb --- /dev/null +++ b/backend/app/infographics/templates/design.html.j2 @@ -0,0 +1,22 @@ +{#- The frame of an AI-composed infographic. `body` is sanitised markup (design.sanitize_body); every other value is autoescaped. -#} + + +
+ {{ stars }} +
+
+
+
{{ c.eyebrow }}
+

{{ c.title }}

+

{{ c.subtitle }}

+ {% if c.key_figures %}
{% for f in c.key_figures %}
{{ f.value }}{{ f.label }}
{% endfor %}
{% endif %} +
+
+
{{ c.rule }}
+
    {% for n in c.notes %}
  • {{ n }}
  • {% endfor %}
+
+
+
{{ body }}
+
+
{{ logo }}notestack.ai
+
diff --git a/backend/app/infographics/themes.py b/backend/app/infographics/themes.py index daf8386ed1a3cc62449c69c93f080a04ab8109bb..52798238f73a0ba5174404c4ad2b4486f412057d 100644 --- a/backend/app/infographics/themes.py +++ b/backend/app/infographics/themes.py @@ -19,8 +19,11 @@ THEMES: dict[str, Theme] = {t.id: t for t in ( Theme("moonbase", "Moon Base", "A lunar surface, a dome and a flag", ("#2b2b30", "#d8d2c4", "#c8745f")), Theme("observatory", "Observatory", "A telescope under a chart of stars", ("#14282b", "#8fc2b4", "#d6b66a")), )} -DEFAULT_THEME = "launch" +DEFAULT_THEME = "midnight" # the look new infographics start with (the six above are the older fixed themes) def theme_id(value: object) -> str: - return value if isinstance(value, str) and value in THEMES else DEFAULT_THEME + """A look (design.LOOKS) or one of the older themes; the default when unknown.""" + from app.infographics.design import LOOKS + + return value if isinstance(value, str) and (value in LOOKS or value in THEMES) else DEFAULT_THEME diff --git a/backend/app/llm/provider.py b/backend/app/llm/provider.py index 38c4d0ef59aa49dea3929c627c8ddaea0b3e94ae..58187c5a6412f01d2cd0b2e75e38eb9cd9c3a41f 100644 --- a/backend/app/llm/provider.py +++ b/backend/app/llm/provider.py @@ -49,7 +49,7 @@ def main_lm(effort: str | None = None) -> dspy.LM: @lru_cache def fast_lm() -> dspy.LM: - return _build(settings.llm_fast_model, effort="low", temperature=None, max_tokens=8000) + return _build(settings.llm_fast_model, effort="low", temperature=None, max_tokens=settings.llm_fast_max_tokens) REPORT_MAX_TOKENS = 16000 # a few pages of prose plus GLM's low-effort reasoning @@ -62,6 +62,27 @@ def report_lm() -> dspy.LM: max_tokens=REPORT_MAX_TOKENS) +REPORT_PLAN_MAX_TOKENS = 6000 # the blueprint: a storyline and up to eight short section plans plus reasoning +REPORT_SECTION_MAX_TOKENS = 4000 # one section (two to five paragraphs) plus reasoning, a quarter of a whole report's cap + + +def _report_model() -> str: + return settings.llm_report_model or settings.llm_fast_model + + +@lru_cache +def report_plan_lm() -> dspy.LM: + """Plans a themed report (the blueprint). The report model, with a small cap: the answer is a plan, not prose.""" + return _build(_report_model(), effort="low", temperature=None, max_tokens=REPORT_PLAN_MAX_TOKENS) + + +@lru_cache +def report_section_lm(retry: bool = False) -> dspy.LM: + """Writes one section of a themed report. retry=True is the second try after a failed one: a different temperature, + so it is a fresh call and not the failed one again from the cache.""" + return _build(_report_model(), effort="low", temperature=0.7 if retry else None, max_tokens=REPORT_SECTION_MAX_TOKENS) + + @lru_cache def triage_lm() -> dspy.LM: """Cheap model that decides whether a chat message needs research at all.""" @@ -82,6 +103,16 @@ def focus_lm(retry: bool = False) -> dspy.LM: max_tokens=FOCUS_MAX_TOKENS) +SUGGEST_TEMPLATES_MAX_TOKENS = 2000 # four short template ideas plus GLM's low-effort reasoning + + +@lru_cache +def suggest_templates_lm() -> dspy.LM: + """The Create report dialog's suggested templates: four short template ideas out, so a tighter cap than the + general fast_lm keeps this quick without cutting into GLM's reasoning headroom.""" + return _build(settings.llm_fast_model, effort="low", temperature=None, max_tokens=SUGGEST_TEMPLATES_MAX_TOKENS) + + def configure_default() -> None: dspy.configure(lm=main_lm(), adapter=dspy.JSONAdapter()) diff --git a/backend/app/llm/run.py b/backend/app/llm/run.py index f28c36a52c578c7756486271a4e5ec072238b349..e768560ce2740045955b615cf5ec414bc86efbad 100644 --- a/backend/app/llm/run.py +++ b/backend/app/llm/run.py @@ -77,7 +77,8 @@ def predict_many( def _record(db: Session, workspace_id: uuid.UUID, job: Job | None, signature: type[dspy.Signature], lm: dspy.LM, usage, calls: list[tuple[dict, dict]]) -> None: if usage.total: - record_usage(db, workspace_id=workspace_id, kind="llm", provider=provider_name(), model=settings.llm_model, + model = getattr(lm, "model", None) or settings.llm_model # the model that answered, not always the main one + record_usage(db, workspace_id=workspace_id, kind="llm", provider=provider_name(model), model=model, quantity=usage.total, unit="tokens", cost_usd=usage.cost_usd, job=job) workspace = db.get(Workspace, workspace_id) for inputs, outputs in calls: diff --git a/backend/app/llm/signatures.py b/backend/app/llm/signatures.py index a5d55c1272158e0358acc808ef34e15a3807df44..9eb5c7c0d298a30b964d3dde9f50e0cfe4b32c40 100644 --- a/backend/app/llm/signatures.py +++ b/backend/app/llm/signatures.py @@ -451,6 +451,58 @@ class PlanInfographic(dspy.Signature): infographic: InfographicOut = dspy.OutputField() +class KeyFigure(BaseModel): + value: str = Field(description="A number or a very short fact, at most 12 characters, e.g. 30%, 8 steps, Layer 1") + label: str = Field(description="What it counts or means, at most 6 words") + + +class DesignOut(BaseModel): + title: str = Field(description="At most 8 words") + subtitle: str = Field(description="One line, at most 16 words") + eyebrow: str = Field(description="A 1 to 3 word topic label, e.g. Agent engineering") + key_figures: list[KeyFigure] = Field(description="The 2 or 3 most important numbers or short facts in the material, shown beside the title") + body_html: str = Field(description="The body of the page: 2 to 4 sections of HTML built only from the allowed components") + rule: str = Field(description="The one takeaway, at most 14 words") + notes: list[str] = Field(description="Exactly 3 supporting notes, at most 14 words each") + + +class DesignInfographic(dspy.Signature): + """Design a one page infographic from the material. You write the body of the page as HTML, composed ONLY from the + components below, choosing the ones that make the content clearest (a flow chart for steps, a timeline for events, + a hierarchy for a whole and its parts, a comparison for two options, cards for the key ideas, stats for numbers that + matter). Use only what the material says. When a layout style is asked for, build the page around it. When a focus is + given, make it about that, as far as the material covers it. The page is black, blue and white and printed on a + poster: plain text only, short lines, no markdown, no emoji, no quotes around names, no em dashes. Write in the + language of the material. + + HTML rules: tags allowed are div, section, span, p, ul, ol, li, strong, em, small, br. Use no style, id, href, src, + script, svg, img or any attribute except class. Only these class names exist: sec, sec-t, lead, hl, node, n-no, n-t, + n-d, flow, col, grid, c2, c3, c4, card, c-k, c-t, c-d, timeline, t-item, t-when, t-t, t-d, tree, t-root, t-kids, + compare, side, s-h, stats, stat, s-n, s-l, cols, pills, pill, callout. + + Every part sits in a section:
Short heading
COMPONENT
. + Components: + - Flow chart, left to right, at most 4 boxes, arrows are drawn between them for you: +
01TitleOne short line
...
+ Add class hl to the box that matters most (class="node hl"). For more than 4 steps use two flows, or a vertical one: class="flow col" (at most 5 boxes). + - Cards in a grid of 2, 3 or 4 columns (class grid c2, grid c3 or grid c4, at most 8 cards): +
KickerTitleOne short line
...
+ - Timeline, at most 5 moments:
WhenTitleOne short line
...
+ - Hierarchy, one root and 2 to 4 children:
WholeLine
PartLine
...
+ - Comparison of two sides, 2 to 4 short lines each:
Side A
  • Line
Side B
  • Line
+ - Numbers, 2 to 4:
30%What it counts
...
+ - A callout sentence:
One sentence.
+ The key_figures are separate from the body and are shown beside the title, so do not repeat them in the body. + Lengths: a box or card title at most 4 words, its line at most 14 words, a heading at most 4 words. Use two to four + sections, and mix components (never one grid of 8 cards alone). Keep the whole page short enough for one poster.""" + + title: str = dspy.InputField(desc="What the material is from") + material: list[str] = dspy.InputField(desc="Ideas from posts, or chat transcripts") + focus: str = dspy.InputField(desc="What the infographic should be about, or (none)") + layout_style: str = dspy.InputField(desc="The kind of layout asked for, or auto to choose") + design: DesignOut = dspy.OutputField() + + class WriteReport(dspy.Signature): """Write the report the reader describes, using only the material given: never add facts from outside it. Follow the reader's instructions on structure, style, tone and length. Write in Markdown that starts straight at the @@ -460,9 +512,8 @@ class WriteReport(dspy.Signature): unless the reader's instructions ask for a different structure. Every "## " heading is specific and descriptive of what is in that section (for example "The Context Window Bottleneck: Moving Beyond Basic Summarization", never "Section 2" or "Details"), and they read in a logical order. Use "### " inside sections, lists, and tables where - they help. When the material has numbered lines (FILE blocks), put a [n] marker after each - claim it supports and list each n in citations with the path and lines it came from; when it is a chat transcript, - write no markers and no citations. Do not write a quiz, flashcards, practice or review questions into the report + they help. Do not cite: no [n] markers, footnotes, references or lists of sources in the text; the app shows the reader + the sources separately. Do not write a quiz, flashcards, practice or review questions into the report unless the reader's instructions 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 @@ -476,7 +527,6 @@ class WriteReport(dspy.Signature): language: str = dspy.InputField(desc="The language to write in") report_title: str = dspy.OutputField(desc="A specific title, 3 to 10 words") markdown: str = dspy.OutputField(desc="The report body in Markdown, starting at the first ## heading") - citations: list[FileCitation] = dspy.OutputField() class PlannedEmbed(BaseModel): @@ -521,22 +571,166 @@ class WriteReportSection(dspy.Signature): 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 review questions in the section (those are offered to the reader separately). Do not repeat - the heading, and do not use "#" or "##" headings; "###" subheadings are fine. When the material has numbered lines (FILE - blocks), put a [n] marker after each claim it supports and list each n in citations with the path and lines; when it - is a chat transcript, write no markers and no citations. Never write about visuals, mind maps, flashcards, + the heading, and do not use "#" or "##" headings; "###" subheadings are fine. Do not cite: no [n] markers, footnotes, + references or lists of sources in the text; the app shows the reader the sources separately. 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.""" + # The parts every section shares come first, so a provider can reuse them across the section calls. title: str = dspy.InputField() report_title: str = dspy.InputField() + request: str = dspy.InputField(desc="What the reader wants the report to be") + material: list[str] = dspy.InputField(desc="Posts with path and numbered lines, or chat transcripts") + language: str = dspy.InputField() heading: str = dspy.InputField() brief: str = dspy.InputField(desc="What this section should say") + markdown: str = dspy.OutputField(desc="The section body in Markdown, without its heading") + + +# Themed reports: the posts' stored ideas are grouped into themes, a blueprint plans the whole report, and each section is +# written from its own evidence (pipeline/report_themes.py, report_blueprint.py, report_evidence.py). + + +class ReportTheme(BaseModel): + name: str = Field(description="2 to 5 words, a noun phrase a reader would recognise") + summary: str = Field(description="One or two plain sentences on what the posts say about it") + idea_ids: list[str] = Field(description="Ids of the ideas that belong to this theme, exactly as listed") + matches_request: bool = Field(False, description="True when the reader's request asks for this theme to be focused on, " + "included or given more room") + + +class ThemeRelation(BaseModel): + a: str = Field(description="A theme name, exactly as you wrote it in themes") + b: str = Field(description="Another theme name, exactly as you wrote it in themes") + relation: str = Field(description="How they connect, one short sentence") + + +class GroupThemes(dspy.Signature): + """Group ideas taken from a writer's posts into themes. Every idea belongs to exactly one theme, listed by its id as + given. A theme is something several ideas are about, named the way a reader would name it: not a post title, not a + single idea, not 'Other'. Choose 4 to 12 themes, fewer when the ideas really are about few things. Also note how themes + connect (one cause of another, one the context of another, two sides of a choice), only where the ideas show it. + Where the reader's request asks to focus on, include or give more room to something, set matches_request on the theme + that is about it. Never use em dashes.""" + + title: str = dspy.InputField(desc="The notebook or posts the ideas come from") + request: str = dspy.InputField(desc="What the reader wants the report to be") + ideas: list[str] = dspy.InputField(desc="One per line: id | label: note") + themes: list[ReportTheme] = dspy.OutputField(desc="4 to 12 themes") + relations: list[ThemeRelation] = dspy.OutputField(desc="How themes connect, at most 8, only where the ideas show it") + + +class BlueprintSection(BaseModel): + heading: str = Field(description="2 to 8 words, specific to what the section says, never generic") + role: Literal["intro", "body", "synthesis", "summary"] = Field( + "body", description="intro opens the report, summary closes it, synthesis ties two or more themes together, " + "body covers its themes") + brief: str = Field(description="What this section says, 1 to 2 sentences") + theme_ids: list[str] = Field(description="Ids of the themes this section is about, as listed. A body section's " + "themes belong to it alone") + depth: Literal["deep", "standard", "brief"] = Field("standard", description="deep for a theme with a large weight " + "or one the reader asked for, brief for a small one") + must_cover: list[str] = Field(description="2 to 4 specific points this section has to make") + embed: PlannedEmbed | None = Field(None, description="A visual to suggest right after this section, only where it " + "really helps") + + +class ThemeConnection(BaseModel): + theme_ids: list[str] = Field(description="Two or more theme ids that are connected") + relation: str = Field(description="How they connect, one short sentence") + section: str = Field(description="Heading of the section that should bridge them") + + +class PlanReportBlueprint(dspy.Signature): + """Plan a whole report before any of it is written. The themes come with their weight (their share of what the posts say), a + tier (major, medium, minor) and whether the reader asked for them. Give every major theme and every theme the reader asked + for its own section with depth deep, in an order that builds an argument; put the ones the reader asked for early. Put medium + themes in standard sections and merge the minor ones into one brief section. A theme is the subject of exactly one section. + Write a storyline of three to five sentences: what the report as a whole says, so every section can be written toward it. + Start with an intro section and end with a summary section unless the reader asks for something else; the summary must + restate the major themes. Where themes connect, say which section bridges them (connections), and add a synthesis section + only if the connection deserves its own. Each section's must_cover is specific to it and shares no point with another. + Choose at most max_sections sections. Never plan a section that is itself a quiz, flashcards, practice questions or a + mind map: when allowed_kinds is not empty those are visuals, suggested through a section's embed (at most four in all, each + kind once, only the kinds allowed): a mind_map to show how many linked themes fit together (at most one, near the start or + the end), flashcards to learn a set of terms or facts, a quiz to check understanding after a dense section, and an + infographic, a one page picture of the main ideas, which every report with that kind allowed should suggest once, on the + last section before the summary. Suggest the others only where they really help; none where plain prose reads better. When + allowed_kinds is empty, plan no embeds, and a quiz or practice section is allowed only if the reader's request asks for + one. Use only what the themes cover. Never use em dashes.""" + + title: str = dspy.InputField() + request: str = dspy.InputField(desc="What the reader wants the report to be") + themes: list[str] = dspy.InputField(desc="One per line: id | name | tier | weight | posts | asked for: summary") + relations: list[str] = dspy.InputField(desc="One per line: id <-> id: how they connect, or empty") + allowed_kinds: list[str] = dspy.InputField(desc="The visuals that may be suggested, or empty") + language: str = dspy.InputField() + max_sections: int = dspy.InputField(desc="The most sections to plan") + report_title: str = dspy.OutputField(desc="A specific title, 3 to 10 words") + storyline: str = dspy.OutputField(desc="Three to five sentences on what the whole report says") + sections: list[BlueprintSection] = dspy.OutputField(desc="2 to max_sections sections, in reading order") + connections: list[ThemeConnection] = dspy.OutputField(desc="At most 6") + + +class WriteThemedSection(dspy.Signature): + """Write one section of a report that has already been planned. You are given the storyline of the whole report and its + outline: write toward that storyline, and do not make points that another section owns (covered_elsewhere). Make the + points in must_cover. Use only the evidence given; never add facts from outside it. The evidence is grouped by post and + comes in two forms: text as the writer wrote it, and short summaries of other posts. Combine what + several posts say about the same thing: say where they agree, where they differ or where the writer changed their mind, + and never go through the posts one at a time. Where a connection is listed, bridge the themes in a sentence or two. + Follow the reader's instructions on style, tone and length; a section is two to five short paragraphs, or a list or table + where that reads better; a deep section is longer than a brief one. For role intro, say what the report covers and why it + matters; for role summary, restate the main points of the sections as written, in the digests given. Do not repeat the + heading, and do not use "#" or "##" headings; "###" subheadings are fine. Do not cite: no [n] markers, footnotes, + references or lists of sources in the text, and do not name the posts or their paths; the app shows the reader the + sources separately. Do not write quiz questions, flashcards or review questions, and never write about visuals, mind maps, + flashcards, quizzes, tables or timelines in the text: the app offers those separately. Write in the language requested. + Never use em dashes.""" + + # What every section shares comes first, so a provider can reuse it across the section calls. + storyline: str = dspy.InputField(desc="What the whole report says") + outline: list[str] = dspy.InputField(desc="Every section: number, heading, role, what it says") + connections: list[str] = dspy.InputField(desc="Themes the report connects, with the section that bridges them") request: str = dspy.InputField(desc="What the reader wants the report to be") - material: list[str] = dspy.InputField(desc="Posts with path and numbered lines, or chat transcripts") language: str = dspy.InputField() + heading: str = dspy.InputField(desc="The section to write") + role: str = dspy.InputField(desc="intro, body, synthesis or summary") + depth: str = dspy.InputField(desc="deep, standard or brief") + brief: str = dspy.InputField(desc="What this section should say") + must_cover: list[str] = dspy.InputField(desc="Points this section has to make") + covered_elsewhere: list[str] = dspy.InputField(desc="Points other sections own: do not make them here") + bridges: list[str] = dspy.InputField(desc="Connections this section should bridge, or empty") + evidence: list[str] = dspy.InputField(desc="Posts as written, short summaries of other posts, or digests of the written " + "sections") markdown: str = dspy.OutputField(desc="The section body in Markdown, without its heading") - citations: list[FileCitation] = dspy.OutputField() + + +class WriteThemedReport(dspy.Signature): + """Write a whole report that has already been planned, in one piece. You are given the storyline and the planned sections + in order, each with what it must cover and the evidence for it. Write in Markdown that starts straight at the first "## " + heading (the title goes in report_title, not in the text), with one "## " heading per planned section, in that order and + with those headings, and write toward the storyline. Make each section's must_cover points, and do not make a point + another section owns. Use only the evidence given; never add facts from outside it. The evidence is grouped by post and + comes in two forms: text as the writer wrote it, and short summaries of other posts. Combine what + several posts say about the same thing: say where they agree, where they differ or where the writer changed their mind, + and never go through the posts one at a time. Where a connection is listed, bridge the themes. A deep section is longer + than a brief one. Follow the reader's instructions on style, tone and length. Use "### " inside sections, lists, and tables + where they help. Do not cite: no [n] markers, footnotes, references or lists of sources in the text, and do not name the + posts or their paths; the app shows the reader the sources separately. Do not write a quiz, flashcards, practice or + review questions unless the reader's instructions ask for one, and never write about visuals, mind maps, flashcards or + timelines in the text. Write in the language requested. Never use em dashes.""" + + title: str = dspy.InputField(desc="The notebook or posts the evidence comes from") + storyline: str = dspy.InputField(desc="What the whole report says") + request: str = dspy.InputField(desc="What the reader wants the report to be") + language: str = dspy.InputField(desc="The language to write in") + connections: list[str] = dspy.InputField(desc="Themes the report connects, with the section that bridges them") + sections: list[str] = dspy.InputField(desc="The planned sections in order: heading, role, depth, what it says, must cover, " + "followed by its evidence") + report_title: str = dspy.OutputField(desc="A specific title, 3 to 10 words") + markdown: str = dspy.OutputField(desc="The report body in Markdown, starting at the first ## heading") class ReportTemplateIdea(BaseModel): @@ -552,7 +746,7 @@ class SuggestReportTemplates(dspy.Signature): a practical how-to, a comparison, a beginner primer and an in-depth analysis, when the material supports them). Never use em dashes.""" - items: list[str] = dspy.InputField(desc="The title and the start of each source") + items: list[str] = dspy.InputField(desc="Each source: its title, topics and key ideas, or the start of it") topic: str = dspy.InputField(desc="What the reader wants the report to be about, or (none)") templates: list[ReportTemplateIdea] = dspy.OutputField(desc="Exactly 4") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index b8f545e4188f1b670c5df0f258fbcd2ad2de684d..446e62a8440696c30acef75279ff5496c1f0a79a 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,5 +1,6 @@ from app.models.content import ( Artifact, + ArtifactFeedback, ArtifactShare, B2VCustomVoice, B2VStyle, @@ -17,6 +18,7 @@ from app.models.content import ( MessageFeedback, Notebook, NotebookDocument, + ReportTemplateCache, SocialAccount, Source, Topic, @@ -37,6 +39,7 @@ __all__ = [ "SupportConversation", "SupportMessage", "Artifact", + "ArtifactFeedback", "ArtifactShare", "AuthProvider", "B2VCustomVoice", @@ -58,6 +61,7 @@ __all__ = [ "Notebook", "PlanRecord", "NotebookDocument", + "ReportTemplateCache", "SocialAccount", "Source", "Subscription", diff --git a/backend/app/models/content.py b/backend/app/models/content.py index d68e1837abb67774d95566e49e95a8ad44a37d97..361a96e6fb0e317e220aff39f5a9b3d8623d42af 100644 --- a/backend/app/models/content.py +++ b/backend/app/models/content.py @@ -155,6 +155,37 @@ class MessageFeedback(IdMixin, TimestampMixin, Base): recall: Mapped[dict] = mapped_column(JSON, default=dict) # copy of Message.recall_json +class ArtifactFeedback(IdMixin, TimestampMixin, Base): + """A thumbs up or down on one generated report, quiz, flashcard set or infographic, with a snapshot of what it was + (title, prompt, sources, options) taken when it was rated, so the ratings stay useful after it is deleted (the link + then becomes null). One row per artifact and person: rating again replaces it.""" + + __tablename__ = "artifact_feedback" + __table_args__ = (UniqueConstraint("artifact_id", "user_id"),) + + workspace_id: Mapped[uuid.UUID] = _ws_fk() + artifact_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("artifacts.id", ondelete="SET NULL"), index=True) + user_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("users.id", ondelete="SET NULL")) + artifact_type: Mapped[str] = mapped_column(String(30)) + rating: Mapped[int] = mapped_column(Integer) # 1 up, -1 down + reasons: Mapped[list] = mapped_column(JSON, default=list) + comment: Mapped[str | None] = mapped_column(Text) + snapshot: Mapped[dict] = mapped_column(JSON, default=dict) + + +class ReportTemplateCache(IdMixin, TimestampMixin, Base): + """The suggested report templates already written for some sources, found again by a hash of exactly what the AI read (each + post's stored ideas and topics, and the topic). The same input gives the same templates at once, so the Create report dialog + is instant, and a changed post (its ideas change) gets new ones.""" + + __tablename__ = "report_template_cache" + __table_args__ = (UniqueConstraint("workspace_id", "key"),) + + workspace_id: Mapped[uuid.UUID] = _ws_fk() + key: Mapped[str] = mapped_column(String(64)) + templates: Mapped[list] = mapped_column(JSON, default=list) + + class WorkspaceMemory(IdMixin, TimestampMixin, Base): """The writer's standing notes, one row per workspace, shared by every notebook and chat. diff --git a/backend/app/models/user.py b/backend/app/models/user.py index a283403e530b972f22cc5c74ffbf6f5b72acbc81..c717ad90107e5b4b3e79815aa96f58140099bb26 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -13,7 +13,6 @@ from sqlalchemy import ( String, UniqueConstraint, Uuid, - false, true, ) from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -56,7 +55,6 @@ class Workspace(IdMixin, TimestampMixin, Base): training_opt_in: Mapped[bool] = mapped_column(Boolean, default=False) # Off: no report of this workspace can be shared by link, and existing links stop working. allow_public_links: Mapped[bool] = mapped_column(Boolean, default=True, server_default=true()) - brand_json: Mapped[dict] = mapped_column(JSON, default=dict) members: Mapped[list["WorkspaceMember"]] = relationship(back_populates="workspace") @@ -112,11 +110,10 @@ class PlanRecord(TimestampMixin, Base): launch_kits: Mapped[int] = mapped_column(Integer, default=0) # -1 = unlimited reports: Mapped[int] = mapped_column(Integer, default=0, server_default="0") # per month, -1 = unlimited infographics: Mapped[int] = mapped_column(Integer, default=0, server_default="0") # per month, -1 = unlimited + audio_overviews: Mapped[int] = mapped_column(Integer, default=-1, server_default="-1") # allowed (in total on Free); -1 = no cap voice_cloning: Mapped[bool] = mapped_column(Boolean, default=False) - brand_kit: Mapped[bool] = mapped_column(Boolean, default=False) features: Mapped[list] = mapped_column(JSON, default=list) # bullet points on the pricing page # Server defaults: 0006 seeds this table without these columns on a database 0001 built from the models. - video_premium: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false()) # premium options # False: `videos` is a lifetime total that never refills (Free). True: per month. videos_monthly: Mapped[bool] = mapped_column(Boolean, default=True, server_default=true()) video_limits: Mapped[dict | None] = mapped_column(JSON, default=dict) # see plans.VIDEO_LIMITS diff --git a/backend/app/pipeline/flashcards.py b/backend/app/pipeline/flashcards.py index 370a5563ece902637a4e787fa7732073b87cb46b..e9d4550b0f4f369f657e5c340ac9e8e63e5800ba 100644 --- a/backend/app/pipeline/flashcards.py +++ b/backend/app/pipeline/flashcards.py @@ -8,6 +8,7 @@ from app.llm import run from app.llm.provider import fast_lm from app.llm.signatures import GenerateFlashcards from app.models import Artifact, Job +from app.pipeline import idea_pool from app.pipeline.generate import NothingToDo from app.pipeline.material import Material, load_material from app.services.jobs import update_job @@ -16,20 +17,29 @@ SAME_LANGUAGE = "the same language as the sources" # no language picked: write CARD_COUNTS = {"fewer": 8, "standard": 16, "more": 24} +def _card(c: dict, refs) -> dict | None: + """One generated card as the deck plays it, or None when it has no front or no back.""" + front, back = (c.get("front") or "").strip(), (c.get("back") or "").strip() + if not front or not back: + return None + return {"front": front[:300], "back": back[:800], "sources": refs(c.get("sources"))} + + def make_cards(db: Session, workspace_id: uuid.UUID, material: Material, *, topic: str = "", count: int = 16, difficulty: str = "medium", language: str = SAME_LANGUAGE, job: Job | None = None) -> list[dict]: """Cards about the material: a front, a back and verified source lines. Repeats and empty cards are dropped.""" - out = run.predict(GenerateFlashcards, db=db, workspace_id=workspace_id, job=job, title=material.title, - material=material.text, topic=topic or "(none)", difficulty=difficulty, count=count, - language=language.strip() or SAME_LANGUAGE, lm=fast_lm()) + inputs = dict(title=material.title, topic=topic or "(none)", difficulty=difficulty, language=language.strip() or SAME_LANGUAGE) + # Posts with their ideas stored (the Mind Constellation's): one call over a random sample of those ideas, not the posts' text. + text = idea_pool.sample(db, workspace_id, material.docs, topic, chats=material.text) if material.mode == "ideas" else material.text + out = run.predict(GenerateFlashcards, db=db, workspace_id=workspace_id, job=job, material=text, count=count, + lm=fast_lm(), **inputs) cards, seen = [], set() - for c in out.get("cards") or []: - front, back = (c.get("front") or "").strip(), (c.get("back") or "").strip() - key = front.lower() - if not front or not back or key in seen: + for raw in out.get("cards") or []: + card = _card(raw, material.refs) + if not card or card["front"].lower() in seen: continue - seen.add(key) - cards.append({"front": front[:300], "back": back[:800], "sources": material.refs(c.get("sources"))}) + seen.add(card["front"].lower()) + cards.append(card) return cards[:count] @@ -38,7 +48,8 @@ def build_flashcards(db: Session, job: Job, artifact: Artifact) -> dict: count = CARD_COUNTS.get(params.get("count") or "standard", CARD_COUNTS["standard"]) topic = (params.get("topic") or "").strip() difficulty = params.get("difficulty") if params.get("difficulty") in ("easy", "medium", "hard") else "medium" - material = load_material(db, artifact.workspace_id, artifact.notebook_id, params, job=job, what="flashcard set") + material = load_material(db, artifact.workspace_id, artifact.notebook_id, params, job=job, what="flashcard set", + large_ok=True, ideas_always=True) update_job(db, job, progress=0.4, message="Writing the cards") cards = make_cards(db, artifact.workspace_id, material, topic=topic, count=count, difficulty=difficulty, language=params.get("language") or SAME_LANGUAGE, job=job) diff --git a/backend/app/pipeline/generate.py b/backend/app/pipeline/generate.py index 98450143004d50e638858ff17e66eab6c449b096..8460fc5041158e45fc112fb1c5e2f7b0d76d10cf 100644 --- a/backend/app/pipeline/generate.py +++ b/backend/app/pipeline/generate.py @@ -112,22 +112,39 @@ def ideas_fresh(d) -> bool: return bool(md.get("ideas")) and md.get("ideas_hash") == (d.content_hash or "") -def extract_ideas(db: Session, job: Job | None, workspace_id: uuid.UUID, doc_ids: list[str] | None = None) -> dict: +def extract_ideas(db: Session, job: Job | None, workspace_id: uuid.UUID, doc_ids: list[str] | None = None, *, + parallel: bool = False, limit: int | None = None) -> dict: """Read each post that has no current ideas once and keep what it says on the post (label, note, points and - the lines they came from). Maps are arranged from these, so they never re-read the posts.""" + the lines they came from). Maps are arranged from these, so they never re-read the posts. `parallel` reads the + posts together instead of one after the other (a report that needs many at once); `limit` reads at most that many.""" docs = workspace_docs(db, workspace_id, [uuid.UUID(str(i)) for i in doc_ids] if doc_ids is not None else None) todo = [d for d in docs if not ideas_fresh(d)] + if limit is not None: + todo = todo[:limit] corpus = Corpus(workspace_id) lm = fast_lm() done = 0 - for i, d in enumerate(todo): + answers: list[dict | None] | None = None + if parallel and todo: if job: - update_job(db, job, progress=0.05 + 0.85 * i / max(len(todo), 1), message=f"Reading {d.title[:70]}") - try: - out = run.predict(ExtractIdeas, db=db, workspace_id=workspace_id, job=job, lm=lm, title=d.title, - passage="\n\n".join(passages_for(corpus, [d], budget_chars=IDEAS_BUDGET))) - except Exception: - continue + update_job(db, job, progress=0.05, message=f"Reading {len(todo)} posts for their key ideas") + answers = run.predict_many( + ExtractIdeas, + [dict(title=d.title, passage="\n\n".join(passages_for(corpus, [d], budget_chars=IDEAS_BUDGET))) for d in todo], + db=db, workspace_id=workspace_id, lm=lm) + for i, d in enumerate(todo): + if answers is not None: + out = answers[i] + if out is None: + continue + else: + if job: + update_job(db, job, progress=0.05 + 0.85 * i / max(len(todo), 1), message=f"Reading {d.title[:70]}") + try: + out = run.predict(ExtractIdeas, db=db, workspace_id=workspace_id, job=job, lm=lm, title=d.title, + passage="\n\n".join(passages_for(corpus, [d], budget_chars=IDEAS_BUDGET))) + except Exception: + continue tools = tools_for(corpus, [d]) def keep(items, tools=tools, d=d) -> list[dict]: diff --git a/backend/app/pipeline/idea_pool.py b/backend/app/pipeline/idea_pool.py new file mode 100644 index 0000000000000000000000000000000000000000..6fec1e7da7ab8b09c2330cec2c5f5a81b6be14e8 --- /dev/null +++ b/backend/app/pipeline/idea_pool.py @@ -0,0 +1,85 @@ +"""Flashcards (always) and quizzes (above ARTIFACT_MAX_POSTS posts) from the ideas already extracted from each post, the same stored +ideas the Mind Constellation is drawn from. Instead of sending post text, a small random sample of those ideas goes to the model: each +as one line with the post and the lines it came from, so the model can cite them and the existing check can verify the lines. +Nothing here extracts anything and no post text is read: a post with no stored ideas yet is represented by a short opening, at most +a few of them. A sample of ARTIFACT_IDEA_SAMPLE ideas is about 1,500 tokens whatever the size of the selection.""" + +import math +import random +import re +import uuid + +from sqlalchemy.orm import Session + +from app.config import settings +from app.corpus import Corpus +from app.models import Document +from app.pipeline.generate import ideas_fresh +from app.pipeline.passages import passages_for + +IDEAS_PER_POST = 2 # so a few posts cannot fill the sample (more when the selection is small, to fill it) +OPENING_BUDGET = 800 # characters of a post's opening, for a post with no stored ideas +MAX_OPENINGS = 3 # posts with no stored ideas represented by their opening +CHAT_BUDGET = 12_000 # characters of chat transcripts kept (the end of each) when chats are picked as well + +FORMAT_NOTE = ("Each item below is a key idea from one post, written as: IDEA [path lines a-b] label: note. The path and lines say " + "where in the post the idea comes from. When a question or card comes from an idea, cite that path and those lines.") + +_STOP = {"the", "and", "for", "with", "that", "this", "from", "how", "what", "why", "are", "was", "about", "your", "you", + "into", "over", "does", "can", "not", "but", "all", "any", "its", "their", "them", "will", "have", "has"} + + +def _tokens(text: str) -> set[str]: + return {w for w in re.findall(r"[a-z0-9]{3,}", (text or "").lower()) if w not in _STOP} + + +def has_ideas(docs: list[Document]) -> int: + """How many of the posts have their ideas stored and current.""" + return sum(1 for d in docs if ideas_fresh(d)) + + +def _line(doc: Document, idea: dict) -> str: + """One stored idea as one line, tagged with where it came from (its first cited range, when it has one).""" + where = doc.path + for ref in idea.get("sources") or []: + try: + a, b = int(ref["line_start"]), int(ref["line_end"]) + except (KeyError, TypeError, ValueError): + continue + where = f"{doc.path} lines {a}-{max(a, b)}" + break + label, note = (idea.get("label") or "").strip(), (idea.get("note") or "").strip() + return f"IDEA [{where}] {label}: {note}" + + +def sample(db: Session, workspace_id: uuid.UUID, docs: list[Document], topic: str = "", chats: list[str] | None = None, + size: int | None = None, rng: random.Random | None = None) -> list[str]: + """The material for one call: the format note, a random sample of the posts' stored ideas, a few short openings for posts with + none, and the end of any chats. With a topic, ideas that match it come first. A fresh draw each time unless `rng` is given.""" + rng = rng or random.Random() + size = size or max(settings.artifact_idea_sample, 1) + wanted = _tokens(topic) + have = [d for d in docs if ideas_fresh(d)] + per_post = max(IDEAS_PER_POST, math.ceil(size / max(len(have), 1))) + matching: list[tuple[Document, dict]] = [] + rest: list[tuple[Document, dict]] = [] + for d in have: + mine = [i for i in (d.metadata_json or {}).get("ideas") or [] if (i.get("label") or "").strip()] + rng.shuffle(mine) + for idea in mine[:per_post]: + hit = wanted and wanted & _tokens(" ".join([idea.get("label", ""), idea.get("note", ""), d.title])) + (matching if hit else rest).append((d, idea)) + rng.shuffle(matching) + rng.shuffle(rest) + chosen = (matching + rest)[:size] + rng.shuffle(chosen) # not grouped by relevance or by post + out = [FORMAT_NOTE] + [_line(d, i) for d, i in chosen] + bare = [d for d in docs if not ideas_fresh(d)] + if bare: + corpus = Corpus(workspace_id) + for d in rng.sample(bare, min(MAX_OPENINGS, len(bare))): + out.extend(passages_for(corpus, [d], budget_chars=OPENING_BUDGET)) + if chats: + share = CHAT_BUDGET // len(chats) + out += [t[-share:] for t in chats] + return out diff --git a/backend/app/pipeline/infographic.py b/backend/app/pipeline/infographic.py index f1748ddc1bb27cd6782fb130ac1e36ede1cce7da..b82fe33697c0f8da6d138eeb3140510ef9875e35 100644 --- a/backend/app/pipeline/infographic.py +++ b/backend/app/pipeline/infographic.py @@ -9,15 +9,18 @@ from sqlalchemy import select from sqlalchemy.orm import Session from app.corpus import Corpus -from app.infographics.content import clamp +from concurrent.futures import ThreadPoolExecutor + +from app.infographics.design import LOOKS, STYLES, build_content, render_design +from app.infographics.image import measure_fit from app.infographics.render import build_html from app.infographics.themes import theme_id from app.llm import run from app.llm.provider import fast_lm -from app.llm.signatures import PlanInfographic +from app.llm.signatures import DesignInfographic from app.models import Artifact, Chat, Document, Job, Notebook from app.pipeline.generate import NothingToDo, extract_ideas, ideas_fresh -from app.pipeline.material import MAX_POSTS, chat_material +from app.pipeline.material import chat_material, max_posts from app.pipeline.passages import notebook_docs, passages_for, workspace_docs from app.services.jobs import update_job @@ -67,7 +70,7 @@ def write_content(db: Session, job: Job, artifact: Artifact, params: dict) -> tu docs = workspace_docs(db, ws, picked) elif not chat_ids and nb: docs = notebook_docs(db, nb.id) - docs = docs[:MAX_POSTS] + docs = docs[:max_posts()] chats, chat_blocks = _chat_text(db, ws, chat_ids) if chat_ids else ([], []) if not docs and not chat_blocks: raise NothingToDo("Pick at least one post or chat for the infographic.") @@ -76,12 +79,14 @@ def write_content(db: Session, job: Job, artifact: Artifact, params: dict) -> tu material = (_ideas_text(db, ws, docs, job) if docs else []) + chat_blocks prompt = (params.get("instructions") or "").strip() title = docs[0].title if docs else (chats[0].title or "Chat") - update_job(db, job, progress=0.35, message="Writing the infographic") + update_job(db, job, progress=0.35, message="Designing the infographic") + style = params.get("style") if params.get("style") in STYLES else "auto" content = None for _ in range(2): # one retry when the model gives too little to draw - out = run.predict(PlanInfographic, db=db, workspace_id=ws, job=job, title=title, material=material, - focus=prompt or "(none)", lm=fast_lm()) - content = clamp(out.get("infographic") or {}) + out = run.predict(DesignInfographic, db=db, workspace_id=ws, job=job, title=title, material=material, + focus=prompt or "(none)", layout_style=f"{style}: {STYLES[style]}" if style != "auto" else "auto", + lm=fast_lm()) + content = build_content(out.get("design") or {}) if content: break if not content: @@ -101,6 +106,8 @@ def build_infographic(db: Session, job: Job, artifact: Artifact) -> dict: params = job.params theme = theme_id(params.get("theme")) content, source, _title = write_content(db, job, artifact, params) + update_job(db, job, progress=0.8, message="Fitting it to the page") + content["fit"] = fit_scales(theme, content) artifact.content_json = {**(artifact.content_json or {}), "title": f"Infographic: {content['title']}"[:140], "theme": theme, "prompt": (params.get("instructions") or "").strip(), "source": source, "content": content} @@ -110,7 +117,22 @@ def build_infographic(db: Session, job: Job, artifact: Artifact) -> dict: return {"artifact_id": str(artifact.id)} +def fit_scales(theme: str, content: dict) -> dict: + """The scale at which the body fits the page, tall and wide (measured in Chrome once, kept with the content).""" + look = theme if theme in LOOKS else "midnight" + with ThreadPoolExecutor(max_workers=2) as pool: + wide, tall = (pool.submit(measure_fit, render_design(look, content, layout, fit=1.0), layout) + for layout in ("landscape", "portrait")) + return {"landscape": wide.result(), "portrait": tall.result()} + + def infographic_html(content: dict, layout: str = "portrait") -> str | None: - """The page for a finished infographic's stored text and theme, tall or wide (None when it has none yet).""" + """The page for a finished infographic's stored content and look, tall or wide (None when it has none yet). Older + infographics (fixed text in a premade theme) still draw in that theme.""" text = content.get("content") - return build_html(theme_id(content.get("theme")), text, layout) if text else None + if not text: + return None + theme = theme_id(content.get("theme")) + if "body_html" in text: + return render_design(theme if theme in LOOKS else "midnight", text, layout) + return build_html(theme, text, layout) if "items" in text else None diff --git a/backend/app/pipeline/ingest.py b/backend/app/pipeline/ingest.py index 35bd63fe11a0e81b78c13a3c40502b84018d3281..90fe2e6f0b705c8238dbd5a9cdd40f619c357c0b 100644 --- a/backend/app/pipeline/ingest.py +++ b/backend/app/pipeline/ingest.py @@ -630,29 +630,39 @@ def store_locked(db: Session, source: Source, entries: list[FeedEntry]) -> tuple def ingest_source(db: Session, source: Source, job: Job, max_posts: int) -> dict: source.sync_status = "syncing" - update_job(db, job, status="running", progress=0.02, message="Contacting your feed") fetch_limit = max(SCAN_CAP, max_posts) + # Live counts the Sources page shows while this runs: found on the site, fetched (read), indexed. + live = {"stage": "fetching", "found": 0, "fetched": 0, "fetch_total": 0, "indexed": 0, "index_total": 0} + + def report(progress: float | None = None, message: str | None = None, **counts) -> None: + live.update(counts) + update_job(db, job, progress=progress, message=message, result=dict(live)) + + update_job(db, job, status="running", progress=0.02, message="Contacting your feed", result=dict(live)) if source.feed_url.startswith(SITE_PREFIX): update_job(db, job, message="Mapping your site") entries = crawl_site( source.feed_url.removeprefix(SITE_PREFIX), fetch_limit, - on_found=lambda n: update_job(db, job, progress=0.05, message=f"Found {n} pages, reading them"), - on_post=lambda done, total: update_job(db, job, progress=0.05 + 0.05 * done / total, - message=f"Read {done} of {total} pages"), - read_limit=max_posts, + on_found=lambda n: report(0.05, f"Found {n} pages, reading them", found=n), + on_post=lambda done, total: report(0.05 + 0.05 * done / total, f"Read {done} of {total} pages", + fetched=done, fetch_total=total), + read_limit=max(max_posts, 1), ) title = None else: title, entries = fetch_feed(source.feed_url, fetch_limit) + report(found=len(entries), fetched=len(entries), fetch_total=len(entries)) source.title = source.title or title if source.platform == "substack" and len(entries) < fetch_limit: site = f"{urlparse(source.feed_url).scheme}://{urlparse(source.feed_url).netloc}" - update_job(db, job, progress=0.05, message="Reading your Substack archive") + report(0.05, "Reading your Substack archive") try: entries += fetch_substack_archive( site, {e.url for e in entries}, fetch_limit, - on_page=lambda n: update_job(db, job, message=f"Found {len(entries) + n} posts in the archive"), + on_page=lambda n: report(message=f"Found {len(entries) + n} posts in the archive", + found=len(entries) + n, fetched=len(entries) + n, + fetch_total=len(entries) + n), body_budget=max(0, max_posts - len(entries)), ) except (httpx.HTTPError, ValueError): @@ -662,9 +672,9 @@ def ingest_source(db: Session, source: Source, job: Job, max_posts: int) -> dict available, beyond = entries[:max_posts], entries[max_posts:] def progress(done: int, total: int, post_title: str) -> None: - update_job(db, job, progress=0.1 + 0.7 * done / total, message=f"{post_title[:80]} is in orbit") + report(0.1 + 0.7 * done / total, f"{post_title[:80]} is in orbit", stage="indexing", indexed=done) - update_job(db, job, progress=0.1, message=f"Indexing {len(available)} posts") + report(0.1, f"Indexing {len(available)} posts", stage="indexing", found=len(entries), index_total=len(available)) indexed, skipped, changed = store_entries(db, source, available, progress) locked = 0 diff --git a/backend/app/pipeline/material.py b/backend/app/pipeline/material.py index fa26ebaebe9bb87b2803b60ac91b0083879c4d98..9065cbee407f01fa8ada8d3f0247b0c1f2d6bbf0 100644 --- a/backend/app/pipeline/material.py +++ b/backend/app/pipeline/material.py @@ -9,16 +9,36 @@ from dataclasses import dataclass, field from sqlalchemy import select from sqlalchemy.orm import Session +from app.config import settings from app.corpus import Corpus -from app.models import Chat, Document, Job, Message, Notebook +from app.models import Chat, Document, Job, Message, Notebook, Workspace from app.pipeline.generate import NothingToDo from app.pipeline.passages import ScopedTools, notebook_docs, passages_for, tools_for, verify_refs, workspace_docs from app.services.jobs import update_job +from app.services.plans import effective_plan -MAX_POSTS = 20 # posts one artifact reads; the passage budget is shared between them CHAT_BUDGET = 60_000 # characters of chat transcripts +def max_posts() -> int: + """Posts a report or infographic reads, and the most a quiz or flashcard set reads straight from the posts (ARTIFACT_MAX_POSTS); + the passage budget is shared between them.""" + return max(settings.artifact_max_posts, 1) + + +def use_ideas(docs: list[Document], *, large_ok: bool, ideas_always: bool) -> bool: + """Read the posts through their stored ideas instead of their text? Only when most of them have ideas stored (the rest are + represented by a short opening). A selection above max_posts() (quiz and flashcards) always, when it can; a flashcard set + (`ideas_always`) whatever the size. Otherwise, or with too few ideas stored, the posts' text is read as it always was.""" + from app.pipeline.idea_pool import has_ideas + + if not docs or not (large_ok or ideas_always): + return False + if len(docs) > max_posts() or ideas_always: + return has_ideas(docs) * 2 >= len(docs) + return False + + @dataclass class Material: title: str # the notebook, the post, or the first chat's title @@ -28,6 +48,9 @@ class Material: # Turns the model's {path, line_start, line_end} items into verified refs with their quote. Chats give none. refs: Callable[[list | None], list[dict]] = lambda _items: [] tools: ScopedTools | None = None # reads back cited lines (posts only) + # "passages": `text` holds the posts' passages. "ideas": more posts than max_posts(), so `text` holds only chat transcripts and + # the posts are read through their stored ideas (pipeline/idea_pool.py). + mode: str = "passages" @property def is_chat(self) -> bool: @@ -52,7 +75,8 @@ def chat_material(db: Session, chats: list[Chat]) -> list[str]: def load_material(db: Session, workspace_id: uuid.UUID, notebook_id: uuid.UUID | None, params: dict, *, - job: Job | None = None, what: str = "quiz") -> Material: + job: Job | None = None, what: str = "quiz", large_ok: bool = False, + ideas_always: bool = False, passage_budget: int | None = None) -> Material: """The source a job's params name: posts (`document_ids`, a notebook's, the archive's or a single post), chats (`chat_ids`), or both together. With `chat_ids` and no posts it reads only the chats; with neither it reads the whole notebook. Raises NothingToDo when there is nothing to read.""" @@ -65,7 +89,11 @@ def load_material(db: Session, workspace_id: uuid.UUID, notebook_id: uuid.UUID | docs = workspace_docs(db, workspace_id, picked) elif not chat_ids and nb: docs = notebook_docs(db, nb.id) - docs = docs[:MAX_POSTS] + # Quiz and flashcards take as many posts as the plan indexes: their ideas are sampled. The rest read max_posts(). + docs = docs[:effective_plan(db, db.get(Workspace, workspace_id)).indexed_posts] if large_ok else docs[:max_posts()] + ideas_mode = use_ideas(docs, large_ok=large_ok, ideas_always=ideas_always) + if large_ok and not ideas_mode: + docs = docs[:max_posts()] # their ideas are not stored yet: read the first posts' text, as a small selection is chats: list[Chat] = [] chat_text: list[str] = [] @@ -84,8 +112,9 @@ def load_material(db: Session, workspace_id: uuid.UUID, notebook_id: uuid.UUID | if docs: corpus = Corpus(workspace_id) if job: - update_job(db, job, progress=0.1, message=f"Reading {len(docs)} posts") - text = passages_for(corpus, docs) + update_job(db, job, progress=0.1, message=f"Gathering the key ideas of {len(docs)} posts" if ideas_mode + else f"Reading {len(docs)} posts") + text = [] if ideas_mode else passages_for(corpus, docs, budget_chars=passage_budget) tools = tools_for(corpus, docs) by_path = {d.path: d for d in docs} @@ -110,4 +139,5 @@ def load_material(db: Session, workspace_id: uuid.UUID, notebook_id: uuid.UUID | if chat_text: source["chat_ids"] = [str(c.id) for c in chats] source["count"] = len(docs) + (len(chats) if chat_text else 0) - return Material(title=title, text=text + chat_text, docs=docs, refs=refs, tools=tools, source=source) + return Material(title=title, text=text + chat_text, docs=docs, refs=refs, tools=tools, source=source, + mode="ideas" if ideas_mode else "passages") diff --git a/backend/app/pipeline/media.py b/backend/app/pipeline/media.py index 2d4773513374827570885fdde26737c6169ac928..0b4e4a5687051174ec2e9bd30a3b136fb919f71f 100644 --- a/backend/app/pipeline/media.py +++ b/backend/app/pipeline/media.py @@ -17,7 +17,7 @@ from app.pipeline.passages import notebook_docs, passages_for, tools_for, verify from app.pipeline.report import SAME_LANGUAGE from app.services import tts from app.services.jobs import record_usage, update_job -from app.services.renderer import PermanentJobError, brand_props, request_render +from app.services.renderer import PermanentJobError, request_render from app.services.storage import keys, storage @@ -160,14 +160,13 @@ def make_audiogram(db: Session, job: Job, artifact: Artifact) -> None: source = db.get(Artifact, uuid.UUID(job.params["audio_artifact_id"])) if not source or source.type != "audio_overview" or not source.storage_key: raise PermanentJobError("Pick a finished audio overview for the audiogram.") - workspace = db.get(Workspace, artifact.workspace_id) content = source.content_json or {} duration = float(content.get("duration_s") or 30) segments = [{"speaker": s["speaker"], "text": s["text"], "startMs": int(s["start"] * 1000), "endMs": int(s["end"] * 1000)} for s in content.get("segments", [])] props = {"title": (content.get("title") or "Audio overview")[:140], "audioUrl": storage.presign_get(source.storage_key, ttl=6 * 3600), - "durationS": round(min(duration, 600), 2), "segments": segments, "brand": brand_props(workspace)} + "durationS": round(min(duration, 600), 2), "segments": segments } artifact.content_json = {**(artifact.content_json or {}), "style": "audiogram", "composition": "AudiogramSquare", "audio_artifact_id": str(source.id), "duration_s": props["durationS"]} artifact.status = "rendering" @@ -184,12 +183,11 @@ def make_audiogram(db: Session, job: Job, artifact: Artifact) -> None: def author_line(db: Session, workspace_id: uuid.UUID) -> str: ws = db.get(Workspace, workspace_id) owner = db.get(User, ws.owner_id) if ws else None - return (ws.brand_json or {}).get("name") or (owner.name if owner and owner.name else "") or "Notestack" + return (owner.name if owner and owner.name else "") or "Notestack" def make_quote_card(db: Session, job: Job, artifact: Artifact) -> None: content = artifact.content_json or {} - workspace = db.get(Workspace, artifact.workspace_id) if not content.get("quote"): docs = artifact_docs(db, artifact) if not docs: @@ -212,8 +210,7 @@ def make_quote_card(db: Session, job: Job, artifact: Artifact) -> None: if doc and doc.source_id: src = db.get(Source, doc.source_id) publication = src.title if src else None - props = {"quote": content["quote"][:380], "author": author_line(db, artifact.workspace_id), - "brand": brand_props(workspace)} + props = {"quote": content["quote"][:380], "author": author_line(db, artifact.workspace_id)} if publication or (doc and doc.title): props["publication"] = (publication or doc.title)[:120] artifact.content_json = {**content, "composition": "QuoteCard"} @@ -226,10 +223,8 @@ def render_carousel(db: Session, job: Job, artifact: Artifact) -> None: slides = (artifact.content_json or {}).get("slides") or [] if not slides: raise PermanentJobError("This carousel has no slides.") - workspace = db.get(Workspace, artifact.workspace_id) - brand = brand_props(workspace) stills = [{"heading": s.get("heading", "")[:120], "body": s.get("body", "")[:500], "index": i + 1, - "total": len(slides), "brand": brand} for i, s in enumerate(slides)] + "total": len(slides)} for i, s in enumerate(slides)] artifact.status = "rendering" db.commit() request_render(job, artifact, "CarouselSlide", stills=stills) diff --git a/backend/app/pipeline/passages.py b/backend/app/pipeline/passages.py index a7c654248d376ab56c8638e90a89b6cc7a31dfd4..9d16d23d79f4e6f4ebb7f7d87c3a8aba78e0a9b9 100644 --- a/backend/app/pipeline/passages.py +++ b/backend/app/pipeline/passages.py @@ -6,10 +6,11 @@ from types import SimpleNamespace from sqlalchemy import select from sqlalchemy.orm import Session +from app.config import settings from app.corpus import Corpus, CorpusError, ScopedTools from app.models import Document, NotebookDocument, VoiceProfile -DEFAULT_BUDGET = 120_000 # characters across all passages; GLM-5.3 has room, this keeps cost sane +DEFAULT_BUDGET = 120_000 # the default of ARTIFACT_PASSAGE_BUDGET: characters across all passages; this keeps cost sane def notebook_docs(db: Session, notebook_id: uuid.UUID) -> list[Document]: @@ -28,10 +29,26 @@ def workspace_docs(db: Session, workspace_id: uuid.UUID, ids: list[uuid.UUID] | return list(db.scalars(q.order_by(Document.published_at.desc())).all()) -def passages_for(corpus: Corpus, docs: list[Document], budget_chars: int = DEFAULT_BUDGET) -> list[str]: +def _body_start(lines: list[str]) -> int: + """The index of the first line after the post file's header (the `---` block of title, url, date and source, and the repeated + `# Title`): none of it is worth sending, the `FILE path (title)` line already names the post.""" + i = 0 + if lines and lines[0].strip() == "---": + end = next((k for k in range(1, min(len(lines), 12)) if lines[k].strip() == "---"), None) + if end is not None: + i = end + 1 + while i < len(lines) and not lines[i].strip(): + i += 1 + if i < len(lines) and lines[i].startswith("# "): + i += 1 + return i + + +def passages_for(corpus: Corpus, docs: list[Document], budget_chars: int | None = None) -> list[str]: """One string per post: a header line then `n| text` numbered lines (same numbering as read()). The budget is split evenly, so one long post cannot crowd out the rest.""" corpus.sync() + budget_chars = budget_chars or settings.artifact_passage_budget per_doc = max(budget_chars // max(len(docs), 1), 1500) out = [] for d in docs: @@ -40,8 +57,9 @@ def passages_for(corpus: Corpus, docs: list[Document], budget_chars: int = DEFAU except CorpusError: continue body, used = [], 0 + first = _body_start(lines) # lines keep their number in the file, so a cited line is still the right one for n, line in enumerate(lines, start=1): - if not line.strip(): + if n <= first or not line.strip(): continue entry = f"{n}| {line}" if used + len(entry) > per_doc: diff --git a/backend/app/pipeline/quiz.py b/backend/app/pipeline/quiz.py index f7ad52eb70ae318f23534ad683f991b8fde079fe..5e660691fee2860504be4b23a125cdc7cd2ce875 100644 --- a/backend/app/pipeline/quiz.py +++ b/backend/app/pipeline/quiz.py @@ -4,10 +4,12 @@ import uuid from sqlalchemy.orm import Session +from app.config import settings from app.llm import run from app.llm.provider import fast_lm from app.llm.signatures import GenerateQuiz from app.models import Artifact, Job +from app.pipeline import idea_pool from app.pipeline.generate import NothingToDo from app.pipeline.material import Material, load_material from app.services.jobs import update_job @@ -51,12 +53,14 @@ def make_questions(db: Session, workspace_id: uuid.UUID, material: Material, *, language: str = SAME_LANGUAGE, job: Job | None = None) -> list[dict]: """Playable questions about the material (fewer than `count` when the model gave unusable ones).""" types = [t for t in types or [] if t in QUESTION_TYPES] or list(QUESTION_TYPES[:2]) - out = run.predict(GenerateQuiz, db=db, workspace_id=workspace_id, job=job, title=material.title, - material=material.text, topic=topic or "(none)", difficulty=difficulty, question_types=types, - count=count, language=language.strip() or SAME_LANGUAGE, lm=fast_lm()) - cleaned = [c for q in out.get("questions") or [] if (c := _clean(q, material.refs))] # A multiple select with a single right answer was turned into a multiple choice: fine when that was asked for too. allowed = set(types) | ({"multiple_choice"} if "multiple_select" in types else set()) + inputs = dict(title=material.title, topic=topic or "(none)", difficulty=difficulty, question_types=types, + language=language.strip() or SAME_LANGUAGE) + # Many posts: one call over a random sample of the ideas already extracted from them (idea_pool), not the posts' text. + text = idea_pool.sample(db, workspace_id, material.docs, topic, chats=material.text) if material.mode == "ideas" else material.text + out = run.predict(GenerateQuiz, db=db, workspace_id=workspace_id, job=job, material=text, count=count, lm=fast_lm(), **inputs) + cleaned = [c for q in out.get("questions") or [] if (c := _clean(q, material.refs))] return [c for c in cleaned if c["type"] in allowed][:count] @@ -66,7 +70,8 @@ def build_quiz(db: Session, job: Job, artifact: Artifact) -> dict: count = QUESTION_COUNTS.get(params.get("count") or "standard", QUESTION_COUNTS["standard"]) difficulty = params.get("difficulty") if params.get("difficulty") in DIFFICULTIES else "medium" topic = (params.get("topic") or "").strip() - material = load_material(db, artifact.workspace_id, artifact.notebook_id, params, job=job, what="quiz") + material = load_material(db, artifact.workspace_id, artifact.notebook_id, params, job=job, what="quiz", large_ok=True, + passage_budget=settings.artifact_quiz_passage_budget) update_job(db, job, progress=0.4, message="Writing the questions") questions = make_questions(db, artifact.workspace_id, material, topic=topic, difficulty=difficulty, types=types, count=count, language=params.get("language") or SAME_LANGUAGE, job=job) diff --git a/backend/app/pipeline/report.py b/backend/app/pipeline/report.py index 9b8bdf12b882b0f10b0502d37afd11706252204b..63308dbc7cea38b331f469e9b65267ebc7e70896 100644 --- a/backend/app/pipeline/report.py +++ b/backend/app/pipeline/report.py @@ -1,42 +1,46 @@ """Reports: a written document (or an interactive one, with visuals) made from a notebook's posts or chats. Every report is stored as `blocks`, even a plain document (a few prose blocks), so the same page can show, extend and -share either kind. Each block is {"id", "type", ...}: `prose` has Markdown `text` whose [n] markers point into the -report's `citations`.""" +share either kind. Each block is {"id", "type", ...}: `prose` has Markdown `text`. A report cites nothing: it carries the +list of the posts it was made from (`sources`) and the instructions it was written to (`instructions`), which the page shows +together in a drop-down. (`citations` stays in the content, empty, for the older reports that have them.)""" import logging import re +import time import uuid +from dataclasses import dataclass, field +from sqlalchemy import select from sqlalchemy.orm import Session +from app.config import settings +from app.corpus import Corpus from app.infographics.themes import theme_id from app.llm import run -from app.llm.provider import fast_lm, report_lm +from app.llm.provider import report_lm, report_section_lm from app.llm.signatures import ( PlanInteractiveReport, - SuggestEmbeds, WriteReport, WriteReportSection, + WriteThemedReport, + WriteThemedSection, ) -from app.models import Artifact, Job +from app.models import Artifact, Document, Job, Notebook +from app.pipeline import report_blueprint, report_evidence, report_themes from app.pipeline.flashcards import make_cards from app.pipeline.generate import NothingToDo, mind_map_content from app.pipeline.material import Material, load_material -from app.pipeline.passages import as_citations from app.pipeline.quiz import make_questions -from app.pipeline.research import verify_citations from app.services.artifacts import start_artifact from app.services.jobs import update_job from app.services.renderer import PermanentJobError from app.services.report_templates import default_instructions -SUGGEST_KINDS = ("mind_map", "flashcards", "quiz", "infographic") # what the AI suggests for a document report -EMBED_KINDS = SUGGEST_KINDS # every visual a reader can add with one click +EMBED_KINDS = ("mind_map", "flashcards", "quiz", "infographic") # every visual a reader can add with one click MAX_PLANNED_VISUALS = 4 # suggestions an interactive report's plan can make MAX_SECTIONS = 8 SAME_LANGUAGE = "the same language as the sources" # no language picked: write as the material is written -MAX_SUGGESTIONS = 4 BLOCK_CARDS, BLOCK_QUESTIONS = 8, 5 # a visual inside a report is a taste of the full thing log = logging.getLogger(__name__) @@ -46,7 +50,6 @@ MARKER = re.compile(r"\s*\[\d+\](?:\[\d+\])*") QUIZ_HEADING = re.compile(r"\b(quiz|test yourself|check your understanding|practice questions|review questions|self[- ]?check)\b", re.I) FLASHCARD_HEADING = re.compile(r"\bflash ?cards?\b", re.I) -ASKED_FOR_VISUAL = re.compile(r"\b(quiz|flash ?cards?|practice questions)\b", re.I) def visual_kind(heading: str) -> str | None: @@ -62,24 +65,6 @@ MOVED_BRIEF = {"quiz": "Check understanding of the key ideas in this report.", "flashcards": "Learn the key terms and facts from this report."} -def pull_visual_sections(blocks: list[dict], kinds: list[str]) -> tuple[list[dict], list[dict]]: - """A section written as a quiz or flashcards is not part of the text: it is taken out and offered as a suggestion - after the section before it, for the reader to add. Never takes out every section.""" - kept: list[dict] = [] - moved: list[tuple[int, str]] = [] - for b in blocks: - first = b["text"].split("\n", 1)[0] if b["type"] == "prose" else "" - kind = visual_kind(first.lstrip("# ").strip()) if first.startswith("#") else None - if kind and kind in kinds and kept: - moved.append((len(kept) - 1, kind)) - else: - kept.append(b) - number_blocks(kept) - return kept, [{"id": f"s{i}", "after_block_id": kept[idx]["id"], "kind": kind, "brief": MOVED_BRIEF[kind], - "why": "Offered here for you to add, not written into the text."} - for i, (idx, kind) in enumerate(moved, start=1)] - - def merge_suggestions(*groups: list[dict]) -> list[dict]: """Suggestions from several places, one per section and kind, renumbered.""" seen, out = set(), [] @@ -92,6 +77,36 @@ def merge_suggestions(*groups: list[dict]) -> list[dict]: return out[:MAX_PLANNED_VISUALS + 2] +FALLBACK_BRIEFS = { + "infographic": ("A one page picture of the main ideas of this report.", "A quick picture of the whole report."), + "flashcards": (MOVED_BRIEF["flashcards"], "Cards to learn the key terms and facts."), + "quiz": (MOVED_BRIEF["quiz"], "A short quiz to check understanding."), + "mind_map": ("How the main ideas of this report connect.", "See how the ideas fit together."), +} +MIN_SUGGESTIONS = 3 # a report always offers at least this many visuals (fewer only when fewer kinds are allowed) + + +def fill_suggestions(suggestions: list[dict], blocks: list[dict], kinds: list[str]) -> list[dict]: + """An interactive report always offers something to add (a document never does). Whatever the plan or the writer suggested is + kept; then an infographic is added if there is none, and flashcards, a quiz and a mind map in turn until there are + MIN_SUGGESTIONS, each after a different section. Plain code, so it never depends on the model remembering to suggest.""" + prose = [b for b in blocks if b.get("type") == "prose"] + if not prose: + return suggestions + inner = prose[1:-1] or prose # not the introduction or the summary, when there are others + place = {"infographic": inner[-1]["id"], "flashcards": inner[0]["id"], "quiz": inner[len(inner) // 2]["id"], + "mind_map": prose[0]["id"]} + out = list(suggestions) + have = {s["kind"] for s in out} + for kind in ("infographic", "flashcards", "quiz", "mind_map"): + if kind in kinds and kind not in have and (kind == "infographic" or len(out) < MIN_SUGGESTIONS): + brief, why = FALLBACK_BRIEFS[kind] + out.append({"id": "", "after_block_id": place[kind], "kind": kind, "brief": brief, "why": why}) + have.add(kind) + log.info("report: no %s suggested by the plan, offering one after %s", kind, place[kind]) + return out + + def split_prose(markdown: str) -> list[dict]: """Markdown cut into prose blocks at each "## " heading, so a block is one section and can be followed by a visual.""" parts = re.split(r"(?m)^(?=## )", markdown.strip()) @@ -108,81 +123,29 @@ def number_blocks(blocks: list[dict]) -> list[dict]: return blocks -def citation_rows(cites, material: Material) -> list[dict]: - by_path = {d.path: d for d in material.docs} - return [{"marker": c.marker, "path": c.path, "line_start": c.line_start, "line_end": c.line_end, - "span": c.quote, "title": by_path[c.path].title, "url": by_path[c.path].url, - "document_id": str(by_path[c.path].id)} - for c in cites if c.path in by_path] +def source_rows(material: Material) -> list[dict]: + """The posts a report was made from, for its Sources list: titles and the posts' own web addresses.""" + return source_rows_for(material.docs) -def source_rows(material: Material) -> list[dict]: - """Where the report came from, for the public page's Sources button: titles and the posts' own web addresses.""" - return [{"title": d.title, "url": d.url, "document_id": str(d.id)} for d in material.docs] +def source_rows_for(docs: list[Document]) -> list[dict]: + return [{"title": d.title, "url": d.url, "document_id": str(d.id)} for d in docs] def write_document(db: Session, workspace_id: uuid.UUID, job: Job | None, material: Material, instructions: str, - language: str) -> tuple[str, str, list[dict]]: - """(title, Markdown, citation rows) for a written report. Citations are checked against the posts' lines and - renumbered 1..n; a chat has none, so any marker the model wrote is removed.""" + language: str) -> tuple[str, str]: + """(title, Markdown) for a written report. Nothing is cited; a marker the model wrote anyway is removed.""" out = run.predict(WriteReport, db=db, workspace_id=workspace_id, job=job, lm=report_lm(), title=material.title, material=material.text, request=instructions, language=language) markdown = (out.get("markdown") or "").strip() if not markdown: raise NothingToDo("The report came back empty. Try again, or describe it differently.") - if material.tools is None: - return (out.get("report_title") or material.title).strip(), MARKER.sub("", markdown), [] - text, cites = verify_citations(material.tools, markdown, as_citations(out.get("citations"))) - return (out.get("report_title") or material.title).strip(), text, citation_rows(cites, material) - - -def allowed_kinds(material_or_source: Material | dict) -> list[str]: - """Which visuals fit this source: a mind map is drawn from posts' stored ideas, so a chat gets cards and a quiz.""" - has_posts = bool(material_or_source.docs) if isinstance(material_or_source, Material) \ - else bool(material_or_source.get("document_ids")) - return [k for k in SUGGEST_KINDS if k != "mind_map" or has_posts] - - -def suggest_embeds(db: Session, workspace_id: uuid.UUID, job: Job | None, blocks: list[dict], - kinds: list[str]) -> list[dict]: - """Up to MAX_SUGGESTIONS places for a visual: after which section, what kind, what it covers, why. Never fails - the report: no suggestions is a fine answer.""" - sections = [] - for b in blocks: - if b["type"] != "prose": - continue - first, _, rest = b["text"].partition("\n") - heading = first.lstrip("# ").strip() if first.startswith("#") else "" - start = re.sub(r"\s+", " ", rest or first)[:240] - sections.append(f"{b['id']} | {heading or 'Introduction'}: {start}") - if len(sections) < 2: - return [] - try: - out = run.predict(SuggestEmbeds, db=db, workspace_id=workspace_id, job=job, lm=fast_lm(), sections=sections, - allowed_kinds=kinds) - except Exception: - return [] - ids = {b["id"] for b in blocks} - taken: set[tuple[str, str]] = {(b.get("after") or "", b["type"]) for b in blocks if b["type"] != "prose"} - found = [] - for s in out.get("suggestions") or []: - key = (s.get("after_block_id"), s.get("kind")) - 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(): - continue - taken.add(key) - found.append({"id": f"s{len(found) + 1}", "after_block_id": s["after_block_id"], "kind": s["kind"], - "brief": s["brief"].strip()[:300], "why": (s.get("why") or "").strip()[:300]}) - return found[:MAX_SUGGESTIONS] - - -def shift_markers(text: str, offset: int) -> str: - """[n] markers renumbered to [n + offset], so sections written apart share one citation list.""" - return re.sub(r"\[(\d+)\]", lambda m: f"[{int(m.group(1)) + offset}]", text) if offset else text + return (out.get("report_title") or material.title).strip(), MARKER.sub("", markdown) def write_interactive(db: Session, workspace_id: uuid.UUID, job: Job, material: Material, instructions: str, - language: str) -> tuple[str, list[dict], list[dict], list[dict]] | None: - """(title, blocks, citation rows, suggestions) for an interactive report: a plan of sections, each written on its + language: str) -> tuple[str, list[dict], list[dict]] | None: + """(title, blocks, suggestions) for an interactive report: a plan of sections, each written on its own (in parallel). The plan also says where a visual would fit; those spots come back as suggestions, and nothing is built until the reader clicks Add. None when planning fails, so the caller can write a plain document.""" kinds = [k for k in EMBED_KINDS if k != "mind_map" or not material.is_chat] @@ -219,21 +182,13 @@ def write_interactive(db: Session, workspace_id: uuid.UUID, job: Job, material: db=db, workspace_id=workspace_id, lm=report_lm()) blocks: list[dict] = [] - citations: list[dict] = [] suggestions: list[dict] = [] used_kinds: set[str] = set() anchor: dict[int, str] = {} # planned section number -> the block it became for n, (sec, out) in enumerate(zip(sections, written, strict=True)): - body = ((out or {}).get("markdown") or "").strip() + body = MARKER.sub("", ((out or {}).get("markdown") or "").strip()) if not body: continue - if material.tools is None: - body = MARKER.sub("", body) - else: - body, cites = verify_citations(material.tools, body, as_citations((out or {}).get("citations"))) - offset = len(citations) - body = shift_markers(body, offset) - citations += [{**c, "marker": c["marker"] + offset} for c in citation_rows(cites, material)] block = {"type": "prose", "text": f"## {sec['heading'].strip()}\n\n{body}"} blocks.append(block) number_blocks(blocks) @@ -252,7 +207,275 @@ def write_interactive(db: Session, workspace_id: uuid.UUID, job: Job, material: suggestions.append({"id": "", "after_block_id": after, "kind": kind, "brief": (sec.get("brief") or MOVED_BRIEF[kind]).strip()[:300], "why": "Offered here for you to add, not written into the text."}) - return report_title, blocks, citations, merge_suggestions(suggestions) + return report_title, blocks, merge_suggestions(fill_suggestions(suggestions, blocks, kinds)) + + +# A report built around themes (report_themes.py, report_blueprint.py, report_evidence.py) + +EVIDENCE_TOTAL = 100_000 # characters of evidence in all for a report written in one piece (about 25k tokens) +DIGEST_CHARS = 300 # of each written section, for the introduction and the summary that are written after them + + +@dataclass +class Themed: + title: str + blocks: list[dict] + suggestions: list[dict] + source: dict + sources: list[dict] + plan: dict + failed: list[str] = field(default_factory=list) + + +@dataclass +class ThemedRun: + """What the steps of a themed report share.""" + db: Session + workspace_id: uuid.UUID + job: Job + docs: list[Document] + corpus: Corpus + bp: report_blueprint.Blueprint + themes: list[report_themes.Theme] + instructions: str + language: str + title: str + + @property + def by_id(self) -> dict[str, report_themes.Theme]: + return {t.id: t for t in self.themes} + + def names(self, s: report_blueprint.Section) -> list[str]: + by_id = self.by_id + return [by_id[i].name for i in s.theme_ids if i in by_id] + + def sections_info(self) -> list[dict]: + return [{"heading": s.heading, "role": s.role, "depth": s.depth, "theme_ids": s.theme_ids, "must_cover": s.must_cover} + for s in self.bp.sections] + + def finish(self, title: str, blocks: list[dict], suggestions: list[dict], used_posts: list[str], + failed: list[str]) -> Themed: + """The Sources list is every post the report was made from: the ones read as written and the ones summarised.""" + used = [d for d in self.docs if d.path in set(used_posts)] or self.docs + log.info("report sources: job=%s posts_total=%d posts_used=%d", self.job.id, len(self.docs), len(used)) + plan = {"planner": "themes", "storyline": self.bp.storyline, "posts_total": len(self.docs), "posts_used": len(used), + "themes": [report_themes.theme_summary(t) for t in self.themes], "sections": self.sections_info(), + "connections": self.bp.connections} + return Themed(title=title, blocks=blocks, suggestions=suggestions, plan=plan, failed=failed, + source={"kind": "posts", "document_ids": [str(d.id) for d in self.docs], "count": len(self.docs), + "read": len(used), "total": len(self.docs)}, + sources=source_rows_for(used)) + + +def themed_docs(db: Session, artifact: Artifact, params: dict) -> list[Document]: + """The posts a themed report would read, or [] when the report is made the usual way: chats, a small selection, or the + legacy planner.""" + if settings.report_planner != "themes" or params.get("chat_ids"): + log.info("report route: legacy artifact=%s reason=%s", artifact.id, + "chats picked" if params.get("chat_ids") else f"REPORT_PLANNER={settings.report_planner}") + return [] + docs = report_themes.select_docs(db, artifact.workspace_id, artifact.notebook_id, params) + if len(docs) <= report_themes.SMALL_SELECTION: + log.info("report route: legacy artifact=%s reason=small selection (%d posts, themes need more than %d)", artifact.id, + len(docs), report_themes.SMALL_SELECTION) + return [] + return docs + + +def themed_title(db: Session, artifact: Artifact, params: dict, docs: list[Document]) -> str: + """What the report is about, named the way load_material names its source.""" + nb = db.get(Notebook, artifact.notebook_id) if artifact.notebook_id else None + if nb and not params.get("document_ids"): + return nb.title + return docs[0].title + (f" + {len(docs) - 1} more" if len(docs) > 1 else "") + + +def _digest(markdown: str) -> str: + """The start of a written section, for the sections written after it to know what it says.""" + text = MARKER.sub("", re.sub(r"(?m)^#+\s*", "", markdown)) + return re.sub(r"\s+", " ", text).strip()[:DIGEST_CHARS] + + +def _section_header(n: int, s: report_blueprint.Section, names: list[str]) -> str: + return (f"SECTION {n}: {s.heading} (role: {s.role}, depth: {s.depth})\nSays: {s.brief}\n" + f"Must cover: {'; '.join(s.must_cover) or '(see what it says)'}\nThemes: {', '.join(names) or '(the whole report)'}") + + +def _section_input(ctx: ThemedRun, s: report_blueprint.Section, evidence: list[str]) -> dict: + """Everything WriteThemedSection is given. What every section shares comes first in the signature.""" + must = list(s.must_cover) + if s.role == "summary" and ctx.names(s): + must.append(f"Restate the main points about: {', '.join(ctx.names(s))}") + return {"storyline": ctx.bp.storyline, "outline": report_blueprint.outline(ctx.bp), + "connections": report_blueprint.connection_lines(ctx.bp, ctx.themes), "request": ctx.instructions, + "language": ctx.language, "heading": s.heading, "role": s.role, "depth": s.depth, "brief": s.brief, + "must_cover": must, "covered_elsewhere": s.covered_elsewhere, + "bridges": report_blueprint.connection_lines(ctx.bp, ctx.themes, s.heading), "evidence": evidence} + + +def _write_sections(ctx: ThemedRun, inputs: list[dict]) -> list[dict | None]: + """WriteThemedSection for each input, together; one that failed or came back empty gets one more try (another temperature).""" + kw = {"db": ctx.db, "workspace_id": ctx.workspace_id} + started = time.monotonic() + outs = run.predict_many(WriteThemedSection, inputs, lm=report_section_lm(), **kw) + again = [k for k, o in enumerate(outs) if not ((o or {}).get("markdown") or "").strip()] + log.info("report sections written: asked=%d ok=%d failed=%d seconds=%.1f", len(inputs), len(inputs) - len(again), len(again), + time.monotonic() - started) + if again: + log.warning("report sections failed, trying again: %s", [inputs[k]["heading"] for k in again]) + redo = run.predict_many(WriteThemedSection, [inputs[k] for k in again], lm=report_section_lm(retry=True), **kw) + for k, o in zip(again, redo, strict=True): + outs[k] = o + gone = [inputs[k]["heading"] for k in again if not ((outs[k] or {}).get("markdown") or "").strip()] + log.info("report sections retried: asked=%d recovered=%d still_failed=%s", len(again), len(again) - len(gone), gone) + return outs + + +def _themed_document(ctx: ThemedRun) -> Themed | None: + """The whole report in one piece: the plan and every section's evidence go to one writer, so one voice writes it all.""" + budgets = report_evidence.scale_budgets(ctx.bp.sections, EVIDENCE_TOTAL) + parts: list[str] = [] + used: list[str] = [] + for n, s in enumerate(ctx.bp.sections, start=1): + head = _section_header(n, s, ctx.names(s)) + if s.role in ("body", "synthesis"): + ev = report_evidence.build(ctx.corpus, s, ctx.by_id, budgets.get(s.heading)) + used += ev.posts + if ev.blocks: + head += "\nEvidence:\n" + "\n\n".join(ev.blocks) + parts.append(head) + report_themes.progress(ctx.db, ctx.job, 0.5, "Writing the report") + log.info("report document: writing in one piece sections=%d evidence_chars=%d (budget %d) posts_used=%d", len(parts), + sum(len(p) for p in parts), EVIDENCE_TOTAL, len(set(used))) + started = time.monotonic() + out = run.predict(WriteThemedReport, db=ctx.db, workspace_id=ctx.workspace_id, job=ctx.job, lm=report_lm(), + title=ctx.title, storyline=ctx.bp.storyline, request=ctx.instructions, language=ctx.language, + connections=report_blueprint.connection_lines(ctx.bp, ctx.themes), sections=parts) + markdown = MARKER.sub("", (out.get("markdown") or "").strip()) + if not markdown: + log.warning("report document came back empty, reading the posts instead") + return None + log.info("report document written: chars=%d seconds=%.1f", len(markdown), time.monotonic() - started) + return ctx.finish((out.get("report_title") or ctx.bp.title).strip(), number_blocks(split_prose(markdown)), [], used, []) + + +def _themed_interactive(ctx: ThemedRun, moved: list[tuple[report_blueprint.Section, str, report_blueprint.Section]]) -> Themed | None: + """Wave 1: the body sections together, each given the storyline, the outline, what the others cover and only its own + evidence. Wave 2: the introduction and the summary, written from what wave 1 produced.""" + sections = ctx.bp.sections + body = [(n, s) for n, s in enumerate(sections) if s.role in ("body", "synthesis")] + frame = [(n, s) for n, s in enumerate(sections) if s.role in ("intro", "summary")] + used: list[str] = [] + inputs = [] + for _, s in body: + ev = report_evidence.build(ctx.corpus, s, ctx.by_id) + used += ev.posts + inputs.append(_section_input(ctx, s, ev.blocks)) + report_themes.progress(ctx.db, ctx.job, 0.45, f"Writing {len(body)} sections") + log.info("report wave 1: body sections=%d evidence_chars=%s", len(body), [sum(len(b) for b in i["evidence"]) for i in inputs]) + written: dict[int, dict] = {} + for (n, _), out in zip(body, _write_sections(ctx, inputs), strict=True): + if ((out or {}).get("markdown") or "").strip(): + written[n] = out + if not written: + log.warning("report wave 1 wrote nothing, reading the posts instead") + return None + digests = [f"{s.heading}: {_digest(written[n]['markdown'])}" for n, s in body if n in written] + report_themes.progress(ctx.db, ctx.job, 0.8, "Writing the introduction and summary") + log.info("report wave 2: intro/summary sections=%d digests=%d", len(frame), len(digests)) + for (n, _), out in zip(frame, _write_sections(ctx, [_section_input(ctx, s, digests) for _, s in frame]), strict=True): + if ((out or {}).get("markdown") or "").strip(): + written[n] = out + + blocks: list[dict] = [] + suggestions: list[dict] = [] + anchor: dict[int, str] = {} + used_kinds: set[str] = set() + for n, s in enumerate(sections): + out = written.get(n) + if not out: + continue + blocks.append({"type": "prose", "text": f"## {s.heading}\n\n{MARKER.sub('', out['markdown'].strip())}"}) + number_blocks(blocks) + anchor[id(s)] = blocks[-1]["id"] + embed = s.embed + if embed and len(suggestions) < MAX_PLANNED_VISUALS and embed.get("kind") in EMBED_KINDS \ + and embed["kind"] not in used_kinds and (embed.get("brief") or "").strip(): + used_kinds.add(embed["kind"]) + suggestions.append({"id": f"s{len(suggestions) + 1}", "after_block_id": blocks[-1]["id"], "kind": embed["kind"], + "brief": embed["brief"].strip()[:300], "why": (embed.get("why") or "").strip()[:300]}) + for after, kind, sec in moved: + suggestions.append({"id": "", "after_block_id": anchor.get(id(after)) or blocks[0]["id"], "kind": kind, + "brief": (sec.brief or MOVED_BRIEF[kind]).strip()[:300], + "why": "Offered here for you to add, not written into the text."}) + failed = [s.heading for n, s in enumerate(sections) if n not in written] + if failed: + log.warning("report sections left out after a retry: %s", failed) + log.info("report interactive written: blocks=%d suggestions=%d moved_to_suggestions=%d failed=%d", len(blocks), + len(suggestions), len(moved), len(failed)) + suggestions = fill_suggestions(suggestions, blocks, list(EMBED_KINDS)) + return ctx.finish(ctx.bp.title, blocks, merge_suggestions(suggestions), used, failed) + + +def write_themed(db: Session, workspace_id: uuid.UUID, job: Job, docs: list[Document], title: str, instructions: str, + language: str, interactive: bool) -> Themed | None: + """The report from the posts' themes: group their stored ideas into weighted themes, plan the whole report, build each + section's evidence from several posts, then write. A document is written in one piece from the plan; an interactive report + section by section, each knowing the storyline, the outline and what the others cover, with the introduction and the + summary written last from what the sections say. None when any step cannot be done (too few ideas, no usable plan, nothing + written), so the caller reads the posts as a report always did. Never makes a report fail.""" + started = time.monotonic() + log.info("themed report starting: job=%s workspace=%s format=%s posts=%d language=%r", job.id, workspace_id, + "interactive" if interactive else "document", len(docs), language) + try: + report_themes.progress(db, job, 0.05, f"Reading {len(docs)} posts") + report_themes.ensure_ideas(db, job, workspace_id, docs) + share = report_themes.fresh_share(docs) + if share < report_themes.MIN_FRESH_SHARE: + log.warning("themed report not possible, reading the posts instead: job=%s only %.0f%% of %d posts have ideas " + "(need %.0f%%)", job.id, share * 100, len(docs), report_themes.MIN_FRESH_SHARE * 100) + return None + corpus = Corpus(workspace_id) + corpus.sync() + ideas = report_themes.collect_ideas(docs, corpus) + report_themes.progress(db, job, 0.2, f"Finding the themes in {len(docs)} posts") + themes, relations = report_themes.group_themes(db, job, workspace_id, title, instructions, ideas) + report_themes.progress(db, job, 0.3, "Planning the report") + kinds = list(EMBED_KINDS) if interactive else [] + bp = report_blueprint.plan_blueprint(db, job, workspace_id, title, instructions, themes, relations, kinds, language) + log.info("themed report planned: job=%s themes=%d sections=%d seconds=%.1f", job.id, len(themes), len(bp.sections), + time.monotonic() - started) + moved: list[tuple[report_blueprint.Section, str, report_blueprint.Section]] = [] + if interactive: # a planned section that is really a quiz or flashcards is a suggestion, not text + kept: list[report_blueprint.Section] = [] + for s in bp.sections: + kind = visual_kind(s.heading) + if kind and kept: + log.info("report section %r is a %s, so it becomes a suggestion after %r", s.heading, kind, kept[-1].heading) + moved.append((kept[-1], kind, s)) + if kept[-1].role == "body": # what the section was about is still said, in the section before it + kept[-1].theme_ids += [i for i in s.theme_ids if i not in kept[-1].theme_ids] + else: + kept.append(s) + bp.sections = kept + if len(bp.sections) < 2: + log.warning("themed report not possible, reading the posts instead: job=%s fewer than two sections left", job.id) + return None + ctx = ThemedRun(db, workspace_id, job, docs, corpus, bp, themes, instructions, language, title) + themed = _themed_interactive(ctx, moved) if interactive else _themed_document(ctx) + if themed: + log.info("themed report done: job=%s posts_total=%d posts_used=%d blocks=%d suggestions=%d failed=%d seconds=%.1f", + job.id, len(docs), themed.plan["posts_used"], len(themed.blocks), len(themed.suggestions), + len(themed.failed), time.monotonic() - started) + else: + log.warning("themed report wrote nothing, reading the posts instead: job=%s", job.id) + return themed + except (report_themes.ThemesUnavailable, report_blueprint.BlueprintUnavailable) as exc: + log.warning("themed report not possible, reading the posts instead: job=%s reason=%s", job.id, exc) + except Exception: + db.rollback() + log.warning("themed report failed, reading the posts instead: job=%s", job.id, exc_info=True) + return None def build_report(db: Session, job: Job, artifact: Artifact) -> dict: @@ -260,30 +483,40 @@ def build_report(db: Session, job: Job, artifact: Artifact) -> dict: topic = (params.get("topic") or "").strip() language = (params.get("language") or "").strip() or SAME_LANGUAGE report_format = "interactive" if params.get("report_format") == "interactive" else "document" - material = load_material(db, artifact.workspace_id, artifact.notebook_id, params, job=job, what="report") + # Posts with ideas stored are read through their themes, however many are picked; chats and small selections as always. + docs = themed_docs(db, artifact, params) + material = None if docs else load_material(db, artifact.workspace_id, artifact.notebook_id, params, job=job, what="report") + about = topic or (themed_title(db, artifact, params, docs) if docs else material.title) instructions = (params.get("instructions") or "").strip() or default_instructions( - report_format, params.get("template_id"), topic or material.title) - - made = write_interactive(db, artifact.workspace_id, job, material, instructions, language) \ - if report_format == "interactive" else None - if made: - title, blocks, citations, suggestions = made + report_format, params.get("template_id"), about) + + themed = write_themed(db, artifact.workspace_id, job, docs, themed_title(db, artifact, params, docs), instructions, language, + report_format == "interactive") if docs else None + log.info("report: job=%s artifact=%s format=%s way=%s", job.id, artifact.id, report_format, + "themes" if themed else "legacy (posts read as text)") + if themed: + extra = {"plan": themed.plan, **({"failed_sections": themed.failed} if themed.failed else {})} + title, blocks, suggestions = themed.title, themed.blocks, themed.suggestions + source, sources = themed.source, themed.sources else: - # A document, or an interactive report whose plan came back unusable: write it as one piece. Never a failure. - report_format = "document" - update_job(db, job, progress=0.3, message="Writing the report") - title, markdown, citations = write_document(db, artifact.workspace_id, job, material, instructions, language) - blocks = number_blocks(split_prose(markdown)) - kinds = allowed_kinds(material) - pulled: list[dict] = [] - if not ASKED_FOR_VISUAL.search(instructions): # a quiz section the reader did not ask for becomes a suggestion - blocks, pulled = pull_visual_sections(blocks, kinds) - update_job(db, job, progress=0.9, message="Finding places for visuals") - suggestions = merge_suggestions(pulled, suggest_embeds(db, artifact.workspace_id, job, blocks, kinds)) + material = material or load_material(db, artifact.workspace_id, artifact.notebook_id, params, job=job, what="report") + extra = {} + made = write_interactive(db, artifact.workspace_id, job, material, instructions, language) \ + if report_format == "interactive" else None + if made: + title, blocks, suggestions = made + else: + # A document, or an interactive report whose plan came back unusable: write it as one piece. Never a failure. + report_format = "document" + update_job(db, job, progress=0.3, message="Writing the report") + title, markdown = write_document(db, artifact.workspace_id, job, material, instructions, language) + blocks = number_blocks(split_prose(markdown)) + suggestions = [] # visuals are offered in interactive reports only, never in a document + source, sources = material.source, source_rows(material) artifact.content_json = { "title": title, "format": report_format, "template_id": params.get("template_id"), - "instructions": instructions, "language": language, "topic": topic, "source": material.source, - "blocks": blocks, "suggestions": suggestions, "citations": citations, "sources": source_rows(material), + "instructions": instructions, "language": language, "topic": topic, "source": source, + "blocks": blocks, "suggestions": suggestions, "suggestions_offered": True, "citations": [], "sources": sources, **extra, } artifact.status = "ready" db.commit() @@ -308,7 +541,7 @@ def copy_block(kind: str, existing: Artifact) -> dict: return {"title": c.get("title"), "questions": c["questions"]} if kind == "flashcards" and c.get("cards"): return {"title": c.get("title"), "cards": c["cards"]} - if kind == "infographic" and existing.storage_key: # the picture itself is shared, not copied + if kind == "infographic" and c.get("content"): # the picture itself is shared, not copied return {"title": c.get("title"), "artifact_id": str(existing.id), "theme": c.get("theme")} raise NothingToDo("That one has nothing to add.") @@ -381,6 +614,7 @@ def handle_report_block(db: Session, job: Job) -> dict: block = copy_block(kind, existing) else: block = make_block(db, job, report, kind, (p.get("brief") or "").strip(), p.get("theme")) + keep_visual(db, report, {"type": kind, **block}) # a new one also lands in the notebook's Studio list except NothingToDo as exc: raise PermanentJobError(str(exc)) from exc report.content_json = insert_block(report.content_json or {}, {"type": kind, **block}, p.get("after_block_id"), @@ -389,8 +623,44 @@ def handle_report_block(db: Session, job: Job) -> dict: return {"artifact_id": str(report.id)} +def keep_visual(db: Session, report: Artifact, block: dict) -> Artifact | None: + """A mind map, flashcards or quiz made inside a report would exist only as that block. It is also saved as an + artifact of its own in the notebook, so it shows in the Studio list and "use one you already made" offers it + again after the block is removed. Content that is already saved is not saved twice.""" + kind = block.get("type") + source = (report.content_json or {}).get("source") or {} + if kind == "flashcards" and block.get("cards"): + key, content = "cards", {"cards": block["cards"], "card_count": len(block["cards"])} + elif kind == "quiz" and block.get("questions"): + key, content = "questions", {"questions": block["questions"], "question_count": len(block["questions"])} + elif kind == "mind_map" and block.get("root"): + key, content = "root", {"root": block["root"], "node_count": block.get("node_count"), + "post_count": block.get("post_count"), + "document_ids": source.get("document_ids") or []} + else: + return None # an infographic already is its own artifact + same = db.scalars(select(Artifact).where(Artifact.workspace_id == report.workspace_id, Artifact.type == kind, + Artifact.notebook_id == report.notebook_id)) + if any((a.content_json or {}).get(key) == content[key] for a in same): + return None + saved = Artifact(workspace_id=report.workspace_id, notebook_id=report.notebook_id, type=kind, status="ready", + content_json={"title": block.get("title") or kind, "source": source, **content}) + db.add(saved) + db.commit() + return saved + + def remove_block(content: dict, block_id: str) -> dict | None: - """The content without that visual (prose cannot be removed), or None when there is no such visual.""" + """The content without that visual (prose cannot be removed), with the offer to add it again restored, or None + when there is no such visual.""" blocks = list(content.get("blocks") or []) - keep = [b for b in blocks if not (b["id"] == block_id and b["type"] != "prose")] - return None if len(keep) == len(blocks) else {**content, "blocks": keep} + removed = next((b for b in blocks if b["id"] == block_id and b["type"] != "prose"), None) + if not removed: + return None + keep = [b for b in blocks if b["id"] != block_id] + suggestions = list(content.get("suggestions") or []) + kind = removed["type"] + if kind in FALLBACK_BRIEFS and not any(s["kind"] == kind for s in suggestions): + brief, why = FALLBACK_BRIEFS[kind] + suggestions.append({"id": "", "after_block_id": removed.get("after"), "kind": kind, "brief": brief, "why": why}) + return {**content, "blocks": keep, "suggestions": suggestions} diff --git a/backend/app/pipeline/report_blueprint.py b/backend/app/pipeline/report_blueprint.py new file mode 100644 index 0000000000000000000000000000000000000000..b05b71259ccc6c6fc03b485b8297991c37d744df --- /dev/null +++ b/backend/app/pipeline/report_blueprint.py @@ -0,0 +1,220 @@ +"""The plan of a themed report, made before any of it is written. + +One model call (PlanReportBlueprint) sees the themes with their weights and the reader's request, never the posts, and plans the +whole report: a storyline, the sections in order (what each is about, how deep, what it must cover), and which section bridges which +themes. Code then checks the plan against the weights, because a plan that forgets a major theme or gives a footnote the room of a +main theme is wrong however well it reads: every major or asked-for theme gets a section, depth follows the theme's tier, a body +theme belongs to one section, and what other sections own is worked out here, not left to the model.""" + +import logging +import time +import uuid +from dataclasses import dataclass, field + +from sqlalchemy.orm import Session + +from app.llm import run +from app.llm.provider import report_plan_lm +from app.llm.signatures import PlanReportBlueprint +from app.models import Job +from app.pipeline.report_themes import Theme + +log = logging.getLogger(__name__) + +MAX_SECTIONS = 8 +MAX_CONNECTIONS = 6 +ROLES = ("intro", "body", "synthesis", "summary") +MORE_HEADING = "More to know" # where the themes no section took are gathered + + +class BlueprintUnavailable(Exception): + """The plan was not usable: the caller reads the posts as before.""" + + +@dataclass +class Section: + heading: str + role: str + brief: str + theme_ids: list[str] + depth: str = "standard" + must_cover: list[str] = field(default_factory=list) + embed: dict | None = None + covered_elsewhere: list[str] = field(default_factory=list) + + +@dataclass +class Blueprint: + title: str + storyline: str + sections: list[Section] + connections: list[dict] # {"theme_ids": [...], "relation": str, "section": heading} + + +def theme_lines(themes: list[Theme]) -> list[str]: + """The themes as the planner reads them: id | name | tier | weight | posts | asked for: summary, then a few of their ideas.""" + return [f"{t.id} | {t.name} | {t.tier} | {t.weight:.2f} | {len(t.doc_ids)} posts | " + f"{'asked for' if t.pinned else 'not asked for'}: {t.summary} (e.g. {'; '.join(i.label for i in t.ideas[:3])})" + for t in themes] + + +def relation_lines(relations: list[dict]) -> list[str]: + return [f"{r['a']} <-> {r['b']}: {r['relation']}" for r in relations] + + +def derived_depth(section: Section, by_id: dict[str, Theme]) -> str: + """How deep a section goes follows its heaviest theme, whatever the model wrote: the weights are the plan's reason to exist.""" + if section.role in ("intro", "summary"): + return "brief" + tiers = {by_id[i].tier + ("!" if by_id[i].pinned else "") for i in section.theme_ids if i in by_id} + if any(t.startswith("major") or t.endswith("!") for t in tiers): + return "deep" + if section.role == "synthesis" or any(t.startswith("medium") for t in tiers): + return "standard" + return "brief" + + +def _first_index(sections: list[Section], role: str) -> int | None: + return next((n for n, s in enumerate(sections) if s.role == role), None) + + +def _in_reading_order(sections: list[Section]) -> list[Section]: + """The intro first, the summary last, whatever order the model gave them in. One of each.""" + intro = [s for s in sections if s.role == "intro"][:1] + summary = [s for s in sections if s.role == "summary"][-1:] + return intro + [s for s in sections if s.role in ("body", "synthesis")] + summary + + +def repair(bp: Blueprint, themes: list[Theme]) -> Blueprint: + """Make the plan agree with the weights. Raises BlueprintUnavailable when fewer than two sections remain.""" + by_id = {t.id: t for t in themes} + sections = [s for s in bp.sections if s.heading.strip()] + planned = len(sections) + + # A body theme is the subject of one section; unknown ids go. + seen: set[str] = set() + for s in sections: + s.theme_ids = [i for n, i in enumerate(s.theme_ids) if i in by_id and i not in s.theme_ids[:n]] + if s.role == "body": + s.theme_ids = [i for i in s.theme_ids if i not in seen] + seen.update(s.theme_ids) + before = len(sections) + sections = [s for s in sections if s.role != "body" or s.theme_ids] + if len(sections) < before: + log.info("report blueprint: dropped %d body sections with no theme of their own", before - len(sections)) + sections = _in_reading_order(sections) + + def insert(section: Section) -> None: + at = _first_index(sections, "summary") + sections.insert(len(sections) if at is None else at, section) + + # Every major or asked-for theme has a section; whatever else no section took is gathered into one. + for t in sorted(themes, key=lambda t: -t.weight): + if t.id not in seen and (t.tier == "major" or t.pinned): + log.info("report blueprint: no section for %s theme %s (%s), added one", "asked-for" if t.pinned else t.tier, t.id, + t.name) + insert(Section(heading=t.name, role="body", brief=t.summary, theme_ids=[t.id], depth="deep")) + seen.add(t.id) + rest = [t.id for t in sorted(themes, key=lambda t: -t.weight) if t.id not in seen] + if rest: + log.info("report blueprint: themes in no section %s go to %r", rest, MORE_HEADING) + more = next((s for s in sections if s.role == "body" and s.heading == MORE_HEADING), None) + if more: + more.theme_ids += rest + else: + insert(Section(heading=MORE_HEADING, role="body", theme_ids=rest, depth="brief", + brief="The smaller themes in the posts, in a line or two each.")) + seen.update(rest) + + # Too many sections: the lightest body sections (never one holding a major or asked-for theme) are gathered into one brief + # section, so a footnote never shares a section with a main theme. + def weight(s: Section) -> float: + return sum(by_id[i].weight for i in s.theme_ids) + + def protected(s: Section) -> bool: + return any(by_id[i].tier == "major" or by_id[i].pinned for i in s.theme_ids) + + while len(sections) > MAX_SECTIONS: + more = next((s for s in sections if s.role == "body" and s.heading == MORE_HEADING), None) + candidates = [s for s in sections if s.role == "body" and not protected(s) and s is not more] + if not candidates: + break + lightest = min(candidates, key=weight) + log.info("report blueprint: %d sections, over the %d allowed: merging %r (themes %s) into %r", len(sections), + MAX_SECTIONS, lightest.heading, lightest.theme_ids, MORE_HEADING) + if more: + more.theme_ids += lightest.theme_ids + sections.remove(lightest) + else: + lightest.heading, lightest.must_cover = MORE_HEADING, [] + lightest.brief = "The smaller themes in the posts, in a line or two each." + + # intro first, summary last, depth from the weights, the summary restates the main themes. + sections = _in_reading_order(sections) + for s in [s for s in sections if s.role == "summary"]: + s.theme_ids = [t.id for t in themes if t.tier == "major" or t.pinned] or s.theme_ids + for s in sections: + s.depth = derived_depth(s, by_id) + if len(sections) < 2: + log.warning("report blueprint unusable: sections=%d planned=%d", len(sections), planned) + raise BlueprintUnavailable("the plan has fewer than two sections") + + # What the other body sections own, so no section writes another's points. The intro and summary may touch everything. + for s in sections: + s.covered_elsewhere = [] if s.role in ("intro", "summary") else [ + f"{o.heading}: {'; '.join(o.must_cover) or o.brief}"[:240] for o in sections + if o is not s and o.role in ("body", "synthesis")] + + headings = {s.heading.lower(): s.heading for s in sections} + connections = [] + for c in bp.connections: + ids = [i for i in c.get("theme_ids") or [] if i in by_id] + relation = (c.get("relation") or "").strip() + if len(ids) < 2 or not relation: + continue + home = headings.get(str(c.get("section", "")).strip().lower()) or next( + (s.heading for s in sections if s.role in ("body", "synthesis") and set(ids) & set(s.theme_ids)), None) + if home: + connections.append({"theme_ids": ids, "relation": relation[:200], "section": home}) + log.info("report blueprint checked: planned=%d final=%d connections=%d/%d", planned, len(sections), len(connections), + len(bp.connections)) + for n, s in enumerate(sections, start=1): + log.info("report section %d: heading=%r role=%s depth=%s themes=%s must_cover=%d embed=%s", n, s.heading, s.role, s.depth, + s.theme_ids, len(s.must_cover), (s.embed or {}).get("kind")) + return Blueprint(title=bp.title, storyline=bp.storyline, sections=sections, connections=connections[:MAX_CONNECTIONS]) + + +def plan_blueprint(db: Session, job: Job | None, workspace_id: uuid.UUID, title: str, request: str, + themes: list[Theme], relations: list[dict], kinds: list[str], language: str) -> Blueprint: + """The checked plan for these themes. Raises BlueprintUnavailable (or whatever the model call raised) when there is none.""" + log.info("report blueprint: asking the model workspace=%s themes=%d relations=%d allowed_visuals=%s", workspace_id, len(themes), + len(relations), kinds) + started = time.monotonic() + out = run.predict(PlanReportBlueprint, db=db, workspace_id=workspace_id, job=job, lm=report_plan_lm(), title=title, + request=request, themes=theme_lines(themes), relations=relation_lines(relations), allowed_kinds=kinds, + language=language, max_sections=MAX_SECTIONS) + sections = [] + for s in out.get("sections") or []: + heading = (s.get("heading") or "").strip() + if heading: + sections.append(Section( + heading=heading, role=s.get("role") if s.get("role") in ROLES else "body", brief=(s.get("brief") or "").strip(), + theme_ids=[str(i) for i in s.get("theme_ids") or []], depth=s.get("depth") or "standard", + must_cover=[str(p).strip() for p in s.get("must_cover") or [] if str(p).strip()][:4], embed=s.get("embed"))) + log.info("report blueprint: model answered sections=%d connections=%d seconds=%.1f", len(sections), + len(out.get("connections") or []), time.monotonic() - started) + bp = Blueprint(title=(out.get("report_title") or title).strip(), storyline=(out.get("storyline") or "").strip(), + sections=sections, connections=out.get("connections") or []) + return repair(bp, themes) + + +def outline(bp: Blueprint) -> list[str]: + """Every section as every writer sees it: number, heading, role, what it says.""" + return [f"{n}. {s.heading} ({s.role}): {s.brief}" for n, s in enumerate(bp.sections, start=1)] + + +def connection_lines(bp: Blueprint, themes: list[Theme], heading: str | None = None) -> list[str]: + """The connections (all of them, or the ones one section bridges) as 'Pricing + Hiring: how they connect (section)'.""" + names = {t.id: t.name for t in themes} + return [f"{' + '.join(names[i] for i in c['theme_ids'])}: {c['relation']} (bridged in: {c['section']})" + for c in bp.connections if heading is None or c["section"] == heading] diff --git a/backend/app/pipeline/report_evidence.py b/backend/app/pipeline/report_evidence.py new file mode 100644 index 0000000000000000000000000000000000000000..9bcee75d9b8a2879e4c0efd658e9aa6039d308fa --- /dev/null +++ b/backend/app/pipeline/report_evidence.py @@ -0,0 +1,166 @@ +"""The evidence a themed report section is written from. Plain code, no model. + +A section is about one or more themes (report_themes.py) and its depth (report_blueprint.py) says how much room it has. What +the writer gets, from the posts that have ideas in those themes: + + - the posts that say the most about them (most of the section's themes first, then most ideas) as the writer wrote them: numbered + lines taken from around the lines the ideas came from, with the start of the post for context; a short post that is mostly about + the theme goes in whole. Several posts, each with an equal share, so a section blends posts and does not walk through one; + - every other post in the themes as the short summaries already stored for it (label, note, points, and the lines to cite); + - nothing raw for a brief section: summaries only. + +Nothing is cited in the report, so the text carries no line numbers and no paths: a post is named by its title.""" + +import logging +import math +import uuid +from dataclasses import dataclass, field + +from app.corpus import Corpus +from app.models import Document +from app.pipeline.passages import _body_start +from app.pipeline.report_blueprint import Section +from app.pipeline.report_themes import Idea, Theme + +BUDGETS = {"deep": 32_000, "standard": 16_000, "brief": 4_000} # characters of evidence (about 8k, 4k and 1k tokens) +RAW_POSTS = {"deep": 5, "standard": 3, "brief": 0} # posts read as written, at most +RAW_SHARE = 0.75 # of the budget when other posts are summarised too +WHOLE_POST_CHARS = 10_000 # a post this short, mostly about the theme, goes in whole (about 2,500 tokens) +WHOLE_POST_SHARE = 0.5 +MARGIN = 6 # lines kept around the lines an idea came from +LEAD_LINES = 6 # lines kept from the start of a post, for context +JOIN_GAP = 2 # windows this close are one +SUMMARY_DETAILS = 2 + +log = logging.getLogger(__name__) + + +@dataclass +class Evidence: + blocks: list[str] = field(default_factory=list) + posts: list[str] = field(default_factory=list) # paths of every post used + raw_posts: int = 0 # how many of them were read as written + + +def _merge(ranges: list[tuple[int, int]]) -> list[tuple[int, int]]: + out: list[tuple[int, int]] = [] + for a, b in sorted(ranges): + if out and a <= out[-1][1] + JOIN_GAP: + out[-1] = (out[-1][0], max(out[-1][1], b)) + else: + out.append((a, b)) + return out + + +def _text(lines: list[str], a: int, b: int) -> list[str]: + return [lines[n - 1] for n in range(a, min(b, len(lines)) + 1) if lines[n - 1].strip()] + + +def _idea_ranges(d: Document, ideas: list[Idea], total: int, first: int) -> list[tuple[int, int]]: + out = [] + for idea in ideas: + for s in idea.sources: + try: + a, b = int(s["line_start"]), int(s["line_end"]) + except (KeyError, TypeError, ValueError): + continue + if s.get("path") == d.path and total: + out.append((max(first + 1, a - MARGIN), min(total, max(a, b) + MARGIN))) + return out + + +def raw_post(corpus: Corpus, d: Document, ideas: list[Idea], names: list[str], cap: int) -> str | None: + """One post as written: the whole of it when it is short and mostly about the section's themes, else the lead and the windows + around its ideas' lines, merged, cut to `cap` characters (the windows that fit, in order of the post). None if unreadable.""" + try: + lines = corpus.read_lines(d.path) + except Exception: + log.warning("report evidence: cannot read post %s, its summary stands in", d.path, exc_info=True) + return None + first = _body_start(lines) # lines before this are the file's header + head = f"POST {d.title} [themes: {', '.join(names)}]" + stored = len((d.metadata_json or {}).get("ideas") or []) or 1 + if len(d.clean_text or "") <= WHOLE_POST_CHARS and len(ideas) / stored >= WHOLE_POST_SHARE: + whole = "\n".join(_text(lines, first + 1, len(lines))) + if len(head) + len(whole) <= cap: + log.debug("report evidence: whole post %s (%d chars)", d.path, len(whole)) + return f"{head}\n{whole}" + ranges = _idea_ranges(d, ideas, len(lines), first) + windows = _merge([(first + 1, min(len(lines), first + LEAD_LINES))] + ranges) + parts, used = [], len(head) + for a, b in windows: + text = "\n".join(_text(lines, a, b)) + if not text: + continue + if parts and used + len(text) + 6 > cap: + break + parts.append(text[: max(cap - used, 200)] if not parts else text) + used += len(parts[-1]) + 6 + return f"{head}\n" + "\n[...]\n".join(parts) if parts else None + + +def _summary_line(idea: Idea, theme_name: str) -> str: + points = "; ".join(f"{x.get('label', '')}: {x.get('note', '')}".strip(": ") for x in idea.details[:SUMMARY_DETAILS] + if isinstance(x, dict)) + return f'- {idea.label}: {idea.note}' + (f" ({points})" if points else "") + f' (from "{idea.doc.title}", {theme_name})' + + +def build(corpus: Corpus, section: Section, themes: dict[str, Theme], budget: int | None = None) -> Evidence: + """The evidence for one body or synthesis section: raw posts first, then summaries of the rest, within `budget` characters.""" + budget = budget or BUDGETS.get(section.depth, BUDGETS["standard"]) + items: list[tuple[Theme, Idea]] = [(themes[t], i) for t in section.theme_ids if t in themes for i in themes[t].ideas] + out = Evidence() + if not items: + log.warning("report evidence: section=%r has no ideas (themes %s)", section.heading, section.theme_ids) + return out + by_doc: dict[uuid.UUID, list[tuple[Theme, Idea]]] = {} + for t, i in items: + by_doc.setdefault(i.doc.id, []).append((t, i)) + + def key(entries: list[tuple[Theme, Idea]]) -> tuple: + d = entries[0][1].doc + stamp = d.published_at.timestamp() if d.published_at else 0 + return (-len({t.id for t, _ in entries}), -len(entries), -stamp) + + ranked = sorted(by_doc.values(), key=key) + wanted = RAW_POSTS.get(section.depth, 0) + raw_budget = (budget if len(ranked) <= wanted else int(budget * RAW_SHARE)) if wanted else 0 + each = raw_budget // max(min(wanted, len(ranked)), 1) + rest: list[list[tuple[Theme, Idea]]] = [] + for entries in ranked: + if out.raw_posts < wanted: + d = entries[0][1].doc + names = list(dict.fromkeys(t.name for t, _ in entries)) + block = raw_post(corpus, d, [i for _, i in entries], names, each) + if block is not None: # an unreadable post is passed over: the next one takes its place, its summary stands in + out.blocks.append(block) + out.posts.append(d.path) + out.raw_posts += 1 + continue + rest.append(entries) + + summary_budget = max(budget - sum(len(b) for b in out.blocks), 0) + lists = [[_summary_line(i, t.name) for t, i in entries] for entries in rest] + lines, used, rank = [], 0, 0 + while any(rank < len(x) for x in lists): + for x in lists: + if rank < len(x) and used + len(x[rank]) <= summary_budget: + lines.append(x[rank]) + used += len(x[rank]) + 1 + rank += 1 + if used >= summary_budget: + break + if lines: + out.blocks.append("SUMMARIES of other posts\n" + "\n".join(lines)) + out.posts += [e[0][1].doc.path for e in rest if e[0][1].doc.path not in out.posts] + log.info("report evidence: section=%r depth=%s themes=%s ideas=%d posts=%d raw_posts=%d summarised_posts=%d summary_lines=%d " + "chars=%d budget=%d", section.heading, section.depth, section.theme_ids, len(items), len(by_doc), out.raw_posts, + len(rest), len(lines), sum(len(b) for b in out.blocks), budget) + return out + + +def scale_budgets(sections: list[Section], total: int) -> dict[str, int]: + """Section heading -> budget, the standard budgets scaled down together when they add up to more than `total`.""" + wanted = {s.heading: BUDGETS.get(s.depth, BUDGETS["standard"]) for s in sections if s.role in ("body", "synthesis")} + factor = min(1.0, total / max(sum(wanted.values()), 1)) + return {h: max(int(math.floor(b * factor)), 1_000) for h, b in wanted.items()} diff --git a/backend/app/pipeline/report_themes.py b/backend/app/pipeline/report_themes.py new file mode 100644 index 0000000000000000000000000000000000000000..9fce94dac23670e83d718fe60c6b1b168afbed48 --- /dev/null +++ b/backend/app/pipeline/report_themes.py @@ -0,0 +1,280 @@ +"""Themes for a report: what a notebook's posts are about, and how much of it each thing is. + +A report no longer reads the posts' text to find out. Every post already has its key ideas stored (documents.metadata_json["ideas"], +written by generate.extract_ideas, each with the lines it came from), so those ideas stand for the posts, however many are picked: + + 1. one model call (GroupThemes) names themes and says which ideas belong to which (it sees at most MAX_LISTED_IDEAS of them); + 2. code files every other idea into a theme and counts: how many posts and ideas each theme has gives its weight, so the weights + are true for the whole selection and not for the sample the model saw. A model groups well and counts badly. + +The weights decide how much room a theme gets in the report (report_blueprint.py) and how much of the posts' own text it is written +from (report_evidence.py). A theme the reader asked for is moved up a tier whatever its share.""" + +import logging +import time +import uuid +from collections import Counter +from dataclasses import dataclass, field + +from sqlalchemy.orm import Session + +from app.config import settings +from app.corpus import Corpus +from app.llm import run +from app.llm.provider import fast_lm +from app.llm.signatures import GroupThemes +from app.models import Document, Job, Notebook, Workspace +from app.pipeline.generate import extract_ideas, ideas_fresh +from app.pipeline.idea_pool import _tokens +from app.pipeline.passages import _body_start, notebook_docs, workspace_docs +from app.services.jobs import update_job +from app.services.plans import effective_plan + +log = logging.getLogger(__name__) + +SMALL_SELECTION = 5 # posts or fewer: the posts are read whole, as a report always was +MIN_FRESH_SHARE = 0.5 # fewer posts than this with ideas stored: read the posts' text as before +MAX_LISTED_IDEAS = 300 # ideas the model sees; the rest are filed by code +MAX_THEMES = 12 +MIN_THEMES = 2 +MAJOR_AT, MEDIUM_AT = 0.25, 0.10 # a theme's share of the report: major from, medium from +POST_WEIGHT, IDEA_WEIGHT = 0.6, 0.4 # how many posts mention it counts for more than how many ideas +CATCH_ALL = "More from your posts" +OPENING_LINES = 8 # lines of a post's opening that stand for a post with no ideas stored yet + + +class ThemesUnavailable(Exception): + """The ideas did not group into themes: the caller reads the posts as before.""" + + +@dataclass +class Idea: + id: str + doc: Document + label: str + note: str + details: list[dict] = field(default_factory=list) + sources: list[dict] = field(default_factory=list) + bare: bool = False # no ideas stored for this post: this is its title and opening + + +@dataclass +class Theme: + id: str + name: str + summary: str + ideas: list[Idea] + asked: bool = False # the reader's request asks for it + weight: float = 0.0 # share of the whole, all themes add up to 1 + tier: str = "minor" # major | medium | minor + pinned: bool = False # asked for: always given a deep section + + @property + def doc_ids(self) -> set[uuid.UUID]: + return {i.doc.id for i in self.ideas} + + +# The posts + +def select_docs(db: Session, workspace_id: uuid.UUID, notebook_id: uuid.UUID | None, params: dict) -> list[Document]: + """The posts a report is made from: the ones picked, or the notebook's, newest first. Not cut at ARTIFACT_MAX_POSTS: only + what the plan indexes (and REPORT_MAX_POSTS, when set) limits it.""" + picked = [uuid.UUID(str(i)) for i in params.get("document_ids") or []] + nb = db.get(Notebook, notebook_id) if notebook_id else None + if picked: + docs = workspace_docs(db, workspace_id, picked) + elif nb: + docs = notebook_docs(db, nb.id) + else: + docs = [] + cap = effective_plan(db, db.get(Workspace, workspace_id)).indexed_posts + if settings.report_max_posts > 0: + cap = min(cap, settings.report_max_posts) if cap and cap > 0 else settings.report_max_posts + chosen = docs[:cap] if cap and cap > 0 else docs + log.info("report posts: workspace=%s notebook=%s picked=%d found=%d used=%d plan_cap=%s report_max_posts=%s", workspace_id, + notebook_id, len(picked), len(docs), len(chosen), cap, settings.report_max_posts) + if len(chosen) < len(docs): + log.warning("report posts cut by the plan or REPORT_MAX_POSTS: workspace=%s found=%d used=%d", workspace_id, len(docs), + len(chosen)) + return chosen + + +def ensure_ideas(db: Session, job: Job | None, workspace_id: uuid.UUID, docs: list[Document]) -> None: + """Extract the ideas of posts that have none yet, together, at most REPORT_INLINE_IDEAS of them. The rest are represented + by their opening; the background ideas job reaches them later.""" + stale = [d for d in docs if not ideas_fresh(d)] + if not stale or settings.report_inline_ideas <= 0: + log.info("report ideas: workspace=%s posts=%d stale=%d inline_limit=%d, nothing extracted now", workspace_id, len(docs), + len(stale), settings.report_inline_ideas) + return + started = time.monotonic() + result = extract_ideas(db, job, workspace_id, [str(d.id) for d in stale], parallel=True, limit=settings.report_inline_ideas) + for d in stale: + db.refresh(d) + log.info("report ideas: workspace=%s posts=%d stale=%d extracted=%s skipped=%s left_for_background=%d seconds=%.1f", + workspace_id, len(docs), len(stale), result.get("extracted"), result.get("skipped"), + sum(1 for d in stale if not ideas_fresh(d)), time.monotonic() - started) + + +def fresh_share(docs: list[Document]) -> float: + return sum(1 for d in docs if ideas_fresh(d)) / max(len(docs), 1) + + +def _opening_idea(d: Document, di: int, corpus: Corpus) -> Idea: + """A post with no ideas stored: its title, its topics and where its opening is, so it still counts and can be cited.""" + meta = d.metadata_json or {} + tags = ", ".join(t["name"] for t in (meta.get("topics") or [])[:3] if t.get("name")) + try: + lines = corpus.read_lines(d.path) + except Exception: + lines = [] + first = _body_start(lines) + 1 if lines else 1 + sources = [{"path": d.path, "line_start": first, "line_end": min(first + OPENING_LINES, len(lines) or first)}] + return Idea(id=f"p{di}.0", doc=d, label=d.title[:80], bare=True, sources=sources, + note=f"Topics: {tags}" if tags else "Only the opening of this post was read.") + + +def collect_ideas(docs: list[Document], corpus: Corpus) -> list[Idea]: + """Every post's stored ideas as Ideas with ids like p3.1 (the third post's second idea), or its opening when it has none.""" + out: list[Idea] = [] + for di, d in enumerate(docs): + stored = [i for i in ((d.metadata_json or {}).get("ideas") or []) if (i.get("label") or "").strip()] \ + if ideas_fresh(d) else [] + if not stored: + out.append(_opening_idea(d, di, corpus)) + continue + for ii, i in enumerate(stored): + out.append(Idea(id=f"p{di}.{ii}", doc=d, label=i["label"].strip(), note=(i.get("note") or "").strip(), + details=i.get("details") or [], sources=i.get("sources") or [])) + log.info("report ideas collected: posts=%d ideas=%d posts_with_only_an_opening=%d", len(docs), len(out), + sum(1 for i in out if i.bare)) + return out + + +# Themes + +def listed_ideas(ideas: list[Idea]) -> list[Idea]: + """The ideas the model sees: all of them, or when there are too many, the same number from every post in turn.""" + if len(ideas) <= MAX_LISTED_IDEAS: + return ideas + by_doc: dict[uuid.UUID, list[Idea]] = {} + for i in ideas: + by_doc.setdefault(i.doc.id, []).append(i) + lists, picked, rank = list(by_doc.values()), [], 0 + while len(picked) < MAX_LISTED_IDEAS and any(rank < len(x) for x in lists): + for x in lists: + if rank < len(x) and len(picked) < MAX_LISTED_IDEAS: + picked.append(x[rank]) + rank += 1 + return picked + + +def _file_leftovers(themes: list[Theme], leftovers: list[Idea]) -> None: + """Every idea the model did not place goes to the theme its post's other ideas are mostly in, else the theme whose words it + shares most, else a catch-all theme: the way the Mind Constellation files what the model leaves out.""" + by_doc: dict[uuid.UUID, Counter] = {} + for ti, t in enumerate(themes): + for i in t.ideas: + by_doc.setdefault(i.doc.id, Counter())[ti] += 1 + theme_words = [_tokens(" ".join([t.name, t.summary] + [i.label for i in t.ideas[:20]])) for t in themes] + # A word in most themes ("idea", "post") says nothing about which one an idea is about. + common = {w for w, n in Counter(w for tw in theme_words for w in tw).items() if n > max(1, len(themes) // 2)} + theme_words = [tw - common for tw in theme_words] + catch: Theme | None = None + for idea in leftovers: + counts = by_doc.get(idea.doc.id) + if counts: + themes[counts.most_common(1)[0][0]].ideas.append(idea) + continue + words = _tokens(f"{idea.label} {idea.note}") - common + best, score = max(((ti, len(words & tw)) for ti, tw in enumerate(theme_words)), key=lambda x: x[1]) + if score > 0: + themes[best].ideas.append(idea) + continue + if catch is None: + catch = Theme(id="", name=CATCH_ALL, summary="Ideas that fit none of the other themes.", ideas=[]) + themes.append(catch) + catch.ideas.append(idea) + log.info("report leftover ideas filed: leftovers=%d catch_all=%d", len(leftovers), len(catch.ideas) if catch else 0) + + +def weigh(themes: list[Theme], total_posts: int, total_ideas: int) -> None: + """Each theme's share of the whole, from how many posts and ideas it has (not from anything the model said), and its tier. A + theme the reader asked for moves up one tier and is pinned. If nothing reached major, the heaviest theme becomes major.""" + raw = [POST_WEIGHT * len(t.doc_ids) / max(total_posts, 1) + IDEA_WEIGHT * len(t.ideas) / max(total_ideas, 1) for t in themes] + total = sum(raw) or 1.0 + for t, r in zip(themes, raw, strict=True): + t.weight = round(r / total, 4) + t.tier = "major" if t.weight >= MAJOR_AT else "medium" if t.weight >= MEDIUM_AT else "minor" + if themes and not any(t.tier == "major" for t in themes): + max(themes, key=lambda t: t.weight).tier = "major" + for t in themes: + if t.asked: + t.pinned = True + t.tier = {"minor": "medium", "medium": "major"}.get(t.tier, t.tier) + + +def _relations(raw: list[dict] | None, themes: list[Theme]) -> list[dict]: + by_name = {t.name.lower(): t.id for t in themes} + out = [] + for r in raw or []: + a, b = by_name.get(str(r.get("a", "")).strip().lower()), by_name.get(str(r.get("b", "")).strip().lower()) + relation = (r.get("relation") or "").strip() + if a and b and a != b and relation: + out.append({"a": a, "b": b, "relation": relation[:200]}) + return out[:8] + + +def group_themes(db: Session, job: Job | None, workspace_id: uuid.UUID, title: str, request: str, + ideas: list[Idea]) -> tuple[list[Theme], list[dict]]: + """Themes with their weights, and how they relate (by theme id). Raises ThemesUnavailable when the answer is not usable.""" + by_id = {i.id: i for i in ideas} + listed = listed_ideas(ideas) + listing = [f"{i.id} | {i.label}: {i.note[:140]}" for i in listed] + log.info("report themes: asking the model workspace=%s ideas=%d listed=%d (cap %d) posts=%d", workspace_id, len(ideas), + len(listed), MAX_LISTED_IDEAS, len({i.doc.id for i in ideas})) + started = time.monotonic() + out = run.predict(GroupThemes, db=db, workspace_id=workspace_id, job=job, lm=fast_lm(), title=title, + request=request, ideas=listing) + themes: list[Theme] = [] + taken: set[str] = set() + dropped = 0 + for t in out.get("themes") or []: + name = (t.get("name") or "").strip() + wanted = list(t.get("idea_ids") or []) + mine = [by_id[i] for i in wanted if i in by_id and i not in taken] + dropped += len(wanted) - len(mine) # unknown ids, or ideas another theme already took + if not name or not mine: + log.info("report theme skipped: name=%r usable_ideas=%d", name, len(mine)) + continue + taken.update(i.id for i in mine) + themes.append(Theme(id="", name=name[:80], summary=(t.get("summary") or "").strip()[:400], ideas=mine, + asked=bool(t.get("matches_request")))) + if len(themes) == MAX_THEMES: + break + log.info("report themes: model answered themes=%d usable=%d ids_dropped=%d seconds=%.1f", len(out.get("themes") or []), + len(themes), dropped, time.monotonic() - started) + if len(themes) < MIN_THEMES: + log.warning("report themes unusable: usable=%d needed=%d", len(themes), MIN_THEMES) + raise ThemesUnavailable("the ideas did not group into themes") + _file_leftovers(themes, [i for i in ideas if i.id not in taken]) + for n, t in enumerate(themes, start=1): + t.id = f"t{n}" + weigh(themes, len({i.doc.id for i in ideas}), len(ideas)) + relations = _relations(out.get("relations"), themes) + for t in themes: + log.info("report theme: id=%s name=%r weight=%.3f tier=%s asked=%s pinned=%s posts=%d ideas=%d", t.id, t.name, t.weight, + t.tier, t.asked, t.pinned, len(t.doc_ids), len(t.ideas)) + log.info("report theme relations: model=%d kept=%d", len(out.get("relations") or []), len(relations)) + return themes, relations + + +def theme_summary(t: Theme) -> dict: + """What is kept with the report about a theme.""" + return {"id": t.id, "name": t.name, "summary": t.summary, "weight": t.weight, "tier": t.tier, "pinned": t.pinned, + "posts": len(t.doc_ids), "ideas": len(t.ideas)} + + +def progress(db: Session, job: Job | None, value: float, message: str) -> None: + if job: + update_job(db, job, progress=value, message=message) diff --git a/backend/app/routers/artifacts.py b/backend/app/routers/artifacts.py index bfc9cb46e6a9f70f58221cbef28f0244edc0ee53..5a936b1bf400878b4cb9901f05bc5f2bfdb66845 100644 --- a/backend/app/routers/artifacts.py +++ b/backend/app/routers/artifacts.py @@ -1,3 +1,4 @@ +import logging import re import uuid from typing import Literal @@ -10,8 +11,9 @@ from app.auth import Ctx, get_ctx from app.config import settings from app.infographics.image import ImageUnavailable, render_png from app.infographics.themes import theme_id -from app.models import Artifact, CalendarItem, Chat, Document, Job, Notebook, UserSavedVoice +from app.models import Artifact, ArtifactFeedback, CalendarItem, Chat, Document, Job, Notebook, UserSavedVoice from app.routers.notebooks import ensure_archive_notebook +from app.services import artifact_feedback from app.services.artifacts import latest_jobs, serialize_artifact, start_artifact from app.services.jobs import create_job, serialize_job from app.services.plans import effective_plan, plan_limit_error @@ -25,6 +27,8 @@ from app.slides.build import stored as stored_deck from app.slides.build import view as slide_pages from app.slides.themes import theme_id as slide_theme_id +log = logging.getLogger(__name__) + router = APIRouter(prefix="/api/artifacts", tags=["artifacts"]) ArtifactType = Literal["summary", "audio_overview", "video", "quote_card", "carousel", "launch_kit", "mind_map", "quiz", @@ -69,7 +73,8 @@ class GenerateIn(BaseModel): question_types: list[Literal["multiple_choice", "multiple_select", "fill_blank", "short_answer"]] = Field( default_factory=lambda: ["multiple_choice", "multiple_select"], min_length=1) language: str | None = Field(None, max_length=40) - theme: str | None = Field(None, max_length=30) # infographic: app.infographics.themes; slide_deck: app.slides.themes + theme: str | None = Field(None, max_length=30) # infographic: one of the looks (app.infographics.design.LOOKS); slide_deck: app.slides.themes + ig_style: str | None = Field(None, max_length=20) # infographic: the layout to lean towards (design.STYLES), auto when empty # slide_deck: read on its own ("detailed") or shown behind a speaker ("presenter"), and how many slides deck_format: Literal["detailed", "presenter"] = "detailed" deck_length: Literal["short", "default", "long"] = "default" @@ -166,6 +171,7 @@ def generate(body: GenerateIn, ctx: Ctx = Depends(get_ctx)): if body.format == "debate" and body.hosts == 1: raise HTTPException(400, "A debate needs two hosts") check_limit(ctx.db, ctx.workspace, "audio_minutes", body.minutes) + check_limit(ctx.db, ctx.workspace, "audio_overviews", 1) ids = body.document_ids or ([body.document_id] if body.document_id else []) hosts = _hosts(ctx, body) if body.hosts == 1: @@ -197,7 +203,8 @@ def generate(body: GenerateIn, ctx: Ctx = Depends(get_ctx)): check_limit(ctx.db, ctx.workspace, "infographics", 1) ids = body.document_ids or ([body.document_id] if body.document_id else []) params = {"document_ids": [str(i) for i in ids], "chat_ids": [str(i) for i in body.chat_ids or []], - "theme": theme_id(body.theme), "instructions": (body.instructions or "").strip()[:600]} + "theme": theme_id(body.theme), "style": body.ig_style or "auto", + "instructions": (body.instructions or "").strip()[:600]} title = f"Infographic: {params['instructions'][:60] or target}" elif body.type == "slide_deck": ids = body.document_ids or ([body.document_id] if body.document_id else []) @@ -257,13 +264,85 @@ def list_artifacts( rows = ctx.db.scalars(query.order_by(Artifact.created_at.desc()).offset(max(offset, 0)).limit(min(limit, 200))) rows = list(rows.all()) jobs = latest_jobs(ctx.db, [a.id for a in rows]) - return {"total": total, "items": [serialize_artifact(a, jobs.get(a.id)) for a in rows]} + items = [serialize_artifact(a, jobs.get(a.id)) for a in rows] + return {"total": total, "items": artifact_feedback.attach(ctx.db, ctx.user.id, items)} + + +@router.get("/feedback/export") +def export_artifact_feedback(ctx: Ctx = Depends(get_ctx), rating: Literal["up", "down"] | None = None, type: str | None = None, + limit: int = 200): + """The ratings of generated reports, quizzes, flashcards and infographics, newest first, with what each one was, to see + which kinds do well and which do not.""" + q = select(ArtifactFeedback).where(ArtifactFeedback.workspace_id == ctx.workspace.id) + if rating: + q = q.where(ArtifactFeedback.rating == (1 if rating == "up" else -1)) + if type: + q = q.where(ArtifactFeedback.artifact_type == type) + rows = ctx.db.scalars(q.order_by(ArtifactFeedback.updated_at.desc()).limit(min(limit, 1000))).all() + log.info("feedback exported: rows=%d rating=%s type=%s user=%s workspace=%s", len(rows), rating, type, ctx.user.id, + ctx.workspace.id) + return [{"id": str(f.id), "artifact_id": str(f.artifact_id) if f.artifact_id else None, "type": f.artifact_type, + "rating": "up" if f.rating > 0 else "down", "reasons": f.reasons, "comment": f.comment, "snapshot": f.snapshot, + "rated_at": f.updated_at.isoformat() if f.updated_at else None} for f in rows] @router.get("/{artifact_id}") def get_artifact(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)): a = get_artifact_or_404(ctx, artifact_id) - return serialize_artifact(a, latest_jobs(ctx.db, [a.id]).get(a.id)) + _offer_visuals_once(ctx, a) + return artifact_feedback.attach(ctx.db, ctx.user.id, [serialize_artifact(a, latest_jobs(ctx.db, [a.id]).get(a.id))])[0] + + +def _offer_visuals_once(ctx: Ctx, a: Artifact) -> None: + """An interactive report made before interactive reports always offered visuals gets them the first time it is opened (once: after + that, the ones the reader has added or taken away stay as they are). A document report never offers any.""" + c = a.content_json or {} + if a.type != "report" or a.status != "ready" or c.get("format") != "interactive" or c.get("suggestions_offered") \ + or not c.get("blocks"): + return + from app.pipeline.report import EMBED_KINDS, fill_suggestions, merge_suggestions + + has_posts = bool((c.get("source") or {}).get("document_ids")) + kinds = [k for k in EMBED_KINDS if k != "mind_map" or has_posts] + a.content_json = {**c, "suggestions": merge_suggestions(fill_suggestions(list(c.get("suggestions") or []), c["blocks"], kinds)), + "suggestions_offered": True} + ctx.db.commit() + + +class FeedbackIn(BaseModel): + rating: Literal["up", "down"] | None # null takes the rating back + reasons: list[str] = [] + comment: str | None = Field(None, max_length=1000) + + +@router.put("/{artifact_id}/feedback") +def rate_artifact(artifact_id: uuid.UUID, body: FeedbackIn, ctx: Ctx = Depends(get_ctx)): + """Thumbs up or down on a finished report, quiz, flashcard set or infographic. Rating again replaces it; null removes it.""" + a = get_artifact_or_404(ctx, artifact_id) + allowed = artifact_feedback.REASONS.get(a.type) + if allowed is None or a.status != "ready": + log.warning("feedback refused: artifact=%s type=%s status=%s user=%s", a.id, a.type, a.status, ctx.user.id) + raise HTTPException(400, "Only a finished report, quiz, flashcard set or infographic can be rated") + row = ctx.db.scalar(select(ArtifactFeedback).where(ArtifactFeedback.artifact_id == a.id, ArtifactFeedback.user_id == ctx.user.id)) + if body.rating is None: + if row: + ctx.db.delete(row) + ctx.db.commit() + log.info("feedback removed: artifact=%s type=%s user=%s workspace=%s", a.id, a.type, ctx.user.id, ctx.workspace.id) + return {"feedback": None} + if not row: + row = ArtifactFeedback(workspace_id=ctx.workspace.id, artifact_id=a.id, user_id=ctx.user.id) + ctx.db.add(row) + row.artifact_type = a.type + row.rating = 1 if body.rating == "up" else -1 + row.reasons = [r for r in dict.fromkeys(body.reasons) if r in allowed] if body.rating == "down" else [] + row.comment = (body.comment or "").strip() or None + row.snapshot = artifact_feedback.snapshot(a) + ctx.db.commit() + # The comment's text stays out of the logs (it is the user's own words); its length and the reasons are enough. + log.info("feedback saved: artifact=%s type=%s rating=%s reasons=%s comment_chars=%d user=%s workspace=%s", + a.id, a.type, body.rating, row.reasons, len(row.comment or ""), ctx.user.id, ctx.workspace.id) + return {"feedback": artifact_feedback.feedback_out(row)} @router.get("/{artifact_id}/image") diff --git a/backend/app/routers/notebooks.py b/backend/app/routers/notebooks.py index be555fa2ded84e1d81e232cce465f11ca6d24ed5..da3b81a3f85fc3c772fec96b36cec204f58fbc20 100644 --- a/backend/app/routers/notebooks.py +++ b/backend/app/routers/notebooks.py @@ -36,6 +36,7 @@ from app.pipeline.research import Turn, research from app.pipeline.trace import ChatTrace, use_trace from app.routers.sources import serialize_document from app.services.artifacts import latest_jobs, serialize_artifact +from app.services import report_suggestions from app.services.jobs import create_job, record_usage from app.services.memory import list_facts, profile_text from app.services.storage import storage @@ -86,6 +87,8 @@ def _add_docs(ctx: Ctx, nb: Notebook, ids: list[uuid.UUID]) -> int: fresh = [d for d in ctx.db.scalars(select(Document).where(Document.id.in_(valid - existing))) if not ideas_fresh(d)] if fresh and settings.llm_api_key: create_job(ctx.db, ctx.workspace.id, "ideas", {"document_ids": [str(d.id) for d in fresh]}, max_attempts=2) + if valid - existing: + report_suggestions.queue_warmup(ctx.db, ctx.workspace.id) # the Create report dialog's templates, ready before it is opened return len(valid - existing) @@ -254,7 +257,9 @@ def notebook_artifacts(notebook_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)): select(Artifact).where(Artifact.notebook_id == nb.id).order_by(Artifact.created_at.desc()) ).all()) jobs = latest_jobs(ctx.db, [a.id for a in rows]) - return [serialize_artifact(a, jobs.get(a.id)) for a in rows] + from app.services import artifact_feedback # the ratings ride along on each artifact the panel lists + + return artifact_feedback.attach(ctx.db, ctx.user.id, [serialize_artifact(a, jobs.get(a.id)) for a in rows]) @router.get("/{notebook_id}/chats") diff --git a/backend/app/routers/reports.py b/backend/app/routers/reports.py index bdadd496e35964edff26e0eabf236d3123e46bb6..81404fe28a40e532ea292b3f2b8540150c9cfbb5 100644 --- a/backend/app/routers/reports.py +++ b/backend/app/routers/reports.py @@ -7,12 +7,11 @@ from sqlalchemy import select from app.auth import Ctx, get_ctx from app.llm import run -from app.llm.provider import fast_lm -from app.llm.signatures import SuggestReportTemplates from app.models import Artifact -from app.pipeline.report import EMBED_KINDS, remove_block +from app.pipeline.report import EMBED_KINDS, keep_visual, remove_block from app.routers.artifacts import get_artifact_or_404 from app.routers.videos import FocusIn, _focus_material +from app.services import report_suggestions from app.services.artifacts import latest_jobs, serialize_artifact from app.services.jobs import create_job from app.services.report_templates import INTERACTIVE, TEMPLATES @@ -20,7 +19,6 @@ from app.services.usage import check_limit router = APIRouter(prefix="/api/reports", tags=["reports"]) -SUGGEST_ITEMS = 5 # sources the AI reads to suggest templates @router.get("/templates") @@ -35,25 +33,15 @@ class SuggestIn(FocusIn): @router.post("/suggest") def suggest_templates(body: SuggestIn, ctx: Ctx = Depends(get_ctx)): - """Four kinds of report these sources could make, each with its own ready-written instructions. Never an error: - when the AI has nothing usable the list is empty and the dialog shows only the fixed templates.""" - material, _docs = _focus_material(ctx, body) + """Four kinds of report these sources could make, each with its own ready-written instructions. The AI reads each post's stored + ideas and topics (not its text); the answer is kept, so the same sources get it again at once (and it is prepared in the + background as soon as ideas are extracted). Never an error: when there is nothing usable the list is empty and the dialog shows + only the fixed templates.""" + material, docs = _focus_material(ctx, body) if not material: return {"templates": []} - try: - out = run.predict(SuggestReportTemplates, db=ctx.db, workspace_id=ctx.workspace.id, lm=fast_lm(), - items=list(material.values())[:SUGGEST_ITEMS], topic=(body.topic or "").strip() or "(none)") - except Exception: - return {"templates": []} - seen, found = set(), [] - for t in out.get("templates") or []: - name, prompt = (t.get("name") or "").strip(), (t.get("prompt") or "").strip() - if not name or not prompt or name.lower() in seen: - continue - seen.add(name.lower()) - found.append({"id": f"suggested-{len(found) + 1}", "name": name[:60], - "description": (t.get("description") or "").strip()[:200], "prompt": prompt[:1500]}) - return {"templates": found[:4]} + items = report_suggestions.suggestion_items(material, docs) + return {"templates": report_suggestions.suggest(ctx.db, ctx.workspace.id, items, (body.topic or "").strip())} class AddBlockIn(BaseModel): @@ -105,9 +93,11 @@ def add_block(artifact_id: uuid.UUID, body: AddBlockIn, ctx: Ctx = Depends(get_c @router.delete("/{artifact_id}/blocks/{block_id}") def delete_block(artifact_id: uuid.UUID, block_id: str, ctx: Ctx = Depends(get_ctx)): a = _report_or_404(ctx, artifact_id) + removed = next((b for b in (a.content_json or {}).get("blocks") or [] if b.get("id") == block_id), None) content = remove_block(a.content_json or {}, block_id) if content is None: raise HTTPException(404, "Visual not found") + keep_visual(ctx.db, a, removed) a.content_json = content ctx.db.commit() return serialize_artifact(a, latest_jobs(ctx.db, [a.id]).get(a.id)) diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py index 90f9d25677a623194fe8db1dd737ff9476a4833b..822de0e247143659a572f367608a43762fc0a020 100644 --- a/backend/app/routers/settings.py +++ b/backend/app/routers/settings.py @@ -1,14 +1,9 @@ -import re - -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends from pydantic import BaseModel, Field -from sqlalchemy import select from app.auth import Ctx, get_ctx from app.config import settings as app_settings -from app.models import Upload from app.services.plans import effective_plan, plan_dict -from app.services.storage import storage from app.services.usage import usage_report router = APIRouter(prefix="/api/settings", tags=["settings"]) @@ -17,24 +12,18 @@ router = APIRouter(prefix="/api/settings", tags=["settings"]) class SettingsIn(BaseModel): name: str | None = Field(None, max_length=200) workspace_name: str | None = Field(None, min_length=1, max_length=200) - brand_name: str | None = Field(None, max_length=120) - brand_accent: str | None = None - logo_upload_id: str | None = None # "" removes the logo training_opt_in: bool | None = None allow_public_links: bool | None = None email_unsubscribed: bool | None = None def _serialize(ctx: Ctx) -> dict: - brand = ctx.workspace.brand_json or {} return { "user": {"name": ctx.user.name, "email": ctx.user.email, "auth_provider": ctx.user.auth_provider.value, "email_unsubscribed": ctx.user.email_unsubscribed}, "workspace": {"id": str(ctx.workspace.id), "name": ctx.workspace.name, "training_opt_in": ctx.workspace.training_opt_in, "allow_public_links": ctx.workspace.allow_public_links}, - "brand": {"name": brand.get("name"), "accent": brand.get("accent") or "#217cff", - "logo_url": storage.presign_get(brand["logo_key"]) if brand.get("logo_key") else None}, "plan": plan_dict(effective_plan(ctx.db, ctx.workspace)), "billing_enabled": app_settings.billing_enabled, "usage": usage_report(ctx.db, ctx.workspace), @@ -45,6 +34,15 @@ def _serialize(ctx: Ctx) -> dict: } +@router.get("/limits") +def artifact_limits(_ctx: Ctx = Depends(get_ctx)): + """How many posts a report or infographic reads, and a dialog ticks to start with (ARTIFACT_MAX_POSTS), so the pickers match the + server. A quiz or flashcard set can read as many posts as the plan indexes (selectable_posts).""" + from app.pipeline.material import max_posts + + return {"posts": max_posts(), "selectable_posts": effective_plan(_ctx.db, _ctx.workspace).indexed_posts} + + @router.get("") def get_settings(ctx: Ctx = Depends(get_ctx)): return _serialize(ctx) @@ -62,23 +60,6 @@ def update_settings(body: SettingsIn, ctx: Ctx = Depends(get_ctx)): ctx.workspace.allow_public_links = body.allow_public_links if body.email_unsubscribed is not None: ctx.user.email_unsubscribed = body.email_unsubscribed - brand = dict(ctx.workspace.brand_json or {}) - if body.brand_name is not None: - brand["name"] = body.brand_name.strip() or None - if body.brand_accent is not None: - if not re.fullmatch(r"#[0-9a-fA-F]{6}", body.brand_accent): - raise HTTPException(422, "Use a hex color like #217cff") - brand["accent"] = body.brand_accent.lower() - if body.logo_upload_id is not None: - if body.logo_upload_id == "": - brand.pop("logo_key", None) - else: - upload = ctx.db.scalar(select(Upload).where(Upload.id == body.logo_upload_id, - Upload.workspace_id == ctx.workspace.id)) - if not upload or upload.status != "complete" or not upload.content_type.startswith("image/"): - raise HTTPException(400, "Upload an image first") - brand["logo_key"] = upload.key - ctx.workspace.brand_json = brand ctx.db.commit() return _serialize(ctx) diff --git a/backend/app/routers/sources.py b/backend/app/routers/sources.py index e7b60e17c927f8d8168a0ac4efa1048a148529c2..651e7ee0f9a902a4f20edb5860a300833d7f876d 100644 --- a/backend/app/routers/sources.py +++ b/backend/app/routers/sources.py @@ -10,7 +10,7 @@ from app.corpus import INDEX, Corpus, CorpusError from app.models import Document, Job, NotebookDocument, Source, Upload from app.pipeline.ingest import IMPORTS_FEED, FeedNotFound, discover_feed, imports_source, rebuild_index from app.services.jobs import create_job, serialize_job -from app.services.plans import effective_plan, plan_limit_error +from app.services.plans import effective_plan, plan_limit_error, post_room from app.services.storage import storage router = APIRouter(prefix="/api/sources", tags=["sources"]) @@ -74,7 +74,7 @@ async def create_source(body: SourceIn, ctx: Ctx = Depends(get_ctx)): select(func.count()).select_from(Source) .where(Source.workspace_id == ctx.workspace.id, Source.feed_url != IMPORTS_FEED) ) - if count >= plan.sources: + if 0 <= plan.sources <= count: raise plan_limit_error(plan, "sources", f"Your plan includes {plan.sources} sources.") source = Source(workspace_id=ctx.workspace.id, feed_url=found.feed_url, platform=found.platform, site_url=found.site_url, title=found.title) @@ -135,8 +135,17 @@ def delete_source(source_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)): return {"ok": True, "removed_posts": len(docs)} +def _require_room(ctx: Ctx) -> None: + """A link or a file adds a post: refused (with the upgrade prompt) once the plan's posts are all indexed.""" + plan = effective_plan(ctx.db, ctx.workspace) + if post_room(ctx.db, ctx.workspace.id, plan) <= 0: + raise plan_limit_error(plan, "indexed_posts", f"Your plan indexes up to {plan.indexed_posts} posts, and they are all " + "used. Remove a post or upgrade to add more.") + + @router.post("/url") def import_url(body: SourceIn, ctx: Ctx = Depends(get_ctx)): + _require_room(ctx) source = imports_source(ctx.db, ctx.workspace.id) ctx.db.commit() job = create_job(ctx.db, ctx.workspace.id, "import_url", {"source_id": str(source.id), "url": body.url}) @@ -148,6 +157,7 @@ def import_upload(body: UploadImportIn, ctx: Ctx = Depends(get_ctx)): upload = ctx.db.scalar(select(Upload).where(Upload.id == body.upload_id, Upload.workspace_id == ctx.workspace.id)) if not upload or upload.status != "complete": raise HTTPException(404, "Upload not found or not finished") + _require_room(ctx) source = imports_source(ctx.db, ctx.workspace.id) ctx.db.commit() job = create_job(ctx.db, ctx.workspace.id, "import_upload", diff --git a/backend/app/routers/video_edit.py b/backend/app/routers/video_edit.py index 8db81d830838a219ea75ea5f4679c68b9085fa34..4c39a4da174e7c190d0550a27dd944b933bef96c 100644 --- a/backend/app/routers/video_edit.py +++ b/backend/app/routers/video_edit.py @@ -4,7 +4,7 @@ Only paths in RULES pass (anything else is 404). Before forwarding, the video must be this workspace's (b2v_access.video_or_404), every template / voice / style id in a JSON body must be this workspace's (check_refs), -premium (★) features need a paid plan, and AI edits come out of the workspace's own counter (given back if +AI edits come out of the workspace's own counter (given back if blog2video refuses). A template switch also uses one video, as it does on blog2video. JSON bodies, multipart uploads (images, voiceovers, portraits) and binary answers (downloads, stills) pass through. @@ -35,8 +35,6 @@ S = r"\d+" # a scene id @dataclass(frozen=True) class Rule: ai_edits: int = 0 # AI edits this call uses (per workspace, video_limits "ai_edits") - per_scene: bool = False # ai_edits is per scene_ids entry (avatar batches) - premium: str | None = None # ★: what to call it in the upgrade message uses_video: bool = False # a template switch: one video from the allowance raw: bool = False # binary answer (download, still) timeout: float = 30 @@ -94,29 +92,12 @@ RULES: list[tuple[str, re.Pattern, Rule]] = [(m, re.compile(f"^{p}$"), r) for m, ("GET", "regenerate-script/preview", Rule()), ("POST", "regenerate-script/verify", Rule()), ("POST", "regenerate-script/regenerate", Rule()), - # AI chat editing ★ - ("POST", "chat", Rule(ai_edits=1, premium="AI chat editing", timeout=120)), - ("GET", "chat/history", Rule(premium="AI chat editing")), - # Avatars ★ (blog2video charges the whole batch at authorize: 10 AI edits per scene) - ("POST", "avatar-batch/authorize", Rule(ai_edits=10, per_scene=True, premium="Avatars")), - ("POST", rf"scenes/{S}/avatar", Rule(premium="Avatars")), - ("GET", rf"scenes/{S}/avatar-status", Rule()), - ("DELETE", rf"scenes/{S}/avatar", Rule()), - ("POST", rf"scenes/{S}/avatar-matte", Rule(premium="Avatars")), - ("POST", "avatar-matte-all", Rule(premium="Avatars")), - ("POST", "avatar-retry-failed", Rule(premium="Avatars")), - ("GET", "avatar-progress", Rule()), - ("POST", "avatar-portrait", Rule(premium="Avatars", timeout=60)), - ("DELETE", "avatar-portrait", Rule()), - ("PATCH", rf"scenes/{S}/avatar-focus", Rule()), - ("PATCH", rf"scenes/{S}/avatar-appearance", Rule()), # Render, download, frames ("POST", "render", Rule(status="rendering")), ("GET", "render-status", Rule()), ("POST", "cancel-render", Rule()), ("GET", "download-url", Rule()), ("GET", "download", Rule(raw=True, timeout=120)), - ("GET", "download-studio", Rule(raw=True, premium="The Remotion source download", timeout=120)), ("GET", "render-still", Rule(raw=True, timeout=60)), ("POST", "render-stills", Rule(timeout=120)), ]] @@ -157,19 +138,12 @@ async def _read_body(request: Request) -> tuple[dict | None, dict | None, list | def _charge(ctx: Ctx, rule: Rule, body: dict | None, form: dict | None) -> int: - """Premium, references and counters, all before blog2video is called. Returns AI edits taken.""" + """References and counters, all before blog2video is called. Returns AI edits taken.""" plan = effective_plan(ctx.db, ctx.workspace) - if rule.premium: - video_limits.require_premium(ctx.db, ctx.workspace, rule.premium, plan) refs = body if isinstance(body, dict) else (form or {}) with upstream(): # checking a template or voice may need blog2video's catalog b2v_access.check_refs(ctx, refs, plan) edits = rule.ai_edits - if rule.per_scene: - scenes = (body or {}).get("scene_ids") if isinstance(body, dict) else None - if not isinstance(scenes, list) or not scenes: - raise HTTPException(400, "Pick at least one scene") - edits *= len(scenes) if edits: video_limits.take(ctx.db, ctx.workspace, "ai_edits", edits, plan=plan) if rule.uses_video and not video_quota.reserve(ctx.db, ctx.workspace.id): diff --git a/backend/app/routers/video_templates.py b/backend/app/routers/video_templates.py index 642a702265e7259f9ae4e35a0cffbee8dd17f0e3..5dc70a1fa011d70edff7842af97942ed359d4ef4 100644 --- a/backend/app/routers/video_templates.py +++ b/backend/app/routers/video_templates.py @@ -179,7 +179,6 @@ def delete_template(template_id: int, ctx: Ctx = Depends(get_ctx), _: None = Dep @dataclass(frozen=True) class Rule: - ai_edit: bool = False # ★ one AI edit metric: str | None = None # another counter this uses (a lifetime slot, a daily AI step) raw: bool = False @@ -190,8 +189,6 @@ RULES: list[tuple[str, re.Pattern, Rule]] = [(m, re.compile(f"^{p}$"), r) for m, ("PUT", "", Rule()), ("GET", "code", Rule()), ("POST", "upload-logo", Rule()), - ("POST", f"scenes/{K}/ai-edit", Rule(ai_edit=True)), - ("GET", f"scenes/{K}/ai-edit/status", Rule()), ("GET", "scene-drafts", Rule()), ("GET", f"scenes/{K}/draft", Rule()), ("POST", f"scenes/{K}/draft/apply", Rule()), @@ -214,10 +211,6 @@ def _rule(method: str, path: str) -> Rule | None: def _take(ctx: Ctx, rule: Rule) -> list[str]: taken: list[str] = [] try: - if rule.ai_edit: - plan = video_limits.require_premium(ctx.db, ctx.workspace, "AI template editing") - video_limits.take(ctx.db, ctx.workspace, "ai_edits", plan=plan) - taken.append("ai_edits") if rule.metric: video_limits.take(ctx.db, ctx.workspace, rule.metric) taken.append(rule.metric) diff --git a/backend/app/routers/video_voices.py b/backend/app/routers/video_voices.py index bc1f626deec106aa928d006c6b6a6b69d13e895d..1c23807c2fdb4b72dcf82c9f512a87a090b7e2f3 100644 --- a/backend/app/routers/video_voices.py +++ b/backend/app/routers/video_voices.py @@ -1,24 +1,19 @@ -"""Voices for videos: the workspace's saved voices, the built-in library, custom voices (design, clone), and its -Notestack voices (made on the Voice page; blog2video speaks with the same ElevenLabs account). +"""Voices for videos: the workspace's saved voices, the free built-in library, and its Notestack voices (made on the Voice +page; they speak audio overviews, not videos). "Your voices" (user_saved_voices, as many as wanted) is the one list for audio overviews and videos, and lives only here: blog2video's saved-voice list is one list for the whole account. -Custom voices are made on our blog2video account, so b2v_custom_voices records which workspace made each one; -only that workspace can use, play or delete it. Designing, keeping, cloning and samples are premium (★) and come out -of the workspace's own daily and total counters (video_limits). """ import logging -from typing import Literal -from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile +from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from sqlalchemy import select from app.auth import Ctx, get_ctx -from app.models import B2VCustomVoice, UserSavedVoice +from app.models import UserSavedVoice from app.routers.videos import b2v_ready, upstream -from app.services import b2v_access, video_limits from app.services import blog2video as b2v from app.services.notestack_voices import notestack_voices, save_notestack_voice @@ -26,7 +21,6 @@ log = logging.getLogger(__name__) router = APIRouter(prefix="/api/video-voices", tags=["videos"]) -CLONE_MAX_BYTES = 50 * 1024 * 1024 STARTER_VOICES = 4 # a new workspace starts with this many free built-in voices saved @@ -36,7 +30,7 @@ def _labels(v: dict) -> dict: def _voice_out(v: UserSavedVoice, ours: set[str] = frozenset()) -> dict: return {"voice_id": v.voice_id, "name": v.name, "preview_url": v.preview_url, "gender": v.gender, - "accent": v.accent, "premium": v.premium, "is_custom": v.is_custom, + "accent": v.accent, "source": "notestack" if v.voice_id in ours else "blog2video"} @@ -45,7 +39,7 @@ def _library_out(v: dict, saved: set[str]) -> dict: return {"voice_id": v["voice_id"], "name": (v.get("name") or "Voice").split(" - ")[0].strip(), "description": v.get("description") or labels.get("description") or "", "preview_url": v.get("preview_url"), "gender": labels.get("gender"), "accent": labels.get("accent"), "age": labels.get("age"), - "premium": v.get("plan") == "paid", "saved": v["voice_id"] in saved} + "saved": v["voice_id"] in saved} def _save_builtin(ctx: Ctx, v: dict) -> UserSavedVoice: @@ -53,37 +47,36 @@ def _save_builtin(ctx: Ctx, v: dict) -> UserSavedVoice: row = UserSavedVoice(workspace_id=ctx.workspace.id, voice_id=v["voice_id"], name=(v.get("name") or "Voice").split(" - ")[0].strip()[:255], preview_url=v.get("preview_url"), - gender=labels.get("gender"), accent=labels.get("accent"), premium=v.get("plan") == "paid") + gender=labels.get("gender"), accent=labels.get("accent")) ctx.db.merge(row) return row -def _saved(ctx: Ctx) -> list[UserSavedVoice]: - return list(ctx.db.scalars(select(UserSavedVoice).where(UserSavedVoice.workspace_id == ctx.workspace.id) - .order_by(UserSavedVoice.created_at))) +def _saved(ctx: Ctx, ours: set[str] = frozenset()) -> list[UserSavedVoice]: + """The saved voices that can still be used: free built-in voices and the workspace's Notestack voices. Voices that + were designed or cloned for videos, and paid built-in ones, are gone.""" + rows = ctx.db.scalars(select(UserSavedVoice).where(UserSavedVoice.workspace_id == ctx.workspace.id) + .order_by(UserSavedVoice.created_at)) + return [v for v in rows if v.voice_id in ours or not (v.is_custom or v.premium)] @router.get("") def voices(ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)): - """Your voices (audio overviews and videos), the built-in library (to save from) and my custom voices.""" + """Your voices (audio overviews and videos), the free built-in library (to save from) and your Notestack voices.""" with upstream(): - library = b2v.prebuilt_voices() - saved = _saved(ctx) + library = [v for v in b2v.prebuilt_voices() if v.get("plan") != "paid"] + mine = notestack_voices(ctx.db, ctx.workspace.id) + ours = {v["voice_id"] for v in mine} + saved = _saved(ctx, ours) if not saved: - for v in [v for v in library if v.get("plan") != "paid" and v.get("voice_id")][:STARTER_VOICES]: + for v in [v for v in library if v.get("voice_id")][:STARTER_VOICES]: _save_builtin(ctx, v) ctx.db.commit() - saved = _saved(ctx) + saved = _saved(ctx, ours) ids = {v.voice_id for v in saved} - custom = ctx.db.scalars(select(B2VCustomVoice).where(B2VCustomVoice.workspace_id == ctx.workspace.id) - .order_by(B2VCustomVoice.created_at.desc())) - mine = notestack_voices(ctx.db, ctx.workspace.id) - ours = {v["voice_id"] for v in mine} return {"saved": [_voice_out(v, ours) for v in saved], "notestack": [{**v, "saved": v["voice_id"] in ids} for v in mine], - "library": [_library_out(v, ids) for v in library if v.get("voice_id")], - "custom": [{"id": c.b2v_custom_voice_id, "voice_id": c.voice_id, "name": c.name, "source": c.source, - "preview_url": c.preview_url, "saved": c.voice_id in ids} for c in custom]} + "library": [_library_out(v, ids) for v in library if v.get("voice_id")]} class SaveIn(BaseModel): @@ -94,25 +87,17 @@ class SaveIn(BaseModel): def save_voice(body: SaveIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)): if ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": body.voice_id}): return _voice_out(ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": body.voice_id})) - custom = ctx.db.scalar(select(B2VCustomVoice).where(B2VCustomVoice.voice_id == body.voice_id, - B2VCustomVoice.workspace_id == ctx.workspace.id)) mine = next((v for v in notestack_voices(ctx.db, ctx.workspace.id) if v["voice_id"] == body.voice_id), None) if mine: - # A Notestack voice: saved on any plan, premium to use in a video. No preview link (the Voice page plays it). + # A Notestack voice: saved on any plan, for audio overviews. No preview link (the Voice page plays it). row = save_notestack_voice(ctx.db, ctx.workspace.id, mine["voice_id"], mine["name"]) ctx.db.commit() return _voice_out(row, {row.voice_id}) - if custom: - row = ctx.db.merge(UserSavedVoice(workspace_id=ctx.workspace.id, voice_id=custom.voice_id, name=custom.name, - preview_url=custom.preview_url, premium=True, is_custom=True)) - else: - with upstream(): - v = next((v for v in b2v.prebuilt_voices() if v.get("voice_id") == body.voice_id), None) - if not v: - raise HTTPException(404, "Voice not found") - if v.get("plan") == "paid": - video_limits.require_premium(ctx.db, ctx.workspace, "This voice") - row = _save_builtin(ctx, v) + with upstream(): + v = next((v for v in b2v.prebuilt_voices() if v.get("voice_id") == body.voice_id), None) + if not v or v.get("plan") == "paid": + raise HTTPException(404, "Voice not found") + row = _save_builtin(ctx, v) ctx.db.commit() return _voice_out(row) @@ -125,158 +110,3 @@ def unsave_voice(voice_id: str, ctx: Ctx = Depends(get_ctx)): ctx.db.delete(row) ctx.db.commit() return {"ok": True} - - -# Design a voice ★: a description or options -> a few previews; keep one to make it a custom voice - - -class DesignPromptIn(BaseModel): - prompt: str = Field(min_length=20, max_length=1000) - - -class DesignPresetIn(BaseModel): - gender: str = Field("", max_length=30) - age: str = Field("", max_length=30) - persona: str = Field("", max_length=100) - speed: str = Field("", max_length=30) - accent: str = Field("", max_length=60) - - -def _design(ctx: Ctx, path: str, body: dict) -> dict: - plan = video_limits.require_premium(ctx.db, ctx.workspace, "Designing a voice") - video_limits.take(ctx.db, ctx.workspace, "voice_designs_daily", plan=plan) - try: - with upstream(): - data = b2v.request("POST", path, json=body, timeout=90) or {} - except HTTPException: - video_limits.give_back(ctx.db, ctx.workspace.id, "voice_designs_daily") - raise - return {"previews": [{k: p.get(k) for k in ("generated_voice_id", "audio_base_64", "media_type", "duration_secs")} - for p in data.get("previews") or []]} - - -@router.post("/design/prompt") -def design_from_prompt(body: DesignPromptIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)): - return _design(ctx, "/api/voices/design-from-prompt", body.model_dump()) - - -@router.post("/design/preset") -def design_from_preset(body: DesignPresetIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)): - return _design(ctx, "/api/voices/design-from-preset", body.model_dump()) - - -class KeepIn(BaseModel): - generated_voice_id: str = Field(min_length=1, max_length=200) - source: Literal["prompt", "preset"] - name: str = Field(min_length=1, max_length=100) - prompt_text: str | None = Field(None, max_length=1000) - preview_url: str | None = Field(None, max_length=1000) - - -def _record_custom(ctx: Ctx, made: dict, source: str) -> dict: - row = B2VCustomVoice(b2v_custom_voice_id=int(made["id"]), workspace_id=ctx.workspace.id, - voice_id=made["voice_id"], name=(made.get("name") or "My voice")[:255], source=source, - preview_url=made.get("preview_url")) - ctx.db.add(row) - ctx.db.merge(UserSavedVoice(workspace_id=ctx.workspace.id, voice_id=row.voice_id, name=row.name, - preview_url=row.preview_url, premium=True, is_custom=True)) # a new voice is saved - ctx.db.commit() - return {"id": row.b2v_custom_voice_id, "voice_id": row.voice_id, "name": row.name, "source": source, - "preview_url": row.preview_url, "saved": True} - - -def _make_custom(ctx: Ctx, source: str, send) -> dict: - plan = video_limits.require_premium(ctx.db, ctx.workspace, "Custom voices") - video_limits.take(ctx.db, ctx.workspace, "custom_voices", plan=plan) - try: - with upstream(): - made = send() - except HTTPException: - video_limits.give_back(ctx.db, ctx.workspace.id, "custom_voices") - raise - return _record_custom(ctx, made, source) - - -@router.post("/custom", status_code=201) -def keep_designed_voice(body: KeepIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)): - """Keep one designed preview as a permanent custom voice.""" - payload = {"voice_id": body.generated_voice_id, "source": "prompt" if body.source == "prompt" else "form", - "name": body.name, "prompt_text": body.prompt_text, "preview_url": body.preview_url} - return _make_custom(ctx, body.source, - lambda: b2v.request("POST", "/api/voices/custom", json=payload, timeout=60)) - - -@router.post("/clone", status_code=201) -async def clone_voice(name: str = Form(..., min_length=1, max_length=100), remove_background_noise: bool = Form(True), - file: UploadFile = File(...), ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)): - """Clone a voice from one audio or video file (up to 50 MB).""" - data = await file.read(CLONE_MAX_BYTES + 1) - if not data: - raise HTTPException(400, "The file is empty.") - if len(data) > CLONE_MAX_BYTES: - raise HTTPException(400, "The file must be under 50 MB.") - form = {"name": name, "remove_background_noise": "true" if remove_background_noise else "false"} - files = {"file": (file.filename or "voice.mp3", data, file.content_type or "application/octet-stream")} - return _make_custom(ctx, "clone", - lambda: b2v.request("POST", "/api/voices/clone", data=form, files=files, timeout=180)) - - -@router.get("/custom/{custom_id}/preview") -def custom_preview(custom_id: int, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)): - row = b2v_access.owned_custom_voice(ctx, custom_id) - if row.preview_url: - return {"preview_url": row.preview_url, "ready": True} - with upstream(): - result = b2v.request("GET", f"/api/voices/custom/{custom_id}/preview") or {} - if result.get("preview_url"): - row.preview_url = result["preview_url"] - saved = ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": row.voice_id}) - if saved: - saved.preview_url = row.preview_url - ctx.db.commit() - return {"preview_url": result.get("preview_url"), "ready": bool(result.get("ready"))} - - -@router.delete("/custom/{custom_id}") -def delete_custom(custom_id: int, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)): - row = b2v_access.owned_custom_voice(ctx, custom_id) - try: - with upstream(): - b2v.request("DELETE", f"/api/voices/custom/{custom_id}") - except HTTPException as e: - if e.status_code != 404: - raise - saved = ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": row.voice_id}) - if saved: - ctx.db.delete(saved) - ctx.db.delete(row) - ctx.db.commit() - video_limits.give_back(ctx.db, ctx.workspace.id, "custom_voices") - return {"ok": True} - - -# Voice sample ★: a few seconds of the chosen voice with the chosen tuning - - -class SampleIn(BaseModel): - voice_gender: Literal["female", "male"] = "female" - voice_accent: str = Field("american", max_length=30) - custom_voice_id: str | None = Field(None, max_length=100) - voice_emotion: str | None = Field(None, max_length=200) - video_style: str | None = Field(None, max_length=40) - - -@router.post("/sample") -def voice_sample(body: SampleIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)): - plan = video_limits.require_premium(ctx.db, ctx.workspace, "Voice samples") - payload = body.model_dump(exclude_none=True) - with upstream(): # checking the voice may need blog2video's catalog - b2v_access.check_refs(ctx, {k: v for k, v in payload.items() if k != "voice_emotion"}, plan) - video_limits.take(ctx.db, ctx.workspace, "voice_samples_daily", plan=plan) - try: - with upstream(): - audio, ctype = b2v.request("POST", "/api/voice/preview", json=payload, timeout=60, raw=True) - except HTTPException: - video_limits.give_back(ctx.db, ctx.workspace.id, "voice_samples_daily") - raise - return Response(audio, media_type=ctype or "audio/mpeg", headers={"Cache-Control": "no-store"}) diff --git a/backend/app/routers/videos.py b/backend/app/routers/videos.py index 61f697726f67e2ca0ac71ed284efb62f8d371443..627538fc9c16dfda262e220a4fa46867cde34059 100644 --- a/backend/app/routers/videos.py +++ b/backend/app/routers/videos.py @@ -140,7 +140,7 @@ class VideoOptions(BaseModel): # Step 1: Project (script review is always off: blog2video goes straight to the video) stock_footage_enabled: bool = True aspect_ratio: Literal["landscape", "portrait"] = "landscape" - video_length: Literal["short", "medium", "detailed", "more_detailed"] = "short" + video_length: Literal["short", "medium"] = "short" logo_upload_id: uuid.UUID | None = None # a file uploaded through /api/storage/uploads logo_position: Literal["bottom_right", "bottom_left", "top_left", "top_right"] = "bottom_right" logo_opacity: float = Field(0.9, ge=0.1, le=1.0) @@ -155,7 +155,6 @@ class VideoOptions(BaseModel): voice_gender: Literal["female", "male", "none"] = "female" voice_accent: str = Field("american", max_length=30) custom_voice_id: str | None = Field(None, max_length=100) - voice_emotion: str | None = Field(None, max_length=200) # ["","","",...] bgm_track_id: str | None = Field(None, max_length=100) bgm_volume: float = Field(0.10, ge=0, le=1) @@ -167,7 +166,6 @@ class VideoOptions(BaseModel): data["script_review_enabled"] = False if self.voice_gender == "none": data.pop("custom_voice_id", None) - data.pop("voice_emotion", None) if not self.bgm_track_id: data.pop("bgm_volume", None) return data @@ -369,9 +367,6 @@ def _check(ctx: Ctx, options: VideoOptions) -> tuple[dict, Plan]: if length := b2v_access.house_length(payload.get("video_style")): payload["video_length"] = length plan = b2v_access.check_refs(ctx, payload) - accent = (ctx.workspace.brand_json or {}).get("accent") - if plan.brand_kit and accent and "accent_color" not in payload: - payload["accent_color"] = accent return payload, plan @@ -626,10 +621,10 @@ def quota(ctx: Ctx = Depends(get_ctx)): def _with_posters(t: dict) -> dict: """Poster images for the template picker. Built-in templates have baked posters on blog2video's web app (a few - have none; the picker then shows the template's colors); designer templates carry their own image.""" + have none; the picker then shows the template's colors).""" if t.get("preview_image_url") and not t.get("preview_url"): return {**t, "preview_url": t["preview_image_url"]} - if t.get("preview_url") or not t.get("id") or str(t["id"]).startswith("crafted_"): + if t.get("preview_url") or not t.get("id"): return t base = f"{settings.b2v_app_url.rstrip('/')}/template-posters/{t['id']}" return {**t, "preview_url": f"{base}.webp", "preview_portrait_url": f"{base}-portrait.webp"} @@ -637,20 +632,15 @@ def _with_posters(t: dict) -> dict: @router.get("/catalog") def catalog(ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)): - """What the wizard offers this workspace: built-in and designer templates, its own ready custom templates, + """What the wizard offers this workspace: built-in templates, its own ready custom templates, built-in and its own styles, music, languages. Nothing another workspace made.""" with upstream(): builtin = b2v.builtin_templates() - try: - crafted = b2v.crafted_templates() - except b2v.B2VError: - crafted = [] mine = [{"id": f"custom_{t.b2v_template_id}", "name": t.name, "custom": True} for t in b2v_access.my_templates(ctx, ready_only=True)] styles = b2v_access.visible_styles(ctx) music = b2v.music_tracks() return {"templates": [_with_posters(t) for t in builtin if isinstance(t, dict)], - "crafted_templates": [_with_posters(t) for t in crafted if isinstance(t, dict)], "my_templates": mine, "video_styles": styles, "music": music, "languages": [{"code": c, "name": n} for c, n in LANGUAGES]} @@ -888,10 +878,6 @@ class ReviewPreviewIn(BaseModel): revision: int = Field(0, ge=0) -class ReviewAiIn(ReviewPreviewIn): - instruction: str = Field(min_length=2, max_length=1000) - - class ReviewApproveScene(DraftScene): preferred_layout: str | None = Field(None, max_length=100) source_fingerprint: str | None = Field(None, max_length=200) @@ -923,22 +909,6 @@ def narration_preview(artifact_id: uuid.UUID, scene_id: int, body: ReviewPreview json=body.model_dump(), timeout=60) -@router.post("/{artifact_id}/script/scenes/{scene_id}/ai-preview") -def ai_preview(artifact_id: uuid.UUID, scene_id: int, body: ReviewAiIn, ctx: Ctx = Depends(get_ctx), - _: None = Depends(b2v_ready)): - """★ Rewrite one scene from an instruction. Uses one AI edit.""" - _, vid, _ = b2v_access.video_or_404(ctx, artifact_id) - plan = video_limits.require_premium(ctx.db, ctx.workspace, "AI rewrites") - video_limits.take(ctx.db, ctx.workspace, "ai_edits", plan=plan) - try: - with upstream(): - return b2v.project_call("POST", vid, f"script-review/scenes/{scene_id}/ai-preview", - json=body.model_dump(), timeout=60) - except HTTPException: - video_limits.give_back(ctx.db, ctx.workspace.id, "ai_edits") - raise - - @router.post("/{artifact_id}/script/approve") def approve_script(artifact_id: uuid.UUID, body: ReviewApproveIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)): diff --git a/backend/app/services/artifact_feedback.py b/backend/app/services/artifact_feedback.py new file mode 100644 index 0000000000000000000000000000000000000000..62c26d80a7644939332d233aff108b8221587753 --- /dev/null +++ b/backend/app/services/artifact_feedback.py @@ -0,0 +1,47 @@ +"""Thumbs up or down on generated reports, quizzes, flashcards and infographics: what can be rated, why a thumbs down can be +given, and how the ratings ride along on the artifacts the app lists.""" + +import uuid + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models import Artifact, ArtifactFeedback + +# What each kind can be marked down for (the ids the app sends; anything else is dropped). +REASONS: dict[str, set[str]] = { + "report": {"inaccurate", "missed_points", "structure", "tone", "too_long", "too_short", "other"}, + "quiz": {"wrong_answers", "too_easy", "too_hard", "unclear", "off_topic", "other"}, + "flashcards": {"inaccurate", "too_basic", "too_detailed", "unclear", "other"}, + "infographic": {"messy_layout", "cut_off", "missing_content", "look", "hard_to_read", "other"}, +} +RATEABLE = tuple(REASONS) + + +def feedback_out(f: ArtifactFeedback | None) -> dict | None: + if not f: + return None + return {"rating": "up" if f.rating > 0 else "down", "reasons": f.reasons or [], "comment": f.comment} + + +def attach(db: Session, user_id: uuid.UUID, items: list[dict]) -> list[dict]: + """Add `feedback` (this person's rating, or None) to each serialized artifact that can be rated. One query.""" + ids = [uuid.UUID(i["id"]) for i in items if i.get("type") in RATEABLE] + rows = {f.artifact_id: f for f in db.scalars(select(ArtifactFeedback).where( + ArtifactFeedback.artifact_id.in_(ids), ArtifactFeedback.user_id == user_id))} if ids else {} + for i in items: + if i.get("type") in RATEABLE: + i["feedback"] = feedback_out(rows.get(uuid.UUID(i["id"]))) + return items + + +def snapshot(a: Artifact) -> dict: + """What the artifact was when it was rated: enough to tell what a rating is about.""" + c = a.content_json or {} + inner = c.get("content") if isinstance(c.get("content"), dict) else {} + keep = {k: c.get(k) for k in ("title", "prompt", "source", "theme", "format", "template_id", "difficulty", "question_count", + "card_count", "language", "topic") if c.get(k) is not None} + if a.type == "infographic": + keep["layout"] = "composed" if "body_html" in inner else "fixed" + keep["fit"] = inner.get("fit") + return keep diff --git a/backend/app/services/b2v_access.py b/backend/app/services/b2v_access.py index a8be1ffb10bf4789ff272ec060bbd03e0d777406..35474925adfc439f8fd045243b167f1f6c87a96b 100644 --- a/backend/app/services/b2v_access.py +++ b/backend/app/services/b2v_access.py @@ -1,6 +1,6 @@ """Who owns what on our one blog2video account. -blog2video's ownership checks stop at the account: any video, custom template, custom voice or style made with our +blog2video's ownership checks stop at the account: any video, custom template or style made with our key can be used, listed and edited with it. So every route that takes a blog2video id checks here first that it belongs to the current workspace (404 otherwise, so another workspace's ids are never confirmed), and every create or edit body has its references checked before it is forwarded (check_refs). @@ -12,11 +12,9 @@ from fastapi import HTTPException from sqlalchemy import select from app.auth import Ctx -from app.models import Artifact, B2VCustomVoice, B2VStyle, B2VTemplate, B2VVideo +from app.models import Artifact, B2VStyle, B2VTemplate, B2VVideo from app.services import blog2video as b2v -from app.services.notestack_voices import is_notestack_voice from app.services.plans import Plan, effective_plan -from app.services.video_limits import require_premium BUILTIN_STYLES = {"auto", "explainer", "storytelling", "promotional"} # Custom styles made on our blog2video account itself, offered to every workspace in place of the others when they @@ -29,7 +27,6 @@ HOUSE_STYLES = [ # (name on blog2video, video length, what the wizard says it i "scene building on the last. Best for YouTube, tutorials and full explainers. Usually 1 to " "2 minutes long."), ] -PREMIUM_LENGTHS = {"detailed", "more_detailed", "mdetailed"} def not_found(what: str = "Not found") -> HTTPException: @@ -79,14 +76,6 @@ def owned_template(ctx: Ctx, template_id: int) -> B2VTemplate: return row -def owned_custom_voice(ctx: Ctx, custom_voice_id: int) -> B2VCustomVoice: - row = ctx.db.scalar(select(B2VCustomVoice).where(B2VCustomVoice.b2v_custom_voice_id == custom_voice_id, - B2VCustomVoice.workspace_id == ctx.workspace.id)) - if not row: - raise not_found("Voice not found") - return row - - def owned_style(ctx: Ctx, style_id: int) -> B2VStyle: row = ctx.db.scalar(select(B2VStyle).where(B2VStyle.b2v_style_id == style_id, B2VStyle.workspace_id == ctx.workspace.id)) @@ -100,15 +89,6 @@ def _custom_id(ref: str, prefix: str) -> int | None: return int(raw) if ref.startswith(prefix) and raw.isdigit() else None -def crafted_ids() -> set[str]: - out = set() - for t in b2v.crafted_templates(): - tid = t.get("id") if isinstance(t, dict) else t - if isinstance(tid, str) and tid.startswith("crafted_"): - out.add(tid) - return out - - def check_template(ctx: Ctx, template: str, plan: Plan) -> None: if template == "default": return @@ -117,12 +97,6 @@ def check_template(ctx: Ctx, template: str, plan: Plan) -> None: if not row.ready: raise HTTPException(400, "That template is still being made.") return - if template.startswith("crafted_"): - # blog2video silently falls back to "default" for an unknown id, so check it exists. - if template not in crafted_ids(): - raise not_found("Template not found") - require_premium(ctx.db, ctx.workspace, "Designer templates", plan) - return if template not in {t.get("id") for t in b2v.builtin_templates() if isinstance(t, dict)}: raise not_found("Template not found") @@ -157,38 +131,26 @@ def check_style(ctx: Ctx, style: str) -> None: raise not_found("Video style not found") # includes your_style, which learns from every workspace's edits -def check_voice(ctx: Ctx, voice_id: str, plan: Plan) -> None: - """A built-in voice (premium if blog2video marks it paid), or one of this workspace's custom or Notestack - voices.""" - prebuilt = {v.get("voice_id"): v for v in b2v.prebuilt_voices()} - if voice_id in prebuilt: - if prebuilt[voice_id].get("plan") == "paid": - require_premium(ctx.db, ctx.workspace, "This voice", plan) - return - if ctx.db.scalar(select(B2VCustomVoice).where(B2VCustomVoice.voice_id == voice_id, - B2VCustomVoice.workspace_id == ctx.workspace.id)): - require_premium(ctx.db, ctx.workspace, "Custom voices", plan) - return - # One of this workspace's Notestack voices (clone, designed, added): same ElevenLabs account, premium like custom. - if is_notestack_voice(ctx.db, ctx.workspace.id, voice_id): - require_premium(ctx.db, ctx.workspace, "Custom voices", plan) - return - raise not_found("Voice not found") +def check_voice(voice_id: str) -> None: + """A free built-in voice (blog2video marks the paid ones).""" + voice = next((v for v in b2v.prebuilt_voices() if v.get("voice_id") == voice_id), None) + if not voice or voice.get("plan") == "paid": + raise not_found("Voice not found") def check_refs(ctx: Ctx, body: dict, plan: Plan | None = None) -> Plan: - """Every id in a create or edit body must be this workspace's, and premium options need a paid plan.""" + """Every id in a create or edit body must be this workspace's, and only the standard options are accepted.""" plan = plan or effective_plan(ctx.db, ctx.workspace) if body.get("template"): check_template(ctx, str(body["template"]), plan) if body.get("video_style"): check_style(ctx, str(body["video_style"])) if body.get("custom_voice_id") and body.get("voice_gender") != "none": - check_voice(ctx, str(body["custom_voice_id"]), plan) - if str(body.get("video_length") or "") in PREMIUM_LENGTHS: - require_premium(ctx.db, ctx.workspace, "Longer videos", plan) + check_voice(str(body["custom_voice_id"])) + if str(body.get("video_length") or "") not in {"", "short", "medium"}: + raise HTTPException(400, "That video length is not available.") if body.get("voice_emotion"): - require_premium(ctx.db, ctx.workspace, "Advanced voice options", plan) + raise HTTPException(400, "Voice tuning is not available.") return plan diff --git a/backend/app/services/billing.py b/backend/app/services/billing.py index 69ac2775a1f3ff1cae7ffb6d1b436babf3935b3a..8a4057be9f2e6fbe3ddbe20eb7e29eac5877052d 100644 --- a/backend/app/services/billing.py +++ b/backend/app/services/billing.py @@ -10,7 +10,7 @@ from sqlalchemy import func, select from sqlalchemy.orm import Session from app.config import settings -from app.models import Artifact, CalendarItem, Notebook, Source, Subscription, User, VoiceProfile, Workspace +from app.models import Artifact, CalendarItem, Document, Notebook, Source, Subscription, User, VoiceProfile, Workspace from app.services.plans import PLANS, Plan, effective_plan, next_plan, plan_dict from app.services.usage import month_usage from app.services.video_quota import sync_video_quota, video_usage @@ -27,15 +27,30 @@ METERS = { def _meters(plan: Plan, used: dict, sources: int) -> list[dict]: rows = [] - for key, (label, unit) in METERS.items(): + meters = dict(METERS) + if plan.audio_overviews >= 0: # a count of audio overviews (Free), not minutes + meters = {("audio_overviews" if k == "audio_minutes" else k): (("Audio overviews", "") if k == "audio_minutes" else v) + for k, v in METERS.items()} + for key, (label, unit) in meters.items(): limit = getattr(plan, key) rows.append({"key": key, "label": label, "unit": unit, "used": used[key], "limit": limit, "pct": 0 if limit < 0 else round(min(1.0, used[key] / max(limit, 1)), 3)}) rows.append({"key": "sources", "label": "Sources", "unit": "", "used": sources, "limit": plan.sources, - "pct": round(min(1.0, sources / max(plan.sources, 1)), 3)}) + "pct": 0 if plan.sources < 0 else round(min(1.0, sources / max(plan.sources, 1)), 3)}) return rows +def _more_meters(db: Session, workspace: Workspace, plan: Plan, used: dict) -> list[dict]: + """The rest of the plan's limits, shown when the sidebar card is opened: indexed posts, reports and infographics.""" + posts = db.scalar(select(func.count()).select_from(Document) + .where(Document.workspace_id == workspace.id, Document.path.is_not(None))) or 0 + rows = [("indexed_posts", "Posts indexed", posts, plan.indexed_posts), + ("reports", "Reports", used["reports"], plan.reports), + ("infographics", "Infographics", used["infographics"], plan.infographics)] + return [{"key": k, "label": label, "unit": "", "used": u, "limit": limit, + "pct": 0 if limit < 0 else round(min(1.0, u / max(limit, 1)), 3)} for k, label, u, limit in rows] + + def _count(db: Session, model, workspace_id, *where) -> int: return db.scalar(select(func.count()).select_from(model).where(model.workspace_id == workspace_id, *where)) or 0 @@ -57,23 +72,20 @@ def build_nudges(db: Session, workspace: Workspace, plan: Plan, meters: list[dic continue label = m["label"].lower() amount = f"{m['limit']:g} min of {label}" if m["unit"] else f"{m['limit']:g} {label}" + when = "" if plan.lifetime else " this month" if m["pct"] >= 1: - out.append(_nudge(f"empty-{m['key']}-{month}", "limit", f"Out of {label} this month", + out.append(_nudge(f"empty-{m['key']}-{month}", "limit", f"Out of {label}{when}", f"You have used all {amount}. Upgrade to keep launching.", "Refuel now", priority=100)) elif m["pct"] >= LOW_FUEL: left = f"{m['limit'] - m['used']:g}" + (" min" if m["unit"] else "") out.append(_nudge(f"low-{m['key']}-{month}", "limit", f"Running low on {label}", - f"Only {left} left this month. Upgrade before you stall mid launch.", + f"Only {left} left{when}. Upgrade before you stall mid launch.", "See plans", priority=90)) if not plan.voice_cloning: out.append(_nudge("perk-voice", "upgrade", "Narrate in your own voice", "Writer unlocks voice cloning, so every audio overview sounds like you.", "Unlock voice", priority=40)) - if not plan.brand_kit: - out.append(_nudge("perk-brand", "upgrade", "Put your brand on every render", - "Your colors and logo on videos, quote cards and carousels.", "Unlock brand kit", - priority=30)) sources = _count(db, Source, ws) if sources == 0: @@ -119,7 +131,7 @@ def build_nudges(db: Session, workspace: Workspace, plan: Plan, meters: list[dic def billing_status(db: Session, workspace: Workspace) -> dict: plan = effective_plan(db, workspace) - used = month_usage(db, workspace.id) + used = month_usage(db, workspace.id, plan.lifetime) videos = video_usage(db, workspace) used["videos"] = videos["used"] sources = _count(db, Source, workspace.id) @@ -136,6 +148,7 @@ def billing_status(db: Session, workspace: Workspace) -> dict: "has_billing_account": bool(sub and sub.provider_customer_id), "period_end": sub.current_period_end.isoformat() if sub and sub.current_period_end else None, "meters": meters, + "more_meters": _more_meters(db, workspace, plan, used), "since": used["since"], "videos_resets_at": videos["resets_at"], "nudges": build_nudges(db, workspace, plan, meters, can_upgrade), diff --git a/backend/app/services/notestack_voices.py b/backend/app/services/notestack_voices.py index 15277a5199b2d9e71edc288ea55379ce87ba3d1d..dc8e5f50be6900100348559db0ad69b88ce24ad8 100644 --- a/backend/app/services/notestack_voices.py +++ b/backend/app/services/notestack_voices.py @@ -1,6 +1,5 @@ """The workspace's own ElevenLabs voices, made or added on the Voice page: its clone, designed voices, and voices added -from the ElevenLabs library. blog2video speaks with the same ElevenLabs account, so these can be used in videos too -(as `custom_voice_id`); video_voices lists them and b2v_access accepts them.""" +from the ElevenLabs library. They speak audio overviews; video_voices lists them with the workspace's saved voices.""" import uuid @@ -32,8 +31,8 @@ def is_notestack_voice(db: Session, workspace_id: uuid.UUID, voice_id: str) -> b def save_notestack_voice(db: Session, workspace_id: uuid.UUID, voice_id: str, name: str) -> UserSavedVoice: - """Put a Notestack voice in the workspace's voices (the one list for audio and video), once. Saving needs no plan: - using it in a video is premium (b2v_access.check_voice). No preview link: it is played through /api/voice/preview. + """Put a Notestack voice in the workspace's voices (the one list for audio and video), once. Saving needs no plan; it speaks audio + overviews (a video uses the free built-in voices). No preview link: it is played through /api/voice/preview. The caller commits.""" row = db.get(UserSavedVoice, {"workspace_id": workspace_id, "voice_id": voice_id}) if row is None: diff --git a/backend/app/services/plans.py b/backend/app/services/plans.py index e7509848778360ce55e1dea4a4d3691f4d0cf9b5..63c4f2400ff87a1a855d71619c469d3353bcb347 100644 --- a/backend/app/services/plans.py +++ b/backend/app/services/plans.py @@ -31,36 +31,32 @@ class Plan: indexed_posts: int audio_minutes: int videos: int # blog2video videos per month, or in total when videos_monthly is False - launch_kits: int # -1 = unlimited + launch_kits: int # a month (or in total on a lifetime plan); -1 = unlimited voice_cloning: bool - brand_kit: bool features: tuple[str, ...] - # Premium (★) video options: long durations, paid and custom voices, voice tuning and samples, AI script - # rewrites, AI chat editing, avatars, the Remotion source download. - video_premium: bool = False # Per-workspace shares of what blog2video only limits account-wide (keys in VIDEO_LIMITS; 0 = none). video_limits: dict = field(default_factory=dict) videos_monthly: bool = True reports: int = 0 # reports a month; -1 = unlimited - infographics: int = 0 # infographics a month; -1 = unlimited + infographics: int = 0 # infographics a month (in total on a lifetime plan); -1 = unlimited + audio_overviews: int = -1 # audio overviews allowed (in total on a lifetime plan), whatever their length; -1 = no cap + + @property + def lifetime(self) -> bool: + """The allowances for videos, audio overviews, Launch Kits and infographics are totals that never reset (Free).""" + return not self.videos_monthly # metric -> period it counts over (app/services/video_limits.py) VIDEO_LIMITS = { - "ai_edits": "month", # scene regenerate/add, AI images, stock clips, AI chat, template AI edits, avatars + "ai_edits": "month", # scene regenerate/add, AI images, stock clips "templates": "all", # custom templates: blog2video's slots are lifetime, never given back "template_ai_daily": "day", # template theme extraction from a prompt or document, code generation - "voice_designs_daily": "day", - "voice_samples_daily": "day", - "custom_voices": "all", # kept voices; deleting one frees its place } -FREE_VIDEO_LIMITS = {"ai_edits": 20, "templates": 0, "template_ai_daily": 0, "voice_designs_daily": 0, - "voice_samples_daily": 0, "custom_voices": 0} -WRITER_VIDEO_LIMITS = {"ai_edits": 300, "templates": 2, "template_ai_daily": 3, "voice_designs_daily": 5, - "voice_samples_daily": 20, "custom_voices": 3} -STUDIO_VIDEO_LIMITS = {"ai_edits": 1000, "templates": 5, "template_ai_daily": 5, "voice_designs_daily": 10, - "voice_samples_daily": 40, "custom_voices": 10} +FREE_VIDEO_LIMITS = {"ai_edits": 20, "templates": 0, "template_ai_daily": 0} +WRITER_VIDEO_LIMITS = {"ai_edits": 300, "templates": 2, "template_ai_daily": 3} +STUDIO_VIDEO_LIMITS = {"ai_edits": 1000, "templates": 5, "template_ai_daily": 5} DEFAULT_PLANS: dict[str, Plan] = { @@ -69,23 +65,23 @@ DEFAULT_PLANS: dict[str, Plan] = { name="Free", tagline="For trying Notestack on your archive", price_monthly_usd=0, - sources=1, + sources=-1, indexed_posts=5, - audio_minutes=3, + audio_minutes=-1, + audio_overviews=1, videos=1, launch_kits=2, reports=2, infographics=2, voice_cloning=False, - brand_kit=False, features=( - "1 source, your latest 5 posts indexed", + "Up to 5 posts indexed, from any source", "Grounded research chat with citations", - "3 min of audio overviews a month", - "1 video to try it", - "2 Launch Kits a month", + "1 audio overview, any length", + "1 video", + "2 Launch Kits", + "2 infographics", "2 reports a month", - "2 infographics a month", ), video_limits=FREE_VIDEO_LIMITS, videos_monthly=False, @@ -95,26 +91,23 @@ DEFAULT_PLANS: dict[str, Plan] = { name="Writer", tagline="For writers publishing every week", price_monthly_usd=24.99, - sources=3, - indexed_posts=500, + sources=-1, + indexed_posts=100, audio_minutes=60, videos=10, - launch_kits=50, + launch_kits=20, reports=30, infographics=30, voice_cloning=True, - brand_kit=True, features=( - "3 sources, 500 indexed posts", - "60 min of audio overviews a month", + "Up to 100 posts indexed, from any source", + "10x more audio overviews than Free (60 min a month)", "10 videos a month", - "50 Launch Kits a month", + "20 Launch Kits a month", "30 reports a month", "30 infographics a month", "Voice cloning with consent", - "Your brand colors and logo", ), - video_premium=True, video_limits=WRITER_VIDEO_LIMITS, ), "studio": Plan( @@ -122,26 +115,24 @@ DEFAULT_PLANS: dict[str, Plan] = { name="Studio", tagline="For publications and power users", price_monthly_usd=48.99, - sources=10, - indexed_posts=5000, + sources=-1, + indexed_posts=250, audio_minutes=240, videos=20, - launch_kits=-1, + launch_kits=50, reports=-1, infographics=-1, voice_cloning=True, - brand_kit=True, features=( - "10 sources, 5,000 indexed posts", - "240 min of audio overviews a month", + "Up to 250 posts indexed, from any source", + "40x more audio overviews than Free (240 min a month)", "20 videos a month", - "Unlimited Launch Kits", + "50 Launch Kits a month", "Unlimited reports", "Unlimited infographics", "Launchpad calendar and resurfacing", "Priority rendering", ), - video_premium=True, video_limits=STUDIO_VIDEO_LIMITS, ), } @@ -154,10 +145,10 @@ def _plan_from_row(row: PlanRecord) -> Plan: return Plan(id=row.id, name=row.name, tagline=row.tagline or "", price_monthly_usd=float(row.price_monthly_usd or 0), sources=row.sources, indexed_posts=row.indexed_posts, audio_minutes=row.audio_minutes, - videos=row.videos, launch_kits=row.launch_kits, reports=row.reports, infographics=row.infographics, + videos=row.videos, launch_kits=row.launch_kits, reports=row.reports, infographics=row.infographics, audio_overviews=row.audio_overviews, voice_cloning=bool(row.voice_cloning), - brand_kit=bool(row.brand_kit), features=tuple(row.features or ()), - video_premium=bool(row.video_premium), videos_monthly=bool(row.videos_monthly), + features=tuple(row.features or ()), + videos_monthly=bool(row.videos_monthly), video_limits={**{k: 0 for k in VIDEO_LIMITS}, **(row.video_limits or {})}) @@ -251,6 +242,22 @@ def effective_plan(db: Session, workspace: Workspace) -> Plan: return PLANS.get(sub.plan, PLANS["free"]) +def post_room(db: Session, workspace_id, plan: Plan, source_id=None) -> int: + """How many more posts the plan lets this workspace index: its limit across every source, link and upload, less the + posts indexed already. With `source_id`, that source's own posts are left out of the count, and a source never loses + posts it already has indexed (a plan that went down keeps what is stored; it only stops new ones).""" + from sqlalchemy import func + + from app.models import Document + + base = select(func.count()).select_from(Document).where(Document.workspace_id == workspace_id, Document.path.is_not(None)) + total = db.scalar(base) or 0 + if source_id is None: + return max(plan.indexed_posts - total, 0) + own = db.scalar(base.where(Document.source_id == source_id)) or 0 + return max(plan.indexed_posts - (total - own), own) + + def plan_order() -> list[str]: return PLANS.order() diff --git a/backend/app/services/renderer.py b/backend/app/services/renderer.py index 3d3d8b6fc5ea72b96612bbd61247afc44707804b..54523a9dc8480d4bbdd825b094623a417b2b13de 100644 --- a/backend/app/services/renderer.py +++ b/backend/app/services/renderer.py @@ -57,15 +57,3 @@ def request_render(job: Job, artifact: Artifact, composition: str, props: dict | if resp.status_code == 400: raise PermanentJobError(f"Renderer rejected the props: {resp.text[:400]}") resp.raise_for_status() - - -def brand_props(workspace, *, ttl: int = 6 * 3600) -> dict: - brand = workspace.brand_json or {} - out: dict = {} - if brand.get("accent"): - out["accent"] = brand["accent"] - if brand.get("name"): - out["name"] = brand["name"] - if brand.get("logo_key"): - out["logoUrl"] = storage.presign_get(brand["logo_key"], ttl=ttl) - return out diff --git a/backend/app/services/report_suggestions.py b/backend/app/services/report_suggestions.py new file mode 100644 index 0000000000000000000000000000000000000000..b1dba8a5dcbdcdea2dcd8199b150aef4e2df2b13 --- /dev/null +++ b/backend/app/services/report_suggestions.py @@ -0,0 +1,148 @@ +"""The suggested templates in the Create report dialog: written by the AI from each post's stored ideas and topics, kept by a hash of +what it read, prepared in the background as soon as ideas are extracted, and served at once whenever the dialog opens.""" + +import hashlib +import logging +import uuid + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.config import settings +from app.llm import run +from app.llm.provider import suggest_templates_lm +from app.llm.signatures import SuggestReportTemplates +from app.models import Document, Notebook, ReportTemplateCache +from app.pipeline.generate import ideas_fresh +from app.pipeline.material import max_posts +from app.pipeline.passages import notebook_docs +from app.services.jobs import create_job, pending_job + +log = logging.getLogger("notestack") + +SUGGEST_ITEMS = 4 # sources the AI reads to suggest templates +SUMMARY_IDEAS = 3 # a post's stored ideas shown to the AI +SUMMARY_CHARS = 350 # most characters of one post's summary +WARM_NOTEBOOKS = 30 # notebooks prepared per run (the most recently changed) +KEEP_PER_WORKSPACE = 10 # stored sets kept per workspace; the oldest go + + +def summary(d: Document) -> str: + """A post as the AI sees it when suggesting templates: its title, its topics and the key ideas already extracted from it (the + ones the Mind Constellation and the topic map are built from), a few hundred characters at most.""" + meta = d.metadata_json or {} + ideas = [f"{i['label'].strip()}: {(i.get('note') or '').strip()}" for i in meta.get("ideas") or [] if (i.get("label") or "").strip()] + tags = [t["name"] for t in meta.get("topics") or [] if t.get("name")][:3] + head = d.title + (f"\nTopics: {', '.join(tags)}" if tags else "") + return (head + "\n" + "\n".join(f"- {x}" for x in ideas[:SUMMARY_IDEAS]))[:SUMMARY_CHARS] + + +def stable_order(docs: list[Document]) -> list[Document]: + """The posts in an order that belongs to the posts, not to the selection: the same posts come first whenever they are picked. + The templates are read from the first few, so ticking or unticking any other post gives the same input, the same key, and the + templates kept for it (the ones prepared in the background for the whole notebook) are served at once, not written again.""" + return sorted(docs, key=lambda d: hashlib.sha256(str(d.id).encode()).hexdigest()) + + +def suggestion_items(material: dict[str, str], docs: list[Document]) -> list[str]: + """What the AI reads: each post's stored summary when it has one (else the start of the post, as `material` holds it), or for + chats their first exchange. At most SUGGEST_ITEMS, from the posts that come first in stable_order.""" + if not docs: + return list(material.values())[:SUGGEST_ITEMS] + out = [] + for d in stable_order(docs): + item = summary(d) if ideas_fresh(d) else material.get(str(d.id)) + if item: + out.append(item) + if len(out) == SUGGEST_ITEMS: + break + return out + + +def cache_key(items: list[str], topic: str) -> str: + return hashlib.sha256(("\x1f".join(items) + "\x1e" + (topic or "").strip()).encode()).hexdigest() + + +def _clean(out: dict) -> list[dict]: + seen, found = set(), [] + for t in out.get("templates") or []: + name, prompt = (t.get("name") or "").strip(), (t.get("prompt") or "").strip() + if not name or not prompt or name.lower() in seen: + continue + seen.add(name.lower()) + found.append({"id": f"suggested-{len(found) + 1}", "name": name[:60], + "description": (t.get("description") or "").strip()[:200], "prompt": prompt[:1500]}) + return found[:4] + + +def suggest(db: Session, workspace_id: uuid.UUID, items: list[str], topic: str = "") -> list[dict]: + """Four templates for these sources: the stored ones when the same sources were seen before, else written now (one AI call) and + stored. A failed or empty answer is never stored. Never raises.""" + if not items: + return [] + key = cache_key(items, topic) + hit = db.scalar(select(ReportTemplateCache).where(ReportTemplateCache.workspace_id == workspace_id, ReportTemplateCache.key == key)) + if hit and hit.templates: + log.info("report templates: kept set served workspace=%s key=%s", workspace_id, key[:10]) + return hit.templates + log.info("report templates: no kept set, asking the model workspace=%s key=%s items=%d", workspace_id, key[:10], len(items)) + try: + out = run.predict(SuggestReportTemplates, db=db, workspace_id=workspace_id, lm=suggest_templates_lm(), items=items, + topic=(topic or "").strip() or "(none)") + except Exception: + log.warning("template suggestions failed", exc_info=True) + return [] + templates = _clean(out) + if templates: + _store(db, workspace_id, key, templates) + return templates + + +def _store(db: Session, workspace_id: uuid.UUID, key: str, templates: list[dict]) -> None: + try: + row = db.scalar(select(ReportTemplateCache).where(ReportTemplateCache.workspace_id == workspace_id, ReportTemplateCache.key == key)) + if row: + row.templates = templates + else: + db.add(ReportTemplateCache(workspace_id=workspace_id, key=key, templates=templates)) + db.commit() + except IntegrityError: # another request stored the same set first + db.rollback() + return + old = db.scalars(select(ReportTemplateCache).where(ReportTemplateCache.workspace_id == workspace_id) + .order_by(ReportTemplateCache.updated_at.desc()).offset(KEEP_PER_WORKSPACE)).all() + for row in old: + db.delete(row) + if old: + db.commit() + + +def queue_warmup(db: Session, workspace_id: uuid.UUID) -> None: + """Have the templates for the workspace's notebooks prepared in the background (once at a time). Called when ideas have been + extracted and when posts join a notebook, so they are ready before anyone opens the dialog.""" + if settings.llm_api_key and not pending_job(db, workspace_id, "report_templates"): + create_job(db, workspace_id, "report_templates", {}, max_attempts=2) + + +def warm_workspace(db: Session, workspace_id: uuid.UUID) -> int: + """Write and keep the templates of each recently changed notebook for the posts its dialog starts with (the newest ones, as the + dialog ticks them), skipping a notebook whose posts have mostly no stored ideas yet (they are prepared once the ideas land) and + any set already stored. Returns how many were written.""" + from app.routers.videos import _excerpt, is_locked # the dialog's own preview of a post, so the keys match + + written = 0 + for nb in db.scalars(select(Notebook).where(Notebook.workspace_id == workspace_id) + .order_by(Notebook.updated_at.desc()).limit(WARM_NOTEBOOKS)): + docs = [d for d in notebook_docs(db, nb.id) if not is_locked(d)][:max_posts()] + if not docs or sum(1 for d in docs if ideas_fresh(d)) * 2 < len(docs): + continue + docs.sort(key=lambda d: d.published_at or d.created_at, reverse=True) + each = 800 if len(docs) == 1 else 400 + material = {str(d.id): "\n".join(p for p in (d.title or "", _excerpt(d.clean_text, each)) if p) for d in docs} + items = suggestion_items(material, docs) + key = cache_key(items, "") + if items and not db.scalar(select(ReportTemplateCache.id).where(ReportTemplateCache.workspace_id == workspace_id, + ReportTemplateCache.key == key)): + written += bool(suggest(db, workspace_id, items, "")) + return written diff --git a/backend/app/services/usage.py b/backend/app/services/usage.py index 11afde78aac95c3d5ab4fdeaa366be9525bf96c9..23eef7842a685e02851edf9ca8ab8faeab0fceb0 100644 --- a/backend/app/services/usage.py +++ b/backend/app/services/usage.py @@ -5,7 +5,7 @@ from datetime import UTC, datetime from sqlalchemy import func, select from sqlalchemy.orm import Session -from app.models import Artifact, UsageEvent, Workspace +from app.models import Artifact, Document, UsageEvent, Workspace from app.services.plans import Plan, effective_plan, plan_limit_error from app.services.video_quota import video_usage @@ -15,7 +15,9 @@ def month_start(now: datetime | None = None) -> datetime: return now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) -def month_usage(db: Session, workspace_id) -> dict: +def month_usage(db: Session, workspace_id, lifetime: bool = False) -> dict: + """What this month has used. With `lifetime` (the Free plan) Launch Kits, infographics and audio overviews are + counted over all time instead, not counting the ones that failed (a failed one never uses up an allowance).""" since = month_start() def total(kind: str, unit: str) -> float: @@ -26,26 +28,20 @@ def month_usage(db: Session, workspace_id) -> dict: ) ) or 0) - kits = db.scalar( - select(func.count()).select_from(Artifact).where( - Artifact.workspace_id == workspace_id, Artifact.type == "launch_kit", Artifact.created_at >= since - ) - ) or 0 - reports = db.scalar( - select(func.count()).select_from(Artifact).where( - Artifact.workspace_id == workspace_id, Artifact.type == "report", Artifact.created_at >= since - ) - ) or 0 - infographics = db.scalar( - select(func.count()).select_from(Artifact).where( - Artifact.workspace_id == workspace_id, Artifact.type == "infographic", Artifact.created_at >= since - ) - ) or 0 + def made(type_: str, *, always_month: bool = False) -> int: + q = select(func.count()).select_from(Artifact).where(Artifact.workspace_id == workspace_id, Artifact.type == type_) + if lifetime and not always_month: + q = q.where(Artifact.status != "failed") + else: + q = q.where(Artifact.created_at >= since) + return db.scalar(q) or 0 + return { "audio_minutes": round(total("tts", "seconds") / 60, 1), - "launch_kits": kits, - "reports": reports, - "infographics": infographics, + "audio_overviews": made("audio_overview"), + "launch_kits": made("launch_kit"), + "reports": made("report", always_month=True), # reports stay monthly on every plan + "infographics": made("infographic"), "llm_tokens": int(total("llm", "tokens")), "since": since.isoformat(), } @@ -53,15 +49,19 @@ def month_usage(db: Session, workspace_id) -> dict: def usage_report(db: Session, workspace: Workspace) -> dict: plan = effective_plan(db, workspace) - used = month_usage(db, workspace.id) + used = month_usage(db, workspace.id, plan.lifetime) videos = video_usage(db, workspace) used["videos"] = videos["used"] + used["indexed_posts"] = db.scalar( + select(func.count()).select_from(Document).where(Document.workspace_id == workspace.id, Document.path.is_not(None)) + ) or 0 return { "plan": plan.id, "used": used, "videos_resets_at": videos["resets_at"], "limits": { "audio_minutes": plan.audio_minutes, + "audio_overviews": plan.audio_overviews, "videos": videos["limit"], "launch_kits": plan.launch_kits, "reports": plan.reports, @@ -73,12 +73,13 @@ def usage_report(db: Session, workspace: Workspace) -> dict: def check_limit(db: Session, workspace: Workspace, kind: str, amount: float = 1) -> Plan: - """Raise 402 plan_limit when this request would go over the monthly allowance.""" + """Raise 402 plan_limit when this request would go over the allowance (monthly, or in total on Free).""" plan = effective_plan(db, workspace) - used = month_usage(db, workspace.id) - limit = {"audio_minutes": plan.audio_minutes, "launch_kits": plan.launch_kits, "reports": plan.reports, - "infographics": plan.infographics}[kind] + used = month_usage(db, workspace.id, plan.lifetime) + limit = {"audio_minutes": plan.audio_minutes, "audio_overviews": plan.audio_overviews, + "launch_kits": plan.launch_kits, "reports": plan.reports, "infographics": plan.infographics}[kind] if limit >= 0 and used[kind] + amount > limit: label = kind.replace("_", " ") - raise plan_limit_error(plan, kind, f"This would go over your {limit} {label} this month.") + total = plan.lifetime and kind != "reports" # Free's allowances are totals, its reports are monthly + raise plan_limit_error(plan, kind, f"This would go over your {limit} {label}{' in total' if total else ' this month'}.") return plan diff --git a/backend/app/services/video_limits.py b/backend/app/services/video_limits.py index 15d647e5a6611f6337ca0b52d65af3fa0ff3445c..643b14e0e967e87420b152b6e0c9fce56c39e525 100644 --- a/backend/app/services/video_limits.py +++ b/backend/app/services/video_limits.py @@ -1,5 +1,5 @@ """Per-workspace shares of what blog2video only limits account-wide: AI edits, custom templates, template AI -operations, voice designs, voice samples, custom voices (plans.VIDEO_LIMITS), plus premium (★) video options. +operations (plans.VIDEO_LIMITS). blog2video checks every limit against our own (Pro) account, so every workspace passes its checks. These counters stop a workspace before it uses up everyone's share. Videos themselves are counted in video_quota.py. @@ -21,9 +21,6 @@ LABELS = { "ai_edits": ("AI edits", "this month"), "templates": ("custom templates", "in total"), "template_ai_daily": ("AI template steps", "today"), - "voice_designs_daily": ("voice designs", "today"), - "voice_samples_daily": ("voice samples", "today"), - "custom_voices": ("custom voices", "at a time"), } @@ -68,7 +65,7 @@ def take(db: Session, workspace: Workspace, metric: str, n: int = 1, plan: Plan def give_back(db: Session, workspace_id: uuid.UUID, metric: str, n: int = 1) -> None: - """Undo a take whose blog2video call failed (or a custom voice that was deleted).""" + """Undo a take whose blog2video call failed.""" db.execute(update(UsageCounter) .where(UsageCounter.workspace_id == workspace_id, UsageCounter.period == period_key(metric), UsageCounter.metric == metric) @@ -76,18 +73,10 @@ def give_back(db: Session, workspace_id: uuid.UUID, metric: str, n: int = 1) -> db.commit() -def require_premium(db: Session, workspace: Workspace, what: str, plan: Plan | None = None) -> Plan: - plan = plan or effective_plan(db, workspace) - if not plan.video_premium: - raise plan_limit_error(plan, "video_premium", f"{what} comes with a paid plan.") - return plan - - def report(db: Session, workspace: Workspace) -> dict: - """What the wizard shows: premium or not, and each counter's used / limit.""" + """What the wizard shows: each counter's used / limit.""" plan = effective_plan(db, workspace) rows = {(r.period, r.metric): r.used for r in db.scalars( select(UsageCounter).where(UsageCounter.workspace_id == workspace.id))} - return {"premium": plan.video_premium, - "limits": {m: {"used": rows.get((period_key(m), m), 0), "limit": int(plan.video_limits.get(m, 0))} + return {"limits": {m: {"used": rows.get((period_key(m), m), 0), "limit": int(plan.video_limits.get(m, 0))} for m in VIDEO_LIMITS}} diff --git a/backend/app/support/corpus.py b/backend/app/support/corpus.py index 705e34c1fee86aaa73fd360f168869456b3c672f..0c03e60e9116a90455cedc233105598a35bb8d99 100644 --- a/backend/app/support/corpus.py +++ b/backend/app/support/corpus.py @@ -83,7 +83,7 @@ def _plans_doc() -> Doc: f"{p.audio_minutes} minutes of audio a month, " f"{p.videos} videos {'a month' if p.videos_monthly else 'in total'}, {kits} Launch Kits a month, " f"{reports} reports a month, {infographics} infographics a month. " - f"Voice cloning: {'yes' if p.voice_cloning else 'no'}. Brand kit: {'yes' if p.brand_kit else 'no'}.", + f"Voice cloning: {'yes' if p.voice_cloning else 'no'}.", "Includes: " + "; ".join(p.features) + ".", ] lines += [ diff --git a/backend/app/worker.py b/backend/app/worker.py index 281e61417dc0aa61ddc666e5fb975bbf0b6fa987..bab96134d1d83d0855500cabcc17e96a729f2783 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -49,7 +49,7 @@ from app.pipeline import flashcards, generate, infographic, launchkit, media, qu from app.pipeline.generate import NothingToDo from app.pipeline.ingest import FeedNotFound, entry_from_upload, extract_article, ingest_source, store_entries from app.pipeline.memory import learn_from_message -from app.services import launchpad, tts, video_quota +from app.services import launchpad, report_suggestions, tts, video_quota from app.services.email import email_service from app.services.email_verification import purge_old_codes from app.services.jobs import ( @@ -62,7 +62,7 @@ from app.services.jobs import ( update_job, ) from app.services.notestack_voices import save_notestack_voice -from app.services.plans import effective_plan +from app.services.plans import effective_plan, post_room from app.services.renderer import PermanentJobError, request_render from app.services.social.health import social_health from app.services.storage import keys, storage @@ -102,8 +102,10 @@ def handle_ingest(db: Session, job: Job): if not source: return Done({"error": "source deleted"}, "Source no longer exists") plan = effective_plan(db, db.get(Workspace, source.workspace_id)) + # The plan limit is one total across every source, link and upload: this source gets what the others leave. + room = post_room(db, source.workspace_id, plan, source.id) try: - result = ingest_source(db, source, job, max_posts=plan.indexed_posts) + result = ingest_source(db, source, job, max_posts=room) except Exception as exc: db.rollback() source.sync_status = "error" @@ -155,6 +157,16 @@ def handle_chat_memory_cleanup(db: Session, job: Job): return Done({"removed": removed}, f"Removed {removed} orphaned chats") +def _require_room(db: Session, source: Source, url: str) -> None: + """Re-reading a post that is already indexed is fine; a new one needs room under the plan's post limit.""" + known = db.scalar(select(Document.id).where(Document.source_id == source.id, Document.url == url, + Document.path.is_not(None))) + plan = effective_plan(db, db.get(Workspace, source.workspace_id)) + if not known and post_room(db, source.workspace_id, plan) <= 0: + raise PermanentJobError(f"Your plan indexes up to {plan.indexed_posts} posts, and they are all used. " + "Remove a post or upgrade to add more.") + + def handle_import_url(db: Session, job: Job): source = db.get(Source, uuid.UUID(job.params["source_id"])) update_job(db, job, progress=0.1, message="Fetching the article") @@ -162,6 +174,7 @@ def handle_import_url(db: Session, job: Job): entry = extract_article(job.params["url"]) except FeedNotFound as exc: raise PermanentJobError(str(exc)) from exc + _require_room(db, source, entry.url) indexed, _, changed = store_entries(db, source, [entry]) _after_import(db, job, source.workspace_id, [str(i) for i in changed]) return Done({"indexed": indexed, "document_ids": [str(i) for i in changed]}, f"{entry.title[:80]} is in orbit") @@ -174,6 +187,7 @@ def handle_import_upload(db: Session, job: Job): entry = entry_from_upload(upload.filename, upload.content_type, storage.get_bytes(upload.key)) if not entry.sections and not entry.html: raise PermanentJobError("We could not read any text in that file.") + _require_room(db, source, entry.url) indexed, _, changed = store_entries(db, source, [entry]) _after_import(db, job, source.workspace_id, [str(i) for i in changed]) return Done({"indexed": indexed, "document_ids": [str(i) for i in changed]}, f"{entry.title[:80]} is in orbit") @@ -185,9 +199,16 @@ def handle_topics(db: Session, job: Job): def handle_ideas(db: Session, job: Job): result = generate.extract_ideas(db, job, job.workspace_id, job.params.get("document_ids")) + if result["extracted"]: + report_suggestions.queue_warmup(db, job.workspace_id) # their notebooks' suggested report templates are written now return Done(result, f"Read {result['extracted']} posts for their ideas") +def handle_report_templates(db: Session, job: Job): + written = report_suggestions.warm_workspace(db, job.workspace_id) + return Done({"written": written}, f"Prepared report templates for {written} notebooks") + + def handle_voice_profile(db: Session, job: Job): try: result = generate.build_voice_profile(db, job, job.workspace_id, job.params.get("document_ids") or []) @@ -282,6 +303,7 @@ HANDLERS: dict[str, Callable[[Session, Job], object]] = { "topics": handle_topics, "publish_post": handle_publish_post, "ideas": handle_ideas, + "report_templates": handle_report_templates, "memory_update": handle_memory, "chat_memory": handle_chat_memory, "chat_memory_rebuild": handle_chat_memory_rebuild, diff --git a/backend/scripts/render_theme_thumbnails.py b/backend/scripts/render_theme_thumbnails.py index 54cfe34c386e389fb81fe349ed1f3a6ad94da32b..eebe34855f1dd6f8de43fba171a81fc9a9648be8 100644 --- a/backend/scripts/render_theme_thumbnails.py +++ b/backend/scripts/render_theme_thumbnails.py @@ -1,12 +1,8 @@ -"""Draws every infographic theme with the sample content (and, with --stress, the longest text allowed) using a local -Chrome, so the themes can be checked by eye and the picker thumbnails regenerated. +"""Draws each infographic look with the sample content using a local Chrome, so the picker thumbnails can be regenerated. - python scripts/render_theme_thumbnails.py # writes ../frontend/public/infographics/.png (small) - python scripts/render_theme_thumbnails.py --stress # writes full-size stress renders to a temp folder + python scripts/render_theme_thumbnails.py # writes ../frontend/public/infographics/.png (small) Set CHROME to the browser binary if it is not found.""" -import glob -import os import subprocess import sys import tempfile @@ -14,32 +10,24 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from app.infographics.content import HEIGHT, SAMPLE, STRESS, WIDTH, clamp # noqa: E402 -from app.infographics.render import thumbnail_html # noqa: E402 -from app.infographics.themes import THEMES # noqa: E402 +from app.infographics.design import LOOKS, SAMPLE, render_design # noqa: E402 +from app.infographics.image import chrome # noqa: E402 +from app.infographics.render import SIZES # noqa: E402 -def chrome() -> str: - 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"))), "") \ - or "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" - return found - -def shoot(html: str, out: Path) -> None: +def shoot(html: str, out: Path, layout: str) -> None: + width, height = SIZES[layout] with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False) as f: - f.write(html) - subprocess.run([chrome(), "--headless=new", "--disable-gpu", "--hide-scrollbars", f"--window-size={WIDTH},{HEIGHT}", - "--virtual-time-budget=8000", f"--screenshot={out}", f"file://{f.name}"], check=True, capture_output=True) - os.unlink(f.name) + f.write(f'{html}') + subprocess.run([chrome(), "--headless=new", "--disable-gpu", "--no-sandbox", "--hide-scrollbars", f"--window-size={width},{height}", + "--force-device-scale-factor=2", "--virtual-time-budget=8000", f"--screenshot={out}", f"file://{f.name}"], check=True, capture_output=True) if __name__ == "__main__": - stress = "--stress" in sys.argv - folder = Path(tempfile.gettempdir()) / "ig-stress" if stress else Path(__file__).resolve().parents[2] / "frontend/public/infographics" + folder = Path(__file__).resolve().parents[2] / "frontend/public/infographics" folder.mkdir(parents=True, exist_ok=True) - content = clamp(STRESS if stress else SAMPLE) - for tid in THEMES: - out = folder / f"{tid}.png" - shoot(thumbnail_html(tid, content), out) - if not stress: # picker thumbnails: a third of the size is plenty - subprocess.run(["sips", "--resampleWidth", "560", str(out)], check=False, capture_output=True) + for look in LOOKS: + out = folder / f"{look}.png" + shoot(render_design(look, SAMPLE, "landscape", fit=1.0), out, "landscape") + subprocess.run(["sips", "--resampleWidth", "1400", str(out)], check=False, capture_output=True) # drawn at 2x, kept at 1400 wide: sharp when shown large print(out) diff --git a/backend/tests/test_billing.py b/backend/tests/test_billing.py index 61418f58a85762c29923a06f4abc8077950fc001..c29588aa522bf7499834ce02d3c0f003b4cdc72a 100644 --- a/backend/tests/test_billing.py +++ b/backend/tests/test_billing.py @@ -3,7 +3,7 @@ import pytest from app.config import settings -from app.models import Artifact, Notebook, Workspace +from app.models import Artifact, Document, Notebook, Source, Workspace from app.services.billing import apply_subscription from tests.conftest import last_code @@ -38,6 +38,8 @@ def test_free_plan_nudges_and_out_of_fuel_402(client, auth, billing_on, db_sessi assert body["plan"]["id"] == "free" and body["can_upgrade"] and body["next_plan"] == "writer" kits = next(m for m in body["meters"] if m["key"] == "launch_kits") assert kits["used"] == 2 and kits["pct"] == 1 + more = {m["key"]: m for m in body["more_meters"]} # the rest of the plan, behind the sidebar card's chevron + assert set(more) == {"indexed_posts", "reports", "infographics"} and more["indexed_posts"]["limit"] == 5 ids = [n["id"] for n in body["nudges"]] assert ids[0].startswith("empty-launch_kits") and "perk-voice" in ids assert "—" not in str(body) @@ -45,10 +47,77 @@ def test_free_plan_nudges_and_out_of_fuel_402(client, auth, billing_on, db_sessi nb = Notebook(workspace_id=ws.id, title="Pricing") db_session.add(nb) db_session.commit() - r = client.post("/api/artifacts/generate", headers=auth, - json={"type": "audio_overview", "notebook_id": str(nb.id), "minutes": 6}) + # Free gets one audio overview in total, whatever its length (here 6 minutes), then it is used up for good. + body_in = {"type": "audio_overview", "notebook_id": str(nb.id), "minutes": 6} + assert client.post("/api/artifacts/generate", headers=auth, json=body_in).status_code == 200 + r = client.post("/api/artifacts/generate", headers=auth, json=body_in) detail = r.json()["detail"] - assert r.status_code == 402 and detail["kind"] == "audio_minutes" and detail["upgrade_to"] == "writer" + assert r.status_code == 402 and detail["kind"] == "audio_overviews" and detail["upgrade_to"] == "writer" + assert "in total" in detail["message"] + audio = next(m for m in client.get("/api/billing/status", headers=auth).json()["meters"] if m["key"] == "audio_overviews") + assert audio["used"] == 1 and audio["limit"] == 1 + + +def _index_posts(db, ws, n, source): + for i in range(n): + db.add(Document(workspace_id=ws.id, source_id=source.id, title=f"Post {i}", url=f"https://x.test/{source.title}/{i}", + path=f"sources/{source.title}/post-{i}.md")) + db.commit() + + +def test_post_limit_is_one_total_across_sources_links_and_uploads(client, auth, billing_on, db_session): + from app.services.plans import PLANS, post_room + + ws = db_session.query(Workspace).one() + feed_a = Source(workspace_id=ws.id, feed_url="https://a.test/feed", title="a") + feed_b = Source(workspace_id=ws.id, feed_url="https://b.test/feed", title="b") + db_session.add_all([feed_a, feed_b]) + db_session.commit() + free = PLANS["free"] + assert free.indexed_posts == 5 and free.sources == -1 # any number of sources, five posts in all + _index_posts(db_session, ws, 3, feed_a) + assert post_room(db_session, ws.id, free) == 2 + assert post_room(db_session, ws.id, free, feed_b.id) == 2 # a second source gets what the first leaves + _index_posts(db_session, ws, 2, feed_b) + assert post_room(db_session, ws.id, free) == 0 + assert post_room(db_session, ws.id, free, feed_a.id) == 3 # a source keeps the posts it already has + + # A link or a file is refused with the upgrade prompt once all five are used. + r = client.post("/api/sources/url", headers=auth, json={"url": "https://example.com/a-post"}) + assert r.status_code == 402 and r.json()["detail"]["kind"] == "indexed_posts" and r.json()["detail"]["upgrade_to"] == "writer" + assert client.post("/api/sources/upload", headers=auth, json={"upload_id": "00000000-0000-0000-0000-000000000000"}).status_code == 404 + assert (PLANS["writer"].indexed_posts, PLANS["studio"].indexed_posts) == (100, 250) + + +def test_free_allowances_are_totals_and_paid_ones_are_monthly(client, auth, billing_on, db_session): + from app.services.plans import PLANS + from app.services.usage import check_limit, month_usage + + ws = db_session.query(Workspace).one() + free, writer, studio = PLANS["free"], PLANS["writer"], PLANS["studio"] + assert (free.videos, free.audio_overviews, free.launch_kits, free.infographics) == (2, 1, 2, 2) and free.lifetime + assert (writer.launch_kits, writer.audio_minutes, writer.reports) == (20, 60, 30) and not writer.lifetime + assert (studio.launch_kits, studio.audio_minutes, studio.reports) == (50, 240, -1) + + old = Artifact(workspace_id=ws.id, type="infographic", status="ready") + db_session.add_all([old, Artifact(workspace_id=ws.id, type="infographic", status="failed")]) + db_session.commit() + # An infographic made in an earlier month still counts on Free, a failed one never does. + from datetime import UTC, datetime, timedelta + + old.created_at = datetime.now(UTC) - timedelta(days=90) + db_session.commit() + assert month_usage(db_session, ws.id, lifetime=True)["infographics"] == 1 + assert month_usage(db_session, ws.id, lifetime=False)["infographics"] == 1 # (the failed one is this month's) + db_session.add(Artifact(workspace_id=ws.id, type="infographic", status="ready")) + db_session.commit() + with pytest.raises(Exception) as err: + check_limit(db_session, ws, "infographics", 1) + assert err.value.status_code == 402 and "in total" in err.value.detail["message"] + # Deleting one gives its place back. + db_session.delete(old) + db_session.commit() + check_limit(db_session, ws, "infographics", 1) def test_checkout_guards(client, auth, billing_on, db_session): @@ -100,7 +169,7 @@ def plans_table(monkeypatch): db.add(PlanRecord(id=p.id, sort_order=i, name=p.name, tagline=p.tagline, price_monthly_usd=p.price_monthly_usd, sources=p.sources, indexed_posts=p.indexed_posts, audio_minutes=p.audio_minutes, videos=p.videos, launch_kits=p.launch_kits, - voice_cloning=p.voice_cloning, brand_kit=p.brand_kit, features=list(p.features))) + voice_cloning=p.voice_cloning, features=list(p.features))) db.commit() PLANS.reload() yield session_factory diff --git a/backend/tests/test_buildout.py b/backend/tests/test_buildout.py index 53f2bf3828f2746208affabf01a0a75cf4d036a8..1607fc5d47295b388bc6a4ad8afb74824e662dbd 100644 --- a/backend/tests/test_buildout.py +++ b/backend/tests/test_buildout.py @@ -939,11 +939,10 @@ def test_publish_now_with_media_goes_through_a_job(client, auth, db_session, mon def test_settings_and_usage(client, auth): s = client.get("/api/settings", headers=auth).json() assert s["user"]["name"] == "Ada Lovelace" and s["usage"]["used"]["launch_kits"] == 0 - r = client.patch("/api/settings", json={"name": "Ada L", "workspace_name": "Ada HQ", "brand_accent": "#FF0000", + r = client.patch("/api/settings", json={"name": "Ada L", "workspace_name": "Ada HQ", "training_opt_in": True}, headers=auth).json() assert r["user"]["name"] == "Ada L" and r["workspace"]["name"] == "Ada HQ" - assert r["brand"]["accent"] == "#ff0000" and r["workspace"]["training_opt_in"] is True - assert client.patch("/api/settings", json={"brand_accent": "red"}, headers=auth).status_code == 422 + assert r["workspace"]["training_opt_in"] is True and "brand" not in r def test_active_jobs_listing(client, auth, feed): @@ -1657,14 +1656,7 @@ def test_flashcards_drop_repeats_empties_and_invented_sources(client, auth, run_ def _make_report(client, auth, run_jobs, llm, pricing, nb): - good = {"path": pricing["path"], "line_start": 1, "line_end": 3} - fake = {"path": "sources/fake.md", "line_start": 1, "line_end": 2} - llm["WriteReport"] = {"report_title": "Pricing Brief", "markdown": "## One\nFirst point [1].\n\n## Two\nSecond [2].", - "citations": [{"marker": 1, **good}, {"marker": 2, **fake}]} - llm["SuggestEmbeds"] = {"suggestions": [ - {"after_block_id": "b1", "kind": "flashcards", "brief": "Terms", "why": "Learn them"}, - {"after_block_id": "nope", "kind": "quiz", "brief": "x", "why": "y"}, - {"after_block_id": "b2", "kind": "mind_map", "brief": "Links", "why": "See them"}]} + llm["WriteReport"] = {"report_title": "Pricing Brief", "markdown": "## One\nFirst point [1].\n\n## Two\nSecond [2]."} art = client.post("/api/artifacts/generate", json={ "type": "report", "notebook_id": nb["id"], "document_ids": [pricing["id"]], "report_format": "document", "template_id": "briefing", "instructions": "Write a brief."}, headers=auth).json() @@ -1672,15 +1664,15 @@ def _make_report(client, auth, run_jobs, llm, pricing, nb): return client.get(f"/api/artifacts/{art['id']}", headers=auth).json() -def test_document_report_is_blocks_with_checked_citations_and_suggestions(client, auth, run_jobs, feed, llm): +def test_document_report_is_blocks_without_citations_and_with_the_posts_it_was_made_from(client, auth, run_jobs, feed, llm): pricing, nb = _notebook_with_pricing(client, auth, run_jobs) done = _make_report(client, auth, run_jobs, llm, pricing, nb) c = done["content"] assert done["status"] == "ready" and done["type_label"] == "Report" and c["format"] == "document" assert [b["type"] for b in c["blocks"]] == ["prose", "prose"] and c["blocks"][0]["id"] == "b1" - assert len(c["citations"]) == 1 and "[2]" not in c["blocks"][1]["text"] # the invented source was dropped - assert [s["after_block_id"] for s in c["suggestions"]] == ["b1", "b2"] # the unknown section was dropped - assert c["sources"][0]["title"] == "On Pricing" + assert c["citations"] == [] and not any("[" in b["text"] for b in c["blocks"]) # a report cites nothing: markers are removed + assert c["suggestions"] == [] # a document never offers visuals; only an interactive report does + assert c["sources"][0]["title"] == "On Pricing" and c["instructions"] == "Write a brief." # what the drop-down shows def test_add_and_remove_a_suggested_visual(client, auth, run_jobs, feed, llm): @@ -1688,15 +1680,22 @@ def test_add_and_remove_a_suggested_visual(client, auth, run_jobs, feed, llm): done = _make_report(client, auth, run_jobs, llm, pricing, nb) llm["GenerateFlashcards"] = {"cards": [{"front": "Term", "back": "Meaning", "sources": []}]} r = client.post(f"/api/reports/{done['id']}/blocks", json={ - "kind": "flashcards", "after_block_id": "b1", "suggestion_id": "s1", "brief": "Terms"}, headers=auth) + "kind": "flashcards", "after_block_id": "b1", "brief": "Terms"}, headers=auth) assert r.status_code == 200, r.text run_jobs() c = client.get(f"/api/artifacts/{done['id']}", headers=auth).json()["content"] assert [b["type"] for b in c["blocks"]] == ["prose", "flashcards", "prose"] - assert c["blocks"][1]["after"] == "b1" and [s["id"] for s in c["suggestions"]] == ["s2"] + assert c["blocks"][1]["after"] == "b1" and c["suggestions"] == [] added = c["blocks"][1]["id"] + + def saved_sets(): + items = client.get("/api/artifacts", params={"type": "flashcards", "notebook_id": nb["id"]}, headers=auth).json() + return [a for a in (items["items"] if isinstance(items, dict) else items) if a["status"] == "ready"] + + assert len(saved_sets()) == 1 # a set made in the report is also in the notebook's Studio list assert client.delete(f"/api/reports/{done['id']}/blocks/b1", headers=auth).status_code == 404 # prose stays assert client.delete(f"/api/reports/{done['id']}/blocks/{added}", headers=auth).status_code == 200 + assert len(saved_sets()) == 1 # still there to use again, and not saved twice # a kind of visual the report does not know is refused assert client.post(f"/api/reports/{done['id']}/blocks", json={"kind": "poem"}, headers=auth).status_code == 422 @@ -1743,7 +1742,7 @@ def test_interactive_report_only_suggests_visuals_until_the_reader_adds_them(cli {"heading": "Compare", "brief": "b", "embed": {"kind": "table", "title": "Options", "brief": "options", "why": "Not a kind we offer"}}]} monkeypatch.setattr(run, "predict_many", lambda sig, inputs, **kw: [ - {"markdown": f"Body {i} [1].", "citations": [{"marker": 1, **good}]} for i, _ in enumerate(inputs)]) + {"markdown": f"Body {i} [1]."} for i, _ in enumerate(inputs)]) art = client.post("/api/artifacts/generate", json={ "type": "report", "notebook_id": nb["id"], "document_ids": [pricing["id"]], "report_format": "interactive", "instructions": "Make it interactive."}, headers=auth).json() @@ -1751,9 +1750,11 @@ def test_interactive_report_only_suggests_visuals_until_the_reader_adds_them(cli c = client.get(f"/api/artifacts/{art['id']}", headers=auth).json()["content"] assert c["format"] == "interactive" assert [b["type"] for b in c["blocks"]] == ["prose", "prose", "prose"] # nothing is built until the reader asks - assert [(s["after_block_id"], s["kind"]) for s in c["suggestions"]] == [("b1", "flashcards")] # a table is not a visual we offer + # a table is not a visual we offer; the plan suggested no infographic, so one is offered before the last section + assert [(s["after_block_id"], s["kind"]) for s in c["suggestions"]] == [ + ("b1", "flashcards"), ("b2", "infographic"), ("b2", "quiz")] # filled up to three assert "GenerateFlashcards" not in llm["_calls"] - assert [x["marker"] for x in c["citations"]] == [1, 2, 3] and "[3]" in c["blocks"][2]["text"] # renumbered + assert c["citations"] == [] and not any("[" in b["text"] for b in c["blocks"]) # nothing is cited r = client.post(f"/api/reports/{art['id']}/blocks", json={ "kind": "table", "after_block_id": "b3", "brief": "options"}, headers=auth) @@ -1799,11 +1800,10 @@ def test_quiz_can_be_made_from_posts_and_chats_together(client, auth, run_jobs, assert any(m.startswith("FILE ") for m in seen["material"]) and any(m.startswith("CHAT ") for m in seen["material"]) -def test_a_quiz_section_the_ai_wrote_becomes_a_suggestion_unless_it_was_asked_for(client, auth, run_jobs, feed, llm): +def test_a_document_report_keeps_whatever_sections_the_ai_wrote_and_suggests_nothing(client, auth, run_jobs, feed, llm): pricing, nb = _notebook_with_pricing(client, auth, run_jobs) llm["WriteReport"] = {"report_title": "Pricing", "citations": [], "markdown": "## Why price\nText.\n\n## How to raise\nMore.\n\n## Quick quiz\n1. What is a price? (Answer: x)"} - llm["SuggestEmbeds"] = {"suggestions": []} def make(instructions): art = client.post("/api/artifacts/generate", json={ @@ -1813,21 +1813,25 @@ def test_a_quiz_section_the_ai_wrote_becomes_a_suggestion_unless_it_was_asked_fo return client.get(f"/api/artifacts/{art['id']}", headers=auth).json()["content"] c = make("Write a short brief.") - assert [b["text"].split("\n")[0] for b in c["blocks"]] == ["## Why price", "## How to raise"] # no quiz in the text - assert [(s["after_block_id"], s["kind"]) for s in c["suggestions"]] == [("b2", "quiz")] # offered, not added - - c = make("Write a study guide with a quiz at the end.") # the reader asked for it: it stays assert [b["text"].split("\n")[0] for b in c["blocks"]][-1] == "## Quick quiz" and c["suggestions"] == [] # ---- infographics ---------------------------------------------------------------------------------------------- def _fake_infographic(**over): - out = {"title": "Pricing Plan", "subtitle": "How to raise prices", "items_label": "Ideas", - "items": [{"name": f"Idea {i}", "text": "A short plain sentence about it."} for i in range(4)], - "steps_label": "Steps", "steps": [{"name": f"Step {i}", "text": "Do the thing."} for i in range(5)], + nodes = "".join(f'
0{i}Idea {i}' + 'A short plain sentence about it.
' for i in range(4)) + out = {"title": "Pricing Plan", "subtitle": "How to raise prices", "eyebrow": "Pricing", "key_figures": [{"value": "30%", "label": "Raise carefully"}, {"value": "x" * 40, "label": "Long one"}], + "body_html": f'
Ideas
{nodes}
' + 'a link that stays as plain text', "rule": "Raise slowly.", "notes": ["One.", "Two.", "Three."]} - return {"infographic": {**out, **over}} + return {"design": {**out, **over}} + + +@pytest.fixture(autouse=True) +def _no_chrome_fit(monkeypatch): + """The fit of an infographic is measured in Chrome when it is made; tests do not need a browser.""" + monkeypatch.setattr("app.pipeline.infographic.measure_fit", lambda html, layout: 1.0) def test_every_theme_escapes_and_clamps_model_text(): @@ -1844,22 +1848,26 @@ def test_every_theme_escapes_and_clamps_model_text(): html = build_html(tid, evil) assert "notestack.ai' in build_html(tid, evil) # every theme carries the mark + html = build_html(tid, evil) + assert 'class="mark"' in html and "notestack.ai" in html # every theme carries the mark (logo and name) def test_infographic_is_a_page_at_once_and_can_be_shared_by_link(client, auth, run_jobs, feed, llm): pricing, nb = _notebook_with_pricing(client, auth, run_jobs) llm["ExtractIdeas"] = {"ideas": []} - llm["PlanInfographic"] = _fake_infographic() + llm["DesignInfographic"] = _fake_infographic() art = client.post("/api/artifacts/generate", json={"type": "infographic", "notebook_id": nb["id"], - "document_ids": [pricing["id"]], "theme": "galaxy", "instructions": "About raising prices"}, + "document_ids": [pricing["id"]], "theme": "paper", "ig_style": "flowchart", "instructions": "About raising prices"}, headers=auth).json() assert art["type_label"] == "Infographic" run_jobs() done = client.get(f"/api/artifacts/{art['id']}", headers=auth).json() - assert done["status"] == "ready" and done["content"]["theme"] == "galaxy" and not done["download_url"] - assert "Idea 0" in done["content"]["html"] and "notestack.ai' in done["content"]["html"] + assert done["status"] == "ready" and done["content"]["theme"] == "paper" and not done["download_url"] + html = done["content"]["html"] + assert "Idea 0" in html and "30%<' in html + assert "notestack.ai" in html and "html_landscape" in done["content"] and "ig2 look-paper landscape" in done["content"]["html_landscape"] + assert done["content"]["content"]["fit"] == {"landscape": 1.0, "portrait": 1.0} share = client.post(f"/api/artifacts/{art['id']}/share", headers=auth).json() assert share["shared"] and "/r/" in share["url"] @@ -1879,7 +1887,7 @@ def test_infographic_needs_a_source_and_enough_text_and_obeys_its_limit(client, pricing, nb = _notebook_with_pricing(client, auth, run_jobs) llm["ExtractIdeas"] = {"ideas": []} - llm["PlanInfographic"] = _fake_infographic(items=[]) + llm["DesignInfographic"] = _fake_infographic(body_html="") art = client.post("/api/artifacts/generate", json={"type": "infographic", "notebook_id": nb["id"], "document_ids": [pricing["id"]]}, headers=auth).json() run_jobs() @@ -1903,15 +1911,95 @@ def test_a_report_can_add_an_infographic_that_shows_on_the_shared_page(client, a pricing, nb = _notebook_with_pricing(client, auth, run_jobs) done = _make_report(client, auth, run_jobs, llm, pricing, nb) llm["ExtractIdeas"] = {"ideas": []} - llm["PlanInfographic"] = _fake_infographic() + llm["DesignInfographic"] = _fake_infographic() r = client.post(f"/api/reports/{done['id']}/blocks", json={"kind": "infographic", "after_block_id": "b1", - "brief": "The steps to raise prices", "theme": "moonbase"}, headers=auth) + "brief": "The steps to raise prices", "theme": "paper"}, headers=auth) assert r.status_code == 200, r.text run_jobs() # adds the block, and writes the infographic it points at block = next(b for b in client.get(f"/api/artifacts/{done['id']}", headers=auth).json()["content"]["blocks"] if b["type"] == "infographic") - assert block["theme"] == "moonbase" and block["status"] == "ready" and "Idea 0" in block["html"] + assert block["theme"] == "paper" and block["status"] == "ready" and "Idea 0" in block["html"] token = client.post(f"/api/artifacts/{done['id']}/share", headers=auth).json()["url"].rsplit("/", 1)[1] public = client.get(f"/api/public/reports/{token}").json() shared = next(b for b in public["blocks"] if b["type"] == "infographic") assert "Idea 0" in shared["html"] and "artifact_id" not in shared and "theme" not in shared + + +def test_a_composed_infographic_keeps_only_allowed_markup_and_draws_in_every_look(): + from app.infographics.design import LOOKS, SAMPLE, build_content, render_design, sanitize_body + from app.infographics.render import SIZES + + dirty = ('
A node title long enough to count' + 'words that stay' + 'Another line of readable text here — with a dash
') + clean = sanitize_body(dirty) + assert clean and 'class="node"' in clean + for bad in ("style", "onclick", "id=", "") is None and sanitize_body("") is None + assert sanitize_body('
' + "word " * 5000 + "
") is None # more than a poster holds + figs = build_content({**SAMPLE, "title": "Hi there", "key_figures": [{"value": "9" * 40, "label": "Up a lot"}, {"value": "", "label": "none"}]})["key_figures"] + assert figs == [{"value": "9" * 13 + "…", "label": "Up a lot"}] + assert build_content({**SAMPLE, "body_html": "

"}) is None + for look in LOOKS: + for layout in SIZES: + html = render_design(look, SAMPLE, layout) + assert f"look-{look} {layout}" in html and "notestack.ai" in html and " list[Document]: + """n indexed posts in the corpus, spread over three themes; the first `with_ideas` have stored ideas (all by default).""" + with_ideas = n if with_ideas is None else with_ideas + files, docs = {}, [] + for i in range(start, start + n): + theme = THEMES[i % len(THEMES)] + lines = ["---", f'title: "Post {i}"', "---", "", f"# Post {i}", ""] + [f"Sentence {k} about {theme.lower()} in post {i}." for k in range(1, body + 1)] + path = f"sources/x/post-{i}.md" + files[path] = "\n".join(lines) + "\n" + d = Document(workspace_id=ws.id, title=f"Post {i} on {theme}", url=f"https://x.test/{i}", path=path, content_hash=f"h{i}", + clean_text=" ".join(lines), metadata_json={}) + if i - start < with_ideas: + d.metadata_json = {"ideas_hash": f"h{i}", "ideas": [ + {"label": f"{theme} idea {i}.{k}", "note": f"What post {i} says about {theme.lower()}.", + "sources": [{"path": path, "line_start": 7 + k, "line_end": 9 + k}], "details": []} for k in range(ideas_each)]} + db.add(d) + docs.append(d) + db.commit() + Corpus(ws.id).write_files(files) + return docs + + +def idea_lines(material: list[str]) -> list[str]: + return [m for m in material if m.startswith("IDEA [")] + + +def test_a_sample_is_small_cited_and_spread_over_the_posts(owner, db_session): + _, ws = owner + docs = make_posts(db_session, ws, 60) + material = idea_pool.sample(db_session, ws.id, docs, rng=random.Random(1)) + lines = idea_lines(material) + assert material[0] == idea_pool.FORMAT_NOTE and len(lines) == settings.artifact_idea_sample == 30 + assert sum(len(m) for m in material) < 8_000 # about 1,500 tokens, whatever the size of the selection + assert all(re.fullmatch(r"IDEA \[sources/x/post-\d+\.md lines \d+-\d+\] .+ idea \d+\.\d: What post \d+ says about .+\.", m) for m in lines) + per_post = [m.split("[")[1].split(" ")[0] for m in lines] + assert max(per_post.count(p) for p in set(per_post)) <= idea_pool.IDEAS_PER_POST and len(set(per_post)) >= 15 + assert lines != idea_lines(idea_pool.sample(db_session, ws.id, docs, rng=random.Random(2))) # a fresh draw each time + assert lines == idea_lines(idea_pool.sample(db_session, ws.id, docs, rng=random.Random(1))) # the same draw for the same seed + assert db_session.scalars(select(Job)).all() == [] # nothing is queued or extracted here + + +def test_a_small_selection_sends_all_its_ideas_and_a_topic_comes_first(owner, db_session): + _, ws = owner + few = make_posts(db_session, ws, 3, ideas_each=5) # 15 ideas, fewer than the sample: all of them, however many per post + assert len(idea_lines(idea_pool.sample(db_session, ws.id, few))) == 15 + docs = make_posts(db_session, ws, 30, start=100) + picked = idea_lines(idea_pool.sample(db_session, ws.id, docs, topic="audience growth", size=10, rng=random.Random(3))) + assert len(picked) == 10 and all("audience growth" in m.lower() for m in picked) # ten of the matching ideas, before any other + + +def test_posts_with_no_ideas_get_a_short_opening_and_chats_only_their_end(owner, db_session): + _, ws = owner + docs = make_posts(db_session, ws, 12, with_ideas=5) + material = idea_pool.sample(db_session, ws.id, docs, chats=["x" * 50_000, "y" * 50_000], rng=random.Random(4)) + openings = [m for m in material if m.startswith("FILE ")] + assert len(openings) == idea_pool.MAX_OPENINGS and all(len(o) < 1_100 for o in openings) + assert [len(m) for m in material[-2:]] == [idea_pool.CHAT_BUDGET // 2] * 2 + assert db_session.scalars(select(Job)).all() == [] + + +def test_limits_and_budgets_come_from_settings(owner, db_session, monkeypatch): + from app.pipeline.passages import passages_for + + assert max_posts() == 20 + monkeypatch.setattr(settings, "artifact_max_posts", 5) + assert max_posts() == 5 + monkeypatch.undo() + _, ws = owner + docs = make_posts(db_session, ws, 20, with_ideas=0, body=300) # long posts: each fills whatever it is given + text = lambda: sum(len(p) for p in passages_for(Corpus(ws.id), docs)) # noqa: E731 + full = text() + assert 20 * 5_500 < full < 20 * 6_500 # 120,000 characters shared by 20 posts: about 6,000 each + monkeypatch.setattr(settings, "artifact_passage_budget", 40_000) + mid = text() + assert 20 * 1_500 < mid < 20 * 2_500 # 2,000 each + monkeypatch.setattr(settings, "artifact_passage_budget", 1) # the floor is 1,500 characters a post + assert 20 * 1_000 < text() < mid + + +def _notebook(client, owner, docs): + headers, _ = owner + return client.post("/api/notebooks", json={"title": "Big", "document_ids": [str(d.id) for d in docs]}, headers=headers).json() + + +def test_when_ideas_are_used_instead_of_the_posts_text(owner, db_session): + _, ws = owner + nb = uuid.uuid4() # an unknown notebook: only the picked posts count + big = make_posts(db_session, ws, 30) + ids = [str(d.id) for d in big] + quiz_20 = load_material(db_session, ws.id, nb, {"document_ids": ids[:20]}, what="quiz", large_ok=True) + assert quiz_20.mode == "passages" and len(quiz_20.text) == 20 # up to the limit: the text, exactly as before + quiz_30 = load_material(db_session, ws.id, nb, {"document_ids": ids}, what="quiz", large_ok=True) + assert quiz_30.mode == "ideas" and len(quiz_30.docs) == 30 and quiz_30.text == [] + report = load_material(db_session, ws.id, nb, {"document_ids": ids}, what="report") + assert report.mode == "passages" and len(report.docs) == 20 # a report still reads its first posts' text + cards_3 = load_material(db_session, ws.id, nb, {"document_ids": ids[:3]}, what="flashcard set", large_ok=True, ideas_always=True) + assert cards_3.mode == "ideas" and len(cards_3.docs) == 3 # flashcards: always the ideas, even from three posts + # Ideas not stored yet: the posts' text is read (the first 20 when there are more), as it always was + bare = make_posts(db_session, ws, 30, with_ideas=0, start=200) + bare_ids = [str(d.id) for d in bare] + assert load_material(db_session, ws.id, nb, {"document_ids": bare_ids}, what="quiz", large_ok=True).mode == "passages" + big_bare = load_material(db_session, ws.id, nb, {"document_ids": bare_ids}, what="quiz", large_ok=True) + assert len(big_bare.docs) == 20 and len(big_bare.text) == 20 + few_bare = load_material(db_session, ws.id, nb, {"document_ids": bare_ids[:3]}, what="flashcard set", large_ok=True, ideas_always=True) + assert few_bare.mode == "passages" and len(few_bare.text) == 3 + # Half or more with ideas: the ideas, and a short opening for the rest + half = make_posts(db_session, ws, 4, with_ideas=2, start=300) + assert load_material(db_session, ws.id, nb, {"document_ids": [str(d.id) for d in half]}, what="flashcard set", large_ok=True, + ideas_always=True).mode == "ideas" + + +def _cited(inputs: dict) -> list[dict]: + """A source for each idea line, copied from its tag the way the model is told to.""" + tags = [re.match(r"IDEA \[(\S+) lines (\d+)-(\d+)\]", m) for m in inputs["material"]] + return [{"path": t[1], "line_start": int(t[2]), "line_end": int(t[3])} for t in tags if t] + + +def test_a_big_quiz_is_one_call_over_a_sample_of_ideas(client, owner, db_session, run_jobs, monkeypatch): + headers, ws = owner + docs = make_posts(db_session, ws, 40) + nb = _notebook(client, owner, docs) + calls: list[dict] = [] + + def predict(signature, *, db, workspace_id, job=None, lm=None, **inputs): + assert signature.__name__ == "GenerateQuiz" + calls.append(inputs) + cited = _cited(inputs) + return {"questions": [{"type": "multiple_choice", "question": f"Question {k}?", "options": ["a", "b"], "correct": [0], + "explanation": "", "sources": [cited[k]]} for k in range(inputs["count"])]} + + monkeypatch.setattr(run, "predict", predict) + monkeypatch.setattr(run, "predict_many", lambda *a, **k: pytest.fail("one call")) + art = client.post("/api/artifacts/generate", json={"type": "quiz", "notebook_id": nb["id"], "document_ids": [str(d.id) for d in docs], + "question_types": ["multiple_choice"]}, headers=headers).json() + run_jobs() + done = client.get(f"/api/artifacts/{art['id']}", headers=headers).json() + assert done["status"] == "ready", done["content"] + assert len(calls) == 1 and calls[0]["count"] == 10 and calls[0]["material"][0] == idea_pool.FORMAT_NOTE + assert len(idea_lines(calls[0]["material"])) == 30 + qs = done["content"]["questions"] + assert len(qs) == 10 and all(q["sources"] and q["sources"][0]["document_id"] and q["sources"][0]["quote"] for q in qs) # lines verified + assert len(done["content"]["source"]["document_ids"]) == 40 + assert db_session.scalars(select(Job).where(Job.kind == "ideas")).all() == [] + + +def test_flashcards_use_the_ideas_for_any_size_of_selection_but_not_for_chats(client, owner, db_session, run_jobs, monkeypatch): + headers, ws = owner + docs = make_posts(db_session, ws, 3, ideas_each=4) + nb = _notebook(client, owner, docs) + calls: list[list[str]] = [] + + def predict(signature, *, db, workspace_id, job=None, lm=None, **inputs): + calls.append(inputs["material"]) + cited = _cited(inputs) or [{"path": docs[0].path, "line_start": 1, "line_end": 2}] + return {"cards": [{"front": f"Front {k}", "back": "Back.", "sources": [cited[k % len(cited)]]} for k in range(inputs["count"])]} + + monkeypatch.setattr(run, "predict", predict) + art = client.post("/api/artifacts/generate", json={"type": "flashcards", "notebook_id": nb["id"], + "document_ids": [str(d.id) for d in docs]}, headers=headers).json() + run_jobs() + done = client.get(f"/api/artifacts/{art['id']}", headers=headers).json() + assert done["status"] == "ready" and done["content"]["card_count"] == 16 + assert calls[0][0] == idea_pool.FORMAT_NOTE and len(idea_lines(calls[0])) == 12 # all twelve ideas, no post text + assert not any(m.startswith("FILE ") for m in calls[0]) + assert all(c["sources"] and c["sources"][0]["document_id"] for c in done["content"]["cards"]) + + +def test_a_quiz_or_flashcards_take_any_number_of_posts_and_the_limits_are_served(client, owner, db_session, monkeypatch): + headers, ws = owner + docs = make_posts(db_session, ws, 130) + ids = [str(d.id) for d in docs] + nb = _notebook(client, owner, docs) + assert client.get("/api/settings/limits", headers=headers).json() == {"posts": 20} + big = load_material(db_session, ws.id, uuid.UUID(nb["id"]), {"document_ids": ids}, what="quiz", large_ok=True) + assert big.mode == "ideas" and len(big.docs) == 130 and big.text == [] # all of them count; the sample stays the same size + assert len(idea_lines(idea_pool.sample(db_session, ws.id, big.docs))) == settings.artifact_idea_sample + cap = load_material(db_session, ws.id, uuid.UUID(nb["id"]), {"document_ids": ids}, what="report") + assert len(cap.docs) == 20 # a report still reads its first posts + monkeypatch.setattr(run, "predict", lambda *a, **k: {"questions": []}) + for kind in ("quiz", "flashcards"): # no refusal for a big selection + r = client.post("/api/artifacts/generate", json={"type": kind, "notebook_id": nb["id"], "document_ids": ids}, headers=headers) + assert r.status_code == 200, r.text + + +def test_a_passage_leaves_out_the_file_header_and_keeps_the_line_numbers(owner, db_session): + from app.pipeline.passages import passages_for + + _, ws = owner + doc = make_posts(db_session, ws, 1, with_ideas=0)[0] + text = passages_for(Corpus(ws.id), [doc])[0] + assert text.startswith(f"FILE {doc.path} ({doc.title})\n7| Sentence 1 about") # no ---, title, url, date or "# Post" lines + assert "---" not in text and "# Post" not in text and "title:" not in text + assert "11| Sentence 5" in text # numbered as in the file, so a cited line is still the right one + + +def test_a_quiz_reads_posts_with_its_own_smaller_budget(client, owner, db_session, run_jobs, monkeypatch): + headers, ws = owner + docs = make_posts(db_session, ws, 20, with_ideas=0, body=300) + nb = _notebook(client, owner, docs) + sizes: dict[str, int] = {} + + def predict(signature, *, db, workspace_id, job=None, lm=None, **inputs): + sizes[signature.__name__] = sum(len(m) for m in inputs["material"]) + raise RuntimeError("stop here: only the size of what was sent is wanted") + + monkeypatch.setattr(run, "predict", predict) + ids = [str(d.id) for d in docs] + for kind in ("quiz", "report"): + client.post("/api/artifacts/generate", json={"type": kind, "notebook_id": nb["id"], "document_ids": ids}, headers=headers) + run_jobs() + assert settings.artifact_quiz_passage_budget == 60_000 + assert sizes["GenerateQuiz"] < 0.6 * sizes["WriteReport"] # a quiz's 60,000 characters against a report's 120,000 + + + +def test_suggested_report_templates_read_each_posts_stored_summary(client, owner, db_session, monkeypatch): + headers, ws = owner + docs = make_posts(db_session, ws, 6, with_ideas=4, ideas_each=6) # the last two posts have no stored ideas yet + for d in docs[:4]: + d.metadata_json = {**d.metadata_json, "topics": [{"name": "Pricing", "weight": 0.9}, {"name": "Growth", "weight": 0.5}]} + db_session.commit() + seen: list[dict] = [] + + def predict(signature, *, db, workspace_id, job=None, lm=None, **inputs): + assert signature.__name__ == "SuggestReportTemplates" + seen.append(inputs) + return {"templates": [{"name": f"Template {n}", "description": "d", "prompt": "p"} for n in range(4)]} + + monkeypatch.setattr(run, "predict", predict) + r = client.post("/api/reports/suggest", json={"document_ids": [str(d.id) for d in docs]}, headers=headers) + assert r.status_code == 200 and len(r.json()["templates"]) == 4 + items = seen[0]["items"] + assert len(items) == report_suggestions.SUGGEST_ITEMS # at most this many sources, however many are selected (newest first) + summaries = [i for i in items if "\n- " in i] + starts = [i for i in items if "\n- " not in i] + assert len(summaries) == 2 and len(starts) == 2 # the two newest posts have no stored ideas: read by their start + for item in summaries: + ideas = report_suggestions.SUMMARY_IDEAS + assert "Topics: Pricing, Growth" in item and item.count("\n- ") == ideas and len(item) <= report_suggestions.SUMMARY_CHARS + assert " idea " in item and "Sentence 1 about" not in item # the stored ideas, not the post text + assert all("Sentence" in i for i in starts) + # A post with no stored ideas is still read, by its start + r = client.post("/api/reports/suggest", json={"document_ids": [str(docs[13].id)]}, headers=headers) + assert r.status_code == 200 and "Sentence" in seen[1]["items"][0] + + +def test_the_fast_models_output_cap_is_a_setting(): + from app.llm.provider import fast_lm + + assert settings.llm_fast_max_tokens == 6000 and fast_lm().kwargs["max_tokens"] == 6000 # quizzes and flashcards write under 3,000 diff --git a/backend/tests/test_report_blocks.py b/backend/tests/test_report_blocks.py new file mode 100644 index 0000000000000000000000000000000000000000..3875cf8631894f475dfb6610c8d4a52932d8e49e --- /dev/null +++ b/backend/tests/test_report_blocks.py @@ -0,0 +1,91 @@ +"""remove_block: a removed visual's offer to add it again comes back, so the reader is never stuck with no way in. +copy_block: reusing an artifact the user already made points the new block at it, never raising for one that is ready.""" + +import uuid + +import pytest + +from app.models import Artifact +from app.pipeline.generate import NothingToDo +from app.pipeline.report import copy_block, remove_block + + +def _content(blocks, suggestions=None): + return {"blocks": blocks, "suggestions": suggestions or []} + + +def test_removing_a_visual_restores_its_add_offer(): + content = _content([ + {"id": "b1", "type": "prose", "text": "intro"}, + {"id": "b2", "type": "quiz", "after": "b1", "title": "Quiz"}, + {"id": "b3", "type": "prose", "text": "more"}, + ]) + out = remove_block(content, "b2") + assert out is not None + assert [b["id"] for b in out["blocks"]] == ["b1", "b3"] + assert [s["kind"] for s in out["suggestions"]] == ["quiz"] + assert out["suggestions"][0]["after_block_id"] == "b1" + + +def test_removing_leaves_an_existing_suggestion_of_the_same_kind_alone(): + existing = {"id": "s1", "after_block_id": "b1", "kind": "quiz", "brief": "custom", "why": "custom"} + content = _content( + [{"id": "b1", "type": "prose", "text": "intro"}, {"id": "b2", "type": "quiz", "after": "b1", "title": "Quiz"}], + [existing], + ) + out = remove_block(content, "b2") + assert out["suggestions"] == [existing] # not duplicated + + +def test_prose_cannot_be_removed(): + content = _content([{"id": "b1", "type": "prose", "text": "intro"}]) + assert remove_block(content, "b1") is None + + +def test_unknown_block_id_returns_none(): + content = _content([{"id": "b1", "type": "prose", "text": "intro"}]) + assert remove_block(content, "nope") is None + + +def test_a_kind_with_no_fallback_brief_is_removed_without_a_restored_suggestion(): + content = _content([ + {"id": "b1", "type": "prose", "text": "intro"}, + {"id": "b2", "type": "table", "after": "b1"}, + ]) + out = remove_block(content, "b2") + assert out["blocks"] == [{"id": "b1", "type": "prose", "text": "intro"}] + assert out["suggestions"] == [] + + +def _artifact(content_json: dict, storage_key: str | None = None) -> Artifact: + a = Artifact(id=uuid.uuid4(), workspace_id=uuid.uuid4(), type="x", status="ready", content_json=content_json) + a.storage_key = storage_key + return a + + +def test_copy_block_reuses_a_ready_infographic_by_its_content_not_a_storage_key(): + # Infographics render from stored HTML (content_json["content"]), never from storage_key: that field is always + # None for them. Checking storage_key here used to make every reuse fail with "nothing to add". + existing = _artifact({"title": "My infographic", "theme": "midnight", "content": {"title": "t"}}, storage_key=None) + block = copy_block("infographic", existing) + assert block == {"title": "My infographic", "artifact_id": str(existing.id), "theme": "midnight"} + + +def test_copy_block_rejects_an_infographic_with_no_content(): + existing = _artifact({"title": "Empty"}, storage_key=None) + with pytest.raises(NothingToDo): + copy_block("infographic", existing) + + +def test_copy_block_reuses_mind_map_quiz_and_flashcards(): + assert copy_block("mind_map", _artifact({"title": "Map", "root": {"id": "root"}, "node_count": 3, "post_count": 2})) == \ + {"title": "Map", "root": {"id": "root"}, "node_count": 3, "post_count": 2} + assert copy_block("quiz", _artifact({"title": "Q", "questions": [{"question": "?"}]})) == \ + {"title": "Q", "questions": [{"question": "?"}]} + assert copy_block("flashcards", _artifact({"title": "C", "cards": [{"front": "f", "back": "b"}]})) == \ + {"title": "C", "cards": [{"front": "f", "back": "b"}]} + + +def test_copy_block_rejects_a_kind_with_nothing_to_reuse(): + with pytest.raises(NothingToDo): + copy_block("quiz", _artifact({"title": "Q", "questions": []})) diff --git a/backend/tests/test_report_suggestions.py b/backend/tests/test_report_suggestions.py new file mode 100644 index 0000000000000000000000000000000000000000..3ebf83c297139353943b8eefb0eadbd9ae3bc6b8 --- /dev/null +++ b/backend/tests/test_report_suggestions.py @@ -0,0 +1,121 @@ +"""The suggested templates in the Create report dialog are written once, kept, prepared in the background and served at once.""" + +import pytest +from sqlalchemy import select + +from app.config import settings +from app.llm import run +from app.models import Job, ReportTemplateCache +from app.services import report_suggestions +from tests.test_idea_pool import make_posts, owner # noqa: F401 (the fixture) + + +@pytest.fixture() +def asked(monkeypatch): + """Fake the model; `asked` lists what it was asked, and `answer` is what it says.""" + calls: list[list[str]] = [] + state = {"answer": [{"name": f"Template {n}", "description": "d", "prompt": "Write about {about}."} for n in range(4)]} + + def predict(signature, *, db, workspace_id, job=None, lm=None, **inputs): + assert signature.__name__ == "SuggestReportTemplates" + calls.append(inputs["items"]) + return {"templates": state["answer"]} + + monkeypatch.setattr(run, "predict", predict) + return calls, state + + +def _suggest(client, headers, docs): + return client.post("/api/reports/suggest", json={"document_ids": [str(d.id) for d in docs]}, headers=headers) + + +def test_the_same_sources_get_their_templates_again_without_the_model(client, owner, db_session, asked): # noqa: F811 + headers, ws = owner + calls, state = asked + docs = make_posts(db_session, ws, 4) + first = _suggest(client, headers, docs).json()["templates"] + again = _suggest(client, headers, list(reversed(docs))).json()["templates"] # the order they were ticked in does not matter + assert len(calls) == 1 and first == again and [t["id"] for t in first] == [f"suggested-{n}" for n in range(1, 5)] + assert len(db_session.scalars(select(ReportTemplateCache)).all()) == 1 + # A post whose ideas changed is a different input: new templates, the old set is left alone + docs[0].metadata_json = {**docs[0].metadata_json, "ideas": [{"label": "A new idea", "note": "Changed.", "sources": [], "details": []}]} + db_session.commit() + _suggest(client, headers, docs) + assert len(calls) == 2 and len(db_session.scalars(select(ReportTemplateCache)).all()) == 2 + # What the model read is the stored ideas, not the post text + assert any("- A new idea: Changed." in item for item in calls[1]) and not any("Sentence 1 about" in item for item in calls[1]) + + +def test_a_failed_or_empty_answer_is_never_kept(client, owner, db_session, asked): # noqa: F811 + headers, ws = owner + calls, state = asked + docs = make_posts(db_session, ws, 3) + state["answer"] = [] + assert _suggest(client, headers, docs).json() == {"templates": []} + state["answer"] = [{"name": "", "description": "", "prompt": ""}, {"name": "Only", "description": "d", "prompt": "p"}, + {"name": "only", "description": "dup", "prompt": "p"}] # a blank and a repeat are dropped + assert [t["name"] for t in _suggest(client, headers, docs).json()["templates"]] == ["Only"] + assert len(calls) == 2 # the empty answer was not kept, so the next request asked again + + +def test_templates_are_prepared_in_the_background_so_the_dialog_is_instant(client, owner, db_session, run_jobs, asked, monkeypatch): # noqa: F811 + headers, ws = owner + calls, _ = asked + monkeypatch.setattr(settings, "llm_api_key", "test-key") + docs = make_posts(db_session, ws, 12, ideas_each=4) + client.post("/api/notebooks", json={"title": "Big", "document_ids": [str(d.id) for d in docs]}, headers=headers) + client.post("/api/notebooks", json={"title": "Same again", "document_ids": [str(d.id) for d in docs[:3]]}, headers=headers) + queued = db_session.scalars(select(Job).where(Job.kind == "report_templates")).all() + assert len(queued) == 1 # one at a time, however many notebooks changed + assert run_jobs()[0].kind == "report_templates" and len(calls) == 2 # one set written for each notebook + ran = len(calls) + monkeypatch.setattr(run, "predict", lambda *a, **k: pytest.fail("the dialog's request was prepared already")) + r = _suggest(client, headers, docs) # what the dialog sends: the notebook's posts + assert r.status_code == 200 and len(r.json()["templates"]) == 4 and ran == 2 + + +def test_warming_skips_posts_whose_ideas_are_not_stored_yet_and_sets_already_kept(db_session, owner, asked): # noqa: F811 + _, ws = owner + calls, _ = asked + from app.models import Notebook, NotebookDocument + + bare = make_posts(db_session, ws, 4, with_ideas=0) + nb = Notebook(workspace_id=ws.id, title="Bare") + db_session.add(nb) + db_session.flush() + db_session.add_all([NotebookDocument(notebook_id=nb.id, document_id=d.id) for d in bare]) + db_session.commit() + assert report_suggestions.warm_workspace(db_session, ws.id) == 0 and calls == [] # waits for the ideas + for d in bare: + d.metadata_json = {"ideas_hash": d.content_hash, "ideas": [{"label": "Idea", "note": "Note.", "sources": [], "details": []}]} + db_session.commit() + assert report_suggestions.warm_workspace(db_session, ws.id) == 1 and len(calls) == 1 + assert report_suggestions.warm_workspace(db_session, ws.id) == 0 and len(calls) == 1 # already kept + + +def test_only_one_warm_up_is_queued_and_none_without_a_model_key(db_session, owner, monkeypatch): # noqa: F811 + _, ws = owner + monkeypatch.setattr(settings, "llm_api_key", "") + report_suggestions.queue_warmup(db_session, ws.id) + assert db_session.scalars(select(Job).where(Job.kind == "report_templates")).all() == [] + monkeypatch.setattr(settings, "llm_api_key", "k") + report_suggestions.queue_warmup(db_session, ws.id) + report_suggestions.queue_warmup(db_session, ws.id) + assert len(db_session.scalars(select(Job).where(Job.kind == "report_templates")).all()) == 1 + + +def test_unticking_a_post_that_is_not_one_of_the_few_read_gives_the_kept_templates_at_once(client, owner, db_session, asked): # noqa: F811 + headers, ws = owner + calls, _ = asked + docs = make_posts(db_session, ws, 30) + everything = _suggest(client, headers, docs).json()["templates"] + assert len(calls) == 1 + read = report_suggestions.stable_order(docs)[: report_suggestions.SUGGEST_ITEMS] + others = [d for d in docs if d not in read] + for gone in (others[0], others[7], others[-1]): # the newest, one in the middle, the oldest + picked = [d for d in docs if d is not gone] + assert _suggest(client, headers, picked).json()["templates"] == everything + assert len(calls) == 1 # no new model call: every one of these was a kept set + # Unticking one of the few that are read is a different input, and is asked for (and kept) once + _suggest(client, headers, [d for d in docs if d is not read[0]]) + assert len(calls) == 2 diff --git a/backend/tests/test_report_themes.py b/backend/tests/test_report_themes.py new file mode 100644 index 0000000000000000000000000000000000000000..1eccb4e533dbca52cd280503d4dd8fc22d7e8a34 --- /dev/null +++ b/backend/tests/test_report_themes.py @@ -0,0 +1,553 @@ +"""Reports built around themes: the posts' stored ideas are grouped into weighted themes, a blueprint plans the whole report, each +section is written from its own evidence. The model is always faked here (by signature name, as the other report tests do).""" + +import logging +import uuid +from types import SimpleNamespace + +import pytest +from sqlalchemy import select + +from app.config import settings +from app.corpus import Corpus +from app.llm import run +from app.models import Document, Workspace +from app.pipeline import report_blueprint, report_evidence, report_themes +from app.pipeline.generate import ideas_fresh +from app.pipeline.report_blueprint import Blueprint, Section +from app.pipeline.report_themes import Idea, Theme, ThemesUnavailable +from tests.conftest import last_code +from tests.test_idea_pool import _notebook, make_posts + + +@pytest.fixture() +def owner(client, db_session): + client.post("/api/auth/email/register/start", json={"email": "ada@example.com", "password": "stardust-42", "name": "Ada"}) + r = client.post("/api/auth/email/register/verify", json={"email": "ada@example.com", "code": last_code()}) + return {"Authorization": f"Bearer {r.json()['access_token']}"}, db_session.scalar(select(Workspace)) + + +def fake_doc() -> SimpleNamespace: + return SimpleNamespace(id=uuid.uuid4(), path="sources/x/a.md", title="A") + + +def theme(tid: str, name: str, posts: int, *, asked: bool = False, tier: str = "minor", weight: float = 0.1) -> Theme: + t = Theme(id=tid, name=name, summary=f"About {name}.", ideas=[Idea(f"{tid}.{n}", fake_doc(), f"{name} {n}", "n") for n in range(posts)], + asked=asked) + t.tier, t.weight, t.pinned = tier, weight, asked + return t + + +# Weights are counted in code + + +def test_weights_come_from_counting_posts_and_ideas_and_a_priority_theme_moves_up(): + a, b, c = theme("t1", "A", 8), theme("t2", "B", 4), theme("t3", "C", 1, asked=True) + themes = [a, b, c] + report_themes.weigh(themes, total_posts=13, total_ideas=13) + assert round(sum(t.weight for t in themes), 3) == 1.0 + assert (a.weight, b.weight, c.weight) == (0.6154, 0.3077, 0.0769) + assert (a.tier, b.tier) == ("major", "major") + assert c.tier == "medium" and c.pinned # minor by share, asked for: one tier up and pinned + + +def test_the_heaviest_theme_is_major_even_when_the_weights_are_flat(): + themes = [theme(f"t{n}", f"T{n}", 3) for n in range(1, 11)] # ten equal themes: 0.1 each, medium + report_themes.weigh(themes, total_posts=30, total_ideas=30) + assert sum(t.tier == "major" for t in themes) == 1 + + +# Grouping: the model names themes, code files what it left out + + +def _ideas(db_session, ws, n=8): + docs = make_posts(db_session, ws, n, ideas_each=2) + corpus = Corpus(ws.id) + corpus.sync() + return docs, report_themes.collect_ideas(docs, corpus) + + +def test_ideas_the_model_left_out_are_filed_by_code_and_the_rest_are_dropped_or_caught(owner, db_session, monkeypatch): + _, ws = owner + docs, ideas = _ideas(db_session, ws) # post i has ideas p{i}.0 and p{i}.1; posts cycle Pricing, Growth, Craft + + def predict(sig, *, db, workspace_id, job=None, lm=None, **inputs): + assert sig.__name__ == "GroupThemes" and "p0.0 | Pricing idea 0.0:" in inputs["ideas"][0] + return {"themes": [ + {"name": "Pricing", "summary": "s", "matches_request": False, + "idea_ids": ["p0.0", "p0.1", "p3.0", "p3.1", "p6.0", "p6.1", "nope"]}, + {"name": "Audience Growth", "summary": "s", "matches_request": True, + "idea_ids": ["p1.0", "p1.1", "p4.0", "p4.1", "p0.0"]}, + {"name": "", "summary": "no name", "idea_ids": ["p2.0"], "matches_request": False}], + "relations": [{"a": "pricing", "b": "Audience Growth", "relation": "Price decides who stays."}, + {"a": "Pricing", "b": "Ghost", "relation": "unknown theme"}]} + + monkeypatch.setattr(run, "predict", predict) + themes, relations = report_themes.group_themes(db_session, None, ws.id, "Money", "a report", ideas) + by_name = {t.name: t for t in themes} + assert set(by_name) == {"Pricing", "Audience Growth", report_themes.CATCH_ALL} + assert len(by_name["Pricing"].ideas) == 6 # the invented id was dropped + assert "p0.0" not in [i.id for i in by_name["Audience Growth"].ideas] # an idea belongs to one theme only + growth = {i.id for i in by_name["Audience Growth"].ideas} + assert {"p7.0", "p7.1"} <= growth # filed by the words it shares with the theme + assert {i.doc.path for i in by_name[report_themes.CATCH_ALL].ideas} == {docs[2].path, docs[5].path} # nothing fits them + assert sum(len(t.ideas) for t in themes) == len(ideas) # every idea counts, so the weights cover all posts + assert by_name["Audience Growth"].pinned and [t.id for t in themes] == ["t1", "t2", "t3"] + assert relations == [{"a": "t1", "b": "t2", "relation": "Price decides who stays."}] + + +def test_too_few_themes_means_the_posts_are_read_as_before(owner, db_session, monkeypatch): + _, ws = owner + _, ideas = _ideas(db_session, ws) + monkeypatch.setattr(run, "predict", lambda *a, **k: {"themes": [{"name": "Only", "summary": "", "idea_ids": ["p0.0"]}], + "relations": []}) + with pytest.raises(ThemesUnavailable): + report_themes.group_themes(db_session, None, ws.id, "Money", "r", ideas) + + +def test_the_model_sees_at_most_300_ideas_one_round_of_every_post_at_a_time(owner, db_session): + _, ws = owner + docs = make_posts(db_session, ws, 120, ideas_each=8) + corpus = Corpus(ws.id) + corpus.sync() + ideas = report_themes.collect_ideas(docs, corpus) + listed = report_themes.listed_ideas(ideas) + assert len(ideas) == 960 and len(listed) == report_themes.MAX_LISTED_IDEAS + assert len({i.doc.id for i in listed}) == 120 # every post is in the sample + + +def test_a_post_without_ideas_is_represented_by_its_opening(owner, db_session): + _, ws = owner + docs = make_posts(db_session, ws, 2, with_ideas=0) + corpus = Corpus(ws.id) + corpus.sync() + ideas = report_themes.collect_ideas(docs, corpus) + assert [i.bare for i in ideas] == [True, True] and ideas[0].label == docs[0].title + assert ideas[0].sources[0]["path"] == docs[0].path and ideas[0].sources[0]["line_start"] >= 1 + + +def test_ideas_are_extracted_together_for_at_most_the_inline_limit(owner, db_session, monkeypatch): + _, ws = owner + docs = make_posts(db_session, ws, 6, with_ideas=0) + seen = {} + + def predict_many(sig, inputs_list, *, db, workspace_id, lm=None, workers=5): + seen["n"] = len(inputs_list) + return [{"ideas": [{"label": "Idea", "note": "n", "details": [], + "sources": [{"path": docs[0].path, "line_start": 7, "line_end": 9}]}]} for _ in inputs_list] + + monkeypatch.setattr(run, "predict_many", predict_many) + monkeypatch.setattr(run, "predict", lambda *a, **k: pytest.fail("together, not one by one")) + monkeypatch.setattr(settings, "report_inline_ideas", 4) + report_themes.ensure_ideas(db_session, None, ws.id, docs) + assert seen["n"] == 4 and sum(ideas_fresh(d) for d in docs) == 4 + assert report_themes.fresh_share(docs) == pytest.approx(4 / 6) + + +# The blueprint is checked against the weights + + +def _bp(*sections: Section, connections=None) -> Blueprint: + return Blueprint(title="T", storyline="S", sections=list(sections), connections=connections or []) + + +def _sec(heading, role="body", ids=(), depth="standard", must=()): + return Section(heading=heading, role=role, brief=f"{heading} brief", theme_ids=list(ids), depth=depth, must_cover=list(must)) + + +def test_the_plan_is_made_to_cover_every_major_and_asked_for_theme_in_its_own_section(): + themes = [theme("t1", "Pricing", 5, tier="major", weight=0.5), theme("t2", "Hiring", 3, tier="major", weight=0.3), + theme("t3", "Tools", 1, weight=0.1), theme("t4", "Remote", 1, asked=True, tier="medium", weight=0.1)] + plan = _bp(_sec("Summary", "summary"), _sec("Pricing power", ids=["t1", "t1", "ghost"], depth="brief", must=["a"]), + _sec("Also pricing", ids=["t1"]), _sec("Opening", "intro"), + connections=[{"theme_ids": ["t1", "t2"], "relation": "linked", "section": "pricing POWER"}, + {"theme_ids": ["t1", "zzz"], "relation": "unknown theme", "section": "x"}]) + fixed = report_blueprint.repair(plan, themes) + heads = [s.heading for s in fixed.sections] + assert heads == ["Opening", "Pricing power", "Hiring", "Remote", "More to know", "Summary"] # intro first, summary last + by = {s.heading: s for s in fixed.sections} + assert by["Pricing power"].theme_ids == ["t1"] and by["Pricing power"].depth == "deep" # depth follows the weight + assert by["Remote"].depth == "deep" and by["More to know"].theme_ids == ["t3"] and by["More to know"].depth == "brief" + assert by["Summary"].theme_ids == ["t1", "t2", "t4"] and by["Summary"].depth == "brief" + assert "Hiring: About Hiring." in by["Pricing power"].covered_elsewhere + assert not any(c.startswith("Pricing power") for c in by["Pricing power"].covered_elsewhere) + assert by["Opening"].covered_elsewhere == [] and by["Summary"].covered_elsewhere == [] + assert fixed.connections == [{"theme_ids": ["t1", "t2"], "relation": "linked", "section": "Pricing power"}] + + +def test_too_many_sections_merge_the_lightest_but_never_a_major_theme(): + themes = [theme("t1", "Big", 5, tier="major", weight=0.4)] + \ + [theme(f"t{n}", f"Small {n}", 1, weight=0.05 + n / 1000) for n in range(2, 12)] + plan = _bp(_sec("Open", "intro"), _sec("Big one", ids=["t1"]), *[_sec(f"S{n}", ids=[f"t{n}"]) for n in range(2, 12)], + _sec("End", "summary")) + fixed = report_blueprint.repair(plan, themes) + assert len(fixed.sections) == report_blueprint.MAX_SECTIONS + assert [s for s in fixed.sections if s.heading == "Big one"][0].theme_ids == ["t1"] + assert sorted(i for s in fixed.sections if s.role == "body" for i in s.theme_ids) == sorted(t.id for t in themes) # none lost + + +def test_a_plan_with_one_section_is_not_usable(): + with pytest.raises(report_blueprint.BlueprintUnavailable): + report_blueprint.repair(_bp(_sec("Only", ids=["t1"])), [theme("t1", "A", 1, tier="major", weight=1.0), ]) + + +# The evidence of a section + + +def _themes_from(docs, ideas, groups): + """Themes from an index range of posts each, weighed.""" + themes = [Theme(id=f"t{n}", name=name, summary=f"About {name}.", ideas=[i for i in ideas if lo <= docs.index(i.doc) < hi]) + for n, (name, lo, hi) in enumerate(groups, start=1)] + report_themes.weigh(themes, len(docs), len(ideas)) + return themes + + +def test_a_deep_section_reads_several_posts_as_written_and_summarises_the_rest(owner, db_session): + _, ws = owner + docs, ideas = _ideas(db_session, ws, 10) + corpus = Corpus(ws.id) + t = _themes_from(docs, ideas, [("Everything", 0, 10), ("Other", 0, 0)][:1]) + sec = _sec("Deep", ids=["t1"], depth="deep") + ev = report_evidence.build(corpus, sec, {x.id: x for x in t}) + raw = [b for b in ev.blocks if b.startswith("POST ")] + assert 2 <= len(raw) == ev.raw_posts == 5 # a deep section reads five posts as written, never one + assert any(b.startswith("SUMMARIES of other posts") for b in ev.blocks) + assert len(ev.posts) == 10 # the other five are represented by their summaries + assert "Sentence 1 about" in raw[0] and "[themes: Everything]" in raw[0] # the post's own words + assert "7| " not in raw[0] and "sources/x" not in raw[0] # nothing is cited, so no line numbers and no paths + assert sum(len(b) for b in ev.blocks) <= report_evidence.BUDGETS["deep"] + + +def test_a_brief_section_gets_summaries_only_and_a_standard_one_three_posts(owner, db_session): + _, ws = owner + docs, ideas = _ideas(db_session, ws, 10) + corpus = Corpus(ws.id) + t = _themes_from(docs, ideas, [("Everything", 0, 10)]) + by_id = {x.id: x for x in t} + brief = report_evidence.build(corpus, _sec("Brief", ids=["t1"], depth="brief"), by_id) + assert brief.raw_posts == 0 and not any(b.startswith("POST ") for b in brief.blocks) + assert sum(len(b) for b in brief.blocks) <= report_evidence.BUDGETS["brief"] + assert all(' (from "Post ' in line for line in brief.blocks[0].splitlines()[1:]) # each summary says which post it is from + standard = report_evidence.build(corpus, _sec("Std", ids=["t1"], depth="standard"), by_id) + assert standard.raw_posts == 3 + + +def test_a_section_about_two_themes_starts_with_the_posts_that_speak_of_both(owner, db_session): + _, ws = owner + docs, ideas = _ideas(db_session, ws, 8) + corpus = Corpus(ws.id) + a, b = Theme("t1", "A", "", []), Theme("t2", "B", "", []) + for d in docs: + mine = [i for i in ideas if i.doc is d] + a.ideas.append(mine[0]) + if d in docs[5:7]: # only these two posts have an idea in each theme + b.ideas.append(mine[1]) + report_themes.weigh([a, b], 8, len(ideas)) + ev = report_evidence.build(corpus, _sec("Bridge", "synthesis", ids=["t1", "t2"], depth="standard"), {"t1": a, "t2": b}) + first = [b_ for b_ in ev.blocks if b_.startswith("POST ")][:2] + assert {docs[5].title, docs[6].title} == {blk.split(" [themes")[0][5:] for blk in first} + + +def test_an_unreadable_post_falls_back_to_its_summary(owner, db_session): + _, ws = owner + docs, ideas = _ideas(db_session, ws, 6) + ideas[0].doc.path = "sources/x/missing.md" + corpus = Corpus(ws.id) + t = _themes_from(docs, ideas, [("All", 0, 6)]) + ev = report_evidence.build(corpus, _sec("S", ids=["t1"], depth="deep"), {"t1": t[0]}) + assert "sources/x/missing.md" in ev.posts and ev.raw_posts == 5 # the five others + + +# The whole report + + +@pytest.fixture() +def free_to_make(monkeypatch): + monkeypatch.setattr("app.routers.artifacts.check_limit", lambda *a, **k: None) + + +class Fake: + """The model, by signature name. `calls` is every call in order; `sections` the inputs of every WriteThemedSection.""" + + def __init__(self, monkeypatch, docs, groups, ask=None, fail=(), quiz_themes=()): + self.docs, self.calls, self.sections, self.fail = docs, [], [], set(fail) + self.quiz_themes = list(quiz_themes) + self.groups, self.ask, self.plan_inputs, self.report_inputs = groups, ask, None, None + monkeypatch.setattr(run, "predict", self.predict) + monkeypatch.setattr(run, "predict_many", self.predict_many) + + def predict(self, sig, *, db, workspace_id, job=None, lm=None, **inputs): + name = sig.__name__ + self.calls.append(name) + if name == "GroupThemes": + ids = [line.split(" | ")[0] for line in inputs["ideas"]] + out = [] + for title, lo, hi in self.groups: + mine = [i for i in ids if lo <= int(i[1:].split(".")[0]) < hi] + out.append({"name": title, "summary": f"About {title}.", "idea_ids": mine, "matches_request": title == self.ask}) + rel = [{"a": self.groups[0][0], "b": self.groups[1][0], "relation": "One shapes the other."}] if len(self.groups) > 1 else [] + return {"themes": out, "relations": rel} + if name == "PlanReportBlueprint": + self.plan_inputs = inputs + return {"report_title": "Pricing and growth", "storyline": "Prices set expectations and growth follows.", "sections": [ + {"heading": "Why this matters", "role": "intro", "brief": "Opens.", "theme_ids": [], "depth": "brief", + "must_cover": ["scope"]}, + {"heading": "Pricing power", "role": "body", "brief": "Pricing.", "theme_ids": ["t1"], "depth": "deep", + "must_cover": ["raise slowly"], + "embed": {"kind": "flashcards", "title": "Terms", "brief": "Price terms", "why": "Learn them"}}, + {"heading": "Audience growth", "role": "body", "brief": "Growth.", "theme_ids": ["t2"], "depth": "standard", + "must_cover": ["retention"]}, + {"heading": "Quick quiz", "role": "body", "brief": "Test it.", "theme_ids": self.quiz_themes, "depth": "brief", + "must_cover": []}, + {"heading": "Wrap up", "role": "summary", "brief": "Closes.", "theme_ids": [], "depth": "brief", + "must_cover": ["main points"]}], + "connections": [{"theme_ids": ["t1", "t2"], "relation": "Price shapes who stays.", + "section": "Audience growth"}]} + if name == "WriteThemedReport": + self.report_inputs = inputs + text = "## Why this matters\nIntro [1].\n\n## Pricing power\nBody [2].\n\n## Wrap up\nEnd." + return {"report_title": "One Piece", "markdown": text} + if name == "WriteReport": + return {"report_title": "Legacy", "markdown": "## One\nText."} + raise AssertionError(f"unexpected {name}") + + def predict_many(self, sig, inputs_list, *, db, workspace_id, lm=None, workers=5): + assert sig.__name__ == "WriteThemedSection" + out = [] + for inp in inputs_list: + self.sections.append(inp) + self.calls.append("WriteThemedSection") + if inp["heading"] in self.fail: + out.append(None) + else: + out.append({"markdown": f"{inp['heading']} text [1]."}) # a marker the model wrote anyway + return out + + +def _report(client, owner, docs, fmt, **extra): + headers, _ = owner + nb = _notebook(client, owner, docs) + return client.post("/api/artifacts/generate", json={ + "type": "report", "notebook_id": nb["id"], "document_ids": [str(d.id) for d in docs], "report_format": fmt, + "instructions": "Write a report.", **extra}, headers=headers).json() + + +def _content(client, owner, art): + return client.get(f"/api/artifacts/{art['id']}", headers=owner[0]).json()["content"] + + +GROUPS = [("Pricing", 0, 6), ("Audience Growth", 6, 8), ("Writing Craft", 8, 10)] # 60%, 20%, 20% of the posts + + +def test_an_interactive_report_is_planned_then_written_section_by_section_from_its_own_evidence(client, owner, db_session, run_jobs, + monkeypatch, free_to_make): + docs = make_posts(db_session, owner[1], 10, ideas_each=2) + fake = Fake(monkeypatch, docs, GROUPS, quiz_themes=["t3"]) # the plan wrongly makes a section of a quiz + art = _report(client, owner, docs, "interactive") + run_jobs() + c = _content(client, owner, art) + assert c["format"] == "interactive" and c["plan"]["planner"] == "themes" + assert [b["text"].split("\n")[0] for b in c["blocks"]] == [ + "## Why this matters", "## Pricing power", "## Audience growth", "## Wrap up"] + assert "PlanInteractiveReport" not in fake.calls and "WriteReportSection" not in fake.calls and "WriteReport" not in fake.calls + assert fake.calls[:2] == ["GroupThemes", "PlanReportBlueprint"] + # The weights are counted: Pricing is in six of ten posts + themes = {t["name"]: t for t in c["plan"]["themes"]} + assert themes["Pricing"]["tier"] == "major" and themes["Pricing"]["weight"] == 0.6 and themes["Audience Growth"]["tier"] == "medium" + assert c["plan"]["posts_total"] == 10 and c["source"]["total"] == 10 and len(c["source"]["document_ids"]) == 10 + # Nothing is cited. The Sources list is every post the report was made from, shown in a drop-down with the prompt + assert c["citations"] == [] and not any("[" in b["text"] for b in c["blocks"]) + assert c["source"]["read"] == c["plan"]["posts_used"] == len(c["sources"]) == 10 + assert {x["document_id"] for x in c["sources"]} == {str(d.id) for d in docs} and all("markers" not in x for x in c["sources"]) + assert c["instructions"] == "Write a report." + # The planner saw the allowed visuals and the themes, never the posts + assert fake.plan_inputs["allowed_kinds"] and all("POST" not in line for line in fake.plan_inputs["themes"]) + # Body sections first, then the introduction and the summary, written from what the sections say + written = [s["heading"] for s in fake.sections] + assert written[:2] == ["Pricing power", "Audience growth"] and written[2:] == ["Why this matters", "Wrap up"] + pricing = fake.sections[0] + assert pricing["storyline"].startswith("Prices set") and len(pricing["outline"]) == 4 + assert pricing["covered_elsewhere"][0].startswith("Audience growth:") + assert all(not x.startswith("Pricing power") for x in pricing["covered_elsewhere"]) + assert sum(e.startswith("POST ") for e in pricing["evidence"]) >= 2 # several posts as written + growth = fake.sections[1] + assert growth["bridges"] == ["Pricing + Audience Growth: Price shapes who stays. (bridged in: Audience growth)"] + assert fake.sections[2]["evidence"][0].startswith("Pricing power: Pricing power text") and fake.sections[2]["role"] == "intro" + assert "Restate the main points about: Pricing" in fake.sections[3]["must_cover"][-1] + assert growth["evidence"] and "Writing Craft" in growth["evidence"][-1] # the quiz section's theme stayed in the report + # Visuals are only suggested; the quiz section became a suggestion + # The plan suggested no infographic, so one is offered after the last section before the summary + assert [(s["after_block_id"], s["kind"]) for s in c["suggestions"]] == [ + ("b2", "flashcards"), ("b3", "quiz"), ("b3", "infographic")] + assert "failed_sections" not in c + + +def test_a_document_report_is_written_in_one_piece_from_the_plan_and_offers_no_visuals(client, owner, db_session, run_jobs, + monkeypatch, free_to_make): + docs = make_posts(db_session, owner[1], 10, ideas_each=2) + fake = Fake(monkeypatch, docs, GROUPS) + art = _report(client, owner, docs, "document") + run_jobs() + c = _content(client, owner, art) + assert c["format"] == "document" and c["plan"]["planner"] == "themes" + assert c["suggestions"] == [] # never in a document + assert fake.calls == ["GroupThemes", "PlanReportBlueprint", "WriteThemedReport"] + assert fake.plan_inputs["allowed_kinds"] == [] + sections = fake.report_inputs["sections"] + assert sections[0].startswith("SECTION 1: Why this matters") and all(s.startswith("SECTION") for s in sections) + assert "Evidence:\nPOST " in sections[1] and fake.report_inputs["storyline"].startswith("Prices set") + assert [b["text"].split("\n")[0] for b in c["blocks"]] == ["## Why this matters", "## Pricing power", "## Wrap up"] + assert c["citations"] == [] and not any("[" in b["text"] for b in c["blocks"]) and len(c["sources"]) == 10 + assert sum(len(s) for s in sections) < report_evidence.BUDGETS["deep"] * 4 # the evidence fits its budget + + +def test_more_than_twenty_posts_are_all_used(client, owner, db_session, run_jobs, monkeypatch, free_to_make): + docs = make_posts(db_session, owner[1], 30, ideas_each=2) + fake = Fake(monkeypatch, docs, [("Pricing", 0, 20), ("Audience Growth", 20, 25), ("Writing Craft", 25, 30)]) + art = _report(client, owner, docs, "interactive") + run_jobs() + c = _content(client, owner, art) + assert c["plan"]["posts_total"] == 30 and len(c["source"]["document_ids"]) == 30 + assert {t["name"]: t["posts"] for t in c["plan"]["themes"]} == {"Pricing": 20, "Audience Growth": 5, "Writing Craft": 5} + assert fake.calls[0] == "GroupThemes" + + +def test_a_theme_the_reader_asked_for_gets_its_own_deep_section(client, owner, db_session, run_jobs, monkeypatch, free_to_make): + docs = make_posts(db_session, owner[1], 10, ideas_each=2) + Fake(monkeypatch, docs, GROUPS, ask="Writing Craft") + art = _report(client, owner, docs, "interactive", instructions="Focus on writing craft.") + run_jobs() + c = _content(client, owner, art) + sections = {s["heading"]: s for s in c["plan"]["sections"]} + assert sections["Writing Craft"]["depth"] == "deep" and sections["Writing Craft"]["theme_ids"] == ["t3"] + assert {t["name"]: t["pinned"] for t in c["plan"]["themes"]}["Writing Craft"] is True + + +def test_a_section_that_fails_is_tried_again_then_reported_not_hidden(client, owner, db_session, run_jobs, monkeypatch, free_to_make): + docs = make_posts(db_session, owner[1], 10, ideas_each=2) + seen: list[str] = [] + + class Flaky(Fake): + def predict_many(self, sig, inputs_list, **kw): + seen.extend(i["heading"] for i in inputs_list) + return super().predict_many(sig, inputs_list, **kw) + + Flaky(monkeypatch, docs, GROUPS, fail=["Audience growth"]) + art = _report(client, owner, docs, "interactive") + run_jobs() + c = _content(client, owner, art) + assert seen.count("Audience growth") == 2 # once, then once more + assert c["failed_sections"] == ["Audience growth"] + assert [b["text"].split("\n")[0] for b in c["blocks"]] == ["## Why this matters", "## Pricing power", "## More to know", "## Wrap up"] + + +def test_when_the_ideas_cannot_be_themed_the_posts_are_read_as_before(client, owner, db_session, run_jobs, monkeypatch, free_to_make): + docs = make_posts(db_session, owner[1], 10, ideas_each=2) + fake = Fake(monkeypatch, docs, [("Only", 0, 10)]) # a single theme is no plan + art = _report(client, owner, docs, "document") + run_jobs() + c = _content(client, owner, art) + assert fake.calls == ["GroupThemes", "WriteReport"] and "plan" not in c and c["title"] == "Legacy" + + +def test_small_selections_chats_and_the_legacy_setting_keep_the_old_way(client, owner, db_session, run_jobs, monkeypatch, free_to_make): + docs = make_posts(db_session, owner[1], 10, ideas_each=2) + fake = Fake(monkeypatch, docs, GROUPS) + small = _report(client, owner, docs[:5], "document") # five posts or fewer: read whole + run_jobs() + assert fake.calls == ["WriteReport"] and "plan" not in _content(client, owner, small) + fake.calls.clear() + monkeypatch.setattr(settings, "report_planner", "legacy") + _report(client, owner, docs, "document") + run_jobs() + assert fake.calls == ["WriteReport"] + + +def test_posts_without_ideas_use_the_old_way_until_half_have_them(client, owner, db_session, run_jobs, monkeypatch, free_to_make): + docs = make_posts(db_session, owner[1], 10, with_ideas=3, ideas_each=2) + monkeypatch.setattr(settings, "report_inline_ideas", 0) # nothing is extracted while the report is made + fake = Fake(monkeypatch, docs, GROUPS) + _report(client, owner, docs, "document") + run_jobs() + assert fake.calls == ["WriteReport"] + assert [d for d in db_session.scalars(select(Document)) if ideas_fresh(d)].__len__() == 3 + + +def test_every_step_of_a_themed_report_is_logged(client, owner, db_session, run_jobs, monkeypatch, free_to_make, caplog): + caplog.set_level(logging.INFO, logger="app") + docs = make_posts(db_session, owner[1], 10, ideas_each=2) + Fake(monkeypatch, docs, GROUPS) + _report(client, owner, docs, "interactive") + run_jobs() + text = "\n".join(r.getMessage() for r in caplog.records) + for expected in ("report posts: ", "themed report starting: ", "report ideas collected: ", "report themes: asking the model", + "report theme: id=t1 name='Pricing' weight=0.600 tier=major", "report blueprint checked: ", + "report section 1: heading='Why this matters'", "report evidence: section='Pricing power' depth=deep", + "report wave 1: ", "report wave 2: ", "report interactive written: ", "themed report done: ", + "report: job="): + assert expected in text, expected + assert "way=themes" in text + + +def test_a_fallback_says_why(client, owner, db_session, run_jobs, monkeypatch, free_to_make, caplog): + caplog.set_level(logging.INFO, logger="app") + docs = make_posts(db_session, owner[1], 10, ideas_each=2) + Fake(monkeypatch, docs, [("Only", 0, 10)]) + _report(client, owner, docs, "document") + run_jobs() + text = "\n".join(r.getMessage() for r in caplog.records) + assert "report themes unusable: usable=1 needed=2" in text and "reading the posts instead" in text and "way=legacy" in text + caplog.clear() + _report(client, owner, docs[:5], "document") + run_jobs() + assert "report route: legacy" in "\n".join(r.getMessage() for r in caplog.records) + + +def test_a_report_always_offers_visuals_and_never_repeats_what_the_plan_suggested(): + from app.pipeline import report + + blocks = [{"id": f"b{n}", "type": "prose"} for n in range(1, 6)] + kinds = list(report.EMBED_KINDS) + nothing = report.fill_suggestions([], blocks, kinds) + assert [(s["kind"], s["after_block_id"]) for s in nothing] == [("infographic", "b4"), ("flashcards", "b2"), ("quiz", "b3")] + planned = [{"id": "s1", "after_block_id": "b1", "kind": "infographic", "brief": "x", "why": "y"}] + kept = report.fill_suggestions(planned, blocks, kinds) + assert kept[0] == planned[0] and [s["kind"] for s in kept] == ["infographic", "flashcards", "quiz"] # not offered twice + assert [s["kind"] for s in report.fill_suggestions([], blocks, ["infographic", "mind_map"])] == ["infographic", "mind_map"] + assert report.fill_suggestions([], blocks, []) == [] and report.fill_suggestions([], [], kinds) == [] + chat = report.fill_suggestions([], blocks, [k for k in kinds if k != "mind_map"]) + assert "mind_map" not in {s["kind"] for s in chat} + + +def test_an_older_report_without_suggestions_gets_them_once_when_opened(client, owner, db_session, run_jobs, monkeypatch, free_to_make): + from app.models import Artifact + + docs = make_posts(db_session, owner[1], 10, ideas_each=2) + Fake(monkeypatch, docs, GROUPS) + art = _report(client, owner, docs, "interactive") + run_jobs() + row = db_session.get(Artifact, uuid.UUID(art["id"])) + row.content_json = {k: v for k, v in row.content_json.items() if k not in ("suggestions", "suggestions_offered")} # as an old one + db_session.commit() + first = _content(client, owner, art) + assert {"infographic", "flashcards", "quiz"} <= {s["kind"] for s in first["suggestions"]} and first["suggestions_offered"] + # Once: a reader who has added or taken everything away is not offered them again + row = db_session.get(Artifact, uuid.UUID(art["id"])) + db_session.refresh(row) + row.content_json = {**row.content_json, "suggestions": []} + db_session.commit() + assert _content(client, owner, art)["suggestions"] == [] + + +def test_a_document_report_is_never_given_suggestions_not_even_an_old_one_when_opened(client, owner, db_session, run_jobs, monkeypatch, + free_to_make): + from app.models import Artifact + + docs = make_posts(db_session, owner[1], 10, ideas_each=2) + Fake(monkeypatch, docs, GROUPS) + art = _report(client, owner, docs, "document") + run_jobs() + row = db_session.get(Artifact, uuid.UUID(art["id"])) + row.content_json = {k: v for k, v in row.content_json.items() if k not in ("suggestions", "suggestions_offered")} + db_session.commit() + assert _content(client, owner, art).get("suggestions", []) == [] diff --git a/backend/tests/test_support.py b/backend/tests/test_support.py index 0e213cc27c98a80012c9f690604eb1ecdb7e24aa..faf16447ff78d09e7d1a7c96a3fccb8315a56034 100644 --- a/backend/tests/test_support.py +++ b/backend/tests/test_support.py @@ -232,7 +232,7 @@ def test_create_panel_buttons_in_the_docs_exist_in_the_app(): """The docs name the Create panel's buttons; each must still be a real button in NotebookView.""" view = (FRONTEND / "pages" / "NotebookView.tsx").read_text(encoding="utf-8") docs = " ".join(d.body for d in get_corpus()) - for label in ("Report", "Summary", "Infographic", "Quiz", "Flashcards", "Mind Constellation", "Video"): + for label in ("Report", "Infographic", "Quiz", "Flashcards", "Mind Constellation", "Video"): assert re.search(rf">\s*{label}\s*<", view) or re.search(rf"\n\s*{label}\n", view), f"{label} button is gone" assert f"**{label}**" in docs, f"{label} is not documented" @@ -272,7 +272,6 @@ def test_plan_doc_matches_the_plan_table(): ("why did I get an email reminder instead of an auto post", "launchpad"), ("how does voice cloning work", "voice-profile"), ("what is the topic map", "topic-map-and-resurfacing"), - ("how do I add my logo and brand colors", "settings-and-account"), ("how do I delete my account", "settings-and-account"), ("how much does the writer plan cost", "plans-and-billing"), ("what is in the free plan", "plans-and-billing"), diff --git a/backend/tests/test_videos.py b/backend/tests/test_videos.py index 8c8f69d820fb491e9adbede86fa4123a7306cf36..8cb9a4aed1a63c48df8281524e766c2ce5c7a865 100644 --- a/backend/tests/test_videos.py +++ b/backend/tests/test_videos.py @@ -237,7 +237,7 @@ def test_link_create_forwards_every_wizard_field(client, b2v, owner, db_session) "video_length": "medium", "logo_position": "top_left", "logo_opacity": 0.5, "template": "geometric", "video_style": "storytelling", "accent_color": "#FF5A1F", "bg_color": "#0B0B0F", "text_color": "#FFFFFF", "content_language": "es", "voice_gender": "female", "voice_accent": "british", - "custom_voice_id": SARAH, "voice_emotion": '["0.5","1.0","calm","0","1"]', + "custom_voice_id": SARAH, "bgm_track_id": "corporate_upbeat", "bgm_volume": 0.2} art = make_video(client, headers, **body) call = creates(b2v)[0] @@ -508,12 +508,9 @@ def test_foreign_references_are_refused_before_calling_out(client, b2v, owner, t def test_own_references_pass(client, b2v, owner, db_session): headers, ws = owner db_session.add_all([B2VTemplate(b2v_template_id=58, workspace_id=ws.id, name="Mine", ready=True), - B2VStyle(b2v_style_id=7, workspace_id=ws.id, name="Mine"), - B2VCustomVoice(b2v_custom_voice_id=31, workspace_id=ws.id, voice_id="vc_mine", name="M", - source="prompt")]) + B2VStyle(b2v_style_id=7, workspace_id=ws.id, name="Mine")]) db_session.commit() - make_video(client, headers, template="custom_58", video_style="custom:7", custom_voice_id="vc_mine") - make_video(client, headers, template="crafted_neon") + make_video(client, headers, template="custom_58", video_style="custom:7", custom_voice_id=SARAH) def test_template_still_generating_is_refused(client, b2v, owner, db_session): @@ -566,7 +563,7 @@ def test_catalog_offers_only_the_house_styles(client, b2v, owner, house, db_sess @pytest.mark.parametrize("style,length", [("custom:20", "short"), ("custom:21", "medium")]) def test_house_style_sets_the_length(client, b2v, owner, house, free_plan, style, length): - make_video(client, owner[0], video_style=style, video_length="detailed") + make_video(client, owner[0], video_style=style, video_length="short" if style == "custom:21" else "medium") sent = creates(b2v)[-1]["json"] assert sent["video_style"] == style and sent["video_length"] == length @@ -910,17 +907,15 @@ def test_chat_with_a_focus_keeps_the_chat_context_first(client, b2v, owner, hous assert content.startswith(CHAT_CONTEXT) and content.index('"Annual raises"') < content.index("User:") -# Premium (★) and per-workspace counters +# The standard options only, and per-workspace counters @pytest.mark.parametrize("body", [{"video_length": "detailed"}, {"video_length": "more_detailed"}, - {"custom_voice_id": BELLA}, {"voice_emotion": '["0.5","1.0","calm","0","1"]'}, - {"template": "crafted_neon"}]) -def test_premium_options_refused_on_free(client, b2v, owner, free_plan, db_session, body): + {"custom_voice_id": BELLA}, {"template": "crafted_neon"}]) +def test_options_that_are_not_offered_are_refused_on_every_plan(client, b2v, owner, body): headers, ws = owner r = client.post("/api/videos", json={"url": "https://a.test/x", **body}, headers=headers) - assert r.status_code == 402 and r.json()["detail"]["kind"] == "video_premium" and not creates(b2v) - assert r.json()["detail"]["upgrade_to"] == "writer" + assert r.status_code in (400, 404, 422) and not creates(b2v) def test_free_plan_can_make_a_plain_video(client, b2v, owner, free_plan): @@ -944,18 +939,10 @@ def test_ai_edits_stop_at_the_workspace_limit(client, b2v, owner, db_session): used=1000)) db_session.commit() b2v.calls.clear() - r = client.post(f"/api/videos/{art['id']}/p/chat", json={"message": "shorter"}, headers=headers) + r = client.post(f"/api/videos/{art['id']}/p/scenes/1/regenerate", json={"description": "shorter"}, headers=headers) assert r.status_code == 402 and r.json()["detail"]["kind"] == "video_limits.ai_edits" and b2v.calls == [] -def test_avatar_batch_charges_ten_per_scene(client, b2v, owner, db_session): - headers, ws = owner - art = make_video(client, headers) - r = client.post(f"/api/videos/{art['id']}/p/avatar-batch/authorize", json={"scene_ids": [1, 2, 3]}, - headers=headers) - assert r.status_code == 200 and video_limits.used(db_session, ws.id, "ai_edits") == 30 - - def test_template_switch_uses_a_video_and_checks_the_template(client, b2v, owner, theirs, db_session): headers, ws = owner art = make_video(client, headers) @@ -1019,12 +1006,14 @@ def test_editor_passes_downloads_through(client, b2v, owner): assert r.status_code == 200 and r.content == b"ID3audio" and r.headers["content-type"] == "audio/mpeg" -def test_editor_premium_features_refused_on_free(client, b2v, owner, free_plan): +def test_removed_editor_features_are_not_proxied(client, b2v, owner): headers, _ = owner art = make_video(client, headers) - for method, path in [("POST", "chat"), ("GET", "download-studio"), ("POST", "avatar-portrait")]: - r = client.request(method, f"/api/videos/{art['id']}/p/{path}", json={}, headers=headers) - assert r.status_code == 402, path + b2v.calls.clear() + for method, path in [("POST", "chat"), ("GET", "chat/history"), ("GET", "download-studio"), + ("POST", "avatar-portrait"), ("POST", "avatar-batch/authorize")]: + assert client.request(method, f"/api/videos/{art['id']}/p/{path}", json={}, headers=headers).status_code == 404 + assert b2v.calls == [] # Script review @@ -1036,9 +1025,9 @@ def test_script_review_flow(client, b2v, owner, db_session): scenes = client.get(f"/api/videos/{art['id']}/script", headers=headers).json()["scenes"] assert [s["id"] for s in scenes] == [1, 2] draft = {"title": "A", "display_text": "Text", "narration_text": "", "draft_scenes": [], "revision": 1} - client.post(f"/api/videos/{art['id']}/script/scenes/1/ai-preview", json={**draft, "instruction": "punchier"}, - headers=headers) - assert video_limits.used(db_session, ws.id, "ai_edits") == 1 + r = client.post(f"/api/videos/{art['id']}/script/scenes/1/ai-preview", json={**draft, "instruction": "punchier"}, + headers=headers) + assert r.status_code in (404, 405) # rewriting a scene with AI is not offered r = client.post(f"/api/videos/{art['id']}/script/approve", json={"scenes": [{"id": 1, "title": "A", "narration_text": "Hi"}]}, headers=headers) assert r.status_code == 200 and "/api/projects/812/script-review/approve" in b2v.paths("POST") @@ -1136,12 +1125,14 @@ def test_capacity_alert(b2v, caplog): def test_new_workspace_gets_starter_voices_and_library(client, b2v, owner): got = client.get("/api/video-voices", headers=owner[0]).json() assert [v["voice_id"] for v in got["saved"]] == [SARAH] # free voices only - assert {v["voice_id"]: v["premium"] for v in got["library"]} == {SARAH: False, BELLA: True} + assert [v["voice_id"] for v in got["library"]] == [SARAH] # the paid voice is not offered + assert "custom" not in got and all("premium" not in v for v in got["saved"] + got["library"]) -def test_saving_a_paid_voice_needs_premium(client, b2v, owner, free_plan): - r = client.post("/api/video-voices/saved", json={"voice_id": BELLA}, headers=owner[0]) - assert r.status_code == 402 +def test_a_paid_voice_cannot_be_saved_or_used_on_any_plan(client, b2v, owner): + assert client.post("/api/video-voices/saved", json={"voice_id": BELLA}, headers=owner[0]).status_code == 404 + r = client.post("/api/videos", json={"url": "https://a.test/x", "custom_voice_id": BELLA}, headers=owner[0]) + assert r.status_code == 404 and not creates(b2v) def test_saved_voices_never_touch_blog2video_saved_list(client, b2v, owner, db_session): @@ -1163,36 +1154,22 @@ def test_any_number_of_voices_can_be_saved(client, b2v, owner, db_session): for i in range(7): db_session.add(UserSavedVoice(workspace_id=ws.id, voice_id=f"v{i}", name=f"V{i}")) db_session.commit() - assert client.post("/api/video-voices/saved", json={"voice_id": BELLA}, headers=headers).status_code == 201 + assert client.post("/api/video-voices/saved", json={"voice_id": SARAH}, headers=headers).status_code in (200, 201) got = client.get("/api/video-voices", headers=headers).json() - assert len(got["saved"]) == 9 and "max_saved" not in got + assert len(got["saved"]) == 8 and "max_saved" not in got -def test_notestack_voices_can_be_saved_and_used_in_a_video(client, b2v, owner, other, db_session): +def test_notestack_voices_are_saved_for_audio_but_not_used_in_videos(client, b2v, owner, other, db_session): headers, ws = owner notestack_voice(db_session, ws) got = client.get("/api/video-voices", headers=headers).json() assert got["notestack"] == [{"voice_id": "nv_narrator", "name": "Narrator", "kind": "designed", "saved": False}] r = client.post("/api/video-voices/saved", json={"voice_id": "nv_narrator"}, headers=headers) - assert r.status_code == 201 and r.json()["source"] == "notestack" and r.json()["premium"] + assert r.status_code == 201 and r.json()["source"] == "notestack" saved = client.get("/api/video-voices", headers=headers).json()["saved"] - assert [v["source"] for v in saved if v["voice_id"] == "nv_narrator"] == ["notestack"] - make_video(client, headers, custom_voice_id="nv_narrator") - assert creates(b2v)[-1]["json"]["custom_voice_id"] == "nv_narrator" - # Another workspace cannot use it. - r = client.post("/api/videos", json={"url": "https://a.test/x", "custom_voice_id": "nv_narrator"}, - headers=other[0]) - assert r.status_code == 404 - - -def test_notestack_voices_are_saved_on_any_plan_but_need_premium_in_videos(client, b2v, owner, db_session, - free_plan): - headers, ws = owner - notestack_voice(db_session, ws) - assert client.post("/api/video-voices/saved", json={"voice_id": "nv_narrator"}, - headers=headers).status_code == 201 # in the list, for audio overviews + assert [v["source"] for v in saved if v["voice_id"] == "nv_narrator"] == ["notestack"] # kept, for audio overviews r = client.post("/api/videos", json={"url": "https://a.test/x", "custom_voice_id": "nv_narrator"}, headers=headers) - assert r.status_code == 402 and not creates(b2v) + assert r.status_code == 404 and not creates(b2v) # a video uses the free built-in voices def test_revoking_the_clone_drops_it_from_video_voices(client, b2v, owner, db_session, monkeypatch): @@ -1207,37 +1184,11 @@ def test_revoking_the_clone_drops_it_from_video_voices(client, b2v, owner, db_se assert "nv_clone" not in [v["voice_id"] for v in client.get("/api/video-voices", headers=headers).json()["saved"]] -def test_design_keep_and_delete_a_custom_voice(client, b2v, owner, other, db_session): - headers, ws = owner - previews = client.post("/api/video-voices/design/prompt", json={"prompt": "A calm, warm narrator voice please"}, - headers=headers).json()["previews"] - kept = client.post("/api/video-voices/custom", json={"generated_voice_id": previews[0]["generated_voice_id"], - "source": "prompt", "name": "Narrator"}, headers=headers) - assert kept.status_code == 201 - assert db_session.get(UserSavedVoice, {"workspace_id": ws.id, "voice_id": "vc_designed"}).is_custom - assert video_limits.used(db_session, ws.id, "voice_designs_daily") == 1 - assert video_limits.used(db_session, ws.id, "custom_voices") == 1 - # Another workspace can not play or delete it, nor use it on a video. - assert client.get("/api/video-voices/custom/31/preview", headers=other[0]).status_code == 404 - assert client.delete("/api/video-voices/custom/31", headers=other[0]).status_code == 404 - r = client.post("/api/videos", json={"url": "https://a.test/x", "custom_voice_id": "vc_designed"}, - headers=other[0]) - assert r.status_code == 404 - assert client.delete("/api/video-voices/custom/31", headers=headers).status_code == 200 - assert video_limits.used(db_session, ws.id, "custom_voices") == 0 - - -def test_clone_voice(client, b2v, owner): - r = client.post("/api/video-voices/clone", data={"name": "Me"}, files={"file": ("me.mp3", b"ID3", "audio/mpeg")}, - headers=owner[0]) - assert r.status_code == 201 and r.json()["voice_id"] == "vc_clone" - - -def test_voice_sample_returns_audio_and_counts(client, b2v, owner, db_session): - headers, ws = owner - r = client.post("/api/video-voices/sample", json={"custom_voice_id": SARAH}, headers=headers) - assert r.status_code == 200 and r.content == b"ID3audio" - assert video_limits.used(db_session, ws.id, "voice_samples_daily") == 1 +def test_designing_cloning_and_sampling_voices_for_videos_are_gone(client, b2v, owner): + headers, _ = owner + for method, path in [("POST", "design/prompt"), ("POST", "design/preset"), ("POST", "custom"), ("POST", "clone"), + ("POST", "sample"), ("GET", "custom/31/preview"), ("DELETE", "custom/31")]: + assert client.request(method, f"/api/video-voices/{path}", json={}, headers=headers).status_code in (404, 405), path # Styles @@ -1324,7 +1275,7 @@ def test_sync_follows_effective_plan(client, owner, db_session, monkeypatch): sub.status = "canceled" db_session.commit() sub = video_quota.sync_video_quota(db_session, ws) - assert (sub.video_plan, sub.video_limit) == ("free", 1) + assert (sub.video_plan, sub.video_limit) == ("free", 2) def test_monthly_fallback_reset(client, owner, db_session, session_factory): @@ -1405,15 +1356,16 @@ def test_plan_video_limits(client, owner, db_session, monkeypatch): _, ws = owner monkeypatch.setattr(settings, "billing_enabled", True) video_quota.sync_video_quota(db_session, ws) - for plan, limit in (("free", 1), ("writer", 10), ("studio", 20)): + for plan, limit in (("free", 2), ("writer", 10), ("studio", 20)): _on_plan(db_session, ws, plan) assert video_quota.sync_video_quota(db_session, ws).video_limit == limit -def test_free_is_one_video_in_total(client, b2v, owner, free_plan, db_session, session_factory): +def test_free_is_two_videos_in_total(client, b2v, owner, free_plan, db_session, session_factory): headers, ws = owner make_video(client, headers) - r = client.post("/api/videos", json={"url": "https://a.test/2"}, headers=headers) + make_video(client, headers, url="https://a.test/2") + r = client.post("/api/videos", json={"url": "https://a.test/3"}, headers=headers) assert r.status_code == 402 usage = client.get("/api/videos/quota", headers=headers).json() assert usage["resets_at"] is None @@ -1424,7 +1376,7 @@ def test_free_is_one_video_in_total(client, b2v, owner, free_plan, db_session, s # Deleting the video does not give it back. art_id = db_session.scalar(select(B2VVideo)).artifact_id client.delete(f"/api/videos/{art_id}", headers=headers) - assert client.post("/api/videos", json={"url": "https://a.test/3"}, headers=headers).status_code == 402 + assert client.post("/api/videos", json={"url": "https://a.test/4"}, headers=headers).status_code == 402 def test_free_video_that_failed_is_given_back(client, b2v, owner, free_plan, db_session): @@ -1443,7 +1395,7 @@ def test_dropping_to_free_counts_what_was_made(client, b2v, owner, db_session, m make_video(client, headers, url=f"https://a.test/{i}") _on_plan(db_session, ws, "free") usage = client.get("/api/videos/quota", headers=headers).json() - assert (usage["used"], usage["limit"]) == (3, 1) + assert (usage["used"], usage["limit"]) == (3, 2) assert client.post("/api/videos", json={"url": "https://a.test/x"}, headers=headers).status_code == 402 diff --git a/docs/BLOG2VIDEO.md b/docs/BLOG2VIDEO.md index 8513c283664254f66b1b02e30f7e381b496d944b..6a89d056adf9b631530e70748977e85041d29d19 100644 --- a/docs/BLOG2VIDEO.md +++ b/docs/BLOG2VIDEO.md @@ -29,7 +29,7 @@ log an `ALERT` line and users see "temporarily unavailable". ## Ownership -Everything made with the key (videos, custom templates, custom voices, styles) belongs to one blog2video account, +Everything made with the key (videos, custom templates, styles) belongs to one blog2video account, and blog2video's ownership checks stop at the account. Notestack is the only place each workspace's things are kept apart (`backend/app/services/b2v_access.py`): @@ -64,20 +64,19 @@ built-in style overrides, style pin/selection, "Your Style", script preferences) The live preview link comes from `POST /api/embed/token/{id}` and is safe for the browser. -## Premium and per-workspace limits +## Per-workspace limits blog2video checks every limit against our Pro account, so every workspace passes its checks. Notestack divides them: -each plan row has `video_premium` (★: detailed lengths, paid and custom voices, advanced voice tuning, voice samples, -AI script rewrites, AI chat, avatars, designer templates, the source download) and `video_limits`: +each plan row has `video_limits`. There are no premium (★) video options: videos use the standard lengths (short, +medium), the built-in templates and styles, and the free built-in voices. Notestack does not proxy AI chat editing, +avatars, AI script or scene rewrites, AI template editing, designer templates, paid or custom voices, voice tuning, +voice design or cloning for videos, or the source download. | Metric | Period | Free / Writer / Studio | Counts | |---|---|---|---| -| `ai_edits` | month | 20 / 300 / 1000 | scene regenerate/add, AI image, stock clip, AI chat, AI rewrites, template AI edits, avatars (10 per scene) | +| `ai_edits` | month | 20 / 300 / 1000 | scene regenerate/add, AI image, stock clip | | `templates` | lifetime | 0 / 2 / 5 | custom templates made (blog2video slots are never given back) | | `template_ai_daily` | day | 0 / 3 / 5 | theme from a document or prompt, code generation | -| `voice_designs_daily` | day | 0 / 5 / 10 | voice designs | -| `voice_samples_daily` | day | 0 / 20 / 40 | voice samples | -| `custom_voices` | total | 0 / 3 / 10 | kept or cloned voices (a delete frees one) | Edit a plan row to change them. A counter is given back when blog2video refuses the call. A template switch also uses a video. diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index b86925311a39763c516b8bc6130437a019ed241c..0baf1d1c96239f41661e9bed72a228b55ac59b3f 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -50,8 +50,6 @@ import type { VideoCreateBody, VideoFocusSource, VideoFocusTopic, - VideoCustomVoice, - VideoDesignedVoice, VideoJobState, VideoListItem, VideoOptions, @@ -141,8 +139,9 @@ export type GenerateBody = { report_format?: "document" | "interactive"; template_id?: string; instructions?: string; - /** infographic: one of the premade themes (see components/Infographic.tsx); slide_deck: dark-space or light-space. */ + /** infographic: one of the looks, and the kind of layout to lean towards (see components/Infographic.tsx); slide_deck: dark-space or light-space. */ theme?: string; + ig_style?: string; /** slide_deck: read on its own ("detailed") or shown behind a speaker ("presenter"), and how many slides. */ deck_format?: "detailed" | "presenter"; deck_length?: "short" | "default" | "long"; @@ -158,6 +157,9 @@ export const artifactsApi = { remove: (id: string) => del(`/api/artifacts/${id}`), /** An infographic drawn as a PNG. */ image: (id: string, layout: "landscape" | "portrait" = "landscape") => apiBlob(`/api/artifacts/${id}/image${qs({ layout })}`), + /** Thumbs up or down (null takes it back) on a finished report, quiz, flashcard set or infographic. */ + rate: (id: string, body: { rating: "up" | "down" | null; reasons?: string[]; comment?: string }) => + put<{ feedback: AnswerFeedback | null }>(`/api/artifacts/${id}/feedback`, body), retry: (id: string) => post(`/api/artifacts/${id}/retry`), /** A slide deck as a PDF (exactly as shown) or an editable PowerPoint. */ slidesFile: (id: string, ext: "pdf" | "pptx") => apiBlob(`/api/artifacts/${id}/slides.${ext}`), @@ -285,9 +287,6 @@ export const resurfaceApi = { export type SettingsPatch = { name?: string; workspace_name?: string; - brand_name?: string; - brand_accent?: string; - logo_upload_id?: string; training_opt_in?: boolean; allow_public_links?: boolean; email_unsubscribed?: boolean; @@ -297,6 +296,8 @@ export const settingsApi = { get: () => api("/api/settings"), update: (body: SettingsPatch) => patch("/api/settings", body), usage: () => api("/api/settings/usage"), + /** How many posts a generated thing can read (set by the server's ARTIFACT_MAX_POSTS settings). */ + limits: () => api<{ posts: number; selectable_posts: number }>("/api/settings/limits"), }; export const memoryApi = { @@ -374,8 +375,6 @@ export const videosApi = { script: (id: string) => api<{ status: string; scenes: ScriptScene[] }>(`/api/videos/${id}/script`), narrationPreview: (id: string, sceneId: number, body: ScriptDraft) => post(`/api/videos/${id}/script/scenes/${sceneId}/narration-preview`, body), - aiPreview: (id: string, sceneId: number, body: ScriptDraft & { instruction: string }) => - post(`/api/videos/${id}/script/scenes/${sceneId}/ai-preview`, body), approveScript: (id: string, scenes: (ScriptScene & { source_fingerprint?: string; accepted_ai_instructions?: string[] })[]) => post(`/api/videos/${id}/script/approve`, { scenes }), }; @@ -448,8 +447,7 @@ export const videoEditApi = { uploadVoiceover: (id: string, sceneId: number, audio: File) => postForm(P(id, `scenes/${sceneId}/voiceover`), form({ audio })), deleteVoiceover: (id: string) => post(P(id, "delete-voiceover")), - changeVoice: (id: string, body: { voice_gender?: string; voice_accent?: string; custom_voice_id?: string | null; - voice_emotion?: string | null }) => post(P(id, "change-voice"), body), + changeVoice: (id: string, body: { voice_gender?: string; voice_accent?: string; custom_voice_id?: string | null }) => post(P(id, "change-voice"), body), voiceStatus: (id: string) => api(P(id, "voice-change-status")), changeLanguage: (id: string, content_language: string) => post(P(id, "change-language"), { content_language }), languageStatus: (id: string) => api(P(id, "language-change-status")), @@ -463,21 +461,6 @@ export const videoEditApi = { refreshVerify: (id: string) => post(P(id, "regenerate-script/verify")), refreshRetry: (id: string, user_instruction?: string) => post(P(id, "regenerate-script/regenerate"), { user_instruction }), - // AI chat editing ★ - chat: (id: string, message: string, conversation_id?: number) => - post<{ reply?: string; message?: string; conversation_id?: number; [k: string]: unknown }>(P(id, "chat"), { message, conversation_id }), - chatHistory: (id: string) => api(P(id, "chat/history")), - // Avatars ★ - authorizeAvatars: (id: string, scene_ids: number[], avatar_preset?: string, avatar_motion_style?: string) => - post(P(id, "avatar-batch/authorize"), { scene_ids, avatar_preset, avatar_motion_style }), - makeAvatar: (id: string, sceneId: number, avatar_preset?: string) => - postForm(P(id, `scenes/${sceneId}/avatar`), form({ avatar_preset })), - avatarProgress: (id: string) => api>(P(id, "avatar-progress")), - deleteAvatar: (id: string, sceneId: number) => del(P(id, `scenes/${sceneId}/avatar`)), - uploadPortrait: (id: string, file: File) => postForm(P(id, "avatar-portrait"), form({ file })), - deletePortrait: (id: string) => del(P(id, "avatar-portrait")), - avatarAppearance: (id: string, sceneId: number, body: { avatar_shape?: string; avatar_size?: number; - avatar_position?: string; avatar_opacity?: number }) => patch(P(id, `scenes/${sceneId}/avatar-appearance`), body), // Render, download, frames /** force renders again even when an MP4 is already on R2 (blog2video otherwise just returns the old one). */ render: (id: string, { force = false, resolution = "1080p" }: { force?: boolean; resolution?: string } = {}) => @@ -485,7 +468,6 @@ export const videoEditApi = { renderStatus: (id: string) => api(P(id, "render-status")), cancelRender: (id: string) => post(P(id, "cancel-render")), downloadUrl: (id: string) => api<{ url: string }>(P(id, "download-url")), - downloadStudio: (id: string) => apiBlob(P(id, "download-studio")), still: (id: string, frame: number) => apiBlob(P(id, `render-still${qs({ frame })}`)), }; @@ -493,19 +475,6 @@ export const videoVoicesApi = { list: () => api("/api/video-voices"), save: (voice_id: string) => post("/api/video-voices/saved", { voice_id }), unsave: (voice_id: string) => del(`/api/video-voices/saved/${encodeURIComponent(voice_id)}`), - designFromPrompt: (prompt: string) => post<{ previews: VideoDesignedVoice[] }>("/api/video-voices/design/prompt", { prompt }), - designFromPreset: (body: { gender: string; age: string; persona: string; speed: string; accent: string }) => - post<{ previews: VideoDesignedVoice[] }>("/api/video-voices/design/preset", body), - keep: (body: { generated_voice_id: string; source: "prompt" | "preset"; name: string; prompt_text?: string }) => - post("/api/video-voices/custom", body), - clone: (name: string, file: File, remove_background_noise = true) => - postForm("/api/video-voices/clone", form({ name, file, remove_background_noise })), - customPreview: (customId: number) => - api<{ preview_url: string | null; ready: boolean }>(`/api/video-voices/custom/${customId}/preview`), - removeCustom: (customId: number) => del(`/api/video-voices/custom/${customId}`), - /** ★ A few seconds of the voice with the chosen tuning (audio/mpeg). */ - sample: (body: { voice_gender: string; voice_accent: string; custom_voice_id?: string; voice_emotion?: string; - video_style?: string }) => apiBlob("/api/video-voices/sample", { method: "POST", body: JSON.stringify(body) }), }; export const videoStylesApi = { @@ -535,8 +504,6 @@ export const videoTemplatesApi = { get: (id: number) => api>(T(id, "")), update: (id: number, body: { name?: string; theme?: Record }) => put>(T(id, ""), body), uploadLogo: (id: number, file: File) => postForm(T(id, "upload-logo"), form({ file })), - aiEdit: (id: number, sceneKey: string, prompt: string) => post(T(id, `scenes/${sceneKey}/ai-edit`), { prompt }), - aiEditStatus: (id: number, sceneKey: string) => api(T(id, `scenes/${sceneKey}/ai-edit/status`)), drafts: (id: number) => api(T(id, "scene-drafts")), applyDraft: (id: number, sceneKey: string) => post(T(id, `scenes/${sceneKey}/draft/apply`)), discardDraft: (id: number, sceneKey: string) => post(T(id, `scenes/${sceneKey}/draft/discard`)), diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 68579bd8a5d9e67c1a5193309d1b1303595b29a3..7789d8e81268121021549b8043061c2fdf0d136d 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -188,6 +188,8 @@ export type Artifact = { job: Job | null; /** "blog2video" for videos made in the Videos editor. */ provider?: string | null; + /** This person's thumbs up or down on a finished report, quiz, flashcard set or infographic. */ + feedback?: AnswerFeedback | null; }; export type TopicStatus = "rising" | "steady" | "dormant"; @@ -411,20 +413,20 @@ export type Plan = { reports: number; infographics: number; voice_cloning: boolean; - brand_kit: boolean; + /** Audio overviews allowed (in total on Free), whatever their length; -1 = no cap. */ + audio_overviews: number; }; export type Usage = { plan: string; - used: { audio_minutes: number; videos: number; launch_kits: number; reports: number; infographics: number; llm_tokens: number; since: string }; - limits: { audio_minutes: number; videos: number; launch_kits: number; reports: number; infographics: number; sources: number; indexed_posts: number }; + used: { audio_minutes: number; audio_overviews: number; videos: number; launch_kits: number; reports: number; infographics: number; indexed_posts: number; llm_tokens: number; since: string }; + limits: { audio_minutes: number; audio_overviews: number; videos: number; launch_kits: number; reports: number; infographics: number; sources: number; indexed_posts: number }; videos_resets_at: string | null; }; export type Settings = { user: { name: string | null; email: string; auth_provider: string; email_unsubscribed: boolean }; workspace: { id: string; name: string; training_opt_in: boolean; allow_public_links: boolean }; - brand: { name: string | null; accent: string; logo_url: string | null }; plan: Plan; billing_enabled: boolean; usage: Usage; @@ -467,6 +469,8 @@ export type BillingStatus = { has_billing_account: boolean; period_end: string | null; meters: Meter[]; + /** Posts indexed, reports and infographics: shown when the sidebar plan card is opened. */ + more_meters: Meter[]; since: string; /** Videos reset on renewal (or monthly without one), not on the 1st like the other meters. */ videos_resets_at: string | null; @@ -483,13 +487,10 @@ export type MindNode = { // Videos (blog2video). Everything goes through our API; ids are our artifact ids unless named b2v/custom. -export type VideoLimitMetric = - | "ai_edits" | "templates" | "template_ai_daily" | "voice_designs_daily" | "voice_samples_daily" | "custom_voices"; +export type VideoLimitMetric = "ai_edits" | "templates" | "template_ai_daily"; export type VideoConfig = { configured: boolean; - /** Premium (★) video options are included in this workspace's plan. */ - premium: boolean; limits: Record; }; @@ -535,7 +536,6 @@ export type MusicTrack = { track_id: string; display_name: string; mood?: string export type VideoCatalog = { templates: VideoTemplate[]; - crafted_templates: VideoTemplate[]; my_templates: VideoTemplate[]; video_styles: VideoStyleItem[]; music: MusicTrack[]; @@ -549,8 +549,6 @@ export type VideoSavedVoice = { preview_url: string | null; gender: string | null; accent: string | null; - premium: boolean; - is_custom: boolean; /** notestack: made or added on the Voice page (played through the Voice page's preview, not a link). */ source?: "notestack" | "blog2video"; }; @@ -563,16 +561,6 @@ export type VideoLibraryVoice = { gender: string | null; accent: string | null; age?: string | null; - premium: boolean; - saved: boolean; -}; - -export type VideoCustomVoice = { - id: number; - voice_id: string; - name: string; - source: "prompt" | "preset" | "clone"; - preview_url: string | null; saved: boolean; }; @@ -582,18 +570,10 @@ export type VideoNotestackVoice = { voice_id: string; name: string; kind: "clone export type VideoVoicesResponse = { saved: VideoSavedVoice[]; library: VideoLibraryVoice[]; - custom: VideoCustomVoice[]; notestack: VideoNotestackVoice[]; }; -export type VideoDesignedVoice = { - generated_voice_id: string; - audio_base_64: string; - media_type: string; - duration_secs: number; -}; - -export type VideoLength = "short" | "medium" | "detailed" | "more_detailed"; +export type VideoLength = "short" | "medium"; export type LogoPosition = "bottom_right" | "bottom_left" | "top_left" | "top_right"; /** Every field of the 3-step wizard, named as blog2video names them. */ @@ -616,7 +596,6 @@ export type VideoOptions = { voice_gender: "female" | "male" | "none"; voice_accent: string; custom_voice_id?: string; - voice_emotion?: string; bgm_track_id?: string; bgm_volume: number; }; diff --git a/frontend/src/components/AnswerFeedback.tsx b/frontend/src/components/AnswerFeedback.tsx index 81443a16cc10933b899484dc7b36844e531c9bd4..b3e05d5219401aaa60edb91ac4d2025034db2473 100644 --- a/frontend/src/components/AnswerFeedback.tsx +++ b/frontend/src/components/AnswerFeedback.tsx @@ -11,7 +11,7 @@ const REASONS: { id: string; label: string }[] = [ { id: "other", label: "Something else" }, ]; -const Thumb = ({ down }: { down?: boolean }) => ( +export const Thumb = ({ down }: { down?: boolean }) => ( diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index d16e1a19e42a00bb6cc578746e7bbb7d2cbc8c67..877d630d9a59bdc7f3764fb16e7bacf06715ec53 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -63,7 +63,7 @@ export default function AppShell() { ) : ( - + )} + + {thanks && Thanks, saved.} + {error && Could not save that. Try again.} + + {!open && fb && (fb.comment || fb.reasons.length > 0) && ( +
+ Your review + {fb.reasons.length > 0 && ( +
+ {fb.reasons.map((id) => {options.find((o) => o.id === id)?.label ?? id})} +
+ )} + {fb.comment &&

{fb.comment}

} + +
+ )} + {open && fb && ( +
+

{fb.rating === "up" ? "What worked well? (optional)" : "What was wrong? (optional)"}

+ {fb.rating === "down" &&
+ {options.map((r) => ( + + ))} +
} +