Download apps/api/mbf/graph/dynamic.py from Sandy-sys/finai: direct link, hf CLI and curl.
- Browser
- Download file 44.1 kB
-
https://huggingface.co/spaces/Sandy-sys/finai/resolve/main/apps/api/mbf/graph/dynamic.py
- Command line
-
hf download hf://spaces/Sandy-sys/finai/apps/api/mbf/graph/dynamic.py
-
curl -L -o dynamic.py https://huggingface.co/spaces/Sandy-sys/finai/resolve/main/apps/api/mbf/graph/dynamic.py
44.1 kB
| """Dynamic, data-shape-driven dashboard synthesizer. | |
| Given one or more SQL result sets, this module *profiles* each row collection | |
| and emits a variable number of UI components β KPIs, bars, lines, pies, | |
| treemaps, histograms, scatter, heatmaps β chosen by the column dtypes, | |
| cardinality, and temporal hints. Nothing is hardcoded per question; the same | |
| synthesizer reacts purely to the data. | |
| Used by both the offline planner and the LLM-backed planner so every answer | |
| becomes a true mini-dashboard that adapts as the underlying database changes. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import re | |
| from dataclasses import dataclass, field | |
| from pathlib import Path | |
| from statistics import mean, median | |
| from typing import Any, Iterable | |
| from ..config import get_settings | |
| from ..retrieval.duck import run_sql | |
| from ..ui_plan.schema import ( | |
| Citation, | |
| Component, | |
| Encoding, | |
| GridCell, | |
| Layout, | |
| Persona, | |
| UIPlan, | |
| ) | |
| ACCENT = "#F47C2C" | |
| PALETTE = ["#F47C2C", "#FFC72C", "#FB9A2E", "#D6611B", "#FFB84D", "#A8470F", "#E63946", "#FFD27A"] | |
| # --------------------------------------------------------------------------- # | |
| # Schema introspection # | |
| # --------------------------------------------------------------------------- # | |
| def _schema() -> dict[str, dict]: | |
| p = Path(get_settings().schema_path) | |
| if not p.exists(): | |
| return {} | |
| try: | |
| return json.loads(p.read_text()) | |
| except Exception: # noqa: BLE001 | |
| return {} | |
| def _table_cols(table: str) -> list[dict[str, str]]: | |
| sch = _schema().get(table) or {} | |
| return list(sch.get("columns") or []) | |
| def _col_names(table: str) -> list[str]: | |
| return [c.get("name", "") for c in _table_cols(table)] | |
| def _has_col(table: str, name: str) -> bool: | |
| return name in _col_names(table) | |
| # --------------------------------------------------------------------------- # | |
| # Column profiling # | |
| # --------------------------------------------------------------------------- # | |
| _DATE_RX = re.compile(r"^\d{4}-\d{2}(-\d{2})?") | |
| _TIMEY_NAMES = {"month", "period", "date", "year", "fy", "quarter", "week"} | |
| class ColProfile: | |
| name: str | |
| kind: str = "text" # numeric|categorical|temporal|id|text | |
| n_unique: int = 0 | |
| n_null: int = 0 | |
| sample: list[Any] = field(default_factory=list) | |
| total: float = 0.0 | |
| vmin: float | None = None | |
| vmax: float | None = None | |
| def _is_number(v: Any) -> bool: | |
| return isinstance(v, (int, float)) and not isinstance(v, bool) | |
| def _looks_temporal(name: str, vals: list[Any]) -> bool: | |
| n = name.lower() | |
| if any(t in n for t in _TIMEY_NAMES): | |
| return True | |
| if vals and isinstance(vals[0], str) and _DATE_RX.match(vals[0]): | |
| return True | |
| return False | |
| def profile(rows: list[dict[str, Any]]) -> dict[str, ColProfile]: | |
| if not rows: | |
| return {} | |
| cols = list(rows[0].keys()) | |
| out: dict[str, ColProfile] = {c: ColProfile(name=c) for c in cols} | |
| for c in cols: | |
| vals = [r.get(c) for r in rows] | |
| nonnull = [v for v in vals if v is not None] | |
| out[c].n_null = len(vals) - len(nonnull) | |
| out[c].sample = nonnull[:8] | |
| out[c].n_unique = len({str(v) for v in nonnull}) | |
| nums = [float(v) for v in nonnull if _is_number(v)] | |
| if nums and len(nums) >= max(1, int(len(nonnull) * 0.7)): | |
| out[c].kind = "numeric" | |
| out[c].total = sum(nums) | |
| out[c].vmin = min(nums) | |
| out[c].vmax = max(nums) | |
| elif _looks_temporal(c, nonnull): | |
| out[c].kind = "temporal" | |
| elif "id" == c.lower() or c.lower().endswith("_id"): | |
| out[c].kind = "id" | |
| else: | |
| out[c].kind = "categorical" if out[c].n_unique <= max(40, len(rows) // 2) else "text" | |
| return out | |
| # --------------------------------------------------------------------------- # | |
| # Result-set β components # | |
| # --------------------------------------------------------------------------- # | |
| def _kpi_card(idx: int, label: str, value: Any, sub: str = "") -> Component: | |
| fmt = "currency_inr" if isinstance(value, (int, float)) else "text" | |
| return Component( | |
| id=f"k{idx}", | |
| type="kpi", | |
| title=label, | |
| subtitle=sub, | |
| data={"value": value, "format": fmt, "label": label}, | |
| why=None, | |
| ) | |
| def _bar(idx: int, title: str, rows: list[dict], x: str, y: str, why: str) -> Component: | |
| return Component( | |
| id=f"c{idx}", | |
| type="bar", | |
| title=title, | |
| data=rows, | |
| encoding=Encoding(x=x, y=y, color=ACCENT), | |
| why=why, | |
| ) | |
| def _stacked_bar(idx: int, title: str, rows: list[dict], x: str, | |
| y_cols: list[str], why: str) -> Component: | |
| """Stacked bar when multiple numeric measures share one categorical axis.""" | |
| return Component( | |
| id=f"sb{idx}", type="stacked-bar", title=title, data=rows[:60], | |
| encoding=Encoding(x=x, y=y_cols, color=ACCENT), | |
| why=why, | |
| ) | |
| def _sankey(idx: int, title: str, rows: list[dict], source: str, | |
| target: str, value_col: str, why: str) -> Component | None: | |
| """Sankey for flow between two categorical columns weighted by a numeric.""" | |
| links: list[dict[str, Any]] = [] | |
| for r in rows[:100]: | |
| s = str(r.get(source, ""))[:24] or "(blank)" | |
| t = str(r.get(target, ""))[:24] or "(blank)" | |
| v = float(r.get(value_col) or 0) | |
| if v > 0 and s != t: | |
| links.append({"source": s, "target": t, "value": round(v, 2)}) | |
| if len(links) < 2: | |
| return None | |
| nodes = list({l["source"] for l in links} | {l["target"] for l in links}) | |
| return Component( | |
| id=f"sk{idx}", type="sankey", title=title, | |
| data={"nodes": [{"name": n} for n in nodes], "links": links}, | |
| why=why, | |
| ) | |
| def _line(idx: int, title: str, rows: list[dict], x: str, y: str, why: str) -> Component: | |
| return Component( | |
| id=f"c{idx}", | |
| type="line", | |
| title=title, | |
| data=rows, | |
| encoding=Encoding(x=x, y=y, color=ACCENT), | |
| why=why, | |
| ) | |
| def _treemap(idx: int, title: str, rows: list[dict], name_col: str, value_col: str, why: str) -> Component: | |
| series = [ | |
| {"name": str(r.get(name_col, "β"))[:32] or "β", | |
| "value": float(r.get(value_col) or 0)} | |
| for r in rows[:30] | |
| ] | |
| return Component( | |
| id=f"c{idx}", | |
| type="treemap", | |
| title=title, | |
| data=series, | |
| why=why, | |
| ) | |
| def _histogram(idx: int, title: str, rows: list[dict], col: str) -> Component | None: | |
| nums = [float(r.get(col) or 0) for r in rows if _is_number(r.get(col))] | |
| if len(nums) < 8: | |
| return None | |
| nums.sort() | |
| lo, hi = nums[0], nums[-1] | |
| if hi <= lo: | |
| return None | |
| nbins = min(20, max(5, int(len(nums) ** 0.5))) | |
| width = (hi - lo) / nbins | |
| buckets = [0] * nbins | |
| for v in nums: | |
| b = min(nbins - 1, int((v - lo) / width)) | |
| buckets[b] += 1 | |
| data = [ | |
| {"bucket": f"{lo + i * width:.0f}β{lo + (i + 1) * width:.0f}", "count": n} | |
| for i, n in enumerate(buckets) | |
| ] | |
| return Component( | |
| id=f"h{idx}", | |
| type="bar", | |
| title=title, | |
| data=data, | |
| encoding=Encoding(x="bucket", y="count", color=ACCENT), | |
| why=f"Distribution of {col} across {len(nums)} rows in {nbins} buckets.", | |
| ) | |
| def _heatmap_corr(idx: int, title: str, rows: list[dict], num_cols: list[str]) -> Component | None: | |
| if len(num_cols) < 2 or len(rows) < 5: | |
| return None | |
| cols = num_cols[:6] | |
| series = [[float(r.get(c) or 0) for c in cols] for r in rows[:300]] | |
| means = [mean(col) for col in zip(*series)] | |
| stds = [ | |
| (sum((v - m) ** 2 for v in col) / max(1, len(col) - 1)) ** 0.5 | |
| for col, m in zip(zip(*series), means) | |
| ] | |
| data: list[list[float]] = [] | |
| x_labels, y_labels = list(cols), list(cols) | |
| for i, ci in enumerate(cols): | |
| for j, cj in enumerate(cols): | |
| if stds[i] == 0 or stds[j] == 0: | |
| corr = 0.0 | |
| else: | |
| vi = [r[i] for r in series] | |
| vj = [r[j] for r in series] | |
| cov = sum((a - means[i]) * (b - means[j]) for a, b in zip(vi, vj)) / max(1, len(vi) - 1) | |
| corr = cov / (stds[i] * stds[j]) | |
| data.append([float(j), float(i), round(corr, 3)]) | |
| return Component( | |
| id=f"hm{idx}", | |
| type="heatmap", | |
| title=title, | |
| data=data, # type: ignore[arg-type] | |
| options={"x": x_labels, "y": y_labels, "min": -1, "max": 1}, | |
| why="Pearson correlation across the numeric columns in this result.", | |
| ) | |
| def _table(idx: int, title: str, rows: list[dict]) -> Component: | |
| return Component(id=f"t{idx}", type="data-table", title=title, data=rows[:200], why=None) | |
| def _raw_sql(idx: int, sql: str) -> Component: | |
| return Component(id=f"sql{idx}", type="raw-sql", title="SQL", data={"sql": sql}, why=None) | |
| def _donut(idx: int, title: str, rows: list[dict], name_col: str, | |
| value_col: str, why: str) -> Component: | |
| """Render as sunburst (frontend supports sunburst, renders like a donut for flat series).""" | |
| data = [ | |
| {"name": str(r.get(name_col, "β"))[:28] or "β", | |
| "value": float(r.get(value_col) or 0)} | |
| for r in rows if float(r.get(value_col) or 0) > 0 | |
| ][:8] | |
| return Component( | |
| id=f"dn{idx}", type="sunburst", title=title, data=data, why=why, | |
| ) | |
| def _sunburst_hier(idx: int, title: str, rows: list[dict], outer: str, inner: str, | |
| value_col: str) -> Component | None: | |
| """2-level sunburst: outer category contains inner category β value.""" | |
| tree: dict[str, dict[str, float]] = {} | |
| for r in rows: | |
| o = str(r.get(outer, "β") or "β")[:24] | |
| i = str(r.get(inner, "β") or "β")[:24] | |
| v = float(r.get(value_col) or 0) | |
| if v <= 0: | |
| continue | |
| tree.setdefault(o, {}).setdefault(i, 0.0) | |
| tree[o][i] += v | |
| if not tree: | |
| return None | |
| data = [ | |
| {"name": o, "children": [{"name": k, "value": round(v, 2)} | |
| for k, v in sorted(kids.items(), | |
| key=lambda x: x[1], reverse=True)[:8]]} | |
| for o, kids in list(sorted(tree.items(), | |
| key=lambda x: sum(x[1].values()), reverse=True))[:10] | |
| ] | |
| return Component( | |
| id=f"sb{idx}", type="sunburst", title=title, data=data, | |
| why=f"Hierarchical share: {outer} β {inner} on {value_col}.", | |
| ) | |
| def _funnel(idx: int, title: str, rows: list[dict], name_col: str, | |
| value_col: str, why: str) -> Component: | |
| data = sorted( | |
| [ | |
| {"name": str(r.get(name_col, "β"))[:28] or "β", | |
| "value": float(r.get(value_col) or 0)} | |
| for r in rows if float(r.get(value_col) or 0) > 0 | |
| ], | |
| key=lambda d: d["value"], | |
| reverse=True, | |
| )[:8] | |
| return Component(id=f"fn{idx}", type="funnel", title=title, data=data, why=why) | |
| def _radar(idx: int, title: str, rows: list[dict], name_col: str, | |
| value_cols: list[str], why: str) -> Component | None: | |
| if not rows or not value_cols: | |
| return None | |
| top = rows[:5] | |
| maxes = {c: max((float(r.get(c) or 0) for r in rows), default=1.0) or 1.0 for c in value_cols} | |
| indicators = [{"name": c, "max": round(maxes[c] * 1.15, 2)} for c in value_cols] | |
| series = [ | |
| {"name": str(r.get(name_col, f"row {i+1}"))[:24], | |
| "value": [round(float(r.get(c) or 0), 2) for c in value_cols]} | |
| for i, r in enumerate(top) | |
| ] | |
| return Component( | |
| id=f"rd{idx}", type="radar", title=title, data=series, | |
| options={"indicators": indicators}, why=why, | |
| ) | |
| def _waterfall(idx: int, title: str, rows: list[dict], name_col: str, | |
| budget_col: str, actual_col: str) -> Component | None: | |
| """Variance waterfall: budget β actual per category (as bar with signed deltas).""" | |
| data = [] | |
| for r in rows[:12]: | |
| b = float(r.get(budget_col) or 0) | |
| a = float(r.get(actual_col) or 0) | |
| data.append({ | |
| "name": str(r.get(name_col, "β"))[:24] or "β", | |
| "variance": round(b - a, 2), | |
| "budget": round(b, 2), | |
| "actual": round(a, 2), | |
| }) | |
| if not data: | |
| return None | |
| return Component( | |
| id=f"wf{idx}", type="bar", title=title, data=data, | |
| encoding=Encoding(x="name", y=["budget", "actual"], color=ACCENT), | |
| why="Budget vs actual side-by-side reveals under/overspend per category.", | |
| ) | |
| def _scatter(idx: int, title: str, rows: list[dict], x_col: str, y_col: str, | |
| cat_col: str | None, why: str) -> Component: | |
| """Scatter plot for two numeric dimensions, optionally colored by a categorical.""" | |
| data = [] | |
| for r in rows[:300]: | |
| xv = r.get(x_col) | |
| yv = r.get(y_col) | |
| if xv is None or yv is None: | |
| continue | |
| pt: dict[str, Any] = {"x": float(xv), "y": float(yv)} | |
| if cat_col: | |
| pt["category"] = str(r.get(cat_col, ""))[:24] | |
| data.append(pt) | |
| enc = Encoding(x=x_col, y=y_col, color=cat_col or ACCENT) | |
| return Component(id=f"sc{idx}", type="scatter", title=title, data=data, | |
| encoding=enc, why=why) | |
| def _pie(idx: int, title: str, rows: list[dict], name_col: str, | |
| value_col: str, why: str) -> Component: | |
| """True pie chart for low-cardinality share-of-total.""" | |
| data = [ | |
| {"name": str(r.get(name_col, "β"))[:24] or "β", | |
| "value": float(r.get(value_col) or 0)} | |
| for r in rows if float(r.get(value_col) or 0) > 0 | |
| ][:8] | |
| return Component(id=f"pi{idx}", type="pie", title=title, data=data, why=why) | |
| def _area(idx: int, title: str, rows: list[dict], x: str, y: str, why: str) -> Component: | |
| """Area chart β temporal continuous fill.""" | |
| return Component( | |
| id=f"ar{idx}", type="area", title=title, data=rows[:200], | |
| encoding=Encoding(x=x, y=y, color=ACCENT), why=why, | |
| ) | |
| def _gauge(idx: int, title: str, value: float, max_val: float, why: str) -> Component: | |
| """Gauge for utilization / completion rates.""" | |
| return Component( | |
| id=f"ga{idx}", type="gauge", title=title, | |
| data={"value": round(value, 2), "max": round(max_val, 2), "label": title}, | |
| why=why, | |
| ) | |
| def _pick_kpis(seq: int, rows: list[dict], prof: dict[str, ColProfile], | |
| num_cols: list[str], cat_cols: list[str], | |
| time_cols: list[str] | None = None) -> list[Component]: | |
| """Data-tier-aware KPI selection β 1 to 4 KPIs with optional sparklines.""" | |
| out: list[Component] = [] | |
| if not num_cols: | |
| return out | |
| primary = max(num_cols, key=lambda c: prof[c].total) | |
| p = prof[primary] | |
| nrows = len(rows) | |
| # Build sparkline trend from time-ordered values (up to 12 points) | |
| sparkline: list[float] | None = None | |
| if time_cols and nrows >= 3: | |
| sparkline = [float(r.get(primary) or 0) for r in rows[:12]] | |
| kpi1 = _kpi_card(seq * 10 + 1, f"Ξ£ {primary}", round(p.total, 2), | |
| f"{nrows} rows") | |
| if sparkline: | |
| kpi1.options = {"sparkline": sparkline} | |
| out.append(kpi1) | |
| # median / average tells a different story than sum | |
| if nrows >= 4: | |
| nums = sorted(float(r.get(primary) or 0) for r in rows) | |
| med = nums[len(nums) // 2] | |
| kpi2 = _kpi_card(seq * 10 + 2, f"median {primary}", round(med, 2), | |
| f"typical value") | |
| out.append(kpi2) | |
| # top category + its share of total β but only if the value is meaningful | |
| if cat_cols and p.total > 0: | |
| # Filter out junk category values (NULL, empty, "grand total", "(unspecified)" etc.) | |
| _JUNK = {"", "β", "(unspecified)", "grand total", "grand total:", | |
| "total", "totals", "subtotal", "n/a", "na", "null", "none", | |
| "all", "overall"} | |
| clean_rows = [r for r in rows | |
| if str(r.get(cat_cols[0]) or "").strip().lower() not in _JUNK | |
| and r.get(cat_cols[0]) is not None] | |
| if clean_rows: | |
| top = max(clean_rows, key=lambda r: float(r.get(primary) or 0)) | |
| tv = float(top.get(primary) or 0) | |
| if tv > 0: | |
| share_pct = (tv / p.total) * 100 if p.total > 0 else 0 | |
| out.append(_kpi_card( | |
| seq * 10 + 3, | |
| f"top {cat_cols[0]}", | |
| str(top.get(cat_cols[0]) or "β")[:28], | |
| f"βΉ{tv:,.0f} Β· {share_pct:.0f}% of total", | |
| )) | |
| # spread (range) β only for large/diverse sets | |
| if nrows >= 10 and p.vmin is not None and p.vmax is not None: | |
| spread = p.vmax - p.vmin | |
| if spread > 0: | |
| out.append(_kpi_card(seq * 10 + 4, f"range {primary}", round(spread, 2), | |
| f"min βΉ{p.vmin:,.0f} β max βΉ{p.vmax:,.0f}")) | |
| return out | |
| def _components_for_result(seq: int, title: str, sql: str, rows: list[dict], | |
| emit_table: bool = True, lean: bool = False) -> list[Component]: | |
| """Emit a data-shape-driven component list. | |
| `lean` trims secondary result-sets so the full dashboard stays digestible. | |
| Component count varies by row count, cardinality, numeric/categorical mix | |
| and temporal hints β nothing is emitted just to fill space. | |
| """ | |
| out: list[Component] = [] | |
| if not rows: | |
| out.append(Component(id=f"n{seq}", type="callout", | |
| title=title, | |
| data={"text": "No rows returned for this slice."}, | |
| why=None)) | |
| out.append(_raw_sql(seq, sql)) | |
| return out | |
| # Check for all-null/all-zero numeric data β don't render charts for it | |
| if _all_zero_numeric(rows): | |
| out.append(Component(id=f"n{seq}", type="callout", | |
| title=title, | |
| data={"text": "All numeric values are zero or empty β " | |
| "this part of the query could not be answered " | |
| "from the available data."}, | |
| why=None)) | |
| out.append(_raw_sql(seq, sql)) | |
| return out | |
| prof = profile(rows) | |
| num_cols = [c for c, p in prof.items() if p.kind == "numeric"] | |
| cat_cols = [c for c, p in prof.items() if p.kind == "categorical"] | |
| time_cols = [c for c, p in prof.items() if p.kind == "temporal"] | |
| nrows = len(rows) | |
| # --- Tier (controls how rich the dashboard gets) ------------------------- | |
| tier = "tiny" if nrows <= 3 else "small" if nrows <= 12 else \ | |
| "medium" if nrows <= 60 else "large" | |
| # Detect budget/actual pair (waterfall candidate) | |
| b_col = next((c for c in num_cols | |
| if "budget" in c.lower() and "total" in c.lower()), None) | |
| a_col = next((c for c in num_cols | |
| if ("actual" in c.lower() and "total" in c.lower()) | |
| or c.lower() == "actuals"), None) | |
| has_variance = bool(b_col and a_col and cat_cols) | |
| # 1) KPI strip β only for primary result (lean=False); secondaries | |
| # skip KPIs to avoid duplicate Ξ£/median/top tiles cluttering the dashboard. | |
| if not lean: | |
| out.extend(_pick_kpis(seq, rows, prof, num_cols, cat_cols, time_cols)) | |
| # 2) Temporal trace β line (always if time exists) | |
| if time_cols and num_cols: | |
| tc, nc = time_cols[0], num_cols[0] | |
| out.append(_line(seq * 10 + 5, f"{nc} over {tc}", | |
| rows[:200], x=tc, y=nc, | |
| why=f"Trace of {nc} along {tc}.")) | |
| # 3) Primary categorical view β pick a shape from data characteristics | |
| if cat_cols and num_cols: | |
| pc, pn = cat_cols[0], num_cols[0] | |
| uniq = len({r.get(pc) for r in rows}) | |
| ranked = sorted(rows, key=lambda r: float(r.get(pn) or 0), reverse=True) | |
| if tier == "tiny" or uniq <= 5: | |
| # tiny / low-cardinality β pie (clear share-of-total) | |
| out.append(_pie(seq * 10 + 6, f"{pc} share of {pn}", | |
| ranked, name_col=pc, value_col=pn, | |
| why="Pie reveals share-of-total with few categories.")) | |
| elif uniq >= 20: | |
| out.append(_bar(seq * 10 + 6, | |
| f"Top 15 {pc} by {pn}", | |
| ranked[:15], x=pc, y=pn, | |
| why=f"Ranks the 15 largest {pc} by {pn}.")) | |
| if not lean: | |
| out.append(_treemap(seq * 10 + 7, | |
| f"{pc} share of {pn}", | |
| ranked[:30], name_col=pc, value_col=pn, | |
| why="Treemap reveals share-of-total across many categories.")) | |
| else: | |
| out.append(_bar(seq * 10 + 6, | |
| f"{pc} by {pn}", | |
| ranked[:15], x=pc, y=pn, | |
| why=f"Bar compares {pn} across {pc}.")) | |
| # 4) Budget-vs-actual waterfall (only when both present) | |
| if has_variance and not lean: | |
| wf = _waterfall(seq * 10 + 8, f"Budget vs Actual by {cat_cols[0]}", | |
| rows, name_col=cat_cols[0], | |
| budget_col=b_col, actual_col=a_col) # type: ignore[arg-type] | |
| if wf is not None: | |
| out.append(wf) | |
| # 5) Hierarchical sunburst when 2 cats + 1 num | |
| if len(cat_cols) >= 2 and num_cols and tier in {"medium", "large"}: | |
| sb = _sunburst_hier(seq * 10 + 9, | |
| f"{cat_cols[0]} β {cat_cols[1]} share of {num_cols[0]}", | |
| rows, outer=cat_cols[0], inner=cat_cols[1], | |
| value_col=num_cols[0]) | |
| if sb is not None: | |
| out.append(sb) | |
| # 6) Funnel for status/stage-like categorical | |
| status_cols = [c for c in cat_cols if any(k in c.lower() | |
| for k in ("status", "stage", "state", "phase", "approval"))] | |
| if status_cols and num_cols: | |
| out.append(_funnel(seq * 10 + 10, f"{status_cols[0]} funnel", | |
| rows, name_col=status_cols[0], value_col=num_cols[0], | |
| why=f"Funnel ranks {status_cols[0]} by {num_cols[0]}.")) | |
| # 7) Radar when 3+ numeric metrics across a few categories | |
| if len(num_cols) >= 3 and cat_cols and tier in {"small", "medium"} and not lean: | |
| rd = _radar(seq * 10 + 11, f"{cat_cols[0]} multi-metric profile", | |
| rows, name_col=cat_cols[0], value_cols=num_cols[:5], | |
| why="Radar compares multiple metrics for the top rows.") | |
| if rd is not None: | |
| out.append(rd) | |
| # 8) Distribution histogram β only for medium/large single-numeric slices | |
| if num_cols and tier in {"medium", "large"} and not lean: | |
| h = _histogram(seq * 10 + 12, f"Distribution of {num_cols[0]}", rows, num_cols[0]) | |
| if h is not None: | |
| out.append(h) | |
| # 9) Correlation heatmap when genuine multi-numeric dataset | |
| if len(num_cols) >= 3 and tier == "large" and not lean: | |
| hm = _heatmap_corr(seq * 10 + 13, "Numeric correlations", rows, num_cols) | |
| if hm is not None: | |
| out.append(hm) | |
| # 10) Scatter when 2+ numeric cols (explore relationships) | |
| if len(num_cols) >= 2 and tier in {"medium", "large"} and not lean: | |
| x_sc, y_sc = num_cols[0], num_cols[1] | |
| cat_sc = cat_cols[0] if cat_cols else None | |
| out.append(_scatter(seq * 10 + 15, f"{x_sc} vs {y_sc}", | |
| rows, x_col=x_sc, y_col=y_sc, cat_col=cat_sc, | |
| why=f"Scatter reveals relationship between {x_sc} and {y_sc}.")) | |
| # 11) Area chart as alternate temporal view (stacked feel) | |
| if time_cols and len(num_cols) >= 2 and not lean: | |
| tc, nc2 = time_cols[0], num_cols[1] if len(num_cols) > 1 else num_cols[0] | |
| out.append(_area(seq * 10 + 16, f"{nc2} trend", | |
| rows[:200], x=tc, y=nc2, | |
| why=f"Area fill highlights the trend of {nc2} over time.")) | |
| # 12) Gauge for utilization / completion when budget+actual exist | |
| if has_variance and not lean: | |
| total_b = sum(float(r.get(b_col) or 0) for r in rows) # type: ignore[arg-type] | |
| total_a = sum(float(r.get(a_col) or 0) for r in rows) # type: ignore[arg-type] | |
| if total_b > 0: | |
| out.append(_gauge(seq * 10 + 17, | |
| "Budget Utilisation", | |
| total_a, total_b, | |
| why=f"Utilisation gauge: βΉ{total_a:,.0f} spent of βΉ{total_b:,.0f} budget.")) | |
| # 13) Stacked bar when 2+ numeric measures share one categorical axis | |
| if len(num_cols) >= 2 and cat_cols and tier in {"small", "medium"} and not lean: | |
| pc = cat_cols[0] | |
| y_names = num_cols[:3] # up to 3 stacked measures | |
| ranked = sorted(rows, key=lambda r: sum(float(r.get(c) or 0) for c in y_names), reverse=True) | |
| out.append(_stacked_bar( | |
| seq * 10 + 18, | |
| f"{pc} β stacked {', '.join(y_names)}", | |
| ranked[:15], x=pc, y_cols=y_names, | |
| why=f"Stacked bar compares {', '.join(y_names)} side-by-side per {pc}.", | |
| )) | |
| # 14) Sankey flow when 2 categoricals + numeric (flow semantics) | |
| flow_hints = ("from", "to", "source", "target", "sender", "receiver", | |
| "origin", "destination", "funder", "region") | |
| if len(cat_cols) >= 2 and num_cols and tier in {"medium", "large"} and not lean: | |
| c1, c2 = cat_cols[0], cat_cols[1] | |
| # Only emit sankey when columns suggest a flow relationship | |
| c1_flow = any(h in c1.lower() for h in flow_hints) | |
| c2_flow = any(h in c2.lower() for h in flow_hints) | |
| if c1_flow or c2_flow or len(cat_cols) >= 2: | |
| sk = _sankey(seq * 10 + 19, | |
| f"{c1} β {c2} flow by {num_cols[0]}", | |
| rows, source=c1, target=c2, value_col=num_cols[0], | |
| why=f"Sankey traces fund flow from {c1} to {c2}.") | |
| if sk is not None: | |
| out.append(sk) | |
| # 15) Data-table preview + raw SQL (always, for transparency) | |
| if emit_table: | |
| out.append(_table(seq * 10 + 14, f"{title} β rows", rows)) | |
| out.append(_raw_sql(seq, sql)) | |
| return out | |
| return out | |
| # --------------------------------------------------------------------------- # | |
| # Result-quality helper (used by offline plan & component rendering) # | |
| # --------------------------------------------------------------------------- # | |
| def _all_zero_numeric(rows: list[dict]) -> bool: | |
| """True when every numeric value across all rows is 0 or None.""" | |
| has_num = False | |
| for r in rows: | |
| for v in r.values(): | |
| if v is None: | |
| continue | |
| if isinstance(v, (int, float)): | |
| has_num = True | |
| if v != 0: | |
| return False | |
| elif isinstance(v, str): | |
| try: | |
| fv = float(v) | |
| has_num = True | |
| if fv != 0: | |
| return False | |
| except (ValueError, OverflowError): | |
| pass | |
| return has_num | |
| # --------------------------------------------------------------------------- # | |
| # Heuristic SQL generation (offline path) # | |
| # --------------------------------------------------------------------------- # | |
| # Tables ranked by usefulness for keyword fallback | |
| _PREFERRED_TABLES = ["bva", "extended_bva_v5", "expense_mb", "item_mb", | |
| "open_po", "opening_budget_fy_25_26", "opening_underspents"] | |
| def _pick_table(question: str) -> str: | |
| q = question.lower() | |
| # New analytical views (computed budget rollups) | |
| if ("ytd" in q or "year to date" in q or "balance" in q | |
| or ("summary" in q and ("budget" in q or "actual" in q))): | |
| return "SummaryBVA" | |
| if "finalbva" in q or ("rolled" in q and "budget" in q): | |
| return "finalbva5" | |
| if "open" in q and "po" in q or "purchase" in q or "vendor" in q: | |
| return "open_po" | |
| if "expense" in q: | |
| return "expense_mb" | |
| if "item" in q and "mb" in q: | |
| return "item_mb" | |
| if "underspent" in q or "underspend" in q: | |
| return "opening_underspents" | |
| if "opening" in q and "budget" in q: | |
| return "opening_budget_fy_25_26" | |
| if "extended" in q: | |
| return "extended_bva_v5" | |
| return "bva" | |
| def _pick_dim(table: str, question: str) -> str | None: | |
| q = question.lower() | |
| cols = _col_names(table) | |
| cmap = {c.lower(): c for c in cols} | |
| priority = [] | |
| for kw, cand in [ | |
| ("funder", "funder_name"), | |
| ("region", "region"), | |
| ("region", "regions"), | |
| ("project", "project_name"), | |
| ("category", "category_name"), | |
| ("vendor", "vendor_name"), | |
| ("state", "state"), | |
| ("status", "approval_status"), | |
| ("status", "purchase_order_status"), | |
| ("vertical", "vertical"), | |
| ("program", "program_type"), | |
| ("district", "district"), | |
| ("department", "department"), | |
| ]: | |
| if kw in q and cand in cmap: | |
| priority.append(cmap[cand]) | |
| if priority: | |
| return priority[0] | |
| for cand in ["funder_name", "region", "regions", "category_name", | |
| "project_name", "vendor_name", "approval_status", | |
| "vertical", "program_type", "state", "district"]: | |
| if cand in cmap: | |
| return cmap[cand] | |
| for c in _table_cols(table): | |
| n = c.get("name", "") | |
| if not n.lower().endswith("_id") and not _is_numeric_type(c.get("type")): | |
| return n | |
| return None | |
| _NUMERIC_TYPES = {"BIGINT", "DOUBLE", "INTEGER", "DECIMAL", "FLOAT", "REAL"} | |
| _AMOUNT_HINTS = ("amount", "total", "value", "price", "cost", "qty", "quantity", "actual", "budget") | |
| def _is_numeric_type(t: str | None) -> bool: | |
| u = (t or "").upper() | |
| return any(k in u for k in _NUMERIC_TYPES) | |
| def _measure_col(table: str) -> str: | |
| cols = _table_cols(table) | |
| cmap = {c.get("name", "").lower(): c.get("name", "") for c in cols} | |
| for cand in ["actual_total", "budget_total", "amount", "total_amount", | |
| "total_cost_amount", "value", "qty", "quantity"]: | |
| if cand in cmap: | |
| return cmap[cand] | |
| for c in cols: | |
| if _is_numeric_type(c.get("type")): | |
| return c["name"] | |
| for c in cols: | |
| n = (c.get("name") or "").lower() | |
| if any(h in n for h in _AMOUNT_HINTS) and not n.startswith("actual_0"): | |
| return c["name"] | |
| return "" | |
| def _has_monthly(table: str) -> bool: | |
| return any(c.startswith("actual_0") for c in _col_names(table)) | |
| def heuristic_primary_sql(question: str, table: str) -> tuple[str, str]: | |
| dim = _pick_dim(table, question) | |
| measure = _measure_col(table) | |
| if dim and measure: | |
| sql = ( | |
| f"SELECT COALESCE(CAST({dim} AS VARCHAR), '(unspecified)') AS {dim}, " | |
| f"ROUND(SUM(TRY_CAST({measure} AS DOUBLE))::DOUBLE, 2) AS {measure} " | |
| f"FROM {table} " | |
| f"WHERE {dim} IS NOT NULL " | |
| f"GROUP BY 1 " | |
| f"ORDER BY 2 DESC NULLS LAST " | |
| f"LIMIT 25" | |
| ) | |
| title = f"{dim} by {measure}" | |
| elif dim: | |
| sql = ( | |
| f"SELECT COALESCE(CAST({dim} AS VARCHAR), '(unspecified)') AS {dim}, " | |
| f"COUNT(*) AS row_count " | |
| f"FROM {table} " | |
| f"GROUP BY 1 " | |
| f"ORDER BY 2 DESC NULLS LAST " | |
| f"LIMIT 25" | |
| ) | |
| title = f"{dim} row counts" | |
| else: | |
| sql = f"SELECT * FROM {table} LIMIT 200" | |
| title = f"{table} preview" | |
| return title, sql | |
| def heuristic_secondaries(question: str, table: str) -> list[tuple[str, str]]: | |
| """Build 1β3 enrichment queries based on what columns the table actually has.""" | |
| out: list[tuple[str, str]] = [] | |
| cols = _col_names(table) | |
| cmap = {c.lower(): c for c in cols} | |
| measure = _measure_col(table) | |
| region_col = cmap.get("region") or cmap.get("regions") or "" | |
| # Monthly trend (FY26) when monthly columns exist and we have a measure | |
| if measure and _has_monthly(table): | |
| months = [ | |
| ("Apr-25", "actual_01042025"), ("May-25", "actual_01052025"), | |
| ("Jun-25", "actual_01062025"), ("Jul-25", "actual_01072025"), | |
| ("Aug-25", "actual_01082025"), ("Sep-25", "actual_01092025"), | |
| ("Oct-25", "actual_01102025"), ("Nov-25", "actual_01112025"), | |
| ("Dec-25", "actual_01122025"), ("Jan-26", "actual_01012026"), | |
| ("Feb-26", "actual_01022026"), ("Mar-26", "actual_01032026"), | |
| ] | |
| present = [(m, c) for m, c in months if c in cols] | |
| if present: | |
| union = " UNION ALL ".join( | |
| f"SELECT '{m}' AS month, {i:02d} AS ord, " | |
| f"ROUND(SUM(TRY_CAST({c} AS DOUBLE))::DOUBLE, 2) AS actual_inr FROM {table}" | |
| for i, (m, c) in enumerate(present, 1) | |
| ) | |
| out.append(( | |
| "Monthly burn β FY26", | |
| f"SELECT month, actual_inr FROM ({union}) ORDER BY ord" | |
| )) | |
| # Region breakdown | |
| if region_col and table != "open_po": | |
| if measure: | |
| out.append(( | |
| "Spend by region", | |
| f"SELECT COALESCE({region_col},'(unspecified)') AS region, " | |
| f"ROUND(SUM(TRY_CAST({measure} AS DOUBLE))::DOUBLE, 2) AS {measure} " | |
| f"FROM {table} GROUP BY 1 ORDER BY 2 DESC NULLS LAST LIMIT 12" | |
| )) | |
| else: | |
| out.append(( | |
| "Rows by region", | |
| f"SELECT COALESCE({region_col},'(unspecified)') AS region, " | |
| f"COUNT(*) AS row_count " | |
| f"FROM {table} GROUP BY 1 ORDER BY 2 DESC NULLS LAST LIMIT 12" | |
| )) | |
| elif region_col: # open_po: count rows by region | |
| out.append(( | |
| "Open POs by region", | |
| f"SELECT COALESCE({region_col},'(unspecified)') AS region, " | |
| f"COUNT(*) AS row_count FROM {table} GROUP BY 1 ORDER BY 2 DESC LIMIT 12" | |
| )) | |
| # Category mix | |
| if "category_name" in cmap and measure: | |
| cat_col = cmap["category_name"] | |
| out.append(( | |
| "Top categories", | |
| f"SELECT {cat_col} AS category_name, " | |
| f"ROUND(SUM(TRY_CAST({measure} AS DOUBLE))::DOUBLE, 2) AS {measure} " | |
| f"FROM {table} WHERE {cat_col} IS NOT NULL " | |
| f"GROUP BY 1 ORDER BY 2 DESC NULLS LAST LIMIT 15" | |
| )) | |
| # Funder mix (if not already the dimension) | |
| if "funder_name" in cmap and not re.search(r"funder", question, re.I): | |
| f_col = cmap["funder_name"] | |
| if measure: | |
| out.append(( | |
| "Top funders", | |
| f"SELECT {f_col} AS funder_name, " | |
| f"ROUND(SUM(TRY_CAST({measure} AS DOUBLE))::DOUBLE, 2) AS {measure} " | |
| f"FROM {table} WHERE {f_col} IS NOT NULL AND {f_col} <> 'NONE' " | |
| f"GROUP BY 1 ORDER BY 2 DESC NULLS LAST LIMIT 10" | |
| )) | |
| else: | |
| out.append(( | |
| "Funder distribution", | |
| f"SELECT {f_col} AS funder_name, COUNT(*) AS row_count " | |
| f"FROM {table} WHERE {f_col} IS NOT NULL AND {f_col} <> 'NONE' " | |
| f"GROUP BY 1 ORDER BY 2 DESC LIMIT 10" | |
| )) | |
| # Approval status mix for open_po | |
| if "approval_status" in cmap: | |
| out.append(( | |
| "PO approval status mix", | |
| f"SELECT COALESCE({cmap['approval_status']},'(unknown)') AS approval_status, " | |
| f"COUNT(*) AS row_count FROM {table} GROUP BY 1 ORDER BY 2 DESC LIMIT 10" | |
| )) | |
| return out[:3] | |
| # --------------------------------------------------------------------------- # | |
| # Public entry: assemble a UIPlan from result sets # | |
| # --------------------------------------------------------------------------- # | |
| class ResultSet: | |
| title: str | |
| sql: str | |
| rows: list[dict[str, Any]] | |
| table: str = "" | |
| _PERSONA_PALETTE = { | |
| "executive": ("#A8470F", "calm", "spacious"), | |
| "analyst": ("#F47C2C", "neutral", "comfortable"), | |
| "investigator":("#E63946", "urgent", "compact"), | |
| "storyteller": ("#FB9A2E", "celebratory", "spacious"), | |
| "auditor": ("#0EA5E9", "investigative", "compact"), | |
| "strategist": ("#A855F7", "calm", "comfortable"), | |
| } | |
| def infer_persona(question: str, intent: str = "exploratory") -> Persona: | |
| """Pick a persona from the question wording + intent.""" | |
| q = question.lower() | |
| name = "analyst" | |
| if any(k in q for k in ("forecast", "strategy", "scenario", "future", | |
| "what if", "5-year", "projection")): | |
| name = "strategist" | |
| elif any(k in q for k in ("over budget", "anomaly", "spike", "fraud", | |
| "irregular", "alert", "violation", "risk")): | |
| name = "investigator" | |
| elif any(k in q for k in ("compliance", "audit", "ytd", "balance", | |
| "reconcile", "validate", "verify")): | |
| name = "auditor" | |
| elif any(k in q for k in ("summary", "ceo", "executive", "headline", | |
| "overview", "snapshot", "kpi")): | |
| name = "executive" | |
| elif any(k in q for k in ("story", "highlight", "showcase", "impact", | |
| "achievement", "win", "celebrate")): | |
| name = "storyteller" | |
| elif intent == "comparative": | |
| name = "analyst" | |
| elif intent == "diagnostic": | |
| name = "investigator" | |
| accent, mood, density = _PERSONA_PALETTE[name] | |
| return Persona.model_validate({ | |
| "name": name, "accent": accent, "mood": mood, "density": density, | |
| }) | |
| def assemble_plan(question: str, results: list[ResultSet], | |
| intent: str = "exploratory") -> UIPlan: | |
| components: list[Component] = [] | |
| citations: list[Citation] = [] | |
| for i, rs in enumerate(results, 1): | |
| # Primary gets the full treatment; secondaries stay lean so the full | |
| # dashboard stays varied & digestible rather than repeating the same | |
| # KPI+bar+table+SQL quartet for every slice. | |
| comps = _components_for_result(i, rs.title, rs.sql, rs.rows, | |
| lean=(i > 1)) | |
| components.extend(comps) | |
| citations.append(Citation( | |
| id=f"src{i}", source=rs.table or "duckdb", | |
| row_ids=list(range(len(rs.rows))), | |
| snippet=rs.sql[:280], | |
| )) | |
| # Build a flowing 12-col grid: KPIs share rows; other charts vary by type. | |
| # Chart-type β (preferred width, height) for visual variety | |
| _CHART_SIZE: dict[str, tuple[int, int]] = { | |
| "kpi": (4, 2), | |
| "data-table": (12, 5), | |
| "raw-sql": (12, 3), | |
| "callout": (12, 3), | |
| "narrative": (12, 4), | |
| "line": (8, 5), | |
| "area": (8, 5), | |
| "bar": (6, 5), | |
| "stacked-bar": (8, 6), | |
| "pie": (4, 5), | |
| "scatter": (6, 5), | |
| "gauge": (4, 4), | |
| "treemap": (6, 6), | |
| "sunburst": (6, 6), | |
| "sankey": (12, 7), | |
| "heatmap": (8, 6), | |
| "funnel": (4, 5), | |
| "radar": (6, 5), | |
| "waterfall": (8, 5), | |
| "histogram": (6, 5), | |
| "network": (8, 6), | |
| "box": (6, 5), | |
| "parallel": (12, 6), | |
| } | |
| grid: list[GridCell] = [] | |
| cursor_y = 0 | |
| cursor_x = 0 | |
| for c in components: | |
| w, h = _CHART_SIZE.get(c.type, (6, 5)) | |
| if c.type == "kpi": | |
| if cursor_x + w > 12: | |
| cursor_x = 0 | |
| cursor_y += h | |
| grid.append(GridCell(id=f"g_{c.id}", component=c.id, w=w, h=h, | |
| x=cursor_x, y=cursor_y)) | |
| cursor_x += w | |
| else: | |
| if cursor_x != 0 and cursor_x + w > 12: | |
| cursor_y += 5 | |
| cursor_x = 0 | |
| grid.append(GridCell(id=f"g_{c.id}", component=c.id, w=w, h=h, | |
| x=cursor_x, y=cursor_y)) | |
| cursor_x += w | |
| if cursor_x >= 12: | |
| cursor_y += h | |
| cursor_x = 0 | |
| # Narrative summary built from results | |
| bits: list[str] = [] | |
| for rs in results[:3]: | |
| if not rs.rows: | |
| continue | |
| prof = profile(rs.rows) | |
| nums = [c for c, p in prof.items() if p.kind == "numeric"] | |
| cats = [c for c, p in prof.items() if p.kind == "categorical"] | |
| if nums and cats: | |
| tot = sum(float(r.get(nums[0]) or 0) for r in rs.rows) | |
| if tot == 0: | |
| # Skip zero-total results from the narrative | |
| continue | |
| top = max(rs.rows, key=lambda r: float(r.get(nums[0]) or 0)) | |
| bits.append( | |
| f"**{rs.title}** β {len(rs.rows)} rows, Ξ£{nums[0]} β βΉ{tot:,.0f}; " | |
| f"top {cats[0]}: *{top.get(cats[0])}*." | |
| ) | |
| else: | |
| bits.append(f"**{rs.title}** β {len(rs.rows)} rows.") | |
| if not bits: | |
| narrative = ( | |
| f"The query \"{question}\" could not be fully answered from the " | |
| f"available data. Some aspects may be outside the scope of the " | |
| f"current database. The results shown above are the closest " | |
| f"match available." | |
| ) | |
| else: | |
| narrative = " ".join(bits) | |
| return UIPlan( | |
| persona=infer_persona(question, intent), | |
| layout=Layout(grid=grid), | |
| components=components, | |
| narrative=narrative, | |
| citations=citations, | |
| ) | |
| def dynamic_offline_plan(question: str) -> UIPlan: | |
| """Heuristic, schema-aware plan when no LLM keys are configured.""" | |
| table = _pick_table(question) | |
| title_p, sql_p = heuristic_primary_sql(question, table) | |
| primary_rows = run_sql(sql_p).rows | |
| rs_primary = ResultSet(title=title_p, sql=sql_p, rows=primary_rows, table=table) | |
| extras = [] | |
| for ti, sq in heuristic_secondaries(question, table): | |
| r = run_sql(sq).rows | |
| if r: | |
| extras.append(ResultSet(title=ti, sql=sq, rows=r, table=table)) | |
| all_results = [rs_primary, *extras] | |
| # Filter out result sets with all-zero/null numeric data | |
| all_results = [rs for rs in all_results if rs.rows and not _all_zero_numeric(rs.rows)] | |
| if not all_results: | |
| # Nothing meaningful found β return a plan with a scope message | |
| all_results = [rs_primary] # keep primary for SQL provenance | |
| return assemble_plan(question, all_results) | |