Skip to content

Commit 5593e41

Browse files
committed
feat: Load initial quiz questions in parallel
1 parent 5fc8c55 commit 5593e41

File tree

7 files changed

+215
-25
lines changed

7 files changed

+215
-25
lines changed

app/actions/exercise.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,17 @@ import { countCachedExercises } from '@/lib/exercise-cache';
99
import { validateRequestParams, getOrGenerateExercise } from './exercise-orchestrator';
1010
import { checkRateLimit } from '@/lib/rate-limiter';
1111
import type { ZodIssue } from 'zod';
12+
import { z } from 'zod';
1213
import type { GenerateExerciseResult } from '@/lib/domain/schemas';
1314
import { LANGUAGES } from '@/lib/domain/language';
1415
import { getRandomTopicForLevel } from '@/lib/domain/topics';
1516
import { getGrammarGuidance, getVocabularyGuidance } from '@/lib/domain/language-guidance';
1617
import type { ExerciseGenerationParams } from '@/lib/domain/ai';
18+
import {
19+
// InitialExercisePairResultSchema, // Unused schema
20+
type InitialExercisePairResult,
21+
GenerateExerciseResultSchema,
22+
} from '@/lib/domain/schemas';
1723

1824
// --- Main Action ---
1925

@@ -35,6 +41,7 @@ export const generateExerciseResponse = async (
3541
const validParams = validationResult.data;
3642

3743
const isAllowed = checkRateLimit(ip);
44+
3845
if (!isAllowed) {
3946
const cachedResult = await tryGetCachedExercise(validParams, userId);
4047
if (cachedResult) {
@@ -75,3 +82,84 @@ export const generateExerciseResponse = async (
7582
return createErrorResponse(errorMessage);
7683
}
7784
};
85+
86+
// --- New Action for Initial Pair ---
87+
88+
export const generateInitialExercisePair = async (
89+
requestParams: unknown
90+
): Promise<InitialExercisePairResult> => {
91+
// const actionStartTime = Date.now(); // Unused var
92+
const headersList = await headers();
93+
const ip = headersList.get('x-forwarded-for') || 'unknown';
94+
const session: Session | null = await getServerSession(authOptions);
95+
const userId = getDbUserIdFromSession(session);
96+
97+
const validationResult = validateRequestParams(requestParams); // Reuse existing validation
98+
if (!validationResult.success) {
99+
const errorMsg = `Invalid request parameters: ${validationResult.error.errors
100+
.map((e: ZodIssue) => `${e.path.join('.')}: ${e.message}`)
101+
.join(', ')}`;
102+
return { quizzes: [], error: errorMsg }; // Return error in the new format
103+
}
104+
const validParams = validationResult.data;
105+
106+
// --- 2. Rate Limiting (Counts as one action call) ---
107+
const isAllowed = checkRateLimit(ip);
108+
if (!isAllowed) {
109+
// Maybe try returning cached *pair*? For now, just deny.
110+
return { quizzes: [], error: 'Rate limit exceeded.' };
111+
}
112+
113+
// --- 3. Prepare Generation Params (Same for both calls) ---
114+
const genParams: ExerciseGenerationParams = {
115+
passageLanguage: validParams.passageLanguage,
116+
questionLanguage: validParams.questionLanguage,
117+
level: validParams.cefrLevel,
118+
passageLangName: LANGUAGES[validParams.passageLanguage],
119+
questionLangName: LANGUAGES[validParams.questionLanguage],
120+
topic: getRandomTopicForLevel(validParams.cefrLevel),
121+
grammarGuidance: getGrammarGuidance(validParams.cefrLevel),
122+
vocabularyGuidance: getVocabularyGuidance(validParams.cefrLevel),
123+
};
124+
const genParams2 = { ...genParams, topic: getRandomTopicForLevel(validParams.cefrLevel) };
125+
126+
// --- 4. Concurrent Generation Attempt ---
127+
let results: [GenerateExerciseResult, GenerateExerciseResult] | null = null;
128+
let errorResult: { quizzes: []; error: string } | null = null;
129+
130+
try {
131+
const generationPromises = [
132+
getOrGenerateExercise(genParams, userId, 0),
133+
getOrGenerateExercise(genParams2, userId, 0),
134+
];
135+
136+
const settledResults = await Promise.all(generationPromises);
137+
138+
if (settledResults.every((r) => r.error === null && r.quizId !== -1)) {
139+
const validatedResults = z.array(GenerateExerciseResultSchema).safeParse(settledResults);
140+
if (validatedResults.success) {
141+
results = validatedResults.data as [GenerateExerciseResult, GenerateExerciseResult];
142+
} else {
143+
errorResult = { quizzes: [], error: 'Internal error processing generated results.' };
144+
}
145+
} else {
146+
const errors = settledResults.map((r) => r.error).filter((e) => e !== null);
147+
errorResult = {
148+
quizzes: [],
149+
error: `Failed to generate exercise pair: ${errors.join('; ')}`,
150+
};
151+
}
152+
} catch (error) {
153+
const message = error instanceof Error ? error.message : 'Unknown generation error';
154+
errorResult = { quizzes: [], error: `Server error during generation: ${message}` };
155+
}
156+
157+
// --- 5. Return Result ---
158+
if (results) {
159+
// console.log(`[Action:InitialPair] Successfully generated pair. Total time: ${Date.now() - actionStartTime}ms`); // Remove reference to unused var
160+
return { quizzes: results, error: null };
161+
} else {
162+
// console.log(`[Action:InitialPair] Failed to generate pair. Total time: ${Date.now() - actionStartTime}ms`); // Remove reference to unused var
163+
return errorResult ?? { quizzes: [], error: 'Unknown failure generating exercise pair.' };
164+
}
165+
};

app/components/TextGenerator/Generator.test.tsx

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { render, screen, fireEvent } from '@testing-library/react';
22
import { vi } from 'vitest';
33
import Generator from './Generator';
44
import React from 'react';
5+
import type { QuizData } from '@/lib/domain/schemas';
56

67
vi.mock('next-auth/react', () => ({
78
useSession: () => ({ status: 'authenticated' }),
@@ -11,7 +12,20 @@ vi.mock('react-i18next', () => ({
1112
useTranslation: () => ({ t: (key: string) => key }),
1213
}));
1314

14-
const mockStore = {
15+
const mockStore: {
16+
loading: boolean;
17+
quizData: QuizData | null;
18+
isAnswered: boolean;
19+
generateText: any;
20+
feedbackSubmitted: boolean;
21+
submitFeedback: any;
22+
nextQuizAvailable: boolean;
23+
loadNextQuiz: any;
24+
resetQuizWithNewData: any;
25+
setNextQuizAvailable: any;
26+
fetchInitialPair: any;
27+
useHoverCredit: any;
28+
} = {
1529
loading: false,
1630
quizData: null,
1731
isAnswered: false,
@@ -20,6 +34,10 @@ const mockStore = {
2034
submitFeedback: vi.fn(),
2135
nextQuizAvailable: false,
2236
loadNextQuiz: vi.fn(),
37+
resetQuizWithNewData: vi.fn(),
38+
setNextQuizAvailable: vi.fn(),
39+
fetchInitialPair: vi.fn(),
40+
useHoverCredit: vi.fn(),
2341
};
2442

2543
vi.mock('@/store/textGeneratorStore', () => ({
@@ -50,6 +68,10 @@ describe('Generator', () => {
5068
submitFeedback: vi.fn(),
5169
nextQuizAvailable: false,
5270
loadNextQuiz: vi.fn(),
71+
resetQuizWithNewData: vi.fn(),
72+
setNextQuizAvailable: vi.fn(),
73+
fetchInitialPair: vi.fn(),
74+
useHoverCredit: vi.fn(),
5375
});
5476
});
5577

@@ -58,14 +80,20 @@ describe('Generator', () => {
5880
expect(screen.getByTestId('generate-button')).toBeInTheDocument();
5981
});
6082

61-
it('calls generateText when generate button clicked and no nextQuizAvailable', () => {
83+
it('calls fetchInitialPair when generate button clicked and quizData is null', () => {
6284
render(<Generator />);
6385
fireEvent.click(screen.getByTestId('generate-button'));
64-
expect(mockStore.generateText).toHaveBeenCalled();
86+
expect(mockStore.fetchInitialPair).toHaveBeenCalled();
6587
});
6688

67-
it('calls loadNextQuiz when nextQuizAvailable is true', () => {
68-
mockStore.nextQuizAvailable = true;
89+
it('calls loadNextQuiz when quizData exists', () => {
90+
mockStore.quizData = {
91+
paragraph: 'mock paragraph',
92+
question: 'mock question?',
93+
options: { A: 'a', B: 'b', C: 'c', D: 'd' },
94+
};
95+
mockStore.isAnswered = true;
96+
mockStore.feedbackSubmitted = true;
6997
render(<Generator />);
7098
fireEvent.click(screen.getByTestId('generate-button'));
7199
expect(mockStore.loadNextQuiz).toHaveBeenCalled();

app/components/TextGenerator/Generator.tsx

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,29 +14,13 @@ const Generator = () => {
1414
loading,
1515
quizData,
1616
isAnswered,
17-
generateText,
1817
feedbackSubmitted,
1918
submitFeedback,
20-
nextQuizAvailable,
2119
loadNextQuiz,
20+
fetchInitialPair,
2221
} = useTextGeneratorStore();
2322
const contentContainerRef = useRef<HTMLDivElement>(null);
2423

25-
const generateTextHandler = useCallback(() => {
26-
if (contentContainerRef.current) {
27-
contentContainerRef.current.scrollIntoView({
28-
behavior: 'smooth',
29-
block: 'start',
30-
});
31-
}
32-
33-
if (nextQuizAvailable) {
34-
loadNextQuiz();
35-
} else {
36-
void generateText();
37-
}
38-
}, [generateText, nextQuizAvailable, loadNextQuiz]);
39-
4024
const handleFeedbackSubmit = useCallback(
4125
(is_good: boolean) => {
4226
if (contentContainerRef.current) {
@@ -99,7 +83,13 @@ const Generator = () => {
9983

10084
{shouldOfferGeneration && (
10185
<button
102-
onClick={generateTextHandler}
86+
onClick={() => {
87+
if (!quizData) {
88+
void fetchInitialPair();
89+
} else {
90+
loadNextQuiz();
91+
}
92+
}}
10393
disabled={loading}
10494
data-testid="generate-button"
10595
className={`w-full px-6 py-3 border border-transparent rounded-md shadow-sm text-base font-medium text-white bg-gradient-to-r from-blue-600 via-indigo-600 to-green-600 hover:from-blue-700 hover:via-indigo-700 hover:to-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900 focus:ring-cyan-500 transition duration-150 ease-in-out flex items-center justify-center ${loading ? 'opacity-75 cursor-not-allowed' : ''}`}

app/store/progressSlice.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ const setupStore = () =>
105105
resetQuizWithNewData: vi.fn(),
106106
setNextQuizAvailable: vi.fn(),
107107
loadNextQuiz: vi.fn(),
108+
fetchInitialPair: vi.fn(),
108109
useHoverCredit: vi.fn(),
109110
setQuizComplete: vi.fn(),
110111
goToNextQuestion: vi.fn(),

app/store/quizSlice.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import type { StateCreator } from 'zustand';
22
import { submitAnswer, submitFeedback } from '@/app/actions/progress';
3-
import { generateExerciseResponse } from '@/app/actions/exercise';
3+
import { generateExerciseResponse, generateInitialExercisePair } from '@/app/actions/exercise';
44
import type { TextGeneratorState } from './textGeneratorStore';
55
import type { CEFRLevel } from '@/lib/domain/language-guidance';
66
import {} from '@/hooks/useLanguage';
77
import {
88
PartialQuizData,
99
GenerateExerciseResultSchema,
1010
SubmitAnswerResultSchema,
11+
InitialExercisePairResultSchema,
1112
} from '@/lib/domain/schemas';
1213
import type { BaseSlice } from './baseSlice';
1314
import { createBaseSlice } from './baseSlice';
@@ -49,6 +50,7 @@ export interface QuizSlice extends BaseSlice {
4950
resetQuizWithNewData: (newQuizData: PartialQuizData, quizId: number) => void;
5051
setNextQuizAvailable: (info: NextQuizInfo | null) => void;
5152
loadNextQuiz: () => void;
53+
fetchInitialPair: () => Promise<void>;
5254

5355
useHoverCredit: () => boolean;
5456
}
@@ -160,7 +162,80 @@ export const createQuizSlice: StateCreator<
160162
}
161163
},
162164

165+
fetchInitialPair: async (): Promise<void> => {
166+
set({ loading: true, error: null, showContent: false });
167+
get().stopPassageSpeech();
168+
get().resetQuizState();
169+
170+
try {
171+
const fetchParams = {
172+
passageLanguage: get().passageLanguage,
173+
questionLanguage: get().generatedQuestionLanguage,
174+
cefrLevel: get().cefrLevel,
175+
};
176+
177+
const rawResult = await generateInitialExercisePair(fetchParams);
178+
179+
const parseResult = InitialExercisePairResultSchema.safeParse(rawResult);
180+
181+
if (!parseResult.success) {
182+
console.error(
183+
'[Store] Zod validation error (fetchInitialPair):',
184+
parseResult.error.format()
185+
);
186+
throw new Error(`Invalid API response structure: ${parseResult.error.message}`);
187+
}
188+
189+
const result = parseResult.data;
190+
191+
if (result.error || result.quizzes.length !== 2) {
192+
throw new Error(result.error || 'Invalid number of quizzes received');
193+
}
194+
195+
const [quizInfo1, quizInfo2] = result.quizzes;
196+
197+
set((state) => {
198+
state.quizData = quizInfo1.quizData;
199+
state.currentQuizId = quizInfo1.quizId;
200+
state.generatedPassageLanguage = fetchParams.passageLanguage;
201+
202+
state.selectedAnswer = null;
203+
state.isAnswered = false;
204+
state.relevantTextRange = null;
205+
state.feedbackIsCorrect = null;
206+
state.feedbackCorrectAnswer = null;
207+
state.feedbackCorrectExplanation = null;
208+
state.feedbackChosenIncorrectExplanation = null;
209+
state.feedbackRelevantText = null;
210+
state.showExplanation = false;
211+
state.feedbackSubmitted = false;
212+
state.hoverCreditsUsed = 0;
213+
214+
state.nextQuizAvailable = { quizData: quizInfo2.quizData, quizId: quizInfo2.quizId };
215+
216+
state.showQuestionSection = true;
217+
state.showContent = true;
218+
state.loading = false;
219+
state.error = null;
220+
221+
console.log('[Store] Initial exercise pair fetched and processed.');
222+
});
223+
} catch (e) {
224+
const errorMessage = e instanceof Error ? e.message : 'Unknown error fetching initial pair';
225+
console.error('[Store] Error in fetchInitialPair:', e);
226+
get().setError(errorMessage);
227+
get().setShowContent(false);
228+
set({ loading: false, nextQuizAvailable: null });
229+
}
230+
},
231+
163232
generateText: async (isPrefetch = false): Promise<void> => {
233+
if (!isPrefetch && get().nextQuizAvailable) {
234+
console.log('[Store] Using pre-fetched quiz for generateText request.');
235+
get().loadNextQuiz();
236+
return;
237+
}
238+
164239
set({ loading: !isPrefetch, error: null });
165240
if (!isPrefetch) {
166241
get().stopPassageSpeech();

lib/ai/exercise-generator.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export const generateAndValidateExercise = async (
2727
options: ExerciseGenerationOptions
2828
): Promise<ExerciseContent> => {
2929
const prompt = generateExercisePrompt(options);
30-
console.log('[AI:generateAndValidateExercise] Generated Prompt:\n', prompt);
30+
// console.log('[AI:generateAndValidateExercise] Generated Prompt:\n', prompt);
3131

3232
let aiResponse: unknown;
3333
try {

lib/domain/schemas.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,11 @@ export const apiResponseSchema = z.object({
152152
result: z.string(),
153153
});
154154
export type ApiResponse = z.infer<typeof apiResponseSchema>;
155+
156+
// Schema for the result of generating an initial pair of exercises
157+
export const InitialExercisePairResultSchema = z.object({
158+
quizzes: z.array(GenerateExerciseResultSchema).length(2),
159+
error: z.string().nullable(),
160+
});
161+
162+
export type InitialExercisePairResult = z.infer<typeof InitialExercisePairResultSchema>;

0 commit comments

Comments
 (0)