# ============================================================ # 📄 الملف: app/search.py # 🎯 الغرض: منطق البحث الدلالي (Semantic Search) الأساسي. # # هذا قلب المشروع: يأخذ تعريفًا (استعلام) ويحوّله إلى متجه، # ثم يقارنه بكل متجهات البيانات عبر تشابه الجيب التمام (cosine)، # ويرجّع أقرب الكلمات المطابقة فوق حدّ معيّن (threshold). # # يحتوي على دالتين: # search_one_model → بحث في نموذج واحد. # search_all_models → بحث في النماذج الثلاثة معًا (المستخدم في الـ API). # ============================================================ import numpy as np import pandas as pd from sklearn.metrics.pairwise import cosine_similarity import config from app.models_loader import resources from app.text_utils import strip_diacritics def _safe(value) -> str: """يحوّل القيمة إلى نص نظيف ويتعامل مع القيم الفارغة (NaN).""" if value is None or (isinstance(value, float) and pd.isna(value)): return "" text = str(value) return "" if text.lower() == "nan" else text def _build_item(row, score: float) -> dict: """يبني عنصر نتيجة واحد من صف البيانات ودرجة التشابه.""" return { "lemma": _safe(row.get("lemma")), "definition": _safe(row.get("definition")), "example": _safe(row.get("example")), "pos": _safe(row.get("pos_label")), "score": round(float(score), 3), } def search_one_model(query: str, model_cfg: dict, threshold: float, top_k: int | None = None) -> list[dict]: """ يبحث في نموذج واحد ويرجّع قائمة نتائج مرتّبة تنازليًا حسب التشابه. المعاملات: query : نص التعريف المُدخل. model_cfg : إعدادات النموذج (من config.MODELS). threshold : أقل قيمة تشابه مقبولة. top_k : حدّ أقصى للنتائج يتجاوز قيمة config (اختياري). إن لم يُمرَّر، يُستخدم model_cfg["top_k"]. """ df = resources.get_dataframe() doc_embeds = resources.get_embeddings(model_cfg["key"]) model = resources.get_model(model_cfg["key"], model_cfg["hf_id"]) # حذف التشكيل من الاستعلام ليطابق معالجة المستندات (نفس خطوة البناء) query = strip_diacritics(query) # نماذج e5 تتطلّب بادئة "query:" للاستعلام prepared_query = f"query: {query}" if model_cfg["use_prefix"] else query # ترميز الاستعلام إلى متجه مُطبّع q_emb = model.encode([prepared_query], normalize_embeddings=True, convert_to_numpy=True) # حساب التشابه مع كل المتجهات ثم الترتيب تنازليًا sims = cosine_similarity(q_emb, doc_embeds)[0] idx_sorted = np.argsort(sims)[::-1] # top_k الممرّر يتجاوز قيمة config (وإلا نستخدم قيمة النموذج) top_k = top_k if top_k is not None else model_cfg["top_k"] # كوساين + threshold + top_k results = [] for i in idx_sorted: score = float(sims[i]) if score < threshold: break # القائمة مرتّبة، فلا داعي للاستمرار results.append(_build_item(df.iloc[i], score)) if top_k is not None and len(results) >= top_k: break return results def search_all_models(query: str, threshold: float) -> list[dict]: """يبحث في النماذج الثلاثة ويرجّع نتائج كل واحد منفصلة.""" all_results = [] for index, model_cfg in enumerate(config.MODELS): items = search_one_model(query, model_cfg, threshold) all_results.append({ "model_index": index, "model_key": model_cfg["key"], "display_name": model_cfg["display_name"], "items": items, "top_lemma": items[0]["lemma"] if items else None, "top_score": items[0]["score"] if items else None, }) return all_results