# ============================================================ # 📄 الملف: 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(""" """, 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("

🔍 تقييم البحث الدلالي

", unsafe_allow_html=True) st.markdown( "

" "ساهِم في تقييم النماذج: اكتب تعريفًا، واطّلع على النتائج المقترحة من كل " "نموذج، ثم اختر التقييم الأنسب لكل نموذج." "

", 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("
", unsafe_allow_html=True) _, mid, _ = st.columns([1, 2, 1]) with mid: with st.form("register_form"): st.markdown("

تسجيل المقيّم

", 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"

" f"التصنيف: {word_type}

", 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"
{display_name}
", unsafe_allow_html=True) if not has_query: st.markdown("

اكتب تعريفًا للبحث…

", unsafe_allow_html=True) continue with st.spinner(f"⏳ {display_name}..."): results = cached_search(query.strip(), model_cfg["key"]) if not results: st.markdown("

لا توجد نتائج.

", 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"{r['pos']} " if r["pos"] else "") definition = r["definition"] or "" st.markdown( f"
{r['lemma']}
" f"

{pos_html}{definition}

", unsafe_allow_html=True, ) # ------------------------------------------------------------ # 💾 زر حفظ واحد للاستعلام كامل # ------------------------------------------------------------ st.markdown("
", 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("

📊 سجل تقييماتك

", 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("لا توجد تقييمات بعد. جرّب البحث، أشّر الكلمات المناسبة، واحفظ.")