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

fix: Word-level grammar suggestions + robust grammar model validation

Browse files

Grammar service:
- Add word count validation (reject if ratio < 0.5 or > 2.0)
- Add repetition detection (reject if 2+ consecutive repeated words)
- Add length check (reject if output > 2x input)
- Add character overlap check (reject if < 50% chars preserved)

Grammar diff in app.py:
- Break large replace blocks into individual word suggestions
- When word counts match, process EACH word pair separately
- Skip large blocks (>3 words) to prevent hallucination suggestions
- Each grammar suggestion now points to a single word, not entire sentence

Files changed (2) hide show
  1. src/app.py +85 -43
  2. src/nlp/grammar/grammar_service.py +41 -3
src/app.py CHANGED
@@ -911,49 +911,91 @@ def analyze_text():
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:
 
911
  if tag != 'replace':
912
  continue
913
 
914
+ o_count = i2 - i1
915
+ c_count = j2 - j1
916
+
917
+ # If word counts match, process EACH word individually
918
+ if o_count == c_count:
919
+ for offset in range(o_count):
920
+ sw = spell_words[i1 + offset]
921
+ gw = grammar_words[j1 + offset]
922
+ if sw == gw:
923
+ continue # no change at this position
924
+
925
+ # Map to original text position
926
+ spell_idx = i1 + offset
927
+ if spell_idx >= len(spelling_word_map):
928
+ continue
929
+ orig_idx = spelling_word_map[spell_idx]
930
+ if orig_idx >= len(orig_word_positions):
931
+ continue
932
+
933
+ orig_start = orig_word_positions[orig_idx][1]
934
+ orig_end = orig_word_positions[orig_idx][2]
935
+ original_word = text[orig_start:orig_end]
936
+
937
+ if original_word == gw:
938
+ continue
939
+
940
+ # Overlap check
941
+ overlaps = any(
942
+ not (orig_end <= sr_s or orig_start >= sr_e)
943
+ for sr_s, sr_e in spelling_ranges
944
+ )
945
+ if overlaps:
946
+ continue
947
+
948
+ suggestions.append({
949
+ 'start': orig_start,
950
+ 'end': orig_end,
951
+ 'original': original_word,
952
+ 'correction': gw,
953
+ 'type': 'grammar'
954
+ })
955
+ else:
956
+ # Word count differs — create one grouped suggestion
957
+ # but only if it's a small block (max 3 words)
958
+ if o_count > 3 or c_count > 3:
959
+ continue # skip large blocks (likely hallucination)
960
+
961
+ g_segment = grammar_words[j1:j2]
962
+ correction_text = " ".join(g_segment)
963
+
964
+ orig_indices = set()
965
+ for k in range(i1, i2):
966
+ if k < len(spelling_word_map):
967
+ orig_indices.add(spelling_word_map[k])
968
+
969
+ if not orig_indices:
970
+ continue
971
+
972
+ min_orig = min(orig_indices)
973
+ max_orig = max(orig_indices)
974
+
975
+ if min_orig >= len(orig_word_positions) or max_orig >= len(orig_word_positions):
976
+ continue
977
+
978
+ orig_start = orig_word_positions[min_orig][1]
979
+ orig_end = orig_word_positions[max_orig][2]
980
+ original_span = text[orig_start:orig_end]
981
+
982
+ if original_span == correction_text:
983
+ continue
984
+
985
+ overlaps = any(
986
+ not (orig_end <= sr_s or orig_start >= sr_e)
987
+ for sr_s, sr_e in spelling_ranges
988
+ )
989
+ if overlaps:
990
+ continue
991
+
992
+ suggestions.append({
993
+ 'start': orig_start,
994
+ 'end': orig_end,
995
+ 'original': original_span,
996
+ 'correction': correction_text,
997
+ 'type': 'grammar'
998
+ })
999
 
1000
  current_text = corrected_grammar
1001
  except Exception as e:
src/nlp/grammar/grammar_service.py CHANGED
@@ -102,16 +102,54 @@ def correct_grammar(text: str) -> str:
102
  # Decode output
103
  corrected = tokenizer.decode(outputs[0], skip_special_tokens=True)
104
 
105
- # Safety: if model returns empty or much shorter, keep original
 
 
106
  if not corrected or not corrected.strip():
107
  return text
108
 
109
- # If the correction is drastically different (>80% changed), keep original
 
 
110
  if len(corrected) < len(text) * 0.3:
111
  logger.warning(f"Grammar output too short ({len(corrected)} vs {len(text)}), keeping original")
112
  return text
113
 
114
- return corrected.strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
 
116
  except Exception as e:
117
  logger.error(f"Grammar correction failed: {e}")
 
102
  # Decode output
103
  corrected = tokenizer.decode(outputs[0], skip_special_tokens=True)
104
 
105
+ # === VALIDATION ===
106
+
107
+ # 1. Empty check
108
  if not corrected or not corrected.strip():
109
  return text
110
 
111
+ corrected = corrected.strip()
112
+
113
+ # 2. Length check: too short = hallucination
114
  if len(corrected) < len(text) * 0.3:
115
  logger.warning(f"Grammar output too short ({len(corrected)} vs {len(text)}), keeping original")
116
  return text
117
 
118
+ # 3. Length check: too long = repetition/hallucination
119
+ if len(corrected) > len(text) * 2.0:
120
+ logger.warning(f"Grammar output too long ({len(corrected)} vs {len(text)}), keeping original")
121
+ return text
122
+
123
+ # 4. Word count check: grammar shouldn't drastically change word count
124
+ orig_wc = len(text.split())
125
+ corr_wc = len(corrected.split())
126
+ if orig_wc > 0:
127
+ ratio = corr_wc / orig_wc
128
+ if ratio < 0.5 or ratio > 2.0:
129
+ logger.warning(f"Grammar word count mismatch ({corr_wc} vs {orig_wc}), keeping original")
130
+ return text
131
+
132
+ # 5. Repetition detection: reject if output has excessive repeated words
133
+ corr_words = corrected.split()
134
+ if len(corr_words) >= 4:
135
+ consecutive_repeats = 0
136
+ for i in range(1, len(corr_words)):
137
+ if corr_words[i] == corr_words[i-1]:
138
+ consecutive_repeats += 1
139
+ if consecutive_repeats >= 2:
140
+ logger.warning(f"Grammar output has excessive repetition ({consecutive_repeats} repeats), keeping original")
141
+ return text
142
+
143
+ # 6. Character overlap check: grammar should preserve most of the content
144
+ orig_chars = set(text.replace(' ', ''))
145
+ corr_chars = set(corrected.replace(' ', ''))
146
+ if orig_chars:
147
+ overlap = len(orig_chars & corr_chars) / len(orig_chars)
148
+ if overlap < 0.5:
149
+ logger.warning(f"Grammar output low char overlap ({overlap:.2f}), keeping original")
150
+ return text
151
+
152
+ return corrected
153
 
154
  except Exception as e:
155
  logger.error(f"Grammar correction failed: {e}")