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