Commit ·
b0bed28
1
Parent(s): 63587df
fix: Word-level grammar suggestions + robust grammar model validation
Browse filesGrammar 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
- src/app.py +85 -43
- 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 |
-
|
| 915 |
-
|
| 916 |
-
|
| 917 |
-
|
| 918 |
-
|
| 919 |
-
|
| 920 |
-
|
| 921 |
-
|
| 922 |
-
|
| 923 |
-
|
| 924 |
-
|
| 925 |
-
|
| 926 |
-
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
|
| 930 |
-
|
| 931 |
-
|
| 932 |
-
|
| 933 |
-
|
| 934 |
-
|
| 935 |
-
|
| 936 |
-
|
| 937 |
-
|
| 938 |
-
|
| 939 |
-
|
| 940 |
-
|
| 941 |
-
|
| 942 |
-
|
| 943 |
-
|
| 944 |
-
|
| 945 |
-
|
| 946 |
-
|
| 947 |
-
|
| 948 |
-
|
| 949 |
-
|
| 950 |
-
|
| 951 |
-
|
| 952 |
-
|
| 953 |
-
|
| 954 |
-
|
| 955 |
-
|
| 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 |
-
#
|
|
|
|
|
|
|
| 106 |
if not corrected or not corrected.strip():
|
| 107 |
return text
|
| 108 |
|
| 109 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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}")
|