Skip to content

Commit e9f6570

Browse files
committed
refactor(ai): move Ask AI domain out of the adapter
The Ask AI prompt assembly, the model instruction and the event serialisation live in src/integrations/vercel-ai/ although none of them import anything from the provider. Replacing the provider drags the domain with it, and anything applied around the model call can only be reused by importing from another adapter's internals. Move those modules to src/services/askAi/ and reduce VercelAIApi to a transport taking a system/prompt pair. The service moves with them and is renamed to AskAiService, which is why src/resolvers/event.js changes too.
1 parent 6a616ca commit e9f6570

9 files changed

Lines changed: 163 additions & 39 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "hawk.api",
3-
"version": "1.5.11",
3+
"version": "1.5.12",
44
"main": "index.ts",
55
"license": "BUSL-1.1",
66
"scripts": {
Lines changed: 43 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,57 @@
1-
import { EventAddons, EventData } from '@hawk.so/types';
21
import { generateText } from 'ai';
3-
import { eventSolvingInput } from './inputs/eventSolving';
4-
import { ctoInstruction } from './instructions/cto';
2+
3+
/**
4+
* Params for a single completion call to the model
5+
*/
6+
export interface CompletionParams {
7+
/**
8+
* System instruction that steers the model's behavior
9+
*/
10+
system: string;
11+
12+
/**
13+
* User-facing prompt describing what the model should complete
14+
*/
15+
prompt: string;
16+
}
517

618
/**
719
* Interface for interacting with Vercel AI Gateway
820
*/
921
class VercelAIApi {
10-
/**
11-
* Model ID to use for generating suggestions
12-
*/
13-
private readonly modelId: string;
14-
15-
constructor() {
16-
/**
17-
* @todo make it dynamic, get from project settings
18-
*/
19-
this.modelId = 'deepseek/deepseek-v4-flash';
20-
}
22+
/**
23+
* Model ID to use for generating suggestions
24+
*/
25+
private readonly modelId: string;
2126

27+
constructor() {
2228
/**
23-
* Generate AI suggestion for the event
24-
*
25-
* @param {EventData<EventAddons>} payload - event data to make suggestion
26-
* @returns {Promise<string>} AI suggestion for the event
27-
* @todo add defence against invalid prompt injection
29+
* @todo make it dynamic, get from project settings
2830
*/
29-
public async generateSuggestion(payload: EventData<EventAddons>) {
30-
const { text } = await generateText({
31-
model: this.modelId,
32-
system: ctoInstruction,
33-
prompt: eventSolvingInput(payload),
34-
providerOptions: {
35-
gateway: {
36-
order: ['novita', 'azure', 'deepseek'],
37-
},
31+
this.modelId = 'deepseek/deepseek-v4-flash';
32+
}
33+
34+
/**
35+
* Send a system/prompt pair to the model and return the generated text
36+
*
37+
* @param {CompletionParams} params - system instruction and prompt to complete
38+
* @returns {Promise<string>} text generated by the model
39+
* @todo add defence against invalid prompt injection
40+
*/
41+
public async complete({ system, prompt }: CompletionParams): Promise<string> {
42+
const { text } = await generateText({
43+
model: this.modelId,
44+
system,
45+
prompt,
46+
providerOptions: {
47+
gateway: {
48+
order: ['novita', 'azure', 'deepseek'],
3849
},
39-
});
50+
},
51+
});
4052

41-
return text;
42-
}
53+
return text;
54+
}
4355
}
4456

4557
export const vercelAIApi = new VercelAIApi();

src/resolvers/event.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ const {
33
parseBulkEventIds,
44
enqueueAssigneeNotification,
55
} = require('./helpers/bulkEventUtils');
6-
const { aiService } = require('../services/ai');
6+
const { askAiService } = require('../services/askAi');
77
const { UserInputError } = require('apollo-server-express');
88
const { ObjectId } = require('mongodb');
99

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

109-
return aiService.generateSuggestion(factory, eventId, originalEventId);
109+
return askAiService.generateSuggestion(factory, eventId, originalEventId);
110110
},
111111

112112
/**

src/services/askAi/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { AskAiService, askAiService } from './service';
File renamed without changes.
File renamed without changes.
Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
import { vercelAIApi } from '../integrations/vercel-ai/';
2-
import { EventsFactoryInterface } from './types';
1+
import { vercelAIApi } from '../../integrations/vercel-ai/';
2+
import { eventSolvingInput } from './inputs/eventSolving';
3+
import { ctoInstruction } from './instructions/cto';
4+
import { EventsFactoryInterface } from '../types';
35

46
/**
57
* Service for interacting with AI
68
*/
7-
export class AIService {
9+
export class AskAiService {
810
/**
911
* Generate suggestion for the event
1012
*
@@ -20,8 +22,11 @@ export class AIService {
2022
throw new Error('Event not found');
2123
}
2224

23-
return vercelAIApi.generateSuggestion(event.payload);
25+
return vercelAIApi.complete({
26+
system: ctoInstruction,
27+
prompt: eventSolvingInput(event.payload),
28+
});
2429
}
2530
}
2631

27-
export const aiService = new AIService();
32+
export const askAiService = new AskAiService();
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import '../../src/env-test';
2+
import { generateText } from 'ai';
3+
import { vercelAIApi } from '../../src/integrations/vercel-ai/';
4+
5+
jest.mock('ai', () => ({
6+
generateText: jest.fn(),
7+
}));
8+
9+
describe('VercelAIApi', () => {
10+
const testSystem = 'system instruction';
11+
const testPrompt = 'user prompt';
12+
const testModelId = 'deepseek/deepseek-v4-flash';
13+
const testProviderOptions = {
14+
gateway: {
15+
order: ['novita', 'azure', 'deepseek'],
16+
},
17+
};
18+
19+
beforeEach(() => {
20+
jest.clearAllMocks();
21+
});
22+
23+
describe('complete', () => {
24+
it('should forward the system/prompt pair to generateText and return its text', async () => {
25+
(generateText as jest.Mock).mockResolvedValue({ text: 'model output' });
26+
27+
const result = await vercelAIApi.complete({
28+
system: testSystem,
29+
prompt: testPrompt,
30+
});
31+
32+
expect(generateText).toHaveBeenCalledWith({
33+
model: testModelId,
34+
system: testSystem,
35+
prompt: testPrompt,
36+
providerOptions: testProviderOptions,
37+
});
38+
expect(result).toBe('model output');
39+
});
40+
});
41+
});

test/services/askAi.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import '../../src/env-test';
2+
import { EventAddons, EventData } from '@hawk.so/types';
3+
import { AskAiService } from '../../src/services/askAi/service';
4+
import { vercelAIApi } from '../../src/integrations/vercel-ai/';
5+
import { ctoInstruction } from '../../src/services/askAi/instructions/cto';
6+
import { eventSolvingInput } from '../../src/services/askAi/inputs/eventSolving';
7+
8+
jest.mock('../../src/integrations/vercel-ai/', () => ({
9+
vercelAIApi: {
10+
complete: jest.fn(),
11+
},
12+
}));
13+
14+
describe('AskAiService', () => {
15+
let askAiService: AskAiService;
16+
const testEventId = 'repetition-id';
17+
const testOriginalEventId = 'original-event-id';
18+
const testPayload: EventData<EventAddons> = {
19+
title: 'TypeError: cannot read property of undefined',
20+
};
21+
22+
/**
23+
* Build a stub events factory returning the given event
24+
*
25+
* @param event - event repetition to resolve, or null when not found
26+
* @returns {object} stub factory
27+
*/
28+
const createEventsFactory = (event: { _id: string; payload: EventData<EventAddons> } | null): { getEventRepetition: jest.Mock } => ({
29+
getEventRepetition: jest.fn().mockResolvedValue(event),
30+
});
31+
32+
const eventsFactoryWithPayload = (): ReturnType<typeof createEventsFactory> => createEventsFactory({
33+
_id: testEventId,
34+
payload: testPayload,
35+
});
36+
37+
beforeEach(() => {
38+
jest.clearAllMocks();
39+
askAiService = new AskAiService();
40+
});
41+
42+
describe('generateSuggestion', () => {
43+
it('should send the instruction and serialized event to the transport and return its text unchanged', async () => {
44+
(vercelAIApi.complete as jest.Mock).mockResolvedValue('generated suggestion');
45+
const eventsFactory = eventsFactoryWithPayload();
46+
47+
const result = await askAiService.generateSuggestion(eventsFactory, testEventId, testOriginalEventId);
48+
49+
expect(eventsFactory.getEventRepetition).toHaveBeenCalledWith(testEventId, testOriginalEventId);
50+
expect(vercelAIApi.complete).toHaveBeenCalledWith({
51+
system: ctoInstruction,
52+
prompt: eventSolvingInput(testPayload),
53+
});
54+
expect(result).toBe('generated suggestion');
55+
});
56+
57+
it('should throw Event not found when the events factory returns nothing', async () => {
58+
await expect(
59+
askAiService.generateSuggestion(createEventsFactory(null), testEventId, testOriginalEventId)
60+
).rejects.toThrow('Event not found');
61+
62+
expect(vercelAIApi.complete).not.toHaveBeenCalled();
63+
});
64+
});
65+
});

0 commit comments

Comments
 (0)