Leen172 commited on
Commit
82f92ea
·
verified ·
1 Parent(s): 8285ac1

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +196 -57
app.py CHANGED
@@ -132,9 +132,9 @@ def yake_keywords(t: str, k: int = 160) -> List[str]:
132
  pairs = []
133
  for w, _ in pairs:
134
  w = re2.sub(r"\s+", " ", w.strip())
135
- if not w or w in seen:
136
  continue
137
- if re2.match(r"^[\p{P}\p{S}\d_]+$", w):
138
  continue
139
  if 2 <= len(w) <= 40:
140
  phrases.append(w)
@@ -144,6 +144,58 @@ def yake_keywords(t: str, k: int = 160) -> List[str]:
144
  def good_kw(kw:str)->bool:
145
  return kw and len(kw)>=2 and kw not in AR_STOP and not re2.match(r"^[\p{P}\p{S}\d_]+$", kw)
146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  # ====== (2) جيران دلاليًا + (3) FILL-MASK كبديل ======
148
  _EMB = None
149
  def get_embedder():
@@ -211,79 +263,130 @@ def legacy_distractors(correct:str, pool:List[str], k:int=3)->List[str]:
211
  if abs(len(w)-L)<=3: cand.append(w)
212
  random.shuffle(cand)
213
  out=cand[:k]
214
- while len(out)<k: out.append("")
215
  return out
216
 
217
- def smart_distractors(correct: str, phrase_pool: List[str], sentence: str, k: int = 3) -> List[str]:
218
- # 1) جيران دلاليًا
219
- neigh = nearest_terms(correct, phrase_pool, k=12)
220
- neigh = [w for w,sim in neigh if w != correct][:k+4]
221
-
222
- # 2) FILL-MASK على الجملة (بديل)
223
- if len(neigh) < k:
224
- mlm = mlm_distractors(sentence.replace(correct, "_____"), correct, k=10)
225
- for w in mlm:
226
- if w not in neigh and w != correct:
227
- neigh.append(w)
228
- if len(neigh) >= k+4:
229
- break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
 
231
- # 3) فلترة خفيفة
232
- out = []
233
- L = len(correct)
234
- for w in neigh:
235
- if w in AR_STOP:
 
 
 
 
 
 
 
 
 
 
 
 
236
  continue
237
- if abs(len(w) - L) > max(6, L//2):
 
 
238
  continue
239
  if norm_ar(w) == norm_ar(correct):
240
  continue
241
- out.append(w)
242
- if len(out) >= k:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  break
244
-
245
- # 4) رجوع للخطة القديمة إذا ما كفى
246
  if len(out) < k:
247
- extra = legacy_distractors(correct, phrase_pool, k=k-len(out))
248
- out.extend(extra)
249
-
250
- while len(out) < k:
251
- out.append("—")
252
- return out
253
 
254
  # ====== (4) مُولِّد أسئلة جديد بمحافظته على نفس الواجهة تمامًا ======
255
  def make_mcqs(text:str, n:int=6)->List[MCQ]:
256
  sents=split_sents(text)
257
- if not sents:
258
  raise ValueError("النص قصير أو غير صالح.")
259
 
260
- # عبارات مفتاحية 1–3 كلمات
261
  keyphrases = yake_keywords(text, k=160)
262
- keyphrases = [kp for kp in keyphrases if good_kw(kp) and 2 <= len(kp) <= 40]
263
 
264
- # ربط العبارة بجملة مناسبة (طول معقول 60) لضمان سياق واضح
265
  sent_for={}
266
  for s in sents:
267
- if len(s) < 60:
268
  continue
269
  for kp in keyphrases:
270
- if kp in sent_for:
271
  continue
272
- if re2.search(rf"(?<!\p{{L}}){re2.escape(kp)}(?!\p{{L}})", s):
 
273
  sent_for[kp]=s
 
 
274
 
275
  if not sent_for:
276
- # fallback: لو ما لقينا مطابقات جيدة، نرجع للمفردات العامة من النص
277
  tokens = [t for t in re2.findall(r"[\p{L}\p{N}_]+", text) if good_kw(t)]
278
  freq = [w for w,_ in sorted(((t, text.count(t)) for t in tokens), key=lambda x:-x[1])]
279
- keyphrases = freq[:120]
280
  for s in sents:
281
- if len(s) < 60:
282
  continue
283
  for kp in keyphrases:
284
  if kp in sent_for:
285
  continue
286
- if re2.search(rf"(?<!\p{{L}}){re2.escape(kp)}(?!\p{{L}})", s):
 
287
  sent_for[kp]=s
288
  if len(sent_for)>=n*2:
289
  break
@@ -296,7 +399,7 @@ def make_mcqs(text:str, n:int=6)->List[MCQ]:
296
  for kp in sorted(sent_for.keys(), key=lambda x: (-len(x), x)):
297
  if len(items)>=n: break
298
  s=sent_for[kp]
299
- if s in used_sents or kp in used_keys:
300
  continue
301
 
302
  # ابنِ سؤال الفراغ
@@ -305,23 +408,47 @@ def make_mcqs(text:str, n:int=6)->List[MCQ]:
305
  # مشتتات أذكى (مع رجوع تلقائي لو النماذج مش متاحة)
306
  pool = [x for x in keyphrases if x != kp]
307
  ch = smart_distractors(kp, pool, s, k=3) + [kp]
308
- random.shuffle(ch); ans=ch.index(kp)
 
 
 
 
 
 
 
 
 
 
 
 
 
309
 
310
  items.append(MCQ(id=str(uuid.uuid4())[:8], question=q, choices=ch, answer_index=ans))
311
  used_sents.add(s); used_keys.add(kp)
312
 
313
- if not items:
314
  raise RuntimeError("تعذّر توليد أسئلة.")
315
  return items
316
 
 
 
 
 
 
 
 
317
  def to_records(items:List[MCQ])->List[dict]:
318
  recs=[]
319
  for it in items:
320
  opts=[]
 
321
  for i,lbl in enumerate(["A","B","C","D"]):
322
- txt=(it.choices[i] if i<len(it.choices) else "").strip()
323
- txt=txt.replace(",", "،").replace("?", "؟").replace(";", "؛")
324
- opts.append({"id":lbl,"text":txt or "—","is_correct":(i==it.answer_index)})
 
 
 
325
  recs.append({"id":it.id,"question":it.question.strip(),"options":opts})
326
  return recs
327
 
@@ -333,7 +460,7 @@ def render_quiz_html(records: List[dict]) -> str:
333
  qtxt = rec["question"]
334
  cor = next((o["id"] for o in rec["options"] if o["is_correct"]), "")
335
  opts_html=[]
336
- for o in rec["options"]:
337
  lid, txt = o["id"], o["text"]
338
  opts_html.append(f"""
339
  <label class="opt" data-letter="{lid}">
@@ -372,7 +499,7 @@ def build_quiz(text_area, file_path, n, model_id, zoom):
372
  recs = to_records(items)
373
  return render_quiz_html(recs), gr.update(visible=False), gr.update(visible=True), ""
374
 
375
- # ------------------ CSS ------------------
376
  CSS = """
377
  :root{
378
  --bg:#0e0e11; --panel:#15161a; --card:#1a1b20; --muted:#a7b0be;
@@ -404,7 +531,7 @@ textarea{min-height:120px}
404
  .q-badge.ok{background:#083a2a;color:#b6f4db;border:1px solid #145b44}
405
  .q-badge.err{background:#3a0d14;color:#ffd1d6;border:1px solid #6a1e2b}
406
 
407
- .q-text{color:var(--text);font-size:1.06rem;line-height:1.8;margin:8px 0 12px}
408
  .opts{display:flex;flex-direction:column;gap:8px}
409
  .opt{display:flex;gap:10px;align-items:center;background:#14161c;border:1px solid #2a2d3a;border-radius:12px;padding:10px;transition:background .15s,border-color .15s}
410
  .opt input{accent-color:var(--accent2)}
@@ -422,7 +549,7 @@ textarea{min-height:120px}
422
  .q-note.warn{color:#ffd1d6}
423
  """
424
 
425
- # ------------------ JS: ربط Submit بعد الرندر (مع Output مخفي لضمان التنفيذ) ------------------
426
  ATTACH_LISTENERS_JS = """
427
  () => {
428
  // اربط مرة واحدة فقط
@@ -448,7 +575,7 @@ ATTACH_LISTENERS_JS = """
448
 
449
  const chosenLabel = chosen.closest('.opt');
450
 
451
- // حالة صحيحة: لوّن أخضر وأقفل السؤال كاملاً
452
  if (chosen.value === correct) {
453
  chosenLabel.classList.add('ok');
454
  if (badge){ badge.hidden=false; badge.className='q-badge ok'; badge.textContent='Correct!'; }
@@ -456,21 +583,33 @@ ATTACH_LISTENERS_JS = """
456
  card.querySelectorAll('input[type="radio"]').forEach(i => i.disabled = true);
457
  e.target.disabled = true;
458
  if (note) note.textContent = '';
 
 
 
 
 
 
 
 
 
 
 
 
 
459
  return;
460
  }
461
 
462
  // حالة خاطئة: لوّن أحمر فقط، ولا تعطل أي شيء — ليقدر يجرّب خيار آخر
463
- chosenLabel.classList.add('err'); // اتركه أحمر
464
  if (badge){ badge.hidden=false; badge.className='q-badge err'; badge.textContent='Incorrect.'; }
465
  if (note) note.textContent = '';
466
- // مهم: لا تعطّل الراديو ولا الزر
467
  });
468
 
469
  return 'wired-multi2';
470
  }
471
  """
472
 
473
- # ------------------ واجهة Gradio ------------------
474
  with gr.Blocks(title="Question Generator", css=CSS) as demo:
475
  gr.Markdown("<h2 class='top'>Question Generator</h2>")
476
 
 
132
  pairs = []
133
  for w, _ in pairs:
134
  w = re2.sub(r"\s+", " ", w.strip())
135
+ if not w or w in seen:
136
  continue
137
+ if re2.match(r"^[\p{P}\p{S}\d_]+$", w):
138
  continue
139
  if 2 <= len(w) <= 40:
140
  phrases.append(w)
 
144
  def good_kw(kw:str)->bool:
145
  return kw and len(kw)>=2 and kw not in AR_STOP and not re2.match(r"^[\p{P}\p{S}\d_]+$", kw)
146
 
147
+ # ====== تحسينات "الذكاء": POS/NER اختياري مع fallback ======
148
+ _HAS_CAMEL = False
149
+ try:
150
+ from camel_tools.tokenizers.word import simple_word_tokenize
151
+ from camel_tools.morphology.analyzer import Analyzer
152
+ from camel_tools.ner import NERecognizer
153
+ _HAS_CAMEL = True
154
+ _AN = Analyzer.builtin_analyzer()
155
+ _NER = NERecognizer.pretrained()
156
+ except Exception:
157
+ _HAS_CAMEL = False
158
+
159
+ NER_TAGS = {"PER","LOC","ORG","MISC"} # أسماء علم
160
+
161
+ def ar_pos(word: str) -> str:
162
+ if not _HAS_CAMEL:
163
+ # fallback مبسّط
164
+ if re2.match(r"^(في|على|الى|إلى|من|عن|حتى|ثم|بل|لكن|أو|و)$", word): return "PART"
165
+ if re2.match(r"^[\p{N}]+$", word): return "NUM"
166
+ if re2.search(r"(ة|ات|ون|ين|ان)$", word): return "NOUN"
167
+ return "X"
168
+ try:
169
+ ana = _AN.analyze(word)
170
+ if not ana: return "X"
171
+ pos_candidates = [a.get('pos','X') for a in ana]
172
+ # خذ الأكثر تكرارًا
173
+ from collections import Counter
174
+ return Counter(pos_candidates).most_common(1)[0][0] if pos_candidates else "X"
175
+ except Exception:
176
+ return "X"
177
+
178
+ def is_named_entity(token: str) -> bool:
179
+ if not _HAS_CAMEL:
180
+ return False
181
+ try:
182
+ tag = _NER.predict_sentence([token])[0]
183
+ return tag in NER_TAGS
184
+ except Exception:
185
+ return False
186
+
187
+ def is_clean_sentence(s: str) -> bool:
188
+ if not (70 <= len(s) <= 220): return False
189
+ if re2.search(r"https?://|www\.", s): return False
190
+ if re2.search(r"\d{2,}", s): return False
191
+ return True
192
+
193
+ def safe_keyword(k: str) -> bool:
194
+ if not good_kw(k): return False
195
+ if is_named_entity(k): return False
196
+ if ar_pos(k) in {"PRON","PART"}: return False
197
+ return True
198
+
199
  # ====== (2) جيران دلاليًا + (3) FILL-MASK كبديل ======
200
  _EMB = None
201
  def get_embedder():
 
263
  if abs(len(w)-L)<=3: cand.append(w)
264
  random.shuffle(cand)
265
  out=cand[:k]
266
+ while len(out)<k: out.append("")
267
  return out
268
 
269
+ # ====== Cross-Encoder اختياري للترتيب ======
270
+ _CE = None
271
+ def get_cross_encoder():
272
+ global _CE
273
+ if _CE is None:
274
+ try:
275
+ from sentence_transformers import CrossEncoder
276
+ _CE = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
277
+ except Exception:
278
+ _CE = False
279
+ return _CE
280
+
281
+ def pos_compatible(a: str, b: str) -> bool:
282
+ pa, pb = ar_pos(a), ar_pos(b)
283
+ if "X" in (pa, pb):
284
+ return True
285
+ return pa == pb
286
+
287
+ def length_close(a: str, b: str) -> bool:
288
+ return abs(len(a) - len(b)) <= max(6, len(b)//2)
289
+
290
+ def rank_by_ce(sentence_with_blank: str, candidates: List[str]) -> List[str]:
291
+ ce = get_cross_encoder()
292
+ if not ce or not candidates:
293
+ return candidates
294
+ pairs = [(sentence_with_blank.replace("_____", c), c) for c in candidates]
295
+ try:
296
+ scores = ce.predict([p[0] for p in pairs])
297
+ ranked = [c for _, c in sorted(zip(scores, [p[1] for p in pairs]), key=lambda x:-x[0])]
298
+ return ranked
299
+ except Exception:
300
+ return candidates
301
 
302
+ def smart_distractors(correct: str, phrase_pool: List[str], sentence: str, k: int = 3) -> List[str]:
303
+ base = []
304
+ # 1) جيران دلاليين
305
+ neigh = nearest_terms(correct, phrase_pool, k=20)
306
+ base.extend([w for w,_ in neigh])
307
+ # 2) FILL-MASK بديل
308
+ mlm = mlm_distractors(sentence.replace(correct, "_____"), correct, k=15)
309
+ for w in mlm:
310
+ if w not in base:
311
+ base.append(w)
312
+ # 3) فلترة POS/NER وطول وتشابه/تطبيع
313
+ clean = []
314
+ for w in base:
315
+ w = w.strip()
316
+ if not w or w == correct:
317
+ continue
318
+ if is_named_entity(w):
319
  continue
320
+ if not pos_compatible(w, correct):
321
+ continue
322
+ if not length_close(w, correct):
323
  continue
324
  if norm_ar(w) == norm_ar(correct):
325
  continue
326
+ clean.append(w)
327
+ # 4) ترتيب Cross-Encoder اختياري
328
+ clean = rank_by_ce(sentence.replace(correct, "_____"), clean)[:max(k*2, k)]
329
+ # 5) إزالة المتشابه جداً مع الجواب
330
+ try:
331
+ emb = get_embedder()
332
+ if emb and clean:
333
+ vecs = emb.encode([correct] + clean, normalize_embeddings=True)
334
+ c, others = vecs[0], vecs[1:]
335
+ import numpy as np
336
+ sims = others @ c
337
+ filtered = [w for w, s in zip(clean, sims) if s < 0.92]
338
+ if len(filtered) >= k:
339
+ clean = filtered
340
+ except Exception:
341
+ pass
342
+ out = clean[:k]
343
+ while len(out) < k:
344
+ extra = [w for w in phrase_pool if w not in out and w != correct and length_close(w, correct)]
345
+ if not extra:
346
  break
347
+ out.extend(extra[:(k-len(out))])
348
+ break
349
  if len(out) < k:
350
+ out.extend(legacy_distractors(correct, phrase_pool, k=k-len(out)))
351
+ return out[:k]
 
 
 
 
352
 
353
  # ====== (4) مُولِّد أسئلة جديد بمحافظته على نفس الواجهة تمامًا ======
354
  def make_mcqs(text:str, n:int=6)->List[MCQ]:
355
  sents=split_sents(text)
356
+ if not sents:
357
  raise ValueError("النص قصير أو غير صالح.")
358
 
359
+ # عبارات مفتاحية 1–3 كلمات + فلترة أذكى
360
  keyphrases = yake_keywords(text, k=160)
361
+ keyphrases = [kp for kp in keyphrases if safe_keyword(kp) and 2 <= len(kp) <= 40]
362
 
363
+ # ربط العبارة بجملة مناسبة (نظيفة، ظهور وحيد للعبارة)
364
  sent_for={}
365
  for s in sents:
366
+ if not is_clean_sentence(s):
367
  continue
368
  for kp in keyphrases:
369
+ if kp in sent_for:
370
  continue
371
+ hits = re2.findall(rf"(?<!\p{{L}}){re2.escape(kp)}(?!\p{{L}})", s)
372
+ if len(hits) == 1:
373
  sent_for[kp]=s
374
+ if len(sent_for)>=n*3:
375
+ break
376
 
377
  if not sent_for:
378
+ # fallback: لو ما لقينا مطابقات جيدة، نرجع للمفردات من النص
379
  tokens = [t for t in re2.findall(r"[\p{L}\p{N}_]+", text) if good_kw(t)]
380
  freq = [w for w,_ in sorted(((t, text.count(t)) for t in tokens), key=lambda x:-x[1])]
381
+ keyphrases = [w for w in freq if safe_keyword(w)][:120]
382
  for s in sents:
383
+ if not is_clean_sentence(s):
384
  continue
385
  for kp in keyphrases:
386
  if kp in sent_for:
387
  continue
388
+ hits = re2.findall(rf"(?<!\p{{L}}){re2.escape(kp)}(?!\p{{L}})", s)
389
+ if len(hits) == 1:
390
  sent_for[kp]=s
391
  if len(sent_for)>=n*2:
392
  break
 
399
  for kp in sorted(sent_for.keys(), key=lambda x: (-len(x), x)):
400
  if len(items)>=n: break
401
  s=sent_for[kp]
402
+ if s in used_sents or kp in used_keys:
403
  continue
404
 
405
  # ابنِ سؤال الفراغ
 
408
  # مشتتات أذكى (مع رجوع تلقائي لو النماذج مش متاحة)
409
  pool = [x for x in keyphrases if x != kp]
410
  ch = smart_distractors(kp, pool, s, k=3) + [kp]
411
+ # تنظيف سريع وخلوّ من التكرار
412
+ clean_choices=[]
413
+ seen=set()
414
+ for c in ch:
415
+ c = c.strip()
416
+ if not c: continue
417
+ if c in seen: continue
418
+ seen.add(c)
419
+ clean_choices.append(c)
420
+ ch = clean_choices[:4]
421
+ # تأكيد وجود 4 خيارات
422
+ while len(ch)<4:
423
+ ch.append("…")
424
+ random.shuffle(ch); ans=ch.index(kp) if kp in ch else 3
425
 
426
  items.append(MCQ(id=str(uuid.uuid4())[:8], question=q, choices=ch, answer_index=ans))
427
  used_sents.add(s); used_keys.add(kp)
428
 
429
+ if not items:
430
  raise RuntimeError("تعذّر توليد أسئلة.")
431
  return items
432
 
433
+ def clean_option_text(t: str) -> str:
434
+ t = (t or "").strip()
435
+ t = re2.sub(AR_DIAC, "", t)
436
+ t = re2.sub(r"\s+", " ", t)
437
+ t = re2.sub(r"^[\p{P}\p{S}_-]+|[\p{P}\p{S}_-]+$", "", t)
438
+ return t or "…"
439
+
440
  def to_records(items:List[MCQ])->List[dict]:
441
  recs=[]
442
  for it in items:
443
  opts=[]
444
+ used=set()
445
  for i,lbl in enumerate(["A","B","C","D"]):
446
+ txt=(it.choices[i] if i<len(it.choices) else "")
447
+ txt=clean_option_text(txt.replace(",", "،").replace("?", "؟").replace(";", "؛"))
448
+ if txt in used:
449
+ txt = f"…{i+1}"
450
+ used.add(txt)
451
+ opts.append({"id":lbl,"text":txt,"is_correct":(i==it.answer_index)})
452
  recs.append({"id":it.id,"question":it.question.strip(),"options":opts})
453
  return recs
454
 
 
460
  qtxt = rec["question"]
461
  cor = next((o["id"] for o in rec["options"] if o["is_correct"]), "")
462
  opts_html=[]
463
+ for o in rec["options"]]:
464
  lid, txt = o["id"], o["text"]
465
  opts_html.append(f"""
466
  <label class="opt" data-letter="{lid}">
 
499
  recs = to_records(items)
500
  return render_quiz_html(recs), gr.update(visible=False), gr.update(visible=True), ""
501
 
502
+ # ------------------ CSS (كما هو) ------------------
503
  CSS = """
504
  :root{
505
  --bg:#0e0e11; --panel:#15161a; --card:#1a1b20; --muted:#a7b0be;
 
531
  .q-badge.ok{background:#083a2a;color:#b6f4db;border:1px solid #145b44}
532
  .q-badge.err{background:#3a0d14;color:#ffd1d6;border:1px solid #6a1e2b}
533
 
534
+ .q-text{color:#eaeaf2;font-size:1.06rem;line-height:1.8;margin:8px 0 12px}
535
  .opts{display:flex;flex-direction:column;gap:8px}
536
  .opt{display:flex;gap:10px;align-items:center;background:#14161c;border:1px solid #2a2d3a;border-radius:12px;padding:10px;transition:background .15s,border-color .15s}
537
  .opt input{accent-color:var(--accent2)}
 
549
  .q-note.warn{color:#ffd1d6}
550
  """
551
 
552
+ # ------------------ JS: ربط Submit بعد الرندر (مع تحسين إبراز الصحيحة) ------------------
553
  ATTACH_LISTENERS_JS = """
554
  () => {
555
  // اربط مرة واحدة فقط
 
575
 
576
  const chosenLabel = chosen.closest('.opt');
577
 
578
+ // حالة صحيحة: لوّن أخضر وأقفل السؤال كاملاً + إبراز الكلمة الصحيحة داخل الجملة
579
  if (chosen.value === correct) {
580
  chosenLabel.classList.add('ok');
581
  if (badge){ badge.hidden=false; badge.className='q-badge ok'; badge.textContent='Correct!'; }
 
583
  card.querySelectorAll('input[type="radio"]').forEach(i => i.disabled = true);
584
  e.target.disabled = true;
585
  if (note) note.textContent = '';
586
+
587
+ // إبراز الجواب الصحيح ضمن الجملة الحالية دون تغيير البنية
588
+ const qNode = card.querySelector('.q-text');
589
+ if (qNode){
590
+ const full = qNode.textContent || '';
591
+ const correctText = [...card.querySelectorAll('.opt')].find(o =>
592
+ o.querySelector('input').value === correct
593
+ )?.querySelector('.opt-text')?.textContent || '';
594
+ if (full && correctText){
595
+ const highlighted = full.replace('_____', `<mark style="background:#2dd4bf22;border:1px solid #2dd4bf55;border-radius:6px;padding:0 4px">${correctText}</mark>`);
596
+ qNode.innerHTML = highlighted;
597
+ }
598
+ }
599
  return;
600
  }
601
 
602
  // حالة خاطئة: لوّن أحمر فقط، ولا تعطل أي شيء — ليقدر يجرّب خيار آخر
603
+ chosenLabel.classList.add('err');
604
  if (badge){ badge.hidden=false; badge.className='q-badge err'; badge.textContent='Incorrect.'; }
605
  if (note) note.textContent = '';
 
606
  });
607
 
608
  return 'wired-multi2';
609
  }
610
  """
611
 
612
+ # ------------------ واجهة Gradio (بدون تغيير بنية الواجهات) ------------------
613
  with gr.Blocks(title="Question Generator", css=CSS) as demo:
614
  gr.Markdown("<h2 class='top'>Question Generator</h2>")
615