shahad altamimi Claude Opus 4.8 commited on
Commit
90badd7
·
1 Parent(s): 49f9fdf

تعديل أسماء وترتيب النماذج، حذف Reranking، إصلاح إعادة تعيين التقييم

Browse files
reverse-dictionary/backend/app/database.py CHANGED
@@ -23,7 +23,7 @@ from pathlib import Path
23
  import pandas as pd
24
  import streamlit as st
25
  from sqlalchemy import (
26
- Boolean, Column, DateTime, Float, Integer, MetaData, Table, Text,
27
  create_engine, delete, func, insert, select, update,
28
  )
29
 
@@ -51,7 +51,6 @@ ratings = Table(
51
  Column("word", Text),
52
  Column("similarity", Float),
53
  Column("verdict", Text), # "صح" أو "خطأ"
54
- Column("rerank", Boolean), # هل كان Reranking مفعّلًا؟
55
  Column("evaluator_name", Text),
56
  Column("evaluator_email", Text),
57
  )
@@ -66,7 +65,6 @@ _AR_COLUMNS = {
66
  "verdict": "التقييم",
67
  "evaluator_name": "المقيّم",
68
  "evaluator_email": "الإيميل",
69
- "rerank": "إعادة الترتيب",
70
  }
71
 
72
 
@@ -154,8 +152,7 @@ def upsert_evaluator(name: str, email: str) -> None:
154
  # 💾 حفظ تقييم استعلام كامل (كل النماذج وكلماتها دفعة واحدة)
155
  # ------------------------------------------------------------
156
  def save_evaluation(query: str, per_model: list[dict],
157
- evaluator_name: str, evaluator_email: str,
158
- rerank: bool) -> int:
159
  """per_model = [{"model": str, "items": [{"lemma","score","chosen"}, ...]}, ...].
160
  يرجّع عدد الصفوف المحفوظة."""
161
  ts = datetime.now() # وقت واحد لكل صفوف هذا الحفظ (لتماسك التجميع)
@@ -169,7 +166,6 @@ def save_evaluation(query: str, per_model: list[dict],
169
  "word": item["lemma"],
170
  "similarity": round(float(item["score"]), 3),
171
  "verdict": "صح" if item["chosen"] else "خطأ",
172
- "rerank": bool(rerank),
173
  "evaluator_name": evaluator_name,
174
  "evaluator_email": evaluator_email,
175
  })
 
23
  import pandas as pd
24
  import streamlit as st
25
  from sqlalchemy import (
26
+ Column, DateTime, Float, Integer, MetaData, Table, Text,
27
  create_engine, delete, func, insert, select, update,
28
  )
29
 
 
51
  Column("word", Text),
52
  Column("similarity", Float),
53
  Column("verdict", Text), # "صح" أو "خطأ"
 
54
  Column("evaluator_name", Text),
55
  Column("evaluator_email", Text),
56
  )
 
65
  "verdict": "التقييم",
66
  "evaluator_name": "المقيّم",
67
  "evaluator_email": "الإيميل",
 
68
  }
69
 
70
 
 
152
  # 💾 حفظ تقييم استعلام كامل (كل النماذج وكلماتها دفعة واحدة)
153
  # ------------------------------------------------------------
154
  def save_evaluation(query: str, per_model: list[dict],
155
+ evaluator_name: str, evaluator_email: str) -> int:
 
156
  """per_model = [{"model": str, "items": [{"lemma","score","chosen"}, ...]}, ...].
157
  يرجّع عدد الصفوف المحفوظة."""
158
  ts = datetime.now() # وقت واحد لكل صفوف هذا الحفظ (لتماسك التجميع)
 
166
  "word": item["lemma"],
167
  "similarity": round(float(item["score"]), 3),
168
  "verdict": "صح" if item["chosen"] else "خطأ",
 
169
  "evaluator_name": evaluator_name,
170
  "evaluator_email": evaluator_email,
171
  })
reverse-dictionary/backend/app/models_loader.py CHANGED
@@ -26,7 +26,6 @@ class ResourceManager:
26
  self.df: pd.DataFrame | None = None
27
  self.embeddings: dict[str, np.ndarray] = {} # model_key -> مصفوفة embeddings
28
  self.models: dict[str, SentenceTransformer] = {} # model_key -> النموذج المحمّل
29
- self.reranker = None # نموذج الـ Cross-Encoder (يُحمّل كسولًا عند أول استخدام)
30
 
31
  # --------------------------------------------------------
32
  # 📂 تحميل البيانات المنظّفة
@@ -67,22 +66,6 @@ class ResourceManager:
67
  print(f"✅ تم تحميل: {model_key}")
68
  return self.models[model_key]
69
 
70
- # --------------------------------------------------------
71
- # 🔁 تحميل نموذج إعادة الترتيب (Cross-Encoder) كسولًا
72
- # --------------------------------------------------------
73
- def get_reranker(self):
74
- """يحمّل الـ reranker مرة واحدة عند أول بحث بـ rerank=True.
75
-
76
- لا نضمّه في warmup لأنه اختياري وثقيل؛ فنؤجّل تحميله حتى
77
- يُطلب فعلًا (قد يأخذ أول طلب reranking بعض الوقت).
78
- """
79
- if self.reranker is None:
80
- from app.reranker import load_reranker
81
- print("⏳ تحميل نموذج إعادة الترتيب (Reranker) ...")
82
- self.reranker = load_reranker()
83
- print("✅ تم تحميل الـ Reranker")
84
- return self.reranker
85
-
86
  # --------------------------------------------------------
87
  # 🔥 تسخين: تحميل كل النماذج والـ embeddings مسبقًا
88
  # --------------------------------------------------------
 
26
  self.df: pd.DataFrame | None = None
27
  self.embeddings: dict[str, np.ndarray] = {} # model_key -> مصفوفة embeddings
28
  self.models: dict[str, SentenceTransformer] = {} # model_key -> النموذج المحمّل
 
29
 
30
  # --------------------------------------------------------
31
  # 📂 تحميل البيانات المنظّفة
 
66
  print(f"✅ تم تحميل: {model_key}")
67
  return self.models[model_key]
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  # --------------------------------------------------------
70
  # 🔥 تسخين: تحميل كل النماذج والـ embeddings مسبقًا
71
  # --------------------------------------------------------
reverse-dictionary/backend/app/reranker.py DELETED
@@ -1,49 +0,0 @@
1
- # ============================================================
2
- # 📄 الملف: app/reranker.py
3
- # 🎯 الغرض: مرحلة إعادة الترتيب (Reranking) باستخدام Cross-Encoder.
4
- #
5
- # البحث الدلالي (search.py) يجيب أفضل N مرشّح بسرعة عبر تشابه
6
- # الجيب التمام (bi-encoder + cosine). لكن الـ bi-encoder يرمّز
7
- # الاستعلام والتعريف كلٌّ على حدة، فلا "يقارنهما مباشرة".
8
- #
9
- # الـ Cross-Encoder أدق: يأخذ الزوج (الاستعلام، التعريف) معًا
10
- # ويعطي درجة تطابق واحدة. نستخدمه لإعادة ترتيب المرشّحين القلائل
11
- # (مثلًا 40) بدل تطبيقه على كل البيانات (لأنه أبطأ بكثير).
12
- #
13
- # النموذج المستخدم: BAAI/bge-reranker-v2-m3 (config.RERANKER_MODEL).
14
- # يُحمّل كسولًا داخل ResourceManager (models_loader.py).
15
- # ============================================================
16
-
17
- from sentence_transformers import CrossEncoder
18
-
19
- import config
20
-
21
-
22
- def load_reranker() -> CrossEncoder:
23
- """يحمّل نموذج الـ Cross-Encoder (يُستدعى مرة واحدة كسولًا)."""
24
- return CrossEncoder(config.RERANKER_MODEL, device=config.DEVICE)
25
-
26
-
27
- def rerank(model: CrossEncoder, query: str, candidates: list[dict]) -> list[dict]:
28
- """
29
- يعيد ترتيب قائمة المرشّحين تنازليًا حسب درجة (الاستعلام، التعريف).
30
-
31
- المعاملات:
32
- model : نموذج الـ Cross-Encoder المحمّل.
33
- query : نص التعريف المُدخل من المستخدم.
34
- candidates : قائمة نتائج (كل عنصر فيه "definition") من المرحلة الأولى.
35
-
36
- يرجّع نفس العناصر لكن مرتّبة حسب درجة إعادة الترتيب (الأعلى أولًا).
37
- """
38
- if not candidates:
39
- return candidates
40
-
41
- # نكوّن أزواج (الاستعلام، التعريف) — وهي ما يقارنه الـ Cross-Encoder
42
- pairs = [[query, c.get("definition") or c.get("lemma") or ""] for c in candidates]
43
-
44
- # درجة لكل زوج (كلما زادت، زاد التطابق)
45
- scores = model.predict(pairs)
46
-
47
- # ترتيب الفهارس تنازليًا حسب الدرجة
48
- order = sorted(range(len(candidates)), key=lambda i: float(scores[i]), reverse=True)
49
- return [candidates[i] for i in order]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
reverse-dictionary/backend/app/search.py CHANGED
@@ -16,7 +16,6 @@ import pandas as pd
16
  from sklearn.metrics.pairwise import cosine_similarity
17
 
18
  import config
19
- from app import reranker
20
  from app.models_loader import resources
21
 
22
 
@@ -40,7 +39,7 @@ def _build_item(row, score: float) -> dict:
40
 
41
 
42
  def search_one_model(query: str, model_cfg: dict, threshold: float,
43
- top_k: int | None = None, rerank: bool = False) -> list[dict]:
44
  """
45
  يبحث في نموذج واحد ويرجّع قائمة نتائج مرتّبة تنازليًا حسب التشابه.
46
 
@@ -50,8 +49,6 @@ def search_one_model(query: str, model_cfg: dict, threshold: float,
50
  threshold : أقل قيمة تشابه مقبولة.
51
  top_k : حدّ أقصى للنتائج يتجاوز قيمة config (اختياري). إن لم
52
  يُمرَّر، يُستخدم model_cfg["top_k"].
53
- rerank : لو True نطبّق مرحلة ثانية تعيد ترتيب أفضل المرشّحين
54
- عبر Cross-Encoder. لو False (الافتراضي) السلوك كما هو.
55
  """
56
  df = resources.get_dataframe()
57
  doc_embeds = resources.get_embeddings(model_cfg["key"])
@@ -70,27 +67,7 @@ def search_one_model(query: str, model_cfg: dict, threshold: float,
70
  # top_k الممرّر يتجاوز قيمة config (وإلا نستخدم قيمة النموذج)
71
  top_k = top_k if top_k is not None else model_cfg["top_k"]
72
 
73
- # ====================================================
74
- # 🔁 مع إعادة الترتيب: مرحلتان
75
- # (1) نجمع أفضل RERANK_CANDIDATES مرشّح بالكوساين.
76
- # (2) نعيد ترتيبهم بالـ Cross-Encoder، ثم نطبّق top_k.
77
- # ====================================================
78
- if rerank:
79
- candidates = []
80
- for i in idx_sorted:
81
- score = float(sims[i])
82
- if score < threshold:
83
- break # القائمة مرتّبة تنازليًا
84
- candidates.append(_build_item(df.iloc[i], score))
85
- if len(candidates) >= config.RERANK_CANDIDATES:
86
- break
87
-
88
- reranked = reranker.rerank(resources.get_reranker(), query, candidates)
89
- return reranked[:top_k] if top_k is not None else reranked
90
-
91
- # ====================================================
92
- # بدون إعادة الترتيب: السلوك الأصلي تمامًا (كوساين + threshold + top_k)
93
- # ====================================================
94
  results = []
95
  for i in idx_sorted:
96
  score = float(sims[i])
@@ -102,11 +79,11 @@ def search_one_model(query: str, model_cfg: dict, threshold: float,
102
  return results
103
 
104
 
105
- def search_all_models(query: str, threshold: float, rerank: bool = False) -> list[dict]:
106
  """يبحث في النماذج الثلاثة ويرجّع نتائج كل واحد منفصلة."""
107
  all_results = []
108
  for index, model_cfg in enumerate(config.MODELS):
109
- items = search_one_model(query, model_cfg, threshold, rerank=rerank)
110
  all_results.append({
111
  "model_index": index,
112
  "model_key": model_cfg["key"],
 
16
  from sklearn.metrics.pairwise import cosine_similarity
17
 
18
  import config
 
19
  from app.models_loader import resources
20
 
21
 
 
39
 
40
 
41
  def search_one_model(query: str, model_cfg: dict, threshold: float,
42
+ top_k: int | None = None) -> list[dict]:
43
  """
44
  يبحث في نموذج واحد ويرجّع قائمة نتائج مرتّبة تنازليًا حسب التشابه.
45
 
 
49
  threshold : أقل قيمة تشابه مقبولة.
50
  top_k : حدّ أقصى للنتائج يتجاوز قيمة config (اختياري). إن لم
51
  يُمرَّر، يُستخدم model_cfg["top_k"].
 
 
52
  """
53
  df = resources.get_dataframe()
54
  doc_embeds = resources.get_embeddings(model_cfg["key"])
 
67
  # top_k الممرّر يتجاوز قيمة config (وإلا نستخدم قيمة النموذج)
68
  top_k = top_k if top_k is not None else model_cfg["top_k"]
69
 
70
+ # كوساين + threshold + top_k
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  results = []
72
  for i in idx_sorted:
73
  score = float(sims[i])
 
79
  return results
80
 
81
 
82
+ def search_all_models(query: str, threshold: float) -> list[dict]:
83
  """يبحث في النماذج الثلاثة ويرجّع نتائج كل واحد منفصلة."""
84
  all_results = []
85
  for index, model_cfg in enumerate(config.MODELS):
86
+ items = search_one_model(query, model_cfg, threshold)
87
  all_results.append({
88
  "model_index": index,
89
  "model_key": model_cfg["key"],
reverse-dictionary/backend/config.py CHANGED
@@ -60,21 +60,21 @@ else:
60
  MODELS = [
61
  {
62
  "key": "model_1_microsoft",
63
- "display_name": "نموذج مايكروسوفت",
64
  "hf_id": "intfloat/multilingual-e5-large",
65
  "use_prefix": True,
66
  "top_k": 10,
67
  },
68
  {
69
  "key": "model_2_researchers",
70
- "display_name": "نموذج مطور من قبل باحثين",
71
  "hf_id": "Omartificial-Intelligence-Space/Arabic-Triplet-Matryoshka-V2",
72
  "use_prefix": False,
73
  "top_k": None,
74
  },
75
  {
76
  "key": "model_3_ksaa",
77
- "display_name": "نموذج مطور من قبل قسم حوسبة المعاجم",
78
  "hf_id": MODEL_3_PATH,
79
  "use_prefix": False,
80
  "top_k": None,
@@ -87,16 +87,6 @@ MODELS = [
87
  DEFAULT_THRESHOLD = 0.70 # حد التشابه الافتراضي
88
  BATCH_SIZE = 64 # حجم الدفعة عند بناء الـ embeddings
89
 
90
- # ------------------------------------------------------------
91
- # 🔁 إعدادات إعادة الترتيب (Reranking) — مرحلة ثانية اختيارية
92
- # ------------------------------------------------------------
93
- # نموذج Cross-Encoder يعيد ترتيب أفضل المرشّحين حسب درجة (الاستعلام، التعريف).
94
- # يُحمّل كسولًا عند أول استخدام فقط (انظر models_loader.py).
95
- RERANKER_MODEL = os.getenv("RERANKER_MODEL", "BAAI/bge-reranker-v2-m3")
96
-
97
- # عدد المرشّحين اللي نجيبهم بالكوساين قبل إعادة الترتيب (المرحلة الأولى).
98
- RERANK_CANDIDATES = 40
99
-
100
  # الأعمدة المستخدمة في بناء النص المُدمج للـ embedding
101
  TEXT_COLUMNS = ["lemma", "definition", "example"]
102
 
 
60
  MODELS = [
61
  {
62
  "key": "model_1_microsoft",
63
+ "display_name": "النموذج الأول",
64
  "hf_id": "intfloat/multilingual-e5-large",
65
  "use_prefix": True,
66
  "top_k": 10,
67
  },
68
  {
69
  "key": "model_2_researchers",
70
+ "display_name": "النموذج الثاني",
71
  "hf_id": "Omartificial-Intelligence-Space/Arabic-Triplet-Matryoshka-V2",
72
  "use_prefix": False,
73
  "top_k": None,
74
  },
75
  {
76
  "key": "model_3_ksaa",
77
+ "display_name": "النموذج الثالث",
78
  "hf_id": MODEL_3_PATH,
79
  "use_prefix": False,
80
  "top_k": None,
 
87
  DEFAULT_THRESHOLD = 0.70 # حد التشابه الافتراضي
88
  BATCH_SIZE = 64 # حجم الدفعة عند بناء الـ embeddings
89
 
 
 
 
 
 
 
 
 
 
 
90
  # الأعمدة المستخدمة في بناء النص المُدمج للـ embedding
91
  TEXT_COLUMNS = ["lemma", "definition", "example"]
92
 
reverse-dictionary/frontend/streamlit_app.py CHANGED
@@ -153,10 +153,9 @@ st.markdown("""
153
  # نجيب أفضل TOP_N كلمات دائمًا، بدون حدّ تشابه (threshold = -1).
154
  # ============================================================
155
  @st.cache_data(show_spinner=False)
156
- def cached_search(query: str, model_key: str, rerank: bool):
157
  model_cfg = next(m for m in config.MODELS if m["key"] == model_key)
158
- return search_one_model(query, model_cfg, threshold=-1.0,
159
- top_k=TOP_N, rerank=rerank)
160
 
161
 
162
  # ============================================================
@@ -225,23 +224,22 @@ with c_input:
225
  query = st.text_input("البحث", placeholder="اكتب التعريف هنا...",
226
  key="search_box", label_visibility="collapsed")
227
 
228
- # 🔁 تفعيل إعادة الترتيب (Reranking) — مرحلة ثانية اختيارية للمقارنة
229
- rerank = st.checkbox("🔁 تفعيل إعادة الترتيب (Reranking)", key="rerank_toggle",
230
- help="يعيد ترتيب أفضل المرشّحين عبر نموذج Cross-Encoder "
231
- "حسب درجة (الاستعلام، التعريف). أول تفعيل قد يأخذ لحظات "
232
- "لتحميل النموذج.")
233
-
234
- # عند تغيّر الاستعلام، نمسح اختيارات المربّعات السابقة حتى لا تختلط
235
  if st.session_state.get("_last_query") != query:
236
- for k in list(st.session_state.keys()):
237
- if k.startswith("chk_"):
238
- del st.session_state[k]
239
  st.session_state["_last_query"] = query
 
 
 
240
 
241
  has_query = bool(query.strip())
242
  cols = st.columns(3)
243
 
244
- for i, col in enumerate(cols):
 
 
 
 
245
  with col:
246
  model_cfg = config.MODELS[i]
247
  display_name = model_cfg["display_name"]
@@ -255,7 +253,7 @@ for i, col in enumerate(cols):
255
  continue
256
 
257
  with st.spinner(f"⏳ {display_name}..."):
258
- results = cached_search(query.strip(), model_cfg["key"], rerank)
259
 
260
  if not results:
261
  st.markdown("<p class='empty-note'>لا توجد نتائج.</p>",
@@ -267,7 +265,7 @@ for i, col in enumerate(cols):
267
  # مربّع الاختيار يسار | الكلمة والتعريف يمين
268
  c_chk, c_txt = st.columns([1, 5], vertical_alignment="center")
269
  with c_chk:
270
- st.checkbox("صح", key=f"chk_{i}_{j}",
271
  label_visibility="collapsed")
272
  with c_txt:
273
  pos_html = (f"<span class='word-pos'>{r['pos']}</span> "
@@ -290,11 +288,11 @@ with save_col:
290
  per_model = []
291
  for i in range(3):
292
  model_cfg = config.MODELS[i]
293
- results = cached_search(query.strip(), model_cfg["key"], rerank)
294
  items = [{
295
  "lemma": r["lemma"],
296
  "score": r["score"],
297
- "chosen": st.session_state.get(f"chk_{i}_{j}", False),
298
  } for j, r in enumerate(results)]
299
  if items:
300
  per_model.append({"model": model_cfg["display_name"], "items": items})
@@ -305,7 +303,6 @@ with save_col:
305
  query.strip(), per_model,
306
  evaluator_name=evaluator["name"],
307
  evaluator_email=evaluator["email"],
308
- rerank=rerank,
309
  )
310
  st.toast(f"✅ حُفظ التقييم ({saved} كلمة)", icon="💾")
311
  except Exception as e:
 
153
  # نجيب أفضل TOP_N كلمات دائمًا، بدون حدّ تشابه (threshold = -1).
154
  # ============================================================
155
  @st.cache_data(show_spinner=False)
156
+ def cached_search(query: str, model_key: str):
157
  model_cfg = next(m for m in config.MODELS if m["key"] == model_key)
158
+ return search_one_model(query, model_cfg, threshold=-1.0, top_k=TOP_N)
 
159
 
160
 
161
  # ============================================================
 
224
  query = st.text_input("البحث", placeholder="اكتب التعريف هنا...",
225
  key="search_box", label_visibility="collapsed")
226
 
227
+ # عند إدخال استعلام جديد: نزيد "جيل التقييم" فتتغيّر مفاتيح المربّعات
228
+ # (chk_{gen}_..) فتُنشأ مربّعات جديدة فارغة — إعادة تعيين كاملة وموثوقة.
 
 
 
 
 
229
  if st.session_state.get("_last_query") != query:
 
 
 
230
  st.session_state["_last_query"] = query
231
+ st.session_state["_eval_gen"] = st.session_state.get("_eval_gen", 0) + 1
232
+
233
+ gen = st.session_state.get("_eval_gen", 0)
234
 
235
  has_query = bool(query.strip())
236
  cols = st.columns(3)
237
 
238
+ # ترتيب العرض من اليمين لليسار: يمين=النموذج الأول، وسط=الثاني، يسار=الثالث.
239
+ # (أعمدة Streamlit يسار→يمين، فنعكس الإسناد ونُبقي فهرس النموذج الحقيقي i.)
240
+ DISPLAY_ORDER = [2, 1, 0]
241
+
242
+ for col, i in zip(cols, DISPLAY_ORDER):
243
  with col:
244
  model_cfg = config.MODELS[i]
245
  display_name = model_cfg["display_name"]
 
253
  continue
254
 
255
  with st.spinner(f"⏳ {display_name}..."):
256
+ results = cached_search(query.strip(), model_cfg["key"])
257
 
258
  if not results:
259
  st.markdown("<p class='empty-note'>لا توجد نتائج.</p>",
 
265
  # مربّع الاختيار يسار | الكلمة والتعريف يمين
266
  c_chk, c_txt = st.columns([1, 5], vertical_alignment="center")
267
  with c_chk:
268
+ st.checkbox("صح", key=f"chk_{gen}_{i}_{j}",
269
  label_visibility="collapsed")
270
  with c_txt:
271
  pos_html = (f"<span class='word-pos'>{r['pos']}</span> "
 
288
  per_model = []
289
  for i in range(3):
290
  model_cfg = config.MODELS[i]
291
+ results = cached_search(query.strip(), model_cfg["key"])
292
  items = [{
293
  "lemma": r["lemma"],
294
  "score": r["score"],
295
+ "chosen": st.session_state.get(f"chk_{gen}_{i}_{j}", False),
296
  } for j, r in enumerate(results)]
297
  if items:
298
  per_model.append({"model": model_cfg["display_name"], "items": items})
 
303
  query.strip(), per_model,
304
  evaluator_name=evaluator["name"],
305
  evaluator_email=evaluator["email"],
 
306
  )
307
  st.toast(f"✅ حُفظ التقييم ({saved} كلمة)", icon="💾")
308
  except Exception as e:
reverse-dictionary/supabase_schema.sql CHANGED
@@ -24,7 +24,6 @@ CREATE TABLE IF NOT EXISTS ratings (
24
  word TEXT, -- الكلمة المقترحة
25
  similarity REAL, -- نسبة التشابه
26
  verdict TEXT, -- "صح" أو "خطأ"
27
- rerank BOOLEAN, -- هل كان Reranking مفعّلًا؟
28
  evaluator_name TEXT,
29
  evaluator_email TEXT
30
  );
 
24
  word TEXT, -- الكلمة المقترحة
25
  similarity REAL, -- نسبة التشابه
26
  verdict TEXT, -- "صح" أو "خطأ"
 
27
  evaluator_name TEXT,
28
  evaluator_email TEXT
29
  );