Spaces:
Running
Running
| # ============================================================ | |
| # 📄 الملف: 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 أول تشغيل على السحابة) | |
| # محليًا: الملفات موجودة فترجع فورًا بلا تنزيل. | |
| # ============================================================ | |
| 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). | |
| # ============================================================ | |
| 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("لا توجد تقييمات بعد. جرّب البحث، أشّر الكلمات المناسبة، واحفظ.") | |