Spaces:
Sleeping
Sleeping
| """ | |
| 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://huggingface.co/proxy/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"<b>{L}.</b> {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'<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.",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)) | |
| 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 | |
| def make_excel(mcqs, title): | |
| 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","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"); 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 | |