File size: 16,891 Bytes
1339959
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d60fa18
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
# -*- 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": "<base64 PCM16 16k أحاديّ>", "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 رأس الفونيمات: رمزٌ فارغ/<blank>/<pad> في المفردات، وإلّا 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 ("", "<blank>", "<pad>", "<blk>", "|", "_"):
            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="<i2").astype(np.float32) / 32768.0
            if x.size == 0:
                return {"error": "empty audio"}
            pk = float(np.max(np.abs(x))) or 1.0
            x = (x / pk) * 0.97
            surah = int(inp["surah"])
            ayahs = [int(a) for a in inp["ayahs"]] if inp.get("ayahs") else None
            want_sifat = inp.get("sifat", True)
            prob_thresh = float(inp.get("prob_thresh", self.qps.SIFAT_PROB_THRESH))

            # المرجع (يحوي الآن sifat وsifa_word) — تمريرةٌ واحدة على كامل الصوت (لا تجزئة):
            # الفكّ الكامل يحتاج مرجعًا واحدًا متّسقًا لمحاذاة الصفات؛ زوائد المنطوق (بسملة/ضوضاء)
            # تُعالَج بمحاذاة المجموعات (بلا نظير) داخل compare_sifat، وبقصّ البسملة في qps للفونيم.
            if ayahs:
                ref = self.qps.build_ref_multi(surah, ayahs)
            else:
                ref = self.qps.build_ref(surah, int(inp["ayah"]))

            t_infer = time.time()
            pred_ph, pred_sifat, ph_logits = self._run(x, self._ref_obj(ref))
            infer_ms = int((time.time() - t_infer) * 1000)

            # الحقول القديمة (فونيم فقط) عبر منطق qps المُتحقَّق (قصّ البسملة/الحروف المقطّعة…).
            if ayahs:
                r = self.qps.compare_multi(surah, ayahs, pred_ph)
            else:
                r = self.qps.compare(surah, int(inp["ayah"]), pred_ph)
            out = self._result(r, pred_ph)

            if want_sifat:
                out = self._attach_sifat(out, ref, pred_sifat, prob_thresh)
            out["mode"] = "whole-sifat" if want_sifat else "whole"
            if self._madd is not None:
                try:
                    out["madd"] = self._madd.madd_flags(
                        ph_logits, surah, ayahs or [int(inp["ayah"])], ref)
                except Exception:
                    pass
            # GOP للحركات القصيرة: إشارةٌ صوتيّة مستقرّة (احتمال الحركة المتوقّعة مقابل الأعلى) بمحاذاةٍ
            # قسريّة — تُغني العميل عن word_soft المتذبذب. إضافةٌ غير كاسرة (حقولٌ إضافيّة فقط، try/except).
            # الفهارس بكلمات المرجع الفونيميّ (قد تختلف عن word_soft عند الدمج)؛ العميل يحاذيها بـalignRefToMu.
            if self._harakat_gop is not None:
                try:
                    pp = torch.nn.functional.softmax(
                        ph_logits.to(torch.float32), dim=-1).cpu().numpy()   # (T,V) احتمالات رأس الفونيمات
                    lp = np.log(pp + 1e-9)
                    hg = self._harakat_gop(lp, ref["phonemes"], self.tok.id_to_vocab["phonemes"],
                                           blank_id=self._phon_blank_id())
                    out["harakat_gop"] = hg["per_word"]
                    out["harakat_flagged"] = hg["flagged"]
                    out["harakat_ref_words"] = ref["phonemes"].split(" ")
                except Exception as _e:
                    out["harakat_gop_error"] = str(_e)[:200]
            out["timing_ms"] = {"infer": infer_ms, "total": int((time.time() - t0) * 1000)}
            return out
        except Exception as e:
            import traceback
            # لا نُعيد أثر الاستثناء (مسارات ملفّاتٍ داخليّة) للمتصل — نطبعه في سجلّ الخادم فقط
            # ونُرجع رسالة عامّة نظيفة. (اكتُشِف هذا في تدقيقٍ أمنيّ: طلبٌ فاسدٌ/خارج المدى كان
            # يُرجع تتبّعًا كاملًا يكشف بنية الملفّات الداخليّة.)
            print(f"[al-moratel-qps] خطأ: {e}\n{traceback.format_exc()}", flush=True)
            return {"error": "processing_failed"}