Commit ·
4bde1ea
1
Parent(s): a9630ec
fix: Grammar retry on rate-limit + cursor position after correction
Browse files- Grammar service: transient errors (rate limiting, timeout) no longer cached permanently — next request will retry loading instead of failing forever
- Editor: cursor now placed right after corrected text instead of jumping to start — applied to all 3 code paths (UUID span, text-match fallback, offset fallback)
- src/js/editor.js +40 -0
- src/nlp/grammar/grammar_service.py +17 -1
src/js/editor.js
CHANGED
|
@@ -451,6 +451,15 @@ function applySuggestionAtOffsets(suggestion) {
|
|
| 451 |
parent.insertBefore(correctedNode, errorSpan);
|
| 452 |
parent.removeChild(errorSpan);
|
| 453 |
parent.normalize();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
} else {
|
| 455 |
// Fallback: find span by matching original text
|
| 456 |
const allErrorSpans = document.querySelectorAll('.spelling-error, .grammar-error, .punctuation-suggestion');
|
|
@@ -462,6 +471,15 @@ function applySuggestionAtOffsets(suggestion) {
|
|
| 462 |
p.insertBefore(correctedNode, span);
|
| 463 |
p.removeChild(span);
|
| 464 |
p.normalize();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 465 |
found = true;
|
| 466 |
}
|
| 467 |
});
|
|
@@ -472,6 +490,8 @@ function applySuggestionAtOffsets(suggestion) {
|
|
| 472 |
const after = text.substring(suggestion.end);
|
| 473 |
const newText = before + suggestion.correction + after;
|
| 474 |
setEditorHTML(escapeHtml(newText));
|
|
|
|
|
|
|
| 475 |
}
|
| 476 |
}
|
| 477 |
hideTooltip();
|
|
@@ -517,6 +537,15 @@ function applyAlternativeCorrection(suggestion, correctionText) {
|
|
| 517 |
parent.insertBefore(correctedNode, errorSpan);
|
| 518 |
parent.removeChild(errorSpan);
|
| 519 |
parent.normalize();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 520 |
} else {
|
| 521 |
const allErrorSpans = document.querySelectorAll('.spelling-error, .grammar-error, .punctuation-suggestion');
|
| 522 |
let found = false;
|
|
@@ -527,6 +556,15 @@ function applyAlternativeCorrection(suggestion, correctionText) {
|
|
| 527 |
p.insertBefore(correctedNode, span);
|
| 528 |
p.removeChild(span);
|
| 529 |
p.normalize();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 530 |
found = true;
|
| 531 |
}
|
| 532 |
});
|
|
@@ -536,6 +574,8 @@ function applyAlternativeCorrection(suggestion, correctionText) {
|
|
| 536 |
const after = text.substring(suggestion.end);
|
| 537 |
const newText = before + correctionText + after;
|
| 538 |
setEditorHTML(escapeHtml(newText));
|
|
|
|
|
|
|
| 539 |
}
|
| 540 |
}
|
| 541 |
hideTooltip();
|
|
|
|
| 451 |
parent.insertBefore(correctedNode, errorSpan);
|
| 452 |
parent.removeChild(errorSpan);
|
| 453 |
parent.normalize();
|
| 454 |
+
// Place cursor right after the corrected text
|
| 455 |
+
try {
|
| 456 |
+
const sel = window.getSelection();
|
| 457 |
+
const r = document.createRange();
|
| 458 |
+
r.setStartAfter(correctedNode);
|
| 459 |
+
r.collapse(true);
|
| 460 |
+
sel.removeAllRanges();
|
| 461 |
+
sel.addRange(r);
|
| 462 |
+
} catch(e) {}
|
| 463 |
} else {
|
| 464 |
// Fallback: find span by matching original text
|
| 465 |
const allErrorSpans = document.querySelectorAll('.spelling-error, .grammar-error, .punctuation-suggestion');
|
|
|
|
| 471 |
p.insertBefore(correctedNode, span);
|
| 472 |
p.removeChild(span);
|
| 473 |
p.normalize();
|
| 474 |
+
// Place cursor right after the corrected text
|
| 475 |
+
try {
|
| 476 |
+
const sel = window.getSelection();
|
| 477 |
+
const r = document.createRange();
|
| 478 |
+
r.setStartAfter(correctedNode);
|
| 479 |
+
r.collapse(true);
|
| 480 |
+
sel.removeAllRanges();
|
| 481 |
+
sel.addRange(r);
|
| 482 |
+
} catch(e) {}
|
| 483 |
found = true;
|
| 484 |
}
|
| 485 |
});
|
|
|
|
| 490 |
const after = text.substring(suggestion.end);
|
| 491 |
const newText = before + suggestion.correction + after;
|
| 492 |
setEditorHTML(escapeHtml(newText));
|
| 493 |
+
// Place cursor after the inserted correction
|
| 494 |
+
setCaretOffset(suggestion.start + suggestion.correction.length);
|
| 495 |
}
|
| 496 |
}
|
| 497 |
hideTooltip();
|
|
|
|
| 537 |
parent.insertBefore(correctedNode, errorSpan);
|
| 538 |
parent.removeChild(errorSpan);
|
| 539 |
parent.normalize();
|
| 540 |
+
// Place cursor right after the corrected text
|
| 541 |
+
try {
|
| 542 |
+
const sel = window.getSelection();
|
| 543 |
+
const r = document.createRange();
|
| 544 |
+
r.setStartAfter(correctedNode);
|
| 545 |
+
r.collapse(true);
|
| 546 |
+
sel.removeAllRanges();
|
| 547 |
+
sel.addRange(r);
|
| 548 |
+
} catch(e) {}
|
| 549 |
} else {
|
| 550 |
const allErrorSpans = document.querySelectorAll('.spelling-error, .grammar-error, .punctuation-suggestion');
|
| 551 |
let found = false;
|
|
|
|
| 556 |
p.insertBefore(correctedNode, span);
|
| 557 |
p.removeChild(span);
|
| 558 |
p.normalize();
|
| 559 |
+
// Place cursor right after the corrected text
|
| 560 |
+
try {
|
| 561 |
+
const sel = window.getSelection();
|
| 562 |
+
const r = document.createRange();
|
| 563 |
+
r.setStartAfter(correctedNode);
|
| 564 |
+
r.collapse(true);
|
| 565 |
+
sel.removeAllRanges();
|
| 566 |
+
sel.addRange(r);
|
| 567 |
+
} catch(e) {}
|
| 568 |
found = true;
|
| 569 |
}
|
| 570 |
});
|
|
|
|
| 574 |
const after = text.substring(suggestion.end);
|
| 575 |
const newText = before + correctionText + after;
|
| 576 |
setEditorHTML(escapeHtml(newText));
|
| 577 |
+
// Place cursor after the inserted correction
|
| 578 |
+
setCaretOffset(suggestion.start + correctionText.length);
|
| 579 |
}
|
| 580 |
}
|
| 581 |
hideTooltip();
|
src/nlp/grammar/grammar_service.py
CHANGED
|
@@ -67,6 +67,9 @@ def get_grammar_model():
|
|
| 67 |
"""
|
| 68 |
Lazy-load the grammar model on first call.
|
| 69 |
Returns the GrammarChecker instance, or raises RuntimeError if loading fails.
|
|
|
|
|
|
|
|
|
|
| 70 |
"""
|
| 71 |
global _grammar_checker, _load_error
|
| 72 |
|
|
@@ -101,9 +104,22 @@ def get_grammar_model():
|
|
| 101 |
|
| 102 |
except Exception as e:
|
| 103 |
import traceback
|
| 104 |
-
|
| 105 |
logger.error(f"Failed to load grammar model: {e}")
|
| 106 |
logger.error(traceback.format_exc())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
raise RuntimeError(f"Grammar model load failed: {e}")
|
| 108 |
|
| 109 |
|
|
|
|
| 67 |
"""
|
| 68 |
Lazy-load the grammar model on first call.
|
| 69 |
Returns the GrammarChecker instance, or raises RuntimeError if loading fails.
|
| 70 |
+
|
| 71 |
+
Transient errors (rate limiting, network timeouts) are NOT cached —
|
| 72 |
+
the next request will retry loading. Only permanent failures are cached.
|
| 73 |
"""
|
| 74 |
global _grammar_checker, _load_error
|
| 75 |
|
|
|
|
| 104 |
|
| 105 |
except Exception as e:
|
| 106 |
import traceback
|
| 107 |
+
error_msg = str(e)
|
| 108 |
logger.error(f"Failed to load grammar model: {e}")
|
| 109 |
logger.error(traceback.format_exc())
|
| 110 |
+
|
| 111 |
+
# Transient errors (rate limiting, network) should NOT be cached —
|
| 112 |
+
# allow retry on next request
|
| 113 |
+
transient_keywords = ['Too many requests', 'rate limit', 'timeout',
|
| 114 |
+
'ConnectionError', 'ConnectTimeout', 'ReadTimeout']
|
| 115 |
+
is_transient = any(kw.lower() in error_msg.lower() for kw in transient_keywords)
|
| 116 |
+
|
| 117 |
+
if is_transient:
|
| 118 |
+
logger.warning(f"Grammar load error is TRANSIENT — will retry on next request: {error_msg}")
|
| 119 |
+
# Do NOT set _load_error — next call will retry
|
| 120 |
+
else:
|
| 121 |
+
_load_error = error_msg # Cache permanent failures only
|
| 122 |
+
|
| 123 |
raise RuntimeError(f"Grammar model load failed: {e}")
|
| 124 |
|
| 125 |
|