feat: generalized chat quiz generator with PDF and Word downloads#495
feat: generalized chat quiz generator with PDF and Word downloads#495nandani-singh15 wants to merge 2 commits into
Conversation
|
@nandani-singh15 is attempting to deploy a commit to the firefistisdead's projects Team on Vercel. A member of the Team first needs to authorize it. |
|
Warning Review limit reached
More reviews will be available in 51 minutes and 32 seconds. Learn how PR review limits work. Your organization has run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (5)
📝 WalkthroughWalkthroughThis PR adds a "quiz" generation mode across the full stack. The backend detects quiz-related queries, generates 5-question multiple-choice quizzes in markdown format with correct answers marked, and disables extractive answers for quiz requests. The frontend adds PDF and Word export functionality with markdown-to-HTML parsing and styled document generation, plus UI buttons to download generated quizzes from chat messages. ChangesQuiz Mode Feature – Full Stack
Sequence Diagram(s)sequenceDiagram
participant Client
participant AskEndpoint as /ask endpoint
participant IntentDetection as detect_question_intent
participant ExtractionBuilder as build_answer_from_documents
participant GenerationBuilder as build generation prompt
participant LLM
Client->>AskEndpoint: POST with question/mode
AskEndpoint->>IntentDetection: Analyze question text
IntentDetection-->>AskEndpoint: Returns "quiz" if quiz-related phrasing detected
AskEndpoint->>ExtractionBuilder: Check if extractive path applies
ExtractionBuilder-->>AskEndpoint: Returns None for quiz intent (skip extractive)
AskEndpoint->>GenerationBuilder: Build prompt (mode/intent="quiz")
GenerationBuilder-->>AskEndpoint: Returns quiz prompt: "Generate 5-question multiple-choice quiz..."
AskEndpoint->>LLM: Generate with max_new_tokens=512
LLM-->>Client: Markdown quiz with correct answers
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@FireFistisDead kindly reveiw suggest and merge |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src/components/ChatPanel/MessageBubble.jsx`:
- Around line 114-150: The predicate that decides whether to render the
quiz-export buttons calls msg.text.includes and msg.text.toLowerCase() which
will throw if msg.text is null or not a string; update the conditional in
MessageBubble.jsx to guard msg.text by checking typeof msg.text === "string"
(e.g. typeof msg.text === "string" && (msg.mode === "quiz" ||
msg.text.includes("# Quiz") || msg.text.toLowerCase().includes("quiz:"))), so
the exportQuizToPdf/exportQuizToWord handlers are only shown when msg.text is a
string.
In `@frontend/src/utils/quizExporter.js`:
- Around line 7-45: The parser injects unescaped quiz text into HTML
(parseMarkdownToHtml), allowing HTML injection; create and use an escapeHtml
helper that replaces &, <, >, ", and ' with their entities and call it on each
input line (or the whole markdown) before any tag insertion, then perform
markdown-specific replacements (e.g., converting **bold** to <strong>) on the
escaped text so only safe HTML is emitted; update parseMarkdownToHtml to call
escapeHtml where lines are trimmed/processed and also apply the same escapeHtml
usage at the other export spot referenced around lines 84-85 to ensure all
exported content is escaped before building the .doc markup.
In `@rag-service/main.py`:
- Around line 993-994: The current quiz-detection if statement uses simple
substring checks on normalized_question which causes false positives; replace it
with regex/phrase-based matching that checks whole-word boundaries and explicit
intent phrases (e.g., patterns like "\bcreate (a )?quiz\b", "\bgive me (a
)?quiz\b", "\bgenerate (a )?quiz\b", "\bcreate (a )?test\b", "\bquiz me\b") and
only return "quiz" when one of these intent patterns matches
normalized_question; update the detection logic around the existing
normalized_question usage to use compiled regexes or an allowlist of explicit
phrases instead of bare substring checks.
In `@src/data/users.json`:
- Around line 1-10: Remove the committed user credential entries from the
runtime credential store file (src/data/users.json) so that the app's
signup/login flow in src/controllers/authController.js does not seed real
accounts; replace the committed objects with an empty array (or move these test
fixtures to a non-runtime test fixture file) and ensure any tests or local dev
scripts that relied on these sample users point to the new fixture path or
create users at runtime via the signup flow.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 52666651-8f6f-4eda-91df-5a6fb8851977
⛔ Files ignored due to path filters (1)
frontend/package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (6)
frontend/package.jsonfrontend/src/components/ChatPanel/MessageBubble.jsxfrontend/src/utils/quizExporter.jsrag-service/main.pysrc/data/users.jsonvalidators/schemas.js
| {msg.role === "bot" && !msg.streaming && (msg.mode === "quiz" || msg.text.includes("# Quiz") || msg.text.toLowerCase().includes("quiz:")) && ( | ||
| <div style={{ marginTop: "14px", display: "flex", gap: "10px" }}> | ||
| <button | ||
| onClick={() => exportQuizToPdf(msg.text, "quiz")} | ||
| style={{ | ||
| padding: "6px 14px", | ||
| borderRadius: "10px", | ||
| border: "none", | ||
| background: "linear-gradient(135deg, #8B5CF6 0%, #6366F1 100%)", | ||
| color: "white", | ||
| fontWeight: 600, | ||
| color: darkMode ? "#E5E7EB" : "#1F2937", | ||
| marginBottom: "10px", | ||
| fontSize: "14px" | ||
| }}> | ||
| {followup.question} | ||
| </div> | ||
| )} | ||
| <div style={{ display: "flex", flexDirection: "column", gap: "8px" }}> | ||
| {followup.options.map((opt, idx) => ( | ||
| <button | ||
| key={idx} | ||
| onClick={() => onOptionClick?.(opt)} | ||
| style={{ | ||
| textAlign: "left", | ||
| padding: "8px 12px", | ||
| borderRadius: "8px", | ||
| background: darkMode ? "rgba(255,255,255,0.05)" : "#FFFFFF", | ||
| border: darkMode ? "1px solid rgba(255,255,255,0.1)" : "1px solid rgba(0,0,0,0.08)", | ||
| color: darkMode ? "#D1D5DB" : "#4B5563", | ||
| fontSize: "13px", | ||
| cursor: "pointer", | ||
| transition: "all 0.2s ease" | ||
| }} | ||
| onMouseOver={(e) => { | ||
| e.currentTarget.style.background = darkMode ? "rgba(139, 92, 246, 0.2)" : "rgba(139, 92, 246, 0.1)"; | ||
| e.currentTarget.style.borderColor = "#8B5CF6"; | ||
| e.currentTarget.style.color = darkMode ? "#FFF" : "#111"; | ||
| }} | ||
| onMouseOut={(e) => { | ||
| e.currentTarget.style.background = darkMode ? "rgba(255,255,255,0.05)" : "#FFFFFF"; | ||
| e.currentTarget.style.borderColor = darkMode ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.08)"; | ||
| e.currentTarget.style.color = darkMode ? "#D1D5DB" : "#4B5563"; | ||
| }} | ||
| > | ||
| {opt} | ||
| </button> | ||
| ))} | ||
| </div> | ||
| fontSize: "12px", | ||
| cursor: "pointer", | ||
| boxShadow: "0 4px 10px rgba(139, 92, 246, 0.2)", | ||
| transition: "all 0.2s ease" | ||
| }} | ||
| > | ||
| 📄 Download PDF | ||
| </button> | ||
| <button | ||
| onClick={() => exportQuizToWord(msg.text, "quiz")} | ||
| style={{ | ||
| padding: "6px 14px", | ||
| borderRadius: "10px", | ||
| border: darkMode ? "1px solid rgba(255,255,255,0.15)" : "1px solid rgba(0,0,0,0.15)", | ||
| background: "transparent", | ||
| color: darkMode ? "#D1D5DB" : "#4B5563", | ||
| fontWeight: 600, | ||
| fontSize: "12px", | ||
| cursor: "pointer", | ||
| transition: "all 0.2s ease" | ||
| }} | ||
| > | ||
| 📝 Download Word | ||
| </button> | ||
| </div> | ||
| )} |
There was a problem hiding this comment.
Guard quiz-export predicate against non-string msg.text.
Line [114] calls .includes and .toLowerCase() directly on msg.text; a null/non-string payload will throw during render and break the chat view.
Proposed fix
const MessageBubble = ({ msg, darkMode, onOpenSource }) => {
+ const messageText = typeof msg.text === "string" ? msg.text : "";
+ const isQuizMessage =
+ msg.role === "bot" &&
+ !msg.streaming &&
+ (msg.mode === "quiz" ||
+ messageText.includes("# Quiz") ||
+ messageText.toLowerCase().includes("quiz:"));
const getSourceLabel = (source) => source.document || "Source Document";
const hasOpenablePage = (source) => Boolean(source.page && source.document);
@@
- <ReactMarkdown rehypePlugins={[[rehypeSanitize, MARKDOWN_SANITIZE_SCHEMA]]}>{msg.text}</ReactMarkdown>
+ <ReactMarkdown rehypePlugins={[[rehypeSanitize, MARKDOWN_SANITIZE_SCHEMA]]}>{messageText}</ReactMarkdown>
@@
- {msg.role === "bot" && !msg.streaming && (msg.mode === "quiz" || msg.text.includes("# Quiz") || msg.text.toLowerCase().includes("quiz:")) && (
+ {isQuizMessage && (
<div style={{ marginTop: "14px", display: "flex", gap: "10px" }}>
<button
- onClick={() => exportQuizToPdf(msg.text, "quiz")}
+ onClick={() => exportQuizToPdf(messageText, "quiz")}
@@
<button
- onClick={() => exportQuizToWord(msg.text, "quiz")}
+ onClick={() => exportQuizToWord(messageText, "quiz")}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/components/ChatPanel/MessageBubble.jsx` around lines 114 - 150,
The predicate that decides whether to render the quiz-export buttons calls
msg.text.includes and msg.text.toLowerCase() which will throw if msg.text is
null or not a string; update the conditional in MessageBubble.jsx to guard
msg.text by checking typeof msg.text === "string" (e.g. typeof msg.text ===
"string" && (msg.mode === "quiz" || msg.text.includes("# Quiz") ||
msg.text.toLowerCase().includes("quiz:"))), so the
exportQuizToPdf/exportQuizToWord handlers are only shown when msg.text is a
string.
| const parseMarkdownToHtml = (markdown) => { | ||
| return markdown | ||
| .split("\n") | ||
| .map((line) => { | ||
| const trimmed = line.trim(); | ||
| if (!trimmed) { | ||
| return "<br/>"; | ||
| } | ||
|
|
||
| // Headers | ||
| if (line.startsWith("# ")) { | ||
| return `<h1>${line.substring(2)}</h1>`; | ||
| } | ||
| if (line.startsWith("## ")) { | ||
| return `<h2>${line.substring(3)}</h2>`; | ||
| } | ||
|
|
||
| // Question line (e.g., "1. **Question:** What is...") | ||
| if (trimmed.match(/^\d+\./)) { | ||
| const formatted = trimmed.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>"); | ||
| return `<p class="question" style="font-size: 14pt; font-weight: bold; margin-top: 18pt; margin-bottom: 6pt; color: #1e293b;">${formatted}</p>`; | ||
| } | ||
|
|
||
| // Option line (e.g., "- A) Option text") | ||
| if (trimmed.startsWith("- ") || trimmed.startsWith("* ")) { | ||
| const optionText = trimmed.substring(2); | ||
| return `<p class="option" style="font-size: 11pt; margin-left: 20pt; margin-top: 3pt; margin-bottom: 3pt; color: #4b5563;">${optionText}</p>`; | ||
| } | ||
|
|
||
| // Correct Answer line | ||
| if (trimmed.includes("Correct Answer:")) { | ||
| const formatted = trimmed.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>"); | ||
| return `<p class="answer" style="font-size: 11pt; font-weight: bold; margin-left: 20pt; margin-top: 4pt; margin-bottom: 12pt; color: #10b981;">${formatted}</p>`; | ||
| } | ||
|
|
||
| // Default line formatting | ||
| const formatted = trimmed.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>"); | ||
| return `<p style="font-size: 11pt; margin-top: 6pt; margin-bottom: 6pt; color: #1f2937;">${formatted}</p>`; | ||
| }) |
There was a problem hiding this comment.
Escape HTML before injecting quiz text into Word-export markup.
Line [7] onward builds HTML using raw quiz text; this allows HTML injection into the generated .doc (e.g., untrusted tags/attributes from model output or prompt-injected content). Escape text first, then apply markdown formatting.
Proposed fix
+const escapeHtml = (value = "") =>
+ value
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "&`#39`;");
+
+const formatInline = (value = "") =>
+ escapeHtml(value).replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>");
+
const parseMarkdownToHtml = (markdown) => {
- return markdown
+ return String(markdown ?? "")
.split("\n")
.map((line) => {
const trimmed = line.trim();
if (!trimmed) {
return "<br/>";
}
@@
- if (line.startsWith("# ")) {
- return `<h1>${line.substring(2)}</h1>`;
+ if (line.startsWith("# ")) {
+ return `<h1>${formatInline(line.substring(2))}</h1>`;
}
if (line.startsWith("## ")) {
- return `<h2>${line.substring(3)}</h2>`;
+ return `<h2>${formatInline(line.substring(3))}</h2>`;
}
@@
- const formatted = trimmed.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>");
+ const formatted = formatInline(trimmed);
return `<p class="question" style="font-size: 14pt; font-weight: bold; margin-top: 18pt; margin-bottom: 6pt; color: `#1e293b`;">${formatted}</p>`;
}
@@
- const optionText = trimmed.substring(2);
+ const optionText = formatInline(trimmed.substring(2));
return `<p class="option" style="font-size: 11pt; margin-left: 20pt; margin-top: 3pt; margin-bottom: 3pt; color: `#4b5563`;">${optionText}</p>`;
}
@@
- const formatted = trimmed.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>");
+ const formatted = formatInline(trimmed);
return `<p class="answer" style="font-size: 11pt; font-weight: bold; margin-left: 20pt; margin-top: 4pt; margin-bottom: 12pt; color: `#10b981`;">${formatted}</p>`;
}
@@
- const formatted = trimmed.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>");
+ const formatted = formatInline(trimmed);
return `<p style="font-size: 11pt; margin-top: 6pt; margin-bottom: 6pt; color: `#1f2937`;">${formatted}</p>`;
})
.join("\n");
};Also applies to: 84-85
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/src/utils/quizExporter.js` around lines 7 - 45, The parser injects
unescaped quiz text into HTML (parseMarkdownToHtml), allowing HTML injection;
create and use an escapeHtml helper that replaces &, <, >, ", and ' with their
entities and call it on each input line (or the whole markdown) before any tag
insertion, then perform markdown-specific replacements (e.g., converting
**bold** to <strong>) on the escaped text so only safe HTML is emitted; update
parseMarkdownToHtml to call escapeHtml where lines are trimmed/processed and
also apply the same escapeHtml usage at the other export spot referenced around
lines 84-85 to ensure all exported content is escaped before building the .doc
markup.
| if "quiz" in normalized_question or "test" in normalized_question or "questions" in normalized_question or "question paper" in normalized_question: | ||
| return "quiz" |
There was a problem hiding this comment.
Overly broad quiz detection triggers false positives.
The current detection uses simple substring matching that will incorrectly trigger quiz mode for legitimate questions containing "test", "questions", etc.
False positive examples:
- "What questions does this document answer?" → contains "questions" → unwanted quiz
- "Can you test my understanding?" → contains "test" → unwanted quiz
- "The study questions the methodology" → contains "questions" → unwanted quiz
This degrades UX by generating quizzes when users expect a normal answer.
🔧 Suggested fix with word-boundary matching
- if "quiz" in normalized_question or "test" in normalized_question or "questions" in normalized_question or "question paper" in normalized_question:
+ # Match quiz intent only when quiz-related terms appear as distinct words/phrases
+ if re.search(r'\b(quiz|quizzes)\b', normalized_question) or \
+ re.search(r'\b(generate|create|make|give me).*\b(test|exam)\b', normalized_question) or \
+ 'question paper' in normalized_question:
return "quiz"This uses word boundaries and context patterns to reduce false positives while still catching genuine quiz requests like "generate a quiz", "create a test", "give me a quiz", etc.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@rag-service/main.py` around lines 993 - 994, The current quiz-detection if
statement uses simple substring checks on normalized_question which causes false
positives; replace it with regex/phrase-based matching that checks whole-word
boundaries and explicit intent phrases (e.g., patterns like "\bcreate (a
)?quiz\b", "\bgive me (a )?quiz\b", "\bgenerate (a )?quiz\b", "\bcreate (a
)?test\b", "\bquiz me\b") and only return "quiz" when one of these intent
patterns matches normalized_question; update the detection logic around the
existing normalized_question usage to use compiled regexes or an allowlist of
explicit phrases instead of bare substring checks.
| [ | ||
| { | ||
| "email": "testuser-1780133113651@example.com", | ||
| "password": "$2b$10$VlcKirFc6zLReabLg97GeOslLoXzPF5WUigQ/JjPrQi5pMpwBuMMe" | ||
| "email": "testuser-1780934845930@example.com", | ||
| "password": "$2b$10$9AAsvzxfwNp9haLU2oKNMuMTeY3icNBzPYcPHTx3Wv4w78ZUVD7Ry" | ||
| }, | ||
| { | ||
| "email": "testuser2-1780133113736@example.com", | ||
| "password": "$2b$10$JHamUog0/4MOtnc.la59ZuhhYZy/hxG5BSSVJjyVFW3.SYieaeHGu" | ||
| }, | ||
| { | ||
| "email": "testuser-1780133179622@example.com", | ||
| "password": "$2b$10$C3zvUpwQMBS7YQhGnyO7TOq5Mo/9rrxgj2n/mN7PnfbSqcYfz0Lte" | ||
| }, | ||
| { | ||
| "email": "testuser2-1780133179757@example.com", | ||
| "password": "$2b$10$Qae0TsUBvMMjZ0Ofog1B6uHp0GmV5hlbL1q6mVgFtUX6/STIJJliO" | ||
| "email": "testuser2-1780934846010@example.com", | ||
| "password": "$2b$10$6oXLfmhPAQzfCVLwS5iy/Oqoi5THfbxdKVMjxpWIB/29FwmLnPcTC" | ||
| } | ||
| ] |
There was a problem hiding this comment.
Remove committed user records from the runtime credential store.
src/controllers/authController.js reads this file as the live signup/login backend, so checking in concrete { email, password } entries seeds real accounts into every deployed environment and stores credential material in source control. Keep src/data/users.json empty in git (or move test fixtures to a separate file/path) and let signup populate it at runtime instead.
Suggested change
-[
- {
- "email": "testuser-1780934845930@example.com",
- "password": "$2b$10$9AAsvzxfwNp9haLU2oKNMuMTeY3icNBzPYcPHTx3Wv4w78ZUVD7Ry"
- },
- {
- "email": "testuser2-1780934846010@example.com",
- "password": "$2b$10$6oXLfmhPAQzfCVLwS5iy/Oqoi5THfbxdKVMjxpWIB/29FwmLnPcTC"
- }
-]
+[]📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| [ | |
| { | |
| "email": "testuser-1780133113651@example.com", | |
| "password": "$2b$10$VlcKirFc6zLReabLg97GeOslLoXzPF5WUigQ/JjPrQi5pMpwBuMMe" | |
| "email": "testuser-1780934845930@example.com", | |
| "password": "$2b$10$9AAsvzxfwNp9haLU2oKNMuMTeY3icNBzPYcPHTx3Wv4w78ZUVD7Ry" | |
| }, | |
| { | |
| "email": "testuser2-1780133113736@example.com", | |
| "password": "$2b$10$JHamUog0/4MOtnc.la59ZuhhYZy/hxG5BSSVJjyVFW3.SYieaeHGu" | |
| }, | |
| { | |
| "email": "testuser-1780133179622@example.com", | |
| "password": "$2b$10$C3zvUpwQMBS7YQhGnyO7TOq5Mo/9rrxgj2n/mN7PnfbSqcYfz0Lte" | |
| }, | |
| { | |
| "email": "testuser2-1780133179757@example.com", | |
| "password": "$2b$10$Qae0TsUBvMMjZ0Ofog1B6uHp0GmV5hlbL1q6mVgFtUX6/STIJJliO" | |
| "email": "testuser2-1780934846010@example.com", | |
| "password": "$2b$10$6oXLfmhPAQzfCVLwS5iy/Oqoi5THfbxdKVMjxpWIB/29FwmLnPcTC" | |
| } | |
| ] | |
| [] |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/data/users.json` around lines 1 - 10, Remove the committed user
credential entries from the runtime credential store file (src/data/users.json)
so that the app's signup/login flow in src/controllers/authController.js does
not seed real accounts; replace the committed objects with an empty array (or
move these test fixtures to a non-runtime test fixture file) and ensure any
tests or local dev scripts that relied on these sample users point to the new
fixture path or create users at runtime via the signup flow.
Summary
Enhanced the existing PDF QA bot into a more generalized LLM-driven assistant where quiz generation is triggered naturally through user intent rather than a dedicated quiz button.
Now, when a user explicitly asks for a quiz within the conversation, the LLM automatically generates a quiz using the available context from the chat/PDF. This makes the system more intelligent and flexible, rather than being limited to a fixed UI action.
Additionally, the generated quiz can be exported and downloaded in both PDF and Word formats, allowing users to save structured quiz outputs easily.
This improves the system from a tool-based flow to an intent-based intelligent assistant.
Related issue
Closes: 101
Testing
Checklist:
Screenshots / recordings
Notes
quizExporter.jsis correctly handling both export formatsSecurity
Summary by CodeRabbit