youssefreda9 commited on
Commit
10ec354
·
1 Parent(s): 1afad28

fix: QA sweep — 6 issues resolved (sidebar chips, corrected field, input sanitization, punc cap, docs, tests)

Browse files

Fix 1: Sidebar cards now use resolveAlternatives() shared helper (empty array fallback)
Fix 2: 'corrected' field rebuilt from original + accepted patches, not ctx.current_text
Fix 3: HTML tags stripped, non-Arabic inputs rejected (DoS prevention)
Fix 4: Aggregate punctuation cap (max 3 per response, keep earliest)
Fix 5: Models don't expose alternatives — Fix 1 fallback is permanent (documented)
Fix 6: applyAllSuggestions reverse-sort documented with CRITICAL comment

Added 12 new regression tests (31 total, all passing)

Files changed (4) hide show
  1. src/app.py +46 -1
  2. src/js/editor.js +10 -3
  3. src/js/ui.js +12 -1
  4. tests/test_pipeline_hardening.py +210 -0
src/app.py CHANGED
@@ -775,6 +775,23 @@ def analyze_text():
775
  if not text:
776
  return jsonify({'error': 'Text is required', 'status': 'error'}), 400
777
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
778
  # Pipeline state — PipelineContext carries all shared state
779
  ctx = PipelineContext(text)
780
  current_text = text # Local alias (updated alongside ctx.current_text)
@@ -999,6 +1016,20 @@ def analyze_text():
999
  'punctuation', d['start'], d['end'],
1000
  d['correction'], confidence=0.8
1001
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1002
  ctx.mutate_text(corrected_punc, OffsetMapper)
1003
  current_text = ctx.current_text
1004
  except Exception as e:
@@ -1015,6 +1046,20 @@ def analyze_text():
1015
  # One range = one owner. No stacking.
1016
  suggestions = ctx.patches.to_list()
1017
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1018
  logger.info(f"[ANALYZE] Total: {timing_ms['total_ms']}ms | "
1019
  f"Spelling: {timing_ms['spelling_ms']}ms | "
1020
  f"Grammar: {timing_ms['grammar_ms']}ms | "
@@ -1023,7 +1068,7 @@ def analyze_text():
1023
 
1024
  return jsonify({
1025
  'original': text,
1026
- 'corrected': ctx.current_text,
1027
  'suggestions': suggestions,
1028
  'timing_ms': timing_ms,
1029
  'status': 'success'
 
775
  if not text:
776
  return jsonify({'error': 'Text is required', 'status': 'error'}), 400
777
 
778
+ # ── Input Sanitization (Fix 3: prevent pathological model inputs) ──
779
+ # Strip HTML tags — prevents AraSpell from doing exhaustive edit-distance
780
+ # on tag characters like <script>, </div>, etc.
781
+ text = re.sub(r'<[^>]*>', '', text).strip()
782
+ if not text:
783
+ return jsonify({'error': 'Text is required', 'status': 'error'}), 400
784
+
785
+ # Reject inputs that are predominantly non-Arabic (code, markup, etc.)
786
+ arabic_chars = len(re.findall(r'[\u0600-\u06FF]', text))
787
+ alpha_chars = len(re.findall(r'[a-zA-Z\u0600-\u06FF]', text))
788
+ if alpha_chars > 0 and arabic_chars / alpha_chars < 0.3:
789
+ return jsonify({
790
+ 'original': text, 'corrected': text,
791
+ 'suggestions': [], 'timing_ms': {},
792
+ 'status': 'success'
793
+ })
794
+
795
  # Pipeline state — PipelineContext carries all shared state
796
  ctx = PipelineContext(text)
797
  current_text = text # Local alias (updated alongside ctx.current_text)
 
1016
  'punctuation', d['start'], d['end'],
1017
  d['correction'], confidence=0.8
1018
  )
1019
+
1020
+ # ── Aggregate punctuation cap (Fix 4): max 3 punctuation patches per response ──
1021
+ MAX_PUNC_PATCHES_PER_RESPONSE = 3
1022
+ punc_patches = [p for p in ctx.patches.patches if p.stage == 'punctuation']
1023
+ if len(punc_patches) > MAX_PUNC_PATCHES_PER_RESPONSE:
1024
+ # Keep earliest patches (by start_original) — consistent with PatchSet sort
1025
+ punc_patches_sorted = sorted(punc_patches, key=lambda p: p.start_original)
1026
+ to_remove = set(id(p) for p in punc_patches_sorted[MAX_PUNC_PATCHES_PER_RESPONSE:])
1027
+ ctx.patches.patches = [p for p in ctx.patches.patches if id(p) not in to_remove]
1028
+ logger.info(
1029
+ f"[PUNC-CAP] Capped punctuation patches: "
1030
+ f"{len(punc_patches)} → {MAX_PUNC_PATCHES_PER_RESPONSE}"
1031
+ )
1032
+
1033
  ctx.mutate_text(corrected_punc, OffsetMapper)
1034
  current_text = ctx.current_text
1035
  except Exception as e:
 
1046
  # One range = one owner. No stacking.
1047
  suggestions = ctx.patches.to_list()
1048
 
1049
+ # ── Rebuild 'corrected' from original + accepted patches (Fix 2) ──
1050
+ # This ensures 'corrected' exactly matches what you'd get by applying
1051
+ # all suggestions to 'original'. ctx.current_text includes StageLocker-
1052
+ # blocked and safety-rejected mutations and must NOT be used.
1053
+ def _apply_patches_to_original(original_text, suggestion_dicts):
1054
+ """Apply patches in reverse offset order to produce corrected text."""
1055
+ result = original_text
1056
+ # Sort by start DESC so offset shifts don't invalidate later patches
1057
+ for s in sorted(suggestion_dicts, key=lambda x: -x['start']):
1058
+ result = result[:s['start']] + s['correction'] + result[s['end']:]
1059
+ return result
1060
+
1061
+ corrected = _apply_patches_to_original(text, suggestions)
1062
+
1063
  logger.info(f"[ANALYZE] Total: {timing_ms['total_ms']}ms | "
1064
  f"Spelling: {timing_ms['spelling_ms']}ms | "
1065
  f"Grammar: {timing_ms['grammar_ms']}ms | "
 
1068
 
1069
  return jsonify({
1070
  'original': text,
1071
+ 'corrected': corrected,
1072
  'suggestions': suggestions,
1073
  'timing_ms': timing_ms,
1074
  'status': 'success'
src/js/editor.js CHANGED
@@ -379,9 +379,12 @@ function showTooltip(element) {
379
 
380
  // Render alternatives
381
  if (alternativesEl) {
382
- const alts = (suggestion.alternatives && suggestion.alternatives.length > 0)
383
- ? suggestion.alternatives
384
- : [suggestion.correction, suggestion.original];
 
 
 
385
  let html = '';
386
  // Render corrections first (non-keep)
387
  alts.forEach((alt, i) => {
@@ -651,6 +654,10 @@ function applySuggestionById(id) {
651
  }
652
 
653
  function applyAllSuggestions() {
 
 
 
 
654
  const suggestions = [...(window.currentSuggestions || [])].sort((a, b) => b.start - a.start);
655
  if (suggestions.length === 0) return;
656
  _isApplyingSuggestion = true;
 
379
 
380
  // Render alternatives
381
  if (alternativesEl) {
382
+ // Use shared helper (defined in ui.js, loaded before editor.js)
383
+ const alts = (typeof resolveAlternatives === 'function')
384
+ ? resolveAlternatives(suggestion)
385
+ : (suggestion.alternatives && suggestion.alternatives.length > 0)
386
+ ? suggestion.alternatives
387
+ : [suggestion.correction, suggestion.original];
388
  let html = '';
389
  // Render corrections first (non-keep)
390
  alts.forEach((alt, i) => {
 
654
  }
655
 
656
  function applyAllSuggestions() {
657
+ // CRITICAL: Sort in REVERSE order (highest start offset first).
658
+ // Applying edits back-to-front preserves the offset validity of all
659
+ // subsequent edits. Front-to-back would invalidate every offset after
660
+ // the first edit due to text length changes. Do NOT change this sort.
661
  const suggestions = [...(window.currentSuggestions || [])].sort((a, b) => b.start - a.start);
662
  if (suggestions.length === 0) return;
663
  _isApplyingSuggestion = true;
src/js/ui.js CHANGED
@@ -59,10 +59,21 @@ function updateWritingScore(spelling, grammar, punctuation) {
59
  /**
60
  * Build HTML for a single suggestion card
61
  */
 
 
 
 
 
 
 
 
 
 
 
62
  function buildSuggestionCardHTML(suggestion, index) {
63
  const badgeClass = `badge-${suggestion.type}`;
64
  const label = TYPE_LABELS[suggestion.type] || suggestion.type;
65
- const alts = suggestion.alternatives || [suggestion.correction, suggestion.original];
66
  // Pipeline Hardening v3.3: Use suggestion.id (UUID) instead of array index
67
  const suggestionId = suggestion.id || index;
68
 
 
59
  /**
60
  * Build HTML for a single suggestion card
61
  */
62
+ /**
63
+ * Resolve alternatives for a suggestion, falling back to [correction, original]
64
+ * when the model doesn't provide alternatives (e.g. grammar, punctuation).
65
+ * Shared logic — must stay in sync with tooltip rendering in editor.js.
66
+ */
67
+ function resolveAlternatives(suggestion) {
68
+ return (suggestion.alternatives && suggestion.alternatives.length > 0)
69
+ ? suggestion.alternatives
70
+ : [suggestion.correction, suggestion.original];
71
+ }
72
+
73
  function buildSuggestionCardHTML(suggestion, index) {
74
  const badgeClass = `badge-${suggestion.type}`;
75
  const label = TYPE_LABELS[suggestion.type] || suggestion.type;
76
+ const alts = resolveAlternatives(suggestion);
77
  // Pipeline Hardening v3.3: Use suggestion.id (UUID) instead of array index
78
  const suggestionId = suggestion.id || index;
79
 
tests/test_pipeline_hardening.py CHANGED
@@ -405,5 +405,215 @@ class TestOffsetMapperMonotonicity(unittest.TestCase):
405
  f"Monotonicity violated: start={start} > end={end}")
406
 
407
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
408
  if __name__ == '__main__':
409
  unittest.main()
 
405
  f"Monotonicity violated: start={start} > end={end}")
406
 
407
 
408
+ # ══════════════════════════════════════════════════════════════════════════════
409
+ # QA Fix 1: Sidebar card alternatives fallback
410
+ # ══════════════════════════════════════════════════════════════════════════════
411
+ class TestSidebarAlternativesFallback(unittest.TestCase):
412
+ def test_ui_has_resolve_alternatives(self):
413
+ """ui.js must contain resolveAlternatives helper function."""
414
+ ui_path = os.path.join(os.path.dirname(__file__), '..', 'src', 'js', 'ui.js')
415
+ with open(ui_path, 'r', encoding='utf-8') as f:
416
+ content = f.read()
417
+ self.assertIn('function resolveAlternatives', content)
418
+ self.assertIn('alternatives.length > 0', content)
419
+ # buildSuggestionCardHTML must call resolveAlternatives
420
+ self.assertIn('resolveAlternatives(suggestion)', content)
421
+
422
+ def test_editor_uses_resolve_alternatives(self):
423
+ """editor.js tooltip must use resolveAlternatives or equivalent fallback."""
424
+ editor_path = os.path.join(os.path.dirname(__file__), '..', 'src', 'js', 'editor.js')
425
+ with open(editor_path, 'r', encoding='utf-8') as f:
426
+ content = f.read()
427
+ # Must NOT have the old broken pattern
428
+ self.assertNotIn(
429
+ "suggestion.alternatives || [suggestion.correction",
430
+ content,
431
+ "Old broken fallback pattern still present — empty array [] is truthy"
432
+ )
433
+
434
+
435
+ # ══════════════════════════════════════════════════════════════════════════════
436
+ # QA Fix 2: corrected field = apply(original, suggestions)
437
+ # ══════════════════════════════════════════════════════════════════════════════
438
+ class TestCorrectedFieldConsistency(unittest.TestCase):
439
+ def test_apply_patches_equals_corrected(self):
440
+ """Applying all patches to original must produce the corrected text."""
441
+ ctx = PipelineContext("المدرسه يعملوا هناك")
442
+
443
+ # Simulate: spelling corrects المدرسه → المدرسة at [0:7]
444
+ ctx.add_patch('spelling', 0, 7, 'المدرسة', confidence=0.9)
445
+ ctx.mutate_text("المدرسة يعملوا هناك", OffsetMapper)
446
+
447
+ suggestions = ctx.patches.to_list()
448
+
449
+ # Rebuild corrected from original + patches (same logic as app.py Fix 2)
450
+ result = ctx.original_text
451
+ for s in sorted(suggestions, key=lambda x: -x['start']):
452
+ result = result[:s['start']] + s['correction'] + result[s['end']:]
453
+
454
+ # Verify consistency: original[start:end] == suggestion['original']
455
+ for s in suggestions:
456
+ actual = ctx.original_text[s['start']:s['end']]
457
+ self.assertEqual(actual, s['original'],
458
+ f"Coordinate mismatch: original[{s['start']}:{s['end']}] = '{actual}' != '{s['original']}'")
459
+
460
+ # Verify corrected text is what we expect
461
+ self.assertEqual(result, "المدرسة يعملوا هناك")
462
+
463
+ def test_multi_stage_corrected_consistency(self):
464
+ """Multiple stages: corrected = apply(original, all_patches)."""
465
+ ctx = PipelineContext("المدرسه هنا")
466
+
467
+ # Stage 1: Spelling corrects المدرسه → المدرسة (CURRENT coords [0:7])
468
+ ctx.add_patch('spelling', 0, 7, 'المدرسة', confidence=0.9)
469
+ ctx.mutate_text("المدرسة هنا", OffsetMapper)
470
+
471
+ # Stage 2: Punctuation adds ! after هنا
472
+ # CURRENT text = "المدرسة هنا", so هنا is at [8:11] in CURRENT coords
473
+ ctx.add_patch('punctuation', 8, 11, 'هنا!', confidence=0.8)
474
+
475
+ suggestions = ctx.patches.to_list()
476
+ result = "المدرسه هنا" # original
477
+ for s in sorted(suggestions, key=lambda x: -x['start']):
478
+ result = result[:s['start']] + s['correction'] + result[s['end']:]
479
+
480
+ # Should contain both corrections
481
+ self.assertIn('المدرسة', result)
482
+ self.assertIn('هنا!', result)
483
+
484
+
485
+ # ══════════════════════════════════════════════════════════════════════════════
486
+ # QA Fix 3: HTML/malformed input sanitization
487
+ # ══════════════════════════════════════════════════════════════════════════════
488
+ class TestInputSanitization(unittest.TestCase):
489
+ def test_html_tags_stripped(self):
490
+ """app.py must strip HTML tags from input."""
491
+ import re
492
+ test_input = '<script>alert(1)</script>'
493
+ sanitized = re.sub(r'<[^>]*>', '', test_input).strip()
494
+ self.assertEqual(sanitized, 'alert(1)')
495
+
496
+ def test_pure_html_becomes_empty(self):
497
+ """Pure HTML tags with no text content should be stripped to empty."""
498
+ import re
499
+ test_input = '<div><p></p></div>'
500
+ sanitized = re.sub(r'<[^>]*>', '', test_input).strip()
501
+ self.assertEqual(sanitized, '')
502
+
503
+ def test_arabic_ratio_check(self):
504
+ """Non-Arabic inputs should be detected by ratio check."""
505
+ import re
506
+ # Predominantly English/code
507
+ text = 'function hello() { return 42; }'
508
+ arabic_chars = len(re.findall(r'[\u0600-\u06FF]', text))
509
+ alpha_chars = len(re.findall(r'[a-zA-Z\u0600-\u06FF]', text))
510
+ ratio = arabic_chars / alpha_chars if alpha_chars > 0 else 0
511
+ self.assertLess(ratio, 0.3, "Pure English should fail Arabic ratio check")
512
+
513
+ # Predominantly Arabic
514
+ text2 = 'السلام عليكم hello'
515
+ arabic_chars2 = len(re.findall(r'[\u0600-\u06FF]', text2))
516
+ alpha_chars2 = len(re.findall(r'[a-zA-Z\u0600-\u06FF]', text2))
517
+ ratio2 = arabic_chars2 / alpha_chars2 if alpha_chars2 > 0 else 0
518
+ self.assertGreaterEqual(ratio2, 0.3, "Mostly Arabic should pass ratio check")
519
+
520
+
521
+ # ══════════════════════════════════════════════════════════════════════════════
522
+ # QA Fix 4: Aggregate punctuation cap (max 3 per response)
523
+ # ══════════════════════════════════════════════════════════════════════════════
524
+ class TestAggregatePunctuationCap(unittest.TestCase):
525
+ def test_excess_punctuation_patches_capped(self):
526
+ """More than 3 punctuation patches should be capped to 3."""
527
+ ctx = PipelineContext("كلمة أولى ثم ثانية ثم ثالثة ثم رابعة ثم خامسة")
528
+
529
+ # Add 5 punctuation patches
530
+ for i in range(5):
531
+ ctx.add_patch('punctuation', i * 8, i * 8 + 4,
532
+ f'word{i}!', confidence=0.8)
533
+
534
+ # Simulate the cap logic from app.py
535
+ MAX_PUNC_PATCHES_PER_RESPONSE = 3
536
+ punc_patches = [p for p in ctx.patches.patches if p.stage == 'punctuation']
537
+ if len(punc_patches) > MAX_PUNC_PATCHES_PER_RESPONSE:
538
+ punc_patches_sorted = sorted(punc_patches, key=lambda p: p.start_original)
539
+ to_remove = set(id(p) for p in punc_patches_sorted[MAX_PUNC_PATCHES_PER_RESPONSE:])
540
+ ctx.patches.patches = [p for p in ctx.patches.patches if id(p) not in to_remove]
541
+
542
+ punc_remaining = [p for p in ctx.patches.patches if p.stage == 'punctuation']
543
+ self.assertLessEqual(len(punc_remaining), MAX_PUNC_PATCHES_PER_RESPONSE)
544
+
545
+ def test_cap_preserves_earliest_patches(self):
546
+ """The cap should keep earliest patches (lowest start_original)."""
547
+ ctx = PipelineContext("a b c d e f g h")
548
+
549
+ # Add 4 patches at positions 0, 10, 20, 30
550
+ positions = [0, 10, 20, 30]
551
+ for pos in positions:
552
+ ctx.add_patch('punctuation', pos, pos + 3, 'fix!', confidence=0.8)
553
+
554
+ MAX_PUNC_PATCHES_PER_RESPONSE = 3
555
+ punc_patches = [p for p in ctx.patches.patches if p.stage == 'punctuation']
556
+ punc_patches_sorted = sorted(punc_patches, key=lambda p: p.start_original)
557
+ to_remove = set(id(p) for p in punc_patches_sorted[MAX_PUNC_PATCHES_PER_RESPONSE:])
558
+ ctx.patches.patches = [p for p in ctx.patches.patches if id(p) not in to_remove]
559
+
560
+ remaining = ctx.patches.patches
561
+ # The patch at position 30 should be dropped (it's the 4th, earliest-last)
562
+ remaining_starts = sorted([p.start_original for p in remaining])
563
+ self.assertEqual(remaining_starts, [0, 10, 20])
564
+
565
+
566
+ # ══════════════════════════════════════════════════════════════════════════════
567
+ # QA Fix 6: applyAllSuggestions reverse sort documented + tested
568
+ # ══════════════════════════════════════════════════════════════════════════════
569
+ class TestApplyAllReverseSort(unittest.TestCase):
570
+ def test_reverse_sort_comment_exists(self):
571
+ """editor.js must document why reverse sort is required in applyAllSuggestions."""
572
+ editor_path = os.path.join(os.path.dirname(__file__), '..', 'src', 'js', 'editor.js')
573
+ with open(editor_path, 'r', encoding='utf-8') as f:
574
+ content = f.read()
575
+ self.assertIn('CRITICAL: Sort in REVERSE order', content)
576
+ self.assertIn('back-to-front', content)
577
+
578
+ def test_apply_patches_reverse_order_correctness(self):
579
+ """Applying patches in reverse order must produce correct result."""
580
+ original = "الطلاب ذهب الي المدرسه"
581
+ suggestions = [
582
+ {'start': 0, 'end': 6, 'original': 'الطلاب', 'correction': 'الطلّاب'},
583
+ {'start': 7, 'end': 10, 'original': 'ذهب', 'correction': 'ذهبوا'},
584
+ {'start': 11, 'end': 14, 'original': 'الي', 'correction': 'إلى'},
585
+ {'start': 15, 'end': 22, 'original': 'المدرسه', 'correction': 'المدرسة'},
586
+ ]
587
+
588
+ # Apply in reverse order (same as applyAllSuggestions)
589
+ result = original
590
+ for s in sorted(suggestions, key=lambda x: -x['start']):
591
+ result = result[:s['start']] + s['correction'] + result[s['end']:]
592
+
593
+ self.assertEqual(result, "الطلّاب ذهبوا إلى المدرسة")
594
+
595
+ def test_forward_order_would_corrupt(self):
596
+ """Applying patches in forward order would produce wrong result when lengths differ."""
597
+ original = "ab cd ef"
598
+ suggestions = [
599
+ {'start': 0, 'end': 2, 'original': 'ab', 'correction': 'abc'}, # length change!
600
+ {'start': 3, 'end': 5, 'original': 'cd', 'correction': 'cde'},
601
+ ]
602
+
603
+ # Forward (WRONG — offsets shift after first edit)
604
+ forward_result = original
605
+ for s in sorted(suggestions, key=lambda x: x['start']):
606
+ forward_result = forward_result[:s['start']] + s['correction'] + forward_result[s['end']:]
607
+
608
+ # Reverse (CORRECT)
609
+ reverse_result = original
610
+ for s in sorted(suggestions, key=lambda x: -x['start']):
611
+ reverse_result = reverse_result[:s['start']] + s['correction'] + reverse_result[s['end']:]
612
+
613
+ self.assertEqual(reverse_result, "abc cde ef")
614
+ # Forward produces wrong result because offsets shift
615
+ self.assertNotEqual(forward_result, reverse_result)
616
+
617
+
618
  if __name__ == '__main__':
619
  unittest.main()