Skip to content
Merged
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
3 changes: 2 additions & 1 deletion api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ NODE_ENV=development
Keep in mind that you have limited calls to the GitHub API (60 calls per hour). The [FetchService](./api/src/fetch/service.ts) does a great job of caching these calls so it doesn't unnecessarily consume the GitHub API quota. If you wish to extend the limit from 60 to 5000, simply create a [GitHub Personal Access Token](https://github.com/settings/tokens) (make sure it has `Access public repositories` checked), and set it in `./api/.env` like this:

```.env
GITHUB_TOKEN=Paste_Your_Token_Here
GITHUB_TOKEN=Paste_your_token_here
NODE_ENV=development
OPENAI_KEY=Pase_your_key_here
```

**Note:** If the README is still unclear, please create a PR with your suggested changes/additions.
1 change: 1 addition & 0 deletions api/oracle-cloud/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const appPath = "~/app";
const sshPrefix =
"ssh -o StrictHostKeyChecking=no " + (sshKeyPath ? `-i ${sshKeyPath} ` : "") + sshServer + " ";

// todo-ZM: let docker-compose handle deletion of old containers
// Check for existing containers
logs = execSync(sshPrefix + '"sudo docker ps -aq"');

Expand Down
1 change: 1 addition & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"@types/make-fetch-happen": "^10.0.4",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"class-validator-jsonschema": "^5.0.1",
"cors": "^2.8.5",
"cron": "^3.1.7",
"dotenv": "^16.4.5",
Expand Down
66 changes: 66 additions & 0 deletions api/src/ai/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { ConfigService } from "src/config/service";
import { LoggerService } from "src/logger/service";
import { Service } from "typedi";
import { targetConstructorToSchema } from "class-validator-jsonschema";
import { FetchService } from "src/fetch/service";
import { ClassConstructor, plainToClass } from "class-transformer";
import { validateSync } from "class-validator";

type AIChat = { role: "user" | "system"; content: string };

type OpenAIResponse = {
choices: Array<{ message: AIChat }>;
};

@Service()
export class AIService {
constructor(
private readonly configService: ConfigService,
private readonly logger: LoggerService,
private readonly fetchService: FetchService,
) {}

public query = async <T extends object>(
payload: AIChat[],
ResponseDto: ClassConstructor<T>,
): Promise<T> => {
const schema = targetConstructorToSchema(ResponseDto);

const payloadWithValidationPrompt: AIChat[] = [
{
role: "system",
content: `system response must strictly follow the schema:\n${JSON.stringify(schema)}`,
},
...payload,
];

const { OPENAI_KEY } = this.configService.env();

// todo: cache response
const res = await this.fetchService.post<OpenAIResponse>(
"https://api.openai.com/v1/chat/completions",
{
headers: { Authorization: `Bearer ${OPENAI_KEY}` },
body: {
model: "gpt-4o",
messages: payloadWithValidationPrompt,
},
},
);

const chatResponseUnchecked = JSON.parse(res.choices[0].message.content) as T;

const output = plainToClass(ResponseDto, chatResponseUnchecked);
const errors = validateSync(output);

if (errors.length > 0)
throw new Error(
`⚠️ Errors in AI response in the following keys:${errors.reduce(
(pV, cV) => (pV += "\n" + cV.property + " : " + JSON.stringify(cV.constraints)),
"",
)}`,
);

return output;
};
}
3 changes: 3 additions & 0 deletions api/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,7 @@ export class EnvRecord {
}

MEILISEARCH_MASTER_KEY = "default";

@IsString()
OPENAI_KEY = "no-key";
}
91 changes: 76 additions & 15 deletions api/src/digest/cron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { RepositoryRepository } from "src/repository/repository";
import { SearchService } from "src/search/service";
import { Service } from "typedi";
import { TagRepository } from "src/tag/repository";
import { AIService } from "src/ai/service";
import { AIResponseTranslateNameDto, AIResponseTranslateTitleDto } from "./dto";

@Service()
export class DigestCron {
Expand All @@ -29,7 +31,8 @@ export class DigestCron {
private readonly contributionsRepository: ContributionRepository,
private readonly contributorsRepository: ContributorRepository,
private readonly searchService: SearchService,
private readonly tagsRepository: TagRepository,
private readonly tagRepository: TagRepository,
private readonly aiService: AIService,
) {
const SentryCronJob = cron.instrumentCron(CronJob, "DigestCron");
new SentryCronJob(
Expand Down Expand Up @@ -81,10 +84,29 @@ export class DigestCron {
// or uncomment to skip the cron
// if (Math.random()) return;

const projectTitleSystemPrompt = `user will give you an open-source project name, and you will translate it to Arabic.`;
const contributorNameSystemPrompt = `user will give you an open-source contributor name, and you will translate it to Arabic.
if the name contain both english and arabic only keep the parts related to the language.`;
const issueTitleSystemPrompt = `user will give you an open-source issue/PR title, and you will translate it to Arabic.`;

for (const project of projectsFromDataFolder) {
// todo: call AIService
const name_en = project.name;
const name_ar = `ar ${name_en}`;
let name_en = project.name;
let name_ar = name_en;

try {
const aiRes = await this.aiService.query(
[
{ role: "system", content: projectTitleSystemPrompt },
{ role: "user", content: name_en },
],
AIResponseTranslateNameDto,
);

name_en = aiRes.name_en;
name_ar = aiRes.name_ar;
} catch (error) {
captureException(error, { tags: { type: "CRON" } });
}

const projectEntity: ProjectRow = {
runId,
Expand All @@ -94,7 +116,7 @@ export class DigestCron {
};
const [{ id: projectId }] = await this.projectsRepository.upsert(projectEntity);
for (const tagId of project.tags || []) {
await this.tagsRepository.upsert({ id: tagId, runId });
await this.tagRepository.upsert({ id: tagId, runId });
await this.projectsRepository.upsertRelationWithTag({ projectId, tagId, runId });
}
await this.searchService.upsert("project", projectEntity);
Expand Down Expand Up @@ -133,9 +155,22 @@ export class DigestCron {

if (githubUser.type !== "User") continue;

// todo: call AIService
const name_en = githubUser.name || githubUser.login;
const name_ar = `ar ${name_en}`;
let name_en = githubUser.name || githubUser.login;
let name_ar = name_en;
try {
const aiRes = await this.aiService.query(
[
{ role: "system", content: contributorNameSystemPrompt },
{ role: "user", content: name_en },
],
AIResponseTranslateNameDto,
);

name_en = aiRes.name_en;
name_ar = aiRes.name_ar;
} catch (error) {
captureException(error, { tags: { type: "CRON" } });
}

const contributorEntity: ContributorRow = {
name_en,
Expand All @@ -160,9 +195,22 @@ export class DigestCron {

const type = issue.pull_request ? "PULL_REQUEST" : "ISSUE";

// todo: call AIService
const title_en = issue.title;
const title_ar = `ar ${title_en}`;
let title_en = issue.title;
let title_ar = `ar ${title_en}`;
try {
const aiRes = await this.aiService.query(
[
{ role: "system", content: issueTitleSystemPrompt },
{ role: "user", content: title_en },
],
AIResponseTranslateTitleDto,
);

title_en = aiRes.title_en;
title_ar = aiRes.title_ar;
} catch (error) {
captureException(error, { tags: { type: "CRON" } });
}

const contributionEntity: ContributionRow = {
title_en,
Expand Down Expand Up @@ -194,9 +242,22 @@ export class DigestCron {
username: repoContributor.login,
});

// todo: call AIService
const name_en = contributor.name || contributor.login;
const name_ar = `ar ${name_en}`;
let name_en = contributor.name || contributor.login;
let name_ar = `ar ${name_en}`;
try {
const aiRes = await this.aiService.query(
[
{ role: "system", content: contributorNameSystemPrompt },
{ role: "user", content: name_en },
],
AIResponseTranslateNameDto,
);

name_en = aiRes.name_en;
name_ar = aiRes.name_ar;
} catch (error) {
captureException(error, { tags: { type: "CRON" } });
}

const contributorEntity: ContributorRow = {
name_en,
Expand Down Expand Up @@ -244,7 +305,7 @@ export class DigestCron {
await this.projectsRepository.deleteAllRelationWithTagButWithRunId(runId);
await this.projectsRepository.deleteAllButWithRunId(runId);

await this.tagsRepository.deleteAllButWithRunId(runId);
await this.tagRepository.deleteAllButWithRunId(runId);

await Promise.all([
this.searchService.deleteAllButWithRunId("project", runId),
Expand Down
17 changes: 17 additions & 0 deletions api/src/digest/dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { IsString } from "class-validator";

export class AIResponseTranslateNameDto {
@IsString()
name_en!: string;

@IsString()
name_ar!: string;
}

export class AIResponseTranslateTitleDto {
@IsString()
title_en!: string;

@IsString()
title_ar!: string;
}
23 changes: 19 additions & 4 deletions api/src/fetch/service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { defaults } from "make-fetch-happen";
import { defaults, FetchOptions } from "make-fetch-happen";
import { ConfigService } from "src/config/service";
import { LoggerService } from "src/logger/service";
import { Service } from "typedi";
Expand All @@ -18,21 +18,36 @@ export class FetchService {
});
}

public post = async <T>(
url: string,
{ headers = {}, body }: FetchConfig = {},
): Promise<Awaited<T>> => {
const response = await this.fetch<T>(url, {
headers: {
"Content-Type": "application/json",
...headers,
},
method: "POST",
body: body ? JSON.stringify(body) : undefined,
});
return response;
};

public get = async <T>(
url: string,
{ params = {}, headers = {} }: FetchConfig = {},
): Promise<Awaited<T>> => {
const _url = new URL(url);
Object.keys(params).forEach((key) => _url.searchParams.append(key, String(params[key])));

const response = await this.fetch<T>(_url.toString(), { headers });
const response = await this.fetch<T>(_url.toString(), { headers, method: "GET" });
return response;
};

private makeFetchHappenInstance;
private async fetch<T>(url: string, { headers }: Omit<FetchConfig, "params"> = {}) {
private async fetch<T>(url: string, options: FetchOptions) {
this.logger.info({ message: `Fetching ${url}` });
const response = await this.makeFetchHappenInstance(url, { headers });
const response = await this.makeFetchHappenInstance(url, options);
const jsonResponse = (await response.json()) as T;
return jsonResponse;
}
Expand Down
1 change: 1 addition & 0 deletions api/src/fetch/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export interface FetchConfig {
params?: Record<string, string | number | boolean>;
headers?: Record<string, string>;
body?: Record<string, unknown>;
}
Loading
Loading