|
| 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 | +); |
0 commit comments