EduClone-AI / mcq_engine.py
Universities's picture
Upload mcq_engine.py
df52332 verified
Raw
History Blame Contribute Delete
49 kB
"""
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 &amp; 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