Skip to content

Commit afb095e

Browse files
committed
feat: chatgpt explanations
1 parent 4d94a05 commit afb095e

File tree

13 files changed

+365
-11
lines changed

13 files changed

+365
-11
lines changed

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
This repository contains the code for
44
[examtraining.online](https://examtraining.online).
55

6-
It relies on Firebase Hosting, Firestore and Functions. The only other external
7-
dependency is an SMTP service.
6+
It relies on Firebase Hosting, Firestore and Functions. Other external
7+
dependency is an SMTP service and an OpenAI API key.
88

9-
To run this code locally, run the devcontainer and then `yarn start`.
9+
To run this code locally, run the devcontainer, `yarn`, `yarn firebase login`
10+
and then `yarn start`.

packages/functions/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
OPENAI_API_KEY=

packages/functions/.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,7 @@ typings/
66

77
# Node.js dependency directory
88
node_modules/
9+
10+
# env files
11+
.env*
12+
!.env.example

packages/functions/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
"cors": "2.8.5",
1919
"firebase-admin": "^12.6.0",
2020
"firebase-functions": "^6.0.1",
21-
"node-fetch": "3.3.2"
21+
"node-fetch": "3.3.2",
22+
"openai": "4.67.2"
2223
},
2324
"devDependencies": {
2425
"@types/cors": "^2.8.17",
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import admin from "firebase-admin";
2+
3+
try {
4+
admin.initializeApp();
5+
} catch (error) {}
6+
7+
import { logger } from "firebase-functions/v2";
8+
import { HttpsError, onCall } from "firebase-functions/v2/https";
9+
// @ts-ignore
10+
import { FirestoreCollection } from "@examtraining/core";
11+
import { defineSecret } from "firebase-functions/params";
12+
import OpenAI from "openai";
13+
import { collectionRef, getDocument } from "./utils";
14+
15+
const openAIApiKey = defineSecret("OPENAI_API_KEY");
16+
17+
let client: OpenAI;
18+
19+
const OPENAI_MAX_RETRIES = 2;
20+
const OPENAI_TIMEOUT = 20000; // 20 seconds
21+
const CHATGPT_MODEL: OpenAI.ChatModel = "gpt-4o-mini";
22+
const CHATGPT_TEMPERATURE: number = 0;
23+
const CHATGPT_FREQUENCY_PENALTY: number = 0;
24+
const CHATGPT_PRESENCE_PENALTY: number = 0;
25+
const CHATGPT_MAX_COMPLETION_TOKENS: number = 512;
26+
const CHATGPT_N = 1;
27+
28+
function generatePrompt(question: string, correctAnswer: string) {
29+
return `Explain in one short paragraph why "${correctAnswer}" is the correct \
30+
answer to the question "${question}". Provide as much important details as \
31+
possible. Write in the language of the question. Do not quote the question or \
32+
the answer itself.`;
33+
}
34+
35+
function getOpenAIClient() {
36+
if (client) {
37+
return client;
38+
}
39+
40+
const apiKey = openAIApiKey.value() || process.env.OPENAI_API_KEY;
41+
42+
if (!apiKey) {
43+
throw new Error("OPENAI_API_KEY is not set");
44+
}
45+
46+
return (client = new OpenAI({
47+
apiKey,
48+
maxRetries: OPENAI_MAX_RETRIES,
49+
timeout: OPENAI_TIMEOUT,
50+
}));
51+
}
52+
53+
async function generateCompletion(prompt: string, system?: string) {
54+
const openai = getOpenAIClient();
55+
const chatCompletion = await openai.chat.completions.create({
56+
messages: [
57+
...(system ? [{ role: "system" as const, content: system }] : []),
58+
{ role: "user", content: prompt },
59+
],
60+
model: CHATGPT_MODEL,
61+
temperature: CHATGPT_TEMPERATURE,
62+
frequency_penalty: CHATGPT_FREQUENCY_PENALTY,
63+
presence_penalty: CHATGPT_PRESENCE_PENALTY,
64+
max_completion_tokens: CHATGPT_MAX_COMPLETION_TOKENS,
65+
n: CHATGPT_N,
66+
});
67+
68+
const result = chatCompletion.choices[0]?.message.content;
69+
70+
if (typeof result !== "string") {
71+
throw new Error("No result from gpt-3.5-turbo");
72+
}
73+
74+
return result;
75+
}
76+
77+
type ExplainQuestionParams = {
78+
slug: string;
79+
accessCode?: string;
80+
questionId: string;
81+
};
82+
83+
type ExplainQuestionReturn = {
84+
explanation: string;
85+
} | null;
86+
87+
export const explainQuestion = onCall<
88+
ExplainQuestionParams,
89+
Promise<ExplainQuestionReturn>
90+
>(
91+
{ region: "europe-west1", cors: "*", secrets: [openAIApiKey] },
92+
async ({ data }) => {
93+
if (!data.slug) {
94+
throw new HttpsError("invalid-argument", "slug not specified.");
95+
}
96+
97+
try {
98+
const exam = await getDocument(FirestoreCollection.Exams, data.slug);
99+
100+
if (!exam) {
101+
return null;
102+
}
103+
104+
const secrets = await exam.secrets.get();
105+
106+
if (!secrets.exists) {
107+
throw new HttpsError("internal", "secrets not found for exam.");
108+
}
109+
110+
if (exam.private === true) {
111+
// If exam is private, verify access code
112+
if (!data.accessCode) {
113+
throw new HttpsError(
114+
"permission-denied",
115+
"You need an access code to view this exam.",
116+
);
117+
}
118+
119+
// Find secrets
120+
if (secrets.data()!.accessCode !== data.accessCode) {
121+
throw new HttpsError(
122+
"permission-denied",
123+
"The access code provided is incorrect.",
124+
);
125+
}
126+
}
127+
128+
const question = (
129+
await collectionRef(
130+
FirestoreCollection.Exams,
131+
data.slug,
132+
FirestoreCollection.Questions,
133+
)
134+
.doc(data.questionId)
135+
.get()
136+
).data();
137+
138+
if (!question) {
139+
return null;
140+
}
141+
142+
const correctAnswer = question.answers.find((a) => a.correct);
143+
144+
if (!correctAnswer) {
145+
return null;
146+
}
147+
148+
const explanation = await generateCompletion(
149+
generatePrompt(question.description, correctAnswer.description),
150+
);
151+
152+
return {
153+
explanation,
154+
};
155+
} catch (error) {
156+
logger.error(error);
157+
throw error;
158+
}
159+
},
160+
);

packages/functions/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export * from "./createExam";
44
export * from "./createExamQuestion";
55
export * from "./editExamDetails";
66
export * from "./editExamQuestion";
7+
export * from "./explainQuestion";
78
export * from "./getExam";
89
export * from "./isSlugAvailable";
910
export * from "./onExamDeleted";

packages/web/public/openai.svg

Lines changed: 2 additions & 0 deletions
Loading

packages/web/src/api/functions.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
createExamQuestion,
55
editExamDetails,
66
editExamQuestion,
7+
explainQuestion,
78
getExam,
89
isSlugAvailable,
910
removeExamQuestion,
@@ -17,6 +18,7 @@ export enum Functions {
1718
CreateExamQuestion = "createExamQuestion",
1819
EditExamQuestion = "editExamQuestion",
1920
RemoveExamQuestion = "removeExamQuestion",
21+
ExplainQuestion = "explainQuestion",
2022
}
2123

2224
export type FunctionTypes = {
@@ -27,6 +29,7 @@ export type FunctionTypes = {
2729
[Functions.CreateExamQuestion]: typeof createExamQuestion;
2830
[Functions.EditExamQuestion]: typeof editExamQuestion;
2931
[Functions.RemoveExamQuestion]: typeof removeExamQuestion;
32+
[Functions.ExplainQuestion]: typeof explainQuestion;
3033
};
3134

3235
export type FunctionParams<Function> =

packages/web/src/components/Training/Results.tsx

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { ExamWithQuestions } from "@examtraining/core";
2+
import { ReactNode, useCallback, useState } from "react";
23
import Markdown from "react-markdown";
34
import nl2br from "react-nl2br";
45
import { Fragment } from "react/jsx-runtime";
56
import { Link } from "wouter";
67
import { Jumbotron, Section } from "..";
7-
import { useTraining } from "../../hooks";
8+
import { Functions } from "../../api";
9+
import { useFunction, useLogEvent, useTraining } from "../../hooks";
810

911
type Props = {
1012
exam: ExamWithQuestions;
@@ -14,6 +16,56 @@ export function Results({ exam }: Props) {
1416
console.debug("Rendering component Results");
1517

1618
const { trainingQuestions, answers } = useTraining(exam.id);
19+
const explain = useFunction(Functions.ExplainQuestion);
20+
const logEvent = useLogEvent();
21+
const [explanations, setExplanations] = useState<Record<string, ReactNode>>(
22+
{},
23+
);
24+
25+
const explainQuestion = useCallback(
26+
async (questionId: string) => {
27+
if (explanations[questionId]) {
28+
return;
29+
}
30+
31+
setExplanations((explanations) => ({
32+
...explanations,
33+
[questionId]: <span aria-busy="true">Loading explanation...</span>,
34+
}));
35+
36+
logEvent("explain_question", {
37+
slug: exam.id,
38+
question_id: questionId,
39+
});
40+
41+
try {
42+
const result = await explain({ slug: exam.id, questionId });
43+
44+
if (!result) {
45+
setExplanations((explanations) => ({
46+
...explanations,
47+
[questionId]:
48+
"❌ Could not retrieve explanation from ChatGPT, please try again later.",
49+
}));
50+
return;
51+
}
52+
53+
setExplanations((explanations) => ({
54+
...explanations,
55+
[questionId]: result.explanation,
56+
}));
57+
} catch (error) {
58+
console.error(error);
59+
setExplanations((explanations) => ({
60+
...explanations,
61+
[questionId]:
62+
"❌ Could not retrieve explanation from ChatGPT, please try again later.",
63+
}));
64+
return;
65+
}
66+
},
67+
[exam.id, explain, explanations, logEvent],
68+
);
1769

1870
const totalCorrect = trainingQuestions.reduce((total, question, index) => {
1971
const answer = answers[question.id];
@@ -115,8 +167,38 @@ export function Results({ exam }: Props) {
115167
))}
116168
</ul>
117169
{question.explanation ? (
118-
<Markdown>{`_Explanation: ${question.explanation}_`}</Markdown>
170+
<Markdown>{`_**Explanation:** \n${question.explanation}_`}</Markdown>
119171
) : null}
172+
{explanations[question.id] ? (
173+
<p>
174+
<i>
175+
<b>
176+
<img
177+
src="/openai.svg"
178+
style={{ height: "1em", verticalAlign: "middle" }}
179+
alt="OpenAI logo"
180+
/>{" "}
181+
ChatGPT:
182+
</b>
183+
<br />
184+
{explanations[question.id]}
185+
</i>
186+
</p>
187+
) : (
188+
<button
189+
className="inline outline"
190+
onClick={() => {
191+
explainQuestion(question.id);
192+
}}
193+
>
194+
<img
195+
src="/openai.svg"
196+
style={{ height: "1em", verticalAlign: "middle" }}
197+
alt="OpenAI logo"
198+
/>{" "}
199+
Explain with ChatGPT
200+
</button>
201+
)}
120202
</details>
121203
{i !== trainingQuestions.length - 1 ? <hr /> : null}
122204
</Fragment>

packages/web/src/components/Training/TrainQuestions.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ export function TrainQuestions({ exam }: Props) {
2323
}
2424
});
2525

26+
useEffect(() => {
27+
document.getElementById("root")?.scrollIntoView({ behavior: "smooth" });
28+
}, [current]);
29+
2630
const question = trainingQuestions[current];
2731

2832
if (!showSplash && !question) {

0 commit comments

Comments
 (0)