MuhammadHijazii commited on
Commit
69c35f2
·
verified ·
1 Parent(s): 09187f9

Upload 3 files

Browse files
Files changed (3) hide show
  1. README.md +1 -13
  2. app (1).py +366 -0
  3. requirements.txt +4 -0
README.md CHANGED
@@ -1,13 +1 @@
1
- ---
2
- title: Sammaali Similarity
3
- emoji: 👀
4
- colorFrom: yellow
5
- colorTo: gray
6
- sdk: gradio
7
- sdk_version: 5.43.1
8
- app_file: app.py
9
- pinned: false
10
- license: apache-2.0
11
- ---
12
-
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
+ Placeholder (unchanged from previous message).
 
 
 
 
 
 
 
 
 
 
 
 
app (1).py ADDED
@@ -0,0 +1,366 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import math
3
+ import re
4
+ from functools import lru_cache
5
+ from typing import Dict, List, Tuple, Any
6
+
7
+ import numpy as np
8
+ import gradio as gr
9
+
10
+ # Lazy import to speed up cold start a bit
11
+ _ST_MODEL = None
12
+ _ST_NAME = os.getenv("SEM_MODEL_NAME", "sentence-transformers/paraphrase-multilingual-mpnet-base-v2")
13
+
14
+
15
+ # -----------------------------
16
+ # Arabic normalization utilities
17
+ # -----------------------------
18
+
19
+ _AR_DIACRITICS = re.compile(r"[ًٌٍَُِّْـ]")
20
+ _AR_PUNCT = re.compile(r"[^\w\s]")
21
+ _AR_SPACE = re.compile(r"\s+")
22
+
23
+ def normalize_arabic(text: str, strict: bool = True) -> str:
24
+ """
25
+ Normalize Arabic text.
26
+ strict=True : keep letters distinct, mainly remove diacritics + punctuation and collapse spaces.
27
+ strict=False : additionally unify common variants (ا/أ/إ/آ, ى→ي, ؤ→و, ئ→ي). Useful for semantic similarity.
28
+ """
29
+ if not isinstance(text, str):
30
+ text = "" if text is None else str(text)
31
+ t = text.strip()
32
+ # strip diacritics and tatweel
33
+ t = _AR_DIACRITICS.sub("", t)
34
+ t = t.replace("ـ", "") # tatweel
35
+ if not strict:
36
+ # gentle letter unification for semantic mode
37
+ t = re.sub(r"[إأآا]", "ا", t)
38
+ t = t.replace("ى", "ي").replace("ؤ", "و").replace("ئ", "ي")
39
+ # keep 'ة' as-is; many curricula treat it distinctly from 'ه'
40
+ # punctuation → space, then collapse
41
+ t = _AR_PUNCT.sub(" ", t)
42
+ t = _AR_SPACE.sub(" ", t).strip()
43
+ return t
44
+
45
+
46
+ def tok_words(text: str) -> List[str]:
47
+ # After normalization, simple whitespace split is reliable for Arabic WER
48
+ return [w for w in text.split() if w]
49
+
50
+
51
+ _SEG_SPLIT = re.compile(r"[.!؟…]+")
52
+
53
+ def segment_sentences(text: str) -> List[str]:
54
+ return [s.strip() for s in _SEG_SPLIT.split(text) if s.strip()]
55
+
56
+
57
+ # -----------------------------
58
+ # Literal similarity metrics
59
+ # -----------------------------
60
+
61
+ def _levenshtein(a: List[str] | str, b: List[str] | str) -> int:
62
+ """Works for char-level (str) or word-level (list[str]) with O(min(n,m)) memory."""
63
+ n, m = len(a), len(b)
64
+ if n == 0: return m
65
+ if m == 0: return n
66
+ # ensure a is the shorter for memory
67
+ if n > m:
68
+ a, b = b, a
69
+ n, m = m, n
70
+ prev = list(range(n + 1))
71
+ for j in range(1, m + 1):
72
+ curr = [j] + [0] * n
73
+ bj = b[j - 1]
74
+ for i in range(1, n + 1):
75
+ cost = 0 if a[i - 1] == bj else 1
76
+ curr[i] = min(prev[i] + 1, curr[i - 1] + 1, prev[i - 1] + cost)
77
+ prev = curr
78
+ return prev[n]
79
+
80
+
81
+ def cer(reference: str, hypothesis: str) -> float:
82
+ if not reference:
83
+ return 0.0 if not hypothesis else 1.0
84
+ d = _levenshtein(reference, hypothesis)
85
+ return d / max(1, len(reference))
86
+
87
+
88
+ def wer(ref_words: List[str], hyp_words: List[str]) -> float:
89
+ if not ref_words:
90
+ return 0.0 if not hyp_words else 1.0
91
+ d = _levenshtein(ref_words, hyp_words)
92
+ return d / max(1, len(ref_words))
93
+
94
+
95
+ def _lcs_len(a: List[str], b: List[str]) -> int:
96
+ # standard DP (O(n*m)); fine for typical paragraph sizes
97
+ n, m = len(a), len(b)
98
+ if n == 0 or m == 0:
99
+ return 0
100
+ dp = [0] * (m + 1)
101
+ for i in range(1, n + 1):
102
+ prev = 0
103
+ ai = a[i - 1]
104
+ for j in range(1, m + 1):
105
+ tmp = dp[j]
106
+ if ai == b[j - 1]:
107
+ dp[j] = prev + 1
108
+ else:
109
+ dp[j] = max(dp[j], dp[j - 1])
110
+ prev = tmp
111
+ return dp[m]
112
+
113
+
114
+ def rouge_l_f1(ref_words: List[str], hyp_words: List[str]) -> float:
115
+ lcs = _lcs_len(ref_words, hyp_words)
116
+ if lcs == 0:
117
+ return 0.0
118
+ prec = lcs / max(1, len(hyp_words))
119
+ rec = lcs / max(1, len(ref_words))
120
+ if prec + rec == 0:
121
+ return 0.0
122
+ return (2 * prec * rec) / (prec + rec)
123
+
124
+
125
+ def jaccard_char_3(a: str, b: str) -> float:
126
+ A = {a[i:i+3] for i in range(max(0, len(a) - 2))}
127
+ B = {b[i:i+3] for i in range(max(0, len(b) - 2))}
128
+ if not A and not B: return 1.0
129
+ if not A or not B: return 0.0
130
+ return len(A & B) / len(A | B)
131
+
132
+
133
+ # Default weights (tune on your dev set if needed)
134
+ # 0.35*(1 - CER) + 0.35*(1 - WER) + 0.30*ROUGE-L
135
+ W_CER, W_WER, W_RL = 0.35, 0.35, 0.30
136
+
137
+ def literal_scores(reference: str, student: str) -> Dict[str, float]:
138
+ ref = normalize_arabic(reference, strict=True)
139
+ hyp = normalize_arabic(student, strict=True)
140
+
141
+ c = cer(ref, hyp)
142
+ ref_w, hyp_w = tok_words(ref), tok_words(hyp)
143
+ w = wer(ref_w, hyp_w)
144
+ rl = rouge_l_f1(ref_w, hyp_w)
145
+ jac3 = jaccard_char_3(ref, hyp)
146
+
147
+ literal_score = W_CER*(1 - c) + W_WER*(1 - w) + W_RL*rl
148
+ return {
149
+ "CER": float(c),
150
+ "WER": float(w),
151
+ "ROUGE_L": float(rl),
152
+ "Jaccard3": float(jac3),
153
+ "LiteralScore": float(literal_score)
154
+ }
155
+
156
+
157
+ # -----------------------------
158
+ # Semantic similarity (Sentence-Transformers)
159
+ # -----------------------------
160
+
161
+ @lru_cache(maxsize=1)
162
+ def _get_semantic_model():
163
+ global _ST_MODEL
164
+ if _ST_MODEL is None:
165
+ from sentence_transformers import SentenceTransformer, util # local import
166
+ _ST_MODEL = SentenceTransformer(_ST_NAME)
167
+ return _ST_MODEL
168
+
169
+
170
+ def semantic_score(reference: str, student: str) -> Dict[str, float]:
171
+ model = _get_semantic_model()
172
+ ref = normalize_arabic(reference, strict=False)
173
+ hyp = normalize_arabic(student, strict=False)
174
+ # Sentence-level embeddings of the entire text
175
+ emb = model.encode([ref, hyp], normalize_embeddings=True, convert_to_numpy=True)
176
+ sim = float(np.clip(np.dot(emb[0], emb[1]), -1.0, 1.0))
177
+ return {"SemanticSimilarity": sim}
178
+
179
+
180
+ # -----------------------------
181
+ # Segment (sentence/verse) scoring
182
+ # -----------------------------
183
+
184
+ def _length_weighted_avg(pairs: List[Tuple[str, str]], mode: str) -> float:
185
+ """
186
+ Compute a length-weighted average score over aligned segments.
187
+ """
188
+ total_chars = 0
189
+ accum = 0.0
190
+ for r, h in pairs:
191
+ L = literal_scores(r, h)["LiteralScore"]
192
+ S = semantic_score(r, h)["SemanticSimilarity"]
193
+ if mode == "literal":
194
+ s = L
195
+ elif mode == "understanding":
196
+ s = S
197
+ else: # default hybrid in simple avg
198
+ s = (L + S) / 2.0
199
+ w = max(1, len(normalize_arabic(r, strict=True)))
200
+ accum += w * s
201
+ total_chars += w
202
+ if total_chars == 0:
203
+ return 0.0
204
+ return accum / total_chars
205
+
206
+
207
+ def score_long(reference_text: str, student_text: str, *, mode: str = "hybrid") -> float:
208
+ ref_segs = segment_sentences(reference_text)
209
+ hyp_segs = segment_sentences(student_text)
210
+ if not ref_segs:
211
+ return 0.0
212
+ common = min(len(ref_segs), len(hyp_segs))
213
+ pairs: List[Tuple[str, str]] = list(zip(ref_segs[:common], hyp_segs[:common]))
214
+ if len(ref_segs) > common:
215
+ pairs += [(r, "") for r in ref_segs[common:]]
216
+
217
+ # Compute length-weighted stats
218
+ total_len = 0
219
+ accum = 0.0
220
+ for r, h in pairs:
221
+ L = literal_scores(r, h)["LiteralScore"]
222
+ S = semantic_score(r, h)["SemanticSimilarity"]
223
+ if mode == "literal":
224
+ s = L
225
+ elif mode == "understanding":
226
+ s = S
227
+ else: # hybrid -> use product per your instruction
228
+ s = L * S
229
+ w = max(1, len(normalize_arabic(r, strict=True)))
230
+ accum += w * s
231
+ total_len += w
232
+ if total_len == 0:
233
+ return 0.0
234
+ return max(0.0, min(1.0, accum / total_len))
235
+
236
+
237
+ # -----------------------------
238
+ # Final hybrid grade and letter
239
+ # -----------------------------
240
+
241
+ def clamp01(x: float) -> float:
242
+ return max(0.0, min(1.0, float(x)))
243
+
244
+ def hybrid_grade(literal: float, semantic: float) -> float:
245
+ # Simple mean (can be tuned): equal weight to literal accuracy and understanding
246
+ return float((literal + semantic) / 2.0)
247
+
248
+
249
+ # Two rubrics (thresholds) — tweak if you have empirical calibration
250
+ RUBRIC = {
251
+ "literal": {
252
+ "ممتاز": (0.90, 1.00),
253
+ "جيد جداً": (0.80, 0.90),
254
+ "جيد": (0.70, 0.80),
255
+ "تحتاج إعادة": (0.00, 0.70),
256
+ },
257
+ "semantic": {
258
+ "ممتاز": (0.88, 1.00),
259
+ "جيد جداً": (0.82, 0.88),
260
+ "جيد": (0.75, 0.82),
261
+ "تحتاج إعادة": (0.00, 0.75),
262
+ },
263
+ }
264
+
265
+ def _grade_letter(score: float, kind: str) -> str:
266
+ for letter, (lo, hi) in RUBRIC[kind].items():
267
+ if lo <= score <= hi:
268
+ return letter
269
+ return "تحتاج إعادة"
270
+
271
+
272
+ def final_result(reference_text: str, student_text: str, *,
273
+ mode: str = "hybrid",
274
+ use_segments: bool = False) -> Dict[str, Any]:
275
+ """
276
+ mode: 'literal' | 'understanding' | 'hybrid'
277
+ use_segments: True → sentence/verse-level length-weighted scoring
278
+ """
279
+ if use_segments:
280
+ main = score_long(reference_text, student_text, mode=mode)
281
+ else:
282
+ L = literal_scores(reference_text, student_text)["LiteralScore"]
283
+ S = semantic_score(reference_text, student_text)["SemanticSimilarity"]
284
+ if mode == "literal":
285
+ main = L
286
+ elif mode == "understanding":
287
+ main = S
288
+ else:
289
+ main = hybrid_grade(L, S)
290
+ main = clamp01(main)
291
+
292
+ Ld = literal_scores(reference_text, student_text)
293
+ Sd = semantic_score(reference_text, student_text)
294
+
295
+ # choose rubric mapping
296
+ rubric_kind = "semantic" if mode == "understanding" else "literal"
297
+ letter = _grade_letter(float(main), rubric_kind)
298
+
299
+ return {
300
+ "score": float(main),
301
+ "letter": letter,
302
+ "details": {
303
+ **Ld,
304
+ **Sd
305
+ }
306
+ }
307
+
308
+
309
+ # -----------------------------
310
+ # Gradio UI + API
311
+ # -----------------------------
312
+
313
+ EXAMPLE_REF = "الذكاء الاصطناعي يساعد الطلاب على التعلم من خلال توفير محتوى تفاعلي وتقييمات فورية."
314
+ EXAMPLE_STD = "الذكاء الاصطناعي يدعم تعلم الطلاب بتقديم محتوى تفاعلي وتقويمات سريعة."
315
+
316
+ def score_api(reference_text: str, student_text: str, mode: str, use_segments: bool) -> Dict[str, Any]:
317
+ return final_result(reference_text, student_text, mode=mode, use_segments=use_segments)
318
+
319
+ def score_api_batch(pairs: List[Dict[str, Any]], mode: str, use_segments: bool) -> List[Dict[str, Any]]:
320
+ """
321
+ pairs: list of {"reference": "...", "student": "..."}
322
+ """
323
+ out = []
324
+ for item in pairs or []:
325
+ ref = item.get("reference", "")
326
+ std = item.get("student", "")
327
+ out.append(final_result(ref, std, mode=mode, use_segments=use_segments))
328
+ return out
329
+
330
+
331
+ with gr.Blocks(fill_height=True, title="Samaali — Memorization Scoring") as demo:
332
+ gr.Markdown("### Samaali — Memorization Scoring (ASR/OCR Post‑Processing Stage)")
333
+ with gr.Row():
334
+ ref = gr.Textbox(label="Original Text (from OCR)", lines=8, value=EXAMPLE_REF)
335
+ std = gr.Textbox(label="Student Recitation (ASR post‑processed)", lines=8, value=EXAMPLE_STD)
336
+ with gr.Row():
337
+ mode = gr.Radio(
338
+ choices=["hybrid", "literal", "understanding"],
339
+ value="hybrid",
340
+ label="Scoring Mode"
341
+ )
342
+ use_segments = gr.Checkbox(value=False, label="Use sentence/verse segments (length‑weighted)")
343
+ with gr.Row():
344
+ btn = gr.Button("Score", variant="primary")
345
+ clear = gr.Button("Clear")
346
+ score_out = gr.JSON(label="Result (score ∈ [0,1], letter, metrics)")
347
+
348
+ btn.click(fn=score_api, inputs=[ref, std, mode, use_segments], outputs=[score_out], api_name="score")
349
+ clear.click(lambda: ("", "", "hybrid", False), None, [ref, std, mode, use_segments])
350
+
351
+ # Hidden batch endpoint for programmatic access
352
+ hidden_pairs = gr.State([])
353
+ hidden_mode = gr.State("hybrid")
354
+ hidden_segments = gr.State(False)
355
+ hidden_btn = gr.Button(visible=False)
356
+ hidden_btn.click(
357
+ fn=score_api_batch,
358
+ inputs=[hidden_pairs, hidden_mode, hidden_segments],
359
+ outputs=[gr.JSON()],
360
+ api_name="score_batch"
361
+ )
362
+
363
+
364
+ if __name__ == "__main__":
365
+ # Spaces will call `python app.py`; Gradio handles serving.
366
+ demo.queue(max_size=16).launch()
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ gradio>=4.29.0
2
+ sentence-transformers>=2.2.2
3
+ torch
4
+ numpy