Spaces:
Running
Running
| """ | |
| 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"<b>{L}.</b> {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'<font color="#059669"><b>β {L}. {opt}</b></font>', 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'<font color="#9ca3af"><b>{L}.</b> {opt}</font>', s["PO"])) | |
| if not answer: | |
| bl.append(Paragraph("β οΈ Answer not detected in source document.", s["PW"])) | |
| if m.get("explanation"): | |
| bl.append(Paragraph(f'<i>π‘ {m["explanation"]}</i>', 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)<COLS*2: row+=["",""] | |
| td.append(row) | |
| rt = Table(td, colWidths=[1.1*cm,1.1*cm]*COLS, hAlign="LEFT") | |
| rt.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 += [rt, Spacer(1,1*cm), | |
| HRFlowable(width="100%",thickness=1,color=BLUE), | |
| Paragraph("EduClone AI Β· Abdulqayyum MBA Β· 18 yrs Academic Assessment", s["PF"])] | |
| doc.build(story, onFirstPage=on_p, onLaterPages=on_p) | |
| return path | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # EXCEL EXPORT | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def make_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","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 | |