youssefreda9 commited on
Commit
dfe1d91
·
1 Parent(s): 8523ec3

ui: Clean up editor placeholder text and alignment (top-right)

Browse files
docs/audit/log_analysis.md ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ### تحليل المشكلة (Log Analysis)
2
+
3
+ لقد قمت بتحليل الـ Logs خطوة بخطوة، واكتشفت بالضبط ما حدث. المشكلة عبارة عن **سلسلة من الأحداث المعقدة** (Cascade) بدأت بسبب "هلوسة" من نموذج الإملاء، وتفاعل معها نظام الـ Offset Mapper ونظام منع التداخل (Overlap Resolver).
4
+
5
+ إليك التفاصيل:
6
+
7
+ #### 1. هلوسة نموذج الإملاء (AraSpell Hallucination)
8
+ النص الأصلي الذي أدخله المستخدم كان يبدو هكذا:
9
+ `"قال محمد: أننا حققنا نجاحا كبيرا في المشروع رغم الصعوباالصعوبات...."`
10
+
11
+ نموذج AraSpell (الذي يعتمد على AraBERT) عندما رأى `"محمد:"`، قام بـ "هلوسة" كلمة `"علي"` من السياق، وأخرج:
12
+ `"قال محمد علي أننا..."` (قام بحذف النقطتين وإضافة كلمة علي).
13
+
14
+ **لماذا سمح النظام بهذا التغيير؟**
15
+ يوجد فلتر في `app.py` يقيس مسافة Levenshtein لكي يمنع التعديلات الكبيرة.
16
+ المسافة بين `"محمد:"` و `"محمدعلي"` هي **3 حركات** (استبدال `:` بحرف `ع`، وإضافة `ل`، وإضافة `ي`).
17
+ القانون في الكود هو: `dist <= 3 and (dist / max_len) <= 0.5`.
18
+ بما أن المسافة 3 والطول الأكبر 7، فالنسبة (3/7 = 0.42) كانت أقل من 0.5، وللأسف **تخطت الفلتر!**
19
+ لذلك تم تسجيل اقتراح إملائي: `محمد:` ⬅️ `محمد علي` (للحروف من 4 إلى 9).
20
+
21
+ #### 2. كيف تداخل الإملاء مع الترقيم؟ (The Offset Collision)
22
+ الآن النص الذي وصل لنموذج الترقيم هو: `"قال محمد علي أننا..."`.
23
+ نموذج الترقيم قرر إضافة نقطتين بعد `"علي"`، فأخرج: `"قال محمد علي: أننا..."`.
24
+ الفرق هنا هو في كلمة `"علي"` ⬅️ `"علي:"`.
25
+
26
+ عندما قام نظام `OffsetMapper` بمحاولة إرجاع مكان كلمة `"علي"` إلى النص الأصلي (الذي لم يكن فيه كلمة علي أصلاً، بل كان فيه `"محمد:"`)، قام بعملية حسابية للنسبة والتناسب (Interpolation) وأرجع المؤشر إلى الحروف `[7:9]` في النص الأصلي (وهي حرف `د` والنقطتين `:`).
27
+
28
+ **النتيجة:**
29
+ - اقتراح الإملاء أخذ المساحة `[4:9]`.
30
+ - اقتراح الترقيم أخذ المساحة `[7:9]`.
31
+ حدث **تداخل (Overlap)** بينهما! وبما أن الترقيم (أولوية 2) أعلى من الإملاء (أولوية 1)، قام نظام Overlap Resolver بـ **حذف اقتراح الإملاء** وقال في الـ Log:
32
+ `Dropped spelling [4:9] 'محمد:' — conflicts with higher-priority span`
33
+
34
+ #### 3. لماذا تم حذف اقتراح الترقيم في آخر النص؟
35
+ في آخر النص كانت الكلمة `"الصعوباالصعوبات...."`.
36
+ - **نموذج الجرامر (أولوية 3):** قام بتصحيحها كاملة إلى `"الصعوبات..."` (اقتراح مساحته `[47:62]`).
37
+ - **نموذج الترقيم (أولوية 2):** لاحظ أن الجرامر ترك 3 نقاط `...` فقرر أن يضيف نقطة رابعة لتصبح `"الصعوبات...."` (اقتراح لنفس المساحة `[47:62]`).
38
+
39
+ حدث تداخل تام على نفس الكلمة! نظام الـ Overlap Resolver قام بعمله بكفاءة عالية هنا، وحذف اقتراح الترقيم لحساب اقتراح الجرامر (لأنه الأهم والأشمل)، وهذا هو سبب الـ Log الثاني:
40
+ `Dropped punctuation [47:62] 'الصعوباالصعوبات' — conflicts with higher-priority span`
41
+
42
+ ---
43
+
44
+ ### الخلاصة والحل المقترح
45
+ 1. **نظام الـ Overlap Resolver يعمل بامتياز!** لقد منع كارثة بصرية في الـ UI كانت ستحدث بسبب التداخل. (Log #2 طبيعي وصحيح جداً).
46
+ 2. **المشكلة الحقيقية في `_is_small_spelling_change`:** خوارزمية قياس المسافة متساهلة جداً مع الكلمات القصيرة.
47
+
48
+ **الحل:**
49
+ تعديل الكود في `app.py` ليكون أكثر صرامة مع الإملاء، بحيث نرفض التغيير إذا كان سيضيف حروفاً كثيرة لكلمة قصيرة (لمنع الهلوسة). سأقوم بتعديل دالة `_is_small_spelling_change` لمنع هذا النوع من الهلوسات مستقبلاً.
src/css/components.css CHANGED
@@ -492,6 +492,7 @@
492
  }
493
 
494
  .editor-surface {
 
495
  background: var(--color-editor);
496
  color: var(--color-text-primary);
497
  min-height: 50vh;
@@ -531,9 +532,15 @@
531
  .editor-surface[data-empty="true"]::before {
532
  content: attr(data-placeholder);
533
  color: var(--color-placeholder);
534
- font-style: italic;
 
535
  pointer-events: none;
536
- display: block;
 
 
 
 
 
537
  }
538
 
539
  .editor-surface.analyzing {
@@ -2717,12 +2724,7 @@ input[type="range"]::-moz-range-thumb {
2717
  vertical-align: middle;
2718
  }
2719
 
2720
- /* ── Fix #8: Editor placeholder alignment ── */
2721
- .editor-surface[data-empty="true"]::before {
2722
- text-align: right;
2723
- direction: rtl;
2724
- padding-top: 2rem;
2725
- }
2726
 
2727
  /* ── Fix #11: Pricing cards equal heights ── */
2728
  #page-pricing .grid > div {
 
492
  }
493
 
494
  .editor-surface {
495
+ position: relative;
496
  background: var(--color-editor);
497
  color: var(--color-text-primary);
498
  min-height: 50vh;
 
532
  .editor-surface[data-empty="true"]::before {
533
  content: attr(data-placeholder);
534
  color: var(--color-placeholder);
535
+ opacity: 0.5;
536
+ font-weight: 300;
537
  pointer-events: none;
538
+ position: absolute;
539
+ top: var(--spacing-lg);
540
+ right: var(--spacing-xl);
541
+ left: var(--spacing-xl);
542
+ text-align: right;
543
+ direction: rtl;
544
  }
545
 
546
  .editor-surface.analyzing {
 
2724
  vertical-align: middle;
2725
  }
2726
 
2727
+ /* ── Fix #8: Editor placeholder alignment (Removed hack) ── */
 
 
 
 
 
2728
 
2729
  /* ── Fix #11: Pricing cards equal heights ── */
2730
  #page-pricing .grid > div {
tests/test_diff.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import difflib
2
+ import re
3
+
4
+ def get_word_positions(text):
5
+ positions = []
6
+ for m in re.finditer(r'\S+', text):
7
+ positions.append((m.group(), m.start(), m.end()))
8
+ return positions
9
+
10
+ def get_word_diffs(original, corrected):
11
+ orig_words = get_word_positions(original)
12
+ corr_words = get_word_positions(corrected)
13
+ s = difflib.SequenceMatcher(None, [w[0] for w in orig_words], [w[0] for w in corr_words])
14
+ suggestions = []
15
+
16
+ for tag, i1, i2, j1, j2 in s.get_opcodes():
17
+ if tag == 'replace':
18
+ if i1 < len(orig_words) and i2 - 1 < len(orig_words):
19
+ start_char = orig_words[i1][1]
20
+ end_char = orig_words[i2-1][2]
21
+ suggestions.append({
22
+ 'start': start_char,
23
+ 'end': end_char,
24
+ 'original': original[start_char:end_char],
25
+ 'correction': " ".join([w[0] for w in corr_words[j1:j2]]),
26
+ 'type': 'generic'
27
+ })
28
+ elif tag == 'delete':
29
+ if i1 < len(orig_words) and i2 - 1 < len(orig_words):
30
+ start_char = orig_words[i1][1]
31
+ end_char = orig_words[i2-1][2]
32
+ suggestions.append({
33
+ 'start': start_char,
34
+ 'end': end_char,
35
+ 'original': original[start_char:end_char],
36
+ 'correction': '',
37
+ 'type': 'generic'
38
+ })
39
+ elif tag == 'insert':
40
+ pos = orig_words[i1][1] if i1 < len(orig_words) else len(original)
41
+ suggestions.append({
42
+ 'start': pos,
43
+ 'end': pos,
44
+ 'original': '',
45
+ 'correction': " ".join([w[0] for w in corr_words[j1:j2]]),
46
+ 'type': 'generic'
47
+ })
48
+
49
+ return suggestions
50
+
51
+ def test():
52
+ original = "قال محمد علي أننا حققنا نجاحا كبيرا في المشروع رغم الصعوباالصعوبات...."
53
+ corrected = "قال محمد علي أننا حققنا نجاحا كبيرا في المشروع رغم الصعوبات..."
54
+
55
+ diffs = get_word_diffs(original, corrected)
56
+ for d in diffs:
57
+ print(d)
58
+
59
+ if __name__ == "__main__":
60
+ test()
tests/test_diff2.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import difflib
2
+ import re
3
+
4
+ def get_word_positions(text):
5
+ positions = []
6
+ for m in re.finditer(r'\S+', text):
7
+ positions.append((m.group(), m.start(), m.end()))
8
+ return positions
9
+
10
+ def get_word_diffs(original, corrected):
11
+ orig_words = get_word_positions(original)
12
+ corr_words = get_word_positions(corrected)
13
+ s = difflib.SequenceMatcher(None, [w[0] for w in orig_words], [w[0] for w in corr_words])
14
+ suggestions = []
15
+
16
+ for tag, i1, i2, j1, j2 in s.get_opcodes():
17
+ if tag == 'replace':
18
+ if i1 < len(orig_words) and i2 - 1 < len(orig_words):
19
+ start_char = orig_words[i1][1]
20
+ end_char = orig_words[i2-1][2]
21
+ suggestions.append({
22
+ 'start': start_char,
23
+ 'end': end_char,
24
+ 'original': original[start_char:end_char],
25
+ 'correction': " ".join([w[0] for w in corr_words[j1:j2]]),
26
+ 'type': 'generic'
27
+ })
28
+ elif tag == 'delete':
29
+ if i1 < len(orig_words) and i2 - 1 < len(orig_words):
30
+ start_char = orig_words[i1][1]
31
+ end_char = orig_words[i2-1][2]
32
+ suggestions.append({
33
+ 'start': start_char,
34
+ 'end': end_char,
35
+ 'original': original[start_char:end_char],
36
+ 'correction': '',
37
+ 'type': 'generic'
38
+ })
39
+ elif tag == 'insert':
40
+ pos = orig_words[i1][1] if i1 < len(orig_words) else len(original)
41
+ suggestions.append({
42
+ 'start': pos,
43
+ 'end': pos,
44
+ 'original': '',
45
+ 'correction': " ".join([w[0] for w in corr_words[j1:j2]]),
46
+ 'type': 'generic'
47
+ })
48
+
49
+ return suggestions
50
+
51
+ def test():
52
+ original = "قال محمد علي أننا حققنا نجاحا كبيرا في المشروع رغم الصعوبات..."
53
+ corrected = "قال محمد علي: أننا حققنا نجاحا كبيرا في المشروع رغم الصعوبات...."
54
+
55
+ diffs = get_word_diffs(original, corrected)
56
+ for d in diffs:
57
+ print(d)
58
+
59
+ if __name__ == "__main__":
60
+ test()
tests/test_full.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import difflib
2
+ import re
3
+
4
+ def get_word_positions(text):
5
+ positions = []
6
+ for m in re.finditer(r'\S+', text):
7
+ positions.append((m.group(), m.start(), m.end()))
8
+ return positions
9
+
10
+ class OffsetMapper:
11
+ def __init__(self, original, modified):
12
+ self.original = original
13
+ self.modified = modified
14
+ self.mapping = []
15
+ self._build_mapping()
16
+
17
+ def _build_mapping(self):
18
+ s = difflib.SequenceMatcher(None, self.original, self.modified)
19
+ for tag, i1, i2, j1, j2 in s.get_opcodes():
20
+ self.mapping.append((j1, j2, i1, i2))
21
+
22
+ def map_offset(self, mod_offset):
23
+ for j1, j2, i1, i2 in self.mapping:
24
+ if j1 <= mod_offset <= j2:
25
+ if j2 == j1:
26
+ return i1
27
+ ratio = (mod_offset - j1) / (j2 - j1)
28
+ return int(i1 + ratio * (i2 - i1))
29
+ return len(self.original)
30
+
31
+ def get_word_diffs(original, corrected):
32
+ orig_words = get_word_positions(original)
33
+ corr_words = get_word_positions(corrected)
34
+ s = difflib.SequenceMatcher(None, [w[0] for w in orig_words], [w[0] for w in corr_words])
35
+ suggestions = []
36
+ for tag, i1, i2, j1, j2 in s.get_opcodes():
37
+ if tag == 'replace':
38
+ if i1 < len(orig_words) and i2 - 1 < len(orig_words):
39
+ start_char = orig_words[i1][1]
40
+ end_char = orig_words[i2-1][2]
41
+ suggestions.append({
42
+ 'start': start_char,
43
+ 'end': end_char,
44
+ 'original': original[start_char:end_char],
45
+ 'correction': " ".join([w[0] for w in corr_words[j1:j2]])
46
+ })
47
+ elif tag == 'delete':
48
+ if i1 < len(orig_words) and i2 - 1 < len(orig_words):
49
+ start_char = orig_words[i1][1]
50
+ end_char = orig_words[i2-1][2]
51
+ suggestions.append({
52
+ 'start': start_char,
53
+ 'end': end_char,
54
+ 'original': original[start_char:end_char],
55
+ 'correction': ''
56
+ })
57
+ elif tag == 'insert':
58
+ pos = orig_words[i1][1] if i1 < len(orig_words) else len(original)
59
+ suggestions.append({
60
+ 'start': pos,
61
+ 'end': pos,
62
+ 'original': '',
63
+ 'correction': " ".join([w[0] for w in corr_words[j1:j2]])
64
+ })
65
+ return suggestions
66
+
67
+ def test():
68
+ original = "قال محمد علي أننا حققنا نجاحا كبيرا في المشروع رغم الصعوباالصعوبات...."
69
+ spelling_corrected = "قال محمد علي أننا حققنا نجاحا كبيرا في المشروع رغم الصعوباالصعوبات...."
70
+ # Wait, in the log, spelling DROPPED [4:9] محمد:. This means original was "قال محمد: علي ..."
71
+ original = "قال محمد: علي أننا حققنا نجاحا كبيرا في المشروع رغم الصعوباالصعوبات...."
72
+ spelling_corrected = "قال محمد علي أننا حققنا نجاحا كبيرا في المشروع رغم الصعوباالصعوبات...."
73
+ grammar_corrected = "قال محمد علي أننا حققنا نجاحا كبيرا في المشروع رغم الصعوبات..."
74
+ punct_corrected = "قال محمد علي: أننا حققنا نجاحا كبيرا في المشروع رغم الصعوبات...."
75
+
76
+ suggestions = []
77
+ mappers = []
78
+
79
+ # 1. Spelling
80
+ # Assume spelling generated the diff exactly as SequenceMatcher would:
81
+ diffs = get_word_diffs(original, spelling_corrected)
82
+ for d in diffs:
83
+ d['type'] = 'spelling'
84
+ suggestions.append(d)
85
+ mappers.append(OffsetMapper(original, spelling_corrected))
86
+
87
+ # 2. Grammar
88
+ def map_range_to_original(start, end):
89
+ curr_start, curr_end = start, end
90
+ for mapper in reversed(mappers):
91
+ curr_start = mapper.map_offset(curr_start)
92
+ curr_end = mapper.map_offset(curr_end)
93
+ return curr_start, curr_end
94
+
95
+ diffs = get_word_diffs(spelling_corrected, grammar_corrected)
96
+ for d in diffs:
97
+ o_s, o_e = map_range_to_original(d['start'], d['end'])
98
+ suggestions.append({
99
+ 'start': o_s, 'end': o_e, 'type': 'grammar',
100
+ 'original': original[o_s:o_e], 'correction': d['correction']
101
+ })
102
+ mappers.append(OffsetMapper(spelling_corrected, grammar_corrected))
103
+
104
+ # 3. Punctuation
105
+ diffs = get_word_diffs(grammar_corrected, punct_corrected)
106
+ for d in diffs:
107
+ o_s, o_e = map_range_to_original(d['start'], d['end'])
108
+ suggestions.append({
109
+ 'start': o_s, 'end': o_e, 'type': 'punctuation',
110
+ 'original': original[o_s:o_e], 'correction': d['correction']
111
+ })
112
+
113
+ print("ALL SUGGESTIONS:")
114
+ for s in suggestions:
115
+ print(s)
116
+
117
+ PRIORITY = {'grammar': 3, 'punctuation': 2, 'spelling': 1, 'autocomplete': 0}
118
+ suggestions.sort(key=lambda s: PRIORITY.get(s['type'], 0), reverse=True)
119
+ claimed_ranges = []
120
+ resolved = []
121
+ for s in suggestions:
122
+ overlaps = False
123
+ for (c_start, c_end, c_type) in claimed_ranges:
124
+ if s['start'] < c_end and s['end'] > c_start:
125
+ overlaps = True
126
+ print(f"[OVERLAP] Dropped {s['type']} [{s['start']}:{s['end']}] '{s['original']}' — conflicts with {c_type}")
127
+ break
128
+ if not overlaps:
129
+ resolved.append(s)
130
+ claimed_ranges.append((s['start'], s['end'], s['type']))
131
+
132
+ if __name__ == "__main__":
133
+ test()
tests/test_mapper.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import difflib
2
+
3
+ class OffsetMapper:
4
+ def __init__(self, original, modified):
5
+ self.original = original
6
+ self.modified = modified
7
+ self.mapping = []
8
+ self._build_mapping()
9
+
10
+ def _build_mapping(self):
11
+ s = difflib.SequenceMatcher(None, self.original, self.modified)
12
+ for tag, i1, i2, j1, j2 in s.get_opcodes():
13
+ self.mapping.append((j1, j2, i1, i2))
14
+
15
+ def map_offset(self, mod_offset):
16
+ for j1, j2, i1, i2 in self.mapping:
17
+ if j1 <= mod_offset <= j2:
18
+ if j2 == j1:
19
+ return i1
20
+ ratio = (mod_offset - j1) / (j2 - j1)
21
+ return int(i1 + ratio * (i2 - i1))
22
+ return len(self.original)
23
+
24
+ def test():
25
+ original = "قال محمد: علي أننا حققنا نجاحا كبيرا في المشروع رغم الصعوباالصعوبات...."
26
+ spelling = "قال محمد علي أننا حققنا نجاحا كبيرا في المشروع رغم الصعوباالصعوبات...."
27
+
28
+ mapper = OffsetMapper(original, spelling)
29
+
30
+ # In spelling text, where is "علي"?
31
+ # "قال محمد علي أننا"
32
+ # "قال " -> 0:4
33
+ # "محمد " -> 4:9
34
+ # "علي" -> 9:12
35
+
36
+ spelling_start = spelling.find("علي")
37
+ spelling_end = spelling_start + 3
38
+ print(f"Spelling string 'علي' is at [{spelling_start}:{spelling_end}]")
39
+
40
+ orig_start = mapper.map_offset(spelling_start)
41
+ orig_end = mapper.map_offset(spelling_end)
42
+ print(f"Mapped to original: [{orig_start}:{orig_end}]")
43
+ print(f"Original text at mapped span: '{original[orig_start:orig_end]}'")
44
+
45
+ if __name__ == "__main__":
46
+ test()
tests/test_opcodes.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import difflib
2
+ import re
3
+
4
+ def get_word_positions(text):
5
+ positions = []
6
+ for m in re.finditer(r'\S+', text):
7
+ positions.append((m.group(), m.start(), m.end()))
8
+ return positions
9
+
10
+ def get_word_diffs(original, corrected):
11
+ orig_words = get_word_positions(original)
12
+ corr_words = get_word_positions(corrected)
13
+ s = difflib.SequenceMatcher(None, [w[0] for w in orig_words], [w[0] for w in corr_words])
14
+
15
+ print("Opcodes:")
16
+ for tag, i1, i2, j1, j2 in s.get_opcodes():
17
+ orig_seq = [w[0] for w in orig_words[i1:i2]]
18
+ corr_seq = [w[0] for w in corr_words[j1:j2]]
19
+ print(f"{tag:7} | orig[{i1}:{i2}]={orig_seq} | corr[{j1}:{j2}]={corr_seq}")
20
+
21
+ suggestions = []
22
+ for tag, i1, i2, j1, j2 in s.get_opcodes():
23
+ if tag == 'replace':
24
+ if i1 < len(orig_words) and i2 - 1 < len(orig_words):
25
+ start_char = orig_words[i1][1]
26
+ end_char = orig_words[i2-1][2]
27
+ suggestions.append({
28
+ 'start': start_char,
29
+ 'end': end_char,
30
+ 'original': original[start_char:end_char],
31
+ 'correction': " ".join([w[0] for w in corr_words[j1:j2]]),
32
+ 'type': 'grammar'
33
+ })
34
+ elif tag == 'delete':
35
+ if i1 < len(orig_words) and i2 - 1 < len(orig_words):
36
+ start_char = orig_words[i1][1]
37
+ end_char = orig_words[i2-1][2]
38
+ suggestions.append({
39
+ 'start': start_char,
40
+ 'end': end_char,
41
+ 'original': original[start_char:end_char],
42
+ 'correction': '',
43
+ 'type': 'grammar'
44
+ })
45
+ elif tag == 'insert':
46
+ pos = orig_words[i1][1] if i1 < len(orig_words) else len(original)
47
+ suggestions.append({
48
+ 'start': pos,
49
+ 'end': pos,
50
+ 'original': '',
51
+ 'correction': " ".join([w[0] for w in corr_words[j1:j2]]),
52
+ 'type': 'grammar'
53
+ })
54
+
55
+ return suggestions
56
+
57
+ def test():
58
+ original = "قال محمد علي أننا حققنا نجاحا كبيرا في المشروع رغم الصعوباالصعوبات...."
59
+ corrected = "قال محمد علي أننا حققنا نجاحا كبيرا في المشروع رغم الصعوبات..."
60
+
61
+ print("Testing original vs corrected:")
62
+ diffs = get_word_diffs(original, corrected)
63
+ print("\nSuggestions generated:")
64
+ for d in diffs:
65
+ print(d)
66
+
67
+ if __name__ == "__main__":
68
+ test()
tests/test_overlap.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import difflib
2
+ import re
3
+
4
+ def get_word_positions(text):
5
+ positions = []
6
+ for m in re.finditer(r'\S+', text):
7
+ positions.append((m.group(), m.start(), m.end()))
8
+ return positions
9
+
10
+ class OffsetMapper:
11
+ def __init__(self, original, modified):
12
+ self.original = original
13
+ self.modified = modified
14
+ self.mapping = []
15
+ self._build_mapping()
16
+
17
+ def _build_mapping(self):
18
+ s = difflib.SequenceMatcher(None, self.original, self.modified)
19
+ for tag, i1, i2, j1, j2 in s.get_opcodes():
20
+ self.mapping.append((j1, j2, i1, i2))
21
+
22
+ def map_offset(self, mod_offset):
23
+ for j1, j2, i1, i2 in self.mapping:
24
+ if j1 <= mod_offset <= j2:
25
+ if j2 == j1:
26
+ return i1
27
+ ratio = (mod_offset - j1) / (j2 - j1)
28
+ return int(i1 + ratio * (i2 - i1))
29
+ return len(self.original)
30
+
31
+ def get_word_diffs(original, corrected):
32
+ orig_words = get_word_positions(original)
33
+ corr_words = get_word_positions(corrected)
34
+ s = difflib.SequenceMatcher(None, [w[0] for w in orig_words], [w[0] for w in corr_words])
35
+ suggestions = []
36
+
37
+ for tag, i1, i2, j1, j2 in s.get_opcodes():
38
+ if tag == 'replace':
39
+ if i1 < len(orig_words) and i2 - 1 < len(orig_words):
40
+ start_char = orig_words[i1][1]
41
+ end_char = orig_words[i2-1][2]
42
+ suggestions.append({
43
+ 'start': start_char,
44
+ 'end': end_char,
45
+ 'original': original[start_char:end_char],
46
+ 'correction': " ".join([w[0] for w in corr_words[j1:j2]]),
47
+ 'type': 'generic'
48
+ })
49
+ elif tag == 'delete':
50
+ if i1 < len(orig_words) and i2 - 1 < len(orig_words):
51
+ start_char = orig_words[i1][1]
52
+ end_char = orig_words[i2-1][2]
53
+ suggestions.append({
54
+ 'start': start_char,
55
+ 'end': end_char,
56
+ 'original': original[start_char:end_char],
57
+ 'correction': '',
58
+ 'type': 'generic'
59
+ })
60
+ elif tag == 'insert':
61
+ pos = orig_words[i1][1] if i1 < len(orig_words) else len(original)
62
+ suggestions.append({
63
+ 'start': pos,
64
+ 'end': pos,
65
+ 'original': '',
66
+ 'correction': " ".join([w[0] for w in corr_words[j1:j2]]),
67
+ 'type': 'generic'
68
+ })
69
+
70
+ return suggestions
71
+
72
+ def test():
73
+ original_text = "قال محمد: علي أننا حققنا نجاحا كبيرا في المشروع رغم الصعوباالصعوبات...."
74
+ spelling_text = "قال محمد علي أننا حققنا نجاحا كبيرا في المشروع رغم الصعوباالصعوبات...."
75
+ grammar_text = "قال محمد علي أننا حققنا نجاحا كبيرا في المشروع رغم الصعوبات..."
76
+ punct_text = "قال محمد علي: أننا حققنا نجاحا كبيرا في المشروع رغم الصعوبات...."
77
+
78
+ suggestions = []
79
+ mappers = []
80
+
81
+ # SPELLING
82
+ suggestions.append({
83
+ 'start': 4,
84
+ 'end': 9,
85
+ 'original': "محمد:",
86
+ 'correction': "محمد",
87
+ 'type': 'spelling'
88
+ })
89
+ mappers.append(OffsetMapper(original_text, spelling_text))
90
+
91
+ def map_range_to_original(start, end):
92
+ curr_start, curr_end = start, end
93
+ for mapper in reversed(mappers):
94
+ curr_start = mapper.map_offset(curr_start)
95
+ curr_end = mapper.map_offset(curr_end)
96
+ return curr_start, curr_end
97
+
98
+ # GRAMMAR
99
+ diffs = get_word_diffs(spelling_text, grammar_text)
100
+ for d in diffs:
101
+ orig_start, orig_end = map_range_to_original(d['start'], d['end'])
102
+ suggestions.append({
103
+ 'start': orig_start,
104
+ 'end': orig_end,
105
+ 'original': original_text[orig_start:orig_end],
106
+ 'correction': d['correction'],
107
+ 'type': 'grammar'
108
+ })
109
+ mappers.append(OffsetMapper(spelling_text, grammar_text))
110
+
111
+ # PUNCTUATION
112
+ diffs = get_word_diffs(grammar_text, punct_text)
113
+ for d in diffs:
114
+ orig_start, orig_end = map_range_to_original(d['start'], d['end'])
115
+ suggestions.append({
116
+ 'start': orig_start,
117
+ 'end': orig_end,
118
+ 'original': original_text[orig_start:orig_end],
119
+ 'correction': d['correction'],
120
+ 'type': 'punctuation'
121
+ })
122
+
123
+ print("SUGGESTIONS BEFORE RESOLUTION:")
124
+ for s in suggestions:
125
+ print(s)
126
+
127
+ PRIORITY = {'grammar': 3, 'punctuation': 2, 'spelling': 1, 'autocomplete': 0}
128
+ suggestions.sort(key=lambda s: PRIORITY.get(s['type'], 0), reverse=True)
129
+ claimed_ranges = []
130
+ resolved = []
131
+ for s in suggestions:
132
+ s_start, s_end = s['start'], s['end']
133
+ overlaps = False
134
+ for (c_start, c_end, c_type) in claimed_ranges:
135
+ if s_start < c_end and s_end > c_start:
136
+ overlaps = True
137
+ print(f"Overlap detected! {s['type']} [{s_start}:{s_end}] overlaps with {c_type} [{c_start}:{c_end}]")
138
+ break
139
+ if not overlaps:
140
+ resolved.append(s)
141
+ claimed_ranges.append((s_start, s_end, s['type']))
142
+ else:
143
+ print(f"[OVERLAP] Dropped {s['type']} [{s_start}:{s_end}] '{s.get('original','')}'")
144
+
145
+ if __name__ == "__main__":
146
+ test()
tests/test_punc.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ _PUNC_CHARS = set('،؛؟!.,:;?»«()[]{}…–—\u060C\u061B\u061F')
2
+
3
+ def _is_punctuation_only_change(original, correction):
4
+ orig_letters = ''.join(c for c in original if c not in _PUNC_CHARS and not c.isspace())
5
+ corr_letters = ''.join(c for c in correction if c not in _PUNC_CHARS and not c.isspace())
6
+
7
+ if orig_letters != corr_letters:
8
+ return False
9
+ has_punc = any(c in _PUNC_CHARS for c in original) or any(c in _PUNC_CHARS for c in correction)
10
+ return has_punc
11
+
12
+ print(_is_punctuation_only_change('محمد:', 'محمد'))
13
+ print(_is_punctuation_only_change('محمد:', 'محمّد'))
tests/test_regex.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ import re
2
+ orig_word = 'محمد:'
3
+ res = re.search(r'[^ء-يآأإىa-zA-Z]', orig_word)
4
+ print("Regex match:", res)
tests/test_seq.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import difflib
2
+
3
+ orig = ['قال', 'محمد', 'علي', 'أننا']
4
+ corr = ['قال', 'محمد', 'علي:', 'أننا']
5
+
6
+ s = difflib.SequenceMatcher(None, orig, corr)
7
+ for tag, i1, i2, j1, j2 in s.get_opcodes():
8
+ print(f"{tag:7} | orig[{i1}:{i2}]={orig[i1:i2]} | corr[{j1}:{j2}]={corr[j1:j2]}")