Humera Raheel commited on
Commit
464deb6
·
2 Parent(s): 4cd9a6889250ee

Merge pull request #7 from FireBird-Technologies/feature/report

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. README.md +2 -2
  2. backend/alembic/versions/0006_plans.py +5 -2
  3. backend/alembic/versions/0021_artifact_feedback.py +40 -0
  4. backend/alembic/versions/0022_report_template_cache.py +34 -0
  5. backend/alembic/versions/0023_drop_brand_kit.py +31 -0
  6. backend/alembic/versions/0024_plan_limits.py +49 -0
  7. backend/alembic/versions/0025_drop_video_premium.py +24 -0
  8. backend/alembic/versions/0026_plan_copy.py +40 -0
  9. backend/app/config.py +22 -0
  10. backend/app/db.py +9 -4
  11. backend/app/infographics/design.py +213 -0
  12. backend/app/infographics/image.py +34 -5
  13. backend/app/infographics/templates/design.css +126 -0
  14. backend/app/infographics/templates/design.html.j2 +22 -0
  15. backend/app/infographics/themes.py +5 -2
  16. backend/app/llm/provider.py +32 -1
  17. backend/app/llm/run.py +2 -1
  18. backend/app/llm/signatures.py +204 -10
  19. backend/app/models/__init__.py +4 -0
  20. backend/app/models/content.py +31 -0
  21. backend/app/models/user.py +1 -4
  22. backend/app/pipeline/flashcards.py +21 -10
  23. backend/app/pipeline/generate.py +26 -9
  24. backend/app/pipeline/idea_pool.py +85 -0
  25. backend/app/pipeline/infographic.py +32 -10
  26. backend/app/pipeline/ingest.py +19 -9
  27. backend/app/pipeline/material.py +37 -7
  28. backend/app/pipeline/media.py +5 -10
  29. backend/app/pipeline/passages.py +21 -3
  30. backend/app/pipeline/quiz.py +10 -5
  31. backend/app/pipeline/report.py +395 -125
  32. backend/app/pipeline/report_blueprint.py +220 -0
  33. backend/app/pipeline/report_evidence.py +166 -0
  34. backend/app/pipeline/report_themes.py +280 -0
  35. backend/app/routers/artifacts.py +84 -5
  36. backend/app/routers/notebooks.py +6 -1
  37. backend/app/routers/reports.py +11 -21
  38. backend/app/routers/settings.py +10 -29
  39. backend/app/routers/sources.py +12 -2
  40. backend/app/routers/video_edit.py +2 -28
  41. backend/app/routers/video_templates.py +0 -7
  42. backend/app/routers/video_voices.py +27 -197
  43. backend/app/routers/videos.py +4 -34
  44. backend/app/services/artifact_feedback.py +47 -0
  45. backend/app/services/b2v_access.py +12 -50
  46. backend/app/services/billing.py +23 -10
  47. backend/app/services/notestack_voices.py +3 -4
  48. backend/app/services/plans.py +51 -44
  49. backend/app/services/renderer.py +0 -12
  50. backend/app/services/report_suggestions.py +148 -0
README.md CHANGED
@@ -49,7 +49,7 @@ archive, stays in sync with it, cites the exact lines behind every answer, and t
49
  | Citations | Exact line ranges, verified before you see them | Passage level citations |
50
  | Not in your sources | Says so; unverifiable claims are dropped | Grounded, passage citations |
51
  | Audio | Two hosts on ElevenLabs voices, or your consented voice clone | Two host overviews, Google voices |
52
- | Video | 9:16 shorts, 16:9 explainers, 1:1 audiograms, quote cards, in your brand | Video overviews |
53
  | Publishing | Launch Kits (threads, LinkedIn, Notes, Bluesky, SEO, carousels) and auto posting | Not a publishing tool |
54
  | Your voice | Voice profile learned from your posts, used in every draft | General purpose writing |
55
  | Seeing your archive | Topic constellation with rising and dormant topics, evergreen resurfacing | Idea Constellations per notebook |
@@ -121,7 +121,7 @@ cd renderer && npm install && npm start # only needed for vid
121
  | Launchpad | Calendar and agenda, auto posting to X, LinkedIn and Bluesky, email reminders, tracked links, engagement |
122
  | Archive | Every generated artifact with filters, players, downloads, retry and delete |
123
  | Resurfacing | Evergreen scores and reshare angles for old posts, on this day, straight into a kit or the calendar |
124
- | Settings | Profile, workspace, brand kit, privacy, plan usage meters, connections, sign out everywhere, delete |
125
 
126
  ## Key choices
127
 
 
49
  | Citations | Exact line ranges, verified before you see them | Passage level citations |
50
  | Not in your sources | Says so; unverifiable claims are dropped | Grounded, passage citations |
51
  | Audio | Two hosts on ElevenLabs voices, or your consented voice clone | Two host overviews, Google voices |
52
+ | Video | 9:16 shorts, 16:9 explainers, 1:1 audiograms, quote cards | Video overviews |
53
  | Publishing | Launch Kits (threads, LinkedIn, Notes, Bluesky, SEO, carousels) and auto posting | Not a publishing tool |
54
  | Your voice | Voice profile learned from your posts, used in every draft | General purpose writing |
55
  | Seeing your archive | Topic constellation with rising and dormant topics, evergreen resurfacing | Idea Constellations per notebook |
 
121
  | Launchpad | Calendar and agenda, auto posting to X, LinkedIn and Bluesky, email reminders, tracked links, engagement |
122
  | Archive | Every generated artifact with filters, players, downloads, retry and delete |
123
  | Resurfacing | Evergreen scores and reshare angles for old posts, on this day, straight into a kit or the calendar |
124
+ | Settings | Profile, workspace, privacy, plan usage meters, connections, sign out everywhere, delete |
125
 
126
  ## Key choices
127
 
backend/alembic/versions/0006_plans.py CHANGED
@@ -57,12 +57,15 @@ def upgrade() -> None:
57
  sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
58
  sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
59
  )
 
 
 
60
  types = {"features": sa.JSON(), "voice_cloning": sa.Boolean(), "brand_kit": sa.Boolean(),
61
  "created_at": sa.DateTime(timezone=True), "updated_at": sa.DateTime(timezone=True)}
62
- plans = sa.table("plans", *(sa.column(k, types.get(k)) for k in [*SEED[0], "created_at", "updated_at"]))
63
  existing = {row[0] for row in bind.execute(sa.text("SELECT id FROM plans"))}
64
  now = datetime.now(UTC)
65
- missing = [{**p, "created_at": now, "updated_at": now} for p in SEED if p["id"] not in existing]
66
  if missing:
67
  op.bulk_insert(plans, missing)
68
 
 
57
  sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
58
  sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
59
  )
60
+ # Columns a later migration drops (brand_kit) are not in a table 0001 built from today's models: seed what exists.
61
+ have = {c["name"] for c in sa.inspect(bind).get_columns("plans")}
62
+ seed = [{k: v for k, v in p.items() if k in have} for p in SEED]
63
  types = {"features": sa.JSON(), "voice_cloning": sa.Boolean(), "brand_kit": sa.Boolean(),
64
  "created_at": sa.DateTime(timezone=True), "updated_at": sa.DateTime(timezone=True)}
65
+ plans = sa.table("plans", *(sa.column(k, types.get(k)) for k in [*seed[0], "created_at", "updated_at"]))
66
  existing = {row[0] for row in bind.execute(sa.text("SELECT id FROM plans"))}
67
  now = datetime.now(UTC)
68
+ missing = [{**p, "created_at": now, "updated_at": now} for p in seed if p["id"] not in existing]
69
  if missing:
70
  op.bulk_insert(plans, missing)
71
 
backend/alembic/versions/0021_artifact_feedback.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """artifact_feedback: thumbs up/down on generated reports, quizzes, flashcards and infographics
2
+
3
+ Revision ID: 0021_artifact_feedback
4
+ Revises: 0020_calendar_kit
5
+ Create Date: 2026-10-08
6
+ """
7
+ import sqlalchemy as sa
8
+ from alembic import op
9
+
10
+ revision = "0021_artifact_feedback"
11
+ down_revision = "0020_calendar_kit"
12
+ branch_labels = None
13
+ depends_on = None
14
+
15
+
16
+ def upgrade() -> None:
17
+ # 0001 builds from the live models, so a fresh database may already have the table.
18
+ if "artifact_feedback" in sa.inspect(op.get_bind()).get_table_names():
19
+ return
20
+ op.create_table(
21
+ "artifact_feedback",
22
+ sa.Column("id", sa.Uuid(), primary_key=True),
23
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
24
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
25
+ sa.Column("workspace_id", sa.Uuid(), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False),
26
+ sa.Column("artifact_id", sa.Uuid(), sa.ForeignKey("artifacts.id", ondelete="SET NULL")),
27
+ sa.Column("user_id", sa.Uuid(), sa.ForeignKey("users.id", ondelete="SET NULL")),
28
+ sa.Column("artifact_type", sa.String(30), nullable=False),
29
+ sa.Column("rating", sa.Integer(), nullable=False),
30
+ sa.Column("reasons", sa.JSON(), nullable=False),
31
+ sa.Column("comment", sa.Text()),
32
+ sa.Column("snapshot", sa.JSON(), nullable=False),
33
+ sa.UniqueConstraint("artifact_id", "user_id"),
34
+ )
35
+ op.create_index("ix_artifact_feedback_workspace_id", "artifact_feedback", ["workspace_id"])
36
+ op.create_index("ix_artifact_feedback_artifact_id", "artifact_feedback", ["artifact_id"])
37
+
38
+
39
+ def downgrade() -> None:
40
+ op.drop_table("artifact_feedback")
backend/alembic/versions/0022_report_template_cache.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """report_template_cache: suggested report templates kept, so the Create report dialog is instant
2
+
3
+ Revision ID: 0022_report_template_cache
4
+ Revises: 0021_artifact_feedback
5
+ Create Date: 2026-10-08
6
+ """
7
+ import sqlalchemy as sa
8
+ from alembic import op
9
+
10
+ revision = "0022_report_template_cache"
11
+ down_revision = "0021_artifact_feedback"
12
+ branch_labels = None
13
+ depends_on = None
14
+
15
+
16
+ def upgrade() -> None:
17
+ # 0001 builds from the live models, so a fresh database may already have the table.
18
+ if "report_template_cache" in sa.inspect(op.get_bind()).get_table_names():
19
+ return
20
+ op.create_table(
21
+ "report_template_cache",
22
+ sa.Column("id", sa.Uuid(), primary_key=True),
23
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
24
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
25
+ sa.Column("workspace_id", sa.Uuid(), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False),
26
+ sa.Column("key", sa.String(64), nullable=False),
27
+ sa.Column("templates", sa.JSON(), nullable=False),
28
+ sa.UniqueConstraint("workspace_id", "key"),
29
+ )
30
+ op.create_index("ix_report_template_cache_workspace_id", "report_template_cache", ["workspace_id"])
31
+
32
+
33
+ def downgrade() -> None:
34
+ op.drop_table("report_template_cache")
backend/alembic/versions/0023_drop_brand_kit.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """drop_brand_kit: the brand kit (workspace colors and logo, and the plan flag for it) is gone
2
+
3
+ Revision ID: 0023_drop_brand_kit
4
+ Revises: 0022_report_template_cache
5
+ Create Date: 2026-10-08
6
+ """
7
+ import sqlalchemy as sa
8
+ from alembic import op
9
+
10
+ revision = "0023_drop_brand_kit"
11
+ down_revision = "0022_report_template_cache"
12
+ branch_labels = None
13
+ depends_on = None
14
+
15
+
16
+ def _has(table: str, column: str) -> bool:
17
+ return column in {c["name"] for c in sa.inspect(op.get_bind()).get_columns(table)}
18
+
19
+
20
+ def upgrade() -> None:
21
+ for table, column in (("plans", "brand_kit"), ("workspaces", "brand_json")):
22
+ if _has(table, column):
23
+ with op.batch_alter_table(table) as batch:
24
+ batch.drop_column(column)
25
+
26
+
27
+ def downgrade() -> None:
28
+ if not _has("plans", "brand_kit"):
29
+ op.add_column("plans", sa.Column("brand_kit", sa.Boolean(), nullable=False, server_default=sa.false()))
30
+ if not _has("workspaces", "brand_json"):
31
+ op.add_column("workspaces", sa.Column("brand_json", sa.JSON(), nullable=True))
backend/alembic/versions/0024_plan_limits.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """plan_limits: posts indexed across every source, Free allowances that are totals, new Writer and Studio numbers
2
+
3
+ Revision ID: 0024_plan_limits
4
+ Revises: 0023_drop_brand_kit
5
+ Create Date: 2026-10-08
6
+ """
7
+ import sqlalchemy as sa
8
+ from alembic import op
9
+
10
+ revision = "0024_plan_limits"
11
+ down_revision = "0023_drop_brand_kit"
12
+ branch_labels = None
13
+ depends_on = None
14
+
15
+ PLANS = {
16
+ "free": dict(sources=-1, indexed_posts=5, audio_minutes=-1, audio_overviews=1, videos=2, launch_kits=2, reports=2,
17
+ infographics=2, videos_monthly=False, features=[
18
+ "Up to 5 posts indexed, from any source", "Grounded research chat with citations",
19
+ "1 audio overview, any length", "2 videos", "2 Launch Kits", "2 infographics", "2 reports a month"]),
20
+ "writer": dict(sources=-1, indexed_posts=100, audio_minutes=60, audio_overviews=-1, videos=10, launch_kits=20,
21
+ reports=30, infographics=30, features=[
22
+ "Up to 100 posts indexed, from any source", "60 min of audio overviews a month", "10 videos a month",
23
+ "20 Launch Kits a month", "30 reports a month", "30 infographics a month",
24
+ "Voice cloning with consent"]),
25
+ "studio": dict(sources=-1, indexed_posts=250, audio_minutes=240, audio_overviews=-1, videos=20, launch_kits=50,
26
+ reports=-1, infographics=-1, features=[
27
+ "Up to 250 posts indexed, from any source", "240 min of audio overviews a month",
28
+ "20 videos a month", "50 Launch Kits a month", "Unlimited reports", "Unlimited infographics",
29
+ "Launchpad calendar and resurfacing", "Priority rendering"]),
30
+ }
31
+
32
+
33
+ def upgrade() -> None:
34
+ columns = {c["name"] for c in sa.inspect(op.get_bind()).get_columns("plans")}
35
+ if "audio_overviews" not in columns:
36
+ op.add_column("plans", sa.Column("audio_overviews", sa.Integer(), nullable=False, server_default="-1"))
37
+ plans = sa.table("plans", sa.column("id", sa.String), sa.column("sources", sa.Integer),
38
+ sa.column("indexed_posts", sa.Integer), sa.column("audio_minutes", sa.Integer),
39
+ sa.column("audio_overviews", sa.Integer), sa.column("videos", sa.Integer),
40
+ sa.column("launch_kits", sa.Integer), sa.column("reports", sa.Integer),
41
+ sa.column("infographics", sa.Integer), sa.column("videos_monthly", sa.Boolean),
42
+ sa.column("features", sa.JSON))
43
+ for plan_id, values in PLANS.items():
44
+ op.execute(plans.update().where(plans.c.id == plan_id).values(**values))
45
+
46
+
47
+ def downgrade() -> None:
48
+ with op.batch_alter_table("plans") as batch:
49
+ batch.drop_column("audio_overviews")
backend/alembic/versions/0025_drop_video_premium.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """drop_video_premium: the premium video options are gone, so is the plan flag for them
2
+
3
+ Revision ID: 0025_drop_video_premium
4
+ Revises: 0024_plan_limits
5
+ Create Date: 2026-10-08
6
+ """
7
+ import sqlalchemy as sa
8
+ from alembic import op
9
+
10
+ revision = "0025_drop_video_premium"
11
+ down_revision = "0024_plan_limits"
12
+ branch_labels = None
13
+ depends_on = None
14
+
15
+
16
+ def upgrade() -> None:
17
+ if "video_premium" in {c["name"] for c in sa.inspect(op.get_bind()).get_columns("plans")}:
18
+ with op.batch_alter_table("plans") as batch:
19
+ batch.drop_column("video_premium")
20
+
21
+
22
+ def downgrade() -> None:
23
+ if "video_premium" not in {c["name"] for c in sa.inspect(op.get_bind()).get_columns("plans")}:
24
+ op.add_column("plans", sa.Column("video_premium", sa.Boolean(), nullable=False, server_default=sa.false()))
backend/alembic/versions/0026_plan_copy.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """plan_copy: Free gets 1 video; Writer and Studio audio copy is relative to Free
2
+
3
+ Revision ID: 0026_plan_copy
4
+ Revises: 0025_drop_video_premium
5
+ Create Date: 2026-10-08
6
+ """
7
+ import sqlalchemy as sa
8
+ from alembic import op
9
+
10
+ revision = "0026_plan_copy"
11
+ down_revision = "0025_drop_video_premium"
12
+ branch_labels = None
13
+ depends_on = None
14
+
15
+ SWAPS = {
16
+ "free": {"2 videos": "1 video"},
17
+ "writer": {"60 min of audio overviews a month": "10x more audio overviews than Free (60 min a month)"},
18
+ "studio": {"240 min of audio overviews a month": "40x more audio overviews than Free (240 min a month)"},
19
+ }
20
+
21
+
22
+ def _swap(rows: dict) -> None:
23
+ plans = sa.table("plans", sa.column("id", sa.String), sa.column("videos", sa.Integer), sa.column("features", sa.JSON))
24
+ bind = op.get_bind()
25
+ for plan_id, swaps in rows.items():
26
+ features = bind.execute(sa.select(plans.c.features).where(plans.c.id == plan_id)).scalar()
27
+ if features is None:
28
+ continue
29
+ values = {"features": [swaps.get(f, f) for f in features]}
30
+ if plan_id == "free":
31
+ values["videos"] = 1 if "1 video" in swaps.values() else 2
32
+ op.execute(plans.update().where(plans.c.id == plan_id).values(**values))
33
+
34
+
35
+ def upgrade() -> None:
36
+ _swap(SWAPS)
37
+
38
+
39
+ def downgrade() -> None:
40
+ _swap({k: {v: o for o, v in d.items()} for k, d in SWAPS.items()})
backend/app/config.py CHANGED
@@ -64,6 +64,25 @@ class Settings(BaseSettings):
64
  r2_presign_ttl_seconds: int = 3600
65
  max_upload_bytes: int = 300 * 1024 * 1024
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  # LLM (LiteLLM model strings; Z.ai GLM by default)
68
  llm_model: str = "openai/glm-5.3"
69
  llm_fast_model: str = "openai/glm-5.3-flash"
@@ -86,6 +105,9 @@ class Settings(BaseSettings):
86
  llm_temperature: float = 1.0 # Z.ai recommends 1.0 for GLM-5.x
87
  # Reasoning output counts against max_tokens, so leave headroom for the structured answer.
88
  llm_max_tokens: int = 10000
 
 
 
89
  # GLM-5.x always reasons (it cannot be disabled); this sets how hard. low | high | max
90
  llm_reasoning_effort: str = "low"
91
 
 
64
  r2_presign_ttl_seconds: int = 3600
65
  max_upload_bytes: int = 300 * 1024 * 1024
66
 
67
+ # Posts an infographic reads, and a report that is not built from themes (REPORT_PLANNER below). A quiz or flashcard set reads up to that many straight from the posts; with more (no limit
68
+ # on how many are picked) it reads a small sample of the ideas already extracted from them, so its size does not grow.
69
+ artifact_max_posts: int = 20
70
+ # Characters of post text a report, quiz or flashcard set may read in all (split evenly between the posts, 1,500 at least each).
71
+ artifact_passage_budget: int = 120_000
72
+ # The same for a quiz: it needs less text than a report to ask good questions.
73
+ artifact_quiz_passage_budget: int = 60_000
74
+ # Ideas (already extracted from each post) in the evidence of a flashcard set, or of a quiz above ARTIFACT_MAX_POSTS posts.
75
+ artifact_idea_sample: int = 30
76
+
77
+ # Reports. "themes": the posts' stored ideas are grouped into weighted themes, a blueprint plans the report, and each section is
78
+ # written from its own evidence (pipeline/report_themes.py). "legacy": the posts' text goes straight to the writer, at most
79
+ # ARTIFACT_MAX_POSTS of them. Chats, small selections and posts without stored ideas always use the legacy way.
80
+ report_planner: str = "themes"
81
+ # Posts a themed report reads: 0 = every post picked (the plan's indexed_posts still applies).
82
+ report_max_posts: int = 0
83
+ # Posts whose ideas are extracted while the report is made (in parallel); the rest are represented by their opening.
84
+ report_inline_ideas: int = 25
85
+
86
  # LLM (LiteLLM model strings; Z.ai GLM by default)
87
  llm_model: str = "openai/glm-5.3"
88
  llm_fast_model: str = "openai/glm-5.3-flash"
 
105
  llm_temperature: float = 1.0 # Z.ai recommends 1.0 for GLM-5.x
106
  # Reasoning output counts against max_tokens, so leave headroom for the structured answer.
107
  llm_max_tokens: int = 10000
108
+ # The output cap of the fast model: quizzes, flashcards, infographics, idea extraction, template suggestions. A cap, not a target:
109
+ # only the tokens written are charged, and the thinking counts too, so keep room for it. Measured answers are under 3,000.
110
+ llm_fast_max_tokens: int = 6000
111
  # GLM-5.x always reasons (it cannot be disabled); this sets how hard. low | high | max
112
  llm_reasoning_effort: str = "low"
113
 
backend/app/db.py CHANGED
@@ -11,13 +11,18 @@ class Base(DeclarativeBase):
11
 
12
 
13
  _sqlite = settings.database_url.startswith("sqlite")
14
- # Postgres: a connection that dies silently (laptop sleep, network change, Neon dropping it) otherwise leaves a query
15
- # waiting on the socket forever, and the worker loop with it. Keepalives notice a dead peer within about a minute
16
- # and the query fails instead; connect_timeout does the same for a connect that never completes.
 
 
 
 
17
  _pg_connect_args = {"connect_timeout": 10, "keepalives": 1, "keepalives_idle": 30, "keepalives_interval": 10,
18
  "keepalives_count": 3}
19
  _connect_args = {"check_same_thread": False, "timeout": 30} if _sqlite else _pg_connect_args
20
- engine = create_engine(settings.database_url, pool_pre_ping=True, pool_recycle=300, connect_args=_connect_args)
 
21
 
22
  if _sqlite:
23
  # Local dev: the API and the worker share one file. WAL lets reads run during a write, the busy
 
11
 
12
 
13
  _sqlite = settings.database_url.startswith("sqlite")
14
+ # A hosted Postgres (Neon) closes idle connections and drops the odd one on a network blip, and a connection that dies silently
15
+ # (laptop sleep, network change) otherwise leaves a query waiting on the socket forever, and the worker loop with it. TCP
16
+ # keepalives notice a dead peer within about a minute, so the query fails instead and a dead socket is noticed while it sits in
17
+ # the pool; connect_timeout does the same for a connect that never completes. A pooled connection is also replaced before it
18
+ # is handed out (pool_recycle, below Neon's idle timeout), since pool_pre_ping alone can fail on a dead SSL socket with a
19
+ # ProgrammingError ("can't change 'autocommit' now") that SQLAlchemy does not treat as a lost connection, and the request
20
+ # ends in a 500.
21
  _pg_connect_args = {"connect_timeout": 10, "keepalives": 1, "keepalives_idle": 30, "keepalives_interval": 10,
22
  "keepalives_count": 3}
23
  _connect_args = {"check_same_thread": False, "timeout": 30} if _sqlite else _pg_connect_args
24
+ engine = create_engine(settings.database_url, pool_pre_ping=True, connect_args=_connect_args,
25
+ **({} if _sqlite else {"pool_recycle": 240, "pool_use_lifo": True}))
26
 
27
  if _sqlite:
28
  # Local dev: the API and the worker share one file. WAL lets reads run during a write, the busy
backend/app/infographics/design.py ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The AI-composed infographic: a page frame of ours (head, takeaway, background, mark) around a body the model writes in
2
+ HTML from a small vocabulary of components (flow charts, timelines, hierarchies, comparisons, cards, numbers). The
3
+ model's markup is never trusted: it is parsed and rebuilt from an allowlist of tags and class names, with no
4
+ attributes but `class`, so it can carry no script, style, link, image or address."""
5
+
6
+ import hashlib
7
+ import random
8
+ import re
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ from bs4 import BeautifulSoup, Comment, NavigableString, Tag
13
+ from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape
14
+ from markupsafe import Markup
15
+
16
+ from app.infographics.render import LOGO_SVG, SIZES
17
+
18
+ _DIR = Path(__file__).parent / "templates"
19
+ CSS = (_DIR / "design.css").read_text(encoding="utf-8")
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Look:
24
+ id: str
25
+ name: str
26
+ blurb: str
27
+ vars: dict # CSS variables of the palette
28
+
29
+
30
+ LOOKS: dict[str, Look] = {l.id: l for l in (
31
+ Look("midnight", "Midnight", "Black and blue, white type", {
32
+ "bg": "#03050a", "bg2": "#0b1a3a", "ink": "#ffffff", "mute": "#9fb0cc", "acc": "#2f7bff", "acc2": "#8db8ff",
33
+ "line": "rgba(130,170,255,.26)", "card": "rgba(255,255,255,.04)", "hl": "rgba(47,123,255,.18)", "paper": "#ffffff",
34
+ "paperink": "#03050a", "star": "#ffffff", "markbg": "rgba(3,5,10,.75)", "markline": "rgba(141,184,255,.4)", "markink": "#ffffff"}),
35
+ Look("paper", "Orbit", "White, black type, blue orbits and stars", {
36
+ "bg": "#ffffff", "bg2": "#eaf1ff", "ink": "#05070d", "mute": "#4b5668", "acc": "#1f5fe6", "acc2": "#1f5fe6",
37
+ "line": "rgba(5,7,13,.18)", "card": "#ffffff", "hl": "rgba(31,95,230,.09)", "paper": "#05070d",
38
+ "paperink": "#ffffff", "star": "#1f5fe6", "markbg": "#05070d", "markline": "#05070d", "markink": "#ffffff"}),
39
+ )}
40
+ DEFAULT_LOOK = "midnight"
41
+
42
+ # What the person can ask the layout to lean towards; "auto" lets the AI choose from the content.
43
+ STYLES = {
44
+ "auto": "Choose the layout that suits the content",
45
+ "flowchart": "A flow chart: boxes joined by arrows, steps in order",
46
+ "timeline": "A timeline: what happened or happens, in order",
47
+ "compare": "A comparison: two sides set against each other",
48
+ "hierarchy": "A hierarchy: one idea and the parts it breaks into",
49
+ "overview": "An overview: cards of the key ideas and the numbers that matter",
50
+ }
51
+
52
+ # Every class the model's markup may use (the components in design.css); anything else is dropped.
53
+ ALLOWED_CLASSES = {
54
+ "sec", "sec-t", "lead", "mono", "small", "hl", "node", "n-no", "n-t", "n-d", "flow", "col", "grid", "c2", "c3", "c4",
55
+ "card", "c-k", "c-t", "c-d", "timeline", "t-item", "t-when", "t-t", "t-d", "tree", "t-root", "t-kids", "compare",
56
+ "side", "s-h", "stats", "stat", "s-n", "s-l", "cols", "pills", "pill", "callout",
57
+ }
58
+ ALLOWED_TAGS = {"div", "section", "span", "p", "ul", "ol", "li", "strong", "b", "em", "i", "small", "br", "h3", "h4"}
59
+ DROP_WITH_CONTENT = {"script", "style", "iframe", "object", "embed", "link", "meta", "svg", "img", "video", "audio", "form",
60
+ "input", "button", "textarea", "select", "canvas", "math", "head", "title", "noscript", "template"}
61
+ MAX_BODY_CHARS = 14_000
62
+ MAX_ELEMENTS = 320
63
+
64
+ _SPACE = re.compile(r"\s+")
65
+
66
+
67
+ def _clean_text(text: str) -> str:
68
+ return text.replace("—", ",").replace("–", "-")
69
+
70
+
71
+ def sanitize_body(raw: object) -> str | None:
72
+ """The model's body markup rebuilt from the allowlist, or None when nothing readable is left."""
73
+ if not isinstance(raw, str) or not raw.strip():
74
+ return None
75
+ soup = BeautifulSoup(raw[: MAX_BODY_CHARS * 2], "html.parser")
76
+ for c in soup.find_all(string=lambda t: isinstance(t, Comment)):
77
+ c.extract()
78
+ for tag in soup.find_all(DROP_WITH_CONTENT):
79
+ tag.decompose()
80
+ count = 0
81
+ for tag in list(soup.find_all(True)):
82
+ count += 1
83
+ if tag.name not in ALLOWED_TAGS or count > MAX_ELEMENTS:
84
+ tag.unwrap() if count <= MAX_ELEMENTS else tag.decompose()
85
+ continue
86
+ classes = [c for c in (tag.get("class") or []) if c in ALLOWED_CLASSES]
87
+ tag.attrs = {"class": classes} if classes else {}
88
+ for node in soup.find_all(string=True):
89
+ if isinstance(node, NavigableString):
90
+ node.replace_with(NavigableString(_clean_text(str(node))))
91
+ html = _SPACE.sub(" ", str(soup)).strip()
92
+ text = soup.get_text(strip=True)
93
+ if len(html) > MAX_BODY_CHARS or len(text) < 40:
94
+ return None
95
+ return html
96
+
97
+
98
+ def _clip(text: object, limit: int) -> str:
99
+ s = _SPACE.sub(" ", re.sub(r"<[^>]*>", " ", str(text or ""))).strip()
100
+ s = _clean_text(s)
101
+ if len(s) <= limit:
102
+ return s
103
+ cut = s[: limit - 1].rsplit(" ", 1)[0].rstrip(" ,;:.-") or s[: limit - 1]
104
+ return cut + "…"
105
+
106
+
107
+ def build_content(raw: dict) -> dict | None:
108
+ """The stored content for a model answer (title, subtitle, eyebrow, key_figures, body_html, rule, notes), or None when it is
109
+ too thin to draw."""
110
+ if hasattr(raw, "model_dump"):
111
+ raw = raw.model_dump()
112
+ title = _clip(raw.get("title"), 70)
113
+ body = sanitize_body(raw.get("body_html"))
114
+ if not title or not body:
115
+ return None
116
+ figures = []
117
+ for f in raw.get("key_figures") or []:
118
+ f = f if isinstance(f, dict) else getattr(f, "__dict__", {})
119
+ value, label = _clip(f.get("value"), 14), _clip(f.get("label"), 44)
120
+ if value and label:
121
+ figures.append({"value": value, "label": label})
122
+ notes = [n for n in (_clip(n, 100) for n in raw.get("notes") or []) if n][:3]
123
+ return {"title": title, "subtitle": _clip(raw.get("subtitle"), 130), "eyebrow": _clip(raw.get("eyebrow"), 30) or "Key ideas",
124
+ "key_figures": figures[:3], "body_html": body, "rule": _clip(raw.get("rule"), 90), "notes": notes}
125
+
126
+
127
+ def _stars(seed: str, w: int, h: int, n: int = 90) -> str:
128
+ rnd = random.Random(int(hashlib.sha1(seed.encode()).hexdigest()[:8], 16))
129
+ out = []
130
+ for _ in range(n):
131
+ r = rnd.choice((1.2, 1.5, 1.5, 2, 2.6))
132
+ out.append(f'<circle cx="{rnd.randint(0, w)}" cy="{rnd.randint(0, h)}" r="{r}" fill="var(--star)" opacity="{round(rnd.uniform(.12, .5), 2)}"/>')
133
+ return "".join(out)
134
+
135
+
136
+ def _orbits(w: int, h: int) -> str:
137
+ """The Orbit look's scenery: a ringed planet like the app's icon, orbit rings with small moons, and shooting stars."""
138
+ big = round(w * 0.2)
139
+ cx, cy = round(w * 0.86), round(h * 0.085)
140
+ pr = round(w * 0.05)
141
+
142
+ def ring(x, y, rx, ry, op, dash=""):
143
+ return (f'<ellipse cx="{x}" cy="{y}" rx="{rx}" ry="{ry}" transform="rotate(-20 {x} {y})" fill="none" stroke="var(--acc)" '
144
+ f'stroke-width="2" opacity="{op}"{dash}/>')
145
+
146
+ def planet_ring(front: bool):
147
+ sweep = 0 if front else 1
148
+ return (f'<g transform="rotate(-22 {cx} {cy})"><path d="M{cx - pr * 2} {cy} A{pr * 2} {round(pr * .6)} 0 0 {sweep} {cx + pr * 2} {cy}" '
149
+ f'fill="none" stroke="var(--acc)" stroke-width="5" opacity="{1 if front else .5}"/></g>')
150
+
151
+ out = [
152
+ '<defs><linearGradient id="shoot" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="var(--acc)" stop-opacity="0"/>'
153
+ '<stop offset="1" stop-color="var(--acc)" stop-opacity=".85"/></linearGradient></defs>',
154
+ ring(cx, cy, big, round(big * .3), .35, ' stroke-dasharray="3 12"'),
155
+ ring(cx, cy, round(big * 1.55), round(big * .46), .22),
156
+ ring(round(w * .06), round(h * .96), round(w * .5), round(w * .14), .2, ' stroke-dasharray="3 12"'),
157
+ ring(round(w * .06), round(h * .96), round(w * .34), round(w * .09), .28),
158
+ planet_ring(False),
159
+ f'<circle cx="{cx}" cy="{cy}" r="{pr}" fill="var(--bg)" stroke="var(--acc)" stroke-width="5"/>',
160
+ f'<circle cx="{cx}" cy="{cy}" r="{pr - 12}" fill="var(--acc)" opacity=".12"/>',
161
+ planet_ring(True),
162
+ f'<circle cx="{round(w * .6)}" cy="{round(h * .036)}" r="9" fill="var(--acc)" opacity=".7"/>',
163
+ f'<circle cx="{round(w * .31)}" cy="{round(h * .93)}" r="8" fill="var(--acc)" opacity=".6"/>',
164
+ ]
165
+ for fx, fy, length, op in ((.46, .2, 190, .8), (.7, .4, 150, .6), (.12, .55, 120, .5)):
166
+ x, y = round(w * fx), round(h * fy)
167
+ out.append(f'<g transform="rotate(28 {x} {y})" opacity="{op}"><rect x="{x - length}" y="{y - 2}" width="{length}" height="4" rx="2" fill="url(#shoot)"/>'
168
+ f'<circle cx="{x}" cy="{y}" r="5" fill="var(--acc)"/></g>')
169
+ return "".join(out)
170
+
171
+
172
+ _ENV = Environment(loader=FileSystemLoader(_DIR), autoescape=select_autoescape(["j2", "html"], default=True), undefined=StrictUndefined,
173
+ trim_blocks=True, lstrip_blocks=True)
174
+
175
+
176
+ def render_design(look: str, content: dict, layout: str = "portrait", fit: float | None = None) -> str:
177
+ """The page for an AI-composed infographic. `fit` is the scale that makes its body fit (stored with the content)."""
178
+ look = look if look in LOOKS else DEFAULT_LOOK
179
+ layout = layout if layout in SIZES else "portrait"
180
+ w, h = SIZES[layout]
181
+ if fit is None:
182
+ fit = float(((content.get("fit") or {}) if isinstance(content.get("fit"), dict) else {}).get(layout) or 1)
183
+ fit = max(0.5, min(1.0, fit))
184
+ vars_css = ";".join(f"--{k}:{v}" for k, v in LOOKS[look].vars.items())
185
+ return _ENV.get_template("design.html.j2").render(
186
+ c=content, look=look, layout=layout, w=w, h=h, fit=fit, vars_css=vars_css, css=Markup(CSS), logo=Markup(LOGO_SVG),
187
+ body=Markup(content["body_html"]),
188
+ stars=Markup(_stars(content["title"] + look, w, h) + (_orbits(w, h) if look == "paper" else "")),
189
+ 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")
190
+
191
+
192
+ SAMPLE_BODY = (
193
+ '<section class="sec"><div class="sec-t">The five layers</div><div class="flow">'
194
+ '<div class="node"><span class="n-no">01</span><span class="n-t">Prompt</span><span class="n-d">Wording, examples and format of one request.</span></div>'
195
+ '<div class="node"><span class="n-no">02</span><span class="n-t">Context</span><span class="n-d">Docs, history, tools and memory for each call.</span></div>'
196
+ '<div class="node hl"><span class="n-no">03</span><span class="n-t">Harness</span><span class="n-d">Tools, checks and guardrails around the model.</span></div>'
197
+ '<div class="node"><span class="n-no">04</span><span class="n-t">Loop</span><span class="n-d">Reason, act and observe until done.</span></div></div></section>'
198
+ '<section class="sec"><div class="sec-t">Every request goes through</div><div class="grid c4">'
199
+ + "".join(f'<div class="card{" hl" if i == 3 else ""}"><span class="c-k">Step {i + 1}</span><span class="c-t">{t}</span><span class="c-d">{d}</span></div>'
200
+ for i, (t, d) in enumerate([("Auth", "Identity, limits and input guards."), ("Cache", "Answers to repeat questions."),
201
+ ("Safety", "Scrub PII, catch injection."), ("Memory", "Session history, summarised."),
202
+ ("Retrieve", "Fetch docs, split queries."), ("Answer", "Sub-queries in parallel."),
203
+ ("Validate", "Check quality first."), ("Save", "Store and log one trace.")]))
204
+ + '</div></section>'
205
+ '<section class="sec"><div class="sec-t">Why it matters</div><div class="callout">Each layer builds on the one below it, so start at the bottom and add a layer only when the task needs it.</div></section>'
206
+ )
207
+ SAMPLE = {"title": "From Prompt to Production Agent", "subtitle": "Five engineering layers and the steps every request takes",
208
+ "eyebrow": "Agent engineering", "body_html": SAMPLE_BODY,
209
+ "key_figures": [{"value": "5", "label": "engineering layers"}, {"value": "8", "label": "steps on every request"},
210
+ {"value": "30%", "label": "of traffic skips the pipeline"}],
211
+ "rule": "Most tasks only need the bottom layers; avoid complex graphs without cause.",
212
+ "notes": ["Each layer builds on the one below it.", "Cache and middleware run outside the graph.",
213
+ "Every request produces one end-to-end trace."]}
backend/app/infographics/image.py CHANGED
@@ -5,6 +5,7 @@ import functools
5
  import glob
6
  import hashlib
7
  import os
 
8
  import shutil
9
  import subprocess
10
  import tempfile
@@ -14,8 +15,9 @@ from threading import Lock
14
 
15
  from app.infographics.render import SIZES
16
 
17
- TIMEOUT = 60 # seconds for one drawing
18
- KEEP = 8 # drawings kept in memory
 
19
 
20
  _cache: OrderedDict[str, bytes] = OrderedDict()
21
  _lock = Lock()
@@ -51,9 +53,9 @@ def chrome() -> str | None:
51
 
52
 
53
  def render_png(html: str, layout: str = "portrait") -> bytes:
54
- """The page as a PNG at its own size: tall (1600 x 2260) or wide (2260 x 1600)."""
55
  width, height = SIZES.get(layout, SIZES["portrait"])
56
- key = hashlib.sha256(f"{width}x{height}{html}".encode()).hexdigest()
57
  with _lock:
58
  if key in _cache:
59
  _cache.move_to_end(key)
@@ -65,7 +67,7 @@ def render_png(html: str, layout: str = "portrait") -> bytes:
65
  with tempfile.TemporaryDirectory() as tmp:
66
  src, out = Path(tmp) / "page.html", Path(tmp) / "out.png"
67
  src.write_text(page, encoding="utf-8")
68
- cmd = [binary, "--headless=new", "--disable-gpu", "--no-sandbox", "--hide-scrollbars", "--force-device-scale-factor=1",
69
  f"--user-data-dir={tmp}/profile", f"--window-size={width},{height}", "--virtual-time-budget=8000",
70
  f"--screenshot={out}", f"file://{src}"]
71
  try:
@@ -80,3 +82,30 @@ def render_png(html: str, layout: str = "portrait") -> bytes:
80
  while len(_cache) > KEEP:
81
  _cache.popitem(last=False)
82
  return data
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  import glob
6
  import hashlib
7
  import os
8
+ import re
9
  import shutil
10
  import subprocess
11
  import tempfile
 
15
 
16
  from app.infographics.render import SIZES
17
 
18
+ TIMEOUT = 90 # seconds for one drawing
19
+ SCALE = 2 # pixels per page pixel: a 1600 x 2260 page is drawn as 3200 x 4520, sharp on any screen and in print
20
+ KEEP = 6 # drawings kept in memory
21
 
22
  _cache: OrderedDict[str, bytes] = OrderedDict()
23
  _lock = Lock()
 
53
 
54
 
55
  def render_png(html: str, layout: str = "portrait") -> bytes:
56
+ """The page as a PNG, drawn at SCALE times its size: tall (1600 x 2260) or wide (2260 x 1600) before scaling."""
57
  width, height = SIZES.get(layout, SIZES["portrait"])
58
+ key = hashlib.sha256(f"{width}x{height}@{SCALE}{html}".encode()).hexdigest()
59
  with _lock:
60
  if key in _cache:
61
  _cache.move_to_end(key)
 
67
  with tempfile.TemporaryDirectory() as tmp:
68
  src, out = Path(tmp) / "page.html", Path(tmp) / "out.png"
69
  src.write_text(page, encoding="utf-8")
70
+ cmd = [binary, "--headless=new", "--disable-gpu", "--no-sandbox", "--hide-scrollbars", f"--force-device-scale-factor={SCALE}",
71
  f"--user-data-dir={tmp}/profile", f"--window-size={width},{height}", "--virtual-time-budget=8000",
72
  f"--screenshot={out}", f"file://{src}"]
73
  try:
 
82
  while len(_cache) > KEEP:
83
  _cache.popitem(last=False)
84
  return data
85
+
86
+
87
+ _FIT = re.compile(r"FIT=([0-9.]+)")
88
+ _MEASURE = ("<script>document.fonts.ready.then(function(){var b=document.querySelector('.ig-body'),i=document.querySelector('.ig-inner');"
89
+ "var s=1;if(b&&i){var inner=i.scrollHeight*parseFloat(getComputedStyle(i).getPropertyValue('--fit')||1);s=Math.min(1,b.clientHeight/Math.max(inner,1));}"
90
+ "document.title='FIT='+s.toFixed(3);});</script>")
91
+
92
+
93
+ def measure_fit(html: str, layout: str) -> float:
94
+ """The scale (1 or less) that makes an AI-composed page's body fit its space: the page drawn once, in Chrome, with our own
95
+ small script measuring it (the page's markup is sanitised, so no other script is in it). 1 when Chrome is missing."""
96
+ width, height = SIZES.get(layout, SIZES["portrait"])
97
+ binary = chrome()
98
+ if not binary:
99
+ return 1.0
100
+ page = f'<!doctype html><meta charset="utf-8"><body style="margin:0">{html}{_MEASURE}</body>'
101
+ with tempfile.TemporaryDirectory() as tmp:
102
+ src = Path(tmp) / "page.html"
103
+ src.write_text(page, encoding="utf-8")
104
+ cmd = [binary, "--headless=new", "--disable-gpu", "--no-sandbox", "--hide-scrollbars", f"--user-data-dir={tmp}/profile",
105
+ f"--window-size={width},{height}", "--virtual-time-budget=10000", "--dump-dom", f"file://{src}"]
106
+ try:
107
+ out = subprocess.run(cmd, check=True, capture_output=True, timeout=TIMEOUT, text=True).stdout
108
+ except (subprocess.SubprocessError, OSError):
109
+ return 1.0
110
+ found = _FIT.search(out)
111
+ return max(0.55, min(1.0, float(found.group(1)))) if found else 1.0
backend/app/infographics/templates/design.css ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* The infographic design system: black, blue and white, thin lines, flow charts. The page frame (head, takeaway, mark, the
2
+ background) is ours; the body is composed by the AI from the components below, and only these class names survive the
3
+ sanitiser (design.ALLOWED_CLASSES). */
4
+ .ig2,.ig2 *{box-sizing:border-box;margin:0;padding:0}
5
+ .ig2{position:relative;width:var(--w);height:var(--h);overflow:hidden;color:var(--ink);font-family:'Inter',sans-serif;
6
+ background:radial-gradient(1300px 900px at 82% 6%,var(--bg2),var(--bg) 68%)}
7
+ .ig2 .ig-bg{position:absolute;inset:0;pointer-events:none}
8
+ .ig2 .ig-main{position:absolute;left:110px;right:110px;top:84px;bottom:132px;display:flex;flex-direction:column;gap:34px}
9
+ .ig2 .ig-left{display:contents}
10
+ .ig2 .ig-head{order:1;position:relative;flex:none;min-height:286px}
11
+ .ig2 .ig-body{order:2;flex:1;min-height:0;overflow:hidden;display:flex}
12
+ .ig2 .ig-take{order:3;flex:none}
13
+
14
+ .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}
15
+ .ig2 .ig-eyebrow:before{content:"";width:44px;height:2px;background:var(--acc)}
16
+ .ig2 h1{font:600 74px/1.04 'Space Grotesk',sans-serif;letter-spacing:-.02em;max-width:850px;margin-bottom:20px}
17
+ .ig2 .ig-sub{font-size:30px;line-height:1.38;color:var(--mute);max-width:850px}
18
+ /* The key figures: the few numbers or short facts that matter most, beside the title (or under it when wide) */
19
+ .ig2 .ig-figs{position:absolute;right:0;top:0;width:430px;display:flex;flex-direction:column;gap:16px}
20
+ .ig2 .fig{padding:6px 0 6px 24px;border-left:3px solid var(--acc)}
21
+ .ig2 .fig-v{display:block;font:600 56px/1.02 'Space Grotesk',sans-serif;letter-spacing:-.02em;overflow-wrap:anywhere}
22
+ .ig2 .fig-l{display:block;margin-top:6px;font:500 19px 'JetBrains Mono',monospace;letter-spacing:.1em;text-transform:uppercase;color:var(--mute)}
23
+
24
+ .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)}
25
+ .ig2 .ig-take .q{font:600 38px/1.16 'Space Grotesk',sans-serif;letter-spacing:-.01em}
26
+ .ig2 .ig-take ul{list-style:none;font-size:24px;line-height:1.38}
27
+ .ig2 .ig-take li{position:relative;padding-left:28px;margin:8px 0}
28
+ .ig2 .ig-take li:before{content:"";position:absolute;left:0;top:12px;width:11px;height:11px;border-radius:50%;background:var(--acc)}
29
+
30
+ .ig2 .ig-mark{position:absolute;right:44px;bottom:34px;display:flex;align-items:center;gap:16px;padding:8px 26px 8px 10px;border-radius:999px;
31
+ background:var(--markbg);border:1px solid var(--markline);font:700 30px 'JetBrains Mono',monospace;letter-spacing:.04em;color:var(--markink)}
32
+ .ig2 .ig-mark svg{width:52px;height:52px;display:block}
33
+
34
+ /* The body: fitted by scaling when the AI wrote more than a poster holds (the scale is measured once, when it is made) */
35
+ .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}
36
+ .ig2 .sec{display:flex;flex-direction:column;gap:20px}
37
+ .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)}
38
+ .ig2 .sec-t:after{content:"";flex:1;height:1.5px;background:var(--line)}
39
+ .ig2 .lead{font-size:28px;line-height:1.4;color:var(--ink)}
40
+ .ig2 .ig-inner p{font-size:24px;line-height:1.4;color:var(--mute)}
41
+ .ig2 .ig-inner strong,.ig2 .ig-inner b{color:var(--ink);font-weight:600}
42
+ .ig2 .ig-inner em,.ig2 .ig-inner i{color:var(--acc2);font-style:normal}
43
+ .ig2 .mono{font-family:'JetBrains Mono',monospace}
44
+ .ig2 .small{font-size:20px}
45
+
46
+ /* A box in a flow: a small number, a title, a line of text */
47
+ .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))}
48
+ .ig2 .node.hl{background:linear-gradient(var(--hl),var(--hl)),linear-gradient(var(--bg),var(--bg));border-color:var(--acc)}
49
+ .ig2 .n-no{display:block;font:500 19px 'JetBrains Mono',monospace;letter-spacing:.14em;color:var(--acc2);margin-bottom:10px}
50
+ .ig2 .n-t{display:block;font:600 31px/1.15 'Space Grotesk',sans-serif;margin-bottom:8px}
51
+ .ig2 .n-d{display:block;font-size:23px;line-height:1.38;color:var(--mute)}
52
+
53
+ /* Flow chart: boxes in a row (or a column) joined by arrows */
54
+ .ig2 .flow{display:flex;gap:58px;align-items:stretch}
55
+ .ig2 .flow>.node{flex:1;min-width:0}
56
+ .ig2 .flow>.node+.node:before{content:"";position:absolute;left:-48px;top:50%;width:34px;height:2px;background:var(--acc)}
57
+ .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)}
58
+ .ig2 .flow.col{flex-direction:column;gap:50px}
59
+ .ig2 .flow.col>.node+.node:before{left:50%;top:-40px;width:2px;height:28px;margin-left:-1px}
60
+ .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}
61
+
62
+ /* Cards in a grid */
63
+ .ig2 .grid{display:grid;gap:22px}
64
+ .ig2 .c2{grid-template-columns:repeat(2,minmax(0,1fr))}
65
+ .ig2 .c3{grid-template-columns:repeat(3,minmax(0,1fr))}
66
+ .ig2 .c4{grid-template-columns:repeat(4,minmax(0,1fr))}
67
+ .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))}
68
+ .ig2 .card.hl{background:linear-gradient(var(--hl),var(--hl)),linear-gradient(var(--bg),var(--bg));border-color:var(--acc)}
69
+ .ig2 .c-k{display:block;font:500 19px 'JetBrains Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:var(--acc2);margin-bottom:10px}
70
+ .ig2 .c-t{display:block;font:600 29px/1.15 'Space Grotesk',sans-serif;margin-bottom:8px}
71
+ .ig2 .c-d{display:block;font-size:22px;line-height:1.38;color:var(--mute)}
72
+
73
+ /* A timeline: a line with a dot for each moment */
74
+ .ig2 .timeline{position:relative;display:flex;flex-direction:column;gap:28px;padding-left:46px}
75
+ .ig2 .timeline:before{content:"";position:absolute;left:10px;top:8px;bottom:8px;width:2px;background:var(--line)}
76
+ .ig2 .t-item{position:relative}
77
+ .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)}
78
+ .ig2 .t-when{display:block;font:500 19px 'JetBrains Mono',monospace;letter-spacing:.14em;text-transform:uppercase;color:var(--acc2);margin-bottom:6px}
79
+ .ig2 .t-t{display:block;font:600 30px/1.15 'Space Grotesk',sans-serif;margin-bottom:6px}
80
+ .ig2 .t-d{display:block;font-size:23px;line-height:1.38;color:var(--mute)}
81
+
82
+ /* A hierarchy: one box over the boxes it leads to, joined by lines */
83
+ .ig2 .tree{display:flex;flex-direction:column;align-items:center}
84
+ .ig2 .t-root{width:560px;max-width:100%;text-align:center;position:relative}
85
+ .ig2 .t-root:after{content:"";position:absolute;left:50%;top:100%;width:2px;height:28px;margin-left:-1px;background:var(--acc)}
86
+ .ig2 .t-kids{display:flex;gap:24px;width:100%;margin-top:28px}
87
+ .ig2 .t-kids>*{flex:1;min-width:0;position:relative;margin-top:28px}
88
+ .ig2 .t-kids>*:before{content:"";position:absolute;left:50%;top:-28px;width:2px;height:28px;margin-left:-1px;background:var(--acc)}
89
+ .ig2 .t-kids>*:after{content:"";position:absolute;top:-28px;left:-12px;right:-12px;height:2px;background:var(--acc)}
90
+ .ig2 .t-kids>*:first-child:after{left:50%}
91
+ .ig2 .t-kids>*:last-child:after{right:50%}
92
+ .ig2 .t-kids>*:only-child:after{display:none}
93
+
94
+ /* Two sides compared */
95
+ .ig2 .compare{display:grid;grid-template-columns:1fr 1fr;border:1.5px solid var(--line);border-radius:14px;overflow:hidden}
96
+ .ig2 .side{padding:26px 32px 22px;background:linear-gradient(var(--card),var(--card)),linear-gradient(var(--bg),var(--bg))}
97
+ .ig2 .side+.side{border-left:1.5px solid var(--line)}
98
+ .ig2 .side.hl{background:linear-gradient(var(--hl),var(--hl)),linear-gradient(var(--bg),var(--bg))}
99
+ .ig2 .s-h{display:block;font:600 32px/1.15 'Space Grotesk',sans-serif;margin-bottom:14px}
100
+ .ig2 .side ul{list-style:none}
101
+ .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)}
102
+ .ig2 .side li:before{content:"";position:absolute;left:2px;top:23px;width:10px;height:10px;border-radius:50%;background:var(--acc)}
103
+
104
+ /* Big numbers */
105
+ .ig2 .stats{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(0,1fr);gap:22px}
106
+ .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}
107
+ .ig2 .s-n{display:block;font:600 62px/1 'Space Grotesk',sans-serif;letter-spacing:-.02em}
108
+ .ig2 .s-l{display:block;font-size:22px;line-height:1.35;color:var(--mute);margin-top:8px}
109
+
110
+ .ig2 .cols{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:40px}
111
+ .ig2 .pills{display:flex;flex-wrap:wrap;gap:12px}
112
+ .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))}
113
+ .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}
114
+
115
+ /* Wide: the title and the takeaway in a column, the body beside them */
116
+ .ig2.landscape .ig-main{top:70px;bottom:104px;flex-direction:row;gap:60px}
117
+ .ig2.landscape .ig-left{display:flex;flex-direction:column;justify-content:space-between;gap:28px;width:600px;flex:none;min-height:0}
118
+ .ig2.landscape .ig-head{min-height:0}
119
+ .ig2.landscape h1{font-size:58px;max-width:none}
120
+ .ig2.landscape .ig-sub{font-size:27px;max-width:none}
121
+ .ig2.landscape .ig-figs{position:static;width:100%;margin-top:34px;gap:14px}
122
+ .ig2.landscape .fig-v{font-size:50px}
123
+ .ig2.landscape .ig-body{order:0;width:1380px;flex:none}
124
+ .ig2.landscape .ig-take{grid-template-columns:1fr;gap:18px;padding:30px 34px}
125
+ .ig2.landscape .ig-take .q{font-size:35px}
126
+ .ig2.landscape .ig-take ul{font-size:22px}
backend/app/infographics/templates/design.html.j2 ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {#- The frame of an AI-composed infographic. `body` is sanitised markup (design.sanitize_body); every other value is autoescaped. -#}
2
+ <link rel="stylesheet" href="{{ fonts }}">
3
+ <style>{{ css }}</style>
4
+ <div class="ig2 look-{{ look }} {{ layout }}" style="--w:{{ w }}px;--h:{{ h }}px;--fit:{{ fit }};{{ vars_css }}">
5
+ <svg class="ig-bg" width="{{ w }}" height="{{ h }}" viewBox="0 0 {{ w }} {{ h }}">{{ stars }}</svg>
6
+ <div class="ig-main">
7
+ <div class="ig-left">
8
+ <header class="ig-head">
9
+ <div class="ig-eyebrow">{{ c.eyebrow }}</div>
10
+ <h1>{{ c.title }}</h1>
11
+ <p class="ig-sub">{{ c.subtitle }}</p>
12
+ {% if c.key_figures %}<div class="ig-figs">{% for f in c.key_figures %}<div class="fig"><span class="fig-v">{{ f.value }}</span><span class="fig-l">{{ f.label }}</span></div>{% endfor %}</div>{% endif %}
13
+ </header>
14
+ <div class="ig-take">
15
+ <div class="q">{{ c.rule }}</div>
16
+ <ul>{% for n in c.notes %}<li>{{ n }}</li>{% endfor %}</ul>
17
+ </div>
18
+ </div>
19
+ <div class="ig-body"><div class="ig-inner">{{ body }}</div></div>
20
+ </div>
21
+ <div class="ig-mark">{{ logo }}<span>notestack.ai</span></div>
22
+ </div>
backend/app/infographics/themes.py CHANGED
@@ -19,8 +19,11 @@ THEMES: dict[str, Theme] = {t.id: t for t in (
19
  Theme("moonbase", "Moon Base", "A lunar surface, a dome and a flag", ("#2b2b30", "#d8d2c4", "#c8745f")),
20
  Theme("observatory", "Observatory", "A telescope under a chart of stars", ("#14282b", "#8fc2b4", "#d6b66a")),
21
  )}
22
- DEFAULT_THEME = "launch"
23
 
24
 
25
  def theme_id(value: object) -> str:
26
- return value if isinstance(value, str) and value in THEMES else DEFAULT_THEME
 
 
 
 
19
  Theme("moonbase", "Moon Base", "A lunar surface, a dome and a flag", ("#2b2b30", "#d8d2c4", "#c8745f")),
20
  Theme("observatory", "Observatory", "A telescope under a chart of stars", ("#14282b", "#8fc2b4", "#d6b66a")),
21
  )}
22
+ DEFAULT_THEME = "midnight" # the look new infographics start with (the six above are the older fixed themes)
23
 
24
 
25
  def theme_id(value: object) -> str:
26
+ """A look (design.LOOKS) or one of the older themes; the default when unknown."""
27
+ from app.infographics.design import LOOKS
28
+
29
+ return value if isinstance(value, str) and (value in LOOKS or value in THEMES) else DEFAULT_THEME
backend/app/llm/provider.py CHANGED
@@ -49,7 +49,7 @@ def main_lm(effort: str | None = None) -> dspy.LM:
49
 
50
  @lru_cache
51
  def fast_lm() -> dspy.LM:
52
- return _build(settings.llm_fast_model, effort="low", temperature=None, max_tokens=8000)
53
 
54
 
55
  REPORT_MAX_TOKENS = 16000 # a few pages of prose plus GLM's low-effort reasoning
@@ -62,6 +62,27 @@ def report_lm() -> dspy.LM:
62
  max_tokens=REPORT_MAX_TOKENS)
63
 
64
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  @lru_cache
66
  def triage_lm() -> dspy.LM:
67
  """Cheap model that decides whether a chat message needs research at all."""
@@ -82,6 +103,16 @@ def focus_lm(retry: bool = False) -> dspy.LM:
82
  max_tokens=FOCUS_MAX_TOKENS)
83
 
84
 
 
 
 
 
 
 
 
 
 
 
85
  def configure_default() -> None:
86
  dspy.configure(lm=main_lm(), adapter=dspy.JSONAdapter())
87
 
 
49
 
50
  @lru_cache
51
  def fast_lm() -> dspy.LM:
52
+ return _build(settings.llm_fast_model, effort="low", temperature=None, max_tokens=settings.llm_fast_max_tokens)
53
 
54
 
55
  REPORT_MAX_TOKENS = 16000 # a few pages of prose plus GLM's low-effort reasoning
 
62
  max_tokens=REPORT_MAX_TOKENS)
63
 
64
 
65
+ REPORT_PLAN_MAX_TOKENS = 6000 # the blueprint: a storyline and up to eight short section plans plus reasoning
66
+ REPORT_SECTION_MAX_TOKENS = 4000 # one section (two to five paragraphs) plus reasoning, a quarter of a whole report's cap
67
+
68
+
69
+ def _report_model() -> str:
70
+ return settings.llm_report_model or settings.llm_fast_model
71
+
72
+
73
+ @lru_cache
74
+ def report_plan_lm() -> dspy.LM:
75
+ """Plans a themed report (the blueprint). The report model, with a small cap: the answer is a plan, not prose."""
76
+ return _build(_report_model(), effort="low", temperature=None, max_tokens=REPORT_PLAN_MAX_TOKENS)
77
+
78
+
79
+ @lru_cache
80
+ def report_section_lm(retry: bool = False) -> dspy.LM:
81
+ """Writes one section of a themed report. retry=True is the second try after a failed one: a different temperature,
82
+ so it is a fresh call and not the failed one again from the cache."""
83
+ return _build(_report_model(), effort="low", temperature=0.7 if retry else None, max_tokens=REPORT_SECTION_MAX_TOKENS)
84
+
85
+
86
  @lru_cache
87
  def triage_lm() -> dspy.LM:
88
  """Cheap model that decides whether a chat message needs research at all."""
 
103
  max_tokens=FOCUS_MAX_TOKENS)
104
 
105
 
106
+ SUGGEST_TEMPLATES_MAX_TOKENS = 2000 # four short template ideas plus GLM's low-effort reasoning
107
+
108
+
109
+ @lru_cache
110
+ def suggest_templates_lm() -> dspy.LM:
111
+ """The Create report dialog's suggested templates: four short template ideas out, so a tighter cap than the
112
+ general fast_lm keeps this quick without cutting into GLM's reasoning headroom."""
113
+ return _build(settings.llm_fast_model, effort="low", temperature=None, max_tokens=SUGGEST_TEMPLATES_MAX_TOKENS)
114
+
115
+
116
  def configure_default() -> None:
117
  dspy.configure(lm=main_lm(), adapter=dspy.JSONAdapter())
118
 
backend/app/llm/run.py CHANGED
@@ -77,7 +77,8 @@ def predict_many(
77
  def _record(db: Session, workspace_id: uuid.UUID, job: Job | None, signature: type[dspy.Signature], lm: dspy.LM,
78
  usage, calls: list[tuple[dict, dict]]) -> None:
79
  if usage.total:
80
- record_usage(db, workspace_id=workspace_id, kind="llm", provider=provider_name(), model=settings.llm_model,
 
81
  quantity=usage.total, unit="tokens", cost_usd=usage.cost_usd, job=job)
82
  workspace = db.get(Workspace, workspace_id)
83
  for inputs, outputs in calls:
 
77
  def _record(db: Session, workspace_id: uuid.UUID, job: Job | None, signature: type[dspy.Signature], lm: dspy.LM,
78
  usage, calls: list[tuple[dict, dict]]) -> None:
79
  if usage.total:
80
+ model = getattr(lm, "model", None) or settings.llm_model # the model that answered, not always the main one
81
+ record_usage(db, workspace_id=workspace_id, kind="llm", provider=provider_name(model), model=model,
82
  quantity=usage.total, unit="tokens", cost_usd=usage.cost_usd, job=job)
83
  workspace = db.get(Workspace, workspace_id)
84
  for inputs, outputs in calls:
backend/app/llm/signatures.py CHANGED
@@ -451,6 +451,58 @@ class PlanInfographic(dspy.Signature):
451
  infographic: InfographicOut = dspy.OutputField()
452
 
453
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
454
  class WriteReport(dspy.Signature):
455
  """Write the report the reader describes, using only the material given: never add facts from outside it. Follow
456
  the reader's instructions on structure, style, tone and length. Write in Markdown that starts straight at the
@@ -460,9 +512,8 @@ class WriteReport(dspy.Signature):
460
  unless the reader's instructions ask for a different structure. Every "## " heading is specific and descriptive of
461
  what is in that section (for example "The Context Window Bottleneck: Moving Beyond Basic Summarization", never
462
  "Section 2" or "Details"), and they read in a logical order. Use "### " inside sections, lists, and tables where
463
- they help. When the material has numbered lines (FILE blocks), put a [n] marker after each
464
- claim it supports and list each n in citations with the path and lines it came from; when it is a chat transcript,
465
- write no markers and no citations. Do not write a quiz, flashcards, practice or review questions into the report
466
  unless the reader's instructions
467
  ask for one: those are offered to the reader separately as visuals they can add. Never write about visuals, mind
468
  maps, flashcards, quizzes, tables or timelines in the text, and never suggest or describe one, even if the
@@ -476,7 +527,6 @@ class WriteReport(dspy.Signature):
476
  language: str = dspy.InputField(desc="The language to write in")
477
  report_title: str = dspy.OutputField(desc="A specific title, 3 to 10 words")
478
  markdown: str = dspy.OutputField(desc="The report body in Markdown, starting at the first ## heading")
479
- citations: list[FileCitation] = dspy.OutputField()
480
 
481
 
482
  class PlannedEmbed(BaseModel):
@@ -521,22 +571,166 @@ class WriteReportSection(dspy.Signature):
521
  and length; a section is two to five short paragraphs, or a list or table where that reads better. Do not write
522
  quiz questions, flashcards or
523
  review questions in the section (those are offered to the reader separately). Do not repeat
524
- the heading, and do not use "#" or "##" headings; "###" subheadings are fine. When the material has numbered lines (FILE
525
- blocks), put a [n] marker after each claim it supports and list each n in citations with the path and lines; when it
526
- is a chat transcript, write no markers and no citations. Never write about visuals, mind maps, flashcards,
527
  quizzes, tables or timelines in the text, and never suggest or describe one, even if the reader's instructions
528
  mention them: the app offers those to the reader separately, so write only the report itself. Write in the
529
  language requested. Never use em dashes."""
530
 
 
531
  title: str = dspy.InputField()
532
  report_title: str = dspy.InputField()
 
 
 
533
  heading: str = dspy.InputField()
534
  brief: str = dspy.InputField(desc="What this section should say")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
535
  request: str = dspy.InputField(desc="What the reader wants the report to be")
536
- material: list[str] = dspy.InputField(desc="Posts with path and numbered lines, or chat transcripts")
537
  language: str = dspy.InputField()
 
 
 
 
 
 
 
 
 
538
  markdown: str = dspy.OutputField(desc="The section body in Markdown, without its heading")
539
- citations: list[FileCitation] = dspy.OutputField()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
540
 
541
 
542
  class ReportTemplateIdea(BaseModel):
@@ -552,7 +746,7 @@ class SuggestReportTemplates(dspy.Signature):
552
  a practical how-to, a comparison, a beginner primer and an in-depth analysis, when the material supports them).
553
  Never use em dashes."""
554
 
555
- items: list[str] = dspy.InputField(desc="The title and the start of each source")
556
  topic: str = dspy.InputField(desc="What the reader wants the report to be about, or (none)")
557
  templates: list[ReportTemplateIdea] = dspy.OutputField(desc="Exactly 4")
558
 
 
451
  infographic: InfographicOut = dspy.OutputField()
452
 
453
 
454
+ class KeyFigure(BaseModel):
455
+ value: str = Field(description="A number or a very short fact, at most 12 characters, e.g. 30%, 8 steps, Layer 1")
456
+ label: str = Field(description="What it counts or means, at most 6 words")
457
+
458
+
459
+ class DesignOut(BaseModel):
460
+ title: str = Field(description="At most 8 words")
461
+ subtitle: str = Field(description="One line, at most 16 words")
462
+ eyebrow: str = Field(description="A 1 to 3 word topic label, e.g. Agent engineering")
463
+ key_figures: list[KeyFigure] = Field(description="The 2 or 3 most important numbers or short facts in the material, shown beside the title")
464
+ body_html: str = Field(description="The body of the page: 2 to 4 sections of HTML built only from the allowed components")
465
+ rule: str = Field(description="The one takeaway, at most 14 words")
466
+ notes: list[str] = Field(description="Exactly 3 supporting notes, at most 14 words each")
467
+
468
+
469
+ class DesignInfographic(dspy.Signature):
470
+ """Design a one page infographic from the material. You write the body of the page as HTML, composed ONLY from the
471
+ components below, choosing the ones that make the content clearest (a flow chart for steps, a timeline for events,
472
+ a hierarchy for a whole and its parts, a comparison for two options, cards for the key ideas, stats for numbers that
473
+ matter). Use only what the material says. When a layout style is asked for, build the page around it. When a focus is
474
+ given, make it about that, as far as the material covers it. The page is black, blue and white and printed on a
475
+ poster: plain text only, short lines, no markdown, no emoji, no quotes around names, no em dashes. Write in the
476
+ language of the material.
477
+
478
+ HTML rules: tags allowed are div, section, span, p, ul, ol, li, strong, em, small, br. Use no style, id, href, src,
479
+ script, svg, img or any attribute except class. Only these class names exist: sec, sec-t, lead, hl, node, n-no, n-t,
480
+ n-d, flow, col, grid, c2, c3, c4, card, c-k, c-t, c-d, timeline, t-item, t-when, t-t, t-d, tree, t-root, t-kids,
481
+ compare, side, s-h, stats, stat, s-n, s-l, cols, pills, pill, callout.
482
+
483
+ Every part sits in a section: <section class="sec"><div class="sec-t">Short heading</div> COMPONENT </section>.
484
+ Components:
485
+ - Flow chart, left to right, at most 4 boxes, arrows are drawn between them for you:
486
+ <div class="flow"><div class="node"><span class="n-no">01</span><span class="n-t">Title</span><span class="n-d">One short line</span></div> ...</div>
487
+ Add class hl to the box that matters most (class="node hl"). For more than 4 steps use two flows, or a vertical one: class="flow col" (at most 5 boxes).
488
+ - Cards in a grid of 2, 3 or 4 columns (class grid c2, grid c3 or grid c4, at most 8 cards):
489
+ <div class="grid c3"><div class="card"><span class="c-k">Kicker</span><span class="c-t">Title</span><span class="c-d">One short line</span></div> ...</div>
490
+ - Timeline, at most 5 moments: <div class="timeline"><div class="t-item"><span class="t-when">When</span><span class="t-t">Title</span><span class="t-d">One short line</span></div> ...</div>
491
+ - Hierarchy, one root and 2 to 4 children: <div class="tree"><div class="node hl t-root"><span class="n-t">Whole</span><span class="n-d">Line</span></div><div class="t-kids"><div class="node"><span class="n-t">Part</span><span class="n-d">Line</span></div> ...</div></div>
492
+ - Comparison of two sides, 2 to 4 short lines each: <div class="compare"><div class="side"><span class="s-h">Side A</span><ul><li>Line</li></ul></div><div class="side hl"><span class="s-h">Side B</span><ul><li>Line</li></ul></div></div>
493
+ - Numbers, 2 to 4: <div class="stats"><div class="stat"><span class="s-n">30%</span><span class="s-l">What it counts</span></div> ...</div>
494
+ - A callout sentence: <div class="callout">One sentence.</div>
495
+ The key_figures are separate from the body and are shown beside the title, so do not repeat them in the body.
496
+ Lengths: a box or card title at most 4 words, its line at most 14 words, a heading at most 4 words. Use two to four
497
+ sections, and mix components (never one grid of 8 cards alone). Keep the whole page short enough for one poster."""
498
+
499
+ title: str = dspy.InputField(desc="What the material is from")
500
+ material: list[str] = dspy.InputField(desc="Ideas from posts, or chat transcripts")
501
+ focus: str = dspy.InputField(desc="What the infographic should be about, or (none)")
502
+ layout_style: str = dspy.InputField(desc="The kind of layout asked for, or auto to choose")
503
+ design: DesignOut = dspy.OutputField()
504
+
505
+
506
  class WriteReport(dspy.Signature):
507
  """Write the report the reader describes, using only the material given: never add facts from outside it. Follow
508
  the reader's instructions on structure, style, tone and length. Write in Markdown that starts straight at the
 
512
  unless the reader's instructions ask for a different structure. Every "## " heading is specific and descriptive of
513
  what is in that section (for example "The Context Window Bottleneck: Moving Beyond Basic Summarization", never
514
  "Section 2" or "Details"), and they read in a logical order. Use "### " inside sections, lists, and tables where
515
+ they help. Do not cite: no [n] markers, footnotes, references or lists of sources in the text; the app shows the reader
516
+ the sources separately. Do not write a quiz, flashcards, practice or review questions into the report
 
517
  unless the reader's instructions
518
  ask for one: those are offered to the reader separately as visuals they can add. Never write about visuals, mind
519
  maps, flashcards, quizzes, tables or timelines in the text, and never suggest or describe one, even if the
 
527
  language: str = dspy.InputField(desc="The language to write in")
528
  report_title: str = dspy.OutputField(desc="A specific title, 3 to 10 words")
529
  markdown: str = dspy.OutputField(desc="The report body in Markdown, starting at the first ## heading")
 
530
 
531
 
532
  class PlannedEmbed(BaseModel):
 
571
  and length; a section is two to five short paragraphs, or a list or table where that reads better. Do not write
572
  quiz questions, flashcards or
573
  review questions in the section (those are offered to the reader separately). Do not repeat
574
+ the heading, and do not use "#" or "##" headings; "###" subheadings are fine. Do not cite: no [n] markers, footnotes,
575
+ references or lists of sources in the text; the app shows the reader the sources separately. Never write about visuals, mind maps, flashcards,
 
576
  quizzes, tables or timelines in the text, and never suggest or describe one, even if the reader's instructions
577
  mention them: the app offers those to the reader separately, so write only the report itself. Write in the
578
  language requested. Never use em dashes."""
579
 
580
+ # The parts every section shares come first, so a provider can reuse them across the section calls.
581
  title: str = dspy.InputField()
582
  report_title: str = dspy.InputField()
583
+ request: str = dspy.InputField(desc="What the reader wants the report to be")
584
+ material: list[str] = dspy.InputField(desc="Posts with path and numbered lines, or chat transcripts")
585
+ language: str = dspy.InputField()
586
  heading: str = dspy.InputField()
587
  brief: str = dspy.InputField(desc="What this section should say")
588
+ markdown: str = dspy.OutputField(desc="The section body in Markdown, without its heading")
589
+
590
+
591
+ # Themed reports: the posts' stored ideas are grouped into themes, a blueprint plans the whole report, and each section is
592
+ # written from its own evidence (pipeline/report_themes.py, report_blueprint.py, report_evidence.py).
593
+
594
+
595
+ class ReportTheme(BaseModel):
596
+ name: str = Field(description="2 to 5 words, a noun phrase a reader would recognise")
597
+ summary: str = Field(description="One or two plain sentences on what the posts say about it")
598
+ idea_ids: list[str] = Field(description="Ids of the ideas that belong to this theme, exactly as listed")
599
+ matches_request: bool = Field(False, description="True when the reader's request asks for this theme to be focused on, "
600
+ "included or given more room")
601
+
602
+
603
+ class ThemeRelation(BaseModel):
604
+ a: str = Field(description="A theme name, exactly as you wrote it in themes")
605
+ b: str = Field(description="Another theme name, exactly as you wrote it in themes")
606
+ relation: str = Field(description="How they connect, one short sentence")
607
+
608
+
609
+ class GroupThemes(dspy.Signature):
610
+ """Group ideas taken from a writer's posts into themes. Every idea belongs to exactly one theme, listed by its id as
611
+ given. A theme is something several ideas are about, named the way a reader would name it: not a post title, not a
612
+ single idea, not 'Other'. Choose 4 to 12 themes, fewer when the ideas really are about few things. Also note how themes
613
+ connect (one cause of another, one the context of another, two sides of a choice), only where the ideas show it.
614
+ Where the reader's request asks to focus on, include or give more room to something, set matches_request on the theme
615
+ that is about it. Never use em dashes."""
616
+
617
+ title: str = dspy.InputField(desc="The notebook or posts the ideas come from")
618
+ request: str = dspy.InputField(desc="What the reader wants the report to be")
619
+ ideas: list[str] = dspy.InputField(desc="One per line: id | label: note")
620
+ themes: list[ReportTheme] = dspy.OutputField(desc="4 to 12 themes")
621
+ relations: list[ThemeRelation] = dspy.OutputField(desc="How themes connect, at most 8, only where the ideas show it")
622
+
623
+
624
+ class BlueprintSection(BaseModel):
625
+ heading: str = Field(description="2 to 8 words, specific to what the section says, never generic")
626
+ role: Literal["intro", "body", "synthesis", "summary"] = Field(
627
+ "body", description="intro opens the report, summary closes it, synthesis ties two or more themes together, "
628
+ "body covers its themes")
629
+ brief: str = Field(description="What this section says, 1 to 2 sentences")
630
+ theme_ids: list[str] = Field(description="Ids of the themes this section is about, as listed. A body section's "
631
+ "themes belong to it alone")
632
+ depth: Literal["deep", "standard", "brief"] = Field("standard", description="deep for a theme with a large weight "
633
+ "or one the reader asked for, brief for a small one")
634
+ must_cover: list[str] = Field(description="2 to 4 specific points this section has to make")
635
+ embed: PlannedEmbed | None = Field(None, description="A visual to suggest right after this section, only where it "
636
+ "really helps")
637
+
638
+
639
+ class ThemeConnection(BaseModel):
640
+ theme_ids: list[str] = Field(description="Two or more theme ids that are connected")
641
+ relation: str = Field(description="How they connect, one short sentence")
642
+ section: str = Field(description="Heading of the section that should bridge them")
643
+
644
+
645
+ class PlanReportBlueprint(dspy.Signature):
646
+ """Plan a whole report before any of it is written. The themes come with their weight (their share of what the posts say), a
647
+ tier (major, medium, minor) and whether the reader asked for them. Give every major theme and every theme the reader asked
648
+ for its own section with depth deep, in an order that builds an argument; put the ones the reader asked for early. Put medium
649
+ themes in standard sections and merge the minor ones into one brief section. A theme is the subject of exactly one section.
650
+ Write a storyline of three to five sentences: what the report as a whole says, so every section can be written toward it.
651
+ Start with an intro section and end with a summary section unless the reader asks for something else; the summary must
652
+ restate the major themes. Where themes connect, say which section bridges them (connections), and add a synthesis section
653
+ only if the connection deserves its own. Each section's must_cover is specific to it and shares no point with another.
654
+ Choose at most max_sections sections. Never plan a section that is itself a quiz, flashcards, practice questions or a
655
+ mind map: when allowed_kinds is not empty those are visuals, suggested through a section's embed (at most four in all, each
656
+ kind once, only the kinds allowed): a mind_map to show how many linked themes fit together (at most one, near the start or
657
+ the end), flashcards to learn a set of terms or facts, a quiz to check understanding after a dense section, and an
658
+ infographic, a one page picture of the main ideas, which every report with that kind allowed should suggest once, on the
659
+ last section before the summary. Suggest the others only where they really help; none where plain prose reads better. When
660
+ allowed_kinds is empty, plan no embeds, and a quiz or practice section is allowed only if the reader's request asks for
661
+ one. Use only what the themes cover. Never use em dashes."""
662
+
663
+ title: str = dspy.InputField()
664
+ request: str = dspy.InputField(desc="What the reader wants the report to be")
665
+ themes: list[str] = dspy.InputField(desc="One per line: id | name | tier | weight | posts | asked for: summary")
666
+ relations: list[str] = dspy.InputField(desc="One per line: id <-> id: how they connect, or empty")
667
+ allowed_kinds: list[str] = dspy.InputField(desc="The visuals that may be suggested, or empty")
668
+ language: str = dspy.InputField()
669
+ max_sections: int = dspy.InputField(desc="The most sections to plan")
670
+ report_title: str = dspy.OutputField(desc="A specific title, 3 to 10 words")
671
+ storyline: str = dspy.OutputField(desc="Three to five sentences on what the whole report says")
672
+ sections: list[BlueprintSection] = dspy.OutputField(desc="2 to max_sections sections, in reading order")
673
+ connections: list[ThemeConnection] = dspy.OutputField(desc="At most 6")
674
+
675
+
676
+ class WriteThemedSection(dspy.Signature):
677
+ """Write one section of a report that has already been planned. You are given the storyline of the whole report and its
678
+ outline: write toward that storyline, and do not make points that another section owns (covered_elsewhere). Make the
679
+ points in must_cover. Use only the evidence given; never add facts from outside it. The evidence is grouped by post and
680
+ comes in two forms: text as the writer wrote it, and short summaries of other posts. Combine what
681
+ several posts say about the same thing: say where they agree, where they differ or where the writer changed their mind,
682
+ and never go through the posts one at a time. Where a connection is listed, bridge the themes in a sentence or two.
683
+ Follow the reader's instructions on style, tone and length; a section is two to five short paragraphs, or a list or table
684
+ where that reads better; a deep section is longer than a brief one. For role intro, say what the report covers and why it
685
+ matters; for role summary, restate the main points of the sections as written, in the digests given. Do not repeat the
686
+ heading, and do not use "#" or "##" headings; "###" subheadings are fine. Do not cite: no [n] markers, footnotes,
687
+ references or lists of sources in the text, and do not name the posts or their paths; the app shows the reader the
688
+ sources separately. Do not write quiz questions, flashcards or review questions, and never write about visuals, mind maps,
689
+ flashcards, quizzes, tables or timelines in the text: the app offers those separately. Write in the language requested.
690
+ Never use em dashes."""
691
+
692
+ # What every section shares comes first, so a provider can reuse it across the section calls.
693
+ storyline: str = dspy.InputField(desc="What the whole report says")
694
+ outline: list[str] = dspy.InputField(desc="Every section: number, heading, role, what it says")
695
+ connections: list[str] = dspy.InputField(desc="Themes the report connects, with the section that bridges them")
696
  request: str = dspy.InputField(desc="What the reader wants the report to be")
 
697
  language: str = dspy.InputField()
698
+ heading: str = dspy.InputField(desc="The section to write")
699
+ role: str = dspy.InputField(desc="intro, body, synthesis or summary")
700
+ depth: str = dspy.InputField(desc="deep, standard or brief")
701
+ brief: str = dspy.InputField(desc="What this section should say")
702
+ must_cover: list[str] = dspy.InputField(desc="Points this section has to make")
703
+ covered_elsewhere: list[str] = dspy.InputField(desc="Points other sections own: do not make them here")
704
+ bridges: list[str] = dspy.InputField(desc="Connections this section should bridge, or empty")
705
+ evidence: list[str] = dspy.InputField(desc="Posts as written, short summaries of other posts, or digests of the written "
706
+ "sections")
707
  markdown: str = dspy.OutputField(desc="The section body in Markdown, without its heading")
708
+
709
+
710
+ class WriteThemedReport(dspy.Signature):
711
+ """Write a whole report that has already been planned, in one piece. You are given the storyline and the planned sections
712
+ in order, each with what it must cover and the evidence for it. Write in Markdown that starts straight at the first "## "
713
+ heading (the title goes in report_title, not in the text), with one "## " heading per planned section, in that order and
714
+ with those headings, and write toward the storyline. Make each section's must_cover points, and do not make a point
715
+ another section owns. Use only the evidence given; never add facts from outside it. The evidence is grouped by post and
716
+ comes in two forms: text as the writer wrote it, and short summaries of other posts. Combine what
717
+ several posts say about the same thing: say where they agree, where they differ or where the writer changed their mind,
718
+ and never go through the posts one at a time. Where a connection is listed, bridge the themes. A deep section is longer
719
+ than a brief one. Follow the reader's instructions on style, tone and length. Use "### " inside sections, lists, and tables
720
+ where they help. Do not cite: no [n] markers, footnotes, references or lists of sources in the text, and do not name the
721
+ posts or their paths; the app shows the reader the sources separately. Do not write a quiz, flashcards, practice or
722
+ review questions unless the reader's instructions ask for one, and never write about visuals, mind maps, flashcards or
723
+ timelines in the text. Write in the language requested. Never use em dashes."""
724
+
725
+ title: str = dspy.InputField(desc="The notebook or posts the evidence comes from")
726
+ storyline: str = dspy.InputField(desc="What the whole report says")
727
+ request: str = dspy.InputField(desc="What the reader wants the report to be")
728
+ language: str = dspy.InputField(desc="The language to write in")
729
+ connections: list[str] = dspy.InputField(desc="Themes the report connects, with the section that bridges them")
730
+ sections: list[str] = dspy.InputField(desc="The planned sections in order: heading, role, depth, what it says, must cover, "
731
+ "followed by its evidence")
732
+ report_title: str = dspy.OutputField(desc="A specific title, 3 to 10 words")
733
+ markdown: str = dspy.OutputField(desc="The report body in Markdown, starting at the first ## heading")
734
 
735
 
736
  class ReportTemplateIdea(BaseModel):
 
746
  a practical how-to, a comparison, a beginner primer and an in-depth analysis, when the material supports them).
747
  Never use em dashes."""
748
 
749
+ items: list[str] = dspy.InputField(desc="Each source: its title, topics and key ideas, or the start of it")
750
  topic: str = dspy.InputField(desc="What the reader wants the report to be about, or (none)")
751
  templates: list[ReportTemplateIdea] = dspy.OutputField(desc="Exactly 4")
752
 
backend/app/models/__init__.py CHANGED
@@ -1,5 +1,6 @@
1
  from app.models.content import (
2
  Artifact,
 
3
  ArtifactShare,
4
  B2VCustomVoice,
5
  B2VStyle,
@@ -17,6 +18,7 @@ from app.models.content import (
17
  MessageFeedback,
18
  Notebook,
19
  NotebookDocument,
 
20
  SocialAccount,
21
  Source,
22
  Topic,
@@ -37,6 +39,7 @@ __all__ = [
37
  "SupportConversation",
38
  "SupportMessage",
39
  "Artifact",
 
40
  "ArtifactShare",
41
  "AuthProvider",
42
  "B2VCustomVoice",
@@ -58,6 +61,7 @@ __all__ = [
58
  "Notebook",
59
  "PlanRecord",
60
  "NotebookDocument",
 
61
  "SocialAccount",
62
  "Source",
63
  "Subscription",
 
1
  from app.models.content import (
2
  Artifact,
3
+ ArtifactFeedback,
4
  ArtifactShare,
5
  B2VCustomVoice,
6
  B2VStyle,
 
18
  MessageFeedback,
19
  Notebook,
20
  NotebookDocument,
21
+ ReportTemplateCache,
22
  SocialAccount,
23
  Source,
24
  Topic,
 
39
  "SupportConversation",
40
  "SupportMessage",
41
  "Artifact",
42
+ "ArtifactFeedback",
43
  "ArtifactShare",
44
  "AuthProvider",
45
  "B2VCustomVoice",
 
61
  "Notebook",
62
  "PlanRecord",
63
  "NotebookDocument",
64
+ "ReportTemplateCache",
65
  "SocialAccount",
66
  "Source",
67
  "Subscription",
backend/app/models/content.py CHANGED
@@ -155,6 +155,37 @@ class MessageFeedback(IdMixin, TimestampMixin, Base):
155
  recall: Mapped[dict] = mapped_column(JSON, default=dict) # copy of Message.recall_json
156
 
157
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  class WorkspaceMemory(IdMixin, TimestampMixin, Base):
159
  """The writer's standing notes, one row per workspace, shared by every notebook and chat.
160
 
 
155
  recall: Mapped[dict] = mapped_column(JSON, default=dict) # copy of Message.recall_json
156
 
157
 
158
+ class ArtifactFeedback(IdMixin, TimestampMixin, Base):
159
+ """A thumbs up or down on one generated report, quiz, flashcard set or infographic, with a snapshot of what it was
160
+ (title, prompt, sources, options) taken when it was rated, so the ratings stay useful after it is deleted (the link
161
+ then becomes null). One row per artifact and person: rating again replaces it."""
162
+
163
+ __tablename__ = "artifact_feedback"
164
+ __table_args__ = (UniqueConstraint("artifact_id", "user_id"),)
165
+
166
+ workspace_id: Mapped[uuid.UUID] = _ws_fk()
167
+ artifact_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("artifacts.id", ondelete="SET NULL"), index=True)
168
+ user_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("users.id", ondelete="SET NULL"))
169
+ artifact_type: Mapped[str] = mapped_column(String(30))
170
+ rating: Mapped[int] = mapped_column(Integer) # 1 up, -1 down
171
+ reasons: Mapped[list] = mapped_column(JSON, default=list)
172
+ comment: Mapped[str | None] = mapped_column(Text)
173
+ snapshot: Mapped[dict] = mapped_column(JSON, default=dict)
174
+
175
+
176
+ class ReportTemplateCache(IdMixin, TimestampMixin, Base):
177
+ """The suggested report templates already written for some sources, found again by a hash of exactly what the AI read (each
178
+ post's stored ideas and topics, and the topic). The same input gives the same templates at once, so the Create report dialog
179
+ is instant, and a changed post (its ideas change) gets new ones."""
180
+
181
+ __tablename__ = "report_template_cache"
182
+ __table_args__ = (UniqueConstraint("workspace_id", "key"),)
183
+
184
+ workspace_id: Mapped[uuid.UUID] = _ws_fk()
185
+ key: Mapped[str] = mapped_column(String(64))
186
+ templates: Mapped[list] = mapped_column(JSON, default=list)
187
+
188
+
189
  class WorkspaceMemory(IdMixin, TimestampMixin, Base):
190
  """The writer's standing notes, one row per workspace, shared by every notebook and chat.
191
 
backend/app/models/user.py CHANGED
@@ -13,7 +13,6 @@ from sqlalchemy import (
13
  String,
14
  UniqueConstraint,
15
  Uuid,
16
- false,
17
  true,
18
  )
19
  from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -56,7 +55,6 @@ class Workspace(IdMixin, TimestampMixin, Base):
56
  training_opt_in: Mapped[bool] = mapped_column(Boolean, default=False)
57
  # Off: no report of this workspace can be shared by link, and existing links stop working.
58
  allow_public_links: Mapped[bool] = mapped_column(Boolean, default=True, server_default=true())
59
- brand_json: Mapped[dict] = mapped_column(JSON, default=dict)
60
 
61
  members: Mapped[list["WorkspaceMember"]] = relationship(back_populates="workspace")
62
 
@@ -112,11 +110,10 @@ class PlanRecord(TimestampMixin, Base):
112
  launch_kits: Mapped[int] = mapped_column(Integer, default=0) # -1 = unlimited
113
  reports: Mapped[int] = mapped_column(Integer, default=0, server_default="0") # per month, -1 = unlimited
114
  infographics: Mapped[int] = mapped_column(Integer, default=0, server_default="0") # per month, -1 = unlimited
 
115
  voice_cloning: Mapped[bool] = mapped_column(Boolean, default=False)
116
- brand_kit: Mapped[bool] = mapped_column(Boolean, default=False)
117
  features: Mapped[list] = mapped_column(JSON, default=list) # bullet points on the pricing page
118
  # Server defaults: 0006 seeds this table without these columns on a database 0001 built from the models.
119
- video_premium: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false()) # premium options
120
  # False: `videos` is a lifetime total that never refills (Free). True: per month.
121
  videos_monthly: Mapped[bool] = mapped_column(Boolean, default=True, server_default=true())
122
  video_limits: Mapped[dict | None] = mapped_column(JSON, default=dict) # see plans.VIDEO_LIMITS
 
13
  String,
14
  UniqueConstraint,
15
  Uuid,
 
16
  true,
17
  )
18
  from sqlalchemy.orm import Mapped, mapped_column, relationship
 
55
  training_opt_in: Mapped[bool] = mapped_column(Boolean, default=False)
56
  # Off: no report of this workspace can be shared by link, and existing links stop working.
57
  allow_public_links: Mapped[bool] = mapped_column(Boolean, default=True, server_default=true())
 
58
 
59
  members: Mapped[list["WorkspaceMember"]] = relationship(back_populates="workspace")
60
 
 
110
  launch_kits: Mapped[int] = mapped_column(Integer, default=0) # -1 = unlimited
111
  reports: Mapped[int] = mapped_column(Integer, default=0, server_default="0") # per month, -1 = unlimited
112
  infographics: Mapped[int] = mapped_column(Integer, default=0, server_default="0") # per month, -1 = unlimited
113
+ audio_overviews: Mapped[int] = mapped_column(Integer, default=-1, server_default="-1") # allowed (in total on Free); -1 = no cap
114
  voice_cloning: Mapped[bool] = mapped_column(Boolean, default=False)
 
115
  features: Mapped[list] = mapped_column(JSON, default=list) # bullet points on the pricing page
116
  # Server defaults: 0006 seeds this table without these columns on a database 0001 built from the models.
 
117
  # False: `videos` is a lifetime total that never refills (Free). True: per month.
118
  videos_monthly: Mapped[bool] = mapped_column(Boolean, default=True, server_default=true())
119
  video_limits: Mapped[dict | None] = mapped_column(JSON, default=dict) # see plans.VIDEO_LIMITS
backend/app/pipeline/flashcards.py CHANGED
@@ -8,6 +8,7 @@ from app.llm import run
8
  from app.llm.provider import fast_lm
9
  from app.llm.signatures import GenerateFlashcards
10
  from app.models import Artifact, Job
 
11
  from app.pipeline.generate import NothingToDo
12
  from app.pipeline.material import Material, load_material
13
  from app.services.jobs import update_job
@@ -16,20 +17,29 @@ SAME_LANGUAGE = "the same language as the sources" # no language picked: write
16
  CARD_COUNTS = {"fewer": 8, "standard": 16, "more": 24}
17
 
18
 
 
 
 
 
 
 
 
 
19
  def make_cards(db: Session, workspace_id: uuid.UUID, material: Material, *, topic: str = "", count: int = 16,
20
  difficulty: str = "medium", language: str = SAME_LANGUAGE, job: Job | None = None) -> list[dict]:
21
  """Cards about the material: a front, a back and verified source lines. Repeats and empty cards are dropped."""
22
- out = run.predict(GenerateFlashcards, db=db, workspace_id=workspace_id, job=job, title=material.title,
23
- material=material.text, topic=topic or "(none)", difficulty=difficulty, count=count,
24
- language=language.strip() or SAME_LANGUAGE, lm=fast_lm())
 
 
25
  cards, seen = [], set()
26
- for c in out.get("cards") or []:
27
- front, back = (c.get("front") or "").strip(), (c.get("back") or "").strip()
28
- key = front.lower()
29
- if not front or not back or key in seen:
30
  continue
31
- seen.add(key)
32
- cards.append({"front": front[:300], "back": back[:800], "sources": material.refs(c.get("sources"))})
33
  return cards[:count]
34
 
35
 
@@ -38,7 +48,8 @@ def build_flashcards(db: Session, job: Job, artifact: Artifact) -> dict:
38
  count = CARD_COUNTS.get(params.get("count") or "standard", CARD_COUNTS["standard"])
39
  topic = (params.get("topic") or "").strip()
40
  difficulty = params.get("difficulty") if params.get("difficulty") in ("easy", "medium", "hard") else "medium"
41
- material = load_material(db, artifact.workspace_id, artifact.notebook_id, params, job=job, what="flashcard set")
 
42
  update_job(db, job, progress=0.4, message="Writing the cards")
43
  cards = make_cards(db, artifact.workspace_id, material, topic=topic, count=count, difficulty=difficulty,
44
  language=params.get("language") or SAME_LANGUAGE, job=job)
 
8
  from app.llm.provider import fast_lm
9
  from app.llm.signatures import GenerateFlashcards
10
  from app.models import Artifact, Job
11
+ from app.pipeline import idea_pool
12
  from app.pipeline.generate import NothingToDo
13
  from app.pipeline.material import Material, load_material
14
  from app.services.jobs import update_job
 
17
  CARD_COUNTS = {"fewer": 8, "standard": 16, "more": 24}
18
 
19
 
20
+ def _card(c: dict, refs) -> dict | None:
21
+ """One generated card as the deck plays it, or None when it has no front or no back."""
22
+ front, back = (c.get("front") or "").strip(), (c.get("back") or "").strip()
23
+ if not front or not back:
24
+ return None
25
+ return {"front": front[:300], "back": back[:800], "sources": refs(c.get("sources"))}
26
+
27
+
28
  def make_cards(db: Session, workspace_id: uuid.UUID, material: Material, *, topic: str = "", count: int = 16,
29
  difficulty: str = "medium", language: str = SAME_LANGUAGE, job: Job | None = None) -> list[dict]:
30
  """Cards about the material: a front, a back and verified source lines. Repeats and empty cards are dropped."""
31
+ inputs = dict(title=material.title, topic=topic or "(none)", difficulty=difficulty, language=language.strip() or SAME_LANGUAGE)
32
+ # Posts with their ideas stored (the Mind Constellation's): one call over a random sample of those ideas, not the posts' text.
33
+ text = idea_pool.sample(db, workspace_id, material.docs, topic, chats=material.text) if material.mode == "ideas" else material.text
34
+ out = run.predict(GenerateFlashcards, db=db, workspace_id=workspace_id, job=job, material=text, count=count,
35
+ lm=fast_lm(), **inputs)
36
  cards, seen = [], set()
37
+ for raw in out.get("cards") or []:
38
+ card = _card(raw, material.refs)
39
+ if not card or card["front"].lower() in seen:
 
40
  continue
41
+ seen.add(card["front"].lower())
42
+ cards.append(card)
43
  return cards[:count]
44
 
45
 
 
48
  count = CARD_COUNTS.get(params.get("count") or "standard", CARD_COUNTS["standard"])
49
  topic = (params.get("topic") or "").strip()
50
  difficulty = params.get("difficulty") if params.get("difficulty") in ("easy", "medium", "hard") else "medium"
51
+ material = load_material(db, artifact.workspace_id, artifact.notebook_id, params, job=job, what="flashcard set",
52
+ large_ok=True, ideas_always=True)
53
  update_job(db, job, progress=0.4, message="Writing the cards")
54
  cards = make_cards(db, artifact.workspace_id, material, topic=topic, count=count, difficulty=difficulty,
55
  language=params.get("language") or SAME_LANGUAGE, job=job)
backend/app/pipeline/generate.py CHANGED
@@ -112,22 +112,39 @@ def ideas_fresh(d) -> bool:
112
  return bool(md.get("ideas")) and md.get("ideas_hash") == (d.content_hash or "")
113
 
114
 
115
- def extract_ideas(db: Session, job: Job | None, workspace_id: uuid.UUID, doc_ids: list[str] | None = None) -> dict:
 
116
  """Read each post that has no current ideas once and keep what it says on the post (label, note, points and
117
- the lines they came from). Maps are arranged from these, so they never re-read the posts."""
 
118
  docs = workspace_docs(db, workspace_id, [uuid.UUID(str(i)) for i in doc_ids] if doc_ids is not None else None)
119
  todo = [d for d in docs if not ideas_fresh(d)]
 
 
120
  corpus = Corpus(workspace_id)
121
  lm = fast_lm()
122
  done = 0
123
- for i, d in enumerate(todo):
 
124
  if job:
125
- update_job(db, job, progress=0.05 + 0.85 * i / max(len(todo), 1), message=f"Reading {d.title[:70]}")
126
- try:
127
- out = run.predict(ExtractIdeas, db=db, workspace_id=workspace_id, job=job, lm=lm, title=d.title,
128
- passage="\n\n".join(passages_for(corpus, [d], budget_chars=IDEAS_BUDGET)))
129
- except Exception:
130
- continue
 
 
 
 
 
 
 
 
 
 
 
 
131
  tools = tools_for(corpus, [d])
132
 
133
  def keep(items, tools=tools, d=d) -> list[dict]:
 
112
  return bool(md.get("ideas")) and md.get("ideas_hash") == (d.content_hash or "")
113
 
114
 
115
+ def extract_ideas(db: Session, job: Job | None, workspace_id: uuid.UUID, doc_ids: list[str] | None = None, *,
116
+ parallel: bool = False, limit: int | None = None) -> dict:
117
  """Read each post that has no current ideas once and keep what it says on the post (label, note, points and
118
+ the lines they came from). Maps are arranged from these, so they never re-read the posts. `parallel` reads the
119
+ posts together instead of one after the other (a report that needs many at once); `limit` reads at most that many."""
120
  docs = workspace_docs(db, workspace_id, [uuid.UUID(str(i)) for i in doc_ids] if doc_ids is not None else None)
121
  todo = [d for d in docs if not ideas_fresh(d)]
122
+ if limit is not None:
123
+ todo = todo[:limit]
124
  corpus = Corpus(workspace_id)
125
  lm = fast_lm()
126
  done = 0
127
+ answers: list[dict | None] | None = None
128
+ if parallel and todo:
129
  if job:
130
+ update_job(db, job, progress=0.05, message=f"Reading {len(todo)} posts for their key ideas")
131
+ answers = run.predict_many(
132
+ ExtractIdeas,
133
+ [dict(title=d.title, passage="\n\n".join(passages_for(corpus, [d], budget_chars=IDEAS_BUDGET))) for d in todo],
134
+ db=db, workspace_id=workspace_id, lm=lm)
135
+ for i, d in enumerate(todo):
136
+ if answers is not None:
137
+ out = answers[i]
138
+ if out is None:
139
+ continue
140
+ else:
141
+ if job:
142
+ update_job(db, job, progress=0.05 + 0.85 * i / max(len(todo), 1), message=f"Reading {d.title[:70]}")
143
+ try:
144
+ out = run.predict(ExtractIdeas, db=db, workspace_id=workspace_id, job=job, lm=lm, title=d.title,
145
+ passage="\n\n".join(passages_for(corpus, [d], budget_chars=IDEAS_BUDGET)))
146
+ except Exception:
147
+ continue
148
  tools = tools_for(corpus, [d])
149
 
150
  def keep(items, tools=tools, d=d) -> list[dict]:
backend/app/pipeline/idea_pool.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Flashcards (always) and quizzes (above ARTIFACT_MAX_POSTS posts) from the ideas already extracted from each post, the same stored
2
+ ideas the Mind Constellation is drawn from. Instead of sending post text, a small random sample of those ideas goes to the model: each
3
+ 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.
4
+ 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
5
+ a few of them. A sample of ARTIFACT_IDEA_SAMPLE ideas is about 1,500 tokens whatever the size of the selection."""
6
+
7
+ import math
8
+ import random
9
+ import re
10
+ import uuid
11
+
12
+ from sqlalchemy.orm import Session
13
+
14
+ from app.config import settings
15
+ from app.corpus import Corpus
16
+ from app.models import Document
17
+ from app.pipeline.generate import ideas_fresh
18
+ from app.pipeline.passages import passages_for
19
+
20
+ IDEAS_PER_POST = 2 # so a few posts cannot fill the sample (more when the selection is small, to fill it)
21
+ OPENING_BUDGET = 800 # characters of a post's opening, for a post with no stored ideas
22
+ MAX_OPENINGS = 3 # posts with no stored ideas represented by their opening
23
+ CHAT_BUDGET = 12_000 # characters of chat transcripts kept (the end of each) when chats are picked as well
24
+
25
+ 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 "
26
+ "where in the post the idea comes from. When a question or card comes from an idea, cite that path and those lines.")
27
+
28
+ _STOP = {"the", "and", "for", "with", "that", "this", "from", "how", "what", "why", "are", "was", "about", "your", "you",
29
+ "into", "over", "does", "can", "not", "but", "all", "any", "its", "their", "them", "will", "have", "has"}
30
+
31
+
32
+ def _tokens(text: str) -> set[str]:
33
+ return {w for w in re.findall(r"[a-z0-9]{3,}", (text or "").lower()) if w not in _STOP}
34
+
35
+
36
+ def has_ideas(docs: list[Document]) -> int:
37
+ """How many of the posts have their ideas stored and current."""
38
+ return sum(1 for d in docs if ideas_fresh(d))
39
+
40
+
41
+ def _line(doc: Document, idea: dict) -> str:
42
+ """One stored idea as one line, tagged with where it came from (its first cited range, when it has one)."""
43
+ where = doc.path
44
+ for ref in idea.get("sources") or []:
45
+ try:
46
+ a, b = int(ref["line_start"]), int(ref["line_end"])
47
+ except (KeyError, TypeError, ValueError):
48
+ continue
49
+ where = f"{doc.path} lines {a}-{max(a, b)}"
50
+ break
51
+ label, note = (idea.get("label") or "").strip(), (idea.get("note") or "").strip()
52
+ return f"IDEA [{where}] {label}: {note}"
53
+
54
+
55
+ def sample(db: Session, workspace_id: uuid.UUID, docs: list[Document], topic: str = "", chats: list[str] | None = None,
56
+ size: int | None = None, rng: random.Random | None = None) -> list[str]:
57
+ """The material for one call: the format note, a random sample of the posts' stored ideas, a few short openings for posts with
58
+ 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."""
59
+ rng = rng or random.Random()
60
+ size = size or max(settings.artifact_idea_sample, 1)
61
+ wanted = _tokens(topic)
62
+ have = [d for d in docs if ideas_fresh(d)]
63
+ per_post = max(IDEAS_PER_POST, math.ceil(size / max(len(have), 1)))
64
+ matching: list[tuple[Document, dict]] = []
65
+ rest: list[tuple[Document, dict]] = []
66
+ for d in have:
67
+ mine = [i for i in (d.metadata_json or {}).get("ideas") or [] if (i.get("label") or "").strip()]
68
+ rng.shuffle(mine)
69
+ for idea in mine[:per_post]:
70
+ hit = wanted and wanted & _tokens(" ".join([idea.get("label", ""), idea.get("note", ""), d.title]))
71
+ (matching if hit else rest).append((d, idea))
72
+ rng.shuffle(matching)
73
+ rng.shuffle(rest)
74
+ chosen = (matching + rest)[:size]
75
+ rng.shuffle(chosen) # not grouped by relevance or by post
76
+ out = [FORMAT_NOTE] + [_line(d, i) for d, i in chosen]
77
+ bare = [d for d in docs if not ideas_fresh(d)]
78
+ if bare:
79
+ corpus = Corpus(workspace_id)
80
+ for d in rng.sample(bare, min(MAX_OPENINGS, len(bare))):
81
+ out.extend(passages_for(corpus, [d], budget_chars=OPENING_BUDGET))
82
+ if chats:
83
+ share = CHAT_BUDGET // len(chats)
84
+ out += [t[-share:] for t in chats]
85
+ return out
backend/app/pipeline/infographic.py CHANGED
@@ -9,15 +9,18 @@ from sqlalchemy import select
9
  from sqlalchemy.orm import Session
10
 
11
  from app.corpus import Corpus
12
- from app.infographics.content import clamp
 
 
 
13
  from app.infographics.render import build_html
14
  from app.infographics.themes import theme_id
15
  from app.llm import run
16
  from app.llm.provider import fast_lm
17
- from app.llm.signatures import PlanInfographic
18
  from app.models import Artifact, Chat, Document, Job, Notebook
19
  from app.pipeline.generate import NothingToDo, extract_ideas, ideas_fresh
20
- from app.pipeline.material import MAX_POSTS, chat_material
21
  from app.pipeline.passages import notebook_docs, passages_for, workspace_docs
22
  from app.services.jobs import update_job
23
 
@@ -67,7 +70,7 @@ def write_content(db: Session, job: Job, artifact: Artifact, params: dict) -> tu
67
  docs = workspace_docs(db, ws, picked)
68
  elif not chat_ids and nb:
69
  docs = notebook_docs(db, nb.id)
70
- docs = docs[:MAX_POSTS]
71
  chats, chat_blocks = _chat_text(db, ws, chat_ids) if chat_ids else ([], [])
72
  if not docs and not chat_blocks:
73
  raise NothingToDo("Pick at least one post or chat for the infographic.")
@@ -76,12 +79,14 @@ def write_content(db: Session, job: Job, artifact: Artifact, params: dict) -> tu
76
  material = (_ideas_text(db, ws, docs, job) if docs else []) + chat_blocks
77
  prompt = (params.get("instructions") or "").strip()
78
  title = docs[0].title if docs else (chats[0].title or "Chat")
79
- update_job(db, job, progress=0.35, message="Writing the infographic")
 
80
  content = None
81
  for _ in range(2): # one retry when the model gives too little to draw
82
- out = run.predict(PlanInfographic, db=db, workspace_id=ws, job=job, title=title, material=material,
83
- focus=prompt or "(none)", lm=fast_lm())
84
- content = clamp(out.get("infographic") or {})
 
85
  if content:
86
  break
87
  if not content:
@@ -101,6 +106,8 @@ def build_infographic(db: Session, job: Job, artifact: Artifact) -> dict:
101
  params = job.params
102
  theme = theme_id(params.get("theme"))
103
  content, source, _title = write_content(db, job, artifact, params)
 
 
104
  artifact.content_json = {**(artifact.content_json or {}), "title": f"Infographic: {content['title']}"[:140],
105
  "theme": theme, "prompt": (params.get("instructions") or "").strip(), "source": source,
106
  "content": content}
@@ -110,7 +117,22 @@ def build_infographic(db: Session, job: Job, artifact: Artifact) -> dict:
110
  return {"artifact_id": str(artifact.id)}
111
 
112
 
 
 
 
 
 
 
 
 
 
113
  def infographic_html(content: dict, layout: str = "portrait") -> str | None:
114
- """The page for a finished infographic's stored text and theme, tall or wide (None when it has none yet)."""
 
115
  text = content.get("content")
116
- return build_html(theme_id(content.get("theme")), text, layout) if text else None
 
 
 
 
 
 
9
  from sqlalchemy.orm import Session
10
 
11
  from app.corpus import Corpus
12
+ from concurrent.futures import ThreadPoolExecutor
13
+
14
+ from app.infographics.design import LOOKS, STYLES, build_content, render_design
15
+ from app.infographics.image import measure_fit
16
  from app.infographics.render import build_html
17
  from app.infographics.themes import theme_id
18
  from app.llm import run
19
  from app.llm.provider import fast_lm
20
+ from app.llm.signatures import DesignInfographic
21
  from app.models import Artifact, Chat, Document, Job, Notebook
22
  from app.pipeline.generate import NothingToDo, extract_ideas, ideas_fresh
23
+ from app.pipeline.material import chat_material, max_posts
24
  from app.pipeline.passages import notebook_docs, passages_for, workspace_docs
25
  from app.services.jobs import update_job
26
 
 
70
  docs = workspace_docs(db, ws, picked)
71
  elif not chat_ids and nb:
72
  docs = notebook_docs(db, nb.id)
73
+ docs = docs[:max_posts()]
74
  chats, chat_blocks = _chat_text(db, ws, chat_ids) if chat_ids else ([], [])
75
  if not docs and not chat_blocks:
76
  raise NothingToDo("Pick at least one post or chat for the infographic.")
 
79
  material = (_ideas_text(db, ws, docs, job) if docs else []) + chat_blocks
80
  prompt = (params.get("instructions") or "").strip()
81
  title = docs[0].title if docs else (chats[0].title or "Chat")
82
+ update_job(db, job, progress=0.35, message="Designing the infographic")
83
+ style = params.get("style") if params.get("style") in STYLES else "auto"
84
  content = None
85
  for _ in range(2): # one retry when the model gives too little to draw
86
+ out = run.predict(DesignInfographic, db=db, workspace_id=ws, job=job, title=title, material=material,
87
+ focus=prompt or "(none)", layout_style=f"{style}: {STYLES[style]}" if style != "auto" else "auto",
88
+ lm=fast_lm())
89
+ content = build_content(out.get("design") or {})
90
  if content:
91
  break
92
  if not content:
 
106
  params = job.params
107
  theme = theme_id(params.get("theme"))
108
  content, source, _title = write_content(db, job, artifact, params)
109
+ update_job(db, job, progress=0.8, message="Fitting it to the page")
110
+ content["fit"] = fit_scales(theme, content)
111
  artifact.content_json = {**(artifact.content_json or {}), "title": f"Infographic: {content['title']}"[:140],
112
  "theme": theme, "prompt": (params.get("instructions") or "").strip(), "source": source,
113
  "content": content}
 
117
  return {"artifact_id": str(artifact.id)}
118
 
119
 
120
+ def fit_scales(theme: str, content: dict) -> dict:
121
+ """The scale at which the body fits the page, tall and wide (measured in Chrome once, kept with the content)."""
122
+ look = theme if theme in LOOKS else "midnight"
123
+ with ThreadPoolExecutor(max_workers=2) as pool:
124
+ wide, tall = (pool.submit(measure_fit, render_design(look, content, layout, fit=1.0), layout)
125
+ for layout in ("landscape", "portrait"))
126
+ return {"landscape": wide.result(), "portrait": tall.result()}
127
+
128
+
129
  def infographic_html(content: dict, layout: str = "portrait") -> str | None:
130
+ """The page for a finished infographic's stored content and look, tall or wide (None when it has none yet). Older
131
+ infographics (fixed text in a premade theme) still draw in that theme."""
132
  text = content.get("content")
133
+ if not text:
134
+ return None
135
+ theme = theme_id(content.get("theme"))
136
+ if "body_html" in text:
137
+ return render_design(theme if theme in LOOKS else "midnight", text, layout)
138
+ return build_html(theme, text, layout) if "items" in text else None
backend/app/pipeline/ingest.py CHANGED
@@ -630,29 +630,39 @@ def store_locked(db: Session, source: Source, entries: list[FeedEntry]) -> tuple
630
 
631
  def ingest_source(db: Session, source: Source, job: Job, max_posts: int) -> dict:
632
  source.sync_status = "syncing"
633
- update_job(db, job, status="running", progress=0.02, message="Contacting your feed")
634
  fetch_limit = max(SCAN_CAP, max_posts)
 
 
 
 
 
 
 
 
635
 
636
  if source.feed_url.startswith(SITE_PREFIX):
637
  update_job(db, job, message="Mapping your site")
638
  entries = crawl_site(
639
  source.feed_url.removeprefix(SITE_PREFIX), fetch_limit,
640
- on_found=lambda n: update_job(db, job, progress=0.05, message=f"Found {n} pages, reading them"),
641
- on_post=lambda done, total: update_job(db, job, progress=0.05 + 0.05 * done / total,
642
- message=f"Read {done} of {total} pages"),
643
- read_limit=max_posts,
644
  )
645
  title = None
646
  else:
647
  title, entries = fetch_feed(source.feed_url, fetch_limit)
 
648
  source.title = source.title or title
649
  if source.platform == "substack" and len(entries) < fetch_limit:
650
  site = f"{urlparse(source.feed_url).scheme}://{urlparse(source.feed_url).netloc}"
651
- update_job(db, job, progress=0.05, message="Reading your Substack archive")
652
  try:
653
  entries += fetch_substack_archive(
654
  site, {e.url for e in entries}, fetch_limit,
655
- on_page=lambda n: update_job(db, job, message=f"Found {len(entries) + n} posts in the archive"),
 
 
656
  body_budget=max(0, max_posts - len(entries)),
657
  )
658
  except (httpx.HTTPError, ValueError):
@@ -662,9 +672,9 @@ def ingest_source(db: Session, source: Source, job: Job, max_posts: int) -> dict
662
  available, beyond = entries[:max_posts], entries[max_posts:]
663
 
664
  def progress(done: int, total: int, post_title: str) -> None:
665
- update_job(db, job, progress=0.1 + 0.7 * done / total, message=f"{post_title[:80]} is in orbit")
666
 
667
- update_job(db, job, progress=0.1, message=f"Indexing {len(available)} posts")
668
  indexed, skipped, changed = store_entries(db, source, available, progress)
669
 
670
  locked = 0
 
630
 
631
  def ingest_source(db: Session, source: Source, job: Job, max_posts: int) -> dict:
632
  source.sync_status = "syncing"
 
633
  fetch_limit = max(SCAN_CAP, max_posts)
634
+ # Live counts the Sources page shows while this runs: found on the site, fetched (read), indexed.
635
+ live = {"stage": "fetching", "found": 0, "fetched": 0, "fetch_total": 0, "indexed": 0, "index_total": 0}
636
+
637
+ def report(progress: float | None = None, message: str | None = None, **counts) -> None:
638
+ live.update(counts)
639
+ update_job(db, job, progress=progress, message=message, result=dict(live))
640
+
641
+ update_job(db, job, status="running", progress=0.02, message="Contacting your feed", result=dict(live))
642
 
643
  if source.feed_url.startswith(SITE_PREFIX):
644
  update_job(db, job, message="Mapping your site")
645
  entries = crawl_site(
646
  source.feed_url.removeprefix(SITE_PREFIX), fetch_limit,
647
+ on_found=lambda n: report(0.05, f"Found {n} pages, reading them", found=n),
648
+ on_post=lambda done, total: report(0.05 + 0.05 * done / total, f"Read {done} of {total} pages",
649
+ fetched=done, fetch_total=total),
650
+ read_limit=max(max_posts, 1),
651
  )
652
  title = None
653
  else:
654
  title, entries = fetch_feed(source.feed_url, fetch_limit)
655
+ report(found=len(entries), fetched=len(entries), fetch_total=len(entries))
656
  source.title = source.title or title
657
  if source.platform == "substack" and len(entries) < fetch_limit:
658
  site = f"{urlparse(source.feed_url).scheme}://{urlparse(source.feed_url).netloc}"
659
+ report(0.05, "Reading your Substack archive")
660
  try:
661
  entries += fetch_substack_archive(
662
  site, {e.url for e in entries}, fetch_limit,
663
+ on_page=lambda n: report(message=f"Found {len(entries) + n} posts in the archive",
664
+ found=len(entries) + n, fetched=len(entries) + n,
665
+ fetch_total=len(entries) + n),
666
  body_budget=max(0, max_posts - len(entries)),
667
  )
668
  except (httpx.HTTPError, ValueError):
 
672
  available, beyond = entries[:max_posts], entries[max_posts:]
673
 
674
  def progress(done: int, total: int, post_title: str) -> None:
675
+ report(0.1 + 0.7 * done / total, f"{post_title[:80]} is in orbit", stage="indexing", indexed=done)
676
 
677
+ report(0.1, f"Indexing {len(available)} posts", stage="indexing", found=len(entries), index_total=len(available))
678
  indexed, skipped, changed = store_entries(db, source, available, progress)
679
 
680
  locked = 0
backend/app/pipeline/material.py CHANGED
@@ -9,16 +9,36 @@ from dataclasses import dataclass, field
9
  from sqlalchemy import select
10
  from sqlalchemy.orm import Session
11
 
 
12
  from app.corpus import Corpus
13
- from app.models import Chat, Document, Job, Message, Notebook
14
  from app.pipeline.generate import NothingToDo
15
  from app.pipeline.passages import ScopedTools, notebook_docs, passages_for, tools_for, verify_refs, workspace_docs
16
  from app.services.jobs import update_job
 
17
 
18
- MAX_POSTS = 20 # posts one artifact reads; the passage budget is shared between them
19
  CHAT_BUDGET = 60_000 # characters of chat transcripts
20
 
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  @dataclass
23
  class Material:
24
  title: str # the notebook, the post, or the first chat's title
@@ -28,6 +48,9 @@ class Material:
28
  # Turns the model's {path, line_start, line_end} items into verified refs with their quote. Chats give none.
29
  refs: Callable[[list | None], list[dict]] = lambda _items: []
30
  tools: ScopedTools | None = None # reads back cited lines (posts only)
 
 
 
31
 
32
  @property
33
  def is_chat(self) -> bool:
@@ -52,7 +75,8 @@ def chat_material(db: Session, chats: list[Chat]) -> list[str]:
52
 
53
 
54
  def load_material(db: Session, workspace_id: uuid.UUID, notebook_id: uuid.UUID | None, params: dict, *,
55
- job: Job | None = None, what: str = "quiz") -> Material:
 
56
  """The source a job's params name: posts (`document_ids`, a notebook's, the archive's or a single post), chats
57
  (`chat_ids`), or both together. With `chat_ids` and no posts it reads only the chats; with neither it reads the whole
58
  notebook. Raises NothingToDo when there is nothing to read."""
@@ -65,7 +89,11 @@ def load_material(db: Session, workspace_id: uuid.UUID, notebook_id: uuid.UUID |
65
  docs = workspace_docs(db, workspace_id, picked)
66
  elif not chat_ids and nb:
67
  docs = notebook_docs(db, nb.id)
68
- docs = docs[:MAX_POSTS]
 
 
 
 
69
 
70
  chats: list[Chat] = []
71
  chat_text: list[str] = []
@@ -84,8 +112,9 @@ def load_material(db: Session, workspace_id: uuid.UUID, notebook_id: uuid.UUID |
84
  if docs:
85
  corpus = Corpus(workspace_id)
86
  if job:
87
- update_job(db, job, progress=0.1, message=f"Reading {len(docs)} posts")
88
- text = passages_for(corpus, docs)
 
89
  tools = tools_for(corpus, docs)
90
  by_path = {d.path: d for d in docs}
91
 
@@ -110,4 +139,5 @@ def load_material(db: Session, workspace_id: uuid.UUID, notebook_id: uuid.UUID |
110
  if chat_text:
111
  source["chat_ids"] = [str(c.id) for c in chats]
112
  source["count"] = len(docs) + (len(chats) if chat_text else 0)
113
- return Material(title=title, text=text + chat_text, docs=docs, refs=refs, tools=tools, source=source)
 
 
9
  from sqlalchemy import select
10
  from sqlalchemy.orm import Session
11
 
12
+ from app.config import settings
13
  from app.corpus import Corpus
14
+ from app.models import Chat, Document, Job, Message, Notebook, Workspace
15
  from app.pipeline.generate import NothingToDo
16
  from app.pipeline.passages import ScopedTools, notebook_docs, passages_for, tools_for, verify_refs, workspace_docs
17
  from app.services.jobs import update_job
18
+ from app.services.plans import effective_plan
19
 
 
20
  CHAT_BUDGET = 60_000 # characters of chat transcripts
21
 
22
 
23
+ def max_posts() -> int:
24
+ """Posts a report or infographic reads, and the most a quiz or flashcard set reads straight from the posts (ARTIFACT_MAX_POSTS);
25
+ the passage budget is shared between them."""
26
+ return max(settings.artifact_max_posts, 1)
27
+
28
+
29
+ def use_ideas(docs: list[Document], *, large_ok: bool, ideas_always: bool) -> bool:
30
+ """Read the posts through their stored ideas instead of their text? Only when most of them have ideas stored (the rest are
31
+ represented by a short opening). A selection above max_posts() (quiz and flashcards) always, when it can; a flashcard set
32
+ (`ideas_always`) whatever the size. Otherwise, or with too few ideas stored, the posts' text is read as it always was."""
33
+ from app.pipeline.idea_pool import has_ideas
34
+
35
+ if not docs or not (large_ok or ideas_always):
36
+ return False
37
+ if len(docs) > max_posts() or ideas_always:
38
+ return has_ideas(docs) * 2 >= len(docs)
39
+ return False
40
+
41
+
42
  @dataclass
43
  class Material:
44
  title: str # the notebook, the post, or the first chat's title
 
48
  # Turns the model's {path, line_start, line_end} items into verified refs with their quote. Chats give none.
49
  refs: Callable[[list | None], list[dict]] = lambda _items: []
50
  tools: ScopedTools | None = None # reads back cited lines (posts only)
51
+ # "passages": `text` holds the posts' passages. "ideas": more posts than max_posts(), so `text` holds only chat transcripts and
52
+ # the posts are read through their stored ideas (pipeline/idea_pool.py).
53
+ mode: str = "passages"
54
 
55
  @property
56
  def is_chat(self) -> bool:
 
75
 
76
 
77
  def load_material(db: Session, workspace_id: uuid.UUID, notebook_id: uuid.UUID | None, params: dict, *,
78
+ job: Job | None = None, what: str = "quiz", large_ok: bool = False,
79
+ ideas_always: bool = False, passage_budget: int | None = None) -> Material:
80
  """The source a job's params name: posts (`document_ids`, a notebook's, the archive's or a single post), chats
81
  (`chat_ids`), or both together. With `chat_ids` and no posts it reads only the chats; with neither it reads the whole
82
  notebook. Raises NothingToDo when there is nothing to read."""
 
89
  docs = workspace_docs(db, workspace_id, picked)
90
  elif not chat_ids and nb:
91
  docs = notebook_docs(db, nb.id)
92
+ # Quiz and flashcards take as many posts as the plan indexes: their ideas are sampled. The rest read max_posts().
93
+ docs = docs[:effective_plan(db, db.get(Workspace, workspace_id)).indexed_posts] if large_ok else docs[:max_posts()]
94
+ ideas_mode = use_ideas(docs, large_ok=large_ok, ideas_always=ideas_always)
95
+ if large_ok and not ideas_mode:
96
+ docs = docs[:max_posts()] # their ideas are not stored yet: read the first posts' text, as a small selection is
97
 
98
  chats: list[Chat] = []
99
  chat_text: list[str] = []
 
112
  if docs:
113
  corpus = Corpus(workspace_id)
114
  if job:
115
+ update_job(db, job, progress=0.1, message=f"Gathering the key ideas of {len(docs)} posts" if ideas_mode
116
+ else f"Reading {len(docs)} posts")
117
+ text = [] if ideas_mode else passages_for(corpus, docs, budget_chars=passage_budget)
118
  tools = tools_for(corpus, docs)
119
  by_path = {d.path: d for d in docs}
120
 
 
139
  if chat_text:
140
  source["chat_ids"] = [str(c.id) for c in chats]
141
  source["count"] = len(docs) + (len(chats) if chat_text else 0)
142
+ return Material(title=title, text=text + chat_text, docs=docs, refs=refs, tools=tools, source=source,
143
+ mode="ideas" if ideas_mode else "passages")
backend/app/pipeline/media.py CHANGED
@@ -17,7 +17,7 @@ from app.pipeline.passages import notebook_docs, passages_for, tools_for, verify
17
  from app.pipeline.report import SAME_LANGUAGE
18
  from app.services import tts
19
  from app.services.jobs import record_usage, update_job
20
- from app.services.renderer import PermanentJobError, brand_props, request_render
21
  from app.services.storage import keys, storage
22
 
23
 
@@ -160,14 +160,13 @@ def make_audiogram(db: Session, job: Job, artifact: Artifact) -> None:
160
  source = db.get(Artifact, uuid.UUID(job.params["audio_artifact_id"]))
161
  if not source or source.type != "audio_overview" or not source.storage_key:
162
  raise PermanentJobError("Pick a finished audio overview for the audiogram.")
163
- workspace = db.get(Workspace, artifact.workspace_id)
164
  content = source.content_json or {}
165
  duration = float(content.get("duration_s") or 30)
166
  segments = [{"speaker": s["speaker"], "text": s["text"], "startMs": int(s["start"] * 1000),
167
  "endMs": int(s["end"] * 1000)} for s in content.get("segments", [])]
168
  props = {"title": (content.get("title") or "Audio overview")[:140],
169
  "audioUrl": storage.presign_get(source.storage_key, ttl=6 * 3600),
170
- "durationS": round(min(duration, 600), 2), "segments": segments, "brand": brand_props(workspace)}
171
  artifact.content_json = {**(artifact.content_json or {}), "style": "audiogram", "composition": "AudiogramSquare",
172
  "audio_artifact_id": str(source.id), "duration_s": props["durationS"]}
173
  artifact.status = "rendering"
@@ -184,12 +183,11 @@ def make_audiogram(db: Session, job: Job, artifact: Artifact) -> None:
184
  def author_line(db: Session, workspace_id: uuid.UUID) -> str:
185
  ws = db.get(Workspace, workspace_id)
186
  owner = db.get(User, ws.owner_id) if ws else None
187
- return (ws.brand_json or {}).get("name") or (owner.name if owner and owner.name else "") or "Notestack"
188
 
189
 
190
  def make_quote_card(db: Session, job: Job, artifact: Artifact) -> None:
191
  content = artifact.content_json or {}
192
- workspace = db.get(Workspace, artifact.workspace_id)
193
  if not content.get("quote"):
194
  docs = artifact_docs(db, artifact)
195
  if not docs:
@@ -212,8 +210,7 @@ def make_quote_card(db: Session, job: Job, artifact: Artifact) -> None:
212
  if doc and doc.source_id:
213
  src = db.get(Source, doc.source_id)
214
  publication = src.title if src else None
215
- props = {"quote": content["quote"][:380], "author": author_line(db, artifact.workspace_id),
216
- "brand": brand_props(workspace)}
217
  if publication or (doc and doc.title):
218
  props["publication"] = (publication or doc.title)[:120]
219
  artifact.content_json = {**content, "composition": "QuoteCard"}
@@ -226,10 +223,8 @@ def render_carousel(db: Session, job: Job, artifact: Artifact) -> None:
226
  slides = (artifact.content_json or {}).get("slides") or []
227
  if not slides:
228
  raise PermanentJobError("This carousel has no slides.")
229
- workspace = db.get(Workspace, artifact.workspace_id)
230
- brand = brand_props(workspace)
231
  stills = [{"heading": s.get("heading", "")[:120], "body": s.get("body", "")[:500], "index": i + 1,
232
- "total": len(slides), "brand": brand} for i, s in enumerate(slides)]
233
  artifact.status = "rendering"
234
  db.commit()
235
  request_render(job, artifact, "CarouselSlide", stills=stills)
 
17
  from app.pipeline.report import SAME_LANGUAGE
18
  from app.services import tts
19
  from app.services.jobs import record_usage, update_job
20
+ from app.services.renderer import PermanentJobError, request_render
21
  from app.services.storage import keys, storage
22
 
23
 
 
160
  source = db.get(Artifact, uuid.UUID(job.params["audio_artifact_id"]))
161
  if not source or source.type != "audio_overview" or not source.storage_key:
162
  raise PermanentJobError("Pick a finished audio overview for the audiogram.")
 
163
  content = source.content_json or {}
164
  duration = float(content.get("duration_s") or 30)
165
  segments = [{"speaker": s["speaker"], "text": s["text"], "startMs": int(s["start"] * 1000),
166
  "endMs": int(s["end"] * 1000)} for s in content.get("segments", [])]
167
  props = {"title": (content.get("title") or "Audio overview")[:140],
168
  "audioUrl": storage.presign_get(source.storage_key, ttl=6 * 3600),
169
+ "durationS": round(min(duration, 600), 2), "segments": segments }
170
  artifact.content_json = {**(artifact.content_json or {}), "style": "audiogram", "composition": "AudiogramSquare",
171
  "audio_artifact_id": str(source.id), "duration_s": props["durationS"]}
172
  artifact.status = "rendering"
 
183
  def author_line(db: Session, workspace_id: uuid.UUID) -> str:
184
  ws = db.get(Workspace, workspace_id)
185
  owner = db.get(User, ws.owner_id) if ws else None
186
+ return (owner.name if owner and owner.name else "") or "Notestack"
187
 
188
 
189
  def make_quote_card(db: Session, job: Job, artifact: Artifact) -> None:
190
  content = artifact.content_json or {}
 
191
  if not content.get("quote"):
192
  docs = artifact_docs(db, artifact)
193
  if not docs:
 
210
  if doc and doc.source_id:
211
  src = db.get(Source, doc.source_id)
212
  publication = src.title if src else None
213
+ props = {"quote": content["quote"][:380], "author": author_line(db, artifact.workspace_id)}
 
214
  if publication or (doc and doc.title):
215
  props["publication"] = (publication or doc.title)[:120]
216
  artifact.content_json = {**content, "composition": "QuoteCard"}
 
223
  slides = (artifact.content_json or {}).get("slides") or []
224
  if not slides:
225
  raise PermanentJobError("This carousel has no slides.")
 
 
226
  stills = [{"heading": s.get("heading", "")[:120], "body": s.get("body", "")[:500], "index": i + 1,
227
+ "total": len(slides)} for i, s in enumerate(slides)]
228
  artifact.status = "rendering"
229
  db.commit()
230
  request_render(job, artifact, "CarouselSlide", stills=stills)
backend/app/pipeline/passages.py CHANGED
@@ -6,10 +6,11 @@ from types import SimpleNamespace
6
  from sqlalchemy import select
7
  from sqlalchemy.orm import Session
8
 
 
9
  from app.corpus import Corpus, CorpusError, ScopedTools
10
  from app.models import Document, NotebookDocument, VoiceProfile
11
 
12
- DEFAULT_BUDGET = 120_000 # characters across all passages; GLM-5.3 has room, this keeps cost sane
13
 
14
 
15
  def notebook_docs(db: Session, notebook_id: uuid.UUID) -> list[Document]:
@@ -28,10 +29,26 @@ def workspace_docs(db: Session, workspace_id: uuid.UUID, ids: list[uuid.UUID] |
28
  return list(db.scalars(q.order_by(Document.published_at.desc())).all())
29
 
30
 
31
- def passages_for(corpus: Corpus, docs: list[Document], budget_chars: int = DEFAULT_BUDGET) -> list[str]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  """One string per post: a header line then `n| text` numbered lines (same numbering as read()).
33
  The budget is split evenly, so one long post cannot crowd out the rest."""
34
  corpus.sync()
 
35
  per_doc = max(budget_chars // max(len(docs), 1), 1500)
36
  out = []
37
  for d in docs:
@@ -40,8 +57,9 @@ def passages_for(corpus: Corpus, docs: list[Document], budget_chars: int = DEFAU
40
  except CorpusError:
41
  continue
42
  body, used = [], 0
 
43
  for n, line in enumerate(lines, start=1):
44
- if not line.strip():
45
  continue
46
  entry = f"{n}| {line}"
47
  if used + len(entry) > per_doc:
 
6
  from sqlalchemy import select
7
  from sqlalchemy.orm import Session
8
 
9
+ from app.config import settings
10
  from app.corpus import Corpus, CorpusError, ScopedTools
11
  from app.models import Document, NotebookDocument, VoiceProfile
12
 
13
+ DEFAULT_BUDGET = 120_000 # the default of ARTIFACT_PASSAGE_BUDGET: characters across all passages; this keeps cost sane
14
 
15
 
16
  def notebook_docs(db: Session, notebook_id: uuid.UUID) -> list[Document]:
 
29
  return list(db.scalars(q.order_by(Document.published_at.desc())).all())
30
 
31
 
32
+ def _body_start(lines: list[str]) -> int:
33
+ """The index of the first line after the post file's header (the `---` block of title, url, date and source, and the repeated
34
+ `# Title`): none of it is worth sending, the `FILE path (title)` line already names the post."""
35
+ i = 0
36
+ if lines and lines[0].strip() == "---":
37
+ end = next((k for k in range(1, min(len(lines), 12)) if lines[k].strip() == "---"), None)
38
+ if end is not None:
39
+ i = end + 1
40
+ while i < len(lines) and not lines[i].strip():
41
+ i += 1
42
+ if i < len(lines) and lines[i].startswith("# "):
43
+ i += 1
44
+ return i
45
+
46
+
47
+ def passages_for(corpus: Corpus, docs: list[Document], budget_chars: int | None = None) -> list[str]:
48
  """One string per post: a header line then `n| text` numbered lines (same numbering as read()).
49
  The budget is split evenly, so one long post cannot crowd out the rest."""
50
  corpus.sync()
51
+ budget_chars = budget_chars or settings.artifact_passage_budget
52
  per_doc = max(budget_chars // max(len(docs), 1), 1500)
53
  out = []
54
  for d in docs:
 
57
  except CorpusError:
58
  continue
59
  body, used = [], 0
60
+ first = _body_start(lines) # lines keep their number in the file, so a cited line is still the right one
61
  for n, line in enumerate(lines, start=1):
62
+ if n <= first or not line.strip():
63
  continue
64
  entry = f"{n}| {line}"
65
  if used + len(entry) > per_doc:
backend/app/pipeline/quiz.py CHANGED
@@ -4,10 +4,12 @@ import uuid
4
 
5
  from sqlalchemy.orm import Session
6
 
 
7
  from app.llm import run
8
  from app.llm.provider import fast_lm
9
  from app.llm.signatures import GenerateQuiz
10
  from app.models import Artifact, Job
 
11
  from app.pipeline.generate import NothingToDo
12
  from app.pipeline.material import Material, load_material
13
  from app.services.jobs import update_job
@@ -51,12 +53,14 @@ def make_questions(db: Session, workspace_id: uuid.UUID, material: Material, *,
51
  language: str = SAME_LANGUAGE, job: Job | None = None) -> list[dict]:
52
  """Playable questions about the material (fewer than `count` when the model gave unusable ones)."""
53
  types = [t for t in types or [] if t in QUESTION_TYPES] or list(QUESTION_TYPES[:2])
54
- out = run.predict(GenerateQuiz, db=db, workspace_id=workspace_id, job=job, title=material.title,
55
- material=material.text, topic=topic or "(none)", difficulty=difficulty, question_types=types,
56
- count=count, language=language.strip() or SAME_LANGUAGE, lm=fast_lm())
57
- cleaned = [c for q in out.get("questions") or [] if (c := _clean(q, material.refs))]
58
  # A multiple select with a single right answer was turned into a multiple choice: fine when that was asked for too.
59
  allowed = set(types) | ({"multiple_choice"} if "multiple_select" in types else set())
 
 
 
 
 
 
60
  return [c for c in cleaned if c["type"] in allowed][:count]
61
 
62
 
@@ -66,7 +70,8 @@ def build_quiz(db: Session, job: Job, artifact: Artifact) -> dict:
66
  count = QUESTION_COUNTS.get(params.get("count") or "standard", QUESTION_COUNTS["standard"])
67
  difficulty = params.get("difficulty") if params.get("difficulty") in DIFFICULTIES else "medium"
68
  topic = (params.get("topic") or "").strip()
69
- material = load_material(db, artifact.workspace_id, artifact.notebook_id, params, job=job, what="quiz")
 
70
  update_job(db, job, progress=0.4, message="Writing the questions")
71
  questions = make_questions(db, artifact.workspace_id, material, topic=topic, difficulty=difficulty, types=types,
72
  count=count, language=params.get("language") or SAME_LANGUAGE, job=job)
 
4
 
5
  from sqlalchemy.orm import Session
6
 
7
+ from app.config import settings
8
  from app.llm import run
9
  from app.llm.provider import fast_lm
10
  from app.llm.signatures import GenerateQuiz
11
  from app.models import Artifact, Job
12
+ from app.pipeline import idea_pool
13
  from app.pipeline.generate import NothingToDo
14
  from app.pipeline.material import Material, load_material
15
  from app.services.jobs import update_job
 
53
  language: str = SAME_LANGUAGE, job: Job | None = None) -> list[dict]:
54
  """Playable questions about the material (fewer than `count` when the model gave unusable ones)."""
55
  types = [t for t in types or [] if t in QUESTION_TYPES] or list(QUESTION_TYPES[:2])
 
 
 
 
56
  # A multiple select with a single right answer was turned into a multiple choice: fine when that was asked for too.
57
  allowed = set(types) | ({"multiple_choice"} if "multiple_select" in types else set())
58
+ inputs = dict(title=material.title, topic=topic or "(none)", difficulty=difficulty, question_types=types,
59
+ language=language.strip() or SAME_LANGUAGE)
60
+ # Many posts: one call over a random sample of the ideas already extracted from them (idea_pool), not the posts' text.
61
+ text = idea_pool.sample(db, workspace_id, material.docs, topic, chats=material.text) if material.mode == "ideas" else material.text
62
+ out = run.predict(GenerateQuiz, db=db, workspace_id=workspace_id, job=job, material=text, count=count, lm=fast_lm(), **inputs)
63
+ cleaned = [c for q in out.get("questions") or [] if (c := _clean(q, material.refs))]
64
  return [c for c in cleaned if c["type"] in allowed][:count]
65
 
66
 
 
70
  count = QUESTION_COUNTS.get(params.get("count") or "standard", QUESTION_COUNTS["standard"])
71
  difficulty = params.get("difficulty") if params.get("difficulty") in DIFFICULTIES else "medium"
72
  topic = (params.get("topic") or "").strip()
73
+ material = load_material(db, artifact.workspace_id, artifact.notebook_id, params, job=job, what="quiz", large_ok=True,
74
+ passage_budget=settings.artifact_quiz_passage_budget)
75
  update_job(db, job, progress=0.4, message="Writing the questions")
76
  questions = make_questions(db, artifact.workspace_id, material, topic=topic, difficulty=difficulty, types=types,
77
  count=count, language=params.get("language") or SAME_LANGUAGE, job=job)
backend/app/pipeline/report.py CHANGED
@@ -1,42 +1,46 @@
1
  """Reports: a written document (or an interactive one, with visuals) made from a notebook's posts or chats.
2
 
3
  Every report is stored as `blocks`, even a plain document (a few prose blocks), so the same page can show, extend and
4
- share either kind. Each block is {"id", "type", ...}: `prose` has Markdown `text` whose [n] markers point into the
5
- report's `citations`."""
 
6
 
7
  import logging
8
  import re
 
9
  import uuid
 
10
 
 
11
  from sqlalchemy.orm import Session
12
 
 
 
13
  from app.infographics.themes import theme_id
14
  from app.llm import run
15
- from app.llm.provider import fast_lm, report_lm
16
  from app.llm.signatures import (
17
  PlanInteractiveReport,
18
- SuggestEmbeds,
19
  WriteReport,
20
  WriteReportSection,
 
 
21
  )
22
- from app.models import Artifact, Job
 
23
  from app.pipeline.flashcards import make_cards
24
  from app.pipeline.generate import NothingToDo, mind_map_content
25
  from app.pipeline.material import Material, load_material
26
- from app.pipeline.passages import as_citations
27
  from app.pipeline.quiz import make_questions
28
- from app.pipeline.research import verify_citations
29
  from app.services.artifacts import start_artifact
30
  from app.services.jobs import update_job
31
  from app.services.renderer import PermanentJobError
32
  from app.services.report_templates import default_instructions
33
 
34
- SUGGEST_KINDS = ("mind_map", "flashcards", "quiz", "infographic") # what the AI suggests for a document report
35
- EMBED_KINDS = SUGGEST_KINDS # every visual a reader can add with one click
36
  MAX_PLANNED_VISUALS = 4 # suggestions an interactive report's plan can make
37
  MAX_SECTIONS = 8
38
  SAME_LANGUAGE = "the same language as the sources" # no language picked: write as the material is written
39
- MAX_SUGGESTIONS = 4
40
  BLOCK_CARDS, BLOCK_QUESTIONS = 8, 5 # a visual inside a report is a taste of the full thing
41
 
42
  log = logging.getLogger(__name__)
@@ -46,7 +50,6 @@ MARKER = re.compile(r"\s*\[\d+\](?:\[\d+\])*")
46
 
47
  QUIZ_HEADING = re.compile(r"\b(quiz|test yourself|check your understanding|practice questions|review questions|self[- ]?check)\b", re.I)
48
  FLASHCARD_HEADING = re.compile(r"\bflash ?cards?\b", re.I)
49
- ASKED_FOR_VISUAL = re.compile(r"\b(quiz|flash ?cards?|practice questions)\b", re.I)
50
 
51
 
52
  def visual_kind(heading: str) -> str | None:
@@ -62,24 +65,6 @@ MOVED_BRIEF = {"quiz": "Check understanding of the key ideas in this report.",
62
  "flashcards": "Learn the key terms and facts from this report."}
63
 
64
 
65
- def pull_visual_sections(blocks: list[dict], kinds: list[str]) -> tuple[list[dict], list[dict]]:
66
- """A section written as a quiz or flashcards is not part of the text: it is taken out and offered as a suggestion
67
- after the section before it, for the reader to add. Never takes out every section."""
68
- kept: list[dict] = []
69
- moved: list[tuple[int, str]] = []
70
- for b in blocks:
71
- first = b["text"].split("\n", 1)[0] if b["type"] == "prose" else ""
72
- kind = visual_kind(first.lstrip("# ").strip()) if first.startswith("#") else None
73
- if kind and kind in kinds and kept:
74
- moved.append((len(kept) - 1, kind))
75
- else:
76
- kept.append(b)
77
- number_blocks(kept)
78
- return kept, [{"id": f"s{i}", "after_block_id": kept[idx]["id"], "kind": kind, "brief": MOVED_BRIEF[kind],
79
- "why": "Offered here for you to add, not written into the text."}
80
- for i, (idx, kind) in enumerate(moved, start=1)]
81
-
82
-
83
  def merge_suggestions(*groups: list[dict]) -> list[dict]:
84
  """Suggestions from several places, one per section and kind, renumbered."""
85
  seen, out = set(), []
@@ -92,6 +77,36 @@ def merge_suggestions(*groups: list[dict]) -> list[dict]:
92
  return out[:MAX_PLANNED_VISUALS + 2]
93
 
94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  def split_prose(markdown: str) -> list[dict]:
96
  """Markdown cut into prose blocks at each "## " heading, so a block is one section and can be followed by a visual."""
97
  parts = re.split(r"(?m)^(?=## )", markdown.strip())
@@ -108,81 +123,29 @@ def number_blocks(blocks: list[dict]) -> list[dict]:
108
  return blocks
109
 
110
 
111
- def citation_rows(cites, material: Material) -> list[dict]:
112
- by_path = {d.path: d for d in material.docs}
113
- return [{"marker": c.marker, "path": c.path, "line_start": c.line_start, "line_end": c.line_end,
114
- "span": c.quote, "title": by_path[c.path].title, "url": by_path[c.path].url,
115
- "document_id": str(by_path[c.path].id)}
116
- for c in cites if c.path in by_path]
117
 
118
 
119
- def source_rows(material: Material) -> list[dict]:
120
- """Where the report came from, for the public page's Sources button: titles and the posts' own web addresses."""
121
- return [{"title": d.title, "url": d.url, "document_id": str(d.id)} for d in material.docs]
122
 
123
 
124
  def write_document(db: Session, workspace_id: uuid.UUID, job: Job | None, material: Material, instructions: str,
125
- language: str) -> tuple[str, str, list[dict]]:
126
- """(title, Markdown, citation rows) for a written report. Citations are checked against the posts' lines and
127
- renumbered 1..n; a chat has none, so any marker the model wrote is removed."""
128
  out = run.predict(WriteReport, db=db, workspace_id=workspace_id, job=job, lm=report_lm(), title=material.title,
129
  material=material.text, request=instructions, language=language)
130
  markdown = (out.get("markdown") or "").strip()
131
  if not markdown:
132
  raise NothingToDo("The report came back empty. Try again, or describe it differently.")
133
- if material.tools is None:
134
- return (out.get("report_title") or material.title).strip(), MARKER.sub("", markdown), []
135
- text, cites = verify_citations(material.tools, markdown, as_citations(out.get("citations")))
136
- return (out.get("report_title") or material.title).strip(), text, citation_rows(cites, material)
137
-
138
-
139
- def allowed_kinds(material_or_source: Material | dict) -> list[str]:
140
- """Which visuals fit this source: a mind map is drawn from posts' stored ideas, so a chat gets cards and a quiz."""
141
- has_posts = bool(material_or_source.docs) if isinstance(material_or_source, Material) \
142
- else bool(material_or_source.get("document_ids"))
143
- return [k for k in SUGGEST_KINDS if k != "mind_map" or has_posts]
144
-
145
-
146
- def suggest_embeds(db: Session, workspace_id: uuid.UUID, job: Job | None, blocks: list[dict],
147
- kinds: list[str]) -> list[dict]:
148
- """Up to MAX_SUGGESTIONS places for a visual: after which section, what kind, what it covers, why. Never fails
149
- the report: no suggestions is a fine answer."""
150
- sections = []
151
- for b in blocks:
152
- if b["type"] != "prose":
153
- continue
154
- first, _, rest = b["text"].partition("\n")
155
- heading = first.lstrip("# ").strip() if first.startswith("#") else ""
156
- start = re.sub(r"\s+", " ", rest or first)[:240]
157
- sections.append(f"{b['id']} | {heading or 'Introduction'}: {start}")
158
- if len(sections) < 2:
159
- return []
160
- try:
161
- out = run.predict(SuggestEmbeds, db=db, workspace_id=workspace_id, job=job, lm=fast_lm(), sections=sections,
162
- allowed_kinds=kinds)
163
- except Exception:
164
- return []
165
- ids = {b["id"] for b in blocks}
166
- taken: set[tuple[str, str]] = {(b.get("after") or "", b["type"]) for b in blocks if b["type"] != "prose"}
167
- found = []
168
- for s in out.get("suggestions") or []:
169
- key = (s.get("after_block_id"), s.get("kind"))
170
- if s.get("after_block_id") not in ids or s.get("kind") not in kinds or key in taken or not (s.get("brief") or "").strip():
171
- continue
172
- taken.add(key)
173
- found.append({"id": f"s{len(found) + 1}", "after_block_id": s["after_block_id"], "kind": s["kind"],
174
- "brief": s["brief"].strip()[:300], "why": (s.get("why") or "").strip()[:300]})
175
- return found[:MAX_SUGGESTIONS]
176
-
177
-
178
- def shift_markers(text: str, offset: int) -> str:
179
- """[n] markers renumbered to [n + offset], so sections written apart share one citation list."""
180
- return re.sub(r"\[(\d+)\]", lambda m: f"[{int(m.group(1)) + offset}]", text) if offset else text
181
 
182
 
183
  def write_interactive(db: Session, workspace_id: uuid.UUID, job: Job, material: Material, instructions: str,
184
- language: str) -> tuple[str, list[dict], list[dict], list[dict]] | None:
185
- """(title, blocks, citation rows, suggestions) for an interactive report: a plan of sections, each written on its
186
  own (in parallel). The plan also says where a visual would fit; those spots come back as suggestions, and nothing
187
  is built until the reader clicks Add. None when planning fails, so the caller can write a plain document."""
188
  kinds = [k for k in EMBED_KINDS if k != "mind_map" or not material.is_chat]
@@ -219,21 +182,13 @@ def write_interactive(db: Session, workspace_id: uuid.UUID, job: Job, material:
219
  db=db, workspace_id=workspace_id, lm=report_lm())
220
 
221
  blocks: list[dict] = []
222
- citations: list[dict] = []
223
  suggestions: list[dict] = []
224
  used_kinds: set[str] = set()
225
  anchor: dict[int, str] = {} # planned section number -> the block it became
226
  for n, (sec, out) in enumerate(zip(sections, written, strict=True)):
227
- body = ((out or {}).get("markdown") or "").strip()
228
  if not body:
229
  continue
230
- if material.tools is None:
231
- body = MARKER.sub("", body)
232
- else:
233
- body, cites = verify_citations(material.tools, body, as_citations((out or {}).get("citations")))
234
- offset = len(citations)
235
- body = shift_markers(body, offset)
236
- citations += [{**c, "marker": c["marker"] + offset} for c in citation_rows(cites, material)]
237
  block = {"type": "prose", "text": f"## {sec['heading'].strip()}\n\n{body}"}
238
  blocks.append(block)
239
  number_blocks(blocks)
@@ -252,7 +207,275 @@ def write_interactive(db: Session, workspace_id: uuid.UUID, job: Job, material:
252
  suggestions.append({"id": "", "after_block_id": after, "kind": kind,
253
  "brief": (sec.get("brief") or MOVED_BRIEF[kind]).strip()[:300],
254
  "why": "Offered here for you to add, not written into the text."})
255
- return report_title, blocks, citations, merge_suggestions(suggestions)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
 
257
 
258
  def build_report(db: Session, job: Job, artifact: Artifact) -> dict:
@@ -260,30 +483,40 @@ def build_report(db: Session, job: Job, artifact: Artifact) -> dict:
260
  topic = (params.get("topic") or "").strip()
261
  language = (params.get("language") or "").strip() or SAME_LANGUAGE
262
  report_format = "interactive" if params.get("report_format") == "interactive" else "document"
263
- material = load_material(db, artifact.workspace_id, artifact.notebook_id, params, job=job, what="report")
 
 
 
264
  instructions = (params.get("instructions") or "").strip() or default_instructions(
265
- report_format, params.get("template_id"), topic or material.title)
266
-
267
- made = write_interactive(db, artifact.workspace_id, job, material, instructions, language) \
268
- if report_format == "interactive" else None
269
- if made:
270
- title, blocks, citations, suggestions = made
 
 
 
 
271
  else:
272
- # A document, or an interactive report whose plan came back unusable: write it as one piece. Never a failure.
273
- report_format = "document"
274
- update_job(db, job, progress=0.3, message="Writing the report")
275
- title, markdown, citations = write_document(db, artifact.workspace_id, job, material, instructions, language)
276
- blocks = number_blocks(split_prose(markdown))
277
- kinds = allowed_kinds(material)
278
- pulled: list[dict] = []
279
- if not ASKED_FOR_VISUAL.search(instructions): # a quiz section the reader did not ask for becomes a suggestion
280
- blocks, pulled = pull_visual_sections(blocks, kinds)
281
- update_job(db, job, progress=0.9, message="Finding places for visuals")
282
- suggestions = merge_suggestions(pulled, suggest_embeds(db, artifact.workspace_id, job, blocks, kinds))
 
 
 
283
  artifact.content_json = {
284
  "title": title, "format": report_format, "template_id": params.get("template_id"),
285
- "instructions": instructions, "language": language, "topic": topic, "source": material.source,
286
- "blocks": blocks, "suggestions": suggestions, "citations": citations, "sources": source_rows(material),
287
  }
288
  artifact.status = "ready"
289
  db.commit()
@@ -308,7 +541,7 @@ def copy_block(kind: str, existing: Artifact) -> dict:
308
  return {"title": c.get("title"), "questions": c["questions"]}
309
  if kind == "flashcards" and c.get("cards"):
310
  return {"title": c.get("title"), "cards": c["cards"]}
311
- if kind == "infographic" and existing.storage_key: # the picture itself is shared, not copied
312
  return {"title": c.get("title"), "artifact_id": str(existing.id), "theme": c.get("theme")}
313
  raise NothingToDo("That one has nothing to add.")
314
 
@@ -381,6 +614,7 @@ def handle_report_block(db: Session, job: Job) -> dict:
381
  block = copy_block(kind, existing)
382
  else:
383
  block = make_block(db, job, report, kind, (p.get("brief") or "").strip(), p.get("theme"))
 
384
  except NothingToDo as exc:
385
  raise PermanentJobError(str(exc)) from exc
386
  report.content_json = insert_block(report.content_json or {}, {"type": kind, **block}, p.get("after_block_id"),
@@ -389,8 +623,44 @@ def handle_report_block(db: Session, job: Job) -> dict:
389
  return {"artifact_id": str(report.id)}
390
 
391
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
392
  def remove_block(content: dict, block_id: str) -> dict | None:
393
- """The content without that visual (prose cannot be removed), or None when there is no such visual."""
 
394
  blocks = list(content.get("blocks") or [])
395
- keep = [b for b in blocks if not (b["id"] == block_id and b["type"] != "prose")]
396
- return None if len(keep) == len(blocks) else {**content, "blocks": keep}
 
 
 
 
 
 
 
 
 
1
  """Reports: a written document (or an interactive one, with visuals) made from a notebook's posts or chats.
2
 
3
  Every report is stored as `blocks`, even a plain document (a few prose blocks), so the same page can show, extend and
4
+ share either kind. Each block is {"id", "type", ...}: `prose` has Markdown `text`. A report cites nothing: it carries the
5
+ list of the posts it was made from (`sources`) and the instructions it was written to (`instructions`), which the page shows
6
+ together in a drop-down. (`citations` stays in the content, empty, for the older reports that have them.)"""
7
 
8
  import logging
9
  import re
10
+ import time
11
  import uuid
12
+ from dataclasses import dataclass, field
13
 
14
+ from sqlalchemy import select
15
  from sqlalchemy.orm import Session
16
 
17
+ from app.config import settings
18
+ from app.corpus import Corpus
19
  from app.infographics.themes import theme_id
20
  from app.llm import run
21
+ from app.llm.provider import report_lm, report_section_lm
22
  from app.llm.signatures import (
23
  PlanInteractiveReport,
 
24
  WriteReport,
25
  WriteReportSection,
26
+ WriteThemedReport,
27
+ WriteThemedSection,
28
  )
29
+ from app.models import Artifact, Document, Job, Notebook
30
+ from app.pipeline import report_blueprint, report_evidence, report_themes
31
  from app.pipeline.flashcards import make_cards
32
  from app.pipeline.generate import NothingToDo, mind_map_content
33
  from app.pipeline.material import Material, load_material
 
34
  from app.pipeline.quiz import make_questions
 
35
  from app.services.artifacts import start_artifact
36
  from app.services.jobs import update_job
37
  from app.services.renderer import PermanentJobError
38
  from app.services.report_templates import default_instructions
39
 
40
+ EMBED_KINDS = ("mind_map", "flashcards", "quiz", "infographic") # every visual a reader can add with one click
 
41
  MAX_PLANNED_VISUALS = 4 # suggestions an interactive report's plan can make
42
  MAX_SECTIONS = 8
43
  SAME_LANGUAGE = "the same language as the sources" # no language picked: write as the material is written
 
44
  BLOCK_CARDS, BLOCK_QUESTIONS = 8, 5 # a visual inside a report is a taste of the full thing
45
 
46
  log = logging.getLogger(__name__)
 
50
 
51
  QUIZ_HEADING = re.compile(r"\b(quiz|test yourself|check your understanding|practice questions|review questions|self[- ]?check)\b", re.I)
52
  FLASHCARD_HEADING = re.compile(r"\bflash ?cards?\b", re.I)
 
53
 
54
 
55
  def visual_kind(heading: str) -> str | None:
 
65
  "flashcards": "Learn the key terms and facts from this report."}
66
 
67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  def merge_suggestions(*groups: list[dict]) -> list[dict]:
69
  """Suggestions from several places, one per section and kind, renumbered."""
70
  seen, out = set(), []
 
77
  return out[:MAX_PLANNED_VISUALS + 2]
78
 
79
 
80
+ FALLBACK_BRIEFS = {
81
+ "infographic": ("A one page picture of the main ideas of this report.", "A quick picture of the whole report."),
82
+ "flashcards": (MOVED_BRIEF["flashcards"], "Cards to learn the key terms and facts."),
83
+ "quiz": (MOVED_BRIEF["quiz"], "A short quiz to check understanding."),
84
+ "mind_map": ("How the main ideas of this report connect.", "See how the ideas fit together."),
85
+ }
86
+ MIN_SUGGESTIONS = 3 # a report always offers at least this many visuals (fewer only when fewer kinds are allowed)
87
+
88
+
89
+ def fill_suggestions(suggestions: list[dict], blocks: list[dict], kinds: list[str]) -> list[dict]:
90
+ """An interactive report always offers something to add (a document never does). Whatever the plan or the writer suggested is
91
+ kept; then an infographic is added if there is none, and flashcards, a quiz and a mind map in turn until there are
92
+ MIN_SUGGESTIONS, each after a different section. Plain code, so it never depends on the model remembering to suggest."""
93
+ prose = [b for b in blocks if b.get("type") == "prose"]
94
+ if not prose:
95
+ return suggestions
96
+ inner = prose[1:-1] or prose # not the introduction or the summary, when there are others
97
+ place = {"infographic": inner[-1]["id"], "flashcards": inner[0]["id"], "quiz": inner[len(inner) // 2]["id"],
98
+ "mind_map": prose[0]["id"]}
99
+ out = list(suggestions)
100
+ have = {s["kind"] for s in out}
101
+ for kind in ("infographic", "flashcards", "quiz", "mind_map"):
102
+ if kind in kinds and kind not in have and (kind == "infographic" or len(out) < MIN_SUGGESTIONS):
103
+ brief, why = FALLBACK_BRIEFS[kind]
104
+ out.append({"id": "", "after_block_id": place[kind], "kind": kind, "brief": brief, "why": why})
105
+ have.add(kind)
106
+ log.info("report: no %s suggested by the plan, offering one after %s", kind, place[kind])
107
+ return out
108
+
109
+
110
  def split_prose(markdown: str) -> list[dict]:
111
  """Markdown cut into prose blocks at each "## " heading, so a block is one section and can be followed by a visual."""
112
  parts = re.split(r"(?m)^(?=## )", markdown.strip())
 
123
  return blocks
124
 
125
 
126
+ def source_rows(material: Material) -> list[dict]:
127
+ """The posts a report was made from, for its Sources list: titles and the posts' own web addresses."""
128
+ return source_rows_for(material.docs)
 
 
 
129
 
130
 
131
+ def source_rows_for(docs: list[Document]) -> list[dict]:
132
+ return [{"title": d.title, "url": d.url, "document_id": str(d.id)} for d in docs]
 
133
 
134
 
135
  def write_document(db: Session, workspace_id: uuid.UUID, job: Job | None, material: Material, instructions: str,
136
+ language: str) -> tuple[str, str]:
137
+ """(title, Markdown) for a written report. Nothing is cited; a marker the model wrote anyway is removed."""
 
138
  out = run.predict(WriteReport, db=db, workspace_id=workspace_id, job=job, lm=report_lm(), title=material.title,
139
  material=material.text, request=instructions, language=language)
140
  markdown = (out.get("markdown") or "").strip()
141
  if not markdown:
142
  raise NothingToDo("The report came back empty. Try again, or describe it differently.")
143
+ return (out.get("report_title") or material.title).strip(), MARKER.sub("", markdown)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
 
145
 
146
  def write_interactive(db: Session, workspace_id: uuid.UUID, job: Job, material: Material, instructions: str,
147
+ language: str) -> tuple[str, list[dict], list[dict]] | None:
148
+ """(title, blocks, suggestions) for an interactive report: a plan of sections, each written on its
149
  own (in parallel). The plan also says where a visual would fit; those spots come back as suggestions, and nothing
150
  is built until the reader clicks Add. None when planning fails, so the caller can write a plain document."""
151
  kinds = [k for k in EMBED_KINDS if k != "mind_map" or not material.is_chat]
 
182
  db=db, workspace_id=workspace_id, lm=report_lm())
183
 
184
  blocks: list[dict] = []
 
185
  suggestions: list[dict] = []
186
  used_kinds: set[str] = set()
187
  anchor: dict[int, str] = {} # planned section number -> the block it became
188
  for n, (sec, out) in enumerate(zip(sections, written, strict=True)):
189
+ body = MARKER.sub("", ((out or {}).get("markdown") or "").strip())
190
  if not body:
191
  continue
 
 
 
 
 
 
 
192
  block = {"type": "prose", "text": f"## {sec['heading'].strip()}\n\n{body}"}
193
  blocks.append(block)
194
  number_blocks(blocks)
 
207
  suggestions.append({"id": "", "after_block_id": after, "kind": kind,
208
  "brief": (sec.get("brief") or MOVED_BRIEF[kind]).strip()[:300],
209
  "why": "Offered here for you to add, not written into the text."})
210
+ return report_title, blocks, merge_suggestions(fill_suggestions(suggestions, blocks, kinds))
211
+
212
+
213
+ # A report built around themes (report_themes.py, report_blueprint.py, report_evidence.py)
214
+
215
+ EVIDENCE_TOTAL = 100_000 # characters of evidence in all for a report written in one piece (about 25k tokens)
216
+ DIGEST_CHARS = 300 # of each written section, for the introduction and the summary that are written after them
217
+
218
+
219
+ @dataclass
220
+ class Themed:
221
+ title: str
222
+ blocks: list[dict]
223
+ suggestions: list[dict]
224
+ source: dict
225
+ sources: list[dict]
226
+ plan: dict
227
+ failed: list[str] = field(default_factory=list)
228
+
229
+
230
+ @dataclass
231
+ class ThemedRun:
232
+ """What the steps of a themed report share."""
233
+ db: Session
234
+ workspace_id: uuid.UUID
235
+ job: Job
236
+ docs: list[Document]
237
+ corpus: Corpus
238
+ bp: report_blueprint.Blueprint
239
+ themes: list[report_themes.Theme]
240
+ instructions: str
241
+ language: str
242
+ title: str
243
+
244
+ @property
245
+ def by_id(self) -> dict[str, report_themes.Theme]:
246
+ return {t.id: t for t in self.themes}
247
+
248
+ def names(self, s: report_blueprint.Section) -> list[str]:
249
+ by_id = self.by_id
250
+ return [by_id[i].name for i in s.theme_ids if i in by_id]
251
+
252
+ def sections_info(self) -> list[dict]:
253
+ return [{"heading": s.heading, "role": s.role, "depth": s.depth, "theme_ids": s.theme_ids, "must_cover": s.must_cover}
254
+ for s in self.bp.sections]
255
+
256
+ def finish(self, title: str, blocks: list[dict], suggestions: list[dict], used_posts: list[str],
257
+ failed: list[str]) -> Themed:
258
+ """The Sources list is every post the report was made from: the ones read as written and the ones summarised."""
259
+ used = [d for d in self.docs if d.path in set(used_posts)] or self.docs
260
+ log.info("report sources: job=%s posts_total=%d posts_used=%d", self.job.id, len(self.docs), len(used))
261
+ plan = {"planner": "themes", "storyline": self.bp.storyline, "posts_total": len(self.docs), "posts_used": len(used),
262
+ "themes": [report_themes.theme_summary(t) for t in self.themes], "sections": self.sections_info(),
263
+ "connections": self.bp.connections}
264
+ return Themed(title=title, blocks=blocks, suggestions=suggestions, plan=plan, failed=failed,
265
+ source={"kind": "posts", "document_ids": [str(d.id) for d in self.docs], "count": len(self.docs),
266
+ "read": len(used), "total": len(self.docs)},
267
+ sources=source_rows_for(used))
268
+
269
+
270
+ def themed_docs(db: Session, artifact: Artifact, params: dict) -> list[Document]:
271
+ """The posts a themed report would read, or [] when the report is made the usual way: chats, a small selection, or the
272
+ legacy planner."""
273
+ if settings.report_planner != "themes" or params.get("chat_ids"):
274
+ log.info("report route: legacy artifact=%s reason=%s", artifact.id,
275
+ "chats picked" if params.get("chat_ids") else f"REPORT_PLANNER={settings.report_planner}")
276
+ return []
277
+ docs = report_themes.select_docs(db, artifact.workspace_id, artifact.notebook_id, params)
278
+ if len(docs) <= report_themes.SMALL_SELECTION:
279
+ log.info("report route: legacy artifact=%s reason=small selection (%d posts, themes need more than %d)", artifact.id,
280
+ len(docs), report_themes.SMALL_SELECTION)
281
+ return []
282
+ return docs
283
+
284
+
285
+ def themed_title(db: Session, artifact: Artifact, params: dict, docs: list[Document]) -> str:
286
+ """What the report is about, named the way load_material names its source."""
287
+ nb = db.get(Notebook, artifact.notebook_id) if artifact.notebook_id else None
288
+ if nb and not params.get("document_ids"):
289
+ return nb.title
290
+ return docs[0].title + (f" + {len(docs) - 1} more" if len(docs) > 1 else "")
291
+
292
+
293
+ def _digest(markdown: str) -> str:
294
+ """The start of a written section, for the sections written after it to know what it says."""
295
+ text = MARKER.sub("", re.sub(r"(?m)^#+\s*", "", markdown))
296
+ return re.sub(r"\s+", " ", text).strip()[:DIGEST_CHARS]
297
+
298
+
299
+ def _section_header(n: int, s: report_blueprint.Section, names: list[str]) -> str:
300
+ return (f"SECTION {n}: {s.heading} (role: {s.role}, depth: {s.depth})\nSays: {s.brief}\n"
301
+ f"Must cover: {'; '.join(s.must_cover) or '(see what it says)'}\nThemes: {', '.join(names) or '(the whole report)'}")
302
+
303
+
304
+ def _section_input(ctx: ThemedRun, s: report_blueprint.Section, evidence: list[str]) -> dict:
305
+ """Everything WriteThemedSection is given. What every section shares comes first in the signature."""
306
+ must = list(s.must_cover)
307
+ if s.role == "summary" and ctx.names(s):
308
+ must.append(f"Restate the main points about: {', '.join(ctx.names(s))}")
309
+ return {"storyline": ctx.bp.storyline, "outline": report_blueprint.outline(ctx.bp),
310
+ "connections": report_blueprint.connection_lines(ctx.bp, ctx.themes), "request": ctx.instructions,
311
+ "language": ctx.language, "heading": s.heading, "role": s.role, "depth": s.depth, "brief": s.brief,
312
+ "must_cover": must, "covered_elsewhere": s.covered_elsewhere,
313
+ "bridges": report_blueprint.connection_lines(ctx.bp, ctx.themes, s.heading), "evidence": evidence}
314
+
315
+
316
+ def _write_sections(ctx: ThemedRun, inputs: list[dict]) -> list[dict | None]:
317
+ """WriteThemedSection for each input, together; one that failed or came back empty gets one more try (another temperature)."""
318
+ kw = {"db": ctx.db, "workspace_id": ctx.workspace_id}
319
+ started = time.monotonic()
320
+ outs = run.predict_many(WriteThemedSection, inputs, lm=report_section_lm(), **kw)
321
+ again = [k for k, o in enumerate(outs) if not ((o or {}).get("markdown") or "").strip()]
322
+ log.info("report sections written: asked=%d ok=%d failed=%d seconds=%.1f", len(inputs), len(inputs) - len(again), len(again),
323
+ time.monotonic() - started)
324
+ if again:
325
+ log.warning("report sections failed, trying again: %s", [inputs[k]["heading"] for k in again])
326
+ redo = run.predict_many(WriteThemedSection, [inputs[k] for k in again], lm=report_section_lm(retry=True), **kw)
327
+ for k, o in zip(again, redo, strict=True):
328
+ outs[k] = o
329
+ gone = [inputs[k]["heading"] for k in again if not ((outs[k] or {}).get("markdown") or "").strip()]
330
+ log.info("report sections retried: asked=%d recovered=%d still_failed=%s", len(again), len(again) - len(gone), gone)
331
+ return outs
332
+
333
+
334
+ def _themed_document(ctx: ThemedRun) -> Themed | None:
335
+ """The whole report in one piece: the plan and every section's evidence go to one writer, so one voice writes it all."""
336
+ budgets = report_evidence.scale_budgets(ctx.bp.sections, EVIDENCE_TOTAL)
337
+ parts: list[str] = []
338
+ used: list[str] = []
339
+ for n, s in enumerate(ctx.bp.sections, start=1):
340
+ head = _section_header(n, s, ctx.names(s))
341
+ if s.role in ("body", "synthesis"):
342
+ ev = report_evidence.build(ctx.corpus, s, ctx.by_id, budgets.get(s.heading))
343
+ used += ev.posts
344
+ if ev.blocks:
345
+ head += "\nEvidence:\n" + "\n\n".join(ev.blocks)
346
+ parts.append(head)
347
+ report_themes.progress(ctx.db, ctx.job, 0.5, "Writing the report")
348
+ log.info("report document: writing in one piece sections=%d evidence_chars=%d (budget %d) posts_used=%d", len(parts),
349
+ sum(len(p) for p in parts), EVIDENCE_TOTAL, len(set(used)))
350
+ started = time.monotonic()
351
+ out = run.predict(WriteThemedReport, db=ctx.db, workspace_id=ctx.workspace_id, job=ctx.job, lm=report_lm(),
352
+ title=ctx.title, storyline=ctx.bp.storyline, request=ctx.instructions, language=ctx.language,
353
+ connections=report_blueprint.connection_lines(ctx.bp, ctx.themes), sections=parts)
354
+ markdown = MARKER.sub("", (out.get("markdown") or "").strip())
355
+ if not markdown:
356
+ log.warning("report document came back empty, reading the posts instead")
357
+ return None
358
+ log.info("report document written: chars=%d seconds=%.1f", len(markdown), time.monotonic() - started)
359
+ return ctx.finish((out.get("report_title") or ctx.bp.title).strip(), number_blocks(split_prose(markdown)), [], used, [])
360
+
361
+
362
+ def _themed_interactive(ctx: ThemedRun, moved: list[tuple[report_blueprint.Section, str, report_blueprint.Section]]) -> Themed | None:
363
+ """Wave 1: the body sections together, each given the storyline, the outline, what the others cover and only its own
364
+ evidence. Wave 2: the introduction and the summary, written from what wave 1 produced."""
365
+ sections = ctx.bp.sections
366
+ body = [(n, s) for n, s in enumerate(sections) if s.role in ("body", "synthesis")]
367
+ frame = [(n, s) for n, s in enumerate(sections) if s.role in ("intro", "summary")]
368
+ used: list[str] = []
369
+ inputs = []
370
+ for _, s in body:
371
+ ev = report_evidence.build(ctx.corpus, s, ctx.by_id)
372
+ used += ev.posts
373
+ inputs.append(_section_input(ctx, s, ev.blocks))
374
+ report_themes.progress(ctx.db, ctx.job, 0.45, f"Writing {len(body)} sections")
375
+ log.info("report wave 1: body sections=%d evidence_chars=%s", len(body), [sum(len(b) for b in i["evidence"]) for i in inputs])
376
+ written: dict[int, dict] = {}
377
+ for (n, _), out in zip(body, _write_sections(ctx, inputs), strict=True):
378
+ if ((out or {}).get("markdown") or "").strip():
379
+ written[n] = out
380
+ if not written:
381
+ log.warning("report wave 1 wrote nothing, reading the posts instead")
382
+ return None
383
+ digests = [f"{s.heading}: {_digest(written[n]['markdown'])}" for n, s in body if n in written]
384
+ report_themes.progress(ctx.db, ctx.job, 0.8, "Writing the introduction and summary")
385
+ log.info("report wave 2: intro/summary sections=%d digests=%d", len(frame), len(digests))
386
+ for (n, _), out in zip(frame, _write_sections(ctx, [_section_input(ctx, s, digests) for _, s in frame]), strict=True):
387
+ if ((out or {}).get("markdown") or "").strip():
388
+ written[n] = out
389
+
390
+ blocks: list[dict] = []
391
+ suggestions: list[dict] = []
392
+ anchor: dict[int, str] = {}
393
+ used_kinds: set[str] = set()
394
+ for n, s in enumerate(sections):
395
+ out = written.get(n)
396
+ if not out:
397
+ continue
398
+ blocks.append({"type": "prose", "text": f"## {s.heading}\n\n{MARKER.sub('', out['markdown'].strip())}"})
399
+ number_blocks(blocks)
400
+ anchor[id(s)] = blocks[-1]["id"]
401
+ embed = s.embed
402
+ if embed and len(suggestions) < MAX_PLANNED_VISUALS and embed.get("kind") in EMBED_KINDS \
403
+ and embed["kind"] not in used_kinds and (embed.get("brief") or "").strip():
404
+ used_kinds.add(embed["kind"])
405
+ suggestions.append({"id": f"s{len(suggestions) + 1}", "after_block_id": blocks[-1]["id"], "kind": embed["kind"],
406
+ "brief": embed["brief"].strip()[:300], "why": (embed.get("why") or "").strip()[:300]})
407
+ for after, kind, sec in moved:
408
+ suggestions.append({"id": "", "after_block_id": anchor.get(id(after)) or blocks[0]["id"], "kind": kind,
409
+ "brief": (sec.brief or MOVED_BRIEF[kind]).strip()[:300],
410
+ "why": "Offered here for you to add, not written into the text."})
411
+ failed = [s.heading for n, s in enumerate(sections) if n not in written]
412
+ if failed:
413
+ log.warning("report sections left out after a retry: %s", failed)
414
+ log.info("report interactive written: blocks=%d suggestions=%d moved_to_suggestions=%d failed=%d", len(blocks),
415
+ len(suggestions), len(moved), len(failed))
416
+ suggestions = fill_suggestions(suggestions, blocks, list(EMBED_KINDS))
417
+ return ctx.finish(ctx.bp.title, blocks, merge_suggestions(suggestions), used, failed)
418
+
419
+
420
+ def write_themed(db: Session, workspace_id: uuid.UUID, job: Job, docs: list[Document], title: str, instructions: str,
421
+ language: str, interactive: bool) -> Themed | None:
422
+ """The report from the posts' themes: group their stored ideas into weighted themes, plan the whole report, build each
423
+ section's evidence from several posts, then write. A document is written in one piece from the plan; an interactive report
424
+ section by section, each knowing the storyline, the outline and what the others cover, with the introduction and the
425
+ summary written last from what the sections say. None when any step cannot be done (too few ideas, no usable plan, nothing
426
+ written), so the caller reads the posts as a report always did. Never makes a report fail."""
427
+ started = time.monotonic()
428
+ log.info("themed report starting: job=%s workspace=%s format=%s posts=%d language=%r", job.id, workspace_id,
429
+ "interactive" if interactive else "document", len(docs), language)
430
+ try:
431
+ report_themes.progress(db, job, 0.05, f"Reading {len(docs)} posts")
432
+ report_themes.ensure_ideas(db, job, workspace_id, docs)
433
+ share = report_themes.fresh_share(docs)
434
+ if share < report_themes.MIN_FRESH_SHARE:
435
+ log.warning("themed report not possible, reading the posts instead: job=%s only %.0f%% of %d posts have ideas "
436
+ "(need %.0f%%)", job.id, share * 100, len(docs), report_themes.MIN_FRESH_SHARE * 100)
437
+ return None
438
+ corpus = Corpus(workspace_id)
439
+ corpus.sync()
440
+ ideas = report_themes.collect_ideas(docs, corpus)
441
+ report_themes.progress(db, job, 0.2, f"Finding the themes in {len(docs)} posts")
442
+ themes, relations = report_themes.group_themes(db, job, workspace_id, title, instructions, ideas)
443
+ report_themes.progress(db, job, 0.3, "Planning the report")
444
+ kinds = list(EMBED_KINDS) if interactive else []
445
+ bp = report_blueprint.plan_blueprint(db, job, workspace_id, title, instructions, themes, relations, kinds, language)
446
+ log.info("themed report planned: job=%s themes=%d sections=%d seconds=%.1f", job.id, len(themes), len(bp.sections),
447
+ time.monotonic() - started)
448
+ moved: list[tuple[report_blueprint.Section, str, report_blueprint.Section]] = []
449
+ if interactive: # a planned section that is really a quiz or flashcards is a suggestion, not text
450
+ kept: list[report_blueprint.Section] = []
451
+ for s in bp.sections:
452
+ kind = visual_kind(s.heading)
453
+ if kind and kept:
454
+ log.info("report section %r is a %s, so it becomes a suggestion after %r", s.heading, kind, kept[-1].heading)
455
+ moved.append((kept[-1], kind, s))
456
+ if kept[-1].role == "body": # what the section was about is still said, in the section before it
457
+ kept[-1].theme_ids += [i for i in s.theme_ids if i not in kept[-1].theme_ids]
458
+ else:
459
+ kept.append(s)
460
+ bp.sections = kept
461
+ if len(bp.sections) < 2:
462
+ log.warning("themed report not possible, reading the posts instead: job=%s fewer than two sections left", job.id)
463
+ return None
464
+ ctx = ThemedRun(db, workspace_id, job, docs, corpus, bp, themes, instructions, language, title)
465
+ themed = _themed_interactive(ctx, moved) if interactive else _themed_document(ctx)
466
+ if themed:
467
+ log.info("themed report done: job=%s posts_total=%d posts_used=%d blocks=%d suggestions=%d failed=%d seconds=%.1f",
468
+ job.id, len(docs), themed.plan["posts_used"], len(themed.blocks), len(themed.suggestions),
469
+ len(themed.failed), time.monotonic() - started)
470
+ else:
471
+ log.warning("themed report wrote nothing, reading the posts instead: job=%s", job.id)
472
+ return themed
473
+ except (report_themes.ThemesUnavailable, report_blueprint.BlueprintUnavailable) as exc:
474
+ log.warning("themed report not possible, reading the posts instead: job=%s reason=%s", job.id, exc)
475
+ except Exception:
476
+ db.rollback()
477
+ log.warning("themed report failed, reading the posts instead: job=%s", job.id, exc_info=True)
478
+ return None
479
 
480
 
481
  def build_report(db: Session, job: Job, artifact: Artifact) -> dict:
 
483
  topic = (params.get("topic") or "").strip()
484
  language = (params.get("language") or "").strip() or SAME_LANGUAGE
485
  report_format = "interactive" if params.get("report_format") == "interactive" else "document"
486
+ # Posts with ideas stored are read through their themes, however many are picked; chats and small selections as always.
487
+ docs = themed_docs(db, artifact, params)
488
+ material = None if docs else load_material(db, artifact.workspace_id, artifact.notebook_id, params, job=job, what="report")
489
+ about = topic or (themed_title(db, artifact, params, docs) if docs else material.title)
490
  instructions = (params.get("instructions") or "").strip() or default_instructions(
491
+ report_format, params.get("template_id"), about)
492
+
493
+ themed = write_themed(db, artifact.workspace_id, job, docs, themed_title(db, artifact, params, docs), instructions, language,
494
+ report_format == "interactive") if docs else None
495
+ log.info("report: job=%s artifact=%s format=%s way=%s", job.id, artifact.id, report_format,
496
+ "themes" if themed else "legacy (posts read as text)")
497
+ if themed:
498
+ extra = {"plan": themed.plan, **({"failed_sections": themed.failed} if themed.failed else {})}
499
+ title, blocks, suggestions = themed.title, themed.blocks, themed.suggestions
500
+ source, sources = themed.source, themed.sources
501
  else:
502
+ material = material or load_material(db, artifact.workspace_id, artifact.notebook_id, params, job=job, what="report")
503
+ extra = {}
504
+ made = write_interactive(db, artifact.workspace_id, job, material, instructions, language) \
505
+ if report_format == "interactive" else None
506
+ if made:
507
+ title, blocks, suggestions = made
508
+ else:
509
+ # A document, or an interactive report whose plan came back unusable: write it as one piece. Never a failure.
510
+ report_format = "document"
511
+ update_job(db, job, progress=0.3, message="Writing the report")
512
+ title, markdown = write_document(db, artifact.workspace_id, job, material, instructions, language)
513
+ blocks = number_blocks(split_prose(markdown))
514
+ suggestions = [] # visuals are offered in interactive reports only, never in a document
515
+ source, sources = material.source, source_rows(material)
516
  artifact.content_json = {
517
  "title": title, "format": report_format, "template_id": params.get("template_id"),
518
+ "instructions": instructions, "language": language, "topic": topic, "source": source,
519
+ "blocks": blocks, "suggestions": suggestions, "suggestions_offered": True, "citations": [], "sources": sources, **extra,
520
  }
521
  artifact.status = "ready"
522
  db.commit()
 
541
  return {"title": c.get("title"), "questions": c["questions"]}
542
  if kind == "flashcards" and c.get("cards"):
543
  return {"title": c.get("title"), "cards": c["cards"]}
544
+ if kind == "infographic" and c.get("content"): # the picture itself is shared, not copied
545
  return {"title": c.get("title"), "artifact_id": str(existing.id), "theme": c.get("theme")}
546
  raise NothingToDo("That one has nothing to add.")
547
 
 
614
  block = copy_block(kind, existing)
615
  else:
616
  block = make_block(db, job, report, kind, (p.get("brief") or "").strip(), p.get("theme"))
617
+ keep_visual(db, report, {"type": kind, **block}) # a new one also lands in the notebook's Studio list
618
  except NothingToDo as exc:
619
  raise PermanentJobError(str(exc)) from exc
620
  report.content_json = insert_block(report.content_json or {}, {"type": kind, **block}, p.get("after_block_id"),
 
623
  return {"artifact_id": str(report.id)}
624
 
625
 
626
+ def keep_visual(db: Session, report: Artifact, block: dict) -> Artifact | None:
627
+ """A mind map, flashcards or quiz made inside a report would exist only as that block. It is also saved as an
628
+ artifact of its own in the notebook, so it shows in the Studio list and "use one you already made" offers it
629
+ again after the block is removed. Content that is already saved is not saved twice."""
630
+ kind = block.get("type")
631
+ source = (report.content_json or {}).get("source") or {}
632
+ if kind == "flashcards" and block.get("cards"):
633
+ key, content = "cards", {"cards": block["cards"], "card_count": len(block["cards"])}
634
+ elif kind == "quiz" and block.get("questions"):
635
+ key, content = "questions", {"questions": block["questions"], "question_count": len(block["questions"])}
636
+ elif kind == "mind_map" and block.get("root"):
637
+ key, content = "root", {"root": block["root"], "node_count": block.get("node_count"),
638
+ "post_count": block.get("post_count"),
639
+ "document_ids": source.get("document_ids") or []}
640
+ else:
641
+ return None # an infographic already is its own artifact
642
+ same = db.scalars(select(Artifact).where(Artifact.workspace_id == report.workspace_id, Artifact.type == kind,
643
+ Artifact.notebook_id == report.notebook_id))
644
+ if any((a.content_json or {}).get(key) == content[key] for a in same):
645
+ return None
646
+ saved = Artifact(workspace_id=report.workspace_id, notebook_id=report.notebook_id, type=kind, status="ready",
647
+ content_json={"title": block.get("title") or kind, "source": source, **content})
648
+ db.add(saved)
649
+ db.commit()
650
+ return saved
651
+
652
+
653
  def remove_block(content: dict, block_id: str) -> dict | None:
654
+ """The content without that visual (prose cannot be removed), with the offer to add it again restored, or None
655
+ when there is no such visual."""
656
  blocks = list(content.get("blocks") or [])
657
+ removed = next((b for b in blocks if b["id"] == block_id and b["type"] != "prose"), None)
658
+ if not removed:
659
+ return None
660
+ keep = [b for b in blocks if b["id"] != block_id]
661
+ suggestions = list(content.get("suggestions") or [])
662
+ kind = removed["type"]
663
+ if kind in FALLBACK_BRIEFS and not any(s["kind"] == kind for s in suggestions):
664
+ brief, why = FALLBACK_BRIEFS[kind]
665
+ suggestions.append({"id": "", "after_block_id": removed.get("after"), "kind": kind, "brief": brief, "why": why})
666
+ return {**content, "blocks": keep, "suggestions": suggestions}
backend/app/pipeline/report_blueprint.py ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The plan of a themed report, made before any of it is written.
2
+
3
+ One model call (PlanReportBlueprint) sees the themes with their weights and the reader's request, never the posts, and plans the
4
+ whole report: a storyline, the sections in order (what each is about, how deep, what it must cover), and which section bridges which
5
+ 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
6
+ 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
7
+ theme belongs to one section, and what other sections own is worked out here, not left to the model."""
8
+
9
+ import logging
10
+ import time
11
+ import uuid
12
+ from dataclasses import dataclass, field
13
+
14
+ from sqlalchemy.orm import Session
15
+
16
+ from app.llm import run
17
+ from app.llm.provider import report_plan_lm
18
+ from app.llm.signatures import PlanReportBlueprint
19
+ from app.models import Job
20
+ from app.pipeline.report_themes import Theme
21
+
22
+ log = logging.getLogger(__name__)
23
+
24
+ MAX_SECTIONS = 8
25
+ MAX_CONNECTIONS = 6
26
+ ROLES = ("intro", "body", "synthesis", "summary")
27
+ MORE_HEADING = "More to know" # where the themes no section took are gathered
28
+
29
+
30
+ class BlueprintUnavailable(Exception):
31
+ """The plan was not usable: the caller reads the posts as before."""
32
+
33
+
34
+ @dataclass
35
+ class Section:
36
+ heading: str
37
+ role: str
38
+ brief: str
39
+ theme_ids: list[str]
40
+ depth: str = "standard"
41
+ must_cover: list[str] = field(default_factory=list)
42
+ embed: dict | None = None
43
+ covered_elsewhere: list[str] = field(default_factory=list)
44
+
45
+
46
+ @dataclass
47
+ class Blueprint:
48
+ title: str
49
+ storyline: str
50
+ sections: list[Section]
51
+ connections: list[dict] # {"theme_ids": [...], "relation": str, "section": heading}
52
+
53
+
54
+ def theme_lines(themes: list[Theme]) -> list[str]:
55
+ """The themes as the planner reads them: id | name | tier | weight | posts | asked for: summary, then a few of their ideas."""
56
+ return [f"{t.id} | {t.name} | {t.tier} | {t.weight:.2f} | {len(t.doc_ids)} posts | "
57
+ f"{'asked for' if t.pinned else 'not asked for'}: {t.summary} (e.g. {'; '.join(i.label for i in t.ideas[:3])})"
58
+ for t in themes]
59
+
60
+
61
+ def relation_lines(relations: list[dict]) -> list[str]:
62
+ return [f"{r['a']} <-> {r['b']}: {r['relation']}" for r in relations]
63
+
64
+
65
+ def derived_depth(section: Section, by_id: dict[str, Theme]) -> str:
66
+ """How deep a section goes follows its heaviest theme, whatever the model wrote: the weights are the plan's reason to exist."""
67
+ if section.role in ("intro", "summary"):
68
+ return "brief"
69
+ tiers = {by_id[i].tier + ("!" if by_id[i].pinned else "") for i in section.theme_ids if i in by_id}
70
+ if any(t.startswith("major") or t.endswith("!") for t in tiers):
71
+ return "deep"
72
+ if section.role == "synthesis" or any(t.startswith("medium") for t in tiers):
73
+ return "standard"
74
+ return "brief"
75
+
76
+
77
+ def _first_index(sections: list[Section], role: str) -> int | None:
78
+ return next((n for n, s in enumerate(sections) if s.role == role), None)
79
+
80
+
81
+ def _in_reading_order(sections: list[Section]) -> list[Section]:
82
+ """The intro first, the summary last, whatever order the model gave them in. One of each."""
83
+ intro = [s for s in sections if s.role == "intro"][:1]
84
+ summary = [s for s in sections if s.role == "summary"][-1:]
85
+ return intro + [s for s in sections if s.role in ("body", "synthesis")] + summary
86
+
87
+
88
+ def repair(bp: Blueprint, themes: list[Theme]) -> Blueprint:
89
+ """Make the plan agree with the weights. Raises BlueprintUnavailable when fewer than two sections remain."""
90
+ by_id = {t.id: t for t in themes}
91
+ sections = [s for s in bp.sections if s.heading.strip()]
92
+ planned = len(sections)
93
+
94
+ # A body theme is the subject of one section; unknown ids go.
95
+ seen: set[str] = set()
96
+ for s in sections:
97
+ 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]]
98
+ if s.role == "body":
99
+ s.theme_ids = [i for i in s.theme_ids if i not in seen]
100
+ seen.update(s.theme_ids)
101
+ before = len(sections)
102
+ sections = [s for s in sections if s.role != "body" or s.theme_ids]
103
+ if len(sections) < before:
104
+ log.info("report blueprint: dropped %d body sections with no theme of their own", before - len(sections))
105
+ sections = _in_reading_order(sections)
106
+
107
+ def insert(section: Section) -> None:
108
+ at = _first_index(sections, "summary")
109
+ sections.insert(len(sections) if at is None else at, section)
110
+
111
+ # Every major or asked-for theme has a section; whatever else no section took is gathered into one.
112
+ for t in sorted(themes, key=lambda t: -t.weight):
113
+ if t.id not in seen and (t.tier == "major" or t.pinned):
114
+ log.info("report blueprint: no section for %s theme %s (%s), added one", "asked-for" if t.pinned else t.tier, t.id,
115
+ t.name)
116
+ insert(Section(heading=t.name, role="body", brief=t.summary, theme_ids=[t.id], depth="deep"))
117
+ seen.add(t.id)
118
+ rest = [t.id for t in sorted(themes, key=lambda t: -t.weight) if t.id not in seen]
119
+ if rest:
120
+ log.info("report blueprint: themes in no section %s go to %r", rest, MORE_HEADING)
121
+ more = next((s for s in sections if s.role == "body" and s.heading == MORE_HEADING), None)
122
+ if more:
123
+ more.theme_ids += rest
124
+ else:
125
+ insert(Section(heading=MORE_HEADING, role="body", theme_ids=rest, depth="brief",
126
+ brief="The smaller themes in the posts, in a line or two each."))
127
+ seen.update(rest)
128
+
129
+ # Too many sections: the lightest body sections (never one holding a major or asked-for theme) are gathered into one brief
130
+ # section, so a footnote never shares a section with a main theme.
131
+ def weight(s: Section) -> float:
132
+ return sum(by_id[i].weight for i in s.theme_ids)
133
+
134
+ def protected(s: Section) -> bool:
135
+ return any(by_id[i].tier == "major" or by_id[i].pinned for i in s.theme_ids)
136
+
137
+ while len(sections) > MAX_SECTIONS:
138
+ more = next((s for s in sections if s.role == "body" and s.heading == MORE_HEADING), None)
139
+ candidates = [s for s in sections if s.role == "body" and not protected(s) and s is not more]
140
+ if not candidates:
141
+ break
142
+ lightest = min(candidates, key=weight)
143
+ log.info("report blueprint: %d sections, over the %d allowed: merging %r (themes %s) into %r", len(sections),
144
+ MAX_SECTIONS, lightest.heading, lightest.theme_ids, MORE_HEADING)
145
+ if more:
146
+ more.theme_ids += lightest.theme_ids
147
+ sections.remove(lightest)
148
+ else:
149
+ lightest.heading, lightest.must_cover = MORE_HEADING, []
150
+ lightest.brief = "The smaller themes in the posts, in a line or two each."
151
+
152
+ # intro first, summary last, depth from the weights, the summary restates the main themes.
153
+ sections = _in_reading_order(sections)
154
+ for s in [s for s in sections if s.role == "summary"]:
155
+ s.theme_ids = [t.id for t in themes if t.tier == "major" or t.pinned] or s.theme_ids
156
+ for s in sections:
157
+ s.depth = derived_depth(s, by_id)
158
+ if len(sections) < 2:
159
+ log.warning("report blueprint unusable: sections=%d planned=%d", len(sections), planned)
160
+ raise BlueprintUnavailable("the plan has fewer than two sections")
161
+
162
+ # What the other body sections own, so no section writes another's points. The intro and summary may touch everything.
163
+ for s in sections:
164
+ s.covered_elsewhere = [] if s.role in ("intro", "summary") else [
165
+ f"{o.heading}: {'; '.join(o.must_cover) or o.brief}"[:240] for o in sections
166
+ if o is not s and o.role in ("body", "synthesis")]
167
+
168
+ headings = {s.heading.lower(): s.heading for s in sections}
169
+ connections = []
170
+ for c in bp.connections:
171
+ ids = [i for i in c.get("theme_ids") or [] if i in by_id]
172
+ relation = (c.get("relation") or "").strip()
173
+ if len(ids) < 2 or not relation:
174
+ continue
175
+ home = headings.get(str(c.get("section", "")).strip().lower()) or next(
176
+ (s.heading for s in sections if s.role in ("body", "synthesis") and set(ids) & set(s.theme_ids)), None)
177
+ if home:
178
+ connections.append({"theme_ids": ids, "relation": relation[:200], "section": home})
179
+ log.info("report blueprint checked: planned=%d final=%d connections=%d/%d", planned, len(sections), len(connections),
180
+ len(bp.connections))
181
+ for n, s in enumerate(sections, start=1):
182
+ 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,
183
+ s.theme_ids, len(s.must_cover), (s.embed or {}).get("kind"))
184
+ return Blueprint(title=bp.title, storyline=bp.storyline, sections=sections, connections=connections[:MAX_CONNECTIONS])
185
+
186
+
187
+ def plan_blueprint(db: Session, job: Job | None, workspace_id: uuid.UUID, title: str, request: str,
188
+ themes: list[Theme], relations: list[dict], kinds: list[str], language: str) -> Blueprint:
189
+ """The checked plan for these themes. Raises BlueprintUnavailable (or whatever the model call raised) when there is none."""
190
+ log.info("report blueprint: asking the model workspace=%s themes=%d relations=%d allowed_visuals=%s", workspace_id, len(themes),
191
+ len(relations), kinds)
192
+ started = time.monotonic()
193
+ out = run.predict(PlanReportBlueprint, db=db, workspace_id=workspace_id, job=job, lm=report_plan_lm(), title=title,
194
+ request=request, themes=theme_lines(themes), relations=relation_lines(relations), allowed_kinds=kinds,
195
+ language=language, max_sections=MAX_SECTIONS)
196
+ sections = []
197
+ for s in out.get("sections") or []:
198
+ heading = (s.get("heading") or "").strip()
199
+ if heading:
200
+ sections.append(Section(
201
+ heading=heading, role=s.get("role") if s.get("role") in ROLES else "body", brief=(s.get("brief") or "").strip(),
202
+ theme_ids=[str(i) for i in s.get("theme_ids") or []], depth=s.get("depth") or "standard",
203
+ must_cover=[str(p).strip() for p in s.get("must_cover") or [] if str(p).strip()][:4], embed=s.get("embed")))
204
+ log.info("report blueprint: model answered sections=%d connections=%d seconds=%.1f", len(sections),
205
+ len(out.get("connections") or []), time.monotonic() - started)
206
+ bp = Blueprint(title=(out.get("report_title") or title).strip(), storyline=(out.get("storyline") or "").strip(),
207
+ sections=sections, connections=out.get("connections") or [])
208
+ return repair(bp, themes)
209
+
210
+
211
+ def outline(bp: Blueprint) -> list[str]:
212
+ """Every section as every writer sees it: number, heading, role, what it says."""
213
+ return [f"{n}. {s.heading} ({s.role}): {s.brief}" for n, s in enumerate(bp.sections, start=1)]
214
+
215
+
216
+ def connection_lines(bp: Blueprint, themes: list[Theme], heading: str | None = None) -> list[str]:
217
+ """The connections (all of them, or the ones one section bridges) as 'Pricing + Hiring: how they connect (section)'."""
218
+ names = {t.id: t.name for t in themes}
219
+ return [f"{' + '.join(names[i] for i in c['theme_ids'])}: {c['relation']} (bridged in: {c['section']})"
220
+ for c in bp.connections if heading is None or c["section"] == heading]
backend/app/pipeline/report_evidence.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The evidence a themed report section is written from. Plain code, no model.
2
+
3
+ A section is about one or more themes (report_themes.py) and its depth (report_blueprint.py) says how much room it has. What
4
+ the writer gets, from the posts that have ideas in those themes:
5
+
6
+ - the posts that say the most about them (most of the section's themes first, then most ideas) as the writer wrote them: numbered
7
+ 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
8
+ the theme goes in whole. Several posts, each with an equal share, so a section blends posts and does not walk through one;
9
+ - every other post in the themes as the short summaries already stored for it (label, note, points, and the lines to cite);
10
+ - nothing raw for a brief section: summaries only.
11
+
12
+ Nothing is cited in the report, so the text carries no line numbers and no paths: a post is named by its title."""
13
+
14
+ import logging
15
+ import math
16
+ import uuid
17
+ from dataclasses import dataclass, field
18
+
19
+ from app.corpus import Corpus
20
+ from app.models import Document
21
+ from app.pipeline.passages import _body_start
22
+ from app.pipeline.report_blueprint import Section
23
+ from app.pipeline.report_themes import Idea, Theme
24
+
25
+ BUDGETS = {"deep": 32_000, "standard": 16_000, "brief": 4_000} # characters of evidence (about 8k, 4k and 1k tokens)
26
+ RAW_POSTS = {"deep": 5, "standard": 3, "brief": 0} # posts read as written, at most
27
+ RAW_SHARE = 0.75 # of the budget when other posts are summarised too
28
+ WHOLE_POST_CHARS = 10_000 # a post this short, mostly about the theme, goes in whole (about 2,500 tokens)
29
+ WHOLE_POST_SHARE = 0.5
30
+ MARGIN = 6 # lines kept around the lines an idea came from
31
+ LEAD_LINES = 6 # lines kept from the start of a post, for context
32
+ JOIN_GAP = 2 # windows this close are one
33
+ SUMMARY_DETAILS = 2
34
+
35
+ log = logging.getLogger(__name__)
36
+
37
+
38
+ @dataclass
39
+ class Evidence:
40
+ blocks: list[str] = field(default_factory=list)
41
+ posts: list[str] = field(default_factory=list) # paths of every post used
42
+ raw_posts: int = 0 # how many of them were read as written
43
+
44
+
45
+ def _merge(ranges: list[tuple[int, int]]) -> list[tuple[int, int]]:
46
+ out: list[tuple[int, int]] = []
47
+ for a, b in sorted(ranges):
48
+ if out and a <= out[-1][1] + JOIN_GAP:
49
+ out[-1] = (out[-1][0], max(out[-1][1], b))
50
+ else:
51
+ out.append((a, b))
52
+ return out
53
+
54
+
55
+ def _text(lines: list[str], a: int, b: int) -> list[str]:
56
+ return [lines[n - 1] for n in range(a, min(b, len(lines)) + 1) if lines[n - 1].strip()]
57
+
58
+
59
+ def _idea_ranges(d: Document, ideas: list[Idea], total: int, first: int) -> list[tuple[int, int]]:
60
+ out = []
61
+ for idea in ideas:
62
+ for s in idea.sources:
63
+ try:
64
+ a, b = int(s["line_start"]), int(s["line_end"])
65
+ except (KeyError, TypeError, ValueError):
66
+ continue
67
+ if s.get("path") == d.path and total:
68
+ out.append((max(first + 1, a - MARGIN), min(total, max(a, b) + MARGIN)))
69
+ return out
70
+
71
+
72
+ def raw_post(corpus: Corpus, d: Document, ideas: list[Idea], names: list[str], cap: int) -> str | None:
73
+ """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
74
+ around its ideas' lines, merged, cut to `cap` characters (the windows that fit, in order of the post). None if unreadable."""
75
+ try:
76
+ lines = corpus.read_lines(d.path)
77
+ except Exception:
78
+ log.warning("report evidence: cannot read post %s, its summary stands in", d.path, exc_info=True)
79
+ return None
80
+ first = _body_start(lines) # lines before this are the file's header
81
+ head = f"POST {d.title} [themes: {', '.join(names)}]"
82
+ stored = len((d.metadata_json or {}).get("ideas") or []) or 1
83
+ if len(d.clean_text or "") <= WHOLE_POST_CHARS and len(ideas) / stored >= WHOLE_POST_SHARE:
84
+ whole = "\n".join(_text(lines, first + 1, len(lines)))
85
+ if len(head) + len(whole) <= cap:
86
+ log.debug("report evidence: whole post %s (%d chars)", d.path, len(whole))
87
+ return f"{head}\n{whole}"
88
+ ranges = _idea_ranges(d, ideas, len(lines), first)
89
+ windows = _merge([(first + 1, min(len(lines), first + LEAD_LINES))] + ranges)
90
+ parts, used = [], len(head)
91
+ for a, b in windows:
92
+ text = "\n".join(_text(lines, a, b))
93
+ if not text:
94
+ continue
95
+ if parts and used + len(text) + 6 > cap:
96
+ break
97
+ parts.append(text[: max(cap - used, 200)] if not parts else text)
98
+ used += len(parts[-1]) + 6
99
+ return f"{head}\n" + "\n[...]\n".join(parts) if parts else None
100
+
101
+
102
+ def _summary_line(idea: Idea, theme_name: str) -> str:
103
+ points = "; ".join(f"{x.get('label', '')}: {x.get('note', '')}".strip(": ") for x in idea.details[:SUMMARY_DETAILS]
104
+ if isinstance(x, dict))
105
+ return f'- {idea.label}: {idea.note}' + (f" ({points})" if points else "") + f' (from "{idea.doc.title}", {theme_name})'
106
+
107
+
108
+ def build(corpus: Corpus, section: Section, themes: dict[str, Theme], budget: int | None = None) -> Evidence:
109
+ """The evidence for one body or synthesis section: raw posts first, then summaries of the rest, within `budget` characters."""
110
+ budget = budget or BUDGETS.get(section.depth, BUDGETS["standard"])
111
+ items: list[tuple[Theme, Idea]] = [(themes[t], i) for t in section.theme_ids if t in themes for i in themes[t].ideas]
112
+ out = Evidence()
113
+ if not items:
114
+ log.warning("report evidence: section=%r has no ideas (themes %s)", section.heading, section.theme_ids)
115
+ return out
116
+ by_doc: dict[uuid.UUID, list[tuple[Theme, Idea]]] = {}
117
+ for t, i in items:
118
+ by_doc.setdefault(i.doc.id, []).append((t, i))
119
+
120
+ def key(entries: list[tuple[Theme, Idea]]) -> tuple:
121
+ d = entries[0][1].doc
122
+ stamp = d.published_at.timestamp() if d.published_at else 0
123
+ return (-len({t.id for t, _ in entries}), -len(entries), -stamp)
124
+
125
+ ranked = sorted(by_doc.values(), key=key)
126
+ wanted = RAW_POSTS.get(section.depth, 0)
127
+ raw_budget = (budget if len(ranked) <= wanted else int(budget * RAW_SHARE)) if wanted else 0
128
+ each = raw_budget // max(min(wanted, len(ranked)), 1)
129
+ rest: list[list[tuple[Theme, Idea]]] = []
130
+ for entries in ranked:
131
+ if out.raw_posts < wanted:
132
+ d = entries[0][1].doc
133
+ names = list(dict.fromkeys(t.name for t, _ in entries))
134
+ block = raw_post(corpus, d, [i for _, i in entries], names, each)
135
+ if block is not None: # an unreadable post is passed over: the next one takes its place, its summary stands in
136
+ out.blocks.append(block)
137
+ out.posts.append(d.path)
138
+ out.raw_posts += 1
139
+ continue
140
+ rest.append(entries)
141
+
142
+ summary_budget = max(budget - sum(len(b) for b in out.blocks), 0)
143
+ lists = [[_summary_line(i, t.name) for t, i in entries] for entries in rest]
144
+ lines, used, rank = [], 0, 0
145
+ while any(rank < len(x) for x in lists):
146
+ for x in lists:
147
+ if rank < len(x) and used + len(x[rank]) <= summary_budget:
148
+ lines.append(x[rank])
149
+ used += len(x[rank]) + 1
150
+ rank += 1
151
+ if used >= summary_budget:
152
+ break
153
+ if lines:
154
+ out.blocks.append("SUMMARIES of other posts\n" + "\n".join(lines))
155
+ out.posts += [e[0][1].doc.path for e in rest if e[0][1].doc.path not in out.posts]
156
+ log.info("report evidence: section=%r depth=%s themes=%s ideas=%d posts=%d raw_posts=%d summarised_posts=%d summary_lines=%d "
157
+ "chars=%d budget=%d", section.heading, section.depth, section.theme_ids, len(items), len(by_doc), out.raw_posts,
158
+ len(rest), len(lines), sum(len(b) for b in out.blocks), budget)
159
+ return out
160
+
161
+
162
+ def scale_budgets(sections: list[Section], total: int) -> dict[str, int]:
163
+ """Section heading -> budget, the standard budgets scaled down together when they add up to more than `total`."""
164
+ wanted = {s.heading: BUDGETS.get(s.depth, BUDGETS["standard"]) for s in sections if s.role in ("body", "synthesis")}
165
+ factor = min(1.0, total / max(sum(wanted.values()), 1))
166
+ return {h: max(int(math.floor(b * factor)), 1_000) for h, b in wanted.items()}
backend/app/pipeline/report_themes.py ADDED
@@ -0,0 +1,280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Themes for a report: what a notebook's posts are about, and how much of it each thing is.
2
+
3
+ A report no longer reads the posts' text to find out. Every post already has its key ideas stored (documents.metadata_json["ideas"],
4
+ written by generate.extract_ideas, each with the lines it came from), so those ideas stand for the posts, however many are picked:
5
+
6
+ 1. one model call (GroupThemes) names themes and says which ideas belong to which (it sees at most MAX_LISTED_IDEAS of them);
7
+ 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
8
+ are true for the whole selection and not for the sample the model saw. A model groups well and counts badly.
9
+
10
+ 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
11
+ from (report_evidence.py). A theme the reader asked for is moved up a tier whatever its share."""
12
+
13
+ import logging
14
+ import time
15
+ import uuid
16
+ from collections import Counter
17
+ from dataclasses import dataclass, field
18
+
19
+ from sqlalchemy.orm import Session
20
+
21
+ from app.config import settings
22
+ from app.corpus import Corpus
23
+ from app.llm import run
24
+ from app.llm.provider import fast_lm
25
+ from app.llm.signatures import GroupThemes
26
+ from app.models import Document, Job, Notebook, Workspace
27
+ from app.pipeline.generate import extract_ideas, ideas_fresh
28
+ from app.pipeline.idea_pool import _tokens
29
+ from app.pipeline.passages import _body_start, notebook_docs, workspace_docs
30
+ from app.services.jobs import update_job
31
+ from app.services.plans import effective_plan
32
+
33
+ log = logging.getLogger(__name__)
34
+
35
+ SMALL_SELECTION = 5 # posts or fewer: the posts are read whole, as a report always was
36
+ MIN_FRESH_SHARE = 0.5 # fewer posts than this with ideas stored: read the posts' text as before
37
+ MAX_LISTED_IDEAS = 300 # ideas the model sees; the rest are filed by code
38
+ MAX_THEMES = 12
39
+ MIN_THEMES = 2
40
+ MAJOR_AT, MEDIUM_AT = 0.25, 0.10 # a theme's share of the report: major from, medium from
41
+ POST_WEIGHT, IDEA_WEIGHT = 0.6, 0.4 # how many posts mention it counts for more than how many ideas
42
+ CATCH_ALL = "More from your posts"
43
+ OPENING_LINES = 8 # lines of a post's opening that stand for a post with no ideas stored yet
44
+
45
+
46
+ class ThemesUnavailable(Exception):
47
+ """The ideas did not group into themes: the caller reads the posts as before."""
48
+
49
+
50
+ @dataclass
51
+ class Idea:
52
+ id: str
53
+ doc: Document
54
+ label: str
55
+ note: str
56
+ details: list[dict] = field(default_factory=list)
57
+ sources: list[dict] = field(default_factory=list)
58
+ bare: bool = False # no ideas stored for this post: this is its title and opening
59
+
60
+
61
+ @dataclass
62
+ class Theme:
63
+ id: str
64
+ name: str
65
+ summary: str
66
+ ideas: list[Idea]
67
+ asked: bool = False # the reader's request asks for it
68
+ weight: float = 0.0 # share of the whole, all themes add up to 1
69
+ tier: str = "minor" # major | medium | minor
70
+ pinned: bool = False # asked for: always given a deep section
71
+
72
+ @property
73
+ def doc_ids(self) -> set[uuid.UUID]:
74
+ return {i.doc.id for i in self.ideas}
75
+
76
+
77
+ # The posts
78
+
79
+ def select_docs(db: Session, workspace_id: uuid.UUID, notebook_id: uuid.UUID | None, params: dict) -> list[Document]:
80
+ """The posts a report is made from: the ones picked, or the notebook's, newest first. Not cut at ARTIFACT_MAX_POSTS: only
81
+ what the plan indexes (and REPORT_MAX_POSTS, when set) limits it."""
82
+ picked = [uuid.UUID(str(i)) for i in params.get("document_ids") or []]
83
+ nb = db.get(Notebook, notebook_id) if notebook_id else None
84
+ if picked:
85
+ docs = workspace_docs(db, workspace_id, picked)
86
+ elif nb:
87
+ docs = notebook_docs(db, nb.id)
88
+ else:
89
+ docs = []
90
+ cap = effective_plan(db, db.get(Workspace, workspace_id)).indexed_posts
91
+ if settings.report_max_posts > 0:
92
+ cap = min(cap, settings.report_max_posts) if cap and cap > 0 else settings.report_max_posts
93
+ chosen = docs[:cap] if cap and cap > 0 else docs
94
+ log.info("report posts: workspace=%s notebook=%s picked=%d found=%d used=%d plan_cap=%s report_max_posts=%s", workspace_id,
95
+ notebook_id, len(picked), len(docs), len(chosen), cap, settings.report_max_posts)
96
+ if len(chosen) < len(docs):
97
+ log.warning("report posts cut by the plan or REPORT_MAX_POSTS: workspace=%s found=%d used=%d", workspace_id, len(docs),
98
+ len(chosen))
99
+ return chosen
100
+
101
+
102
+ def ensure_ideas(db: Session, job: Job | None, workspace_id: uuid.UUID, docs: list[Document]) -> None:
103
+ """Extract the ideas of posts that have none yet, together, at most REPORT_INLINE_IDEAS of them. The rest are represented
104
+ by their opening; the background ideas job reaches them later."""
105
+ stale = [d for d in docs if not ideas_fresh(d)]
106
+ if not stale or settings.report_inline_ideas <= 0:
107
+ log.info("report ideas: workspace=%s posts=%d stale=%d inline_limit=%d, nothing extracted now", workspace_id, len(docs),
108
+ len(stale), settings.report_inline_ideas)
109
+ return
110
+ started = time.monotonic()
111
+ result = extract_ideas(db, job, workspace_id, [str(d.id) for d in stale], parallel=True, limit=settings.report_inline_ideas)
112
+ for d in stale:
113
+ db.refresh(d)
114
+ log.info("report ideas: workspace=%s posts=%d stale=%d extracted=%s skipped=%s left_for_background=%d seconds=%.1f",
115
+ workspace_id, len(docs), len(stale), result.get("extracted"), result.get("skipped"),
116
+ sum(1 for d in stale if not ideas_fresh(d)), time.monotonic() - started)
117
+
118
+
119
+ def fresh_share(docs: list[Document]) -> float:
120
+ return sum(1 for d in docs if ideas_fresh(d)) / max(len(docs), 1)
121
+
122
+
123
+ def _opening_idea(d: Document, di: int, corpus: Corpus) -> Idea:
124
+ """A post with no ideas stored: its title, its topics and where its opening is, so it still counts and can be cited."""
125
+ meta = d.metadata_json or {}
126
+ tags = ", ".join(t["name"] for t in (meta.get("topics") or [])[:3] if t.get("name"))
127
+ try:
128
+ lines = corpus.read_lines(d.path)
129
+ except Exception:
130
+ lines = []
131
+ first = _body_start(lines) + 1 if lines else 1
132
+ sources = [{"path": d.path, "line_start": first, "line_end": min(first + OPENING_LINES, len(lines) or first)}]
133
+ return Idea(id=f"p{di}.0", doc=d, label=d.title[:80], bare=True, sources=sources,
134
+ note=f"Topics: {tags}" if tags else "Only the opening of this post was read.")
135
+
136
+
137
+ def collect_ideas(docs: list[Document], corpus: Corpus) -> list[Idea]:
138
+ """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."""
139
+ out: list[Idea] = []
140
+ for di, d in enumerate(docs):
141
+ stored = [i for i in ((d.metadata_json or {}).get("ideas") or []) if (i.get("label") or "").strip()] \
142
+ if ideas_fresh(d) else []
143
+ if not stored:
144
+ out.append(_opening_idea(d, di, corpus))
145
+ continue
146
+ for ii, i in enumerate(stored):
147
+ out.append(Idea(id=f"p{di}.{ii}", doc=d, label=i["label"].strip(), note=(i.get("note") or "").strip(),
148
+ details=i.get("details") or [], sources=i.get("sources") or []))
149
+ log.info("report ideas collected: posts=%d ideas=%d posts_with_only_an_opening=%d", len(docs), len(out),
150
+ sum(1 for i in out if i.bare))
151
+ return out
152
+
153
+
154
+ # Themes
155
+
156
+ def listed_ideas(ideas: list[Idea]) -> list[Idea]:
157
+ """The ideas the model sees: all of them, or when there are too many, the same number from every post in turn."""
158
+ if len(ideas) <= MAX_LISTED_IDEAS:
159
+ return ideas
160
+ by_doc: dict[uuid.UUID, list[Idea]] = {}
161
+ for i in ideas:
162
+ by_doc.setdefault(i.doc.id, []).append(i)
163
+ lists, picked, rank = list(by_doc.values()), [], 0
164
+ while len(picked) < MAX_LISTED_IDEAS and any(rank < len(x) for x in lists):
165
+ for x in lists:
166
+ if rank < len(x) and len(picked) < MAX_LISTED_IDEAS:
167
+ picked.append(x[rank])
168
+ rank += 1
169
+ return picked
170
+
171
+
172
+ def _file_leftovers(themes: list[Theme], leftovers: list[Idea]) -> None:
173
+ """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
174
+ shares most, else a catch-all theme: the way the Mind Constellation files what the model leaves out."""
175
+ by_doc: dict[uuid.UUID, Counter] = {}
176
+ for ti, t in enumerate(themes):
177
+ for i in t.ideas:
178
+ by_doc.setdefault(i.doc.id, Counter())[ti] += 1
179
+ theme_words = [_tokens(" ".join([t.name, t.summary] + [i.label for i in t.ideas[:20]])) for t in themes]
180
+ # A word in most themes ("idea", "post") says nothing about which one an idea is about.
181
+ 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)}
182
+ theme_words = [tw - common for tw in theme_words]
183
+ catch: Theme | None = None
184
+ for idea in leftovers:
185
+ counts = by_doc.get(idea.doc.id)
186
+ if counts:
187
+ themes[counts.most_common(1)[0][0]].ideas.append(idea)
188
+ continue
189
+ words = _tokens(f"{idea.label} {idea.note}") - common
190
+ best, score = max(((ti, len(words & tw)) for ti, tw in enumerate(theme_words)), key=lambda x: x[1])
191
+ if score > 0:
192
+ themes[best].ideas.append(idea)
193
+ continue
194
+ if catch is None:
195
+ catch = Theme(id="", name=CATCH_ALL, summary="Ideas that fit none of the other themes.", ideas=[])
196
+ themes.append(catch)
197
+ catch.ideas.append(idea)
198
+ log.info("report leftover ideas filed: leftovers=%d catch_all=%d", len(leftovers), len(catch.ideas) if catch else 0)
199
+
200
+
201
+ def weigh(themes: list[Theme], total_posts: int, total_ideas: int) -> None:
202
+ """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
203
+ theme the reader asked for moves up one tier and is pinned. If nothing reached major, the heaviest theme becomes major."""
204
+ raw = [POST_WEIGHT * len(t.doc_ids) / max(total_posts, 1) + IDEA_WEIGHT * len(t.ideas) / max(total_ideas, 1) for t in themes]
205
+ total = sum(raw) or 1.0
206
+ for t, r in zip(themes, raw, strict=True):
207
+ t.weight = round(r / total, 4)
208
+ t.tier = "major" if t.weight >= MAJOR_AT else "medium" if t.weight >= MEDIUM_AT else "minor"
209
+ if themes and not any(t.tier == "major" for t in themes):
210
+ max(themes, key=lambda t: t.weight).tier = "major"
211
+ for t in themes:
212
+ if t.asked:
213
+ t.pinned = True
214
+ t.tier = {"minor": "medium", "medium": "major"}.get(t.tier, t.tier)
215
+
216
+
217
+ def _relations(raw: list[dict] | None, themes: list[Theme]) -> list[dict]:
218
+ by_name = {t.name.lower(): t.id for t in themes}
219
+ out = []
220
+ for r in raw or []:
221
+ a, b = by_name.get(str(r.get("a", "")).strip().lower()), by_name.get(str(r.get("b", "")).strip().lower())
222
+ relation = (r.get("relation") or "").strip()
223
+ if a and b and a != b and relation:
224
+ out.append({"a": a, "b": b, "relation": relation[:200]})
225
+ return out[:8]
226
+
227
+
228
+ def group_themes(db: Session, job: Job | None, workspace_id: uuid.UUID, title: str, request: str,
229
+ ideas: list[Idea]) -> tuple[list[Theme], list[dict]]:
230
+ """Themes with their weights, and how they relate (by theme id). Raises ThemesUnavailable when the answer is not usable."""
231
+ by_id = {i.id: i for i in ideas}
232
+ listed = listed_ideas(ideas)
233
+ listing = [f"{i.id} | {i.label}: {i.note[:140]}" for i in listed]
234
+ log.info("report themes: asking the model workspace=%s ideas=%d listed=%d (cap %d) posts=%d", workspace_id, len(ideas),
235
+ len(listed), MAX_LISTED_IDEAS, len({i.doc.id for i in ideas}))
236
+ started = time.monotonic()
237
+ out = run.predict(GroupThemes, db=db, workspace_id=workspace_id, job=job, lm=fast_lm(), title=title,
238
+ request=request, ideas=listing)
239
+ themes: list[Theme] = []
240
+ taken: set[str] = set()
241
+ dropped = 0
242
+ for t in out.get("themes") or []:
243
+ name = (t.get("name") or "").strip()
244
+ wanted = list(t.get("idea_ids") or [])
245
+ mine = [by_id[i] for i in wanted if i in by_id and i not in taken]
246
+ dropped += len(wanted) - len(mine) # unknown ids, or ideas another theme already took
247
+ if not name or not mine:
248
+ log.info("report theme skipped: name=%r usable_ideas=%d", name, len(mine))
249
+ continue
250
+ taken.update(i.id for i in mine)
251
+ themes.append(Theme(id="", name=name[:80], summary=(t.get("summary") or "").strip()[:400], ideas=mine,
252
+ asked=bool(t.get("matches_request"))))
253
+ if len(themes) == MAX_THEMES:
254
+ break
255
+ log.info("report themes: model answered themes=%d usable=%d ids_dropped=%d seconds=%.1f", len(out.get("themes") or []),
256
+ len(themes), dropped, time.monotonic() - started)
257
+ if len(themes) < MIN_THEMES:
258
+ log.warning("report themes unusable: usable=%d needed=%d", len(themes), MIN_THEMES)
259
+ raise ThemesUnavailable("the ideas did not group into themes")
260
+ _file_leftovers(themes, [i for i in ideas if i.id not in taken])
261
+ for n, t in enumerate(themes, start=1):
262
+ t.id = f"t{n}"
263
+ weigh(themes, len({i.doc.id for i in ideas}), len(ideas))
264
+ relations = _relations(out.get("relations"), themes)
265
+ for t in themes:
266
+ 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,
267
+ t.tier, t.asked, t.pinned, len(t.doc_ids), len(t.ideas))
268
+ log.info("report theme relations: model=%d kept=%d", len(out.get("relations") or []), len(relations))
269
+ return themes, relations
270
+
271
+
272
+ def theme_summary(t: Theme) -> dict:
273
+ """What is kept with the report about a theme."""
274
+ return {"id": t.id, "name": t.name, "summary": t.summary, "weight": t.weight, "tier": t.tier, "pinned": t.pinned,
275
+ "posts": len(t.doc_ids), "ideas": len(t.ideas)}
276
+
277
+
278
+ def progress(db: Session, job: Job | None, value: float, message: str) -> None:
279
+ if job:
280
+ update_job(db, job, progress=value, message=message)
backend/app/routers/artifacts.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import re
2
  import uuid
3
  from typing import Literal
@@ -10,8 +11,9 @@ from app.auth import Ctx, get_ctx
10
  from app.config import settings
11
  from app.infographics.image import ImageUnavailable, render_png
12
  from app.infographics.themes import theme_id
13
- from app.models import Artifact, CalendarItem, Chat, Document, Job, Notebook, UserSavedVoice
14
  from app.routers.notebooks import ensure_archive_notebook
 
15
  from app.services.artifacts import latest_jobs, serialize_artifact, start_artifact
16
  from app.services.jobs import create_job, serialize_job
17
  from app.services.plans import effective_plan, plan_limit_error
@@ -25,6 +27,8 @@ from app.slides.build import stored as stored_deck
25
  from app.slides.build import view as slide_pages
26
  from app.slides.themes import theme_id as slide_theme_id
27
 
 
 
28
  router = APIRouter(prefix="/api/artifacts", tags=["artifacts"])
29
 
30
  ArtifactType = Literal["summary", "audio_overview", "video", "quote_card", "carousel", "launch_kit", "mind_map", "quiz",
@@ -69,7 +73,8 @@ class GenerateIn(BaseModel):
69
  question_types: list[Literal["multiple_choice", "multiple_select", "fill_blank", "short_answer"]] = Field(
70
  default_factory=lambda: ["multiple_choice", "multiple_select"], min_length=1)
71
  language: str | None = Field(None, max_length=40)
72
- theme: str | None = Field(None, max_length=30) # infographic: app.infographics.themes; slide_deck: app.slides.themes
 
73
  # slide_deck: read on its own ("detailed") or shown behind a speaker ("presenter"), and how many slides
74
  deck_format: Literal["detailed", "presenter"] = "detailed"
75
  deck_length: Literal["short", "default", "long"] = "default"
@@ -166,6 +171,7 @@ def generate(body: GenerateIn, ctx: Ctx = Depends(get_ctx)):
166
  if body.format == "debate" and body.hosts == 1:
167
  raise HTTPException(400, "A debate needs two hosts")
168
  check_limit(ctx.db, ctx.workspace, "audio_minutes", body.minutes)
 
169
  ids = body.document_ids or ([body.document_id] if body.document_id else [])
170
  hosts = _hosts(ctx, body)
171
  if body.hosts == 1:
@@ -197,7 +203,8 @@ def generate(body: GenerateIn, ctx: Ctx = Depends(get_ctx)):
197
  check_limit(ctx.db, ctx.workspace, "infographics", 1)
198
  ids = body.document_ids or ([body.document_id] if body.document_id else [])
199
  params = {"document_ids": [str(i) for i in ids], "chat_ids": [str(i) for i in body.chat_ids or []],
200
- "theme": theme_id(body.theme), "instructions": (body.instructions or "").strip()[:600]}
 
201
  title = f"Infographic: {params['instructions'][:60] or target}"
202
  elif body.type == "slide_deck":
203
  ids = body.document_ids or ([body.document_id] if body.document_id else [])
@@ -257,13 +264,85 @@ def list_artifacts(
257
  rows = ctx.db.scalars(query.order_by(Artifact.created_at.desc()).offset(max(offset, 0)).limit(min(limit, 200)))
258
  rows = list(rows.all())
259
  jobs = latest_jobs(ctx.db, [a.id for a in rows])
260
- return {"total": total, "items": [serialize_artifact(a, jobs.get(a.id)) for a in rows]}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
 
262
 
263
  @router.get("/{artifact_id}")
264
  def get_artifact(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
265
  a = get_artifact_or_404(ctx, artifact_id)
266
- return serialize_artifact(a, latest_jobs(ctx.db, [a.id]).get(a.id))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
 
268
 
269
  @router.get("/{artifact_id}/image")
 
1
+ import logging
2
  import re
3
  import uuid
4
  from typing import Literal
 
11
  from app.config import settings
12
  from app.infographics.image import ImageUnavailable, render_png
13
  from app.infographics.themes import theme_id
14
+ from app.models import Artifact, ArtifactFeedback, CalendarItem, Chat, Document, Job, Notebook, UserSavedVoice
15
  from app.routers.notebooks import ensure_archive_notebook
16
+ from app.services import artifact_feedback
17
  from app.services.artifacts import latest_jobs, serialize_artifact, start_artifact
18
  from app.services.jobs import create_job, serialize_job
19
  from app.services.plans import effective_plan, plan_limit_error
 
27
  from app.slides.build import view as slide_pages
28
  from app.slides.themes import theme_id as slide_theme_id
29
 
30
+ log = logging.getLogger(__name__)
31
+
32
  router = APIRouter(prefix="/api/artifacts", tags=["artifacts"])
33
 
34
  ArtifactType = Literal["summary", "audio_overview", "video", "quote_card", "carousel", "launch_kit", "mind_map", "quiz",
 
73
  question_types: list[Literal["multiple_choice", "multiple_select", "fill_blank", "short_answer"]] = Field(
74
  default_factory=lambda: ["multiple_choice", "multiple_select"], min_length=1)
75
  language: str | None = Field(None, max_length=40)
76
+ theme: str | None = Field(None, max_length=30) # infographic: one of the looks (app.infographics.design.LOOKS); slide_deck: app.slides.themes
77
+ ig_style: str | None = Field(None, max_length=20) # infographic: the layout to lean towards (design.STYLES), auto when empty
78
  # slide_deck: read on its own ("detailed") or shown behind a speaker ("presenter"), and how many slides
79
  deck_format: Literal["detailed", "presenter"] = "detailed"
80
  deck_length: Literal["short", "default", "long"] = "default"
 
171
  if body.format == "debate" and body.hosts == 1:
172
  raise HTTPException(400, "A debate needs two hosts")
173
  check_limit(ctx.db, ctx.workspace, "audio_minutes", body.minutes)
174
+ check_limit(ctx.db, ctx.workspace, "audio_overviews", 1)
175
  ids = body.document_ids or ([body.document_id] if body.document_id else [])
176
  hosts = _hosts(ctx, body)
177
  if body.hosts == 1:
 
203
  check_limit(ctx.db, ctx.workspace, "infographics", 1)
204
  ids = body.document_ids or ([body.document_id] if body.document_id else [])
205
  params = {"document_ids": [str(i) for i in ids], "chat_ids": [str(i) for i in body.chat_ids or []],
206
+ "theme": theme_id(body.theme), "style": body.ig_style or "auto",
207
+ "instructions": (body.instructions or "").strip()[:600]}
208
  title = f"Infographic: {params['instructions'][:60] or target}"
209
  elif body.type == "slide_deck":
210
  ids = body.document_ids or ([body.document_id] if body.document_id else [])
 
264
  rows = ctx.db.scalars(query.order_by(Artifact.created_at.desc()).offset(max(offset, 0)).limit(min(limit, 200)))
265
  rows = list(rows.all())
266
  jobs = latest_jobs(ctx.db, [a.id for a in rows])
267
+ items = [serialize_artifact(a, jobs.get(a.id)) for a in rows]
268
+ return {"total": total, "items": artifact_feedback.attach(ctx.db, ctx.user.id, items)}
269
+
270
+
271
+ @router.get("/feedback/export")
272
+ def export_artifact_feedback(ctx: Ctx = Depends(get_ctx), rating: Literal["up", "down"] | None = None, type: str | None = None,
273
+ limit: int = 200):
274
+ """The ratings of generated reports, quizzes, flashcards and infographics, newest first, with what each one was, to see
275
+ which kinds do well and which do not."""
276
+ q = select(ArtifactFeedback).where(ArtifactFeedback.workspace_id == ctx.workspace.id)
277
+ if rating:
278
+ q = q.where(ArtifactFeedback.rating == (1 if rating == "up" else -1))
279
+ if type:
280
+ q = q.where(ArtifactFeedback.artifact_type == type)
281
+ rows = ctx.db.scalars(q.order_by(ArtifactFeedback.updated_at.desc()).limit(min(limit, 1000))).all()
282
+ log.info("feedback exported: rows=%d rating=%s type=%s user=%s workspace=%s", len(rows), rating, type, ctx.user.id,
283
+ ctx.workspace.id)
284
+ return [{"id": str(f.id), "artifact_id": str(f.artifact_id) if f.artifact_id else None, "type": f.artifact_type,
285
+ "rating": "up" if f.rating > 0 else "down", "reasons": f.reasons, "comment": f.comment, "snapshot": f.snapshot,
286
+ "rated_at": f.updated_at.isoformat() if f.updated_at else None} for f in rows]
287
 
288
 
289
  @router.get("/{artifact_id}")
290
  def get_artifact(artifact_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
291
  a = get_artifact_or_404(ctx, artifact_id)
292
+ _offer_visuals_once(ctx, a)
293
+ return artifact_feedback.attach(ctx.db, ctx.user.id, [serialize_artifact(a, latest_jobs(ctx.db, [a.id]).get(a.id))])[0]
294
+
295
+
296
+ def _offer_visuals_once(ctx: Ctx, a: Artifact) -> None:
297
+ """An interactive report made before interactive reports always offered visuals gets them the first time it is opened (once: after
298
+ that, the ones the reader has added or taken away stay as they are). A document report never offers any."""
299
+ c = a.content_json or {}
300
+ if a.type != "report" or a.status != "ready" or c.get("format") != "interactive" or c.get("suggestions_offered") \
301
+ or not c.get("blocks"):
302
+ return
303
+ from app.pipeline.report import EMBED_KINDS, fill_suggestions, merge_suggestions
304
+
305
+ has_posts = bool((c.get("source") or {}).get("document_ids"))
306
+ kinds = [k for k in EMBED_KINDS if k != "mind_map" or has_posts]
307
+ a.content_json = {**c, "suggestions": merge_suggestions(fill_suggestions(list(c.get("suggestions") or []), c["blocks"], kinds)),
308
+ "suggestions_offered": True}
309
+ ctx.db.commit()
310
+
311
+
312
+ class FeedbackIn(BaseModel):
313
+ rating: Literal["up", "down"] | None # null takes the rating back
314
+ reasons: list[str] = []
315
+ comment: str | None = Field(None, max_length=1000)
316
+
317
+
318
+ @router.put("/{artifact_id}/feedback")
319
+ def rate_artifact(artifact_id: uuid.UUID, body: FeedbackIn, ctx: Ctx = Depends(get_ctx)):
320
+ """Thumbs up or down on a finished report, quiz, flashcard set or infographic. Rating again replaces it; null removes it."""
321
+ a = get_artifact_or_404(ctx, artifact_id)
322
+ allowed = artifact_feedback.REASONS.get(a.type)
323
+ if allowed is None or a.status != "ready":
324
+ log.warning("feedback refused: artifact=%s type=%s status=%s user=%s", a.id, a.type, a.status, ctx.user.id)
325
+ raise HTTPException(400, "Only a finished report, quiz, flashcard set or infographic can be rated")
326
+ row = ctx.db.scalar(select(ArtifactFeedback).where(ArtifactFeedback.artifact_id == a.id, ArtifactFeedback.user_id == ctx.user.id))
327
+ if body.rating is None:
328
+ if row:
329
+ ctx.db.delete(row)
330
+ ctx.db.commit()
331
+ log.info("feedback removed: artifact=%s type=%s user=%s workspace=%s", a.id, a.type, ctx.user.id, ctx.workspace.id)
332
+ return {"feedback": None}
333
+ if not row:
334
+ row = ArtifactFeedback(workspace_id=ctx.workspace.id, artifact_id=a.id, user_id=ctx.user.id)
335
+ ctx.db.add(row)
336
+ row.artifact_type = a.type
337
+ row.rating = 1 if body.rating == "up" else -1
338
+ row.reasons = [r for r in dict.fromkeys(body.reasons) if r in allowed] if body.rating == "down" else []
339
+ row.comment = (body.comment or "").strip() or None
340
+ row.snapshot = artifact_feedback.snapshot(a)
341
+ ctx.db.commit()
342
+ # The comment's text stays out of the logs (it is the user's own words); its length and the reasons are enough.
343
+ log.info("feedback saved: artifact=%s type=%s rating=%s reasons=%s comment_chars=%d user=%s workspace=%s",
344
+ a.id, a.type, body.rating, row.reasons, len(row.comment or ""), ctx.user.id, ctx.workspace.id)
345
+ return {"feedback": artifact_feedback.feedback_out(row)}
346
 
347
 
348
  @router.get("/{artifact_id}/image")
backend/app/routers/notebooks.py CHANGED
@@ -36,6 +36,7 @@ from app.pipeline.research import Turn, research
36
  from app.pipeline.trace import ChatTrace, use_trace
37
  from app.routers.sources import serialize_document
38
  from app.services.artifacts import latest_jobs, serialize_artifact
 
39
  from app.services.jobs import create_job, record_usage
40
  from app.services.memory import list_facts, profile_text
41
  from app.services.storage import storage
@@ -86,6 +87,8 @@ def _add_docs(ctx: Ctx, nb: Notebook, ids: list[uuid.UUID]) -> int:
86
  fresh = [d for d in ctx.db.scalars(select(Document).where(Document.id.in_(valid - existing))) if not ideas_fresh(d)]
87
  if fresh and settings.llm_api_key:
88
  create_job(ctx.db, ctx.workspace.id, "ideas", {"document_ids": [str(d.id) for d in fresh]}, max_attempts=2)
 
 
89
  return len(valid - existing)
90
 
91
 
@@ -254,7 +257,9 @@ def notebook_artifacts(notebook_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
254
  select(Artifact).where(Artifact.notebook_id == nb.id).order_by(Artifact.created_at.desc())
255
  ).all())
256
  jobs = latest_jobs(ctx.db, [a.id for a in rows])
257
- return [serialize_artifact(a, jobs.get(a.id)) for a in rows]
 
 
258
 
259
 
260
  @router.get("/{notebook_id}/chats")
 
36
  from app.pipeline.trace import ChatTrace, use_trace
37
  from app.routers.sources import serialize_document
38
  from app.services.artifacts import latest_jobs, serialize_artifact
39
+ from app.services import report_suggestions
40
  from app.services.jobs import create_job, record_usage
41
  from app.services.memory import list_facts, profile_text
42
  from app.services.storage import storage
 
87
  fresh = [d for d in ctx.db.scalars(select(Document).where(Document.id.in_(valid - existing))) if not ideas_fresh(d)]
88
  if fresh and settings.llm_api_key:
89
  create_job(ctx.db, ctx.workspace.id, "ideas", {"document_ids": [str(d.id) for d in fresh]}, max_attempts=2)
90
+ if valid - existing:
91
+ report_suggestions.queue_warmup(ctx.db, ctx.workspace.id) # the Create report dialog's templates, ready before it is opened
92
  return len(valid - existing)
93
 
94
 
 
257
  select(Artifact).where(Artifact.notebook_id == nb.id).order_by(Artifact.created_at.desc())
258
  ).all())
259
  jobs = latest_jobs(ctx.db, [a.id for a in rows])
260
+ from app.services import artifact_feedback # the ratings ride along on each artifact the panel lists
261
+
262
+ return artifact_feedback.attach(ctx.db, ctx.user.id, [serialize_artifact(a, jobs.get(a.id)) for a in rows])
263
 
264
 
265
  @router.get("/{notebook_id}/chats")
backend/app/routers/reports.py CHANGED
@@ -7,12 +7,11 @@ from sqlalchemy import select
7
 
8
  from app.auth import Ctx, get_ctx
9
  from app.llm import run
10
- from app.llm.provider import fast_lm
11
- from app.llm.signatures import SuggestReportTemplates
12
  from app.models import Artifact
13
- from app.pipeline.report import EMBED_KINDS, remove_block
14
  from app.routers.artifacts import get_artifact_or_404
15
  from app.routers.videos import FocusIn, _focus_material
 
16
  from app.services.artifacts import latest_jobs, serialize_artifact
17
  from app.services.jobs import create_job
18
  from app.services.report_templates import INTERACTIVE, TEMPLATES
@@ -20,7 +19,6 @@ from app.services.usage import check_limit
20
 
21
  router = APIRouter(prefix="/api/reports", tags=["reports"])
22
 
23
- SUGGEST_ITEMS = 5 # sources the AI reads to suggest templates
24
 
25
 
26
  @router.get("/templates")
@@ -35,25 +33,15 @@ class SuggestIn(FocusIn):
35
 
36
  @router.post("/suggest")
37
  def suggest_templates(body: SuggestIn, ctx: Ctx = Depends(get_ctx)):
38
- """Four kinds of report these sources could make, each with its own ready-written instructions. Never an error:
39
- when the AI has nothing usable the list is empty and the dialog shows only the fixed templates."""
40
- material, _docs = _focus_material(ctx, body)
 
 
41
  if not material:
42
  return {"templates": []}
43
- try:
44
- out = run.predict(SuggestReportTemplates, db=ctx.db, workspace_id=ctx.workspace.id, lm=fast_lm(),
45
- items=list(material.values())[:SUGGEST_ITEMS], topic=(body.topic or "").strip() or "(none)")
46
- except Exception:
47
- return {"templates": []}
48
- seen, found = set(), []
49
- for t in out.get("templates") or []:
50
- name, prompt = (t.get("name") or "").strip(), (t.get("prompt") or "").strip()
51
- if not name or not prompt or name.lower() in seen:
52
- continue
53
- seen.add(name.lower())
54
- found.append({"id": f"suggested-{len(found) + 1}", "name": name[:60],
55
- "description": (t.get("description") or "").strip()[:200], "prompt": prompt[:1500]})
56
- return {"templates": found[:4]}
57
 
58
 
59
  class AddBlockIn(BaseModel):
@@ -105,9 +93,11 @@ def add_block(artifact_id: uuid.UUID, body: AddBlockIn, ctx: Ctx = Depends(get_c
105
  @router.delete("/{artifact_id}/blocks/{block_id}")
106
  def delete_block(artifact_id: uuid.UUID, block_id: str, ctx: Ctx = Depends(get_ctx)):
107
  a = _report_or_404(ctx, artifact_id)
 
108
  content = remove_block(a.content_json or {}, block_id)
109
  if content is None:
110
  raise HTTPException(404, "Visual not found")
 
111
  a.content_json = content
112
  ctx.db.commit()
113
  return serialize_artifact(a, latest_jobs(ctx.db, [a.id]).get(a.id))
 
7
 
8
  from app.auth import Ctx, get_ctx
9
  from app.llm import run
 
 
10
  from app.models import Artifact
11
+ from app.pipeline.report import EMBED_KINDS, keep_visual, remove_block
12
  from app.routers.artifacts import get_artifact_or_404
13
  from app.routers.videos import FocusIn, _focus_material
14
+ from app.services import report_suggestions
15
  from app.services.artifacts import latest_jobs, serialize_artifact
16
  from app.services.jobs import create_job
17
  from app.services.report_templates import INTERACTIVE, TEMPLATES
 
19
 
20
  router = APIRouter(prefix="/api/reports", tags=["reports"])
21
 
 
22
 
23
 
24
  @router.get("/templates")
 
33
 
34
  @router.post("/suggest")
35
  def suggest_templates(body: SuggestIn, ctx: Ctx = Depends(get_ctx)):
36
+ """Four kinds of report these sources could make, each with its own ready-written instructions. The AI reads each post's stored
37
+ ideas and topics (not its text); the answer is kept, so the same sources get it again at once (and it is prepared in the
38
+ background as soon as ideas are extracted). Never an error: when there is nothing usable the list is empty and the dialog shows
39
+ only the fixed templates."""
40
+ material, docs = _focus_material(ctx, body)
41
  if not material:
42
  return {"templates": []}
43
+ items = report_suggestions.suggestion_items(material, docs)
44
+ return {"templates": report_suggestions.suggest(ctx.db, ctx.workspace.id, items, (body.topic or "").strip())}
 
 
 
 
 
 
 
 
 
 
 
 
45
 
46
 
47
  class AddBlockIn(BaseModel):
 
93
  @router.delete("/{artifact_id}/blocks/{block_id}")
94
  def delete_block(artifact_id: uuid.UUID, block_id: str, ctx: Ctx = Depends(get_ctx)):
95
  a = _report_or_404(ctx, artifact_id)
96
+ removed = next((b for b in (a.content_json or {}).get("blocks") or [] if b.get("id") == block_id), None)
97
  content = remove_block(a.content_json or {}, block_id)
98
  if content is None:
99
  raise HTTPException(404, "Visual not found")
100
+ keep_visual(ctx.db, a, removed)
101
  a.content_json = content
102
  ctx.db.commit()
103
  return serialize_artifact(a, latest_jobs(ctx.db, [a.id]).get(a.id))
backend/app/routers/settings.py CHANGED
@@ -1,14 +1,9 @@
1
- import re
2
-
3
- from fastapi import APIRouter, Depends, HTTPException
4
  from pydantic import BaseModel, Field
5
- from sqlalchemy import select
6
 
7
  from app.auth import Ctx, get_ctx
8
  from app.config import settings as app_settings
9
- from app.models import Upload
10
  from app.services.plans import effective_plan, plan_dict
11
- from app.services.storage import storage
12
  from app.services.usage import usage_report
13
 
14
  router = APIRouter(prefix="/api/settings", tags=["settings"])
@@ -17,24 +12,18 @@ router = APIRouter(prefix="/api/settings", tags=["settings"])
17
  class SettingsIn(BaseModel):
18
  name: str | None = Field(None, max_length=200)
19
  workspace_name: str | None = Field(None, min_length=1, max_length=200)
20
- brand_name: str | None = Field(None, max_length=120)
21
- brand_accent: str | None = None
22
- logo_upload_id: str | None = None # "" removes the logo
23
  training_opt_in: bool | None = None
24
  allow_public_links: bool | None = None
25
  email_unsubscribed: bool | None = None
26
 
27
 
28
  def _serialize(ctx: Ctx) -> dict:
29
- brand = ctx.workspace.brand_json or {}
30
  return {
31
  "user": {"name": ctx.user.name, "email": ctx.user.email, "auth_provider": ctx.user.auth_provider.value,
32
  "email_unsubscribed": ctx.user.email_unsubscribed},
33
  "workspace": {"id": str(ctx.workspace.id), "name": ctx.workspace.name,
34
  "training_opt_in": ctx.workspace.training_opt_in,
35
  "allow_public_links": ctx.workspace.allow_public_links},
36
- "brand": {"name": brand.get("name"), "accent": brand.get("accent") or "#217cff",
37
- "logo_url": storage.presign_get(brand["logo_key"]) if brand.get("logo_key") else None},
38
  "plan": plan_dict(effective_plan(ctx.db, ctx.workspace)),
39
  "billing_enabled": app_settings.billing_enabled,
40
  "usage": usage_report(ctx.db, ctx.workspace),
@@ -45,6 +34,15 @@ def _serialize(ctx: Ctx) -> dict:
45
  }
46
 
47
 
 
 
 
 
 
 
 
 
 
48
  @router.get("")
49
  def get_settings(ctx: Ctx = Depends(get_ctx)):
50
  return _serialize(ctx)
@@ -62,23 +60,6 @@ def update_settings(body: SettingsIn, ctx: Ctx = Depends(get_ctx)):
62
  ctx.workspace.allow_public_links = body.allow_public_links
63
  if body.email_unsubscribed is not None:
64
  ctx.user.email_unsubscribed = body.email_unsubscribed
65
- brand = dict(ctx.workspace.brand_json or {})
66
- if body.brand_name is not None:
67
- brand["name"] = body.brand_name.strip() or None
68
- if body.brand_accent is not None:
69
- if not re.fullmatch(r"#[0-9a-fA-F]{6}", body.brand_accent):
70
- raise HTTPException(422, "Use a hex color like #217cff")
71
- brand["accent"] = body.brand_accent.lower()
72
- if body.logo_upload_id is not None:
73
- if body.logo_upload_id == "":
74
- brand.pop("logo_key", None)
75
- else:
76
- upload = ctx.db.scalar(select(Upload).where(Upload.id == body.logo_upload_id,
77
- Upload.workspace_id == ctx.workspace.id))
78
- if not upload or upload.status != "complete" or not upload.content_type.startswith("image/"):
79
- raise HTTPException(400, "Upload an image first")
80
- brand["logo_key"] = upload.key
81
- ctx.workspace.brand_json = brand
82
  ctx.db.commit()
83
  return _serialize(ctx)
84
 
 
1
+ from fastapi import APIRouter, Depends
 
 
2
  from pydantic import BaseModel, Field
 
3
 
4
  from app.auth import Ctx, get_ctx
5
  from app.config import settings as app_settings
 
6
  from app.services.plans import effective_plan, plan_dict
 
7
  from app.services.usage import usage_report
8
 
9
  router = APIRouter(prefix="/api/settings", tags=["settings"])
 
12
  class SettingsIn(BaseModel):
13
  name: str | None = Field(None, max_length=200)
14
  workspace_name: str | None = Field(None, min_length=1, max_length=200)
 
 
 
15
  training_opt_in: bool | None = None
16
  allow_public_links: bool | None = None
17
  email_unsubscribed: bool | None = None
18
 
19
 
20
  def _serialize(ctx: Ctx) -> dict:
 
21
  return {
22
  "user": {"name": ctx.user.name, "email": ctx.user.email, "auth_provider": ctx.user.auth_provider.value,
23
  "email_unsubscribed": ctx.user.email_unsubscribed},
24
  "workspace": {"id": str(ctx.workspace.id), "name": ctx.workspace.name,
25
  "training_opt_in": ctx.workspace.training_opt_in,
26
  "allow_public_links": ctx.workspace.allow_public_links},
 
 
27
  "plan": plan_dict(effective_plan(ctx.db, ctx.workspace)),
28
  "billing_enabled": app_settings.billing_enabled,
29
  "usage": usage_report(ctx.db, ctx.workspace),
 
34
  }
35
 
36
 
37
+ @router.get("/limits")
38
+ def artifact_limits(_ctx: Ctx = Depends(get_ctx)):
39
+ """How many posts a report or infographic reads, and a dialog ticks to start with (ARTIFACT_MAX_POSTS), so the pickers match the
40
+ server. A quiz or flashcard set can read as many posts as the plan indexes (selectable_posts)."""
41
+ from app.pipeline.material import max_posts
42
+
43
+ return {"posts": max_posts(), "selectable_posts": effective_plan(_ctx.db, _ctx.workspace).indexed_posts}
44
+
45
+
46
  @router.get("")
47
  def get_settings(ctx: Ctx = Depends(get_ctx)):
48
  return _serialize(ctx)
 
60
  ctx.workspace.allow_public_links = body.allow_public_links
61
  if body.email_unsubscribed is not None:
62
  ctx.user.email_unsubscribed = body.email_unsubscribed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  ctx.db.commit()
64
  return _serialize(ctx)
65
 
backend/app/routers/sources.py CHANGED
@@ -10,7 +10,7 @@ from app.corpus import INDEX, Corpus, CorpusError
10
  from app.models import Document, Job, NotebookDocument, Source, Upload
11
  from app.pipeline.ingest import IMPORTS_FEED, FeedNotFound, discover_feed, imports_source, rebuild_index
12
  from app.services.jobs import create_job, serialize_job
13
- from app.services.plans import effective_plan, plan_limit_error
14
  from app.services.storage import storage
15
 
16
  router = APIRouter(prefix="/api/sources", tags=["sources"])
@@ -74,7 +74,7 @@ async def create_source(body: SourceIn, ctx: Ctx = Depends(get_ctx)):
74
  select(func.count()).select_from(Source)
75
  .where(Source.workspace_id == ctx.workspace.id, Source.feed_url != IMPORTS_FEED)
76
  )
77
- if count >= plan.sources:
78
  raise plan_limit_error(plan, "sources", f"Your plan includes {plan.sources} sources.")
79
  source = Source(workspace_id=ctx.workspace.id, feed_url=found.feed_url, platform=found.platform,
80
  site_url=found.site_url, title=found.title)
@@ -135,8 +135,17 @@ def delete_source(source_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
135
  return {"ok": True, "removed_posts": len(docs)}
136
 
137
 
 
 
 
 
 
 
 
 
138
  @router.post("/url")
139
  def import_url(body: SourceIn, ctx: Ctx = Depends(get_ctx)):
 
140
  source = imports_source(ctx.db, ctx.workspace.id)
141
  ctx.db.commit()
142
  job = create_job(ctx.db, ctx.workspace.id, "import_url", {"source_id": str(source.id), "url": body.url})
@@ -148,6 +157,7 @@ def import_upload(body: UploadImportIn, ctx: Ctx = Depends(get_ctx)):
148
  upload = ctx.db.scalar(select(Upload).where(Upload.id == body.upload_id, Upload.workspace_id == ctx.workspace.id))
149
  if not upload or upload.status != "complete":
150
  raise HTTPException(404, "Upload not found or not finished")
 
151
  source = imports_source(ctx.db, ctx.workspace.id)
152
  ctx.db.commit()
153
  job = create_job(ctx.db, ctx.workspace.id, "import_upload",
 
10
  from app.models import Document, Job, NotebookDocument, Source, Upload
11
  from app.pipeline.ingest import IMPORTS_FEED, FeedNotFound, discover_feed, imports_source, rebuild_index
12
  from app.services.jobs import create_job, serialize_job
13
+ from app.services.plans import effective_plan, plan_limit_error, post_room
14
  from app.services.storage import storage
15
 
16
  router = APIRouter(prefix="/api/sources", tags=["sources"])
 
74
  select(func.count()).select_from(Source)
75
  .where(Source.workspace_id == ctx.workspace.id, Source.feed_url != IMPORTS_FEED)
76
  )
77
+ if 0 <= plan.sources <= count:
78
  raise plan_limit_error(plan, "sources", f"Your plan includes {plan.sources} sources.")
79
  source = Source(workspace_id=ctx.workspace.id, feed_url=found.feed_url, platform=found.platform,
80
  site_url=found.site_url, title=found.title)
 
135
  return {"ok": True, "removed_posts": len(docs)}
136
 
137
 
138
+ def _require_room(ctx: Ctx) -> None:
139
+ """A link or a file adds a post: refused (with the upgrade prompt) once the plan's posts are all indexed."""
140
+ plan = effective_plan(ctx.db, ctx.workspace)
141
+ if post_room(ctx.db, ctx.workspace.id, plan) <= 0:
142
+ raise plan_limit_error(plan, "indexed_posts", f"Your plan indexes up to {plan.indexed_posts} posts, and they are all "
143
+ "used. Remove a post or upgrade to add more.")
144
+
145
+
146
  @router.post("/url")
147
  def import_url(body: SourceIn, ctx: Ctx = Depends(get_ctx)):
148
+ _require_room(ctx)
149
  source = imports_source(ctx.db, ctx.workspace.id)
150
  ctx.db.commit()
151
  job = create_job(ctx.db, ctx.workspace.id, "import_url", {"source_id": str(source.id), "url": body.url})
 
157
  upload = ctx.db.scalar(select(Upload).where(Upload.id == body.upload_id, Upload.workspace_id == ctx.workspace.id))
158
  if not upload or upload.status != "complete":
159
  raise HTTPException(404, "Upload not found or not finished")
160
+ _require_room(ctx)
161
  source = imports_source(ctx.db, ctx.workspace.id)
162
  ctx.db.commit()
163
  job = create_job(ctx.db, ctx.workspace.id, "import_upload",
backend/app/routers/video_edit.py CHANGED
@@ -4,7 +4,7 @@
4
 
5
  Only paths in RULES pass (anything else is 404). Before forwarding, the video must be this workspace's
6
  (b2v_access.video_or_404), every template / voice / style id in a JSON body must be this workspace's (check_refs),
7
- premium (★) features need a paid plan, and AI edits come out of the workspace's own counter (given back if
8
  blog2video refuses). A template switch also uses one video, as it does on blog2video.
9
 
10
  JSON bodies, multipart uploads (images, voiceovers, portraits) and binary answers (downloads, stills) pass through.
@@ -35,8 +35,6 @@ S = r"\d+" # a scene id
35
  @dataclass(frozen=True)
36
  class Rule:
37
  ai_edits: int = 0 # AI edits this call uses (per workspace, video_limits "ai_edits")
38
- per_scene: bool = False # ai_edits is per scene_ids entry (avatar batches)
39
- premium: str | None = None # ★: what to call it in the upgrade message
40
  uses_video: bool = False # a template switch: one video from the allowance
41
  raw: bool = False # binary answer (download, still)
42
  timeout: float = 30
@@ -94,29 +92,12 @@ RULES: list[tuple[str, re.Pattern, Rule]] = [(m, re.compile(f"^{p}$"), r) for m,
94
  ("GET", "regenerate-script/preview", Rule()),
95
  ("POST", "regenerate-script/verify", Rule()),
96
  ("POST", "regenerate-script/regenerate", Rule()),
97
- # AI chat editing ★
98
- ("POST", "chat", Rule(ai_edits=1, premium="AI chat editing", timeout=120)),
99
- ("GET", "chat/history", Rule(premium="AI chat editing")),
100
- # Avatars ★ (blog2video charges the whole batch at authorize: 10 AI edits per scene)
101
- ("POST", "avatar-batch/authorize", Rule(ai_edits=10, per_scene=True, premium="Avatars")),
102
- ("POST", rf"scenes/{S}/avatar", Rule(premium="Avatars")),
103
- ("GET", rf"scenes/{S}/avatar-status", Rule()),
104
- ("DELETE", rf"scenes/{S}/avatar", Rule()),
105
- ("POST", rf"scenes/{S}/avatar-matte", Rule(premium="Avatars")),
106
- ("POST", "avatar-matte-all", Rule(premium="Avatars")),
107
- ("POST", "avatar-retry-failed", Rule(premium="Avatars")),
108
- ("GET", "avatar-progress", Rule()),
109
- ("POST", "avatar-portrait", Rule(premium="Avatars", timeout=60)),
110
- ("DELETE", "avatar-portrait", Rule()),
111
- ("PATCH", rf"scenes/{S}/avatar-focus", Rule()),
112
- ("PATCH", rf"scenes/{S}/avatar-appearance", Rule()),
113
  # Render, download, frames
114
  ("POST", "render", Rule(status="rendering")),
115
  ("GET", "render-status", Rule()),
116
  ("POST", "cancel-render", Rule()),
117
  ("GET", "download-url", Rule()),
118
  ("GET", "download", Rule(raw=True, timeout=120)),
119
- ("GET", "download-studio", Rule(raw=True, premium="The Remotion source download", timeout=120)),
120
  ("GET", "render-still", Rule(raw=True, timeout=60)),
121
  ("POST", "render-stills", Rule(timeout=120)),
122
  ]]
@@ -157,19 +138,12 @@ async def _read_body(request: Request) -> tuple[dict | None, dict | None, list |
157
 
158
 
159
  def _charge(ctx: Ctx, rule: Rule, body: dict | None, form: dict | None) -> int:
160
- """Premium, references and counters, all before blog2video is called. Returns AI edits taken."""
161
  plan = effective_plan(ctx.db, ctx.workspace)
162
- if rule.premium:
163
- video_limits.require_premium(ctx.db, ctx.workspace, rule.premium, plan)
164
  refs = body if isinstance(body, dict) else (form or {})
165
  with upstream(): # checking a template or voice may need blog2video's catalog
166
  b2v_access.check_refs(ctx, refs, plan)
167
  edits = rule.ai_edits
168
- if rule.per_scene:
169
- scenes = (body or {}).get("scene_ids") if isinstance(body, dict) else None
170
- if not isinstance(scenes, list) or not scenes:
171
- raise HTTPException(400, "Pick at least one scene")
172
- edits *= len(scenes)
173
  if edits:
174
  video_limits.take(ctx.db, ctx.workspace, "ai_edits", edits, plan=plan)
175
  if rule.uses_video and not video_quota.reserve(ctx.db, ctx.workspace.id):
 
4
 
5
  Only paths in RULES pass (anything else is 404). Before forwarding, the video must be this workspace's
6
  (b2v_access.video_or_404), every template / voice / style id in a JSON body must be this workspace's (check_refs),
7
+ AI edits come out of the workspace's own counter (given back if
8
  blog2video refuses). A template switch also uses one video, as it does on blog2video.
9
 
10
  JSON bodies, multipart uploads (images, voiceovers, portraits) and binary answers (downloads, stills) pass through.
 
35
  @dataclass(frozen=True)
36
  class Rule:
37
  ai_edits: int = 0 # AI edits this call uses (per workspace, video_limits "ai_edits")
 
 
38
  uses_video: bool = False # a template switch: one video from the allowance
39
  raw: bool = False # binary answer (download, still)
40
  timeout: float = 30
 
92
  ("GET", "regenerate-script/preview", Rule()),
93
  ("POST", "regenerate-script/verify", Rule()),
94
  ("POST", "regenerate-script/regenerate", Rule()),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  # Render, download, frames
96
  ("POST", "render", Rule(status="rendering")),
97
  ("GET", "render-status", Rule()),
98
  ("POST", "cancel-render", Rule()),
99
  ("GET", "download-url", Rule()),
100
  ("GET", "download", Rule(raw=True, timeout=120)),
 
101
  ("GET", "render-still", Rule(raw=True, timeout=60)),
102
  ("POST", "render-stills", Rule(timeout=120)),
103
  ]]
 
138
 
139
 
140
  def _charge(ctx: Ctx, rule: Rule, body: dict | None, form: dict | None) -> int:
141
+ """References and counters, all before blog2video is called. Returns AI edits taken."""
142
  plan = effective_plan(ctx.db, ctx.workspace)
 
 
143
  refs = body if isinstance(body, dict) else (form or {})
144
  with upstream(): # checking a template or voice may need blog2video's catalog
145
  b2v_access.check_refs(ctx, refs, plan)
146
  edits = rule.ai_edits
 
 
 
 
 
147
  if edits:
148
  video_limits.take(ctx.db, ctx.workspace, "ai_edits", edits, plan=plan)
149
  if rule.uses_video and not video_quota.reserve(ctx.db, ctx.workspace.id):
backend/app/routers/video_templates.py CHANGED
@@ -179,7 +179,6 @@ def delete_template(template_id: int, ctx: Ctx = Depends(get_ctx), _: None = Dep
179
 
180
  @dataclass(frozen=True)
181
  class Rule:
182
- ai_edit: bool = False # ★ one AI edit
183
  metric: str | None = None # another counter this uses (a lifetime slot, a daily AI step)
184
  raw: bool = False
185
 
@@ -190,8 +189,6 @@ RULES: list[tuple[str, re.Pattern, Rule]] = [(m, re.compile(f"^{p}$"), r) for m,
190
  ("PUT", "", Rule()),
191
  ("GET", "code", Rule()),
192
  ("POST", "upload-logo", Rule()),
193
- ("POST", f"scenes/{K}/ai-edit", Rule(ai_edit=True)),
194
- ("GET", f"scenes/{K}/ai-edit/status", Rule()),
195
  ("GET", "scene-drafts", Rule()),
196
  ("GET", f"scenes/{K}/draft", Rule()),
197
  ("POST", f"scenes/{K}/draft/apply", Rule()),
@@ -214,10 +211,6 @@ def _rule(method: str, path: str) -> Rule | None:
214
  def _take(ctx: Ctx, rule: Rule) -> list[str]:
215
  taken: list[str] = []
216
  try:
217
- if rule.ai_edit:
218
- plan = video_limits.require_premium(ctx.db, ctx.workspace, "AI template editing")
219
- video_limits.take(ctx.db, ctx.workspace, "ai_edits", plan=plan)
220
- taken.append("ai_edits")
221
  if rule.metric:
222
  video_limits.take(ctx.db, ctx.workspace, rule.metric)
223
  taken.append(rule.metric)
 
179
 
180
  @dataclass(frozen=True)
181
  class Rule:
 
182
  metric: str | None = None # another counter this uses (a lifetime slot, a daily AI step)
183
  raw: bool = False
184
 
 
189
  ("PUT", "", Rule()),
190
  ("GET", "code", Rule()),
191
  ("POST", "upload-logo", Rule()),
 
 
192
  ("GET", "scene-drafts", Rule()),
193
  ("GET", f"scenes/{K}/draft", Rule()),
194
  ("POST", f"scenes/{K}/draft/apply", Rule()),
 
211
  def _take(ctx: Ctx, rule: Rule) -> list[str]:
212
  taken: list[str] = []
213
  try:
 
 
 
 
214
  if rule.metric:
215
  video_limits.take(ctx.db, ctx.workspace, rule.metric)
216
  taken.append(rule.metric)
backend/app/routers/video_voices.py CHANGED
@@ -1,24 +1,19 @@
1
- """Voices for videos: the workspace's saved voices, the built-in library, custom voices (design, clone), and its
2
- Notestack voices (made on the Voice page; blog2video speaks with the same ElevenLabs account).
3
 
4
  "Your voices" (user_saved_voices, as many as wanted) is the one list for audio overviews and videos, and lives only
5
  here: blog2video's saved-voice list is one list for the whole account.
6
- Custom voices are made on our blog2video account, so b2v_custom_voices records which workspace made each one;
7
- only that workspace can use, play or delete it. Designing, keeping, cloning and samples are premium (★) and come out
8
- of the workspace's own daily and total counters (video_limits).
9
  """
10
 
11
  import logging
12
- from typing import Literal
13
 
14
- from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile
15
  from pydantic import BaseModel, Field
16
  from sqlalchemy import select
17
 
18
  from app.auth import Ctx, get_ctx
19
- from app.models import B2VCustomVoice, UserSavedVoice
20
  from app.routers.videos import b2v_ready, upstream
21
- from app.services import b2v_access, video_limits
22
  from app.services import blog2video as b2v
23
  from app.services.notestack_voices import notestack_voices, save_notestack_voice
24
 
@@ -26,7 +21,6 @@ log = logging.getLogger(__name__)
26
 
27
  router = APIRouter(prefix="/api/video-voices", tags=["videos"])
28
 
29
- CLONE_MAX_BYTES = 50 * 1024 * 1024
30
  STARTER_VOICES = 4 # a new workspace starts with this many free built-in voices saved
31
 
32
 
@@ -36,7 +30,7 @@ def _labels(v: dict) -> dict:
36
 
37
  def _voice_out(v: UserSavedVoice, ours: set[str] = frozenset()) -> dict:
38
  return {"voice_id": v.voice_id, "name": v.name, "preview_url": v.preview_url, "gender": v.gender,
39
- "accent": v.accent, "premium": v.premium, "is_custom": v.is_custom,
40
  "source": "notestack" if v.voice_id in ours else "blog2video"}
41
 
42
 
@@ -45,7 +39,7 @@ def _library_out(v: dict, saved: set[str]) -> dict:
45
  return {"voice_id": v["voice_id"], "name": (v.get("name") or "Voice").split(" - ")[0].strip(),
46
  "description": v.get("description") or labels.get("description") or "", "preview_url": v.get("preview_url"),
47
  "gender": labels.get("gender"), "accent": labels.get("accent"), "age": labels.get("age"),
48
- "premium": v.get("plan") == "paid", "saved": v["voice_id"] in saved}
49
 
50
 
51
  def _save_builtin(ctx: Ctx, v: dict) -> UserSavedVoice:
@@ -53,37 +47,36 @@ def _save_builtin(ctx: Ctx, v: dict) -> UserSavedVoice:
53
  row = UserSavedVoice(workspace_id=ctx.workspace.id, voice_id=v["voice_id"],
54
  name=(v.get("name") or "Voice").split(" - ")[0].strip()[:255],
55
  preview_url=v.get("preview_url"),
56
- gender=labels.get("gender"), accent=labels.get("accent"), premium=v.get("plan") == "paid")
57
  ctx.db.merge(row)
58
  return row
59
 
60
 
61
- def _saved(ctx: Ctx) -> list[UserSavedVoice]:
62
- return list(ctx.db.scalars(select(UserSavedVoice).where(UserSavedVoice.workspace_id == ctx.workspace.id)
63
- .order_by(UserSavedVoice.created_at)))
 
 
 
64
 
65
 
66
  @router.get("")
67
  def voices(ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
68
- """Your voices (audio overviews and videos), the built-in library (to save from) and my custom voices."""
69
  with upstream():
70
- library = b2v.prebuilt_voices()
71
- saved = _saved(ctx)
 
 
72
  if not saved:
73
- for v in [v for v in library if v.get("plan") != "paid" and v.get("voice_id")][:STARTER_VOICES]:
74
  _save_builtin(ctx, v)
75
  ctx.db.commit()
76
- saved = _saved(ctx)
77
  ids = {v.voice_id for v in saved}
78
- custom = ctx.db.scalars(select(B2VCustomVoice).where(B2VCustomVoice.workspace_id == ctx.workspace.id)
79
- .order_by(B2VCustomVoice.created_at.desc()))
80
- mine = notestack_voices(ctx.db, ctx.workspace.id)
81
- ours = {v["voice_id"] for v in mine}
82
  return {"saved": [_voice_out(v, ours) for v in saved],
83
  "notestack": [{**v, "saved": v["voice_id"] in ids} for v in mine],
84
- "library": [_library_out(v, ids) for v in library if v.get("voice_id")],
85
- "custom": [{"id": c.b2v_custom_voice_id, "voice_id": c.voice_id, "name": c.name, "source": c.source,
86
- "preview_url": c.preview_url, "saved": c.voice_id in ids} for c in custom]}
87
 
88
 
89
  class SaveIn(BaseModel):
@@ -94,25 +87,17 @@ class SaveIn(BaseModel):
94
  def save_voice(body: SaveIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
95
  if ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": body.voice_id}):
96
  return _voice_out(ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": body.voice_id}))
97
- custom = ctx.db.scalar(select(B2VCustomVoice).where(B2VCustomVoice.voice_id == body.voice_id,
98
- B2VCustomVoice.workspace_id == ctx.workspace.id))
99
  mine = next((v for v in notestack_voices(ctx.db, ctx.workspace.id) if v["voice_id"] == body.voice_id), None)
100
  if mine:
101
- # A Notestack voice: saved on any plan, premium to use in a video. No preview link (the Voice page plays it).
102
  row = save_notestack_voice(ctx.db, ctx.workspace.id, mine["voice_id"], mine["name"])
103
  ctx.db.commit()
104
  return _voice_out(row, {row.voice_id})
105
- if custom:
106
- row = ctx.db.merge(UserSavedVoice(workspace_id=ctx.workspace.id, voice_id=custom.voice_id, name=custom.name,
107
- preview_url=custom.preview_url, premium=True, is_custom=True))
108
- else:
109
- with upstream():
110
- v = next((v for v in b2v.prebuilt_voices() if v.get("voice_id") == body.voice_id), None)
111
- if not v:
112
- raise HTTPException(404, "Voice not found")
113
- if v.get("plan") == "paid":
114
- video_limits.require_premium(ctx.db, ctx.workspace, "This voice")
115
- row = _save_builtin(ctx, v)
116
  ctx.db.commit()
117
  return _voice_out(row)
118
 
@@ -125,158 +110,3 @@ def unsave_voice(voice_id: str, ctx: Ctx = Depends(get_ctx)):
125
  ctx.db.delete(row)
126
  ctx.db.commit()
127
  return {"ok": True}
128
-
129
-
130
- # Design a voice ★: a description or options -> a few previews; keep one to make it a custom voice
131
-
132
-
133
- class DesignPromptIn(BaseModel):
134
- prompt: str = Field(min_length=20, max_length=1000)
135
-
136
-
137
- class DesignPresetIn(BaseModel):
138
- gender: str = Field("", max_length=30)
139
- age: str = Field("", max_length=30)
140
- persona: str = Field("", max_length=100)
141
- speed: str = Field("", max_length=30)
142
- accent: str = Field("", max_length=60)
143
-
144
-
145
- def _design(ctx: Ctx, path: str, body: dict) -> dict:
146
- plan = video_limits.require_premium(ctx.db, ctx.workspace, "Designing a voice")
147
- video_limits.take(ctx.db, ctx.workspace, "voice_designs_daily", plan=plan)
148
- try:
149
- with upstream():
150
- data = b2v.request("POST", path, json=body, timeout=90) or {}
151
- except HTTPException:
152
- video_limits.give_back(ctx.db, ctx.workspace.id, "voice_designs_daily")
153
- raise
154
- return {"previews": [{k: p.get(k) for k in ("generated_voice_id", "audio_base_64", "media_type", "duration_secs")}
155
- for p in data.get("previews") or []]}
156
-
157
-
158
- @router.post("/design/prompt")
159
- def design_from_prompt(body: DesignPromptIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
160
- return _design(ctx, "/api/voices/design-from-prompt", body.model_dump())
161
-
162
-
163
- @router.post("/design/preset")
164
- def design_from_preset(body: DesignPresetIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
165
- return _design(ctx, "/api/voices/design-from-preset", body.model_dump())
166
-
167
-
168
- class KeepIn(BaseModel):
169
- generated_voice_id: str = Field(min_length=1, max_length=200)
170
- source: Literal["prompt", "preset"]
171
- name: str = Field(min_length=1, max_length=100)
172
- prompt_text: str | None = Field(None, max_length=1000)
173
- preview_url: str | None = Field(None, max_length=1000)
174
-
175
-
176
- def _record_custom(ctx: Ctx, made: dict, source: str) -> dict:
177
- row = B2VCustomVoice(b2v_custom_voice_id=int(made["id"]), workspace_id=ctx.workspace.id,
178
- voice_id=made["voice_id"], name=(made.get("name") or "My voice")[:255], source=source,
179
- preview_url=made.get("preview_url"))
180
- ctx.db.add(row)
181
- ctx.db.merge(UserSavedVoice(workspace_id=ctx.workspace.id, voice_id=row.voice_id, name=row.name,
182
- preview_url=row.preview_url, premium=True, is_custom=True)) # a new voice is saved
183
- ctx.db.commit()
184
- return {"id": row.b2v_custom_voice_id, "voice_id": row.voice_id, "name": row.name, "source": source,
185
- "preview_url": row.preview_url, "saved": True}
186
-
187
-
188
- def _make_custom(ctx: Ctx, source: str, send) -> dict:
189
- plan = video_limits.require_premium(ctx.db, ctx.workspace, "Custom voices")
190
- video_limits.take(ctx.db, ctx.workspace, "custom_voices", plan=plan)
191
- try:
192
- with upstream():
193
- made = send()
194
- except HTTPException:
195
- video_limits.give_back(ctx.db, ctx.workspace.id, "custom_voices")
196
- raise
197
- return _record_custom(ctx, made, source)
198
-
199
-
200
- @router.post("/custom", status_code=201)
201
- def keep_designed_voice(body: KeepIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
202
- """Keep one designed preview as a permanent custom voice."""
203
- payload = {"voice_id": body.generated_voice_id, "source": "prompt" if body.source == "prompt" else "form",
204
- "name": body.name, "prompt_text": body.prompt_text, "preview_url": body.preview_url}
205
- return _make_custom(ctx, body.source,
206
- lambda: b2v.request("POST", "/api/voices/custom", json=payload, timeout=60))
207
-
208
-
209
- @router.post("/clone", status_code=201)
210
- async def clone_voice(name: str = Form(..., min_length=1, max_length=100), remove_background_noise: bool = Form(True),
211
- file: UploadFile = File(...), ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
212
- """Clone a voice from one audio or video file (up to 50 MB)."""
213
- data = await file.read(CLONE_MAX_BYTES + 1)
214
- if not data:
215
- raise HTTPException(400, "The file is empty.")
216
- if len(data) > CLONE_MAX_BYTES:
217
- raise HTTPException(400, "The file must be under 50 MB.")
218
- form = {"name": name, "remove_background_noise": "true" if remove_background_noise else "false"}
219
- files = {"file": (file.filename or "voice.mp3", data, file.content_type or "application/octet-stream")}
220
- return _make_custom(ctx, "clone",
221
- lambda: b2v.request("POST", "/api/voices/clone", data=form, files=files, timeout=180))
222
-
223
-
224
- @router.get("/custom/{custom_id}/preview")
225
- def custom_preview(custom_id: int, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
226
- row = b2v_access.owned_custom_voice(ctx, custom_id)
227
- if row.preview_url:
228
- return {"preview_url": row.preview_url, "ready": True}
229
- with upstream():
230
- result = b2v.request("GET", f"/api/voices/custom/{custom_id}/preview") or {}
231
- if result.get("preview_url"):
232
- row.preview_url = result["preview_url"]
233
- saved = ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": row.voice_id})
234
- if saved:
235
- saved.preview_url = row.preview_url
236
- ctx.db.commit()
237
- return {"preview_url": result.get("preview_url"), "ready": bool(result.get("ready"))}
238
-
239
-
240
- @router.delete("/custom/{custom_id}")
241
- def delete_custom(custom_id: int, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
242
- row = b2v_access.owned_custom_voice(ctx, custom_id)
243
- try:
244
- with upstream():
245
- b2v.request("DELETE", f"/api/voices/custom/{custom_id}")
246
- except HTTPException as e:
247
- if e.status_code != 404:
248
- raise
249
- saved = ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": row.voice_id})
250
- if saved:
251
- ctx.db.delete(saved)
252
- ctx.db.delete(row)
253
- ctx.db.commit()
254
- video_limits.give_back(ctx.db, ctx.workspace.id, "custom_voices")
255
- return {"ok": True}
256
-
257
-
258
- # Voice sample ★: a few seconds of the chosen voice with the chosen tuning
259
-
260
-
261
- class SampleIn(BaseModel):
262
- voice_gender: Literal["female", "male"] = "female"
263
- voice_accent: str = Field("american", max_length=30)
264
- custom_voice_id: str | None = Field(None, max_length=100)
265
- voice_emotion: str | None = Field(None, max_length=200)
266
- video_style: str | None = Field(None, max_length=40)
267
-
268
-
269
- @router.post("/sample")
270
- def voice_sample(body: SampleIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
271
- plan = video_limits.require_premium(ctx.db, ctx.workspace, "Voice samples")
272
- payload = body.model_dump(exclude_none=True)
273
- with upstream(): # checking the voice may need blog2video's catalog
274
- b2v_access.check_refs(ctx, {k: v for k, v in payload.items() if k != "voice_emotion"}, plan)
275
- video_limits.take(ctx.db, ctx.workspace, "voice_samples_daily", plan=plan)
276
- try:
277
- with upstream():
278
- audio, ctype = b2v.request("POST", "/api/voice/preview", json=payload, timeout=60, raw=True)
279
- except HTTPException:
280
- video_limits.give_back(ctx.db, ctx.workspace.id, "voice_samples_daily")
281
- raise
282
- return Response(audio, media_type=ctype or "audio/mpeg", headers={"Cache-Control": "no-store"})
 
1
+ """Voices for videos: the workspace's saved voices, the free built-in library, and its Notestack voices (made on the Voice
2
+ page; they speak audio overviews, not videos).
3
 
4
  "Your voices" (user_saved_voices, as many as wanted) is the one list for audio overviews and videos, and lives only
5
  here: blog2video's saved-voice list is one list for the whole account.
 
 
 
6
  """
7
 
8
  import logging
 
9
 
10
+ from fastapi import APIRouter, Depends, HTTPException
11
  from pydantic import BaseModel, Field
12
  from sqlalchemy import select
13
 
14
  from app.auth import Ctx, get_ctx
15
+ from app.models import UserSavedVoice
16
  from app.routers.videos import b2v_ready, upstream
 
17
  from app.services import blog2video as b2v
18
  from app.services.notestack_voices import notestack_voices, save_notestack_voice
19
 
 
21
 
22
  router = APIRouter(prefix="/api/video-voices", tags=["videos"])
23
 
 
24
  STARTER_VOICES = 4 # a new workspace starts with this many free built-in voices saved
25
 
26
 
 
30
 
31
  def _voice_out(v: UserSavedVoice, ours: set[str] = frozenset()) -> dict:
32
  return {"voice_id": v.voice_id, "name": v.name, "preview_url": v.preview_url, "gender": v.gender,
33
+ "accent": v.accent,
34
  "source": "notestack" if v.voice_id in ours else "blog2video"}
35
 
36
 
 
39
  return {"voice_id": v["voice_id"], "name": (v.get("name") or "Voice").split(" - ")[0].strip(),
40
  "description": v.get("description") or labels.get("description") or "", "preview_url": v.get("preview_url"),
41
  "gender": labels.get("gender"), "accent": labels.get("accent"), "age": labels.get("age"),
42
+ "saved": v["voice_id"] in saved}
43
 
44
 
45
  def _save_builtin(ctx: Ctx, v: dict) -> UserSavedVoice:
 
47
  row = UserSavedVoice(workspace_id=ctx.workspace.id, voice_id=v["voice_id"],
48
  name=(v.get("name") or "Voice").split(" - ")[0].strip()[:255],
49
  preview_url=v.get("preview_url"),
50
+ gender=labels.get("gender"), accent=labels.get("accent"))
51
  ctx.db.merge(row)
52
  return row
53
 
54
 
55
+ def _saved(ctx: Ctx, ours: set[str] = frozenset()) -> list[UserSavedVoice]:
56
+ """The saved voices that can still be used: free built-in voices and the workspace's Notestack voices. Voices that
57
+ were designed or cloned for videos, and paid built-in ones, are gone."""
58
+ rows = ctx.db.scalars(select(UserSavedVoice).where(UserSavedVoice.workspace_id == ctx.workspace.id)
59
+ .order_by(UserSavedVoice.created_at))
60
+ return [v for v in rows if v.voice_id in ours or not (v.is_custom or v.premium)]
61
 
62
 
63
  @router.get("")
64
  def voices(ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
65
+ """Your voices (audio overviews and videos), the free built-in library (to save from) and your Notestack voices."""
66
  with upstream():
67
+ library = [v for v in b2v.prebuilt_voices() if v.get("plan") != "paid"]
68
+ mine = notestack_voices(ctx.db, ctx.workspace.id)
69
+ ours = {v["voice_id"] for v in mine}
70
+ saved = _saved(ctx, ours)
71
  if not saved:
72
+ for v in [v for v in library if v.get("voice_id")][:STARTER_VOICES]:
73
  _save_builtin(ctx, v)
74
  ctx.db.commit()
75
+ saved = _saved(ctx, ours)
76
  ids = {v.voice_id for v in saved}
 
 
 
 
77
  return {"saved": [_voice_out(v, ours) for v in saved],
78
  "notestack": [{**v, "saved": v["voice_id"] in ids} for v in mine],
79
+ "library": [_library_out(v, ids) for v in library if v.get("voice_id")]}
 
 
80
 
81
 
82
  class SaveIn(BaseModel):
 
87
  def save_voice(body: SaveIn, ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
88
  if ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": body.voice_id}):
89
  return _voice_out(ctx.db.get(UserSavedVoice, {"workspace_id": ctx.workspace.id, "voice_id": body.voice_id}))
 
 
90
  mine = next((v for v in notestack_voices(ctx.db, ctx.workspace.id) if v["voice_id"] == body.voice_id), None)
91
  if mine:
92
+ # A Notestack voice: saved on any plan, for audio overviews. No preview link (the Voice page plays it).
93
  row = save_notestack_voice(ctx.db, ctx.workspace.id, mine["voice_id"], mine["name"])
94
  ctx.db.commit()
95
  return _voice_out(row, {row.voice_id})
96
+ with upstream():
97
+ v = next((v for v in b2v.prebuilt_voices() if v.get("voice_id") == body.voice_id), None)
98
+ if not v or v.get("plan") == "paid":
99
+ raise HTTPException(404, "Voice not found")
100
+ row = _save_builtin(ctx, v)
 
 
 
 
 
 
101
  ctx.db.commit()
102
  return _voice_out(row)
103
 
 
110
  ctx.db.delete(row)
111
  ctx.db.commit()
112
  return {"ok": True}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/routers/videos.py CHANGED
@@ -140,7 +140,7 @@ class VideoOptions(BaseModel):
140
  # Step 1: Project (script review is always off: blog2video goes straight to the video)
141
  stock_footage_enabled: bool = True
142
  aspect_ratio: Literal["landscape", "portrait"] = "landscape"
143
- video_length: Literal["short", "medium", "detailed", "more_detailed"] = "short"
144
  logo_upload_id: uuid.UUID | None = None # a file uploaded through /api/storage/uploads
145
  logo_position: Literal["bottom_right", "bottom_left", "top_left", "top_right"] = "bottom_right"
146
  logo_opacity: float = Field(0.9, ge=0.1, le=1.0)
@@ -155,7 +155,6 @@ class VideoOptions(BaseModel):
155
  voice_gender: Literal["female", "male", "none"] = "female"
156
  voice_accent: str = Field("american", max_length=30)
157
  custom_voice_id: str | None = Field(None, max_length=100)
158
- voice_emotion: str | None = Field(None, max_length=200) # ["<expressiveness>","<speed>","<emotion>",...]
159
  bgm_track_id: str | None = Field(None, max_length=100)
160
  bgm_volume: float = Field(0.10, ge=0, le=1)
161
 
@@ -167,7 +166,6 @@ class VideoOptions(BaseModel):
167
  data["script_review_enabled"] = False
168
  if self.voice_gender == "none":
169
  data.pop("custom_voice_id", None)
170
- data.pop("voice_emotion", None)
171
  if not self.bgm_track_id:
172
  data.pop("bgm_volume", None)
173
  return data
@@ -369,9 +367,6 @@ def _check(ctx: Ctx, options: VideoOptions) -> tuple[dict, Plan]:
369
  if length := b2v_access.house_length(payload.get("video_style")):
370
  payload["video_length"] = length
371
  plan = b2v_access.check_refs(ctx, payload)
372
- accent = (ctx.workspace.brand_json or {}).get("accent")
373
- if plan.brand_kit and accent and "accent_color" not in payload:
374
- payload["accent_color"] = accent
375
  return payload, plan
376
 
377
 
@@ -626,10 +621,10 @@ def quota(ctx: Ctx = Depends(get_ctx)):
626
 
627
  def _with_posters(t: dict) -> dict:
628
  """Poster images for the template picker. Built-in templates have baked posters on blog2video's web app (a few
629
- have none; the picker then shows the template's colors); designer templates carry their own image."""
630
  if t.get("preview_image_url") and not t.get("preview_url"):
631
  return {**t, "preview_url": t["preview_image_url"]}
632
- if t.get("preview_url") or not t.get("id") or str(t["id"]).startswith("crafted_"):
633
  return t
634
  base = f"{settings.b2v_app_url.rstrip('/')}/template-posters/{t['id']}"
635
  return {**t, "preview_url": f"{base}.webp", "preview_portrait_url": f"{base}-portrait.webp"}
@@ -637,20 +632,15 @@ def _with_posters(t: dict) -> dict:
637
 
638
  @router.get("/catalog")
639
  def catalog(ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
640
- """What the wizard offers this workspace: built-in and designer templates, its own ready custom templates,
641
  built-in and its own styles, music, languages. Nothing another workspace made."""
642
  with upstream():
643
  builtin = b2v.builtin_templates()
644
- try:
645
- crafted = b2v.crafted_templates()
646
- except b2v.B2VError:
647
- crafted = []
648
  mine = [{"id": f"custom_{t.b2v_template_id}", "name": t.name, "custom": True}
649
  for t in b2v_access.my_templates(ctx, ready_only=True)]
650
  styles = b2v_access.visible_styles(ctx)
651
  music = b2v.music_tracks()
652
  return {"templates": [_with_posters(t) for t in builtin if isinstance(t, dict)],
653
- "crafted_templates": [_with_posters(t) for t in crafted if isinstance(t, dict)],
654
  "my_templates": mine, "video_styles": styles,
655
  "music": music, "languages": [{"code": c, "name": n} for c, n in LANGUAGES]}
656
 
@@ -888,10 +878,6 @@ class ReviewPreviewIn(BaseModel):
888
  revision: int = Field(0, ge=0)
889
 
890
 
891
- class ReviewAiIn(ReviewPreviewIn):
892
- instruction: str = Field(min_length=2, max_length=1000)
893
-
894
-
895
  class ReviewApproveScene(DraftScene):
896
  preferred_layout: str | None = Field(None, max_length=100)
897
  source_fingerprint: str | None = Field(None, max_length=200)
@@ -923,22 +909,6 @@ def narration_preview(artifact_id: uuid.UUID, scene_id: int, body: ReviewPreview
923
  json=body.model_dump(), timeout=60)
924
 
925
 
926
- @router.post("/{artifact_id}/script/scenes/{scene_id}/ai-preview")
927
- def ai_preview(artifact_id: uuid.UUID, scene_id: int, body: ReviewAiIn, ctx: Ctx = Depends(get_ctx),
928
- _: None = Depends(b2v_ready)):
929
- """★ Rewrite one scene from an instruction. Uses one AI edit."""
930
- _, vid, _ = b2v_access.video_or_404(ctx, artifact_id)
931
- plan = video_limits.require_premium(ctx.db, ctx.workspace, "AI rewrites")
932
- video_limits.take(ctx.db, ctx.workspace, "ai_edits", plan=plan)
933
- try:
934
- with upstream():
935
- return b2v.project_call("POST", vid, f"script-review/scenes/{scene_id}/ai-preview",
936
- json=body.model_dump(), timeout=60)
937
- except HTTPException:
938
- video_limits.give_back(ctx.db, ctx.workspace.id, "ai_edits")
939
- raise
940
-
941
-
942
  @router.post("/{artifact_id}/script/approve")
943
  def approve_script(artifact_id: uuid.UUID, body: ReviewApproveIn, ctx: Ctx = Depends(get_ctx),
944
  _: None = Depends(b2v_ready)):
 
140
  # Step 1: Project (script review is always off: blog2video goes straight to the video)
141
  stock_footage_enabled: bool = True
142
  aspect_ratio: Literal["landscape", "portrait"] = "landscape"
143
+ video_length: Literal["short", "medium"] = "short"
144
  logo_upload_id: uuid.UUID | None = None # a file uploaded through /api/storage/uploads
145
  logo_position: Literal["bottom_right", "bottom_left", "top_left", "top_right"] = "bottom_right"
146
  logo_opacity: float = Field(0.9, ge=0.1, le=1.0)
 
155
  voice_gender: Literal["female", "male", "none"] = "female"
156
  voice_accent: str = Field("american", max_length=30)
157
  custom_voice_id: str | None = Field(None, max_length=100)
 
158
  bgm_track_id: str | None = Field(None, max_length=100)
159
  bgm_volume: float = Field(0.10, ge=0, le=1)
160
 
 
166
  data["script_review_enabled"] = False
167
  if self.voice_gender == "none":
168
  data.pop("custom_voice_id", None)
 
169
  if not self.bgm_track_id:
170
  data.pop("bgm_volume", None)
171
  return data
 
367
  if length := b2v_access.house_length(payload.get("video_style")):
368
  payload["video_length"] = length
369
  plan = b2v_access.check_refs(ctx, payload)
 
 
 
370
  return payload, plan
371
 
372
 
 
621
 
622
  def _with_posters(t: dict) -> dict:
623
  """Poster images for the template picker. Built-in templates have baked posters on blog2video's web app (a few
624
+ have none; the picker then shows the template's colors)."""
625
  if t.get("preview_image_url") and not t.get("preview_url"):
626
  return {**t, "preview_url": t["preview_image_url"]}
627
+ if t.get("preview_url") or not t.get("id"):
628
  return t
629
  base = f"{settings.b2v_app_url.rstrip('/')}/template-posters/{t['id']}"
630
  return {**t, "preview_url": f"{base}.webp", "preview_portrait_url": f"{base}-portrait.webp"}
 
632
 
633
  @router.get("/catalog")
634
  def catalog(ctx: Ctx = Depends(get_ctx), _: None = Depends(b2v_ready)):
635
+ """What the wizard offers this workspace: built-in templates, its own ready custom templates,
636
  built-in and its own styles, music, languages. Nothing another workspace made."""
637
  with upstream():
638
  builtin = b2v.builtin_templates()
 
 
 
 
639
  mine = [{"id": f"custom_{t.b2v_template_id}", "name": t.name, "custom": True}
640
  for t in b2v_access.my_templates(ctx, ready_only=True)]
641
  styles = b2v_access.visible_styles(ctx)
642
  music = b2v.music_tracks()
643
  return {"templates": [_with_posters(t) for t in builtin if isinstance(t, dict)],
 
644
  "my_templates": mine, "video_styles": styles,
645
  "music": music, "languages": [{"code": c, "name": n} for c, n in LANGUAGES]}
646
 
 
878
  revision: int = Field(0, ge=0)
879
 
880
 
 
 
 
 
881
  class ReviewApproveScene(DraftScene):
882
  preferred_layout: str | None = Field(None, max_length=100)
883
  source_fingerprint: str | None = Field(None, max_length=200)
 
909
  json=body.model_dump(), timeout=60)
910
 
911
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
912
  @router.post("/{artifact_id}/script/approve")
913
  def approve_script(artifact_id: uuid.UUID, body: ReviewApproveIn, ctx: Ctx = Depends(get_ctx),
914
  _: None = Depends(b2v_ready)):
backend/app/services/artifact_feedback.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Thumbs up or down on generated reports, quizzes, flashcards and infographics: what can be rated, why a thumbs down can be
2
+ given, and how the ratings ride along on the artifacts the app lists."""
3
+
4
+ import uuid
5
+
6
+ from sqlalchemy import select
7
+ from sqlalchemy.orm import Session
8
+
9
+ from app.models import Artifact, ArtifactFeedback
10
+
11
+ # What each kind can be marked down for (the ids the app sends; anything else is dropped).
12
+ REASONS: dict[str, set[str]] = {
13
+ "report": {"inaccurate", "missed_points", "structure", "tone", "too_long", "too_short", "other"},
14
+ "quiz": {"wrong_answers", "too_easy", "too_hard", "unclear", "off_topic", "other"},
15
+ "flashcards": {"inaccurate", "too_basic", "too_detailed", "unclear", "other"},
16
+ "infographic": {"messy_layout", "cut_off", "missing_content", "look", "hard_to_read", "other"},
17
+ }
18
+ RATEABLE = tuple(REASONS)
19
+
20
+
21
+ def feedback_out(f: ArtifactFeedback | None) -> dict | None:
22
+ if not f:
23
+ return None
24
+ return {"rating": "up" if f.rating > 0 else "down", "reasons": f.reasons or [], "comment": f.comment}
25
+
26
+
27
+ def attach(db: Session, user_id: uuid.UUID, items: list[dict]) -> list[dict]:
28
+ """Add `feedback` (this person's rating, or None) to each serialized artifact that can be rated. One query."""
29
+ ids = [uuid.UUID(i["id"]) for i in items if i.get("type") in RATEABLE]
30
+ rows = {f.artifact_id: f for f in db.scalars(select(ArtifactFeedback).where(
31
+ ArtifactFeedback.artifact_id.in_(ids), ArtifactFeedback.user_id == user_id))} if ids else {}
32
+ for i in items:
33
+ if i.get("type") in RATEABLE:
34
+ i["feedback"] = feedback_out(rows.get(uuid.UUID(i["id"])))
35
+ return items
36
+
37
+
38
+ def snapshot(a: Artifact) -> dict:
39
+ """What the artifact was when it was rated: enough to tell what a rating is about."""
40
+ c = a.content_json or {}
41
+ inner = c.get("content") if isinstance(c.get("content"), dict) else {}
42
+ keep = {k: c.get(k) for k in ("title", "prompt", "source", "theme", "format", "template_id", "difficulty", "question_count",
43
+ "card_count", "language", "topic") if c.get(k) is not None}
44
+ if a.type == "infographic":
45
+ keep["layout"] = "composed" if "body_html" in inner else "fixed"
46
+ keep["fit"] = inner.get("fit")
47
+ return keep
backend/app/services/b2v_access.py CHANGED
@@ -1,6 +1,6 @@
1
  """Who owns what on our one blog2video account.
2
 
3
- blog2video's ownership checks stop at the account: any video, custom template, custom voice or style made with our
4
  key can be used, listed and edited with it. So every route that takes a blog2video id checks here first that it
5
  belongs to the current workspace (404 otherwise, so another workspace's ids are never confirmed), and every create or
6
  edit body has its references checked before it is forwarded (check_refs).
@@ -12,11 +12,9 @@ from fastapi import HTTPException
12
  from sqlalchemy import select
13
 
14
  from app.auth import Ctx
15
- from app.models import Artifact, B2VCustomVoice, B2VStyle, B2VTemplate, B2VVideo
16
  from app.services import blog2video as b2v
17
- from app.services.notestack_voices import is_notestack_voice
18
  from app.services.plans import Plan, effective_plan
19
- from app.services.video_limits import require_premium
20
 
21
  BUILTIN_STYLES = {"auto", "explainer", "storytelling", "promotional"}
22
  # Custom styles made on our blog2video account itself, offered to every workspace in place of the others when they
@@ -29,7 +27,6 @@ HOUSE_STYLES = [ # (name on blog2video, video length, what the wizard says it i
29
  "scene building on the last. Best for YouTube, tutorials and full explainers. Usually 1 to "
30
  "2 minutes long."),
31
  ]
32
- PREMIUM_LENGTHS = {"detailed", "more_detailed", "mdetailed"}
33
 
34
 
35
  def not_found(what: str = "Not found") -> HTTPException:
@@ -79,14 +76,6 @@ def owned_template(ctx: Ctx, template_id: int) -> B2VTemplate:
79
  return row
80
 
81
 
82
- def owned_custom_voice(ctx: Ctx, custom_voice_id: int) -> B2VCustomVoice:
83
- row = ctx.db.scalar(select(B2VCustomVoice).where(B2VCustomVoice.b2v_custom_voice_id == custom_voice_id,
84
- B2VCustomVoice.workspace_id == ctx.workspace.id))
85
- if not row:
86
- raise not_found("Voice not found")
87
- return row
88
-
89
-
90
  def owned_style(ctx: Ctx, style_id: int) -> B2VStyle:
91
  row = ctx.db.scalar(select(B2VStyle).where(B2VStyle.b2v_style_id == style_id,
92
  B2VStyle.workspace_id == ctx.workspace.id))
@@ -100,15 +89,6 @@ def _custom_id(ref: str, prefix: str) -> int | None:
100
  return int(raw) if ref.startswith(prefix) and raw.isdigit() else None
101
 
102
 
103
- def crafted_ids() -> set[str]:
104
- out = set()
105
- for t in b2v.crafted_templates():
106
- tid = t.get("id") if isinstance(t, dict) else t
107
- if isinstance(tid, str) and tid.startswith("crafted_"):
108
- out.add(tid)
109
- return out
110
-
111
-
112
  def check_template(ctx: Ctx, template: str, plan: Plan) -> None:
113
  if template == "default":
114
  return
@@ -117,12 +97,6 @@ def check_template(ctx: Ctx, template: str, plan: Plan) -> None:
117
  if not row.ready:
118
  raise HTTPException(400, "That template is still being made.")
119
  return
120
- if template.startswith("crafted_"):
121
- # blog2video silently falls back to "default" for an unknown id, so check it exists.
122
- if template not in crafted_ids():
123
- raise not_found("Template not found")
124
- require_premium(ctx.db, ctx.workspace, "Designer templates", plan)
125
- return
126
  if template not in {t.get("id") for t in b2v.builtin_templates() if isinstance(t, dict)}:
127
  raise not_found("Template not found")
128
 
@@ -157,38 +131,26 @@ def check_style(ctx: Ctx, style: str) -> None:
157
  raise not_found("Video style not found") # includes your_style, which learns from every workspace's edits
158
 
159
 
160
- def check_voice(ctx: Ctx, voice_id: str, plan: Plan) -> None:
161
- """A built-in voice (premium if blog2video marks it paid), or one of this workspace's custom or Notestack
162
- voices."""
163
- prebuilt = {v.get("voice_id"): v for v in b2v.prebuilt_voices()}
164
- if voice_id in prebuilt:
165
- if prebuilt[voice_id].get("plan") == "paid":
166
- require_premium(ctx.db, ctx.workspace, "This voice", plan)
167
- return
168
- if ctx.db.scalar(select(B2VCustomVoice).where(B2VCustomVoice.voice_id == voice_id,
169
- B2VCustomVoice.workspace_id == ctx.workspace.id)):
170
- require_premium(ctx.db, ctx.workspace, "Custom voices", plan)
171
- return
172
- # One of this workspace's Notestack voices (clone, designed, added): same ElevenLabs account, premium like custom.
173
- if is_notestack_voice(ctx.db, ctx.workspace.id, voice_id):
174
- require_premium(ctx.db, ctx.workspace, "Custom voices", plan)
175
- return
176
- raise not_found("Voice not found")
177
 
178
 
179
  def check_refs(ctx: Ctx, body: dict, plan: Plan | None = None) -> Plan:
180
- """Every id in a create or edit body must be this workspace's, and premium options need a paid plan."""
181
  plan = plan or effective_plan(ctx.db, ctx.workspace)
182
  if body.get("template"):
183
  check_template(ctx, str(body["template"]), plan)
184
  if body.get("video_style"):
185
  check_style(ctx, str(body["video_style"]))
186
  if body.get("custom_voice_id") and body.get("voice_gender") != "none":
187
- check_voice(ctx, str(body["custom_voice_id"]), plan)
188
- if str(body.get("video_length") or "") in PREMIUM_LENGTHS:
189
- require_premium(ctx.db, ctx.workspace, "Longer videos", plan)
190
  if body.get("voice_emotion"):
191
- require_premium(ctx.db, ctx.workspace, "Advanced voice options", plan)
192
  return plan
193
 
194
 
 
1
  """Who owns what on our one blog2video account.
2
 
3
+ blog2video's ownership checks stop at the account: any video, custom template or style made with our
4
  key can be used, listed and edited with it. So every route that takes a blog2video id checks here first that it
5
  belongs to the current workspace (404 otherwise, so another workspace's ids are never confirmed), and every create or
6
  edit body has its references checked before it is forwarded (check_refs).
 
12
  from sqlalchemy import select
13
 
14
  from app.auth import Ctx
15
+ from app.models import Artifact, B2VStyle, B2VTemplate, B2VVideo
16
  from app.services import blog2video as b2v
 
17
  from app.services.plans import Plan, effective_plan
 
18
 
19
  BUILTIN_STYLES = {"auto", "explainer", "storytelling", "promotional"}
20
  # Custom styles made on our blog2video account itself, offered to every workspace in place of the others when they
 
27
  "scene building on the last. Best for YouTube, tutorials and full explainers. Usually 1 to "
28
  "2 minutes long."),
29
  ]
 
30
 
31
 
32
  def not_found(what: str = "Not found") -> HTTPException:
 
76
  return row
77
 
78
 
 
 
 
 
 
 
 
 
79
  def owned_style(ctx: Ctx, style_id: int) -> B2VStyle:
80
  row = ctx.db.scalar(select(B2VStyle).where(B2VStyle.b2v_style_id == style_id,
81
  B2VStyle.workspace_id == ctx.workspace.id))
 
89
  return int(raw) if ref.startswith(prefix) and raw.isdigit() else None
90
 
91
 
 
 
 
 
 
 
 
 
 
92
  def check_template(ctx: Ctx, template: str, plan: Plan) -> None:
93
  if template == "default":
94
  return
 
97
  if not row.ready:
98
  raise HTTPException(400, "That template is still being made.")
99
  return
 
 
 
 
 
 
100
  if template not in {t.get("id") for t in b2v.builtin_templates() if isinstance(t, dict)}:
101
  raise not_found("Template not found")
102
 
 
131
  raise not_found("Video style not found") # includes your_style, which learns from every workspace's edits
132
 
133
 
134
+ def check_voice(voice_id: str) -> None:
135
+ """A free built-in voice (blog2video marks the paid ones)."""
136
+ voice = next((v for v in b2v.prebuilt_voices() if v.get("voice_id") == voice_id), None)
137
+ if not voice or voice.get("plan") == "paid":
138
+ raise not_found("Voice not found")
 
 
 
 
 
 
 
 
 
 
 
 
139
 
140
 
141
  def check_refs(ctx: Ctx, body: dict, plan: Plan | None = None) -> Plan:
142
+ """Every id in a create or edit body must be this workspace's, and only the standard options are accepted."""
143
  plan = plan or effective_plan(ctx.db, ctx.workspace)
144
  if body.get("template"):
145
  check_template(ctx, str(body["template"]), plan)
146
  if body.get("video_style"):
147
  check_style(ctx, str(body["video_style"]))
148
  if body.get("custom_voice_id") and body.get("voice_gender") != "none":
149
+ check_voice(str(body["custom_voice_id"]))
150
+ if str(body.get("video_length") or "") not in {"", "short", "medium"}:
151
+ raise HTTPException(400, "That video length is not available.")
152
  if body.get("voice_emotion"):
153
+ raise HTTPException(400, "Voice tuning is not available.")
154
  return plan
155
 
156
 
backend/app/services/billing.py CHANGED
@@ -10,7 +10,7 @@ from sqlalchemy import func, select
10
  from sqlalchemy.orm import Session
11
 
12
  from app.config import settings
13
- from app.models import Artifact, CalendarItem, Notebook, Source, Subscription, User, VoiceProfile, Workspace
14
  from app.services.plans import PLANS, Plan, effective_plan, next_plan, plan_dict
15
  from app.services.usage import month_usage
16
  from app.services.video_quota import sync_video_quota, video_usage
@@ -27,15 +27,30 @@ METERS = {
27
 
28
  def _meters(plan: Plan, used: dict, sources: int) -> list[dict]:
29
  rows = []
30
- for key, (label, unit) in METERS.items():
 
 
 
 
31
  limit = getattr(plan, key)
32
  rows.append({"key": key, "label": label, "unit": unit, "used": used[key], "limit": limit,
33
  "pct": 0 if limit < 0 else round(min(1.0, used[key] / max(limit, 1)), 3)})
34
  rows.append({"key": "sources", "label": "Sources", "unit": "", "used": sources, "limit": plan.sources,
35
- "pct": round(min(1.0, sources / max(plan.sources, 1)), 3)})
36
  return rows
37
 
38
 
 
 
 
 
 
 
 
 
 
 
 
39
  def _count(db: Session, model, workspace_id, *where) -> int:
40
  return db.scalar(select(func.count()).select_from(model).where(model.workspace_id == workspace_id, *where)) or 0
41
 
@@ -57,23 +72,20 @@ def build_nudges(db: Session, workspace: Workspace, plan: Plan, meters: list[dic
57
  continue
58
  label = m["label"].lower()
59
  amount = f"{m['limit']:g} min of {label}" if m["unit"] else f"{m['limit']:g} {label}"
 
60
  if m["pct"] >= 1:
61
- out.append(_nudge(f"empty-{m['key']}-{month}", "limit", f"Out of {label} this month",
62
  f"You have used all {amount}. Upgrade to keep launching.", "Refuel now",
63
  priority=100))
64
  elif m["pct"] >= LOW_FUEL:
65
  left = f"{m['limit'] - m['used']:g}" + (" min" if m["unit"] else "")
66
  out.append(_nudge(f"low-{m['key']}-{month}", "limit", f"Running low on {label}",
67
- f"Only {left} left this month. Upgrade before you stall mid launch.",
68
  "See plans", priority=90))
69
  if not plan.voice_cloning:
70
  out.append(_nudge("perk-voice", "upgrade", "Narrate in your own voice",
71
  "Writer unlocks voice cloning, so every audio overview sounds like you.",
72
  "Unlock voice", priority=40))
73
- if not plan.brand_kit:
74
- out.append(_nudge("perk-brand", "upgrade", "Put your brand on every render",
75
- "Your colors and logo on videos, quote cards and carousels.", "Unlock brand kit",
76
- priority=30))
77
 
78
  sources = _count(db, Source, ws)
79
  if sources == 0:
@@ -119,7 +131,7 @@ def build_nudges(db: Session, workspace: Workspace, plan: Plan, meters: list[dic
119
 
120
  def billing_status(db: Session, workspace: Workspace) -> dict:
121
  plan = effective_plan(db, workspace)
122
- used = month_usage(db, workspace.id)
123
  videos = video_usage(db, workspace)
124
  used["videos"] = videos["used"]
125
  sources = _count(db, Source, workspace.id)
@@ -136,6 +148,7 @@ def billing_status(db: Session, workspace: Workspace) -> dict:
136
  "has_billing_account": bool(sub and sub.provider_customer_id),
137
  "period_end": sub.current_period_end.isoformat() if sub and sub.current_period_end else None,
138
  "meters": meters,
 
139
  "since": used["since"],
140
  "videos_resets_at": videos["resets_at"],
141
  "nudges": build_nudges(db, workspace, plan, meters, can_upgrade),
 
10
  from sqlalchemy.orm import Session
11
 
12
  from app.config import settings
13
+ from app.models import Artifact, CalendarItem, Document, Notebook, Source, Subscription, User, VoiceProfile, Workspace
14
  from app.services.plans import PLANS, Plan, effective_plan, next_plan, plan_dict
15
  from app.services.usage import month_usage
16
  from app.services.video_quota import sync_video_quota, video_usage
 
27
 
28
  def _meters(plan: Plan, used: dict, sources: int) -> list[dict]:
29
  rows = []
30
+ meters = dict(METERS)
31
+ if plan.audio_overviews >= 0: # a count of audio overviews (Free), not minutes
32
+ meters = {("audio_overviews" if k == "audio_minutes" else k): (("Audio overviews", "") if k == "audio_minutes" else v)
33
+ for k, v in METERS.items()}
34
+ for key, (label, unit) in meters.items():
35
  limit = getattr(plan, key)
36
  rows.append({"key": key, "label": label, "unit": unit, "used": used[key], "limit": limit,
37
  "pct": 0 if limit < 0 else round(min(1.0, used[key] / max(limit, 1)), 3)})
38
  rows.append({"key": "sources", "label": "Sources", "unit": "", "used": sources, "limit": plan.sources,
39
+ "pct": 0 if plan.sources < 0 else round(min(1.0, sources / max(plan.sources, 1)), 3)})
40
  return rows
41
 
42
 
43
+ def _more_meters(db: Session, workspace: Workspace, plan: Plan, used: dict) -> list[dict]:
44
+ """The rest of the plan's limits, shown when the sidebar card is opened: indexed posts, reports and infographics."""
45
+ posts = db.scalar(select(func.count()).select_from(Document)
46
+ .where(Document.workspace_id == workspace.id, Document.path.is_not(None))) or 0
47
+ rows = [("indexed_posts", "Posts indexed", posts, plan.indexed_posts),
48
+ ("reports", "Reports", used["reports"], plan.reports),
49
+ ("infographics", "Infographics", used["infographics"], plan.infographics)]
50
+ return [{"key": k, "label": label, "unit": "", "used": u, "limit": limit,
51
+ "pct": 0 if limit < 0 else round(min(1.0, u / max(limit, 1)), 3)} for k, label, u, limit in rows]
52
+
53
+
54
  def _count(db: Session, model, workspace_id, *where) -> int:
55
  return db.scalar(select(func.count()).select_from(model).where(model.workspace_id == workspace_id, *where)) or 0
56
 
 
72
  continue
73
  label = m["label"].lower()
74
  amount = f"{m['limit']:g} min of {label}" if m["unit"] else f"{m['limit']:g} {label}"
75
+ when = "" if plan.lifetime else " this month"
76
  if m["pct"] >= 1:
77
+ out.append(_nudge(f"empty-{m['key']}-{month}", "limit", f"Out of {label}{when}",
78
  f"You have used all {amount}. Upgrade to keep launching.", "Refuel now",
79
  priority=100))
80
  elif m["pct"] >= LOW_FUEL:
81
  left = f"{m['limit'] - m['used']:g}" + (" min" if m["unit"] else "")
82
  out.append(_nudge(f"low-{m['key']}-{month}", "limit", f"Running low on {label}",
83
+ f"Only {left} left{when}. Upgrade before you stall mid launch.",
84
  "See plans", priority=90))
85
  if not plan.voice_cloning:
86
  out.append(_nudge("perk-voice", "upgrade", "Narrate in your own voice",
87
  "Writer unlocks voice cloning, so every audio overview sounds like you.",
88
  "Unlock voice", priority=40))
 
 
 
 
89
 
90
  sources = _count(db, Source, ws)
91
  if sources == 0:
 
131
 
132
  def billing_status(db: Session, workspace: Workspace) -> dict:
133
  plan = effective_plan(db, workspace)
134
+ used = month_usage(db, workspace.id, plan.lifetime)
135
  videos = video_usage(db, workspace)
136
  used["videos"] = videos["used"]
137
  sources = _count(db, Source, workspace.id)
 
148
  "has_billing_account": bool(sub and sub.provider_customer_id),
149
  "period_end": sub.current_period_end.isoformat() if sub and sub.current_period_end else None,
150
  "meters": meters,
151
+ "more_meters": _more_meters(db, workspace, plan, used),
152
  "since": used["since"],
153
  "videos_resets_at": videos["resets_at"],
154
  "nudges": build_nudges(db, workspace, plan, meters, can_upgrade),
backend/app/services/notestack_voices.py CHANGED
@@ -1,6 +1,5 @@
1
  """The workspace's own ElevenLabs voices, made or added on the Voice page: its clone, designed voices, and voices added
2
- from the ElevenLabs library. blog2video speaks with the same ElevenLabs account, so these can be used in videos too
3
- (as `custom_voice_id`); video_voices lists them and b2v_access accepts them."""
4
 
5
  import uuid
6
 
@@ -32,8 +31,8 @@ def is_notestack_voice(db: Session, workspace_id: uuid.UUID, voice_id: str) -> b
32
 
33
 
34
  def save_notestack_voice(db: Session, workspace_id: uuid.UUID, voice_id: str, name: str) -> UserSavedVoice:
35
- """Put a Notestack voice in the workspace's voices (the one list for audio and video), once. Saving needs no plan:
36
- using it in a video is premium (b2v_access.check_voice). No preview link: it is played through /api/voice/preview.
37
  The caller commits."""
38
  row = db.get(UserSavedVoice, {"workspace_id": workspace_id, "voice_id": voice_id})
39
  if row is None:
 
1
  """The workspace's own ElevenLabs voices, made or added on the Voice page: its clone, designed voices, and voices added
2
+ from the ElevenLabs library. They speak audio overviews; video_voices lists them with the workspace's saved voices."""
 
3
 
4
  import uuid
5
 
 
31
 
32
 
33
  def save_notestack_voice(db: Session, workspace_id: uuid.UUID, voice_id: str, name: str) -> UserSavedVoice:
34
+ """Put a Notestack voice in the workspace's voices (the one list for audio and video), once. Saving needs no plan; it speaks audio
35
+ overviews (a video uses the free built-in voices). No preview link: it is played through /api/voice/preview.
36
  The caller commits."""
37
  row = db.get(UserSavedVoice, {"workspace_id": workspace_id, "voice_id": voice_id})
38
  if row is None:
backend/app/services/plans.py CHANGED
@@ -31,36 +31,32 @@ class Plan:
31
  indexed_posts: int
32
  audio_minutes: int
33
  videos: int # blog2video videos per month, or in total when videos_monthly is False
34
- launch_kits: int # -1 = unlimited
35
  voice_cloning: bool
36
- brand_kit: bool
37
  features: tuple[str, ...]
38
- # Premium (★) video options: long durations, paid and custom voices, voice tuning and samples, AI script
39
- # rewrites, AI chat editing, avatars, the Remotion source download.
40
- video_premium: bool = False
41
  # Per-workspace shares of what blog2video only limits account-wide (keys in VIDEO_LIMITS; 0 = none).
42
  video_limits: dict = field(default_factory=dict)
43
  videos_monthly: bool = True
44
  reports: int = 0 # reports a month; -1 = unlimited
45
- infographics: int = 0 # infographics a month; -1 = unlimited
 
 
 
 
 
 
46
 
47
 
48
  # metric -> period it counts over (app/services/video_limits.py)
49
  VIDEO_LIMITS = {
50
- "ai_edits": "month", # scene regenerate/add, AI images, stock clips, AI chat, template AI edits, avatars
51
  "templates": "all", # custom templates: blog2video's slots are lifetime, never given back
52
  "template_ai_daily": "day", # template theme extraction from a prompt or document, code generation
53
- "voice_designs_daily": "day",
54
- "voice_samples_daily": "day",
55
- "custom_voices": "all", # kept voices; deleting one frees its place
56
  }
57
 
58
- FREE_VIDEO_LIMITS = {"ai_edits": 20, "templates": 0, "template_ai_daily": 0, "voice_designs_daily": 0,
59
- "voice_samples_daily": 0, "custom_voices": 0}
60
- WRITER_VIDEO_LIMITS = {"ai_edits": 300, "templates": 2, "template_ai_daily": 3, "voice_designs_daily": 5,
61
- "voice_samples_daily": 20, "custom_voices": 3}
62
- STUDIO_VIDEO_LIMITS = {"ai_edits": 1000, "templates": 5, "template_ai_daily": 5, "voice_designs_daily": 10,
63
- "voice_samples_daily": 40, "custom_voices": 10}
64
 
65
 
66
  DEFAULT_PLANS: dict[str, Plan] = {
@@ -69,23 +65,23 @@ DEFAULT_PLANS: dict[str, Plan] = {
69
  name="Free",
70
  tagline="For trying Notestack on your archive",
71
  price_monthly_usd=0,
72
- sources=1,
73
  indexed_posts=5,
74
- audio_minutes=3,
 
75
  videos=1,
76
  launch_kits=2,
77
  reports=2,
78
  infographics=2,
79
  voice_cloning=False,
80
- brand_kit=False,
81
  features=(
82
- "1 source, your latest 5 posts indexed",
83
  "Grounded research chat with citations",
84
- "3 min of audio overviews a month",
85
- "1 video to try it",
86
- "2 Launch Kits a month",
 
87
  "2 reports a month",
88
- "2 infographics a month",
89
  ),
90
  video_limits=FREE_VIDEO_LIMITS,
91
  videos_monthly=False,
@@ -95,26 +91,23 @@ DEFAULT_PLANS: dict[str, Plan] = {
95
  name="Writer",
96
  tagline="For writers publishing every week",
97
  price_monthly_usd=24.99,
98
- sources=3,
99
- indexed_posts=500,
100
  audio_minutes=60,
101
  videos=10,
102
- launch_kits=50,
103
  reports=30,
104
  infographics=30,
105
  voice_cloning=True,
106
- brand_kit=True,
107
  features=(
108
- "3 sources, 500 indexed posts",
109
- "60 min of audio overviews a month",
110
  "10 videos a month",
111
- "50 Launch Kits a month",
112
  "30 reports a month",
113
  "30 infographics a month",
114
  "Voice cloning with consent",
115
- "Your brand colors and logo",
116
  ),
117
- video_premium=True,
118
  video_limits=WRITER_VIDEO_LIMITS,
119
  ),
120
  "studio": Plan(
@@ -122,26 +115,24 @@ DEFAULT_PLANS: dict[str, Plan] = {
122
  name="Studio",
123
  tagline="For publications and power users",
124
  price_monthly_usd=48.99,
125
- sources=10,
126
- indexed_posts=5000,
127
  audio_minutes=240,
128
  videos=20,
129
- launch_kits=-1,
130
  reports=-1,
131
  infographics=-1,
132
  voice_cloning=True,
133
- brand_kit=True,
134
  features=(
135
- "10 sources, 5,000 indexed posts",
136
- "240 min of audio overviews a month",
137
  "20 videos a month",
138
- "Unlimited Launch Kits",
139
  "Unlimited reports",
140
  "Unlimited infographics",
141
  "Launchpad calendar and resurfacing",
142
  "Priority rendering",
143
  ),
144
- video_premium=True,
145
  video_limits=STUDIO_VIDEO_LIMITS,
146
  ),
147
  }
@@ -154,10 +145,10 @@ def _plan_from_row(row: PlanRecord) -> Plan:
154
  return Plan(id=row.id, name=row.name, tagline=row.tagline or "",
155
  price_monthly_usd=float(row.price_monthly_usd or 0), sources=row.sources,
156
  indexed_posts=row.indexed_posts, audio_minutes=row.audio_minutes,
157
- videos=row.videos, launch_kits=row.launch_kits, reports=row.reports, infographics=row.infographics,
158
  voice_cloning=bool(row.voice_cloning),
159
- brand_kit=bool(row.brand_kit), features=tuple(row.features or ()),
160
- video_premium=bool(row.video_premium), videos_monthly=bool(row.videos_monthly),
161
  video_limits={**{k: 0 for k in VIDEO_LIMITS}, **(row.video_limits or {})})
162
 
163
 
@@ -251,6 +242,22 @@ def effective_plan(db: Session, workspace: Workspace) -> Plan:
251
  return PLANS.get(sub.plan, PLANS["free"])
252
 
253
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  def plan_order() -> list[str]:
255
  return PLANS.order()
256
 
 
31
  indexed_posts: int
32
  audio_minutes: int
33
  videos: int # blog2video videos per month, or in total when videos_monthly is False
34
+ launch_kits: int # a month (or in total on a lifetime plan); -1 = unlimited
35
  voice_cloning: bool
 
36
  features: tuple[str, ...]
 
 
 
37
  # Per-workspace shares of what blog2video only limits account-wide (keys in VIDEO_LIMITS; 0 = none).
38
  video_limits: dict = field(default_factory=dict)
39
  videos_monthly: bool = True
40
  reports: int = 0 # reports a month; -1 = unlimited
41
+ infographics: int = 0 # infographics a month (in total on a lifetime plan); -1 = unlimited
42
+ audio_overviews: int = -1 # audio overviews allowed (in total on a lifetime plan), whatever their length; -1 = no cap
43
+
44
+ @property
45
+ def lifetime(self) -> bool:
46
+ """The allowances for videos, audio overviews, Launch Kits and infographics are totals that never reset (Free)."""
47
+ return not self.videos_monthly
48
 
49
 
50
  # metric -> period it counts over (app/services/video_limits.py)
51
  VIDEO_LIMITS = {
52
+ "ai_edits": "month", # scene regenerate/add, AI images, stock clips
53
  "templates": "all", # custom templates: blog2video's slots are lifetime, never given back
54
  "template_ai_daily": "day", # template theme extraction from a prompt or document, code generation
 
 
 
55
  }
56
 
57
+ FREE_VIDEO_LIMITS = {"ai_edits": 20, "templates": 0, "template_ai_daily": 0}
58
+ WRITER_VIDEO_LIMITS = {"ai_edits": 300, "templates": 2, "template_ai_daily": 3}
59
+ STUDIO_VIDEO_LIMITS = {"ai_edits": 1000, "templates": 5, "template_ai_daily": 5}
 
 
 
60
 
61
 
62
  DEFAULT_PLANS: dict[str, Plan] = {
 
65
  name="Free",
66
  tagline="For trying Notestack on your archive",
67
  price_monthly_usd=0,
68
+ sources=-1,
69
  indexed_posts=5,
70
+ audio_minutes=-1,
71
+ audio_overviews=1,
72
  videos=1,
73
  launch_kits=2,
74
  reports=2,
75
  infographics=2,
76
  voice_cloning=False,
 
77
  features=(
78
+ "Up to 5 posts indexed, from any source",
79
  "Grounded research chat with citations",
80
+ "1 audio overview, any length",
81
+ "1 video",
82
+ "2 Launch Kits",
83
+ "2 infographics",
84
  "2 reports a month",
 
85
  ),
86
  video_limits=FREE_VIDEO_LIMITS,
87
  videos_monthly=False,
 
91
  name="Writer",
92
  tagline="For writers publishing every week",
93
  price_monthly_usd=24.99,
94
+ sources=-1,
95
+ indexed_posts=100,
96
  audio_minutes=60,
97
  videos=10,
98
+ launch_kits=20,
99
  reports=30,
100
  infographics=30,
101
  voice_cloning=True,
 
102
  features=(
103
+ "Up to 100 posts indexed, from any source",
104
+ "10x more audio overviews than Free (60 min a month)",
105
  "10 videos a month",
106
+ "20 Launch Kits a month",
107
  "30 reports a month",
108
  "30 infographics a month",
109
  "Voice cloning with consent",
 
110
  ),
 
111
  video_limits=WRITER_VIDEO_LIMITS,
112
  ),
113
  "studio": Plan(
 
115
  name="Studio",
116
  tagline="For publications and power users",
117
  price_monthly_usd=48.99,
118
+ sources=-1,
119
+ indexed_posts=250,
120
  audio_minutes=240,
121
  videos=20,
122
+ launch_kits=50,
123
  reports=-1,
124
  infographics=-1,
125
  voice_cloning=True,
 
126
  features=(
127
+ "Up to 250 posts indexed, from any source",
128
+ "40x more audio overviews than Free (240 min a month)",
129
  "20 videos a month",
130
+ "50 Launch Kits a month",
131
  "Unlimited reports",
132
  "Unlimited infographics",
133
  "Launchpad calendar and resurfacing",
134
  "Priority rendering",
135
  ),
 
136
  video_limits=STUDIO_VIDEO_LIMITS,
137
  ),
138
  }
 
145
  return Plan(id=row.id, name=row.name, tagline=row.tagline or "",
146
  price_monthly_usd=float(row.price_monthly_usd or 0), sources=row.sources,
147
  indexed_posts=row.indexed_posts, audio_minutes=row.audio_minutes,
148
+ videos=row.videos, launch_kits=row.launch_kits, reports=row.reports, infographics=row.infographics, audio_overviews=row.audio_overviews,
149
  voice_cloning=bool(row.voice_cloning),
150
+ features=tuple(row.features or ()),
151
+ videos_monthly=bool(row.videos_monthly),
152
  video_limits={**{k: 0 for k in VIDEO_LIMITS}, **(row.video_limits or {})})
153
 
154
 
 
242
  return PLANS.get(sub.plan, PLANS["free"])
243
 
244
 
245
+ def post_room(db: Session, workspace_id, plan: Plan, source_id=None) -> int:
246
+ """How many more posts the plan lets this workspace index: its limit across every source, link and upload, less the
247
+ posts indexed already. With `source_id`, that source's own posts are left out of the count, and a source never loses
248
+ posts it already has indexed (a plan that went down keeps what is stored; it only stops new ones)."""
249
+ from sqlalchemy import func
250
+
251
+ from app.models import Document
252
+
253
+ base = select(func.count()).select_from(Document).where(Document.workspace_id == workspace_id, Document.path.is_not(None))
254
+ total = db.scalar(base) or 0
255
+ if source_id is None:
256
+ return max(plan.indexed_posts - total, 0)
257
+ own = db.scalar(base.where(Document.source_id == source_id)) or 0
258
+ return max(plan.indexed_posts - (total - own), own)
259
+
260
+
261
  def plan_order() -> list[str]:
262
  return PLANS.order()
263
 
backend/app/services/renderer.py CHANGED
@@ -57,15 +57,3 @@ def request_render(job: Job, artifact: Artifact, composition: str, props: dict |
57
  if resp.status_code == 400:
58
  raise PermanentJobError(f"Renderer rejected the props: {resp.text[:400]}")
59
  resp.raise_for_status()
60
-
61
-
62
- def brand_props(workspace, *, ttl: int = 6 * 3600) -> dict:
63
- brand = workspace.brand_json or {}
64
- out: dict = {}
65
- if brand.get("accent"):
66
- out["accent"] = brand["accent"]
67
- if brand.get("name"):
68
- out["name"] = brand["name"]
69
- if brand.get("logo_key"):
70
- out["logoUrl"] = storage.presign_get(brand["logo_key"], ttl=ttl)
71
- return out
 
57
  if resp.status_code == 400:
58
  raise PermanentJobError(f"Renderer rejected the props: {resp.text[:400]}")
59
  resp.raise_for_status()
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/services/report_suggestions.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """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
2
+ what it read, prepared in the background as soon as ideas are extracted, and served at once whenever the dialog opens."""
3
+
4
+ import hashlib
5
+ import logging
6
+ import uuid
7
+
8
+ from sqlalchemy import select
9
+ from sqlalchemy.exc import IntegrityError
10
+ from sqlalchemy.orm import Session
11
+
12
+ from app.config import settings
13
+ from app.llm import run
14
+ from app.llm.provider import suggest_templates_lm
15
+ from app.llm.signatures import SuggestReportTemplates
16
+ from app.models import Document, Notebook, ReportTemplateCache
17
+ from app.pipeline.generate import ideas_fresh
18
+ from app.pipeline.material import max_posts
19
+ from app.pipeline.passages import notebook_docs
20
+ from app.services.jobs import create_job, pending_job
21
+
22
+ log = logging.getLogger("notestack")
23
+
24
+ SUGGEST_ITEMS = 4 # sources the AI reads to suggest templates
25
+ SUMMARY_IDEAS = 3 # a post's stored ideas shown to the AI
26
+ SUMMARY_CHARS = 350 # most characters of one post's summary
27
+ WARM_NOTEBOOKS = 30 # notebooks prepared per run (the most recently changed)
28
+ KEEP_PER_WORKSPACE = 10 # stored sets kept per workspace; the oldest go
29
+
30
+
31
+ def summary(d: Document) -> str:
32
+ """A post as the AI sees it when suggesting templates: its title, its topics and the key ideas already extracted from it (the
33
+ ones the Mind Constellation and the topic map are built from), a few hundred characters at most."""
34
+ meta = d.metadata_json or {}
35
+ ideas = [f"{i['label'].strip()}: {(i.get('note') or '').strip()}" for i in meta.get("ideas") or [] if (i.get("label") or "").strip()]
36
+ tags = [t["name"] for t in meta.get("topics") or [] if t.get("name")][:3]
37
+ head = d.title + (f"\nTopics: {', '.join(tags)}" if tags else "")
38
+ return (head + "\n" + "\n".join(f"- {x}" for x in ideas[:SUMMARY_IDEAS]))[:SUMMARY_CHARS]
39
+
40
+
41
+ def stable_order(docs: list[Document]) -> list[Document]:
42
+ """The posts in an order that belongs to the posts, not to the selection: the same posts come first whenever they are picked.
43
+ The templates are read from the first few, so ticking or unticking any other post gives the same input, the same key, and the
44
+ templates kept for it (the ones prepared in the background for the whole notebook) are served at once, not written again."""
45
+ return sorted(docs, key=lambda d: hashlib.sha256(str(d.id).encode()).hexdigest())
46
+
47
+
48
+ def suggestion_items(material: dict[str, str], docs: list[Document]) -> list[str]:
49
+ """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
50
+ chats their first exchange. At most SUGGEST_ITEMS, from the posts that come first in stable_order."""
51
+ if not docs:
52
+ return list(material.values())[:SUGGEST_ITEMS]
53
+ out = []
54
+ for d in stable_order(docs):
55
+ item = summary(d) if ideas_fresh(d) else material.get(str(d.id))
56
+ if item:
57
+ out.append(item)
58
+ if len(out) == SUGGEST_ITEMS:
59
+ break
60
+ return out
61
+
62
+
63
+ def cache_key(items: list[str], topic: str) -> str:
64
+ return hashlib.sha256(("\x1f".join(items) + "\x1e" + (topic or "").strip()).encode()).hexdigest()
65
+
66
+
67
+ def _clean(out: dict) -> list[dict]:
68
+ seen, found = set(), []
69
+ for t in out.get("templates") or []:
70
+ name, prompt = (t.get("name") or "").strip(), (t.get("prompt") or "").strip()
71
+ if not name or not prompt or name.lower() in seen:
72
+ continue
73
+ seen.add(name.lower())
74
+ found.append({"id": f"suggested-{len(found) + 1}", "name": name[:60],
75
+ "description": (t.get("description") or "").strip()[:200], "prompt": prompt[:1500]})
76
+ return found[:4]
77
+
78
+
79
+ def suggest(db: Session, workspace_id: uuid.UUID, items: list[str], topic: str = "") -> list[dict]:
80
+ """Four templates for these sources: the stored ones when the same sources were seen before, else written now (one AI call) and
81
+ stored. A failed or empty answer is never stored. Never raises."""
82
+ if not items:
83
+ return []
84
+ key = cache_key(items, topic)
85
+ hit = db.scalar(select(ReportTemplateCache).where(ReportTemplateCache.workspace_id == workspace_id, ReportTemplateCache.key == key))
86
+ if hit and hit.templates:
87
+ log.info("report templates: kept set served workspace=%s key=%s", workspace_id, key[:10])
88
+ return hit.templates
89
+ log.info("report templates: no kept set, asking the model workspace=%s key=%s items=%d", workspace_id, key[:10], len(items))
90
+ try:
91
+ out = run.predict(SuggestReportTemplates, db=db, workspace_id=workspace_id, lm=suggest_templates_lm(), items=items,
92
+ topic=(topic or "").strip() or "(none)")
93
+ except Exception:
94
+ log.warning("template suggestions failed", exc_info=True)
95
+ return []
96
+ templates = _clean(out)
97
+ if templates:
98
+ _store(db, workspace_id, key, templates)
99
+ return templates
100
+
101
+
102
+ def _store(db: Session, workspace_id: uuid.UUID, key: str, templates: list[dict]) -> None:
103
+ try:
104
+ row = db.scalar(select(ReportTemplateCache).where(ReportTemplateCache.workspace_id == workspace_id, ReportTemplateCache.key == key))
105
+ if row:
106
+ row.templates = templates
107
+ else:
108
+ db.add(ReportTemplateCache(workspace_id=workspace_id, key=key, templates=templates))
109
+ db.commit()
110
+ except IntegrityError: # another request stored the same set first
111
+ db.rollback()
112
+ return
113
+ old = db.scalars(select(ReportTemplateCache).where(ReportTemplateCache.workspace_id == workspace_id)
114
+ .order_by(ReportTemplateCache.updated_at.desc()).offset(KEEP_PER_WORKSPACE)).all()
115
+ for row in old:
116
+ db.delete(row)
117
+ if old:
118
+ db.commit()
119
+
120
+
121
+ def queue_warmup(db: Session, workspace_id: uuid.UUID) -> None:
122
+ """Have the templates for the workspace's notebooks prepared in the background (once at a time). Called when ideas have been
123
+ extracted and when posts join a notebook, so they are ready before anyone opens the dialog."""
124
+ if settings.llm_api_key and not pending_job(db, workspace_id, "report_templates"):
125
+ create_job(db, workspace_id, "report_templates", {}, max_attempts=2)
126
+
127
+
128
+ def warm_workspace(db: Session, workspace_id: uuid.UUID) -> int:
129
+ """Write and keep the templates of each recently changed notebook for the posts its dialog starts with (the newest ones, as the
130
+ dialog ticks them), skipping a notebook whose posts have mostly no stored ideas yet (they are prepared once the ideas land) and
131
+ any set already stored. Returns how many were written."""
132
+ from app.routers.videos import _excerpt, is_locked # the dialog's own preview of a post, so the keys match
133
+
134
+ written = 0
135
+ for nb in db.scalars(select(Notebook).where(Notebook.workspace_id == workspace_id)
136
+ .order_by(Notebook.updated_at.desc()).limit(WARM_NOTEBOOKS)):
137
+ docs = [d for d in notebook_docs(db, nb.id) if not is_locked(d)][:max_posts()]
138
+ if not docs or sum(1 for d in docs if ideas_fresh(d)) * 2 < len(docs):
139
+ continue
140
+ docs.sort(key=lambda d: d.published_at or d.created_at, reverse=True)
141
+ each = 800 if len(docs) == 1 else 400
142
+ material = {str(d.id): "\n".join(p for p in (d.title or "", _excerpt(d.clean_text, each)) if p) for d in docs}
143
+ items = suggestion_items(material, docs)
144
+ key = cache_key(items, "")
145
+ if items and not db.scalar(select(ReportTemplateCache.id).where(ReportTemplateCache.workspace_id == workspace_id,
146
+ ReportTemplateCache.key == key)):
147
+ written += bool(suggest(db, workspace_id, items, ""))
148
+ return written