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

Visualize: starter questions carry their plans (no planning call), hedged model calls so one slow provider can't stall a chart, sturdier label discovery, spread samples, whole-number histograms

Browse files
Files changed (2) hide show
  1. app/visualize_api.py +24 -9
  2. datainventor/visualize.py +82 -26
app/visualize_api.py CHANGED
@@ -45,6 +45,7 @@ class VisSet:
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
@@ -86,7 +87,7 @@ def _hub_rows(dataset: str, n: int) -> list[dict]:
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()
@@ -192,13 +193,13 @@ def load(body: LoadIn):
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
@@ -228,14 +229,28 @@ def run_ask(a: Ask, vs: VisSet, ep):
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
 
45
  created: float = field(default_factory=time.time)
46
  lock: threading.Lock = field(default_factory=threading.Lock)
47
  busy: bool = False
48
+ presets: dict = field(default_factory=dict)
49
 
50
 
51
  @dataclass
 
87
  except Exception as e:
88
  raise HTTPException(400, f"Could not open {dataset}: {str(e)[:160]}")
89
  total = info.get("num_rows") or 0
90
+ chunks = 1 if not total or total <= n else min(4, max(3, math.ceil(n / 25))) # sorted datasets need several slices
91
  per = math.ceil(n / chunks)
92
  starts = [0] if chunks == 1 else [max(0, int(total * (i + 0.5) / chunks) - per // 2) for i in range(chunks)]
93
  out, seen = [], set()
 
193
  if not cols:
194
  raise HTTPException(400, "None of the columns hold text, categories or numbers this can chart.")
195
  sid = uuid.uuid4().hex[:10]
196
+ vs = VisSet(id=sid, name=name, source=src, rows=rows, cols=cols, presets=V.presets(rows, cols))
197
  with GUARD:
198
  SETS[sid] = vs
199
  for old in sorted(SETS, key=lambda k: SETS[k].created)[:-30]:
200
  SETS.pop(old, None)
201
  return {"id": sid, "name": name, "source": src, "n": len(rows), "note": note, "columns": V.describe(rows, cols),
202
+ "glance": V.glance(rows, cols), "suggestions": list(vs.presets), "rows": rows}
203
 
204
 
205
  # ------------------------------------------------------------------ asking
 
229
  rows = [r for r in vs.rows if r["_id"] in ids] if ids else list(vs.rows)
230
  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 ""
231
  plan, err = None, ""
232
+ pre = vs.presets.get(a.question)
233
+ if pre: # a starter question: its plan is already written, and labels found before are reused
234
+ plan = {**pre, "derive": [], "chart": dict(pre["chart"])}
235
+ for d in pre["derive"]:
236
+ c = vs.cols.get(d["name"])
237
+ if not (c and c.get("derived") == d["kind"] and c.get("from") == d["from"]):
238
+ plan["derive"].append(dict(d))
239
+ def plan_once(temp):
240
+ text = chat(V.plan_prompt(a.question, vs.name, rows, vs.cols, scope, err), max_tokens=700, temperature=temp)
241
  try:
242
+ return V.parse_plan(text, vs.cols)
 
243
  except V.PlanError as e:
244
+ errs.append(str(e))
245
+ print(f"[visualize] plan rejected: {e} | reply: {text[:600]!r}", flush=True)
246
+ return None
247
+
248
+ errs: list = []
249
+ for _ in range(0 if plan else 2):
250
+ plan = V.hedged(plan_once, tries=1)
251
+ if plan:
252
+ break
253
+ err = errs[-1] if errs else ""
254
  if not plan:
255
  plan = V.fallback_plan(a.question, rows, vs.cols)
256
  # new columns, plus earlier model-made columns this chart needs that do not cover these rows yet
datainventor/visualize.py CHANGED
@@ -11,7 +11,8 @@ 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"}
@@ -23,6 +24,8 @@ 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
 
@@ -404,30 +407,61 @@ 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
@@ -632,6 +666,27 @@ def _read_labels(text: str, d: dict, k: int) -> Optional[dict]:
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"]."""
@@ -645,25 +700,26 @@ def fill(d: dict, rows: list[dict], chat: Callable[..., str], question: str = ""
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)
 
11
  import math
12
  import re
13
  import statistics
14
+ import time
15
+ from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
16
  from typing import Callable, Optional
17
 
18
  ID_NAMES = {"id", "_id", "idx", "index", "row_idx", "uuid", "row_id"}
 
24
  MAX_GROUPS = 12 # categories on one axis before the rest fold into "Other"
25
  MAX_COLORS = 6
26
  KINDS = ("label", "score", "pattern", "count")
27
+ HEDGE_AFTER = 8.0 # seconds before a slow call gets a twin; the first good answer wins
28
+ _HEDGE = ThreadPoolExecutor(max_workers=24)
29
  CHARTS = ("bar", "stack", "box", "hist", "scatter")
30
 
31
 
 
407
  return name if name.endswith("s") else name + "s"
408
 
409
 
410
+ def presets(rows: list[dict], cols: dict) -> dict[str, dict]:
411
+ """Starter questions with their plans already written, so clicking one skips the planning call."""
412
  cat, ln = _main_cat(cols, rows), _main_len(cols)
413
  texts = [k for k, c in cols.items() if c["type"] == "text"]
414
  reply = next((k for k in ("completion", "chosen", "response", "output", "answer", "messages", "conversations") if k in texts), None)
415
  ask = next((k for k in ("prompt", "instruction", "question", "input", "query") if k in texts), None)
416
  other = next((k for k in texts if k not in (reply, ask)), None)
417
+ out: dict[str, dict] = {}
418
+
419
+ def add(q, title, spec, derive=(), ups=()):
420
+ known = dict(cols)
421
+ for d in derive:
422
+ known[d["name"]] = {"type": "num" if d["kind"] in ("score", "count") else "cat"}
423
+ try:
424
+ out[q] = {"title": title, "derive": [dict(d) for d in derive], "chart": check_spec(spec, known), "follow_ups": list(ups)}
425
+ except PlanError:
426
+ pass
427
+
428
  if reply:
429
+ tone = {"name": "tone", "kind": "label", "from": reply, "guide": f"the overall tone of the {reply}",
430
+ "labels": ["friendly", "formal", "empathetic", "technical", "blunt"]}
431
+ add(f"What tone do the {_pl(reply)} take" + (f" in each {cat}?" if cat else "?"), f"Tone of the {_pl(reply)}" + (f" by {cat}" if cat else ""),
432
+ {"type": "stack", "x": cat, "color": "tone", "agg": "share"} if cat else {"type": "bar", "x": "tone"}, [tone],
433
+ [f"How long are the {_pl(reply)} for each tone?"] if ln else [])
434
  if ask:
435
+ add(f"What do the {_pl(ask)} ask for? Group them into a few themes.", f"What the {_pl(ask)} ask for",
436
+ {"type": "bar", "x": "request"}, [{"name": "request", "kind": "label", "from": ask, "guide": f"what the {ask} asks for", "labels": []}])
437
  if not ask and not reply and other:
438
+ add(f"What themes come up in the {_pl(other)}?", f"Themes in the {_pl(other)}", {"type": "bar", "x": "theme"},
439
+ [{"name": "theme", "kind": "label", "from": other, "guide": f"the main theme of the {other}", "labels": []}])
440
+ add(f"How positive or negative is each {other}?", f"Sentiment of the {_pl(other)}", {"type": "hist", "x": "sentiment"},
441
+ [{"name": "sentiment", "kind": "score", "from": other, "guide": "1 = very negative, 3 = neutral or mixed, 5 = very positive"}])
442
  if ln and cat:
443
+ add(f"Which {cat} gets the longest {_pl(ln.replace(' words', ''))}?", f"{ln.replace(' words', '').capitalize()} length by {cat}",
444
+ {"type": "box", "x": cat, "y": ln})
445
  elif ln:
446
+ add(f"How long are the {_pl(ln.replace(' words', ''))}?", f"{ln.capitalize()}", {"type": "hist", "x": ln})
447
  if reply:
448
+ add(f"How many {_pl(reply)} give step-by-step instructions?", f"{_pl(reply).capitalize()} with step-by-step instructions",
449
+ {"type": "bar", "x": "gives steps"},
450
+ [{"name": "gives steps", "kind": "label", "from": reply, "guide": "whether it walks the reader through numbered or ordered steps",
451
+ "labels": ["yes", "no"]}])
452
  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]
453
  if cat and seconds:
454
+ add(f"How does {seconds[0]} vary across {cat}?", f"{seconds[0].capitalize()} within each {cat}",
455
+ {"type": "stack", "x": cat, "color": seconds[0], "agg": "share"})
456
+ return dict(list(out.items())[:4])
457
+
458
+
459
+ def _pl(name: str) -> str:
460
+ return name if name.endswith("s") else name + "s"
461
+
462
+
463
+ def suggestions(rows: list[dict], cols: dict) -> list[str]:
464
+ return list(presets(rows, cols))
465
 
466
 
467
  # ------------------------------------------------------------------ the model's part
 
666
  return out if len(out) >= max(1, int(0.8 * k)) else None
667
 
668
 
669
+ def hedged(fn: Callable[[float], object], after: float = HEDGE_AFTER, tries: int = 2):
670
+ """Call fn(temperature) and return its first truthy result. A call still running after `after`
671
+ seconds gets a twin, so one slow provider does not hold up the chart. None if every try fails."""
672
+ for attempt in range(tries):
673
+ futs = {_HEDGE.submit(fn, 0.1 if attempt == 0 else 0.4)}
674
+ start, twin = time.time(), False
675
+ while futs:
676
+ done, futs = wait(futs, timeout=None if twin else max(0.05, after - (time.time() - start)), return_when=FIRST_COMPLETED)
677
+ for f in done:
678
+ try:
679
+ got = f.result()
680
+ except Exception:
681
+ got = None
682
+ if got:
683
+ return got
684
+ if not twin and (time.time() - start >= after or not futs):
685
+ futs = set(futs) | {_HEDGE.submit(fn, 0.5)}
686
+ twin = True
687
+ return None
688
+
689
+
690
  def fill(d: dict, rows: list[dict], chat: Callable[..., str], question: str = "",
691
  progress: Optional[Callable[[int], None]] = None, workers: int = 6) -> dict:
692
  """Compute one derived column for `rows`. Returns {row id: value} and may set d["labels"]."""
 
700
  progress(len(rows))
701
  return vals
702
  if d["kind"] == "label" and len(d.get("labels") or []) < 2:
703
+ def discover(temp):
704
+ try:
705
+ obj = first_json(chat(discover_prompt(question, d, rows), max_tokens=200, temperature=temp + 0.2))
706
+ except ValueError:
707
+ return None
708
+ if isinstance(obj, dict): # {"labels": [...]} and similar
709
+ obj = next((v for v in obj.values() if isinstance(v, list)), [])
710
+ labels = [str(x).strip().lower()[:40] for x in obj if isinstance(x, (str, int, float)) and str(x).strip()]
711
+ labels = list(dict.fromkeys(labels))[:7]
712
+ return labels if len(labels) >= 2 else None
713
+ labels = hedged(discover)
714
+ if not labels:
715
+ raise PlanError(f'Could not find labels for "{d["name"]}". Try asking again, or name the groups you expect.')
716
+ d["labels"] = labels
717
  batches = [rows[i:i + LABEL_BATCH] for i in range(0, len(rows), LABEL_BATCH)]
718
  vals: dict[int, object] = {}
719
  done = [0]
720
 
721
  def run(batch):
722
+ got = hedged(lambda temp: _read_labels(chat(label_prompt(d, batch), max_tokens=12 * len(batch) + 40, temperature=temp), d, len(batch)))
 
 
 
 
 
723
  res = {}
724
  for k, r in enumerate(batch, 1):
725
  res[r["_id"]] = (got or {}).get(k, "unclear" if d["kind"] == "label" else None)