HumeraR commited on
Commit
55d0eca
·
1 Parent(s): 4cd9a68

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 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
+ }