|
| 1 | +"""Generate docs/demo.svg: an animated (SMIL) walkthrough of gknowledge. |
| 2 | +
|
| 3 | +Everything shown comes from the real core: the chess sample documents are |
| 4 | +ingested with the offline engine and the page titles, summaries, counts and |
| 5 | +answer text are read back from the resulting project. Regenerate with: |
| 6 | +
|
| 7 | + python3 build-aux/dev/make_demo_svg.py [output.svg] |
| 8 | +""" |
| 9 | +from __future__ import annotations |
| 10 | + |
| 11 | +import html |
| 12 | +import sys |
| 13 | +import tempfile |
| 14 | +import textwrap |
| 15 | +from pathlib import Path |
| 16 | + |
| 17 | +ROOT = Path(__file__).resolve().parents[2] |
| 18 | +sys.path.insert(0, str(ROOT)) |
| 19 | + |
| 20 | +from gknowledge.core.ingestion import IngestionManager # noqa: E402 |
| 21 | +from gknowledge.core.knowledge import KnowledgeWriter # noqa: E402 |
| 22 | +from gknowledge.core.project import Project, Settings # noqa: E402 |
| 23 | +from gknowledge.core.query import QueryEngine # noqa: E402 |
| 24 | +from gknowledge.providers import build_provider # noqa: E402 |
| 25 | + |
| 26 | +OUT = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "docs" / "demo.svg" |
| 27 | +QUESTION = "What is the Ruy Lopez?" |
| 28 | +T = 24.0 # loop length in seconds |
| 29 | +import os |
| 30 | +FRAME = float(os.environ["FRAME"]) if os.environ.get("FRAME") else None # debug: static frame |
| 31 | + |
| 32 | +# Palette measured from Nautilus on Ubuntu (dark). |
| 33 | +SIDEBAR, CONTENT, TEXT, DIM = "#303030", "#1e1e1e", "#ffffff", "#959595" |
| 34 | +ICON, SELECT, ACCENT, BORDER = "#c0c0c0", "#444444", "#3584e4", "#1f1f1f" |
| 35 | +FONT = "Cantarell, 'Noto Sans', 'Ubuntu', 'DejaVu Sans', sans-serif" |
| 36 | + |
| 37 | + |
| 38 | +def esc(s: str) -> str: |
| 39 | + return html.escape(s, quote=True) |
| 40 | + |
| 41 | + |
| 42 | +def clean(s: str, title: str = "") -> str: |
| 43 | + s = " ".join(s.replace("#", " ").split()) |
| 44 | + if title and s.startswith(title + " "): # drop the repeated heading |
| 45 | + s = s[len(title) + 1:] |
| 46 | + return s |
| 47 | + |
| 48 | + |
| 49 | +# -- real data ---------------------------------------------------------------- |
| 50 | +def collect(): |
| 51 | + tmp = Path(tempfile.mkdtemp()) |
| 52 | + proj = Project.create(tmp / "p", name="Chess demo") |
| 53 | + proj.save_settings(Settings(provider="offline")) |
| 54 | + mgr = IngestionManager(proj) |
| 55 | + files = sorted((ROOT / "data" / "demo" / "chess").glob("*.md")) |
| 56 | + for f in files: |
| 57 | + mgr.import_file(f) |
| 58 | + mgr.process_pending() |
| 59 | + pages = {p.title: p for p in proj.store.list_pages()} |
| 60 | + w = KnowledgeWriter(proj) |
| 61 | + page = pages["Ruy Lopez"] |
| 62 | + body = w.read_body(page) |
| 63 | + summary, points = "", [] |
| 64 | + section = "" |
| 65 | + for line in body.splitlines(): |
| 66 | + if line.startswith("## "): |
| 67 | + section = line[3:].strip() |
| 68 | + elif section == "Summary" and line.strip(): |
| 69 | + summary += " " + line.strip() |
| 70 | + elif section == "Key points" and line.startswith("- "): |
| 71 | + points.append(clean(line[2:], "Ruy Lopez")) |
| 72 | + engine = QueryEngine(proj, build_provider(proj.settings.as_dict())) |
| 73 | + res = engine.query(QUESTION) |
| 74 | + first = next(l for l in res.answer.splitlines() if l.startswith("- #")) |
| 75 | + answer = clean(first[2:], "Ruy Lopez") |
| 76 | + answer = answer.rsplit(" [", 1)[0] |
| 77 | + cites, seen = [], set() |
| 78 | + for c in res.citations: |
| 79 | + if c.title not in seen: |
| 80 | + seen.add(c.title) |
| 81 | + cites.append((c.number, c.title, c.kind)) |
| 82 | + chosen = [t for t in ("Ruy Lopez", "Deep Blue versus Kasparov", "Deep Blue", |
| 83 | + "Garry Kasparov", "Vladimir Kramnik", "Bobby Fischer", |
| 84 | + "Sicilian Defence", "Wilhelm Steinitz") if t in pages] |
| 85 | + titles = [s.title for s in sorted(proj.store.list_sources(), key=lambda s: s.title)] |
| 86 | + return { |
| 87 | + "files": titles, |
| 88 | + "n_pages": len(pages), |
| 89 | + "pages": [(t, pages[t].kind.value) for t in chosen], |
| 90 | + "summary": clean(summary, "Ruy Lopez"), |
| 91 | + "points": points[:3], |
| 92 | + "answer": answer, "cites": cites[:3], |
| 93 | + } |
| 94 | + |
| 95 | + |
| 96 | +# -- svg helpers ----------------------------------------------------------------- |
| 97 | +def vis(start: float, end: float, fade: float = 0.25) -> str: |
| 98 | + """opacity animation: hidden, fade in at start, fade out at end.""" |
| 99 | + s, e = start / T, min(end, T) / T |
| 100 | + f = fade / T |
| 101 | + kt = [0, max(s - 0.0001, 0), min(s + f, 1), max(e - f, 0), min(e, 1), 1] |
| 102 | + kt = sorted(set(round(k, 5) for k in kt)) |
| 103 | + pts = [] |
| 104 | + for k in kt: |
| 105 | + on = (s + f <= k <= e - f) or (s < k < e) |
| 106 | + pts.append("1" if on else "0") |
| 107 | + if start <= 0.01: |
| 108 | + pts[0] = "1" |
| 109 | + return (f'<animate attributeName="opacity" dur="{T}s" repeatCount="indefinite" ' |
| 110 | + f'keyTimes="{";".join(str(k) for k in kt)}" values="{";".join(pts)}"/>') |
| 111 | + |
| 112 | + |
| 113 | +def g(content: str, start: float, end: float) -> str: |
| 114 | + if FRAME is not None: |
| 115 | + return f'<g opacity="{1 if start <= FRAME < end else 0}">{content}</g>' |
| 116 | + return f'<g opacity="0">{vis(start, end)}{content}</g>' |
| 117 | + |
| 118 | + |
| 119 | +def text(x, y, s, size=13, fill=TEXT, anchor="start", weight="400", extra=""): |
| 120 | + return (f'<text x="{x}" y="{y}" font-size="{size}" fill="{fill}" ' |
| 121 | + f'text-anchor="{anchor}" font-weight="{weight}" {extra}>{esc(s)}</text>') |
| 122 | + |
| 123 | + |
| 124 | +def wrapped(x, y, s, width, size=13, fill=TEXT, lh=19, max_lines=6): |
| 125 | + lines = textwrap.wrap(s, width=width)[:max_lines] |
| 126 | + if len(textwrap.wrap(s, width=width)) > max_lines: |
| 127 | + lines[-1] = lines[-1].rstrip(".,; ") + "…" |
| 128 | + return "".join(text(x, y + i * lh, ln, size, fill) for i, ln in enumerate(lines)) |
| 129 | + |
| 130 | + |
| 131 | +def prog_w() -> float: |
| 132 | + if FRAME is None: |
| 133 | + return 0 |
| 134 | + return max(0.0, min(1.0, (FRAME - 4.6) / 2.0)) * 256 |
| 135 | + |
| 136 | + |
| 137 | +def typed_w(n: int) -> float: |
| 138 | + if FRAME is None: |
| 139 | + return 0 |
| 140 | + return max(0.0, min(1.0, (FRAME - 15.4) / 1.8)) * n * 8 |
| 141 | + |
| 142 | + |
| 143 | +def build(d) -> str: |
| 144 | + W, H, SB, HH = 900, 560, 280, 48 |
| 145 | + CX = SB + (W - SB) / 2 |
| 146 | + o = [] |
| 147 | + o.append(f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W} {H}" ' |
| 148 | + f'width="{W}" height="{H}" font-family="{FONT}" role="img" ' |
| 149 | + f'aria-label="gknowledge turns documents into a linked wiki and answers questions with citations">') |
| 150 | + o.append(f'<rect width="{W}" height="{H}" rx="14" fill="{CONTENT}"/>') |
| 151 | + o.append(f'<path d="M14 0H{SB}V{H - 44}H0V14A14 14 0 0 1 14 0Z" fill="{SIDEBAR}"/>') |
| 152 | + o.append(f'<rect x="{SB}" y="0" width="1" height="{H - 44}" fill="{BORDER}"/>') |
| 153 | + # sidebar header |
| 154 | + o.append(text(26, 31, "+", 22, ICON, "middle")) |
| 155 | + o.append(text(SB / 2, 22, "Chess demo", 13, TEXT, "middle", "700")) |
| 156 | + o.append(text(SB / 2, 38, "Offline (local heuristics)", 10, DIM, "middle")) |
| 157 | + o.append(text(SB - 24, 30, "≡", 20, ICON, "middle")) |
| 158 | + # tabs |
| 159 | + tabs = [("Sources", 470), ("Knowledge", 570), ("Assistant", 670), ("Review", 760)] |
| 160 | + spans = [(0.2, 8.5), (8.5, 15.0), (15.0, T), (99, 99)] |
| 161 | + for (name, x), (a, b) in zip(tabs, spans): |
| 162 | + o.append(g(f'<rect x="{x - 42}" y="10" width="84" height="30" rx="8" fill="#3a3a3a"/>', a, b)) |
| 163 | + o.append(text(x, 30, name, 13, TEXT, "middle", "700")) |
| 164 | + # window controls |
| 165 | + for i, sym in enumerate(("–", "□", "✕")): |
| 166 | + o.append(f'<circle cx="{W - 70 + i * 28}" cy="25" r="11" fill="#3a3a3a"/>') |
| 167 | + o.append(text(W - 70 + i * 28, 30, sym, 11, TEXT, "middle")) |
| 168 | + |
| 169 | + # ---- scene 1: sources |
| 170 | + files = d["files"] |
| 171 | + rows = [] |
| 172 | + for i, name in enumerate(files): |
| 173 | + y = 72 + i * 46 |
| 174 | + t0 = 1.2 + 0.45 * i |
| 175 | + row = (f'<circle cx="30" cy="{y - 4}" r="9" fill="none" stroke="{ICON}" stroke-width="1.5"/>' |
| 176 | + + text(50, y, name, 13) |
| 177 | + + g(text(50, y + 16, "queued", 10, DIM), 0, 7.0) |
| 178 | + + g(text(50, y + 16, "ingested", 10, DIM), 7.0, 8.5)) |
| 179 | + rows.append(g(row, t0, 8.5)) |
| 180 | + o.append("".join(rows)) |
| 181 | + o.append(g(f'<rect x="12" y="{H - 44 - 60}" width="{SB - 24}" height="36" rx="8" fill="{ACCENT}"/>' |
| 182 | + + text(SB / 2, H - 44 - 37, "Ingest Queued", 13, TEXT, "middle", "700"), 1.2, 8.5)) |
| 183 | + o.append(g(f'<rect x="12" y="{H - 44 - 78}" width="{SB - 24}" height="6" rx="3" fill="#444"/>' |
| 184 | + f'<rect x="12" y="{H - 44 - 78}" width="{prog_w()}" height="6" rx="3" fill="{ACCENT}">' |
| 185 | + + ("" if FRAME is not None else f'<animate attributeName="width" dur="{T}s" repeatCount="indefinite" ' |
| 186 | + f'keyTimes="0;{4.6 / T:.4f};{6.6 / T:.4f};1" values="0;0;{SB - 24};{SB - 24}"/>') |
| 187 | + + '</rect>', 4.4, 7.2)) |
| 188 | + o.append(g(text(CX, 250, "Drop files here", 20, DIM, "middle", "700") |
| 189 | + + text(CX, 276, "Markdown, text, PDF, HTML", 13, DIM, "middle"), 0.2, 3.0)) |
| 190 | + o.append(g(text(CX, 240, f"{len(files)} sources ingested", 22, TEXT, "middle", "700") |
| 191 | + + text(CX, 268, f"{d['n_pages']} pages created, linked and traced to their sources", 14, DIM, "middle"), |
| 192 | + 7.0, 8.5)) |
| 193 | + |
| 194 | + # ---- scene 2: knowledge |
| 195 | + kr = [] |
| 196 | + for i, (title, kind) in enumerate(d["pages"]): |
| 197 | + y = 72 + i * 46 |
| 198 | + row = "" |
| 199 | + if i == 0: |
| 200 | + row += f'<rect x="8" y="{y - 24}" width="{SB - 16}" height="40" rx="8" fill="{SELECT}"/>' |
| 201 | + row += (f'<circle cx="30" cy="{y - 4}" r="9" fill="none" stroke="{ICON}" stroke-width="1.5"/>' |
| 202 | + + text(50, y, title, 13) + text(50, y + 16, kind, 10, DIM) |
| 203 | + + text(SB - 24, y, "AI", 10, DIM, "middle")) |
| 204 | + kr.append(g(row, 8.8 + 0.3 * i, 15.0)) |
| 205 | + o.append("".join(kr)) |
| 206 | + page = [text(310, 100, "Ruy Lopez", 24, TEXT, weight="700"), |
| 207 | + text(310, 124, "type: source · generated · linked to ruy-lopez.md", 11, DIM), |
| 208 | + text(310, 160, "Summary", 14, TEXT, weight="700"), |
| 209 | + wrapped(310, 182, d["summary"], 78, 12.5, TEXT, 18, 5), |
| 210 | + text(310, 292, "Key points", 14, TEXT, weight="700")] |
| 211 | + for i, pt in enumerate(d["points"]): |
| 212 | + page.append(wrapped(322, 314 + i * 40, "• " + pt, 74, 12.5, TEXT, 18, 2)) |
| 213 | + page.append(text(310, 440, "Linked pages", 14, TEXT, weight="700")) |
| 214 | + for i, nm in enumerate(["Garry Kasparov", "Vladimir Kramnik", "Sicilian Defence"]): |
| 215 | + x = 310 + i * 160 |
| 216 | + page.append(f'<rect x="{x}" y="452" width="148" height="26" rx="13" fill="#2f4a73"/>' |
| 217 | + + text(x + 74, 470, "[[" + nm + "]]", 11, "#cfe0ff", "middle")) |
| 218 | + o.append(g("".join(page), 9.2, 15.0)) |
| 219 | + |
| 220 | + # ---- scene 3: assistant |
| 221 | + o.append(g(text(SB / 2, 90, "No saved answers yet.", 13, DIM, "middle"), 15.0, 20.0)) |
| 222 | + saved = (f'<rect x="8" y="48" width="{SB - 16}" height="40" rx="8" fill="{SELECT}"/>' |
| 223 | + + f'<circle cx="30" cy="68" r="9" fill="none" stroke="{ICON}" stroke-width="1.5"/>' |
| 224 | + + text(50, 72, QUESTION, 13) + text(50, 88, "saved answer", 10, DIM)) |
| 225 | + o.append(g(saved, 20.2, T)) |
| 226 | + o.append(g(f'<rect x="310" y="{H - 44 - 58}" width="440" height="36" rx="8" fill="#2a2a2a"/>' |
| 227 | + f'<rect x="760" y="{H - 44 - 58}" width="52" height="36" rx="8" fill="{ACCENT}"/>' |
| 228 | + + text(786, H - 44 - 35, "Ask", 13, TEXT, "middle", "700") |
| 229 | + + f'<rect x="820" y="{H - 44 - 58}" width="52" height="36" rx="8" fill="#3a3a3a"/>' |
| 230 | + + text(846, H - 44 - 35, "Save", 13, TEXT, "middle", "700"), 15.0, T)) |
| 231 | + # typed question |
| 232 | + n = len(QUESTION) |
| 233 | + o.append(f'<clipPath id="typed"><rect x="320" y="0" width="{typed_w(n)}" height="{H}">' |
| 234 | + + ("" if FRAME is not None else f'<animate attributeName="width" dur="{T}s" repeatCount="indefinite" ' |
| 235 | + f'keyTimes="0;{15.4 / T:.4f};{17.2 / T:.4f};1" values="0;0;{n * 8};{n * 8}"/>') |
| 236 | + + '</rect></clipPath>') |
| 237 | + o.append(g(f'<g clip-path="url(#typed)">{text(322, H - 44 - 35, QUESTION, 13)}</g>', 15.0, T)) |
| 238 | + ans = [text(310, 90, "Answer", 14, TEXT, weight="700"), |
| 239 | + wrapped(310, 114, d["answer"], 78, 13, TEXT, 20, 6)] |
| 240 | + n_lines = min(6, len(textwrap.wrap(d["answer"], width=78))) |
| 241 | + top = 114 + n_lines * 20 + 22 |
| 242 | + ans.append(text(310, top, "Sources", 14, TEXT, weight="700")) |
| 243 | + for i, (_num, title, kind) in enumerate(d["cites"]): |
| 244 | + ans.append(text(310, top + 24 + i * 22, f"[{i + 1}] {title} · {kind}", 12, "#8ab4f8")) |
| 245 | + o.append(g("".join(ans), 17.6, T)) |
| 246 | + |
| 247 | + # ---- caption bar |
| 248 | + o.append(f'<path d="M0 {H - 44}H{W}V{H - 14}A14 14 0 0 1 {W - 14} {H}H14A14 14 0 0 1 0 {H - 14}Z" fill="#141414"/>') |
| 249 | + caps = [(0.2, 8.5, "1 Drop in your documents. Nothing leaves your machine."), |
| 250 | + (8.5, 15.0, "2 gknowledge builds a linked wiki, as plain Markdown with provenance."), |
| 251 | + (15.0, T, "3 Ask questions. Every answer cites the pages and sources it used.")] |
| 252 | + for a, b, s in caps: |
| 253 | + o.append(g(text(24, H - 17, s, 14, "#ffffff", weight="700"), a, b)) |
| 254 | + o.append(text(W - 20, H - 17, "native GNOME · offline by default", 11, DIM, "end")) |
| 255 | + o.append("</svg>") |
| 256 | + return "\n".join(o) |
| 257 | + |
| 258 | + |
| 259 | +if __name__ == "__main__": |
| 260 | + data = collect() |
| 261 | + OUT.parent.mkdir(parents=True, exist_ok=True) |
| 262 | + OUT.write_text(build(data), "utf-8") |
| 263 | + print(f"Wrote {OUT} ({OUT.stat().st_size // 1024} KiB), " |
| 264 | + f"{len(data['files'])} sources, {data['n_pages']} pages") |
0 commit comments