""" EduClone AI — MCQ Engine v4 Tab 1 : Topic → AI generates fresh MCQs (4 options A B C D) Tab 2 : Upload existing MCQs → detect answer from formatting → clone with 4 distractors Answer detection priority (no auto-default if none found): 1. Bold option text in .docx 2. Underlined option text in .docx 3. Coloured (non-black) option text in .docx 4. Highlighted (background) option text in .docx 5. "Answer: X" / "Key: X" line in plain text If none of the above → correct_answer = None (unknown) """ import re, tempfile, datetime, random, requests, time import openpyxl from openpyxl.styles import Font, PatternFill, Alignment from openpyxl.utils import get_column_letter 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 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_JUSTIFY try: import pdfplumber; HAS_PDF = True except ImportError: HAS_PDF = False # ── colours ─────────────────────────────────────────────────────────────────── DARK_BLUE = HexColor("#1e3a5f"); BLUE = HexColor("#1a56db") GREEN = HexColor("#059669"); GRAY = HexColor("#6b7280") YELLOW_BG = HexColor("#fef9c3") # ── free HF inference models ─────────────────────────────────────────────────── MODELS = ["microsoft/phi-2","google/gemma-2b", "HuggingFaceTB/SmolLM-135M","bigscience/bloom-560m"] HF_BASE = "https://api-inference.huggingface.co/models/" # ══════════════════════════════════════════════════════════════════════════════ # FREE HF API CALL # ══════════════════════════════════════════════════════════════════════════════ def call_hf(prompt: str, max_tokens: int = 900) -> str: payload = {"inputs": prompt, "parameters": {"max_new_tokens": max_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: d = r.json() if isinstance(d, list) and d: t = d[0].get("generated_text", "").strip() if t: return t elif r.status_code == 503: time.sleep(5) except Exception: time.sleep(2) return "" # ══════════════════════════════════════════════════════════════════════════════ # TAB 1 — GENERATE MCQs FROM TOPIC # ══════════════════════════════════════════════════════════════════════════════ BLOOMS = { "Mixed (All)": "Use a variety of cognitive levels.", "Remember": "Use verbs: recall, identify, list, name, define.", "Understand": "Use verbs: explain, describe, summarise, classify.", "Apply": "Use verbs: use, solve, demonstrate, apply.", "Analyse": "Use verbs: compare, contrast, distinguish, examine.", "Evaluate": "Use verbs: judge, assess, critique, justify.", "Create": "Use verbs: design, construct, formulate, propose.", } def _topic_prompt(topic: str, num_q: int, blooms: str, difficulty: str) -> str: bl = BLOOMS.get(blooms, BLOOMS["Mixed (All)"]) df = f"Difficulty level: {difficulty}." if difficulty != "Mixed" else "" return ( f'Generate {num_q} multiple choice questions about "{topic}".\n' f'Bloom\'s level: {blooms}. {bl}\n{df}\n\n' "STRICT FORMAT — use exactly this for every question:\n" "Q1. [Question text]\n" "A. [Option A]\n" "B. [Option B]\n" "C. [Option C]\n" "D. [Option D]\n" "Answer: [Letter]\n" "Explanation: [One sentence]\n\n" f"Generate all {num_q} questions now:" ) def _fallback_mcqs(topic: str, num_q: int, blooms: str) -> list: """Template MCQs when AI is unavailable — always 4 options, answer always provided.""" pool = [ {"question": f"Which statement BEST defines {topic}?", "A": f"The core concept of {topic}", "B": "An unrelated concept", "C": f"A partial aspect of {topic}", "D": "A common misconception", "answer": "A", "blooms": "Remember", "explanation": f"Option A correctly captures the essential definition of {topic}."}, {"question": f"How would you BEST explain {topic} in your own words?", "A": "It is purely theoretical with no applications", "B": f"It describes the core principles and practices of {topic}", "C": "It replaces all prior knowledge in the field", "D": "It contradicts current research", "answer": "B", "blooms": "Understand", "explanation": f"Explaining {topic} in one's own words demonstrates understanding."}, {"question": f"A practitioner applies {topic} to a real scenario. What is MOST appropriate?", "A": "Ignore all contextual factors", "B": f"Apply structured methods derived from {topic}", "C": "Rely entirely on guesswork", "D": "Avoid all established frameworks", "answer": "B", "blooms": "Apply", "explanation": "Applying domain knowledge systematically is an Apply-level skill."}, {"question": f"Comparing {topic} to a related concept, what is the KEY distinction?", "A": "They are completely identical", "B": f"{topic} has no practical dimension", "C": f"{topic} has a distinct focus that differentiates it from similar concepts", "D": "No meaningful comparison exists", "answer": "C", "blooms": "Analyse", "explanation": "Identifying distinctions between concepts is an Analyse-level task."}, {"question": f"What is the MOST significant limitation of {topic}?", "A": "It has no limitations whatsoever", "B": "It applies perfectly to every situation", "C": "Its scope may be constrained by contextual or empirical boundaries", "D": "All practitioners agree it is irrelevant", "answer": "C", "blooms": "Evaluate", "explanation": "Judging limitations requires Evaluate-level critical thinking."}, {"question": f"Design a framework integrating {topic} into a new context.", "A": "Copy an existing solution without modification", "B": f"Develop an original approach building on {topic}", "C": "Avoid all established knowledge", "D": "Restrict the solution to a single stakeholder", "answer": "B", "blooms": "Create", "explanation": "Designing a new framework demonstrates Create-level thinking."}, {"question": f"Which resource BEST supports learning {topic}?", "A": "Opinion blogs with no citations", "B": f"Peer-reviewed literature specific to {topic}", "C": "Outdated manuals from unrelated fields", "D": "A textbook from an entirely different discipline", "answer": "B", "blooms": "Evaluate", "explanation": "Peer-reviewed domain-specific sources are the gold standard for learning."}, {"question": f"Which outcome is MOST likely when {topic} is correctly implemented?", "A": "Increased confusion and inefficiency", "B": "No measurable impact on outcomes", "C": "Improved clarity and measurable positive results", "D": "Reduced accuracy in decision-making", "answer": "C", "blooms": "Apply", "explanation": "Correct implementation of evidence-based approaches yields measurable improvement."}, {"question": f"A student studying {topic} would PRIMARILY focus on which area?", "A": "Unrelated administrative procedures", "B": f"Core principles and real-world applications of {topic}", "C": "Abstract mathematics unrelated to the field", "D": "Historical events from unrelated disciplines", "answer": "B", "blooms": "Remember", "explanation": f"Core principles and applications are the foundation of studying {topic}."}, {"question": f"Which approach yields the DEEPEST understanding of {topic}?", "A": "Rote memorisation without context", "B": "Avoiding practical examples entirely", "C": "Combining conceptual understanding with applied practice", "D": "Relying exclusively on textbook theory", "answer": "C", "blooms": "Understand", "explanation": "Combining theory with practice is the most effective learning strategy."}, ] if blooms != "Mixed (All)": matched = [p for p in pool if p["blooms"] == blooms] unmatched = [p for p in pool if p["blooms"] != blooms] pool = (matched * 4 + unmatched) return pool[:min(num_q, len(pool))] def _parse_generated(text: str) -> list: """Parse AI-generated text into structured MCQ list (4 options, answer required).""" 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, re.MULTILINE) b = re.search(r'^B[\.\)]\s*(.+?)(?:\n|$)', block, re.MULTILINE) c = re.search(r'^C[\.\)]\s*(.+?)(?:\n|$)', block, re.MULTILINE) d = re.search(r'^D[\.\)]\s*(.+?)(?:\n|$)', block, re.MULTILINE) an = re.search(r'Answer:\s*([A-Da-d])', block, re.IGNORECASE) ex = re.search(r'Explanation:\s*(.+?)(?:\n|$)', block) bl = re.search(r"Bloom'?s:\s*(\w+)", block, re.IGNORECASE) if q and a and b and c and d and an: mcqs.append({ "question": q.group(1).strip(), "A": a.group(1).strip(), "B": b.group(1).strip(), "C": c.group(1).strip(), "D": d.group(1).strip(), "answer": an.group(1).upper(), "explanation": ex.group(1).strip() if ex else "", "blooms": bl.group(1).title() if bl else "", }) return mcqs def generate_from_topic(topic: str, num_q: int, blooms: str = "Mixed (All)", difficulty: str = "Mixed") -> tuple: """ Returns (mcqs: list, source: str) mcqs dicts have keys: question, A, B, C, D, answer, explanation, blooms """ if not topic.strip(): return [], "no_topic" prompt = _topic_prompt(topic.strip(), int(num_q), blooms, difficulty) raw = call_hf(prompt, max_tokens=950) mcqs = _parse_generated(raw) if raw else [] if not mcqs: mcqs = _fallback_mcqs(topic, int(num_q), blooms) source = "template" else: mcqs = mcqs[:int(num_q)] source = "ai" return mcqs, source # ══════════════════════════════════════════════════════════════════════════════ # TAB 2 — PARSE UPLOADED MCQs + SMART ANSWER DETECTION # ══════════════════════════════════════════════════════════════════════════════ # ── DOCX-aware reader with formatting detection ──────────────────────────────── def _rgb_is_dark(rgb_val) -> bool: """Return True if colour is close to black (not a highlight colour).""" if rgb_val is None: return True try: r, g, b = rgb_val.rgb >> 16, (rgb_val.rgb >> 8) & 0xFF, rgb_val.rgb & 0xFF return (r < 40 and g < 40 and b < 40) # very dark = black except Exception: return True def _run_is_bold(run) -> bool: return bool(run.bold) def _run_is_underline(run) -> bool: return bool(run.underline) def _run_has_colour(run) -> bool: """True if run has non-black, non-auto font colour.""" try: c = run.font.color if c.type is None: return False rgb = c.rgb if rgb is None: return False r, g, b = (rgb >> 16) & 0xFF, (rgb >> 8) & 0xFF, rgb & 0xFF # exclude near-black return not (r < 50 and g < 50 and b < 50) except Exception: return False def _run_has_highlight(run) -> bool: """True if run has any Word highlight (yellow, green, etc.).""" try: rPr = run._r.find(qn('w:rPr')) if rPr is None: return False return rPr.find(qn('w:highlight')) is not None except Exception: return False def _detect_answer_from_paragraph(para) -> bool: """ Returns True if THIS paragraph's runs contain a formatting mark indicating it is the correct answer (bold / underline / colour / highlight). Requires at least 60% of non-whitespace text to carry the mark. """ runs = para.runs if not runs: return False total_chars = sum(len(r.text.strip()) for r in runs) if total_chars == 0: return False bold_chars = sum(len(r.text.strip()) for r in runs if _run_is_bold(r)) ul_chars = sum(len(r.text.strip()) for r in runs if _run_is_underline(r)) col_chars = sum(len(r.text.strip()) for r in runs if _run_has_colour(r)) hl_chars = sum(len(r.text.strip()) for r in runs if _run_has_highlight(r)) threshold = 0.6 return (bold_chars / total_chars >= threshold or ul_chars / total_chars >= threshold or col_chars / total_chars >= threshold or hl_chars / total_chars >= threshold) # ── Plain-text answer key patterns ──────────────────────────────────────────── _ANSWER_RE = re.compile( r'(?:^|\n)\s*(?:answer|key|correct|ans)[\s\:\.\)]*([A-Da-d])\b', re.IGNORECASE) def _detect_answer_plain(block: str) -> str | None: """ Look for explicit answer key in plain text. Returns letter (uppercase) or None. """ m = _ANSWER_RE.search(block) return m.group(1).upper() if m else None # ── Option-line regex ───────────────────────────────────────────────────────── _OPT_RE = re.compile( r'(?m)^[ \t]*(?:\(([A-Da-d])\)|([A-Da-d])[\.\)])\s+') # ── DOCX full parse (with formatting-based answer detection) ────────────────── def _parse_docx(path: str) -> list: """ Parse a .docx file into MCQ list. For each option paragraph, checks formatting to infer the correct answer. Returns list of dicts: question, A, B, C, D, answer (or None), explanation, source """ doc = DocxDocument(path) paras = doc.paragraphs mcqs = [] i = 0 q_num = 0 while i < len(paras): text = paras[i].text.strip() # Detect question start: "1." / "Q1." / "Question 1." qm = re.match(r'^(?:Q(?:uestion\s*)?)(\d+)[\.\)]\s*(.+)', text, re.IGNORECASE) if not qm: i += 1 continue q_num = int(qm.group(1)) q_lines = [qm.group(2).strip()] i += 1 # Accumulate question body until first option line while i < len(paras): t = paras[i].text.strip() if _OPT_RE.match(t): break if re.match(r'^(?:Q(?:uestion\s*)?)?\d+[\.\)]', t): break if t: q_lines.append(t) i += 1 q_body = "\n".join(q_lines).strip() options = {} # letter → text opt_fmt = {} # letter → has_answer_formatting answer = None # Read options A–D (and optionally E for 5-choice, but we want 4) while i < len(paras) and len(options) < 4: t = paras[i].text.strip() om = _OPT_RE.match(t) if not om: break letter = (om.group(1) or om.group(2)).upper() if letter not in "ABCD": i += 1 continue opt_text = t[om.end():].strip() options[letter] = opt_text opt_fmt[letter] = _detect_answer_from_paragraph(paras[i]) i += 1 # Check for explicit answer line immediately after options while i < len(paras): t = paras[i].text.strip() if re.match(r'^(?:Q(?:uestion\s*)?)?\d+[\.\)]', t): break # next question a = _detect_answer_plain(t) if a and a in options: answer = a i += 1 break if re.match(r'^(?:explanation|rationale)', t, re.IGNORECASE): i += 1 break i += 1 # Formatting-based detection (if no explicit key found) if answer is None: for letter in "ABCD": if opt_fmt.get(letter): answer = letter break if q_body and len(options) >= 2: mcqs.append({ "number": q_num, "question": q_body, "A": options.get("A", ""), "B": options.get("B", ""), "C": options.get("C", ""), "D": options.get("D", ""), "answer": answer, # None if not detected "explanation": "", "blooms": "", "source": "docx", }) return mcqs # ── Plain text / PDF parse ───────────────────────────────────────────────────── def _parse_plain(raw: str) -> list: """ Parse MCQs from plain text (txt, pdf-extracted text). Detects answer ONLY from explicit 'Answer: X' / 'Key: X' line. Does NOT infer or default the answer. """ mcqs = [] splits = list(re.finditer( r'(?m)^[ \t]*(?:Q(?:uestion\s*)?\d+[\.\)]|\d+[\.\)])\s+', raw)) if not splits: return [] for idx, sm in enumerate(splits): block = raw[sm.start(): splits[idx+1].start() if idx+1 < len(splits) else len(raw)].strip() nm = re.match(r'(?:Q(?:uestion\s*)?)(\d+)[\.\)]\s*', block) q_num = int(nm.group(1)) if nm else idx + 1 rest = block[nm.end():].strip() if nm else block opts = list(_OPT_RE.finditer(rest)) if not opts: continue q_body = re.sub(r'\n{3,}', '\n\n', rest[:opts[0].start()].strip()) options = {} for j, om in enumerate(opts): letter = (om.group(1) or om.group(2)).upper() o_end = opts[j+1].start() if j+1 < len(opts) else len(rest) opt_txt = rest[om.end():o_end] # Cut at answer line cut = re.search(r'\n[ \t]*(?:answer|key|correct)\s*[:=\.]', opt_txt, re.IGNORECASE) if cut: opt_txt = opt_txt[:cut.start()] options[letter] = re.sub(r'\s+', ' ', opt_txt.strip()) answer = _detect_answer_plain(block) exp_m = re.search(r'(?:explanation|rationale)\s*[:=]\s*(.+)', block, re.IGNORECASE | re.DOTALL) if q_body and options.get("A") and options.get("B"): mcqs.append({ "number": q_num, "question": q_body, "A": options.get("A", ""), "B": options.get("B", ""), "C": options.get("C", ""), "D": options.get("D", ""), "answer": answer, # None if not found "explanation": exp_m.group(1).strip()[:300] if exp_m else "", "blooms": "", "source": "text", }) return mcqs def read_and_parse(file_obj) -> tuple: """ Read uploaded file and return (mcqs, warning_message). """ if not file_obj: return [], "" name = file_obj.name.lower() if name.endswith((".docx", ".doc")): try: mcqs = _parse_docx(file_obj.name) return mcqs, "" except Exception as e: return [], f"DOCX read error: {e}" if name.endswith(".pdf") and HAS_PDF: try: parts = [] with pdfplumber.open(file_obj.name) as pdf: for page in pdf.pages: t = page.extract_text() if t: parts.append(t) return _parse_plain("\n".join(parts)), "" except Exception as e: return [], f"PDF read error: {e}" # txt / csv / fallback try: raw = open(file_obj.name, encoding="utf-8", errors="ignore").read() return _parse_plain(raw), "" except Exception as e: return [], f"File read error: {e}" # ══════════════════════════════════════════════════════════════════════════════ # CLONER — Generate clones with 4 distractors # ══════════════════════════════════════════════════════════════════════════════ def _clone_prompt(orig: dict, blooms: str) -> str: bl = BLOOMS.get(blooms, BLOOMS["Mixed (All)"]) ans_hint = f"The correct answer is option {orig['answer']}." if orig.get("answer") else \ "The correct answer is not specified — choose the most defensible option." return ( "Rewrite the MCQ below. Use DIFFERENT wording but the SAME concept and difficulty.\n" f"Bloom's level: {blooms}. {bl}\n" f"{ans_hint}\n\n" f"Original question: {orig['question']}\n" f"A. {orig['A']}\nB. {orig['B']}\nC. {orig['C']}\nD. {orig['D']}\n\n" "Write ONE clone with EXACTLY 4 options in this format:\n" "Q1. [New question]\n" "A. [Option A]\n" "B. [Option B]\n" "C. [Option C]\n" "D. [Option D]\n" "Answer: [Letter A/B/C/D]\n" "Explanation: [One sentence]" ) def _fallback_clone(orig: dict, clone_num: int) -> dict: """Generate a simple rephrased clone when AI is unavailable.""" prefixes = [ "Considering the scenario above,", "In relation to the clinical context,", "Based on the information provided,", "From the perspective of best practice,", ] prefix = prefixes[clone_num % len(prefixes)] q = orig["question"] # Rephrase: add prefix and slightly modify question new_q = f"{prefix} {q[0].lower()}{q[1:]}" if q else q return { "number": orig.get("number", 1), "question": new_q, "A": orig["A"], "B": orig["B"], "C": orig["C"], "D": orig["D"], "answer": orig.get("answer"), # preserve None if unknown "explanation": "Clone of the original question with rephrased stem.", "blooms": "", "source": "clone_fallback", } def clone_mcqs(originals: list, num_clones: int, blooms: str = "Mixed (All)") -> list: """ For each original MCQ, generate num_clones clones. Each clone has 4 distractors. answer is preserved from original (may be None). """ results = [] for orig in originals: for c in range(int(num_clones)): prompt = _clone_prompt(orig, blooms) raw = call_hf(prompt, max_tokens=500) parsed = _parse_generated(raw) if raw else [] if parsed: clone = parsed[0] # Preserve original answer if AI didn't find one if not clone.get("answer") and orig.get("answer"): clone["answer"] = orig["answer"] else: clone = _fallback_clone(orig, c) clone["original_question"] = orig["question"] clone["clone_num"] = c + 1 clone["number"] = orig.get("number", 1) results.append(clone) return results # ══════════════════════════════════════════════════════════════════════════════ # PREVIEW TEXT # ══════════════════════════════════════════════════════════════════════════════ def preview_mcqs(mcqs: list, title: str = "") -> str: if not mcqs: return "No questions found." lines = [f"### {title}\n"] if title else [] lines.append(f"✅ **{len(mcqs)} question(s)**\n") for m in mcqs: answer = m.get("answer") q_num = m.get("number", "?") bl = f" *[{m['blooms']}]*" if m.get("blooms") else "" orig = m.get("original_question") if orig: lines.append(f"---\n**Original:** {orig}") lines.append(f"**Clone {m.get('clone_num',1)}:{bl}** {m['question']}\n") else: lines.append(f"---\n**Q{q_num}.{bl}** {m['question']}\n") for L in "ABCD": opt = m.get(L, "") if not opt: continue if answer and L == answer: lines.append(f"**{L}.** {opt} ✅ **← CORRECT**") else: lines.append(f"**{L}.** {opt}") if not answer: lines.append("⚠️ *Answer not detected — not marked in output*") if m.get("explanation"): lines.append(f"\n💡 *{m['explanation']}*") lines.append("") return "\n".join(lines) # ══════════════════════════════════════════════════════════════════════════════ # WORD EXPORT # ══════════════════════════════════════════════════════════════════════════════ def _sc(cell, hx): tc=cell._tc; p=tc.get_or_add_tcPr(); s=OxmlElement('w:shd') s.set(qn('w:val'),'clear'); s.set(qn('w:color'),'auto') s.set(qn('w:fill'), hx.lstrip('#')); p.append(s) def _hl(run, color='yellow'): rPr=run._r.get_or_add_rPr(); hl=OxmlElement('w:highlight') hl.set(qn('w:val'), color); rPr.append(hl) def make_word(mcqs: list, title: str) -> str: doc = DocxDocument() for sec in doc.sections: sec.top_margin=Inches(1); sec.bottom_margin=Inches(1) sec.left_margin=Inches(1.2); sec.right_margin=Inches(1) hp = doc.sections[0].header.paragraphs[0] hp.text = f"EduClone AI · {title} · Abdulqayyum MBA" hp.alignment = WD_ALIGN_PARAGRAPH.CENTER for r in hp.runs: r.font.size = Pt(9); r.font.color.rgb = RGBColor(0x6b,0x72,0x80) tp = doc.add_heading(title, level=0); tp.alignment = WD_ALIGN_PARAGRAPH.CENTER for r in tp.runs: r.font.color.rgb = RGBColor(0x1a,0x56,0xdb); r.font.size = Pt(20) sub = doc.add_paragraph(); sub.alignment = WD_ALIGN_PARAGRAPH.CENTER sr = sub.add_run(f"Total: {len(mcqs)} Questions · {datetime.date.today():%d %B %Y} · EduClone AI") sr.font.size=Pt(10); sr.italic=True; sr.font.color.rgb=RGBColor(0x6b,0x72,0x80) doc.add_paragraph() # Section 1 — Exam Paper (no answers) h1 = doc.add_heading("EXAMINATION PAPER", level=1) for r in h1.runs: r.font.color.rgb = RGBColor(0x1e,0x3a,0x5f) ip = doc.add_paragraph("Instructions: Choose the BEST answer. Circle the letter of your choice.") ip.runs[0].italic=True; ip.runs[0].font.size=Pt(10) doc.add_paragraph() for m in mcqs: qp = doc.add_paragraph(); qp.paragraph_format.space_before=Pt(12) qr = qp.add_run(f"Q{m.get('number','')}. ") qr.bold=True; qr.font.size=Pt(12); qr.font.color.rgb=RGBColor(0x1a,0x56,0xdb) if m.get("blooms"): qp.add_run(f"[{m['blooms']}] ").font.color.rgb = RGBColor(0x93,0xc5,0xfd) for i, line in enumerate(m["question"].split("\n")): if not line.strip(): continue if i > 0: qp.add_run().add_break() tr = qp.add_run(line.strip()); tr.bold=True; tr.font.size=Pt(12) for L in "ABCD": opt = m.get(L, "") if not opt: continue op = doc.add_paragraph(); op.paragraph_format.left_indent=Inches(0.45) op.paragraph_format.space_before=Pt(3) lr = op.add_run(f"{L}."); lr.bold=True; lr.font.size=Pt(11) lr.font.color.rgb = RGBColor(0x1e,0x3a,0x5f) op.add_run(f" {opt}").font.size=Pt(11) doc.add_paragraph() # Section 2 — Answer Key (only if answers exist) has_answers = any(m.get("answer") for m in mcqs) if has_answers: 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) doc.add_paragraph("✓ Correct answers highlighted in yellow (green text). " "Questions without a detected answer are marked ⚠️." ).runs[0].italic=True doc.add_paragraph() for m in mcqs: answer = m.get("answer") qp = doc.add_paragraph(); qp.paragraph_format.space_before=Pt(12) qr = qp.add_run(f"Q{m.get('number','')}. ") qr.bold=True; qr.font.size=Pt(12); qr.font.color.rgb=RGBColor(0x1a,0x56,0xdb) for i, line in enumerate(m["question"].split("\n")): if not line.strip(): continue if i > 0: qp.add_run().add_break() tr = qp.add_run(line.strip()); tr.bold=True; tr.font.size=Pt(12) for L in "ABCD": opt = m.get(L, "") if not opt: continue op = doc.add_paragraph() op.paragraph_format.left_indent=Inches(0.45) op.paragraph_format.space_before=Pt(3) if answer and L == answer: tk = op.add_run(" ✓ "); tk.bold=True; tk.font.size=Pt(11) tk.font.color.rgb=RGBColor(0x05,0x96,0x69) lr = op.add_run(f"{L}."); lr.bold=True; lr.font.size=Pt(11) lr.font.color.rgb=RGBColor(0xd9,0x77,0x06); _hl(lr) tr = op.add_run(f" {opt}"); tr.bold=True; tr.font.size=Pt(11) tr.font.color.rgb=RGBColor(0x05,0x96,0x69); _hl(tr) else: lr = op.add_run(f"{L}."); lr.bold=True; lr.font.size=Pt(11) lr.font.color.rgb=RGBColor(0x9c,0xa3,0xaf) op.add_run(f" {opt}").font.size=Pt(11) if not answer: wp = doc.add_paragraph() wp.paragraph_format.left_indent=Inches(0.45) wr = wp.add_run("⚠️ Answer not detected in source document.") wr.font.size=Pt(10); wr.italic=True wr.font.color.rgb=RGBColor(0xd9,0x77,0x06) if m.get("explanation"): ep = doc.add_paragraph() ep.paragraph_format.left_indent=Inches(0.45) ep.paragraph_format.space_before=Pt(4) er = ep.add_run(f"💡 {m['explanation']}") er.font.size=Pt(10); er.italic=True er.font.color.rgb=RGBColor(0x37,0x41,0x51) doc.add_paragraph() # Section 3 — Quick Ref (only for questions with answers) answered = [m for m in mcqs if m.get("answer")] if answered: 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; nr=-(-len(answered)//COLS) tbl=doc.add_table(rows=nr+1, cols=COLS*2); tbl.style="Table Grid" for ci in range(COLS*2): cell=tbl.rows[0].cells[ci]; cell.text="Q #" if ci%2==0 else "Ans" for r in cell.paragraphs[0].runs: r.bold=True; r.font.size=Pt(10); r.font.color.rgb=RGBColor(0xFF,0xFF,0xFF) _sc(cell,"#1a56db") for qi, m in enumerate(answered): ri=qi//COLS+1; cb=(qi%COLS)*2 qc=tbl.rows[ri].cells[cb]; ac=tbl.rows[ri].cells[cb+1] qc.text=str(m.get("number",qi+1)); ac.text=m["answer"] for r in ac.paragraphs[0].runs: r.bold=True; r.font.color.rgb=RGBColor(0x05,0x96,0x69) _sc(ac,"#f0fdf4") doc.add_paragraph() fp = doc.add_paragraph("EduClone AI · Abdulqayyum MBA · 18 yrs Academic Assessment") fp.alignment=WD_ALIGN_PARAGRAPH.CENTER for r in fp.runs: r.font.size=Pt(9); r.italic=True; r.font.color.rgb=RGBColor(0x9c,0xa3,0xaf) path = tempfile.mktemp(suffix=".docx") doc.save(path) return path # ══════════════════════════════════════════════════════════════════════════════ # PDF EXPORT # ══════════════════════════════════════════════════════════════════════════════ def _pstyles(): s=getSampleStyleSheet() s.add(ParagraphStyle("PT",parent=s["Title"],fontSize=20,textColor=DARK_BLUE,alignment=TA_CENTER,fontName="Helvetica-Bold",spaceAfter=4)) s.add(ParagraphStyle("PS",parent=s["Normal"],fontSize=10,textColor=GRAY,alignment=TA_CENTER,fontName="Helvetica-Oblique",spaceAfter=14)) s.add(ParagraphStyle("PH",parent=s["Normal"],fontSize=13,textColor=DARK_BLUE,fontName="Helvetica-Bold",spaceBefore=12,spaceAfter=8)) s.add(ParagraphStyle("PAH",parent=s["Normal"],fontSize=13,textColor=GREEN,fontName="Helvetica-Bold",spaceBefore=12,spaceAfter=8)) s.add(ParagraphStyle("PQN",parent=s["Normal"],fontSize=12,textColor=BLUE,fontName="Helvetica-Bold",spaceBefore=10,spaceAfter=2)) s.add(ParagraphStyle("PQB",parent=s["Normal"],fontSize=11,textColor=black,fontName="Helvetica-Bold",spaceAfter=3,leading=16,alignment=TA_JUSTIFY)) s.add(ParagraphStyle("PO",parent=s["Normal"],fontSize=11,textColor=HexColor("#374151"),leftIndent=18,fontName="Helvetica",spaceAfter=3,leading=15)) s.add(ParagraphStyle("POK",parent=s["Normal"],fontSize=11,textColor=GREEN,leftIndent=18,fontName="Helvetica-Bold",spaceAfter=3,leading=15)) s.add(ParagraphStyle("PX",parent=s["Normal"],fontSize=10,textColor=HexColor("#374151"),leftIndent=18,fontName="Helvetica-Oblique",spaceBefore=4,spaceAfter=8)) s.add(ParagraphStyle("PI",parent=s["Normal"],fontSize=10,textColor=GRAY,fontName="Helvetica-Oblique",spaceAfter=12)) s.add(ParagraphStyle("PW",parent=s["Normal"],fontSize=10,textColor=HexColor("#d97706"),leftIndent=18,fontName="Helvetica-Oblique",spaceAfter=8)) s.add(ParagraphStyle("PF",parent=s["Normal"],fontSize=8.5,textColor=GRAY,alignment=TA_CENTER,fontName="Helvetica-Oblique")) return s def _pdeco(c, d, title): c.saveState(); w,h=A4 c.setFillColor(DARK_BLUE); c.rect(0,h-32,w,32,fill=1,stroke=0) c.setFillColor(white); c.setFont("Helvetica-Bold",9) c.drawString(1.5*cm,h-20,f"EduClone AI · {title}") c.setFont("Helvetica",9) c.drawRightString(w-1.5*cm,h-20,f"Abdulqayyum MBA · {datetime.date.today():%d %b %Y}") c.setFillColor(DARK_BLUE); c.rect(0,0,w,20,fill=1,stroke=0) c.setFillColor(white); c.setFont("Helvetica",8) c.drawCentredString(w/2,6,f"Page {d.page}"); c.restoreState() def make_pdf(mcqs: list, title: str) -> 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 = _pstyles(); story = [] on_p = lambda c,d: _pdeco(c,d,title) story += [Spacer(1,0.5*cm), Paragraph(title, s["PT"]), Paragraph(f"Questions: {len(mcqs)} · {datetime.date.today():%d %B %Y} · EduClone AI", s["PS"]), HRFlowable(width="100%",thickness=1.5,color=BLUE,spaceAfter=14)] # Exam paper story.append(Paragraph("EXAMINATION PAPER", s["PH"])) story.append(Paragraph("Instructions: Choose the BEST answer. Circle the letter of your choice.", s["PI"])) story.append(Spacer(1,0.3*cm)) for m in mcqs: bl = [Paragraph(f"Q{m.get('number','')}.", s["PQN"])] for line in m["question"].split("\n"): if line.strip(): bl.append(Paragraph(line.strip(), s["PQB"])) bl.append(Spacer(1,0.12*cm)) for L in "ABCD": opt = m.get(L,"") if opt: bl.append(Paragraph(f"{L}. {opt}", s["PO"])) bl.append(Spacer(1,0.3*cm)) story.append(KeepTogether(bl)) # Answer key has_answers = any(m.get("answer") for m in mcqs) if has_answers: story.append(PageBreak()) story.append(Paragraph("ANSWER KEY & EXPLANATIONS", s["PAH"])) story.append(Paragraph("✓ Correct answers highlighted in yellow with green text. " "⚠️ = answer not detected in source.", s["PI"])) story.append(Spacer(1,0.3*cm)) for m in mcqs: answer = m.get("answer") bl = [Paragraph(f"Q{m.get('number','')}.", s["PQN"])] for line in m["question"].split("\n"): if line.strip(): bl.append(Paragraph(line.strip(), s["PQB"])) bl.append(Spacer(1,0.1*cm)) for L in "ABCD": opt = m.get(L,"") if not opt: continue if answer and L == answer: t = Table([[Paragraph( f'✓ {L}. {opt}', s["POK"])]], colWidths=["100%"]) t.setStyle(TableStyle([("BACKGROUND",(0,0),(-1,-1),YELLOW_BG), ("ROWPADDING",(0,0),(-1,-1),5), ("LEFTPADDING",(0,0),(-1,-1),18)])) bl.append(t) else: bl.append(Paragraph(f'{L}. {opt}', s["PO"])) if not answer: bl.append(Paragraph("⚠️ Answer not detected in source document.", s["PW"])) if m.get("explanation"): bl.append(Paragraph(f'💡 {m["explanation"]}', s["PX"])) bl.append(Spacer(1,0.28*cm)) story.append(KeepTogether(bl)) # Quick ref answered = [m for m in mcqs if m.get("answer")] if answered: story.append(PageBreak()) story.append(Paragraph("QUICK ANSWER REFERENCE", s["PH"])) story.append(Spacer(1,0.3*cm)) COLS=5; td=[["Q","Ans"]*COLS]; row=[] for m in answered: row += [str(m.get("number","?")), m["answer"]] if len(row)==COLS*2: td.append(row); row=[] if row: while len(row) 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","Answer Source"] 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) wfill=PatternFill("solid",fgColor="FEF9C3") # yellow for unknown afill=PatternFill("solid",fgColor="EFF6FF") for i,m in enumerate(mcqs,1): row=i+1 answer=m.get("answer") or "" src =m.get("source","") data=[m.get("number",i), m["question"], m.get("A",""), m.get("B",""), m.get("C",""), m.get("D",""), answer, m.get("blooms",""), m.get("explanation",""), src] 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 ac=ws.cell(row=row,column=7) if answer: ac.fill=gfill; ac.font=gfont else: ac.fill=wfill ac.value="⚠️ Unknown" ac.alignment=Alignment(horizontal="center",vertical="center") widths=[5,60,28,28,28,28,14,14,50,14] 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