Spaces:
Running
Running
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 +13 -2
- app/main.py +5 -1
- app/visualize_api.py +344 -0
- datainventor/llm.py +2 -2
- datainventor/visualize.py +738 -0
- static/app.css +86 -0
- static/app.js +4 -2
- static/curate.js +12 -4
- static/index.html +3 -2
- static/visualize.js +382 -0
- tests/test_visualize.py +98 -0
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,
|
| 14 |
---
|
| 15 |
|
| 16 |
# DataInventor
|
| 17 |
|
| 18 |
-
DataInventor has
|
| 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=
|
| 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 ==
|
| 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
|
| 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 |
-
|
|
|
|
| 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 ==
|
| 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,
|
| 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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()
|