OmniFile-Processor / modules /nlp /entity_extractor.py
Dr. Abdulmalek
deploy: OmniFile AI Processor v4.3.0
900df0b
"""
مستخرج الكيانات المسماة (Named Entity Extractor)
===================================================
يستخرج الكيانات المسماة من النصوص العربية: أشخاص، مؤسسات، أماكن، تواريخ.
يدعم الاستخراج بالأنماط والكلمات المفتاحية (بدون نموذج) أو بنموذج AraBERT NER.
"""
import logging
import re
from typing import Optional
logger = logging.getLogger(__name__)
class EntityExtractor:
"""
مستخرج الكيانات المسماة — يستخرج الأشخاص والمؤسسات والأماكن والتواريخ من النصوص.
أنواع الكيانات المدعومة:
- PER: شخص (شخص، أسماء أشخاص)
- ORG: مؤسسة (شركات، جامعات، وزارات)
- LOC: موقع (مدن، دول، أماكن)
- DATE: تاريخ (تواريخ، فترات زمنية)
الخصائص:
model_name (str, optional): اسم نموذج NER.
device (str): الجهاز المستخدم.
"""
# ------------------------------------------------------------------
# أنماط الكيانات — الأشخاص (PER)
# ------------------------------------------------------------------
_PERSON_PREFIXES: list[str] = [
"السيد", "السيدة", "الأستاذ", "الأستاذة", "الدكتور",
"الشيخ", "السيد", "المهندس", "القاضي", "الوزير",
"الأمير", "الملك", "الرئيس", "المدير", "البروفيسور",
"أ.د", "د.", "م.", "أ.",
]
_KNOWN_PERSONS: list[str] = [
"محمد", "أحمد", "علي", "حسن", "حسين", "إبراهيم", "يوسف",
"عمر", "خالد", "عبدالله", "سعود", "فيصل", "ناصر", "سلطان",
"فاطمة", "خديجة", "عائشة", "مريم", "سارة", "نورة", "هند",
"محمد رسول الله", "أبو بكر", "عمر بن الخطاب", "عثمان بن عفان",
"علي بن أبي طالب",
]
# ------------------------------------------------------------------
# أنماط الكيانات — المؤسسات (ORG)
# ------------------------------------------------------------------
_ORG_SUFFIXES: list[str] = [
"شركة", "مؤسسة", "جامعة", "وزارة", "بنك", "مستشفى",
"مجلس", "هيئة", "جمعية", "نادي", "معهد", "مختبر",
"منظمة", "اتحاد", "مكتبة", "متحف", "مسجد",
]
_KNOWN_ORGS: list[str] = [
"الأمم المتحدة", "جامعة الدول العربية", "منظمة التعاون",
"أوبك", "ناتو", "اليونسكو", "منظمة الصحة العالمية",
"صندوق النقد الدولي", "البنك الدولي",
]
# ------------------------------------------------------------------
# أنماط الكيانات — المواقع (LOC)
# ------------------------------------------------------------------
_LOC_SUFFIXES: list[str] = [
"مدينة", "قرية", "حي", "شارع", "طريق", "ميناء",
"مطار", "محافظة", "إقليم", "ولاية", "منطقة",
]
_KNOWN_LOCATIONS: list[str] = [
"الرياض", "مكة", "المدينة", "جدة", "الدمام", "القاهرة",
"دمشق", "بغداد", "بيروت", "عمان", "الدوحة", "الكويت",
"المغرب", "تونس", "الجزائر", "السودان", "ليبيا", "اليمن",
"فلسطين", "الأردن", "الإمارات", "عمان", "البحرين",
"مصر", "السعودية", "تركيا", "إيران", "العراق", "سوريا",
"أفغانستان", "باكستان", "الهند", "الصين", "اليابان",
"أمريكا", "بريطانيا", "فرنسا", "ألمانيا", "إيطاليا",
"إسبانيا", "روسيا", "كندا", "أستراليا", "البرازيل",
"أبوظبي", "دبي", "Sharjah", "Ajman",
]
# ------------------------------------------------------------------
# أنماط الكيانات — التواريخ (DATE)
# ------------------------------------------------------------------
_DATE_PATTERNS: list[str] = [
# هجري: يوم شهر سنة هـ
# ملاحظة: استخدام [\u0647\u0640]? بدلاً من هـ? لتجنب مشكلة
# خاصية tatweel في regex مع أنماط Unicode الطويلة
r"\d{1,2}\s+(يناير|فبراير|مارس|أبريل|مايو|يونيو|يوليو|أغسطس|سبتمبر|أكتوبر|نوفمبر|ديسمبر)\s+\d{4}\s*[\u0647\u0640]?",
# ميلادي: يوم/شهر/سنة
r"\d{1,2}/\d{1,2}/\d{2,4}",
# سنة بالكلمات
r"(?:عام|سنة)\s+\d{4}",
# القرن
r"القرن\s+(?:الأول|الثاني|الثالث|الرابع|الخامس|السادس|السابع|الثامن|التاسع|العاشر"
r"|الحادي عشر|الثاني عشر|الثالث عشر|الرابع عشر|الخامس عشر|السادس عشر"
r"|السابع عشر|الثامن عشر|التاسع عشر|العشرين|الواحد والعشرين|الثاني والعشرين)",
# اليوم / الشهر / السنة
r"(?:اليوم|الغد|أمس|بالأمس)",
r"(?:هذا الشهر|الشهر الماضي|الشهر القادم|هذه السنة|السنة الماضية|السنة القادمة)",
]
# كلمات عربية تُستخدم كحدود للأسماء (توقف التوسيع)
_ARABIC_STOPWORDS: frozenset[str] = frozenset([
# حروف جر
"في", "من", "إلى", "على", "عن", "مع", "ب", "ل", "ك",
# أسماء إشارة وموصولة
"هذا", "هذه", "ذلك", "تلك", "الذي", "التي", "الذين",
"اللاتي", "اللواتي", "اللذين", "اللتين",
# أفعال شائعة
"كان", "كانت", "يكون", "يوم", "أمس", "غدا",
"قال", "قالت", "ذهب", "جاء", "زار", "سافر",
"عمل", "يعمل", "درس", "يلتقي", "التقى", "يتم",
# حروف عطف وربط
"ثم", "أو", "و", "ف", "حتى", "بعد", "قبل",
"بين", "عند", "منذ", "خلال", "عبر", "ضد",
# أدوات نفي واستفهام
"لا", "لم", "لن", "ما", "أن", "إن", "هل", "أم",
"بل", "لكن", "غير", "قد", "سوف", "لقد",
# أسماء مكان (تُستخدم كفواصل بين الكيانات)
"مدينة", "قرية", "حي", "شارع", "طريق", "منطقة",
# كلمات دينية
"بسم", "الله", "الرحمن", "الرحيم", "الحمد", "لله",
"سبحان", "والصلاة", "والسلام", "رسول",
# أخرى
"حول", "دون", "ذات", "ذو", "ذي",
"حيث", "كيف", "متى", "أين", "لماذا", "أي",
"هو", "هي", "هم", "نحن", "أنا", "كل", "بعض",
])
def __init__(
self,
model_name: Optional[str] = None,
device: str = "cpu",
) -> None:
"""
تهيئة مستخرج الكيانات المسماة.
المعاملات:
model_name: اسم نموذج NER (اختياري).
مثال: "UBC-NLP/ARBERT"
device: الجهاز المستخدم ('cpu' أو 'cuda').
"""
self.model_name = model_name
self.device = device
self._pipeline = None
self._model_available = False
self._tokenizer = None
# تجميع أنماط التواريخ
self._compiled_date_patterns: list[re.Pattern] = []
for pat in self._DATE_PATTERNS:
try:
self._compiled_date_patterns.append(re.compile(pat, re.IGNORECASE))
except re.error:
logger.debug("نمط تاريخ غير صالح: %s", pat)
# محاولة تحميل النموذج
if model_name:
self._try_load_model()
def _try_load_model(self) -> None:
"""محاولة تحميل نموذج NER من HuggingFace."""
try:
from transformers import pipeline # type: ignore
logger.info("جاري تحميل نموذج NER: %s ...", self.model_name)
self._pipeline = pipeline(
"ner",
model=self.model_name,
device=self.device,
aggregation_strategy="simple",
)
self._model_available = True
logger.info("تم تحميل نموذج NER بنجاح")
except ImportError:
logger.warning(
"مكتبة transformers غير مثبتة. سيتم الاعتماد على الأنماط فقط. "
"pip install transformers torch"
)
except Exception as e:
logger.warning("فشل تحميل نموذج NER '%s': %s", self.model_name, e)
@staticmethod
def _trim_entity(entity_text: str, stopwords: frozenset[str]) -> str:
"""
قص الكيان من النهاية عند كلمات التوقف.
المعاملات:
entity_text: نص الكيان الخام.
stopwords: مجموعة كلمات التوقف.
العائد:
النص المقصوص.
"""
words = entity_text.strip().split()
while len(words) > 1 and words[-1] in stopwords:
words.pop()
return " ".join(words)
# ------------------------------------------------------------------
# استخراج بالأنماط (يعمل دائماً)
# ------------------------------------------------------------------
def _extract_dates(self, text: str) -> list[dict]:
"""
استخراج التواريخ من النص.
المعاملات:
text: النص المراد استخراج التواريخ منه.
العائد:
قائمة بقواميس الكيانات.
"""
entities: list[dict] = []
seen_spans: set[tuple[int, int]] = set()
for pattern in self._compiled_date_patterns:
for match in pattern.finditer(text):
span = (match.start(), match.end())
if span not in seen_spans:
seen_spans.add(span)
entities.append({
"entity": match.group().strip(),
"type": "DATE",
"start": match.start(),
"end": match.end(),
})
return entities
def _extract_locations(self, text: str) -> list[dict]:
"""
استخراج المواقع من النص.
المعاملات:
text: النص المراد استخراج المواقع منه.
العائد:
قائمة بقواميس الكيانات.
"""
entities: list[dict] = []
seen_spans: set[tuple[int, int]] = set()
# البحث عن مواقع معروفة
for loc in self._KNOWN_LOCATIONS:
for match in re.finditer(re.escape(loc), text, re.IGNORECASE):
span = (match.start(), match.end())
if span not in seen_spans:
seen_spans.add(span)
entities.append({
"entity": match.group(),
"type": "LOC",
"start": match.start(),
"end": match.end(),
})
# البحث عن كلمات موقع متبوعة باسم
for suffix in self._LOC_SUFFIXES:
# نمط: كلمة موقع متبوعة باسم عربي (حد أقصى كلمتين)
pattern = re.compile(
rf"(?:{re.escape(suffix)})\s+[\u0600-\u06FF]+(?:\s+[\u0600-\u06FF]+)?"
)
for match in pattern.finditer(text):
span = (match.start(), match.end())
if span not in seen_spans:
trimmed = self._trim_entity(match.group(), self._ARABIC_STOPWORDS)
seen_spans.add(span)
entities.append({
"entity": trimmed,
"type": "LOC",
"start": match.start(),
"end": match.start() + len(trimmed),
})
return entities
def _extract_organizations(self, text: str) -> list[dict]:
"""
استخراج المؤسسات من النص.
المعاملات:
text: النص المراد استخراج المؤسسات منه.
العائد:
قائمة بقواميس الكيانات.
"""
entities: list[dict] = []
seen_spans: set[tuple[int, int]] = set()
# البحث عن مؤسسات معروفة
for org in self._KNOWN_ORGS:
for match in re.finditer(re.escape(org), text, re.IGNORECASE):
span = (match.start(), match.end())
if span not in seen_spans:
seen_spans.add(span)
entities.append({
"entity": match.group(),
"type": "ORG",
"start": match.start(),
"end": match.end(),
})
# البحث عن كلمات مؤسسة متبوعة باسم
for suffix in self._ORG_SUFFIXES:
# نمط: كلمة مؤسسة متبوعة باسم عربي (حد أقصى 3 كلمات)
pattern = re.compile(
rf"(?:(?:ال|أل|لل)?{re.escape(suffix)})\s+[\u0600-\u06FF]+(?:\s+[\u0600-\u06FF]+){{0,2}}"
)
for match in pattern.finditer(text):
span = (match.start(), match.end())
if span not in seen_spans:
trimmed = self._trim_entity(match.group(), self._ARABIC_STOPWORDS)
seen_spans.add(span)
entities.append({
"entity": trimmed,
"type": "ORG",
"start": match.start(),
"end": match.start() + len(trimmed),
})
return entities
def _extract_persons(self, text: str) -> list[dict]:
"""
استخراج أسماء الأشخاص من النص.
المعاملات:
text: النص المراد استخراج الأشخاص منه.
العائد:
قائمة بقواميس الكيانات.
"""
entities: list[dict] = []
seen_spans: set[tuple[int, int]] = set()
# البحث عن أسماء معرفة بـ ألقاب
for prefix in self._PERSON_PREFIXES:
# نمط: لقب متبوع باسم عربي (حد أقصى كلمتين)
pattern = re.compile(
rf"{re.escape(prefix)}\s+[\u0600-\u06FF]+(?:\s+[\u0600-\u06FF]+)?"
)
for match in pattern.finditer(text):
span = (match.start(), match.end())
if span not in seen_spans:
trimmed = self._trim_entity(match.group(), self._ARABIC_STOPWORDS)
seen_spans.add(span)
entities.append({
"entity": trimmed,
"type": "PER",
"start": match.start(),
"end": match.start() + len(trimmed),
})
# البحث عن أسماء أشخاص معروفة
for person in self._KNOWN_PERSONS:
for match in re.finditer(re.escape(person), text):
span = (match.start(), match.end())
if span not in seen_spans:
seen_spans.add(span)
entities.append({
"entity": match.group(),
"type": "PER",
"start": match.start(),
"end": match.end(),
})
return entities
def _pattern_extract(self, text: str) -> list[dict]:
"""
استخراج جميع الكيانات باستخدام الأنماط.
المعاملات:
text: النص المراد استخراج الكيانات منه.
العائد:
قائمة مرتبة بالكيانات المستخرجة.
"""
all_entities: list[dict] = []
# استخراج كل نوع
all_entities.extend(self._extract_persons(text))
all_entities.extend(self._extract_organizations(text))
all_entities.extend(self._extract_locations(text))
all_entities.extend(self._extract_dates(text))
# ترتيب حسب موضع الظهور
all_entities.sort(key=lambda e: e["start"])
# إزالة التداخلات
cleaned: list[dict] = []
last_end = -1
for entity in all_entities:
if entity["start"] >= last_end:
cleaned.append(entity)
last_end = entity["end"]
return cleaned
# ------------------------------------------------------------------
# استخراج بالنموذج (إذا توفر)
# ------------------------------------------------------------------
def _model_extract(self, text: str) -> list[dict]:
"""
استخراج الكيانات باستخدام نموذج NER.
المعاملات:
text: النص المراد استخراج الكيانات منه.
العائد:
قائمة بقواميس الكيانات.
"""
if not self._pipeline:
return self._pattern_extract(text)
try:
results = self._pipeline(text)
entities: list[dict] = []
for item in results:
entity_type = item.get("entity_group", item.get("entity", "MISC"))
# تحويل أنواع الكيانات
type_map = {
"B-PER": "PER", "I-PER": "PER",
"B-ORG": "ORG", "I-ORG": "ORG",
"B-LOC": "LOC", "I-LOC": "LOC",
"B-DATE": "DATE", "I-DATE": "DATE",
"PER": "PER", "ORG": "ORG", "LOC": "LOC", "DATE": "DATE",
}
mapped_type = type_map.get(entity_type, entity_type)
entities.append({
"entity": item.get("word", "").strip(),
"type": mapped_type,
"start": item.get("start", 0),
"end": item.get("end", 0),
"score": round(item.get("score", 0.0), 4),
})
return entities
except Exception as e:
logger.warning("فشل الاستخراج بالنموذج: %s — يتم الرجوع للأنماط", e)
return self._pattern_extract(text)
# ------------------------------------------------------------------
# الواجهة العامة
# ------------------------------------------------------------------
def extract(self, text: str) -> list[dict]:
"""
استخراج الكيانات المسماة من النص.
المعاملات:
text: النص المراد استخراج الكيانات منه.
العائد:
قائمة بقواميس الكيانات: {entity, type, start, end}
"""
if not text or not text.strip():
return []
cleaned = text.strip()
if self._model_available and self._pipeline is not None:
return self._model_extract(cleaned)
return self._pattern_extract(cleaned)
def extract_from_document(self, text: str) -> dict:
"""
استخراج الكيانات من مستند كامل.
المعاملات:
text: نص المستند الكامل.
العائد:
قاموس يحتوي على:
- entities: قائمة جميع الكيانات
- by_type: كيانات مصنفة حسب النوع
- unique_entities: الكيانات الفريدة
- total_count: العدد الإجمالي
"""
if not text or not text.strip():
return {
"entities": [],
"by_type": {},
"unique_entities": [],
"total_count": 0,
}
entities = self.extract(text)
# تصنيف حسب النوع
by_type: dict[str, list[dict]] = {}
for entity in entities:
etype = entity["type"]
if etype not in by_type:
by_type[etype] = []
by_type[etype].append(entity)
# الكيانات الفريدة
unique_names: list[str] = []
seen_names: set[str] = set()
for entity in entities:
name = entity["entity"]
if name not in seen_names:
seen_names.add(name)
unique_names.append(name)
return {
"entities": entities,
"by_type": by_type,
"unique_entities": unique_names,
"total_count": len(entities),
}
def extract_by_type(self, text: str, entity_type: str) -> list[dict]:
"""
استخراج كيانات من نوع محدد.
المعاملات:
text: النص.
entity_type: نوع الكيان (PER/ORG/LOC/DATE).
العائد:
قائمة بالكيانات من النوع المطلوب.
"""
all_entities = self.extract(text)
return [e for e in all_entities if e["type"] == entity_type.upper()]
def get_supported_types(self) -> list[str]:
"""
عرض أنواع الكيانات المدعومة.
العائد:
قائمة بأنواع الكيانات.
"""
return ["PER", "ORG", "LOC", "DATE"]