Bayan AI commited on
Commit ·
76b9ec3
1
Parent(s): dd8a53f
Fix: TerminalPunctuationGuard for short phrases and comprehensive Nasb/Jazm grammar rules fixes
Browse files- apply_locks.py +77 -0
- debug_pipeline.py +24 -0
- debug_punctuation.py +45 -0
- extract_grammar_fails.py +23 -0
- grammar_fails_output.md +37 -0
- reports/Phase10_Post_IVtoOOV_Audit.md +72 -0
- src/app.py +9 -0
- src/nlp/grammar/grammar_rules.py +51 -8
- src/nlp/punctuation/punctuation_rules.py +3 -3
- src/nlp/spelling/araspell_service.py +1 -1
- test_grammar_fixes.py +18 -0
- test_models.py +24 -0
- test_punctuation.py +17 -0
- tests/phase10/benchmark_runner.py +1 -0
- tests/phase10/reports/phase10_results.json +0 -0
apply_locks.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
def apply_lock_to_file(filepath, var_name, engine_name, func_name):
|
| 4 |
+
with open(filepath, 'r', encoding='utf-8') as f:
|
| 5 |
+
lines = f.readlines()
|
| 6 |
+
|
| 7 |
+
out_lines = []
|
| 8 |
+
in_imports = False
|
| 9 |
+
added_threading = False
|
| 10 |
+
in_globals = False
|
| 11 |
+
added_lock_var = False
|
| 12 |
+
in_func = False
|
| 13 |
+
|
| 14 |
+
for line in lines:
|
| 15 |
+
if line.startswith('import ') and not added_threading:
|
| 16 |
+
out_lines.append(line)
|
| 17 |
+
out_lines.append("import threading\n")
|
| 18 |
+
added_threading = True
|
| 19 |
+
continue
|
| 20 |
+
|
| 21 |
+
if line.startswith(f'_{var_name} = None') and not added_lock_var:
|
| 22 |
+
out_lines.append(line)
|
| 23 |
+
out_lines.append(f"_load_lock = threading.Lock()\n")
|
| 24 |
+
added_lock_var = True
|
| 25 |
+
continue
|
| 26 |
+
|
| 27 |
+
if line.startswith(f'def {func_name}('):
|
| 28 |
+
in_func = True
|
| 29 |
+
out_lines.append(line)
|
| 30 |
+
continue
|
| 31 |
+
|
| 32 |
+
if in_func:
|
| 33 |
+
if line.startswith(f' global '):
|
| 34 |
+
out_lines.append(line.replace('\n', f', _load_lock\n'))
|
| 35 |
+
continue
|
| 36 |
+
|
| 37 |
+
if line.startswith(f' try:'):
|
| 38 |
+
# The start of the old try block. We wrap everything from here.
|
| 39 |
+
out_lines.append(f' with _load_lock:\n')
|
| 40 |
+
out_lines.append(f' if _{var_name} is not None:\n')
|
| 41 |
+
out_lines.append(f' return _{var_name}\n\n')
|
| 42 |
+
out_lines.append(f' try:\n')
|
| 43 |
+
continue
|
| 44 |
+
|
| 45 |
+
# If we are inside the function and past the global declaration,
|
| 46 |
+
# and it's indented with at least 4 spaces, we need to add 4 more spaces
|
| 47 |
+
# for the lines that were inside the old `try:` and `except:`
|
| 48 |
+
# EXCEPT for `if _xxx is not None: return _xxx` which comes before the try
|
| 49 |
+
if line.startswith(' if _') or line.startswith(' return _'):
|
| 50 |
+
# This is the old `if checker is not None:` logic before try. Leave it alone.
|
| 51 |
+
out_lines.append(line)
|
| 52 |
+
continue
|
| 53 |
+
|
| 54 |
+
if line.startswith(' '):
|
| 55 |
+
# Shift everything that was inside try/except right by 4 spaces
|
| 56 |
+
if line.strip() == '':
|
| 57 |
+
out_lines.append('\n')
|
| 58 |
+
else:
|
| 59 |
+
out_lines.append(' ' + line)
|
| 60 |
+
|
| 61 |
+
if line.startswith(' return _') or line.startswith(' raise RuntimeError'):
|
| 62 |
+
# End of function
|
| 63 |
+
in_func = False
|
| 64 |
+
continue
|
| 65 |
+
|
| 66 |
+
out_lines.append(line)
|
| 67 |
+
|
| 68 |
+
with open(filepath, 'w', encoding='utf-8') as f:
|
| 69 |
+
f.writelines(out_lines)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
apply_lock_to_file(r'src/nlp/spelling/araspell_service.py', 'spell_checker', 'AraSpell', 'get_spelling_model')
|
| 73 |
+
apply_lock_to_file(r'src/nlp/punctuation/punctuation_service.py', 'punctuation_checker', 'PuncAra', 'get_punctuation_model')
|
| 74 |
+
apply_lock_to_file(r'src/nlp/grammar/grammar_service.py', 'grammar_checker', 'Grammar', 'get_grammar_model')
|
| 75 |
+
apply_lock_to_file(r'src/nlp/autocomplete/autocomplete_service.py', 'autocomplete_engine', 'Autocomplete', 'get_autocomplete_model')
|
| 76 |
+
|
| 77 |
+
print("Locks applied perfectly with correct indentation!")
|
debug_pipeline.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
# Add src to python path
|
| 5 |
+
sys.path.insert(0, str(Path(r'c:\Users\dell\PycharmProjects\JupyterProject1\PythonProject\BAYAN\src')))
|
| 6 |
+
|
| 7 |
+
from app import app
|
| 8 |
+
import json
|
| 9 |
+
|
| 10 |
+
client = app.test_client()
|
| 11 |
+
|
| 12 |
+
def test(text):
|
| 13 |
+
print(f"\n--- Testing: {text} ---")
|
| 14 |
+
resp = client.post('/api/analyze', json={'text': text})
|
| 15 |
+
data = resp.get_json()
|
| 16 |
+
if 'suggestions' in data:
|
| 17 |
+
for s in data['suggestions']:
|
| 18 |
+
print(f"[{s['type'].upper()}] '{s['original']}' -> '{s['correction']}'")
|
| 19 |
+
else:
|
| 20 |
+
print("Error:", data)
|
| 21 |
+
|
| 22 |
+
test("ذهبت المهندسون الي العمل")
|
| 23 |
+
test("ذهبت المهندسون")
|
| 24 |
+
test("الي العمل")
|
debug_punctuation.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
import io
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
# Force UTF-8 encoding for standard output
|
| 6 |
+
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
| 7 |
+
|
| 8 |
+
# Add src to python path
|
| 9 |
+
sys.path.insert(0, str(Path(r'c:\Users\dell\PycharmProjects\JupyterProject1\PythonProject\BAYAN\src')))
|
| 10 |
+
|
| 11 |
+
from app import app
|
| 12 |
+
|
| 13 |
+
client = app.test_client()
|
| 14 |
+
|
| 15 |
+
def test(text):
|
| 16 |
+
print(f"\n--- Testing: {text} ---")
|
| 17 |
+
resp = client.post('/api/analyze', json={'text': text})
|
| 18 |
+
data = resp.get_json()
|
| 19 |
+
|
| 20 |
+
if data and 'corrected' in data:
|
| 21 |
+
print(f"Corrected: {data['corrected']}")
|
| 22 |
+
|
| 23 |
+
if data and 'suggestions' in data:
|
| 24 |
+
for s in data['suggestions']:
|
| 25 |
+
print(f"[{s['type'].upper()}] '{s['original']}' -> '{s['correction']}'")
|
| 26 |
+
elif data and 'error' in data:
|
| 27 |
+
print("Error:", data['error'])
|
| 28 |
+
else:
|
| 29 |
+
print("Raw Data:", data)
|
| 30 |
+
|
| 31 |
+
if __name__ == "__main__":
|
| 32 |
+
# Test 1: Single Entity (Should not have punctuation added)
|
| 33 |
+
test("شركة أبل")
|
| 34 |
+
|
| 35 |
+
# Test 2: Short phrase (Should not have punctuation added)
|
| 36 |
+
test("مرحبا بكم في التطبيق")
|
| 37 |
+
|
| 38 |
+
# Test 3: Grammar error that gets fixed but then corrupted by punctuation
|
| 39 |
+
# "الى" is spelled wrong (needs hamza on alif below if it's إِلى or just remains الى depending on rules,
|
| 40 |
+
# but let's see what grammar does). Actually "يذهبون المهندسون" is a grammar error in Arabic
|
| 41 |
+
# (should be يذهب المهندسون).
|
| 42 |
+
test("يذهبون المهندسون الى الشركة")
|
| 43 |
+
|
| 44 |
+
# Test 4: Verify Grammar Model preserves punctuation
|
| 45 |
+
test("يذهبون المهندسون الى الشركة، أليس كذلك؟")
|
extract_grammar_fails.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import sys
|
| 3 |
+
|
| 4 |
+
sys.stdout.reconfigure(encoding='utf-8')
|
| 5 |
+
|
| 6 |
+
try:
|
| 7 |
+
with open('tests/phase10/reports/phase10_results.json', 'r', encoding='utf-8') as f:
|
| 8 |
+
data = json.load(f)
|
| 9 |
+
|
| 10 |
+
with open('grammar_fails_output.md', 'w', encoding='utf-8') as out_f:
|
| 11 |
+
out_f.write("=== GRAMMAR FALSE NEGATIVES ===\n")
|
| 12 |
+
for r in data.get('results', []):
|
| 13 |
+
if r.get('dataset') == 'grammar' and r.get('pipeline_verdict') == 'FN':
|
| 14 |
+
out_f.write(f"[{r.get('id')}] - {r.get('category')}\n")
|
| 15 |
+
out_f.write(f" IN: {r.get('input')}\n")
|
| 16 |
+
out_f.write(f" EXP: {r.get('expected')}\n")
|
| 17 |
+
out_f.write(f" RAW_GRAM: {r.get('grammar_raw_output')}\n")
|
| 18 |
+
out_f.write(f" FINAL: {r.get('pipeline_output')}\n")
|
| 19 |
+
out_f.write("-" * 50 + "\n")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
except Exception as e:
|
| 23 |
+
print(f"Error: {e}")
|
grammar_fails_output.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
=== GRAMMAR FALSE NEGATIVES ===
|
| 2 |
+
[G006] - sv_agree
|
| 3 |
+
IN: الأولاد لعب في الحديقة
|
| 4 |
+
EXP: لعبوا
|
| 5 |
+
RAW_GRAM: الأولاد لعبوَ في الحديقة
|
| 6 |
+
FINAL: الأولاد لعب في الحديقة.
|
| 7 |
+
--------------------------------------------------
|
| 8 |
+
[G009] - sv_agree
|
| 9 |
+
IN: العمال بنى المبنى
|
| 10 |
+
EXP: بنوا
|
| 11 |
+
RAW_GRAM: العمال بنى المبنى
|
| 12 |
+
FINAL: العمال بنى المبنى.
|
| 13 |
+
--------------------------------------------------
|
| 14 |
+
[G013] - gender
|
| 15 |
+
IN: الطالبة متفوق في دراسته
|
| 16 |
+
EXP: متفوقة/دراستها
|
| 17 |
+
RAW_GRAM: الطالب متفوق في دراسته
|
| 18 |
+
FINAL: الطالب متفوق في دراسته.
|
| 19 |
+
--------------------------------------------------
|
| 20 |
+
[G022] - five_nouns
|
| 21 |
+
IN: رأيت أخوك في المسجد
|
| 22 |
+
EXP: أخاك
|
| 23 |
+
RAW_GRAM: رأيت أخوك في المسجد
|
| 24 |
+
FINAL: رأيت أخوك في المسجد
|
| 25 |
+
--------------------------------------------------
|
| 26 |
+
[G026] - dual
|
| 27 |
+
IN: هاتان الطالبان مجتهدان
|
| 28 |
+
EXP: هذان
|
| 29 |
+
RAW_GRAM: هذان الطالبان مجتهدان
|
| 30 |
+
FINAL: هاتان الطالبات مجتهدان.
|
| 31 |
+
--------------------------------------------------
|
| 32 |
+
[G028] - nasb
|
| 33 |
+
IN: لم يفعلون الواجب بعد
|
| 34 |
+
EXP: يفعلوا
|
| 35 |
+
RAW_GRAM: لم يفعلوَ الواجب بعد
|
| 36 |
+
FINAL: لم يفعلون الواجب بعد
|
| 37 |
+
--------------------------------------------------
|
reports/Phase10_Post_IVtoOOV_Audit.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Phase 10 Benchmark Audit — Post IVtoOOV Removal
|
| 2 |
+
|
| 3 |
+
> **Date**: 2026-06-24
|
| 4 |
+
> **Action**: Removed `IVtoOOV` filter and added advanced `camel-tools` POS tagging for dual/plural noun-adjective agreement.
|
| 5 |
+
|
| 6 |
+
## 1. Top-Level Aggregate Metrics
|
| 7 |
+
|
| 8 |
+
| Metric | Score | Notes |
|
| 9 |
+
|---|---|---|
|
| 10 |
+
| **Overall Pass Rate** | **56.2%** | Massive improvement (previously ~25%) |
|
| 11 |
+
| Total Tests | 320 | |
|
| 12 |
+
| True Positives (TP) | 95 | Successfully fixed real errors |
|
| 13 |
+
| True Negatives (TN) | 85 | Successfully ignored correct text |
|
| 14 |
+
| False Positives (FP) | 79 | Hallucinations or overcorrections |
|
| 15 |
+
| False Negatives (FN) | 61 | Failed to fix real errors |
|
| 16 |
+
|
| 17 |
+
### Root Cause Analysis (61 FN + 79 FP = 140 Failures)
|
| 18 |
+
- **Punctuation Model (`MODEL:punctuation`)**: 64 failures
|
| 19 |
+
- **Integration/Collisions (`PIPELINE:integration`)**: 32 failures
|
| 20 |
+
- **Spelling Model (`MODEL:spelling`)**: 21 failures
|
| 21 |
+
- **Grammar Model (`MODEL:grammar`)**: 18 failures
|
| 22 |
+
|
| 23 |
+
---
|
| 24 |
+
|
| 25 |
+
## 2. Per-Dataset Breakdown
|
| 26 |
+
|
| 27 |
+
### Grammar Dataset
|
| 28 |
+
* **Pass Rate:** 57.8% (up from 26.7%)
|
| 29 |
+
* **Recall:** 80.0% (up from 40.0%)
|
| 30 |
+
* **Analysis:** Removing `IVtoOOV` successfully unblocked valid grammatical structural changes. The recall doubled.
|
| 31 |
+
* **Remaining Issue:** High False Positive Rate on the `correct...` category. The model hallucinates changes on already perfect text.
|
| 32 |
+
|
| 33 |
+
### Spelling Dataset
|
| 34 |
+
* **Pass Rate:** 63.7% (up from 42.5%)
|
| 35 |
+
* **Recall:** 79.4%
|
| 36 |
+
* **Remaining Issue:** Still missing some Hamza errors and complex word splits (`عندالباب` -> `عند الباب`).
|
| 37 |
+
|
| 38 |
+
### Structured Content & Religious Datasets
|
| 39 |
+
* **Structured Pass Rate:** 82.9% (up from 5.7%)
|
| 40 |
+
* **Religious Pass Rate:** 90.0% (up from 10.0%)
|
| 41 |
+
* **Analysis:** The `DigitGuard` and punctuation bypass rules are working incredibly well to protect specialized text.
|
| 42 |
+
|
| 43 |
+
### Pipeline Collision Dataset
|
| 44 |
+
* **Pass Rate:** 16.0% (Terrible)
|
| 45 |
+
* **False Negative Rate:** 84.0%
|
| 46 |
+
* **Analysis:** When a spelling error is adjacent to a grammar error, `StageLocker` is locking the word and preventing the grammar model from seeing or fixing the grammatical context.
|
| 47 |
+
|
| 48 |
+
### Entities Dataset
|
| 49 |
+
* **Pass Rate:** 13.3%
|
| 50 |
+
* **Analysis:** The models (especially punctuation and spelling) are aggressively modifying named entities (people, places).
|
| 51 |
+
|
| 52 |
+
---
|
| 53 |
+
|
| 54 |
+
## 3. Strategic Action Plan for Enhancements
|
| 55 |
+
|
| 56 |
+
To push the pass rate from **56.2%** to **>80%**, we must address the following critical areas:
|
| 57 |
+
|
| 58 |
+
### A. Tame the "StageLocker" (Fix Pipeline Collisions)
|
| 59 |
+
The `StageLocker` in `app.py` enforces a rigid "Spelling locks word X, Grammar cannot touch word X" rule. This breaks multi-stage corrections.
|
| 60 |
+
**Solution:** Relax the `StageLocker`. Allow the grammar model to operate on tokens that were modified by spelling, provided the grammatical change doesn't completely revert the spelling correction (e.g., checking Jaccard distance or allowing suffix-only changes to locked words).
|
| 61 |
+
|
| 62 |
+
### B. Stop Punctuation Hallucinations
|
| 63 |
+
The punctuation model causes **64 failures**, mostly by adding periods `.` or question marks `؟` to the end of short sentences or entities where they don't belong.
|
| 64 |
+
**Solution:** Implement a strict `TerminalPunctuationGuard`. If the original text is < 5 words and doesn't end in punctuation, automatically strip any trailing punctuation added by the model.
|
| 65 |
+
|
| 66 |
+
### C. Implement Named Entity Recognition (NER) Bypass
|
| 67 |
+
Entities (Person names, Cities) are failing at an 86% rate.
|
| 68 |
+
**Solution:** Integrate `camel-tools` NER (Named Entity Recognition). Scan the input text for `LOC`, `PERS`, and `ORG`. If a word is an entity, add it to a dynamic whitelist so the Spelling and Grammar models skip it entirely.
|
| 69 |
+
|
| 70 |
+
### D. Tame Grammar Hallucinations on Correct Text
|
| 71 |
+
The grammar model hallucinates on perfectly correct text.
|
| 72 |
+
**Solution:** Use a POS-based confidence score. If the grammar model attempts to change a noun into a verb, or completely alters the POS structure of an already valid sentence, reject the change. Alternatively, enforce stricter `Jaccard_05` checks for non-structural changes.
|
src/app.py
CHANGED
|
@@ -1101,6 +1101,15 @@ def _is_small_spelling_change(orig_word, corr_word, vocab_manager=None):
|
|
| 1101 |
# Exception: if diff is just adding/removing ا at start (hamza)
|
| 1102 |
if abs(len(orig_word) - len(corr_word)) > 1:
|
| 1103 |
return 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1104 |
# ── Phase 12 (A1): Keyboard-neighbor and phonetic acceptance ──
|
| 1105 |
# Check each differing character: ortho → full accept, keyboard/phonetic → dampened
|
| 1106 |
_has_keyboard_or_phonetic = False
|
|
|
|
| 1101 |
# Exception: if diff is just adding/removing ا at start (hamza)
|
| 1102 |
if abs(len(orig_word) - len(corr_word)) > 1:
|
| 1103 |
return 0.0
|
| 1104 |
+
|
| 1105 |
+
# ── FIX: Block Grammar Changes masked as Spelling Typos (Dual → Plural) ──
|
| 1106 |
+
if orig_word.endswith('ان') and corr_word.endswith('ات') and orig_word[:-2] == corr_word[:-2]:
|
| 1107 |
+
logger.info(
|
| 1108 |
+
f"[SPELLING] Blocked grammatical change (Dual→Plural): "
|
| 1109 |
+
f"'{orig_word}'→'{corr_word}'"
|
| 1110 |
+
)
|
| 1111 |
+
return 0.0
|
| 1112 |
+
|
| 1113 |
# ── Phase 12 (A1): Keyboard-neighbor and phonetic acceptance ──
|
| 1114 |
# Check each differing character: ortho → full accept, keyboard/phonetic → dampened
|
| 1115 |
_has_keyboard_or_phonetic = False
|
src/nlp/grammar/grammar_rules.py
CHANGED
|
@@ -118,7 +118,8 @@ class ArabicGrammarGuard:
|
|
| 118 |
if prev_word in jazm_particles or word.startswith('ل') or word.startswith('ول'):
|
| 119 |
is_jazm_context = True
|
| 120 |
|
| 121 |
-
|
|
|
|
| 122 |
if word.endswith('ون'):
|
| 123 |
word = word[:-2] + 'وا'
|
| 124 |
elif word.endswith('ان'):
|
|
@@ -126,12 +127,30 @@ class ArabicGrammarGuard:
|
|
| 126 |
elif word.endswith('ين'):
|
| 127 |
word = word[:-2] + 'ي'
|
| 128 |
elif is_jazm_context:
|
| 129 |
-
if word.endswith('و') and len(word) > 3:
|
| 130 |
word = word[:-1] + 'ُ'
|
| 131 |
-
elif
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
corrected_tokens.append(word)
|
| 137 |
return " ".join(corrected_tokens)
|
|
@@ -325,15 +344,23 @@ class ArabicGrammarGuard:
|
|
| 325 |
elif is_plural_masc:
|
| 326 |
if (not verb_word.endswith('ون') and not verb_word.endswith('وا')
|
| 327 |
and not verb_word.endswith('ين')):
|
|
|
|
|
|
|
| 328 |
corrected_tokens[i+1] = verb_word + 'ون'
|
| 329 |
else:
|
| 330 |
# Past tense: ذهب→ذهبوا (masc) / ذهبن (fem)
|
| 331 |
if is_plural_fem:
|
| 332 |
if not verb_word.endswith('ن') and not verb_word.endswith('نَ'):
|
|
|
|
|
|
|
| 333 |
corrected_tokens[i+1] = verb_word + 'ن'
|
| 334 |
elif is_plural_masc:
|
| 335 |
if (not verb_word.endswith('وا') and not verb_word.endswith('ون')
|
| 336 |
and not verb_word.endswith('ين')):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 337 |
corrected_tokens[i+1] = verb_word + 'وا'
|
| 338 |
|
| 339 |
return " ".join(corrected_tokens)
|
|
@@ -343,6 +370,10 @@ class ArabicGrammarGuard:
|
|
| 343 |
text = re.sub(r'\b(إن|أن|كأن|لكن|لعل|ليت)\s+(أبوك|أخوك|ذو|فوك)\b',
|
| 344 |
lambda m: f"{m.group(1)} {m.group(2).replace('و', 'ا')}", text)
|
| 345 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 346 |
# حروف الجر المنفصلة بمسافة (في أخوك -> في أخيك)
|
| 347 |
text = re.sub(r'\b([وف]?(?:في|من|إلى|على|عن))\s+(أبوك|أباك|أخوك|أخاك|ذو|ذا)\b',
|
| 348 |
lambda m: f"{m.group(1)} {m.group(2).replace('و', 'ي').replace('ا', 'ي')}", text)
|
|
@@ -509,6 +540,19 @@ class ArabicGrammarGuard:
|
|
| 509 |
"""Apply all grammar rules to model output."""
|
| 510 |
text = self.preserve_numbers(original_text, generated_text)
|
| 511 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 512 |
# Each rule is wrapped in try/except so that if camel-tools
|
| 513 |
# functions fail, the regex-based rules still execute.
|
| 514 |
for rule_name, rule_fn in [
|
|
@@ -527,8 +571,7 @@ class ArabicGrammarGuard:
|
|
| 527 |
text = rule_fn(text)
|
| 528 |
except Exception as e:
|
| 529 |
logger.warning(f"[GRAMMAR-RULES] {rule_name} failed: {e}")
|
| 530 |
-
|
| 531 |
-
|
| 532 |
text = re.sub(r'\s+', ' ', text).strip()
|
| 533 |
return text
|
| 534 |
|
|
|
|
| 118 |
if prev_word in jazm_particles or word.startswith('ل') or word.startswith('ول'):
|
| 119 |
is_jazm_context = True
|
| 120 |
|
| 121 |
+
is_present_tense = word.startswith('ي') or word.startswith('ت') or word.startswith('ن') or word.startswith('أ')
|
| 122 |
+
if (pos_tag == 'verb' or is_present_tense) and (is_nasb_context or is_jazm_context):
|
| 123 |
if word.endswith('ون'):
|
| 124 |
word = word[:-2] + 'وا'
|
| 125 |
elif word.endswith('ان'):
|
|
|
|
| 127 |
elif word.endswith('ين'):
|
| 128 |
word = word[:-2] + 'ي'
|
| 129 |
elif is_jazm_context:
|
| 130 |
+
if word.endswith('و') and len(word) >= 3:
|
| 131 |
word = word[:-1] + 'ُ'
|
| 132 |
+
elif word.endswith('ي') or word.endswith('i'):
|
| 133 |
+
stem = word[:-1]
|
| 134 |
+
fatha_stems = {'يسع', 'تسع', 'أسع', 'نسع',
|
| 135 |
+
'يخش', 'تخش', 'أخش', 'نخش',
|
| 136 |
+
'يرض', 'ترض', 'أرض', 'نرض',
|
| 137 |
+
'ينس', 'تنس', 'أنس', 'ننس',
|
| 138 |
+
'يبق', 'تبق', 'أبق', 'نبق',
|
| 139 |
+
'ير', 'تر', 'أر', 'نر',
|
| 140 |
+
'يلق', 'تلق', 'ألق', 'نلق',
|
| 141 |
+
'ينه', 'تنه', 'أنه', 'ننه'}
|
| 142 |
+
if stem in fatha_stems:
|
| 143 |
+
word = stem + 'َ'
|
| 144 |
+
elif len(word) > 3:
|
| 145 |
+
word = stem + 'ِ'
|
| 146 |
+
elif (word.endswith('ى') or word.endswith('ا')) and len(word) >= 3:
|
| 147 |
+
if not word.endswith('وا'):
|
| 148 |
+
word = word[:-1] + 'َ'
|
| 149 |
+
elif is_nasb_context:
|
| 150 |
+
if word.endswith('و') and len(word) > 3:
|
| 151 |
+
word = word + 'َ'
|
| 152 |
+
elif word.endswith('ي') and len(word) > 3:
|
| 153 |
+
word = word + 'َ'
|
| 154 |
|
| 155 |
corrected_tokens.append(word)
|
| 156 |
return " ".join(corrected_tokens)
|
|
|
|
| 344 |
elif is_plural_masc:
|
| 345 |
if (not verb_word.endswith('ون') and not verb_word.endswith('وا')
|
| 346 |
and not verb_word.endswith('ين')):
|
| 347 |
+
if verb_word.endswith('وَ'):
|
| 348 |
+
verb_word = verb_word[:-1]
|
| 349 |
corrected_tokens[i+1] = verb_word + 'ون'
|
| 350 |
else:
|
| 351 |
# Past tense: ذهب→ذهبوا (masc) / ذهبن (fem)
|
| 352 |
if is_plural_fem:
|
| 353 |
if not verb_word.endswith('ن') and not verb_word.endswith('نَ'):
|
| 354 |
+
if verb_word.endswith('ى') or verb_word.endswith('ا'):
|
| 355 |
+
verb_word = verb_word[:-1]
|
| 356 |
corrected_tokens[i+1] = verb_word + 'ن'
|
| 357 |
elif is_plural_masc:
|
| 358 |
if (not verb_word.endswith('وا') and not verb_word.endswith('ون')
|
| 359 |
and not verb_word.endswith('ين')):
|
| 360 |
+
if verb_word.endswith('وَ'):
|
| 361 |
+
verb_word = verb_word[:-1]
|
| 362 |
+
elif verb_word.endswith('ى') or verb_word.endswith('ا'):
|
| 363 |
+
verb_word = verb_word[:-1]
|
| 364 |
corrected_tokens[i+1] = verb_word + 'وا'
|
| 365 |
|
| 366 |
return " ".join(corrected_tokens)
|
|
|
|
| 370 |
text = re.sub(r'\b(إن|أن|كأن|لكن|لعل|ليت)\s+(أبوك|أخوك|ذو|فوك)\b',
|
| 371 |
lambda m: f"{m.group(1)} {m.group(2).replace('و', 'ا')}", text)
|
| 372 |
|
| 373 |
+
# الأفعال المتعدية (Object position)
|
| 374 |
+
text = re.sub(r'\b(رأيت|شاهدت|قابلت|زرت|سمعت|عرفت|وجدت|أحب|أكرمت|صادفت)\s+(أبوك|أخوك|ذو|فوك)\b',
|
| 375 |
+
lambda m: f"{m.group(1)} {m.group(2).replace('و', 'ا')}", text)
|
| 376 |
+
|
| 377 |
# حروف الجر المنفصلة بمسافة (في أخوك -> في أخيك)
|
| 378 |
text = re.sub(r'\b([وف]?(?:في|من|إلى|على|عن))\s+(أبوك|أباك|أخوك|أخاك|ذو|ذا)\b',
|
| 379 |
lambda m: f"{m.group(1)} {m.group(2).replace('و', 'ي').replace('ا', 'ي')}", text)
|
|
|
|
| 540 |
"""Apply all grammar rules to model output."""
|
| 541 |
text = self.preserve_numbers(original_text, generated_text)
|
| 542 |
|
| 543 |
+
# ── Fix Hallucinated Subject Gender ──
|
| 544 |
+
# If model incorrectly changes female subject to male, restore it.
|
| 545 |
+
orig_words = original_text.split()
|
| 546 |
+
corr_words = text.split()
|
| 547 |
+
if len(orig_words) == len(corr_words):
|
| 548 |
+
for i, (o, c) in enumerate(zip(orig_words, corr_words)):
|
| 549 |
+
o_clean = o.rstrip('.,،؛;:!؟?()[]{}«»"\'…')
|
| 550 |
+
c_clean = c.rstrip('.,،؛;:!؟?()[]{}«»"\'…')
|
| 551 |
+
# If model dropped 'ة' from a word of length >= 4
|
| 552 |
+
if o_clean.endswith('ة') and not c_clean.endswith('ة') and o_clean[:-1] == c_clean:
|
| 553 |
+
corr_words[i] = o
|
| 554 |
+
text = " ".join(corr_words)
|
| 555 |
+
|
| 556 |
# Each rule is wrapped in try/except so that if camel-tools
|
| 557 |
# functions fail, the regex-based rules still execute.
|
| 558 |
for rule_name, rule_fn in [
|
|
|
|
| 571 |
text = rule_fn(text)
|
| 572 |
except Exception as e:
|
| 573 |
logger.warning(f"[GRAMMAR-RULES] {rule_name} failed: {e}")
|
| 574 |
+
|
|
|
|
| 575 |
text = re.sub(r'\s+', ' ', text).strip()
|
| 576 |
return text
|
| 577 |
|
src/nlp/punctuation/punctuation_rules.py
CHANGED
|
@@ -149,7 +149,7 @@ def validate_punctuation_diff(diff: dict, full_text: str = '') -> bool:
|
|
| 149 |
# Also check for ellipsis (... at end)
|
| 150 |
_full_has_ellipsis = full_text.rstrip().endswith('...') if full_text else False
|
| 151 |
|
| 152 |
-
if _full_word_count >=
|
| 153 |
# ── FIX-29: Exclamation mark guard ──
|
| 154 |
# PuncAra sometimes adds ! to declarative sentences.
|
| 155 |
# Only allow ! if text contains exclamatory cues.
|
|
@@ -161,7 +161,7 @@ def validate_punctuation_diff(diff: dict, full_text: str = '') -> bool:
|
|
| 161 |
_has_cue = any(w in _EXCL_CUES for w in full_text.split())
|
| 162 |
if not _has_cue:
|
| 163 |
logger.info(
|
| 164 |
-
f"[PUNC-SAFETY] Blocked !/?
|
| 165 |
f"'{original}' → '{correction}'"
|
| 166 |
)
|
| 167 |
return False
|
|
@@ -175,7 +175,7 @@ def validate_punctuation_diff(diff: dict, full_text: str = '') -> bool:
|
|
| 175 |
else:
|
| 176 |
# Short fragment OR already has terminal punct → REJECT
|
| 177 |
logger.info(
|
| 178 |
-
f"[PUNC-SAFETY]
|
| 179 |
f"'{original}' → '{correction}'"
|
| 180 |
)
|
| 181 |
return False
|
|
|
|
| 149 |
# Also check for ellipsis (... at end)
|
| 150 |
_full_has_ellipsis = full_text.rstrip().endswith('...') if full_text else False
|
| 151 |
|
| 152 |
+
if _full_word_count >= 5 and not _full_already_has_terminal and not _full_has_ellipsis:
|
| 153 |
# ── FIX-29: Exclamation mark guard ──
|
| 154 |
# PuncAra sometimes adds ! to declarative sentences.
|
| 155 |
# Only allow ! if text contains exclamatory cues.
|
|
|
|
| 161 |
_has_cue = any(w in _EXCL_CUES for w in full_text.split())
|
| 162 |
if not _has_cue:
|
| 163 |
logger.info(
|
| 164 |
+
f"[PUNC-SAFETY] Blocked !/? on declarative sentence: "
|
| 165 |
f"'{original}' → '{correction}'"
|
| 166 |
)
|
| 167 |
return False
|
|
|
|
| 175 |
else:
|
| 176 |
# Short fragment OR already has terminal punct → REJECT
|
| 177 |
logger.info(
|
| 178 |
+
f"[PUNC-SAFETY] TerminalPunctuationGuard triggered: removing trailing punctuation "
|
| 179 |
f"'{original}' → '{correction}'"
|
| 180 |
)
|
| 181 |
return False
|
src/nlp/spelling/araspell_service.py
CHANGED
|
@@ -76,8 +76,8 @@ def get_spelling_model():
|
|
| 76 |
|
| 77 |
epoch = checkpoint.get('epoch', 'N/A')
|
| 78 |
logger.info(f"Spelling model loaded on {device}, epoch: {epoch}")
|
| 79 |
-
|
| 80 |
# 6. Initialize the spell checker pipeline (contextual=True for MLM-based refinement)
|
|
|
|
| 81 |
from nlp.spelling.araspell_rules import ArabicSpellChecker
|
| 82 |
_spell_checker = ArabicSpellChecker(
|
| 83 |
model, tokenizer, device, use_contextual=True
|
|
|
|
| 76 |
|
| 77 |
epoch = checkpoint.get('epoch', 'N/A')
|
| 78 |
logger.info(f"Spelling model loaded on {device}, epoch: {epoch}")
|
|
|
|
| 79 |
# 6. Initialize the spell checker pipeline (contextual=True for MLM-based refinement)
|
| 80 |
+
|
| 81 |
from nlp.spelling.araspell_rules import ArabicSpellChecker
|
| 82 |
_spell_checker = ArabicSpellChecker(
|
| 83 |
model, tokenizer, device, use_contextual=True
|
test_grammar_fixes.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
sys.stdout.reconfigure(encoding='utf-8')
|
| 3 |
+
from src.app import init_pipeline
|
| 4 |
+
|
| 5 |
+
p = init_pipeline()
|
| 6 |
+
tests = [
|
| 7 |
+
'الأولاد لعب في الحديقة',
|
| 8 |
+
'العمال بنى المبنى',
|
| 9 |
+
'الطالبة متفوق في دراسته',
|
| 10 |
+
'رأيت أخوك في المسجد',
|
| 11 |
+
'هاتان الطالبان مجتهدان',
|
| 12 |
+
'لم يفعلون الواجب بعد'
|
| 13 |
+
]
|
| 14 |
+
|
| 15 |
+
with open('test_grammar_output.md', 'w', encoding='utf-8') as f:
|
| 16 |
+
for t in tests:
|
| 17 |
+
res = p.analyze(t)['corrected']
|
| 18 |
+
f.write(f"IN: {t}\nOUT: {res}\n---\n")
|
test_models.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
import os
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
sys.path.insert(0, str(Path(r'c:\Users\dell\PycharmProjects\JupyterProject1\PythonProject\BAYAN\src')))
|
| 5 |
+
|
| 6 |
+
from src.nlp.spelling.araspell_service import get_spelling_model
|
| 7 |
+
from src.nlp.grammar.grammar_service import get_grammar_model
|
| 8 |
+
|
| 9 |
+
print("Loading Spelling...")
|
| 10 |
+
spell = get_spelling_model()
|
| 11 |
+
print("Loading Grammar...")
|
| 12 |
+
grammar = get_grammar_model()
|
| 13 |
+
|
| 14 |
+
text1 = "لم ينمو الاقتصاد كالمعتاد"
|
| 15 |
+
text2 = "ذهبت المهندسون"
|
| 16 |
+
|
| 17 |
+
print(f"\n--- Text 1: {text1} ---")
|
| 18 |
+
print("AraSpell output:", spell.correct(text1))
|
| 19 |
+
print("Grammar output:", grammar.correct(text1))
|
| 20 |
+
|
| 21 |
+
print(f"\n--- Text 2: {text2} ---")
|
| 22 |
+
print("AraSpell output:", spell.correct(text2))
|
| 23 |
+
print("Grammar output:", grammar.correct(text2))
|
| 24 |
+
|
test_punctuation.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
sys.stdout.reconfigure(encoding='utf-8')
|
| 3 |
+
|
| 4 |
+
from src.app import init_pipeline
|
| 5 |
+
|
| 6 |
+
p = init_pipeline()
|
| 7 |
+
|
| 8 |
+
tests = [
|
| 9 |
+
"الخطة السنوية للشركة", # 3 words -> should block trailing .
|
| 10 |
+
"هذا هو تقرير المبيعات", # 4 words -> should block trailing .
|
| 11 |
+
"محمد ذهب إلى المدرسة اليوم", # 5 words -> allowed!
|
| 12 |
+
]
|
| 13 |
+
|
| 14 |
+
with open('test_punct_output.md', 'w', encoding='utf-8') as f:
|
| 15 |
+
for t in tests:
|
| 16 |
+
res = p.analyze(t)['corrected']
|
| 17 |
+
f.write(f"IN: {t}\nOUT: {res}\n---\n")
|
tests/phase10/benchmark_runner.py
CHANGED
|
@@ -9,6 +9,7 @@ Usage:
|
|
| 9 |
python tests/phase10/benchmark_runner.py [--url URL] [--dataset NAMES] [--out DIR]
|
| 10 |
"""
|
| 11 |
import argparse, json, time, re, os, sys
|
|
|
|
| 12 |
from pathlib import Path
|
| 13 |
from dataclasses import dataclass, field, asdict
|
| 14 |
from typing import List, Dict, Optional, Any
|
|
|
|
| 9 |
python tests/phase10/benchmark_runner.py [--url URL] [--dataset NAMES] [--out DIR]
|
| 10 |
"""
|
| 11 |
import argparse, json, time, re, os, sys
|
| 12 |
+
sys.stdout.reconfigure(encoding='utf-8')
|
| 13 |
from pathlib import Path
|
| 14 |
from dataclasses import dataclass, field, asdict
|
| 15 |
from typing import List, Dict, Optional, Any
|
tests/phase10/reports/phase10_results.json
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|