Skip to content

Commit 33646a7

Browse files
authored
Merge pull request #600 from objectstack-ai/copilot/evaluate-service-standards
2 parents e972e4c + bd5ae6b commit 33646a7

23 files changed

Lines changed: 1861 additions & 0 deletions
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { describe, it, expect } from 'vitest';
2+
import type { IAIService, AIMessage, AIResult } from './ai-service';
3+
4+
describe('AI Service Contract', () => {
5+
it('should allow a minimal IAIService implementation with required methods', () => {
6+
const service: IAIService = {
7+
chat: async (_messages, _options?) => ({ content: '' }),
8+
complete: async (_prompt, _options?) => ({ content: '' }),
9+
};
10+
11+
expect(typeof service.chat).toBe('function');
12+
expect(typeof service.complete).toBe('function');
13+
});
14+
15+
it('should allow a full implementation with optional methods', () => {
16+
const service: IAIService = {
17+
chat: async () => ({ content: '' }),
18+
complete: async () => ({ content: '' }),
19+
embed: async () => [[]],
20+
listModels: async () => [],
21+
};
22+
23+
expect(service.embed).toBeDefined();
24+
expect(service.listModels).toBeDefined();
25+
});
26+
27+
it('should generate a chat completion', async () => {
28+
const service: IAIService = {
29+
chat: async (messages): Promise<AIResult> => {
30+
const lastMessage = messages[messages.length - 1];
31+
return {
32+
content: `Echo: ${lastMessage.content}`,
33+
model: 'test-model',
34+
usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 },
35+
};
36+
},
37+
complete: async () => ({ content: '' }),
38+
};
39+
40+
const messages: AIMessage[] = [
41+
{ role: 'system', content: 'You are a helpful assistant.' },
42+
{ role: 'user', content: 'Hello' },
43+
];
44+
45+
const result = await service.chat(messages);
46+
expect(result.content).toBe('Echo: Hello');
47+
expect(result.model).toBe('test-model');
48+
expect(result.usage?.totalTokens).toBe(15);
49+
});
50+
51+
it('should generate a text completion', async () => {
52+
const service: IAIService = {
53+
chat: async () => ({ content: '' }),
54+
complete: async (prompt, options?): Promise<AIResult> => ({
55+
content: `Completed: ${prompt}`,
56+
model: options?.model ?? 'default',
57+
}),
58+
};
59+
60+
const result = await service.complete('The sky is', { model: 'gpt-4', maxTokens: 50 });
61+
expect(result.content).toContain('The sky is');
62+
expect(result.model).toBe('gpt-4');
63+
});
64+
65+
it('should generate embeddings', async () => {
66+
const service: IAIService = {
67+
chat: async () => ({ content: '' }),
68+
complete: async () => ({ content: '' }),
69+
embed: async (input) => {
70+
const texts = Array.isArray(input) ? input : [input];
71+
return texts.map(() => [0.1, 0.2, 0.3]);
72+
},
73+
};
74+
75+
const embeddings = await service.embed!('Hello world');
76+
expect(embeddings).toHaveLength(1);
77+
expect(embeddings[0]).toEqual([0.1, 0.2, 0.3]);
78+
79+
const batch = await service.embed!(['Hello', 'World']);
80+
expect(batch).toHaveLength(2);
81+
});
82+
83+
it('should list available models', async () => {
84+
const service: IAIService = {
85+
chat: async () => ({ content: '' }),
86+
complete: async () => ({ content: '' }),
87+
listModels: async () => ['gpt-4', 'gpt-3.5-turbo', 'claude-3-sonnet'],
88+
};
89+
90+
const models = await service.listModels!();
91+
expect(models).toHaveLength(3);
92+
expect(models).toContain('gpt-4');
93+
});
94+
});
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* IAIService - AI Engine Service Contract
5+
*
6+
* Defines the interface for AI capabilities (NLQ, chat, suggestions, embeddings)
7+
* in ObjectStack. Concrete implementations (OpenAI, Anthropic, Ollama, etc.)
8+
* should implement this interface.
9+
*
10+
* Follows Dependency Inversion Principle - plugins depend on this interface,
11+
* not on concrete AI/LLM provider implementations.
12+
*
13+
* Aligned with CoreServiceName 'ai' in core-services.zod.ts.
14+
*/
15+
16+
/**
17+
* A chat message in a conversation
18+
*/
19+
export interface AIMessage {
20+
/** Message role */
21+
role: 'system' | 'user' | 'assistant';
22+
/** Message content */
23+
content: string;
24+
}
25+
26+
/**
27+
* Options for AI completion/chat requests
28+
*/
29+
export interface AIRequestOptions {
30+
/** Model identifier to use */
31+
model?: string;
32+
/** Sampling temperature (0-2) */
33+
temperature?: number;
34+
/** Maximum tokens to generate */
35+
maxTokens?: number;
36+
/** Stop sequences */
37+
stop?: string[];
38+
}
39+
40+
/**
41+
* Result of an AI completion/chat request
42+
*/
43+
export interface AIResult {
44+
/** Generated text content */
45+
content: string;
46+
/** Model used for generation */
47+
model?: string;
48+
/** Token usage statistics */
49+
usage?: {
50+
promptTokens: number;
51+
completionTokens: number;
52+
totalTokens: number;
53+
};
54+
}
55+
56+
export interface IAIService {
57+
/**
58+
* Generate a chat completion from a conversation
59+
* @param messages - Array of conversation messages
60+
* @param options - Optional request configuration
61+
* @returns AI-generated response
62+
*/
63+
chat(messages: AIMessage[], options?: AIRequestOptions): Promise<AIResult>;
64+
65+
/**
66+
* Generate a text completion from a prompt
67+
* @param prompt - Input prompt string
68+
* @param options - Optional request configuration
69+
* @returns AI-generated response
70+
*/
71+
complete(prompt: string, options?: AIRequestOptions): Promise<AIResult>;
72+
73+
/**
74+
* Generate embeddings for a text input
75+
* @param input - Text or array of texts to embed
76+
* @param model - Optional embedding model identifier
77+
* @returns Array of embedding vectors
78+
*/
79+
embed?(input: string | string[], model?: string): Promise<number[][]>;
80+
81+
/**
82+
* List available models
83+
* @returns Array of model identifiers
84+
*/
85+
listModels?(): Promise<string[]>;
86+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { describe, it, expect } from 'vitest';
2+
import type { IAnalyticsService, AnalyticsResult, CubeMeta } from './analytics-service';
3+
4+
describe('Analytics Service Contract', () => {
5+
it('should allow a minimal IAnalyticsService implementation with required methods', () => {
6+
const service: IAnalyticsService = {
7+
query: async (_query) => ({ rows: [], fields: [] }),
8+
getMeta: async () => [],
9+
};
10+
11+
expect(typeof service.query).toBe('function');
12+
expect(typeof service.getMeta).toBe('function');
13+
});
14+
15+
it('should allow a full implementation with optional methods', () => {
16+
const service: IAnalyticsService = {
17+
query: async () => ({ rows: [], fields: [] }),
18+
getMeta: async () => [],
19+
generateSql: async () => ({ sql: 'SELECT 1', params: [] }),
20+
};
21+
22+
expect(service.generateSql).toBeDefined();
23+
});
24+
25+
it('should execute an analytics query', async () => {
26+
const service: IAnalyticsService = {
27+
query: async (query): Promise<AnalyticsResult> => ({
28+
rows: [
29+
{ 'orders.status': 'active', 'orders.count': 42 },
30+
{ 'orders.status': 'closed', 'orders.count': 18 },
31+
],
32+
fields: [
33+
{ name: 'orders.status', type: 'string' },
34+
{ name: 'orders.count', type: 'number' },
35+
],
36+
}),
37+
getMeta: async () => [],
38+
};
39+
40+
const result = await service.query({
41+
cube: 'orders',
42+
measures: ['orders.count'],
43+
dimensions: ['orders.status'],
44+
});
45+
46+
expect(result.rows).toHaveLength(2);
47+
expect(result.fields).toHaveLength(2);
48+
expect(result.rows[0]['orders.count']).toBe(42);
49+
});
50+
51+
it('should return cube metadata', async () => {
52+
const cubes: CubeMeta[] = [{
53+
name: 'orders',
54+
title: 'Orders',
55+
measures: [{ name: 'orders.count', type: 'count' }],
56+
dimensions: [{ name: 'orders.status', type: 'string' }],
57+
}];
58+
59+
const service: IAnalyticsService = {
60+
query: async () => ({ rows: [], fields: [] }),
61+
getMeta: async (cubeName?) => {
62+
if (cubeName) return cubes.filter(c => c.name === cubeName);
63+
return cubes;
64+
},
65+
};
66+
67+
const meta = await service.getMeta();
68+
expect(meta).toHaveLength(1);
69+
expect(meta[0].name).toBe('orders');
70+
expect(meta[0].measures).toHaveLength(1);
71+
});
72+
73+
it('should generate SQL without executing', async () => {
74+
const service: IAnalyticsService = {
75+
query: async () => ({ rows: [], fields: [] }),
76+
getMeta: async () => [],
77+
generateSql: async (query) => ({
78+
sql: `SELECT COUNT(*) FROM ${query.cube}`,
79+
params: [],
80+
}),
81+
};
82+
83+
const result = await service.generateSql!({
84+
cube: 'orders',
85+
measures: ['orders.count'],
86+
});
87+
88+
expect(result.sql).toContain('orders');
89+
expect(result.params).toEqual([]);
90+
});
91+
});
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* IAnalyticsService - Analytics / BI Service Contract
5+
*
6+
* Defines the interface for analytical query execution and semantic layer
7+
* metadata discovery in ObjectStack. Concrete implementations (Cube.js, custom, etc.)
8+
* should implement this interface.
9+
*
10+
* Follows Dependency Inversion Principle - plugins depend on this interface,
11+
* not on concrete analytics engine implementations.
12+
*
13+
* Aligned with CoreServiceName 'analytics' in core-services.zod.ts.
14+
*/
15+
16+
/**
17+
* An analytical query definition
18+
*/
19+
export interface AnalyticsQuery {
20+
/** Target cube name */
21+
cube: string;
22+
/** Measures to compute (e.g. ['orders.count', 'orders.totalRevenue']) */
23+
measures?: string[];
24+
/** Dimensions to group by (e.g. ['orders.status', 'orders.createdAt']) */
25+
dimensions?: string[];
26+
/** Filter conditions */
27+
filters?: Array<{
28+
member: string;
29+
operator: string;
30+
values?: string[];
31+
}>;
32+
/** Time dimension configuration */
33+
timeDimensions?: Array<{
34+
dimension: string;
35+
granularity?: string;
36+
dateRange?: string | string[];
37+
}>;
38+
/** Result limit */
39+
limit?: number;
40+
/** Result offset */
41+
offset?: number;
42+
}
43+
44+
/**
45+
* Analytics query result
46+
*/
47+
export interface AnalyticsResult {
48+
/** Result rows */
49+
rows: Record<string, unknown>[];
50+
/** Column metadata */
51+
fields: Array<{
52+
name: string;
53+
type: string;
54+
}>;
55+
/** Generated SQL (if available) */
56+
sql?: string;
57+
}
58+
59+
/**
60+
* Cube metadata for discovery
61+
*/
62+
export interface CubeMeta {
63+
/** Cube name */
64+
name: string;
65+
/** Human-readable title */
66+
title?: string;
67+
/** Available measures */
68+
measures: Array<{ name: string; type: string; title?: string }>;
69+
/** Available dimensions */
70+
dimensions: Array<{ name: string; type: string; title?: string }>;
71+
}
72+
73+
export interface IAnalyticsService {
74+
/**
75+
* Execute an analytical query
76+
* @param query - The analytics query definition
77+
* @returns Query results with rows and field metadata
78+
*/
79+
query(query: AnalyticsQuery): Promise<AnalyticsResult>;
80+
81+
/**
82+
* Get available cube metadata for discovery
83+
* @param cubeName - Optional cube name to filter (returns all if omitted)
84+
* @returns Array of cube metadata definitions
85+
*/
86+
getMeta(cubeName?: string): Promise<CubeMeta[]>;
87+
88+
/**
89+
* Generate SQL for a query without executing it (dry-run)
90+
* @param query - The analytics query definition
91+
* @returns Generated SQL string and parameters
92+
*/
93+
generateSql?(query: AnalyticsQuery): Promise<{ sql: string; params: unknown[] }>;
94+
}

0 commit comments

Comments
 (0)