# -*- coding: utf-8 -*- """معالج HuggingFace Inference Endpoint الكامل لنموذج المُعلِّم (QPS) — يَخلُف المعالج الفونيميّ. يخلُف `muaalem_handler_deployed.py` الذي يحسب لوجيتات الرؤوس الأحد عشر ثمّ **يفكّ رأس الفونيم وحده ويرمي الصفات العشر**. هذا المعالج يفكّ الرؤوس الأحد عشر جميعًا على `outs` نفسها (تمريرةٌ أماميّةٌ واحدة، لا تحميل نموذجٍ ثانٍ) عبر دوالّ `quran_muaalem/decode.py`: phonemes_level_greedy_decode → chunck_phonemes → multilevel_greedy_decode → format_sifat فيُرجِع لكلّ فونيم/كلمةٍ صفاتِها التجويديّة العشر (hams/jahr، shidda/rakhawa، tafkheem/tarqeeq، itbaq، safeer، qalqla، tikraar، tafashie، istitala، ghonna) مع احتمال كلٍّ، ويقارنها بصفات المرجع (quran_phonetizer.sifat) فيُطلق تنبيهًا كهرمانيًّا عند اختلاف الصفة بثقةٍ عالية. توافقٌ رجعيّ كامل: الحقول القديمة (word_hard/word_soft/flagged/ops/match/basmala_*) تبقى كما هي (محسوبةً من qps.compare/compare_multi على الفونيم المسموع)، والصفات مُضافةٌ فوقها لا كاسرةٌ لها. ملاحظة على decode.py: دالّة `multilevel_greedy_decode` في الحزمة تحوي خطأً في فرع عدم تطابق الطول (تُسنِد المُعرِّفات مكان الاحتمالات: `new_probs = ref_aligned_ids[phonemes_mask]`)، فتخرج احتمالاتٌ = مُعرِّفات (2.0، 3.0…) لبعض الرؤوس. أعدنا تنفيذ الدالّة هنا (`_multilevel_decode`) بالإصلاح الوحيد (`probs = new_probs[phonemes_mask]`) مع إبقاء بقيّة المنطق حرفيًّا، فتصير كلّ الاحتمالات ضمن [0,1] وتصلح عتبة التنبيه. باقي الفكّ يستدعي دوالّ الحزمة كما هي. الطلب: {"inputs": {"pcm": "", "surah": 1, "ayahs": [1,2,3]}} (أو "ayah": 3 لآيةٍ مفردة؛ "sifat": false لتعطيل الصفات؛ "prob_thresh": 0.6 لضبط العتبة) المخرج (متوافقٌ رجعيًّا + sifat لكلّ كلمة): {"uthmani","words":[{"w","hard","soft","sifat":[{"ph", <10 صفات> }]}], "word_hard","word_soft","flagged","ops","match","per","sifat_flagged","timing_ms",...} """ import base64 import os import sys import time from typing import Any, Dict import numpy as np import torch MODEL_REPO = os.environ.get("MUAALEM_REPO", "obadx/muaalem-model-v3_2") class EndpointHandler: def __init__(self, path: str = "."): sys.path.insert(0, path) # ليجد qps (muaalem_qps_deployed) داخل مستودع النقطة from transformers import AutoFeatureExtractor from quran_transcript import QuranPhoneticScriptOutput, chunck_phonemes from quran_muaalem.modeling.modeling_multi_level_ctc import ( Wav2Vec2BertForMultilevelCTC, ) from quran_muaalem.modeling.multi_level_tokenizer import MultiLevelTokenizer from quran_muaalem.modeling.vocab import PAD_TOKEN_IDX from quran_muaalem.decode import ( ctc_decode, phonemes_level_greedy_decode, align_chunked_phonemes_sequence, align_predicted_sequence, ) from quran_muaalem.inference import format_sifat from quran_muaalem.muaalem_typing import Unit import qps self.qps = qps self.QuranPhoneticScriptOutput = QuranPhoneticScriptOutput self._chunck = chunck_phonemes self._ctc_decode = ctc_decode self._phon_decode = phonemes_level_greedy_decode self._align_chunk = align_chunked_phonemes_sequence self._align_pred = align_predicted_sequence self._format_sifat = format_sifat self._Unit = Unit self._PAD = PAD_TOKEN_IDX # GOP للحركات القصيرة (اختياريّ، غير كاسر): يقيس احتمال الحركة المتوقّعة صوتيًّا بمحاذاةٍ # قسريّة بدل word_soft المتذبذب (عتبة مُعايَرة على صوت المستخدم في العميل). تراجعٌ آمن إن غاب. try: from harakat_gop import harakat_gop_per_word self._harakat_gop = harakat_gop_per_word except Exception: self._harakat_gop = None self.model = Wav2Vec2BertForMultilevelCTC.from_pretrained(MODEL_REPO) self.tok = MultiLevelTokenizer(MODEL_REPO) # طبقة المدّ بالتوقيت (forced alignment) — اختياريّة، تراجعٌ آمن إن غاب torchaudio try: import madd_timing madd_timing.attach(self.tok) self._madd = madd_timing except Exception: self._madd = None self.proc = AutoFeatureExtractor.from_pretrained(MODEL_REPO) self.device = "cuda" if torch.cuda.is_available() else "cpu" self.dtype = torch.float16 if self.device == "cuda" else torch.float32 self.model.to(self.device, self.dtype).eval() # --------------------------- فكّ الرؤوس --------------------------- def _multilevel_decode(self, level_to_probs, id_to_vocab, level_to_ref_ids, chunked_pred, ref_chunked, phonemes_units, missing=-100): """نسخةٌ مُصحَّحةٌ من quran_muaalem.decode.multilevel_greedy_decode: تفكّ رؤوس الصفات وتحاذيها بالفونيم المُتنبَّأ، مع إصلاح إسناد الاحتمالات (السطر المعلَّم FIX).""" pad = self._PAD level_to_units = {} for level in level_to_probs: if level == "phonemes": continue batch_probs, batch_ids = level_to_probs[level].topk(1, dim=-1) decode_outs = self._ctc_decode( batch_ids.squeeze(-1), batch_probs.squeeze(-1), collapse_consecutive=True, ) level_to_units[level] = [] for seq_idx, dout in enumerate(decode_outs): phonemes_mask = torch.BoolTensor(self._align_chunk( ref=ref_chunked[seq_idx], predicted=chunked_pred[seq_idx])) if len(dout.ids) != len(chunked_pred[seq_idx]) and ( len(chunked_pred[seq_idx]) <= len(ref_chunked[seq_idx]) ): ref_aligned_ids, mask = self._align_pred( level_to_ref_ids[level][seq_idx], dout.ids, missing_placeholder=missing) probs = dout.p ref_aligned_ids = torch.LongTensor(ref_aligned_ids) mask = torch.BoolTensor(mask) new_probs = torch.zeros(len(ref_aligned_ids), dtype=torch.float32) new_probs[ref_aligned_ids != missing] = probs[mask] ref_aligned_ids[ref_aligned_ids == missing] = pad aligned_ids = ref_aligned_ids[phonemes_mask] probs = new_probs[phonemes_mask] # FIX: كان ref_aligned_ids[phonemes_mask] else: aligned_ids = dout.ids probs = dout.p text = "".join(id_to_vocab[level][int(i)] for i in aligned_ids) level_to_units[level].append( self._Unit(text=text, probs=probs, ids=aligned_ids)) level_to_units["phonemes"] = phonemes_units return level_to_units def _run(self, wave: np.ndarray, ref_obj): """تمريرةٌ أماميّةٌ واحدة → (نصّ الفونيم المسموع، صفات المسموع لكلّ مجموعة). تفكّ الرؤوس الأحد عشر على outs نفسها عبر دوالّ decode.py (لا نموذج ثانٍ).""" feats = self.proc([wave], sampling_rate=16000, return_tensors="pt") feats = {k: v.to(self.device, self.dtype) for k, v in feats.items()} with torch.no_grad(): outs = self.model(**feats, return_dict=False)[0] # dict: 11 رؤوس -> لوجيتات probs = {lv: torch.nn.functional.softmax(outs[lv], dim=-1).cpu().to(torch.float32) for lv in outs} phonemes_units = self._phon_decode( probs["phonemes"], self.tok.id_to_vocab["phonemes"]) chunked_pred = [self._chunck(u.text) for u in phonemes_units] ref_ids = self.tok.tokenize( [ref_obj.phonemes], [ref_obj.sifat], to_dict=True, return_tensors="pt", padding="longest")["input_ids"] ref_chunked = [[s.phonemes for s in ref_obj.sifat]] level_to_units = self._multilevel_decode( probs, self.tok.id_to_vocab, ref_ids, chunked_pred, ref_chunked, phonemes_units) sifat = self._format_sifat(level_to_units, chunked_pred, self.tok)[0] return phonemes_units[0].text, sifat, outs["phonemes"][0] def _ref_obj(self, ref): """يبني QuranPhoneticScriptOutput مرجعيًّا للنموذج من مُخرَج qps.build_ref* (بلا فراغ).""" return self.QuranPhoneticScriptOutput( phonemes=ref["phonemes"].replace(" ", ""), sifat=ref["sifat"], mappings=[]) def _phon_blank_id(self): """يكتشف blank رأس الفونيمات: رمزٌ فارغ// في المفردات، وإلّا PAD_TOKEN_IDX، وإلّا الأخير. (مُتحقَّق على نموذج obadx/muaalem-model-v3_2: blank=0=[PAD].)""" v2i = {v: k for k, v in self.tok.id_to_vocab["phonemes"].items()} for marker in ("", "", "", "", "|", "_"): if marker in v2i: return int(v2i[marker]) if self._PAD is not None: return int(self._PAD) return len(self.tok.id_to_vocab["phonemes"]) - 1 # --------------------------- بناء الرد --------------------------- def _result(self, r, pred): """الحقول القديمة كما في المعالج المنشور (توافقٌ رجعيّ).""" out = { "uthmani": r["uthmani"], "match": r["match"], "per": r.get("per"), "words": [{"w": w, "hard": r["word_hard"][k] > 0, "soft": r["word_soft"][k]} for k, w in enumerate(r["words"])], "word_hard": r["word_hard"], "word_soft": r["word_soft"], "word_errors": r["word_hard"], "flagged": r["flagged"], "flagged_soft": r["flagged_soft"], "ops": r["ops"], "ref_phonemes": r["ref_phonemes"], "pred_phonemes": pred, } for k in ("basmala_hard", "basmala_soft", "basmala_flagged", "basmala_words"): if k in r: out[k] = r[k] return out def _attach_sifat(self, out, ref, pred_sifat, prob_thresh): """يقارن الصفات (compare_sifat) ويُرفِق مجموعات الصفات بكلّ كلمةٍ + ملخّص المُنبَّهة.""" groups = self.qps.compare_sifat( ref["sifat"], pred_sifat, ref["sifa_word"], prob_thresh=prob_thresh) nw = len(out["words"]) for wd in out["words"]: wd["sifat"] = [] for g in groups: wi = g["word"] entry = {k: v for k, v in g.items() if k != "word"} if 0 <= wi < nw: out["words"][wi]["sifat"].append(entry) # ملخّص: الكلمات التي فيها صفةٌ مُنبَّهة + أنواع الصفات المُنبَّهة لكلّ كلمة. flagged, word_sifat = [], [0] * nw for g in groups: wi = g["word"] if g["mismatch"] and 0 <= wi < nw: word_sifat[wi] = 1 mis = [lvl for lvl in self.qps.SIFAT_LEVELS if g[lvl]["mismatch"]] flagged.append({"word": ref["words"][wi] if wi < len(ref["words"]) else "", "index": wi, "ph": g["ph"], "heard_ph": g["heard_ph"], "sifat": mis}) out["word_sifat"] = word_sifat out["sifat_flagged"] = flagged out["prob_thresh"] = prob_thresh return out def __call__(self, data: Dict[str, Any]) -> Dict[str, Any]: t0 = time.time() inp = data.get("inputs", data) if isinstance(inp, str): inp = {"pcm": inp, **{k: v for k, v in data.items() if k != "inputs"}} try: raw = base64.b64decode(inp["pcm"]) x = np.frombuffer(raw, dtype="