""" EduClone AI — MCQ Engine v6 Universal parser — NO API key required. HuggingFace free inference (public models) used for generation & AI fallback. Parse cascade (tried in order, first success wins): 1. Indent-bullet .docx question=low-indent bullet, options=high-indent bullet 2. Classic prefix .docx Q1. / A. / B. style 3. Flexible plain-text handles: Q1. Q1- 1. 1- + A. A) A- a. a) a- 4. Paragraph-block .docx question=bold/regular para, options=next 4 short paras 5. Any-bullet grouping groups bullet lists: every 5 consecutive bullets = 1Q+4opts 6. HF AI fallback free HF inference, no key, Mistral-7B → Zephyr → Flan-T5 Answer detection (all parsers): bold / underline / colour / highlight run → letter from formatting "Answer: X" / "Key: X" explicit line → letter from text Nothing → None (never guessed) """ import re, tempfile, datetime, json, time, requests 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 DARK_BLUE = HexColor("#1e3a5f"); BLUE = HexColor("#1a56db") GREEN = HexColor("#059669"); GRAY = HexColor("#6b7280") YELLOW_BG = HexColor("#fef9c3") # ── HF free models (no key needed) ─────────────────────────────────────────── HF_BASE = "https://api-inference.huggingface.co/models/" HF_GEN = ["mistralai/Mistral-7B-Instruct-v0.3", "HuggingFaceH4/zephyr-7b-beta", "microsoft/phi-2", "google/flan-t5-large"] HF_EXTRACT = ["mistralai/Mistral-7B-Instruct-v0.3", "HuggingFaceH4/zephyr-7b-beta", "google/flan-t5-large"] # ── Bloom's ─────────────────────────────────────────────────────────────────── 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.", } # ══════════════════════════════════════════════════════════════════════════════ # HF API (free, no key) # ══════════════════════════════════════════════════════════════════════════════ def _hf_call(prompt: str, max_tokens: int = 900, models=None) -> str: if models is None: models = HF_GEN payload = {"inputs": prompt, "parameters": {"max_new_tokens": max_tokens, "temperature": 0.7, "do_sample": True, "return_full_text": False}, "options": {"wait_for_model": True, "use_cache": False}} 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 in (503, 429): time.sleep(8) except Exception: time.sleep(2) return "" def _hf_extract_mcqs(raw_text: str) -> list: """Free HF AI extraction — last resort, no key required.""" if not raw_text.strip(): return [] chunk = raw_text[:4000] prompt = ( "[INST] Extract all MCQs from the text. " "Return ONLY a JSON array, no markdown, no explanation:\n" '[{"question":"...","A":"...","B":"...","C":"...","D":"...","answer":null}]\n\n' f"TEXT:\n{chunk}\n[/INST]" ) raw = _hf_call(prompt, max_tokens=1500, models=HF_EXTRACT) if not raw: return [] m = re.search(r'\[.*\]', raw, re.DOTALL) if not m: return [] try: items = json.loads(m.group()) result = [] for i, item in enumerate(items): if not isinstance(item, dict) or not item.get("question"): continue result.append({ "number": i + 1, "question": str(item.get("question","")).strip(), "A": str(item.get("A","")).strip(), "B": str(item.get("B","")).strip(), "C": str(item.get("C","")).strip(), "D": str(item.get("D","")).strip(), "answer": item.get("answer") or None, "explanation": "", "blooms": "", "source": "hf_ai", }) return result except Exception: return [] # ══════════════════════════════════════════════════════════════════════════════ # FORMATTING HELPERS (for .docx run-level answer detection) # ══════════════════════════════════════════════════════════════════════════════ def _run_is_bold(r): return bool(r.bold) def _run_is_underline(r): return bool(r.underline) def _run_has_colour(r): try: c = r.font.color if c.type is None: return False rgb = c.rgb if rgb is None: return False rv, gv, bv = (rgb >> 16) & 0xFF, (rgb >> 8) & 0xFF, rgb & 0xFF return not (rv < 50 and gv < 50 and bv < 50) except Exception: return False def _run_has_highlight(r): try: rPr = r._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 _para_has_answer_fmt(para) -> bool: """True if ≥60% of non-whitespace chars in para carry bold/ul/colour/hl.""" runs = para.runs if not runs: return False total = sum(len(r.text.strip()) for r in runs) if total == 0: return False bold = sum(len(r.text.strip()) for r in runs if _run_is_bold(r)) ul = sum(len(r.text.strip()) for r in runs if _run_is_underline(r)) col = sum(len(r.text.strip()) for r in runs if _run_has_colour(r)) hl = sum(len(r.text.strip()) for r in runs if _run_has_highlight(r)) return any(x / total >= 0.6 for x in [bold, ul, col, hl]) _ANSWER_KEY_RE = re.compile( r'(?:^|\n)\s*(?:answer|key|correct|ans)\s*[:.\)]\s*([A-Da-d])\b', re.IGNORECASE) def _answer_from_text(txt: str): m = _ANSWER_KEY_RE.search(txt) return m.group(1).upper() if m else None # Flexible option line: A. A) A- a. a) a- (A) _OPT_FLEX = re.compile( r'^[ \t]*(?:\(([A-Da-d])\)|([A-Da-d])[.\)\-][ \t]|([a-d])[.\)\-][ \t])', re.MULTILINE | re.IGNORECASE) # Flexible question start: Q1. Q1) Q1- 1. 1) 1- _Q_FLEX = re.compile( r'(?m)^[ \t]*(?:Q(?:uestion\s*)?)?(\d+)[.\)\-][ \t]') _SKIP_RE = re.compile( r'^(Page\s+\d|\d+\s+of\s+\d|Assessment Unit|COAMS|Riyadh|EduClone|http)', re.IGNORECASE) def _para_indent(p) -> int: ind = p.paragraph_format.left_indent return int(ind) if ind else 0 def _para_is_list(p) -> bool: pPr = p._p.find(qn('w:pPr')) if pPr is None: return False return pPr.find(qn('w:numPr')) is not None def _make_mcq(number, question, opts_text, opts_para, extra_text="", source=""): """Build MCQ dict from collected options.""" answer = None # 1. Check formatting on option paragraphs if opts_para: for letter, para in zip("ABCD", opts_para): if para and _para_has_answer_fmt(para): answer = letter break # 2. Check explicit answer key in trailing text if answer is None and extra_text: answer = _answer_from_text(extra_text) opts = list(opts_text) + [""] * 4 return { "number": number, "question": question.strip(), "A": opts[0], "B": opts[1], "C": opts[2], "D": opts[3], "answer": answer, "explanation": "", "blooms": "", "source": source, } # ══════════════════════════════════════════════════════════════════════════════ # PARSER 1 — INDENT-BULLET .docx # Question bullet = smaller indent, Option bullets = larger indent # ══════════════════════════════════════════════════════════════════════════════ def _parse_indent_bullet(doc) -> list: items = [] for p in doc.paragraphs: txt = p.text.strip() if not txt or _SKIP_RE.match(txt): continue if _para_is_list(p): items.append((p, txt, _para_indent(p))) if len(items) < 3: return [] indents = sorted(set(x[2] for x in items)) if len(indents) < 2: return [] # Split at largest gap gap_idx = max(range(len(indents)-1), key=lambda i: indents[i+1]-indents[i]) q_set = set(indents[:gap_idx+1]) o_set = set(indents[gap_idx+1:]) mcqs, cur_q, cur_opts, cur_paras = [], None, [], [] def flush(): if cur_q and len(cur_opts) >= 2: mcqs.append(_make_mcq(len(mcqs)+1, cur_q, cur_opts, cur_paras, source="indent_bullets")) for para, txt, indent in items: if indent in q_set: flush(); cur_q = txt; cur_opts = []; cur_paras = [] elif indent in o_set and cur_q: cur_opts.append(txt); cur_paras.append(para) flush() return mcqs # ══════════════════════════════════════════════════════════════════════════════ # PARSER 2 — CLASSIC Q#. / A. PREFIX .docx # ══════════════════════════════════════════════════════════════════════════════ def _parse_classic_prefix(doc) -> list: paras = doc.paragraphs mcqs = []; i = 0 while i < len(paras): txt = paras[i].text.strip() qm = re.match(r'^(?:Q(?:uestion\s*)?)?(\d+)[.\)]\s+(.+)', txt, re.IGNORECASE) if not qm: i += 1; continue q_num = int(qm.group(1)) q_lines = [qm.group(2).strip()] i += 1 while i < len(paras): t = paras[i].text.strip() if _OPT_FLEX.match(t): break if _Q_FLEX.match(t): break if t: q_lines.append(t) i += 1 opts_text = []; opts_para = []; answer = None while i < len(paras) and len(opts_text) < 4: t = paras[i].text.strip() om = _OPT_FLEX.match(t) if not om: break letter = (om.group(1) or om.group(2) or om.group(3) or "").upper() if letter not in "ABCD": i += 1; continue opts_text.append(t[om.end():].strip()) opts_para.append(paras[i]); i += 1 # Look for answer key line for _ in range(3): if i >= len(paras): break t = paras[i].text.strip() if _Q_FLEX.match(t): break a = _answer_from_text(t) if a: answer = a; i += 1; break i += 1 # Formatting fallback if answer is None: for ltr, para in zip("ABCD", opts_para): if _para_has_answer_fmt(para): answer = ltr; break if q_lines and opts_text: q = _make_mcq(q_num, "\n".join(q_lines), opts_text, opts_para, source="classic_prefix") q["answer"] = answer mcqs.append(q) return mcqs # ══════════════════════════════════════════════════════════════════════════════ # PARSER 3 — FLEXIBLE PLAIN TEXT # Handles: Q1. Q1- 1. 1) + A. A) A- a. a) a- (and "Answer:" / "Key:") # ══════════════════════════════════════════════════════════════════════════════ def _parse_flexible_text(raw: str) -> list: mcqs = [] # Split on question starts splits = list(_Q_FLEX.finditer(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)] q_num = int(sm.group(1)) # Use local re-match so offset is relative to block, not raw local_m = _Q_FLEX.match(block) rest = block[local_m.end():].strip() if local_m else block[sm.end()-sm.start():].strip() # Find first option line opts_iter = list(_OPT_FLEX.finditer(rest)) if not opts_iter: continue q_body = rest[:opts_iter[0].start()].strip() # Collect options opts_text = [] for j, om in enumerate(opts_iter[:4]): end = opts_iter[j+1].start() if j+1 < len(opts_iter) else len(rest) opt = rest[om.end():end] # Cut at answer/explanation line cut = re.search(r'\n[ \t]*(?:answer|key|correct|explanation)\s*[:.\-]', opt, re.IGNORECASE) if cut: opt = opt[:cut.start()] opts_text.append(re.sub(r'\s+', ' ', opt.strip())) answer = _answer_from_text(block) if q_body and opts_text: mcqs.append(_make_mcq(q_num, q_body, opts_text, None, extra_text=block, source="flexible_text")) return mcqs # ══════════════════════════════════════════════════════════════════════════════ # PARSER 4 — PARAGRAPH BLOCK .docx # For files where questions are plain paragraphs (not list bullets) # Strategy: if a paragraph ends with "?" or is long (>40 chars) and is followed # by 3-5 short paragraphs, those short paras are options. # ══════════════════════════════════════════════════════════════════════════════ def _para_looks_like_question(txt: str) -> bool: txt = txt.strip() if len(txt) < 20: return False if _SKIP_RE.match(txt): return False # Strong signal: ends with ? or contains "which"/"what"/"how"/"where"/"when" if txt.endswith('?'): return True if re.search(r'\b(which|what|how|where|when|who|select|identify|choose)\b', txt, re.IGNORECASE): return True return False def _para_looks_like_option(txt: str) -> bool: txt = txt.strip() if not txt or _SKIP_RE.match(txt): return False # Already caught by _OPT_FLEX if it starts with A. etc. if _OPT_FLEX.match(txt): return True # Short paragraph (likely option text) after a question if len(txt) < 120 and '\n' not in txt: return True return False def _parse_paragraph_blocks(doc) -> list: paras = [p for p in doc.paragraphs if p.text.strip() and not _SKIP_RE.match(p.text.strip())] mcqs = []; i = 0 while i < len(paras): txt = paras[i].text.strip() if not _para_looks_like_question(txt): i += 1; continue # Collect following option paragraphs opts_text = []; opts_para = [] j = i + 1 while j < len(paras) and len(opts_text) < 4: ot = paras[j].text.strip() if _para_looks_like_question(ot) and not _OPT_FLEX.match(ot): break if _para_looks_like_option(ot): # Strip leading A. / a. / (A) prefix if present cleaned = re.sub(r'^[ \t]*(?:\([A-Da-d]\)|[A-Da-d][.\)\-])\s*', '', ot, flags=re.IGNORECASE).strip() opts_text.append(cleaned or ot) opts_para.append(paras[j]) j += 1 if len(opts_text) >= 2: # Check next line for answer extra = paras[j].text if j < len(paras) else "" mcqs.append(_make_mcq(len(mcqs)+1, txt, opts_text, opts_para, extra_text=extra, source="para_blocks")) i = j else: i += 1 return mcqs # ══════════════════════════════════════════════════════════════════════════════ # PARSER 5 — ANY-BULLET GROUPING # Last structural resort: every group of ~5 consecutive bullets = Q + 4 opts # ══════════════════════════════════════════════════════════════════════════════ def _parse_any_bullet_groups(doc) -> list: bullets = [] for p in doc.paragraphs: txt = p.text.strip() if not txt or _SKIP_RE.match(txt): continue if _para_is_list(p): bullets.append((txt, p)) if len(bullets) < 5: return [] # Group: first bullet of each group of 5 = question, next 4 = options mcqs = [] for i in range(0, len(bullets) - 4, 5): q_txt, _ = bullets[i] opts = bullets[i+1:i+5] opts_text = [o[0] for o in opts] opts_para = [o[1] for o in opts] mcqs.append(_make_mcq(len(mcqs)+1, q_txt, opts_text, opts_para, source="bullet_groups")) return mcqs # ══════════════════════════════════════════════════════════════════════════════ # MAIN READER # ══════════════════════════════════════════════════════════════════════════════ def read_and_parse(file_obj) -> tuple: """ Universal MCQ reader. Returns (mcqs, status_message). Tries 5 structural parsers before falling back to free HF AI extraction. No API key required at any step. """ if not file_obj: return [], "" path = file_obj.name name = path.lower() # ── DOCX ───────────────────────────────────────────────────────────────── if name.endswith((".docx", ".doc")): try: doc = DocxDocument(path) except Exception as e: return [], f"❌ Cannot open file: {e}" # Parser 1: indent-bullet (your 417.docx format) mcqs = _parse_indent_bullet(doc) if mcqs: return mcqs, f"✅ **Indent-bullet format** · {len(mcqs)} questions" # Parser 2: classic Q#./A. prefix mcqs = _parse_classic_prefix(doc) if mcqs: return mcqs, f"✅ **Numbered Q#./A. format** · {len(mcqs)} questions" # Parser 3: flexible text extracted from docx body raw = "\n".join(p.text for p in doc.paragraphs if p.text.strip()) mcqs = _parse_flexible_text(raw) if mcqs: return mcqs, f"✅ **Flexible text format** · {len(mcqs)} questions" # Parser 4: paragraph blocks (question para + option paras) mcqs = _parse_paragraph_blocks(doc) if mcqs: return mcqs, f"✅ **Paragraph-block format** · {len(mcqs)} questions" # Parser 5: any-bullet grouping (5-per-group) mcqs = _parse_any_bullet_groups(doc) if mcqs: return mcqs, f"✅ **Bullet-group format** · {len(mcqs)} questions" # HF AI fallback raw = "\n".join(p.text for p in doc.paragraphs if p.text.strip()) if raw.strip(): mcqs = _hf_extract_mcqs(raw) if mcqs: return mcqs, f"⚡ **AI extracted** · {len(mcqs)} questions" return [], ("❌ Could not detect MCQ structure in this file.\n\n" "Supported .docx formats:\n" "- Bullet list (questions at one indent, options deeper)\n" "- Numbered Q1./A./B. prefixes\n" "- Plain paragraphs where each question is followed by 4 short options") # ── PDF ─────────────────────────────────────────────────────────────────── elif name.endswith(".pdf"): if not HAS_PDF: return [], "❌ pdfplumber not installed." try: parts = [] with pdfplumber.open(path) as pdf: for pg in pdf.pages: t = pg.extract_text() if t: parts.append(t) raw = "\n".join(parts) except Exception as e: return [], f"❌ PDF read error: {e}" mcqs = _parse_flexible_text(raw) if mcqs: return mcqs, f"✅ **PDF parsed** · {len(mcqs)} questions" if raw.strip(): mcqs = _hf_extract_mcqs(raw) if mcqs: return mcqs, f"⚡ **AI extracted from PDF** · {len(mcqs)} questions" return [], "❌ Could not detect MCQ structure in PDF." # ── TXT / other ─────────────────────────────────────────────────────────── else: try: raw = open(path, encoding="utf-8", errors="ignore").read() except Exception as e: return [], f"❌ File read error: {e}" mcqs = _parse_flexible_text(raw) if mcqs: return mcqs, f"✅ **Plain-text parsed** · {len(mcqs)} questions" if raw.strip(): mcqs = _hf_extract_mcqs(raw) if mcqs: return mcqs, f"⚡ **AI extracted** · {len(mcqs)} questions" return [], "❌ Could not detect MCQ structure in text file." # ══════════════════════════════════════════════════════════════════════════════ # TAB 1: GENERATE FROM TOPIC # ══════════════════════════════════════════════════════════════════════════════ def _topic_prompt(topic, num_q, blooms, difficulty): bl = BLOOMS.get(blooms, BLOOMS["Mixed (All)"]) df = f"Difficulty: {difficulty}." if difficulty != "Mixed" else "" return ( f"[INST] Generate {num_q} MCQs about \"{topic}\". " f"Bloom's: {blooms}. {bl} {df}\n" "Use EXACTLY this format per question:\n" "Q1. [question]\nA. [opt]\nB. [opt]\nC. [opt]\nD. [opt]\n" "Answer: [A/B/C/D]\nExplanation: [one sentence]\n\n" f"Generate all {num_q} questions: [/INST]" ) def _parse_generated(text): mcqs = [] for block in re.split(r'\n(?=Q\d+\.)', text.strip()): 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) 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": "", }) return mcqs def _fallback_mcqs(topic, num_q, blooms): 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", "B": f"It describes core principles of {topic}", "C": "It replaces all prior knowledge", "D": "It contradicts current research", "answer": "B", "blooms": "Understand", "explanation": "Explaining in your own words demonstrates understanding."}, {"question": f"A practitioner applies {topic}. What is MOST appropriate?", "A": "Ignore contextual factors", "B": f"Apply structured methods from {topic}", "C": "Rely on guesswork", "D": "Avoid established frameworks", "answer": "B", "blooms": "Apply", "explanation": "Applying domain knowledge systematically is correct."}, {"question": f"What is the KEY distinction of {topic} vs related concepts?", "A": "They are identical", "B": f"{topic} has no practical dimension", "C": f"{topic} has a distinct focus", "D": "No comparison exists", "answer": "C", "blooms": "Analyse", "explanation": "Identifying distinctions is an Analyse-level skill."}, {"question": f"What is the MOST significant limitation of {topic}?", "A": "No limitations", "B": "Perfect for every situation", "C": "Scope constrained by context", "D": "All practitioners agree it is irrelevant", "answer": "C", "blooms": "Evaluate", "explanation": "Judging limitations is Evaluate-level thinking."}, {"question": f"Design a framework integrating {topic}.", "A": "Copy existing solution", "B": f"Develop original approach using {topic}", "C": "Avoid established knowledge", "D": "Restrict to single stakeholder", "answer": "B", "blooms": "Create", "explanation": "Designing a new framework is Create-level thinking."}, {"question": f"Which resource BEST supports learning {topic}?", "A": "Opinion blogs", "B": f"Peer-reviewed literature on {topic}", "C": "Outdated manuals", "D": "Unrelated textbook", "answer": "B", "blooms": "Evaluate", "explanation": "Peer-reviewed sources are the gold standard."}, {"question": f"Which outcome occurs when {topic} is correctly applied?", "A": "Increased confusion", "B": "No measurable impact", "C": "Improved measurable results", "D": "Reduced accuracy", "answer": "C", "blooms": "Apply", "explanation": "Correct implementation yields measurable improvement."}, {"question": f"A student studying {topic} PRIMARILY focuses on?", "A": "Unrelated procedures", "B": f"Core principles of {topic}", "C": "Abstract mathematics", "D": "Historical unrelated events", "answer": "B", "blooms": "Remember", "explanation": f"Core principles are the foundation of {topic}."}, {"question": f"Which approach gives DEEPEST understanding of {topic}?", "A": "Rote memorisation", "B": "Avoiding practical examples", "C": "Combining theory with practice", "D": "Textbook theory only", "answer": "C", "blooms": "Understand", "explanation": "Combining theory and practice is most effective."}, ] if blooms != "Mixed (All)": matched = [p for p in pool if p.get("blooms") == blooms] pool = (matched * 4 + [p for p in pool if p.get("blooms") != blooms]) out = [] for i, p in enumerate(pool[:min(num_q, len(pool))]): p = dict(p); p["number"] = i + 1; out.append(p) return out def generate_from_topic(topic, num_q, blooms="Mixed (All)", difficulty="Mixed"): if not topic.strip(): return [], "no_topic" raw = _hf_call(_topic_prompt(topic.strip(), int(num_q), blooms, difficulty), max_tokens=950, models=HF_GEN) mcqs = _parse_generated(raw) if raw else [] if not mcqs: mcqs = _fallback_mcqs(topic, int(num_q), blooms); source = "template" else: for i, m in enumerate(mcqs[:int(num_q)]): m["number"] = i + 1 mcqs = mcqs[:int(num_q)]; source = "ai" return mcqs, source # ══════════════════════════════════════════════════════════════════════════════ # CLONER # ══════════════════════════════════════════════════════════════════════════════ def _clone_prompt(orig, blooms): bl = BLOOMS.get(blooms, BLOOMS["Mixed (All)"]) ans = (f"Correct answer is {orig['answer']}." if orig.get("answer") else "Correct answer unknown — choose most defensible.") return ( f"[INST] Rewrite this MCQ with different wording, same concept. " f"Bloom's: {blooms}. {bl} {ans}\n\n" f"Original: {orig['question']}\n" f"A. {orig['A']}\nB. {orig['B']}\nC. {orig['C']}\nD. {orig['D']}\n\n" "Return ONE clone:\nQ1. [question]\nA. [opt]\nB. [opt]\nC. [opt]\nD. [opt]\n" "Answer: [A/B/C/D]\nExplanation: [one sentence] [/INST]" ) def _fallback_clone(orig, n): pfx = ["Considering the scenario above,", "In the clinical context described,", "Based on the information provided,", "From best-practice perspective,"][n % 4] q = orig["question"] return {"number": orig.get("number",1), "question": f"{pfx} {q[0].lower()+q[1:]}" if q else q, "A": orig["A"], "B": orig["B"], "C": orig["C"], "D": orig["D"], "answer": orig.get("answer"), "explanation": "Rephrased clone.", "blooms": "", "source": "clone_fallback"} def clone_mcqs(originals, num_clones, blooms="Mixed (All)"): results = [] for orig in originals: for c in range(int(num_clones)): raw = _hf_call(_clone_prompt(orig, blooms), max_tokens=500, models=HF_GEN) parsed = _parse_generated(raw) if raw else [] clone = parsed[0] if parsed else _fallback_clone(orig, c) if not clone.get("answer") and orig.get("answer"): clone["answer"] = orig["answer"] clone.update({"original_question": orig["question"], "clone_num": c+1, "number": orig.get("number",1)}) results.append(clone) return results # ══════════════════════════════════════════════════════════════════════════════ # PREVIEW # ══════════════════════════════════════════════════════════════════════════════ def preview_mcqs(mcqs, title=""): 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 lines.append(f"**{L}.** {opt} ✅ **← CORRECT**" if answer and L==answer else f"**{L}.** {opt}") if not answer: lines.append("⚠️ *Answer not detected*") if m.get("explanation"): lines.append(f"\n💡 *{m['explanation']}*") lines.append("") return "\n".join(lines) # ══════════════════════════════════════════════════════════════════════════════ # EXPORTS (Word / PDF / Excel) — unchanged from v4 # ══════════════════════════════════════════════════════════════════════════════ 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, title): 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() 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.") 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() 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. ⚠️ = not detected.").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.") 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() 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 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, title): 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)] story+=[Paragraph("EXAMINATION PAPER",s["PH"]), Paragraph("Instructions: Choose the BEST answer.",s["PI"]),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)) if any(m.get("answer") for m in mcqs): story.append(PageBreak()) story+=[Paragraph("ANSWER KEY & EXPLANATIONS",s["PAH"]), Paragraph("✓ Correct = highlighted. ⚠️ = not detected.",s["PI"]),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.",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)) 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)