diff --git a/package.json b/package.json index 80896794..6c7fef04 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hawk.api", - "version": "1.2.25", + "version": "1.2.26", "main": "index.ts", "license": "BUSL-1.1", "scripts": { @@ -34,6 +34,7 @@ "typescript": "^4.7.4" }, "dependencies": { + "@ai-sdk/openai": "^2.0.64", "@amplitude/node": "^1.10.0", "@graphql-tools/merge": "^8.3.1", "@graphql-tools/schema": "^8.5.1", @@ -53,9 +54,9 @@ "@types/mongodb": "^3.6.20", "@types/morgan": "^1.9.10", "@types/node": "^16.11.46", - "@types/node-fetch": "^2.5.4", "@types/safe-regex": "^1.1.6", "@types/uuid": "^8.3.4", + "ai": "^5.0.89", "amqp-connection-manager": "^3.1.0", "amqplib": "^0.5.5", "apollo-server-express": "^3.10.0", @@ -86,6 +87,7 @@ "redis": "^4.7.0", "safe-regex": "^2.1.0", "ts-node-dev": "^2.0.0", - "uuid": "^8.3.2" + "uuid": "^8.3.2", + "zod": "^3.25.76" } } diff --git a/src/integrations/vercel-ai/index.ts b/src/integrations/vercel-ai/index.ts new file mode 100644 index 00000000..48010f25 --- /dev/null +++ b/src/integrations/vercel-ai/index.ts @@ -0,0 +1,41 @@ +import { EventAddons, EventData } from '@hawk.so/types'; +import { generateText } from 'ai'; +import { openai } from '@ai-sdk/openai'; +import { eventSolvingInput } from './inputs/eventSolving'; +import { ctoInstruction } from './instructions/cto'; + +/** + * 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 = 'gpt-4o'; + } + + /** + * Generate AI suggestion for the event + * + * @param {EventData} payload - event data + * @returns {Promise} AI suggestion for the event + * @todo add defence against invalid prompt injection + */ + public async generateSuggestion(payload: EventData) { + const { text } = await generateText({ + model: openai(this.modelId), + system: ctoInstruction, + prompt: eventSolvingInput(payload), + }); + + return text; + } +} + +export const vercelAIApi = new VercelAIApi(); diff --git a/src/integrations/vercel-ai/inputs/eventSolving.ts b/src/integrations/vercel-ai/inputs/eventSolving.ts new file mode 100644 index 00000000..0969b048 --- /dev/null +++ b/src/integrations/vercel-ai/inputs/eventSolving.ts @@ -0,0 +1,5 @@ +import { EventData, EventAddons } from '@hawk.so/types'; + +export const eventSolvingInput = (payload: EventData) => ` +Payload: ${JSON.stringify(payload)} +`; diff --git a/src/integrations/vercel-ai/instructions/cto.ts b/src/integrations/vercel-ai/instructions/cto.ts new file mode 100644 index 00000000..9d267044 --- /dev/null +++ b/src/integrations/vercel-ai/instructions/cto.ts @@ -0,0 +1,9 @@ +export const ctoInstruction = `Ты технический директор ИТ компании, тебе нужно пояснить ошибку и предложить решение. + +Предоставь ответ в следующем формате: + +1. Описание проблемы +2. Решение проблемы +3. Описание того, как можно предотвратить подобную ошибку в будущем + +Ответь на русском языке.`; diff --git a/src/models/eventsFactory.js b/src/models/eventsFactory.js index 91e123e4..91d3d6a1 100644 --- a/src/models/eventsFactory.js +++ b/src/models/eventsFactory.js @@ -691,7 +691,7 @@ class EventsFactory extends Factory { /** * If originalEventId equals repetitionId than user wants to get first repetition which is original event */ - if (repetitionId === originalEventId) { + if (repetitionId.toString() === originalEventId.toString()) { const originalEvent = await this.eventsDataLoader.load(originalEventId); /** diff --git a/src/resolvers/event.js b/src/resolvers/event.js index b9f59ea3..08aee5c7 100644 --- a/src/resolvers/event.js +++ b/src/resolvers/event.js @@ -1,5 +1,6 @@ const getEventsFactory = require('./helpers/eventsFactory').default; const sendPersonalNotification = require('../utils/personalNotifications').default; +const { aiService } = require('../services/ai'); /** * See all types and fields here {@see ../typeDefs/event.graphql} @@ -89,6 +90,20 @@ module.exports = { return factory.getEventDailyChart(groupHash, days, timezoneOffset); }, + /** + * Return AI suggestion for the event + * + * @param {string} projectId - event's project + * @param {string} eventId - event id + * @param {string} originalEventId - original event id + * @returns {Promise} AI suggestion for the event + */ + async aiSuggestion({ projectId, _id: eventId, originalEventId }, _args, context) { + const factory = getEventsFactory(context, projectId); + + return aiService.generateSuggestion(factory, eventId, originalEventId); + }, + /** * Return release data for the event * diff --git a/src/services/ai.ts b/src/services/ai.ts new file mode 100644 index 00000000..e366be28 --- /dev/null +++ b/src/services/ai.ts @@ -0,0 +1,27 @@ +import { vercelAIApi } from '../integrations/vercel-ai/'; +import { EventsFactoryInterface } from './types'; + +/** + * Service for interacting with AI + */ +export class AIService { + /** + * Generate suggestion for the event + * + * @param eventsFactory - events factory + * @param eventId - event id + * @param originalEventId - original event id + * @returns {Promise} - suggestion + */ + public async generateSuggestion(eventsFactory: EventsFactoryInterface, eventId: string, originalEventId: string): Promise { + const event = await eventsFactory.getEventRepetition(eventId, originalEventId); + + if (!event) { + throw new Error('Event not found'); + } + + return vercelAIApi.generateSuggestion(event.payload); + } +} + +export const aiService = new AIService(); \ No newline at end of file diff --git a/src/services/types.ts b/src/services/types.ts new file mode 100644 index 00000000..1b14501f --- /dev/null +++ b/src/services/types.ts @@ -0,0 +1,23 @@ +import { EventAddons, EventData } from '@hawk.so/types'; + +/** + * Event type which is returned by events factory + */ +type Event = { + _id: string; + payload: EventData; +}; + +/** + * Interface for interacting with events factory + */ +export interface EventsFactoryInterface { + /** + * Get event repetition + * + * @param repetitionId - repetition id + * @param originalEventId - original event id + * @returns {Promise>} - event repetition + */ + getEventRepetition(repetitionId: string, originalEventId: string): Promise; +} \ No newline at end of file diff --git a/src/typeDefs/event.ts b/src/typeDefs/event.ts index c1f4ec77..2bc0bd9b 100644 --- a/src/typeDefs/event.ts +++ b/src/typeDefs/event.ts @@ -262,6 +262,11 @@ type Event { """ repetitionsPortion(cursor: String = null, limit: Int = 10): RepetitionsPortion! + """ + AI suggestion for the event + """ + aiSuggestion: String + """ Array of users who visited event """ diff --git a/src/types/vercel-ai.d.ts b/src/types/vercel-ai.d.ts new file mode 100644 index 00000000..33d02c19 --- /dev/null +++ b/src/types/vercel-ai.d.ts @@ -0,0 +1,18 @@ +declare module 'ai' { + /** + * Minimal type for generateText used in server-side integration. + */ + export function generateText(input: { + model: any; + system?: string; + prompt: string; + }): Promise<{ text: string }>; +} + +declare module '@ai-sdk/openai' { + /** + * Minimal types for OpenAI provider. + */ + export function createOpenAI(config?: { apiKey?: string }): (model: string) => any; + export const openai: (model: string) => any; +} diff --git a/src/types/zod-v4.d.ts b/src/types/zod-v4.d.ts new file mode 100644 index 00000000..c0fc3475 --- /dev/null +++ b/src/types/zod-v4.d.ts @@ -0,0 +1,5 @@ +declare module 'zod/v4' { + export * from 'zod'; + import z from 'zod'; + export default z; +} diff --git a/tsconfig.json b/tsconfig.json index 54843532..2f666715 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -41,13 +41,16 @@ /* Module Resolution Options */ // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ - // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ - // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ + "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ + "paths": { /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ + "zod/v4": ["src/types/zod-v4.d.ts"] + }, // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ // "typeRoots": [], /* List of folders to include type definitions from. */ // "types": [], /* Type declaration files to be included in compilation. */ // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ - "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ + "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ + "skipLibCheck": true // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ diff --git a/yarn.lock b/yarn.lock index 239d0887..0782faeb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,39 @@ # yarn lockfile v1 +"@ai-sdk/gateway@2.0.7": + version "2.0.7" + resolved "https://registry.yarnpkg.com/@ai-sdk/gateway/-/gateway-2.0.7.tgz#e3b77ef01658b47a19956313fc2a36b9e5a951f3" + integrity sha512-/AI5AKi4vOK9SEb8Z1dfXkhsJ5NAfWsoJQc96B/mzn2KIrjw5occOjIwD06scuhV9xWlghCoXJT1sQD9QH/tyg== + dependencies: + "@ai-sdk/provider" "2.0.0" + "@ai-sdk/provider-utils" "3.0.16" + "@vercel/oidc" "3.0.3" + +"@ai-sdk/openai@^2.0.64": + version "2.0.64" + resolved "https://registry.yarnpkg.com/@ai-sdk/openai/-/openai-2.0.64.tgz#d8746bd341c277b440d2ed54179bfe1b43e7853c" + integrity sha512-+1mqxn42uB32DPZ6kurSyGAmL3MgCaDpkYU7zNDWI4NLy3Zg97RxTsI1jBCGIqkEVvRZKJlIMYtb89OvMnq3AQ== + dependencies: + "@ai-sdk/provider" "2.0.0" + "@ai-sdk/provider-utils" "3.0.16" + +"@ai-sdk/provider-utils@3.0.16": + version "3.0.16" + resolved "https://registry.yarnpkg.com/@ai-sdk/provider-utils/-/provider-utils-3.0.16.tgz#17b7170bf51a7a690bf0186490ce29a8ce50a961" + integrity sha512-lsWQY9aDXHitw7C1QRYIbVGmgwyT98TF3MfM8alNIXKpdJdi+W782Rzd9f1RyOfgRmZ08gJ2EYNDhWNK7RqpEA== + dependencies: + "@ai-sdk/provider" "2.0.0" + "@standard-schema/spec" "^1.0.0" + eventsource-parser "^3.0.6" + +"@ai-sdk/provider@2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@ai-sdk/provider/-/provider-2.0.0.tgz#b853c739d523b33675bc74b6c506b2c690bc602b" + integrity sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA== + dependencies: + json-schema "^0.4.0" + "@amplitude/identify@^1.10.0": version "1.10.0" resolved "https://registry.yarnpkg.com/@amplitude/identify/-/identify-1.10.0.tgz#d62b8b6785c29350c368810475a6fc7b04985210" @@ -725,7 +758,7 @@ resolved "https://registry.yarnpkg.com/@n1ru4l/json-patch-plus/-/json-patch-plus-0.2.0.tgz#b8fa09fd980c3460dfdc109a7c4cc5590157aa6b" integrity sha512-pLkJy83/rVfDTyQgDSC8GeXAHEdXNHGNJrB1b7wAyGQu0iv7tpMXntKVSqj0+XKNVQbco40SZffNfVALzIt0SQ== -"@opentelemetry/api@^1.4.0": +"@opentelemetry/api@1.9.0", "@opentelemetry/api@^1.4.0": version "1.9.0" resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.0.tgz#d03eba68273dc0f7509e2a3d5cba21eae10379fe" integrity sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg== @@ -845,6 +878,11 @@ dependencies: "@sinonjs/commons" "^1.7.0" +"@standard-schema/spec@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.0.0.tgz#f193b73dc316c4170f2e82a881da0f550d551b9c" + integrity sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA== + "@tootallnate/once@1": version "1.1.2" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" @@ -1183,14 +1221,6 @@ "@types/node" "*" form-data "^4.0.0" -"@types/node-fetch@^2.5.4": - version "2.6.2" - resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.2.tgz#d1a9c5fd049d9415dce61571557104dec3ec81da" - integrity sha512-DHqhlq5jeESLy19TYhLakJ07kNumXWjcDdxXsLUMJZ6ue8VZJj4kLPQVE/2mdHh3xZziNF1xppu5lwmS53HR+A== - dependencies: - "@types/node" "*" - form-data "^3.0.0" - "@types/node@*": version "18.6.2" resolved "https://registry.yarnpkg.com/@types/node/-/node-18.6.2.tgz#ffc5f0f099d27887c8d9067b54e55090fcd54126" @@ -1339,6 +1369,11 @@ semver "^7.3.2" tsutils "^3.17.1" +"@vercel/oidc@3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@vercel/oidc/-/oidc-3.0.3.tgz#82c2b6dd4d5c3b37dcb1189718cdeb9db402d052" + integrity sha512-yNEQvPcVrK9sIe637+I0jD6leluPxzwJKx/Haw6F4H77CdDsszUn5V3o96LPziXkSNE2B83+Z3mjqGKBK/R6Gg== + abab@^2.0.3, abab@^2.0.5: version "2.0.6" resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" @@ -1397,6 +1432,16 @@ agent-base@6: dependencies: debug "4" +ai@^5.0.89: + version "5.0.89" + resolved "https://registry.yarnpkg.com/ai/-/ai-5.0.89.tgz#8929fbc18f247aa9e4442836a12aa84191edf2a4" + integrity sha512-8Nq+ZojGacQrupoJEQLrTDzT5VtR3gyp5AaqFSV3tzsAXlYQ9Igb7QE3yeoEdzOk5IRfDwWL7mDCUD+oBg1hDA== + dependencies: + "@ai-sdk/gateway" "2.0.7" + "@ai-sdk/provider" "2.0.0" + "@ai-sdk/provider-utils" "3.0.16" + "@opentelemetry/api" "1.9.0" + ajv@^6.10.0, ajv@^6.10.2: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" @@ -2967,6 +3012,11 @@ events@1.1.1: resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924" integrity sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw== +eventsource-parser@^3.0.6: + version "3.0.6" + resolved "https://registry.yarnpkg.com/eventsource-parser/-/eventsource-parser-3.0.6.tgz#292e165e34cacbc936c3c92719ef326d4aeb4e90" + integrity sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg== + exec-sh@^0.3.2: version "0.3.6" resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.3.6.tgz#ff264f9e325519a60cb5e273692943483cca63bc" @@ -4570,6 +4620,11 @@ json-schema-traverse@^0.4.1: resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== +json-schema@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5" + integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA== + json-stable-stringify-without-jsonify@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" @@ -7215,3 +7270,8 @@ yn@3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== + +zod@^3.25.76: + version "3.25.76" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34" + integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==