Spaces:
Runtime error
Runtime error
Merge pull request #7 from FireBird-Technologies/feature/report
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- README.md +2 -2
- backend/alembic/versions/0006_plans.py +5 -2
- backend/alembic/versions/0021_artifact_feedback.py +40 -0
- backend/alembic/versions/0022_report_template_cache.py +34 -0
- backend/alembic/versions/0023_drop_brand_kit.py +31 -0
- backend/alembic/versions/0024_plan_limits.py +49 -0
- backend/alembic/versions/0025_drop_video_premium.py +24 -0
- backend/alembic/versions/0026_plan_copy.py +40 -0
- backend/app/config.py +22 -0
- backend/app/db.py +9 -4
- backend/app/infographics/design.py +213 -0
- backend/app/infographics/image.py +34 -5
- backend/app/infographics/templates/design.css +126 -0
- backend/app/infographics/templates/design.html.j2 +22 -0
- backend/app/infographics/themes.py +5 -2
- backend/app/llm/provider.py +32 -1
- backend/app/llm/run.py +2 -1
- backend/app/llm/signatures.py +204 -10
- backend/app/models/__init__.py +4 -0
- backend/app/models/content.py +31 -0
- backend/app/models/user.py +1 -4
- backend/app/pipeline/flashcards.py +21 -10
- backend/app/pipeline/generate.py +26 -9
- backend/app/pipeline/idea_pool.py +85 -0
- backend/app/pipeline/infographic.py +32 -10
- backend/app/pipeline/ingest.py +19 -9
- backend/app/pipeline/material.py +37 -7
- backend/app/pipeline/media.py +5 -10
- backend/app/pipeline/passages.py +21 -3
- backend/app/pipeline/quiz.py +10 -5
- backend/app/pipeline/report.py +395 -125
- backend/app/pipeline/report_blueprint.py +220 -0
- backend/app/pipeline/report_evidence.py +166 -0
- backend/app/pipeline/report_themes.py +280 -0
- backend/app/routers/artifacts.py +84 -5
- backend/app/routers/notebooks.py +6 -1
- backend/app/routers/reports.py +11 -21
- backend/app/routers/settings.py +10 -29
- backend/app/routers/sources.py +12 -2
- backend/app/routers/video_edit.py +2 -28
- backend/app/routers/video_templates.py +0 -7
- backend/app/routers/video_voices.py +27 -197
- backend/app/routers/videos.py +4 -34
- backend/app/services/artifact_feedback.py +47 -0
- backend/app/services/b2v_access.py +12 -50
- backend/app/services/billing.py +23 -10
- backend/app/services/notestack_voices.py +3 -4
- backend/app/services/plans.py +51 -44
- backend/app/services/renderer.py +0 -12
- 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
|
| 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,
|
| 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 [*
|
| 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
|
| 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
|
| 15 |
-
# waiting on the socket forever, and the worker loop with it.
|
| 16 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
| 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 =
|
| 18 |
-
|
|
|
|
| 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
|
| 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=
|
| 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 = "
|
| 23 |
|
| 24 |
|
| 25 |
def theme_id(value: object) -> str:
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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 |
-
|
|
|
|
| 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.
|
| 464 |
-
|
| 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.
|
| 525 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="
|
| 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 |
-
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
| 25 |
cards, seen = [], set()
|
| 26 |
-
for
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
if not front or not back or key in seen:
|
| 30 |
continue
|
| 31 |
-
seen.add(
|
| 32 |
-
cards.append(
|
| 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
|
|
|
|
| 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 |
-
|
|
|
|
| 124 |
if job:
|
| 125 |
-
update_job(db, job, progress=0.05
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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[:
|
| 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="
|
|
|
|
| 80 |
content = None
|
| 81 |
for _ in range(2): # one retry when the model gives too little to draw
|
| 82 |
-
out = run.predict(
|
| 83 |
-
focus=prompt or "(none)",
|
| 84 |
-
|
|
|
|
| 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
|
|
|
|
| 115 |
text = content.get("content")
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 641 |
-
on_post=lambda done, total:
|
| 642 |
-
|
| 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 |
-
|
| 652 |
try:
|
| 653 |
entries += fetch_substack_archive(
|
| 654 |
site, {e.url for e in entries}, fetch_limit,
|
| 655 |
-
on_page=lambda n:
|
|
|
|
|
|
|
| 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 |
-
|
| 666 |
|
| 667 |
-
|
| 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"
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|
| 88 |
-
|
|
|
|
| 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,
|
| 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
|
| 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 (
|
| 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)
|
| 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;
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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`
|
| 5 |
-
|
|
|
|
| 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
|
| 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 |
-
|
| 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
|
| 112 |
-
|
| 113 |
-
return
|
| 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
|
| 120 |
-
""
|
| 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
|
| 126 |
-
"""(title, Markdown
|
| 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 |
-
|
| 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]
|
| 185 |
-
"""(title, blocks,
|
| 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,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 264 |
instructions = (params.get("instructions") or "").strip() or default_instructions(
|
| 265 |
-
report_format, params.get("template_id"),
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
else:
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
|
|
|
|
|
|
|
|
|
| 283 |
artifact.content_json = {
|
| 284 |
"title": title, "format": report_format, "template_id": params.get("template_id"),
|
| 285 |
-
"instructions": instructions, "language": language, "topic": topic, "source":
|
| 286 |
-
"blocks": blocks, "suggestions": suggestions, "
|
| 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
|
| 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),
|
|
|
|
| 394 |
blocks = list(content.get("blocks") or [])
|
| 395 |
-
|
| 396 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
| 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), "
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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.
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
| 41 |
if not material:
|
| 42 |
return {"templates": []}
|
| 43 |
-
|
| 44 |
-
|
| 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
|
| 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
|
| 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 |
-
|
| 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 |
-
"""
|
| 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,
|
| 2 |
-
|
| 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,
|
| 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
|
| 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,
|
| 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 |
-
"
|
| 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")
|
| 57 |
ctx.db.merge(row)
|
| 58 |
return row
|
| 59 |
|
| 60 |
|
| 61 |
-
def _saved(ctx: Ctx) -> list[UserSavedVoice]:
|
| 62 |
-
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 69 |
with upstream():
|
| 70 |
-
library = b2v.prebuilt_voices()
|
| 71 |
-
|
|
|
|
|
|
|
| 72 |
if not saved:
|
| 73 |
-
for v in [v for v in library if v.get("
|
| 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,
|
| 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 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 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"
|
| 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)
|
| 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")
|
| 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
|
| 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
|
| 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,
|
| 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(
|
| 161 |
-
"""A built-in voice (
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 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
|
| 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(
|
| 188 |
-
if str(body.get("video_length") or "") in
|
| 189 |
-
|
| 190 |
if body.get("voice_emotion"):
|
| 191 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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}
|
| 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
|
| 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.
|
| 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 |
-
|
| 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
|
| 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
|
| 59 |
-
|
| 60 |
-
|
| 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=
|
|
|
|
| 75 |
videos=1,
|
| 76 |
launch_kits=2,
|
| 77 |
reports=2,
|
| 78 |
infographics=2,
|
| 79 |
voice_cloning=False,
|
| 80 |
-
brand_kit=False,
|
| 81 |
features=(
|
| 82 |
-
"
|
| 83 |
"Grounded research chat with citations",
|
| 84 |
-
"
|
| 85 |
-
"1 video
|
| 86 |
-
"2 Launch Kits
|
|
|
|
| 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=
|
| 99 |
-
indexed_posts=
|
| 100 |
audio_minutes=60,
|
| 101 |
videos=10,
|
| 102 |
-
launch_kits=
|
| 103 |
reports=30,
|
| 104 |
infographics=30,
|
| 105 |
voice_cloning=True,
|
| 106 |
-
brand_kit=True,
|
| 107 |
features=(
|
| 108 |
-
"
|
| 109 |
-
"
|
| 110 |
"10 videos a month",
|
| 111 |
-
"
|
| 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=
|
| 126 |
-
indexed_posts=
|
| 127 |
audio_minutes=240,
|
| 128 |
videos=20,
|
| 129 |
-
launch_kits=
|
| 130 |
reports=-1,
|
| 131 |
infographics=-1,
|
| 132 |
voice_cloning=True,
|
| 133 |
-
brand_kit=True,
|
| 134 |
features=(
|
| 135 |
-
"
|
| 136 |
-
"
|
| 137 |
"20 videos a month",
|
| 138 |
-
"
|
| 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 |
-
|
| 160 |
-
|
| 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
|