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
- 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 |
-
#
|
| 858 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|