238 | )
239 | }
240 |
--------------------------------------------------------------------------------
/convex/quizzes/actions.ts:
--------------------------------------------------------------------------------
1 | import { createOpenAI } from '@ai-sdk/openai'
2 | import { generateObject, generateText } from 'ai'
3 | import { ConvexError, v } from 'convex/values'
4 | import { nanoid } from 'nanoid'
5 | import { z } from 'zod'
6 | import { api, internal } from '../_generated/api'
7 | import { Doc, Id } from '../_generated/dataModel'
8 | import { action, internalQuery } from '../_generated/server'
9 | import { ALPHABET_MAP } from '../constants'
10 | import { requireCurrentUser } from '../users'
11 | import { handlePromise, quizAuthCheckFunc } from '../utils'
12 |
13 | export const createQuiz = action({
14 | args: {
15 | notes: v.string(),
16 | context: v.optional(v.string()),
17 | numQuestions: v.number(),
18 | optionsPerQuestion: v.number(),
19 | },
20 | handler: async (ctx, args) => {
21 | const user = await requireCurrentUser(ctx)
22 |
23 | const [apiKey, error] = await handlePromise(
24 | ctx.runAction(api.key.getApiKey)
25 | )
26 |
27 | if (error) {
28 | throw new ConvexError('No API key found. Please create an API key.')
29 | }
30 |
31 | if (!user) {
32 | // Shouldn't happen otherwise user should be redirected
33 | throw new ConvexError('Unauthorized. Please login to create a quiz.')
34 | }
35 |
36 | if (!apiKey) {
37 | throw new ConvexError('No API key found. Please create an API key.')
38 | }
39 |
40 | const openai = createOpenAI({
41 | apiKey,
42 | })
43 |
44 | // Generate quiz with title and questions
45 | const { object } = await generateObject({
46 | model: openai('gpt-4-turbo'),
47 | schema: z.object({
48 | title: z
49 | .string()
50 | .describe(
51 | 'A catchy, descriptive title for the quiz based on the notes. Do not use words like "Mastering" or "Exploring". Try to be creative.'
52 | ),
53 | questions: z
54 | .array(
55 | z.object({
56 | question: z
57 | .string()
58 | .describe('A question with a single correct answer'),
59 | options: z
60 | .array(
61 | z
62 | .object({
63 | text: z.string(),
64 | isCorrect: z.boolean(),
65 | })
66 | .describe(
67 | 'An option with text and a boolean indicating if it is correct'
68 | )
69 | )
70 | .describe('An array of options')
71 | .length(args.optionsPerQuestion),
72 | explanation: z
73 | .string()
74 | .describe('Explanation of why the correct answer is correct'),
75 | })
76 | )
77 | .describe('An array of questions with options and explanations')
78 | .length(args.numQuestions),
79 | }),
80 | prompt: `Generate a quiz based on the following notes:\n\n${args.notes}\n\n${
81 | args.context ? `Additional context: ${args.context}\n\n` : ''
82 | }Create ${args.numQuestions} challenging questions with ${args.optionsPerQuestion} options each.
83 | Ensure exactly one option is correct for each question.
84 | Include a brief explanation for each correct answer.
85 | Also generate a descriptive title for this quiz.`,
86 | })
87 |
88 | // Add unique IDs to questions and options
89 | const questionsWithIds = object.questions.map((question) => ({
90 | ...question,
91 | id: nanoid(),
92 | options: question.options.map((option, optionIndex) => ({
93 | ...option,
94 | id: ALPHABET_MAP[optionIndex],
95 | })),
96 | }))
97 |
98 | const quizId: Id<'quizzes'> = await ctx.runMutation(
99 | internal.quizzes.mutations.storeQuiz,
100 | {
101 | userId: user._id,
102 | title: object.title,
103 | notes: args.notes,
104 | context: args.context,
105 | numQuestions: args.numQuestions,
106 | optionsPerQuestion: args.optionsPerQuestion,
107 | createdAt: Date.now(),
108 | questions: questionsWithIds,
109 | isCompleted: false,
110 | progress: {
111 | currentQuestionIndex: 0,
112 | answers: [],
113 | lastUpdated: Date.now(),
114 | },
115 | }
116 | )
117 |
118 | return quizId
119 | },
120 | })
121 |
122 | type QuestionResult = {
123 | question: string
124 | selectedAnswer: string
125 | correctAnswer: string
126 | isCorrect: boolean
127 | explanation: string
128 | }
129 |
130 | const zodAnswer = z.object({
131 | questionId: z.string(),
132 | selectedOptionId: z.string(),
133 | })
134 |
135 | export const quizAuthCheck = internalQuery({
136 | args: { quizId: v.id('quizzes') },
137 | handler: async (ctx, args) => {
138 | return await quizAuthCheckFunc({ quizId: args.quizId, ctx })
139 | },
140 | })
141 |
142 | export const completeQuiz = action({
143 | args: { quizId: v.id('quizzes'), selectedOptionId: v.string() },
144 | handler: async (ctx, args) => {
145 | const [apiKey, error] = await handlePromise(
146 | ctx.runAction(api.key.getApiKey)
147 | )
148 |
149 | // If no API key, do nothing to avoid ruining state of DB!
150 |
151 | if (error) {
152 | throw new ConvexError('No API key found. Please create an API key.')
153 | }
154 |
155 | if (!apiKey) {
156 | throw new ConvexError('No API key found. Please create an API key.')
157 | }
158 |
159 | const user = await requireCurrentUser(ctx)
160 |
161 | // If user is not found, throw an error
162 | if (!user) {
163 | throw new ConvexError('Unauthenticated. Please login to submit a quiz.')
164 | }
165 |
166 | const quizForAuthCheck: Doc<'quizzes'> | null = await ctx.runQuery(
167 | api.quizzes.queries.getQuizById,
168 | {
169 | id: args.quizId,
170 | }
171 | )
172 |
173 | // If quiz is not found, throw an error
174 | if (!quizForAuthCheck) {
175 | throw new ConvexError('Quiz not found')
176 | }
177 |
178 | if (quizForAuthCheck.userId !== user._id) {
179 | throw new ConvexError('Unauthorized. You have no access to this quiz.')
180 | }
181 |
182 | // Make sure we add the new answer to the progress
183 | await ctx.runMutation(api.quizzes.mutations.nextQuestion, {
184 | quizId: args.quizId,
185 | selectedOptionId: args.selectedOptionId,
186 | })
187 |
188 | const quiz = await ctx.runQuery(api.quizzes.queries.getQuizById, {
189 | id: args.quizId,
190 | })
191 |
192 | if (!quiz) {
193 | throw new ConvexError('Quiz not found')
194 | }
195 |
196 | const answers = quiz.progress.answers || []
197 |
198 | // Calculate results
199 | let correctCount = 0
200 | const incorrectQuestionIdsMap: Record