Skip to content

Commit de21f86

Browse files
feat: Add reading comprehension page with AI-generated content
- Add reading comprehension data types to app/types.ts - Create server actions for content generation and scoring - Build reading comprehension page with timer-based reading - Add 5-question quiz with automatic and manual scoring - Store sessions and attempts in MongoDB - Add navigation button from main page Co-authored-by: tylerthecoder <tylerthecoder@users.noreply.github.com>
1 parent 64c870f commit de21f86

File tree

4 files changed

+729
-1
lines changed

4 files changed

+729
-1
lines changed

app/actions/readingComprehension.ts

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
'use server';
2+
3+
import { z } from 'zod';
4+
import { ObjectId } from 'mongodb';
5+
import { connectToDatabase } from '@/lib/db';
6+
import { getOpenAIClient } from '@/lib/openai';
7+
import { mapMongoId } from '@/lib/utils';
8+
import type {
9+
ReadingSessionDocument,
10+
ReadingSession,
11+
ReadingAttemptDocument,
12+
ReadingComprehensionQuestion,
13+
} from '@/types';
14+
15+
// --- Zod Schemas for Validation ---
16+
17+
const ReadingComprehensionQuestionSchema = z.object({
18+
question_text: z.string().trim().min(1, 'Question text cannot be empty'),
19+
correct_answer: z.string().trim().min(1, 'Answer text cannot be empty'),
20+
});
21+
22+
const ReadingContentResponseSchema = z.object({
23+
passage: z.string().trim().min(50, 'Passage must be at least 50 characters'),
24+
questions: z.array(ReadingComprehensionQuestionSchema).length(5, 'Must generate exactly 5 questions'),
25+
});
26+
27+
// --- Helper: Map Reading Session Document ---
28+
function mapReadingSessionDocument(doc: ReadingSessionDocument): ReadingSession {
29+
const mapped = mapMongoId(doc);
30+
return mapped as ReadingSession;
31+
}
32+
33+
// --- Action: Generate Reading Content ---
34+
35+
interface GenerateReadingContentResult {
36+
success: boolean;
37+
session?: ReadingSession;
38+
message?: string;
39+
}
40+
41+
export async function generateReadingContentAction(topic: string): Promise<GenerateReadingContentResult> {
42+
if (!topic || !topic.trim()) {
43+
return { success: false, message: 'Topic cannot be empty.' };
44+
}
45+
46+
try {
47+
const openai = getOpenAIClient();
48+
const { db } = await connectToDatabase();
49+
const readingSessionsCollection = db.collection<ReadingSessionDocument>('reading_sessions');
50+
51+
const prompt = `Generate reading comprehension content for the topic: "${topic}".
52+
53+
Create a short paragraph (150-250 words) about this topic that is informative and engaging. The paragraph should contain specific facts, dates, names, or details that can be tested.
54+
55+
Then create exactly 5 comprehension questions with specific answers based on the passage. The questions should:
56+
- Test specific details from the passage
57+
- Have clear, factual answers (not open-ended)
58+
- Be answerable directly from the text
59+
- Include a mix of who, what, when, where, why questions
60+
61+
Output Format:
62+
Provide the output as a JSON object with "passage" (string) and "questions" (array of objects). Each question object must have "question_text" and "correct_answer" fields.
63+
64+
Example format:
65+
{
66+
"passage": "Your informative paragraph here...",
67+
"questions": [
68+
{
69+
"question_text": "What year was X founded?",
70+
"correct_answer": "1995"
71+
},
72+
...
73+
]
74+
}`;
75+
76+
const completion = await openai.chat.completions.create({
77+
model: 'gpt-4o',
78+
messages: [{ role: 'user', content: prompt }],
79+
response_format: { type: 'json_object' },
80+
temperature: 0.7,
81+
});
82+
83+
const content = completion.choices[0]?.message?.content;
84+
if (!content) {
85+
throw new Error('OpenAI did not return content.');
86+
}
87+
88+
let parsedContent;
89+
try {
90+
parsedContent = JSON.parse(content);
91+
} catch (parseError) {
92+
console.error('Failed to parse OpenAI JSON response:', parseError, 'Content:', content);
93+
throw new Error('Failed to parse reading content from AI response.');
94+
}
95+
96+
const validationResult = ReadingContentResponseSchema.safeParse(parsedContent);
97+
if (!validationResult.success) {
98+
console.error('OpenAI response validation failed:', validationResult.error.errors);
99+
throw new Error('Generated reading content is not in the expected format.');
100+
}
101+
102+
const newReadingSessionData: Omit<ReadingSessionDocument, '_id'> = {
103+
topic: topic.trim(),
104+
passage: validationResult.data.passage,
105+
questions: validationResult.data.questions,
106+
createdAt: new Date(),
107+
};
108+
109+
const insertResult = await readingSessionsCollection.insertOne(newReadingSessionData as ReadingSessionDocument);
110+
if (!insertResult.insertedId) {
111+
throw new Error('Failed to insert reading session into database.');
112+
}
113+
114+
const createdDoc = await readingSessionsCollection.findOne({ _id: insertResult.insertedId });
115+
if (!createdDoc) {
116+
throw new Error('Failed to retrieve created reading session from database.');
117+
}
118+
119+
const mappedSession = mapReadingSessionDocument(createdDoc);
120+
return { success: true, session: mappedSession };
121+
122+
} catch (error) {
123+
console.error('[Generate Reading Content Action Error]', error);
124+
const message = error instanceof Error ? error.message : 'Failed to generate reading content.';
125+
return { success: false, message };
126+
}
127+
}
128+
129+
// --- Action: Submit Reading Attempt ---
130+
131+
interface SubmitReadingAttemptResult {
132+
success: boolean;
133+
attempt?: {
134+
id: string;
135+
total_score: number;
136+
answers: {
137+
question_index: number;
138+
user_answer: string;
139+
is_correct: boolean;
140+
correct_answer: string;
141+
}[];
142+
};
143+
message?: string;
144+
}
145+
146+
export async function submitReadingAttemptAction(
147+
sessionId: string,
148+
readingTimeMs: number,
149+
userAnswers: { question_index: number; user_answer: string }[]
150+
): Promise<SubmitReadingAttemptResult> {
151+
if (!sessionId || !ObjectId.isValid(sessionId)) {
152+
return { success: false, message: 'Invalid session ID.' };
153+
}
154+
if (readingTimeMs <= 0) {
155+
return { success: false, message: 'Invalid reading time.' };
156+
}
157+
if (!Array.isArray(userAnswers) || userAnswers.length === 0) {
158+
return { success: false, message: 'No answers provided.' };
159+
}
160+
161+
try {
162+
const { db } = await connectToDatabase();
163+
const readingSessionsCollection = db.collection<ReadingSessionDocument>('reading_sessions');
164+
const readingAttemptsCollection = db.collection<ReadingAttemptDocument>('reading_attempts');
165+
166+
// Fetch the reading session to get correct answers
167+
const session = await readingSessionsCollection.findOne({ _id: new ObjectId(sessionId) });
168+
if (!session) {
169+
throw new Error('Reading session not found.');
170+
}
171+
172+
// Score the answers by simple string comparison (case-insensitive)
173+
const scoredAnswers = userAnswers.map(userAnswer => {
174+
const question = session.questions[userAnswer.question_index];
175+
if (!question) {
176+
throw new Error(`Question at index ${userAnswer.question_index} not found.`);
177+
}
178+
179+
const isCorrect = userAnswer.user_answer.trim().toLowerCase() ===
180+
question.correct_answer.trim().toLowerCase();
181+
182+
return {
183+
question_index: userAnswer.question_index,
184+
user_answer: userAnswer.user_answer.trim(),
185+
is_correct: isCorrect,
186+
correct_answer: question.correct_answer,
187+
};
188+
});
189+
190+
const totalScore = scoredAnswers.filter(answer => answer.is_correct).length;
191+
192+
// Store the attempt
193+
const newAttemptData: Omit<ReadingAttemptDocument, '_id'> = {
194+
session_id: new ObjectId(sessionId),
195+
reading_time_ms: readingTimeMs,
196+
answers: scoredAnswers.map(answer => ({
197+
question_index: answer.question_index,
198+
user_answer: answer.user_answer,
199+
is_correct: answer.is_correct,
200+
})),
201+
total_score: totalScore,
202+
createdAt: new Date(),
203+
};
204+
205+
const insertResult = await readingAttemptsCollection.insertOne(newAttemptData as ReadingAttemptDocument);
206+
if (!insertResult.insertedId) {
207+
throw new Error('Failed to store reading attempt.');
208+
}
209+
210+
return {
211+
success: true,
212+
attempt: {
213+
id: insertResult.insertedId.toString(),
214+
total_score: totalScore,
215+
answers: scoredAnswers,
216+
},
217+
};
218+
219+
} catch (error) {
220+
console.error('[Submit Reading Attempt Action Error]', error);
221+
const message = error instanceof Error ? error.message : 'Failed to submit reading attempt.';
222+
return { success: false, message };
223+
}
224+
}
225+
226+
// --- Action: Update Answer Score (Override) ---
227+
228+
interface UpdateAnswerScoreResult {
229+
success: boolean;
230+
message?: string;
231+
}
232+
233+
export async function updateAnswerScoreAction(
234+
attemptId: string,
235+
questionIndex: number,
236+
newIsCorrect: boolean
237+
): Promise<UpdateAnswerScoreResult> {
238+
if (!attemptId || !ObjectId.isValid(attemptId)) {
239+
return { success: false, message: 'Invalid attempt ID.' };
240+
}
241+
242+
try {
243+
const { db } = await connectToDatabase();
244+
const readingAttemptsCollection = db.collection<ReadingAttemptDocument>('reading_attempts');
245+
246+
// Find the attempt
247+
const attempt = await readingAttemptsCollection.findOne({ _id: new ObjectId(attemptId) });
248+
if (!attempt) {
249+
throw new Error('Reading attempt not found.');
250+
}
251+
252+
// Update the specific answer
253+
const updatedAnswers = attempt.answers.map(answer => {
254+
if (answer.question_index === questionIndex) {
255+
return {
256+
...answer,
257+
is_correct: newIsCorrect,
258+
overridden: true,
259+
};
260+
}
261+
return answer;
262+
});
263+
264+
// Recalculate total score
265+
const newTotalScore = updatedAnswers.filter(answer => answer.is_correct).length;
266+
267+
// Update the document
268+
await readingAttemptsCollection.updateOne(
269+
{ _id: new ObjectId(attemptId) },
270+
{
271+
$set: {
272+
answers: updatedAnswers,
273+
total_score: newTotalScore,
274+
}
275+
}
276+
);
277+
278+
return { success: true };
279+
280+
} catch (error) {
281+
console.error('[Update Answer Score Action Error]', error);
282+
const message = error instanceof Error ? error.message : 'Failed to update answer score.';
283+
return { success: false, message };
284+
}
285+
}

app/page.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,9 @@ export default function HomePage() {
187187
<Link href="/quiz" passHref legacyBehavior>
188188
<Button as="a" variant="secondary" size="sm" disabled={isLoadingOverall}>AI Quiz Generator</Button>
189189
</Link>
190+
<Link href="/reading-comprehension" passHref legacyBehavior>
191+
<Button as="a" variant="secondary" size="sm" disabled={isLoadingOverall}>Reading Comprehension</Button>
192+
</Link>
190193
</div>
191194
</div>
192195
)}

0 commit comments

Comments
 (0)