AI_Quiz / app.py
Afeezee's picture
Update app.py
f568f4e verified
# ai_quiz_from_pdf_gradio.py
# Gradio app: AI Quiz Generation System from PDF Documents using LLM (Groq: meta-llama/llama-4-maverick-17b-128e-instruct)
#
# Prerequisites:
# - pip install -r requirements.txt
# - Set environment variable: GROQ_API_KEY=<your_api_key>
#
# Run:
# - python ai_quiz_from_pdf_gradio.py
#
# Notes:
# - The app uses PyMuPDF to extract text from PDFs.
# - The LLM is used to generate quizzes (MCQ/TrueFalse/ShortAnswer/Essay) and to grade Essay answers.
# - Tabs are used for a clean interface: "Upload & Generate", "Take Quiz", "Grade Essays", "Export".
import os
import io
import json
import time
from typing import List, Dict, Any, Tuple, Optional
import pandas as pd
import fitz # PyMuPDF
import gradio as gr
# --- LLM (Groq) ---
from groq import Groq
GROQ_MODEL = "meta-llama/llama-4-maverick-17b-128e-instruct"
def call_groq(system_prompt: str, user_prompt: str, temperature: float = 0.7, max_tokens: int = 2048) -> str:
"""
Calls the Groq chat completion API and returns the full combined response text.
Streams tokens and accumulates them into a final string.
"""
client = Groq(api_key = "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") # Uses GROQ_API_KEY env var
completion = client.chat.completions.create (
model=GROQ_MODEL,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
temperature=temperature,
max_completion_tokens=max_tokens,
top_p=1,
stream=True,
stop=None
)
chunks = []
for chunk in completion:
piece = chunk.choices[0].delta.content or ""
chunks.append(piece)
return "".join(chunks)
# -------------------- PDF Utilities --------------------
def extract_text_from_pdf(pdf_bytes: bytes, max_pages: int = 50) -> str:
"""
Extracts text from a PDF byte stream using PyMuPDF.
Limits to max_pages for performance.
"""
text_parts = []
with fitz.open(stream=pdf_bytes, filetype="pdf") as doc:
page_count = min(len(doc), max_pages)
for i in range(page_count):
page = doc.load_page(i)
text_parts.append(page.get_text("text"))
full_text = "\n".join(text_parts)
# Basic cleanup
lines = [ln.strip() for ln in full_text.splitlines() if ln.strip()]
return "\n".join(lines)
def chunk_text(text: str, max_chars: int = 4000, overlap: int = 200) -> List[str]:
"""
Splits text into overlapping chunks to keep prompts within context limits.
"""
chunks = []
start = 0
n = len(text)
while start < n:
end = min(start + max_chars, n)
chunk = text[start:end]
chunks.append(chunk)
if end == n:
break
start = end - overlap
if start < 0:
start = 0
return chunks
# -------------------- Quiz Generation --------------------
QUIZ_SCHEMA_EXAMPLE = {
"metadata": {
"source": "string (e.g., PDF title or filename)",
"difficulty": "Easy|Medium|Hard"
},
"questions": [
# MCQ
{"id": 1, "type": "mcq", "question": "Q text", "options": ["A", "B", "C", "D"], "answer": "B", "explanation": "why"},
# True/False
{"id": 2, "type": "true_false", "question": "Statement", "answer": True, "explanation": "why"},
# Short Answer (open, brief)
{"id": 3, "type": "short_answer", "question": "Q text", "expected_answer": "reference answer", "rubric": "key points"},
# Essay (theory)
{"id": 4, "type": "essay", "question": "Essay prompt", "rubric": "grading rubric/bullets", "max_score": 10}
]
}
SYSTEM_PROMPT_GENERATE = """You are an expert educational content creator.
You will receive textbook or lecture content and must produce a structured quiz in strict JSON.
Output ONLY valid JSON that follows the given schema. Do not add commentary.
JSON schema keys: metadata{source, difficulty}, questions[list].
Question item types: "mcq", "true_false", "short_answer", "essay".
For "mcq": include 4 distinct options, exactly one correct "answer", and a brief "explanation".
For "true_false": "answer" must be true or false (boolean), include "explanation".
For "short_answer": include "expected_answer" and a concise "rubric" with key points.
For "essay": include a clear "rubric" and "max_score" as an integer.
Make questions faithful to the source text (no hallucinations). Prefer concise, unambiguous wording.
"""
def build_generation_prompt(chunk: str, source_name: str, difficulty: str,
n_mcq: int, n_tf: int, n_short: int, n_essay: int) -> str:
return f"""
Source name: {source_name}
Desired difficulty: {difficulty}
Required counts -> MCQ: {n_mcq}, True/False: {n_tf}, Short Answer: {n_short}, Essay: {n_essay}
Source text:
\"\"\"
{chunk}
\"\"\"
Produce a single JSON object matching this schema (example):
{json.dumps(QUIZ_SCHEMA_EXAMPLE, indent=2)}
Ensure total number of questions equals the requested counts combined.
Use simple language appropriate for undergraduates.
"""
def merge_quizzes(quizzes: List[Dict[str, Any]], source_name: str, difficulty: str) -> Dict[str, Any]:
"""
Merge multiple chunk-level quiz JSONs into one. Re-index question IDs.
"""
merged = {"metadata": {"source": source_name, "difficulty": difficulty}, "questions": []}
qid = 1
for q in quizzes:
for item in q.get("questions", []):
item["id"] = qid
merged["questions"].append(item)
qid += 1
return merged
def generate_quiz_from_text(text: str, source_name: str, difficulty: str,
n_mcq: int, n_tf: int, n_short: int, n_essay: int,
temperature: float = 0.7) -> Tuple[Dict[str, Any], str]:
"""
Generates a quiz by calling the LLM on one or more chunks, distributing question counts across chunks.
Returns (quiz_json, raw_model_output_debug).
"""
chunks = chunk_text(text, max_chars=3500, overlap=200)
total_required = n_mcq + n_tf + n_short + n_essay
if total_required == 0:
return {"metadata": {"source": source_name, "difficulty": difficulty}, "questions": []}, ""
# Divide counts across chunks (simple even split)
c = max(1, len(chunks))
split = lambda total: [total // c + (1 if i < (total % c) else 0) for i in range(c)]
mcq_split = split(n_mcq)
tf_split = split(n_tf)
short_split = split(n_short)
essay_split = split(n_essay)
partial_quizzes = []
debug_texts = []
for i, ch in enumerate(chunks):
if mcq_split[i] + tf_split[i] + short_split[i] + essay_split[i] == 0:
continue
user_prompt = build_generation_prompt(
chunk=ch,
source_name=source_name,
difficulty=difficulty,
n_mcq=mcq_split[i],
n_tf=tf_split[i],
n_short=short_split[i],
n_essay=essay_split[i]
)
raw = call_groq(SYSTEM_PROMPT_GENERATE, user_prompt, temperature=temperature, max_tokens=2048)
debug_texts.append(raw)
# attempt to parse JSON (robustly strip fences, trailing text)
cleaned = raw.strip()
if cleaned.startswith("```"):
cleaned = cleaned.strip("`")
# if it included language tag like ```json
if cleaned.startswith("json"):
cleaned = cleaned[len("json"):].strip()
# Find first and last braces to extract JSON
start = cleaned.find("{")
end = cleaned.rfind("}")
if start != -1 and end != -1 and end > start:
cleaned = cleaned[start:end+1]
try:
obj = json.loads(cleaned)
partial_quizzes.append(obj)
except Exception as e:
partial_quizzes.append({"metadata":{"source":source_name, "difficulty":difficulty},"questions":[]})
merged = merge_quizzes(partial_quizzes, source_name, difficulty)
return merged, "\n\n---\n\n".join(debug_texts)
# -------------------- Grading --------------------
SYSTEM_PROMPT_GRADE_ESSAY = """You are a strict but fair examiner.
Grade the student's essay against the rubric and max_score provided.
Return ONLY a JSON object with keys: score (integer), feedback (string).
Be concise and specific in feedback. Do not add commentary outside JSON.
"""
def build_grading_prompt(question_text: str, rubric: str, max_score: int, student_answer: str) -> str:
return f"""
Question:
{question_text}
Rubric (key points to award marks):
{rubric}
Max Score: {max_score}
Student Answer:
\"\"\"
{student_answer}
\"\"\"
Return JSON only: {{"score": <int>, "feedback": "<text>"}}
"""
def grade_essay_answer(question_text: str, rubric: str, max_score: int, student_answer: str, temperature: float = 0.2) -> Dict[str, Any]:
raw = call_groq(SYSTEM_PROMPT_GRADE_ESSAY, build_grading_prompt(question_text, rubric, max_score, student_answer), temperature=temperature, max_tokens=512)
cleaned = raw.strip()
if cleaned.startswith("```"):
cleaned = cleaned.strip("`")
if cleaned.startswith("json"):
cleaned = cleaned[len("json"):].strip()
start = cleaned.find("{"); end = cleaned.rfind("}")
if start != -1 and end != -1 and end > start:
cleaned = cleaned[start:end+1]
try:
obj = json.loads(cleaned)
except Exception:
obj = {"score": 0, "feedback": "Grading failed to parse. Please retry."}
# clamp score
try:
obj["score"] = int(obj.get("score", 0))
if obj["score"] < 0: obj["score"] = 0
if obj["score"] > max_score: obj["score"] = max_score
except Exception:
obj["score"] = 0
return obj
# -------------------- Gradio App --------------------
with gr.Blocks(title="AI Quiz Generation from PDF (LLM)") as demo:
gr.Markdown("# AI Quiz Generation System from PDF Documents using LLM")
gr.Markdown("Upload a PDF, generate MCQ/True/Short/Essay questions, take the quiz, and grade essay answers via LLM.\n")
state_pdf_text = gr.State("")
state_quiz = gr.State({"metadata": {}, "questions": []})
state_source_name = gr.State("")
state_debug = gr.State("")
# Extra states for step-by-step quiz flow
state_non_essay = gr.State([]) # holds MCQ/TF/Short questions
state_take_idx = gr.State(0) # index for Take Quiz flow
state_take_results = gr.State({"attempted": 0, "correct": 0, "details": []})
state_essay_list = gr.State([]) # holds Essay questions
state_essay_idx = gr.State(0) # index for Essay flow
state_essay_results = gr.State([]) # list of {"id","score","max","feedback"}
with gr.Tabs():
with gr.Tab("1) Upload & Generate"):
with gr.Row():
pdf_input = gr.File(label="Upload PDF", file_types=[".pdf"])
source_name_tb = gr.Textbox(label="Source Name (optional)", placeholder="e.g., Intro_to_AI_Notes.pdf")
with gr.Accordion("Extraction & Generation Settings", open=True):
with gr.Row():
difficulty_dd = gr.Dropdown(choices=["Easy","Medium","Hard"], value="Medium", label="Difficulty")
temp_slider = gr.Slider(0.0, 1.5, value=0.7, step=0.1, label="Creativity (temperature)")
with gr.Row():
n_mcq = gr.Number(value=5, precision=0, label="Number of MCQ")
n_tf = gr.Number(value=3, precision=0, label="Number of True/False")
n_short = gr.Number(value=2, precision=0, label="Number of Short Answer")
n_essay = gr.Number(value=1, precision=0, label="Number of Essay")
extract_btn = gr.Button("Extract Text")
extracted_preview = gr.Textbox(label="Extracted Text Preview (first 2000 chars)", lines=12)
generate_btn = gr.Button("Generate Quiz")
quiz_json_out = gr.JSON(label="Generated Quiz (JSON)")
debug_out = gr.Textbox(label="Raw Model Output (debug)", visible=False)
def do_extract(pdf_file, source_name):
if not pdf_file:
return gr.update(value=""), "", "No file provided."
with open(pdf_file.name, "rb") as f:
pdf_bytes = f.read()
text = extract_text_from_pdf(pdf_bytes, max_pages=50)
preview = text[:2000]
src = source_name or os.path.basename(pdf_file.name)
return preview, text, src
extract_btn.click(
do_extract,
inputs=[pdf_input, source_name_tb],
outputs=[extracted_preview, state_pdf_text, state_source_name]
)
def do_generate(text, source_name, difficulty, temp, n1, n2, n3, n4):
if not text or not text.strip():
return {"metadata": {}, "questions": []}, "No text to generate from.", {"metadata": {}, "questions": []}
quiz, dbg = generate_quiz_from_text(
text=text,
source_name=source_name or "Uploaded PDF",
difficulty=difficulty,
n_mcq=int(n1 or 0),
n_tf=int(n2 or 0),
n_short=int(n3 or 0),
n_essay=int(n4 or 0),
temperature=float(temp)
)
return quiz, dbg, quiz
generate_btn.click(
do_generate,
inputs=[state_pdf_text, state_source_name, difficulty_dd, temp_slider, n_mcq, n_tf, n_short, n_essay],
outputs=[quiz_json_out, debug_out, state_quiz]
)
with gr.Tab("2) Take Quiz"):
gr.Markdown("Answer one question at a time. MCQ and True/False use radio buttons; Short Answer uses a textbox. A summary appears at the end.")
take_progress = gr.Markdown("")
take_q_text = gr.Textbox(label="Question", lines=4, interactive=False)
take_mcq_radio = gr.Radio(choices=[], label="Choose one (MCQ)", visible=False)
take_tf_radio = gr.Radio(choices=["True", "False"], label="True/False", visible=False)
take_short_tb = gr.Textbox(label="Short Answer", visible=False, placeholder="Type your short answer here...")
take_submit_btn = gr.Button("Submit & Next")
take_feedback = gr.JSON(label="Feedback (this question)", visible=False)
take_summary = gr.JSON(label="Final Summary", visible=False)
def _split_non_essay(quiz: Dict[str, Any]):
return [q for q in quiz.get("questions", []) if q.get("type") in ["mcq", "true_false", "short_answer"]]
def _progress_str(idx: int, total: int) -> str:
return f"**Question {min(idx+1, total)} of {total}**" if total else "**No questions available.**"
def _show_non_essay_question(non_essay: List[Dict[str, Any]], idx: int):
total = len(non_essay)
if total == 0:
# nothing to show
return (
_progress_str(0, 0),
gr.update(value="No non-essay questions in this quiz.", interactive=False),
gr.update(visible=False), # mcq
gr.update(visible=False), # tf
gr.update(visible=False), # short
gr.update(visible=False), # feedback
gr.update(value={"message": "Nothing to attempt."}, visible=True) # summary
)
if idx >= total:
# finished
return (
_progress_str(total, total),
gr.update(value="You have completed all non-essay questions.", interactive=False),
gr.update(visible=False),
gr.update(visible=False),
gr.update(visible=False),
gr.update(visible=False),
gr.update(visible=True)
)
q = non_essay[idx]
qtype = q.get("type")
qtext = q.get("question", "")
if qtype == "mcq":
opts = q.get("options", []) or []
return (
_progress_str(idx, total),
gr.update(value=qtext, interactive=False),
gr.update(choices=opts, value=None, visible=True),
gr.update(visible=False),
gr.update(visible=False),
gr.update(visible=False),
gr.update(visible=False),
)
elif qtype == "true_false":
return (
_progress_str(idx, total),
gr.update(value=qtext, interactive=False),
gr.update(visible=False),
gr.update(value=None, visible=True),
gr.update(visible=False),
gr.update(visible=False),
gr.update(visible=False),
)
else: # short_answer
return (
_progress_str(idx, total),
gr.update(value=qtext, interactive=False),
gr.update(visible=False),
gr.update(visible=False),
gr.update(value="", visible=True),
gr.update(visible=False),
gr.update(visible=False),
)
def init_take_quiz(quiz: Dict[str, Any]):
non_essay = _split_non_essay(quiz)
idx0 = 0
results0 = {"attempted": 0, "correct": 0, "details": []}
ui = _show_non_essay_question(non_essay, idx0)
# return UI + states
return (*ui, non_essay, idx0, results0)
def _compare_short_answer(gold: str, ans: str) -> bool:
gold = (gold or "").strip().lower()
ans = (ans or "").strip().lower()
if not gold or not ans:
return False
gold_tokens = set(gold.split())
ans_tokens = set(ans.split())
# require at least 25% token overlap (min 1)
common = gold_tokens & ans_tokens
return len(common) >= max(1, len(gold_tokens) // 4)
def submit_next(mcq_choice, tf_choice, short_ans,
non_essay: List[Dict[str, Any]],
idx: int,
results_state: Dict[str, Any]):
total = len(non_essay)
if total == 0 or idx >= total:
# already done
return (
_progress_str(total, total),
gr.update(value="You have completed all non-essay questions.", interactive=False),
gr.update(visible=False),
gr.update(visible=False),
gr.update(visible=False),
gr.update(visible=False),
gr.update(
value={"summary": results_state},
visible=True
),
idx,
results_state,
)
q = non_essay[idx]
qid = q.get("id")
qtype = q.get("type")
explanation = q.get("explanation", "")
feedback = {}
is_correct = False
your_answer = None
if qtype == "mcq":
your_answer = mcq_choice
is_correct = (str(your_answer).strip() == str(q.get("answer", "")).strip())
elif qtype == "true_false":
your_answer = tf_choice
gold = str(q.get("answer", "")).strip().lower()
val = str(your_answer or "").strip().lower()
# normalize radio labels
if val in ["t", "true", "yes", "1"]:
val = "true"
elif val in ["f", "false", "no", "0"]:
val = "false"
is_correct = (val == gold)
else: # short_answer
your_answer = short_ans
gold = str(q.get("expected_answer", "")).strip()
is_correct = _compare_short_answer(gold, your_answer)
# update results
results_state = dict(results_state) # copy
results_state["attempted"] += 1
if is_correct:
results_state["correct"] += 1
results_state["details"] = list(results_state.get("details", [])) + [{
"id": qid,
"type": qtype,
"your_answer": your_answer,
"correct_answer": q.get("answer", q.get("expected_answer", "")),
"correct": is_correct
}]
feedback = {
"id": qid,
"type": qtype,
"your_answer": your_answer,
"correct_answer": q.get("answer", q.get("expected_answer", "")),
"correct": is_correct,
}
if explanation and qtype in ["mcq", "true_false"]:
feedback["explanation"] = explanation
# advance
idx_next = idx + 1
# if finished, show summary
if idx_next >= total:
return (
_progress_str(total, total),
gr.update(value="Completed non-essay questions.", interactive=False),
gr.update(visible=False),
gr.update(visible=False),
gr.update(visible=False),
gr.update(value=feedback, visible=True),
gr.update(value={
"summary": {
"attempted": results_state["attempted"],
"correct": results_state["correct"],
"accuracy": round(100.0 * results_state["correct"] / max(1, results_state["attempted"]), 2)
},
"details": results_state["details"]
}, visible=True),
idx_next,
results_state,
)
# otherwise show next question
ui_next = _show_non_essay_question(non_essay, idx_next)
return (
*ui_next[:5],
gr.update(value=feedback, visible=True), # feedback visible
gr.update(visible=False), # summary hidden until end
idx_next,
results_state,
)
# reinitialize when a new quiz is generated
state_quiz.change(
init_take_quiz,
inputs=[state_quiz],
outputs=[
take_progress, take_q_text,
take_mcq_radio, take_tf_radio, take_short_tb,
take_feedback, take_summary,
state_non_essay, state_take_idx, state_take_results
]
)
# submit current answer and move next
take_submit_btn.click(
submit_next,
inputs=[
take_mcq_radio, take_tf_radio, take_short_tb,
state_non_essay, state_take_idx, state_take_results
],
outputs=[
take_progress, take_q_text,
take_mcq_radio, take_tf_radio, take_short_tb,
take_feedback, take_summary,
state_take_idx, state_take_results
]
)
with gr.Tab("3) Essay/Theory Grading (LLM)"):
gr.Markdown("Essay questions are presented one at a time. Submit your answer to get LLM grading, then proceed to the next.")
essay_progress = gr.Markdown("")
essay_q_view = gr.Textbox(label="Essay Question", lines=4, interactive=False)
with gr.Accordion("Rubric & Max Score", open=False):
essay_rubric_view = gr.Textbox(label="Rubric", lines=6, interactive=False)
essay_max_score = gr.Number(label="Max Score", value=10, precision=0, interactive=False)
essay_answer_tb = gr.Textbox(label="Your Essay/Theory Answer", lines=10, placeholder="Write your answer here...")
essay_submit_btn = gr.Button("Submit & Grade with LLM")
essay_grade_json = gr.JSON(label="Grade (this essay)", visible=False)
essay_final_summary = gr.JSON(label="Essay Summary", visible=False)
def _essays_only(quiz: Dict[str, Any]):
return [q for q in quiz.get("questions", []) if q.get("type") == "essay"]
def _essay_progress_str(idx: int, total: int) -> str:
return f"**Essay {min(idx+1, total)} of {total}**" if total else "**No essay questions available.**"
def _show_essay(essays: List[Dict[str, Any]], idx: int):
total = len(essays)
if total == 0:
return (
_essay_progress_str(0, 0),
gr.update(value="No essay questions in this quiz.", interactive=False),
gr.update(value="", interactive=False),
gr.update(value=10, interactive=False),
gr.update(value="", visible=False),
gr.update(visible=False),
gr.update(value={"message": "Nothing to grade."}, visible=True)
)
if idx >= total:
return (
_essay_progress_str(total, total),
gr.update(value="All essay questions completed.", interactive=False),
gr.update(value="", interactive=False),
gr.update(value=10, interactive=False),
gr.update(value="", visible=False),
gr.update(visible=False),
gr.update(visible=True)
)
q = essays[idx]
return (
_essay_progress_str(idx, total),
gr.update(value=q.get("question", ""), interactive=False),
gr.update(value=q.get("rubric", ""), interactive=False),
gr.update(value=int(q.get("max_score", 10)), interactive=False),
gr.update(value="", visible=True),
gr.update(visible=False),
gr.update(visible=False),
)
def init_essay_flow(quiz: Dict[str, Any]):
essays = _essays_only(quiz)
idx0 = 0
results0 = []
ui = _show_essay(essays, idx0)
return (*ui, essays, idx0, results0)
def grade_and_next_essay(answer: str,
essays: List[Dict[str, Any]],
idx: int,
results: List[Dict[str, Any]]):
total = len(essays)
if total == 0 or idx >= total:
# already done
return (
_essay_progress_str(total, total),
gr.update(value="All essay questions completed.", interactive=False),
gr.update(value="", interactive=False),
gr.update(value=10, interactive=False),
gr.update(value="", visible=False),
gr.update(visible=False),
gr.update(value={"message": "Completed."}, visible=True),
essays, idx, results
)
q = essays[idx]
qtext = q.get("question", "")
rubric = q.get("rubric", "")
max_score = int(q.get("max_score", 10))
if not str(answer or "").strip():
# ask to provide an answer
return (
_essay_progress_str(idx, total),
gr.update(value=qtext, interactive=False),
gr.update(value=rubric, interactive=False),
gr.update(value=max_score, interactive=False),
gr.update(value=answer or "", visible=True),
gr.update(value={"error": "Provide an answer before submitting."}, visible=True),
gr.update(visible=False),
essays, idx, results
)
# grade with LLM
grade_obj = grade_essay_answer(qtext, rubric, max_score, answer)
# store result
results = list(results) + [{
"id": q.get("id"),
"score": grade_obj.get("score", 0),
"max": max_score,
"feedback": grade_obj.get("feedback", "")
}]
idx_next = idx + 1
# if finished, compute summary
if idx_next >= total:
total_score = sum(r["score"] for r in results)
max_total = sum(r["max"] for r in results)
summary = {
"completed": len(results),
"total_score": total_score,
"max_total": max_total,
"percentage": round(100.0 * total_score / max(1, max_total), 2),
"details": results
}
return (
_essay_progress_str(total, total),
gr.update(value="All essay questions completed.", interactive=False),
gr.update(value=rubric, interactive=False),
gr.update(value=max_score, interactive=False),
gr.update(value="", visible=False),
gr.update(value=grade_obj, visible=True),
gr.update(value=summary, visible=True),
essays, idx_next, results
)
# otherwise move to next essay
ui_next = _show_essay(essays, idx_next)
return (
*ui_next[:5],
gr.update(value=grade_obj, visible=True), # show grade for current
gr.update(visible=False), # final summary hidden
essays, idx_next, results
)
# initialize the essay flow on new quiz
state_quiz.change(
init_essay_flow,
inputs=[state_quiz],
outputs=[
essay_progress, essay_q_view,
essay_rubric_view, essay_max_score,
essay_answer_tb, essay_grade_json, essay_final_summary,
state_essay_list, state_essay_idx, state_essay_results
]
)
# submit current essay answer and move to next
essay_submit_btn.click(
grade_and_next_essay,
inputs=[essay_answer_tb, state_essay_list, state_essay_idx, state_essay_results],
outputs=[
essay_progress, essay_q_view,
essay_rubric_view, essay_max_score,
essay_answer_tb, essay_grade_json, essay_final_summary,
state_essay_list, state_essay_idx, state_essay_results
]
)
with gr.Tab("4) Export"):
gr.Markdown("Download your quiz as JSON or CSV.")
export_json_btn = gr.Button("Download Quiz (JSON)")
export_csv_btn = gr.Button("Download Quiz (CSV)")
file_json = gr.File(label="Quiz JSON")
file_csv = gr.File(label="Quiz CSV")
def export_json(quiz: Dict[str,Any]):
path = "quiz_export.json"
with open(path, "w", encoding="utf-8") as f:
json.dump(quiz, f, ensure_ascii=False, indent=2)
return path
def export_csv(quiz: Dict[str,Any]):
import csv
path = "quiz_export.csv"
fields = ["id","type","question","options","answer","expected_answer","rubric","max_score","explanation"]
with open(path, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=fields)
writer.writeheader()
for q in quiz.get("questions", []):
row = {
"id": q.get("id"),
"type": q.get("type"),
"question": q.get("question",""),
"options": "|".join(q.get("options", [])) if isinstance(q.get("options"), list) else "",
"answer": q.get("answer",""),
"expected_answer": q.get("expected_answer",""),
"rubric": q.get("rubric",""),
"max_score": q.get("max_score",""),
"explanation": q.get("explanation",""),
}
writer.writerow(row)
return path
export_json_btn.click(export_json, inputs=[state_quiz], outputs=[file_json])
export_csv_btn.click(export_csv, inputs=[state_quiz], outputs=[file_csv])
# Footer/help
with gr.Accordion("Help & Notes", open=False):
gr.Markdown("""
**Setup**
- Install dependencies from `requirements.txt`.
- Launch: `python ai_quiz_from_pdf_gradio.py` and open the local URL.
**Usage**
1. Go to **Upload & Generate**: upload your PDF, tweak counts, click *Extract Text* then *Generate Quiz*.
2. In **Take Quiz**, answer MCQ/True-False/Short-Answer and click *Submit* to auto-grade.
3. In **Essay/Theory Grading**, choose an Essay question, paste your answer, and click *Grade*.
4. In **Export**, download the quiz as JSON/CSV for sharing or record-keeping.
**Notes**
- Essay grading uses the LLM with a rubric and max_score. Results are heuristic.
- For scanned PDFs (images), add OCR (e.g., `pytesseract`) in future work.
""")
if __name__ == "__main__":
# If the API key isn't set, warn in console.
if not os.getenv("GROQ_API_KEY"):
print("WARNING: GROQ_API_KEY is not set. Set it before generating or grading with the LLM.")
demo.launch(debug=True)