Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
2b848d2
init openai
slaveeks Nov 7, 2025
bfbd273
Merge branch 'master' of github.com:codex-team/hawk.api.nodejs into f…
slaveeks Nov 7, 2025
349094c
feat(ai): added base for ai suggestions
slaveeks Nov 8, 2025
c6dfb77
Merge branch 'master' of github.com:codex-team/hawk.api.nodejs into f…
slaveeks Nov 8, 2025
1b1d83d
fixes
slaveeks Nov 8, 2025
f70a64f
Bump version up to 1.2.23
github-actions[bot] Nov 8, 2025
3c3f335
rm redundant packages
slaveeks Nov 8, 2025
46f2b74
fix zod
slaveeks Nov 9, 2025
baffb2b
fixes
slaveeks Nov 9, 2025
a63f88a
review changes
slaveeks Nov 9, 2025
018b70a
add todo
slaveeks Nov 9, 2025
ee14c20
added ai service, some refactorng
slaveeks Nov 9, 2025
0c245b2
improve docs
slaveeks Nov 9, 2025
57dae6e
improve prompts
slaveeks Nov 9, 2025
ac48e5c
improve prompts
slaveeks Nov 9, 2025
58a335e
Merge pull request #589 from codex-team/feat/openai-base
slaveeks Nov 9, 2025
28d26ee
Merge branch 'feat/openai' of github.com:codex-team/hawk.api.nodejs i…
slaveeks Nov 9, 2025
efec965
Merge branch 'feature/redis-timeseries-helper' into stage
pavelzotikov Nov 12, 2025
b930494
Merge branch 'feature/redis-timeseries-helper' into stage
pavelzotikov Nov 12, 2025
01e7e5a
Merge branch 'feature/redis-timeseries-helper' into stage
pavelzotikov Nov 12, 2025
c3fd9df
Expose rate-limited series alongside accepted chart
pavelzotikov Nov 13, 2025
2f95428
new type for chart data
pavelzotikov Nov 13, 2025
54e9aed
Bump version up to 1.2.24
github-actions[bot] Nov 13, 2025
15b5c82
Update eventsFactory.js
pavelzotikov Nov 13, 2025
55480b8
Update chartDataService.ts
pavelzotikov Nov 13, 2025
37e1711
change response in getEventDailyChart
pavelzotikov Nov 13, 2025
636c288
Merge branch 'feat/redis-rate-limit-series' into stage
pavelzotikov Nov 14, 2025
6ef7756
feat: update search to use repetition.delta field instead of event.pa…
pavelzotikov Nov 22, 2025
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
8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hawk.api",
"version": "1.2.23",
"version": "1.2.24",
"main": "index.ts",
"license": "BUSL-1.1",
"scripts": {
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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"
}
}
41 changes: 41 additions & 0 deletions src/integrations/vercel-ai/index.ts
Original file line number Diff line number Diff line change
@@ -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<EventAddons>} payload - event data
* @returns {Promise<string>} AI suggestion for the event
* @todo add defence against invalid prompt injection
*/
public async generateSuggestion(payload: EventData<EventAddons>) {
const { text } = await generateText({
model: openai(this.modelId),
system: ctoInstruction,
prompt: eventSolvingInput(payload),
});

return text;
}
}

export const vercelAIApi = new VercelAIApi();
5 changes: 5 additions & 0 deletions src/integrations/vercel-ai/inputs/eventSolving.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { EventData, EventAddons } from '@hawk.so/types';

export const eventSolvingInput = (payload: EventData<EventAddons>) => `
Payload: ${JSON.stringify(payload)}
`;
9 changes: 9 additions & 0 deletions src/integrations/vercel-ai/instructions/cto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const ctoInstruction = `Ты технический директор ИТ компании, тебе нужно пояснить ошибку и предложить решение.

Предоставь ответ в следующем формате:

1. Описание проблемы
2. Решение проблемы
3. Описание того, как можно предотвратить подобную ошибку в будущем

Ответь на русском языке.`;
91 changes: 73 additions & 18 deletions src/models/eventsFactory.js
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,12 @@ class EventsFactory extends Factory {
const searchFilter = search.trim().length > 0
? {
$or: [
{
'repetition.delta': {
$regex: escapedSearch,
$options: 'i',
},
},
{
'event.payload.title': {
$regex: escapedSearch,
Expand Down Expand Up @@ -443,25 +449,50 @@ class EventsFactory extends Factory {
const days = Math.ceil((end - start) / (24 * 60 * 60 * 1000));

try {
const redisData = await this.chartDataService.getProjectChartData(
projectId,
startDate,
endDate,
groupBy,
timezoneOffset
);

if (redisData && redisData.length > 0) {
return redisData;
}

// Fallback to Mongo (empty groupHash for project-level data)
return this.findChartData(days, timezoneOffset, '');
const [acceptedSeries, rateLimitedSeries] = await Promise.all([
this.chartDataService.getProjectChartData(
projectId,
startDate,
endDate,
groupBy,
timezoneOffset,
'events-accepted'
),
this.chartDataService.getProjectChartData(
projectId,
startDate,
endDate,
groupBy,
timezoneOffset,
'events-rate-limited'
),
]);

return [
{
label: 'accepted',
data: acceptedSeries,
},
{
label: 'rate-limited',
data: rateLimitedSeries,
},
];
} catch (err) {
console.error('[EventsFactory] getProjectChartData error:', err);

// Fallback to Mongo on error (empty groupHash for project-level data)
return this.findChartData(days, timezoneOffset, '');
const fallbackAccepted = await this.findChartData(days, timezoneOffset, '');

return [
{
label: 'accepted',
data: fallbackAccepted,
},
{
label: 'rate-limited',
data: this._composeZeroSeries(fallbackAccepted),
},
];
}
}

Expand All @@ -474,7 +505,14 @@ class EventsFactory extends Factory {
* @returns {Promise<Array>}
*/
async getEventDailyChart(groupHash, days, timezoneOffset = 0) {
return this.findChartData(days, timezoneOffset, groupHash);
const data = await this.findChartData(days, timezoneOffset, groupHash);

return [
{
label: 'accepted',
data,
},
];
}

/**
Expand Down Expand Up @@ -568,6 +606,23 @@ class EventsFactory extends Factory {
return result;
}

/**
* Compose zero-filled chart series using timestamps from the provided template
*
* @param {Array<{timestamp: number, count: number}>} template - reference series for timestamps
* @returns {Array<{timestamp: number, count: number}>}
*/
_composeZeroSeries(template = []) {
if (!Array.isArray(template) || template.length === 0) {
return [];
}

return template.map((point) => ({
timestamp: point.timestamp,
count: 0,
}));
}

/**
* Returns number of documents that occurred after the last visit time
*
Expand Down Expand Up @@ -686,7 +741,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);

/**
Expand Down
5 changes: 5 additions & 0 deletions src/redisHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export default class RedisHelper {
constructor() {
if (!process.env.REDIS_URL) {
console.warn('[Redis] REDIS_URL not set, Redis features will be disabled');

return;
}

Expand All @@ -49,7 +50,9 @@ export default class RedisHelper {
* Max wait time: 30 seconds
*/
const delay = Math.min(retries * 1000, 30000);

console.log(`[Redis] Reconnecting... attempt ${retries}, waiting ${delay}ms`);

return delay;
},
},
Expand Down Expand Up @@ -93,6 +96,7 @@ export default class RedisHelper {
if (!RedisHelper.instance) {
RedisHelper.instance = new RedisHelper();
}

return RedisHelper.instance;
}

Expand All @@ -102,6 +106,7 @@ export default class RedisHelper {
public async initialize(): Promise<void> {
if (!this.redisClient) {
console.warn('[Redis] Client not initialized, skipping connection');

return;
}

Expand Down
15 changes: 15 additions & 0 deletions src/resolvers/event.js
Original file line number Diff line number Diff line change
@@ -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}
Expand Down Expand Up @@ -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<string>} 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
*
Expand Down
27 changes: 27 additions & 0 deletions src/services/ai.ts
Original file line number Diff line number Diff line change
@@ -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<string>} - suggestion
*/
public async generateSuggestion(eventsFactory: EventsFactoryInterface, eventId: string, originalEventId: string): Promise<string> {
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();
10 changes: 8 additions & 2 deletions src/services/chartDataService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export default class ChartDataService {
* @param endDate - end date as ISO string (e.g., '2025-01-31T23:59:59Z')
* @param groupBy - grouping interval in minutes (1=minute, 60=hour, 1440=day)
* @param timezoneOffset - user's local timezone offset in minutes (default: 0)
* @param metricType - Redis metric type suffix (e.g., 'events-accepted', 'events-rate-limited')
* @returns Array of data points with timestamp and count
* @throws Error if Redis is not connected (caller should fallback to MongoDB)
*/
Expand All @@ -27,7 +28,8 @@ export default class ChartDataService {
startDate: string,
endDate: string,
groupBy: number,
timezoneOffset = 0
timezoneOffset = 0,
metricType = 'events-accepted'
): Promise<{ timestamp: number; count: number }[]> {
// Check if Redis is connected
if (!this.redisHelper.isConnected()) {
Expand All @@ -37,7 +39,7 @@ export default class ChartDataService {

// Determine granularity and compose key
const granularity = getTimeSeriesSuffix(groupBy);
const key = composeProjectMetricsKey(granularity, projectId);
const key = composeProjectMetricsKey(granularity, projectId, metricType);

// Parse ISO date strings to milliseconds
const start = new Date(startDate).getTime();
Expand All @@ -46,6 +48,7 @@ export default class ChartDataService {

// Fetch data from Redis
let result: TsRangeResult[] = [];

try {
result = await this.redisHelper.tsRange(
key,
Expand All @@ -65,8 +68,10 @@ export default class ChartDataService {

// Transform data from Redis
const dataPoints: { [ts: number]: number } = {};

for (const [tsStr, valStr] of result) {
const tsMs = Number(tsStr);

dataPoints[tsMs] = Number(valStr) || 0;
}

Expand All @@ -79,6 +84,7 @@ export default class ChartDataService {

while (current <= end) {
const count = dataPoints[current] || 0;

filled.push({
timestamp: Math.floor((current + timezoneOffset * 60 * 1000) / 1000),
count,
Expand Down
23 changes: 23 additions & 0 deletions src/services/types.ts
Original file line number Diff line number Diff line change
@@ -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<EventAddons>;
};

/**
* Interface for interacting with events factory
*/
export interface EventsFactoryInterface {
/**
* Get event repetition
*
* @param repetitionId - repetition id
* @param originalEventId - original event id
* @returns {Promise<EventData<EventAddons>>} - event repetition
*/
getEventRepetition(repetitionId: string, originalEventId: string): Promise<Event>;
}
Loading
Loading