Skip to content

Commit 28b3e56

Browse files
committed
added AI service
1 parent ded91cb commit 28b3e56

File tree

6 files changed

+141
-6
lines changed

6 files changed

+141
-6
lines changed

api/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"@types/make-fetch-happen": "^10.0.4",
1616
"class-transformer": "^0.5.1",
1717
"class-validator": "^0.14.1",
18+
"class-validator-jsonschema": "^5.0.1",
1819
"cors": "^2.8.5",
1920
"cron": "^3.1.7",
2021
"dotenv": "^16.4.5",

api/src/ai/service.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { ConfigService } from "src/config/service";
2+
import { LoggerService } from "src/logger/service";
3+
import { Service } from "typedi";
4+
import { targetConstructorToSchema } from "class-validator-jsonschema";
5+
import { FetchService } from "src/fetch/service";
6+
import { ClassConstructor, plainToClass } from "class-transformer";
7+
import { validateSync } from "class-validator";
8+
9+
type AIChat = { role: "user" | "system"; content: string };
10+
11+
type OpenAIResponse = {
12+
choices: Array<{ message: AIChat }>;
13+
};
14+
15+
@Service()
16+
export class AIService {
17+
constructor(
18+
private readonly configService: ConfigService,
19+
private readonly logger: LoggerService,
20+
private readonly fetchService: FetchService,
21+
) {}
22+
23+
public query = async <T extends object>(
24+
payload: AIChat[],
25+
ResponseDto: ClassConstructor<T>,
26+
): Promise<T> => {
27+
const schema = targetConstructorToSchema(ResponseDto);
28+
29+
const payloadWithValidationPrompt: AIChat[] = [
30+
{
31+
role: "system",
32+
content: `system response must strictly follow the schema:\n${JSON.stringify(schema)}`,
33+
},
34+
...payload,
35+
];
36+
37+
const { OPENAI_KEY } = this.configService.env();
38+
39+
// todo: cache response
40+
const res = await this.fetchService.post<OpenAIResponse>(
41+
"https://api.openai.com/v1/chat/completions",
42+
{
43+
headers: { Authorization: `Bearer ${OPENAI_KEY}` },
44+
body: {
45+
model: "gpt-4o",
46+
messages: payloadWithValidationPrompt,
47+
},
48+
},
49+
);
50+
51+
const chatResponseUnchecked = JSON.parse(res.choices[0].message.content) as T;
52+
53+
const output = plainToClass(ResponseDto, chatResponseUnchecked);
54+
const errors = validateSync(output);
55+
56+
if (errors.length > 0)
57+
throw new Error(
58+
`⚠️ Errors in AI response in the following keys:${errors.reduce(
59+
(pV, cV) => (pV += "\n" + cV.property + " : " + JSON.stringify(cV.constraints)),
60+
"",
61+
)}`,
62+
);
63+
64+
return output;
65+
};
66+
}

api/src/config/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,7 @@ export class EnvRecord {
4040
}
4141

4242
MEILISEARCH_MASTER_KEY = "default";
43+
44+
@IsString()
45+
OPENAI_KEY!: string;
4346
}

api/src/fetch/service.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { defaults } from "make-fetch-happen";
1+
import { defaults, FetchOptions } from "make-fetch-happen";
22
import { ConfigService } from "src/config/service";
33
import { LoggerService } from "src/logger/service";
44
import { Service } from "typedi";
@@ -18,21 +18,36 @@ export class FetchService {
1818
});
1919
}
2020

21+
public post = async <T>(
22+
url: string,
23+
{ headers = {}, body }: FetchConfig = {},
24+
): Promise<Awaited<T>> => {
25+
const response = await this.fetch<T>(url, {
26+
headers: {
27+
"Content-Type": "application/json",
28+
...headers,
29+
},
30+
method: "POST",
31+
body: body ? JSON.stringify(body) : undefined,
32+
});
33+
return response;
34+
};
35+
2136
public get = async <T>(
2237
url: string,
2338
{ params = {}, headers = {} }: FetchConfig = {},
2439
): Promise<Awaited<T>> => {
2540
const _url = new URL(url);
2641
Object.keys(params).forEach((key) => _url.searchParams.append(key, String(params[key])));
2742

28-
const response = await this.fetch<T>(_url.toString(), { headers });
43+
const response = await this.fetch<T>(_url.toString(), { headers, method: "GET" });
2944
return response;
3045
};
3146

3247
private makeFetchHappenInstance;
33-
private async fetch<T>(url: string, { headers }: Omit<FetchConfig, "params"> = {}) {
48+
private async fetch<T>(url: string, options: FetchOptions) {
3449
this.logger.info({ message: `Fetching ${url}` });
35-
const response = await this.makeFetchHappenInstance(url, { headers });
50+
const response = await this.makeFetchHappenInstance(url, options);
3651
const jsonResponse = (await response.json()) as T;
3752
return jsonResponse;
3853
}

api/src/fetch/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export interface FetchConfig {
22
params?: Record<string, string | number | boolean>;
33
headers?: Record<string, string>;
4+
body?: Record<string, unknown>;
45
}

package-lock.json

Lines changed: 51 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)