youssefreda9 commited on
Commit
63587df
·
1 Parent(s): 0f10098

fix: Proper grammar suggestion generation — keep spelling suggestions, add grammar on top

Browse files

- Keep spelling suggestions as-is (word-by-word, always correct)
- Build word-index map from corrected text back to original text
- Grammar diffs: spelling-corrected vs grammar-corrected (word-by-word)
- Map grammar positions back to original text via word-index map
- Skip grammar suggestions that overlap with spelling suggestions
- Remove broken unified diff approach that caused wrong alignments

Files changed (1) hide show
  1. src/app.py +91 -91
src/app.py CHANGED
@@ -849,13 +849,45 @@ def analyze_text():
849
  # Track which original words were changed by spelling
850
  for s in suggestions:
851
  spelling_changed_originals.add(s['original'])
852
- mappers.append(OffsetMapper(current_text, safe_text))
853
  current_text = safe_text
854
  except Exception as e:
855
  logger.error(f"[ANALYZE] Spelling failed: {e}")
856
 
857
- # Save the spelling-only suggestions and the text after spelling
858
- spelling_suggestions = list(suggestions)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
859
 
860
  # 2. Grammar (runs on spelling-corrected text)
861
  has_grammar = True
@@ -867,6 +899,62 @@ def analyze_text():
867
  corrected_grammar = correct_grammar(current_text)
868
  logger.info(f"[ANALYZE] Step 2: Grammar done in {time.time()-t0:.2f}s")
869
  if corrected_grammar != current_text:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
870
  current_text = corrected_grammar
871
  except Exception as e:
872
  logger.error(f"[ANALYZE] Grammar failed: {e}")
@@ -887,94 +975,6 @@ def analyze_text():
887
  except Exception as e:
888
  logger.error(f"[ANALYZE] Punctuation failed: {e}")
889
 
890
- # === UNIFIED DIFF: original text vs final text ===
891
- # This produces ONE clean set of non-overlapping suggestions
892
- final_text = current_text
893
- suggestions = [] # rebuild from scratch
894
-
895
- if final_text != text:
896
- # Get word-level diffs between original and final
897
- orig_words = text.split()
898
- final_words = final_text.split()
899
-
900
- # Build position map for original words
901
- orig_positions = []
902
- pos = 0
903
- for w in orig_words:
904
- start = text.index(w, pos)
905
- orig_positions.append((w, start, start + len(w)))
906
- pos = start + len(w)
907
-
908
- # Use SequenceMatcher on word lists
909
- sm = SequenceMatcher(None, orig_words, final_words)
910
- for tag, i1, i2, j1, j2 in sm.get_opcodes():
911
- if tag == 'equal':
912
- continue
913
- elif tag == 'replace':
914
- # Words i1..i2 in original → words j1..j2 in final
915
- o_segment = orig_words[i1:i2]
916
- c_segment = final_words[j1:j2]
917
-
918
- start_idx = orig_positions[i1][1]
919
- end_idx = orig_positions[i2-1][2]
920
- original_span = text[start_idx:end_idx]
921
- correction_span = " ".join(c_segment)
922
-
923
- if original_span == correction_span:
924
- continue
925
-
926
- # Determine type: if any word in spelling_changed_originals, it's spelling
927
- # Otherwise it's grammar
928
- is_spelling = any(w in spelling_changed_originals for w in o_segment)
929
-
930
- # For spelling suggestions, try to process word-by-word
931
- if is_spelling and len(o_segment) == 1 and len(c_segment) == 1:
932
- o_word = o_segment[0]
933
- c_word = c_segment[0]
934
- if _is_small_spelling_change(o_word, c_word):
935
- suggestions.append({
936
- 'start': start_idx,
937
- 'end': end_idx,
938
- 'original': o_word,
939
- 'correction': c_word,
940
- 'type': 'spelling',
941
- 'alternatives': _get_spelling_alternatives(o_word, c_word, spell_checker) if spell_checker else [c_word, o_word],
942
- })
943
- else:
944
- suggestions.append({
945
- 'start': start_idx,
946
- 'end': end_idx,
947
- 'original': original_span,
948
- 'correction': correction_span,
949
- 'type': 'grammar',
950
- })
951
- elif is_spelling and len(o_segment) == 1 and len(c_segment) > 1:
952
- # Word split
953
- suggestions.append({
954
- 'start': start_idx,
955
- 'end': end_idx,
956
- 'original': original_span,
957
- 'correction': correction_span,
958
- 'type': 'spelling',
959
- 'alternatives': [correction_span, original_span],
960
- })
961
- else:
962
- # Grammar or multi-word change
963
- stype = 'spelling' if is_spelling else 'grammar'
964
- suggestions.append({
965
- 'start': start_idx,
966
- 'end': end_idx,
967
- 'original': original_span,
968
- 'correction': correction_span,
969
- 'type': stype,
970
- })
971
- elif tag == 'delete':
972
- # Words deleted — skip (don't suggest deleting words)
973
- continue
974
- elif tag == 'insert':
975
- # Words inserted — skip for now
976
- continue
977
-
978
  total_time = time.time() - total_start
979
  logger.info(f"[ANALYZE] Total analysis time: {total_time:.2f}s | Suggestions: {len(suggestions)}")
980
 
 
849
  # Track which original words were changed by spelling
850
  for s in suggestions:
851
  spelling_changed_originals.add(s['original'])
 
852
  current_text = safe_text
853
  except Exception as e:
854
  logger.error(f"[ANALYZE] Spelling failed: {e}")
855
 
856
+ # Build word position map for original text (needed for grammar mapping)
857
+ orig_word_positions = get_word_positions(text)
858
+
859
+ # Build mapping: corrected_text word index → original_text word index
860
+ # This handles word splits (1 orig word → N corrected words)
861
+ spelling_word_map = [] # spelling_word_map[j] = original word index
862
+ orig_words_list = text.split()
863
+ corrected_words_list = current_text.split()
864
+
865
+ if current_text != text:
866
+ sm_map = SequenceMatcher(None, orig_words_list, corrected_words_list)
867
+ for tag, i1, i2, j1, j2 in sm_map.get_opcodes():
868
+ if tag == 'equal':
869
+ for k in range(j1, j2):
870
+ spelling_word_map.append(i1 + (k - j1))
871
+ elif tag == 'replace':
872
+ # Map corrected words proportionally to original words
873
+ o_count = i2 - i1
874
+ c_count = j2 - j1
875
+ for k in range(j1, j2):
876
+ # Map proportionally
877
+ orig_idx = i1 + min(int((k - j1) * o_count / c_count), o_count - 1)
878
+ spelling_word_map.append(orig_idx)
879
+ elif tag == 'insert':
880
+ for k in range(j1, j2):
881
+ # Inserted words map to the nearest original word
882
+ spelling_word_map.append(min(i1, len(orig_words_list) - 1))
883
+ elif tag == 'delete':
884
+ pass # deleted original words have no corrected counterpart
885
+ else:
886
+ # No spelling changes — 1:1 mapping
887
+ spelling_word_map = list(range(len(orig_words_list)))
888
+
889
+ # Save text after spelling for grammar diff
890
+ text_after_spelling = current_text
891
 
892
  # 2. Grammar (runs on spelling-corrected text)
893
  has_grammar = True
 
899
  corrected_grammar = correct_grammar(current_text)
900
  logger.info(f"[ANALYZE] Step 2: Grammar done in {time.time()-t0:.2f}s")
901
  if corrected_grammar != current_text:
902
+ # Word-by-word diff: spelling-corrected vs grammar-corrected
903
+ spell_words = current_text.split()
904
+ grammar_words = corrected_grammar.split()
905
+
906
+ # Existing spelling suggestion positions (for overlap check)
907
+ spelling_ranges = [(s['start'], s['end']) for s in suggestions]
908
+
909
+ sm_grammar = SequenceMatcher(None, spell_words, grammar_words)
910
+ for tag, i1, i2, j1, j2 in sm_grammar.get_opcodes():
911
+ if tag != 'replace':
912
+ continue
913
+
914
+ # Process each changed word individually
915
+ g_segment = grammar_words[j1:j2]
916
+ correction_text = " ".join(g_segment)
917
+
918
+ # Map back to original text positions
919
+ # Find the earliest and latest original word indices
920
+ orig_indices = set()
921
+ for k in range(i1, i2):
922
+ if k < len(spelling_word_map):
923
+ orig_indices.add(spelling_word_map[k])
924
+
925
+ if not orig_indices:
926
+ continue
927
+
928
+ min_orig = min(orig_indices)
929
+ max_orig = max(orig_indices)
930
+
931
+ if min_orig >= len(orig_word_positions) or max_orig >= len(orig_word_positions):
932
+ continue
933
+
934
+ orig_start = orig_word_positions[min_orig][1]
935
+ orig_end = orig_word_positions[max_orig][2]
936
+ original_span = text[orig_start:orig_end]
937
+
938
+ # Skip if the correction is the same as original
939
+ if original_span == correction_text:
940
+ continue
941
+
942
+ # Skip if this overlaps with any spelling suggestion
943
+ overlaps = any(
944
+ not (orig_end <= sr_start or orig_start >= sr_end)
945
+ for sr_start, sr_end in spelling_ranges
946
+ )
947
+ if overlaps:
948
+ continue
949
+
950
+ suggestions.append({
951
+ 'start': orig_start,
952
+ 'end': orig_end,
953
+ 'original': original_span,
954
+ 'correction': correction_text,
955
+ 'type': 'grammar'
956
+ })
957
+
958
  current_text = corrected_grammar
959
  except Exception as e:
960
  logger.error(f"[ANALYZE] Grammar failed: {e}")
 
975
  except Exception as e:
976
  logger.error(f"[ANALYZE] Punctuation failed: {e}")
977
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
978
  total_time = time.time() - total_start
979
  logger.info(f"[ANALYZE] Total analysis time: {total_time:.2f}s | Suggestions: {len(suggestions)}")
980