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
74 changes: 43 additions & 31 deletions src/integrations/vercel-ai/index.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,57 @@
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
*/
class VercelAIApi {
/**
* Model ID to use for generating suggestions
*/
private readonly modelId: string;

constructor() {
/**
* @todo make it dynamic, get from project settings
*/
this.modelId = 'deepseek/deepseek-v4-flash';
}
/**
* Model ID to use for generating suggestions
*/
private readonly modelId: string;

constructor() {
/**
* Generate AI suggestion for the event
*
* @param {EventData<EventAddons>} payload - event data to make suggestion
* @returns {Promise<string>} AI suggestion for the event
* @todo add defence against invalid prompt injection
* @todo make it dynamic, get from project settings
*/
public async generateSuggestion(payload: EventData<EventAddons>) {
const { text } = await generateText({
model: this.modelId,
system: ctoInstruction,
prompt: eventSolvingInput(payload),
providerOptions: {
gateway: {
order: ['novita', 'azure', 'deepseek'],
},
this.modelId = 'deepseek/deepseek-v4-flash';
}

/**
* Send a system/prompt pair to the model and return the generated text
*
* @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 complete({ system, prompt }: CompletionParams): Promise<string> {
const { text } = await generateText({
model: this.modelId,
system,
prompt,
providerOptions: {
gateway: {
order: ['novita', 'azure', 'deepseek'],
},
});
},
});

return text;
}
return text;
}
}

export const vercelAIApi = new VercelAIApi();
4 changes: 2 additions & 2 deletions src/resolvers/event.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ const {
parseBulkEventIds,
enqueueAssigneeNotification,
} = require('./helpers/bulkEventUtils');
const { aiService } = require('../services/ai');
const { askAiService } = require('../services/askAi');
const { UserInputError } = require('apollo-server-express');
const { ObjectId } = require('mongodb');

Expand Down Expand Up @@ -106,7 +106,7 @@ module.exports = {
async aiSuggestion({ projectId, _id: eventId, originalEventId }, _args, context) {
const factory = getEventsFactory(context, projectId);

return aiService.generateSuggestion(factory, eventId, originalEventId);
return askAiService.generateSuggestion(factory, eventId, originalEventId);
},

/**
Expand Down
1 change: 1 addition & 0 deletions src/services/askAi/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { AskAiService, askAiService } from './service';
15 changes: 10 additions & 5 deletions src/services/ai.ts → src/services/askAi/service.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { vercelAIApi } from '../integrations/vercel-ai/';
import { EventsFactoryInterface } from './types';
import { vercelAIApi } from '../../integrations/vercel-ai/';
import { eventSolvingInput } from './inputs/eventSolving';
import { ctoInstruction } from './instructions/cto';
import { EventsFactoryInterface } from '../types';

/**
* Service for interacting with AI
*/
export class AIService {
export class AskAiService {
/**
* Generate suggestion for the event
*
Expand All @@ -20,8 +22,11 @@ export class AIService {
throw new Error('Event not found');
}

return vercelAIApi.generateSuggestion(event.payload);
return vercelAIApi.complete({
system: ctoInstruction,
prompt: eventSolvingInput(event.payload),
});
}
}

export const aiService = new AIService();
export const askAiService = new AskAiService();
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');
});
});
});
65 changes: 65 additions & 0 deletions test/services/askAi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import '../../src/env-test';
import { EventAddons, EventData } from '@hawk.so/types';
import { AskAiService } from '../../src/services/askAi/service';
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('AskAiService', () => {
let askAiService: AskAiService;
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.Mock } => ({
getEventRepetition: jest.fn().mockResolvedValue(event),
});

const eventsFactoryWithPayload = (): ReturnType<typeof createEventsFactory> => createEventsFactory({
_id: testEventId,
payload: testPayload,
});

beforeEach(() => {
jest.clearAllMocks();
askAiService = new AskAiService();
});

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 eventsFactory = eventsFactoryWithPayload();

const result = await askAiService.generateSuggestion(eventsFactory, testEventId, testOriginalEventId);

expect(eventsFactory.getEventRepetition).toHaveBeenCalledWith(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(
askAiService.generateSuggestion(createEventsFactory(null), testEventId, testOriginalEventId)
).rejects.toThrow('Event not found');

expect(vercelAIApi.complete).not.toHaveBeenCalled();
});
});
});
Loading