codelion commited on
Commit
917845f
·
verified ·
1 Parent(s): bf2993f

Visualize: ask a dataset a question, get an exact chart with the rows behind every mark (small datasets first: instant glance charts, batched model labels, drill-down, scoped follow-ups)

Browse files
README.md CHANGED
@@ -10,15 +10,16 @@ hf_oauth: true
10
  hf_oauth_scopes:
11
  - contribute-repos
12
  hf_oauth_expiration_minutes: 120
13
- short_description: Invent training rows, or curate your pretraining data
14
  ---
15
 
16
  # DataInventor
17
 
18
- DataInventor has two modes.
19
 
20
  - **Invent rows.** An open-source recreation of the [Invent a Dataset](https://adaptionlabs.ai/invent-a-dataset) workflow. Describe the data you want, choose a format, domains and languages, preview a blueprint, and get an instruction or preference dataset back as CSV or JSONL.
21
  - **Curate a corpus.** Clean, score, rephrase and mix the pretraining data you already have. Preview every step here on a sample, then download the recipe and run it on all of your data, on your own machines.
 
22
 
23
  It works with any OpenAI-compatible endpoint. The default is a free model served through Hugging Face Inference Providers.
24
 
@@ -52,6 +53,16 @@ It writes `curated/<source>/`, `synthetic/<source>/` and `mixes/<mix>/` as JSONL
52
 
53
  Two limits to know. Token counts are estimates at four characters per token. The learned scorer is a hashed n-gram model. In our test it reached a rank agreement of about 0.5 with the grading model after 300 grades, so grade more documents, or set `calibrate.scorer: llm` to grade every document with the model.
54
 
 
 
 
 
 
 
 
 
 
 
55
  ## How it compares
56
 
57
  | | [Invent a Dataset](https://adaptionlabs.ai/invent-a-dataset) (Adaption) | [Curation Studio](https://www.datologyai.com/) (DatologyAI) | DataInventor |
 
10
  hf_oauth_scopes:
11
  - contribute-repos
12
  hf_oauth_expiration_minutes: 120
13
+ short_description: Invent training rows, curate data, chart any dataset
14
  ---
15
 
16
  # DataInventor
17
 
18
+ DataInventor has three modes.
19
 
20
  - **Invent rows.** An open-source recreation of the [Invent a Dataset](https://adaptionlabs.ai/invent-a-dataset) workflow. Describe the data you want, choose a format, domains and languages, preview a blueprint, and get an instruction or preference dataset back as CSV or JSONL.
21
  - **Curate a corpus.** Clean, score, rephrase and mix the pretraining data you already have. Preview every step here on a sample, then download the recipe and run it on all of your data, on your own machines.
22
+ - **Visualize.** Ask a question about a dataset in plain words and get a chart back, with the rows behind every bar, box or dot one click away. Use the rows you just wrote, a sample, any public Hugging Face dataset, or a CSV/JSONL file.
23
 
24
  It works with any OpenAI-compatible endpoint. The default is a free model served through Hugging Face Inference Providers.
25
 
 
53
 
54
  Two limits to know. Token counts are estimates at four characters per token. The learned scorer is a hashed n-gram model. In our test it reached a rank agreement of about 0.5 with the grading model after 300 grades, so grade more documents, or set `calibrate.scorer: llm` to grade every document with the model.
55
 
56
+ ## How Visualize works
57
+
58
+ It is built for small datasets first: the 25 to 200 rows a hosted run writes. Every row stays in memory, so each chart is an exact count over all of them.
59
+
60
+ - **At a glance.** As soon as data loads you see two or three charts that need no model: rows per category, and how long the texts are in each.
61
+ - **Ask.** A model turns your question into a chart plan. If an existing column answers it (area, language, length), the chart comes back in a couple of seconds. If it needs something no column has, such as tone, intent or whether a reply gives steps, the model reads the rows in small parallel batches and fills a new column. A regex handles literal marks like links or numbered lists without a model.
62
+ - **Drill down.** Click a bar, box, dot or legend entry to read the rows behind it, download them, or ask a follow-up about just those rows.
63
+ - **Reuse.** Columns the model made stay with the dataset, so follow-up questions reuse them instead of reading every row again.
64
+ - **Honest numbers.** The write-up uses only the chart's exact numbers, says when a group is too small to read much into, and the page notes which columns a model assigned.
65
+
66
  ## How it compares
67
 
68
  | | [Invent a Dataset](https://adaptionlabs.ai/invent-a-dataset) (Adaption) | [Curation Studio](https://www.datologyai.com/) (DatologyAI) | DataInventor |
app/main.py CHANGED
@@ -264,11 +264,15 @@ from curate_api import router as curate_router # noqa: E402 (imports make_endp
264
 
265
  app.include_router(curate_router)
266
 
 
 
 
 
267
 
268
  def _asset_version() -> str:
269
  import hashlib
270
  h = hashlib.sha1()
271
- for f in ("app.js", "curate.js", "app.css"):
272
  h.update((STATIC / f).read_bytes())
273
  return h.hexdigest()[:10]
274
 
 
264
 
265
  app.include_router(curate_router)
266
 
267
+ from visualize_api import router as vis_router # noqa: E402
268
+
269
+ app.include_router(vis_router)
270
+
271
 
272
  def _asset_version() -> str:
273
  import hashlib
274
  h = hashlib.sha1()
275
+ for f in ("app.js", "curate.js", "visualize.js", "app.css"):
276
  h.update((STATIC / f).read_bytes())
277
  return h.hexdigest()[:10]
278
 
app/visualize_api.py ADDED
@@ -0,0 +1,344 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Visualize: load a dataset (a run from this page, a sample, a Hugging Face dataset or a file), ask questions, get charts."""
2
+ from __future__ import annotations
3
+
4
+ import csv
5
+ import io
6
+ import json
7
+ import math
8
+ import os
9
+ import re
10
+ import threading
11
+ import time
12
+ import uuid
13
+ from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeout
14
+ from dataclasses import dataclass, field
15
+ from typing import Optional
16
+
17
+ from fastapi import APIRouter, HTTPException
18
+ from fastapi.responses import Response
19
+ from pydantic import BaseModel
20
+
21
+ from datainventor import visualize as V
22
+ from datainventor.llm import EndpointError
23
+ from datainventor.sources import _get, inspect
24
+
25
+ router = APIRouter(prefix="/api/vis")
26
+ MAX_ROWS = int(os.environ.get("VIS_MAX_ROWS", "200"))
27
+ MAX_UPLOAD = 6_000_000
28
+ MAX_RUNNING = int(os.environ.get("VIS_MAX_RUNNING", "4"))
29
+ WRITE_SECONDS = float(os.environ.get("VIS_WRITE_SECONDS", "20"))
30
+ CALL_SECONDS = int(os.environ.get("VIS_CALL_SECONDS", "30"))
31
+ WRITERS = ThreadPoolExecutor(max_workers=4)
32
+ SAMPLES = [("support-general", "Customer support replies"), ("hotel-reviews", "Hotel reviews"), ("medical-qa", "Medical questions"),
33
+ ("legal-qa", "Legal questions"), ("support-banking", "Banking support"), ("african-qa", "Questions about Africa"),
34
+ ("hindi-news", "Hindi news requests"), ("serbian-car-ads", "Serbian car ads")]
35
+ JOB_ORDER = ["prompt", "completion", "chosen", "rejected", "area", "subtopic", "topic", "persona", "shape", "difficulty", "language"]
36
+
37
+
38
+ @dataclass
39
+ class VisSet:
40
+ id: str
41
+ name: str
42
+ source: str
43
+ rows: list
44
+ cols: dict
45
+ created: float = field(default_factory=time.time)
46
+ lock: threading.Lock = field(default_factory=threading.Lock)
47
+ busy: bool = False
48
+
49
+
50
+ @dataclass
51
+ class Ask:
52
+ id: str
53
+ set_id: str
54
+ question: str
55
+ scope: list
56
+ scope_label: str
57
+ stage: str = "plan" # plan | label | chart | write | done | failed
58
+ done: int = 0
59
+ total: int = 0
60
+ result: Optional[dict] = None
61
+ error: str = ""
62
+ started: float = field(default_factory=time.time)
63
+
64
+
65
+ SETS: dict[str, VisSet] = {}
66
+ ASKS: dict[str, Ask] = {}
67
+ GUARD = threading.Lock()
68
+
69
+
70
+ def _ep():
71
+ from main import make_endpoint # the host's endpoint; visitors never supply one
72
+ return make_endpoint()
73
+
74
+
75
+ def _token() -> str:
76
+ return os.environ.get("HF_TOKEN", "")
77
+
78
+
79
+ # ------------------------------------------------------------------ loading
80
+ def _hub_rows(dataset: str, n: int) -> list[dict]:
81
+ """A spread of up to n full rows through the dataset viewer: no download, every column kept."""
82
+ if not re.fullmatch(r"[\w.-]+/[\w.-]+", dataset):
83
+ raise HTTPException(400, "Give a dataset id like owner/name.")
84
+ try:
85
+ info = inspect(dataset, _token())
86
+ except Exception as e:
87
+ raise HTTPException(400, f"Could not open {dataset}: {str(e)[:160]}")
88
+ total = info.get("num_rows") or 0
89
+ chunks = 1 if not total or total <= n else min(4, math.ceil(n / 25))
90
+ per = math.ceil(n / chunks)
91
+ starts = [0] if chunks == 1 else [max(0, int(total * (i + 0.5) / chunks) - per // 2) for i in range(chunks)]
92
+ out, seen = [], set()
93
+ for s in starts:
94
+ off = s
95
+ while off < s + per and len(out) < n:
96
+ try:
97
+ got = _get("rows", {"dataset": dataset, "config": info["config"], "split": info["split"], "offset": off, "length": min(100, s + per - off)}, _token())
98
+ except Exception as e:
99
+ if not out:
100
+ raise HTTPException(502, f"The dataset viewer did not return rows for {dataset}: {str(e)[:160]}")
101
+ break
102
+ rows = got.get("rows") or []
103
+ for r in rows:
104
+ idx = r.get("row_idx", off)
105
+ if idx not in seen:
106
+ seen.add(idx)
107
+ out.append(r.get("row") or {})
108
+ if len(rows) < min(100, s + per - off):
109
+ break
110
+ off += len(rows)
111
+ if not out:
112
+ raise HTTPException(400, f"{dataset} has no rows the viewer can show.")
113
+ return out[:n]
114
+
115
+
116
+ def _parse_upload(text: str) -> list[dict]:
117
+ t = text.lstrip("")
118
+ s = t.lstrip()
119
+ try:
120
+ if s.startswith("["):
121
+ data = json.loads(s)
122
+ elif s.startswith("{"):
123
+ try:
124
+ one = json.loads(s)
125
+ data = next((v for v in one.values() if isinstance(v, list)), [one]) if isinstance(one, dict) else []
126
+ except ValueError:
127
+ data = [json.loads(line) for line in s.splitlines() if line.strip()]
128
+ else:
129
+ csv.field_size_limit(10_000_000)
130
+ data = list(csv.DictReader(io.StringIO(t)))
131
+ except (ValueError, csv.Error) as e:
132
+ raise HTTPException(400, f"Could not read that file as CSV, JSON or JSONL: {str(e)[:120]}")
133
+ rows = [r for r in data if isinstance(r, dict)]
134
+ if not rows:
135
+ raise HTTPException(400, "The file has no rows. Use CSV with a header row, a JSON list of objects, or JSONL.")
136
+ return rows
137
+
138
+
139
+ def _job_rows(jid: str) -> tuple[list[dict], str]:
140
+ from main import JOBS
141
+ j = JOBS.get(jid)
142
+ if not j:
143
+ raise HTTPException(404, "That run is no longer on this host. Download it and upload the file instead.")
144
+ with j.lock:
145
+ rows = list(j.rows)
146
+ if not rows:
147
+ raise HTTPException(400, "That run has no rows yet.")
148
+ keys = [k for k in JOB_ORDER if k in rows[0]]
149
+ req = (j.config.get("request") or "Your run").strip()
150
+ return [{k: r.get(k) for k in keys} for r in rows], req if len(req) <= 80 else req[:78].rsplit(" ", 1)[0] + "…"
151
+
152
+
153
+ class LoadIn(BaseModel):
154
+ source: str # job | sample | hub | upload
155
+ job: str = ""
156
+ key: str = ""
157
+ dataset: str = ""
158
+ text: str = ""
159
+ filename: str = ""
160
+ n: int = 50
161
+
162
+
163
+ @router.get("/samples")
164
+ def samples():
165
+ return {"samples": [{"key": k, "title": t, "repo": f"LocalLLaMA/datainventor-{k}"} for k, t in SAMPLES], "max_rows": MAX_ROWS}
166
+
167
+
168
+ @router.post("/load")
169
+ def load(body: LoadIn):
170
+ n = max(5, min(body.n or 50, MAX_ROWS))
171
+ note = ""
172
+ if body.source == "job":
173
+ raw, name = _job_rows(body.job)
174
+ src = "This page's run"
175
+ elif body.source == "sample":
176
+ if body.key not in dict(SAMPLES):
177
+ raise HTTPException(400, "Unknown sample.")
178
+ raw, name, src = _hub_rows(f"LocalLLaMA/datainventor-{body.key}", n), dict(SAMPLES)[body.key], f"LocalLLaMA/datainventor-{body.key}"
179
+ elif body.source == "hub":
180
+ ds = body.dataset.strip().removeprefix("https://huggingface.co/datasets/").strip("/")
181
+ raw, name, src = _hub_rows(ds, n), ds.split("/")[-1], ds
182
+ elif body.source == "upload":
183
+ if len(body.text) > MAX_UPLOAD:
184
+ raise HTTPException(413, "That file is over 6 MB. Upload a smaller sample.")
185
+ raw, name, src = _parse_upload(body.text), (body.filename or "Your file")[:80], "Uploaded file"
186
+ else:
187
+ raise HTTPException(400, "Unknown source.")
188
+ if len(raw) > MAX_ROWS:
189
+ note = f"Showing the first {MAX_ROWS} of {len(raw)} rows."
190
+ raw = raw[:MAX_ROWS]
191
+ rows, cols = V.prepare(raw)
192
+ if not cols:
193
+ raise HTTPException(400, "None of the columns hold text, categories or numbers this can chart.")
194
+ sid = uuid.uuid4().hex[:10]
195
+ vs = VisSet(id=sid, name=name, source=src, rows=rows, cols=cols)
196
+ with GUARD:
197
+ SETS[sid] = vs
198
+ for old in sorted(SETS, key=lambda k: SETS[k].created)[:-30]:
199
+ SETS.pop(old, None)
200
+ return {"id": sid, "name": name, "source": src, "n": len(rows), "note": note, "columns": V.describe(rows, cols),
201
+ "glance": V.glance(rows, cols), "suggestions": V.suggestions(rows, cols), "rows": rows}
202
+
203
+
204
+ # ------------------------------------------------------------------ asking
205
+ class AskIn(BaseModel):
206
+ question: str
207
+ scope: list[int] = []
208
+ scope_label: str = ""
209
+
210
+
211
+ def _column(d: dict, vals: dict, filled: set, prev: Optional[dict] = None) -> dict:
212
+ c = {"type": "num" if d["kind"] in ("score", "count") else "cat", "derived": d["kind"], "from": d["from"],
213
+ "guide": d.get("guide", ""), "filled": set(filled)}
214
+ if d.get("labels"):
215
+ c["labels"] = d["labels"]
216
+ if d.get("pattern"):
217
+ c["pattern"] = d["pattern"]
218
+ if prev:
219
+ c["filled"] |= prev.get("filled", set())
220
+ return c
221
+
222
+
223
+ def run_ask(a: Ask, vs: VisSet, ep):
224
+ # short per-call timeouts: a stuck provider is retried instead of holding the chart for minutes
225
+ chat = lambda prompt, **kw: ep.chat(prompt, retries=3, timeout=CALL_SECONDS, **kw)
226
+ try:
227
+ ids = set(a.scope)
228
+ rows = [r for r in vs.rows if r["_id"] in ids] if ids else list(vs.rows)
229
+ scope = f" (this question is about a selection of {len(rows)} of {len(vs.rows)} rows: {a.scope_label or 'chosen on a chart'})" if ids else ""
230
+ plan, err = None, ""
231
+ for _ in range(2):
232
+ text = chat(V.plan_prompt(a.question, vs.name, rows, vs.cols, scope, err), max_tokens=700, temperature=0.2)
233
+ try:
234
+ plan = V.parse_plan(text, vs.cols)
235
+ break
236
+ except V.PlanError as e:
237
+ err = str(e)
238
+ print(f"[visualize] plan rejected: {err} | reply: {text[:600]!r}", flush=True)
239
+ if not plan:
240
+ plan = V.fallback_plan(a.question, rows, vs.cols)
241
+ # new columns, plus earlier model-made columns this chart needs that do not cover these rows yet
242
+ todo = [(d, rows, False) for d in plan["derive"]]
243
+ for k in ("x", "y", "color"):
244
+ c = vs.cols.get(plan["chart"].get(k) or "")
245
+ if c and c.get("derived") in V.KINDS:
246
+ missing = [r for r in rows if r["_id"] not in c.get("filled", set())]
247
+ if missing:
248
+ todo.append(({"name": plan["chart"][k], "kind": c["derived"], "from": c["from"], "guide": c.get("guide", ""),
249
+ "labels": c.get("labels", []), "pattern": c.get("pattern", "")}, missing, True))
250
+ touched = {}
251
+ if todo:
252
+ a.stage, a.total, a.done = "label", sum(len(rs) for _, rs, _ in todo), 0
253
+ for d, rs, refill in todo:
254
+ base = a.done
255
+ vals = V.fill(d, rs, chat, a.question, progress=lambda k, base=base: setattr(a, "done", base + k))
256
+ with vs.lock:
257
+ for r in vs.rows:
258
+ if r["_id"] in vals:
259
+ r[d["name"]] = vals[r["_id"]]
260
+ elif not refill or d["name"] not in r: # a new definition: clear values left by an old one
261
+ r[d["name"]] = None if d["kind"] in ("score", "count") else ""
262
+ vs.cols[d["name"]] = _column(d, vals, set(vals), vs.cols.get(d["name"]) if refill else None)
263
+ touched[d["name"]] = {str(i): v for i, v in vals.items()}
264
+ a.stage = "chart"
265
+ with vs.lock:
266
+ c = V.chart(rows, vs.cols, V.check_spec(plan["chart"], vs.cols))
267
+ columns = V.describe(vs.rows, vs.cols)
268
+ made = [{"name": d["name"], "from": d["from"]} for d, _, _ in todo]
269
+ made = list({m["name"]: m for m in made}.values())
270
+ a.result = {"title": plan["title"], "chart": c, "summary": "", "notes": V.notes(c, made, plan.get("fallback", False)),
271
+ "facts": V.facts(c), "follow_ups": plan["follow_ups"], "derived": touched, "columns": columns,
272
+ "scope": len(rows) if ids else 0}
273
+ a.stage = "write"
274
+ try:
275
+ # the chart is already on screen; a slow write-up gives way to the exact numbers after a while
276
+ fut = WRITERS.submit(chat, V.summary_prompt(a.question, plan["title"], c, made), max_tokens=320, temperature=0.3)
277
+ text = fut.result(timeout=WRITE_SECONDS)
278
+ text = re.sub(r"[*#_`]+", "", re.sub(r"^\s*[-•]\s+", "", text, flags=re.M)).strip()
279
+ text = re.sub(r"\s*\n\s*", " ", text)
280
+ a.result["summary"] = " ".join(re.split(r"(?<=[.!?])\s+(?=[A-Z0-9])", text)[:4])[:700]
281
+ except (EndpointError, FutureTimeout):
282
+ pass
283
+ a.stage = "done"
284
+ except (EndpointError, V.PlanError) as e:
285
+ a.stage, a.error = "failed", str(e)[:300]
286
+ except Exception as e: # never leave the page polling a dead ask
287
+ a.stage, a.error = "failed", f"Something went wrong drawing that: {str(e)[:200]}"
288
+ finally:
289
+ vs.busy = False
290
+
291
+
292
+ @router.post("/{sid}/ask")
293
+ def ask(sid: str, body: AskIn):
294
+ vs = SETS.get(sid)
295
+ if not vs:
296
+ raise HTTPException(404, "That dataset is no longer loaded here. Load it again.")
297
+ q = re.sub(r"\s+", " ", body.question).strip()
298
+ if not q:
299
+ raise HTTPException(400, "Type a question first.")
300
+ ep = _ep()
301
+ with GUARD:
302
+ if vs.busy:
303
+ raise HTTPException(409, "This dataset is still answering the last question.")
304
+ if sum(1 for a in ASKS.values() if a.stage not in ("done", "failed")) >= MAX_RUNNING:
305
+ raise HTTPException(429, "Several charts are being drawn right now. Try again in a moment.")
306
+ aid = uuid.uuid4().hex[:10]
307
+ a = Ask(id=aid, set_id=sid, question=q[:400], scope=[i for i in body.scope if isinstance(i, int)][:MAX_ROWS], scope_label=body.scope_label[:120])
308
+ ASKS[aid] = a
309
+ vs.busy = True
310
+ for old in sorted(ASKS, key=lambda k: ASKS[k].started)[:-200]:
311
+ ASKS.pop(old, None)
312
+ threading.Thread(target=run_ask, args=(a, vs, ep), daemon=True).start()
313
+ return {"ask": aid}
314
+
315
+
316
+ @router.get("/ask/{aid}")
317
+ def ask_status(aid: str):
318
+ a = ASKS.get(aid)
319
+ if not a:
320
+ raise HTTPException(404, "That question is no longer here. Ask it again.")
321
+ out = {"stage": a.stage, "done": a.done, "total": a.total, "error": a.error, "elapsed": round(time.time() - a.started, 1)}
322
+ if a.result and a.stage in ("write", "done"):
323
+ out["result"] = a.result
324
+ return out
325
+
326
+
327
+ @router.get("/{sid}/download")
328
+ def download(sid: str, ids: str = "", format: str = "csv"):
329
+ vs = SETS.get(sid)
330
+ if not vs:
331
+ raise HTTPException(404, "That dataset is no longer loaded here.")
332
+ want = {int(x) for x in ids.split(",") if x.strip().isdigit()}
333
+ rows = [r for r in vs.rows if not want or r["_id"] in want]
334
+ keys = ["_id"] + [k for k, c in vs.cols.items() if c.get("derived") not in ("words", "turns")]
335
+ name = re.sub(r"[^\w-]+", "-", vs.name.lower()).strip("-")[:40] or "rows"
336
+ if format == "jsonl":
337
+ body = "\n".join(json.dumps({("row" if k == "_id" else k): r.get(k) for k in keys}, ensure_ascii=False) for r in rows)
338
+ return Response(body, media_type="application/jsonl", headers={"Content-Disposition": f'attachment; filename="{name}-{len(rows)}.jsonl"'})
339
+ buf = io.StringIO()
340
+ w = csv.writer(buf)
341
+ w.writerow(["row" if k == "_id" else k for k in keys])
342
+ for r in rows:
343
+ w.writerow(["" if r.get(k) is None else r.get(k) for k in keys])
344
+ return Response(buf.getvalue().encode("utf-8-sig"), media_type="text/csv", headers={"Content-Disposition": f'attachment; filename="{name}-{len(rows)}.csv"'})
datainventor/llm.py CHANGED
@@ -31,7 +31,7 @@ class Endpoint:
31
  min_max_tokens: int = 0
32
 
33
  def chat(self, prompt: str, max_tokens: int = 1500, temperature: float = 0.8, retries: int = 5,
34
- validate: Optional[Callable[[str], bool]] = None, allow_cut: bool = False) -> str:
35
  reasoning = bool(_REASONING.search(self.model)) and not self.disable_thinking
36
  body = {"model": self.model,
37
  "max_tokens": max(self.min_max_tokens, max_tokens + (REASONING_HEADROOM if reasoning else 0)),
@@ -47,7 +47,7 @@ class Endpoint:
47
  try:
48
  req = urllib.request.Request(url, data, {"Content-Type": "application/json", "User-Agent": "DataInventor/1.0",
49
  "Authorization": "Bearer " + self.api_key})
50
- resp = json.load(urllib.request.urlopen(req, timeout=180))
51
  choice = (resp.get("choices") or [{}])[0] or {}
52
  # allow_cut: short answers (a verdict letter) can be read even if the model kept talking
53
  if choice.get("finish_reason") == "length" and not (allow_cut and ((choice.get("message") or {}).get("content") or "").strip()):
 
31
  min_max_tokens: int = 0
32
 
33
  def chat(self, prompt: str, max_tokens: int = 1500, temperature: float = 0.8, retries: int = 5,
34
+ validate: Optional[Callable[[str], bool]] = None, allow_cut: bool = False, timeout: int = 180) -> str:
35
  reasoning = bool(_REASONING.search(self.model)) and not self.disable_thinking
36
  body = {"model": self.model,
37
  "max_tokens": max(self.min_max_tokens, max_tokens + (REASONING_HEADROOM if reasoning else 0)),
 
47
  try:
48
  req = urllib.request.Request(url, data, {"Content-Type": "application/json", "User-Agent": "DataInventor/1.0",
49
  "Authorization": "Bearer " + self.api_key})
50
+ resp = json.load(urllib.request.urlopen(req, timeout=timeout))
51
  choice = (resp.get("choices") or [{}])[0] or {}
52
  # allow_cut: short answers (a verdict letter) can be read even if the model kept talking
53
  if choice.get("finish_reason") == "length" and not (allow_cut and ((choice.get("message") or {}).get("content") or "").strip()):
datainventor/visualize.py ADDED
@@ -0,0 +1,738 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Visualize: ask a question about a dataset, get a chart and the rows behind every mark.
2
+
3
+ Built for small datasets first, the 25 to 200 rows a hosted run writes. Every row stays in
4
+ memory, charts are exact counts over all rows (nothing is sampled), and when a question needs
5
+ something no column has (tone, whether a reply gives steps), a model reads the rows in a few
6
+ batched calls and the answer becomes a new column that later questions can reuse.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import math
12
+ import re
13
+ import statistics
14
+ from concurrent.futures import ThreadPoolExecutor
15
+ from typing import Callable, Optional
16
+
17
+ ID_NAMES = {"id", "_id", "idx", "index", "row_idx", "uuid", "row_id"}
18
+ CLASS_NAMES = {"label", "labels", "class", "category", "target", "sentiment", "rating", "stars", "grade"}
19
+ MAX_COLUMNS = 30
20
+ MAX_CELL = 4000 # characters kept per cell
21
+ LABEL_CHARS = 600 # characters of each row a labelling call reads
22
+ LABEL_BATCH = 8
23
+ MAX_GROUPS = 12 # categories on one axis before the rest fold into "Other"
24
+ MAX_COLORS = 6
25
+ KINDS = ("label", "score", "pattern", "count")
26
+ CHARTS = ("bar", "stack", "box", "hist", "scatter")
27
+
28
+
29
+ class PlanError(ValueError):
30
+ pass
31
+
32
+
33
+ # ------------------------------------------------------------------ rows and columns
34
+ def _turns(v) -> Optional[list]:
35
+ """Chat-style cells: [{"role", "content"}] or ShareGPT [{"from", "value"}]."""
36
+ if isinstance(v, list) and v and all(isinstance(x, dict) for x in v):
37
+ if all("content" in x for x in v):
38
+ return [(str(x.get("role") or "turn"), x.get("content")) for x in v]
39
+ if all("value" in x for x in v):
40
+ return [(str(x.get("from") or "turn"), x.get("value")) for x in v]
41
+ return None
42
+
43
+
44
+ def _cell(v):
45
+ if v is None:
46
+ return ""
47
+ if isinstance(v, bool):
48
+ return "yes" if v else "no"
49
+ if isinstance(v, (int, float)):
50
+ return v if math.isfinite(v) else ""
51
+ if isinstance(v, str):
52
+ return v[:MAX_CELL]
53
+ t = _turns(v)
54
+ if t:
55
+ return "\n".join(f"{r}: {c if isinstance(c, str) else json.dumps(c, ensure_ascii=False)}" for r, c in t)[:MAX_CELL]
56
+ return json.dumps(v, ensure_ascii=False)[:MAX_CELL]
57
+
58
+
59
+ def _num(v) -> Optional[float]:
60
+ if isinstance(v, bool):
61
+ return None
62
+ if isinstance(v, (int, float)):
63
+ return float(v) if math.isfinite(v) else None
64
+ if isinstance(v, str) and v.strip():
65
+ try:
66
+ f = float(v.strip().replace(",", ""))
67
+ return f if math.isfinite(f) else None
68
+ except ValueError:
69
+ return None
70
+ return None
71
+
72
+
73
+ def words(text) -> int:
74
+ return len(str(text or "").split())
75
+
76
+
77
+ def prepare(raw: list[dict]) -> tuple[list[dict], dict]:
78
+ """Normalize raw rows and type every column: text, category or number.
79
+
80
+ Returns (rows, columns). Each row gets `_id` (1-based). Long text columns get a derived
81
+ "<name> words" number column, and chat columns a "<name> turns" one.
82
+ """
83
+ names: list[str] = []
84
+ for r in raw:
85
+ for k in r:
86
+ if k not in names and str(k).lower() not in ID_NAMES and not str(k).startswith("_"):
87
+ names.append(k)
88
+ names = names[:MAX_COLUMNS]
89
+ rows, turns = [], {}
90
+ for i, r in enumerate(raw, 1):
91
+ row = {"_id": i}
92
+ for k in names:
93
+ v = r.get(k)
94
+ t = _turns(v)
95
+ if t:
96
+ turns.setdefault(k, {})[i] = len(t)
97
+ row[k] = _cell(v)
98
+ rows.append(row)
99
+ cols: dict[str, dict] = {}
100
+ n = len(rows)
101
+ for k in names:
102
+ vals = [row[k] for row in rows if row[k] != ""]
103
+ if not vals:
104
+ continue
105
+ nums = [_num(v) for v in vals]
106
+ few = all(x is not None for x in nums) and all(float(x).is_integer() for x in nums) and \
107
+ (len(set(nums)) <= 2 or (str(k).lower() in CLASS_NAMES and len(set(nums)) <= 12))
108
+ if few: # 0/1 flags and class ids are categories, not amounts
109
+ for row in rows:
110
+ row[k] = str(int(_num(row[k]))) if row[k] != "" else ""
111
+ cols[k] = {"type": "cat"}
112
+ continue
113
+ if all(x is not None for x in nums) and not all(isinstance(v, str) and len(v) > 12 for v in vals):
114
+ for row in rows:
115
+ row[k] = _num(row[k]) if row[k] != "" else None
116
+ cols[k] = {"type": "num"}
117
+ continue
118
+ strs = [str(v) for v in vals]
119
+ avg = sum(len(s) for s in strs) / len(strs)
120
+ distinct = len(set(strs))
121
+ if distinct == len(strs) and len(strs) > 1 and not any(" " in x for x in strs) and k not in turns:
122
+ continue # hashes, ids, urls: unique and never a phrase
123
+ if avg > 60 or k in turns:
124
+ cols[k] = {"type": "text"}
125
+ elif distinct <= 30 and (distinct <= max(6, 0.6 * len(strs)) if len(strs) > 6 else distinct < len(strs)):
126
+ cols[k] = {"type": "cat"}
127
+ elif avg >= 12:
128
+ cols[k] = {"type": "text"}
129
+ else:
130
+ continue # short and unique: names, ids, codes
131
+ for row in rows:
132
+ if not isinstance(row[k], str):
133
+ row[k] = str(row[k])
134
+ for k, c in list(cols.items()):
135
+ if c["type"] != "text":
136
+ continue
137
+ if k in turns:
138
+ name = f"{k} turns"
139
+ for row in rows:
140
+ row[name] = float(turns[k].get(row["_id"], 0))
141
+ cols[name] = {"type": "num", "derived": "turns", "from": k}
142
+ avg_words = sum(words(row[k]) for row in rows) / max(1, n)
143
+ if avg_words >= 8:
144
+ name = f"{k} words"
145
+ for row in rows:
146
+ row[name] = float(words(row[k]))
147
+ cols[name] = {"type": "num", "derived": "words", "from": k}
148
+ order = {"text": 0, "cat": 1, "num": 2}
149
+ cols = dict(sorted(cols.items(), key=lambda kv: (order[kv[1]["type"]], bool(kv[1].get("derived")))))
150
+ return rows, cols
151
+
152
+
153
+ def describe(rows: list[dict], cols: dict) -> list[dict]:
154
+ """Per-column facts for the UI and the planner."""
155
+ out = []
156
+ for k, c in cols.items():
157
+ d = {"name": k, "type": c["type"], "derived": c.get("derived", ""), "from": c.get("from", "")}
158
+ vals = [r.get(k) for r in rows]
159
+ if c["type"] == "cat":
160
+ counts: dict[str, int] = {}
161
+ for v in vals:
162
+ counts[v or "(blank)"] = counts.get(v or "(blank)", 0) + 1
163
+ d["values"] = sorted(counts.items(), key=lambda kv: -kv[1])[:12]
164
+ d["distinct"] = len(counts)
165
+ elif c["type"] == "num":
166
+ xs = [v for v in vals if isinstance(v, (int, float))]
167
+ if xs:
168
+ d.update(min=min(xs), max=max(xs), median=statistics.median(xs))
169
+ else:
170
+ xs = [words(v) for v in vals if v]
171
+ d["avg_words"] = round(sum(xs) / max(1, len(xs)))
172
+ d["example"] = next((str(v)[:140] for v in vals if v), "")
173
+ if c.get("labels"):
174
+ d["labels"] = c["labels"]
175
+ if c.get("guide"):
176
+ d["guide"] = c["guide"]
177
+ out.append(d)
178
+ return out
179
+
180
+
181
+ # ------------------------------------------------------------------ charts
182
+ def _q(xs: list[float], p: float) -> float:
183
+ s = sorted(xs)
184
+ if len(s) == 1:
185
+ return s[0]
186
+ pos = (len(s) - 1) * p
187
+ lo = int(pos)
188
+ return s[lo] + (s[min(lo + 1, len(s) - 1)] - s[lo]) * (pos - lo)
189
+
190
+
191
+ def _nice(x: float) -> float:
192
+ if x <= 0:
193
+ return 1.0
194
+ e = 10 ** math.floor(math.log10(x))
195
+ for m in (1, 2, 2.5, 5, 10):
196
+ if x <= m * e:
197
+ return m * e
198
+ return 10 * e
199
+
200
+
201
+ def _key(v) -> str:
202
+ return str(v) if v not in ("", None) else "(blank)"
203
+
204
+
205
+ def _fold(groups: dict[str, list[int]], limit: int) -> list[tuple[str, list[int]]]:
206
+ items = sorted(groups.items(), key=lambda kv: -len(kv[1]))
207
+ if len(items) <= limit:
208
+ return items
209
+ keep, rest = items[:limit - 1], items[limit - 1:]
210
+ ids = [i for _, g in rest for i in g]
211
+ return keep + [(f"Other ({len(rest)})", ids)]
212
+
213
+
214
+ def chart(rows: list[dict], cols: dict, spec: dict) -> dict:
215
+ """Exact aggregation of `rows` for one chart spec. Every mark carries the ids of its rows."""
216
+ t, x, y, color, agg = spec.get("type"), spec.get("x"), spec.get("y"), spec.get("color"), spec.get("agg") or "count"
217
+ by = {r["_id"]: r for r in rows}
218
+ out = {"type": t, "n": len(rows), "x": x, "y": y, "color": color, "agg": agg}
219
+
220
+ def groups_of(col):
221
+ g: dict[str, list[int]] = {}
222
+ for r in rows:
223
+ g.setdefault(_key(r.get(col)), []).append(r["_id"])
224
+ return g
225
+
226
+ def nums(ids, col):
227
+ return [(i, by[i][col]) for i in ids if isinstance(by[i].get(col), (int, float))]
228
+
229
+ if t == "bar":
230
+ marks = []
231
+ for k, ids in _fold(groups_of(x), MAX_GROUPS):
232
+ if y and agg in ("mean", "median"):
233
+ pts = nums(ids, y)
234
+ if not pts:
235
+ continue
236
+ vs = [v for _, v in pts]
237
+ val = statistics.fmean(vs) if agg == "mean" else statistics.median(vs)
238
+ marks.append({"key": k, "value": round(val, 1 if abs(val) >= 10 else 2), "n": len(pts), "ids": [i for i, _ in pts]})
239
+ else:
240
+ marks.append({"key": k, "value": len(ids), "n": len(ids), "ids": ids})
241
+ marks.sort(key=lambda m: -m["value"])
242
+ out["marks"] = marks
243
+ elif t == "stack":
244
+ colors = [k for k, _ in _fold(groups_of(color), MAX_COLORS)]
245
+ color_ids = dict(_fold(groups_of(color), MAX_COLORS))
246
+ rev = {i: k for k, ids in color_ids.items() for i in ids}
247
+ marks = []
248
+ for k, ids in _fold(groups_of(x), MAX_GROUPS):
249
+ parts = []
250
+ for c in colors:
251
+ pids = [i for i in ids if rev.get(i) == c]
252
+ if pids:
253
+ parts.append({"key": c, "value": len(pids), "ids": pids})
254
+ marks.append({"key": k, "n": len(ids), "ids": ids, "parts": parts})
255
+ marks.sort(key=lambda m: -m["n"])
256
+ out.update(marks=marks, colors=colors)
257
+ elif t == "box":
258
+ gs = _fold(groups_of(x), MAX_GROUPS) if x else [("All rows", [r["_id"] for r in rows])]
259
+ marks = []
260
+ for k, ids in gs:
261
+ pts = nums(ids, y)
262
+ if not pts:
263
+ continue
264
+ vs = [v for _, v in pts]
265
+ q1, med, q3 = _q(vs, .25), _q(vs, .5), _q(vs, .75)
266
+ iqr = q3 - q1
267
+ lo = min(v for v in vs if v >= q1 - 1.5 * iqr)
268
+ hi = max(v for v in vs if v <= q3 + 1.5 * iqr)
269
+ marks.append({"key": k, "n": len(pts), "q1": q1, "median": med, "q3": q3, "lo": lo, "hi": hi,
270
+ "ids": [i for i, _ in pts], "points": [[i, v] for i, v in pts]})
271
+ marks.sort(key=lambda m: -m["median"])
272
+ out["marks"] = marks
273
+ elif t == "hist":
274
+ pts = nums([r["_id"] for r in rows], x)
275
+ vs = [v for _, v in pts]
276
+ if vs and all(float(v).is_integer() for v in vs) and max(vs) - min(vs) <= 12:
277
+ # small whole numbers (scores, counts): one bar per value
278
+ out["discrete"] = True
279
+ out["marks"] = [{"x0": v, "x1": v + 1, "value": len(ids), "ids": ids}
280
+ for v in range(int(min(vs)), int(max(vs)) + 1) for ids in [[i for i, x in pts if x == v]]]
281
+ elif vs:
282
+ lo, hi = min(vs), max(vs)
283
+ k = 6 if len(vs) <= 30 else min(14, int(math.sqrt(len(vs))) + 2)
284
+ step = _nice((hi - lo) / k) if hi > lo else 1.0
285
+ start = math.floor(lo / step) * step
286
+ nb = max(1, min(40, math.ceil((hi - start) / step + 1e-9)))
287
+ if start + nb * step <= hi:
288
+ nb += 1
289
+ bins = [{"x0": round(start + b * step, 4), "x1": round(start + (b + 1) * step, 4), "value": 0, "ids": []} for b in range(nb)]
290
+ for i, v in pts:
291
+ b = min(nb - 1, int((v - start) / step))
292
+ bins[b]["ids"].append(i)
293
+ bins[b]["value"] += 1
294
+ out["marks"] = bins
295
+ else:
296
+ out["marks"] = []
297
+ elif t == "scatter":
298
+ rev = {}
299
+ if color:
300
+ for k, ids in _fold(groups_of(color), MAX_COLORS):
301
+ for i in ids:
302
+ rev[i] = k
303
+ out["colors"] = [k for k, _ in _fold(groups_of(color), MAX_COLORS)]
304
+ marks = [{"id": r["_id"], "x": r[x], "y": r[y], "c": rev.get(r["_id"], "")} for r in rows
305
+ if isinstance(r.get(x), (int, float)) and isinstance(r.get(y), (int, float))]
306
+ out["marks"] = marks
307
+ if len(marks) >= 3:
308
+ xs, ys = [m["x"] for m in marks], [m["y"] for m in marks]
309
+ try:
310
+ out["r"] = round(statistics.correlation(xs, ys), 2)
311
+ except statistics.StatisticsError:
312
+ pass
313
+ else:
314
+ raise PlanError(f"Unknown chart type {t!r}.")
315
+ return out
316
+
317
+
318
+ def check_spec(spec: dict, cols: dict) -> dict:
319
+ """Validate a chart spec against the columns, fixing the mistakes that have one obvious fix."""
320
+ s = {k: spec.get(k) for k in ("type", "x", "y", "color", "agg")}
321
+ for k in ("x", "y", "color"):
322
+ if s[k] in ("", "null", "none", "None"):
323
+ s[k] = None
324
+ typ = lambda c: (cols.get(c) or {}).get("type")
325
+ for k in ("x", "y", "color"):
326
+ if s[k] is not None and s[k] not in cols:
327
+ raise PlanError(f"There is no column named {s[k]!r}. Columns: {', '.join(cols)}.")
328
+ t = s["type"]
329
+ if t not in CHARTS:
330
+ raise PlanError(f"chart.type must be one of {', '.join(CHARTS)}.")
331
+ if t == "bar" and typ(s["x"]) == "num":
332
+ t, s["y"] = "hist", None
333
+ if t == "box" and s["y"] is None and typ(s["x"]) == "num":
334
+ s["x"], s["y"] = None, s["x"]
335
+ if t == "stack" and not s["color"]:
336
+ t = "bar"
337
+ if t == "bar":
338
+ if typ(s["x"]) not in ("cat",):
339
+ raise PlanError("A bar chart needs x to be a category column.")
340
+ if s["y"] and typ(s["y"]) != "num":
341
+ raise PlanError("A bar chart's y must be a number column, or null to count rows.")
342
+ s["agg"] = (s["agg"] if s["agg"] in ("mean", "median") else "mean") if s["y"] else "count"
343
+ elif t == "stack":
344
+ if typ(s["x"]) != "cat" or typ(s["color"]) != "cat":
345
+ raise PlanError("A stacked chart needs x and color to be category columns.")
346
+ s["y"], s["agg"] = None, s["agg"] if s["agg"] in ("share", "count") else "share"
347
+ elif t == "box":
348
+ if typ(s["y"]) != "num":
349
+ raise PlanError("A box plot needs y to be a number column.")
350
+ if s["x"] and typ(s["x"]) != "cat":
351
+ raise PlanError("A box plot's x must be a category column or null.")
352
+ elif t == "hist":
353
+ if typ(s["x"]) != "num":
354
+ raise PlanError("A histogram needs x to be a number column.")
355
+ elif t == "scatter":
356
+ if typ(s["x"]) != "num" or typ(s["y"]) != "num":
357
+ raise PlanError("A scatter plot needs x and y to be number columns.")
358
+ if s["color"] and typ(s["color"]) != "cat":
359
+ s["color"] = None
360
+ s["type"] = t
361
+ return s
362
+
363
+
364
+ # ------------------------------------------------------------------ instant views (no model)
365
+ def _main_cat(cols: dict, rows: list[dict]) -> Optional[str]:
366
+ prefer = ("area", "category", "label", "topic", "subtopic", "department", "language")
367
+ cats = [k for k, c in cols.items() if c["type"] == "cat" and len({r.get(k) for r in rows}) > 1]
368
+ for p in prefer:
369
+ if p in cats:
370
+ return p
371
+ return min(cats, key=lambda k: abs(len({r.get(k) for r in rows}) - 5)) if cats else None
372
+
373
+
374
+ def _main_len(cols: dict) -> Optional[str]:
375
+ prefer = ("completion words", "chosen words", "response words", "output words", "answer words", "text words")
376
+ nums = [k for k, c in cols.items() if c.get("derived") == "words"]
377
+ for p in prefer:
378
+ if p in nums:
379
+ return p
380
+ return nums[-1] if nums else None
381
+
382
+
383
+ def glance(rows: list[dict], cols: dict) -> list[dict]:
384
+ """Two or three charts that need no model, so a dataset shows something the moment it loads."""
385
+ out = []
386
+ cat, ln = _main_cat(cols, rows), _main_len(cols)
387
+ if cat:
388
+ out.append({"title": f"Rows by {cat}", **chart(rows, cols, {"type": "bar", "x": cat})})
389
+ if ln:
390
+ spec = {"type": "box", "x": cat, "y": ln} if cat else {"type": "hist", "x": ln}
391
+ out.append({"title": f"{ln.capitalize()}" + (f" by {cat}" if cat else ""), **chart(rows, cols, spec)})
392
+ if not cat:
393
+ more = [k for k, c in cols.items() if c.get("derived") in ("words", "turns") and k != ln]
394
+ if more:
395
+ out.append({"title": more[0].capitalize(), **chart(rows, cols, {"type": "hist", "x": more[0]})})
396
+ second = [k for k, c in cols.items() if c["type"] == "cat" and k != cat and 1 < len({r.get(k) for r in rows}) <= 8]
397
+ if second:
398
+ k = "language" if "language" in second else "difficulty" if "difficulty" in second else second[0]
399
+ out.append({"title": f"Rows by {k}", **chart(rows, cols, {"type": "bar", "x": k})})
400
+ return out[:3]
401
+
402
+
403
+ def _pl(name: str) -> str:
404
+ return name if name.endswith("s") else name + "s"
405
+
406
+
407
+ def suggestions(rows: list[dict], cols: dict) -> list[str]:
408
+ cat, ln = _main_cat(cols, rows), _main_len(cols)
409
+ texts = [k for k, c in cols.items() if c["type"] == "text"]
410
+ reply = next((k for k in ("completion", "chosen", "response", "output", "answer", "messages", "conversations") if k in texts), None)
411
+ ask = next((k for k in ("prompt", "instruction", "question", "input", "query") if k in texts), None)
412
+ other = next((k for k in texts if k not in (reply, ask)), None)
413
+ out = []
414
+ if reply:
415
+ out.append(f"What tone do the {_pl(reply)} take" + (f" in each {cat}?" if cat else "?"))
416
+ if ask:
417
+ out.append(f"What do the {_pl(ask)} ask for? Group them into a few themes.")
418
+ if not ask and not reply and other:
419
+ out.append(f"What themes come up in the {_pl(other)}?")
420
+ out.append(f"How positive or negative is each {other}?")
421
+ if ln and cat:
422
+ out.append(f"Which {cat} gets the longest {_pl(ln.replace(' words', ''))}?")
423
+ elif ln:
424
+ out.append(f"How long are the {_pl(ln.replace(' words', ''))}?")
425
+ if reply:
426
+ out.append(f"How many {_pl(reply)} give step-by-step instructions?")
427
+ seconds = [k for k, c in cols.items() if c["type"] == "cat" and k != cat and 1 < len({r.get(k) for r in rows}) <= 8]
428
+ if cat and seconds:
429
+ out.append(f"How does {seconds[0]} vary across {cat}?")
430
+ return out[:4]
431
+
432
+
433
+ # ------------------------------------------------------------------ the model's part
434
+ def first_json(text: str):
435
+ """The first JSON object or list in a model reply (fences and chatter around it are ignored)."""
436
+ dec = json.JSONDecoder()
437
+ for i, ch in enumerate(text):
438
+ if ch in "{[":
439
+ try:
440
+ return dec.raw_decode(text[i:])[0]
441
+ except ValueError:
442
+ continue
443
+ raise ValueError("no JSON in the reply")
444
+
445
+
446
+ def _snip(v, n=LABEL_CHARS) -> str:
447
+ return re.sub(r"\s+", " ", str(v or "")).strip()[:n]
448
+
449
+
450
+ def plan_prompt(question: str, name: str, rows: list[dict], cols: dict, scope: str = "", error: str = "") -> str:
451
+ lines = []
452
+ for d in describe(rows, cols):
453
+ if d["type"] == "cat":
454
+ vals = ", ".join(f"{k} {v}" for k, v in d["values"][:8])
455
+ more = f" (+{d['distinct'] - 8} more)" if d["distinct"] > 8 else ""
456
+ lines.append(f'- "{d["name"]}" (category, {d["distinct"]} values: {vals}{more})')
457
+ elif d["type"] == "num":
458
+ if "min" in d:
459
+ lines.append(f'- "{d["name"]}" (number, {d["min"]:g} to {d["max"]:g}, median {d["median"]:g})')
460
+ else:
461
+ lines.append(f'- "{d["name"]}" (text, about {d["avg_words"]} words) e.g. "{_snip(d["example"], 100)}"')
462
+ if d.get("derived") in ("label", "score", "pattern", "count"):
463
+ lines[-1] += f' [made earlier by reading "{d["from"]}"]'
464
+ texts = [k for k, c in cols.items() if c["type"] in ("text", "cat")][:5]
465
+ ex = []
466
+ for r in rows[:5]:
467
+ ex.append(f"#{r['_id']} " + " | ".join(f"{k}: {_snip(r.get(k), 110)}" for k in texts))
468
+ return f"""You turn a question about a dataset into a chart plan.
469
+
470
+ Dataset: {name}, {len(rows)} rows{scope}.
471
+ Columns:
472
+ {chr(10).join(lines)}
473
+
474
+ A few rows:
475
+ {chr(10).join(ex)}
476
+
477
+ Question: "{question}"
478
+
479
+ Reply with only a JSON object:
480
+ {{"title": "...", "derive": [], "chart": {{"type": "...", "x": "...", "y": null, "color": null, "agg": "count"}}, "follow_ups": ["...", "...", "..."]}}
481
+
482
+ Rules:
483
+ - Use the columns above when they can answer the question. They are instant.
484
+ - Questions about what the text says, asks for, means or sounds like (themes, intent, tone, quality) need a derived column read from that text, unless a category column already names exactly that.
485
+ - Chart a length or other number column only when the question is about length or amount.
486
+ - Only when no column answers it, add one or two entries to "derive". Each becomes a new column a model fills by reading one column of every row:
487
+ {{"name": "tone", "kind": "label", "from": "completion", "labels": ["formal", "friendly", "curt"], "guide": "the overall tone of the reply"}}
488
+ kind "label": one of 2 to 7 short labels per row. Give "labels" yourself when the question or the rows above suggest them (faster); use [] only for open-ended themes, to have them discovered from the rows.
489
+ kind "score": an integer from 1 to 5; "guide" must say what 1 and 5 mean.
490
+ kind "pattern": yes or no, whether the case-insensitive regex in "pattern" matches. kind "count": how many times it matches.
491
+ Use "pattern" or "count" only for literal marks (links, numbered lists, tables, code blocks, digits); judge meaning with "label" or "score".
492
+ "name" is 1 to 3 lowercase words, "from" is a text or category column.
493
+ - chart.type:
494
+ "bar": x is a category; y null counts rows, or y is a number column with agg "mean" or "median".
495
+ "stack": x and color are categories; agg "share" or "count". Use it to compare make-up across groups.
496
+ "box": y is a number column; x is a category or null. Use it for how a number spreads in each group.
497
+ "hist": x is a number column.
498
+ "scatter": x and y are number columns; color is a category or null.
499
+ A derived column can be used in the chart by its name.
500
+ - "title": what the chart shows, under 60 characters, sentence case, no claims about the result.
501
+ - "follow_ups": three short questions a person exploring this data might ask next.{chr(10) + chr(10) + "Your last plan was rejected: " + error + " Fix it." if error else ""}"""
502
+
503
+
504
+ def _clean_name(raw: str) -> str:
505
+ return re.sub(r"\s+", " ", re.sub(r"[^a-z0-9 ]", "", re.sub(r"[_\-]+", " ", raw.lower()))).strip()[:24]
506
+
507
+
508
+ def parse_plan(text: str, cols: dict) -> dict:
509
+ try:
510
+ p = first_json(text)
511
+ except ValueError:
512
+ raise PlanError("The reply was not JSON.")
513
+ if not isinstance(p, dict) or not isinstance(p.get("chart"), dict):
514
+ raise PlanError('The reply needs a "chart" object.')
515
+ derive = []
516
+ known = dict(cols)
517
+ for d in (p.get("derive") or [])[:2]:
518
+ if not isinstance(d, dict):
519
+ continue
520
+ kind = d.get("kind")
521
+ raw_name = str(d.get("name") or "")
522
+ name = _clean_name(raw_name)
523
+ for k in ("x", "y", "color"): # the chart may refer to the name as the model wrote it
524
+ if p["chart"].get(k) is not None and p["chart"].get(k) not in cols and _clean_name(str(p["chart"][k])) == name:
525
+ p["chart"][k] = name
526
+ src = d.get("from")
527
+ if kind not in KINDS or not name:
528
+ raise PlanError(f"derive entries need a name and a kind from {', '.join(KINDS)}.")
529
+ if src not in cols or cols[src]["type"] not in ("text", "cat"):
530
+ raise PlanError(f'derive "from" must be a text or category column, not {src!r}.')
531
+ item = {"name": name, "kind": kind, "from": src, "guide": str(d.get("guide") or "")[:200]}
532
+ if kind == "label":
533
+ labels = [str(x).strip()[:40] for x in (d.get("labels") or []) if str(x).strip()]
534
+ item["labels"] = list(dict.fromkeys(labels))[:7]
535
+ elif kind in ("pattern", "count"):
536
+ pat = re.sub(r"^\(\?i\)", "", str(d.get("pattern") or ""))
537
+ if not pat or len(pat) > 160 or re.search(r"\([^)]*[+*]\)[+*{]", pat):
538
+ raise PlanError("pattern must be a short regex without nested repeats.")
539
+ try:
540
+ re.compile(pat, re.I)
541
+ except re.error:
542
+ raise PlanError(f"{pat!r} is not a valid regex.")
543
+ item["pattern"] = pat
544
+ if name in cols and cols[name].get("derived") == kind and cols[name].get("from") == src \
545
+ and (kind != "label" or not item["labels"] or set(item["labels"]) == set(cols[name].get("labels") or [])):
546
+ continue # already made by an earlier question: reuse it
547
+ if name in cols and cols[name].get("derived") not in KINDS: # an original column: never overwrite it
548
+ new = f"{name} (model)"
549
+ for k in ("x", "y", "color"):
550
+ if p["chart"].get(k) == name:
551
+ p["chart"][k] = new
552
+ item["name"] = name = new
553
+ derive.append(item)
554
+ known[name] = {"type": "num" if kind in ("score", "count") else "cat"}
555
+ spec = check_spec(p["chart"], known)
556
+ title = re.sub(r"\s+", " ", str(p.get("title") or "")).strip()[:80] or "What the data shows"
557
+ ups = [re.sub(r"\s+", " ", str(q)).strip()[:140] for q in (p.get("follow_ups") or []) if str(q).strip()][:3]
558
+ return {"title": title, "derive": derive, "chart": spec, "follow_ups": ups}
559
+
560
+
561
+ def fallback_plan(question: str, rows: list[dict], cols: dict) -> dict:
562
+ """Used when the model cannot produce a valid plan: answer with the closest instant chart."""
563
+ q = question.lower()
564
+ cat = next((k for k, c in cols.items() if c["type"] == "cat" and k.lower() in q), None) or _main_cat(cols, rows)
565
+ num = next((k for k, c in cols.items() if c["type"] == "num" and k.lower().replace(" words", "") in q), None)
566
+ if not num and re.search(r"\blong|length|short|words?\b", q):
567
+ num = _main_len(cols)
568
+ if num:
569
+ spec = {"type": "box", "x": cat, "y": num} if cat else {"type": "hist", "x": num}
570
+ title = f"{num.capitalize()}" + (f" by {cat}" if cat else "")
571
+ elif cat:
572
+ spec, title = {"type": "bar", "x": cat}, f"Rows by {cat}"
573
+ else:
574
+ raise PlanError("This data has no column a chart can use.")
575
+ return {"title": title, "derive": [], "chart": check_spec(spec, cols), "follow_ups": [], "fallback": True}
576
+
577
+
578
+ def discover_prompt(question: str, d: dict, rows: list[dict]) -> str:
579
+ snips = "\n".join(f"- {_snip(r.get(d['from']), 220)}" for r in rows[:40])
580
+ return f"""Here are {min(40, len(rows))} entries from the "{d['from']}" column of a dataset. Someone asked: "{question}"
581
+
582
+ We need a label for every row: "{d['name']}"{f" ({d['guide']})" if d['guide'] else ""}.
583
+ Propose 3 to 7 short labels (1 to 4 words each, lowercase) that together cover these entries, most common first. Use labels a reader would understand without seeing the data.
584
+
585
+ {snips}
586
+
587
+ Reply with only a JSON list of strings."""
588
+
589
+
590
+ def label_prompt(d: dict, batch: list[dict]) -> str:
591
+ items = "\n".join(f"[{k}] {_snip(r.get(d['from']))}" for k, r in enumerate(batch, 1))
592
+ if d["kind"] == "label":
593
+ ask = f'Pick exactly one label for each item from: {", ".join(json.dumps(x) for x in d["labels"])}.'
594
+ eg = ", ".join(f'"{k}": {json.dumps(d["labels"][k % len(d["labels"])])}' for k in (1, 2))
595
+ else:
596
+ ask = "Give each item an integer score from 1 to 5."
597
+ eg = '"1": 4, "2": 2'
598
+ return f"""You are labelling rows of a dataset.
599
+ {d['name'].capitalize()}: {d['guide'] or d['name']}
600
+ {ask}
601
+
602
+ {items}
603
+
604
+ Reply with only a JSON object mapping each item number to its {"label" if d["kind"] == "label" else "score"}, like {{{eg}}}."""
605
+
606
+
607
+ def _read_labels(text: str, d: dict, k: int) -> Optional[dict]:
608
+ try:
609
+ obj = first_json(text)
610
+ except ValueError:
611
+ return None
612
+ if isinstance(obj, list):
613
+ obj = {str(i): v for i, v in enumerate(obj, 1)}
614
+ if not isinstance(obj, dict):
615
+ return None
616
+ out = {}
617
+ canon = {x.lower(): x for x in d.get("labels", [])}
618
+ for key, v in obj.items():
619
+ m = re.search(r"\d+", str(key))
620
+ if not m or not 1 <= int(m.group()) <= k:
621
+ continue
622
+ i = int(m.group())
623
+ if d["kind"] == "label":
624
+ s = str(v).strip().lower()
625
+ hit = canon.get(s) or next((canon[c] for c in canon if s.startswith(c) or c.startswith(s)), None)
626
+ if hit:
627
+ out[i] = hit
628
+ else:
629
+ n = _num(v)
630
+ if n is not None and 1 <= n <= 5:
631
+ out[i] = float(round(n))
632
+ return out if len(out) >= max(1, int(0.8 * k)) else None
633
+
634
+
635
+ def fill(d: dict, rows: list[dict], chat: Callable[..., str], question: str = "",
636
+ progress: Optional[Callable[[int], None]] = None, workers: int = 6) -> dict:
637
+ """Compute one derived column for `rows`. Returns {row id: value} and may set d["labels"]."""
638
+ if d["kind"] in ("pattern", "count"):
639
+ rx = re.compile(d["pattern"], re.I)
640
+ vals = {}
641
+ for r in rows:
642
+ hits = len(rx.findall(str(r.get(d["from"]) or "")[:MAX_CELL]))
643
+ vals[r["_id"]] = ("yes" if hits else "no") if d["kind"] == "pattern" else float(hits)
644
+ if progress:
645
+ progress(len(rows))
646
+ return vals
647
+ if d["kind"] == "label" and len(d.get("labels") or []) < 2:
648
+ text = chat(discover_prompt(question, d, rows), max_tokens=200, temperature=0.3)
649
+ try:
650
+ labels = [str(x).strip().lower()[:40] for x in first_json(text) if str(x).strip()]
651
+ except (ValueError, TypeError):
652
+ labels = []
653
+ if len(labels) < 2:
654
+ raise PlanError(f'Could not find labels for "{d["name"]}".')
655
+ d["labels"] = list(dict.fromkeys(labels))[:7]
656
+ batches = [rows[i:i + LABEL_BATCH] for i in range(0, len(rows), LABEL_BATCH)]
657
+ vals: dict[int, object] = {}
658
+ done = [0]
659
+
660
+ def run(batch):
661
+ got = None
662
+ for attempt in range(3):
663
+ text = chat(label_prompt(d, batch), max_tokens=12 * len(batch) + 40, temperature=0.1 if attempt == 0 else 0.4)
664
+ got = _read_labels(text, d, len(batch))
665
+ if got:
666
+ break
667
+ res = {}
668
+ for k, r in enumerate(batch, 1):
669
+ res[r["_id"]] = (got or {}).get(k, "unclear" if d["kind"] == "label" else None)
670
+ done[0] += len(batch)
671
+ if progress:
672
+ progress(done[0])
673
+ return res
674
+
675
+ with ThreadPoolExecutor(max_workers=max(1, min(workers, len(batches)))) as pool:
676
+ for res in pool.map(run, batches):
677
+ vals.update(res)
678
+ return vals
679
+
680
+
681
+ def _rows(k) -> str:
682
+ return f"{k} row" if k == 1 else f"{k} rows"
683
+
684
+
685
+ def _unit(col) -> str:
686
+ col = str(col or "")
687
+ return " words" if col.endswith(" words") else " turns" if col.endswith(" turns") else ""
688
+
689
+
690
+ def facts(c: dict, limit: int = 14) -> list[str]:
691
+ """The numbers behind a chart in plain lines, for the write-up (and nothing else)."""
692
+ t, marks, n = c["type"], c.get("marks") or [], max(1, c["n"])
693
+ u = _unit(c.get("y") if t in ("bar", "box") else c.get("x"))
694
+ out = []
695
+ if t == "bar":
696
+ for m in marks[:limit]:
697
+ if c["agg"] == "count":
698
+ out.append(f"{m['key']}: {_rows(m['value'])} ({round(100 * m['value'] / n)}%)")
699
+ else:
700
+ out.append(f"{m['key']}: {c['agg']} {c['y']} {m['value']:g}{u} ({_rows(m['n'])})")
701
+ elif t == "stack":
702
+ for m in marks[:limit]:
703
+ out.append(f"{m['key']} ({_rows(m['n'])}): " + ", ".join(f"{p['key']} {p['value']} ({round(100 * p['value'] / m['n'])}%)" for p in m["parts"]))
704
+ elif t == "box":
705
+ for m in marks[:limit]:
706
+ out.append(f"{m['key']} ({_rows(m['n'])}): median {m['median']:g}{u}, middle half {m['q1']:g} to {m['q3']:g}{u}, range {m['lo']:g} to {m['hi']:g}{u}")
707
+ elif t == "hist":
708
+ for m in marks:
709
+ if m["value"]:
710
+ out.append(f"{c['x']} {m['x0']:g}{u}: {_rows(m['value'])}" if c.get("discrete") else f"{m['x0']:g} to {m['x1']:g}{u}: {_rows(m['value'])}")
711
+ elif t == "scatter":
712
+ out.append(f"{len(marks)} rows plotted")
713
+ if "r" in c:
714
+ out.append(f"correlation between {c['x']} and {c['y']}: r = {c['r']}")
715
+ return out
716
+
717
+
718
+ def summary_prompt(question: str, title: str, c: dict, derived: list[dict]) -> str:
719
+ made = "".join(f'\n"{d["name"]}" was assigned by a model reading each row\'s "{d["from"]}" text.' for d in derived)
720
+ return f"""Someone asked about a dataset: "{question}"
721
+ The chart "{title}" covers {c['n']} rows. Its exact numbers:
722
+ {chr(10).join(facts(c))}{made}
723
+
724
+ Answer the question in 2 to 4 short sentences of plain prose. Use only these numbers and keep them exact, but write them into sentences; do not paste the lines above. Lead with the clearest difference. Mention groups with fewer than 5 rows at most once, as too small to read much into. No quotation marks, markdown, lists, headings or advice."""
725
+
726
+
727
+ def notes(c: dict, derived: list[dict], fallback: bool = False) -> list[str]:
728
+ out = []
729
+ if fallback:
730
+ out.append("The model could not plan a chart for that question, so this is the closest chart the columns give directly.")
731
+ for d in derived:
732
+ out.append(f'"{d["name"].capitalize()}" was assigned by a model reading each row\'s {d["from"]}. Open a few rows before relying on it.')
733
+ if c["n"] < 20:
734
+ out.append(f"Only {c['n']} rows, so a difference of one or two rows can change the picture.")
735
+ thin = [m["key"] for m in (c.get("marks") or []) if isinstance(m, dict) and "key" in m and 0 < m.get("n", 99) < 3]
736
+ if thin and c["type"] in ("bar", "box", "stack") and c.get("agg") != "count":
737
+ out.append(f"{', '.join(thin[:3])} {'has' if len(thin) == 1 else 'have'} fewer than three rows.")
738
+ return out
static/app.css CHANGED
@@ -243,3 +243,89 @@ code { font-size: .92em; background: var(--paper-2); padding: 0 4px; border-radi
243
  .sctl { justify-content: flex-start; }
244
  }
245
  .in.num.wide { width: 11em; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  .sctl { justify-content: flex-start; }
244
  }
245
  .in.num.wide { width: 11em; }
246
+
247
+ /* visualize */
248
+ .vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
249
+ .vsrcs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px; margin-top: 30px; }
250
+ .vsrc { background: var(--paper-2); border: 1px solid var(--rule); border-radius: var(--r); padding: 18px 20px 20px; min-width: 0; }
251
+ .vsrc > b { display: block; font-size: 18px; margin-bottom: 4px; }
252
+ .vsrc .actions { margin-top: 16px; }
253
+ .vsrc .seg { margin-top: 12px; }
254
+ .vsrc .addrow { grid-template-columns: minmax(0, 1fr) auto; margin-top: 14px; }
255
+ .vsamples { list-style: none; margin: 10px 0 0; padding: 0; columns: 2 150px; column-gap: 18px; }
256
+ .vsamples li { break-inside: avoid; }
257
+ .vsample { background: none; border: 0; padding: 5px 0; cursor: pointer; text-align: left; font-weight: 600; color: var(--ink); text-decoration: underline; text-decoration-color: var(--rule); text-underline-offset: 4px; text-decoration-thickness: 2px; }
258
+ .vsample:hover:not(:disabled) { color: var(--blue); text-decoration-color: var(--pink); }
259
+ .vsample:disabled { cursor: default; opacity: .6; }
260
+ .vsamples em { color: var(--blue); font-style: normal; font-size: 13px; font-weight: 600; }
261
+ .filebtn { display: inline-block; margin-top: 14px; position: relative; overflow: hidden; }
262
+ .filebtn input { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
263
+ .filebtn:focus-within { outline: 3px solid var(--blue); outline-offset: 2px; }
264
+ .askbar { border-bottom: 1px solid var(--rule); padding-bottom: 22px; }
265
+ textarea.in.vq { min-height: 64px; font-size: 19px; }
266
+ .askbar .actions { gap: 10px 22px; }
267
+ .vz-stages { margin: 0; gap: 4px 18px; }
268
+ .chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
269
+ .chip { background: none; border: 1px solid #aeb2bf; border-radius: 999px; padding: 6px 13px; cursor: pointer; font-size: 14.5px; text-align: left; }
270
+ .chip:hover:not(:disabled) { border-color: var(--ink); background: var(--paper-2); }
271
+ .chip:disabled { opacity: .5; cursor: default; }
272
+ .scopechip { display: flex; gap: 12px; align-items: baseline; flex-wrap: wrap; margin-bottom: 12px; padding: 8px 12px; background: var(--paper-2); border-left: 4px solid var(--pink); font-size: 14.5px; }
273
+ .answer { margin-top: 28px; }
274
+ .answer h2 { max-width: 40ch; }
275
+ .prose { font-family: var(--doc); font-size: 17.5px; line-height: 1.6; margin-top: 12px; max-width: 64ch; }
276
+ .facts { margin: 12px 0 0; padding-left: 18px; font-family: var(--doc); }
277
+ .chart { margin-top: 22px; min-height: 120px; }
278
+ .chart.small { margin-top: 8px; min-height: 80px; }
279
+ .svgwrap svg { display: block; max-width: 100%; height: auto; overflow: visible; }
280
+ .chart text { font: 13px var(--ui); fill: var(--ink); }
281
+ .chart .tick { font-size: 11.5px; fill: var(--slate); font-variant-numeric: tabular-nums; }
282
+ .chart .val { font-weight: 600; font-variant-numeric: tabular-nums; }
283
+ .chart .ink { fill: var(--blue); }
284
+ .chart .ghost { fill: var(--pink); opacity: .85; }
285
+ .chart .grid { stroke: var(--rule); stroke-width: 1; }
286
+ .chart .hit { fill: transparent; }
287
+ .chart .bx { fill: var(--paper-2); stroke: var(--ink); stroke-width: 1.5; }
288
+ .chart .whisk { stroke: var(--ink); stroke-width: 1.5; }
289
+ .chart .med { stroke: var(--pink); stroke-width: 3.5; pointer-events: none; }
290
+ .chart .dot { fill: var(--blue); opacity: .72; mix-blend-mode: multiply; cursor: pointer; }
291
+ .chart .seg { mix-blend-mode: multiply; }
292
+ .chart .segv { fill: #fff; font-size: 11.5px; font-weight: 600; pointer-events: none; }
293
+ .chart .mk { cursor: pointer; outline: none; }
294
+ .chart .mk:hover .ink { fill: #2a39e6; }
295
+ .chart .mk:hover .lab { font-weight: 600; }
296
+ .chart .mk:focus-visible .hit { stroke: var(--blue); stroke-width: 2; }
297
+ .chart .sel .ink, .chart .sel.seg, .chart .sel .seg { fill: var(--pink); }
298
+ .chart .sel .ghost { fill: var(--blue); }
299
+ .chart .sel .bx { stroke: var(--pink); stroke-width: 2.5; }
300
+ .chart circle.sel { fill: var(--pink); opacity: 1; }
301
+ .chart .sel .lab { font-weight: 700; }
302
+ .chart .dim { opacity: .28; }
303
+ .chart .thin { opacity: .65; }
304
+ .fresh .grow { animation: vzgrow .55s cubic-bezier(.2, .8, .2, 1) both; animation-delay: var(--d, 0ms); }
305
+ .fresh .grow-y { animation: vzgrowy .55s cubic-bezier(.2, .8, .2, 1) both; animation-delay: var(--d, 0ms); }
306
+ .fresh .grow-o { animation: vzfade .5s ease both; animation-delay: var(--d, 0ms); }
307
+ @keyframes vzgrow { from { transform: scaleX(0); } }
308
+ @keyframes vzgrowy { from { transform: scaleY(0); } }
309
+ @keyframes vzfade { from { opacity: 0; } }
310
+ @media (prefers-reduced-motion: reduce) { .fresh .grow, .fresh .grow-y, .fresh .grow-o { animation: none; } }
311
+ .legend { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-bottom: 10px; }
312
+ .lg { display: inline-flex; align-items: center; gap: 7px; background: none; border: 0; padding: 2px 0; cursor: pointer; font-size: 14px; }
313
+ .lg i { width: 12px; height: 12px; border-radius: 2px; }
314
+ .lg.sel { font-weight: 700; text-decoration: underline; text-underline-offset: 3px; }
315
+ .axisline { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-top: 8px; color: var(--slate); font-size: 13.5px; }
316
+ .linkish.sm { font-size: 13.5px; }
317
+ .vnote { margin-top: 8px; max-width: 70ch; }
318
+ .vtools { display: flex; gap: 18px; margin-top: 14px; flex-wrap: wrap; }
319
+ .glance { margin-top: 30px; }
320
+ .glance > h3 { font-size: 15px; font-weight: 600; }
321
+ .gl-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 22px 30px; margin-top: 10px; }
322
+ .gl { border-top: 1px solid var(--ink); padding-top: 10px; min-width: 0; }
323
+ .gl > b { font-size: 14.5px; }
324
+ .drawer { margin-top: 28px; border-top: 3px solid var(--pink); padding-top: 14px; }
325
+ .drawhead { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; align-items: center; }
326
+ .btn.sm { padding: 7px 13px; font-size: 14px; }
327
+ .tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 6px; }
328
+ .tagc { display: inline-block; background: var(--paper-2); border: 1px solid var(--rule); border-radius: 2px; padding: 1px 7px; font-size: 12.5px; color: var(--slate); }
329
+ .tagc.made { border-color: var(--pink); color: var(--ink); }
330
+ .sheet .tagc { margin: 0 2px 4px 0; }
331
+ @media (max-width: 640px) { .vsrcs { grid-template-columns: 1fr; } textarea.in.vq { font-size: 17px; } }
static/app.js CHANGED
@@ -180,7 +180,7 @@ function headParts() {
180
  count: `<span class="big">${n.toLocaleString()}</span><span class="of">of ${t.toLocaleString()} rows</span>`, pct,
181
  state: `${j ? esc(j.message) : "Starting"}${j && !fin ? ` Elapsed ${Math.round(j.elapsed)} s.` : ""}`,
182
  note: j && j.state === "failed" ? `<p class="note bad">${esc(j.message)}</p>` : j && j.state === "done" && j.count < j.target ? `<p class="note">${esc(j.message)}</p>` : "",
183
- actions: fin ? `${j.count ? `<a class="btn go" href="/api/jobs/${j.id}/download?format=csv" style="text-decoration:none">Download CSV</a><a class="btn" href="/api/jobs/${j.id}/download?format=jsonl" style="text-decoration:none">Download JSONL</a>` : ""}<button class="btn" data-act="again">Start another</button>` : `<button class="btn" data-act="stop">Stop and keep what is written</button>`,
184
  stats: st ? `<div class="stats"><div><b>${st.rows.toLocaleString()}</b><span>rows</span></div><div><b>${st.distinct3_prompts}</b><span>distinct 3-grams in prompts</span></div><div><b>${st.avg_prompt_words}</b><span>words per prompt</span></div><div><b>${st.avg_answer_words}</b><span>words per reply</span></div></div>` : "",
185
  filtered: j && j.rejected && Object.keys(j.rejected).length ? `<p class="hint" style="margin-top:12px">Filtered out: ${Object.entries(j.rejected).map(([k, v]) => `${v} ${esc(k)}`).join(", ")}.</p>` : "",
186
  };
@@ -209,7 +209,7 @@ function updateRun() {
209
 
210
  /* ------------------------------------------------------------ render + events */
211
  function render() {
212
- if (window.DI_MODE === "curate") return;
213
  renderLedger(); renderSheet();
214
  const fn = [stepFormat, stepDomains, stepSubs, stepLangs, stepSummary, stepReview, stepRun][S.step];
215
  const keep = document.activeElement && document.activeElement.id;
@@ -231,6 +231,7 @@ document.addEventListener("click", async (ev) => {
231
  if (act === "blueprint") return doBlueprint();
232
  if (act === "publish") return doPublish();
233
  if (act === "signout") { await api("/api/signout", {}); S.me = { ...S.me, user: null }; return refreshPublish(); }
 
234
  if (act === "stop") { if (S.job) await api(`/api/jobs/${S.job}/stop`, {}); return; }
235
  if (act === "again") { S.job = null; S.jobState = null; S.jobRows = []; S.drawn = 0; S.printed = 0; S.pubShown = false; S.pub = { name: "", priv: true, license: "apache-2.0", ok: false, busy: false, err: "", url: "" }; S.runError = ""; return go(4); }
236
  });
@@ -264,6 +265,7 @@ async function poll() {
264
  } catch (e) { console.error(e.stack); S.jobState = { state: "failed", message: e.message, count: S.jobRows.length, target: S.rows, elapsed: 0, id }; if (S.step === 6) render(); }
265
  }
266
  window.DI_renderInvent = () => render();
 
267
  /* "Start writing" on the review step starts the run */
268
  const _go = go;
269
  go = function (i) { const wasReview = S.step === 5; _go(i); if (i === 6 && wasReview && !S.jobState) startRun(); return true; };
 
180
  count: `<span class="big">${n.toLocaleString()}</span><span class="of">of ${t.toLocaleString()} rows</span>`, pct,
181
  state: `${j ? esc(j.message) : "Starting"}${j && !fin ? ` Elapsed ${Math.round(j.elapsed)} s.` : ""}`,
182
  note: j && j.state === "failed" ? `<p class="note bad">${esc(j.message)}</p>` : j && j.state === "done" && j.count < j.target ? `<p class="note">${esc(j.message)}</p>` : "",
183
+ actions: fin ? `${j.count ? `<a class="btn go" href="/api/jobs/${j.id}/download?format=csv" style="text-decoration:none">Download CSV</a><a class="btn" href="/api/jobs/${j.id}/download?format=jsonl" style="text-decoration:none">Download JSONL</a>` : ""}${j.count ? `<button class="btn" data-act="visualize">Visualize these rows</button>` : ""}<button class="btn" data-act="again">Start another</button>` : `<button class="btn" data-act="stop">Stop and keep what is written</button>`,
184
  stats: st ? `<div class="stats"><div><b>${st.rows.toLocaleString()}</b><span>rows</span></div><div><b>${st.distinct3_prompts}</b><span>distinct 3-grams in prompts</span></div><div><b>${st.avg_prompt_words}</b><span>words per prompt</span></div><div><b>${st.avg_answer_words}</b><span>words per reply</span></div></div>` : "",
185
  filtered: j && j.rejected && Object.keys(j.rejected).length ? `<p class="hint" style="margin-top:12px">Filtered out: ${Object.entries(j.rejected).map(([k, v]) => `${v} ${esc(k)}`).join(", ")}.</p>` : "",
186
  };
 
209
 
210
  /* ------------------------------------------------------------ render + events */
211
  function render() {
212
+ if (window.DI_MODE !== "invent") return;
213
  renderLedger(); renderSheet();
214
  const fn = [stepFormat, stepDomains, stepSubs, stepLangs, stepSummary, stepReview, stepRun][S.step];
215
  const keep = document.activeElement && document.activeElement.id;
 
231
  if (act === "blueprint") return doBlueprint();
232
  if (act === "publish") return doPublish();
233
  if (act === "signout") { await api("/api/signout", {}); S.me = { ...S.me, user: null }; return refreshPublish(); }
234
+ if (act === "visualize") { if (S.job && window.DI_visualizeJob) window.DI_visualizeJob(S.job); return; }
235
  if (act === "stop") { if (S.job) await api(`/api/jobs/${S.job}/stop`, {}); return; }
236
  if (act === "again") { S.job = null; S.jobState = null; S.jobRows = []; S.drawn = 0; S.printed = 0; S.pubShown = false; S.pub = { name: "", priv: true, license: "apache-2.0", ok: false, busy: false, err: "", url: "" }; S.runError = ""; return go(4); }
237
  });
 
265
  } catch (e) { console.error(e.stack); S.jobState = { state: "failed", message: e.message, count: S.jobRows.length, target: S.rows, elapsed: 0, id }; if (S.step === 6) render(); }
266
  }
267
  window.DI_renderInvent = () => render();
268
+ window.DI_lastJob = () => (S.job && isFinal(S.jobState) && S.jobState.count ? { id: S.job, n: S.jobState.count, request: S.request || "" } : null);
269
  /* "Start writing" on the review step starts the run */
270
  const _go = go;
271
  go = function (i) { const wasReview = S.step === 5; _go(i); if (i === 6 && wasReview && !S.jobState) startRun(); return true; };
static/curate.js CHANGED
@@ -357,15 +357,23 @@ datainventor run recipe.yaml --out ./curated --procs 32 --workers 64</pre></div>
357
  document.addEventListener("keydown", (ev) => { if (window.DI_MODE === "curate" && ev.key === "Enter" && ev.target.id === "c-add" && C.add.trim()) addDataset(C.add.trim(), C.addRole); });
358
 
359
  /* ------------------------------------------------------------ mode switch */
 
 
 
 
 
360
  function setMode(mode) {
361
  window.DI_MODE = mode;
362
  document.querySelectorAll("[data-mode]").forEach((b) => b.setAttribute("aria-pressed", String(b.dataset.mode === mode)));
363
- $(".tag").textContent = mode === "curate" ? "Clean, score, rephrase and mix the pretraining data you already have. Preview it here on a sample, then run the recipe on your own machines." : "Describe the data you wish existed. Get training rows back. Bring any OpenAI-compatible model.";
364
  if (mode === "curate") { if (!C.d) api("/api/curate/defaults").then((d) => { C.d = d; render(); }).catch(() => render()); render(); }
 
365
  else if (window.DI_renderInvent) window.DI_renderInvent();
366
- if ((location.hash === "#curate") !== (mode === "curate")) history.replaceState(null, "", mode === "curate" ? "#curate" : location.pathname + location.search);
 
367
  }
 
368
  document.addEventListener("click", (ev) => { const b = ev.target.closest("[data-mode]"); if (b) setMode(b.dataset.mode); });
369
- window.DI_MODE = location.hash === "#curate" ? "curate" : "invent";
370
- if (window.DI_MODE === "curate") document.addEventListener("DOMContentLoaded", () => setMode("curate"));
371
  })();
 
357
  document.addEventListener("keydown", (ev) => { if (window.DI_MODE === "curate" && ev.key === "Enter" && ev.target.id === "c-add" && C.add.trim()) addDataset(C.add.trim(), C.addRole); });
358
 
359
  /* ------------------------------------------------------------ mode switch */
360
+ const TAGS = {
361
+ invent: "Describe the data you wish existed. Get training rows back. Bring any OpenAI-compatible model.",
362
+ curate: "Clean, score, rephrase and mix the pretraining data you already have. Preview it here on a sample, then run the recipe on your own machines.",
363
+ visualize: "Ask a question about a dataset and get a chart back, with the rows behind every bar one click away.",
364
+ };
365
  function setMode(mode) {
366
  window.DI_MODE = mode;
367
  document.querySelectorAll("[data-mode]").forEach((b) => b.setAttribute("aria-pressed", String(b.dataset.mode === mode)));
368
+ $(".tag").textContent = TAGS[mode] || TAGS.invent;
369
  if (mode === "curate") { if (!C.d) api("/api/curate/defaults").then((d) => { C.d = d; render(); }).catch(() => render()); render(); }
370
+ else if (mode === "visualize") { if (window.DI_renderVis) window.DI_renderVis(); }
371
  else if (window.DI_renderInvent) window.DI_renderInvent();
372
+ const hash = mode === "invent" ? "" : "#" + mode;
373
+ if (location.hash !== hash) history.replaceState(null, "", hash || location.pathname + location.search);
374
  }
375
+ window.DI_setMode = setMode;
376
  document.addEventListener("click", (ev) => { const b = ev.target.closest("[data-mode]"); if (b) setMode(b.dataset.mode); });
377
+ window.DI_MODE = location.hash === "#curate" ? "curate" : location.hash === "#visualize" ? "visualize" : "invent";
378
+ if (window.DI_MODE !== "invent") document.addEventListener("DOMContentLoaded", () => setMode(window.DI_MODE));
379
  })();
static/index.html CHANGED
@@ -4,7 +4,7 @@
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
  <title>DataInventor</title>
7
- <meta name="description" content="Describe a dataset in plain words and get training data back, or clean, score, rephrase and mix the pretraining data you have. Open source, works with any OpenAI-compatible model.">
8
  <link rel="preconnect" href="https://fonts.googleapis.com">
9
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
  <link href="https://fonts.googleapis.com/css2?family=Familjen+Grotesk:ital,wght@0,400..700;1,400..700&family=Literata:ital,opsz,wght@0,7..72,400..600;1,7..72,400..600&display=swap" rel="stylesheet">
@@ -17,7 +17,7 @@
17
  <span class="mark" aria-hidden="true"><i></i><i></i></span>
18
  <span class="name">DataInventor</span>
19
  </div>
20
- <nav class="modes" aria-label="Mode"><button data-mode="invent" aria-pressed="true">Invent rows</button><button data-mode="curate" aria-pressed="false">Curate a corpus</button></nav>
21
  <p class="tag">Describe the data you wish existed. Get training rows back. Bring any OpenAI-compatible model.</p>
22
  </header>
23
 
@@ -29,5 +29,6 @@
29
 
30
  <script src="/static/curate.js"></script>
31
  <script src="/static/app.js"></script>
 
32
  </body>
33
  </html>
 
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
  <title>DataInventor</title>
7
+ <meta name="description" content="Describe a dataset in plain words and get training data back, clean, score, rephrase and mix the pretraining data you have, or ask a dataset a question and get a chart. Open source, works with any OpenAI-compatible model.">
8
  <link rel="preconnect" href="https://fonts.googleapis.com">
9
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
  <link href="https://fonts.googleapis.com/css2?family=Familjen+Grotesk:ital,wght@0,400..700;1,400..700&family=Literata:ital,opsz,wght@0,7..72,400..600;1,7..72,400..600&display=swap" rel="stylesheet">
 
17
  <span class="mark" aria-hidden="true"><i></i><i></i></span>
18
  <span class="name">DataInventor</span>
19
  </div>
20
+ <nav class="modes" aria-label="Mode"><button data-mode="invent" aria-pressed="true">Invent rows</button><button data-mode="curate" aria-pressed="false">Curate a corpus</button><button data-mode="visualize" aria-pressed="false">Visualize</button></nav>
21
  <p class="tag">Describe the data you wish existed. Get training rows back. Bring any OpenAI-compatible model.</p>
22
  </header>
23
 
 
29
 
30
  <script src="/static/curate.js"></script>
31
  <script src="/static/app.js"></script>
32
+ <script src="/static/visualize.js"></script>
33
  </body>
34
  </html>
static/visualize.js ADDED
@@ -0,0 +1,382 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use strict";
2
+ /* Visualize: ask a question about a dataset, get a chart, and open the rows behind any mark.
3
+ Built for the small datasets a hosted run writes, so every chart is exact and instant views need no model. */
4
+ (function () {
5
+ const INKS = ["#3a4bff", "#ff5c8f", "#00838a", "#ffb000", "#ff6c2f", "#765ba7"];
6
+ const OTHER = "#8a8fa0";
7
+ const SIZES = [25, 50, 100, 200];
8
+ const STAGES = [["plan", "Planning the chart"], ["label", "Reading the rows"], ["chart", "Counting"], ["write", "Writing it up"]];
9
+ const V = {
10
+ data: null, rows: [], byId: {}, samples: [], maxRows: 200, n: 50, hub: "", loadBusy: "", loadErr: "",
11
+ q: "", asking: null, askErr: "", history: [], cur: -1, scope: null, sel: null, selShow: 30, sorts: {}, drawn: {},
12
+ };
13
+ const $ = (s, el = document) => el.querySelector(s);
14
+ const esc = (t) => String(t ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
15
+ const on = () => window.DI_MODE === "visualize";
16
+ const plural = (n, w) => `${n.toLocaleString()} ${w}${n === 1 ? "" : "s"}`;
17
+ const fmt = (x) => { if (x == null || isNaN(x)) return ""; const a = Math.abs(x); return a >= 1000 ? Math.round(x).toLocaleString() : a >= 10 ? String(Math.round(x * 10) / 10) : String(Math.round(x * 100) / 100); };
18
+ const reduced = () => window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
19
+ async function api(path, body) {
20
+ const r = await fetch(path, { method: body ? "POST" : "GET", headers: body ? { "Content-Type": "application/json" } : {}, body: body ? JSON.stringify(body) : undefined });
21
+ let data = null; try { data = await r.json(); } catch (e) { /* not json */ }
22
+ if (!r.ok) throw new Error((data && (data.detail || data.message)) || `Request failed (${r.status})`);
23
+ return data;
24
+ }
25
+ const cols = () => (V.data ? V.data.columns : []);
26
+ const colType = (name) => (cols().find((c) => c.name === name) || {}).type;
27
+ const textCols = () => cols().filter((c) => c.type === "text").map((c) => c.name);
28
+ const catCols = () => cols().filter((c) => c.type === "cat").map((c) => c.name);
29
+
30
+ /* ------------------------------------------------------------ charts (hand-set SVG, two inks) */
31
+ function niceTicks(lo, hi, want = 5) {
32
+ if (!(hi > lo)) { hi = lo + 1; }
33
+ const raw = (hi - lo) / want, e = Math.pow(10, Math.floor(Math.log10(raw)));
34
+ const step = [1, 2, 2.5, 5, 10].map((m) => m * e).find((s) => s >= raw) || 10 * e;
35
+ const out = []; for (let v = Math.floor(lo / step) * step; v <= hi + step * 1e-9; v += step) out.push(+v.toFixed(10));
36
+ if (out[out.length - 1] < hi) out.push(out[out.length - 1] + step);
37
+ return out;
38
+ }
39
+ function labelW(keys, w) { const longest = Math.max(4, ...keys.map((k) => String(k).length)); return Math.min(Math.round(w * 0.38), Math.max(70, longest * 7.4 + 16)); }
40
+ /* narrow charts set each label above its bar instead of clipping it beside the bar */
41
+ function narrow(marks, w) { const lw = labelW(marks.map((m) => m.key), w); const longest = Math.max(0, ...marks.map((m) => String(m.key).length)); return w < 560 && longest * 7.4 + 16 > lw; }
42
+ function labText(key, lw, y, nar, w) {
43
+ return nar ? `<text class="lab" x="0" y="${y + 13}">${esc(clip(key, w - 10))}</text>` : `<text class="lab" x="${lw - 10}" y="${y + 19}" text-anchor="end">${esc(clip(key, lw - 12))}</text>`;
44
+ }
45
+ function clip(t, px) { const max = Math.max(4, Math.floor(px / 7.1)); t = String(t); return t.length > max ? t.slice(0, max - 1) + "…" : t; }
46
+ function colorMap(keys) { const m = {}; let i = 0; keys.forEach((k) => { m[k] = /^Other \(|^\(blank\)$|^unclear$/.test(k) ? OTHER : INKS[i++ % INKS.length]; }); return m; }
47
+ function mark(attrs, label, ids, inner) {
48
+ return `<g class="mk" tabindex="0" role="button" aria-label="${esc(label)}" data-vzsel="${esc(label.split(": ")[0])}" data-vzids="${ids.join(",")}" ${attrs}>${inner}</g>`;
49
+ }
50
+ function sorted(c, key) {
51
+ const how = V.sorts[key] || "value";
52
+ const m = (c.marks || []).slice();
53
+ if (how === "name") m.sort((a, b) => String(a.key).localeCompare(String(b.key), undefined, { numeric: true }));
54
+ return m;
55
+ }
56
+ function svgBar(c, w, key) {
57
+ const marks = sorted(c, key), nar = narrow(marks, w), row = nar ? 42 : 30, top = 6, lw = nar ? 0 : labelW(marks.map((m) => m.key), w), vw = 54, by = nar ? 14 : 0;
58
+ const bw = Math.max(60, w - lw - vw - 12), max = Math.max(...marks.map((m) => m.value), 1), h = top + marks.length * row + 4;
59
+ const yName = c.agg === "count" ? "rows" : `${c.agg} ${c.y}`;
60
+ const body = marks.map((m, i) => {
61
+ const y = top + i * row, len = Math.max(2, (m.value / max) * bw), lab = c.agg === "count" ? plural(m.value, "row") : `${fmt(m.value)} (${plural(m.n, "row")})`;
62
+ return mark(`style="--d:${i * 40}ms"`, `${c.x} = ${m.key}: ${lab}`, m.ids,
63
+ `${labText(m.key, lw, y, nar, w)}
64
+ <g class="grow" style="transform-origin:${lw}px 0"><rect class="ghost" x="${lw + 3}" y="${y + by + 7}" width="${len}" height="19"></rect>
65
+ <rect class="ink" x="${lw}" y="${y + by + 4}" width="${len}" height="19"></rect></g>
66
+ <text class="val" x="${lw + len + 8}" y="${y + by + 19}">${fmt(m.value)}</text><rect class="hit" x="0" y="${y}" width="${w}" height="${row}"></rect>`);
67
+ }).join("");
68
+ return { svg: `<svg viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" role="img" aria-label="Bar chart of ${esc(yName)} by ${esc(c.x)}">${body}</svg>`, axis: `${c.x} · ${yName}` };
69
+ }
70
+ function svgStack(c, w, key) {
71
+ const marks = sorted(c, key), nar = narrow(marks, w), row = nar ? 42 : 30, top = 6, lw = nar ? 0 : labelW(marks.map((m) => m.key), w), vw = 46, by = nar ? 14 : 0;
72
+ const bw = Math.max(60, w - lw - vw - 10), share = c.agg !== "count", maxN = Math.max(...marks.map((m) => m.n), 1), cm = colorMap(c.colors || []);
73
+ const h = top + marks.length * row + 4;
74
+ const body = marks.map((m, i) => {
75
+ const y = top + i * row, scale = share ? bw / Math.max(1, m.n) : bw / maxN; let x = lw;
76
+ const segs = m.parts.map((p) => {
77
+ const sw = p.value * scale, pct = Math.round((100 * p.value) / m.n);
78
+ const g = mark("", `${c.x} = ${m.key}, ${c.color} = ${p.key}: ${plural(p.value, "row")} (${pct}%)`, p.ids,
79
+ `<rect class="seg" x="${x}" y="${y + by + 4}" width="${Math.max(1, sw - 1)}" height="19" fill="${cm[p.key]}"></rect>${sw > 30 ? `<text class="segv" x="${x + 5}" y="${y + by + 17.5}">${share ? pct + "%" : p.value}</text>` : ""}`);
80
+ x += sw; return g;
81
+ }).join("");
82
+ return `<g class="grow" style="--d:${i * 40}ms;transform-origin:${lw}px 0">${mark("", `${c.x} = ${m.key}: ${plural(m.n, "row")}`, m.ids, `${labText(m.key, lw, y, nar, w)}<rect class="hit" x="0" y="${y}" width="${nar ? w : lw}" height="${nar ? 15 : row}"></rect>`)}${segs}<text class="val" x="${lw + (share ? bw : m.n * scale) + 8}" y="${y + by + 19}">${m.n}</text></g>`;
83
+ }).join("");
84
+ const legend = (c.colors || []).map((k) => {
85
+ const ids = marks.flatMap((m) => (m.parts.find((p) => p.key === k) || { ids: [] }).ids);
86
+ return `<button class="lg" data-vzsel="${esc(`${c.color} = ${k}`)}" data-vzids="${ids.join(",")}"><i style="background:${cm[k]}"></i>${esc(k)}</button>`;
87
+ }).join("");
88
+ return { svg: `<svg viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" role="img" aria-label="Stacked bars of ${esc(c.color)} within each ${esc(c.x)}">${body}</svg>`, legend, axis: `${c.x} · ${share ? "share of rows" : "rows"}, split by ${c.color}` };
89
+ }
90
+ function jitter(id) { const s = Math.sin(id * 12.9898) * 43758.5453; return s - Math.floor(s) - 0.5; }
91
+ function svgBox(c, w, key) {
92
+ const marks = sorted(c, key), nar = c.x && narrow(marks, w), row = nar ? 48 : 38, top = 6, lw = c.x && !nar ? labelW(marks.map((m) => m.key), w) : 8, bw = Math.max(80, w - lw - 16), by = nar ? 8 : 0;
93
+ const all = marks.flatMap((m) => m.points.map((p) => p[1])), ticks = niceTicks(Math.min(...all), Math.max(...all));
94
+ const lo = ticks[0], hi = ticks[ticks.length - 1], X = (v) => lw + ((v - lo) / (hi - lo || 1)) * bw, h = top + marks.length * row + 26;
95
+ const grid = ticks.map((t) => `<line class="grid" x1="${X(t)}" x2="${X(t)}" y1="${top}" y2="${h - 22}"></line><text class="tick" x="${X(t)}" y="${h - 6}" text-anchor="middle">${fmt(t)}</text>`).join("");
96
+ const body = marks.map((m, i) => {
97
+ const y = top + i * row, mid = y + row / 2 - 2 + by, thin = m.n < 3 ? " thin" : "";
98
+ const dots = m.points.map(([id, v]) => `<circle class="dot mk1" cx="${X(v)}" cy="${mid + jitter(id) * (row - 16)}" r="3.4" data-vzsel="row ${id}" data-vzids="${id}" tabindex="-1"><title>Row ${id}: ${fmt(v)}</title></circle>`).join("");
99
+ return `<g class="grow-o${thin}" style="--d:${i * 50}ms">${mark("", `${c.x ? `${c.x} = ${m.key}: ` : ""}median ${fmt(m.median)}, middle half ${fmt(m.q1)} to ${fmt(m.q3)}, ${plural(m.n, "row")}`, m.ids,
100
+ `${c.x ? (nar ? labText(m.key, 0, y - 2, true, w) : `<text class="lab" x="${lw - 10}" y="${mid + 4}" text-anchor="end">${esc(clip(m.key, lw - 12))}</text>`) : ""}${m.n >= 4 ? `<line class="whisk" x1="${X(m.lo)}" x2="${X(m.hi)}" y1="${mid}" y2="${mid}"></line>
101
+ <rect class="bx" x="${X(m.q1)}" y="${mid - 9}" width="${Math.max(2, X(m.q3) - X(m.q1))}" height="18"></rect>` : ""}<rect class="hit" x="0" y="${nar ? y - 2 : y}" width="${nar ? w : lw}" height="${nar ? 14 : row}"></rect>`)}
102
+ ${dots}<line class="med" x1="${X(m.median)}" x2="${X(m.median)}" y1="${mid - 11}" y2="${mid + 11}"></line></g>`;
103
+ }).join("");
104
+ return { svg: `<svg viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" role="img" aria-label="Box plot of ${esc(c.y)}${c.x ? ` by ${esc(c.x)}` : ""}">${grid}${body}</svg>`, axis: `${c.y}${c.x ? ` by ${c.x}` : ""} · each dot is a row` };
105
+ }
106
+ function svgHist(c, w) {
107
+ const marks = c.marks || [], h = 210, left = 34, bottom = 26, top = 8, bw = (w - left - 8) / Math.max(1, marks.length);
108
+ const max = Math.max(...marks.map((m) => m.value), 1), yt = niceTicks(0, max, 4), Y = (v) => top + (1 - v / yt[yt.length - 1]) * (h - top - bottom);
109
+ const grid = yt.map((t) => `<line class="grid" x1="${left}" x2="${w - 4}" y1="${Y(t)}" y2="${Y(t)}"></line><text class="tick" x="${left - 6}" y="${Y(t) + 4}" text-anchor="end">${fmt(t)}</text>`).join("");
110
+ const body = marks.map((m, i) => {
111
+ const x = left + i * bw, y = Y(m.value);
112
+ return mark(`style="--d:${i * 35}ms"`, c.discrete ? `${c.x} = ${fmt(m.x0)}: ${plural(m.value, "row")}` : `${fmt(m.x0)} to ${fmt(m.x1)}: ${plural(m.value, "row")}`, m.ids,
113
+ `<g class="grow-y" style="transform-origin:0 ${h - bottom}px"><rect class="ghost" x="${x + 4}" y="${y + 3}" width="${Math.max(1, bw - 4)}" height="${Math.max(0, h - bottom - y)}"></rect>
114
+ <rect class="ink" x="${x + 1}" y="${y}" width="${Math.max(1, bw - 4)}" height="${Math.max(0, h - bottom - y)}"></rect></g>
115
+ <rect class="hit" x="${x}" y="${top}" width="${bw}" height="${h - top - bottom}"></rect>`);
116
+ }).join("");
117
+ const every = Math.ceil(marks.length / Math.max(2, Math.floor((w - left) / 52)));
118
+ const xl = c.discrete ? marks.map((m, i) => `<text class="tick" x="${left + i * bw + bw / 2}" y="${h - 8}" text-anchor="middle">${fmt(m.x0)}</text>`).join("") :
119
+ marks.map((m, i) => (i % every === 0 ? `<text class="tick" x="${left + i * bw}" y="${h - 8}" text-anchor="middle">${fmt(m.x0)}</text>` : "")).join("") +
120
+ (marks.length ? `<text class="tick" x="${left + marks.length * bw}" y="${h - 8}" text-anchor="end">${fmt(marks[marks.length - 1].x1)}</text>` : "");
121
+ return { svg: `<svg viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" role="img" aria-label="Histogram of ${esc(c.x)}">${grid}${body}${xl}</svg>`, axis: `${c.x} · ${c.discrete ? "rows at each value" : "rows in each range"}` };
122
+ }
123
+ function svgScatter(c, w) {
124
+ const pts = c.marks || [], h = Math.min(360, Math.max(240, w * 0.55)), left = 40, bottom = 26, top = 8;
125
+ const xt = niceTicks(Math.min(...pts.map((p) => p.x)), Math.max(...pts.map((p) => p.x))), yt = niceTicks(Math.min(...pts.map((p) => p.y)), Math.max(...pts.map((p) => p.y)), 4);
126
+ const X = (v) => left + ((v - xt[0]) / (xt[xt.length - 1] - xt[0] || 1)) * (w - left - 10), Y = (v) => top + (1 - (v - yt[0]) / (yt[yt.length - 1] - yt[0] || 1)) * (h - top - bottom);
127
+ const cm = colorMap(c.colors || []);
128
+ const grid = xt.map((t) => `<line class="grid" x1="${X(t)}" x2="${X(t)}" y1="${top}" y2="${h - bottom}"></line><text class="tick" x="${X(t)}" y="${h - 8}" text-anchor="middle">${fmt(t)}</text>`).join("") +
129
+ yt.map((t) => `<line class="grid" x1="${left}" x2="${w - 10}" y1="${Y(t)}" y2="${Y(t)}"></line><text class="tick" x="${left - 6}" y="${Y(t) + 4}" text-anchor="end">${fmt(t)}</text>`).join("");
130
+ const dots = pts.map((p) => `<circle class="dot mk1" cx="${X(p.x)}" cy="${Y(p.y)}" r="4.2" style="${p.c ? `fill:${cm[p.c]}` : ""}" data-vzsel="row ${p.id}" data-vzids="${p.id}" tabindex="0" role="button" aria-label="Row ${p.id}: ${esc(c.x)} ${fmt(p.x)}, ${esc(c.y)} ${fmt(p.y)}"><title>Row ${p.id}</title></circle>`).join("");
131
+ const legend = (c.colors || []).map((k) => `<button class="lg" data-vzsel="${esc(`${c.color} = ${k}`)}" data-vzids="${pts.filter((p) => p.c === k).map((p) => p.id).join(",")}"><i style="background:${cm[k]}"></i>${esc(k)}</button>`).join("");
132
+ return { svg: `<svg viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" role="img" aria-label="Scatter of ${esc(c.y)} against ${esc(c.x)}">${grid}${dots}</svg>`, legend, axis: `${c.x} → · ${c.y} ↑${c.r !== undefined ? ` · r = ${c.r}` : ""}` };
133
+ }
134
+ function chartHtml(c, w, key, fresh) {
135
+ if (!c || !(c.marks || []).length) return `<p class="hint">Nothing to draw: no rows have values for this.</p>`;
136
+ const f = { bar: svgBar, stack: svgStack, box: svgBox, hist: svgHist, scatter: svgScatter }[c.type];
137
+ const out = f(c, Math.max(260, Math.floor(w)), key);
138
+ const sortable = ["bar", "stack", "box"].includes(c.type) && (c.marks || []).length > 2;
139
+ return `${out.legend ? `<div class="legend">${out.legend}</div>` : ""}<div class="svgwrap${fresh ? " fresh" : ""}">${out.svg}</div>
140
+ <div class="axisline"><span>${esc(out.axis)}</span>${sortable ? `<button class="linkish sm" data-vzsort="${esc(key)}">${(V.sorts[key] || "value") === "value" ? "Sort by name" : "Sort by size"}</button>` : ""}</div>`;
141
+ }
142
+
143
+ /* ------------------------------------------------------------ pages */
144
+ function pickHtml() {
145
+ const job = window.DI_lastJob && window.DI_lastJob();
146
+ const card = (title, body) => `<div class="vsrc"><b>${title}</b>${body}</div>`;
147
+ const sizes = `<div class="seg" role="group" aria-label="Rows to load">${SIZES.filter((n) => n <= V.maxRows).map((n) => `<button data-vzn="${n}" aria-pressed="${V.n === n}">${n}</button>`).join("")}</div>`;
148
+ return `<h1>Ask your data a question</h1>
149
+ <p class="lede">Get a chart back, with the rows behind every bar one click away. It is built for the small datasets you write here: every number is exact, and the first charts need no model at all.</p>
150
+ ${V.loadErr ? `<p class="note bad">${esc(V.loadErr)}</p>` : ""}
151
+ <div class="vsrcs">
152
+ ${job ? card("The rows you just wrote", `<p class="hint">${plural(job.n, "row")} from “${esc(job.request.slice(0, 90))}”</p><div class="actions"><button class="btn go" data-vz="load-job" ${V.loadBusy ? "disabled" : ""}>${V.loadBusy === "job" ? "Loading" : "Look at them"}</button></div>`) : ""}
153
+ ${card("A sample dataset", `<p class="hint">Written with DataInventor and published on Hugging Face. Pick how many rows to load.</p>${sizes}
154
+ <ul class="vsamples">${V.samples.map((s) => `<li><button class="vsample" data-vzsample="${esc(s.key)}" ${V.loadBusy ? "disabled" : ""}>${esc(s.title)}${V.loadBusy === s.key ? " <em>loading</em>" : ""}</button></li>`).join("")}</ul>`)}
155
+ ${card("A Hugging Face dataset", `<p class="hint">Any public dataset the viewer can read. A spread of rows is loaded, not the first page.</p>
156
+ <div class="addrow"><input class="in" id="vz-hub" placeholder="owner/name" value="${esc(V.hub)}" spellcheck="false" autocomplete="off"><button class="btn" data-vz="load-hub" ${V.loadBusy || !V.hub.trim() ? "disabled" : ""}>${V.loadBusy === "hub" ? "Loading" : "Load"}</button></div>`)}
157
+ ${card("A file", `<p class="hint">CSV with a header row, JSONL, or a JSON list of objects. Up to ${V.maxRows} rows are used.</p>
158
+ <label class="btn filebtn"><input type="file" id="vz-file" accept=".csv,.jsonl,.json,.txt" ${V.loadBusy ? "disabled" : ""}>${V.loadBusy === "file" ? "Reading" : "Choose a file"}</label>`)}
159
+ </div>`;
160
+ }
161
+ function stagesHtml() {
162
+ const a = V.asking; if (!a) return "";
163
+ const idx = STAGES.findIndex(([k]) => k === a.stage);
164
+ const items = STAGES.filter(([k]) => k !== "label" || a.total).map(([k, name]) => {
165
+ const i = STAGES.findIndex(([x]) => x === k), cls = i < idx ? "done" : i === idx ? "on" : "";
166
+ const extra = k === "label" && a.total ? ` ${a.done} of ${a.total}` : "";
167
+ return `<li class="${cls}">${name}${extra}</li>`;
168
+ }).join("");
169
+ return `<ol class="stages vz-stages" aria-live="polite">${items}</ol>`;
170
+ }
171
+ function answerHtml() {
172
+ const h = V.history[V.cur]; if (!h) return "";
173
+ const r = h.result, key = `a${V.cur}`;
174
+ return `<article class="answer" id="vz-answer">
175
+ ${h.scopeLabel ? `<p class="hint">About ${esc(h.scopeLabel)}</p>` : ""}
176
+ <h2>${esc(r.title)}</h2>
177
+ ${r.summary ? `<p class="prose">${esc(r.summary)}</p>` : V.asking && V.asking.stage === "write" ? `<p class="prose hint">Writing it up…</p>` : `<ul class="facts">${(r.facts || []).slice(0, 6).map((f) => `<li>${esc(f)}</li>`).join("")}</ul>`}
178
+ <div class="chart" id="vz-chart" data-vzkey="${key}"></div>
179
+ ${(r.notes || []).map((n) => `<p class="hint vnote">${esc(n)}</p>`).join("")}
180
+ <div class="vtools"><button class="linkish sm" data-vz="svg">Download the chart</button>${r.chart.n ? `<a class="linkish sm" href="/api/vis/${V.data.id}/download?ids=${allIds(r.chart).join(",")}">Download its rows</a>` : ""}</div>
181
+ ${r.follow_ups && r.follow_ups.length ? `<div class="chips">${r.follow_ups.map((q) => `<button class="chip" data-vzq="${esc(q)}">${esc(q)}</button>`).join("")}</div>` : ""}
182
+ </article>`;
183
+ }
184
+ function allIds(c) {
185
+ if (c.type === "scatter") return c.marks.map((m) => m.id);
186
+ const s = new Set(); (c.marks || []).forEach((m) => (m.ids || []).forEach((i) => s.add(i))); return [...s];
187
+ }
188
+ function glanceHtml() {
189
+ const g = V.data.glance || []; if (!g.length) return "";
190
+ return `<section class="glance"><h3>At a glance</h3><div class="gl-grid">${g.map((c, i) => `<div class="gl"><b>${esc(c.title)}</b><div class="chart small" id="vz-g${i}" data-vzkey="g${i}"></div></div>`).join("")}</div></section>`;
191
+ }
192
+ function rowHtml(r) {
193
+ const long = cols().filter((c) => c.type === "text" && (c.avg_words || 0) >= 12).map((c) => c.name);
194
+ const short = textCols().filter((k) => !long.includes(k));
195
+ const head = long[0] || short[0], rest = long.slice(1);
196
+ const chips = short.filter((k) => k !== head).concat(catCols()).map((k) => r[k] ? `<span class="tagc${derivedCol(k) ? " made" : ""}" title="${esc(k)}">${esc(String(r[k]).slice(0, 80))}</span>` : "").join("");
197
+ const nums = cols().filter((c) => c.type === "num" && derivedCol(c.name) && r[c.name] != null).map((c) => `<span class="tagc made" title="${esc(c.name)}">${esc(c.name)} ${fmt(r[c.name])}</span>`).join("");
198
+ return `<div class="trow"><span class="id">${r._id}</span><div>${head ? `<div class="p">${esc(String(r[head] || "").slice(0, 320))}${String(r[head] || "").length > 320 ? "…" : ""}</div>` : ""}
199
+ <div class="tags">${chips}${nums}</div>
200
+ ${rest.map((k) => `<details><summary>Read the ${esc(k)}</summary><div class="full">${esc(r[k])}</div></details>`).join("")}
201
+ ${head && String(r[head] || "").length > 320 ? `<details><summary>Read the whole ${esc(head)}</summary><div class="full">${esc(r[head])}</div></details>` : ""}</div></div>`;
202
+ }
203
+ const derivedCol = (k) => { const c = cols().find((x) => x.name === k); return c && ["label", "score", "pattern", "count"].includes(c.derived); };
204
+ function selHtml() {
205
+ const s = V.sel; if (!s) return "";
206
+ const rows = s.ids.map((i) => V.byId[i]).filter(Boolean);
207
+ return `<section class="drawer" id="vz-sel" aria-label="Selected rows">
208
+ <div class="drawhead"><div><b>${plural(rows.length, "row")}</b> <span class="hint">${esc(s.label)}</span></div>
209
+ <div class="sctl">${rows.length > 1 ? `<button class="btn sm" data-vz="scope">Ask about these rows</button>` : ""}<a class="btn sm" href="/api/vis/${V.data.id}/download?ids=${s.ids.join(",")}" style="text-decoration:none">Download them</a><button class="linkish sm" data-vz="clear">Close</button></div></div>
210
+ <div class="table">${rows.slice(0, V.selShow).map(rowHtml).join("")}</div>
211
+ ${rows.length > V.selShow ? `<p style="margin-top:12px"><button class="linkish" data-vz="more">Show ${Math.min(30, rows.length - V.selShow)} more</button></p>` : ""}
212
+ </section>`;
213
+ }
214
+ function askHtml() {
215
+ const sug = V.history.length ? [] : V.data.suggestions || [];
216
+ const busy = !!V.asking;
217
+ return `<div class="askbar">
218
+ ${V.scope ? `<div class="scopechip">Asking about ${esc(V.scope.label)} (${plural(V.scope.ids.length, "row")}) <button class="linkish sm" data-vz="unscope" aria-label="Ask about all rows again">Use all rows</button></div>` : ""}
219
+ <label class="f" style="margin-top:0"><span class="vh">Your question</span>
220
+ <textarea class="in vq" id="vz-q" rows="2" placeholder="How long are the replies in each area?" ${busy ? "disabled" : ""}>${esc(V.q)}</textarea></label>
221
+ <div class="actions" style="margin-top:12px"><button class="btn go" data-vz="ask" ${busy || !V.q.trim() ? "disabled" : ""}>${busy ? "Working on it" : "Ask"}</button>${stagesHtml()}</div>
222
+ ${V.askErr ? `<p class="note bad">${esc(V.askErr)}</p>` : ""}
223
+ ${sug.length ? `<div class="chips">${sug.map((q) => `<button class="chip" data-vzq="${esc(q)}" ${busy ? "disabled" : ""}>${esc(q)}</button>`).join("")}</div>` : ""}
224
+ </div>`;
225
+ }
226
+ function mainHtml() {
227
+ return `${askHtml()}<div id="vz-out">${answerHtml()}</div>${V.cur < 0 ? glanceHtml() : ""}<div id="vz-selwrap">${selHtml()}</div>`;
228
+ }
229
+ function ledgerHtml() {
230
+ if (!V.data) return `<li class="on"><button aria-current="step"><span class="n">1</span><span>Pick the data</span></button></li><li><button disabled><span class="n">2</span><span>Ask</span></button></li>`;
231
+ const items = V.history.map((h, i) => `<li class="${i === V.cur ? "on" : "done"}"><button data-vzh="${i}" ${i === V.cur ? 'aria-current="true"' : ""}><span class="n">${i + 1}</span><span>${esc(h.q.length > 64 ? h.q.slice(0, 62) + "…" : h.q)}</span></button></li>`).join("");
232
+ return `<li class="${V.cur < 0 ? "on" : "done"}"><button data-vzh="-1"><span class="n">·</span><span>At a glance</span></button></li>${items}`;
233
+ }
234
+ function sheetHtml() {
235
+ if (!V.data) return `<h2>Your data</h2><p class="hint">Nothing loaded yet. Charts are drawn from every row you load, so a small sample is fast and exact.</p>`;
236
+ const d = V.data, group = (t, f) => { const list = cols().filter(f); return list.length ? `<div class="line"><dt>${t}</dt><dd>${list.map((c) => `<span class="tagc${derivedCol(c.name) ? " made" : ""}">${esc(c.name)}</span>`).join(" ")}</dd></div>` : ""; };
237
+ return `<h2>Your data</h2><dl>
238
+ <div class="line"><dt>Name</dt><dd>${esc(d.name)}</dd></div>
239
+ <div class="line"><dt>From</dt><dd>${/\//.test(d.source) ? `<a href="https://huggingface.co/datasets/${esc(d.source)}" target="_blank" rel="noopener">${esc(d.source)}</a>` : esc(d.source)}</dd></div>
240
+ <div class="line"><dt>Rows</dt><dd>${d.n.toLocaleString()}</dd></div>
241
+ ${group("Text", (c) => c.type === "text")}${group("Categories", (c) => c.type === "cat" && !derivedCol(c.name))}
242
+ ${group("Numbers", (c) => c.type === "num" && !derivedCol(c.name))}${group("Made by the model", (c) => derivedCol(c.name))}
243
+ </dl>${d.note ? `<p class="hint" style="margin-top:12px">${esc(d.note)}</p>` : ""}
244
+ <p class="hint" style="margin-top:14px">Columns the model makes stay here, so follow-up questions reuse them instead of reading every row again.</p>
245
+ <div class="actions" style="margin-top:18px"><button class="btn" data-vz="change">Use different data</button></div>`;
246
+ }
247
+ function render() {
248
+ if (!on()) return;
249
+ $("#steps").innerHTML = ledgerHtml();
250
+ $("#sheet").innerHTML = sheetHtml();
251
+ const keep = document.activeElement && document.activeElement.id;
252
+ $("#panel").innerHTML = V.data ? mainHtml() : pickHtml();
253
+ if (V.data) { redrawCharts(); markSelection(); }
254
+ if (keep) { const el = document.getElementById(keep); if (el) { el.focus(); if (el.setSelectionRange && el.tagName === "TEXTAREA") { const l = el.value.length; el.setSelectionRange(l, l); } } }
255
+ }
256
+ /* charts need their container's width, which exists only after the first paint */
257
+ function redrawCharts() {
258
+ document.querySelectorAll("[data-vzkey]").forEach((el) => {
259
+ const key = el.dataset.vzkey, c = key.startsWith("g") ? V.data.glance[+key.slice(1)] : (V.history[+key.slice(1)] || {}).result?.chart;
260
+ if (c) { el.innerHTML = chartHtml(c, el.clientWidth || 600, key, !V.drawn[key] && !reduced()); V.drawn[key] = true; }
261
+ });
262
+ markSelection();
263
+ }
264
+ function markSelection() {
265
+ const ids = V.sel ? new Set(V.sel.ids) : null;
266
+ document.querySelectorAll("[data-vzids]").forEach((el) => {
267
+ const mine = el.dataset.vzids.split(",").filter(Boolean).map(Number);
268
+ const hit = ids && mine.length && mine.every((i) => ids.has(i)) && (V.sel.label === el.dataset.vzsel || mine.length === 1 && ids.has(mine[0]));
269
+ el.classList.toggle("sel", !!hit);
270
+ el.classList.toggle("dim", !!ids && !hit && !el.classList.contains("lg") && !mine.some((i) => ids.has(i)));
271
+ });
272
+ }
273
+ function refreshAsk() { const el = $(".askbar"); if (el) { el.outerHTML = askHtml(); } }
274
+
275
+ /* ------------------------------------------------------------ actions */
276
+ async function loadData(body, busyKey) {
277
+ V.loadBusy = busyKey; V.loadErr = ""; render();
278
+ try {
279
+ const d = await api("/api/vis/load", body);
280
+ V.data = d; V.rows = d.rows; V.byId = Object.fromEntries(d.rows.map((r) => [r._id, r]));
281
+ V.history = []; V.cur = -1; V.sel = null; V.scope = null; V.q = ""; V.askErr = ""; V.sorts = {}; V.drawn = {};
282
+ } catch (e) { V.loadErr = e.message; }
283
+ V.loadBusy = ""; render(); window.scrollTo({ top: 0 });
284
+ }
285
+ async function ask(q) {
286
+ q = (q || "").trim(); if (!q || V.asking || !V.data) return;
287
+ V.q = q; V.askErr = ""; V.asking = { stage: "plan", done: 0, total: 0 }; refreshAsk();
288
+ const scope = V.scope;
289
+ try {
290
+ const { ask: aid } = await api(`/api/vis/${V.data.id}/ask`, { question: q, scope: scope ? scope.ids : [], scope_label: scope ? scope.label : "" });
291
+ let shown = false;
292
+ for (;;) {
293
+ await new Promise((r) => setTimeout(r, 650));
294
+ const s = await api(`/api/vis/ask/${aid}`);
295
+ V.asking = s;
296
+ if (s.stage === "failed") throw new Error(s.error || "That question could not be answered.");
297
+ if (s.result && !shown) {
298
+ shown = true; mergeResult(s.result);
299
+ V.history.push({ q, result: s.result, scopeLabel: scope ? `${scope.label} (${plural(scope.ids.length, "row")})` : "" });
300
+ V.cur = V.history.length - 1; V.sel = null; render();
301
+ const out = $("#vz-out"); if (out && out.getBoundingClientRect().top > window.innerHeight * 0.6) out.scrollIntoView({ behavior: reduced() ? "auto" : "smooth", block: "start" });
302
+ } else if (shown) { V.history[V.history.length - 1].result = s.result; }
303
+ if (s.stage === "done") break;
304
+ if (!shown) refreshAsk(); else { const st = $(".vz-stages"); if (st) st.outerHTML = stagesHtml(); }
305
+ }
306
+ V.asking = null; V.q = ""; render();
307
+ } catch (e) { V.asking = null; V.askErr = e.message; render(); }
308
+ }
309
+ function mergeResult(r) {
310
+ Object.entries(r.derived || {}).forEach(([name, vals]) => Object.entries(vals).forEach(([id, v]) => { if (V.byId[id]) V.byId[id][name] = v; }));
311
+ if (r.columns) V.data.columns = r.columns;
312
+ }
313
+ function select(label, ids) {
314
+ if (V.sel && V.sel.label === label) { V.sel = null; } else { V.sel = { label, ids }; V.selShow = 30; }
315
+ const w = $("#vz-selwrap"); if (w) w.innerHTML = selHtml();
316
+ markSelection();
317
+ const d = $("#vz-sel"); if (d) d.scrollIntoView({ behavior: reduced() ? "auto" : "smooth", block: "nearest" });
318
+ }
319
+ function downloadSvg() {
320
+ const svg = $("#vz-chart svg"); if (!svg) return;
321
+ const css = `text{font:13px "Familjen Grotesk",system-ui,sans-serif;fill:#1d2036}.tick{font-size:11.5px;fill:#5d6376}.ink{fill:#3a4bff}.ghost{fill:#ff5c8f;opacity:.85}.grid{stroke:#c5c8d1}.bx{fill:#f1f2f4;stroke:#1d2036;stroke-width:1.5}.whisk{stroke:#1d2036;stroke-width:1.5}.med{stroke:#ff5c8f;stroke-width:3.5}.dot{fill:#3a4bff;opacity:.75}.hit{fill:transparent}.segv{fill:#fff;font-size:11.5px;font-weight:600}.val{font-weight:600}`;
322
+ const clone = svg.cloneNode(true); clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
323
+ clone.insertAdjacentHTML("afterbegin", `<style>${css}</style><rect width="100%" height="100%" fill="#e7e8ea"></rect>`);
324
+ const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([clone.outerHTML], { type: "image/svg+xml" }));
325
+ a.download = (V.history[V.cur]?.result.title || "chart").toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 50) + ".svg"; a.click();
326
+ setTimeout(() => URL.revokeObjectURL(a.href), 2000);
327
+ }
328
+
329
+ document.addEventListener("click", (ev) => {
330
+ if (!on()) return;
331
+ const t = ev.target.closest("[data-vz],[data-vzsel],[data-vzq],[data-vzn],[data-vzsample],[data-vzh],[data-vzsort]"); if (!t) return;
332
+ if (t.dataset.vzn) { V.n = +t.dataset.vzn; return render(); }
333
+ if (t.dataset.vzsample) return loadData({ source: "sample", key: t.dataset.vzsample, n: V.n }, t.dataset.vzsample);
334
+ if (t.dataset.vzq) { V.q = t.dataset.vzq; return ask(V.q); }
335
+ if (t.dataset.vzh !== undefined) { V.cur = +t.dataset.vzh; V.sel = null; render(); return window.scrollTo({ top: 0 }); }
336
+ if (t.dataset.vzsort) { const k = t.dataset.vzsort; V.sorts[k] = (V.sorts[k] || "value") === "value" ? "name" : "value"; return redrawCharts(); }
337
+ if (t.dataset.vzsel !== undefined && t.dataset.vzids !== undefined && !t.dataset.vz) {
338
+ ev.stopPropagation();
339
+ return select(t.dataset.vzsel, t.dataset.vzids.split(",").filter(Boolean).map(Number));
340
+ }
341
+ const a = t.dataset.vz;
342
+ if (a === "load-job") { const j = window.DI_lastJob && window.DI_lastJob(); if (j) loadData({ source: "job", job: j.id }, "job"); }
343
+ else if (a === "load-hub") loadData({ source: "hub", dataset: V.hub.trim(), n: V.n }, "hub");
344
+ else if (a === "ask") ask(V.q);
345
+ else if (a === "clear") select(V.sel ? V.sel.label : "", []);
346
+ else if (a === "more") { V.selShow += 30; const w = $("#vz-selwrap"); if (w) w.innerHTML = selHtml(); }
347
+ else if (a === "scope" && V.sel) { V.scope = { label: V.sel.label, ids: V.sel.ids.slice() }; V.sel = null; render(); const q = $("#vz-q"); if (q) { q.focus(); q.scrollIntoView({ block: "center" }); } }
348
+ else if (a === "unscope") { V.scope = null; refreshAsk(); }
349
+ else if (a === "change") { V.data = null; V.history = []; V.cur = -1; V.sel = null; V.scope = null; render(); }
350
+ else if (a === "svg") downloadSvg();
351
+ });
352
+ /* marks are SVG groups: Enter or Space opens them like a button */
353
+ document.addEventListener("keydown", (ev) => {
354
+ if (!on()) return;
355
+ if ((ev.key === "Enter" || ev.key === " ") && ev.target.matches && ev.target.matches("g.mk, circle.mk1")) { ev.preventDefault(); ev.target.dispatchEvent(new MouseEvent("click", { bubbles: true })); }
356
+ if (ev.key === "Enter" && !ev.shiftKey && ev.target.id === "vz-q") { ev.preventDefault(); ask(V.q); }
357
+ if (ev.key === "Enter" && ev.target.id === "vz-hub" && V.hub.trim()) loadData({ source: "hub", dataset: V.hub.trim(), n: V.n }, "hub");
358
+ });
359
+ document.addEventListener("input", (ev) => {
360
+ if (!on()) return;
361
+ if (ev.target.id === "vz-q") { V.q = ev.target.value; const b = $('[data-vz="ask"]'); if (b) b.disabled = !!V.asking || !V.q.trim(); }
362
+ if (ev.target.id === "vz-hub") { V.hub = ev.target.value; const b = $('[data-vz="load-hub"]'); if (b) b.disabled = !!V.loadBusy || !V.hub.trim(); }
363
+ });
364
+ document.addEventListener("change", (ev) => {
365
+ if (!on() || ev.target.id !== "vz-file" || !ev.target.files.length) return;
366
+ const f = ev.target.files[0];
367
+ if (f.size > 6e6) { V.loadErr = "That file is over 6 MB. Upload a smaller sample."; return render(); }
368
+ const rd = new FileReader();
369
+ rd.onload = () => loadData({ source: "upload", text: String(rd.result), filename: f.name }, "file");
370
+ rd.onerror = () => { V.loadErr = "Could not read that file."; render(); };
371
+ rd.readAsText(f);
372
+ });
373
+ let rz = 0;
374
+ window.addEventListener("resize", () => { if (!on() || !V.data) return; clearTimeout(rz); rz = setTimeout(redrawCharts, 120); });
375
+
376
+ window.DI_renderVis = () => {
377
+ if (!V.samples.length) api("/api/vis/samples").then((d) => { V.samples = d.samples; V.maxRows = d.max_rows; if (on() && !V.data) render(); }).catch(() => {});
378
+ render();
379
+ };
380
+ /* "Visualize these rows" on a finished run */
381
+ window.DI_visualizeJob = (id) => { window.DI_setMode && window.DI_setMode("visualize"); loadData({ source: "job", job: id }, "job"); };
382
+ })();
tests/test_visualize.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+
3
+ from datainventor import visualize as V
4
+
5
+
6
+ def sample(n=12):
7
+ areas = ["Billing", "Returns", "Shipping"]
8
+ return [{"id": i, "prompt": f"Question number {i} about an order that arrived late and damaged",
9
+ "completion": " ".join(["word"] * (20 + 10 * i)), "area": areas[i % 3], "difficulty": "basic" if i % 2 else "advanced",
10
+ "score": str(i % 5 + 1), "ok": bool(i % 2)} for i in range(n)]
11
+
12
+
13
+ class Prepare(unittest.TestCase):
14
+ def test_types_and_derived_lengths(self):
15
+ rows, cols = V.prepare(sample())
16
+ self.assertEqual(cols["prompt"]["type"], "text")
17
+ self.assertEqual(cols["area"]["type"], "cat")
18
+ self.assertEqual(cols["score"]["type"], "num")
19
+ self.assertEqual(cols["ok"]["type"], "cat")
20
+ self.assertNotIn("id", cols)
21
+ self.assertEqual(cols["completion words"]["derived"], "words")
22
+ self.assertEqual(rows[0]["_id"], 1)
23
+ self.assertEqual(rows[0]["completion words"], 20.0)
24
+
25
+ def test_chat_cells_become_text_with_turns(self):
26
+ rows, cols = V.prepare([{"messages": [{"role": "user", "content": "hi " * 30}, {"role": "assistant", "content": "hello " * 30}]}] * 3)
27
+ self.assertEqual(cols["messages"]["type"], "text")
28
+ self.assertEqual(rows[0]["messages turns"], 2.0)
29
+
30
+
31
+ class Charts(unittest.TestCase):
32
+ def setUp(self):
33
+ self.rows, self.cols = V.prepare(sample(30))
34
+
35
+ def test_every_row_lands_in_exactly_one_bin(self):
36
+ c = V.chart(self.rows, self.cols, {"type": "hist", "x": "completion words"})
37
+ ids = [i for b in c["marks"] for i in b["ids"]]
38
+ self.assertEqual(sorted(ids), list(range(1, 31)))
39
+
40
+ def test_bar_counts_and_ids(self):
41
+ c = V.chart(self.rows, self.cols, {"type": "bar", "x": "area", "agg": "count"})
42
+ self.assertEqual(sum(m["value"] for m in c["marks"]), 30)
43
+ self.assertTrue(all(len(m["ids"]) == m["value"] for m in c["marks"]))
44
+
45
+ def test_box_quartiles_are_ordered(self):
46
+ c = V.chart(self.rows, self.cols, {"type": "box", "x": "area", "y": "completion words"})
47
+ for m in c["marks"]:
48
+ self.assertTrue(m["lo"] <= m["q1"] <= m["median"] <= m["q3"] <= m["hi"])
49
+
50
+ def test_check_spec_fixes_and_rejects(self):
51
+ self.assertEqual(V.check_spec({"type": "bar", "x": "completion words"}, self.cols)["type"], "hist")
52
+ self.assertEqual(V.check_spec({"type": "bar", "x": "area", "y": "score"}, self.cols)["agg"], "mean")
53
+ with self.assertRaises(V.PlanError):
54
+ V.check_spec({"type": "box", "x": "area", "y": "area"}, self.cols)
55
+ with self.assertRaises(V.PlanError):
56
+ V.check_spec({"type": "bar", "x": "missing"}, self.cols)
57
+
58
+
59
+ class Plans(unittest.TestCase):
60
+ def setUp(self):
61
+ self.rows, self.cols = V.prepare(sample())
62
+
63
+ def test_parse_plan_with_fences_and_derived_column(self):
64
+ p = V.parse_plan('Sure!\n```json\n{"title": "Tone by area", "derive": [{"name": "Tone!", "kind": "label", "from": "completion", "labels": ["calm", "curt"]}],'
65
+ ' "chart": {"type": "stack", "x": "area", "color": "tone"}, "follow_ups": ["a"]}\n```', self.cols)
66
+ self.assertEqual(p["derive"][0]["name"], "tone")
67
+ self.assertEqual(p["chart"]["agg"], "share")
68
+
69
+ def test_derived_name_never_overwrites_an_original_column(self):
70
+ p = V.parse_plan('{"title": "t", "derive": [{"name": "area", "kind": "label", "from": "prompt", "labels": ["a", "b"]}],'
71
+ ' "chart": {"type": "bar", "x": "area"}}', self.cols)
72
+ self.assertEqual(p["derive"][0]["name"], "area (model)")
73
+ self.assertEqual(p["chart"]["x"], "area (model)")
74
+
75
+ def test_snake_case_names_still_match_the_chart(self):
76
+ p = V.parse_plan('{"derive": [{"name": "step_by_step", "kind": "pattern", "from": "completion", "pattern": "(?i)step \\\\d"}],'
77
+ ' "chart": {"type": "bar", "x": "step_by_step"}}', self.cols)
78
+ self.assertEqual(p["derive"][0]["name"], "step by step")
79
+ self.assertEqual(p["chart"]["x"], "step by step")
80
+ self.assertEqual(p["derive"][0]["pattern"], "step \\d")
81
+
82
+ def test_dangerous_pattern_rejected(self):
83
+ with self.assertRaises(V.PlanError):
84
+ V.parse_plan('{"derive": [{"name": "x", "kind": "pattern", "from": "prompt", "pattern": "(a+)+$"}], "chart": {"type": "bar", "x": "x"}}', self.cols)
85
+
86
+ def test_label_reading(self):
87
+ d = {"kind": "label", "labels": ["Formal", "Friendly"]}
88
+ self.assertEqual(V._read_labels('{"1": "formal", "2": "friendly tone"}', d, 2), {1: "Formal", 2: "Friendly"})
89
+ self.assertIsNone(V._read_labels('{"1": "angry"}', d, 2))
90
+
91
+ def test_pattern_fill_needs_no_model(self):
92
+ rows, _ = V.prepare([{"t": "Step 1 do this. Step 2 do that."}, {"t": "Just a plain answer here."}])
93
+ vals = V.fill({"name": "steps", "kind": "pattern", "from": "t", "pattern": r"step \d"}, rows, chat=None)
94
+ self.assertEqual(vals, {1: "yes", 2: "no"})
95
+
96
+
97
+ if __name__ == "__main__":
98
+ unittest.main()