Commit ·
7f0ddc6
1
Parent(s): b0bed28
fix: Remove 'Apply All' button - conflicts with spelling+grammar pipeline
Browse files- src/app.py +23 -141
- src/index.html +2 -2
- src/js/editor.js +0 -6
- src/nlp/grammar/grammar_service.py +3 -41
src/app.py
CHANGED
|
@@ -11,7 +11,6 @@ from flask_cors import CORS
|
|
| 11 |
from pathlib import Path
|
| 12 |
import traceback
|
| 13 |
import difflib
|
| 14 |
-
from difflib import SequenceMatcher
|
| 15 |
import re
|
| 16 |
|
| 17 |
# Load .env file from project root (one level up from src/)
|
|
@@ -682,8 +681,6 @@ def analyze_text():
|
|
| 682 |
|
| 683 |
current_text = text
|
| 684 |
suggestions = []
|
| 685 |
-
spelling_changed_originals = set()
|
| 686 |
-
spell_checker = None
|
| 687 |
mappers = []
|
| 688 |
total_start = time.time()
|
| 689 |
|
|
@@ -846,51 +843,13 @@ def analyze_text():
|
|
| 846 |
continue
|
| 847 |
|
| 848 |
safe_text = " ".join(new_words)
|
| 849 |
-
|
| 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
|
| 894 |
if has_grammar:
|
| 895 |
try:
|
| 896 |
t0 = time.time()
|
|
@@ -899,104 +858,17 @@ def analyze_text():
|
|
| 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 |
-
|
| 903 |
-
|
| 904 |
-
|
| 905 |
-
|
| 906 |
-
|
| 907 |
-
|
| 908 |
-
|
| 909 |
-
|
| 910 |
-
|
| 911 |
-
|
| 912 |
-
|
| 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:
|
| 1002 |
logger.error(f"[ANALYZE] Grammar failed: {e}")
|
|
@@ -1013,6 +885,16 @@ def analyze_text():
|
|
| 1013 |
corrected_punc = punctuation_model.add_punctuation(current_text)
|
| 1014 |
logger.info(f"[ANALYZE] Step 3: Punctuation done in {time.time()-t0:.2f}s")
|
| 1015 |
if corrected_punc != current_text:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1016 |
current_text = corrected_punc
|
| 1017 |
except Exception as e:
|
| 1018 |
logger.error(f"[ANALYZE] Punctuation failed: {e}")
|
|
|
|
| 11 |
from pathlib import Path
|
| 12 |
import traceback
|
| 13 |
import difflib
|
|
|
|
| 14 |
import re
|
| 15 |
|
| 16 |
# Load .env file from project root (one level up from src/)
|
|
|
|
| 681 |
|
| 682 |
current_text = text
|
| 683 |
suggestions = []
|
|
|
|
|
|
|
| 684 |
mappers = []
|
| 685 |
total_start = time.time()
|
| 686 |
|
|
|
|
| 843 |
continue
|
| 844 |
|
| 845 |
safe_text = " ".join(new_words)
|
| 846 |
+
mappers.append(OffsetMapper(current_text, safe_text))
|
|
|
|
|
|
|
| 847 |
current_text = safe_text
|
| 848 |
except Exception as e:
|
| 849 |
logger.error(f"[ANALYZE] Spelling failed: {e}")
|
| 850 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 851 |
# 2. Grammar (runs on spelling-corrected text)
|
| 852 |
+
has_grammar = True # Always available via lazy-loaded grammar_service
|
| 853 |
if has_grammar:
|
| 854 |
try:
|
| 855 |
t0 = time.time()
|
|
|
|
| 858 |
corrected_grammar = correct_grammar(current_text)
|
| 859 |
logger.info(f"[ANALYZE] Step 2: Grammar done in {time.time()-t0:.2f}s")
|
| 860 |
if corrected_grammar != current_text:
|
| 861 |
+
diffs = get_word_diffs(current_text, corrected_grammar)
|
| 862 |
+
for d in diffs:
|
| 863 |
+
orig_start, orig_end = map_range_to_original(d['start'], d['end'])
|
| 864 |
+
suggestions.append({
|
| 865 |
+
'start': orig_start,
|
| 866 |
+
'end': orig_end,
|
| 867 |
+
'original': text[orig_start:orig_end],
|
| 868 |
+
'correction': d['correction'],
|
| 869 |
+
'type': 'grammar'
|
| 870 |
+
})
|
| 871 |
+
mappers.append(OffsetMapper(current_text, corrected_grammar))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 872 |
current_text = corrected_grammar
|
| 873 |
except Exception as e:
|
| 874 |
logger.error(f"[ANALYZE] Grammar failed: {e}")
|
|
|
|
| 885 |
corrected_punc = punctuation_model.add_punctuation(current_text)
|
| 886 |
logger.info(f"[ANALYZE] Step 3: Punctuation done in {time.time()-t0:.2f}s")
|
| 887 |
if corrected_punc != current_text:
|
| 888 |
+
diffs = get_word_diffs(current_text, corrected_punc)
|
| 889 |
+
for d in diffs:
|
| 890 |
+
orig_start, orig_end = map_range_to_original(d['start'], d['end'])
|
| 891 |
+
suggestions.append({
|
| 892 |
+
'start': orig_start,
|
| 893 |
+
'end': orig_end,
|
| 894 |
+
'original': text[orig_start:orig_end],
|
| 895 |
+
'correction': d['correction'],
|
| 896 |
+
'type': 'punctuation'
|
| 897 |
+
})
|
| 898 |
current_text = corrected_punc
|
| 899 |
except Exception as e:
|
| 900 |
logger.error(f"[ANALYZE] Punctuation failed: {e}")
|
src/index.html
CHANGED
|
@@ -560,7 +560,7 @@
|
|
| 560 |
<div class="surface-card sidebar-card">
|
| 561 |
<h3 class="text-xl font-bold mb-4 text-right">الاقتراحات</h3>
|
| 562 |
<div id="suggestions-list" class="suggestions-scroll" role="list" aria-live="polite" aria-label="اقتراحات التصحيح"></div>
|
| 563 |
-
<
|
| 564 |
</div>
|
| 565 |
</div>
|
| 566 |
</div>
|
|
@@ -577,7 +577,7 @@
|
|
| 577 |
<button id="bottom-sheet-close" class="btn-ghost" type="button" aria-label="إغلاق">إغلاق</button>
|
| 578 |
</div>
|
| 579 |
<div id="bottom-sheet-list" class="suggestions-scroll" role="list" aria-live="polite"></div>
|
| 580 |
-
<
|
| 581 |
</div>
|
| 582 |
</div>
|
| 583 |
<!-- Mobile export sheet (documents) -->
|
|
|
|
| 560 |
<div class="surface-card sidebar-card">
|
| 561 |
<h3 class="text-xl font-bold mb-4 text-right">الاقتراحات</h3>
|
| 562 |
<div id="suggestions-list" class="suggestions-scroll" role="list" aria-live="polite" aria-label="اقتراحات التصحيح"></div>
|
| 563 |
+
<!-- apply-all removed: conflicts with multi-step spelling+grammar pipeline -->
|
| 564 |
</div>
|
| 565 |
</div>
|
| 566 |
</div>
|
|
|
|
| 577 |
<button id="bottom-sheet-close" class="btn-ghost" type="button" aria-label="إغلاق">إغلاق</button>
|
| 578 |
</div>
|
| 579 |
<div id="bottom-sheet-list" class="suggestions-scroll" role="list" aria-live="polite"></div>
|
| 580 |
+
<!-- apply-all removed: conflicts with multi-step spelling+grammar pipeline -->
|
| 581 |
</div>
|
| 582 |
</div>
|
| 583 |
<!-- Mobile export sheet (documents) -->
|
src/js/editor.js
CHANGED
|
@@ -362,14 +362,8 @@ function applyAllSuggestions() {
|
|
| 362 |
if (suggestions.length === 0) return;
|
| 363 |
|
| 364 |
let text = getEditorText();
|
| 365 |
-
// Track applied ranges to skip overlapping suggestions
|
| 366 |
-
const appliedRanges = [];
|
| 367 |
suggestions.forEach((s) => {
|
| 368 |
-
// Check if this suggestion overlaps with any already-applied one
|
| 369 |
-
const overlaps = appliedRanges.some(([rs, re]) => !(s.end <= rs || s.start >= re));
|
| 370 |
-
if (overlaps) return; // skip overlapping
|
| 371 |
text = text.substring(0, s.start) + s.correction + text.substring(s.end);
|
| 372 |
-
appliedRanges.push([s.start, s.end]);
|
| 373 |
});
|
| 374 |
|
| 375 |
setEditorHTML(escapeHtml(text));
|
|
|
|
| 362 |
if (suggestions.length === 0) return;
|
| 363 |
|
| 364 |
let text = getEditorText();
|
|
|
|
|
|
|
| 365 |
suggestions.forEach((s) => {
|
|
|
|
|
|
|
|
|
|
| 366 |
text = text.substring(0, s.start) + s.correction + text.substring(s.end);
|
|
|
|
| 367 |
});
|
| 368 |
|
| 369 |
setEditorHTML(escapeHtml(text));
|
src/nlp/grammar/grammar_service.py
CHANGED
|
@@ -102,54 +102,16 @@ def correct_grammar(text: str) -> str:
|
|
| 102 |
# Decode output
|
| 103 |
corrected = tokenizer.decode(outputs[0], skip_special_tokens=True)
|
| 104 |
|
| 105 |
-
#
|
| 106 |
-
|
| 107 |
-
# 1. Empty check
|
| 108 |
if not corrected or not corrected.strip():
|
| 109 |
return text
|
| 110 |
|
| 111 |
-
|
| 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 |
-
|
| 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}")
|
|
|
|
| 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}")
|