Universities commited on
Commit
c549f7c
·
verified ·
1 Parent(s): 7021bb0

Upload 7 files

Browse files
Files changed (7) hide show
  1. Dockerfile +19 -0
  2. README.md +29 -0
  3. app.py +489 -0
  4. auth.py +190 -0
  5. mcq_engine.py +982 -0
  6. python-version +1 -0
  7. requirements.txt +9 -0
Dockerfile ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ RUN apt-get update && apt-get install -y --no-install-recommends \
6
+ gcc \
7
+ && rm -rf /var/lib/apt/lists/*
8
+
9
+ COPY requirements.txt .
10
+ RUN pip install --no-cache-dir -r requirements.txt
11
+
12
+ COPY . .
13
+
14
+ EXPOSE 7860
15
+
16
+ ENV GRADIO_SERVER_NAME="0.0.0.0"
17
+ ENV GRADIO_SERVER_PORT=7860
18
+
19
+ CMD ["python", "app.py"]
README.md ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: EduClone AI Portal
3
+ emoji: 🎓
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ license: mit
10
+ ---
11
+
12
+ # 🎓 EduClone AI — Exam Intelligence Portal
13
+
14
+ ## Default Admin Login
15
+ - **Username:** `abdulqayyum`
16
+ - **Password:** `Admin@EduClone2025`
17
+ > ⚠️ Change `ADMIN_PASSWORD` in `auth.py` before going public.
18
+
19
+ ## Roles
20
+ | Role | Access |
21
+ |---|---|
22
+ | **Admin** | Dashboard · User management · Activity log · Full MCQ tools |
23
+ | **User** | Upload MCQs · Format · Download Word / PDF / Excel |
24
+
25
+ ## Files
26
+ - `Dockerfile` — forces Python 3.11, avoids audioop/pydub conflict
27
+ - `app.py` — Gradio portal
28
+ - `auth.py` — SQLite + bcrypt auth
29
+ - `mcq_engine.py` — MCQ parser and document builder
app.py ADDED
@@ -0,0 +1,489 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ EduClone AI Portal — Login / Register / Admin / User roles
3
+ Tab 1: Generate MCQs from topic (AI, 4 options, answer always provided)
4
+ Tab 2: Upload MCQs → smart answer detection → clone with 4 distractors
5
+ Author: Abdulqayyum MBA
6
+ """
7
+ import gradio as gr
8
+ import pandas as pd
9
+ import datetime
10
+ from auth import (init_db, register_user, login_user, log_activity,
11
+ get_all_users, toggle_user_active, change_user_role,
12
+ delete_user, get_activity_log, get_stats)
13
+ from mcq_engine import (
14
+ generate_from_topic, read_and_parse, clone_mcqs, preview_mcqs,
15
+ make_word, make_pdf, make_excel, BLOOMS
16
+ )
17
+
18
+ init_db()
19
+
20
+ # ── CSS ───────────────────────────────────────────────────────────────────────
21
+ CSS = """
22
+ * { box-sizing: border-box; }
23
+ body { font-family: 'Inter', sans-serif; margin: 0; }
24
+ .portal-banner {
25
+ background: linear-gradient(135deg,#1e3a5f 0%,#1a56db 55%,#059669 100%);
26
+ padding: 20px 28px 18px; border-radius: 14px; color: white; margin-bottom: 18px;
27
+ }
28
+ .portal-banner h1 { margin:0; font-size:1.65rem; letter-spacing:-.3px; font-weight:600; }
29
+ .portal-banner p { margin:5px 0 0; opacity:.88; font-size:.88rem; }
30
+ .auth-card {
31
+ background: var(--color-background-primary);
32
+ border: 1px solid var(--color-border-primary);
33
+ border-radius: 14px; padding: 28px 32px; max-width: 460px;
34
+ margin: 0 auto; box-shadow: 0 4px 24px rgba(0,0,0,.07);
35
+ }
36
+ .auth-card h2 { margin:0 0 18px; font-size:1.25rem; color:#1e3a5f; }
37
+ .auth-divider { text-align:center; color:#9ca3af; margin:10px 0; font-size:.85rem; }
38
+ .user-bar {
39
+ background: var(--color-background-secondary);
40
+ border: 1px solid var(--color-border-tertiary);
41
+ border-radius: 10px; padding: 12px 18px;
42
+ display: flex; align-items: center; justify-content: space-between;
43
+ margin-bottom: 14px; font-size: .9rem;
44
+ }
45
+ .role-badge { font-size:.78rem; font-weight:600; padding:3px 10px; border-radius:20px; }
46
+ .role-admin { background:#dbeafe; color:#1e40af; }
47
+ .role-user { background:#dcfce7; color:#166534; }
48
+ .hint-box {
49
+ background: #fefce8; border: 1px solid #fde68a;
50
+ border-radius: 8px; padding: 10px 14px; font-size:.85rem; line-height:1.7;
51
+ margin-bottom: 12px;
52
+ }
53
+ .detect-box {
54
+ background: #f0fdf4; border: 1px solid #a7f3d0;
55
+ border-radius: 8px; padding: 10px 14px; font-size:.85rem; line-height:1.7;
56
+ margin-bottom: 12px;
57
+ }
58
+ .dl-row { background:#f0fdf4; border:1px solid #a7f3d0; border-radius:8px; padding:12px; margin-top:8px; }
59
+ footer { display:none !important; }
60
+ """
61
+
62
+ BANNER_HTML = """
63
+ <div class="portal-banner">
64
+ <h1>🎓 EduClone AI — Exam Intelligence Portal</h1>
65
+ <p>Generate · Upload · Clone MCQs · Download Word / PDF / Excel
66
+ &nbsp;|&nbsp; <strong>Abdulqayyum MBA</strong> — 18 yrs Academic Assessment · MBA · AI Governance · Azure AI</p>
67
+ </div>
68
+ """
69
+
70
+ TAB1_HINT = """<div class="hint-box">
71
+ <b>📝 Tab 1 — Generate MCQs from a Topic:</b>
72
+ Enter any subject → choose Bloom's level → AI generates fresh MCQs with
73
+ <b>4 options (A B C D)</b> and a correct answer. Download as Word / PDF / Excel.
74
+ </div>"""
75
+
76
+ TAB2_HINT = """<div class="hint-box">
77
+ <b>🔁 Tab 2 — Upload &amp; Clone your existing MCQs:</b><br>
78
+ Upload <b>.docx / .pdf / .txt</b> file with your questions. The system detects the correct answer using:<br>
79
+ &nbsp;&nbsp;<b>1.</b> Bold option text &nbsp;
80
+ <b>2.</b> Underlined option text &nbsp;
81
+ <b>3.</b> Coloured (non-black) option text &nbsp;
82
+ <b>4.</b> Highlighted option text &nbsp;
83
+ <b>5.</b> <code>Answer: X</code> line at the end<br>
84
+ If none of these are found, the answer is left as <b>⚠️ Unknown</b> — no guess is made.
85
+ Then the AI clones each question with <b>4 new distractors</b>.
86
+ </div>"""
87
+
88
+ SAMPLE_MCQS = """1. Your patient is a 44-year-old female, alert and oriented, in moderate distress, complaining of difficulty breathing. She has a 1-week history of fever and malaise, shortness of breath developing 3 days ago. Left-sided chest pain with deep inspiration and a cough with sputum. Examination: hot, pale, dry skin; rhonchi and rales throughout the left lung. Right lung clear.
89
+
90
+ HR = 134, BP = 88/64, RR = 24, SpO2 = 92%.
91
+
92
+ History of two previous myocardial infarctions. Takes nitroglycerin as needed. Which prehospital management is most appropriate?
93
+
94
+ A. Oxygen via venturi mask 24-35%, nebulized albuterol and ipratropium, IV NS KVO, IV corticosteroids
95
+ B. Endotracheal intubation, 100% oxygen, IV NS KVO, nebulized albuterol and Atrovent, IV corticosteroids
96
+ C. Albuterol via nebulizer with 100% oxygen, IV NS KVO
97
+ D. Oxygen via nonrebreathing mask 15 LPM, IV NS with fluid challenge
98
+
99
+ Answer: D
100
+
101
+ 2. A 60-year-old male presents with sudden crushing chest pain radiating to left arm and jaw. Diaphoretic and pale. BP = 90/60, HR = 110, RR = 22, SpO2 = 94%. Most appropriate prehospital intervention?
102
+
103
+ A. Aspirin 324 mg PO, nitroglycerin 0.4 mg SL, oxygen via NRB mask, IV access, transport immediately
104
+ B. Morphine IV, 12-lead ECG, transport to nearest hospital without further intervention
105
+ C. Apply CPAP at 10 cmH2O, furosemide IV, IV access
106
+ D. Endotracheal intubation, lidocaine IV, transport Code 3
107
+
108
+ Answer: A
109
+ """
110
+
111
+ # ── session helpers ───────────────────────────────────────────────────────────
112
+ def _logged_in(s): return s.get("logged_in", False)
113
+ def _is_admin(s): return s.get("role") == "admin"
114
+
115
+ def _welcome_html(session):
116
+ role = session.get("role","user")
117
+ name = session.get("full_name") or session.get("username","")
118
+ bc = "role-admin" if role=="admin" else "role-user"
119
+ bl = "🔑 Admin" if role=="admin" else "👤 User"
120
+ return (f'<div class="user-bar">'
121
+ f'<span>👋 Welcome, <strong>{name}</strong> &nbsp;|&nbsp;'
122
+ f'<span class="role-badge {bc}">{bl}</span></span>'
123
+ f'<span style="font-size:.82rem;color:#6b7280;">'
124
+ f'{datetime.datetime.now():%d %b %Y %H:%M}</span></div>')
125
+
126
+
127
+ # ── auth handlers ─────────────────────────────────────────────────────────────
128
+ def do_login(username, password, session):
129
+ ok, msg, user = login_user(username, password)
130
+ if ok:
131
+ session.update({"logged_in": True, "username": user["username"],
132
+ "role": user["role"], "full_name": user.get("full_name",""),
133
+ "user_id": user["id"]})
134
+ return (msg, session,
135
+ gr.update(visible=False),
136
+ gr.update(visible=True),
137
+ _welcome_html(session),
138
+ gr.update(visible=_is_admin(session)),
139
+ gr.update(visible=True))
140
+ return msg, session, gr.update(visible=True), gr.update(visible=False), \
141
+ "", gr.update(visible=False), gr.update(visible=False)
142
+
143
+ def do_register(username, email, password, confirm, full_name, institution, session):
144
+ if password != confirm:
145
+ return "❌ Passwords do not match.", session
146
+ ok, msg = register_user(username, email, password, full_name, institution)
147
+ return msg, session
148
+
149
+ def do_logout(session):
150
+ if session.get("username"):
151
+ log_activity(session.get("user_id"), session.get("username"), "logout", "")
152
+ session.clear()
153
+ return (session, gr.update(visible=True), gr.update(visible=False),
154
+ "", gr.update(visible=False), gr.update(visible=False))
155
+
156
+
157
+ # ── Tab 1: generate from topic ────────────────────────────────────────────────
158
+ def handle_generate(topic, num_q, blooms, difficulty, want_word, want_pdf, want_excel, session):
159
+ if not _logged_in(session):
160
+ return "⚠️ Please log in first.", None, None, None, ""
161
+ if not topic.strip():
162
+ return "⚠️ Please enter a topic name.", None, None, None, ""
163
+
164
+ mcqs, source = generate_from_topic(topic, int(num_q), blooms, difficulty)
165
+ if not mcqs:
166
+ return "❌ Could not generate questions. Please try again.", None, None, None, ""
167
+
168
+ log_activity(session.get("user_id"), session.get("username"),
169
+ "generate_topic", f"{len(mcqs)} Qs · {topic} · {blooms}")
170
+
171
+ note = ("⚡ AI was busy — template questions shown. Re-run for AI results.\n\n"
172
+ if source == "template" else "✅ AI-generated questions:\n\n")
173
+ preview = preview_mcqs(mcqs, f"Topic: {topic} | Bloom's: {blooms} | Difficulty: {difficulty}")
174
+
175
+ title = f"MCQs: {topic}"
176
+ w = make_word(mcqs, title) if want_word else None
177
+ p = make_pdf(mcqs, title) if want_pdf else None
178
+ e = make_excel(mcqs, title) if want_excel else None
179
+
180
+ metrics = (f"📊 Questions: **{len(mcqs)}** · Bloom's: **{blooms}** · "
181
+ f"Difficulty: **{difficulty}** · Source: **{source}**")
182
+ return note + preview, w, p, e, metrics
183
+
184
+
185
+ # ── Tab 2: upload + clone ─────────────────────────────────────────────────────
186
+ def handle_upload_preview(file_obj, paste_text, session):
187
+ """Step 1: parse uploaded file, show what was detected."""
188
+ if not _logged_in(session):
189
+ return "⚠️ Please log in first.", session
190
+
191
+ mcqs, err = [], ""
192
+ if file_obj:
193
+ mcqs, err = read_and_parse(file_obj)
194
+ elif paste_text.strip():
195
+ from mcq_engine import _parse_plain
196
+ mcqs = _parse_plain(paste_text.strip())
197
+ else:
198
+ return "⚠️ Please upload a file or paste MCQs.", session
199
+
200
+ if err:
201
+ return f"❌ {err}", session
202
+ if not mcqs:
203
+ return ("❌ Could not detect MCQ structure.\n\n"
204
+ "Ensure questions start with `1.` or `Q1.` and options with `A.` / `A)`"), session
205
+
206
+ # Store in session for cloning step
207
+ session["_mcqs"] = mcqs
208
+ detected = sum(1 for m in mcqs if m.get("answer"))
209
+ unknown = len(mcqs) - detected
210
+
211
+ summary = f"✅ **{len(mcqs)} question(s) parsed**"
212
+ if detected:
213
+ summary += f" · **{detected}** answer(s) detected"
214
+ if unknown:
215
+ summary += f" · **{unknown}** answer(s) ⚠️ unknown (no formatting mark or key found)"
216
+ summary += "\n\n"
217
+ summary += preview_mcqs(mcqs)
218
+ return summary, session
219
+
220
+
221
+ def handle_clone(num_clones, blooms, want_word, want_pdf, want_excel, exam_title, session):
222
+ """Step 2: clone the already-parsed MCQs."""
223
+ if not _logged_in(session):
224
+ return "⚠️ Please log in first.", None, None, None, ""
225
+ originals = session.get("_mcqs", [])
226
+ if not originals:
227
+ return "⚠️ Please parse/upload MCQs first (click Preview above).", None, None, None, ""
228
+
229
+ clones = clone_mcqs(originals, int(num_clones), blooms)
230
+ log_activity(session.get("user_id"), session.get("username"),
231
+ "clone_mcqs", f"{len(originals)} orig → {len(clones)} clones")
232
+
233
+ title = exam_title.strip() or "Cloned MCQs"
234
+ w = make_word(clones, title) if want_word else None
235
+ p = make_pdf(clones, title) if want_pdf else None
236
+ e = make_excel(clones, title) if want_excel else None
237
+
238
+ metrics = (f"📊 Original: **{len(originals)}** · "
239
+ f"Clones: **{len(clones)}** · "
240
+ f"Bloom's: **{blooms}**")
241
+ return preview_mcqs(clones, "Generated Clones"), w, p, e, metrics
242
+
243
+
244
+ # ── admin handlers (unchanged) ────────────────────────────────────────────────
245
+ def admin_get_stats(session):
246
+ if not _is_admin(session): return "❌ Admin only."
247
+ s = get_stats()
248
+ return (f"## 📊 Portal Statistics\n|Metric|Value|\n|---|---|\n"
249
+ f"|Total Users|**{s['total']}**|\n|Admins|**{s['admins']}**|\n"
250
+ f"|Active|**{s['active']}**|\n|New Today|**{s['new_today']}**|\n"
251
+ f"|Logins Today|**{s['logins_today']}**|")
252
+
253
+ def admin_get_users(session):
254
+ if not _is_admin(session): return pd.DataFrame()
255
+ users = get_all_users()
256
+ if not users: return pd.DataFrame()
257
+ df = pd.DataFrame(users)[["id","username","email","full_name","institution",
258
+ "role","is_active","created_at","last_login"]]
259
+ df.columns = ["ID","Username","Email","Full Name","Institution",
260
+ "Role","Active","Registered","Last Login"]
261
+ df["Active"] = df["Active"].map({1:"✅ Yes", 0:"❌ No"})
262
+ df["Last Login"] = df["Last Login"].fillna("Never")
263
+ return df
264
+
265
+ def admin_toggle(uid_str, activate, session):
266
+ if not _is_admin(session): return "❌ Admin only.", admin_get_users(session)
267
+ try:
268
+ msg = toggle_user_active(int(uid_str), activate)
269
+ return msg, admin_get_users(session)
270
+ except ValueError:
271
+ return "❌ Enter a numeric User ID.", admin_get_users(session)
272
+
273
+ def admin_change_role(uid_str, role, session):
274
+ if not _is_admin(session): return "❌ Admin only.", admin_get_users(session)
275
+ try:
276
+ msg = change_user_role(int(uid_str), role)
277
+ return msg, admin_get_users(session)
278
+ except ValueError:
279
+ return "❌ Enter a numeric User ID.", admin_get_users(session)
280
+
281
+ def admin_delete(uid_str, session):
282
+ if not _is_admin(session): return "❌ Admin only.", admin_get_users(session)
283
+ try:
284
+ msg = delete_user(int(uid_str))
285
+ return msg, admin_get_users(session)
286
+ except ValueError:
287
+ return "❌ Enter a numeric User ID.", admin_get_users(session)
288
+
289
+ def admin_get_log(session):
290
+ if not _is_admin(session): return pd.DataFrame()
291
+ logs = get_activity_log(200)
292
+ if not logs: return pd.DataFrame()
293
+ df = pd.DataFrame(logs)[["timestamp","username","action","detail"]]
294
+ df.columns = ["Timestamp","User","Action","Detail"]
295
+ df["Timestamp"] = df["Timestamp"].str[:19].str.replace("T"," ")
296
+ return df
297
+
298
+
299
+ # ══════════════════════════════════════════════════════════════════════════════
300
+ # GRADIO UI
301
+ # ══════════════════════════════════════════════════════════════════════════════
302
+ def create_app():
303
+ with gr.Blocks(css=CSS, title="EduClone AI Portal") as demo:
304
+ session_state = gr.State({})
305
+ gr.HTML(BANNER_HTML)
306
+
307
+ # ── Login / Register ──────────────────────────────────────────────────
308
+ with gr.Column(visible=True) as login_panel:
309
+ with gr.Tabs():
310
+ with gr.TabItem("🔐 Login"):
311
+ with gr.Column(elem_classes=["auth-card"]):
312
+ gr.HTML("<h2>Sign in to EduClone AI</h2>")
313
+ li_user = gr.Textbox(label="Username or Email")
314
+ li_pass = gr.Textbox(label="Password", type="password")
315
+ li_btn = gr.Button("🔐 Login", variant="primary", size="lg")
316
+ li_msg = gr.Markdown()
317
+ with gr.TabItem("📝 Register"):
318
+ with gr.Column(elem_classes=["auth-card"]):
319
+ gr.HTML("<h2>Create your account</h2>")
320
+ rg_name = gr.Textbox(label="Full Name")
321
+ rg_inst = gr.Textbox(label="Institution / Organisation")
322
+ rg_user = gr.Textbox(label="Username (3–30 chars, no spaces)")
323
+ rg_email = gr.Textbox(label="Email Address")
324
+ rg_pass = gr.Textbox(label="Password (8+ chars, 1 uppercase, 1 number)", type="password")
325
+ rg_conf = gr.Textbox(label="Confirm Password", type="password")
326
+ rg_btn = gr.Button("📝 Create Account", variant="primary", size="lg")
327
+ rg_msg = gr.Markdown()
328
+
329
+ # ── Main Portal ───────────────────────────────────────────────────────
330
+ with gr.Column(visible=False) as main_portal:
331
+ welcome_html = gr.HTML()
332
+ logout_btn = gr.Button("🚪 Logout", size="sm", variant="secondary")
333
+
334
+ # ── Admin Panel ───────────────────────────────────────────────────
335
+ with gr.Column(visible=False) as admin_tabs:
336
+ gr.HTML("<h3 style='color:#1e3a5f;margin:8px 0 4px'>🔑 Admin Panel</h3>")
337
+ with gr.Tabs():
338
+ with gr.TabItem("📊 Dashboard"):
339
+ dash_btn = gr.Button("🔄 Refresh Stats", variant="secondary")
340
+ dash_out = gr.Markdown()
341
+ with gr.TabItem("👥 User Management"):
342
+ refresh_users_btn = gr.Button("🔄 Refresh List", variant="secondary")
343
+ users_table = gr.DataFrame(interactive=False, wrap=True)
344
+ gr.Markdown("**Actions — enter User ID:**")
345
+ with gr.Row():
346
+ action_uid = gr.Textbox(label="User ID", scale=1, placeholder="e.g. 3")
347
+ action_role = gr.Dropdown(choices=["user","admin"], value="user",
348
+ label="Set Role", scale=1)
349
+ with gr.Row():
350
+ btn_activate = gr.Button("✅ Activate", variant="secondary")
351
+ btn_deactivate = gr.Button("❌ Deactivate", variant="secondary")
352
+ btn_set_role = gr.Button("🔄 Set Role", variant="secondary")
353
+ btn_del_user = gr.Button("🗑️ Delete", variant="stop")
354
+ action_msg = gr.Markdown()
355
+ with gr.TabItem("📋 Activity Log"):
356
+ refresh_log_btn = gr.Button("🔄 Refresh", variant="secondary")
357
+ log_table = gr.DataFrame(interactive=False, wrap=True)
358
+
359
+ # ── USER SECTION — Tab 1 & 2 ─────────────────────────────────────
360
+ with gr.Column(visible=False) as user_tabs:
361
+ with gr.Tabs():
362
+
363
+ # ══ TAB 1: Generate from Topic ═══════════════════════════
364
+ with gr.TabItem("📝 Generate from Topic"):
365
+ gr.HTML(TAB1_HINT)
366
+ with gr.Row():
367
+ with gr.Column(scale=1):
368
+ t1_topic = gr.Textbox(
369
+ label="Topic / Subject Name",
370
+ placeholder="e.g. Mitochondria · Supply Chain · Newton's Laws",
371
+ lines=2)
372
+ t1_num = gr.Slider(1, 10, value=5, step=1,
373
+ label="Number of Questions")
374
+ t1_blooms = gr.Dropdown(
375
+ choices=list(BLOOMS.keys()),
376
+ value="Mixed (All)",
377
+ label="📚 Bloom's Taxonomy Level")
378
+ t1_diff = gr.Radio(
379
+ choices=["Easy","Medium","Hard","Mixed"],
380
+ value="Mixed", label="Difficulty")
381
+ gr.Markdown("**Download formats:**")
382
+ with gr.Row():
383
+ t1_word = gr.Checkbox(label="📄 Word", value=True)
384
+ t1_pdf = gr.Checkbox(label="📑 PDF", value=True)
385
+ t1_excel = gr.Checkbox(label="📊 Excel", value=False)
386
+ t1_btn = gr.Button("🚀 Generate MCQs",
387
+ variant="primary", size="lg")
388
+ with gr.Column(scale=2):
389
+ t1_preview = gr.Markdown(label="Preview")
390
+ with gr.Row(elem_classes=["dl-row"]):
391
+ t1_w = gr.File(label="📄 Word")
392
+ t1_p = gr.File(label="📑 PDF")
393
+ t1_e = gr.File(label="📊 Excel")
394
+ t1_metrics = gr.Markdown()
395
+
396
+ # ══ TAB 2: Upload & Clone ═════════════════════════════════
397
+ with gr.TabItem("🔁 Upload & Clone"):
398
+ gr.HTML(TAB2_HINT)
399
+ with gr.Row():
400
+ with gr.Column(scale=1):
401
+ gr.Markdown("### Step 1 — Upload your MCQs")
402
+ t2_file = gr.File(
403
+ label="Upload File (.docx / .pdf / .txt) — formatting detected from .docx",
404
+ file_types=[".docx",".doc",".pdf",".txt"])
405
+ t2_paste = gr.Textbox(
406
+ label="Or paste plain-text MCQs here",
407
+ lines=10, value=SAMPLE_MCQS)
408
+ t2_preview_btn = gr.Button("🔍 Parse & Preview",
409
+ variant="secondary", size="lg")
410
+ gr.Markdown("---\n### Step 2 — Clone settings")
411
+ t2_clones = gr.Slider(1, 3, value=2, step=1,
412
+ label="Clones per Question")
413
+ t2_blooms = gr.Dropdown(
414
+ choices=list(BLOOMS.keys()),
415
+ value="Mixed (All)",
416
+ label="📚 Bloom's Level for Clones")
417
+ t2_title = gr.Textbox(
418
+ label="Exam / Output Title",
419
+ value="Cloned MCQs")
420
+ gr.Markdown("**Download formats:**")
421
+ with gr.Row():
422
+ t2_word = gr.Checkbox(label="📄 Word", value=True)
423
+ t2_pdf = gr.Checkbox(label="📑 PDF", value=True)
424
+ t2_excel = gr.Checkbox(label="📊 Excel", value=False)
425
+ t2_clone_btn = gr.Button("🔁 Generate Clones",
426
+ variant="primary", size="lg")
427
+ with gr.Column(scale=2):
428
+ t2_preview = gr.Markdown(label="Parsed / Clone Preview")
429
+ with gr.Row(elem_classes=["dl-row"]):
430
+ t2_w = gr.File(label="📄 Word")
431
+ t2_p = gr.File(label="📑 PDF")
432
+ t2_e = gr.File(label="📊 Excel")
433
+ t2_metrics = gr.Markdown()
434
+
435
+ # ── Wire events ───────────────────────────────────────────────────────
436
+
437
+ # Auth
438
+ li_btn.click(fn=do_login,
439
+ inputs=[li_user, li_pass, session_state],
440
+ outputs=[li_msg, session_state, login_panel, main_portal,
441
+ welcome_html, admin_tabs, user_tabs])
442
+ li_pass.submit(fn=do_login,
443
+ inputs=[li_user, li_pass, session_state],
444
+ outputs=[li_msg, session_state, login_panel, main_portal,
445
+ welcome_html, admin_tabs, user_tabs])
446
+ rg_btn.click(fn=do_register,
447
+ inputs=[rg_user, rg_email, rg_pass, rg_conf,
448
+ rg_name, rg_inst, session_state],
449
+ outputs=[rg_msg, session_state])
450
+ logout_btn.click(fn=do_logout,
451
+ inputs=[session_state],
452
+ outputs=[session_state, login_panel, main_portal,
453
+ welcome_html, admin_tabs, user_tabs])
454
+
455
+ # Admin
456
+ dash_btn.click(fn=admin_get_stats, inputs=[session_state], outputs=[dash_out])
457
+ refresh_users_btn.click(fn=admin_get_users, inputs=[session_state], outputs=[users_table])
458
+ btn_activate.click(fn=lambda u,s: admin_toggle(u,True,s),
459
+ inputs=[action_uid,session_state], outputs=[action_msg,users_table])
460
+ btn_deactivate.click(fn=lambda u,s: admin_toggle(u,False,s),
461
+ inputs=[action_uid,session_state], outputs=[action_msg,users_table])
462
+ btn_set_role.click(fn=admin_change_role,
463
+ inputs=[action_uid,action_role,session_state],
464
+ outputs=[action_msg,users_table])
465
+ btn_del_user.click(fn=admin_delete,
466
+ inputs=[action_uid,session_state], outputs=[action_msg,users_table])
467
+ refresh_log_btn.click(fn=admin_get_log, inputs=[session_state], outputs=[log_table])
468
+
469
+ # Tab 1 — Generate
470
+ t1_btn.click(fn=handle_generate,
471
+ inputs=[t1_topic, t1_num, t1_blooms, t1_diff,
472
+ t1_word, t1_pdf, t1_excel, session_state],
473
+ outputs=[t1_preview, t1_w, t1_p, t1_e, t1_metrics])
474
+
475
+ # Tab 2 — Parse then Clone (two separate buttons)
476
+ t2_preview_btn.click(fn=handle_upload_preview,
477
+ inputs=[t2_file, t2_paste, session_state],
478
+ outputs=[t2_preview, session_state])
479
+ t2_clone_btn.click(fn=handle_clone,
480
+ inputs=[t2_clones, t2_blooms, t2_word, t2_pdf,
481
+ t2_excel, t2_title, session_state],
482
+ outputs=[t2_preview, t2_w, t2_p, t2_e, t2_metrics])
483
+
484
+ return demo
485
+
486
+
487
+ if __name__ == "__main__":
488
+ app = create_app()
489
+ app.launch(server_name="0.0.0.0", server_port=7860, share=False, show_error=True)
auth.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ EduClone AI — Authentication & User Management
3
+ SQLite WAL mode, bcrypt passwords, roles: admin / user
4
+ """
5
+ import sqlite3, bcrypt, datetime, os, re, threading
6
+
7
+ DB_PATH = os.environ.get("EDUCLONE_DB", "educlone_users.db")
8
+ ADMIN_USERNAME = "admin"
9
+ ADMIN_EMAIL = "admin@educlone.ai"
10
+ ADMIN_PASSWORD = "admin123"
11
+
12
+ _lock = threading.Lock()
13
+
14
+ def _conn():
15
+ """Return a fresh connection each call — WAL so reads never block."""
16
+ c = sqlite3.connect(DB_PATH, timeout=30)
17
+ c.row_factory = sqlite3.Row
18
+ c.execute("PRAGMA journal_mode=WAL")
19
+ c.execute("PRAGMA busy_timeout=10000")
20
+ return c
21
+
22
+ def init_db():
23
+ with _lock:
24
+ c = _conn()
25
+ c.execute("""CREATE TABLE IF NOT EXISTS users (
26
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
27
+ username TEXT UNIQUE NOT NULL,
28
+ email TEXT UNIQUE NOT NULL,
29
+ password_hash TEXT NOT NULL,
30
+ role TEXT NOT NULL DEFAULT 'user',
31
+ full_name TEXT DEFAULT '',
32
+ institution TEXT DEFAULT '',
33
+ is_approved INTEGER DEFAULT 1,
34
+ is_active INTEGER DEFAULT 1,
35
+ created_at TEXT NOT NULL,
36
+ last_login TEXT)""")
37
+ c.execute("""CREATE TABLE IF NOT EXISTS activity_log (
38
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
39
+ user_id INTEGER,
40
+ username TEXT,
41
+ action TEXT,
42
+ detail TEXT,
43
+ timestamp TEXT)""")
44
+ # seed admin
45
+ if not c.execute("SELECT id FROM users WHERE username=?",
46
+ (ADMIN_USERNAME,)).fetchone():
47
+ ph = bcrypt.hashpw(ADMIN_PASSWORD.encode(), bcrypt.gensalt()).decode()
48
+ c.execute("""INSERT INTO users
49
+ (username,email,password_hash,role,full_name,institution,is_approved,created_at)
50
+ VALUES(?,?,?,?,?,?,?,?)""",
51
+ (ADMIN_USERNAME, ADMIN_EMAIL, ph, "admin",
52
+ "Abdulqayyum MBA", "EduClone AI", 1,
53
+ datetime.datetime.now().isoformat()))
54
+ c.commit(); c.close()
55
+
56
+ def _valid_email(e): return bool(re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', e))
57
+ def _valid_user(u): return bool(re.match(r'^[a-zA-Z0-9_]{3,30}$', u))
58
+ def _valid_pass(p): return len(p)>=8 and re.search(r'[A-Z]',p) and re.search(r'[0-9]',p)
59
+
60
+ def register_user(username, email, password, full_name="", institution=""):
61
+ username = username.strip().lower(); email = email.strip().lower()
62
+ if not _valid_user(username): return False,"Username: 3–30 chars, letters/numbers/underscore only."
63
+ if not _valid_email(email): return False,"Please enter a valid email address."
64
+ if not _valid_pass(password): return False,"Password: 8+ chars, 1 uppercase, 1 number."
65
+ with _lock:
66
+ c = _conn()
67
+ try:
68
+ if c.execute("SELECT id FROM users WHERE username=?",(username,)).fetchone():
69
+ return False,"Username already taken."
70
+ if c.execute("SELECT id FROM users WHERE email=?",(email,)).fetchone():
71
+ return False,"Email already registered."
72
+ ph = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
73
+ c.execute("""INSERT INTO users
74
+ (username,email,password_hash,role,full_name,institution,is_approved,created_at)
75
+ VALUES(?,?,?,?,?,?,?,?)""",
76
+ (username,email,ph,"user",full_name.strip(),institution.strip(),1,
77
+ datetime.datetime.now().isoformat()))
78
+ c.commit()
79
+ return True, f"✅ Account created! Welcome, {username}. You can now log in."
80
+ except Exception as ex:
81
+ return False, f"Registration error: {ex}"
82
+ finally:
83
+ c.close()
84
+
85
+ def login_user(username, password):
86
+ username = username.strip().lower()
87
+ with _lock:
88
+ c = _conn()
89
+ try:
90
+ row = c.execute(
91
+ "SELECT * FROM users WHERE username=? OR email=?",
92
+ (username, username)).fetchone()
93
+ if not row:
94
+ return False, "❌ Username or password incorrect.", None
95
+ if not row["is_active"]:
96
+ return False, "❌ Account deactivated. Contact admin.", None
97
+ if not bcrypt.checkpw(password.encode(), row["password_hash"].encode()):
98
+ return False, "❌ Username or password incorrect.", None
99
+ c.execute("UPDATE users SET last_login=? WHERE id=?",
100
+ (datetime.datetime.now().isoformat(), row["id"]))
101
+ c.commit()
102
+ user = {k: row[k] for k in row.keys() if k != "password_hash"}
103
+ return True, f"✅ Welcome back, {row['full_name'] or row['username']}!", user
104
+ except Exception as ex:
105
+ return False, f"Login error: {ex}", None
106
+ finally:
107
+ c.close()
108
+
109
+ def log_activity(user_id, username, action, detail=""):
110
+ with _lock:
111
+ c = _conn()
112
+ try:
113
+ c.execute("""INSERT INTO activity_log(user_id,username,action,detail,timestamp)
114
+ VALUES(?,?,?,?,?)""",
115
+ (user_id, username, action, detail, datetime.datetime.now().isoformat()))
116
+ c.commit()
117
+ finally:
118
+ c.close()
119
+
120
+ def get_all_users():
121
+ with _lock:
122
+ c = _conn()
123
+ try:
124
+ rows = c.execute("""SELECT id,username,email,role,full_name,institution,
125
+ is_approved,is_active,created_at,last_login
126
+ FROM users ORDER BY created_at DESC""").fetchall()
127
+ return [dict(r) for r in rows]
128
+ finally:
129
+ c.close()
130
+
131
+ def toggle_user_active(user_id, active):
132
+ with _lock:
133
+ c = _conn()
134
+ try:
135
+ c.execute("UPDATE users SET is_active=? WHERE id=?", (1 if active else 0, user_id))
136
+ c.commit()
137
+ return "✅ User status updated."
138
+ finally:
139
+ c.close()
140
+
141
+ def change_user_role(user_id, role):
142
+ if role not in ("admin","user"): return "❌ Invalid role."
143
+ with _lock:
144
+ c = _conn()
145
+ try:
146
+ c.execute("UPDATE users SET role=? WHERE id=?", (role, user_id))
147
+ c.commit()
148
+ return f"✅ Role updated to {role}."
149
+ finally:
150
+ c.close()
151
+
152
+ def delete_user(user_id):
153
+ with _lock:
154
+ c = _conn()
155
+ try:
156
+ c.execute("DELETE FROM users WHERE id=? AND role != 'admin'", (user_id,))
157
+ c.commit()
158
+ return "✅ User deleted."
159
+ finally:
160
+ c.close()
161
+
162
+ def get_activity_log(limit=200):
163
+ with _lock:
164
+ c = _conn()
165
+ try:
166
+ rows = c.execute(
167
+ "SELECT * FROM activity_log ORDER BY timestamp DESC LIMIT ?",
168
+ (limit,)).fetchall()
169
+ return [dict(r) for r in rows]
170
+ finally:
171
+ c.close()
172
+
173
+ def get_stats():
174
+ with _lock:
175
+ c = _conn()
176
+ try:
177
+ today = datetime.date.today().isoformat()
178
+ return {
179
+ "total": c.execute("SELECT COUNT(*) FROM users").fetchone()[0],
180
+ "admins": c.execute("SELECT COUNT(*) FROM users WHERE role='admin'").fetchone()[0],
181
+ "active": c.execute("SELECT COUNT(*) FROM users WHERE is_active=1").fetchone()[0],
182
+ "new_today": c.execute("SELECT COUNT(*) FROM users WHERE created_at LIKE ?",
183
+ (today+"%",)).fetchone()[0],
184
+ "logins_today": c.execute("SELECT COUNT(*) FROM activity_log WHERE action='login' AND timestamp LIKE ?",
185
+ (today+"%",)).fetchone()[0],
186
+ }
187
+ finally:
188
+ c.close()
189
+
190
+ init_db()
mcq_engine.py ADDED
@@ -0,0 +1,982 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ EduClone AI — MCQ Engine v7
3
+ Gemini 2.5 Flash (google-genai SDK) for generation & AI fallback.
4
+ API key via GOOGLE_API_KEY environment variable / HF Space Secret.
5
+
6
+ Parse cascade (tried in order, first success wins):
7
+ 1. Indent-bullet .docx question=low-indent bullet, options=high-indent bullet
8
+ 2. Classic prefix .docx Q1. / A. / B. style
9
+ 3. Flexible plain-text handles: Q1. Q1- 1. 1- + A. A) A- a. a) a-
10
+ 4. Paragraph-block .docx question=bold/regular para, options=next 4 short paras
11
+ 5. Any-bullet grouping groups bullet lists: every 5 consecutive bullets = 1Q+4opts
12
+ 6. Gemini AI fallback semantic extraction when all structural parsers fail
13
+
14
+ Answer detection (all parsers):
15
+ bold / underline / colour / highlight run → letter from formatting
16
+ "Answer: X" / "Key: X" explicit line → letter from text
17
+ Nothing → None (never guessed)
18
+ """
19
+
20
+ import os, re, tempfile, datetime, json
21
+ import openpyxl
22
+ from openpyxl.styles import Font, PatternFill, Alignment
23
+ from openpyxl.utils import get_column_letter
24
+ from docx import Document as DocxDocument
25
+ from docx.shared import Pt, RGBColor, Inches
26
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
27
+ from docx.oxml.ns import qn
28
+ from docx.oxml import OxmlElement
29
+ from reportlab.lib.pagesizes import A4
30
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
31
+ from reportlab.lib.colors import HexColor, black, white
32
+ from reportlab.lib.units import cm
33
+ from reportlab.platypus import (SimpleDocTemplate, Paragraph, Spacer,
34
+ HRFlowable, PageBreak, Table, TableStyle, KeepTogether)
35
+ from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY
36
+
37
+ try:
38
+ import pdfplumber; HAS_PDF = True
39
+ except ImportError:
40
+ HAS_PDF = False
41
+
42
+ DARK_BLUE = HexColor("#1e3a5f"); BLUE = HexColor("#1a56db")
43
+ GREEN = HexColor("#059669"); GRAY = HexColor("#6b7280")
44
+ YELLOW_BG = HexColor("#fef9c3")
45
+
46
+ # ── Gemini client (lazy init) ─────────────────────────────────────────────────
47
+ _gemini_client = None
48
+
49
+ def _get_gemini():
50
+ global _gemini_client
51
+ if _gemini_client is not None:
52
+ return _gemini_client
53
+ api_key = os.environ.get("GOOGLE_API_KEY", "")
54
+ if not api_key:
55
+ return None
56
+ try:
57
+ from google import genai
58
+ _gemini_client = genai.Client(api_key=api_key)
59
+ return _gemini_client
60
+ except Exception:
61
+ return None
62
+
63
+ GEMINI_MODEL = "gemini-2.5-flash"
64
+
65
+ def _gemini_call(prompt: str, max_tokens: int = 1200) -> str:
66
+ """Single Gemini call. Returns text or empty string on failure."""
67
+ client = _get_gemini()
68
+ if client is None:
69
+ return ""
70
+ try:
71
+ from google.genai import types
72
+ response = client.models.generate_content(
73
+ model=GEMINI_MODEL,
74
+ contents=prompt,
75
+ config=types.GenerateContentConfig(
76
+ max_output_tokens=max_tokens,
77
+ temperature=0.7,
78
+ ),
79
+ )
80
+ return response.text.strip() if response.text else ""
81
+ except Exception:
82
+ return ""
83
+
84
+ def _gemini_available() -> bool:
85
+ return bool(os.environ.get("GOOGLE_API_KEY", ""))
86
+
87
+ # ── Bloom's ───────────────────────────────────────────────────────────────────
88
+ BLOOMS = {
89
+ "Mixed (All)": "Use a variety of cognitive levels.",
90
+ "Remember": "Use verbs: recall, identify, list, name, define.",
91
+ "Understand": "Use verbs: explain, describe, summarise, classify.",
92
+ "Apply": "Use verbs: use, solve, demonstrate, apply.",
93
+ "Analyse": "Use verbs: compare, contrast, distinguish, examine.",
94
+ "Evaluate": "Use verbs: judge, assess, critique, justify.",
95
+ "Create": "Use verbs: design, construct, formulate, propose.",
96
+ }
97
+
98
+
99
+ # ══════════════════════════════════════════════════════════════════════════════
100
+ # GEMINI AI EXTRACTION (fallback for unparseable documents)
101
+ # ══════════════════════════════════════════════════════════════════════════════
102
+
103
+ def _gemini_extract_mcqs(raw_text: str) -> list:
104
+ """Gemini semantic extraction — last resort when all structural parsers fail."""
105
+ if not raw_text.strip() or not _gemini_available():
106
+ return []
107
+ chunk = raw_text[:6000]
108
+ prompt = (
109
+ "Extract all MCQs from the text below.\n"
110
+ "Return ONLY a valid JSON array with no markdown, no explanation, no preamble.\n"
111
+ "Each element: {\"question\":\"...\",\"A\":\"...\",\"B\":\"...\",\"C\":\"...\",\"D\":\"...\",\"answer\":null}\n"
112
+ "Set answer to the letter (A/B/C/D) only if clearly indicated in the source text.\n\n"
113
+ f"TEXT:\n{chunk}"
114
+ )
115
+ raw = _gemini_call(prompt, max_tokens=2000)
116
+ if not raw:
117
+ return []
118
+ # Strip markdown fences if present
119
+ raw = re.sub(r'^```(?:json)?\s*', '', raw.strip())
120
+ raw = re.sub(r'\s*```$', '', raw.strip())
121
+ m = re.search(r'\[.*\]', raw, re.DOTALL)
122
+ if not m:
123
+ return []
124
+ try:
125
+ items = json.loads(m.group())
126
+ result = []
127
+ for i, item in enumerate(items):
128
+ if not isinstance(item, dict) or not item.get("question"):
129
+ continue
130
+ result.append({
131
+ "number": i + 1,
132
+ "question": str(item.get("question", "")).strip(),
133
+ "A": str(item.get("A", "")).strip(),
134
+ "B": str(item.get("B", "")).strip(),
135
+ "C": str(item.get("C", "")).strip(),
136
+ "D": str(item.get("D", "")).strip(),
137
+ "answer": item.get("answer") or None,
138
+ "explanation": "", "blooms": "", "source": "gemini_ai",
139
+ })
140
+ return result
141
+ except Exception:
142
+ return []
143
+
144
+
145
+ # ══════════════════════════════════════════════════════════════════════════════
146
+ # FORMATTING HELPERS (for .docx run-level answer detection)
147
+ # ══════════════════════════════════════════════════════════════════════════════
148
+
149
+ def _run_is_bold(r): return bool(r.bold)
150
+ def _run_is_underline(r): return bool(r.underline)
151
+
152
+ def _run_has_colour(r):
153
+ try:
154
+ c = r.font.color
155
+ if c.type is None: return False
156
+ rgb = c.rgb
157
+ if rgb is None: return False
158
+ rv, gv, bv = (rgb >> 16) & 0xFF, (rgb >> 8) & 0xFF, rgb & 0xFF
159
+ return not (rv < 50 and gv < 50 and bv < 50)
160
+ except Exception:
161
+ return False
162
+
163
+ def _run_has_highlight(r):
164
+ try:
165
+ rPr = r._r.find(qn('w:rPr'))
166
+ if rPr is None: return False
167
+ return rPr.find(qn('w:highlight')) is not None
168
+ except Exception:
169
+ return False
170
+
171
+ def _para_has_answer_fmt(para) -> bool:
172
+ runs = para.runs
173
+ if not runs: return False
174
+ total = sum(len(r.text.strip()) for r in runs)
175
+ if total == 0: return False
176
+ bold = sum(len(r.text.strip()) for r in runs if _run_is_bold(r))
177
+ ul = sum(len(r.text.strip()) for r in runs if _run_is_underline(r))
178
+ col = sum(len(r.text.strip()) for r in runs if _run_has_colour(r))
179
+ hl = sum(len(r.text.strip()) for r in runs if _run_has_highlight(r))
180
+ return any(x / total >= 0.6 for x in [bold, ul, col, hl])
181
+
182
+ _ANSWER_KEY_RE = re.compile(
183
+ r'(?:^|\n)\s*(?:answer|key|correct|ans)\s*[:.\)]\s*([A-Da-d])\b',
184
+ re.IGNORECASE)
185
+
186
+ def _answer_from_text(txt: str):
187
+ m = _ANSWER_KEY_RE.search(txt)
188
+ return m.group(1).upper() if m else None
189
+
190
+ _OPT_FLEX = re.compile(
191
+ r'^[ \t]*(?:\(([A-Da-d])\)|([A-Da-d])[.\)\-][ \t]|([a-d])[.\)\-][ \t])',
192
+ re.MULTILINE | re.IGNORECASE)
193
+
194
+ _Q_FLEX = re.compile(
195
+ r'(?m)^[ \t]*(?:Q(?:uestion\s*)?)?(\d+)[.\)\-][ \t]')
196
+
197
+ _SKIP_RE = re.compile(
198
+ r'^(Page\s+\d|\d+\s+of\s+\d|Assessment Unit|COAMS|Riyadh|EduClone|http)',
199
+ re.IGNORECASE)
200
+
201
+ def _para_indent(p) -> int:
202
+ ind = p.paragraph_format.left_indent
203
+ return int(ind) if ind else 0
204
+
205
+ def _para_is_list(p) -> bool:
206
+ pPr = p._p.find(qn('w:pPr'))
207
+ if pPr is None: return False
208
+ return pPr.find(qn('w:numPr')) is not None
209
+
210
+ def _make_mcq(number, question, opts_text, opts_para, extra_text="", source=""):
211
+ answer = None
212
+ if opts_para:
213
+ for letter, para in zip("ABCD", opts_para):
214
+ if para and _para_has_answer_fmt(para):
215
+ answer = letter
216
+ break
217
+ if answer is None and extra_text:
218
+ answer = _answer_from_text(extra_text)
219
+ opts = list(opts_text) + [""] * 4
220
+ return {
221
+ "number": number, "question": question.strip(),
222
+ "A": opts[0], "B": opts[1], "C": opts[2], "D": opts[3],
223
+ "answer": answer, "explanation": "", "blooms": "", "source": source,
224
+ }
225
+
226
+
227
+ # ══════════════════════════════════════════════════════════════════════════════
228
+ # PARSER 1 — INDENT-BULLET .docx
229
+ # ══════════════════════════════════════════════════════════════════════════════
230
+
231
+ def _parse_indent_bullet(doc) -> list:
232
+ items = []
233
+ for p in doc.paragraphs:
234
+ txt = p.text.strip()
235
+ if not txt or _SKIP_RE.match(txt): continue
236
+ if _para_is_list(p):
237
+ items.append((p, txt, _para_indent(p)))
238
+ if len(items) < 3: return []
239
+ indents = sorted(set(x[2] for x in items))
240
+ if len(indents) < 2: return []
241
+ gap_idx = max(range(len(indents)-1), key=lambda i: indents[i+1]-indents[i])
242
+ q_set = set(indents[:gap_idx+1])
243
+ o_set = set(indents[gap_idx+1:])
244
+ mcqs, cur_q, cur_opts, cur_paras = [], None, [], []
245
+
246
+ def flush():
247
+ if cur_q and len(cur_opts) >= 2:
248
+ mcqs.append(_make_mcq(len(mcqs)+1, cur_q, cur_opts, cur_paras,
249
+ source="indent_bullets"))
250
+
251
+ for para, txt, indent in items:
252
+ if indent in q_set:
253
+ flush(); cur_q = txt; cur_opts = []; cur_paras = []
254
+ elif indent in o_set and cur_q:
255
+ cur_opts.append(txt); cur_paras.append(para)
256
+
257
+ flush()
258
+ return mcqs
259
+
260
+
261
+ # ══════════════════════════════════════════════════════════════════════════════
262
+ # PARSER 2 — CLASSIC Q#. / A. PREFIX .docx
263
+ # ══════════════════════════════════════════════════════════════════════════════
264
+
265
+ def _parse_classic_prefix(doc) -> list:
266
+ paras = doc.paragraphs
267
+ mcqs = []; i = 0
268
+ while i < len(paras):
269
+ txt = paras[i].text.strip()
270
+ qm = re.match(r'^(?:Q(?:uestion\s*)?)?(\d+)[.\)]\s+(.+)', txt, re.IGNORECASE)
271
+ if not qm: i += 1; continue
272
+ q_num = int(qm.group(1))
273
+ q_lines = [qm.group(2).strip()]
274
+ i += 1
275
+ while i < len(paras):
276
+ t = paras[i].text.strip()
277
+ if _OPT_FLEX.match(t): break
278
+ if _Q_FLEX.match(t): break
279
+ if t: q_lines.append(t)
280
+ i += 1
281
+ opts_text = []; opts_para = []; answer = None
282
+ while i < len(paras) and len(opts_text) < 4:
283
+ t = paras[i].text.strip()
284
+ om = _OPT_FLEX.match(t)
285
+ if not om: break
286
+ letter = (om.group(1) or om.group(2) or om.group(3) or "").upper()
287
+ if letter not in "ABCD": i += 1; continue
288
+ opts_text.append(t[om.end():].strip())
289
+ opts_para.append(paras[i]); i += 1
290
+ for _ in range(3):
291
+ if i >= len(paras): break
292
+ t = paras[i].text.strip()
293
+ if _Q_FLEX.match(t): break
294
+ a = _answer_from_text(t)
295
+ if a: answer = a; i += 1; break
296
+ i += 1
297
+ if answer is None:
298
+ for ltr, para in zip("ABCD", opts_para):
299
+ if _para_has_answer_fmt(para): answer = ltr; break
300
+ if q_lines and opts_text:
301
+ q = _make_mcq(q_num, "\n".join(q_lines), opts_text, opts_para,
302
+ source="classic_prefix")
303
+ q["answer"] = answer
304
+ mcqs.append(q)
305
+ return mcqs
306
+
307
+
308
+ # ══════════════════════════════════════════════════════════════════════════════
309
+ # PARSER 3 — FLEXIBLE PLAIN TEXT
310
+ # ══════════════════════════════════════════════════════════════════════════════
311
+
312
+ def _parse_plain(raw: str) -> list:
313
+ return _parse_flexible_text(raw)
314
+
315
+ def _parse_flexible_text(raw: str) -> list:
316
+ mcqs = []
317
+ splits = list(_Q_FLEX.finditer(raw))
318
+ if not splits: return []
319
+
320
+ for idx, sm in enumerate(splits):
321
+ block = raw[sm.start(): splits[idx+1].start() if idx+1 < len(splits) else len(raw)]
322
+ q_num = int(sm.group(1))
323
+ local_m = _Q_FLEX.match(block)
324
+ rest = block[local_m.end():].strip() if local_m else block[sm.end()-sm.start():].strip()
325
+ opts_iter = list(_OPT_FLEX.finditer(rest))
326
+ if not opts_iter: continue
327
+ q_body = rest[:opts_iter[0].start()].strip()
328
+ opts_text = []
329
+ for j, om in enumerate(opts_iter[:4]):
330
+ end = opts_iter[j+1].start() if j+1 < len(opts_iter) else len(rest)
331
+ opt = rest[om.end():end]
332
+ cut = re.search(r'\n[ \t]*(?:answer|key|correct|explanation)\s*[:.\-]',
333
+ opt, re.IGNORECASE)
334
+ if cut: opt = opt[:cut.start()]
335
+ opts_text.append(re.sub(r'\s+', ' ', opt.strip()))
336
+ answer = _answer_from_text(block)
337
+ if q_body and opts_text:
338
+ mcqs.append(_make_mcq(q_num, q_body, opts_text, None,
339
+ extra_text=block, source="flexible_text"))
340
+ return mcqs
341
+
342
+
343
+ # ══════════════════════════════════════════════════════════════════════���═══════
344
+ # PARSER 4 — PARAGRAPH BLOCK .docx
345
+ # ══════════════════════════════════════════════════════════════════════════════
346
+
347
+ def _para_looks_like_question(txt: str) -> bool:
348
+ txt = txt.strip()
349
+ if len(txt) < 20: return False
350
+ if _SKIP_RE.match(txt): return False
351
+ if txt.endswith('?'): return True
352
+ if re.search(r'\b(which|what|how|where|when|who|select|identify|choose)\b',
353
+ txt, re.IGNORECASE): return True
354
+ return False
355
+
356
+ def _para_looks_like_option(txt: str) -> bool:
357
+ txt = txt.strip()
358
+ if not txt or _SKIP_RE.match(txt): return False
359
+ if _OPT_FLEX.match(txt): return True
360
+ if len(txt) < 120 and '\n' not in txt: return True
361
+ return False
362
+
363
+ def _parse_paragraph_blocks(doc) -> list:
364
+ paras = [p for p in doc.paragraphs if p.text.strip() and not _SKIP_RE.match(p.text.strip())]
365
+ mcqs = []; i = 0
366
+ while i < len(paras):
367
+ txt = paras[i].text.strip()
368
+ if not _para_looks_like_question(txt):
369
+ i += 1; continue
370
+ opts_text = []; opts_para = []
371
+ j = i + 1
372
+ while j < len(paras) and len(opts_text) < 4:
373
+ ot = paras[j].text.strip()
374
+ if _para_looks_like_question(ot) and not _OPT_FLEX.match(ot):
375
+ break
376
+ if _para_looks_like_option(ot):
377
+ cleaned = re.sub(r'^[ \t]*(?:\([A-Da-d]\)|[A-Da-d][.\)\-])\s*', '',
378
+ ot, flags=re.IGNORECASE).strip()
379
+ opts_text.append(cleaned or ot)
380
+ opts_para.append(paras[j])
381
+ j += 1
382
+ if len(opts_text) >= 2:
383
+ extra = paras[j].text if j < len(paras) else ""
384
+ mcqs.append(_make_mcq(len(mcqs)+1, txt, opts_text, opts_para,
385
+ extra_text=extra, source="para_blocks"))
386
+ i = j
387
+ else:
388
+ i += 1
389
+ return mcqs
390
+
391
+
392
+ # ══════════════════════════════════════════════════════════════════════════════
393
+ # PARSER 5 — ANY-BULLET GROUPING
394
+ # ══════════════════════════════════════════════════════════════════════════════
395
+
396
+ def _parse_any_bullet_groups(doc) -> list:
397
+ bullets = []
398
+ for p in doc.paragraphs:
399
+ txt = p.text.strip()
400
+ if not txt or _SKIP_RE.match(txt): continue
401
+ if _para_is_list(p):
402
+ bullets.append((txt, p))
403
+ if len(bullets) < 5: return []
404
+ mcqs = []
405
+ for i in range(0, len(bullets) - 4, 5):
406
+ q_txt, _ = bullets[i]
407
+ opts = bullets[i+1:i+5]
408
+ opts_text = [o[0] for o in opts]
409
+ opts_para = [o[1] for o in opts]
410
+ mcqs.append(_make_mcq(len(mcqs)+1, q_txt, opts_text, opts_para,
411
+ source="bullet_groups"))
412
+ return mcqs
413
+
414
+
415
+ # ══════════════════════════════════════════════════════════════════════════════
416
+ # MAIN READER
417
+ # ══════════════════════════════════════════════════════════════════════════════
418
+
419
+ def read_and_parse(file_obj) -> tuple:
420
+ if not file_obj: return [], ""
421
+ path = file_obj.name
422
+ name = path.lower()
423
+
424
+ # ── DOCX ─────────────────────────────────────────────────────────────────
425
+ if name.endswith((".docx", ".doc")):
426
+ try:
427
+ doc = DocxDocument(path)
428
+ except Exception as e:
429
+ return [], f"❌ Cannot open file: {e}"
430
+
431
+ mcqs = _parse_indent_bullet(doc)
432
+ if mcqs:
433
+ return mcqs, f"✅ **Indent-bullet format** · {len(mcqs)} questions"
434
+
435
+ mcqs = _parse_classic_prefix(doc)
436
+ if mcqs:
437
+ return mcqs, f"✅ **Numbered Q#./A. format** · {len(mcqs)} questions"
438
+
439
+ raw = "\n".join(p.text for p in doc.paragraphs if p.text.strip())
440
+ mcqs = _parse_flexible_text(raw)
441
+ if mcqs:
442
+ return mcqs, f"✅ **Flexible text format** · {len(mcqs)} questions"
443
+
444
+ mcqs = _parse_paragraph_blocks(doc)
445
+ if mcqs:
446
+ return mcqs, f"✅ **Paragraph-block format** · {len(mcqs)} questions"
447
+
448
+ mcqs = _parse_any_bullet_groups(doc)
449
+ if mcqs:
450
+ return mcqs, f"✅ **Bullet-group format** · {len(mcqs)} questions"
451
+
452
+ # Gemini AI fallback
453
+ raw = "\n".join(p.text for p in doc.paragraphs if p.text.strip())
454
+ if raw.strip():
455
+ mcqs = _gemini_extract_mcqs(raw)
456
+ if mcqs:
457
+ return mcqs, f"⚡ **AI extracted** · {len(mcqs)} questions"
458
+ return [], ("❌ Could not detect MCQ structure in this file.\n\n"
459
+ "Supported .docx formats:\n"
460
+ "- Bullet list (questions at one indent, options deeper)\n"
461
+ "- Numbered Q1./A./B. prefixes\n"
462
+ "- Plain paragraphs where each question is followed by 4 short options")
463
+
464
+ # ── PDF ───────────────────────────────────────────────────────────────────
465
+ elif name.endswith(".pdf"):
466
+ if not HAS_PDF: return [], "❌ pdfplumber not installed."
467
+ try:
468
+ parts = []
469
+ with pdfplumber.open(path) as pdf:
470
+ for pg in pdf.pages:
471
+ t = pg.extract_text()
472
+ if t: parts.append(t)
473
+ raw = "\n".join(parts)
474
+ except Exception as e:
475
+ return [], f"❌ PDF read error: {e}"
476
+ mcqs = _parse_flexible_text(raw)
477
+ if mcqs: return mcqs, f"✅ **PDF parsed** · {len(mcqs)} questions"
478
+ if raw.strip():
479
+ mcqs = _gemini_extract_mcqs(raw)
480
+ if mcqs: return mcqs, f"⚡ **AI extracted from PDF** · {len(mcqs)} questions"
481
+ return [], "❌ Could not detect MCQ structure in PDF."
482
+
483
+ # ── TXT ───────────────────────────────────────────────────────────────────
484
+ else:
485
+ try:
486
+ raw = open(path, encoding="utf-8", errors="ignore").read()
487
+ except Exception as e:
488
+ return [], f"❌ File read error: {e}"
489
+ mcqs = _parse_flexible_text(raw)
490
+ if mcqs: return mcqs, f"✅ **Plain-text parsed** · {len(mcqs)} questions"
491
+ if raw.strip():
492
+ mcqs = _gemini_extract_mcqs(raw)
493
+ if mcqs: return mcqs, f"⚡ **AI extracted** · {len(mcqs)} questions"
494
+ return [], "❌ Could not detect MCQ structure in text file."
495
+
496
+
497
+ # ══════════════════════════════════════════════════════════════════════════════
498
+ # TAB 1: GENERATE FROM TOPIC
499
+ # ══════════════════════════════════════════════════════════════════════════════
500
+
501
+ def _topic_prompt(topic, num_q, blooms, difficulty):
502
+ bl = BLOOMS.get(blooms, BLOOMS["Mixed (All)"])
503
+ df = f"Difficulty: {difficulty}." if difficulty != "Mixed" else "Mix of difficulty levels."
504
+ return (
505
+ f"You are an academic MCQ author. Generate exactly {num_q} high-quality MCQs about \"{topic}\".\n"
506
+ f"Bloom's Taxonomy level: {blooms}. {bl}\n"
507
+ f"{df}\n\n"
508
+ "Rules:\n"
509
+ "- Each question must have exactly 4 options: A, B, C, D\n"
510
+ "- One clearly correct answer\n"
511
+ "- Three plausible distractors (no trick questions, no obviously wrong options)\n"
512
+ "- Clear, unambiguous academic language\n"
513
+ "- No duplicate questions\n\n"
514
+ "Use EXACTLY this format for every question:\n"
515
+ "Q1. [question text]\n"
516
+ "A. [option]\n"
517
+ "B. [option]\n"
518
+ "C. [option]\n"
519
+ "D. [option]\n"
520
+ "Answer: [A/B/C/D]\n"
521
+ "Explanation: [one clear sentence]\n\n"
522
+ f"Generate all {num_q} questions now:"
523
+ )
524
+
525
+ def _parse_generated(text):
526
+ mcqs = []
527
+ for block in re.split(r'\n(?=Q\d+[\.\)])', text.strip()):
528
+ q = re.search(r'Q\d+[\.\)]\s*(.+?)(?:\n|$)', block)
529
+ a = re.search(r'^A[.)]\s*(.+?)(?:\n|$)', block, re.MULTILINE)
530
+ b = re.search(r'^B[.)]\s*(.+?)(?:\n|$)', block, re.MULTILINE)
531
+ c = re.search(r'^C[.)]\s*(.+?)(?:\n|$)', block, re.MULTILINE)
532
+ d = re.search(r'^D[.)]\s*(.+?)(?:\n|$)', block, re.MULTILINE)
533
+ an = re.search(r'Answer:\s*([A-Da-d])', block, re.IGNORECASE)
534
+ ex = re.search(r'Explanation:\s*(.+?)(?:\n|$)', block)
535
+ if q and a and b and c and d and an:
536
+ mcqs.append({
537
+ "question": q.group(1).strip(),
538
+ "A": a.group(1).strip(), "B": b.group(1).strip(),
539
+ "C": c.group(1).strip(), "D": d.group(1).strip(),
540
+ "answer": an.group(1).upper(),
541
+ "explanation": ex.group(1).strip() if ex else "",
542
+ "blooms": "",
543
+ })
544
+ return mcqs
545
+
546
+ def _fallback_mcqs(topic, num_q, blooms):
547
+ pool = [
548
+ {"question": f"Which statement BEST defines {topic}?",
549
+ "A": f"The core concept of {topic}", "B": "An unrelated concept",
550
+ "C": f"A partial aspect of {topic}", "D": "A common misconception",
551
+ "answer": "A", "blooms": "Remember",
552
+ "explanation": f"Option A correctly captures the essential definition of {topic}."},
553
+ {"question": f"How would you BEST explain {topic} in your own words?",
554
+ "A": "It is purely theoretical", "B": f"It describes core principles of {topic}",
555
+ "C": "It replaces all prior knowledge", "D": "It contradicts current research",
556
+ "answer": "B", "blooms": "Understand",
557
+ "explanation": "Explaining in your own words demonstrates understanding."},
558
+ {"question": f"A practitioner applies {topic}. What is MOST appropriate?",
559
+ "A": "Ignore contextual factors", "B": f"Apply structured methods from {topic}",
560
+ "C": "Rely on guesswork", "D": "Avoid established frameworks",
561
+ "answer": "B", "blooms": "Apply",
562
+ "explanation": "Applying domain knowledge systematically is correct."},
563
+ {"question": f"What is the KEY distinction of {topic} vs related concepts?",
564
+ "A": "They are identical", "B": f"{topic} has no practical dimension",
565
+ "C": f"{topic} has a distinct focus", "D": "No comparison exists",
566
+ "answer": "C", "blooms": "Analyse",
567
+ "explanation": "Identifying distinctions is an Analyse-level skill."},
568
+ {"question": f"What is the MOST significant limitation of {topic}?",
569
+ "A": "No limitations", "B": "Perfect for every situation",
570
+ "C": "Scope constrained by context", "D": "All practitioners agree it is irrelevant",
571
+ "answer": "C", "blooms": "Evaluate",
572
+ "explanation": "Judging limitations is Evaluate-level thinking."},
573
+ {"question": f"Design a framework integrating {topic}.",
574
+ "A": "Copy existing solution", "B": f"Develop original approach using {topic}",
575
+ "C": "Avoid established knowledge", "D": "Restrict to single stakeholder",
576
+ "answer": "B", "blooms": "Create",
577
+ "explanation": "Designing a new framework is Create-level thinking."},
578
+ {"question": f"Which resource BEST supports learning {topic}?",
579
+ "A": "Opinion blogs", "B": f"Peer-reviewed literature on {topic}",
580
+ "C": "Outdated manuals", "D": "Unrelated textbook",
581
+ "answer": "B", "blooms": "Evaluate",
582
+ "explanation": "Peer-reviewed sources are the gold standard."},
583
+ {"question": f"Which outcome occurs when {topic} is correctly applied?",
584
+ "A": "Increased confusion", "B": "No measurable impact",
585
+ "C": "Improved measurable results", "D": "Reduced accuracy",
586
+ "answer": "C", "blooms": "Apply",
587
+ "explanation": "Correct implementation yields measurable improvement."},
588
+ {"question": f"A student studying {topic} PRIMARILY focuses on?",
589
+ "A": "Unrelated procedures", "B": f"Core principles of {topic}",
590
+ "C": "Abstract mathematics", "D": "Historical unrelated events",
591
+ "answer": "B", "blooms": "Remember",
592
+ "explanation": f"Core principles are the foundation of {topic}."},
593
+ {"question": f"Which approach gives DEEPEST understanding of {topic}?",
594
+ "A": "Rote memorisation", "B": "Avoiding practical examples",
595
+ "C": "Combining theory with practice", "D": "Textbook theory only",
596
+ "answer": "C", "blooms": "Understand",
597
+ "explanation": "Combining theory and practice is most effective."},
598
+ ]
599
+ if blooms != "Mixed (All)":
600
+ matched = [p for p in pool if p.get("blooms") == blooms]
601
+ pool = (matched * 4 + [p for p in pool if p.get("blooms") != blooms])
602
+ out = []
603
+ for i, p in enumerate(pool[:min(num_q, len(pool))]):
604
+ p = dict(p); p["number"] = i + 1; out.append(p)
605
+ return out
606
+
607
+ def generate_from_topic(topic, num_q, blooms="Mixed (All)", difficulty="Mixed"):
608
+ if not topic.strip(): return [], "no_topic"
609
+ if not _gemini_available():
610
+ mcqs = _fallback_mcqs(topic, int(num_q), blooms)
611
+ return mcqs, "template"
612
+ raw = _gemini_call(_topic_prompt(topic.strip(), int(num_q), blooms, difficulty),
613
+ max_tokens=1500)
614
+ mcqs = _parse_generated(raw) if raw else []
615
+ if not mcqs:
616
+ mcqs = _fallback_mcqs(topic, int(num_q), blooms)
617
+ source = "template"
618
+ else:
619
+ for i, m in enumerate(mcqs[:int(num_q)]): m["number"] = i + 1
620
+ mcqs = mcqs[:int(num_q)]
621
+ source = "ai"
622
+ return mcqs, source
623
+
624
+
625
+ # ══════════════════════════════════════════════════════════════════════════════
626
+ # CLONER — batch Gemini call for efficiency
627
+ # ══════════════════════════════════════════════════════════════════════════════
628
+
629
+ def _clone_prompt_batch(originals, num_clones, blooms):
630
+ bl = BLOOMS.get(blooms, BLOOMS["Mixed (All)"])
631
+ lines = [
632
+ f"You are an academic MCQ author. For each original question below, generate {num_clones} clone(s).",
633
+ f"Bloom's level: {blooms}. {bl}",
634
+ "Rules:",
635
+ "- Each clone must test the same concept with different wording",
636
+ "- Do NOT copy or paraphrase trivially — genuinely rewrite",
637
+ "- Keep 4 options (A B C D), one correct answer, three plausible distractors",
638
+ "- Preserve the correct answer letter where it is known",
639
+ "- Return ONLY a JSON array. No markdown, no explanation.",
640
+ "",
641
+ "JSON format (repeat for every clone):",
642
+ '[{"original_number":1,"clone_num":1,"question":"...","A":"...","B":"...","C":"...","D":"...","answer":"A","explanation":"..."}]',
643
+ "",
644
+ "ORIGINAL QUESTIONS:",
645
+ ]
646
+ for orig in originals:
647
+ ans = orig.get("answer") or "unknown"
648
+ lines.append(
649
+ f'Q{orig.get("number","?")}. {orig["question"]}\n'
650
+ f'A. {orig.get("A","")} B. {orig.get("B","")} '
651
+ f'C. {orig.get("C","")} D. {orig.get("D","")}\n'
652
+ f'Answer: {ans}\n'
653
+ )
654
+ lines.append("\nGenerate all clones now:")
655
+ return "\n".join(lines)
656
+
657
+ def _fallback_clone(orig, n):
658
+ pfx = ["Considering the scenario above,",
659
+ "In the clinical context described,",
660
+ "Based on the information provided,",
661
+ "From best-practice perspective,"][n % 4]
662
+ q = orig["question"]
663
+ return {"number": orig.get("number", 1),
664
+ "question": f"{pfx} {q[0].lower()+q[1:]}" if q else q,
665
+ "A": orig["A"], "B": orig["B"], "C": orig["C"], "D": orig["D"],
666
+ "answer": orig.get("answer"), "explanation": "Rephrased clone.",
667
+ "blooms": "", "source": "clone_fallback"}
668
+
669
+ def clone_mcqs(originals, num_clones, blooms="Mixed (All)"):
670
+ if not originals: return []
671
+
672
+ # Use Gemini batch call if available
673
+ if _gemini_available():
674
+ prompt = _clone_prompt_batch(originals, int(num_clones), blooms)
675
+ raw = _gemini_call(prompt, max_tokens=min(4000, len(originals) * num_clones * 200 + 500))
676
+ if raw:
677
+ raw = re.sub(r'^```(?:json)?\s*', '', raw.strip())
678
+ raw = re.sub(r'\s*```$', '', raw.strip())
679
+ m = re.search(r'\[.*\]', raw, re.DOTALL)
680
+ if m:
681
+ try:
682
+ items = json.loads(m.group())
683
+ results = []
684
+ for item in items:
685
+ if not isinstance(item, dict) or not item.get("question"):
686
+ continue
687
+ orig_num = item.get("original_number", 1)
688
+ orig = next((o for o in originals if o.get("number") == orig_num), originals[0])
689
+ clone = {
690
+ "number": orig_num,
691
+ "question": str(item.get("question", "")).strip(),
692
+ "A": str(item.get("A", "")).strip(),
693
+ "B": str(item.get("B", "")).strip(),
694
+ "C": str(item.get("C", "")).strip(),
695
+ "D": str(item.get("D", "")).strip(),
696
+ "answer": item.get("answer") or orig.get("answer"),
697
+ "explanation": str(item.get("explanation", "")).strip(),
698
+ "blooms": blooms,
699
+ "source": "gemini_clone",
700
+ "clone_num": item.get("clone_num", 1),
701
+ "original_question": orig["question"],
702
+ }
703
+ results.append(clone)
704
+ if results:
705
+ return results
706
+ except Exception:
707
+ pass
708
+
709
+ # Fallback: simple rephrasing without API
710
+ results = []
711
+ for orig in originals:
712
+ for c in range(int(num_clones)):
713
+ clone = _fallback_clone(orig, c)
714
+ clone.update({"original_question": orig["question"],
715
+ "clone_num": c + 1, "number": orig.get("number", 1)})
716
+ results.append(clone)
717
+ return results
718
+
719
+
720
+ # ══════════════════════════════════════════════════════════════════════════════
721
+ # PREVIEW
722
+ # ══════════════════════════════════════════════════════════════════════════════
723
+
724
+ def preview_mcqs(mcqs, title=""):
725
+ if not mcqs: return "No questions found."
726
+ lines = [f"### {title}\n"] if title else []
727
+ lines.append(f"✅ **{len(mcqs)} question(s)**\n")
728
+ for m in mcqs:
729
+ answer = m.get("answer"); q_num = m.get("number", "?")
730
+ bl = f" *[{m['blooms']}]*" if m.get("blooms") else ""
731
+ orig = m.get("original_question")
732
+ if orig:
733
+ lines.append(f"---\n**Original:** {orig}")
734
+ lines.append(f"**Clone {m.get('clone_num',1)}:{bl}** {m['question']}\n")
735
+ else:
736
+ lines.append(f"---\n**Q{q_num}.{bl}** {m['question']}\n")
737
+ for L in "ABCD":
738
+ opt = m.get(L, "")
739
+ if not opt: continue
740
+ lines.append(f"**{L}.** {opt} ✅ **← CORRECT**" if answer and L == answer
741
+ else f"**{L}.** {opt}")
742
+ if not answer: lines.append("⚠️ *Answer not detected*")
743
+ if m.get("explanation"): lines.append(f"\n💡 *{m['explanation']}*")
744
+ lines.append("")
745
+ return "\n".join(lines)
746
+
747
+
748
+ # ══════════════════════════════════════════════════════════════════════════════
749
+ # EXPORTS — Word / PDF / Excel (unchanged)
750
+ # ══════════════════════════════════════════════════════════════════════════════
751
+
752
+ def _sc(cell, hx):
753
+ tc=cell._tc; p=tc.get_or_add_tcPr(); s=OxmlElement('w:shd')
754
+ s.set(qn('w:val'),'clear'); s.set(qn('w:color'),'auto')
755
+ s.set(qn('w:fill'), hx.lstrip('#')); p.append(s)
756
+
757
+ def _hl(run, color='yellow'):
758
+ rPr=run._r.get_or_add_rPr(); hl=OxmlElement('w:highlight')
759
+ hl.set(qn('w:val'), color); rPr.append(hl)
760
+
761
+ def make_word(mcqs, title):
762
+ doc = DocxDocument()
763
+ for sec in doc.sections:
764
+ sec.top_margin=Inches(1); sec.bottom_margin=Inches(1)
765
+ sec.left_margin=Inches(1.2); sec.right_margin=Inches(1)
766
+ hp=doc.sections[0].header.paragraphs[0]
767
+ hp.text=f"EduClone AI · {title} · Abdulqayyum MBA"; hp.alignment=WD_ALIGN_PARAGRAPH.CENTER
768
+ for r in hp.runs: r.font.size=Pt(9); r.font.color.rgb=RGBColor(0x6b,0x72,0x80)
769
+ tp=doc.add_heading(title,level=0); tp.alignment=WD_ALIGN_PARAGRAPH.CENTER
770
+ for r in tp.runs: r.font.color.rgb=RGBColor(0x1a,0x56,0xdb); r.font.size=Pt(20)
771
+ sub=doc.add_paragraph(); sub.alignment=WD_ALIGN_PARAGRAPH.CENTER
772
+ sr=sub.add_run(f"Total: {len(mcqs)} Questions · {datetime.date.today():%d %B %Y} · EduClone AI")
773
+ sr.font.size=Pt(10); sr.italic=True; sr.font.color.rgb=RGBColor(0x6b,0x72,0x80)
774
+ doc.add_paragraph()
775
+ h1=doc.add_heading("EXAMINATION PAPER",level=1)
776
+ for r in h1.runs: r.font.color.rgb=RGBColor(0x1e,0x3a,0x5f)
777
+ ip=doc.add_paragraph("Instructions: Choose the BEST answer.")
778
+ ip.runs[0].italic=True; ip.runs[0].font.size=Pt(10)
779
+ doc.add_paragraph()
780
+ for m in mcqs:
781
+ qp=doc.add_paragraph(); qp.paragraph_format.space_before=Pt(12)
782
+ qr=qp.add_run(f"Q{m.get('number','')}. ")
783
+ qr.bold=True; qr.font.size=Pt(12); qr.font.color.rgb=RGBColor(0x1a,0x56,0xdb)
784
+ if m.get("blooms"):
785
+ qp.add_run(f"[{m['blooms']}] ").font.color.rgb=RGBColor(0x93,0xc5,0xfd)
786
+ for i,line in enumerate(m["question"].split("\n")):
787
+ if not line.strip(): continue
788
+ if i>0: qp.add_run().add_break()
789
+ tr=qp.add_run(line.strip()); tr.bold=True; tr.font.size=Pt(12)
790
+ for L in "ABCD":
791
+ opt=m.get(L,"")
792
+ if not opt: continue
793
+ op=doc.add_paragraph(); op.paragraph_format.left_indent=Inches(0.45)
794
+ op.paragraph_format.space_before=Pt(3)
795
+ lr=op.add_run(f"{L}."); lr.bold=True; lr.font.size=Pt(11)
796
+ lr.font.color.rgb=RGBColor(0x1e,0x3a,0x5f)
797
+ op.add_run(f" {opt}").font.size=Pt(11)
798
+ doc.add_paragraph()
799
+ has_answers=any(m.get("answer") for m in mcqs)
800
+ if has_answers:
801
+ doc.add_page_break()
802
+ h2=doc.add_heading("ANSWER KEY & EXPLANATIONS",level=1)
803
+ for r in h2.runs: r.font.color.rgb=RGBColor(0x05,0x96,0x69)
804
+ doc.add_paragraph("✓ Correct answers highlighted. ⚠️ = not detected.").runs[0].italic=True
805
+ doc.add_paragraph()
806
+ for m in mcqs:
807
+ answer=m.get("answer")
808
+ qp=doc.add_paragraph(); qp.paragraph_format.space_before=Pt(12)
809
+ qr=qp.add_run(f"Q{m.get('number','')}. ")
810
+ qr.bold=True; qr.font.size=Pt(12); qr.font.color.rgb=RGBColor(0x1a,0x56,0xdb)
811
+ for i,line in enumerate(m["question"].split("\n")):
812
+ if not line.strip(): continue
813
+ if i>0: qp.add_run().add_break()
814
+ tr=qp.add_run(line.strip()); tr.bold=True; tr.font.size=Pt(12)
815
+ for L in "ABCD":
816
+ opt=m.get(L,"")
817
+ if not opt: continue
818
+ op=doc.add_paragraph(); op.paragraph_format.left_indent=Inches(0.45)
819
+ op.paragraph_format.space_before=Pt(3)
820
+ if answer and L==answer:
821
+ tk=op.add_run(" ✓ "); tk.bold=True; tk.font.size=Pt(11)
822
+ tk.font.color.rgb=RGBColor(0x05,0x96,0x69)
823
+ lr=op.add_run(f"{L}."); lr.bold=True; lr.font.size=Pt(11)
824
+ lr.font.color.rgb=RGBColor(0xd9,0x77,0x06); _hl(lr)
825
+ tr=op.add_run(f" {opt}"); tr.bold=True; tr.font.size=Pt(11)
826
+ tr.font.color.rgb=RGBColor(0x05,0x96,0x69); _hl(tr)
827
+ else:
828
+ lr=op.add_run(f"{L}."); lr.bold=True; lr.font.size=Pt(11)
829
+ lr.font.color.rgb=RGBColor(0x9c,0xa3,0xaf)
830
+ op.add_run(f" {opt}").font.size=Pt(11)
831
+ if not answer:
832
+ wp=doc.add_paragraph(); wp.paragraph_format.left_indent=Inches(0.45)
833
+ wr=wp.add_run("⚠️ Answer not detected.")
834
+ wr.font.size=Pt(10); wr.italic=True; wr.font.color.rgb=RGBColor(0xd9,0x77,0x06)
835
+ if m.get("explanation"):
836
+ ep=doc.add_paragraph(); ep.paragraph_format.left_indent=Inches(0.45)
837
+ ep.paragraph_format.space_before=Pt(4)
838
+ er=ep.add_run(f"💡 {m['explanation']}"); er.font.size=Pt(10)
839
+ er.italic=True; er.font.color.rgb=RGBColor(0x37,0x41,0x51)
840
+ doc.add_paragraph()
841
+ answered=[m for m in mcqs if m.get("answer")]
842
+ if answered:
843
+ doc.add_page_break()
844
+ h3=doc.add_heading("QUICK ANSWER REFERENCE",level=1)
845
+ for r in h3.runs: r.font.color.rgb=RGBColor(0x1a,0x56,0xdb)
846
+ COLS=5; nr=-(-len(answered)//COLS)
847
+ tbl=doc.add_table(rows=nr+1,cols=COLS*2); tbl.style="Table Grid"
848
+ for ci in range(COLS*2):
849
+ cell=tbl.rows[0].cells[ci]; cell.text="Q #" if ci%2==0 else "Ans"
850
+ for r in cell.paragraphs[0].runs:
851
+ r.bold=True; r.font.size=Pt(10); r.font.color.rgb=RGBColor(0xFF,0xFF,0xFF)
852
+ _sc(cell,"#1a56db")
853
+ for qi,m in enumerate(answered):
854
+ ri=qi//COLS+1; cb=(qi%COLS)*2
855
+ qc=tbl.rows[ri].cells[cb]; ac=tbl.rows[ri].cells[cb+1]
856
+ qc.text=str(m.get("number",qi+1)); ac.text=m["answer"]
857
+ for r in ac.paragraphs[0].runs:
858
+ r.bold=True; r.font.color.rgb=RGBColor(0x05,0x96,0x69)
859
+ _sc(ac,"#f0fdf4")
860
+ doc.add_paragraph()
861
+ fp=doc.add_paragraph("EduClone AI · Abdulqayyum MBA · 18 yrs Academic Assessment")
862
+ fp.alignment=WD_ALIGN_PARAGRAPH.CENTER
863
+ for r in fp.runs: r.font.size=Pt(9); r.italic=True; r.font.color.rgb=RGBColor(0x9c,0xa3,0xaf)
864
+ path=tempfile.mktemp(suffix=".docx"); doc.save(path); return path
865
+
866
+ def _pstyles():
867
+ s=getSampleStyleSheet()
868
+ s.add(ParagraphStyle("PT",parent=s["Title"],fontSize=20,textColor=DARK_BLUE,alignment=TA_CENTER,fontName="Helvetica-Bold",spaceAfter=4))
869
+ s.add(ParagraphStyle("PS",parent=s["Normal"],fontSize=10,textColor=GRAY,alignment=TA_CENTER,fontName="Helvetica-Oblique",spaceAfter=14))
870
+ s.add(ParagraphStyle("PH",parent=s["Normal"],fontSize=13,textColor=DARK_BLUE,fontName="Helvetica-Bold",spaceBefore=12,spaceAfter=8))
871
+ s.add(ParagraphStyle("PAH",parent=s["Normal"],fontSize=13,textColor=GREEN,fontName="Helvetica-Bold",spaceBefore=12,spaceAfter=8))
872
+ s.add(ParagraphStyle("PQN",parent=s["Normal"],fontSize=12,textColor=BLUE,fontName="Helvetica-Bold",spaceBefore=10,spaceAfter=2))
873
+ s.add(ParagraphStyle("PQB",parent=s["Normal"],fontSize=11,textColor=black,fontName="Helvetica-Bold",spaceAfter=3,leading=16,alignment=TA_JUSTIFY))
874
+ s.add(ParagraphStyle("PO",parent=s["Normal"],fontSize=11,textColor=HexColor("#374151"),leftIndent=18,fontName="Helvetica",spaceAfter=3,leading=15))
875
+ s.add(ParagraphStyle("POK",parent=s["Normal"],fontSize=11,textColor=GREEN,leftIndent=18,fontName="Helvetica-Bold",spaceAfter=3,leading=15))
876
+ s.add(ParagraphStyle("PX",parent=s["Normal"],fontSize=10,textColor=HexColor("#374151"),leftIndent=18,fontName="Helvetica-Oblique",spaceBefore=4,spaceAfter=8))
877
+ s.add(ParagraphStyle("PI",parent=s["Normal"],fontSize=10,textColor=GRAY,fontName="Helvetica-Oblique",spaceAfter=12))
878
+ s.add(ParagraphStyle("PW",parent=s["Normal"],fontSize=10,textColor=HexColor("#d97706"),leftIndent=18,fontName="Helvetica-Oblique",spaceAfter=8))
879
+ s.add(ParagraphStyle("PF",parent=s["Normal"],fontSize=8.5,textColor=GRAY,alignment=TA_CENTER,fontName="Helvetica-Oblique"))
880
+ return s
881
+
882
+ def _pdeco(c,d,title):
883
+ c.saveState(); w,h=A4
884
+ c.setFillColor(DARK_BLUE); c.rect(0,h-32,w,32,fill=1,stroke=0)
885
+ c.setFillColor(white); c.setFont("Helvetica-Bold",9)
886
+ c.drawString(1.5*cm,h-20,f"EduClone AI · {title}")
887
+ c.setFont("Helvetica",9)
888
+ c.drawRightString(w-1.5*cm,h-20,f"Abdulqayyum MBA · {datetime.date.today():%d %b %Y}")
889
+ c.setFillColor(DARK_BLUE); c.rect(0,0,w,20,fill=1,stroke=0)
890
+ c.setFillColor(white); c.setFont("Helvetica",8)
891
+ c.drawCentredString(w/2,6,f"Page {d.page}"); c.restoreState()
892
+
893
+ def make_pdf(mcqs, title):
894
+ path=tempfile.mktemp(suffix=".pdf")
895
+ doc=SimpleDocTemplate(path,pagesize=A4,topMargin=1.8*cm,bottomMargin=1.5*cm,
896
+ leftMargin=2*cm,rightMargin=1.8*cm)
897
+ s=_pstyles(); story=[]; on_p=lambda c,d: _pdeco(c,d,title)
898
+ story+=[Spacer(1,0.5*cm),Paragraph(title,s["PT"]),
899
+ Paragraph(f"Questions: {len(mcqs)} · {datetime.date.today():%d %B %Y} · EduClone AI",s["PS"]),
900
+ HRFlowable(width="100%",thickness=1.5,color=BLUE,spaceAfter=14)]
901
+ story+=[Paragraph("EXAMINATION PAPER",s["PH"]),
902
+ Paragraph("Instructions: Choose the BEST answer.",s["PI"]),Spacer(1,0.3*cm)]
903
+ for m in mcqs:
904
+ bl=[Paragraph(f"Q{m.get('number','')}.",s["PQN"])]
905
+ for line in m["question"].split("\n"):
906
+ if line.strip(): bl.append(Paragraph(line.strip(),s["PQB"]))
907
+ bl.append(Spacer(1,0.12*cm))
908
+ for L in "ABCD":
909
+ opt=m.get(L,"")
910
+ if opt: bl.append(Paragraph(f"<b>{L}.</b> {opt}",s["PO"]))
911
+ bl.append(Spacer(1,0.3*cm)); story.append(KeepTogether(bl))
912
+ if any(m.get("answer") for m in mcqs):
913
+ story.append(PageBreak())
914
+ story+=[Paragraph("ANSWER KEY &amp; EXPLANATIONS",s["PAH"]),
915
+ Paragraph("✓ Correct = highlighted. ⚠️ = not detected.",s["PI"]),Spacer(1,0.3*cm)]
916
+ for m in mcqs:
917
+ answer=m.get("answer")
918
+ bl=[Paragraph(f"Q{m.get('number','')}.",s["PQN"])]
919
+ for line in m["question"].split("\n"):
920
+ if line.strip(): bl.append(Paragraph(line.strip(),s["PQB"]))
921
+ bl.append(Spacer(1,0.1*cm))
922
+ for L in "ABCD":
923
+ opt=m.get(L,"")
924
+ if not opt: continue
925
+ if answer and L==answer:
926
+ t=Table([[Paragraph(f'<font color="#059669"><b>✓ {L}. {opt}</b></font>',s["POK"])]],colWidths=["100%"])
927
+ t.setStyle(TableStyle([("BACKGROUND",(0,0),(-1,-1),YELLOW_BG),("ROWPADDING",(0,0),(-1,-1),5),("LEFTPADDING",(0,0),(-1,-1),18)]))
928
+ bl.append(t)
929
+ else:
930
+ bl.append(Paragraph(f'<font color="#9ca3af"><b>{L}.</b> {opt}</font>',s["PO"]))
931
+ if not answer: bl.append(Paragraph("⚠️ Answer not detected.",s["PW"]))
932
+ if m.get("explanation"): bl.append(Paragraph(f'<i>💡 {m["explanation"]}</i>',s["PX"]))
933
+ bl.append(Spacer(1,0.28*cm)); story.append(KeepTogether(bl))
934
+ answered=[m for m in mcqs if m.get("answer")]
935
+ if answered:
936
+ story.append(PageBreak()); story.append(Paragraph("QUICK ANSWER REFERENCE",s["PH"])); story.append(Spacer(1,0.3*cm))
937
+ COLS=5; td=[["Q","Ans"]*COLS]; row=[]
938
+ for m in answered:
939
+ row+=[str(m.get("number","?")),m["answer"]]
940
+ if len(row)==COLS*2: td.append(row); row=[]
941
+ if row:
942
+ while len(row)<COLS*2: row+=["",""]
943
+ td.append(row)
944
+ rt=Table(td,colWidths=[1.1*cm,1.1*cm]*COLS,hAlign="LEFT")
945
+ rt.setStyle(TableStyle([
946
+ ("BACKGROUND",(0,0),(-1,0),DARK_BLUE),("TEXTCOLOR",(0,0),(-1,0),white),
947
+ ("FONTNAME",(0,0),(-1,0),"Helvetica-Bold"),("FONTSIZE",(0,0),(-1,-1),10),
948
+ ("ALIGN",(0,0),(-1,-1),"CENTER"),("VALIGN",(0,0),(-1,-1),"MIDDLE"),
949
+ ("ROWBACKGROUNDS",(0,1),(-1,-1),[HexColor("#f0fdf4"),white]),
950
+ ("GRID",(0,0),(-1,-1),0.5,HexColor("#d1fae5")),("ROWPADDING",(0,0),(-1,-1),5),
951
+ *[("TEXTCOLOR",(c,1),(c,-1),GREEN) for c in range(1,COLS*2,2)],
952
+ *[("FONTNAME",(c,1),(c,-1),"Helvetica-Bold") for c in range(1,COLS*2,2)],
953
+ ]))
954
+ story+=[rt,Spacer(1,1*cm),HRFlowable(width="100%",thickness=1,color=BLUE),
955
+ Paragraph("EduClone AI · Abdulqayyum MBA · 18 yrs Academic Assessment",s["PF"])]
956
+ doc.build(story,onFirstPage=on_p,onLaterPages=on_p); return path
957
+
958
+ def make_excel(mcqs, title):
959
+ wb=openpyxl.Workbook(); ws=wb.active; ws.title="MCQs"
960
+ headers=["#","Question","Option A","Option B","Option C","Option D",
961
+ "Correct Answer","Bloom's Level","Explanation","Source"]
962
+ hfill=PatternFill("solid",fgColor="1A56DB"); hfont=Font(bold=True,color="FFFFFF",size=11)
963
+ for col,h in enumerate(headers,1):
964
+ c=ws.cell(row=1,column=col,value=h); c.fill=hfill; c.font=hfont
965
+ c.alignment=Alignment(horizontal="center",vertical="center",wrap_text=True)
966
+ gfill=PatternFill("solid",fgColor="D1FAE5"); gfont=Font(bold=True,color="065F46",size=11)
967
+ wfill=PatternFill("solid",fgColor="FEF9C3"); afill=PatternFill("solid",fgColor="EFF6FF")
968
+ for i,m in enumerate(mcqs,1):
969
+ row=i+1; answer=m.get("answer") or ""; src=m.get("source","")
970
+ data=[m.get("number",i),m["question"],m.get("A",""),m.get("B",""),m.get("C",""),m.get("D",""),
971
+ answer,m.get("blooms",""),m.get("explanation",""),src]
972
+ for col,val in enumerate(data,1):
973
+ c=ws.cell(row=row,column=col,value=val); c.alignment=Alignment(wrap_text=True,vertical="top")
974
+ if i%2==0: c.fill=afill
975
+ ac=ws.cell(row=row,column=7)
976
+ if answer: ac.fill=gfill; ac.font=gfont
977
+ else: ac.fill=wfill; ac.value="⚠️ Unknown"
978
+ ac.alignment=Alignment(horizontal="center",vertical="center")
979
+ widths=[5,60,28,28,28,28,14,14,50,14]
980
+ for col,w in enumerate(widths,1): ws.column_dimensions[get_column_letter(col)].width=w
981
+ ws.row_dimensions[1].height=28; ws.freeze_panes="A2"
982
+ path=tempfile.mktemp(suffix=".xlsx"); wb.save(path); return path
python-version ADDED
@@ -0,0 +1 @@
 
 
1
+ 3.11
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ gradio>=5.0,<6.0
2
+ google-genai>=0.8.0
3
+ bcrypt>=4.0.0
4
+ pandas>=2.0.0
5
+ python-docx>=0.8.11
6
+ reportlab>=4.0.0
7
+ openpyxl>=3.1.0
8
+ pdfplumber>=0.9.0
9
+ requests>=2.28.0