From 16fd7a6e64afcd963a2c10cf29ee1eb1ae7388b0 Mon Sep 17 00:00:00 2001 From: Reversean Date: Wed, 29 Jul 2026 12:03:25 +0300 Subject: [PATCH 1/3] refactor(ai): move Ask AI domain logic out of the provider adapter The Ask AI prompt assembly, the model instruction and the event serialisation lived in src/integrations/vercel-ai/, a directory meant for adapters to external services, even though none of them referenced the provider. Replacing the provider therefore dragged the domain along, and anything applied around the model call could only be reused by importing from another adapter's internals. The domain now lives in src/services/askAi/ and VercelAIApi is reduced to a transport taking a system/prompt pair. Orchestration sits in AIService, which already owned the event lookup. Anything applied to the model's input or output can now sit above the transport, where a provider swap cannot silently drop it. Behaviour is unchanged: AIService.generateSuggestion keeps its signature and its only caller is untouched. --- package.json | 2 +- src/integrations/vercel-ai/index.ts | 30 ++++++--- src/services/ai.ts | 7 ++- .../askAi}/inputs/eventSolving.ts | 0 .../askAi}/instructions/cto.ts | 0 test/integrations/vercel-ai.test.ts | 41 ++++++++++++ test/services/askAi.test.ts | 63 +++++++++++++++++++ 7 files changed, 132 insertions(+), 11 deletions(-) rename src/{integrations/vercel-ai => services/askAi}/inputs/eventSolving.ts (100%) rename src/{integrations/vercel-ai => services/askAi}/instructions/cto.ts (100%) create mode 100644 test/integrations/vercel-ai.test.ts create mode 100644 test/services/askAi.test.ts 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(); + }); + }); +}); From 5c9a6a0c396fa6ef715ebfc80a52c9bc6d03aaa1 Mon Sep 17 00:00:00 2001 From: Reversean Date: Mon, 3 Aug 2026 10:27:51 +0300 Subject: [PATCH 2/3] test(ai): assert the event lookup receives both repetition ids The suggestion test mocked the events factory but never checked how it was called, so swapping eventId and originalEventId in AIService.generateSuggestion kept it green. That is the one piece of behaviour this refactor moved out of the adapter, so it is the piece worth pinning. Also annotate the factory stub's return type, which the linter flagged. --- test/services/askAi.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/services/askAi.test.ts b/test/services/askAi.test.ts index e69d1a61..b6bb3030 100644 --- a/test/services/askAi.test.ts +++ b/test/services/askAi.test.ts @@ -25,7 +25,7 @@ describe('AIService', () => { * @param event - event repetition to resolve, or null when not found * @returns {object} stub factory */ - const createEventsFactory = (event: { _id: string; payload: EventData } | null) => ({ + const createEventsFactory = (event: { _id: string; payload: EventData } | null): { getEventRepetition: jest.Mock } => ({ getEventRepetition: jest.fn().mockResolvedValue(event), }); @@ -42,9 +42,11 @@ describe('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 eventsFactory = eventsFactoryWithPayload(); - const result = await aiService.generateSuggestion(eventsFactoryWithPayload(), testEventId, testOriginalEventId); + const result = await aiService.generateSuggestion(eventsFactory, testEventId, testOriginalEventId); + expect(eventsFactory.getEventRepetition).toHaveBeenCalledWith(testEventId, testOriginalEventId); expect(vercelAIApi.complete).toHaveBeenCalledWith({ system: ctoInstruction, prompt: eventSolvingInput(testPayload), From ed0cbb6facf7adf6e7e57a4e000531a9630c02c9 Mon Sep 17 00:00:00 2001 From: Reversean Date: Mon, 3 Aug 2026 10:27:59 +0300 Subject: [PATCH 3/3] style(ai): indent the VercelAIApi body with two spaces The class body was indented with four spaces while .editorconfig and the rest of the repo use two. Whitespace only, no code change. Fixing it at the root of the stack means the branches based on this one pick it up on rebase instead of adding more four-space code on top. eslint-config-codex does not catch this: the base indent rule reports nothing here, only @typescript-eslint/indent does. Enabling it repo-wide is a separate change - six other files, all auto-fixable. --- src/integrations/vercel-ai/index.ts | 56 ++++++++++++++--------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/src/integrations/vercel-ai/index.ts b/src/integrations/vercel-ai/index.ts index e57cb9c8..3d22bdf4 100644 --- a/src/integrations/vercel-ai/index.ts +++ b/src/integrations/vercel-ai/index.ts @@ -19,39 +19,39 @@ export interface CompletionParams { * 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() { /** - * Send a system/prompt pair to the model and return the generated text - * - * @param {CompletionParams} params - system instruction and prompt to complete - * @returns {Promise} text generated by the model - * @todo add defence against invalid prompt injection + * @todo make it dynamic, get from project settings */ - public async complete({ system, prompt }: CompletionParams): Promise { - const { text } = await generateText({ - model: this.modelId, - system, - prompt, - 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} text generated by the model + * @todo add defence against invalid prompt injection + */ + public async complete({ system, prompt }: CompletionParams): Promise { + const { text } = await generateText({ + model: this.modelId, + system, + prompt, + providerOptions: { + gateway: { + order: ['novita', 'azure', 'deepseek'], }, - }); + }, + }); - return text; - } + return text; + } } export const vercelAIApi = new VercelAIApi();