shahad altamimi
عرض ١٠ كلمات لكل نموذج بدل ٥ (TOP_N=10)
4033be2
Raw
History Blame Contribute Delete
17.7 kB
# ============================================================
# 📄 الملف: frontend/streamlit_app.py
# 🎯 الغرض: واجهة Streamlit للبحث الدلالي ومقارنة النماذج الثلاثة.
#
# الواجهة تعيد استخدام منطق الـ backend مباشرةً (config + search +
# evaluations) بدل تكرار الكود — مصدر واحد للحقيقة.
#
# آلية التقييم:
# - كل نموذج يعرض أعلى 5 كلمات (بدون إظهار نسبة التشابه).
# - بجانب كل كلمة مربّع اختيار ☑️ — تؤشّر الكلمات الصحيحة.
# - زر "حفظ التقييم" واحد يسجّل الاستعلام + كلمات كل نموذج
# + نسبة التشابه (في السجل) + أي الكلمات اختارها المستخدم.
#
# التشغيل (من مجلد reverse-dictionary):
# backend/.venv/bin/python -m streamlit run frontend/streamlit_app.py
# ============================================================
import sys
from pathlib import Path
# ربط مسار الـ backend حتى نقدر نستورد وحداته (config / app/*)
BACKEND_DIR = Path(__file__).resolve().parent.parent / "backend"
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
import re
import streamlit as st
import config
from app.search import search_one_model
from app import assets
# طبقة التخزين الوحيدة: MongoDB (الرابط MONGODB_URI من .env / أسرار الـ Space)
from app import database as db
# عدد الكلمات المعروضة لكل نموذج
TOP_N = 10
st.set_page_config(page_title="البحث الدلالي", layout="wide")
# ============================================================
# 📥 تجهيز الملفات الكبيرة (تنزيل من HF Hub أول تشغيل على السحابة)
# محليًا: الملفات موجودة فترجع فورًا بلا تنزيل.
# ============================================================
@st.cache_resource(show_spinner="⏳ تجهيز النماذج لأول مرة (تنزيل)… قد يأخذ دقائق")
def _bootstrap_assets():
assets.ensure_assets()
return True
_bootstrap_assets()
# ============================================================
# 🎨 التصميم — لوحة هادئة: رمادي محايد + نعناعي
# ============================================================
st.markdown("""
<style>
/* الخلفية العامة */
.stApp { background-color: #F4F6F6; }
.block-container { padding-top: 2.5rem; max-width: 1200px; }
/* النصوص */
h1, h2, h3, h4, h5, p, label, span, div { color: #37474F; }
h2 { font-weight: 700; }
/* صندوق النموذج (العمود) */
.model-header {
background-color: #5AA9A3; color: #FFFFFF !important;
padding: 12px 16px; border-radius: 10px 10px 0 0;
text-align: center; font-weight: 700; font-size: 17px;
margin-bottom: 0;
}
.model-header span { color: #FFFFFF !important; }
/* بطاقة الكلمة */
div[data-testid="stVerticalBlockBorderWrapper"] {
background-color: #FFFFFF;
border-radius: 8px;
}
/* لون علامة الاختيار النعناعي */
[data-testid="stCheckbox"] svg { color: #338066 !important; }
/* الكلمة (العنوان) — يمين، من اليمين لليسار */
.word-title {
font-size: 17px; font-weight: 700; color: #37474F !important;
direction: rtl; text-align: right; margin: 0;
}
/* التعريف تحت الكلمة — يمين، من اليمين لليسار */
.word-def {
color: #607D8B !important; font-size: 13px;
text-align: right; direction: rtl; margin: 4px 0 0 0;
line-height: 1.6;
}
.word-pos {
background-color: #ECEFF1; color: #546E7A !important;
padding: 1px 8px; border-radius: 4px; font-size: 11px;
}
/* الأزرار — لمسة نعناعية هادئة */
.stButton > button, .stDownloadButton > button {
color: #FFFFFF !important; background-color: #5AA9A3 !important;
border: none !important; border-radius: 8px !important;
padding: 10px 22px !important; font-weight: 700 !important;
font-size: 15px !important;
}
.stButton > button:hover, .stDownloadButton > button:hover {
background-color: #4A938D !important;
}
.stButton > button:disabled {
background-color: #CFD8DC !important; color: #FFFFFF !important;
}
.stButton > button p, .stDownloadButton > button p { color: #FFFFFF !important; }
/* قائمة التصنيف — بنفس لون زر البحث (نعناعي + نص أبيض) */
div[data-testid="stSelectbox"] div[data-baseweb="select"] > div {
background-color: #5AA9A3 !important;
border: none !important; border-radius: 8px !important;
min-height: 44px; font-weight: 700 !important;
}
div[data-testid="stSelectbox"] div[data-baseweb="select"] > div:hover {
background-color: #4A938D !important;
}
div[data-testid="stSelectbox"] div[data-baseweb="select"] * { color: #FFFFFF !important; }
div[data-testid="stSelectbox"] svg { color: #FFFFFF !important; fill: #FFFFFF !important; }
/* خانة البحث */
div[data-testid="stTextInput"] input {
background-color: #FFFFFF; color: #37474F; border: 1px solid #CFD8DC;
text-align: right; font-size: 17px; direction: rtl; border-radius: 8px;
padding: 10px 14px;
}
/* لا نتائج */
.empty-note { color: #90A4AE !important; text-align: center; padding: 20px; }
/* شبكة سجل التقييم — مدمجة، من اليمين لليسار */
.eval-wrap { direction: rtl; overflow-x: auto; }
.eval-grid {
border-collapse: collapse; width: 100%;
font-size: 13px; background: #FFFFFF;
}
.eval-grid th, .eval-grid td {
border: 1px solid #CFD8DC; padding: 6px 8px;
text-align: center; vertical-align: middle; color: #37474F;
}
.eval-grid thead th { background: #5AA9A3; color: #FFFFFF !important; }
.eval-grid thead .model-grp { background: #4A938D; font-size: 13px; }
.eval-grid .qnum { background: #ECEFF1; font-weight: 700; width: 36px; }
.eval-grid .qcell {
background: #F4F6F6; font-weight: 700; text-align: right;
min-width: 120px; direction: rtl;
}
.eval-grid .gw { text-align: right; } /* الكلمة */
.eval-grid .gs { color: #78909C; } /* نسبة التشابه */
.eval-grid .gc { color: #338066; font-weight: 700; } /* علامة ✓ */
.eval-grid tbody tr:hover td { background: #F0F7F6; }
</style>
""", unsafe_allow_html=True)
# ============================================================
# 🔎 غلاف بحث مع كاش (يعيد استخدام search_one_model من الباكند)
# نجيب أفضل TOP_N كلمات دائمًا، بدون حدّ تشابه (threshold = -1).
# ============================================================
@st.cache_data(show_spinner=False)
def cached_search(query: str, model_key: str):
model_cfg = next(m for m in config.MODELS if m["key"] == model_key)
return search_one_model(query, model_cfg, threshold=-1.0, top_k=TOP_N)
# ============================================================
# 🖥️ الواجهة
# ============================================================
st.markdown("<h2 style='text-align:center;'>🔍 تقييم البحث الدلالي</h2>",
unsafe_allow_html=True)
st.markdown(
"<p style='text-align:center; color:#78909C;'>"
"ساهِم في تقييم النماذج: اكتب تعريفًا، واطّلع على النتائج المقترحة من كل "
"نموذج، ثم اختر التقييم الأنسب لكل نموذج."
"</p>", unsafe_allow_html=True)
# ============================================================
# 👤 التسجيل (اسم + إيميل) — لا يظهر البحث إلا بعد التسجيل
# ============================================================
def _email_valid(email: str) -> bool:
return re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", email or "") is not None
if "evaluator" not in st.session_state:
st.markdown("<br>", unsafe_allow_html=True)
_, mid, _ = st.columns([1, 2, 1])
with mid:
with st.form("register_form"):
st.markdown("<h4 style='text-align:center;'>تسجيل المقيّم</h4>",
unsafe_allow_html=True)
name = st.text_input("الاسم")
email = st.text_input("الإيميل")
submitted = st.form_submit_button("ابدأ التقييم ✅",
use_container_width=True)
if submitted:
if not name.strip():
st.warning("اكتب اسمك من فضلك.")
elif not _email_valid(email):
st.warning("صيغة الإيميل غير صحيحة.")
else:
clean_name, clean_email = name.strip(), email.strip().lower()
try:
db.upsert_evaluator(clean_name, clean_email)
st.session_state["evaluator"] = {"name": clean_name,
"email": clean_email}
st.rerun()
except Exception as e:
st.error(f"تعذّر الاتصال بقاعدة البيانات: {e}")
st.stop() # نوقف هنا حتى يكمل التسجيل
# المقيّم الحالي
evaluator = st.session_state["evaluator"]
# الشريط الجانبي: اسم المقيّم + زر تغيير المقيّم + مكان التخزين
with st.sidebar:
st.markdown(f"### 👤 {evaluator['name']}")
st.caption(evaluator["email"])
st.caption(f"🗄️ التخزين: {db.backend_label()}")
if st.button("🔄 تغيير المقيّم", use_container_width=True):
del st.session_state["evaluator"]
st.rerun()
# تصنيف الكلمة (إجباري قبل البحث)
WORD_TYPES = ["فعل", "معنى معجمي متقدم", "معنى عام"]
TYPE_PLACEHOLDER = "— اختر تصنيف —"
# 🔘 مفتاح إظهار قائمة التصنيف في الواجهة.
# False = مخفية (الكود محفوظ للاستخدام لاحقًا) — غيّريه إلى True لإرجاعها.
SHOW_WORD_TYPE = False
if SHOW_WORD_TYPE:
# قائمة التصنيف + زر بحث على اليسار + شريط الإدخال على اليمين
c_type, c_btn, c_input = st.columns([2, 1, 5], vertical_alignment="bottom")
with c_type:
word_type = st.selectbox("التصنيف", [TYPE_PLACEHOLDER] + WORD_TYPES,
key="word_type", label_visibility="collapsed")
type_selected = word_type != TYPE_PLACEHOLDER
with c_btn:
# زر البحث معطّل حتى يُختار تصنيف
st.button("🔍 بحث", use_container_width=True, key="search_btn",
disabled=not type_selected)
with c_input:
query = st.text_input("البحث", placeholder="اكتب التعريف هنا...",
key="search_box", label_visibility="collapsed")
# رسالة إلزام التصنيف: إن كتب استعلامًا قبل اختيار تصنيف
if query.strip() and not type_selected:
st.warning("الرجاء اختيار تصنيف أولاً")
else:
# التصنيف مخفي: زر بحث على اليسار + شريط الإدخال على اليمين (بلا إلزام)
word_type = ""
type_selected = True
c_btn, c_input = st.columns([1, 6], vertical_alignment="bottom")
with c_btn:
st.button("🔍 بحث", use_container_width=True, key="search_btn")
with c_input:
query = st.text_input("البحث", placeholder="اكتب التعريف هنا...",
key="search_box", label_visibility="collapsed")
# عند إدخال استعلام جديد: نزيد "جيل التقييم" فتتغيّر مفاتيح المربّعات
# (chk_{gen}_..) فتُنشأ مربّعات جديدة فارغة — إعادة تعيين كاملة وموثوقة.
if st.session_state.get("_last_query") != query:
st.session_state["_last_query"] = query
st.session_state["_eval_gen"] = st.session_state.get("_eval_gen", 0) + 1
gen = st.session_state.get("_eval_gen", 0)
# لا يبدأ البحث إلا بعد اختيار تصنيف + إدخال استعلام
has_query = bool(query.strip()) and type_selected
n_models = len(config.MODELS) # عدد النماذج (ديناميكي)
# التصنيف المختار يظهر فوق نتائج البحث (فقط عند تفعيل التصنيف)
if has_query and SHOW_WORD_TYPE:
st.markdown(
f"<p style='text-align:right; color:#5AA9A3; font-weight:700; margin:0 0 6px;'>"
f"التصنيف: {word_type}</p>",
unsafe_allow_html=True,
)
cols = st.columns(n_models)
# ترتيب العرض من اليمين لليسار: يمين=النموذج الأول … يسار=الأخير.
# (أعمدة Streamlit يسار→يمين، فنعكس الإسناد ونُبقي فهرس النموذج الحقيقي i.)
DISPLAY_ORDER = list(range(n_models - 1, -1, -1))
for col, i in zip(cols, DISPLAY_ORDER):
with col:
model_cfg = config.MODELS[i]
display_name = model_cfg["display_name"]
st.markdown(f"<div class='model-header'><span>{display_name}</span></div>",
unsafe_allow_html=True)
if not has_query:
st.markdown("<p class='empty-note'>اكتب تعريفًا للبحث…</p>",
unsafe_allow_html=True)
continue
with st.spinner(f"⏳ {display_name}..."):
results = cached_search(query.strip(), model_cfg["key"])
if not results:
st.markdown("<p class='empty-note'>لا توجد نتائج.</p>",
unsafe_allow_html=True)
continue
for j, r in enumerate(results):
with st.container(border=True):
# مربّع الاختيار يسار | الكلمة والتعريف يمين
c_chk, c_txt = st.columns([1, 5], vertical_alignment="center")
with c_chk:
st.checkbox("صح", key=f"chk_{gen}_{i}_{j}",
label_visibility="collapsed")
with c_txt:
pos_html = (f"<span class='word-pos'>{r['pos']}</span> "
if r["pos"] else "")
definition = r["definition"] or ""
st.markdown(
f"<div class='word-title'>{r['lemma']}</div>"
f"<p class='word-def'>{pos_html}{definition}</p>",
unsafe_allow_html=True,
)
# ------------------------------------------------------------
# 💾 زر حفظ واحد للاستعلام كامل
# ------------------------------------------------------------
st.markdown("<br>", unsafe_allow_html=True)
save_col = st.columns([1, 1, 1])[1]
with save_col:
if st.button("💾 حفظ التقييم", disabled=not has_query,
use_container_width=True, key="save_eval"):
per_model = []
for i in range(len(config.MODELS)):
model_cfg = config.MODELS[i]
results = cached_search(query.strip(), model_cfg["key"])
items = [{
"lemma": r["lemma"],
"score": r["score"],
"chosen": st.session_state.get(f"chk_{gen}_{i}_{j}", False),
} for j, r in enumerate(results)]
if items:
per_model.append({"model": model_cfg["display_name"], "items": items})
if per_model:
try:
db.save_evaluation(
query.strip(), per_model,
evaluator_name=evaluator["name"],
evaluator_email=evaluator["email"],
word_type=word_type,
)
# رسالة تأكيد تظهر تحت الزر مباشرة
st.success("تم التقييم ✓")
except Exception as e:
st.error(f"تعذّر حفظ التقييم: {e}")
else:
st.warning("لا توجد نتائج لحفظها")
# ============================================================
# 📊 سجل تقييماتك — الأرقام فقط
# ============================================================
st.markdown("---")
st.markdown("<h3 style='text-align:right;'>📊 سجل تقييماتك</h3>", unsafe_allow_html=True)
# نعرض فقط تقييمات المقيّم الحالي (الخصوصية + تقليل التشتيت)
eval_df = db.load_ratings(evaluator_email=evaluator["email"])
if len(eval_df) > 0:
# إحصاءات سريعة (الأرقام فقط)
c1, c2, c3, c4 = st.columns(4)
c1.metric("عدد الاستعلامات", eval_df["الاستعلام"].nunique())
c2.metric("إجمالي الكلمات", len(eval_df))
c3.metric("✅ صح", int((eval_df["التقييم"] == "صح").sum()))
c4.metric("❌ خطأ", int((eval_df["التقييم"] == "خطأ").sum()))
else:
st.info("لا توجد تقييمات بعد. جرّب البحث، أشّر الكلمات المناسبة، واحفظ.")