Skip to content

Commit abbf958

Browse files
os-zhuangclaude
andcommitted
feat(service-ai): visualize_data tool — return charts from AI data queries
Adds a `visualize_data` AI tool so the data-query assistant can answer with a CHART instead of plain text/markdown. The tool runs an aggregation through the existing analytics service (auto-inferred cube), maps the result into the SDUI `<chart>` contract, and emits it to the client as a `data-chart` custom stream part (same onProgress → Vercel UI-message-stream channel that `data-build-progress` already uses). It also returns a compact textual summary so the model narrates the answer alongside the rendered chart. - tools/visualize-data.tool.ts: VISUALIZE_DATA_TOOL + handler + register fn. function+field → analytics measure key (count / <field>_sum / …); single dimension → x-axis; measures → series; chartType (bar/line/pie/…). - plugin.ts: register when an analytics service is present; persist as tool metadata in lockstep (Studio visibility). - skills/data-explorer-skill.ts: expose visualize_data + chart trigger phrases and guidance to prefer it for "chart/plot/trend/breakdown" requests. Frontend rendering lands in objectui (plugin-chatbot). Verified end-to-end in the console against a live LLM: AI picks visualize_data → analytics aggregates → data-chart part → bar chart renders in the chat bubble. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0856476 commit abbf958

5 files changed

Lines changed: 560 additions & 4 deletions

File tree

packages/services/service-ai/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,14 @@ export {
100100
} from './tools/query-data.tool.js';
101101
export type { QueryDataToolContext, QueryPlan } from './tools/query-data.tool.js';
102102

103+
// visualize_data tool (analytics aggregation → SDUI chart via `data-chart` part)
104+
export {
105+
VISUALIZE_DATA_TOOL,
106+
createVisualizeDataHandler,
107+
registerVisualizeDataTool,
108+
} from './tools/visualize-data.tool.js';
109+
export type { VisualizeDataToolContext } from './tools/visualize-data.tool.js';
110+
103111
// Routes
104112
export { buildAIRoutes } from './routes/ai-routes.js';
105113
export { buildAgentRoutes } from './routes/agent-routes.js';

packages/services/service-ai/src/plugin.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import type { Plugin, PluginContext } from '@objectstack/core';
44
import { readEnvWithDeprecation } from '@objectstack/types';
5-
import type { IAIService, IAIConversationService, IAutomationService, IDataEngine, IEmbedder, IMetadataService, LLMAdapter } from '@objectstack/spec/contracts';
5+
import type { IAIService, IAIConversationService, IAnalyticsService, IAutomationService, IDataEngine, IEmbedder, IMetadataService, LLMAdapter } from '@objectstack/spec/contracts';
66
import { EMBEDDER_SERVICE } from '@objectstack/spec/contracts';
77
import type * as AI from '@objectstack/spec/ai';
88
import { AIService } from './ai-service.js';
@@ -20,6 +20,7 @@ import { AiTraceView, AiMessageView, AiPendingActionView, AiEvalCaseView, AiEval
2020
import { EvalRunner } from './eval/index.js';
2121
import { registerDataTools } from './tools/data-tools.js';
2222
import { registerQueryDataTool } from './tools/query-data.tool.js';
23+
import { registerVisualizeDataTool, VISUALIZE_DATA_TOOL } from './tools/visualize-data.tool.js';
2324
import { registerActionsAsTools } from './tools/action-tools.js';
2425
import { AgentRuntime } from './agent-runtime.js';
2526
import { SkillRegistry } from './skill-registry.js';
@@ -705,6 +706,23 @@ export class AIServicePlugin implements Plugin {
705706
});
706707
ctx.logger.info('[AI] Built-in data tools registered');
707708

709+
// Register visualize_data when an analytics service is available — it
710+
// turns an aggregation into an SDUI chart that renders inline in chat
711+
// (emitted as a `data-chart` stream part). Only needs analytics, so it
712+
// sits outside the metadata gate below.
713+
let analyticsService: IAnalyticsService | undefined;
714+
try {
715+
analyticsService = ctx.getService<IAnalyticsService>('analytics');
716+
} catch {
717+
analyticsService = undefined;
718+
}
719+
if (analyticsService) {
720+
registerVisualizeDataTool(this.service.toolRegistry, { analytics: analyticsService });
721+
ctx.logger.info('[AI] visualize_data tool registered');
722+
} else {
723+
ctx.logger.debug('[AI] No analytics service — visualize_data tool not registered');
724+
}
725+
708726
// Register query_data tool when metadata service is also available —
709727
// it composes AI + Metadata + Data into a single NL-to-records call.
710728
if (metadataService) {
@@ -770,7 +788,12 @@ export class AIServicePlugin implements Plugin {
770788
// Register data tools as metadata (for Studio visibility)
771789
if (metadataService) {
772790
const { DATA_TOOL_DEFINITIONS } = await import('./tools/data-tools.js');
773-
for (const toolDef of DATA_TOOL_DEFINITIONS) {
791+
// visualize_data is only usable (and only registered above) when an
792+
// analytics service is present — persist it as metadata in lockstep.
793+
const toolDefsToPersist = analyticsService
794+
? [...DATA_TOOL_DEFINITIONS, VISUALIZE_DATA_TOOL]
795+
: DATA_TOOL_DEFINITIONS;
796+
for (const toolDef of toolDefsToPersist) {
774797
const toolExists =
775798
typeof metadataService.exists === 'function'
776799
? await withTimeout(metadataService.exists('tool', toolDef.name))
@@ -794,7 +817,7 @@ export class AIServicePlugin implements Plugin {
794817
}
795818
}
796819
}
797-
ctx.logger.info(`[AI] ${DATA_TOOL_DEFINITIONS.length} data tools registered as metadata`);
820+
ctx.logger.info(`[AI] ${toolDefsToPersist.length} data tools registered as metadata`);
798821
}
799822

800823
// Register the built-in agent + skills (requires metadata service).

packages/services/service-ai/src/skills/data-explorer-skill.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Capabilities:
2525
- Query records with filters, sorting, and pagination
2626
- Look up individual records by ID
2727
- Perform aggregations and statistical analysis (count, sum, avg, min, max)
28+
- Render results as a CHART when a visualization communicates the answer better than text
2829
2930
Guidelines:
3031
1. Always use the describe_object tool first to understand a table's structure before querying it.
@@ -33,7 +34,8 @@ Guidelines:
3334
4. When presenting data, format it in a clear and readable way using markdown tables or bullet lists.
3435
5. For large result sets, summarize the data and mention the total count.
3536
6. When performing aggregations, explain the results in plain language.
36-
7. If a query returns no results, suggest possible reasons and alternative queries.
37+
7. Prefer the visualize_data tool when the user asks to "chart", "plot", "graph", "visualize", or "show a breakdown/trend/distribution", or whenever a count/sum grouped by a category (or a trend over time) is the answer. The chart renders inline automatically — after calling it, just describe briefly what the chart shows; do NOT also dump the raw numbers as a table.
38+
8. If a query returns no results, suggest possible reasons and alternative queries.
3739
8. Never expose internal IDs unless the user explicitly asks for them.
3840
9. Always answer in the same language the user is using.`,
3941
tools: [
@@ -43,6 +45,7 @@ Guidelines:
4345
'query_records',
4446
'get_record',
4547
'aggregate_data',
48+
'visualize_data',
4649
],
4750
triggerPhrases: [
4851
'show me',
@@ -54,6 +57,13 @@ Guidelines:
5457
'aggregate',
5558
'sum',
5659
'average',
60+
'chart',
61+
'plot',
62+
'graph',
63+
'visualize',
64+
'trend',
65+
'breakdown',
66+
'distribution',
5767
],
5868
active: true,
5969
};
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import type { AnalyticsQuery, AnalyticsResult } from '@objectstack/spec/contracts';
5+
import { createVisualizeDataHandler, type VisualizeDataToolContext } from './visualize-data.tool.js';
6+
import type { ToolExecutionContext } from './tool-registry.js';
7+
8+
/**
9+
* `visualize_data` runs an analytics aggregation and emits the chart-ready
10+
* result as a `data-chart` custom stream part (via `ctx.onProgress`), while
11+
* returning a compact textual summary for the model to narrate. These tests
12+
* pin both halves of that contract.
13+
*/
14+
15+
/** Build a tool context whose analytics service records the query it received. */
16+
function makeCtx(opts: {
17+
result?: AnalyticsResult;
18+
onQuery?: (q: AnalyticsQuery) => void;
19+
throwError?: string;
20+
}): VisualizeDataToolContext {
21+
return {
22+
analytics: {
23+
query: async (q: AnalyticsQuery): Promise<AnalyticsResult> => {
24+
opts.onQuery?.(q);
25+
if (opts.throwError) throw new Error(opts.throwError);
26+
return (
27+
opts.result ?? {
28+
rows: [
29+
{ status: 'won', count: 5 },
30+
{ status: 'lost', count: 2 },
31+
],
32+
fields: [
33+
{ name: 'status', type: 'string' },
34+
{ name: 'count', type: 'number', label: 'Count' },
35+
],
36+
}
37+
);
38+
},
39+
getMeta: async () => [],
40+
} as never,
41+
};
42+
}
43+
44+
/** Collect the `data-chart` parts emitted through onProgress. */
45+
function makeExecCtx(): { ctx: ToolExecutionContext; parts: Array<{ type: string; id?: string; data?: unknown }> } {
46+
const parts: Array<{ type: string; id?: string; data?: unknown }> = [];
47+
const ctx: ToolExecutionContext = {
48+
onProgress: (p) => parts.push(p),
49+
};
50+
return { ctx, parts };
51+
}
52+
53+
describe('visualize_data', () => {
54+
it('emits a data-chart part shaped for the SDUI <chart> renderer and returns a summary', async () => {
55+
const handler = createVisualizeDataHandler(makeCtx({}));
56+
const { ctx, parts } = makeExecCtx();
57+
58+
const out = JSON.parse(
59+
(await handler(
60+
{
61+
objectName: 'opportunity',
62+
dimension: 'status',
63+
measures: [{ function: 'count' }],
64+
chartType: 'bar',
65+
title: 'Deals by status',
66+
},
67+
ctx,
68+
)) as string,
69+
);
70+
71+
// One data-chart part emitted, carrying the chart descriptor.
72+
expect(parts).toHaveLength(1);
73+
expect(parts[0].type).toBe('data-chart');
74+
expect(parts[0].id).toBeTruthy();
75+
const chart = parts[0].data as Record<string, any>;
76+
expect(chart.type).toBe('chart');
77+
expect(chart.chartType).toBe('bar');
78+
expect(chart.title).toBe('Deals by status');
79+
expect(chart.xAxisKey).toBe('status');
80+
expect(chart.series).toEqual([{ dataKey: 'count', label: 'Count' }]);
81+
expect(chart.data).toHaveLength(2);
82+
83+
// Textual summary for the model — names the chart, not a raw table dump.
84+
expect(out.rendered).toBe('chart');
85+
expect(out.categories).toBe(2);
86+
expect(out.measures).toEqual(['count']);
87+
});
88+
89+
it('maps function+field to the analytics suffix measure key (amount_sum)', async () => {
90+
let received: AnalyticsQuery | undefined;
91+
const handler = createVisualizeDataHandler(
92+
makeCtx({
93+
onQuery: (q) => (received = q),
94+
result: {
95+
rows: [{ region: 'NA', amount_sum: 1000 }],
96+
fields: [
97+
{ name: 'region', type: 'string' },
98+
{ name: 'amount_sum', type: 'number' },
99+
],
100+
},
101+
}),
102+
);
103+
const { ctx, parts } = makeExecCtx();
104+
105+
await handler(
106+
{
107+
objectName: 'order',
108+
dimension: 'region',
109+
measures: [{ function: 'sum', field: 'amount', label: 'Revenue' }],
110+
where: { stage: { $ne: 'draft' } },
111+
},
112+
ctx,
113+
);
114+
115+
expect(received?.cube).toBe('order');
116+
expect(received?.measures).toEqual(['amount_sum']);
117+
expect(received?.dimensions).toEqual(['region']);
118+
expect(received?.where).toEqual({ stage: { $ne: 'draft' } });
119+
120+
// Series dataKey equals the measure key; the caller's label is preserved.
121+
const chart = parts[0].data as Record<string, any>;
122+
expect(chart.series).toEqual([{ dataKey: 'amount_sum', label: 'Revenue' }]);
123+
});
124+
125+
it('defaults chartType to bar and supports multiple measures as series', async () => {
126+
const handler = createVisualizeDataHandler(
127+
makeCtx({
128+
result: {
129+
rows: [{ month: '2026-01', count: 10, amount_sum: 500 }],
130+
fields: [],
131+
},
132+
}),
133+
);
134+
const { ctx, parts } = makeExecCtx();
135+
136+
await handler(
137+
{
138+
objectName: 'order',
139+
dimension: 'month',
140+
measures: [{ function: 'count' }, { function: 'sum', field: 'amount' }],
141+
},
142+
ctx,
143+
);
144+
145+
const chart = parts[0].data as Record<string, any>;
146+
expect(chart.chartType).toBe('bar');
147+
expect(chart.series.map((s: any) => s.dataKey)).toEqual(['count', 'amount_sum']);
148+
});
149+
150+
it('returns a structured error (no chart emitted) when the analytics query fails', async () => {
151+
const handler = createVisualizeDataHandler(makeCtx({ throwError: 'no such cube' }));
152+
const { ctx, parts } = makeExecCtx();
153+
154+
const out = JSON.parse(
155+
(await handler(
156+
{ objectName: 'ghost', dimension: 'x', measures: [{ function: 'count' }] },
157+
ctx,
158+
)) as string,
159+
);
160+
161+
expect(parts).toHaveLength(0);
162+
expect(out.error).toMatch(/Analytics query failed: no such cube/);
163+
});
164+
165+
it('rejects calls with no valid measures', async () => {
166+
const handler = createVisualizeDataHandler(makeCtx({}));
167+
const { ctx, parts } = makeExecCtx();
168+
169+
const out = JSON.parse(
170+
(await handler({ objectName: 'order', measures: [] }, ctx)) as string,
171+
);
172+
expect(parts).toHaveLength(0);
173+
expect(out.error).toMatch(/at least one measure/i);
174+
});
175+
});

0 commit comments

Comments
 (0)