diff --git a/package.json b/package.json index ea24fef6..685830bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.5.9", + "version": "1.5.10", "main": "index.ts", "license": "BUSL-1.1", "scripts": { diff --git a/src/integrations/vercel-ai/index.ts b/src/integrations/vercel-ai/index.ts index c9382eb0..e57cb9c8 100644 --- a/src/integrations/vercel-ai/index.ts +++ b/src/integrations/vercel-ai/index.ts @@ -1,7 +1,19 @@ -import { EventAddons, EventData } from '@hawk.so/types'; import { generateText } from 'ai'; -import { eventSolvingInput } from './inputs/eventSolving'; -import { ctoInstruction } from './instructions/cto'; + +/** + * Params for a single completion call to the model + */ +export interface CompletionParams { + /** + * System instruction that steers the model's behavior + */ + system: string; + + /** + * User-facing prompt describing what the model should complete + */ + prompt: string; +} /** * Interface for interacting with Vercel AI Gateway @@ -20,17 +32,17 @@ class VercelAIApi { } /** - * Generate AI suggestion for the event + * Send a system/prompt pair to the model and return the generated text * - * @param {EventData} payload - event data to make suggestion - * @returns {Promise} AI suggestion for the event + * @param {CompletionParams} params - system instruction and prompt to complete + * @returns {Promise} text generated by the model * @todo add defence against invalid prompt injection */ - public async generateSuggestion(payload: EventData) { + public async complete({ system, prompt }: CompletionParams): Promise { const { text } = await generateText({ model: this.modelId, - system: ctoInstruction, - prompt: eventSolvingInput(payload), + system, + prompt, providerOptions: { gateway: { order: ['novita', 'azure', 'deepseek'], diff --git a/src/services/ai.ts b/src/services/ai.ts index e366be28..34865507 100644 --- a/src/services/ai.ts +++ b/src/services/ai.ts @@ -1,4 +1,6 @@ import { vercelAIApi } from '../integrations/vercel-ai/'; +import { eventSolvingInput } from './askAi/inputs/eventSolving'; +import { ctoInstruction } from './askAi/instructions/cto'; import { EventsFactoryInterface } from './types'; /** @@ -20,7 +22,10 @@ export class AIService { throw new Error('Event not found'); } - return vercelAIApi.generateSuggestion(event.payload); + return vercelAIApi.complete({ + system: ctoInstruction, + prompt: eventSolvingInput(event.payload), + }); } } diff --git a/src/integrations/vercel-ai/inputs/eventSolving.ts b/src/services/askAi/inputs/eventSolving.ts similarity index 100% rename from src/integrations/vercel-ai/inputs/eventSolving.ts rename to src/services/askAi/inputs/eventSolving.ts diff --git a/src/integrations/vercel-ai/instructions/cto.ts b/src/services/askAi/instructions/cto.ts similarity index 100% rename from src/integrations/vercel-ai/instructions/cto.ts rename to src/services/askAi/instructions/cto.ts diff --git a/test/integrations/vercel-ai.test.ts b/test/integrations/vercel-ai.test.ts new file mode 100644 index 00000000..a6234705 --- /dev/null +++ b/test/integrations/vercel-ai.test.ts @@ -0,0 +1,41 @@ +import '../../src/env-test'; +import { generateText } from 'ai'; +import { vercelAIApi } from '../../src/integrations/vercel-ai/'; + +jest.mock('ai', () => ({ + generateText: jest.fn(), +})); + +describe('VercelAIApi', () => { + const testSystem = 'system instruction'; + const testPrompt = 'user prompt'; + const testModelId = 'deepseek/deepseek-v4-flash'; + const testProviderOptions = { + gateway: { + order: ['novita', 'azure', 'deepseek'], + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('complete', () => { + it('should forward the system/prompt pair to generateText and return its text', async () => { + (generateText as jest.Mock).mockResolvedValue({ text: 'model output' }); + + const result = await vercelAIApi.complete({ + system: testSystem, + prompt: testPrompt, + }); + + expect(generateText).toHaveBeenCalledWith({ + model: testModelId, + system: testSystem, + prompt: testPrompt, + providerOptions: testProviderOptions, + }); + expect(result).toBe('model output'); + }); + }); +}); diff --git a/test/services/askAi.test.ts b/test/services/askAi.test.ts new file mode 100644 index 00000000..e69d1a61 --- /dev/null +++ b/test/services/askAi.test.ts @@ -0,0 +1,63 @@ +import '../../src/env-test'; +import { EventAddons, EventData } from '@hawk.so/types'; +import { AIService } from '../../src/services/ai'; +import { vercelAIApi } from '../../src/integrations/vercel-ai/'; +import { ctoInstruction } from '../../src/services/askAi/instructions/cto'; +import { eventSolvingInput } from '../../src/services/askAi/inputs/eventSolving'; + +jest.mock('../../src/integrations/vercel-ai/', () => ({ + vercelAIApi: { + complete: jest.fn(), + }, +})); + +describe('AIService', () => { + let aiService: AIService; + const testEventId = 'repetition-id'; + const testOriginalEventId = 'original-event-id'; + const testPayload: EventData = { + title: 'TypeError: cannot read property of undefined', + }; + + /** + * Build a stub events factory returning the given event + * + * @param event - event repetition to resolve, or null when not found + * @returns {object} stub factory + */ + const createEventsFactory = (event: { _id: string; payload: EventData } | null) => ({ + getEventRepetition: jest.fn().mockResolvedValue(event), + }); + + const eventsFactoryWithPayload = (): ReturnType => createEventsFactory({ + _id: testEventId, + payload: testPayload, + }); + + beforeEach(() => { + jest.clearAllMocks(); + aiService = new AIService(); + }); + + describe('generateSuggestion', () => { + it('should send the instruction and serialized event to the transport and return its text unchanged', async () => { + (vercelAIApi.complete as jest.Mock).mockResolvedValue('generated suggestion'); + + const result = await aiService.generateSuggestion(eventsFactoryWithPayload(), testEventId, testOriginalEventId); + + expect(vercelAIApi.complete).toHaveBeenCalledWith({ + system: ctoInstruction, + prompt: eventSolvingInput(testPayload), + }); + expect(result).toBe('generated suggestion'); + }); + + it('should throw Event not found when the events factory returns nothing', async () => { + await expect( + aiService.generateSuggestion(createEventsFactory(null), testEventId, testOriginalEventId) + ).rejects.toThrow('Event not found'); + + expect(vercelAIApi.complete).not.toHaveBeenCalled(); + }); + }); +});