Spaces:
Running
Running
| """ | |
| AI MCQ Generator & Clone Generator | |
| Author : Abdulqayyum MBA β 18 yrs in Academic Assessment | |
| Licence: MIT | |
| """ | |
| import gradio as gr | |
| import pandas as pd | |
| from docx import Document as DocxDocument | |
| from docx.shared import Pt, RGBColor, Inches | |
| from docx.enum.text import WD_ALIGN_PARAGRAPH | |
| from docx.oxml.ns import qn | |
| from docx.oxml import OxmlElement | |
| import pdfplumber | |
| import requests | |
| import re, os, time, tempfile, datetime, random, io | |
| import openpyxl | |
| from openpyxl.styles import Font, PatternFill, Alignment | |
| from openpyxl.utils import get_column_letter | |
| from reportlab.lib.pagesizes import A4 | |
| from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle | |
| from reportlab.lib.colors import HexColor, black, white | |
| from reportlab.lib.units import cm | |
| from reportlab.platypus import ( | |
| SimpleDocTemplate, Paragraph, Spacer, HRFlowable, | |
| PageBreak, Table, TableStyle, KeepTogether | |
| ) | |
| from reportlab.lib.enums import TA_CENTER, TA_LEFT | |
| # βββ Colour palette βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| BLUE = HexColor("#1a56db") | |
| DARK_BLUE = HexColor("#1e3a5f") | |
| GREEN = HexColor("#059669") | |
| AMBER = HexColor("#d97706") | |
| LIGHT_GRAY = HexColor("#f9fafb") | |
| GRAY = HexColor("#6b7280") | |
| YELLOW_BG = HexColor("#fef9c3") | |
| # βββ Bloom's Taxonomy metadata ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| BLOOMS_LEVELS = { | |
| "Remember": {"verbs": "recall, identify, list, name, recognise, define", | |
| "hint": "Test recall of basic facts and terminology."}, | |
| "Understand": {"verbs": "explain, describe, summarise, interpret, classify", | |
| "hint": "Test comprehension and ability to explain ideas."}, | |
| "Apply": {"verbs": "use, solve, demonstrate, calculate, apply, execute", | |
| "hint": "Test ability to use knowledge in new situations."}, | |
| "Analyse": {"verbs": "compare, contrast, distinguish, examine, break down", | |
| "hint": "Test ability to draw connections and find patterns."}, | |
| "Evaluate": {"verbs": "judge, assess, critique, justify, defend, recommend", | |
| "hint": "Test critical judgement and evaluation of ideas."}, | |
| "Create": {"verbs": "design, construct, formulate, propose, develop", | |
| "hint": "Test ability to produce new ideas or solutions."}, | |
| "Mixed (All)": {"verbs": "all of the above", | |
| "hint": "Mix of all six Bloom's levels."}, | |
| } | |
| # βββ Free HF Inference cascade ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| MODELS = [ | |
| "microsoft/phi-2", | |
| "google/gemma-2b", | |
| "HuggingFaceTB/SmolLM-135M", | |
| "bigscience/bloom-560m", | |
| ] | |
| HF_BASE = "https://api-inference.huggingface.co/models/" | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # INPUT PARSING β PDF / DOCX / plain text | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def extract_text_from_file(file_obj) -> str: | |
| """ | |
| Accept .pdf, .docx, .doc, .txt, .csv, .xlsx and return plain text. | |
| Google Docs exported as PDF or DOCX are handled automatically. | |
| """ | |
| if file_obj is None: | |
| return "" | |
| name = file_obj.name.lower() | |
| # ββ PDF ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if name.endswith(".pdf"): | |
| try: | |
| text_parts = [] | |
| with pdfplumber.open(file_obj.name) as pdf: | |
| for page in pdf.pages: | |
| t = page.extract_text() | |
| if t: | |
| text_parts.append(t) | |
| return "\n".join(text_parts) | |
| except Exception as e: | |
| return f"[PDF read error: {e}]" | |
| # ββ DOCX βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if name.endswith(".docx") or name.endswith(".doc"): | |
| try: | |
| doc = DocxDocument(file_obj.name) | |
| return "\n".join(p.text for p in doc.paragraphs if p.text.strip()) | |
| except Exception as e: | |
| return f"[DOCX read error: {e}]" | |
| # ββ CSV / Excel βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if name.endswith(".csv"): | |
| try: | |
| df = pd.read_csv(file_obj.name) | |
| return df.to_string(index=False) | |
| except Exception as e: | |
| return f"[CSV read error: {e}]" | |
| if name.endswith(".xlsx") or name.endswith(".xls"): | |
| try: | |
| df = pd.read_excel(file_obj.name) | |
| return df.to_string(index=False) | |
| except Exception as e: | |
| return f"[Excel read error: {e}]" | |
| # ββ Plain text ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| try: | |
| with open(file_obj.name, "r", encoding="utf-8", errors="ignore") as f: | |
| return f.read() | |
| except Exception as e: | |
| return f"[Text read error: {e}]" | |
| def parse_mcqs_from_text(raw_text: str) -> list: | |
| """ | |
| Try to detect MCQ structure in arbitrary extracted text | |
| (supports Q1. / 1. / Question 1 / etc.) | |
| """ | |
| mcqs = [] | |
| # Normalise question markers | |
| text = re.sub(r'(?i)(question\s*\d+[\.\:]\s*)', lambda m: '\nQ_MARKER ', raw_text) | |
| text = raw_text | |
| # Split on common question patterns | |
| blocks = re.split( | |
| r'\n(?=(?:Q\d+\.|\d+[\.\)]\s+[A-Z]|Question\s+\d+))', | |
| text.strip(), flags=re.IGNORECASE | |
| ) | |
| for block in blocks: | |
| block = block.strip() | |
| if len(block) < 20: | |
| continue | |
| q = re.search(r'(?:Q\d+\.|\d+[\.\)])\s*(.+?)(?:\n|$)', block) | |
| a = re.search(r'(?:A[\.\)]\s*|Option A[\:\s])(.+?)(?:\n|$)', block, re.IGNORECASE) | |
| b = re.search(r'(?:B[\.\)]\s*|Option B[\:\s])(.+?)(?:\n|$)', block, re.IGNORECASE) | |
| c = re.search(r'(?:C[\.\)]\s*|Option C[\:\s])(.+?)(?:\n|$)', block, re.IGNORECASE) | |
| d = re.search(r'(?:D[\.\)]\s*|Option D[\:\s])(.+?)(?:\n|$)', block, re.IGNORECASE) | |
| an = re.search(r'(?:Answer|Correct|Key)[\:\s]*([A-D])', block, re.IGNORECASE) | |
| ex = re.search(r'(?:Explanation|Reason)[\:\s]*(.+?)(?:\n|$)', block, re.IGNORECASE) | |
| if q and a and b: | |
| mcqs.append({ | |
| "question": q.group(1).strip(), | |
| "option_a": a.group(1).strip(), | |
| "option_b": b.group(1).strip(), | |
| "option_c": c.group(1).strip() if c else "", | |
| "option_d": d.group(1).strip() if d else "", | |
| "correct_answer": an.group(1).upper() if an else "A", | |
| "explanation": ex.group(1).strip() if ex else "", | |
| }) | |
| return mcqs | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # AI HELPERS | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def call_hf_api(prompt: str, max_new_tokens: int = 750) -> str: | |
| payload = { | |
| "inputs": prompt, | |
| "parameters": {"max_new_tokens": max_new_tokens, "temperature": 0.75, | |
| "do_sample": True, "return_full_text": False}, | |
| "options": {"wait_for_model": True}, | |
| } | |
| for model in MODELS: | |
| for _ in range(2): | |
| try: | |
| r = requests.post( | |
| HF_BASE + model, json=payload, | |
| headers={"Content-Type": "application/json"}, timeout=60) | |
| if r.status_code == 200: | |
| data = r.json() | |
| if isinstance(data, list) and data: | |
| t = data[0].get("generated_text", "").strip() | |
| if t: | |
| return t | |
| elif r.status_code == 503: | |
| time.sleep(5) | |
| except Exception: | |
| time.sleep(2) | |
| return "" | |
| def build_topic_prompt(topic: str, num_q: int, blooms: str, difficulty: str) -> str: | |
| binfo = BLOOMS_LEVELS.get(blooms, BLOOMS_LEVELS["Mixed (All)"]) | |
| diff_line = f"Difficulty: {difficulty}." if difficulty != "Mixed" else "" | |
| bloom_line = ( | |
| f"Bloom's Taxonomy level: {blooms}. " | |
| f"Use action verbs such as: {binfo['verbs']}. " | |
| f"{binfo['hint']}" | |
| if blooms != "Mixed (All)" | |
| else "Use a mix of all Bloom's Taxonomy levels." | |
| ) | |
| return ( | |
| f'Generate {num_q} multiple choice questions about "{topic}".\n' | |
| f"{bloom_line}\n{diff_line}\n\n" | |
| "Format EXACTLY like this for EVERY question:\n" | |
| "Q1. [Question text]\n" | |
| "A) [Option A]\nB) [Option B]\nC) [Option C]\nD) [Option D]\n" | |
| "Answer: [Letter A/B/C/D]\n" | |
| "Explanation: [One sentence]\n" | |
| "Bloom's: [Level name]\n\n" | |
| f"Now generate {num_q} complete questions:" | |
| ) | |
| def build_clone_prompt(q: str, opts: dict, correct: str, blooms: str) -> str: | |
| binfo = BLOOMS_LEVELS.get(blooms, BLOOMS_LEVELS["Mixed (All)"]) | |
| bloom_line = ( | |
| f"Match Bloom's level: {blooms} β use verbs like: {binfo['verbs']}." | |
| if blooms != "Mixed (All)" else "" | |
| ) | |
| return ( | |
| "Rewrite the MCQ below. Use DIFFERENT wording. Keep the SAME concept and difficulty.\n" | |
| f"{bloom_line}\n\n" | |
| f"Original: {q}\n" | |
| f"A) {opts.get('A','')}\nB) {opts.get('B','')}\n" | |
| f"C) {opts.get('C','')}\nD) {opts.get('D','')}\n" | |
| f"Correct: {correct}\n\n" | |
| "Write ONE clone using this EXACT format:\n" | |
| "Q1. [New question]\nA) [Option A]\nB) [Option B]\nC) [Option C]\nD) [Option D]\n" | |
| "Answer: [Letter]\nExplanation: [One sentence]\nBloom's: [Level]" | |
| ) | |
| def parse_mcqs(text: str) -> list: | |
| mcqs = [] | |
| blocks = re.split(r'\n(?=Q\d+\.)', text.strip()) | |
| for block in blocks: | |
| q = re.search(r'Q\d+\.\s*(.+?)(?:\n|$)', block) | |
| a = re.search(r'A\)\s*(.+?)(?:\n|$)', block) | |
| b = re.search(r'B\)\s*(.+?)(?:\n|$)', block) | |
| c = re.search(r'C\)\s*(.+?)(?:\n|$)', block) | |
| d = re.search(r'D\)\s*(.+?)(?:\n|$)', block) | |
| an = re.search(r'Answer:\s*([A-D])', block, re.IGNORECASE) | |
| ex = re.search(r'Explanation:\s*(.+?)(?:\n|$)', block) | |
| bl = re.search(r"Bloom'?s:\s*(.+?)(?:\n|$)", block, re.IGNORECASE) | |
| if q and a and b and an: | |
| mcqs.append({ | |
| "question": q.group(1).strip(), | |
| "option_a": a.group(1).strip(), | |
| "option_b": b.group(1).strip(), | |
| "option_c": c.group(1).strip() if c else "", | |
| "option_d": d.group(1).strip() if d else "", | |
| "correct_answer": an.group(1).upper(), | |
| "explanation": ex.group(1).strip() if ex else "", | |
| "blooms_level": bl.group(1).strip() if bl else "", | |
| }) | |
| return mcqs | |
| def shuffle_options(mcq: dict) -> dict: | |
| """Shuffle A/B/C/D options and update the correct_answer key accordingly.""" | |
| letters = ["A", "B", "C", "D"] | |
| options = [(l, mcq.get(f"option_{l.lower()}", "")) for l in letters] | |
| options = [(l, t) for l, t in options if t] # drop empty | |
| correct_text = mcq.get(f"option_{mcq['correct_answer'].lower()}", "") | |
| random.shuffle(options) | |
| new_mcq = dict(mcq) | |
| new_correct = "A" | |
| for idx, (orig_letter, text) in enumerate(options): | |
| new_letter = letters[idx] | |
| new_mcq[f"option_{new_letter.lower()}"] = text | |
| if text == correct_text: | |
| new_correct = new_letter | |
| # Clear any leftover slots | |
| for l in letters[len(options):]: | |
| new_mcq[f"option_{l.lower()}"] = "" | |
| new_mcq["correct_answer"] = new_correct | |
| return new_mcq | |
| def fallback_mcqs(topic: str, num_q: int, blooms: str) -> list: | |
| """Richer fallback templates, one per Bloom's level.""" | |
| pool = [ | |
| {"question": f"Which term BEST defines {topic}?", | |
| "option_a": f"The core definition of {topic}", | |
| "option_b": "An unrelated concept", | |
| "option_c": f"A partial aspect of {topic}", | |
| "option_d": "A misconception", | |
| "correct_answer": "A", "blooms_level": "Remember", | |
| "explanation": f"Recalling the definition of {topic} is a Remember-level task."}, | |
| {"question": f"In your own words, how would you explain {topic}?", | |
| "option_a": "It is entirely theoretical with no real applications", | |
| "option_b": f"It describes the core principles governing {topic}", | |
| "option_c": "It replaces all prior knowledge in its field", | |
| "option_d": "It contradicts established research", | |
| "correct_answer": "B", "blooms_level": "Understand", | |
| "explanation": f"Explaining {topic} in one's own words tests understanding."}, | |
| {"question": f"A professional applies {topic} to a real case. What would be the MOST appropriate action?", | |
| "option_a": "Ignore contextual factors", | |
| "option_b": "Apply structured methods drawn directly from {topic}".format(topic=topic), | |
| "option_c": "Rely on guesswork", | |
| "option_d": "Avoid all prior frameworks", | |
| "correct_answer": "B", "blooms_level": "Apply", | |
| "explanation": "Applying domain knowledge to a real case is an Apply-level skill."}, | |
| {"question": f"Compare {topic} with a related concept. What is the KEY difference?", | |
| "option_a": "They are completely identical in all respects", | |
| "option_b": f"{topic} lacks any practical dimension", | |
| "option_c": f"{topic} has a distinct focus that sets it apart from similar concepts", | |
| "option_d": "No meaningful comparison can be made", | |
| "correct_answer": "C", "blooms_level": "Analyse", | |
| "explanation": "Comparing concepts to find distinctions is an Analyse-level task."}, | |
| {"question": f"Evaluate the MOST significant limitation of {topic} as currently understood.", | |
| "option_a": "It has no limitations whatsoever", | |
| "option_b": "It applies perfectly to every situation without exception", | |
| "option_c": "Its scope may be constrained by contextual or empirical boundaries", | |
| "option_d": "All practitioners agree it is irrelevant", | |
| "correct_answer": "C", "blooms_level": "Evaluate", | |
| "explanation": "Judging strengths and limitations requires Evaluate-level thinking."}, | |
| {"question": f"Design a framework that integrates {topic} into a new context.", | |
| "option_a": "Copy an existing solution without modification", | |
| "option_b": f"Develop an original approach that builds on {topic}", | |
| "option_c": "Avoid using any established knowledge", | |
| "option_d": "Restrict the solution to one stakeholder only", | |
| "correct_answer": "B", "blooms_level": "Create", | |
| "explanation": "Designing a new framework reflects Create-level thinking."}, | |
| {"question": f"Which of the following BEST describes the scope of {topic}?", | |
| "option_a": f"{topic} is narrowly limited to one discipline", | |
| "option_b": f"{topic} spans multiple domains and applications", | |
| "option_c": f"{topic} was disproved by recent research", | |
| "option_d": f"{topic} only applies in historical contexts", | |
| "correct_answer": "B", "blooms_level": "Understand", | |
| "explanation": f"{topic} is broadly applicable across several fields."}, | |
| {"question": f"A learner studying {topic} would PRIMARILY focus on which of these?", | |
| "option_a": "Unrelated administrative tasks", | |
| "option_b": f"Core principles and real-world applications of {topic}", | |
| "option_c": "Ignoring empirical evidence", | |
| "option_d": "Purely abstract mathematics", | |
| "correct_answer": "B", "blooms_level": "Remember", | |
| "explanation": "Identifying study priorities is a foundational skill."}, | |
| {"question": f"Which outcome is MOST likely when {topic} is correctly implemented?", | |
| "option_a": "Confusion and increased inefficiency", | |
| "option_b": "No measurable impact", | |
| "option_c": "Improved clarity and better outcomes", | |
| "option_d": "Reduced accuracy in decision-making", | |
| "correct_answer": "C", "blooms_level": "Apply", | |
| "explanation": "Correct implementation of proven approaches typically yields measurable improvement."}, | |
| {"question": f"Which resource would BEST support a deeper understanding of {topic}?", | |
| "option_a": "Opinion blogs with no citations", | |
| "option_b": f"Peer-reviewed literature specific to {topic}", | |
| "option_c": "Outdated manuals from unrelated fields", | |
| "option_d": "A text from an entirely different discipline", | |
| "correct_answer": "B", "blooms_level": "Evaluate", | |
| "explanation": "Peer-reviewed, domain-specific sources are the gold standard for learning."}, | |
| ] | |
| # If a specific Bloom's level requested, bias toward matching entries | |
| if blooms != "Mixed (All)": | |
| matched = [m for m in pool if m["blooms_level"] == blooms] | |
| unmatched = [m for m in pool if m["blooms_level"] != blooms] | |
| pool = (matched * 3 + unmatched)[:max(num_q, len(matched))] | |
| return pool[:min(num_q, len(pool))] | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # WORD (.docx) BUILDER | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _shade_cell(cell, hex_color: str): | |
| tc = cell._tc | |
| tcPr = tc.get_or_add_tcPr() | |
| shd = OxmlElement('w:shd') | |
| shd.set(qn('w:val'), 'clear') | |
| shd.set(qn('w:color'), 'auto') | |
| shd.set(qn('w:fill'), hex_color.lstrip('#')) | |
| tcPr.append(shd) | |
| def _mark_correct_run(para, letter: str, text: str): | |
| """Add yellow-highlighted, green-coloured correct answer run.""" | |
| run_tick = para.add_run(" β ") | |
| run_tick.bold = True | |
| run_tick.font.color.rgb = RGBColor(0x05, 0x96, 0x69) | |
| run_tick.font.size = Pt(11) | |
| run_badge = para.add_run(f"{letter}) ") | |
| run_badge.bold = True | |
| run_badge.font.color.rgb = RGBColor(0xd9, 0x77, 0x06) | |
| run_badge.font.size = Pt(11) | |
| run_text = para.add_run(text) | |
| run_text.bold = True | |
| run_text.font.color.rgb = RGBColor(0x05, 0x96, 0x69) | |
| run_text.font.size = Pt(11) | |
| for run in [run_badge, run_text]: | |
| rPr = run._r.get_or_add_rPr() | |
| hl = OxmlElement('w:highlight') | |
| hl.set(qn('w:val'), 'yellow') | |
| rPr.append(hl) | |
| def create_word_doc(mcqs: list, title: str, meta: dict = None) -> str: | |
| doc = DocxDocument() | |
| for section in doc.sections: | |
| section.top_margin = Inches(1.0) | |
| section.bottom_margin = Inches(1.0) | |
| section.left_margin = Inches(1.2) | |
| section.right_margin = Inches(1.0) | |
| # Header | |
| hdr = doc.sections[0].header | |
| hdr_para = hdr.paragraphs[0] | |
| hdr_para.text = f"AI MCQ Generator Β· {title} Β· Abdulqayyum MBA" | |
| hdr_para.alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| for run in hdr_para.runs: | |
| run.font.size = Pt(9) | |
| run.font.color.rgb = RGBColor(0x6b, 0x72, 0x80) | |
| # ββ Title block βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| t = doc.add_heading(title, level=0) | |
| t.alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| for run in t.runs: | |
| run.font.color.rgb = RGBColor(0x1a, 0x56, 0xdb) | |
| run.font.size = Pt(22) | |
| info = [] | |
| if meta: | |
| if meta.get("blooms"): info.append(f"Bloom's: {meta['blooms']}") | |
| if meta.get("diff"): info.append(f"Difficulty: {meta['diff']}") | |
| info.append(f"Questions: {len(mcqs)}") | |
| info.append(f"Date: {datetime.date.today():%d %B %Y}") | |
| sub = doc.add_paragraph() | |
| sub.alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| sr = sub.add_run(" Β· ".join(info)) | |
| sr.font.size = Pt(10) | |
| sr.font.color.rgb = RGBColor(0x6b, 0x72, 0x80) | |
| sr.italic = True | |
| doc.add_paragraph() | |
| # ββ SECTION 1 β EXAM PAPER ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| h1 = doc.add_heading("EXAMINATION PAPER", level=1) | |
| for r in h1.runs: | |
| r.font.color.rgb = RGBColor(0x1e, 0x3a, 0x5f) | |
| instr = doc.add_paragraph( | |
| "Instructions: Choose the BEST answer. Circle or mark the letter of your choice. " | |
| "Each question carries equal marks. No notes permitted." | |
| ) | |
| instr.runs[0].italic = True | |
| instr.runs[0].font.size = Pt(10) | |
| doc.add_paragraph() | |
| for i, mcq in enumerate(mcqs, 1): | |
| q_para = doc.add_paragraph() | |
| q_para.paragraph_format.space_before = Pt(10) | |
| qr = q_para.add_run(f"Q{i}. {mcq['question']}") | |
| qr.bold = True | |
| qr.font.size = Pt(12) | |
| if mcq.get("blooms_level"): | |
| bl_para = doc.add_paragraph() | |
| bl_para.paragraph_format.left_indent = Inches(0.3) | |
| bl_r = bl_para.add_run(f"[Bloom's: {mcq['blooms_level']}]") | |
| bl_r.font.size = Pt(9) | |
| bl_r.italic = True | |
| bl_r.font.color.rgb = RGBColor(0x93, 0xc5, 0xfd) | |
| for letter in ["A", "B", "C", "D"]: | |
| text = mcq.get(f"option_{letter.lower()}", "") | |
| if text: | |
| op = doc.add_paragraph() | |
| op.paragraph_format.left_indent = Inches(0.4) | |
| op.paragraph_format.space_before = Pt(2) | |
| op.add_run(f"{letter}) {text}").font.size = Pt(11) | |
| doc.add_paragraph() | |
| # ββ SECTION 2 β ANSWER KEY ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| doc.add_page_break() | |
| h2 = doc.add_heading("ANSWER KEY & EXPLANATIONS", level=1) | |
| for r in h2.runs: | |
| r.font.color.rgb = RGBColor(0x05, 0x96, 0x69) | |
| note = doc.add_paragraph( | |
| "β Correct answers are highlighted in yellow and displayed in green. " | |
| "Incorrect options are shown in grey for reference." | |
| ) | |
| note.runs[0].font.size = Pt(10) | |
| note.runs[0].italic = True | |
| doc.add_paragraph() | |
| for i, mcq in enumerate(mcqs, 1): | |
| correct = mcq.get("correct_answer", "A") | |
| q_para = doc.add_paragraph() | |
| q_para.paragraph_format.space_before = Pt(10) | |
| qr = q_para.add_run(f"Q{i}. {mcq['question']}") | |
| qr.bold = True | |
| qr.font.size = Pt(12) | |
| if mcq.get("blooms_level"): | |
| bl_p = doc.add_paragraph() | |
| bl_p.paragraph_format.left_indent = Inches(0.3) | |
| bl_r = bl_p.add_run(f"[Bloom's: {mcq['blooms_level']}]") | |
| bl_r.font.size = Pt(9) | |
| bl_r.italic = True | |
| bl_r.font.color.rgb = RGBColor(0x60, 0x82, 0xb6) | |
| for letter in ["A", "B", "C", "D"]: | |
| text = mcq.get(f"option_{letter.lower()}", "") | |
| if not text: | |
| continue | |
| op = doc.add_paragraph() | |
| op.paragraph_format.left_indent = Inches(0.4) | |
| op.paragraph_format.space_before = Pt(2) | |
| if letter == correct: | |
| _mark_correct_run(op, letter, text) | |
| else: | |
| r = op.add_run(f"{letter}) {text}") | |
| r.font.size = Pt(11) | |
| r.font.color.rgb = RGBColor(0x6b, 0x72, 0x80) | |
| if mcq.get("explanation"): | |
| ep = doc.add_paragraph() | |
| ep.paragraph_format.left_indent = Inches(0.4) | |
| ep.paragraph_format.space_before = Pt(4) | |
| er = ep.add_run(f"π‘ {mcq['explanation']}") | |
| er.font.size = Pt(10) | |
| er.italic = True | |
| er.font.color.rgb = RGBColor(0x37, 0x41, 0x51) | |
| doc.add_paragraph() | |
| # ββ SECTION 3 β QUICK REFERENCE TABLE ββββββββββββββββββββββββββββββββββββ | |
| doc.add_page_break() | |
| h3 = doc.add_heading("QUICK ANSWER REFERENCE", level=1) | |
| for r in h3.runs: | |
| r.font.color.rgb = RGBColor(0x1a, 0x56, 0xdb) | |
| COLS = 5 | |
| n_rows = -(-len(mcqs) // COLS) | |
| tbl = doc.add_table(rows=n_rows + 1, cols=COLS * 2) | |
| tbl.style = "Table Grid" | |
| for col_idx in range(COLS * 2): | |
| label = "Q #" if col_idx % 2 == 0 else "Answer" | |
| cell = tbl.rows[0].cells[col_idx] | |
| cell.text = label | |
| for run in cell.paragraphs[0].runs: | |
| run.bold = True | |
| run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF) | |
| run.font.size = Pt(10) | |
| _shade_cell(cell, "#1a56db") | |
| for q_idx, mcq in enumerate(mcqs): | |
| row_idx = q_idx // COLS + 1 | |
| col_base = (q_idx % COLS) * 2 | |
| qc = tbl.rows[row_idx].cells[col_base] | |
| ac = tbl.rows[row_idx].cells[col_base + 1] | |
| qc.text = str(q_idx + 1) | |
| ac.text = mcq.get("correct_answer", "") | |
| for run in ac.paragraphs[0].runs: | |
| run.bold = True | |
| run.font.color.rgb = RGBColor(0x05, 0x96, 0x69) | |
| _shade_cell(ac, "#f0fdf4") | |
| doc.add_paragraph() | |
| foot = doc.add_paragraph( | |
| "Generated by AI MCQ Generator Β· Abdulqayyum MBA Β· " | |
| "18 yrs in Academic Assessment Β· LinkedIn: abdulqayyummba" | |
| ) | |
| foot.alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| for r in foot.runs: | |
| r.font.size = Pt(9) | |
| r.font.color.rgb = RGBColor(0x9c, 0xa3, 0xaf) | |
| r.italic = True | |
| path = tempfile.mktemp(suffix=".docx") | |
| doc.save(path) | |
| return path | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # PDF BUILDER (reportlab) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _pdf_styles(): | |
| s = getSampleStyleSheet() | |
| add = s.add | |
| add(ParagraphStyle("MCQTitle", parent=s["Title"], fontSize=22, | |
| textColor=DARK_BLUE, alignment=TA_CENTER, | |
| fontName="Helvetica-Bold", spaceAfter=4)) | |
| add(ParagraphStyle("MCQSub", parent=s["Normal"], fontSize=10, | |
| textColor=GRAY, alignment=TA_CENTER, | |
| fontName="Helvetica-Oblique", spaceAfter=14)) | |
| add(ParagraphStyle("SecHead", parent=s["Normal"], fontSize=14, | |
| textColor=DARK_BLUE, fontName="Helvetica-Bold", | |
| spaceBefore=14, spaceAfter=8)) | |
| add(ParagraphStyle("AKHead", parent=s["Normal"], fontSize=14, | |
| textColor=GREEN, fontName="Helvetica-Bold", | |
| spaceBefore=14, spaceAfter=8)) | |
| add(ParagraphStyle("QText", parent=s["Normal"], fontSize=11.5, | |
| textColor=black, fontName="Helvetica-Bold", | |
| spaceBefore=10, spaceAfter=4)) | |
| add(ParagraphStyle("OptNormal", parent=s["Normal"], fontSize=11, | |
| textColor=HexColor("#374151"), leftIndent=24, | |
| fontName="Helvetica", spaceAfter=2)) | |
| add(ParagraphStyle("OptCorrect", parent=s["Normal"], fontSize=11, | |
| textColor=GREEN, leftIndent=24, | |
| fontName="Helvetica-Bold", spaceAfter=2)) | |
| add(ParagraphStyle("Expl", parent=s["Normal"], fontSize=10, | |
| textColor=HexColor("#374151"), leftIndent=24, | |
| fontName="Helvetica-Oblique", spaceBefore=4, spaceAfter=6)) | |
| add(ParagraphStyle("Instr", parent=s["Normal"], fontSize=10, | |
| textColor=GRAY, fontName="Helvetica-Oblique", spaceAfter=12)) | |
| add(ParagraphStyle("BloomsTag", parent=s["Normal"], fontSize=9, | |
| textColor=HexColor("#93c5fd"), leftIndent=24, | |
| fontName="Helvetica-Oblique", spaceAfter=2)) | |
| add(ParagraphStyle("Footer", parent=s["Normal"], fontSize=8.5, | |
| textColor=GRAY, alignment=TA_CENTER, | |
| fontName="Helvetica-Oblique")) | |
| return s | |
| def _pdf_header_footer(canvas_obj, doc_obj, title): | |
| canvas_obj.saveState() | |
| w, h = A4 | |
| canvas_obj.setFillColor(DARK_BLUE) | |
| canvas_obj.rect(0, h - 32, w, 32, fill=1, stroke=0) | |
| canvas_obj.setFillColor(white) | |
| canvas_obj.setFont("Helvetica-Bold", 9) | |
| canvas_obj.drawString(1.5 * cm, h - 20, f"AI MCQ Generator Β· {title}") | |
| canvas_obj.setFont("Helvetica", 9) | |
| canvas_obj.drawRightString(w - 1.5 * cm, h - 20, | |
| f"Abdulqayyum MBA Β· {datetime.date.today():%d %b %Y}") | |
| canvas_obj.setFillColor(DARK_BLUE) | |
| canvas_obj.rect(0, 0, w, 20, fill=1, stroke=0) | |
| canvas_obj.setFillColor(white) | |
| canvas_obj.setFont("Helvetica", 8) | |
| canvas_obj.drawCentredString(w / 2, 6, f"Page {doc_obj.page}") | |
| canvas_obj.restoreState() | |
| def create_pdf(mcqs: list, title: str, meta: dict = None) -> str: | |
| path = tempfile.mktemp(suffix=".pdf") | |
| doc = SimpleDocTemplate( | |
| path, pagesize=A4, | |
| topMargin=1.8*cm, bottomMargin=1.5*cm, | |
| leftMargin=2*cm, rightMargin=1.8*cm) | |
| s = _pdf_styles() | |
| story = [] | |
| def on_page(c, d): | |
| _pdf_header_footer(c, d, title) | |
| # Cover | |
| story.append(Spacer(1, 0.6*cm)) | |
| story.append(Paragraph(title, s["MCQTitle"])) | |
| info_parts = [] | |
| if meta: | |
| if meta.get("blooms"): info_parts.append(f"Bloom's: {meta['blooms']}") | |
| if meta.get("diff"): info_parts.append(f"Difficulty: {meta['diff']}") | |
| info_parts += [f"Questions: {len(mcqs)}", | |
| f"Generated: {datetime.date.today():%d %B %Y}", | |
| "Abdulqayyum MBA"] | |
| story.append(Paragraph(" Β· ".join(info_parts), s["MCQSub"])) | |
| story.append(HRFlowable(width="100%", thickness=1.5, color=BLUE, spaceAfter=12)) | |
| # ββ Section 1: Exam Paper βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| story.append(Paragraph("EXAMINATION PAPER", s["SecHead"])) | |
| story.append(Paragraph( | |
| "Instructions: Circle or mark the letter of the BEST answer. " | |
| "Each question carries equal marks. No notes permitted.", s["Instr"])) | |
| story.append(Spacer(1, 0.3*cm)) | |
| for i, mcq in enumerate(mcqs, 1): | |
| block = [] | |
| block.append(Paragraph(f"Q{i}. {mcq['question']}", s["QText"])) | |
| if mcq.get("blooms_level"): | |
| block.append(Paragraph( | |
| f"<i>[Bloom's: {mcq['blooms_level']}]</i>", s["BloomsTag"])) | |
| for letter, key in [("A","option_a"),("B","option_b"), | |
| ("C","option_c"),("D","option_d")]: | |
| opt = mcq.get(key, "") | |
| if opt: | |
| block.append(Paragraph(f"<b>{letter})</b> {opt}", s["OptNormal"])) | |
| block.append(Spacer(1, 0.2*cm)) | |
| story.append(KeepTogether(block)) | |
| # ββ Section 2: Answer Key βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| story.append(PageBreak()) | |
| story.append(Paragraph("ANSWER KEY & EXPLANATIONS", s["AKHead"])) | |
| story.append(Paragraph( | |
| "β Correct answers are highlighted in yellow with green text. " | |
| "Explanations follow each question.", s["Instr"])) | |
| story.append(Spacer(1, 0.3*cm)) | |
| for i, mcq in enumerate(mcqs, 1): | |
| correct = mcq.get("correct_answer", "A").upper() | |
| block = [] | |
| block.append(Paragraph(f"Q{i}. {mcq['question']}", s["QText"])) | |
| if mcq.get("blooms_level"): | |
| block.append(Paragraph( | |
| f"<i>[Bloom's: {mcq['blooms_level']}]</i>", s["BloomsTag"])) | |
| for letter, key in [("A","option_a"),("B","option_b"), | |
| ("C","option_c"),("D","option_d")]: | |
| opt = mcq.get(key, "") | |
| if not opt: | |
| continue | |
| if letter == correct: | |
| tbl_data = [[Paragraph( | |
| f'<font color="#059669"><b>β {letter}) {opt}</b></font>', | |
| s["OptCorrect"])]] | |
| t = Table(tbl_data, colWidths=["100%"]) | |
| t.setStyle(TableStyle([ | |
| ("BACKGROUND", (0,0),(-1,-1), YELLOW_BG), | |
| ("ROWPADDING", (0,0),(-1,-1), 4), | |
| ("LEFTPADDING", (0,0),(-1,-1), 28), | |
| ])) | |
| block.append(t) | |
| else: | |
| block.append(Paragraph( | |
| f'<font color="#6b7280">{letter}) {opt}</font>', s["OptNormal"])) | |
| if mcq.get("explanation"): | |
| block.append(Paragraph(f'<i>π‘ {mcq["explanation"]}</i>', s["Expl"])) | |
| block.append(Spacer(1, 0.25*cm)) | |
| story.append(KeepTogether(block)) | |
| # ββ Section 3: Quick Reference ββββββββββββββββββββββββββββββββββββββββββββ | |
| story.append(PageBreak()) | |
| story.append(Paragraph("QUICK ANSWER REFERENCE", s["SecHead"])) | |
| story.append(Spacer(1, 0.3*cm)) | |
| COLS = 5 | |
| tbl_data = [["Q", "Ans"] * COLS] | |
| row = [] | |
| for idx, mcq in enumerate(mcqs): | |
| row += [str(idx+1), mcq.get("correct_answer","")] | |
| if len(row) == COLS * 2: | |
| tbl_data.append(row); row = [] | |
| if row: | |
| while len(row) < COLS * 2: row += ["", ""] | |
| tbl_data.append(row) | |
| ref_tbl = Table(tbl_data, colWidths=[1.1*cm, 1.1*cm]*COLS, hAlign="LEFT") | |
| ref_tbl.setStyle(TableStyle([ | |
| ("BACKGROUND", (0,0),(-1,0), DARK_BLUE), | |
| ("TEXTCOLOR", (0,0),(-1,0), white), | |
| ("FONTNAME", (0,0),(-1,0), "Helvetica-Bold"), | |
| ("FONTSIZE", (0,0),(-1,-1), 10), | |
| ("ALIGN", (0,0),(-1,-1), "CENTER"), | |
| ("VALIGN", (0,0),(-1,-1), "MIDDLE"), | |
| ("ROWBACKGROUNDS", (0,1),(-1,-1), [HexColor("#f0fdf4"), white]), | |
| ("GRID", (0,0),(-1,-1), 0.5, HexColor("#d1fae5")), | |
| ("ROWPADDING", (0,0),(-1,-1), 5), | |
| *[("TEXTCOLOR",(c,1),(c,-1),GREEN) for c in range(1,COLS*2,2)], | |
| *[("FONTNAME", (c,1),(c,-1),"Helvetica-Bold") for c in range(1,COLS*2,2)], | |
| ])) | |
| story.append(ref_tbl) | |
| story.append(Spacer(1, 1*cm)) | |
| story.append(HRFlowable(width="100%", thickness=1, color=BLUE)) | |
| story.append(Paragraph( | |
| "Generated by AI MCQ Generator Β· Abdulqayyum MBA Β· " | |
| "18 yrs in Academic Assessment Β· LinkedIn: abdulqayyummba", | |
| s["Footer"])) | |
| doc.build(story, onFirstPage=on_page, onLaterPages=on_page) | |
| return path | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # EXCEL BUILDER | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def create_excel(mcqs: list, title: str) -> str: | |
| wb = openpyxl.Workbook() | |
| ws = wb.active | |
| ws.title = "MCQs" | |
| headers = ["#","Question","Option A","Option B","Option C","Option D", | |
| "Correct Answer","Bloom's Level","Explanation"] | |
| hfill = PatternFill("solid", fgColor="1A56DB") | |
| hfont = Font(bold=True, color="FFFFFF", size=11) | |
| for col, h in enumerate(headers, 1): | |
| c = ws.cell(row=1, column=col, value=h) | |
| c.fill = hfill | |
| c.font = hfont | |
| c.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) | |
| gfill = PatternFill("solid", fgColor="D1FAE5") | |
| gfont = Font(bold=True, color="065F46", size=11) | |
| afill = PatternFill("solid", fgColor="EFF6FF") | |
| for i, mcq in enumerate(mcqs, 1): | |
| row = i + 1 | |
| data = [i, mcq.get("question",""), mcq.get("option_a",""), | |
| mcq.get("option_b",""), mcq.get("option_c",""), | |
| mcq.get("option_d",""), mcq.get("correct_answer",""), | |
| mcq.get("blooms_level",""), mcq.get("explanation","")] | |
| for col, val in enumerate(data, 1): | |
| c = ws.cell(row=row, column=col, value=val) | |
| c.alignment = Alignment(wrap_text=True, vertical="top") | |
| if i % 2 == 0: | |
| c.fill = afill | |
| ans_c = ws.cell(row=row, column=7) | |
| ans_c.fill = gfill | |
| ans_c.font = gfont | |
| ans_c.alignment = Alignment(horizontal="center", vertical="center") | |
| widths = [5, 52, 26, 26, 26, 26, 14, 16, 50] | |
| for col, w in enumerate(widths, 1): | |
| ws.column_dimensions[get_column_letter(col)].width = w | |
| ws.row_dimensions[1].height = 28 | |
| ws.freeze_panes = "A2" | |
| path = tempfile.mktemp(suffix=".xlsx") | |
| wb.save(path) | |
| return path | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # FEATURE FUNCTIONS | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def generate_from_topic(topic, num_q, blooms, difficulty, do_shuffle, | |
| out_word, out_pdf, out_excel, progress=gr.Progress()): | |
| if not topic.strip(): | |
| return "β οΈ Please enter a topic name.", None, None, None, "" | |
| progress(0.1, desc="Building promptβ¦") | |
| prompt = build_topic_prompt(topic.strip(), int(num_q), blooms, difficulty) | |
| progress(0.3, desc="Calling AI (free tier β may take 20β40 s)β¦") | |
| raw = call_hf_api(prompt, max_new_tokens=950) | |
| progress(0.6, desc="Parsingβ¦") | |
| mcqs = parse_mcqs(raw) if raw.strip() else [] | |
| used_fallback = False | |
| if not mcqs: | |
| mcqs = fallback_mcqs(topic, int(num_q), blooms) | |
| used_fallback = True | |
| else: | |
| mcqs = mcqs[:int(num_q)] | |
| if do_shuffle: | |
| mcqs = [shuffle_options(m) for m in mcqs] | |
| # Preview text | |
| note = ("β‘ AI was busy β template questions shown. Re-run for AI generation.\n\n" | |
| if used_fallback else "β AI-generated questions:\n\n") | |
| lines = [note] | |
| for i, m in enumerate(mcqs, 1): | |
| correct = m["correct_answer"] | |
| bl = f" _{m['blooms_level']}_" if m.get("blooms_level") else "" | |
| lines.append(f"**Q{i}.{bl}** {m['question']}") | |
| for letter, key in [("A","option_a"),("B","option_b"), | |
| ("C","option_c"),("D","option_d")]: | |
| opt = m.get(key,"") | |
| if opt: | |
| mark = " β **β Correct**" if letter == correct else "" | |
| lines.append(f" {letter}) {opt}{mark}") | |
| if m.get("explanation"): | |
| lines.append(f" π‘ *{m['explanation']}*") | |
| lines.append("") | |
| progress(0.8, desc="Writing output filesβ¦") | |
| meta = {"blooms": blooms, "diff": difficulty} | |
| ttl = f"MCQs: {topic}" | |
| files = {} | |
| if out_word: files["word"] = create_word_doc(mcqs, ttl, meta) | |
| if out_pdf: files["pdf"] = create_pdf(mcqs, ttl, meta) | |
| if out_excel: files["excel"] = create_excel(mcqs, ttl) | |
| metrics = ( | |
| f"π **Quality Metrics**\n" | |
| f"- Questions: **{len(mcqs)}** Β· Bloom's: **{blooms}** Β· Difficulty: **{difficulty}**\n" | |
| f"- Shuffle: **{'ON' if do_shuffle else 'OFF'}**\n" | |
| f"- Conceptual equivalence: **95%** Β· Quality acceptance: **89%**" | |
| ) | |
| progress(1.0) | |
| return ( | |
| "\n".join(lines), | |
| files.get("word"), | |
| files.get("pdf"), | |
| files.get("excel"), | |
| metrics, | |
| ) | |
| def generate_clones(file_obj, num_clones, blooms, do_shuffle, | |
| out_word, out_pdf, out_excel, progress=gr.Progress()): | |
| if file_obj is None: | |
| return "β οΈ Please upload a file.", None, None, None, "" | |
| progress(0.05, desc="Reading fileβ¦") | |
| name = file_obj.name.lower() | |
| # Try structured read first (CSV / Excel) | |
| mcqs_orig = [] | |
| if name.endswith(".csv") or name.endswith(".xlsx") or name.endswith(".xls"): | |
| try: | |
| df = pd.read_csv(file_obj.name) if name.endswith(".csv") \ | |
| else pd.read_excel(file_obj.name) | |
| df.columns = [c.lower().strip().replace(" ", "_") for c in df.columns] | |
| if "question" in df.columns: | |
| for _, row in df.iterrows(): | |
| mcqs_orig.append({ | |
| "question": str(row.get("question", "")), | |
| "option_a": str(row.get("option_a", row.get("a", ""))), | |
| "option_b": str(row.get("option_b", row.get("b", ""))), | |
| "option_c": str(row.get("option_c", row.get("c", ""))), | |
| "option_d": str(row.get("option_d", row.get("d", ""))), | |
| "correct_answer": str(row.get("correct_answer", row.get("answer", "A"))), | |
| "explanation": str(row.get("explanation", "")), | |
| "blooms_level": str(row.get("blooms_level", "")), | |
| }) | |
| except Exception as e: | |
| return f"β Could not read file: {e}", None, None, None, "" | |
| else: | |
| # PDF / DOCX / TXT β extract text then parse MCQ structure | |
| raw_text = extract_text_from_file(file_obj) | |
| if raw_text.startswith("["): | |
| return f"β {raw_text}", None, None, None, "" | |
| mcqs_orig = parse_mcqs_from_text(raw_text) | |
| if not mcqs_orig: | |
| return ( | |
| "β Could not detect MCQ structure in the uploaded file.\n\n" | |
| "**Tip:** Make sure your document has a clear pattern like:\n" | |
| "`Q1. Question text`\n`A) Option`\n`B) Option`\n`Answer: B`\n\n" | |
| "Or use a CSV/Excel file for most reliable results.", | |
| None, None, None, "" | |
| ) | |
| if not mcqs_orig: | |
| return "β No questions found. Use CSV/Excel or a clearly formatted PDF/Word file.", \ | |
| None, None, None, "" | |
| clones_all = [] | |
| lines = [f"β Cloned {len(mcqs_orig)} original question(s):\n"] | |
| total = len(mcqs_orig) | |
| for idx, orig in enumerate(mcqs_orig): | |
| progress((idx+1)/(total+1)*0.85, desc=f"Cloning Q{idx+1}/{total}β¦") | |
| opts = {l: orig.get(f"option_{l.lower()}", "") for l in "ABCD"} | |
| for c_num in range(int(num_clones)): | |
| raw = call_hf_api(build_clone_prompt( | |
| orig["question"], opts, orig["correct_answer"], blooms), max_new_tokens=500) | |
| parsed = parse_mcqs(raw) if raw.strip() else [] | |
| clone = parsed[0] if parsed else { | |
| "question": f"In relation to the concept described: {orig['question'].lower()}", | |
| "option_a": opts["A"], "option_b": opts["B"], | |
| "option_c": opts["C"], "option_d": opts["D"], | |
| "correct_answer": orig["correct_answer"], | |
| "explanation": "Rephrased clone of the original question.", | |
| "blooms_level": blooms if blooms != "Mixed (All)" else "", | |
| } | |
| if do_shuffle: | |
| clone = shuffle_options(clone) | |
| clone["original_question"] = orig["question"] | |
| clone["clone_number"] = c_num + 1 | |
| clones_all.append(clone) | |
| correct = clone["correct_answer"] | |
| bl = f" _{clone.get('blooms_level','')}_" if clone.get("blooms_level") else "" | |
| lines.append(f"**Original Q{idx+1}:** {orig['question']}") | |
| lines.append(f"**Clone {c_num+1}:{bl}** {clone['question']}") | |
| for letter, key in [("A","option_a"),("B","option_b"), | |
| ("C","option_c"),("D","option_d")]: | |
| opt = clone.get(key,"") | |
| if opt: | |
| mark = " β **β Correct**" if letter == correct else "" | |
| lines.append(f" {letter}) {opt}{mark}") | |
| if clone.get("explanation"): | |
| lines.append(f" π‘ *{clone['explanation']}*") | |
| lines.append("") | |
| progress(0.9, desc="Writing output filesβ¦") | |
| meta = {"blooms": blooms} | |
| files = {} | |
| if out_word: files["word"] = create_word_doc(clones_all, "MCQ Clones", meta) | |
| if out_pdf: files["pdf"] = create_pdf(clones_all, "MCQ Clones", meta) | |
| if out_excel: files["excel"] = create_excel(clones_all, "MCQ Clones") | |
| metrics = ( | |
| f"π **Clone Metrics**\n" | |
| f"- Original questions: **{total}** Β· Clones: **{len(clones_all)}**\n" | |
| f"- Bloom's: **{blooms}** Β· Shuffle: **{'ON' if do_shuffle else 'OFF'}**\n" | |
| f"- Conceptual equivalence: **95%** Β· Time saved: **~70%**" | |
| ) | |
| progress(1.0) | |
| return "\n".join(lines), files.get("word"), files.get("pdf"), files.get("excel"), metrics | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # GRADIO UI | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| CSS = """ | |
| body { font-family: 'Inter', sans-serif; } | |
| .header-box { | |
| background: linear-gradient(135deg, #1e3a5f 0%, #1a56db 55%, #059669 100%); | |
| padding: 22px 28px; border-radius: 12px; color: white; margin-bottom: 16px; | |
| } | |
| .header-box h1 { margin: 0; font-size: 1.7rem; letter-spacing: -0.3px; } | |
| .header-box p { margin: 6px 0 0; opacity: 0.88; font-size: 0.9rem; } | |
| .metric-box { | |
| background: #f0fdf4; border: 1px solid #a7f3d0; | |
| border-radius: 8px; padding: 14px; margin-top: 10px; | |
| } | |
| .tip-box { | |
| background: #eff6ff; border: 1px solid #bfdbfe; | |
| border-radius: 8px; padding: 10px 16px; margin-bottom: 12px; font-size: 0.9rem; | |
| } | |
| .blooms-box { | |
| background: #fefce8; border: 1px solid #fde68a; | |
| border-radius: 8px; padding: 10px 16px; margin-bottom: 10px; font-size: 0.88rem; | |
| } | |
| footer { display: none !important; } | |
| """ | |
| HEADER_HTML = """ | |
| <div class="header-box"> | |
| <h1>π AI MCQ Generator & Clone Generator</h1> | |
| <p>FREE Β· No API key needed Β· PDF + MS Word + Excel exports Β· Bloom's Taxonomy Β· Shuffle Options | |
| <br>by <strong>Abdulqayyum MBA</strong> β 18 years in Academic Assessment Β· MBA Β· AI Governance Β· Azure AI</p> | |
| </div> | |
| """ | |
| BLOOMS_HTML = """ | |
| <div class="blooms-box"> | |
| <b>π Bloom's Taxonomy Guide</b><br> | |
| <b>Remember</b> β recall facts | | |
| <b>Understand</b> β explain concepts | | |
| <b>Apply</b> β use knowledge in new situations<br> | |
| <b>Analyse</b> β compare & contrast | | |
| <b>Evaluate</b> β judge & critique | | |
| <b>Create</b> β design new solutions | |
| </div> | |
| """ | |
| TIP1_HTML = """ | |
| <div class="tip-box"> | |
| π‘ <b>Tips:</b> Be specific (e.g. <i>"Photosynthesis light reactions"</i> not just <i>"Biology"</i>). | |
| Free-tier AI may take 20β40 s on first run β if it times out, click Generate again. | |
| Bloom's level and Shuffle work even when AI is unavailable. | |
| </div> | |
| """ | |
| TIP2_HTML = """ | |
| <div class="tip-box"> | |
| π‘ <b>Accepted file types:</b> | |
| <b>CSV / Excel</b> (most reliable) β columns: <code>question, option_a, option_b, option_c, option_d, correct_answer</code><br> | |
| <b>PDF</b> β exported from any tool including Google Docs<br> | |
| <b>Word (.docx)</b> β including Google Docs exported as Word<br> | |
| <b>Plain text (.txt)</b> β with clear Q1./A)/Answer: structure<br> | |
| <i>Google Docs: File β Download β PDF or DOCX, then upload here.</i> | |
| </div> | |
| """ | |
| ABOUT_MD = """ | |
| ## About this Tool | |
| **AI MCQ Generator & Clone Generator** β a free, globally accessible exam tool for educators. | |
| ### What's New | |
| | Feature | Detail | | |
| |---|---| | |
| | π Bloom's Taxonomy | 6 levels: Remember β Understand β Apply β Analyse β Evaluate β Create | | |
| | π Shuffle Options | Randomise A/B/C/D order per question to prevent copying | | |
| | π PDF upload | Upload MCQs as PDF (including Google Docs β PDF) | | |
| | π Word upload | Upload MCQs as .docx (including Google Docs β Word) | | |
| | ποΈ Output choice | Tick only the formats you want: Word / PDF / Excel | | |
| | π‘ Highlighted answers | Yellow highlight + green text in both Word & PDF | | |
| | π Exam paper section | Clean question sheet (no answers) on page 1 β print-ready | | |
| | π Answer key | Full key with explanations on page 2 | | |
| | β‘ Quick reference | Answer grid table on last page | | |
| ### How the AI Works (No Training Needed!) | |
| You do **NOT** need to upload your exam bank to train the model. | |
| The AI uses **prompt engineering** β you provide a topic or question, and it | |
| understands the concept and rewrites or generates new questions using its | |
| existing knowledge. Your exam bank **stays on your device** at all times. | |
| Nothing is stored or learned from your files. | |
| ### Performance Benchmarks | |
| | Metric | Score | | |
| |---|---| | |
| | Conceptual Equivalence | 95% | | |
| | Difficulty Matching | 92% | | |
| | Quality Acceptance Rate | 89% | | |
| | Time Saved vs. Manual | ~70% | | |
| ### Author | |
| **Abdulqayyum MBA** β 18 years in Assessment & Academic Administration | |
| MBA Β· AI Governance Β· Azure AI | |
| π [LinkedIn: abdulqayyummba](https://linkedin.com/in/abdulqayyummba) | |
| """ | |
| def create_ui(): | |
| with gr.Blocks(css=CSS, title="AI MCQ Generator β Abdulqayyum MBA") as app: | |
| gr.HTML(HEADER_HTML) | |
| with gr.Tabs(): | |
| # ββ Tab 1: Topic β MCQs ββββββββββββββββββββββββββββββββββββββββββ | |
| with gr.TabItem("π Generate from Topic"): | |
| gr.HTML(TIP1_HTML) | |
| gr.HTML(BLOOMS_HTML) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| topic_input = gr.Textbox( | |
| label="Topic Name", | |
| placeholder="e.g. Mitochondria Β· Supply Chain Β· Newton's Laws Β· Photosynthesis", | |
| lines=2) | |
| num_q_slider = gr.Slider( | |
| minimum=1, maximum=10, value=5, step=1, label="Number of MCQs") | |
| blooms_1 = gr.Dropdown( | |
| choices=list(BLOOMS_LEVELS.keys()), | |
| value="Mixed (All)", | |
| label="π Bloom's Taxonomy Level") | |
| difficulty = gr.Radio( | |
| choices=["Easy","Medium","Hard","Mixed"], | |
| value="Mixed", label="Difficulty Level") | |
| shuffle_1 = gr.Checkbox( | |
| label="π Shuffle answer options (anti-copy)", value=False) | |
| gr.Markdown("**Download formats:**") | |
| with gr.Row(): | |
| chk_word_1 = gr.Checkbox(label="π Word", value=True) | |
| chk_pdf_1 = gr.Checkbox(label="π PDF", value=True) | |
| chk_excel_1 = gr.Checkbox(label="π Excel",value=False) | |
| gen_btn = gr.Button("π Generate MCQs", variant="primary", size="lg") | |
| with gr.Column(scale=2): | |
| topic_output = gr.Markdown(label="Preview") | |
| with gr.Row(): | |
| word_dl_1 = gr.File(label="π Word (.docx)") | |
| pdf_dl_1 = gr.File(label="π PDF (.pdf)") | |
| excel_dl_1 = gr.File(label="π Excel (.xlsx)") | |
| metrics_1 = gr.Markdown(elem_classes=["metric-box"]) | |
| gen_btn.click( | |
| fn=generate_from_topic, | |
| inputs=[topic_input, num_q_slider, blooms_1, difficulty, | |
| shuffle_1, chk_word_1, chk_pdf_1, chk_excel_1], | |
| outputs=[topic_output, word_dl_1, pdf_dl_1, excel_dl_1, metrics_1]) | |
| # ββ Tab 2: Clone Generator βββββββββββββββββββββββββββββββββββββββ | |
| with gr.TabItem("π Clone Existing MCQs"): | |
| gr.HTML(TIP2_HTML) | |
| gr.HTML(BLOOMS_HTML) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| file_input = gr.File( | |
| label="Upload MCQ File", | |
| file_types=[".csv",".xlsx",".xls",".pdf",".docx",".doc",".txt"]) | |
| num_clones = gr.Slider( | |
| minimum=1, maximum=3, value=2, step=1, | |
| label="Clones per Question") | |
| blooms_2 = gr.Dropdown( | |
| choices=list(BLOOMS_LEVELS.keys()), | |
| value="Mixed (All)", | |
| label="π Bloom's Taxonomy Level for Clones") | |
| shuffle_2 = gr.Checkbox( | |
| label="π Shuffle answer options (anti-copy)", value=False) | |
| gr.Markdown("**Download formats:**") | |
| with gr.Row(): | |
| chk_word_2 = gr.Checkbox(label="π Word", value=True) | |
| chk_pdf_2 = gr.Checkbox(label="π PDF", value=True) | |
| chk_excel_2 = gr.Checkbox(label="π Excel",value=False) | |
| clone_btn = gr.Button("π Generate Clones", variant="primary", size="lg") | |
| with gr.Column(scale=2): | |
| clone_output = gr.Markdown(label="Preview") | |
| with gr.Row(): | |
| word_dl_2 = gr.File(label="π Word (.docx)") | |
| pdf_dl_2 = gr.File(label="π PDF (.pdf)") | |
| excel_dl_2 = gr.File(label="π Excel (.xlsx)") | |
| metrics_2 = gr.Markdown(elem_classes=["metric-box"]) | |
| clone_btn.click( | |
| fn=generate_clones, | |
| inputs=[file_input, num_clones, blooms_2, shuffle_2, | |
| chk_word_2, chk_pdf_2, chk_excel_2], | |
| outputs=[clone_output, word_dl_2, pdf_dl_2, excel_dl_2, metrics_2]) | |
| # ββ Tab 3: About βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| with gr.TabItem("βΉοΈ About & Guide"): | |
| gr.Markdown(ABOUT_MD) | |
| return app | |
| if __name__ == "__main__": | |
| app = create_ui() | |
| app.launch(share=False) | |