youssefreda9 commited on
Commit
a2f1290
·
1 Parent(s): acba693

feat: P1-P3 improvements + auto re-analyze after apply

Browse files

P1: 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

Files changed (3) hide show
  1. .github/workflows/deploy.yml +22 -0
  2. src/app.py +61 -0
  3. src/js/editor.js +7 -0
.github/workflows/deploy.yml CHANGED
@@ -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
src/app.py CHANGED
@@ -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(
src/js/editor.js CHANGED
@@ -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() {