Skip to content

Commit 3ce8bcc

Browse files
feat(genie): add automatic chart visualization for query results (#146)
* docs: regenerate API docs for genie plugin export Signed-off-by: Jorge Calvar <jorge.calvar@databricks.com> * docs(appkit): add @internal to genie export and document plugin in plugins.md Add @internal JSDoc to genie const to exclude it from generated API docs. Add Genie plugin section to plugins.md covering configuration, endpoints, SSE events, and programmatic usage. Signed-off-by: Jorge Calvar <jorge.calvar@databricks.com> * docs: remove Variable.genie from generated API docs sidebar and index Signed-off-by: Jorge Calvar <jorge.calvar@databricks.com> * refactor(genie): extract pollWaiter to simplify _handleSendMessage Replace manual concurrency code (statusQueue, notifyGenerator, waiterDone, waiterError, IIFE promise chain) with a reusable pollWaiter async generator that bridges callback-based waiter.wait({ onProgress }) into a for-await-of loop. Signed-off-by: Jorge Calvar <jorge.calvar@databricks.com> * feat(genie): add automatic chart visualization for query results - Infer chart type (bar, line, pie, scatter, table) from query column metadata - Transform Genie query data with column categorization and date parsing - Render charts inline in chat messages via GenieQueryVisualization component - Add chart-inference dev playground page and unit tests Signed-off-by: Jorge Calvar <jorge.calvar@databricks.com> * Refactor Genie into Genie connector (#145) * feat(appkit-ui): add Genie chat React components and dev-playground demo Add plug-and-play React components for Genie AI/BI chat: - useGenieChat hook: manages SSE streaming, conversation persistence via URL params, and history replay on page refresh - GenieChat: all-in-one component wiring hook + UI - GenieChatMessage: renders messages with markdown (via marked), avatars, and collapsible SQL query attachments - GenieChatMessageList: scrollable message list with auto-scroll, loading skeletons, and streaming status indicator - GenieChatInput: textarea with Enter-to-send and auto-resize Also adds Genie demo page to dev-playground at /genie and fixes conversation history ordering in the backend (reverse to chronological). Signed-off-by: Jorge Calvar <jorge.calvar@databricks.com> * fix(appkit-ui): address Genie chat UI review feedback - Rename GENIE_SPACE_ID to DATABRICKS_GENIE_SPACE_ID in env and code - Hide textarea scrollbar; only show overflow-y when content exceeds max height - Skip rendering empty assistant bubbles during loading, show only the spinner - Remove shadow from nested SQL query cards to fix corner shadow artifacts - Move "New conversation" button to top-right of chat widget Signed-off-by: Jorge Calvar <jorge.calvar@databricks.com> * docs: add Genie component documentation with examples Extend the doc generator to scan genie, multi-genie, and chat component directories. Add JSDoc descriptions to all Genie components and props, create usage examples for GenieChat and MultiGenieChat, and generate 8 new doc pages under a "Genie components" sidebar category. Signed-off-by: Jorge Calvar <jorge.calvar@databricks.com> * feat(genie): send requestId query param from frontend for SSE reconnection Generate a UUID per request in useGenieChat and pass it as ?requestId to the sendMessage and loadHistory SSE endpoints. This allows the server to use a stable streamId for reconnection and missed-event replay. Signed-off-by: Jorge Calvar <jorge.calvar@databricks.com> * chore: genie connector * refactor(genie): replace sendMessage with streaming implementation The old non-streaming sendMessage (returning Promise<GenieMessageResponse>) is replaced by the streaming version (returning AsyncGenerator<GenieStreamEvent>). Signed-off-by: Jorge Calvar <jorge.calvar@databricks.com> --------- Signed-off-by: Jorge Calvar <jorge.calvar@databricks.com> Co-authored-by: MarioCadenas <MarioCadenas@users.noreply.github.com> * chore: remove crypto/index.ts * fix(appkit-ui): sort numeric x-data and fix axis labels for scatter/timeseries charts Scatter and timeseries charts had two issues: x-axis data arrived in arbitrary row order from query results, and axis labels were hidden due to axisLabel being set to undefined. Sort numeric x-data in the normalization layer and explicitly enable axis labels in the renderer. Signed-off-by: Jorge Calvar <jorge.calvar@databricks.com> * fix(appkit-ui): skip pie chart for negative values and clean up base chart Add negative value check to chart inference so pie is not chosen when data contains negative numbers (falls through to bar). Simplify xField destructuring in BaseChart. Signed-off-by: Jorge Calvar <jorge.calvar@databricks.com> * fix(appkit-ui): add GenieStatementResponse type and preserve y-value types in sortNumericAscending - Add GenieStatementResponse interface in shared package to replace unknown types - Update transformGenieData, GenieQueryVisualization, and GenieMessageItem to use typed data - Fix sortNumericAscending to preserve original y-value types instead of coercing to Number Signed-off-by: Jorge Calvar <jorge.calvar@databricks.com> * docs: add GenieQueryVisualization API reference Signed-off-by: Jorge Calvar <jorge.calvar@databricks.com> --------- Signed-off-by: Jorge Calvar <jorge.calvar@databricks.com> Co-authored-by: MarioCadenas <MarioCadenas@users.noreply.github.com>
1 parent c4dfc35 commit 3ce8bcc

19 files changed

Lines changed: 1342 additions & 46 deletions

File tree

apps/dev-playground/client/src/routeTree.gen.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { Route as ReconnectRouteRouteImport } from './routes/reconnect.route'
1616
import { Route as LakebaseRouteRouteImport } from './routes/lakebase.route'
1717
import { Route as GenieRouteRouteImport } from './routes/genie.route'
1818
import { Route as DataVisualizationRouteRouteImport } from './routes/data-visualization.route'
19+
import { Route as ChartInferenceRouteRouteImport } from './routes/chart-inference.route'
1920
import { Route as ArrowAnalyticsRouteRouteImport } from './routes/arrow-analytics.route'
2021
import { Route as AnalyticsRouteRouteImport } from './routes/analytics.route'
2122
import { Route as IndexRouteImport } from './routes/index'
@@ -55,6 +56,11 @@ const DataVisualizationRouteRoute = DataVisualizationRouteRouteImport.update({
5556
path: '/data-visualization',
5657
getParentRoute: () => rootRouteImport,
5758
} as any)
59+
const ChartInferenceRouteRoute = ChartInferenceRouteRouteImport.update({
60+
id: '/chart-inference',
61+
path: '/chart-inference',
62+
getParentRoute: () => rootRouteImport,
63+
} as any)
5864
const ArrowAnalyticsRouteRoute = ArrowAnalyticsRouteRouteImport.update({
5965
id: '/arrow-analytics',
6066
path: '/arrow-analytics',
@@ -75,6 +81,7 @@ export interface FileRoutesByFullPath {
7581
'/': typeof IndexRoute
7682
'/analytics': typeof AnalyticsRouteRoute
7783
'/arrow-analytics': typeof ArrowAnalyticsRouteRoute
84+
'/chart-inference': typeof ChartInferenceRouteRoute
7885
'/data-visualization': typeof DataVisualizationRouteRoute
7986
'/genie': typeof GenieRouteRoute
8087
'/lakebase': typeof LakebaseRouteRoute
@@ -87,6 +94,7 @@ export interface FileRoutesByTo {
8794
'/': typeof IndexRoute
8895
'/analytics': typeof AnalyticsRouteRoute
8996
'/arrow-analytics': typeof ArrowAnalyticsRouteRoute
97+
'/chart-inference': typeof ChartInferenceRouteRoute
9098
'/data-visualization': typeof DataVisualizationRouteRoute
9199
'/genie': typeof GenieRouteRoute
92100
'/lakebase': typeof LakebaseRouteRoute
@@ -100,6 +108,7 @@ export interface FileRoutesById {
100108
'/': typeof IndexRoute
101109
'/analytics': typeof AnalyticsRouteRoute
102110
'/arrow-analytics': typeof ArrowAnalyticsRouteRoute
111+
'/chart-inference': typeof ChartInferenceRouteRoute
103112
'/data-visualization': typeof DataVisualizationRouteRoute
104113
'/genie': typeof GenieRouteRoute
105114
'/lakebase': typeof LakebaseRouteRoute
@@ -114,6 +123,7 @@ export interface FileRouteTypes {
114123
| '/'
115124
| '/analytics'
116125
| '/arrow-analytics'
126+
| '/chart-inference'
117127
| '/data-visualization'
118128
| '/genie'
119129
| '/lakebase'
@@ -126,6 +136,7 @@ export interface FileRouteTypes {
126136
| '/'
127137
| '/analytics'
128138
| '/arrow-analytics'
139+
| '/chart-inference'
129140
| '/data-visualization'
130141
| '/genie'
131142
| '/lakebase'
@@ -138,6 +149,7 @@ export interface FileRouteTypes {
138149
| '/'
139150
| '/analytics'
140151
| '/arrow-analytics'
152+
| '/chart-inference'
141153
| '/data-visualization'
142154
| '/genie'
143155
| '/lakebase'
@@ -151,6 +163,7 @@ export interface RootRouteChildren {
151163
IndexRoute: typeof IndexRoute
152164
AnalyticsRouteRoute: typeof AnalyticsRouteRoute
153165
ArrowAnalyticsRouteRoute: typeof ArrowAnalyticsRouteRoute
166+
ChartInferenceRouteRoute: typeof ChartInferenceRouteRoute
154167
DataVisualizationRouteRoute: typeof DataVisualizationRouteRoute
155168
GenieRouteRoute: typeof GenieRouteRoute
156169
LakebaseRouteRoute: typeof LakebaseRouteRoute
@@ -211,6 +224,13 @@ declare module '@tanstack/react-router' {
211224
preLoaderRoute: typeof DataVisualizationRouteRouteImport
212225
parentRoute: typeof rootRouteImport
213226
}
227+
'/chart-inference': {
228+
id: '/chart-inference'
229+
path: '/chart-inference'
230+
fullPath: '/chart-inference'
231+
preLoaderRoute: typeof ChartInferenceRouteRouteImport
232+
parentRoute: typeof rootRouteImport
233+
}
214234
'/arrow-analytics': {
215235
id: '/arrow-analytics'
216236
path: '/arrow-analytics'
@@ -239,6 +259,7 @@ const rootRouteChildren: RootRouteChildren = {
239259
IndexRoute: IndexRoute,
240260
AnalyticsRouteRoute: AnalyticsRouteRoute,
241261
ArrowAnalyticsRouteRoute: ArrowAnalyticsRouteRoute,
262+
ChartInferenceRouteRoute: ChartInferenceRouteRoute,
242263
DataVisualizationRouteRoute: DataVisualizationRouteRoute,
243264
GenieRouteRoute: GenieRouteRoute,
244265
LakebaseRouteRoute: LakebaseRouteRoute,

apps/dev-playground/client/src/routes/__root.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,14 @@ function RootComponent() {
8888
Genie
8989
</Button>
9090
</Link>
91+
<Link to="/chart-inference" className="no-underline">
92+
<Button
93+
variant="ghost"
94+
className="text-foreground hover:text-secondary-foreground"
95+
>
96+
Chart Inference
97+
</Button>
98+
</Link>
9199
<ThemeSelector />
92100
</div>
93101
</nav>
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
import {
2+
Card,
3+
GenieQueryVisualization,
4+
inferChartType,
5+
transformGenieData,
6+
} from "@databricks/appkit-ui/react";
7+
import { createFileRoute } from "@tanstack/react-router";
8+
import { useMemo } from "react";
9+
10+
export const Route = createFileRoute("/chart-inference")({
11+
component: ChartInferenceRoute,
12+
});
13+
14+
// ---------------------------------------------------------------------------
15+
// Helper to build a Genie-shaped statement_response from simple definitions
16+
// ---------------------------------------------------------------------------
17+
18+
interface SampleColumn {
19+
name: string;
20+
type_name: string;
21+
}
22+
23+
function makeStatementResponse(
24+
columns: SampleColumn[],
25+
rows: (string | null)[][],
26+
) {
27+
return {
28+
manifest: { schema: { columns } },
29+
result: { data_array: rows },
30+
};
31+
}
32+
33+
// ---------------------------------------------------------------------------
34+
// Sample datasets — one per inference rule
35+
// ---------------------------------------------------------------------------
36+
37+
const SAMPLES: {
38+
title: string;
39+
description: string;
40+
expected: string;
41+
data: ReturnType<typeof makeStatementResponse>;
42+
}[] = [
43+
{
44+
title: "Timeseries (date + revenue)",
45+
description: "Rule 1: DATE + numeric → line chart",
46+
expected: "line",
47+
data: makeStatementResponse(
48+
[
49+
{ name: "date", type_name: "DATE" },
50+
{ name: "revenue", type_name: "DECIMAL" },
51+
],
52+
[
53+
["2024-01-01", "12000"],
54+
["2024-02-01", "15500"],
55+
["2024-03-01", "13200"],
56+
["2024-04-01", "17800"],
57+
["2024-05-01", "19200"],
58+
["2024-06-01", "21000"],
59+
["2024-07-01", "18500"],
60+
["2024-08-01", "22100"],
61+
["2024-09-01", "24500"],
62+
["2024-10-01", "23000"],
63+
["2024-11-01", "26800"],
64+
["2024-12-01", "29000"],
65+
],
66+
),
67+
},
68+
{
69+
title: "Few categories (region + sales)",
70+
description: "Rule 2: STRING + 1 numeric, 3 categories → pie chart",
71+
expected: "pie",
72+
data: makeStatementResponse(
73+
[
74+
{ name: "region", type_name: "STRING" },
75+
{ name: "sales", type_name: "DECIMAL" },
76+
],
77+
[
78+
["North America", "45000"],
79+
["Europe", "32000"],
80+
["Asia Pacific", "28000"],
81+
],
82+
),
83+
},
84+
{
85+
title: "Moderate categories (product + revenue)",
86+
description: "Rule 3: STRING + 1 numeric, 15 categories → bar chart",
87+
expected: "bar",
88+
data: makeStatementResponse(
89+
[
90+
{ name: "product", type_name: "STRING" },
91+
{ name: "revenue", type_name: "DECIMAL" },
92+
],
93+
Array.from({ length: 15 }, (_, i) => [
94+
`Product ${String.fromCharCode(65 + i)}`,
95+
String(Math.round(5000 + Math.sin(i) * 3000)),
96+
]),
97+
),
98+
},
99+
{
100+
title: "Many categories (city + population)",
101+
description: "Rule 4: STRING + 1 numeric, 150 categories → line chart",
102+
expected: "line",
103+
data: makeStatementResponse(
104+
[
105+
{ name: "city", type_name: "STRING" },
106+
{ name: "population", type_name: "INT" },
107+
],
108+
Array.from({ length: 150 }, (_, i) => [
109+
`City ${i + 1}`,
110+
String(Math.round(10000 + Math.random() * 90000)),
111+
]),
112+
),
113+
},
114+
{
115+
title: "Multi-series timeseries (month + revenue + cost)",
116+
description: "Rule 1: DATE + multiple numerics → line chart",
117+
expected: "line",
118+
data: makeStatementResponse(
119+
[
120+
{ name: "month", type_name: "DATE" },
121+
{ name: "revenue", type_name: "DECIMAL" },
122+
{ name: "cost", type_name: "DECIMAL" },
123+
],
124+
[
125+
["2024-01-01", "12000", "8000"],
126+
["2024-02-01", "15500", "9200"],
127+
["2024-03-01", "13200", "8800"],
128+
["2024-04-01", "17800", "10500"],
129+
["2024-05-01", "19200", "11000"],
130+
["2024-06-01", "21000", "12500"],
131+
],
132+
),
133+
},
134+
{
135+
title: "Grouped bar (department + budget + actual)",
136+
description: "Rule 5: STRING + N numerics, 8 categories → bar chart",
137+
expected: "bar",
138+
data: makeStatementResponse(
139+
[
140+
{ name: "department", type_name: "STRING" },
141+
{ name: "budget", type_name: "DECIMAL" },
142+
{ name: "actual", type_name: "DECIMAL" },
143+
],
144+
[
145+
["Engineering", "500000", "480000"],
146+
["Marketing", "300000", "320000"],
147+
["Sales", "400000", "410000"],
148+
["Support", "200000", "190000"],
149+
["HR", "150000", "145000"],
150+
["Finance", "180000", "175000"],
151+
["Legal", "120000", "115000"],
152+
["Operations", "250000", "240000"],
153+
],
154+
),
155+
},
156+
{
157+
title: "Scatter (height + weight)",
158+
description: "Rule 7: 2 numerics only → scatter chart",
159+
expected: "scatter",
160+
data: makeStatementResponse(
161+
[
162+
{ name: "height_cm", type_name: "DOUBLE" },
163+
{ name: "weight_kg", type_name: "DOUBLE" },
164+
],
165+
Array.from({ length: 30 }, (_, i) => [
166+
String(150 + i * 1.2),
167+
String(Math.round(45 + i * 1.5 + (Math.random() - 0.5) * 10)),
168+
]),
169+
),
170+
},
171+
{
172+
title: "Single row (name + value)",
173+
description: "Skip: < 2 rows → table only",
174+
expected: "none (table only)",
175+
data: makeStatementResponse(
176+
[
177+
{ name: "metric", type_name: "STRING" },
178+
{ name: "value", type_name: "DECIMAL" },
179+
],
180+
[["Total Revenue", "125000"]],
181+
),
182+
},
183+
{
184+
title: "All strings (first_name + last_name + city)",
185+
description: "Skip: no numeric columns → table only",
186+
expected: "none (table only)",
187+
data: makeStatementResponse(
188+
[
189+
{ name: "first_name", type_name: "STRING" },
190+
{ name: "last_name", type_name: "STRING" },
191+
{ name: "city", type_name: "STRING" },
192+
],
193+
[
194+
["Alice", "Smith", "New York"],
195+
["Bob", "Jones", "London"],
196+
["Carol", "Lee", "Tokyo"],
197+
],
198+
),
199+
},
200+
];
201+
202+
// ---------------------------------------------------------------------------
203+
// Per-sample card component
204+
// ---------------------------------------------------------------------------
205+
206+
function SampleCard({
207+
title,
208+
description,
209+
expected,
210+
data,
211+
}: (typeof SAMPLES)[number]) {
212+
const transformed = useMemo(() => transformGenieData(data), [data]);
213+
const inference = useMemo(
214+
() =>
215+
transformed
216+
? inferChartType(transformed.rows, transformed.columns)
217+
: null,
218+
[transformed],
219+
);
220+
221+
return (
222+
<Card className="p-6 flex flex-col gap-4">
223+
<div>
224+
<h3 className="text-lg font-semibold">{title}</h3>
225+
<p className="text-sm text-muted-foreground">{description}</p>
226+
</div>
227+
228+
<div className="flex gap-4 text-xs">
229+
<span>
230+
<strong>Expected:</strong> {expected}
231+
</span>
232+
<span>
233+
<strong>Inferred:</strong>{" "}
234+
{inference
235+
? `${inference.chartType} (x: ${inference.xKey}, y: ${Array.isArray(inference.yKey) ? inference.yKey.join(", ") : inference.yKey})`
236+
: "null (no chart)"}
237+
</span>
238+
<span>
239+
<strong>Rows:</strong> {transformed?.rows.length ?? 0}
240+
</span>
241+
<span>
242+
<strong>Columns:</strong> {transformed?.columns.length ?? 0}
243+
</span>
244+
</div>
245+
246+
<GenieQueryVisualization data={data} />
247+
</Card>
248+
);
249+
}
250+
251+
// ---------------------------------------------------------------------------
252+
// Route component
253+
// ---------------------------------------------------------------------------
254+
255+
function ChartInferenceRoute() {
256+
return (
257+
<div className="min-h-screen bg-background">
258+
<main className="max-w-5xl mx-auto px-6 py-12">
259+
<div className="flex flex-col gap-6">
260+
<div>
261+
<h1 className="text-3xl font-bold tracking-tight text-foreground">
262+
Chart Inference Demo
263+
</h1>
264+
<p className="text-muted-foreground mt-2">
265+
Sample datasets exercising each Genie chart inference rule. Each
266+
card shows the inferred chart type, axes, and the rendered
267+
visualization.
268+
</p>
269+
</div>
270+
271+
<div className="flex flex-col gap-6">
272+
{SAMPLES.map((sample) => (
273+
<SampleCard key={sample.title} {...sample} />
274+
))}
275+
</div>
276+
</div>
277+
</main>
278+
</div>
279+
);
280+
}

0 commit comments

Comments
 (0)