feat: P1-P3 improvements + auto re-analyze after apply
Browse filesP1: Integrate fix_common_hamza into /api/analyze pipeline
- Standalone hamza-fix pass runs AFTER spelling, catches انت→أنت,
الان→الآن, ام→أم, انا→أنا in sentence context
- Works even when AraSpell is skipped (>300 chars)
P1: Split multi-word grammar diffs into individual suggestions
- Grammar corrections like 'الي المدرسه الاستاذ'→'إلى المدرسة الأستاذ'
now appear as 3 separate suggestions, not 1 merged block
- Users can accept/reject each correction individually
P2: Auto re-analyze after applying any suggestion (frontend)
- After applying single, alternative, or all suggestions,
editor automatically re-analyzes with 300ms delay
- Shows 'جار التحليل' without user needing to type anything
P3: Word truncation guard
- Reject spelling corrections that shorten valid words by 2+ chars
- Prevents الدمث→الدم (rare word truncation)
P3: CI/CD - regression tests on every push
- Added test job to GitHub Actions workflow
- Runs 120 tests before lint-and-validate
- .github/workflows/deploy.yml +22 -0
- src/app.py +61 -0
- src/js/editor.js +7 -0
|
@@ -7,8 +7,30 @@ on:
|
|
| 7 |
branches: [main]
|
| 8 |
|
| 9 |
jobs:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
lint-and-validate:
|
| 11 |
name: Validate Code
|
|
|
|
| 12 |
runs-on: ubuntu-latest
|
| 13 |
steps:
|
| 14 |
- uses: actions/checkout@v4
|
|
|
|
| 7 |
branches: [main]
|
| 8 |
|
| 9 |
jobs:
|
| 10 |
+
test:
|
| 11 |
+
name: Run Regression Tests
|
| 12 |
+
runs-on: ubuntu-latest
|
| 13 |
+
steps:
|
| 14 |
+
- uses: actions/checkout@v4
|
| 15 |
+
|
| 16 |
+
- name: Setup Python
|
| 17 |
+
uses: actions/setup-python@v5
|
| 18 |
+
with:
|
| 19 |
+
python-version: '3.12'
|
| 20 |
+
cache: 'pip'
|
| 21 |
+
|
| 22 |
+
- name: Install dependencies
|
| 23 |
+
run: |
|
| 24 |
+
pip install flask flask-cors python-dotenv pytest
|
| 25 |
+
pip install -r requirements.txt || true
|
| 26 |
+
|
| 27 |
+
- name: Run pipeline tests
|
| 28 |
+
run: |
|
| 29 |
+
cd src && python -m pytest ../tests/test_pipeline_hardening.py ../tests/test_bug_fixes.py -v --tb=short
|
| 30 |
+
|
| 31 |
lint-and-validate:
|
| 32 |
name: Validate Code
|
| 33 |
+
needs: test
|
| 34 |
runs-on: ubuntu-latest
|
| 35 |
steps:
|
| 36 |
- uses: actions/checkout@v4
|
|
@@ -831,6 +831,14 @@ def _is_small_spelling_change(orig_word, corr_word, vocab_manager=None):
|
|
| 831 |
if corr_word == orig_word[:-1] or len(corr_word) < len(orig_word):
|
| 832 |
return 0.0
|
| 833 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 834 |
# CRITICAL: If both words are valid Arabic words, only accept known fixes.
|
| 835 |
# This prevents the spelling model from changing one correct word to another
|
| 836 |
# (e.g. وكان→وكأن, which changes "and was" to "as if" — a meaning change).
|
|
@@ -1330,6 +1338,26 @@ def analyze_text():
|
|
| 1330 |
logger.error(traceback.format_exc())
|
| 1331 |
timing_ms['spelling_error'] = f"{type(e).__name__}: {str(e)[:200]}"
|
| 1332 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1333 |
# 2. Grammar (runs on spelling-corrected text — word-level dependency)
|
| 1334 |
try:
|
| 1335 |
t0 = time.time()
|
|
@@ -1341,7 +1369,40 @@ def analyze_text():
|
|
| 1341 |
logger.info(f"[ANALYZE] Step 2: Grammar done in {timing_ms['grammar_ms']}ms")
|
| 1342 |
if corrected_grammar != ctx.current_text:
|
| 1343 |
diffs = get_word_diffs(ctx.current_text, corrected_grammar)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1344 |
for d in diffs:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1345 |
# StageLocker: skip diffs that overlap with locked ranges
|
| 1346 |
if ctx.stage_locker.is_locked(d['start'], d['end']):
|
| 1347 |
logger.info(
|
|
|
|
| 831 |
if corr_word == orig_word[:-1] or len(corr_word) < len(orig_word):
|
| 832 |
return 0.0
|
| 833 |
|
| 834 |
+
# ── GUARD 3: Word truncation protection (Phase 4, P3) ──
|
| 835 |
+
# Reject corrections that significantly shorten a valid word.
|
| 836 |
+
# E.g. الدمث→الدم (rare word truncated to different word).
|
| 837 |
+
if vocab_manager and len(orig_word) - len(corr_word) >= 2:
|
| 838 |
+
if vocab_manager.is_iv(orig_word):
|
| 839 |
+
logger.info(f"[SPELLING] Blocked truncation: '{orig_word}'→'{corr_word}' (valid word shortened by {len(orig_word) - len(corr_word)} chars)")
|
| 840 |
+
return 0.0
|
| 841 |
+
|
| 842 |
# CRITICAL: If both words are valid Arabic words, only accept known fixes.
|
| 843 |
# This prevents the spelling model from changing one correct word to another
|
| 844 |
# (e.g. وكان→وكأن, which changes "and was" to "as if" — a meaning change).
|
|
|
|
| 1338 |
logger.error(traceback.format_exc())
|
| 1339 |
timing_ms['spelling_error'] = f"{type(e).__name__}: {str(e)[:200]}"
|
| 1340 |
|
| 1341 |
+
# ── Standalone hamza-fix pass (P1/P2) ──
|
| 1342 |
+
# fix_common_hamza handles common hamza errors that AraSpell missed
|
| 1343 |
+
# in sentence context: انت→أنت, الان→الآن, ام→أم, انا→أنا, etc.
|
| 1344 |
+
# This runs on the FULL text regardless of whether AraSpell ran.
|
| 1345 |
+
try:
|
| 1346 |
+
from nlp.spelling.araspell_rules import AraSpellPostProcessor
|
| 1347 |
+
hamza_fixed = AraSpellPostProcessor.fix_common_hamza(current_text)
|
| 1348 |
+
if hamza_fixed != current_text:
|
| 1349 |
+
hamza_diffs = get_word_diffs(current_text, hamza_fixed)
|
| 1350 |
+
for hd in hamza_diffs:
|
| 1351 |
+
ctx.add_patch(
|
| 1352 |
+
'spelling', hd['start'], hd['end'],
|
| 1353 |
+
hd['correction'], confidence=0.95,
|
| 1354 |
+
)
|
| 1355 |
+
logger.info(f"[HAMZA-FIX] '{hd.get('original','')}' → '{hd.get('correction','')}'")
|
| 1356 |
+
ctx.mutate_text(hamza_fixed, OffsetMapper)
|
| 1357 |
+
current_text = ctx.current_text
|
| 1358 |
+
except Exception as e:
|
| 1359 |
+
logger.error(f"[ANALYZE] Hamza fix failed: {type(e).__name__}: {e}")
|
| 1360 |
+
|
| 1361 |
# 2. Grammar (runs on spelling-corrected text — word-level dependency)
|
| 1362 |
try:
|
| 1363 |
t0 = time.time()
|
|
|
|
| 1369 |
logger.info(f"[ANALYZE] Step 2: Grammar done in {timing_ms['grammar_ms']}ms")
|
| 1370 |
if corrected_grammar != ctx.current_text:
|
| 1371 |
diffs = get_word_diffs(ctx.current_text, corrected_grammar)
|
| 1372 |
+
|
| 1373 |
+
# ── Phase 2 (P1): Split multi-word grammar diffs ──
|
| 1374 |
+
# Break merged diffs like "الي المدرسه الاستاذ"→"إلى المدرسة الأستاذ"
|
| 1375 |
+
# into individual word-level suggestions.
|
| 1376 |
+
expanded_diffs = []
|
| 1377 |
for d in diffs:
|
| 1378 |
+
orig_text = d.get('original', '')
|
| 1379 |
+
corr_text = d.get('correction', '')
|
| 1380 |
+
orig_words_list = orig_text.split()
|
| 1381 |
+
corr_words_list = corr_text.split()
|
| 1382 |
+
# Only split if same word count and multiple words
|
| 1383 |
+
if (len(orig_words_list) > 1
|
| 1384 |
+
and len(orig_words_list) == len(corr_words_list)):
|
| 1385 |
+
# Split into per-word diffs
|
| 1386 |
+
search_from = d['start']
|
| 1387 |
+
for ow, cw in zip(orig_words_list, corr_words_list):
|
| 1388 |
+
if ow != cw:
|
| 1389 |
+
w_start = ctx.current_text.find(ow, search_from)
|
| 1390 |
+
if w_start >= 0:
|
| 1391 |
+
w_end = w_start + len(ow)
|
| 1392 |
+
expanded_diffs.append({
|
| 1393 |
+
'start': w_start, 'end': w_end,
|
| 1394 |
+
'original': ow, 'correction': cw,
|
| 1395 |
+
'type': 'generic'
|
| 1396 |
+
})
|
| 1397 |
+
search_from = w_end
|
| 1398 |
+
else:
|
| 1399 |
+
search_from += len(ow) + 1
|
| 1400 |
+
else:
|
| 1401 |
+
search_from += len(ow) + 1
|
| 1402 |
+
else:
|
| 1403 |
+
expanded_diffs.append(d)
|
| 1404 |
+
|
| 1405 |
+
for d in expanded_diffs:
|
| 1406 |
# StageLocker: skip diffs that overlap with locked ranges
|
| 1407 |
if ctx.stage_locker.is_locked(d['start'], d['end']):
|
| 1408 |
logger.info(
|
|
@@ -520,6 +520,9 @@ function applySuggestionAtOffsets(suggestion) {
|
|
| 520 |
} finally {
|
| 521 |
_isApplyingSuggestion = false;
|
| 522 |
}
|
|
|
|
|
|
|
|
|
|
| 523 |
}
|
| 524 |
|
| 525 |
function applyCorrection() {
|
|
@@ -603,6 +606,8 @@ function applyAlternativeCorrection(suggestion, correctionText) {
|
|
| 603 |
} finally {
|
| 604 |
_isApplyingSuggestion = false;
|
| 605 |
}
|
|
|
|
|
|
|
| 606 |
}
|
| 607 |
|
| 608 |
function dismissSuggestion(suggestion) {
|
|
@@ -682,6 +687,8 @@ function applyAllSuggestions() {
|
|
| 682 |
} finally {
|
| 683 |
_isApplyingSuggestion = false;
|
| 684 |
}
|
|
|
|
|
|
|
| 685 |
}
|
| 686 |
|
| 687 |
function clearEditor() {
|
|
|
|
| 520 |
} finally {
|
| 521 |
_isApplyingSuggestion = false;
|
| 522 |
}
|
| 523 |
+
// P2/User Request: Auto re-analyze after applying suggestion
|
| 524 |
+
// Runs AFTER _isApplyingSuggestion is reset, so the guard won't block it.
|
| 525 |
+
setTimeout(() => { analyzeTextDelayed(); }, 300);
|
| 526 |
}
|
| 527 |
|
| 528 |
function applyCorrection() {
|
|
|
|
| 606 |
} finally {
|
| 607 |
_isApplyingSuggestion = false;
|
| 608 |
}
|
| 609 |
+
// P2/User Request: Auto re-analyze after applying alternative correction
|
| 610 |
+
setTimeout(() => { analyzeTextDelayed(); }, 300);
|
| 611 |
}
|
| 612 |
|
| 613 |
function dismissSuggestion(suggestion) {
|
|
|
|
| 687 |
} finally {
|
| 688 |
_isApplyingSuggestion = false;
|
| 689 |
}
|
| 690 |
+
// P2/User Request: Auto re-analyze after applying all suggestions
|
| 691 |
+
setTimeout(() => { analyzeTextDelayed(); }, 300);
|
| 692 |
}
|
| 693 |
|
| 694 |
function clearEditor() {
|