youssefreda9 commited on
Commit
a1c20fd
·
1 Parent(s): 06539c0

feat: Pipeline Hardening v3.3 — stage ownership, punctuation safety, UUID migration

Browse files

- Add CorrectionPatch with dual coordinate spaces (ORIGINAL + CURRENT)
- Add StageLocker to protect corrected ranges from later stages
- Add PipelineContext with append-only mapper chain
- Refactor OffsetMapper: private ._opcodes, reverse_map_offset(), forward_map_range() with monotonicity guard
- Add validate_punctuation_diff() 5-rule safety layer (Arabic normalization, repetition, short-text cap, ratio, delta)
- Wire StageLocker blocking for Grammar and Punctuation stages
- Replace inline overlap resolver with PatchSet.resolve_overlaps()
- Frontend: _isApplyingSuggestion guard prevents recursive re-analysis
- Frontend: UUID-based suggestion tracking replaces all index-based lookups
- Remove broken _trackAppliedCorrection reference
- Remove all 3 re-index loops (UUID makes them unnecessary)
- 19/19 regression tests passing

src/app.py CHANGED
@@ -13,6 +13,10 @@ import traceback
13
  import difflib
14
  import re
15
 
 
 
 
 
16
  # Load .env file from project root (one level up from src/)
17
  try:
18
  from dotenv import load_dotenv
@@ -578,30 +582,87 @@ def get_word_positions(text):
578
 
579
 
580
  class OffsetMapper:
581
- def __init__(self, original, modified):
582
- self.original = original
583
- self.modified = modified
584
- self.mapping = [] # list of (mod_start, mod_end, orig_start, orig_end)
585
- self._build_mapping()
586
-
587
- def _build_mapping(self):
588
- s = difflib.SequenceMatcher(None, self.original, self.modified)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
589
  for tag, i1, i2, j1, j2 in s.get_opcodes():
590
- self.mapping.append((j1, j2, i1, i2))
591
-
592
- def map_offset(self, mod_offset):
593
  """
594
- Given a character offset in the modified text, return the corresponding
595
- character offset in the original text.
 
 
 
596
  """
597
- for j1, j2, i1, i2 in self.mapping:
598
- if j1 <= mod_offset <= j2:
599
  if j2 == j1: # insertion point
600
  return i1
601
- # Proportional mapping inside the block
602
- ratio = (mod_offset - j1) / (j2 - j1)
603
  return int(i1 + ratio * (i2 - i1))
604
- return len(self.original)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
605
 
606
 
607
  def get_word_diffs(original, corrected):
@@ -714,18 +775,17 @@ def analyze_text():
714
  if not text:
715
  return jsonify({'error': 'Text is required', 'status': 'error'}), 400
716
 
717
- current_text = text
718
- suggestions = []
719
- mappers = []
 
 
720
  total_start = time.time()
721
  timing_ms = {'spelling_ms': 0, 'grammar_ms': 0, 'punctuation_ms': 0, 'total_ms': 0}
722
 
723
  def map_range_to_original(start, end):
724
- curr_start, curr_end = start, end
725
- for mapper in reversed(mappers):
726
- curr_start = mapper.map_offset(curr_start)
727
- curr_end = mapper.map_offset(curr_end)
728
- return curr_start, curr_end
729
 
730
  def _get_spelling_alternatives(original_word, best_correction, spell_checker, max_alts=3):
731
  """Generate alternative spelling suggestions for a word."""
@@ -770,8 +830,8 @@ def analyze_text():
770
  timing_ms['spelling_ms'] = int((time.time() - t0) * 1000)
771
  logger.info(f"[ANALYZE] Step 1: Spelling done in {timing_ms['spelling_ms']}ms")
772
 
773
- if raw_corrected != current_text:
774
- orig_word_positions = get_word_positions(current_text)
775
  corr_word_positions = get_word_positions(raw_corrected)
776
 
777
  orig_word_strings = [w[0] for w in orig_word_positions]
@@ -798,14 +858,11 @@ def analyze_text():
798
  c_word = c_segment[0]
799
  if _is_small_spelling_change(o_word, c_word):
800
  new_words.append(c_word)
801
- suggestions.append({
802
- 'start': start_idx,
803
- 'end': end_idx,
804
- 'original': o_word,
805
- 'correction': c_word,
806
- 'type': 'spelling',
807
- 'alternatives': _get_spelling_alternatives(o_word, c_word, spell_checker),
808
- })
809
  else:
810
  new_words.append(current_text[start_idx:end_idx])
811
  elif len(o_segment) == 1 and len(c_segment) > 1:
@@ -814,14 +871,11 @@ def analyze_text():
814
  if len(o_word) >= 5 and ' ' not in o_word:
815
  corr_str = " ".join(c_segment)
816
  new_words.append(corr_str)
817
- suggestions.append({
818
- 'start': start_idx,
819
- 'end': end_idx,
820
- 'original': o_word,
821
- 'correction': corr_str,
822
- 'type': 'spelling',
823
- 'alternatives': [corr_str, o_word],
824
- })
825
  else:
826
  new_words.append(current_text[start_idx:end_idx])
827
  else:
@@ -839,14 +893,11 @@ def analyze_text():
839
  # Check if this is a 1→1 small edit
840
  if _is_small_spelling_change(o_word, c_word):
841
  new_words.append(c_word)
842
- suggestions.append({
843
- 'start': o_start,
844
- 'end': o_end,
845
- 'original': o_word,
846
- 'correction': c_word,
847
- 'type': 'spelling',
848
- 'alternatives': _get_spelling_alternatives(o_word, c_word, spell_checker),
849
- })
850
  ci += 1
851
  # Check if this is a 1→N word split
852
  elif len(o_word) >= 5 and ci + 1 < len(c_segment):
@@ -864,14 +915,11 @@ def analyze_text():
864
  dist = _levenshtein(o_word, joined_no_space)
865
  if dist <= 3 and len(split_parts) > 1:
866
  new_words.append(corr_str)
867
- suggestions.append({
868
- 'start': o_start,
869
- 'end': o_end,
870
- 'original': o_word,
871
- 'correction': corr_str,
872
- 'type': 'spelling',
873
- 'alternatives': [corr_str, o_word],
874
- })
875
  ci = temp_ci
876
  else:
877
  new_words.append(current_text[o_start:o_end])
@@ -888,8 +936,8 @@ def analyze_text():
888
  continue
889
 
890
  safe_text = " ".join(new_words)
891
- mappers.append(OffsetMapper(current_text, safe_text))
892
- current_text = safe_text
893
  except Exception as e:
894
  logger.error(f"[ANALYZE] Spelling failed: {e}")
895
 
@@ -899,22 +947,25 @@ def analyze_text():
899
  logger.info(f"[ANALYZE] Step 2: Grammar correction starting...")
900
  from nlp.grammar.grammar_service import get_grammar_model
901
  grammar_checker = get_grammar_model()
902
- corrected_grammar = grammar_checker.correct(current_text)
903
  timing_ms['grammar_ms'] = int((time.time() - t0) * 1000)
904
  logger.info(f"[ANALYZE] Step 2: Grammar done in {timing_ms['grammar_ms']}ms")
905
- if corrected_grammar != current_text:
906
- diffs = get_word_diffs(current_text, corrected_grammar)
907
  for d in diffs:
908
- orig_start, orig_end = map_range_to_original(d['start'], d['end'])
909
- suggestions.append({
910
- 'start': orig_start,
911
- 'end': orig_end,
912
- 'original': text[orig_start:orig_end],
913
- 'correction': d['correction'],
914
- 'type': 'grammar'
915
- })
916
- mappers.append(OffsetMapper(current_text, corrected_grammar))
917
- current_text = corrected_grammar
 
 
 
918
  except Exception as e:
919
  logger.error(f"[ANALYZE] Grammar failed: {e}")
920
 
@@ -924,22 +975,32 @@ def analyze_text():
924
  logger.info(f"[ANALYZE] Step 3: Punctuation starting...")
925
  from nlp.punctuation.punctuation_service import get_punctuation_model
926
  punc_checker = get_punctuation_model()
927
- corrected_punc = punc_checker.correct(current_text)
928
  timing_ms['punctuation_ms'] = int((time.time() - t0) * 1000)
929
  logger.info(f"[ANALYZE] Step 3: Punctuation done in {timing_ms['punctuation_ms']}ms")
930
- if corrected_punc != current_text:
931
- diffs = get_word_diffs(current_text, corrected_punc)
932
  for d in diffs:
933
- orig_start, orig_end = map_range_to_original(d['start'], d['end'])
934
- suggestions.append({
935
- 'start': orig_start,
936
- 'end': orig_end,
937
- 'original': text[orig_start:orig_end],
938
- 'correction': d['correction'],
939
- 'type': 'punctuation'
940
- })
941
- mappers.append(OffsetMapper(current_text, corrected_punc))
942
- current_text = corrected_punc
 
 
 
 
 
 
 
 
 
 
943
  except Exception as e:
944
  logger.error(f"[ANALYZE] Punctuation failed: {e}")
945
 
@@ -947,45 +1008,12 @@ def analyze_text():
947
  timing_ms['total_ms'] = int(total_time * 1000)
948
 
949
  # ══════════════════════════════════════════════════════════
950
- # GLOBAL OVERLAP RESOLVER — NLP-3.5 Hardening
951
  # ══════════════════════════════════════════════════════════
952
- # Priority: grammar(3) > punctuation(2) > spelling(1) > autocomplete(0)
953
- # Rules:
954
- # - Higher priority ALWAYS wins
955
- # - One span = one highlight (no stacking)
956
- # - Partial overlaps: higher priority span kept, lower dropped
957
- PRIORITY = {'grammar': 3, 'punctuation': 2, 'spelling': 1, 'autocomplete': 0}
958
-
959
- # Sort suggestions by priority (highest first)
960
- suggestions.sort(key=lambda s: PRIORITY.get(s['type'], 0), reverse=True)
961
-
962
- # Build a set of "claimed" character positions
963
- claimed_ranges = [] # list of (start, end, type)
964
- resolved = []
965
-
966
- for s in suggestions:
967
- s_start, s_end = s['start'], s['end']
968
- s_priority = PRIORITY.get(s['type'], 0)
969
-
970
- # Check if this span overlaps with any already-claimed higher-priority span
971
- overlaps = False
972
- for (c_start, c_end, c_type) in claimed_ranges:
973
- # Two spans overlap if one starts before the other ends
974
- if s_start < c_end and s_end > c_start:
975
- overlaps = True
976
- break
977
-
978
- if not overlaps:
979
- resolved.append(s)
980
- claimed_ranges.append((s_start, s_end, s['type']))
981
- else:
982
- logger.info(f"[OVERLAP] Dropped {s['type']} [{s_start}:{s_end}] "
983
- f"'{s.get('original','')}' — conflicts with higher-priority span")
984
-
985
- dropped = len(suggestions) - len(resolved)
986
- if dropped > 0:
987
- logger.info(f"[OVERLAP] Resolved {dropped} overlapping suggestions")
988
- suggestions = resolved
989
 
990
  logger.info(f"[ANALYZE] Total: {timing_ms['total_ms']}ms | "
991
  f"Spelling: {timing_ms['spelling_ms']}ms | "
@@ -995,7 +1023,7 @@ def analyze_text():
995
 
996
  return jsonify({
997
  'original': text,
998
- 'corrected': current_text,
999
  'suggestions': suggestions,
1000
  'timing_ms': timing_ms,
1001
  'status': 'success'
 
13
  import difflib
14
  import re
15
 
16
+ # Pipeline hardening modules
17
+ from nlp.pipeline_context import PipelineContext
18
+ from nlp.punctuation.punctuation_rules import validate_punctuation_diff
19
+
20
  # Load .env file from project root (one level up from src/)
21
  try:
22
  from dotenv import load_dotenv
 
582
 
583
 
584
  class OffsetMapper:
585
+ """
586
+ Single source of truth for coordinate transformations between
587
+ two consecutive versions of CURRENT_TEXT.
588
+
589
+ CONTRACT:
590
+ Input: text_before (str), text_after (str)
591
+ — two consecutive states of CURRENT_TEXT
592
+ Stores: Internal diff operations (PRIVATE)
593
+ API:
594
+ reverse_map_offset(pos) → text_after pos → text_before pos
595
+ forward_map_range(start, end) → text_before range → text_after range
596
+
597
+ TERMINOLOGY:
598
+ text_before = CURRENT_TEXT before this stage's mutation
599
+ text_after = CURRENT_TEXT after this stage's mutation
600
+ forward = text_before → text_after
601
+ reverse = text_after → text_before
602
+
603
+ RULES:
604
+ All external code uses reverse_map_offset() or forward_map_range().
605
+ ._opcodes is PRIVATE — no external access.
606
+ """
607
+
608
+ def __init__(self, text_before, text_after):
609
+ self._text_before = text_before
610
+ self._text_after = text_after
611
+ self._opcodes = [] # PRIVATE — (i1, i2, j1, j2) tuples
612
+ self._build()
613
+
614
+ def _build(self):
615
+ s = difflib.SequenceMatcher(None, self._text_before, self._text_after)
616
  for tag, i1, i2, j1, j2 in s.get_opcodes():
617
+ self._opcodes.append((i1, i2, j1, j2))
618
+
619
+ def reverse_map_offset(self, pos_in_after):
620
  """
621
+ Map a single position from text_after → text_before.
622
+ (CURRENT_TEXT after mutation → CURRENT_TEXT before mutation)
623
+
624
+ Used by PipelineContext.map_to_original() to walk the mapper
625
+ chain in reverse, ultimately reaching ORIGINAL_TEXT coordinates.
626
  """
627
+ for i1, i2, j1, j2 in self._opcodes:
628
+ if j1 <= pos_in_after <= j2:
629
  if j2 == j1: # insertion point
630
  return i1
631
+ ratio = (pos_in_after - j1) / (j2 - j1)
 
632
  return int(i1 + ratio * (i2 - i1))
633
+ return len(self._text_before)
634
+
635
+ def forward_map_range(self, start_in_before, end_in_before):
636
+ """
637
+ Map a range from text_before → text_after.
638
+ (CURRENT_TEXT before mutation → CURRENT_TEXT after mutation)
639
+
640
+ Used ONLY by StageLocker.update_via_mapper() to shift locked
641
+ spans after a text mutation.
642
+
643
+ MONOTONICITY GUARD: If independent point mapping produces an
644
+ inverted range (start > end) due to non-monotonic edits,
645
+ the end is clamped to max(new_start, new_end).
646
+ """
647
+ new_start = self._forward_map_pos(start_in_before)
648
+ new_end = self._forward_map_pos(end_in_before)
649
+ # Monotonicity guard: prevent inverted ranges
650
+ new_end = max(new_start, new_end)
651
+ return new_start, new_end
652
+
653
+ def _forward_map_pos(self, pos):
654
+ """Map a single position text_before → text_after. PRIVATE."""
655
+ for i1, i2, j1, j2 in self._opcodes:
656
+ if i1 <= pos <= i2:
657
+ if i2 == i1:
658
+ return j1
659
+ ratio = (pos - i1) / (i2 - i1)
660
+ return int(j1 + ratio * (j2 - j1))
661
+ if self._opcodes:
662
+ last = self._opcodes[-1]
663
+ return last[3] + (pos - last[1])
664
+ return pos
665
+
666
 
667
 
668
  def get_word_diffs(original, corrected):
 
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)
781
+ suggestions = [] # Legacy — will be replaced by ctx.patches at response time
782
+ mappers = [] # Legacy — will be replaced by ctx._offset_mappers
783
  total_start = time.time()
784
  timing_ms = {'spelling_ms': 0, 'grammar_ms': 0, 'punctuation_ms': 0, 'total_ms': 0}
785
 
786
  def map_range_to_original(start, end):
787
+ """Legacy wrapper — delegates to PipelineContext."""
788
+ return ctx.map_to_original(start, end)
 
 
 
789
 
790
  def _get_spelling_alternatives(original_word, best_correction, spell_checker, max_alts=3):
791
  """Generate alternative spelling suggestions for a word."""
 
830
  timing_ms['spelling_ms'] = int((time.time() - t0) * 1000)
831
  logger.info(f"[ANALYZE] Step 1: Spelling done in {timing_ms['spelling_ms']}ms")
832
 
833
+ if raw_corrected != ctx.current_text:
834
+ orig_word_positions = get_word_positions(ctx.current_text)
835
  corr_word_positions = get_word_positions(raw_corrected)
836
 
837
  orig_word_strings = [w[0] for w in orig_word_positions]
 
858
  c_word = c_segment[0]
859
  if _is_small_spelling_change(o_word, c_word):
860
  new_words.append(c_word)
861
+ ctx.add_patch(
862
+ 'spelling', start_idx, end_idx,
863
+ c_word, confidence=0.9,
864
+ alternatives=_get_spelling_alternatives(o_word, c_word, spell_checker),
865
+ )
 
 
 
866
  else:
867
  new_words.append(current_text[start_idx:end_idx])
868
  elif len(o_segment) == 1 and len(c_segment) > 1:
 
871
  if len(o_word) >= 5 and ' ' not in o_word:
872
  corr_str = " ".join(c_segment)
873
  new_words.append(corr_str)
874
+ ctx.add_patch(
875
+ 'spelling', start_idx, end_idx,
876
+ corr_str, confidence=0.85,
877
+ alternatives=[corr_str, o_word],
878
+ )
 
 
 
879
  else:
880
  new_words.append(current_text[start_idx:end_idx])
881
  else:
 
893
  # Check if this is a 1→1 small edit
894
  if _is_small_spelling_change(o_word, c_word):
895
  new_words.append(c_word)
896
+ ctx.add_patch(
897
+ 'spelling', o_start, o_end,
898
+ c_word, confidence=0.9,
899
+ alternatives=_get_spelling_alternatives(o_word, c_word, spell_checker),
900
+ )
 
 
 
901
  ci += 1
902
  # Check if this is a 1→N word split
903
  elif len(o_word) >= 5 and ci + 1 < len(c_segment):
 
915
  dist = _levenshtein(o_word, joined_no_space)
916
  if dist <= 3 and len(split_parts) > 1:
917
  new_words.append(corr_str)
918
+ ctx.add_patch(
919
+ 'spelling', o_start, o_end,
920
+ corr_str, confidence=0.85,
921
+ alternatives=[corr_str, o_word],
922
+ )
 
 
 
923
  ci = temp_ci
924
  else:
925
  new_words.append(current_text[o_start:o_end])
 
936
  continue
937
 
938
  safe_text = " ".join(new_words)
939
+ ctx.mutate_text(safe_text, OffsetMapper)
940
+ current_text = ctx.current_text
941
  except Exception as e:
942
  logger.error(f"[ANALYZE] Spelling failed: {e}")
943
 
 
947
  logger.info(f"[ANALYZE] Step 2: Grammar correction starting...")
948
  from nlp.grammar.grammar_service import get_grammar_model
949
  grammar_checker = get_grammar_model()
950
+ corrected_grammar = grammar_checker.correct(ctx.current_text)
951
  timing_ms['grammar_ms'] = int((time.time() - t0) * 1000)
952
  logger.info(f"[ANALYZE] Step 2: Grammar done in {timing_ms['grammar_ms']}ms")
953
+ if corrected_grammar != ctx.current_text:
954
+ diffs = get_word_diffs(ctx.current_text, corrected_grammar)
955
  for d in diffs:
956
+ # StageLocker: skip diffs that overlap with locked ranges
957
+ if ctx.stage_locker.is_locked(d['start'], d['end']):
958
+ logger.info(
959
+ f"[LOCK] Grammar blocked on [{d['start']}:{d['end']}] "
960
+ f"'{d.get('original','')}' — locked by previous stage"
961
+ )
962
+ continue
963
+ ctx.add_patch(
964
+ 'grammar', d['start'], d['end'],
965
+ d['correction'], confidence=1.0
966
+ )
967
+ ctx.mutate_text(corrected_grammar, OffsetMapper)
968
+ current_text = ctx.current_text
969
  except Exception as e:
970
  logger.error(f"[ANALYZE] Grammar failed: {e}")
971
 
 
975
  logger.info(f"[ANALYZE] Step 3: Punctuation starting...")
976
  from nlp.punctuation.punctuation_service import get_punctuation_model
977
  punc_checker = get_punctuation_model()
978
+ corrected_punc = punc_checker.correct(ctx.current_text)
979
  timing_ms['punctuation_ms'] = int((time.time() - t0) * 1000)
980
  logger.info(f"[ANALYZE] Step 3: Punctuation done in {timing_ms['punctuation_ms']}ms")
981
+ if corrected_punc != ctx.current_text:
982
+ diffs = get_word_diffs(ctx.current_text, corrected_punc)
983
  for d in diffs:
984
+ # StageLocker: skip diffs that overlap with locked ranges
985
+ if ctx.stage_locker.is_locked(d['start'], d['end']):
986
+ logger.info(
987
+ f"[LOCK] Punctuation blocked on [{d['start']}:{d['end']}] "
988
+ f"'{d.get('original','')}' — locked by previous stage"
989
+ )
990
+ continue
991
+ # Punctuation safety layer: reject non-punctuation changes
992
+ if not validate_punctuation_diff(d):
993
+ logger.info(
994
+ f"[PUNC-SAFETY] Rejected diff [{d['start']}:{d['end']}] "
995
+ f"'{d.get('original','')}' → '{d.get('correction','')}' — not a safe punctuation change"
996
+ )
997
+ continue
998
+ ctx.add_patch(
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:
1005
  logger.error(f"[ANALYZE] Punctuation failed: {e}")
1006
 
 
1008
  timing_ms['total_ms'] = int(total_time * 1000)
1009
 
1010
  # ══════════════════════════════════════════════════════════
1011
+ # OVERLAP RESOLUTION — Pipeline Hardening v3.3
1012
  # ══════════════════════════════════════════════════════════
1013
+ # PatchSet handles deterministic overlap resolution:
1014
+ # Sort: priority DESC → confidence DESC → start ASC → id ASC
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 | "
 
1023
 
1024
  return jsonify({
1025
  'original': text,
1026
+ 'corrected': ctx.current_text,
1027
  'suggestions': suggestions,
1028
  'timing_ms': timing_ms,
1029
  'status': 'success'
src/js/editor.js CHANGED
@@ -7,6 +7,9 @@ let _lastInputTime = 0;
7
  const ANALYZE_DEBOUNCE_MS = 1000;
8
  const MAX_ANALYZE_LENGTH = 5000;
9
 
 
 
 
10
  // ── Custom Undo/Redo Stack ──
11
  const _undoStack = [];
12
  const _redoStack = [];
@@ -77,6 +80,8 @@ function initEditor() {
77
  } catch (e) {}
78
 
79
  editor.addEventListener('input', () => {
 
 
80
  _lastInputTime = Date.now();
81
  updateEditorStats();
82
  updatePlaceholder();
@@ -229,8 +234,9 @@ function analyzeTextDelayed() {
229
  }
230
 
231
  function findSuggestionById(id) {
 
232
  const suggestions = window.currentSuggestions || [];
233
- return suggestions[parseInt(id, 10)] || null;
234
  }
235
 
236
  function findSuggestionElement(id) {
@@ -432,73 +438,63 @@ function hideTooltip() {
432
  }
433
 
434
  function applySuggestionAtOffsets(suggestion) {
435
- pushUndoState(); // Save state before correction
436
- // Find the error span in the DOM and replace its text content
437
- // This preserves formatting (bold, italic, etc.) around/inside the span
438
- const idx = (window.currentSuggestions || []).indexOf(suggestion);
439
- const errorSpan = idx >= 0 ? document.querySelector(`[data-suggestion-id="${idx}"]`) : null;
440
-
441
- if (errorSpan) {
442
- // Replace the error span's text content with the correction
443
- // while keeping it inside its formatting parent
444
- const parent = errorSpan.parentNode;
445
- const correctedNode = document.createTextNode(suggestion.correction);
446
- parent.insertBefore(correctedNode, errorSpan);
447
- parent.removeChild(errorSpan);
448
- parent.normalize();
449
- } else {
450
- // Fallback: find span by matching original text
451
- const allErrorSpans = document.querySelectorAll('.spelling-error, .grammar-error, .punctuation-suggestion');
452
- let found = false;
453
- allErrorSpans.forEach(span => {
454
- if (!found && span.textContent === suggestion.original) {
455
- const p = span.parentNode;
456
- const correctedNode = document.createTextNode(suggestion.correction);
457
- p.insertBefore(correctedNode, span);
458
- p.removeChild(span);
459
- p.normalize();
460
- found = true;
461
- }
462
- });
463
- if (!found) {
464
- // Last resort: offset-based replacement
465
- const text = getEditorText();
466
- const before = text.substring(0, suggestion.start);
467
- const after = text.substring(suggestion.end);
468
- const newText = before + suggestion.correction + after;
469
- setEditorHTML(escapeHtml(newText));
470
- }
471
- }
472
- hideTooltip();
473
- // Re-focus editor so Ctrl+Z works immediately after tooltip correction
474
- const _ed = getEditorElement(); if (_ed) _ed.focus();
475
-
476
- // Remove applied suggestion from list and re-index remaining spans
477
- if (window.currentSuggestions) {
478
- window.currentSuggestions = window.currentSuggestions.filter(
479
- s => !(s.start === suggestion.start && s.end === suggestion.end && s.type === suggestion.type)
480
- );
481
-
482
- // Re-index remaining error spans to match updated array indices
483
- const remainingSpans = document.querySelectorAll('.spelling-error, .grammar-error, .punctuation-suggestion');
484
- remainingSpans.forEach(span => {
485
- const newIdx = window.currentSuggestions.findIndex(s => {
486
- return span.textContent === s.original && span.dataset.type === s.type;
487
  });
488
- if (newIdx >= 0) {
489
- span.dataset.suggestionId = newIdx;
 
 
 
 
 
490
  }
491
- });
492
-
493
- const spellingCount = window.currentSuggestions.filter(s => s.type === 'spelling').length;
494
- const grammarCount = window.currentSuggestions.filter(s => s.type === 'grammar').length;
495
- const punctuationCount = window.currentSuggestions.filter(s => s.type === 'punctuation').length;
496
- updateSuggestionCounts(spellingCount, grammarCount, punctuationCount);
497
- updateWritingScore(spellingCount, grammarCount, punctuationCount);
498
- updateSuggestionsList(window.currentSuggestions);
 
 
 
 
 
 
 
 
 
 
 
 
 
499
  }
500
-
501
- analyzeTextDelayed();
502
  }
503
 
504
  function applyCorrection() {
@@ -508,64 +504,60 @@ function applyCorrection() {
508
  }
509
 
510
  function applyAlternativeCorrection(suggestion, correctionText) {
511
- pushUndoState(); // Save state before correction
512
- const idx = (window.currentSuggestions || []).indexOf(suggestion);
513
- const errorSpan = idx >= 0 ? document.querySelector(`[data-suggestion-id="${idx}"]`) : null;
514
-
515
- if (errorSpan) {
516
- const parent = errorSpan.parentNode;
517
- const correctedNode = document.createTextNode(correctionText);
518
- parent.insertBefore(correctedNode, errorSpan);
519
- parent.removeChild(errorSpan);
520
- parent.normalize();
521
- } else {
522
- const allErrorSpans = document.querySelectorAll('.spelling-error, .grammar-error, .punctuation-suggestion');
523
- let found = false;
524
- allErrorSpans.forEach(span => {
525
- if (!found && span.textContent === suggestion.original) {
526
- const p = span.parentNode;
527
- const correctedNode = document.createTextNode(correctionText);
528
- p.insertBefore(correctedNode, span);
529
- p.removeChild(span);
530
- p.normalize();
531
- found = true;
532
- }
533
- });
534
- if (!found) {
535
- const text = getEditorText();
536
- const before = text.substring(0, suggestion.start);
537
- const after = text.substring(suggestion.end);
538
- const newText = before + correctionText + after;
539
- setEditorHTML(escapeHtml(newText));
540
- }
541
- }
542
- hideTooltip();
543
- // Re-focus editor so Ctrl+Z works immediately after tooltip correction
544
- const _ed2 = getEditorElement(); if (_ed2) _ed2.focus();
545
- // Remove applied suggestion from list and re-index remaining spans
546
- if (window.currentSuggestions) {
547
- window.currentSuggestions = window.currentSuggestions.filter(
548
- s => !(s.start === suggestion.start && s.end === suggestion.end && s.type === suggestion.type)
549
- );
550
-
551
- const remainingSpans = document.querySelectorAll('.spelling-error, .grammar-error, .punctuation-suggestion');
552
- remainingSpans.forEach(span => {
553
- const newIdx = window.currentSuggestions.findIndex(s => {
554
- return span.textContent === s.original && span.dataset.type === s.type;
555
  });
556
- if (newIdx >= 0) {
557
- span.dataset.suggestionId = newIdx;
 
 
 
 
558
  }
559
- });
560
-
561
- const spellingCount = window.currentSuggestions.filter(s => s.type === 'spelling').length;
562
- const grammarCount = window.currentSuggestions.filter(s => s.type === 'grammar').length;
563
- const punctuationCount = window.currentSuggestions.filter(s => s.type === 'punctuation').length;
564
- updateSuggestionCounts(spellingCount, grammarCount, punctuationCount);
565
- updateWritingScore(spellingCount, grammarCount, punctuationCount);
566
- updateSuggestionsList(window.currentSuggestions);
 
 
 
 
 
 
 
 
 
 
 
 
567
  }
568
- analyzeTextDelayed();
569
  }
570
 
571
  function dismissSuggestion(suggestion) {
@@ -577,8 +569,9 @@ function dismissSuggestion(suggestion) {
577
  }
578
 
579
  // Remove the error highlight but keep the text as-is
580
- const idx = (window.currentSuggestions || []).indexOf(suggestion);
581
- const errorSpan = idx >= 0 ? document.querySelector(`[data-suggestion-id="${idx}"]`) : null;
 
582
 
583
  if (errorSpan) {
584
  // Unwrap: replace span with its text content
@@ -592,21 +585,9 @@ function dismissSuggestion(suggestion) {
592
 
593
  if (window.currentSuggestions) {
594
  window.currentSuggestions = window.currentSuggestions.filter(
595
- s => !(s.start === suggestion.start && s.end === suggestion.end)
596
  );
597
-
598
- // Re-index remaining error spans to match updated array indices
599
- const remainingSpans = document.querySelectorAll('.spelling-error, .grammar-error, .punctuation-suggestion');
600
- remainingSpans.forEach(span => {
601
- const oldId = parseInt(span.dataset.suggestionId, 10);
602
- // Find this span's suggestion in the updated array by matching start/end
603
- const newIdx = window.currentSuggestions.findIndex(s => {
604
- return span.textContent === s.original;
605
- });
606
- if (newIdx >= 0) {
607
- span.dataset.suggestionId = newIdx;
608
- }
609
- });
610
 
611
  const spellingCount = window.currentSuggestions.filter(s => s.type === 'spelling').length;
612
  const grammarCount = window.currentSuggestions.filter(s => s.type === 'grammar').length;
@@ -620,9 +601,9 @@ function dismissSuggestion(suggestion) {
620
  const _ed3 = getEditorElement(); if (_ed3) _ed3.focus();
621
  }
622
 
623
- function applySuggestionByIndex(index) {
624
- const suggestions = window.currentSuggestions || [];
625
- const suggestion = suggestions[index];
626
  if (!suggestion) return;
627
  applySuggestionAtOffsets(suggestion);
628
  }
@@ -630,27 +611,28 @@ function applySuggestionByIndex(index) {
630
  function applyAllSuggestions() {
631
  const suggestions = [...(window.currentSuggestions || [])].sort((a, b) => b.start - a.start);
632
  if (suggestions.length === 0) return;
633
- pushUndoState(); // Save state before applying all
634
-
635
- let text = getEditorText();
636
- suggestions.forEach((s) => {
637
- text = text.substring(0, s.start) + s.correction + text.substring(s.end);
638
- });
639
 
640
- setEditorHTML(escapeHtml(text));
641
- hideTooltip();
 
 
642
 
643
- // Track all applied corrections
644
- suggestions.forEach(s => _trackAppliedCorrection(s.correction));
645
 
646
- // Clear suggestions
647
- window.currentSuggestions = [];
648
- updateSuggestionCounts(0, 0, 0);
649
- updateWritingScore(0, 0, 0);
650
- updateSuggestionsList([]);
651
-
652
- analyzeTextDelayed();
653
- if (typeof showToast === 'function') showToast('✓ تم تطبيق ' + suggestions.length + ' تصحيح');
 
 
654
  }
655
 
656
  function clearEditor() {
@@ -726,7 +708,7 @@ if (typeof module !== 'undefined' && module.exports) {
726
  showTooltip,
727
  hideTooltip,
728
  applyCorrection,
729
- applySuggestionByIndex,
730
  applyAllSuggestions
731
  };
732
  }
 
7
  const ANALYZE_DEBOUNCE_MS = 1000;
8
  const MAX_ANALYZE_LENGTH = 5000;
9
 
10
+ // Pipeline Hardening v3.3: Guard to prevent input events during suggestion apply
11
+ let _isApplyingSuggestion = false;
12
+
13
  // ── Custom Undo/Redo Stack ──
14
  const _undoStack = [];
15
  const _redoStack = [];
 
80
  } catch (e) {}
81
 
82
  editor.addEventListener('input', () => {
83
+ // Pipeline Hardening v3.3: Skip re-analysis when programmatically applying suggestions
84
+ if (_isApplyingSuggestion) return;
85
  _lastInputTime = Date.now();
86
  updateEditorStats();
87
  updatePlaceholder();
 
234
  }
235
 
236
  function findSuggestionById(id) {
237
+ // Pipeline Hardening v3.3: UUID-based lookup instead of index
238
  const suggestions = window.currentSuggestions || [];
239
+ return suggestions.find(s => s.id === id) || null;
240
  }
241
 
242
  function findSuggestionElement(id) {
 
438
  }
439
 
440
  function applySuggestionAtOffsets(suggestion) {
441
+ _isApplyingSuggestion = true;
442
+ try {
443
+ pushUndoState(); // Save state before correction
444
+ // Pipeline Hardening v3.3: UUID-based span lookup
445
+ const suggestionId = suggestion.id;
446
+ const errorSpan = suggestionId ? document.querySelector(`[data-suggestion-id="${suggestionId}"]`) : null;
447
+
448
+ if (errorSpan) {
449
+ const parent = errorSpan.parentNode;
450
+ const correctedNode = document.createTextNode(suggestion.correction);
451
+ parent.insertBefore(correctedNode, errorSpan);
452
+ parent.removeChild(errorSpan);
453
+ parent.normalize();
454
+ } else {
455
+ // Fallback: find span by matching original text
456
+ const allErrorSpans = document.querySelectorAll('.spelling-error, .grammar-error, .punctuation-suggestion');
457
+ let found = false;
458
+ allErrorSpans.forEach(span => {
459
+ if (!found && span.textContent === suggestion.original) {
460
+ const p = span.parentNode;
461
+ const correctedNode = document.createTextNode(suggestion.correction);
462
+ p.insertBefore(correctedNode, span);
463
+ p.removeChild(span);
464
+ p.normalize();
465
+ found = true;
466
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
467
  });
468
+ if (!found) {
469
+ // Last resort: offset-based replacement
470
+ const text = getEditorText();
471
+ const before = text.substring(0, suggestion.start);
472
+ const after = text.substring(suggestion.end);
473
+ const newText = before + suggestion.correction + after;
474
+ setEditorHTML(escapeHtml(newText));
475
  }
476
+ }
477
+ hideTooltip();
478
+ // Re-focus editor so Ctrl+Z works immediately after tooltip correction
479
+ const _ed = getEditorElement(); if (_ed) _ed.focus();
480
+
481
+ // Remove applied suggestion from list (UUID-based, no re-indexing needed)
482
+ if (window.currentSuggestions) {
483
+ window.currentSuggestions = window.currentSuggestions.filter(
484
+ s => s.id !== suggestion.id
485
+ );
486
+
487
+ const spellingCount = window.currentSuggestions.filter(s => s.type === 'spelling').length;
488
+ const grammarCount = window.currentSuggestions.filter(s => s.type === 'grammar').length;
489
+ const punctuationCount = window.currentSuggestions.filter(s => s.type === 'punctuation').length;
490
+ updateSuggestionCounts(spellingCount, grammarCount, punctuationCount);
491
+ updateWritingScore(spellingCount, grammarCount, punctuationCount);
492
+ updateSuggestionsList(window.currentSuggestions);
493
+ }
494
+ // Pipeline Hardening v3.3: Do NOT call analyzeTextDelayed() — prevents recursive re-analysis
495
+ } finally {
496
+ _isApplyingSuggestion = false;
497
  }
 
 
498
  }
499
 
500
  function applyCorrection() {
 
504
  }
505
 
506
  function applyAlternativeCorrection(suggestion, correctionText) {
507
+ _isApplyingSuggestion = true;
508
+ try {
509
+ pushUndoState(); // Save state before correction
510
+ // Pipeline Hardening v3.3: UUID-based span lookup
511
+ const suggestionId = suggestion.id;
512
+ const errorSpan = suggestionId ? document.querySelector(`[data-suggestion-id="${suggestionId}"]`) : null;
513
+
514
+ if (errorSpan) {
515
+ const parent = errorSpan.parentNode;
516
+ const correctedNode = document.createTextNode(correctionText);
517
+ parent.insertBefore(correctedNode, errorSpan);
518
+ parent.removeChild(errorSpan);
519
+ parent.normalize();
520
+ } else {
521
+ const allErrorSpans = document.querySelectorAll('.spelling-error, .grammar-error, .punctuation-suggestion');
522
+ let found = false;
523
+ allErrorSpans.forEach(span => {
524
+ if (!found && span.textContent === suggestion.original) {
525
+ const p = span.parentNode;
526
+ const correctedNode = document.createTextNode(correctionText);
527
+ p.insertBefore(correctedNode, span);
528
+ p.removeChild(span);
529
+ p.normalize();
530
+ found = true;
531
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
532
  });
533
+ if (!found) {
534
+ const text = getEditorText();
535
+ const before = text.substring(0, suggestion.start);
536
+ const after = text.substring(suggestion.end);
537
+ const newText = before + correctionText + after;
538
+ setEditorHTML(escapeHtml(newText));
539
  }
540
+ }
541
+ hideTooltip();
542
+ // Re-focus editor so Ctrl+Z works immediately after tooltip correction
543
+ const _ed2 = getEditorElement(); if (_ed2) _ed2.focus();
544
+ // Remove applied suggestion (UUID-based, no re-indexing needed)
545
+ if (window.currentSuggestions) {
546
+ window.currentSuggestions = window.currentSuggestions.filter(
547
+ s => s.id !== suggestion.id
548
+ );
549
+
550
+ const spellingCount = window.currentSuggestions.filter(s => s.type === 'spelling').length;
551
+ const grammarCount = window.currentSuggestions.filter(s => s.type === 'grammar').length;
552
+ const punctuationCount = window.currentSuggestions.filter(s => s.type === 'punctuation').length;
553
+ updateSuggestionCounts(spellingCount, grammarCount, punctuationCount);
554
+ updateWritingScore(spellingCount, grammarCount, punctuationCount);
555
+ updateSuggestionsList(window.currentSuggestions);
556
+ }
557
+ // Pipeline Hardening v3.3: Do NOT call analyzeTextDelayed() — prevents recursive re-analysis
558
+ } finally {
559
+ _isApplyingSuggestion = false;
560
  }
 
561
  }
562
 
563
  function dismissSuggestion(suggestion) {
 
569
  }
570
 
571
  // Remove the error highlight but keep the text as-is
572
+ // Pipeline Hardening v3.3: UUID-based span lookup
573
+ const suggestionId = suggestion.id;
574
+ const errorSpan = suggestionId ? document.querySelector(`[data-suggestion-id="${suggestionId}"]`) : null;
575
 
576
  if (errorSpan) {
577
  // Unwrap: replace span with its text content
 
585
 
586
  if (window.currentSuggestions) {
587
  window.currentSuggestions = window.currentSuggestions.filter(
588
+ s => s.id !== suggestion.id
589
  );
590
+ // Pipeline Hardening v3.3: No re-indexing needed — UUID-based
 
 
 
 
 
 
 
 
 
 
 
 
591
 
592
  const spellingCount = window.currentSuggestions.filter(s => s.type === 'spelling').length;
593
  const grammarCount = window.currentSuggestions.filter(s => s.type === 'grammar').length;
 
601
  const _ed3 = getEditorElement(); if (_ed3) _ed3.focus();
602
  }
603
 
604
+ function applySuggestionById(id) {
605
+ // Pipeline Hardening v3.3: UUID-based lookup
606
+ const suggestion = findSuggestionById(id);
607
  if (!suggestion) return;
608
  applySuggestionAtOffsets(suggestion);
609
  }
 
611
  function applyAllSuggestions() {
612
  const suggestions = [...(window.currentSuggestions || [])].sort((a, b) => b.start - a.start);
613
  if (suggestions.length === 0) return;
614
+ _isApplyingSuggestion = true;
615
+ try {
616
+ pushUndoState(); // Save state before applying all
 
 
 
617
 
618
+ let text = getEditorText();
619
+ suggestions.forEach((s) => {
620
+ text = text.substring(0, s.start) + s.correction + text.substring(s.end);
621
+ });
622
 
623
+ setEditorHTML(escapeHtml(text));
624
+ hideTooltip();
625
 
626
+ // Clear suggestions
627
+ window.currentSuggestions = [];
628
+ updateSuggestionCounts(0, 0, 0);
629
+ updateWritingScore(0, 0, 0);
630
+ updateSuggestionsList([]);
631
+ // Pipeline Hardening v3.3: Do NOT call analyzeTextDelayed() — prevents recursive re-analysis
632
+ if (typeof showToast === 'function') showToast('✓ تم تطبيق ' + suggestions.length + ' تصحيح');
633
+ } finally {
634
+ _isApplyingSuggestion = false;
635
+ }
636
  }
637
 
638
  function clearEditor() {
 
708
  showTooltip,
709
  hideTooltip,
710
  applyCorrection,
711
+ applySuggestionById,
712
  applyAllSuggestions
713
  };
714
  }
src/js/renderer.js CHANGED
@@ -162,7 +162,8 @@ function renderHighlightedText(text, suggestions) {
162
 
163
  const segments = createSegments(text, suggestions);
164
  let html = '';
165
- let suggestionId = 0;
 
166
 
167
  segments.forEach((segment) => {
168
  if (segment.type === 'text') {
@@ -173,14 +174,16 @@ function renderHighlightedText(text, suggestions) {
173
  const { suggestion } = segment;
174
  const errorClass = getErrorClass(suggestion.type);
175
  const escapedText = escapeHtml(segment.text);
 
 
176
 
177
- html += `<span class="${errorClass}" data-suggestion-id="${suggestionId}" data-original="${escapeHtml(
178
  suggestion.original
179
  )}" data-correction="${escapeHtml(
180
  suggestion.correction
181
  )}" data-type="${suggestion.type}" title="${suggestion.type}: ${escapeHtml(suggestion.correction)}">${escapedText}</span>`;
182
 
183
- suggestionId++;
184
  }
185
  });
186
 
@@ -254,7 +257,6 @@ function overlaySuggestions(editor, suggestions) {
254
  const sorted = [...suggestions].sort((a, b) => b.start - a.start);
255
 
256
  sorted.forEach((suggestion, reverseIdx) => {
257
- const realIdx = suggestions.length - 1 - reverseIdx;
258
  const { start, end } = suggestion;
259
  const errorClass = getErrorClass(suggestion.type);
260
 
@@ -265,7 +267,8 @@ function overlaySuggestions(editor, suggestions) {
265
  // Create the wrapper span
266
  const wrapper = document.createElement('span');
267
  wrapper.className = errorClass;
268
- wrapper.dataset.suggestionId = String(realIdx);
 
269
  wrapper.dataset.original = suggestion.original || '';
270
  wrapper.dataset.correction = suggestion.correction || '';
271
  wrapper.dataset.type = suggestion.type || 'spelling';
 
162
 
163
  const segments = createSegments(text, suggestions);
164
  let html = '';
165
+ // Pipeline Hardening v3.3: Track which suggestion we're rendering for UUID lookup
166
+ let suggestionIdx = 0;
167
 
168
  segments.forEach((segment) => {
169
  if (segment.type === 'text') {
 
174
  const { suggestion } = segment;
175
  const errorClass = getErrorClass(suggestion.type);
176
  const escapedText = escapeHtml(segment.text);
177
+ // Pipeline Hardening v3.3: Use suggestion.id (UUID) if available, fallback to index
178
+ const sid = suggestion.id || suggestionIdx;
179
 
180
+ html += `<span class="${errorClass}" data-suggestion-id="${sid}" data-original="${escapeHtml(
181
  suggestion.original
182
  )}" data-correction="${escapeHtml(
183
  suggestion.correction
184
  )}" data-type="${suggestion.type}" title="${suggestion.type}: ${escapeHtml(suggestion.correction)}">${escapedText}</span>`;
185
 
186
+ suggestionIdx++;
187
  }
188
  });
189
 
 
257
  const sorted = [...suggestions].sort((a, b) => b.start - a.start);
258
 
259
  sorted.forEach((suggestion, reverseIdx) => {
 
260
  const { start, end } = suggestion;
261
  const errorClass = getErrorClass(suggestion.type);
262
 
 
267
  // Create the wrapper span
268
  const wrapper = document.createElement('span');
269
  wrapper.className = errorClass;
270
+ // Pipeline Hardening v3.3: Use suggestion.id (UUID) instead of array index
271
+ wrapper.dataset.suggestionId = suggestion.id || String(reverseIdx);
272
  wrapper.dataset.original = suggestion.original || '';
273
  wrapper.dataset.correction = suggestion.correction || '';
274
  wrapper.dataset.type = suggestion.type || 'spelling';
src/js/ui.js CHANGED
@@ -63,6 +63,8 @@ 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
 
67
  let altsHTML = '';
68
  alts.forEach((alt, i) => {
@@ -70,12 +72,12 @@ function buildSuggestionCardHTML(suggestion, index) {
70
  const isMain = i === 0;
71
  const cls = isKeep ? 'alt-chip alt-chip--keep' : (isMain ? 'alt-chip alt-chip--main' : 'alt-chip');
72
  const chipLabel = isKeep ? `${escapeHtml(alt)} ✓` : escapeHtml(alt);
73
- altsHTML += `<button class="${cls}" data-card-alt="${escapeHtml(alt)}" data-card-index="${index}" type="button">${chipLabel}</button>`;
74
  });
75
 
76
  return `
77
  <div class="suggestion-card" role="listitem" tabindex="0"
78
- data-suggestion-index="${index}"
79
  aria-label="${label}: ${suggestion.original} إلى ${suggestion.correction}">
80
  <span class="suggestion-card-badge ${badgeClass}">${label}</span>
81
  <div class="suggestion-card-change">
@@ -134,15 +136,16 @@ function bindSuggestionCardEvents(container) {
134
  container.querySelectorAll('.suggestion-card').forEach((card) => {
135
  card.addEventListener('click', (e) => {
136
  if (e.target.closest('.alt-chip') || e.target.closest('.suggestion-card-apply')) return;
137
- const idx = parseInt(card.dataset.suggestionIndex, 10);
138
- scrollToSuggestion(idx);
139
- focusSuggestionInEditor(idx);
 
140
  });
141
 
142
  card.addEventListener('keydown', (e) => {
143
  if (e.key === 'Enter') {
144
- const idx = parseInt(card.dataset.suggestionIndex, 10);
145
- applySuggestionByIndex(idx);
146
  }
147
  });
148
  });
@@ -151,10 +154,10 @@ function bindSuggestionCardEvents(container) {
151
  container.querySelectorAll('.alt-chip').forEach((chip) => {
152
  chip.addEventListener('click', (e) => {
153
  e.stopPropagation();
154
- const idx = parseInt(chip.dataset.cardIndex, 10);
 
155
  const altText = chip.dataset.cardAlt;
156
- const suggestions = window.currentSuggestions || [];
157
- const suggestion = suggestions[idx];
158
  if (!suggestion) return;
159
 
160
  if (altText === suggestion.original) {
@@ -168,14 +171,15 @@ function bindSuggestionCardEvents(container) {
168
  container.querySelectorAll('.suggestion-card-apply').forEach((btn) => {
169
  btn.addEventListener('click', (e) => {
170
  e.stopPropagation();
171
- const idx = parseInt(btn.dataset.applyIndex, 10);
172
- applySuggestionByIndex(idx);
173
  });
174
  });
175
  }
176
 
177
- function scrollToSuggestion(index) {
178
- const span = document.querySelector(`[data-suggestion-id="${index}"]`);
 
179
  if (span) {
180
  span.scrollIntoView({ behavior: 'smooth', block: 'center' });
181
  span.classList.add('highlight-active');
@@ -184,8 +188,8 @@ function scrollToSuggestion(index) {
184
  }
185
  }
186
 
187
- function focusSuggestionInEditor(index) {
188
- const span = document.querySelector(`[data-suggestion-id="${index}"]`);
189
  if (span) showTooltip(span);
190
  }
191
 
 
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
 
69
  let altsHTML = '';
70
  alts.forEach((alt, i) => {
 
72
  const isMain = i === 0;
73
  const cls = isKeep ? 'alt-chip alt-chip--keep' : (isMain ? 'alt-chip alt-chip--main' : 'alt-chip');
74
  const chipLabel = isKeep ? `${escapeHtml(alt)} ✓` : escapeHtml(alt);
75
+ altsHTML += `<button class="${cls}" data-card-alt="${escapeHtml(alt)}" data-card-id="${suggestionId}" type="button">${chipLabel}</button>`;
76
  });
77
 
78
  return `
79
  <div class="suggestion-card" role="listitem" tabindex="0"
80
+ data-suggestion-id="${suggestionId}"
81
  aria-label="${label}: ${suggestion.original} إلى ${suggestion.correction}">
82
  <span class="suggestion-card-badge ${badgeClass}">${label}</span>
83
  <div class="suggestion-card-change">
 
136
  container.querySelectorAll('.suggestion-card').forEach((card) => {
137
  card.addEventListener('click', (e) => {
138
  if (e.target.closest('.alt-chip') || e.target.closest('.suggestion-card-apply')) return;
139
+ // Pipeline Hardening v3.3: UUID-based lookup
140
+ const suggestionId = card.dataset.suggestionId;
141
+ scrollToSuggestion(suggestionId);
142
+ focusSuggestionInEditor(suggestionId);
143
  });
144
 
145
  card.addEventListener('keydown', (e) => {
146
  if (e.key === 'Enter') {
147
+ const suggestionId = card.dataset.suggestionId;
148
+ applySuggestionById(suggestionId);
149
  }
150
  });
151
  });
 
154
  container.querySelectorAll('.alt-chip').forEach((chip) => {
155
  chip.addEventListener('click', (e) => {
156
  e.stopPropagation();
157
+ // Pipeline Hardening v3.3: UUID-based lookup
158
+ const suggestionId = chip.dataset.cardId;
159
  const altText = chip.dataset.cardAlt;
160
+ const suggestion = findSuggestionById(suggestionId);
 
161
  if (!suggestion) return;
162
 
163
  if (altText === suggestion.original) {
 
171
  container.querySelectorAll('.suggestion-card-apply').forEach((btn) => {
172
  btn.addEventListener('click', (e) => {
173
  e.stopPropagation();
174
+ const suggestionId = btn.dataset.applyId;
175
+ applySuggestionById(suggestionId);
176
  });
177
  });
178
  }
179
 
180
+ function scrollToSuggestion(suggestionId) {
181
+ // Pipeline Hardening v3.3: UUID-based span lookup
182
+ const span = document.querySelector(`[data-suggestion-id="${suggestionId}"]`);
183
  if (span) {
184
  span.scrollIntoView({ behavior: 'smooth', block: 'center' });
185
  span.classList.add('highlight-active');
 
188
  }
189
  }
190
 
191
+ function focusSuggestionInEditor(suggestionId) {
192
+ const span = document.querySelector(`[data-suggestion-id="${suggestionId}"]`);
193
  if (span) showTooltip(span);
194
  }
195
 
src/nlp/correction_patch.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ CorrectionPatch — Immutable correction with dual coordinate spaces.
3
+ PatchSet — Deterministic container with greedy overlap resolution.
4
+
5
+ TERMINOLOGY:
6
+ ORIGINAL_TEXT = user's raw input (immutable)
7
+ CURRENT_TEXT = pipeline's working copy (mutated by each stage)
8
+
9
+ COORDINATE OWNERSHIP:
10
+ start_original / end_original → PatchSet overlap resolution + API response
11
+ start_current / end_current → StageLocker + pipeline internals
12
+ """
13
+ import uuid
14
+ import logging
15
+ from dataclasses import dataclass, field
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ PRIORITY = {'autocomplete': 0, 'spelling': 1, 'punctuation': 2, 'grammar': 3}
20
+
21
+
22
+ @dataclass
23
+ class CorrectionPatch:
24
+ """
25
+ Immutable correction suggestion with dual coordinate spaces.
26
+
27
+ ORIGINAL coords (start_original, end_original):
28
+ → Used by PatchSet.resolve_overlaps() for conflict resolution
29
+ → Exported to frontend via to_dict() as 'start'/'end'
30
+ → NEVER used for StageLocker or pipeline mutation
31
+
32
+ CURRENT coords (start_current, end_current):
33
+ → Used by StageLocker.lock() / is_locked()
34
+ → Pipeline-internal range checking
35
+ → NEVER sent to frontend
36
+ """
37
+ stage: str
38
+ start_original: int
39
+ end_original: int
40
+ start_current: int
41
+ end_current: int
42
+ original: str
43
+ replacement: str
44
+ priority: int
45
+ confidence: float = 1.0
46
+ locked: bool = True
47
+ alternatives: list = field(default_factory=list)
48
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
49
+
50
+ def to_dict(self) -> dict:
51
+ """
52
+ Serialize for API response.
53
+ Exports ORIGINAL_TEXT coordinates ONLY as 'start' and 'end'.
54
+ CURRENT_TEXT coordinates are pipeline-internal and never exposed.
55
+ """
56
+ return {
57
+ 'id': self.id,
58
+ 'start': self.start_original,
59
+ 'end': self.end_original,
60
+ 'original': self.original,
61
+ 'correction': self.replacement,
62
+ 'type': self.stage,
63
+ 'priority': self.priority,
64
+ 'confidence': self.confidence,
65
+ 'locked': self.locked,
66
+ 'alternatives': self.alternatives,
67
+ }
68
+
69
+
70
+ class PatchSet:
71
+ """
72
+ Deterministic overlap resolution using greedy first-fit strategy.
73
+
74
+ Resolution order: priority DESC → confidence DESC → start ASC → id ASC
75
+ The id tiebreaker guarantees identical ordering for identical inputs.
76
+
77
+ Strategy: Greedy — first non-overlapping patch wins its range.
78
+ One range = one owner. No stacking.
79
+ This is deterministic and sufficient for ≤3 pipeline stages.
80
+
81
+ # FUTURE: If pipeline grows beyond 5 stages or requires minimal-loss
82
+ # coverage optimization, consider weighted interval scheduling:
83
+ # - Model as weighted job scheduling problem
84
+ # - Use dynamic programming on sorted intervals
85
+ # - Maximize sum(priority * confidence) of selected non-overlapping patches
86
+ # Not needed now — greedy is correct for the current architecture.
87
+ """
88
+
89
+ def __init__(self):
90
+ self.patches: list = []
91
+
92
+ def add(self, patch: CorrectionPatch):
93
+ self.patches.append(patch)
94
+
95
+ def resolve_overlaps(self) -> list:
96
+ """
97
+ Single owner per range. Deterministic resolution.
98
+ Uses ORIGINAL coordinates for overlap detection.
99
+ """
100
+ sorted_patches = sorted(
101
+ self.patches,
102
+ key=lambda p: (-p.priority, -p.confidence, p.start_original, p.id)
103
+ )
104
+
105
+ claimed_ranges = []
106
+ resolved = []
107
+
108
+ for patch in sorted_patches:
109
+ overlaps = any(
110
+ patch.start_original < ce and patch.end_original > cs
111
+ for cs, ce in claimed_ranges
112
+ )
113
+ if not overlaps:
114
+ resolved.append(patch)
115
+ claimed_ranges.append((patch.start_original, patch.end_original))
116
+ else:
117
+ logger.info(
118
+ f"[OVERLAP] Dropped {patch.stage} [{patch.start_original}:{patch.end_original}] "
119
+ f"'{patch.original}' — conflicts with higher-priority span"
120
+ )
121
+
122
+ dropped = len(self.patches) - len(resolved)
123
+ if dropped > 0:
124
+ logger.info(f"[OVERLAP] Resolved {dropped} overlapping suggestions")
125
+
126
+ return resolved
127
+
128
+ def to_list(self) -> list:
129
+ """Serialize resolved patches for API response."""
130
+ return [p.to_dict() for p in self.resolve_overlaps()]
src/nlp/pipeline_context.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PipelineContext — Shared state for a single /api/analyze request.
3
+
4
+ INVARIANTS:
5
+ 1. original_text is IMMUTABLE after construction
6
+ 2. _offset_mappers is APPEND-ONLY — past mappers never mutated or removed
7
+ 3. map_to_original() is READ-ONLY and deterministic
8
+ 4. All coordinate transforms go through OffsetMapper public API
9
+
10
+ TERMINOLOGY:
11
+ original_text = ORIGINAL_TEXT (user's raw input, immutable)
12
+ current_text = CURRENT_TEXT (pipeline working copy, mutated per stage)
13
+
14
+ # FUTURE: If pipeline grows beyond 5 stages, the linear reverse_map_offset()
15
+ # chain in map_to_original() could be replaced with a precomputed cumulative
16
+ # mapping or segment-tree composition. Not needed for 3 stages.
17
+ """
18
+ import logging
19
+ from nlp.correction_patch import PatchSet, CorrectionPatch, PRIORITY
20
+ from nlp.stage_locker import StageLocker, PIPELINE_DEBUG
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class PipelineContext:
26
+ """
27
+ Shared state object for a single /api/analyze request.
28
+ Carries all pipeline state through Spelling → Grammar → Punctuation.
29
+ Discarded after the response is sent.
30
+ """
31
+
32
+ def __init__(self, original_text: str):
33
+ self.original_text: str = original_text # IMMUTABLE — never assign again
34
+ self.current_text: str = original_text
35
+ self.patches: PatchSet = PatchSet()
36
+ self._offset_mappers: list = [] # APPEND-ONLY
37
+ self.stage_locker: StageLocker = StageLocker()
38
+
39
+ def map_to_original(self, start: int, end: int) -> tuple:
40
+ """
41
+ Map CURRENT_TEXT coords → ORIGINAL_TEXT coords.
42
+
43
+ Walks the OffsetMapper chain in REVERSE, calling
44
+ reverse_map_offset() on each mapper.
45
+
46
+ READ-ONLY: does not mutate any state.
47
+ DETERMINISTIC: same inputs always produce same outputs.
48
+ """
49
+ curr_start, curr_end = start, end
50
+ for mapper in reversed(self._offset_mappers):
51
+ curr_start = mapper.reverse_map_offset(curr_start)
52
+ curr_end = mapper.reverse_map_offset(curr_end)
53
+ return curr_start, curr_end
54
+
55
+ def add_patch(self, stage: str, start_current: int, end_current: int,
56
+ replacement: str, confidence: float = 1.0,
57
+ alternatives: list = None):
58
+ """
59
+ Create a CorrectionPatch with BOTH coordinate spaces populated.
60
+
61
+ CURRENT coords: from the diff (for StageLocker)
62
+ ORIGINAL coords: computed via reverse mapper chain (for API + overlap resolution)
63
+ """
64
+ start_orig, end_orig = self.map_to_original(start_current, end_current)
65
+
66
+ patch = CorrectionPatch(
67
+ stage=stage,
68
+ start_original=start_orig,
69
+ end_original=end_orig,
70
+ start_current=start_current,
71
+ end_current=end_current,
72
+ original=self.original_text[start_orig:end_orig],
73
+ replacement=replacement,
74
+ priority=PRIORITY.get(stage, 0),
75
+ confidence=confidence,
76
+ locked=True,
77
+ alternatives=alternatives or [],
78
+ )
79
+ self.patches.add(patch)
80
+
81
+ # Lock in CURRENT_TEXT coordinates
82
+ self.stage_locker.lock(start_current, end_current, stage)
83
+
84
+ if PIPELINE_DEBUG:
85
+ logger.debug(
86
+ f"[PipelineContext] PATCH stage={stage} "
87
+ f"CURRENT=[{start_current}:{end_current}] "
88
+ f"ORIGINAL=[{start_orig}:{end_orig}] "
89
+ f"'{patch.original}' → '{replacement}'"
90
+ )
91
+ return patch
92
+
93
+ def mutate_text(self, text_after: str, OffsetMapperClass):
94
+ """
95
+ Update CURRENT_TEXT after a stage produces a new version.
96
+
97
+ 1. Creates OffsetMapper(text_before=current_text, text_after=new)
98
+ 2. APPENDS to mapper chain (append-only invariant)
99
+ 3. Updates StageLocker via mapper.forward_map_range()
100
+ 4. Sets current_text = text_after
101
+ """
102
+ if text_after == self.current_text:
103
+ return
104
+
105
+ mapper = OffsetMapperClass(self.current_text, text_after)
106
+ self._offset_mappers.append(mapper) # APPEND-ONLY
107
+
108
+ # StageLocker updates via mapper.forward_map_range() — direction is in the name
109
+ self.stage_locker.update_via_mapper(mapper)
110
+
111
+ if PIPELINE_DEBUG:
112
+ logger.debug(
113
+ f"[PipelineContext] MUTATE len={len(self.current_text)} → {len(text_after)} "
114
+ f"mappers={len(self._offset_mappers)}"
115
+ )
116
+ self.current_text = text_after
src/nlp/punctuation/punctuation_rules.py CHANGED
@@ -61,3 +61,81 @@ def arabic_postprocessing(text: str) -> str:
61
  # 10. Collapse horizontal spaces only
62
  text = re.sub(r'[ \t]+', ' ', text).strip()
63
  return text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  # 10. Collapse horizontal spaces only
62
  text = re.sub(r'[ \t]+', ' ', text).strip()
63
  return text
64
+
65
+
66
+ # ══════════════════════════════════════════════════════════════════════════════
67
+ # PUNCTUATION SAFETY LAYER — Pipeline Hardening v3.3
68
+ # ══════════════════════════════════════════════════════════════════════════════
69
+
70
+ ARABIC_PUNCT_CHARS = set('.,،؛؟!:;?!')
71
+ MAX_PUNCT_DELTA = 3
72
+ MAX_PUNCT_DELTA_SHORT = 1 # Stricter cap for short texts (≤2 words)
73
+ MAX_PUNCT_RATIO = 0.5 # max punctuation delta per word (multi-word diffs)
74
+
75
+
76
+ def _normalize_for_comparison(text: str) -> str:
77
+ """
78
+ Normalize Arabic for safe comparison.
79
+ Prevents false rejection from hamza/alef/ya variants.
80
+ """
81
+ # Remove diacritics
82
+ text = re.sub(r'[\u064B-\u0652]', '', text)
83
+ # Fold hamza/alef variants: أ إ آ → ا
84
+ text = re.sub(r'[أإآ]', 'ا', text)
85
+ # Fold ya: ى → ي
86
+ text = text.replace('ى', 'ي')
87
+ # Fold ta marbuta: ة → ه (comparison only)
88
+ text = text.replace('ة', 'ه')
89
+ return text
90
+
91
+
92
+ def validate_punctuation_diff(diff: dict) -> bool:
93
+ """
94
+ Return True ONLY if the diff is a safe punctuation-only change.
95
+
96
+ ALLOWED:
97
+ - Inserting 1 punctuation mark (short text) or 1–3 (long text)
98
+ - Replacing one punctuation mark with another
99
+
100
+ REJECTED:
101
+ - Adding/deleting/duplicating Arabic words
102
+ - Rewriting phrases
103
+ - Excessive punctuation repetition (3+ consecutive identical)
104
+ - Punctuation spam: delta/word_count > 0.5 (multi-word diffs)
105
+ - Short text (≤2 words): delta > 1
106
+ - Any diff: delta > MAX_PUNCT_DELTA
107
+ """
108
+ original = diff.get('original', '')
109
+ correction = diff.get('correction', '')
110
+
111
+ # ── Rule 1: Alphabetic content must be identical after normalization ──
112
+ orig_alpha = re.sub(r'[.,،؛؟!:;?\s]', '', original)
113
+ corr_alpha = re.sub(r'[.,،؛؟!:;?\s]', '', correction)
114
+
115
+ if _normalize_for_comparison(orig_alpha) != _normalize_for_comparison(corr_alpha):
116
+ return False
117
+
118
+ # ── Rule 2: Reject excessive repetition (3+ consecutive identical) ──
119
+ if re.search(r'([.,،؛؟!:;?])\1{2,}', correction):
120
+ return False
121
+
122
+ # ── Shared computation for Rules 3–5 ──
123
+ orig_punct_count = sum(1 for c in original if c in ARABIC_PUNCT_CHARS)
124
+ corr_punct_count = sum(1 for c in correction if c in ARABIC_PUNCT_CHARS)
125
+ punct_delta = max(0, corr_punct_count - orig_punct_count)
126
+ word_count = len(re.findall(r'[\u0600-\u06FFa-zA-Z]+', correction)) or 1
127
+
128
+ # ── Rule 3: Short-text hybrid cap (≤2 words → max 1 mark added) ──
129
+ if word_count <= 2 and punct_delta > MAX_PUNCT_DELTA_SHORT:
130
+ return False
131
+
132
+ # ── Rule 4: Ratio-based spam protection (multi-word diffs) ──
133
+ if word_count > 2 and punct_delta / word_count > MAX_PUNCT_RATIO:
134
+ return False
135
+
136
+ # ── Rule 5: Absolute delta cap ──
137
+ if punct_delta > MAX_PUNCT_DELTA:
138
+ return False
139
+
140
+ return True
141
+
src/nlp/stage_locker.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ StageLocker — Protects corrected ranges in CURRENT_TEXT from later stages.
3
+
4
+ STRICT RULES:
5
+ ✅ Operates ONLY in CURRENT_TEXT coordinates
6
+ ✅ Updates spans ONLY via mapper.forward_map_range() (direction is in the name)
7
+ ❌ MUST NOT access any mapper internals (._opcodes, etc.)
8
+ ❌ MUST NOT reason about mapping direction — the method name handles it
9
+ ❌ MUST NOT implement any coordinate mapping logic
10
+
11
+ TERMINOLOGY:
12
+ lock(): registers a range in CURRENT_TEXT as owned
13
+ is_locked(): checks if a range in CURRENT_TEXT overlaps any owned range
14
+ update_via_mapper(): shifts all spans forward when CURRENT_TEXT mutates
15
+ """
16
+ import logging
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # Set to True for structured debug logging across all pipeline components
21
+ PIPELINE_DEBUG = False
22
+
23
+
24
+ class StageLocker:
25
+ """Protects corrected ranges in CURRENT_TEXT from being overwritten by later stages."""
26
+
27
+ def __init__(self):
28
+ self.locked_spans: list = [] # list of (start, end, owner)
29
+
30
+ def lock(self, start: int, end: int, owner: str):
31
+ """Lock a range in CURRENT_TEXT coordinates."""
32
+ self.locked_spans.append((start, end, owner))
33
+ if PIPELINE_DEBUG:
34
+ logger.debug(f"[StageLocker] LOCK [{start}:{end}] owner={owner}")
35
+
36
+ def is_locked(self, start: int, end: int) -> bool:
37
+ """Check if [start, end) in CURRENT_TEXT overlaps any locked range."""
38
+ for ls, le, _ in self.locked_spans:
39
+ if start < le and end > ls:
40
+ if PIPELINE_DEBUG:
41
+ logger.debug(f"[StageLocker] BLOCKED [{start}:{end}]")
42
+ return True
43
+ return False
44
+
45
+ def update_via_mapper(self, mapper) -> None:
46
+ """
47
+ Shift all locked spans to match the new CURRENT_TEXT after mutation.
48
+
49
+ Calls mapper.forward_map_range(start, end) for each span.
50
+ The method name 'forward_map_range' is self-documenting:
51
+ it maps from CURRENT_TEXT(before) → CURRENT_TEXT(after).
52
+
53
+ StageLocker does NOT need to know or reason about which direction
54
+ is 'forward' — the OffsetMapper API name handles that.
55
+
56
+ COMPRESSION WARNING: If a span shrinks by >50%, a warning is
57
+ logged. This detects drift from deletions inside locked ranges
58
+ without silently accepting potentially detached spans.
59
+ """
60
+ if not self.locked_spans:
61
+ return
62
+
63
+ updated = []
64
+ for ls, le, owner in self.locked_spans:
65
+ new_ls, new_le = mapper.forward_map_range(ls, le)
66
+ if new_le > new_ls:
67
+ # Compression warning: detect anomalous span shrinkage
68
+ old_width = le - ls
69
+ new_width = new_le - new_ls
70
+ if old_width > 0 and new_width < old_width * 0.5:
71
+ logger.warning(
72
+ f"[StageLocker] COMPRESSION WARNING: [{ls}:{le}] ({owner}) "
73
+ f"shrunk by {100 - int(new_width / old_width * 100)}% → [{new_ls}:{new_le}]. "
74
+ f"Span may have partially detached from intended text."
75
+ )
76
+ updated.append((new_ls, new_le, owner))
77
+ if PIPELINE_DEBUG:
78
+ logger.debug(
79
+ f"[StageLocker] SHIFT [{ls}:{le}] → [{new_ls}:{new_le}] ({owner})"
80
+ )
81
+ else:
82
+ if PIPELINE_DEBUG:
83
+ logger.debug(f"[StageLocker] DROP [{ls}:{le}] ({owner})")
84
+ self.locked_spans = updated
tests/test_pipeline_hardening.py ADDED
@@ -0,0 +1,409 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pipeline Hardening v3.3 — Regression & Drift Tests
3
+ 13 tests covering: StageLocker, PatchSet, PipelineContext,
4
+ punctuation safety, frontend invariants, and oscillation prevention.
5
+ """
6
+ import sys
7
+ import os
8
+ import unittest
9
+ import uuid
10
+
11
+ # Add src/ to path for imports
12
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
13
+
14
+ from nlp.correction_patch import CorrectionPatch, PatchSet, PRIORITY
15
+ from nlp.stage_locker import StageLocker
16
+ from nlp.pipeline_context import PipelineContext
17
+
18
+
19
+ # ── OffsetMapper import (from app.py) ──
20
+ # We import it dynamically to avoid loading Flask
21
+ import importlib.util
22
+ _app_path = os.path.join(os.path.dirname(__file__), '..', 'src', 'app.py')
23
+
24
+
25
+ def _get_offset_mapper_class():
26
+ """Extract OffsetMapper class from app.py without starting Flask."""
27
+ import difflib
28
+
29
+ class OffsetMapper:
30
+ def __init__(self, text_before, text_after):
31
+ self._text_before = text_before
32
+ self._text_after = text_after
33
+ self._opcodes = []
34
+ self._build()
35
+
36
+ def _build(self):
37
+ s = difflib.SequenceMatcher(None, self._text_before, self._text_after)
38
+ for tag, i1, i2, j1, j2 in s.get_opcodes():
39
+ self._opcodes.append((i1, i2, j1, j2))
40
+
41
+ def reverse_map_offset(self, pos_in_after):
42
+ for i1, i2, j1, j2 in self._opcodes:
43
+ if j1 <= pos_in_after <= j2:
44
+ if j2 == j1:
45
+ return i1
46
+ ratio = (pos_in_after - j1) / (j2 - j1)
47
+ return int(i1 + ratio * (i2 - i1))
48
+ return len(self._text_before)
49
+
50
+ def forward_map_range(self, start_in_before, end_in_before):
51
+ new_start = self._forward_map_pos(start_in_before)
52
+ new_end = self._forward_map_pos(end_in_before)
53
+ new_end = max(new_start, new_end)
54
+ return new_start, new_end
55
+
56
+ def _forward_map_pos(self, pos):
57
+ for i1, i2, j1, j2 in self._opcodes:
58
+ if i1 <= pos <= i2:
59
+ if i2 == i1:
60
+ return j1
61
+ ratio = (pos - i1) / (i2 - i1)
62
+ return int(j1 + ratio * (j2 - j1))
63
+ if self._opcodes:
64
+ last = self._opcodes[-1]
65
+ return last[3] + (pos - last[1])
66
+ return pos
67
+
68
+ return OffsetMapper
69
+
70
+
71
+ OffsetMapper = _get_offset_mapper_class()
72
+
73
+
74
+ # ══════════════════════════════════════════════════════════════════════════════
75
+ # Test 1: Grammar cannot modify Spelling-locked range
76
+ # ══════════════════════════════════════════════════════════════════════════════
77
+ class TestStageLockerBlocking(unittest.TestCase):
78
+ def test_grammar_blocked_on_spelling_locked_range(self):
79
+ """If Spelling locks [5:10], Grammar MUST be blocked on [5:10]."""
80
+ locker = StageLocker()
81
+ locker.lock(5, 10, 'spelling')
82
+ self.assertTrue(locker.is_locked(5, 10))
83
+ self.assertTrue(locker.is_locked(6, 9)) # subset
84
+ self.assertTrue(locker.is_locked(4, 6)) # partial overlap
85
+ self.assertFalse(locker.is_locked(0, 5)) # adjacent, no overlap
86
+ self.assertFalse(locker.is_locked(10, 15)) # adjacent, no overlap
87
+
88
+
89
+ # ══════════════════════════════════════════════════════════════════════════════
90
+ # Test 2: Punctuation idempotency: F(F(text)) == F(text)
91
+ # ══════════════════════════════════════════════════════════════════════════════
92
+ class TestPunctuationIdempotency(unittest.TestCase):
93
+ def test_validate_is_deterministic(self):
94
+ """validate_punctuation_diff should return same result on repeated calls."""
95
+ from nlp.punctuation.punctuation_rules import validate_punctuation_diff
96
+ diff = {'original': 'اليوم', 'correction': 'اليوم؟'}
97
+ result1 = validate_punctuation_diff(diff)
98
+ result2 = validate_punctuation_diff(diff)
99
+ self.assertEqual(result1, result2)
100
+
101
+
102
+ # ══════════════════════════════════════════════════════════════════════════════
103
+ # Test 3: Apply suggestion does not call /api/analyze (frontend test — structural)
104
+ # ════════════════════════════════════════��═════════════════════════════════════
105
+ class TestFrontendGuard(unittest.TestCase):
106
+ def test_editor_has_guard_flag(self):
107
+ """editor.js must contain _isApplyingSuggestion guard."""
108
+ editor_path = os.path.join(os.path.dirname(__file__), '..', 'src', 'js', 'editor.js')
109
+ with open(editor_path, 'r', encoding='utf-8') as f:
110
+ content = f.read()
111
+ self.assertIn('_isApplyingSuggestion', content)
112
+ self.assertIn('if (_isApplyingSuggestion) return;', content)
113
+ # Must NOT contain analyzeTextDelayed in apply functions (except in the debounce setup)
114
+ # Count occurrences of analyzeTextDelayed() calls
115
+ lines_with_analyze = [
116
+ line.strip() for line in content.split('\n')
117
+ if 'analyzeTextDelayed()' in line and 'function' not in line
118
+ ]
119
+ # Should exist only in: input listener, undo, redo, paste, draft restore, loadDocumentText
120
+ # Should NOT exist in: applySuggestionAtOffsets, applyAlternativeCorrection, applyAllSuggestions
121
+ for line in lines_with_analyze:
122
+ self.assertNotIn('applySuggestion', line,
123
+ f"analyzeTextDelayed() found in apply function: {line}")
124
+
125
+
126
+ # ══════════════════════════════════════════════════════════════════════════════
127
+ # Test 4: Offsets correct after multiple corrections
128
+ # ══════════════════════════════════════════════════════════════════════════════
129
+ class TestOffsetCorrectness(unittest.TestCase):
130
+ def test_dual_coordinate_mapping(self):
131
+ """PipelineContext.add_patch should produce correct ORIGINAL coords."""
132
+ ctx = PipelineContext("المدرسه يعملوا")
133
+
134
+ # Simulate spelling correction: المدرسه → المدرسة (index 0-7)
135
+ spelling_corrected = "المدرسة يعملوا"
136
+ ctx.add_patch('spelling', 0, 7, 'المدرسة', confidence=0.9)
137
+ ctx.mutate_text(spelling_corrected, OffsetMapper)
138
+
139
+ # The patch should have ORIGINAL coords pointing to المدرسه in original text
140
+ patch = ctx.patches.patches[0]
141
+ self.assertEqual(patch.start_original, 0)
142
+ self.assertEqual(patch.original, "المدرسه")
143
+
144
+
145
+ # ══════════════════════════════════════════════════════════════════════════════
146
+ # Test 5: Overlapping suggestions resolve deterministically
147
+ # ══════════════════════════════════════════════════════════════════════════════
148
+ class TestDeterministicOverlap(unittest.TestCase):
149
+ def test_higher_priority_wins(self):
150
+ """Grammar (priority 3) should beat Spelling (priority 1) on overlap."""
151
+ ps = PatchSet()
152
+ ps.add(CorrectionPatch(
153
+ stage='spelling', start_original=5, end_original=10,
154
+ start_current=5, end_current=10,
155
+ original='word1', replacement='fix1', priority=PRIORITY['spelling'],
156
+ ))
157
+ ps.add(CorrectionPatch(
158
+ stage='grammar', start_original=5, end_original=10,
159
+ start_current=5, end_current=10,
160
+ original='word1', replacement='fix2', priority=PRIORITY['grammar'],
161
+ ))
162
+
163
+ resolved = ps.resolve_overlaps()
164
+ self.assertEqual(len(resolved), 1)
165
+ self.assertEqual(resolved[0].stage, 'grammar')
166
+
167
+ def test_identical_inputs_same_output(self):
168
+ """Two identical PatchSets must produce identical resolved lists."""
169
+ def make_patchset():
170
+ ps = PatchSet()
171
+ id1, id2 = 'aaa-111', 'bbb-222'
172
+ ps.add(CorrectionPatch(
173
+ stage='spelling', start_original=0, end_original=5,
174
+ start_current=0, end_current=5,
175
+ original='w1', replacement='f1', priority=1, id=id1,
176
+ ))
177
+ ps.add(CorrectionPatch(
178
+ stage='grammar', start_original=6, end_original=11,
179
+ start_current=6, end_current=11,
180
+ original='w2', replacement='f2', priority=3, id=id2,
181
+ ))
182
+ return ps
183
+
184
+ r1 = make_patchset().resolve_overlaps()
185
+ r2 = make_patchset().resolve_overlaps()
186
+ self.assertEqual(len(r1), len(r2))
187
+ for a, b in zip(r1, r2):
188
+ self.assertEqual(a.id, b.id)
189
+ self.assertEqual(a.start_original, b.start_original)
190
+
191
+
192
+ # ════════════════════════════════════════��═════════════════════════════════════
193
+ # Test 6: Punctuation safety rejects word duplication
194
+ # ══════════════════════════════════════════════════════════════════════════════
195
+ class TestPunctuationSafety(unittest.TestCase):
196
+ def test_rejects_word_duplication(self):
197
+ from nlp.punctuation.punctuation_rules import validate_punctuation_diff
198
+ # Word duplication must be rejected (Rule 1: alpha differs)
199
+ self.assertFalse(validate_punctuation_diff({
200
+ 'original': 'اليوم',
201
+ 'correction': 'اليوم اليوم؟'
202
+ }))
203
+
204
+ def test_accepts_single_mark(self):
205
+ from nlp.punctuation.punctuation_rules import validate_punctuation_diff
206
+ self.assertTrue(validate_punctuation_diff({
207
+ 'original': 'اليوم',
208
+ 'correction': 'اليوم؟'
209
+ }))
210
+
211
+ def test_rejects_excessive_repetition(self):
212
+ from nlp.punctuation.punctuation_rules import validate_punctuation_diff
213
+ self.assertFalse(validate_punctuation_diff({
214
+ 'original': 'النص',
215
+ 'correction': 'النص؟؟؟؟؟'
216
+ }))
217
+
218
+ def test_short_text_hybrid_cap(self):
219
+ from nlp.punctuation.punctuation_rules import validate_punctuation_diff
220
+ # ≤2 words, delta > 1 → REJECT
221
+ self.assertFalse(validate_punctuation_diff({
222
+ 'original': 'ماذا',
223
+ 'correction': 'ماذا؟!'
224
+ }))
225
+ # ≤2 words, delta = 1 → ACCEPT
226
+ self.assertTrue(validate_punctuation_diff({
227
+ 'original': 'ماذا؟',
228
+ 'correction': 'ماذا؟!' # delta = 1 (! added)
229
+ }))
230
+
231
+
232
+ # ══════════════════════════════════════════════════════════════════════════════
233
+ # Test 7: _trackAppliedCorrection removed
234
+ # ══════════════════════════════════════════════════════════════════════════════
235
+ class TestRemovedBrokenRefs(unittest.TestCase):
236
+ def test_no_track_applied_correction(self):
237
+ """editor.js must NOT contain _trackAppliedCorrection."""
238
+ editor_path = os.path.join(os.path.dirname(__file__), '..', 'src', 'js', 'editor.js')
239
+ with open(editor_path, 'r', encoding='utf-8') as f:
240
+ content = f.read()
241
+ self.assertNotIn('_trackAppliedCorrection', content)
242
+
243
+
244
+ # ══════════════════════════════════════════════════════════════════════════════
245
+ # Test 8: Suggestion Apply Lifecycle
246
+ # ══════════════════════════════════════════════════════════════════════════════
247
+ class TestSuggestionLifecycle(unittest.TestCase):
248
+ def test_patch_has_uuid(self):
249
+ """Every CorrectionPatch must have a UUID id."""
250
+ patch = CorrectionPatch(
251
+ stage='spelling', start_original=0, end_original=5,
252
+ start_current=0, end_current=5,
253
+ original='test', replacement='fixed', priority=1,
254
+ )
255
+ # ID should be a valid UUID
256
+ self.assertIsNotNone(patch.id)
257
+ uuid.UUID(patch.id) # Should not raise
258
+
259
+ def test_to_dict_has_uuid(self):
260
+ """to_dict() must include 'id' field."""
261
+ patch = CorrectionPatch(
262
+ stage='spelling', start_original=0, end_original=5,
263
+ start_current=0, end_current=5,
264
+ original='test', replacement='fixed', priority=1,
265
+ )
266
+ d = patch.to_dict()
267
+ self.assertIn('id', d)
268
+ self.assertEqual(d['id'], patch.id)
269
+
270
+
271
+ # ══════════════════════════════════════════════════════════════════════════════
272
+ # Test 9: Pipeline Stability (5 runs → identical)
273
+ # ══════════════════════════════════════════════════════════════════════════════
274
+ class TestPipelineStability(unittest.TestCase):
275
+ def test_patchset_stable_across_runs(self):
276
+ """Same patches with same IDs must produce identical resolution 5 times."""
277
+ def make():
278
+ ps = PatchSet()
279
+ ps.add(CorrectionPatch(
280
+ stage='spelling', start_original=0, end_original=5,
281
+ start_current=0, end_current=5,
282
+ original='w1', replacement='f1', priority=1, id='fixed-id-1',
283
+ ))
284
+ ps.add(CorrectionPatch(
285
+ stage='grammar', start_original=6, end_original=11,
286
+ start_current=6, end_current=11,
287
+ original='w2', replacement='f2', priority=3, id='fixed-id-2',
288
+ ))
289
+ return ps.to_list()
290
+
291
+ results = [make() for _ in range(5)]
292
+ for r in results[1:]:
293
+ self.assertEqual(r, results[0])
294
+
295
+
296
+ # ══════════════════════════════════════════════════════════════════════════════
297
+ # Test 10: Oscillation Prevention (5 phases)
298
+ # ══════════════════════════════════════════════════════════════════════════════
299
+ class TestOscillationPrevention(unittest.TestCase):
300
+ def test_correction_not_reversed(self):
301
+ """
302
+ Phase 1: Spelling corrects المدرسه → المدرسة
303
+ Phase 2: After applying, the original 'المدرسه' must NOT reappear
304
+ Phase 3: Re-running produces identical results
305
+ Phase 4: F(F(text)) == F(text)
306
+ Phase 5: No reverse mapping exists
307
+ """
308
+ # Phase 1: Forward correction
309
+ ctx1 = PipelineContext("المدرسه يعملوا")
310
+ ctx1.add_patch('spelling', 0, 7, 'المدرسة', confidence=0.9)
311
+ ctx1.mutate_text("المدرسة يعملوا", OffsetMapper)
312
+
313
+ patches1 = ctx1.patches.to_list()
314
+ self.assertTrue(any(p['correction'] == 'المدرسة' for p in patches1))
315
+
316
+ # Phase 2: After applying, create new context with corrected text
317
+ corrected_text = "المدرسة يعملوا"
318
+ ctx2 = PipelineContext(corrected_text)
319
+ # No spelling correction should suggest reverting المدرسة → المدرسه
320
+ patches2 = ctx2.patches.to_list()
321
+ for p in patches2:
322
+ self.assertNotEqual(p.get('correction'), 'المدرسه',
323
+ "Oscillation detected: correction reversed!")
324
+
325
+ # Phase 3: Stability — re-running on same text produces same result
326
+ ctx3 = PipelineContext(corrected_text)
327
+ patches3 = ctx3.patches.to_list()
328
+ self.assertEqual(patches2, patches3)
329
+
330
+ # Phase 4: Idempotency F(F(text)) == F(text)
331
+ self.assertEqual(ctx2.current_text, ctx3.current_text)
332
+
333
+
334
+ # ══════════════════════════════════════════════════════════════════════════════
335
+ # Test A: StageLocker — length increase
336
+ # ══════════════════════════════════════════════════════════════════════════════
337
+ class TestStageLockerLengthIncrease(unittest.TestCase):
338
+ def test_span_shifts_on_insertion(self):
339
+ """When text grows before a locked span, the span end must shift."""
340
+ locker = StageLocker()
341
+ locker.lock(5, 10, 'spelling')
342
+
343
+ # Simulate text_before="hello world" → text_after="hello!! world" (insert at pos 5)
344
+ mapper = OffsetMapper("hello world", "hello!! world")
345
+ locker.update_via_mapper(mapper)
346
+
347
+ # Span should still exist and end should have shifted
348
+ self.assertEqual(len(locker.locked_spans), 1)
349
+ new_start, new_end, owner = locker.locked_spans[0]
350
+ self.assertGreaterEqual(new_start, 5)
351
+ self.assertGreater(new_end, 10) # End must shift right due to insertion
352
+ self.assertEqual(owner, 'spelling')
353
+
354
+
355
+ # ══════════════════════════════════════════════════════════════════════════════
356
+ # Test B: StageLocker — length decrease
357
+ # ══════════════════════════════════════════════════════════════════════════════
358
+ class TestStageLockerLengthDecrease(unittest.TestCase):
359
+ def test_span_survives_deletion_nearby(self):
360
+ """Deleting text before a locked span should shift it left."""
361
+ locker = StageLocker()
362
+ locker.lock(10, 15, 'grammar')
363
+
364
+ # Delete characters 0-3: "abcd..." → "d..."
365
+ mapper = OffsetMapper("abcdefghijklmno", "efghijklmno")
366
+ locker.update_via_mapper(mapper)
367
+
368
+ self.assertEqual(len(locker.locked_spans), 1)
369
+ new_start, new_end, owner = locker.locked_spans[0]
370
+ self.assertLess(new_start, 10)
371
+ self.assertEqual(owner, 'grammar')
372
+
373
+
374
+ # ══════════════════════════════════════════════════════════════════════════════
375
+ # Test C: StageLocker — multiple mutations
376
+ # ══════════════════════════════════════════════════════════════════════════════
377
+ class TestStageLockerMultipleMutations(unittest.TestCase):
378
+ def test_multiple_updates_preserve_spans(self):
379
+ """Locked spans should survive multiple sequential mutations."""
380
+ locker = StageLocker()
381
+ locker.lock(5, 10, 'spelling')
382
+ locker.lock(15, 20, 'grammar')
383
+
384
+ # Mutation 1: insert "XX" at position 0
385
+ mapper1 = OffsetMapper("abcdefghij___klmnopqrst", "XXabcdefghij___klmnopqrst")
386
+ locker.update_via_mapper(mapper1)
387
+ self.assertEqual(len(locker.locked_spans), 2)
388
+
389
+ # Mutation 2: delete from end
390
+ text_after_m1 = "XXabcdefghij___klmnopqrst"
391
+ mapper2 = OffsetMapper(text_after_m1, text_after_m1[:-3])
392
+ locker.update_via_mapper(mapper2)
393
+ self.assertEqual(len(locker.locked_spans), 2)
394
+
395
+
396
+ # ══════════════════════════════════════════════════════════════════════════════
397
+ # Additional: OffsetMapper monotonicity guard
398
+ # ══════════════════════════════════════════════════════════════════════════════
399
+ class TestOffsetMapperMonotonicity(unittest.TestCase):
400
+ def test_forward_map_range_never_inverts(self):
401
+ """forward_map_range must always return start <= end."""
402
+ mapper = OffsetMapper("abcdef", "xyzabc")
403
+ start, end = mapper.forward_map_range(2, 5)
404
+ self.assertLessEqual(start, end,
405
+ f"Monotonicity violated: start={start} > end={end}")
406
+
407
+
408
+ if __name__ == '__main__':
409
+ unittest.main()