Skip to content

Commit 8bc3d49

Browse files
fix(exams): fix AI exam grading - missing column, wrong question IDs, hook schema
- Remove non-existent `passing_score` from exams query (caused "Exam not found") - Fix AI prompt to include actual question_id values instead of sequential numbers (AI was returning IDs like 1,2,3,4 instead of 2007,2008,2009,2010, causing FK violation) - Fix config.toml hook URI: public → app_private schema (login was failing) - Fix result page to read `overall_feedback` from ai_data (was reading `summary`) - Add E2E test for full exam grading flow Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ee2d042 commit 8bc3d49

4 files changed

Lines changed: 148 additions & 7 deletions

File tree

app/[locale]/dashboard/student/courses/[courseId]/exams/[examId]/result/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ export default async function ExamResultPage({ params }: PageProps) {
278278
<CardContent className="p-4 sm:p-6">
279279
<div className="prose prose-lg dark:prose-invert max-w-none">
280280
<p className="text-gray-900 dark:text-gray-100 leading-relaxed font-medium text-base">
281-
{aiData.summary || "Your performance has been evaluated. Review the detailed feedback per question below."}
281+
{aiData.overall_feedback || aiData.summary || "Your performance has been evaluated. Review the detailed feedback per question below."}
282282
</p>
283283
</div>
284284
</CardContent>

app/actions/exam-grading.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,6 @@ export async function gradeExamWithAI(
104104
exam_id,
105105
title,
106106
description,
107-
passing_score,
108107
questions:exam_questions (
109108
question_id,
110109
question_text,
@@ -126,6 +125,7 @@ export async function gradeExamWithAI(
126125
.single()
127126

128127
if (examError || !exam) {
128+
console.error('Exam query failed:', examError)
129129
return { success: false, error: 'Exam not found' }
130130
}
131131

@@ -293,7 +293,7 @@ export async function gradeExamWithAI(
293293

294294
const qPoints = q.points || 10
295295
let questionContext = `
296-
**Question ${index + 1}** (${qPoints} points)
296+
**Question ID: ${q.question_id}** (${qPoints} points)
297297
Type: Free Text
298298
Question: ${q.question_text}
299299
Student Answer: ${studentAnswer}
@@ -326,18 +326,19 @@ ${config.ai_grading_prompt ? `\n**Teacher's Custom Instructions:**\n${config.ai_
326326
Title: ${exam.title}
327327
${exam.description ? `Description: ${exam.description}` : ''}
328328
Total Free-Text Questions: ${freeTextQuestions.length}
329-
Passing Score: ${exam.passing_score || 'Not specified'}
329+
Total Questions: ${exam.questions.length}
330330
331331
**Free-Text Questions and Student Answers:**
332332
${questionsContext}
333333
334334
**Your Task:**
335-
Evaluate each free-text answer carefully and provide detailed feedback. Return your evaluation in the following JSON format:
335+
Evaluate each free-text answer carefully and provide detailed feedback. Return your evaluation in the following JSON format.
336+
IMPORTANT: Use the exact "Question ID" number from each question header above (e.g., ${freeTextQuestions.map((q: any) => q.question_id).join(', ')}). Do NOT use sequential numbers like 1, 2, 3.
336337
337338
{
338339
"questions": [
339340
{
340-
"question_id": <question_id>,
341+
"question_id": <exact Question ID number from above>,
341342
"student_answer": "<student's answer>",
342343
"is_correct": true/false,
343344
"points_earned": <points earned (can be partial)>,

supabase/config.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ max_frequency = "5s"
262262
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
263263
[auth.hook.custom_access_token]
264264
enabled = true
265-
uri = "pg-functions://postgres/public/custom_access_token_hook"
265+
uri = "pg-functions://postgres/app_private/custom_access_token_hook"
266266

267267
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
268268
[auth.sms.twilio]
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { test, expect } from '@playwright/test'
2+
3+
const BASE_URL = 'http://code-academy.lvh.me:3000'
4+
const STUDENT_EMAIL = 'alice@student.com'
5+
const STUDENT_PASSWORD = 'password123'
6+
const COURSE_ID = 2001
7+
const EXAM_ID = 2001
8+
9+
test.describe('AI Exam Grading E2E', () => {
10+
test('student takes exam and gets AI-graded results', async ({ page }) => {
11+
test.setTimeout(120000) // 2 minutes for AI grading
12+
// Step 1: Login as student
13+
await page.goto(`${BASE_URL}/en/auth/login`)
14+
await page.waitForLoadState('networkidle')
15+
16+
await page.fill('input[type="email"]', STUDENT_EMAIL)
17+
await page.fill('input[type="password"]', STUDENT_PASSWORD)
18+
await page.click('button[type="submit"]')
19+
await page.waitForURL(/dashboard/, { timeout: 15000 })
20+
console.log('✅ Logged in as student')
21+
22+
// Step 2: Navigate to exam
23+
await page.goto(`${BASE_URL}/en/dashboard/student/courses/${COURSE_ID}/exams/${EXAM_ID}`)
24+
await page.waitForLoadState('networkidle')
25+
console.log('📋 Navigated to exam page:', page.url())
26+
27+
// Check we're on the exam taker (not redirected to result)
28+
const pageContent = await page.textContent('body')
29+
if (pageContent?.includes('No Questions Found')) {
30+
console.log('❌ No questions found for this exam')
31+
return
32+
}
33+
34+
// Check if redirected to result (already submitted)
35+
if (page.url().includes('/result')) {
36+
console.log('ℹ️ Already submitted this exam, checking result page...')
37+
await checkResultPage(page)
38+
return
39+
}
40+
41+
// Step 3: Answer all questions
42+
console.log('📝 Starting to answer questions...')
43+
const totalQuestions = 10
44+
45+
for (let i = 0; i < totalQuestions; i++) {
46+
// Wait for question to load
47+
await page.waitForSelector('h2', { timeout: 5000 })
48+
const questionText = await page.textContent('h2')
49+
console.log(` Q${i + 1}: ${questionText?.substring(0, 60)}...`)
50+
51+
// Determine question type and answer
52+
const hasRadioGroup = await page.locator('[role="radiogroup"]').count()
53+
const hasTextarea = await page.locator('textarea').count()
54+
55+
if (hasTextarea > 0) {
56+
// Free text question - provide a substantive answer
57+
const freeTextAnswers: Record<number, string> = {
58+
6: 'Lists are mutable ordered collections using square brackets [], while tuples are immutable ordered collections using parentheses (). Lists support append, remove, and other modification methods. Tuples cannot be modified after creation. Use lists when you need a dynamic collection that changes, like a shopping cart. Use tuples for fixed data like coordinates (x, y) or database records that should not change.',
59+
7: 'def count_words(text: str) -> dict[str, int]:\n words = text.lower().split()\n result = {}\n for word in words:\n result[word] = result.get(word, 0) + 1\n return result\n\nThis function splits the text into words after converting to lowercase for case-insensitive counting. It uses dict.get() with a default of 0 to handle new words. An alternative would be using collections.Counter.',
60+
8: 'EAFP stands for Easier to Ask Forgiveness than Permission. Instead of checking conditions before an operation (LBYL - Look Before You Leap), you try the operation and handle exceptions if they occur. Benefits: 1) Cleaner code without nested if checks, 2) Avoids race conditions (checking then acting is not atomic), 3) More Pythonic. Example: try: value = my_dict["key"] except KeyError: value = "default" vs if "key" in my_dict: value = my_dict["key"] else: value = "default"',
61+
9: 'A Python project should be structured with a root directory containing a src/ or package directory with __init__.py files. __init__.py marks a directory as a Python package, allowing imports. It can be empty or contain package-level initialization code. The if __name__ == "__main__" guard allows a module to be both imported and run as a script. When run directly, __name__ is set to "__main__", so guarded code executes. When imported, __name__ is the module name, so the guarded code is skipped. Example structure: myproject/ -> src/ -> mypackage/ -> __init__.py, module1.py, module2.py, tests/, setup.py',
62+
}
63+
const answerIndex = i - (totalQuestions - Object.keys(freeTextAnswers).length)
64+
const answer = Object.values(freeTextAnswers)[answerIndex] || 'This is a test answer for the free text question.'
65+
await page.fill('textarea', answer)
66+
} else if (hasRadioGroup > 0) {
67+
// Multiple choice or true/false - click first available option
68+
const labels = page.locator('[role="radiogroup"] label')
69+
const count = await labels.count()
70+
if (count > 0) {
71+
// Pick a reasonable answer (second option for variety)
72+
const pickIndex = Math.min(1, count - 1)
73+
await labels.nth(pickIndex).click()
74+
}
75+
}
76+
77+
// Navigate to next question or submit
78+
if (i < totalQuestions - 1) {
79+
const nextBtn = page.locator('button:has-text("Next Question")')
80+
await nextBtn.click()
81+
await page.waitForTimeout(500) // Brief pause for animation
82+
}
83+
}
84+
85+
console.log('✅ All questions answered')
86+
87+
// Step 4: Submit the exam
88+
const submitBtn = page.locator('[data-testid="exam-finish-submit"]')
89+
await expect(submitBtn).toBeVisible()
90+
91+
// Listen for console errors during submission
92+
const consoleErrors: string[] = []
93+
page.on('console', (msg) => {
94+
if (msg.type() === 'error') {
95+
consoleErrors.push(msg.text())
96+
}
97+
})
98+
99+
console.log('🚀 Submitting exam...')
100+
// Use evaluate to click - base-ui Button has issues with Playwright's .click()
101+
await page.evaluate(() => {
102+
const btn = document.querySelector('[data-testid="exam-finish-submit"]') as HTMLButtonElement
103+
if (btn) btn.click()
104+
})
105+
106+
// Wait for redirect to result page (AI grading may take up to 60s)
107+
await page.waitForURL(/result/, { timeout: 90000 })
108+
console.log('✅ Redirected to result page:', page.url())
109+
110+
if (consoleErrors.length > 0) {
111+
console.log('⚠️ Console errors during submission:', consoleErrors)
112+
}
113+
114+
// Step 5: Check result page
115+
await checkResultPage(page)
116+
})
117+
})
118+
119+
async function checkResultPage(page: any) {
120+
await page.waitForLoadState('networkidle')
121+
await page.waitForTimeout(3000) // Give time for data to load
122+
123+
const resultContent = await page.textContent('body')
124+
console.log('\n📊 Result page content (first 500 chars):')
125+
console.log(resultContent?.substring(0, 500))
126+
127+
// Check for score display
128+
if (resultContent?.includes('Score') || resultContent?.includes('score') || resultContent?.includes('%')) {
129+
console.log('✅ Score is displayed on result page')
130+
} else {
131+
console.log('⚠️ No score visible on result page')
132+
}
133+
134+
// Check for feedback
135+
if (resultContent?.includes('feedback') || resultContent?.includes('Feedback') || resultContent?.includes('Correct') || resultContent?.includes('Incorrect')) {
136+
console.log('✅ Feedback is displayed on result page')
137+
} else {
138+
console.log('⚠️ No feedback visible on result page')
139+
}
140+
}

0 commit comments

Comments
 (0)