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