Spaces:
Runtime error
Runtime error
feat: add artifact feedback component and related hooks
Browse files- Introduced `ArtifactFeedback` component for rating reports, quizzes, flashcards, and infographics.
- Implemented feedback reasons and comments for user ratings.
- Added `suggestTemplates` and `prefetchSuggestions` hooks for report template suggestions.
- Created `useArtifactLimits` hook to manage artifact limits from server settings.
- Added new infographic images: midnight.png and paper.png.
- 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/alembic/versions/0027_quiz_attempts.py +38 -0
- backend/app/infographics/design.py +213 -0
- backend/app/infographics/templates/design.css +126 -0
- backend/app/infographics/templates/design.html.j2 +22 -0
- backend/app/pipeline/idea_pool.py +85 -0
- 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/services/artifact_feedback.py +56 -0
- backend/app/services/report_suggestions.py +148 -0
- backend/tests/test_idea_pool.py +279 -0
- backend/tests/test_report_blocks.py +91 -0
- backend/tests/test_report_suggestions.py +121 -0
- backend/tests/test_report_themes.py +553 -0
- frontend/src/components/ArtifactFeedback.tsx +145 -0
- frontend/src/hooks/reportSuggestions.ts +24 -0
- frontend/src/hooks/useArtifactLimits.ts +38 -0
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/alembic/versions/0027_quiz_attempts.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""quiz_attempts: a person's last finished run of a quiz, shown again when they reopen it
|
| 2 |
+
|
| 3 |
+
Revision ID: 0027_quiz_attempts
|
| 4 |
+
Revises: 0026_plan_copy
|
| 5 |
+
Create Date: 2026-10-08
|
| 6 |
+
"""
|
| 7 |
+
import sqlalchemy as sa
|
| 8 |
+
from alembic import op
|
| 9 |
+
|
| 10 |
+
revision = "0027_quiz_attempts"
|
| 11 |
+
down_revision = "0026_plan_copy"
|
| 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 "quiz_attempts" in sa.inspect(op.get_bind()).get_table_names():
|
| 19 |
+
return
|
| 20 |
+
op.create_table(
|
| 21 |
+
"quiz_attempts",
|
| 22 |
+
sa.Column("id", sa.Uuid(), primary_key=True),
|
| 23 |
+
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
| 24 |
+
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
| 25 |
+
sa.Column("workspace_id", sa.Uuid(), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False),
|
| 26 |
+
sa.Column("artifact_id", sa.Uuid(), sa.ForeignKey("artifacts.id", ondelete="CASCADE"), nullable=False),
|
| 27 |
+
sa.Column("user_id", sa.Uuid(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
| 28 |
+
sa.Column("score", sa.Integer(), nullable=False),
|
| 29 |
+
sa.Column("total", sa.Integer(), nullable=False),
|
| 30 |
+
sa.Column("answers", sa.JSON(), nullable=False),
|
| 31 |
+
sa.UniqueConstraint("artifact_id", "user_id"),
|
| 32 |
+
)
|
| 33 |
+
op.create_index("ix_quiz_attempts_workspace_id", "quiz_attempts", ["workspace_id"])
|
| 34 |
+
op.create_index("ix_quiz_attempts_artifact_id", "quiz_attempts", ["artifact_id"])
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def downgrade() -> None:
|
| 38 |
+
op.drop_table("quiz_attempts")
|
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/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/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/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/services/artifact_feedback.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, QuizAttempt
|
| 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 attempt_out(a: QuizAttempt | None) -> dict | None:
|
| 28 |
+
return {"score": a.score, "total": a.total, "answers": a.answers or []} if a else None
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def attach(db: Session, user_id: uuid.UUID, items: list[dict]) -> list[dict]:
|
| 32 |
+
"""Add `feedback` (this person's rating, or None) to each serialized artifact that can be rated. One query."""
|
| 33 |
+
ids = [uuid.UUID(i["id"]) for i in items if i.get("type") in RATEABLE]
|
| 34 |
+
rows = {f.artifact_id: f for f in db.scalars(select(ArtifactFeedback).where(
|
| 35 |
+
ArtifactFeedback.artifact_id.in_(ids), ArtifactFeedback.user_id == user_id))} if ids else {}
|
| 36 |
+
quiz_ids = [uuid.UUID(i["id"]) for i in items if i.get("type") == "quiz"]
|
| 37 |
+
attempts = {a.artifact_id: a for a in db.scalars(select(QuizAttempt).where(
|
| 38 |
+
QuizAttempt.artifact_id.in_(quiz_ids), QuizAttempt.user_id == user_id))} if quiz_ids else {}
|
| 39 |
+
for i in items:
|
| 40 |
+
if i.get("type") in RATEABLE:
|
| 41 |
+
i["feedback"] = feedback_out(rows.get(uuid.UUID(i["id"])))
|
| 42 |
+
if i.get("type") == "quiz":
|
| 43 |
+
i["attempt"] = attempt_out(attempts.get(uuid.UUID(i["id"])))
|
| 44 |
+
return items
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def snapshot(a: Artifact) -> dict:
|
| 48 |
+
"""What the artifact was when it was rated: enough to tell what a rating is about."""
|
| 49 |
+
c = a.content_json or {}
|
| 50 |
+
inner = c.get("content") if isinstance(c.get("content"), dict) else {}
|
| 51 |
+
keep = {k: c.get(k) for k in ("title", "prompt", "source", "theme", "format", "template_id", "difficulty", "question_count",
|
| 52 |
+
"card_count", "language", "topic") if c.get(k) is not None}
|
| 53 |
+
if a.type == "infographic":
|
| 54 |
+
keep["layout"] = "composed" if "body_html" in inner else "fixed"
|
| 55 |
+
keep["fit"] = inner.get("fit")
|
| 56 |
+
return keep
|
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
|
backend/tests/test_idea_pool.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Flashcards (always) and quizzes (above ARTIFACT_MAX_POSTS posts) made from the ideas already extracted from each post
|
| 2 |
+
(pipeline/idea_pool.py). The model is always faked here."""
|
| 3 |
+
|
| 4 |
+
import random
|
| 5 |
+
import re
|
| 6 |
+
import uuid
|
| 7 |
+
|
| 8 |
+
import pytest
|
| 9 |
+
from sqlalchemy import select
|
| 10 |
+
|
| 11 |
+
from app.config import settings
|
| 12 |
+
from app.corpus import Corpus
|
| 13 |
+
from app.llm import run
|
| 14 |
+
from app.models import Document, Job, Workspace
|
| 15 |
+
from app.pipeline import idea_pool
|
| 16 |
+
from app.pipeline.material import load_material, max_posts
|
| 17 |
+
from app.services import report_suggestions
|
| 18 |
+
from tests.conftest import last_code
|
| 19 |
+
|
| 20 |
+
THEMES = ["Pricing", "Audience Growth", "Writing Craft"]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@pytest.fixture()
|
| 24 |
+
def owner(client, db_session):
|
| 25 |
+
client.post("/api/auth/email/register/start", json={"email": "ada@example.com", "password": "stardust-42", "name": "Ada"})
|
| 26 |
+
r = client.post("/api/auth/email/register/verify", json={"email": "ada@example.com", "code": last_code()})
|
| 27 |
+
return {"Authorization": f"Bearer {r.json()['access_token']}"}, db_session.scalar(select(Workspace))
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def make_posts(db, ws, n: int, *, with_ideas: int | None = None, ideas_each: int = 3, start: int = 0, body: int = 11) -> list[Document]:
|
| 31 |
+
"""n indexed posts in the corpus, spread over three themes; the first `with_ideas` have stored ideas (all by default)."""
|
| 32 |
+
with_ideas = n if with_ideas is None else with_ideas
|
| 33 |
+
files, docs = {}, []
|
| 34 |
+
for i in range(start, start + n):
|
| 35 |
+
theme = THEMES[i % len(THEMES)]
|
| 36 |
+
lines = ["---", f'title: "Post {i}"', "---", "", f"# Post {i}", ""] + [f"Sentence {k} about {theme.lower()} in post {i}." for k in range(1, body + 1)]
|
| 37 |
+
path = f"sources/x/post-{i}.md"
|
| 38 |
+
files[path] = "\n".join(lines) + "\n"
|
| 39 |
+
d = Document(workspace_id=ws.id, title=f"Post {i} on {theme}", url=f"https://x.test/{i}", path=path, content_hash=f"h{i}",
|
| 40 |
+
clean_text=" ".join(lines), metadata_json={})
|
| 41 |
+
if i - start < with_ideas:
|
| 42 |
+
d.metadata_json = {"ideas_hash": f"h{i}", "ideas": [
|
| 43 |
+
{"label": f"{theme} idea {i}.{k}", "note": f"What post {i} says about {theme.lower()}.",
|
| 44 |
+
"sources": [{"path": path, "line_start": 7 + k, "line_end": 9 + k}], "details": []} for k in range(ideas_each)]}
|
| 45 |
+
db.add(d)
|
| 46 |
+
docs.append(d)
|
| 47 |
+
db.commit()
|
| 48 |
+
Corpus(ws.id).write_files(files)
|
| 49 |
+
return docs
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def idea_lines(material: list[str]) -> list[str]:
|
| 53 |
+
return [m for m in material if m.startswith("IDEA [")]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_a_sample_is_small_cited_and_spread_over_the_posts(owner, db_session):
|
| 57 |
+
_, ws = owner
|
| 58 |
+
docs = make_posts(db_session, ws, 60)
|
| 59 |
+
material = idea_pool.sample(db_session, ws.id, docs, rng=random.Random(1))
|
| 60 |
+
lines = idea_lines(material)
|
| 61 |
+
assert material[0] == idea_pool.FORMAT_NOTE and len(lines) == settings.artifact_idea_sample == 30
|
| 62 |
+
assert sum(len(m) for m in material) < 8_000 # about 1,500 tokens, whatever the size of the selection
|
| 63 |
+
assert all(re.fullmatch(r"IDEA \[sources/x/post-\d+\.md lines \d+-\d+\] .+ idea \d+\.\d: What post \d+ says about .+\.", m) for m in lines)
|
| 64 |
+
per_post = [m.split("[")[1].split(" ")[0] for m in lines]
|
| 65 |
+
assert max(per_post.count(p) for p in set(per_post)) <= idea_pool.IDEAS_PER_POST and len(set(per_post)) >= 15
|
| 66 |
+
assert lines != idea_lines(idea_pool.sample(db_session, ws.id, docs, rng=random.Random(2))) # a fresh draw each time
|
| 67 |
+
assert lines == idea_lines(idea_pool.sample(db_session, ws.id, docs, rng=random.Random(1))) # the same draw for the same seed
|
| 68 |
+
assert db_session.scalars(select(Job)).all() == [] # nothing is queued or extracted here
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def test_a_small_selection_sends_all_its_ideas_and_a_topic_comes_first(owner, db_session):
|
| 72 |
+
_, ws = owner
|
| 73 |
+
few = make_posts(db_session, ws, 3, ideas_each=5) # 15 ideas, fewer than the sample: all of them, however many per post
|
| 74 |
+
assert len(idea_lines(idea_pool.sample(db_session, ws.id, few))) == 15
|
| 75 |
+
docs = make_posts(db_session, ws, 30, start=100)
|
| 76 |
+
picked = idea_lines(idea_pool.sample(db_session, ws.id, docs, topic="audience growth", size=10, rng=random.Random(3)))
|
| 77 |
+
assert len(picked) == 10 and all("audience growth" in m.lower() for m in picked) # ten of the matching ideas, before any other
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def test_posts_with_no_ideas_get_a_short_opening_and_chats_only_their_end(owner, db_session):
|
| 81 |
+
_, ws = owner
|
| 82 |
+
docs = make_posts(db_session, ws, 12, with_ideas=5)
|
| 83 |
+
material = idea_pool.sample(db_session, ws.id, docs, chats=["x" * 50_000, "y" * 50_000], rng=random.Random(4))
|
| 84 |
+
openings = [m for m in material if m.startswith("FILE ")]
|
| 85 |
+
assert len(openings) == idea_pool.MAX_OPENINGS and all(len(o) < 1_100 for o in openings)
|
| 86 |
+
assert [len(m) for m in material[-2:]] == [idea_pool.CHAT_BUDGET // 2] * 2
|
| 87 |
+
assert db_session.scalars(select(Job)).all() == []
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def test_limits_and_budgets_come_from_settings(owner, db_session, monkeypatch):
|
| 91 |
+
from app.pipeline.passages import passages_for
|
| 92 |
+
|
| 93 |
+
assert max_posts() == 20
|
| 94 |
+
monkeypatch.setattr(settings, "artifact_max_posts", 5)
|
| 95 |
+
assert max_posts() == 5
|
| 96 |
+
monkeypatch.undo()
|
| 97 |
+
_, ws = owner
|
| 98 |
+
docs = make_posts(db_session, ws, 20, with_ideas=0, body=300) # long posts: each fills whatever it is given
|
| 99 |
+
text = lambda: sum(len(p) for p in passages_for(Corpus(ws.id), docs)) # noqa: E731
|
| 100 |
+
full = text()
|
| 101 |
+
assert 20 * 5_500 < full < 20 * 6_500 # 120,000 characters shared by 20 posts: about 6,000 each
|
| 102 |
+
monkeypatch.setattr(settings, "artifact_passage_budget", 40_000)
|
| 103 |
+
mid = text()
|
| 104 |
+
assert 20 * 1_500 < mid < 20 * 2_500 # 2,000 each
|
| 105 |
+
monkeypatch.setattr(settings, "artifact_passage_budget", 1) # the floor is 1,500 characters a post
|
| 106 |
+
assert 20 * 1_000 < text() < mid
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _notebook(client, owner, docs):
|
| 110 |
+
headers, _ = owner
|
| 111 |
+
return client.post("/api/notebooks", json={"title": "Big", "document_ids": [str(d.id) for d in docs]}, headers=headers).json()
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def test_when_ideas_are_used_instead_of_the_posts_text(owner, db_session):
|
| 115 |
+
_, ws = owner
|
| 116 |
+
nb = uuid.uuid4() # an unknown notebook: only the picked posts count
|
| 117 |
+
big = make_posts(db_session, ws, 30)
|
| 118 |
+
ids = [str(d.id) for d in big]
|
| 119 |
+
quiz_20 = load_material(db_session, ws.id, nb, {"document_ids": ids[:20]}, what="quiz", large_ok=True)
|
| 120 |
+
assert quiz_20.mode == "passages" and len(quiz_20.text) == 20 # up to the limit: the text, exactly as before
|
| 121 |
+
quiz_30 = load_material(db_session, ws.id, nb, {"document_ids": ids}, what="quiz", large_ok=True)
|
| 122 |
+
assert quiz_30.mode == "ideas" and len(quiz_30.docs) == 30 and quiz_30.text == []
|
| 123 |
+
report = load_material(db_session, ws.id, nb, {"document_ids": ids}, what="report")
|
| 124 |
+
assert report.mode == "passages" and len(report.docs) == 20 # a report still reads its first posts' text
|
| 125 |
+
cards_3 = load_material(db_session, ws.id, nb, {"document_ids": ids[:3]}, what="flashcard set", large_ok=True, ideas_always=True)
|
| 126 |
+
assert cards_3.mode == "ideas" and len(cards_3.docs) == 3 # flashcards: always the ideas, even from three posts
|
| 127 |
+
# Ideas not stored yet: the posts' text is read (the first 20 when there are more), as it always was
|
| 128 |
+
bare = make_posts(db_session, ws, 30, with_ideas=0, start=200)
|
| 129 |
+
bare_ids = [str(d.id) for d in bare]
|
| 130 |
+
assert load_material(db_session, ws.id, nb, {"document_ids": bare_ids}, what="quiz", large_ok=True).mode == "passages"
|
| 131 |
+
big_bare = load_material(db_session, ws.id, nb, {"document_ids": bare_ids}, what="quiz", large_ok=True)
|
| 132 |
+
assert len(big_bare.docs) == 20 and len(big_bare.text) == 20
|
| 133 |
+
few_bare = load_material(db_session, ws.id, nb, {"document_ids": bare_ids[:3]}, what="flashcard set", large_ok=True, ideas_always=True)
|
| 134 |
+
assert few_bare.mode == "passages" and len(few_bare.text) == 3
|
| 135 |
+
# Half or more with ideas: the ideas, and a short opening for the rest
|
| 136 |
+
half = make_posts(db_session, ws, 4, with_ideas=2, start=300)
|
| 137 |
+
assert load_material(db_session, ws.id, nb, {"document_ids": [str(d.id) for d in half]}, what="flashcard set", large_ok=True,
|
| 138 |
+
ideas_always=True).mode == "ideas"
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _cited(inputs: dict) -> list[dict]:
|
| 142 |
+
"""A source for each idea line, copied from its tag the way the model is told to."""
|
| 143 |
+
tags = [re.match(r"IDEA \[(\S+) lines (\d+)-(\d+)\]", m) for m in inputs["material"]]
|
| 144 |
+
return [{"path": t[1], "line_start": int(t[2]), "line_end": int(t[3])} for t in tags if t]
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def test_a_big_quiz_is_one_call_over_a_sample_of_ideas(client, owner, db_session, run_jobs, monkeypatch):
|
| 148 |
+
headers, ws = owner
|
| 149 |
+
docs = make_posts(db_session, ws, 40)
|
| 150 |
+
nb = _notebook(client, owner, docs)
|
| 151 |
+
calls: list[dict] = []
|
| 152 |
+
|
| 153 |
+
def predict(signature, *, db, workspace_id, job=None, lm=None, **inputs):
|
| 154 |
+
assert signature.__name__ == "GenerateQuiz"
|
| 155 |
+
calls.append(inputs)
|
| 156 |
+
cited = _cited(inputs)
|
| 157 |
+
return {"questions": [{"type": "multiple_choice", "question": f"Question {k}?", "options": ["a", "b"], "correct": [0],
|
| 158 |
+
"explanation": "", "sources": [cited[k]]} for k in range(inputs["count"])]}
|
| 159 |
+
|
| 160 |
+
monkeypatch.setattr(run, "predict", predict)
|
| 161 |
+
monkeypatch.setattr(run, "predict_many", lambda *a, **k: pytest.fail("one call"))
|
| 162 |
+
art = client.post("/api/artifacts/generate", json={"type": "quiz", "notebook_id": nb["id"], "document_ids": [str(d.id) for d in docs],
|
| 163 |
+
"question_types": ["multiple_choice"]}, headers=headers).json()
|
| 164 |
+
run_jobs()
|
| 165 |
+
done = client.get(f"/api/artifacts/{art['id']}", headers=headers).json()
|
| 166 |
+
assert done["status"] == "ready", done["content"]
|
| 167 |
+
assert len(calls) == 1 and calls[0]["count"] == 10 and calls[0]["material"][0] == idea_pool.FORMAT_NOTE
|
| 168 |
+
assert len(idea_lines(calls[0]["material"])) == 30
|
| 169 |
+
qs = done["content"]["questions"]
|
| 170 |
+
assert len(qs) == 10 and all(q["sources"] and q["sources"][0]["document_id"] and q["sources"][0]["quote"] for q in qs) # lines verified
|
| 171 |
+
assert len(done["content"]["source"]["document_ids"]) == 40
|
| 172 |
+
assert db_session.scalars(select(Job).where(Job.kind == "ideas")).all() == []
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def test_flashcards_use_the_ideas_for_any_size_of_selection_but_not_for_chats(client, owner, db_session, run_jobs, monkeypatch):
|
| 176 |
+
headers, ws = owner
|
| 177 |
+
docs = make_posts(db_session, ws, 3, ideas_each=4)
|
| 178 |
+
nb = _notebook(client, owner, docs)
|
| 179 |
+
calls: list[list[str]] = []
|
| 180 |
+
|
| 181 |
+
def predict(signature, *, db, workspace_id, job=None, lm=None, **inputs):
|
| 182 |
+
calls.append(inputs["material"])
|
| 183 |
+
cited = _cited(inputs) or [{"path": docs[0].path, "line_start": 1, "line_end": 2}]
|
| 184 |
+
return {"cards": [{"front": f"Front {k}", "back": "Back.", "sources": [cited[k % len(cited)]]} for k in range(inputs["count"])]}
|
| 185 |
+
|
| 186 |
+
monkeypatch.setattr(run, "predict", predict)
|
| 187 |
+
art = client.post("/api/artifacts/generate", json={"type": "flashcards", "notebook_id": nb["id"],
|
| 188 |
+
"document_ids": [str(d.id) for d in docs]}, headers=headers).json()
|
| 189 |
+
run_jobs()
|
| 190 |
+
done = client.get(f"/api/artifacts/{art['id']}", headers=headers).json()
|
| 191 |
+
assert done["status"] == "ready" and done["content"]["card_count"] == 16
|
| 192 |
+
assert calls[0][0] == idea_pool.FORMAT_NOTE and len(idea_lines(calls[0])) == 12 # all twelve ideas, no post text
|
| 193 |
+
assert not any(m.startswith("FILE ") for m in calls[0])
|
| 194 |
+
assert all(c["sources"] and c["sources"][0]["document_id"] for c in done["content"]["cards"])
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def test_a_quiz_or_flashcards_take_any_number_of_posts_and_the_limits_are_served(client, owner, db_session, monkeypatch):
|
| 198 |
+
headers, ws = owner
|
| 199 |
+
docs = make_posts(db_session, ws, 130)
|
| 200 |
+
ids = [str(d.id) for d in docs]
|
| 201 |
+
nb = _notebook(client, owner, docs)
|
| 202 |
+
assert client.get("/api/settings/limits", headers=headers).json() == {"posts": 20}
|
| 203 |
+
big = load_material(db_session, ws.id, uuid.UUID(nb["id"]), {"document_ids": ids}, what="quiz", large_ok=True)
|
| 204 |
+
assert big.mode == "ideas" and len(big.docs) == 130 and big.text == [] # all of them count; the sample stays the same size
|
| 205 |
+
assert len(idea_lines(idea_pool.sample(db_session, ws.id, big.docs))) == settings.artifact_idea_sample
|
| 206 |
+
cap = load_material(db_session, ws.id, uuid.UUID(nb["id"]), {"document_ids": ids}, what="report")
|
| 207 |
+
assert len(cap.docs) == 20 # a report still reads its first posts
|
| 208 |
+
monkeypatch.setattr(run, "predict", lambda *a, **k: {"questions": []})
|
| 209 |
+
for kind in ("quiz", "flashcards"): # no refusal for a big selection
|
| 210 |
+
r = client.post("/api/artifacts/generate", json={"type": kind, "notebook_id": nb["id"], "document_ids": ids}, headers=headers)
|
| 211 |
+
assert r.status_code == 200, r.text
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def test_a_passage_leaves_out_the_file_header_and_keeps_the_line_numbers(owner, db_session):
|
| 215 |
+
from app.pipeline.passages import passages_for
|
| 216 |
+
|
| 217 |
+
_, ws = owner
|
| 218 |
+
doc = make_posts(db_session, ws, 1, with_ideas=0)[0]
|
| 219 |
+
text = passages_for(Corpus(ws.id), [doc])[0]
|
| 220 |
+
assert text.startswith(f"FILE {doc.path} ({doc.title})\n7| Sentence 1 about") # no ---, title, url, date or "# Post" lines
|
| 221 |
+
assert "---" not in text and "# Post" not in text and "title:" not in text
|
| 222 |
+
assert "11| Sentence 5" in text # numbered as in the file, so a cited line is still the right one
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def test_a_quiz_reads_posts_with_its_own_smaller_budget(client, owner, db_session, run_jobs, monkeypatch):
|
| 226 |
+
headers, ws = owner
|
| 227 |
+
docs = make_posts(db_session, ws, 20, with_ideas=0, body=300)
|
| 228 |
+
nb = _notebook(client, owner, docs)
|
| 229 |
+
sizes: dict[str, int] = {}
|
| 230 |
+
|
| 231 |
+
def predict(signature, *, db, workspace_id, job=None, lm=None, **inputs):
|
| 232 |
+
sizes[signature.__name__] = sum(len(m) for m in inputs["material"])
|
| 233 |
+
raise RuntimeError("stop here: only the size of what was sent is wanted")
|
| 234 |
+
|
| 235 |
+
monkeypatch.setattr(run, "predict", predict)
|
| 236 |
+
ids = [str(d.id) for d in docs]
|
| 237 |
+
for kind in ("quiz", "report"):
|
| 238 |
+
client.post("/api/artifacts/generate", json={"type": kind, "notebook_id": nb["id"], "document_ids": ids}, headers=headers)
|
| 239 |
+
run_jobs()
|
| 240 |
+
assert settings.artifact_quiz_passage_budget == 60_000
|
| 241 |
+
assert sizes["GenerateQuiz"] < 0.6 * sizes["WriteReport"] # a quiz's 60,000 characters against a report's 120,000
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def test_suggested_report_templates_read_each_posts_stored_summary(client, owner, db_session, monkeypatch):
|
| 246 |
+
headers, ws = owner
|
| 247 |
+
docs = make_posts(db_session, ws, 6, with_ideas=4, ideas_each=6) # the last two posts have no stored ideas yet
|
| 248 |
+
for d in docs[:4]:
|
| 249 |
+
d.metadata_json = {**d.metadata_json, "topics": [{"name": "Pricing", "weight": 0.9}, {"name": "Growth", "weight": 0.5}]}
|
| 250 |
+
db_session.commit()
|
| 251 |
+
seen: list[dict] = []
|
| 252 |
+
|
| 253 |
+
def predict(signature, *, db, workspace_id, job=None, lm=None, **inputs):
|
| 254 |
+
assert signature.__name__ == "SuggestReportTemplates"
|
| 255 |
+
seen.append(inputs)
|
| 256 |
+
return {"templates": [{"name": f"Template {n}", "description": "d", "prompt": "p"} for n in range(4)]}
|
| 257 |
+
|
| 258 |
+
monkeypatch.setattr(run, "predict", predict)
|
| 259 |
+
r = client.post("/api/reports/suggest", json={"document_ids": [str(d.id) for d in docs]}, headers=headers)
|
| 260 |
+
assert r.status_code == 200 and len(r.json()["templates"]) == 4
|
| 261 |
+
items = seen[0]["items"]
|
| 262 |
+
assert len(items) == report_suggestions.SUGGEST_ITEMS # at most this many sources, however many are selected (newest first)
|
| 263 |
+
summaries = [i for i in items if "\n- " in i]
|
| 264 |
+
starts = [i for i in items if "\n- " not in i]
|
| 265 |
+
assert len(summaries) == 2 and len(starts) == 2 # the two newest posts have no stored ideas: read by their start
|
| 266 |
+
for item in summaries:
|
| 267 |
+
ideas = report_suggestions.SUMMARY_IDEAS
|
| 268 |
+
assert "Topics: Pricing, Growth" in item and item.count("\n- ") == ideas and len(item) <= report_suggestions.SUMMARY_CHARS
|
| 269 |
+
assert " idea " in item and "Sentence 1 about" not in item # the stored ideas, not the post text
|
| 270 |
+
assert all("Sentence" in i for i in starts)
|
| 271 |
+
# A post with no stored ideas is still read, by its start
|
| 272 |
+
r = client.post("/api/reports/suggest", json={"document_ids": [str(docs[13].id)]}, headers=headers)
|
| 273 |
+
assert r.status_code == 200 and "Sentence" in seen[1]["items"][0]
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def test_the_fast_models_output_cap_is_a_setting():
|
| 277 |
+
from app.llm.provider import fast_lm
|
| 278 |
+
|
| 279 |
+
assert settings.llm_fast_max_tokens == 6000 and fast_lm().kwargs["max_tokens"] == 6000 # quizzes and flashcards write under 3,000
|
backend/tests/test_report_blocks.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""remove_block: a removed visual's offer to add it again comes back, so the reader is never stuck with no way in.
|
| 2 |
+
copy_block: reusing an artifact the user already made points the new block at it, never raising for one that is ready."""
|
| 3 |
+
|
| 4 |
+
import uuid
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
from app.models import Artifact
|
| 9 |
+
from app.pipeline.generate import NothingToDo
|
| 10 |
+
from app.pipeline.report import copy_block, remove_block
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _content(blocks, suggestions=None):
|
| 14 |
+
return {"blocks": blocks, "suggestions": suggestions or []}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def test_removing_a_visual_restores_its_add_offer():
|
| 18 |
+
content = _content([
|
| 19 |
+
{"id": "b1", "type": "prose", "text": "intro"},
|
| 20 |
+
{"id": "b2", "type": "quiz", "after": "b1", "title": "Quiz"},
|
| 21 |
+
{"id": "b3", "type": "prose", "text": "more"},
|
| 22 |
+
])
|
| 23 |
+
out = remove_block(content, "b2")
|
| 24 |
+
assert out is not None
|
| 25 |
+
assert [b["id"] for b in out["blocks"]] == ["b1", "b3"]
|
| 26 |
+
assert [s["kind"] for s in out["suggestions"]] == ["quiz"]
|
| 27 |
+
assert out["suggestions"][0]["after_block_id"] == "b1"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_removing_leaves_an_existing_suggestion_of_the_same_kind_alone():
|
| 31 |
+
existing = {"id": "s1", "after_block_id": "b1", "kind": "quiz", "brief": "custom", "why": "custom"}
|
| 32 |
+
content = _content(
|
| 33 |
+
[{"id": "b1", "type": "prose", "text": "intro"}, {"id": "b2", "type": "quiz", "after": "b1", "title": "Quiz"}],
|
| 34 |
+
[existing],
|
| 35 |
+
)
|
| 36 |
+
out = remove_block(content, "b2")
|
| 37 |
+
assert out["suggestions"] == [existing] # not duplicated
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_prose_cannot_be_removed():
|
| 41 |
+
content = _content([{"id": "b1", "type": "prose", "text": "intro"}])
|
| 42 |
+
assert remove_block(content, "b1") is None
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_unknown_block_id_returns_none():
|
| 46 |
+
content = _content([{"id": "b1", "type": "prose", "text": "intro"}])
|
| 47 |
+
assert remove_block(content, "nope") is None
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_a_kind_with_no_fallback_brief_is_removed_without_a_restored_suggestion():
|
| 51 |
+
content = _content([
|
| 52 |
+
{"id": "b1", "type": "prose", "text": "intro"},
|
| 53 |
+
{"id": "b2", "type": "table", "after": "b1"},
|
| 54 |
+
])
|
| 55 |
+
out = remove_block(content, "b2")
|
| 56 |
+
assert out["blocks"] == [{"id": "b1", "type": "prose", "text": "intro"}]
|
| 57 |
+
assert out["suggestions"] == []
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _artifact(content_json: dict, storage_key: str | None = None) -> Artifact:
|
| 61 |
+
a = Artifact(id=uuid.uuid4(), workspace_id=uuid.uuid4(), type="x", status="ready", content_json=content_json)
|
| 62 |
+
a.storage_key = storage_key
|
| 63 |
+
return a
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_copy_block_reuses_a_ready_infographic_by_its_content_not_a_storage_key():
|
| 67 |
+
# Infographics render from stored HTML (content_json["content"]), never from storage_key: that field is always
|
| 68 |
+
# None for them. Checking storage_key here used to make every reuse fail with "nothing to add".
|
| 69 |
+
existing = _artifact({"title": "My infographic", "theme": "midnight", "content": {"title": "t"}}, storage_key=None)
|
| 70 |
+
block = copy_block("infographic", existing)
|
| 71 |
+
assert block == {"title": "My infographic", "artifact_id": str(existing.id), "theme": "midnight"}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def test_copy_block_rejects_an_infographic_with_no_content():
|
| 75 |
+
existing = _artifact({"title": "Empty"}, storage_key=None)
|
| 76 |
+
with pytest.raises(NothingToDo):
|
| 77 |
+
copy_block("infographic", existing)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def test_copy_block_reuses_mind_map_quiz_and_flashcards():
|
| 81 |
+
assert copy_block("mind_map", _artifact({"title": "Map", "root": {"id": "root"}, "node_count": 3, "post_count": 2})) == \
|
| 82 |
+
{"title": "Map", "root": {"id": "root"}, "node_count": 3, "post_count": 2}
|
| 83 |
+
assert copy_block("quiz", _artifact({"title": "Q", "questions": [{"question": "?"}]})) == \
|
| 84 |
+
{"title": "Q", "questions": [{"question": "?"}]}
|
| 85 |
+
assert copy_block("flashcards", _artifact({"title": "C", "cards": [{"front": "f", "back": "b"}]})) == \
|
| 86 |
+
{"title": "C", "cards": [{"front": "f", "back": "b"}]}
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def test_copy_block_rejects_a_kind_with_nothing_to_reuse():
|
| 90 |
+
with pytest.raises(NothingToDo):
|
| 91 |
+
copy_block("quiz", _artifact({"title": "Q", "questions": []}))
|
backend/tests/test_report_suggestions.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The suggested templates in the Create report dialog are written once, kept, prepared in the background and served at once."""
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
from sqlalchemy import select
|
| 5 |
+
|
| 6 |
+
from app.config import settings
|
| 7 |
+
from app.llm import run
|
| 8 |
+
from app.models import Job, ReportTemplateCache
|
| 9 |
+
from app.services import report_suggestions
|
| 10 |
+
from tests.test_idea_pool import make_posts, owner # noqa: F401 (the fixture)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@pytest.fixture()
|
| 14 |
+
def asked(monkeypatch):
|
| 15 |
+
"""Fake the model; `asked` lists what it was asked, and `answer` is what it says."""
|
| 16 |
+
calls: list[list[str]] = []
|
| 17 |
+
state = {"answer": [{"name": f"Template {n}", "description": "d", "prompt": "Write about {about}."} for n in range(4)]}
|
| 18 |
+
|
| 19 |
+
def predict(signature, *, db, workspace_id, job=None, lm=None, **inputs):
|
| 20 |
+
assert signature.__name__ == "SuggestReportTemplates"
|
| 21 |
+
calls.append(inputs["items"])
|
| 22 |
+
return {"templates": state["answer"]}
|
| 23 |
+
|
| 24 |
+
monkeypatch.setattr(run, "predict", predict)
|
| 25 |
+
return calls, state
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _suggest(client, headers, docs):
|
| 29 |
+
return client.post("/api/reports/suggest", json={"document_ids": [str(d.id) for d in docs]}, headers=headers)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_the_same_sources_get_their_templates_again_without_the_model(client, owner, db_session, asked): # noqa: F811
|
| 33 |
+
headers, ws = owner
|
| 34 |
+
calls, state = asked
|
| 35 |
+
docs = make_posts(db_session, ws, 4)
|
| 36 |
+
first = _suggest(client, headers, docs).json()["templates"]
|
| 37 |
+
again = _suggest(client, headers, list(reversed(docs))).json()["templates"] # the order they were ticked in does not matter
|
| 38 |
+
assert len(calls) == 1 and first == again and [t["id"] for t in first] == [f"suggested-{n}" for n in range(1, 5)]
|
| 39 |
+
assert len(db_session.scalars(select(ReportTemplateCache)).all()) == 1
|
| 40 |
+
# A post whose ideas changed is a different input: new templates, the old set is left alone
|
| 41 |
+
docs[0].metadata_json = {**docs[0].metadata_json, "ideas": [{"label": "A new idea", "note": "Changed.", "sources": [], "details": []}]}
|
| 42 |
+
db_session.commit()
|
| 43 |
+
_suggest(client, headers, docs)
|
| 44 |
+
assert len(calls) == 2 and len(db_session.scalars(select(ReportTemplateCache)).all()) == 2
|
| 45 |
+
# What the model read is the stored ideas, not the post text
|
| 46 |
+
assert any("- A new idea: Changed." in item for item in calls[1]) and not any("Sentence 1 about" in item for item in calls[1])
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_a_failed_or_empty_answer_is_never_kept(client, owner, db_session, asked): # noqa: F811
|
| 50 |
+
headers, ws = owner
|
| 51 |
+
calls, state = asked
|
| 52 |
+
docs = make_posts(db_session, ws, 3)
|
| 53 |
+
state["answer"] = []
|
| 54 |
+
assert _suggest(client, headers, docs).json() == {"templates": []}
|
| 55 |
+
state["answer"] = [{"name": "", "description": "", "prompt": ""}, {"name": "Only", "description": "d", "prompt": "p"},
|
| 56 |
+
{"name": "only", "description": "dup", "prompt": "p"}] # a blank and a repeat are dropped
|
| 57 |
+
assert [t["name"] for t in _suggest(client, headers, docs).json()["templates"]] == ["Only"]
|
| 58 |
+
assert len(calls) == 2 # the empty answer was not kept, so the next request asked again
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def test_templates_are_prepared_in_the_background_so_the_dialog_is_instant(client, owner, db_session, run_jobs, asked, monkeypatch): # noqa: F811
|
| 62 |
+
headers, ws = owner
|
| 63 |
+
calls, _ = asked
|
| 64 |
+
monkeypatch.setattr(settings, "llm_api_key", "test-key")
|
| 65 |
+
docs = make_posts(db_session, ws, 12, ideas_each=4)
|
| 66 |
+
client.post("/api/notebooks", json={"title": "Big", "document_ids": [str(d.id) for d in docs]}, headers=headers)
|
| 67 |
+
client.post("/api/notebooks", json={"title": "Same again", "document_ids": [str(d.id) for d in docs[:3]]}, headers=headers)
|
| 68 |
+
queued = db_session.scalars(select(Job).where(Job.kind == "report_templates")).all()
|
| 69 |
+
assert len(queued) == 1 # one at a time, however many notebooks changed
|
| 70 |
+
assert run_jobs()[0].kind == "report_templates" and len(calls) == 2 # one set written for each notebook
|
| 71 |
+
ran = len(calls)
|
| 72 |
+
monkeypatch.setattr(run, "predict", lambda *a, **k: pytest.fail("the dialog's request was prepared already"))
|
| 73 |
+
r = _suggest(client, headers, docs) # what the dialog sends: the notebook's posts
|
| 74 |
+
assert r.status_code == 200 and len(r.json()["templates"]) == 4 and ran == 2
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_warming_skips_posts_whose_ideas_are_not_stored_yet_and_sets_already_kept(db_session, owner, asked): # noqa: F811
|
| 78 |
+
_, ws = owner
|
| 79 |
+
calls, _ = asked
|
| 80 |
+
from app.models import Notebook, NotebookDocument
|
| 81 |
+
|
| 82 |
+
bare = make_posts(db_session, ws, 4, with_ideas=0)
|
| 83 |
+
nb = Notebook(workspace_id=ws.id, title="Bare")
|
| 84 |
+
db_session.add(nb)
|
| 85 |
+
db_session.flush()
|
| 86 |
+
db_session.add_all([NotebookDocument(notebook_id=nb.id, document_id=d.id) for d in bare])
|
| 87 |
+
db_session.commit()
|
| 88 |
+
assert report_suggestions.warm_workspace(db_session, ws.id) == 0 and calls == [] # waits for the ideas
|
| 89 |
+
for d in bare:
|
| 90 |
+
d.metadata_json = {"ideas_hash": d.content_hash, "ideas": [{"label": "Idea", "note": "Note.", "sources": [], "details": []}]}
|
| 91 |
+
db_session.commit()
|
| 92 |
+
assert report_suggestions.warm_workspace(db_session, ws.id) == 1 and len(calls) == 1
|
| 93 |
+
assert report_suggestions.warm_workspace(db_session, ws.id) == 0 and len(calls) == 1 # already kept
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def test_only_one_warm_up_is_queued_and_none_without_a_model_key(db_session, owner, monkeypatch): # noqa: F811
|
| 97 |
+
_, ws = owner
|
| 98 |
+
monkeypatch.setattr(settings, "llm_api_key", "")
|
| 99 |
+
report_suggestions.queue_warmup(db_session, ws.id)
|
| 100 |
+
assert db_session.scalars(select(Job).where(Job.kind == "report_templates")).all() == []
|
| 101 |
+
monkeypatch.setattr(settings, "llm_api_key", "k")
|
| 102 |
+
report_suggestions.queue_warmup(db_session, ws.id)
|
| 103 |
+
report_suggestions.queue_warmup(db_session, ws.id)
|
| 104 |
+
assert len(db_session.scalars(select(Job).where(Job.kind == "report_templates")).all()) == 1
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def test_unticking_a_post_that_is_not_one_of_the_few_read_gives_the_kept_templates_at_once(client, owner, db_session, asked): # noqa: F811
|
| 108 |
+
headers, ws = owner
|
| 109 |
+
calls, _ = asked
|
| 110 |
+
docs = make_posts(db_session, ws, 30)
|
| 111 |
+
everything = _suggest(client, headers, docs).json()["templates"]
|
| 112 |
+
assert len(calls) == 1
|
| 113 |
+
read = report_suggestions.stable_order(docs)[: report_suggestions.SUGGEST_ITEMS]
|
| 114 |
+
others = [d for d in docs if d not in read]
|
| 115 |
+
for gone in (others[0], others[7], others[-1]): # the newest, one in the middle, the oldest
|
| 116 |
+
picked = [d for d in docs if d is not gone]
|
| 117 |
+
assert _suggest(client, headers, picked).json()["templates"] == everything
|
| 118 |
+
assert len(calls) == 1 # no new model call: every one of these was a kept set
|
| 119 |
+
# Unticking one of the few that are read is a different input, and is asked for (and kept) once
|
| 120 |
+
_suggest(client, headers, [d for d in docs if d is not read[0]])
|
| 121 |
+
assert len(calls) == 2
|
backend/tests/test_report_themes.py
ADDED
|
@@ -0,0 +1,553 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reports built around themes: the posts' stored ideas are grouped into weighted themes, a blueprint plans the whole report, each
|
| 2 |
+
section is written from its own evidence. The model is always faked here (by signature name, as the other report tests do)."""
|
| 3 |
+
|
| 4 |
+
import logging
|
| 5 |
+
import uuid
|
| 6 |
+
from types import SimpleNamespace
|
| 7 |
+
|
| 8 |
+
import pytest
|
| 9 |
+
from sqlalchemy import select
|
| 10 |
+
|
| 11 |
+
from app.config import settings
|
| 12 |
+
from app.corpus import Corpus
|
| 13 |
+
from app.llm import run
|
| 14 |
+
from app.models import Document, Workspace
|
| 15 |
+
from app.pipeline import report_blueprint, report_evidence, report_themes
|
| 16 |
+
from app.pipeline.generate import ideas_fresh
|
| 17 |
+
from app.pipeline.report_blueprint import Blueprint, Section
|
| 18 |
+
from app.pipeline.report_themes import Idea, Theme, ThemesUnavailable
|
| 19 |
+
from tests.conftest import last_code
|
| 20 |
+
from tests.test_idea_pool import _notebook, make_posts
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@pytest.fixture()
|
| 24 |
+
def owner(client, db_session):
|
| 25 |
+
client.post("/api/auth/email/register/start", json={"email": "ada@example.com", "password": "stardust-42", "name": "Ada"})
|
| 26 |
+
r = client.post("/api/auth/email/register/verify", json={"email": "ada@example.com", "code": last_code()})
|
| 27 |
+
return {"Authorization": f"Bearer {r.json()['access_token']}"}, db_session.scalar(select(Workspace))
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def fake_doc() -> SimpleNamespace:
|
| 31 |
+
return SimpleNamespace(id=uuid.uuid4(), path="sources/x/a.md", title="A")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def theme(tid: str, name: str, posts: int, *, asked: bool = False, tier: str = "minor", weight: float = 0.1) -> Theme:
|
| 35 |
+
t = Theme(id=tid, name=name, summary=f"About {name}.", ideas=[Idea(f"{tid}.{n}", fake_doc(), f"{name} {n}", "n") for n in range(posts)],
|
| 36 |
+
asked=asked)
|
| 37 |
+
t.tier, t.weight, t.pinned = tier, weight, asked
|
| 38 |
+
return t
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# Weights are counted in code
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def test_weights_come_from_counting_posts_and_ideas_and_a_priority_theme_moves_up():
|
| 45 |
+
a, b, c = theme("t1", "A", 8), theme("t2", "B", 4), theme("t3", "C", 1, asked=True)
|
| 46 |
+
themes = [a, b, c]
|
| 47 |
+
report_themes.weigh(themes, total_posts=13, total_ideas=13)
|
| 48 |
+
assert round(sum(t.weight for t in themes), 3) == 1.0
|
| 49 |
+
assert (a.weight, b.weight, c.weight) == (0.6154, 0.3077, 0.0769)
|
| 50 |
+
assert (a.tier, b.tier) == ("major", "major")
|
| 51 |
+
assert c.tier == "medium" and c.pinned # minor by share, asked for: one tier up and pinned
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def test_the_heaviest_theme_is_major_even_when_the_weights_are_flat():
|
| 55 |
+
themes = [theme(f"t{n}", f"T{n}", 3) for n in range(1, 11)] # ten equal themes: 0.1 each, medium
|
| 56 |
+
report_themes.weigh(themes, total_posts=30, total_ideas=30)
|
| 57 |
+
assert sum(t.tier == "major" for t in themes) == 1
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# Grouping: the model names themes, code files what it left out
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _ideas(db_session, ws, n=8):
|
| 64 |
+
docs = make_posts(db_session, ws, n, ideas_each=2)
|
| 65 |
+
corpus = Corpus(ws.id)
|
| 66 |
+
corpus.sync()
|
| 67 |
+
return docs, report_themes.collect_ideas(docs, corpus)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_ideas_the_model_left_out_are_filed_by_code_and_the_rest_are_dropped_or_caught(owner, db_session, monkeypatch):
|
| 71 |
+
_, ws = owner
|
| 72 |
+
docs, ideas = _ideas(db_session, ws) # post i has ideas p{i}.0 and p{i}.1; posts cycle Pricing, Growth, Craft
|
| 73 |
+
|
| 74 |
+
def predict(sig, *, db, workspace_id, job=None, lm=None, **inputs):
|
| 75 |
+
assert sig.__name__ == "GroupThemes" and "p0.0 | Pricing idea 0.0:" in inputs["ideas"][0]
|
| 76 |
+
return {"themes": [
|
| 77 |
+
{"name": "Pricing", "summary": "s", "matches_request": False,
|
| 78 |
+
"idea_ids": ["p0.0", "p0.1", "p3.0", "p3.1", "p6.0", "p6.1", "nope"]},
|
| 79 |
+
{"name": "Audience Growth", "summary": "s", "matches_request": True,
|
| 80 |
+
"idea_ids": ["p1.0", "p1.1", "p4.0", "p4.1", "p0.0"]},
|
| 81 |
+
{"name": "", "summary": "no name", "idea_ids": ["p2.0"], "matches_request": False}],
|
| 82 |
+
"relations": [{"a": "pricing", "b": "Audience Growth", "relation": "Price decides who stays."},
|
| 83 |
+
{"a": "Pricing", "b": "Ghost", "relation": "unknown theme"}]}
|
| 84 |
+
|
| 85 |
+
monkeypatch.setattr(run, "predict", predict)
|
| 86 |
+
themes, relations = report_themes.group_themes(db_session, None, ws.id, "Money", "a report", ideas)
|
| 87 |
+
by_name = {t.name: t for t in themes}
|
| 88 |
+
assert set(by_name) == {"Pricing", "Audience Growth", report_themes.CATCH_ALL}
|
| 89 |
+
assert len(by_name["Pricing"].ideas) == 6 # the invented id was dropped
|
| 90 |
+
assert "p0.0" not in [i.id for i in by_name["Audience Growth"].ideas] # an idea belongs to one theme only
|
| 91 |
+
growth = {i.id for i in by_name["Audience Growth"].ideas}
|
| 92 |
+
assert {"p7.0", "p7.1"} <= growth # filed by the words it shares with the theme
|
| 93 |
+
assert {i.doc.path for i in by_name[report_themes.CATCH_ALL].ideas} == {docs[2].path, docs[5].path} # nothing fits them
|
| 94 |
+
assert sum(len(t.ideas) for t in themes) == len(ideas) # every idea counts, so the weights cover all posts
|
| 95 |
+
assert by_name["Audience Growth"].pinned and [t.id for t in themes] == ["t1", "t2", "t3"]
|
| 96 |
+
assert relations == [{"a": "t1", "b": "t2", "relation": "Price decides who stays."}]
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def test_too_few_themes_means_the_posts_are_read_as_before(owner, db_session, monkeypatch):
|
| 100 |
+
_, ws = owner
|
| 101 |
+
_, ideas = _ideas(db_session, ws)
|
| 102 |
+
monkeypatch.setattr(run, "predict", lambda *a, **k: {"themes": [{"name": "Only", "summary": "", "idea_ids": ["p0.0"]}],
|
| 103 |
+
"relations": []})
|
| 104 |
+
with pytest.raises(ThemesUnavailable):
|
| 105 |
+
report_themes.group_themes(db_session, None, ws.id, "Money", "r", ideas)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def test_the_model_sees_at_most_300_ideas_one_round_of_every_post_at_a_time(owner, db_session):
|
| 109 |
+
_, ws = owner
|
| 110 |
+
docs = make_posts(db_session, ws, 120, ideas_each=8)
|
| 111 |
+
corpus = Corpus(ws.id)
|
| 112 |
+
corpus.sync()
|
| 113 |
+
ideas = report_themes.collect_ideas(docs, corpus)
|
| 114 |
+
listed = report_themes.listed_ideas(ideas)
|
| 115 |
+
assert len(ideas) == 960 and len(listed) == report_themes.MAX_LISTED_IDEAS
|
| 116 |
+
assert len({i.doc.id for i in listed}) == 120 # every post is in the sample
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def test_a_post_without_ideas_is_represented_by_its_opening(owner, db_session):
|
| 120 |
+
_, ws = owner
|
| 121 |
+
docs = make_posts(db_session, ws, 2, with_ideas=0)
|
| 122 |
+
corpus = Corpus(ws.id)
|
| 123 |
+
corpus.sync()
|
| 124 |
+
ideas = report_themes.collect_ideas(docs, corpus)
|
| 125 |
+
assert [i.bare for i in ideas] == [True, True] and ideas[0].label == docs[0].title
|
| 126 |
+
assert ideas[0].sources[0]["path"] == docs[0].path and ideas[0].sources[0]["line_start"] >= 1
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def test_ideas_are_extracted_together_for_at_most_the_inline_limit(owner, db_session, monkeypatch):
|
| 130 |
+
_, ws = owner
|
| 131 |
+
docs = make_posts(db_session, ws, 6, with_ideas=0)
|
| 132 |
+
seen = {}
|
| 133 |
+
|
| 134 |
+
def predict_many(sig, inputs_list, *, db, workspace_id, lm=None, workers=5):
|
| 135 |
+
seen["n"] = len(inputs_list)
|
| 136 |
+
return [{"ideas": [{"label": "Idea", "note": "n", "details": [],
|
| 137 |
+
"sources": [{"path": docs[0].path, "line_start": 7, "line_end": 9}]}]} for _ in inputs_list]
|
| 138 |
+
|
| 139 |
+
monkeypatch.setattr(run, "predict_many", predict_many)
|
| 140 |
+
monkeypatch.setattr(run, "predict", lambda *a, **k: pytest.fail("together, not one by one"))
|
| 141 |
+
monkeypatch.setattr(settings, "report_inline_ideas", 4)
|
| 142 |
+
report_themes.ensure_ideas(db_session, None, ws.id, docs)
|
| 143 |
+
assert seen["n"] == 4 and sum(ideas_fresh(d) for d in docs) == 4
|
| 144 |
+
assert report_themes.fresh_share(docs) == pytest.approx(4 / 6)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
# The blueprint is checked against the weights
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def _bp(*sections: Section, connections=None) -> Blueprint:
|
| 151 |
+
return Blueprint(title="T", storyline="S", sections=list(sections), connections=connections or [])
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def _sec(heading, role="body", ids=(), depth="standard", must=()):
|
| 155 |
+
return Section(heading=heading, role=role, brief=f"{heading} brief", theme_ids=list(ids), depth=depth, must_cover=list(must))
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def test_the_plan_is_made_to_cover_every_major_and_asked_for_theme_in_its_own_section():
|
| 159 |
+
themes = [theme("t1", "Pricing", 5, tier="major", weight=0.5), theme("t2", "Hiring", 3, tier="major", weight=0.3),
|
| 160 |
+
theme("t3", "Tools", 1, weight=0.1), theme("t4", "Remote", 1, asked=True, tier="medium", weight=0.1)]
|
| 161 |
+
plan = _bp(_sec("Summary", "summary"), _sec("Pricing power", ids=["t1", "t1", "ghost"], depth="brief", must=["a"]),
|
| 162 |
+
_sec("Also pricing", ids=["t1"]), _sec("Opening", "intro"),
|
| 163 |
+
connections=[{"theme_ids": ["t1", "t2"], "relation": "linked", "section": "pricing POWER"},
|
| 164 |
+
{"theme_ids": ["t1", "zzz"], "relation": "unknown theme", "section": "x"}])
|
| 165 |
+
fixed = report_blueprint.repair(plan, themes)
|
| 166 |
+
heads = [s.heading for s in fixed.sections]
|
| 167 |
+
assert heads == ["Opening", "Pricing power", "Hiring", "Remote", "More to know", "Summary"] # intro first, summary last
|
| 168 |
+
by = {s.heading: s for s in fixed.sections}
|
| 169 |
+
assert by["Pricing power"].theme_ids == ["t1"] and by["Pricing power"].depth == "deep" # depth follows the weight
|
| 170 |
+
assert by["Remote"].depth == "deep" and by["More to know"].theme_ids == ["t3"] and by["More to know"].depth == "brief"
|
| 171 |
+
assert by["Summary"].theme_ids == ["t1", "t2", "t4"] and by["Summary"].depth == "brief"
|
| 172 |
+
assert "Hiring: About Hiring." in by["Pricing power"].covered_elsewhere
|
| 173 |
+
assert not any(c.startswith("Pricing power") for c in by["Pricing power"].covered_elsewhere)
|
| 174 |
+
assert by["Opening"].covered_elsewhere == [] and by["Summary"].covered_elsewhere == []
|
| 175 |
+
assert fixed.connections == [{"theme_ids": ["t1", "t2"], "relation": "linked", "section": "Pricing power"}]
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def test_too_many_sections_merge_the_lightest_but_never_a_major_theme():
|
| 179 |
+
themes = [theme("t1", "Big", 5, tier="major", weight=0.4)] + \
|
| 180 |
+
[theme(f"t{n}", f"Small {n}", 1, weight=0.05 + n / 1000) for n in range(2, 12)]
|
| 181 |
+
plan = _bp(_sec("Open", "intro"), _sec("Big one", ids=["t1"]), *[_sec(f"S{n}", ids=[f"t{n}"]) for n in range(2, 12)],
|
| 182 |
+
_sec("End", "summary"))
|
| 183 |
+
fixed = report_blueprint.repair(plan, themes)
|
| 184 |
+
assert len(fixed.sections) == report_blueprint.MAX_SECTIONS
|
| 185 |
+
assert [s for s in fixed.sections if s.heading == "Big one"][0].theme_ids == ["t1"]
|
| 186 |
+
assert sorted(i for s in fixed.sections if s.role == "body" for i in s.theme_ids) == sorted(t.id for t in themes) # none lost
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def test_a_plan_with_one_section_is_not_usable():
|
| 190 |
+
with pytest.raises(report_blueprint.BlueprintUnavailable):
|
| 191 |
+
report_blueprint.repair(_bp(_sec("Only", ids=["t1"])), [theme("t1", "A", 1, tier="major", weight=1.0), ])
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
# The evidence of a section
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def _themes_from(docs, ideas, groups):
|
| 198 |
+
"""Themes from an index range of posts each, weighed."""
|
| 199 |
+
themes = [Theme(id=f"t{n}", name=name, summary=f"About {name}.", ideas=[i for i in ideas if lo <= docs.index(i.doc) < hi])
|
| 200 |
+
for n, (name, lo, hi) in enumerate(groups, start=1)]
|
| 201 |
+
report_themes.weigh(themes, len(docs), len(ideas))
|
| 202 |
+
return themes
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def test_a_deep_section_reads_several_posts_as_written_and_summarises_the_rest(owner, db_session):
|
| 206 |
+
_, ws = owner
|
| 207 |
+
docs, ideas = _ideas(db_session, ws, 10)
|
| 208 |
+
corpus = Corpus(ws.id)
|
| 209 |
+
t = _themes_from(docs, ideas, [("Everything", 0, 10), ("Other", 0, 0)][:1])
|
| 210 |
+
sec = _sec("Deep", ids=["t1"], depth="deep")
|
| 211 |
+
ev = report_evidence.build(corpus, sec, {x.id: x for x in t})
|
| 212 |
+
raw = [b for b in ev.blocks if b.startswith("POST ")]
|
| 213 |
+
assert 2 <= len(raw) == ev.raw_posts == 5 # a deep section reads five posts as written, never one
|
| 214 |
+
assert any(b.startswith("SUMMARIES of other posts") for b in ev.blocks)
|
| 215 |
+
assert len(ev.posts) == 10 # the other five are represented by their summaries
|
| 216 |
+
assert "Sentence 1 about" in raw[0] and "[themes: Everything]" in raw[0] # the post's own words
|
| 217 |
+
assert "7| " not in raw[0] and "sources/x" not in raw[0] # nothing is cited, so no line numbers and no paths
|
| 218 |
+
assert sum(len(b) for b in ev.blocks) <= report_evidence.BUDGETS["deep"]
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def test_a_brief_section_gets_summaries_only_and_a_standard_one_three_posts(owner, db_session):
|
| 222 |
+
_, ws = owner
|
| 223 |
+
docs, ideas = _ideas(db_session, ws, 10)
|
| 224 |
+
corpus = Corpus(ws.id)
|
| 225 |
+
t = _themes_from(docs, ideas, [("Everything", 0, 10)])
|
| 226 |
+
by_id = {x.id: x for x in t}
|
| 227 |
+
brief = report_evidence.build(corpus, _sec("Brief", ids=["t1"], depth="brief"), by_id)
|
| 228 |
+
assert brief.raw_posts == 0 and not any(b.startswith("POST ") for b in brief.blocks)
|
| 229 |
+
assert sum(len(b) for b in brief.blocks) <= report_evidence.BUDGETS["brief"]
|
| 230 |
+
assert all(' (from "Post ' in line for line in brief.blocks[0].splitlines()[1:]) # each summary says which post it is from
|
| 231 |
+
standard = report_evidence.build(corpus, _sec("Std", ids=["t1"], depth="standard"), by_id)
|
| 232 |
+
assert standard.raw_posts == 3
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def test_a_section_about_two_themes_starts_with_the_posts_that_speak_of_both(owner, db_session):
|
| 236 |
+
_, ws = owner
|
| 237 |
+
docs, ideas = _ideas(db_session, ws, 8)
|
| 238 |
+
corpus = Corpus(ws.id)
|
| 239 |
+
a, b = Theme("t1", "A", "", []), Theme("t2", "B", "", [])
|
| 240 |
+
for d in docs:
|
| 241 |
+
mine = [i for i in ideas if i.doc is d]
|
| 242 |
+
a.ideas.append(mine[0])
|
| 243 |
+
if d in docs[5:7]: # only these two posts have an idea in each theme
|
| 244 |
+
b.ideas.append(mine[1])
|
| 245 |
+
report_themes.weigh([a, b], 8, len(ideas))
|
| 246 |
+
ev = report_evidence.build(corpus, _sec("Bridge", "synthesis", ids=["t1", "t2"], depth="standard"), {"t1": a, "t2": b})
|
| 247 |
+
first = [b_ for b_ in ev.blocks if b_.startswith("POST ")][:2]
|
| 248 |
+
assert {docs[5].title, docs[6].title} == {blk.split(" [themes")[0][5:] for blk in first}
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def test_an_unreadable_post_falls_back_to_its_summary(owner, db_session):
|
| 252 |
+
_, ws = owner
|
| 253 |
+
docs, ideas = _ideas(db_session, ws, 6)
|
| 254 |
+
ideas[0].doc.path = "sources/x/missing.md"
|
| 255 |
+
corpus = Corpus(ws.id)
|
| 256 |
+
t = _themes_from(docs, ideas, [("All", 0, 6)])
|
| 257 |
+
ev = report_evidence.build(corpus, _sec("S", ids=["t1"], depth="deep"), {"t1": t[0]})
|
| 258 |
+
assert "sources/x/missing.md" in ev.posts and ev.raw_posts == 5 # the five others
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
# The whole report
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
@pytest.fixture()
|
| 265 |
+
def free_to_make(monkeypatch):
|
| 266 |
+
monkeypatch.setattr("app.routers.artifacts.check_limit", lambda *a, **k: None)
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
class Fake:
|
| 270 |
+
"""The model, by signature name. `calls` is every call in order; `sections` the inputs of every WriteThemedSection."""
|
| 271 |
+
|
| 272 |
+
def __init__(self, monkeypatch, docs, groups, ask=None, fail=(), quiz_themes=()):
|
| 273 |
+
self.docs, self.calls, self.sections, self.fail = docs, [], [], set(fail)
|
| 274 |
+
self.quiz_themes = list(quiz_themes)
|
| 275 |
+
self.groups, self.ask, self.plan_inputs, self.report_inputs = groups, ask, None, None
|
| 276 |
+
monkeypatch.setattr(run, "predict", self.predict)
|
| 277 |
+
monkeypatch.setattr(run, "predict_many", self.predict_many)
|
| 278 |
+
|
| 279 |
+
def predict(self, sig, *, db, workspace_id, job=None, lm=None, **inputs):
|
| 280 |
+
name = sig.__name__
|
| 281 |
+
self.calls.append(name)
|
| 282 |
+
if name == "GroupThemes":
|
| 283 |
+
ids = [line.split(" | ")[0] for line in inputs["ideas"]]
|
| 284 |
+
out = []
|
| 285 |
+
for title, lo, hi in self.groups:
|
| 286 |
+
mine = [i for i in ids if lo <= int(i[1:].split(".")[0]) < hi]
|
| 287 |
+
out.append({"name": title, "summary": f"About {title}.", "idea_ids": mine, "matches_request": title == self.ask})
|
| 288 |
+
rel = [{"a": self.groups[0][0], "b": self.groups[1][0], "relation": "One shapes the other."}] if len(self.groups) > 1 else []
|
| 289 |
+
return {"themes": out, "relations": rel}
|
| 290 |
+
if name == "PlanReportBlueprint":
|
| 291 |
+
self.plan_inputs = inputs
|
| 292 |
+
return {"report_title": "Pricing and growth", "storyline": "Prices set expectations and growth follows.", "sections": [
|
| 293 |
+
{"heading": "Why this matters", "role": "intro", "brief": "Opens.", "theme_ids": [], "depth": "brief",
|
| 294 |
+
"must_cover": ["scope"]},
|
| 295 |
+
{"heading": "Pricing power", "role": "body", "brief": "Pricing.", "theme_ids": ["t1"], "depth": "deep",
|
| 296 |
+
"must_cover": ["raise slowly"],
|
| 297 |
+
"embed": {"kind": "flashcards", "title": "Terms", "brief": "Price terms", "why": "Learn them"}},
|
| 298 |
+
{"heading": "Audience growth", "role": "body", "brief": "Growth.", "theme_ids": ["t2"], "depth": "standard",
|
| 299 |
+
"must_cover": ["retention"]},
|
| 300 |
+
{"heading": "Quick quiz", "role": "body", "brief": "Test it.", "theme_ids": self.quiz_themes, "depth": "brief",
|
| 301 |
+
"must_cover": []},
|
| 302 |
+
{"heading": "Wrap up", "role": "summary", "brief": "Closes.", "theme_ids": [], "depth": "brief",
|
| 303 |
+
"must_cover": ["main points"]}],
|
| 304 |
+
"connections": [{"theme_ids": ["t1", "t2"], "relation": "Price shapes who stays.",
|
| 305 |
+
"section": "Audience growth"}]}
|
| 306 |
+
if name == "WriteThemedReport":
|
| 307 |
+
self.report_inputs = inputs
|
| 308 |
+
text = "## Why this matters\nIntro [1].\n\n## Pricing power\nBody [2].\n\n## Wrap up\nEnd."
|
| 309 |
+
return {"report_title": "One Piece", "markdown": text}
|
| 310 |
+
if name == "WriteReport":
|
| 311 |
+
return {"report_title": "Legacy", "markdown": "## One\nText."}
|
| 312 |
+
raise AssertionError(f"unexpected {name}")
|
| 313 |
+
|
| 314 |
+
def predict_many(self, sig, inputs_list, *, db, workspace_id, lm=None, workers=5):
|
| 315 |
+
assert sig.__name__ == "WriteThemedSection"
|
| 316 |
+
out = []
|
| 317 |
+
for inp in inputs_list:
|
| 318 |
+
self.sections.append(inp)
|
| 319 |
+
self.calls.append("WriteThemedSection")
|
| 320 |
+
if inp["heading"] in self.fail:
|
| 321 |
+
out.append(None)
|
| 322 |
+
else:
|
| 323 |
+
out.append({"markdown": f"{inp['heading']} text [1]."}) # a marker the model wrote anyway
|
| 324 |
+
return out
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
def _report(client, owner, docs, fmt, **extra):
|
| 328 |
+
headers, _ = owner
|
| 329 |
+
nb = _notebook(client, owner, docs)
|
| 330 |
+
return client.post("/api/artifacts/generate", json={
|
| 331 |
+
"type": "report", "notebook_id": nb["id"], "document_ids": [str(d.id) for d in docs], "report_format": fmt,
|
| 332 |
+
"instructions": "Write a report.", **extra}, headers=headers).json()
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
def _content(client, owner, art):
|
| 336 |
+
return client.get(f"/api/artifacts/{art['id']}", headers=owner[0]).json()["content"]
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
GROUPS = [("Pricing", 0, 6), ("Audience Growth", 6, 8), ("Writing Craft", 8, 10)] # 60%, 20%, 20% of the posts
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def test_an_interactive_report_is_planned_then_written_section_by_section_from_its_own_evidence(client, owner, db_session, run_jobs,
|
| 343 |
+
monkeypatch, free_to_make):
|
| 344 |
+
docs = make_posts(db_session, owner[1], 10, ideas_each=2)
|
| 345 |
+
fake = Fake(monkeypatch, docs, GROUPS, quiz_themes=["t3"]) # the plan wrongly makes a section of a quiz
|
| 346 |
+
art = _report(client, owner, docs, "interactive")
|
| 347 |
+
run_jobs()
|
| 348 |
+
c = _content(client, owner, art)
|
| 349 |
+
assert c["format"] == "interactive" and c["plan"]["planner"] == "themes"
|
| 350 |
+
assert [b["text"].split("\n")[0] for b in c["blocks"]] == [
|
| 351 |
+
"## Why this matters", "## Pricing power", "## Audience growth", "## Wrap up"]
|
| 352 |
+
assert "PlanInteractiveReport" not in fake.calls and "WriteReportSection" not in fake.calls and "WriteReport" not in fake.calls
|
| 353 |
+
assert fake.calls[:2] == ["GroupThemes", "PlanReportBlueprint"]
|
| 354 |
+
# The weights are counted: Pricing is in six of ten posts
|
| 355 |
+
themes = {t["name"]: t for t in c["plan"]["themes"]}
|
| 356 |
+
assert themes["Pricing"]["tier"] == "major" and themes["Pricing"]["weight"] == 0.6 and themes["Audience Growth"]["tier"] == "medium"
|
| 357 |
+
assert c["plan"]["posts_total"] == 10 and c["source"]["total"] == 10 and len(c["source"]["document_ids"]) == 10
|
| 358 |
+
# Nothing is cited. The Sources list is every post the report was made from, shown in a drop-down with the prompt
|
| 359 |
+
assert c["citations"] == [] and not any("[" in b["text"] for b in c["blocks"])
|
| 360 |
+
assert c["source"]["read"] == c["plan"]["posts_used"] == len(c["sources"]) == 10
|
| 361 |
+
assert {x["document_id"] for x in c["sources"]} == {str(d.id) for d in docs} and all("markers" not in x for x in c["sources"])
|
| 362 |
+
assert c["instructions"] == "Write a report."
|
| 363 |
+
# The planner saw the allowed visuals and the themes, never the posts
|
| 364 |
+
assert fake.plan_inputs["allowed_kinds"] and all("POST" not in line for line in fake.plan_inputs["themes"])
|
| 365 |
+
# Body sections first, then the introduction and the summary, written from what the sections say
|
| 366 |
+
written = [s["heading"] for s in fake.sections]
|
| 367 |
+
assert written[:2] == ["Pricing power", "Audience growth"] and written[2:] == ["Why this matters", "Wrap up"]
|
| 368 |
+
pricing = fake.sections[0]
|
| 369 |
+
assert pricing["storyline"].startswith("Prices set") and len(pricing["outline"]) == 4
|
| 370 |
+
assert pricing["covered_elsewhere"][0].startswith("Audience growth:")
|
| 371 |
+
assert all(not x.startswith("Pricing power") for x in pricing["covered_elsewhere"])
|
| 372 |
+
assert sum(e.startswith("POST ") for e in pricing["evidence"]) >= 2 # several posts as written
|
| 373 |
+
growth = fake.sections[1]
|
| 374 |
+
assert growth["bridges"] == ["Pricing + Audience Growth: Price shapes who stays. (bridged in: Audience growth)"]
|
| 375 |
+
assert fake.sections[2]["evidence"][0].startswith("Pricing power: Pricing power text") and fake.sections[2]["role"] == "intro"
|
| 376 |
+
assert "Restate the main points about: Pricing" in fake.sections[3]["must_cover"][-1]
|
| 377 |
+
assert growth["evidence"] and "Writing Craft" in growth["evidence"][-1] # the quiz section's theme stayed in the report
|
| 378 |
+
# Visuals are only suggested; the quiz section became a suggestion
|
| 379 |
+
# The plan suggested no infographic, so one is offered after the last section before the summary
|
| 380 |
+
assert [(s["after_block_id"], s["kind"]) for s in c["suggestions"]] == [
|
| 381 |
+
("b2", "flashcards"), ("b3", "quiz"), ("b3", "infographic")]
|
| 382 |
+
assert "failed_sections" not in c
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
def test_a_document_report_is_written_in_one_piece_from_the_plan_and_offers_no_visuals(client, owner, db_session, run_jobs,
|
| 386 |
+
monkeypatch, free_to_make):
|
| 387 |
+
docs = make_posts(db_session, owner[1], 10, ideas_each=2)
|
| 388 |
+
fake = Fake(monkeypatch, docs, GROUPS)
|
| 389 |
+
art = _report(client, owner, docs, "document")
|
| 390 |
+
run_jobs()
|
| 391 |
+
c = _content(client, owner, art)
|
| 392 |
+
assert c["format"] == "document" and c["plan"]["planner"] == "themes"
|
| 393 |
+
assert c["suggestions"] == [] # never in a document
|
| 394 |
+
assert fake.calls == ["GroupThemes", "PlanReportBlueprint", "WriteThemedReport"]
|
| 395 |
+
assert fake.plan_inputs["allowed_kinds"] == []
|
| 396 |
+
sections = fake.report_inputs["sections"]
|
| 397 |
+
assert sections[0].startswith("SECTION 1: Why this matters") and all(s.startswith("SECTION") for s in sections)
|
| 398 |
+
assert "Evidence:\nPOST " in sections[1] and fake.report_inputs["storyline"].startswith("Prices set")
|
| 399 |
+
assert [b["text"].split("\n")[0] for b in c["blocks"]] == ["## Why this matters", "## Pricing power", "## Wrap up"]
|
| 400 |
+
assert c["citations"] == [] and not any("[" in b["text"] for b in c["blocks"]) and len(c["sources"]) == 10
|
| 401 |
+
assert sum(len(s) for s in sections) < report_evidence.BUDGETS["deep"] * 4 # the evidence fits its budget
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
def test_more_than_twenty_posts_are_all_used(client, owner, db_session, run_jobs, monkeypatch, free_to_make):
|
| 405 |
+
docs = make_posts(db_session, owner[1], 30, ideas_each=2)
|
| 406 |
+
fake = Fake(monkeypatch, docs, [("Pricing", 0, 20), ("Audience Growth", 20, 25), ("Writing Craft", 25, 30)])
|
| 407 |
+
art = _report(client, owner, docs, "interactive")
|
| 408 |
+
run_jobs()
|
| 409 |
+
c = _content(client, owner, art)
|
| 410 |
+
assert c["plan"]["posts_total"] == 30 and len(c["source"]["document_ids"]) == 30
|
| 411 |
+
assert {t["name"]: t["posts"] for t in c["plan"]["themes"]} == {"Pricing": 20, "Audience Growth": 5, "Writing Craft": 5}
|
| 412 |
+
assert fake.calls[0] == "GroupThemes"
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
def test_a_theme_the_reader_asked_for_gets_its_own_deep_section(client, owner, db_session, run_jobs, monkeypatch, free_to_make):
|
| 416 |
+
docs = make_posts(db_session, owner[1], 10, ideas_each=2)
|
| 417 |
+
Fake(monkeypatch, docs, GROUPS, ask="Writing Craft")
|
| 418 |
+
art = _report(client, owner, docs, "interactive", instructions="Focus on writing craft.")
|
| 419 |
+
run_jobs()
|
| 420 |
+
c = _content(client, owner, art)
|
| 421 |
+
sections = {s["heading"]: s for s in c["plan"]["sections"]}
|
| 422 |
+
assert sections["Writing Craft"]["depth"] == "deep" and sections["Writing Craft"]["theme_ids"] == ["t3"]
|
| 423 |
+
assert {t["name"]: t["pinned"] for t in c["plan"]["themes"]}["Writing Craft"] is True
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
def test_a_section_that_fails_is_tried_again_then_reported_not_hidden(client, owner, db_session, run_jobs, monkeypatch, free_to_make):
|
| 427 |
+
docs = make_posts(db_session, owner[1], 10, ideas_each=2)
|
| 428 |
+
seen: list[str] = []
|
| 429 |
+
|
| 430 |
+
class Flaky(Fake):
|
| 431 |
+
def predict_many(self, sig, inputs_list, **kw):
|
| 432 |
+
seen.extend(i["heading"] for i in inputs_list)
|
| 433 |
+
return super().predict_many(sig, inputs_list, **kw)
|
| 434 |
+
|
| 435 |
+
Flaky(monkeypatch, docs, GROUPS, fail=["Audience growth"])
|
| 436 |
+
art = _report(client, owner, docs, "interactive")
|
| 437 |
+
run_jobs()
|
| 438 |
+
c = _content(client, owner, art)
|
| 439 |
+
assert seen.count("Audience growth") == 2 # once, then once more
|
| 440 |
+
assert c["failed_sections"] == ["Audience growth"]
|
| 441 |
+
assert [b["text"].split("\n")[0] for b in c["blocks"]] == ["## Why this matters", "## Pricing power", "## More to know", "## Wrap up"]
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
def test_when_the_ideas_cannot_be_themed_the_posts_are_read_as_before(client, owner, db_session, run_jobs, monkeypatch, free_to_make):
|
| 445 |
+
docs = make_posts(db_session, owner[1], 10, ideas_each=2)
|
| 446 |
+
fake = Fake(monkeypatch, docs, [("Only", 0, 10)]) # a single theme is no plan
|
| 447 |
+
art = _report(client, owner, docs, "document")
|
| 448 |
+
run_jobs()
|
| 449 |
+
c = _content(client, owner, art)
|
| 450 |
+
assert fake.calls == ["GroupThemes", "WriteReport"] and "plan" not in c and c["title"] == "Legacy"
|
| 451 |
+
|
| 452 |
+
|
| 453 |
+
def test_small_selections_chats_and_the_legacy_setting_keep_the_old_way(client, owner, db_session, run_jobs, monkeypatch, free_to_make):
|
| 454 |
+
docs = make_posts(db_session, owner[1], 10, ideas_each=2)
|
| 455 |
+
fake = Fake(monkeypatch, docs, GROUPS)
|
| 456 |
+
small = _report(client, owner, docs[:5], "document") # five posts or fewer: read whole
|
| 457 |
+
run_jobs()
|
| 458 |
+
assert fake.calls == ["WriteReport"] and "plan" not in _content(client, owner, small)
|
| 459 |
+
fake.calls.clear()
|
| 460 |
+
monkeypatch.setattr(settings, "report_planner", "legacy")
|
| 461 |
+
_report(client, owner, docs, "document")
|
| 462 |
+
run_jobs()
|
| 463 |
+
assert fake.calls == ["WriteReport"]
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
def test_posts_without_ideas_use_the_old_way_until_half_have_them(client, owner, db_session, run_jobs, monkeypatch, free_to_make):
|
| 467 |
+
docs = make_posts(db_session, owner[1], 10, with_ideas=3, ideas_each=2)
|
| 468 |
+
monkeypatch.setattr(settings, "report_inline_ideas", 0) # nothing is extracted while the report is made
|
| 469 |
+
fake = Fake(monkeypatch, docs, GROUPS)
|
| 470 |
+
_report(client, owner, docs, "document")
|
| 471 |
+
run_jobs()
|
| 472 |
+
assert fake.calls == ["WriteReport"]
|
| 473 |
+
assert [d for d in db_session.scalars(select(Document)) if ideas_fresh(d)].__len__() == 3
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
def test_every_step_of_a_themed_report_is_logged(client, owner, db_session, run_jobs, monkeypatch, free_to_make, caplog):
|
| 477 |
+
caplog.set_level(logging.INFO, logger="app")
|
| 478 |
+
docs = make_posts(db_session, owner[1], 10, ideas_each=2)
|
| 479 |
+
Fake(monkeypatch, docs, GROUPS)
|
| 480 |
+
_report(client, owner, docs, "interactive")
|
| 481 |
+
run_jobs()
|
| 482 |
+
text = "\n".join(r.getMessage() for r in caplog.records)
|
| 483 |
+
for expected in ("report posts: ", "themed report starting: ", "report ideas collected: ", "report themes: asking the model",
|
| 484 |
+
"report theme: id=t1 name='Pricing' weight=0.600 tier=major", "report blueprint checked: ",
|
| 485 |
+
"report section 1: heading='Why this matters'", "report evidence: section='Pricing power' depth=deep",
|
| 486 |
+
"report wave 1: ", "report wave 2: ", "report interactive written: ", "themed report done: ",
|
| 487 |
+
"report: job="):
|
| 488 |
+
assert expected in text, expected
|
| 489 |
+
assert "way=themes" in text
|
| 490 |
+
|
| 491 |
+
|
| 492 |
+
def test_a_fallback_says_why(client, owner, db_session, run_jobs, monkeypatch, free_to_make, caplog):
|
| 493 |
+
caplog.set_level(logging.INFO, logger="app")
|
| 494 |
+
docs = make_posts(db_session, owner[1], 10, ideas_each=2)
|
| 495 |
+
Fake(monkeypatch, docs, [("Only", 0, 10)])
|
| 496 |
+
_report(client, owner, docs, "document")
|
| 497 |
+
run_jobs()
|
| 498 |
+
text = "\n".join(r.getMessage() for r in caplog.records)
|
| 499 |
+
assert "report themes unusable: usable=1 needed=2" in text and "reading the posts instead" in text and "way=legacy" in text
|
| 500 |
+
caplog.clear()
|
| 501 |
+
_report(client, owner, docs[:5], "document")
|
| 502 |
+
run_jobs()
|
| 503 |
+
assert "report route: legacy" in "\n".join(r.getMessage() for r in caplog.records)
|
| 504 |
+
|
| 505 |
+
|
| 506 |
+
def test_a_report_always_offers_visuals_and_never_repeats_what_the_plan_suggested():
|
| 507 |
+
from app.pipeline import report
|
| 508 |
+
|
| 509 |
+
blocks = [{"id": f"b{n}", "type": "prose"} for n in range(1, 6)]
|
| 510 |
+
kinds = list(report.EMBED_KINDS)
|
| 511 |
+
nothing = report.fill_suggestions([], blocks, kinds)
|
| 512 |
+
assert [(s["kind"], s["after_block_id"]) for s in nothing] == [("infographic", "b4"), ("flashcards", "b2"), ("quiz", "b3")]
|
| 513 |
+
planned = [{"id": "s1", "after_block_id": "b1", "kind": "infographic", "brief": "x", "why": "y"}]
|
| 514 |
+
kept = report.fill_suggestions(planned, blocks, kinds)
|
| 515 |
+
assert kept[0] == planned[0] and [s["kind"] for s in kept] == ["infographic", "flashcards", "quiz"] # not offered twice
|
| 516 |
+
assert [s["kind"] for s in report.fill_suggestions([], blocks, ["infographic", "mind_map"])] == ["infographic", "mind_map"]
|
| 517 |
+
assert report.fill_suggestions([], blocks, []) == [] and report.fill_suggestions([], [], kinds) == []
|
| 518 |
+
chat = report.fill_suggestions([], blocks, [k for k in kinds if k != "mind_map"])
|
| 519 |
+
assert "mind_map" not in {s["kind"] for s in chat}
|
| 520 |
+
|
| 521 |
+
|
| 522 |
+
def test_an_older_report_without_suggestions_gets_them_once_when_opened(client, owner, db_session, run_jobs, monkeypatch, free_to_make):
|
| 523 |
+
from app.models import Artifact
|
| 524 |
+
|
| 525 |
+
docs = make_posts(db_session, owner[1], 10, ideas_each=2)
|
| 526 |
+
Fake(monkeypatch, docs, GROUPS)
|
| 527 |
+
art = _report(client, owner, docs, "interactive")
|
| 528 |
+
run_jobs()
|
| 529 |
+
row = db_session.get(Artifact, uuid.UUID(art["id"]))
|
| 530 |
+
row.content_json = {k: v for k, v in row.content_json.items() if k not in ("suggestions", "suggestions_offered")} # as an old one
|
| 531 |
+
db_session.commit()
|
| 532 |
+
first = _content(client, owner, art)
|
| 533 |
+
assert {"infographic", "flashcards", "quiz"} <= {s["kind"] for s in first["suggestions"]} and first["suggestions_offered"]
|
| 534 |
+
# Once: a reader who has added or taken everything away is not offered them again
|
| 535 |
+
row = db_session.get(Artifact, uuid.UUID(art["id"]))
|
| 536 |
+
db_session.refresh(row)
|
| 537 |
+
row.content_json = {**row.content_json, "suggestions": []}
|
| 538 |
+
db_session.commit()
|
| 539 |
+
assert _content(client, owner, art)["suggestions"] == []
|
| 540 |
+
|
| 541 |
+
|
| 542 |
+
def test_a_document_report_is_never_given_suggestions_not_even_an_old_one_when_opened(client, owner, db_session, run_jobs, monkeypatch,
|
| 543 |
+
free_to_make):
|
| 544 |
+
from app.models import Artifact
|
| 545 |
+
|
| 546 |
+
docs = make_posts(db_session, owner[1], 10, ideas_each=2)
|
| 547 |
+
Fake(monkeypatch, docs, GROUPS)
|
| 548 |
+
art = _report(client, owner, docs, "document")
|
| 549 |
+
run_jobs()
|
| 550 |
+
row = db_session.get(Artifact, uuid.UUID(art["id"]))
|
| 551 |
+
row.content_json = {k: v for k, v in row.content_json.items() if k not in ("suggestions", "suggestions_offered")}
|
| 552 |
+
db_session.commit()
|
| 553 |
+
assert _content(client, owner, art).get("suggestions", []) == []
|
frontend/src/components/ArtifactFeedback.tsx
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
|
| 2 |
+
import { artifactsApi } from "../api/endpoints";
|
| 3 |
+
import type { AnswerFeedback as Feedback, Artifact } from "../api/types";
|
| 4 |
+
import { Thumb } from "./AnswerFeedback";
|
| 5 |
+
import { errorMessage } from "./ui";
|
| 6 |
+
|
| 7 |
+
/** What can be rated: finished reports, quizzes, flashcard sets and infographics. */
|
| 8 |
+
export const RATEABLE = ["report", "quiz", "flashcards", "infographic"];
|
| 9 |
+
|
| 10 |
+
/** Why a thumbs down, per kind (the ids the server keeps; see services/artifact_feedback.py). */
|
| 11 |
+
const REASONS: Record<string, { id: string; label: string }[]> = {
|
| 12 |
+
report: [
|
| 13 |
+
{ id: "inaccurate", label: "Inaccurate or made up" },
|
| 14 |
+
{ id: "missed_points", label: "Missed key points" },
|
| 15 |
+
{ id: "structure", label: "Poorly structured" },
|
| 16 |
+
{ id: "tone", label: "Wrong tone or style" },
|
| 17 |
+
{ id: "too_long", label: "Too long" },
|
| 18 |
+
{ id: "too_short", label: "Too short" },
|
| 19 |
+
{ id: "other", label: "Something else" },
|
| 20 |
+
],
|
| 21 |
+
quiz: [
|
| 22 |
+
{ id: "wrong_answers", label: "Wrong answers" },
|
| 23 |
+
{ id: "too_easy", label: "Too easy" },
|
| 24 |
+
{ id: "too_hard", label: "Too hard" },
|
| 25 |
+
{ id: "unclear", label: "Unclear questions" },
|
| 26 |
+
{ id: "off_topic", label: "Not from my material" },
|
| 27 |
+
{ id: "other", label: "Something else" },
|
| 28 |
+
],
|
| 29 |
+
flashcards: [
|
| 30 |
+
{ id: "inaccurate", label: "Inaccurate" },
|
| 31 |
+
{ id: "too_basic", label: "Too basic" },
|
| 32 |
+
{ id: "too_detailed", label: "Too detailed" },
|
| 33 |
+
{ id: "unclear", label: "Unclear wording" },
|
| 34 |
+
{ id: "other", label: "Something else" },
|
| 35 |
+
],
|
| 36 |
+
infographic: [
|
| 37 |
+
{ id: "messy_layout", label: "Messy layout" },
|
| 38 |
+
{ id: "cut_off", label: "Text cut off or overlapping" },
|
| 39 |
+
{ id: "missing_content", label: "Wrong or missing content" },
|
| 40 |
+
{ id: "look", label: "The look does not suit" },
|
| 41 |
+
{ id: "hard_to_read", label: "Hard to read" },
|
| 42 |
+
{ id: "other", label: "Something else" },
|
| 43 |
+
],
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
+
// A rating is shown wherever its artifact is (the card, the viewer, the results), so they share one copy.
|
| 47 |
+
const ratings = new Map<string, Feedback | null>();
|
| 48 |
+
const listeners = new Set<() => void>();
|
| 49 |
+
const subscribe = (fn: () => void) => {
|
| 50 |
+
listeners.add(fn);
|
| 51 |
+
return () => listeners.delete(fn);
|
| 52 |
+
};
|
| 53 |
+
function put(id: string, fb: Feedback | null) {
|
| 54 |
+
ratings.set(id, fb);
|
| 55 |
+
listeners.forEach((l) => l());
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/** Thumbs up and thumbs down on a generated report, quiz, flashcard set or infographic, so it is clear which ones are
|
| 59 |
+
* good. A thumbs down asks why (optional). Saved as soon as it is chosen; shared by every place the artifact shows.
|
| 60 |
+
* `compact` opens the reasons over the page instead of pushing it down. */
|
| 61 |
+
export function ArtifactFeedback({ artifact, label, compact = false }: { artifact: Artifact; label?: ReactNode; compact?: boolean }) {
|
| 62 |
+
const initial = artifact.feedback ?? null;
|
| 63 |
+
const fb = useSyncExternalStore(subscribe, () => (ratings.has(artifact.id) ? ratings.get(artifact.id)! : initial), () => initial);
|
| 64 |
+
const [open, setOpen] = useState(false);
|
| 65 |
+
const [reasons, setReasons] = useState<string[]>(initial?.reasons ?? []);
|
| 66 |
+
const [comment, setComment] = useState(initial?.comment ?? "");
|
| 67 |
+
const [error, setError] = useState<string | null>(null);
|
| 68 |
+
const [thanks, setThanks] = useState(false);
|
| 69 |
+
const options = REASONS[artifact.type] ?? [];
|
| 70 |
+
const whyRef = useRef<HTMLDivElement>(null);
|
| 71 |
+
useEffect(() => {
|
| 72 |
+
if (open) whyRef.current?.scrollIntoView({ block: "nearest", behavior: "smooth" }); // in a fixed size popup it can open below the fold
|
| 73 |
+
}, [open, fb?.rating]);
|
| 74 |
+
if (!RATEABLE.includes(artifact.type) || artifact.status !== "ready") return null;
|
| 75 |
+
|
| 76 |
+
const save = async (rating: "up" | "down" | null, r: string[] = [], c = "") => {
|
| 77 |
+
const before = fb;
|
| 78 |
+
setError(null);
|
| 79 |
+
put(artifact.id, rating ? { rating, reasons: r, comment: c || null } : null); // shows at once; undone if the save fails
|
| 80 |
+
try {
|
| 81 |
+
const res = await artifactsApi.rate(artifact.id, { rating, reasons: r, comment: c || undefined });
|
| 82 |
+
put(artifact.id, res.feedback);
|
| 83 |
+
} catch (e) {
|
| 84 |
+
put(artifact.id, before);
|
| 85 |
+
setError(errorMessage(e));
|
| 86 |
+
}
|
| 87 |
+
};
|
| 88 |
+
|
| 89 |
+
const choose = (rating: "up" | "down") => {
|
| 90 |
+
setThanks(false);
|
| 91 |
+
if (fb?.rating === rating) {
|
| 92 |
+
setOpen(false);
|
| 93 |
+
void save(null);
|
| 94 |
+
return;
|
| 95 |
+
}
|
| 96 |
+
setOpen(true); // both ratings can take a few words (a thumbs down also asks why)
|
| 97 |
+
if (rating === "up") {
|
| 98 |
+
setReasons([]);
|
| 99 |
+
setComment("");
|
| 100 |
+
}
|
| 101 |
+
void save(rating, rating === "down" ? reasons : []);
|
| 102 |
+
};
|
| 103 |
+
|
| 104 |
+
return (
|
| 105 |
+
<div className={`fb afb${compact ? " afb-compact" : ""}`}>
|
| 106 |
+
<div className="fb-row">
|
| 107 |
+
{label && <span className="muted small afb-label">{label}</span>}
|
| 108 |
+
<button type="button" className={`fb-btn${fb?.rating === "up" ? " is-on" : ""}`} aria-pressed={fb?.rating === "up"}
|
| 109 |
+
aria-label="Good" title="Good" onClick={() => choose("up")}>
|
| 110 |
+
<Thumb />
|
| 111 |
+
</button>
|
| 112 |
+
<button type="button" className={`fb-btn${fb?.rating === "down" ? " is-on is-down" : ""}`} aria-pressed={fb?.rating === "down"}
|
| 113 |
+
aria-label="Not good" title="Not good" onClick={() => choose("down")}>
|
| 114 |
+
<Thumb down />
|
| 115 |
+
</button>
|
| 116 |
+
{thanks && <span className="mono muted small">Thanks, saved.</span>}
|
| 117 |
+
{error && <span className="error-text small">Could not save that. Try again.</span>}
|
| 118 |
+
</div>
|
| 119 |
+
{open && fb && (
|
| 120 |
+
<div className="fb-why afb-why" ref={whyRef}>
|
| 121 |
+
<p className="mono muted small">{fb.rating === "up" ? "What worked well? (optional)" : "What was wrong? (optional)"}</p>
|
| 122 |
+
{fb.rating === "down" && <div className="fb-chips">
|
| 123 |
+
{options.map((r) => (
|
| 124 |
+
<button key={r.id} type="button" className={`fb-chip${reasons.includes(r.id) ? " is-on" : ""}`} aria-pressed={reasons.includes(r.id)}
|
| 125 |
+
onClick={() => setReasons((list) => (list.includes(r.id) ? list.filter((x) => x !== r.id) : [...list, r.id]))}>
|
| 126 |
+
{r.label}
|
| 127 |
+
</button>
|
| 128 |
+
))}
|
| 129 |
+
</div>}
|
| 130 |
+
<textarea className="input fb-note" rows={2} maxLength={1000} value={comment} onChange={(e) => setComment(e.target.value)}
|
| 131 |
+
placeholder={fb.rating === "up" ? "Tell us more, like what you liked" : "Tell us more, like what it should have been"}
|
| 132 |
+
aria-label={fb.rating === "up" ? "What worked well" : "What was wrong"} />
|
| 133 |
+
<div className="row end">
|
| 134 |
+
<button type="button" className="btn btn-small" onClick={() => setOpen(false)}>Skip</button>
|
| 135 |
+
<button type="button" className="btn btn-small btn-primary" onClick={async () => {
|
| 136 |
+
await save(fb.rating, fb.rating === "down" ? reasons : [], comment.trim());
|
| 137 |
+
setOpen(false);
|
| 138 |
+
setThanks(true);
|
| 139 |
+
}}>Send</button>
|
| 140 |
+
</div>
|
| 141 |
+
</div>
|
| 142 |
+
)}
|
| 143 |
+
</div>
|
| 144 |
+
);
|
| 145 |
+
}
|
frontend/src/hooks/reportSuggestions.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { reportsApi } from "../api/endpoints";
|
| 2 |
+
import type { ReportTemplate } from "../api/types";
|
| 3 |
+
|
| 4 |
+
/** The suggested report templates for some sources. The server keeps them (prepared as soon as ideas are extracted), so a repeat is
|
| 5 |
+
* instant; this also shares one request between the notebook page asking early and the dialog asking when it opens. */
|
| 6 |
+
const asked = new Map<string, Promise<ReportTemplate[]>>();
|
| 7 |
+
|
| 8 |
+
export function suggestTemplates(body: Record<string, unknown>): Promise<ReportTemplate[]> {
|
| 9 |
+
const key = JSON.stringify(body);
|
| 10 |
+
let found = asked.get(key);
|
| 11 |
+
if (!found) {
|
| 12 |
+
found = reportsApi.suggest(body).then((r) => r.templates).catch((e) => {
|
| 13 |
+
asked.delete(key); // a failure is tried again next time
|
| 14 |
+
throw e;
|
| 15 |
+
});
|
| 16 |
+
asked.set(key, found);
|
| 17 |
+
}
|
| 18 |
+
return found;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
/** Ask for a notebook's templates ahead of time, for the posts the Create report dialog starts with ticked, so they are ready when it opens. */
|
| 22 |
+
export function prefetchSuggestions(notebookId: string, postIds: string[]) {
|
| 23 |
+
if (postIds.length) suggestTemplates({ document_ids: postIds, notebook_id: notebookId }).catch(() => undefined);
|
| 24 |
+
}
|
frontend/src/hooks/useArtifactLimits.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from "react";
|
| 2 |
+
import { settingsApi } from "../api/endpoints";
|
| 3 |
+
|
| 4 |
+
/** Posts a report or infographic reads, and a dialog ticks to start with (the server's ARTIFACT_MAX_POSTS; this is the default until it
|
| 5 |
+
* answers). A quiz or flashcard set can pick as many as the plan indexes (`selectable`). */
|
| 6 |
+
export type ArtifactLimits = { posts: number; selectable: number };
|
| 7 |
+
|
| 8 |
+
const DEFAULTS: ArtifactLimits = { posts: 20, selectable: 20 };
|
| 9 |
+
let known: ArtifactLimits | null = null;
|
| 10 |
+
let asked: Promise<ArtifactLimits> | null = null;
|
| 11 |
+
const listeners = new Set<(l: ArtifactLimits) => void>();
|
| 12 |
+
|
| 13 |
+
/** Ask the server once; everyone shares the answer. */
|
| 14 |
+
export function loadArtifactLimits(): Promise<ArtifactLimits> {
|
| 15 |
+
if (!asked) {
|
| 16 |
+
asked = settingsApi.limits().then((r) => {
|
| 17 |
+
known = { posts: r.posts, selectable: r.selectable_posts };
|
| 18 |
+
listeners.forEach((fn) => fn(known!));
|
| 19 |
+
return known;
|
| 20 |
+
}).catch(() => {
|
| 21 |
+
asked = null; // try again next time
|
| 22 |
+
return known ?? DEFAULTS;
|
| 23 |
+
});
|
| 24 |
+
}
|
| 25 |
+
return asked;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export function useArtifactLimits(): ArtifactLimits {
|
| 29 |
+
const [limits, setLimits] = useState<ArtifactLimits>(known ?? DEFAULTS);
|
| 30 |
+
useEffect(() => {
|
| 31 |
+
listeners.add(setLimits);
|
| 32 |
+
void loadArtifactLimits();
|
| 33 |
+
return () => {
|
| 34 |
+
listeners.delete(setLimits);
|
| 35 |
+
};
|
| 36 |
+
}, []);
|
| 37 |
+
return limits;
|
| 38 |
+
}
|