Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/services/service-ai/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,14 @@ export {
} from './tools/query-data.tool.js';
export type { QueryDataToolContext, QueryPlan } from './tools/query-data.tool.js';

// visualize_data tool (analytics aggregation → SDUI chart via `data-chart` part)
export {
VISUALIZE_DATA_TOOL,
createVisualizeDataHandler,
registerVisualizeDataTool,
} from './tools/visualize-data.tool.js';
export type { VisualizeDataToolContext } from './tools/visualize-data.tool.js';

// Routes
export { buildAIRoutes } from './routes/ai-routes.js';
export { buildAgentRoutes } from './routes/agent-routes.js';
Expand Down
29 changes: 26 additions & 3 deletions packages/services/service-ai/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

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

// Register visualize_data when an analytics service is available — it
// turns an aggregation into an SDUI chart that renders inline in chat
// (emitted as a `data-chart` stream part). Only needs analytics, so it
// sits outside the metadata gate below.
let analyticsService: IAnalyticsService | undefined;
try {
analyticsService = ctx.getService<IAnalyticsService>('analytics');
} catch {
analyticsService = undefined;
}
if (analyticsService) {
registerVisualizeDataTool(this.service.toolRegistry, { analytics: analyticsService });
ctx.logger.info('[AI] visualize_data tool registered');
} else {
ctx.logger.debug('[AI] No analytics service — visualize_data tool not registered');
}

// Register query_data tool when metadata service is also available —
// it composes AI + Metadata + Data into a single NL-to-records call.
if (metadataService) {
Expand Down Expand Up @@ -770,7 +788,12 @@ export class AIServicePlugin implements Plugin {
// Register data tools as metadata (for Studio visibility)
if (metadataService) {
const { DATA_TOOL_DEFINITIONS } = await import('./tools/data-tools.js');
for (const toolDef of DATA_TOOL_DEFINITIONS) {
// visualize_data is only usable (and only registered above) when an
// analytics service is present — persist it as metadata in lockstep.
const toolDefsToPersist = analyticsService
? [...DATA_TOOL_DEFINITIONS, VISUALIZE_DATA_TOOL]
: DATA_TOOL_DEFINITIONS;
for (const toolDef of toolDefsToPersist) {
const toolExists =
typeof metadataService.exists === 'function'
? await withTimeout(metadataService.exists('tool', toolDef.name))
Expand All @@ -794,7 +817,7 @@ export class AIServicePlugin implements Plugin {
}
}
}
ctx.logger.info(`[AI] ${DATA_TOOL_DEFINITIONS.length} data tools registered as metadata`);
ctx.logger.info(`[AI] ${toolDefsToPersist.length} data tools registered as metadata`);
}

// Register the built-in agent + skills (requires metadata service).
Expand Down
12 changes: 11 additions & 1 deletion packages/services/service-ai/src/skills/data-explorer-skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Capabilities:
- Query records with filters, sorting, and pagination
- Look up individual records by ID
- Perform aggregations and statistical analysis (count, sum, avg, min, max)
- Render results as a CHART when a visualization communicates the answer better than text

Guidelines:
1. Always use the describe_object tool first to understand a table's structure before querying it.
Expand All @@ -33,7 +34,8 @@ Guidelines:
4. When presenting data, format it in a clear and readable way using markdown tables or bullet lists.
5. For large result sets, summarize the data and mention the total count.
6. When performing aggregations, explain the results in plain language.
7. If a query returns no results, suggest possible reasons and alternative queries.
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.
8. If a query returns no results, suggest possible reasons and alternative queries.
8. Never expose internal IDs unless the user explicitly asks for them.
9. Always answer in the same language the user is using.`,
tools: [
Expand All @@ -43,6 +45,7 @@ Guidelines:
'query_records',
'get_record',
'aggregate_data',
'visualize_data',
],
triggerPhrases: [
'show me',
Expand All @@ -54,6 +57,13 @@ Guidelines:
'aggregate',
'sum',
'average',
'chart',
'plot',
'graph',
'visualize',
'trend',
'breakdown',
'distribution',
],
active: true,
};
175 changes: 175 additions & 0 deletions packages/services/service-ai/src/tools/visualize-data.tool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import type { AnalyticsQuery, AnalyticsResult } from '@objectstack/spec/contracts';
import { createVisualizeDataHandler, type VisualizeDataToolContext } from './visualize-data.tool.js';
import type { ToolExecutionContext } from './tool-registry.js';

/**
* `visualize_data` runs an analytics aggregation and emits the chart-ready
* result as a `data-chart` custom stream part (via `ctx.onProgress`), while
* returning a compact textual summary for the model to narrate. These tests
* pin both halves of that contract.
*/

/** Build a tool context whose analytics service records the query it received. */
function makeCtx(opts: {
result?: AnalyticsResult;
onQuery?: (q: AnalyticsQuery) => void;
throwError?: string;
}): VisualizeDataToolContext {
return {
analytics: {
query: async (q: AnalyticsQuery): Promise<AnalyticsResult> => {
opts.onQuery?.(q);
if (opts.throwError) throw new Error(opts.throwError);
return (
opts.result ?? {
rows: [
{ status: 'won', count: 5 },
{ status: 'lost', count: 2 },
],
fields: [
{ name: 'status', type: 'string' },
{ name: 'count', type: 'number', label: 'Count' },
],
}
);
},
getMeta: async () => [],
} as never,
};
}

/** Collect the `data-chart` parts emitted through onProgress. */
function makeExecCtx(): { ctx: ToolExecutionContext; parts: Array<{ type: string; id?: string; data?: unknown }> } {
const parts: Array<{ type: string; id?: string; data?: unknown }> = [];
const ctx: ToolExecutionContext = {
onProgress: (p) => parts.push(p),
};
return { ctx, parts };
}

describe('visualize_data', () => {
it('emits a data-chart part shaped for the SDUI <chart> renderer and returns a summary', async () => {
const handler = createVisualizeDataHandler(makeCtx({}));
const { ctx, parts } = makeExecCtx();

const out = JSON.parse(
(await handler(
{
objectName: 'opportunity',
dimension: 'status',
measures: [{ function: 'count' }],
chartType: 'bar',
title: 'Deals by status',
},
ctx,
)) as string,
);

// One data-chart part emitted, carrying the chart descriptor.
expect(parts).toHaveLength(1);
expect(parts[0].type).toBe('data-chart');
expect(parts[0].id).toBeTruthy();
const chart = parts[0].data as Record<string, any>;
expect(chart.type).toBe('chart');
expect(chart.chartType).toBe('bar');
expect(chart.title).toBe('Deals by status');
expect(chart.xAxisKey).toBe('status');
expect(chart.series).toEqual([{ dataKey: 'count', label: 'Count' }]);
expect(chart.data).toHaveLength(2);

// Textual summary for the model — names the chart, not a raw table dump.
expect(out.rendered).toBe('chart');
expect(out.categories).toBe(2);
expect(out.measures).toEqual(['count']);
});

it('maps function+field to the analytics suffix measure key (amount_sum)', async () => {
let received: AnalyticsQuery | undefined;
const handler = createVisualizeDataHandler(
makeCtx({
onQuery: (q) => (received = q),
result: {
rows: [{ region: 'NA', amount_sum: 1000 }],
fields: [
{ name: 'region', type: 'string' },
{ name: 'amount_sum', type: 'number' },
],
},
}),
);
const { ctx, parts } = makeExecCtx();

await handler(
{
objectName: 'order',
dimension: 'region',
measures: [{ function: 'sum', field: 'amount', label: 'Revenue' }],
where: { stage: { $ne: 'draft' } },
},
ctx,
);

expect(received?.cube).toBe('order');
expect(received?.measures).toEqual(['amount_sum']);
expect(received?.dimensions).toEqual(['region']);
expect(received?.where).toEqual({ stage: { $ne: 'draft' } });

// Series dataKey equals the measure key; the caller's label is preserved.
const chart = parts[0].data as Record<string, any>;
expect(chart.series).toEqual([{ dataKey: 'amount_sum', label: 'Revenue' }]);
});

it('defaults chartType to bar and supports multiple measures as series', async () => {
const handler = createVisualizeDataHandler(
makeCtx({
result: {
rows: [{ month: '2026-01', count: 10, amount_sum: 500 }],
fields: [],
},
}),
);
const { ctx, parts } = makeExecCtx();

await handler(
{
objectName: 'order',
dimension: 'month',
measures: [{ function: 'count' }, { function: 'sum', field: 'amount' }],
},
ctx,
);

const chart = parts[0].data as Record<string, any>;
expect(chart.chartType).toBe('bar');
expect(chart.series.map((s: any) => s.dataKey)).toEqual(['count', 'amount_sum']);
});

it('returns a structured error (no chart emitted) when the analytics query fails', async () => {
const handler = createVisualizeDataHandler(makeCtx({ throwError: 'no such cube' }));
const { ctx, parts } = makeExecCtx();

const out = JSON.parse(
(await handler(
{ objectName: 'ghost', dimension: 'x', measures: [{ function: 'count' }] },
ctx,
)) as string,
);

expect(parts).toHaveLength(0);
expect(out.error).toMatch(/Analytics query failed: no such cube/);
});

it('rejects calls with no valid measures', async () => {
const handler = createVisualizeDataHandler(makeCtx({}));
const { ctx, parts } = makeExecCtx();

const out = JSON.parse(
(await handler({ objectName: 'order', measures: [] }, ctx)) as string,
);
expect(parts).toHaveLength(0);
expect(out.error).toMatch(/at least one measure/i);
});
});
Loading