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
94 changes: 94 additions & 0 deletions packages/spec/src/contracts/ai-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, it, expect } from 'vitest';
import type { IAIService, AIMessage, AIResult } from './ai-service';

describe('AI Service Contract', () => {
it('should allow a minimal IAIService implementation with required methods', () => {
const service: IAIService = {
chat: async (_messages, _options?) => ({ content: '' }),
complete: async (_prompt, _options?) => ({ content: '' }),
};

expect(typeof service.chat).toBe('function');
expect(typeof service.complete).toBe('function');
});

it('should allow a full implementation with optional methods', () => {
const service: IAIService = {
chat: async () => ({ content: '' }),
complete: async () => ({ content: '' }),
embed: async () => [[]],
listModels: async () => [],
};

expect(service.embed).toBeDefined();
expect(service.listModels).toBeDefined();
});

it('should generate a chat completion', async () => {
const service: IAIService = {
chat: async (messages): Promise<AIResult> => {
const lastMessage = messages[messages.length - 1];
return {
content: `Echo: ${lastMessage.content}`,
model: 'test-model',
usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 },
};
},
complete: async () => ({ content: '' }),
};

const messages: AIMessage[] = [
{ role: 'system', content: 'You are a helpful assistant.' },
{ role: 'user', content: 'Hello' },
];

const result = await service.chat(messages);
expect(result.content).toBe('Echo: Hello');
expect(result.model).toBe('test-model');
expect(result.usage?.totalTokens).toBe(15);
});

it('should generate a text completion', async () => {
const service: IAIService = {
chat: async () => ({ content: '' }),
complete: async (prompt, options?): Promise<AIResult> => ({
content: `Completed: ${prompt}`,
model: options?.model ?? 'default',
}),
};

const result = await service.complete('The sky is', { model: 'gpt-4', maxTokens: 50 });
expect(result.content).toContain('The sky is');
expect(result.model).toBe('gpt-4');
});

it('should generate embeddings', async () => {
const service: IAIService = {
chat: async () => ({ content: '' }),
complete: async () => ({ content: '' }),
embed: async (input) => {
const texts = Array.isArray(input) ? input : [input];
return texts.map(() => [0.1, 0.2, 0.3]);
},
};

const embeddings = await service.embed!('Hello world');
expect(embeddings).toHaveLength(1);
expect(embeddings[0]).toEqual([0.1, 0.2, 0.3]);

const batch = await service.embed!(['Hello', 'World']);
expect(batch).toHaveLength(2);
});

it('should list available models', async () => {
const service: IAIService = {
chat: async () => ({ content: '' }),
complete: async () => ({ content: '' }),
listModels: async () => ['gpt-4', 'gpt-3.5-turbo', 'claude-3-sonnet'],
};

const models = await service.listModels!();
expect(models).toHaveLength(3);
expect(models).toContain('gpt-4');
});
});
86 changes: 86 additions & 0 deletions packages/spec/src/contracts/ai-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* IAIService - AI Engine Service Contract
*
* Defines the interface for AI capabilities (NLQ, chat, suggestions, embeddings)
* in ObjectStack. Concrete implementations (OpenAI, Anthropic, Ollama, etc.)
* should implement this interface.
*
* Follows Dependency Inversion Principle - plugins depend on this interface,
* not on concrete AI/LLM provider implementations.
*
* Aligned with CoreServiceName 'ai' in core-services.zod.ts.
*/

/**
* A chat message in a conversation
*/
export interface AIMessage {
/** Message role */
role: 'system' | 'user' | 'assistant';
/** Message content */
content: string;
}

/**
* Options for AI completion/chat requests
*/
export interface AIRequestOptions {
/** Model identifier to use */
model?: string;
/** Sampling temperature (0-2) */
temperature?: number;
/** Maximum tokens to generate */
maxTokens?: number;
/** Stop sequences */
stop?: string[];
}

/**
* Result of an AI completion/chat request
*/
export interface AIResult {
/** Generated text content */
content: string;
/** Model used for generation */
model?: string;
/** Token usage statistics */
usage?: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
};
}

export interface IAIService {
/**
* Generate a chat completion from a conversation
* @param messages - Array of conversation messages
* @param options - Optional request configuration
* @returns AI-generated response
*/
chat(messages: AIMessage[], options?: AIRequestOptions): Promise<AIResult>;

/**
* Generate a text completion from a prompt
* @param prompt - Input prompt string
* @param options - Optional request configuration
* @returns AI-generated response
*/
complete(prompt: string, options?: AIRequestOptions): Promise<AIResult>;

/**
* Generate embeddings for a text input
* @param input - Text or array of texts to embed
* @param model - Optional embedding model identifier
* @returns Array of embedding vectors
*/
embed?(input: string | string[], model?: string): Promise<number[][]>;

/**
* List available models
* @returns Array of model identifiers
*/
listModels?(): Promise<string[]>;
}
91 changes: 91 additions & 0 deletions packages/spec/src/contracts/analytics-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, it, expect } from 'vitest';
import type { IAnalyticsService, AnalyticsResult, CubeMeta } from './analytics-service';

describe('Analytics Service Contract', () => {
it('should allow a minimal IAnalyticsService implementation with required methods', () => {
const service: IAnalyticsService = {
query: async (_query) => ({ rows: [], fields: [] }),
getMeta: async () => [],
};

expect(typeof service.query).toBe('function');
expect(typeof service.getMeta).toBe('function');
});

it('should allow a full implementation with optional methods', () => {
const service: IAnalyticsService = {
query: async () => ({ rows: [], fields: [] }),
getMeta: async () => [],
generateSql: async () => ({ sql: 'SELECT 1', params: [] }),
};

expect(service.generateSql).toBeDefined();
});

it('should execute an analytics query', async () => {
const service: IAnalyticsService = {
query: async (query): Promise<AnalyticsResult> => ({
rows: [
{ 'orders.status': 'active', 'orders.count': 42 },
{ 'orders.status': 'closed', 'orders.count': 18 },
],
fields: [
{ name: 'orders.status', type: 'string' },
{ name: 'orders.count', type: 'number' },
],
}),
getMeta: async () => [],
};

const result = await service.query({
cube: 'orders',
measures: ['orders.count'],
dimensions: ['orders.status'],
});

expect(result.rows).toHaveLength(2);
expect(result.fields).toHaveLength(2);
expect(result.rows[0]['orders.count']).toBe(42);
});

it('should return cube metadata', async () => {
const cubes: CubeMeta[] = [{
name: 'orders',
title: 'Orders',
measures: [{ name: 'orders.count', type: 'count' }],
dimensions: [{ name: 'orders.status', type: 'string' }],
}];

const service: IAnalyticsService = {
query: async () => ({ rows: [], fields: [] }),
getMeta: async (cubeName?) => {
if (cubeName) return cubes.filter(c => c.name === cubeName);
return cubes;
},
};

const meta = await service.getMeta();
expect(meta).toHaveLength(1);
expect(meta[0].name).toBe('orders');
expect(meta[0].measures).toHaveLength(1);
});

it('should generate SQL without executing', async () => {
const service: IAnalyticsService = {
query: async () => ({ rows: [], fields: [] }),
getMeta: async () => [],
generateSql: async (query) => ({
sql: `SELECT COUNT(*) FROM ${query.cube}`,
params: [],
}),
};

const result = await service.generateSql!({
cube: 'orders',
measures: ['orders.count'],
});

expect(result.sql).toContain('orders');
expect(result.params).toEqual([]);
});
});
94 changes: 94 additions & 0 deletions packages/spec/src/contracts/analytics-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* IAnalyticsService - Analytics / BI Service Contract
*
* Defines the interface for analytical query execution and semantic layer
* metadata discovery in ObjectStack. Concrete implementations (Cube.js, custom, etc.)
* should implement this interface.
*
* Follows Dependency Inversion Principle - plugins depend on this interface,
* not on concrete analytics engine implementations.
*
* Aligned with CoreServiceName 'analytics' in core-services.zod.ts.
*/

/**
* An analytical query definition
*/
export interface AnalyticsQuery {
/** Target cube name */
cube: string;
/** Measures to compute (e.g. ['orders.count', 'orders.totalRevenue']) */
measures?: string[];
/** Dimensions to group by (e.g. ['orders.status', 'orders.createdAt']) */
dimensions?: string[];
/** Filter conditions */
filters?: Array<{
member: string;
operator: string;
values?: string[];
}>;
/** Time dimension configuration */
timeDimensions?: Array<{
dimension: string;
granularity?: string;
dateRange?: string | string[];
}>;
/** Result limit */
limit?: number;
/** Result offset */
offset?: number;
}

/**
* Analytics query result
*/
export interface AnalyticsResult {
/** Result rows */
rows: Record<string, unknown>[];
/** Column metadata */
fields: Array<{
name: string;
type: string;
}>;
/** Generated SQL (if available) */
sql?: string;
}

/**
* Cube metadata for discovery
*/
export interface CubeMeta {
/** Cube name */
name: string;
/** Human-readable title */
title?: string;
/** Available measures */
measures: Array<{ name: string; type: string; title?: string }>;
/** Available dimensions */
dimensions: Array<{ name: string; type: string; title?: string }>;
}

export interface IAnalyticsService {
/**
* Execute an analytical query
* @param query - The analytics query definition
* @returns Query results with rows and field metadata
*/
query(query: AnalyticsQuery): Promise<AnalyticsResult>;

/**
* Get available cube metadata for discovery
* @param cubeName - Optional cube name to filter (returns all if omitted)
* @returns Array of cube metadata definitions
*/
getMeta(cubeName?: string): Promise<CubeMeta[]>;

/**
* Generate SQL for a query without executing it (dry-run)
* @param query - The analytics query definition
* @returns Generated SQL string and parameters
*/
generateSql?(query: AnalyticsQuery): Promise<{ sql: string; params: unknown[] }>;
}
Loading
Loading