Skip to content
Open
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hawk.api",
"version": "1.5.9",
"version": "1.5.10",
"main": "index.ts",
"license": "BUSL-1.1",
"scripts": {
Expand Down
30 changes: 21 additions & 9 deletions src/integrations/vercel-ai/index.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -20,17 +32,17 @@ class VercelAIApi {
}

Comment on lines 32 to 33
/**
* Generate AI suggestion for the event
* Send a system/prompt pair to the model and return the generated text
*
* @param {EventData<EventAddons>} payload - event data to make suggestion
* @returns {Promise<string>} AI suggestion for the event
* @param {CompletionParams} params - system instruction and prompt to complete
* @returns {Promise<string>} text generated by the model
* @todo add defence against invalid prompt injection
*/
public async generateSuggestion(payload: EventData<EventAddons>) {
public async complete({ system, prompt }: CompletionParams): Promise<string> {
const { text } = await generateText({
model: this.modelId,
system: ctoInstruction,
prompt: eventSolvingInput(payload),
system,
prompt,
providerOptions: {
gateway: {
order: ['novita', 'azure', 'deepseek'],
Expand Down
7 changes: 6 additions & 1 deletion src/services/ai.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -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),
});
}
}

Expand Down
41 changes: 41 additions & 0 deletions test/integrations/vercel-ai.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
63 changes: 63 additions & 0 deletions test/services/askAi.test.ts
Original file line number Diff line number Diff line change
@@ -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<EventAddons> = {
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<EventAddons> } | null) => ({
getEventRepetition: jest.fn().mockResolvedValue(event),
});

const eventsFactoryWithPayload = (): ReturnType<typeof createEventsFactory> => 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),
Comment on lines +46 to +50
});
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();
});
});
});
Loading