Spaces:
Sleeping
Sleeping
Update app.py
Browse files
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 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
continue
|
| 237 |
-
if
|
|
|
|
|
|
|
| 238 |
continue
|
| 239 |
if norm_ar(w) == norm_ar(correct):
|
| 240 |
continue
|
| 241 |
-
|
| 242 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
break
|
| 244 |
-
|
| 245 |
-
|
| 246 |
if len(out) < k:
|
| 247 |
-
|
| 248 |
-
|
| 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
|
| 263 |
|
| 264 |
-
# ربط العبارة بجملة مناسبة (
|
| 265 |
sent_for={}
|
| 266 |
for s in sents:
|
| 267 |
-
if
|
| 268 |
continue
|
| 269 |
for kp in keyphrases:
|
| 270 |
-
if kp in sent_for:
|
| 271 |
continue
|
| 272 |
-
|
|
|
|
| 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
|
| 282 |
continue
|
| 283 |
for kp in keyphrases:
|
| 284 |
if kp in sent_for:
|
| 285 |
continue
|
| 286 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 "
|
| 323 |
-
txt=txt.replace(",", "،").replace("?", "؟").replace(";", "؛")
|
| 324 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 بعد الرندر (مع
|
| 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 |
|