File size: 12,268 Bytes
55d0eca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
"""The AI-composed infographic: a page frame of ours (head, takeaway, background, mark) around a body the model writes in
HTML from a small vocabulary of components (flow charts, timelines, hierarchies, comparisons, cards, numbers). The
model's markup is never trusted: it is parsed and rebuilt from an allowlist of tags and class names, with no
attributes but `class`, so it can carry no script, style, link, image or address."""

import hashlib
import random
import re
from dataclasses import dataclass
from pathlib import Path

from bs4 import BeautifulSoup, Comment, NavigableString, Tag
from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape
from markupsafe import Markup

from app.infographics.render import LOGO_SVG, SIZES

_DIR = Path(__file__).parent / "templates"
CSS = (_DIR / "design.css").read_text(encoding="utf-8")


@dataclass(frozen=True)
class Look:
    id: str
    name: str
    blurb: str
    vars: dict  # CSS variables of the palette


LOOKS: dict[str, Look] = {l.id: l for l in (
    Look("midnight", "Midnight", "Black and blue, white type", {
        "bg": "#03050a", "bg2": "#0b1a3a", "ink": "#ffffff", "mute": "#9fb0cc", "acc": "#2f7bff", "acc2": "#8db8ff",
        "line": "rgba(130,170,255,.26)", "card": "rgba(255,255,255,.04)", "hl": "rgba(47,123,255,.18)", "paper": "#ffffff",
        "paperink": "#03050a", "star": "#ffffff", "markbg": "rgba(3,5,10,.75)", "markline": "rgba(141,184,255,.4)", "markink": "#ffffff"}),
    Look("paper", "Orbit", "White, black type, blue orbits and stars", {
        "bg": "#ffffff", "bg2": "#eaf1ff", "ink": "#05070d", "mute": "#4b5668", "acc": "#1f5fe6", "acc2": "#1f5fe6",
        "line": "rgba(5,7,13,.18)", "card": "#ffffff", "hl": "rgba(31,95,230,.09)", "paper": "#05070d",
        "paperink": "#ffffff", "star": "#1f5fe6", "markbg": "#05070d", "markline": "#05070d", "markink": "#ffffff"}),
)}
DEFAULT_LOOK = "midnight"

# What the person can ask the layout to lean towards; "auto" lets the AI choose from the content.
STYLES = {
    "auto": "Choose the layout that suits the content",
    "flowchart": "A flow chart: boxes joined by arrows, steps in order",
    "timeline": "A timeline: what happened or happens, in order",
    "compare": "A comparison: two sides set against each other",
    "hierarchy": "A hierarchy: one idea and the parts it breaks into",
    "overview": "An overview: cards of the key ideas and the numbers that matter",
}

# Every class the model's markup may use (the components in design.css); anything else is dropped.
ALLOWED_CLASSES = {
    "sec", "sec-t", "lead", "mono", "small", "hl", "node", "n-no", "n-t", "n-d", "flow", "col", "grid", "c2", "c3", "c4",
    "card", "c-k", "c-t", "c-d", "timeline", "t-item", "t-when", "t-t", "t-d", "tree", "t-root", "t-kids", "compare",
    "side", "s-h", "stats", "stat", "s-n", "s-l", "cols", "pills", "pill", "callout",
}
ALLOWED_TAGS = {"div", "section", "span", "p", "ul", "ol", "li", "strong", "b", "em", "i", "small", "br", "h3", "h4"}
DROP_WITH_CONTENT = {"script", "style", "iframe", "object", "embed", "link", "meta", "svg", "img", "video", "audio", "form",
                     "input", "button", "textarea", "select", "canvas", "math", "head", "title", "noscript", "template"}
MAX_BODY_CHARS = 14_000
MAX_ELEMENTS = 320

_SPACE = re.compile(r"\s+")


def _clean_text(text: str) -> str:
    return text.replace("—", ",").replace("–", "-")


def sanitize_body(raw: object) -> str | None:
    """The model's body markup rebuilt from the allowlist, or None when nothing readable is left."""
    if not isinstance(raw, str) or not raw.strip():
        return None
    soup = BeautifulSoup(raw[: MAX_BODY_CHARS * 2], "html.parser")
    for c in soup.find_all(string=lambda t: isinstance(t, Comment)):
        c.extract()
    for tag in soup.find_all(DROP_WITH_CONTENT):
        tag.decompose()
    count = 0
    for tag in list(soup.find_all(True)):
        count += 1
        if tag.name not in ALLOWED_TAGS or count > MAX_ELEMENTS:
            tag.unwrap() if count <= MAX_ELEMENTS else tag.decompose()
            continue
        classes = [c for c in (tag.get("class") or []) if c in ALLOWED_CLASSES]
        tag.attrs = {"class": classes} if classes else {}
    for node in soup.find_all(string=True):
        if isinstance(node, NavigableString):
            node.replace_with(NavigableString(_clean_text(str(node))))
    html = _SPACE.sub(" ", str(soup)).strip()
    text = soup.get_text(strip=True)
    if len(html) > MAX_BODY_CHARS or len(text) < 40:
        return None
    return html


def _clip(text: object, limit: int) -> str:
    s = _SPACE.sub(" ", re.sub(r"<[^>]*>", " ", str(text or ""))).strip()
    s = _clean_text(s)
    if len(s) <= limit:
        return s
    cut = s[: limit - 1].rsplit(" ", 1)[0].rstrip(" ,;:.-") or s[: limit - 1]
    return cut + "…"


def build_content(raw: dict) -> dict | None:
    """The stored content for a model answer (title, subtitle, eyebrow, key_figures, body_html, rule, notes), or None when it is
    too thin to draw."""
    if hasattr(raw, "model_dump"):
        raw = raw.model_dump()
    title = _clip(raw.get("title"), 70)
    body = sanitize_body(raw.get("body_html"))
    if not title or not body:
        return None
    figures = []
    for f in raw.get("key_figures") or []:
        f = f if isinstance(f, dict) else getattr(f, "__dict__", {})
        value, label = _clip(f.get("value"), 14), _clip(f.get("label"), 44)
        if value and label:
            figures.append({"value": value, "label": label})
    notes = [n for n in (_clip(n, 100) for n in raw.get("notes") or []) if n][:3]
    return {"title": title, "subtitle": _clip(raw.get("subtitle"), 130), "eyebrow": _clip(raw.get("eyebrow"), 30) or "Key ideas",
            "key_figures": figures[:3], "body_html": body, "rule": _clip(raw.get("rule"), 90), "notes": notes}


def _stars(seed: str, w: int, h: int, n: int = 90) -> str:
    rnd = random.Random(int(hashlib.sha1(seed.encode()).hexdigest()[:8], 16))
    out = []
    for _ in range(n):
        r = rnd.choice((1.2, 1.5, 1.5, 2, 2.6))
        out.append(f'<circle cx="{rnd.randint(0, w)}" cy="{rnd.randint(0, h)}" r="{r}" fill="var(--star)" opacity="{round(rnd.uniform(.12, .5), 2)}"/>')
    return "".join(out)


def _orbits(w: int, h: int) -> str:
    """The Orbit look's scenery: a ringed planet like the app's icon, orbit rings with small moons, and shooting stars."""
    big = round(w * 0.2)
    cx, cy = round(w * 0.86), round(h * 0.085)
    pr = round(w * 0.05)

    def ring(x, y, rx, ry, op, dash=""):
        return (f'<ellipse cx="{x}" cy="{y}" rx="{rx}" ry="{ry}" transform="rotate(-20 {x} {y})" fill="none" stroke="var(--acc)" '
                f'stroke-width="2" opacity="{op}"{dash}/>')

    def planet_ring(front: bool):
        sweep = 0 if front else 1
        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}" '
                f'fill="none" stroke="var(--acc)" stroke-width="5" opacity="{1 if front else .5}"/></g>')

    out = [
        '<defs><linearGradient id="shoot" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="var(--acc)" stop-opacity="0"/>'
        '<stop offset="1" stop-color="var(--acc)" stop-opacity=".85"/></linearGradient></defs>',
        ring(cx, cy, big, round(big * .3), .35, ' stroke-dasharray="3 12"'),
        ring(cx, cy, round(big * 1.55), round(big * .46), .22),
        ring(round(w * .06), round(h * .96), round(w * .5), round(w * .14), .2, ' stroke-dasharray="3 12"'),
        ring(round(w * .06), round(h * .96), round(w * .34), round(w * .09), .28),
        planet_ring(False),
        f'<circle cx="{cx}" cy="{cy}" r="{pr}" fill="var(--bg)" stroke="var(--acc)" stroke-width="5"/>',
        f'<circle cx="{cx}" cy="{cy}" r="{pr - 12}" fill="var(--acc)" opacity=".12"/>',
        planet_ring(True),
        f'<circle cx="{round(w * .6)}" cy="{round(h * .036)}" r="9" fill="var(--acc)" opacity=".7"/>',
        f'<circle cx="{round(w * .31)}" cy="{round(h * .93)}" r="8" fill="var(--acc)" opacity=".6"/>',
    ]
    for fx, fy, length, op in ((.46, .2, 190, .8), (.7, .4, 150, .6), (.12, .55, 120, .5)):
        x, y = round(w * fx), round(h * fy)
        out.append(f'<g transform="rotate(28 {x} {y})" opacity="{op}"><rect x="{x - length}" y="{y - 2}" width="{length}" height="4" rx="2" fill="url(#shoot)"/>'
                   f'<circle cx="{x}" cy="{y}" r="5" fill="var(--acc)"/></g>')
    return "".join(out)


_ENV = Environment(loader=FileSystemLoader(_DIR), autoescape=select_autoescape(["j2", "html"], default=True), undefined=StrictUndefined,
                   trim_blocks=True, lstrip_blocks=True)


def render_design(look: str, content: dict, layout: str = "portrait", fit: float | None = None) -> str:
    """The page for an AI-composed infographic. `fit` is the scale that makes its body fit (stored with the content)."""
    look = look if look in LOOKS else DEFAULT_LOOK
    layout = layout if layout in SIZES else "portrait"
    w, h = SIZES[layout]
    if fit is None:
        fit = float(((content.get("fit") or {}) if isinstance(content.get("fit"), dict) else {}).get(layout) or 1)
    fit = max(0.5, min(1.0, fit))
    vars_css = ";".join(f"--{k}:{v}" for k, v in LOOKS[look].vars.items())
    return _ENV.get_template("design.html.j2").render(
        c=content, look=look, layout=layout, w=w, h=h, fit=fit, vars_css=vars_css, css=Markup(CSS), logo=Markup(LOGO_SVG),
        body=Markup(content["body_html"]),
        stars=Markup(_stars(content["title"] + look, w, h) + (_orbits(w, h) if look == "paper" else "")),
        fonts="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500;700&display=swap")


SAMPLE_BODY = (
    '<section class="sec"><div class="sec-t">The five layers</div><div class="flow">'
    '<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>'
    '<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>'
    '<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>'
    '<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>'
    '<section class="sec"><div class="sec-t">Every request goes through</div><div class="grid c4">'
    + "".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>'
              for i, (t, d) in enumerate([("Auth", "Identity, limits and input guards."), ("Cache", "Answers to repeat questions."),
                                          ("Safety", "Scrub PII, catch injection."), ("Memory", "Session history, summarised."),
                                          ("Retrieve", "Fetch docs, split queries."), ("Answer", "Sub-queries in parallel."),
                                          ("Validate", "Check quality first."), ("Save", "Store and log one trace.")]))
    + '</div></section>'
    '<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>'
)
SAMPLE = {"title": "From Prompt to Production Agent", "subtitle": "Five engineering layers and the steps every request takes",
          "eyebrow": "Agent engineering", "body_html": SAMPLE_BODY,
          "key_figures": [{"value": "5", "label": "engineering layers"}, {"value": "8", "label": "steps on every request"},
                          {"value": "30%", "label": "of traffic skips the pipeline"}],
          "rule": "Most tasks only need the bottom layers; avoid complex graphs without cause.",
          "notes": ["Each layer builds on the one below it.", "Cache and middleware run outside the graph.",
                    "Every request produces one end-to-end trace."]}