youssefreda9 commited on
Commit
39f0dd8
·
1 Parent(s): 7908da0

feat: NLP-2 Grammar Integration — dependency-aware pipeline - Add grammar_rules.py (ArabicGrammarGuard from Grammer_Rules.py with camel-tools) - Add grammar_service.py (lazy-loaded Gradio Client + rules pipeline) - Wire /api/grammar endpoint to real grammar service - Wire /api/analyze Step 2 to grammar (receives AraSpell output) - Update /api/health to report grammar status - Add gradio_client to requirements.txt - Add camel_data download to Dockerfile - Frontend unchanged (already supports grammar-error yellow highlighting)

Browse files
Dockerfile CHANGED
@@ -45,6 +45,9 @@ AutoModelForMaskedLM.from_pretrained('aubmindlab/bert-base-arabertv02'); \
45
  print('Spelling model + MLM cached!'); \
46
  "
47
 
 
 
 
48
  # Copy application code
49
  COPY src/ ./src/
50
  COPY .env* ./
 
45
  print('Spelling model + MLM cached!'); \
46
  "
47
 
48
+ # 3. Grammar — camel-tools MLE disambiguator data
49
+ RUN camel_data -i light
50
+
51
  # Copy application code
52
  COPY src/ ./src/
53
  COPY .env* ./
requirements.txt CHANGED
@@ -13,3 +13,4 @@ jellyfish
13
  python-Levenshtein
14
  gunicorn
15
  python-dotenv
 
 
13
  python-Levenshtein
14
  gunicorn
15
  python-dotenv
16
+ gradio_client
src/app.py CHANGED
@@ -155,7 +155,7 @@ def health_check():
155
  'summarization': summarization_model is not None,
156
  'spelling': _spelling_available(),
157
  'autocomplete': False,
158
- 'grammar': False,
159
  'punctuation': False
160
  },
161
  'note': 'Free tier: summarization local, other models return input unchanged',
@@ -232,6 +232,15 @@ def _spelling_available():
232
  return False
233
 
234
 
 
 
 
 
 
 
 
 
 
235
  @app.route('/api/spelling', methods=['POST'])
236
  def spelling_correction():
237
  """
@@ -438,17 +447,18 @@ def grammar_correction():
438
  """
439
  Correct grammar in Arabic text.
440
 
441
- Expected JSON payload:
442
  {
443
- "text": "Arabic text to correct"
444
  }
445
- """
446
- if not USE_HF_API and grammar_model is None:
447
- return jsonify({
448
- 'error': 'Grammar model not loaded. Please check server logs.',
449
- 'status': 'error'
450
- }), 503
451
 
 
 
 
 
 
 
 
452
  try:
453
  if not request.is_json:
454
  return jsonify({'error': 'Request must be JSON', 'status': 'error'}), 400
@@ -459,20 +469,30 @@ def grammar_correction():
459
  if not text:
460
  return jsonify({'error': 'Text is required', 'status': 'error'}), 400
461
 
462
- logger.info(f"Correcting grammar for text of length: {len(text)}")
463
- if USE_HF_API:
464
- # Grammar uses spelling model as proxy (no dedicated grammar model yet)
465
- corrected = hf_correct_spelling(text)
466
- else:
467
- corrected = grammar_model.correct(text)
 
 
 
 
 
468
 
469
  return jsonify({
470
- 'corrected': corrected,
471
- 'status': 'success',
472
- 'original_length': len(text),
473
- 'corrected_length': len(corrected)
474
- })
475
 
 
 
 
 
 
 
476
  except Exception as e:
477
  logger.error(f"Error during grammar correction: {str(e)}")
478
  logger.error(traceback.format_exc())
@@ -849,32 +869,29 @@ def analyze_text():
849
  except Exception as e:
850
  logger.error(f"[ANALYZE] Spelling failed: {e}")
851
 
852
- # 2. Grammar (runs on spelling-corrected text)
853
- has_grammar = USE_HF_API or grammar_model
854
- if has_grammar:
855
- try:
856
- t0 = time.time()
857
- logger.info(f"[ANALYZE] Step 2: Grammar correction starting...")
858
- if USE_HF_API:
859
- corrected_grammar = hf_correct_spelling(current_text)
860
- else:
861
- corrected_grammar = grammar_model.correct(current_text)
862
- logger.info(f"[ANALYZE] Step 2: Grammar done in {time.time()-t0:.2f}s")
863
- if corrected_grammar != current_text:
864
- diffs = get_word_diffs(current_text, corrected_grammar)
865
- for d in diffs:
866
- orig_start, orig_end = map_range_to_original(d['start'], d['end'])
867
- suggestions.append({
868
- 'start': orig_start,
869
- 'end': orig_end,
870
- 'original': text[orig_start:orig_end],
871
- 'correction': d['correction'],
872
- 'type': 'grammar'
873
- })
874
- mappers.append(OffsetMapper(current_text, corrected_grammar))
875
- current_text = corrected_grammar
876
- except Exception as e:
877
- logger.error(f"[ANALYZE] Grammar failed: {e}")
878
 
879
  # 3. Punctuation (runs on grammar-corrected text)
880
  has_punctuation = USE_HF_API or punctuation_model
 
155
  'summarization': summarization_model is not None,
156
  'spelling': _spelling_available(),
157
  'autocomplete': False,
158
+ 'grammar': _grammar_available(),
159
  'punctuation': False
160
  },
161
  'note': 'Free tier: summarization local, other models return input unchanged',
 
232
  return False
233
 
234
 
235
+ def _grammar_available():
236
+ """Check if grammar model is loaded (without triggering lazy load)."""
237
+ try:
238
+ from nlp.grammar.grammar_service import is_loaded
239
+ return is_loaded()
240
+ except Exception:
241
+ return False
242
+
243
+
244
  @app.route('/api/spelling', methods=['POST'])
245
  def spelling_correction():
246
  """
 
447
  """
448
  Correct grammar in Arabic text.
449
 
450
+ Request JSON:
451
  {
452
+ "text": "Arabic text with grammar errors"
453
  }
 
 
 
 
 
 
454
 
455
+ Response JSON:
456
+ {
457
+ "original_text": "...",
458
+ "corrected_text": "...",
459
+ "status": "success"
460
+ }
461
+ """
462
  try:
463
  if not request.is_json:
464
  return jsonify({'error': 'Request must be JSON', 'status': 'error'}), 400
 
469
  if not text:
470
  return jsonify({'error': 'Text is required', 'status': 'error'}), 400
471
 
472
+ if len(text) > MAX_TEXT_LENGTH:
473
+ return jsonify({
474
+ 'error': f'Text too long. Maximum {MAX_TEXT_LENGTH} characters.',
475
+ 'status': 'error'
476
+ }), 400
477
+
478
+ logger.info(f"Grammar correction request: text_length={len(text)}")
479
+
480
+ from nlp.grammar.grammar_service import get_grammar_model
481
+ checker = get_grammar_model()
482
+ corrected = checker.correct(text)
483
 
484
  return jsonify({
485
+ 'original_text': text,
486
+ 'corrected_text': corrected,
487
+ 'status': 'success'
488
+ }), 200
 
489
 
490
+ except RuntimeError as e:
491
+ logger.error(f"Grammar model error: {e}")
492
+ return jsonify({
493
+ 'error': f'Grammar model unavailable: {str(e)[:200]}',
494
+ 'status': 'error'
495
+ }), 503
496
  except Exception as e:
497
  logger.error(f"Error during grammar correction: {str(e)}")
498
  logger.error(traceback.format_exc())
 
869
  except Exception as e:
870
  logger.error(f"[ANALYZE] Spelling failed: {e}")
871
 
872
+ # 2. Grammar (runs on spelling-corrected text — word-level dependency)
873
+ try:
874
+ t0 = time.time()
875
+ logger.info(f"[ANALYZE] Step 2: Grammar correction starting...")
876
+ from nlp.grammar.grammar_service import get_grammar_model
877
+ grammar_checker = get_grammar_model()
878
+ corrected_grammar = grammar_checker.correct(current_text)
879
+ logger.info(f"[ANALYZE] Step 2: Grammar done in {time.time()-t0:.2f}s")
880
+ if corrected_grammar != current_text:
881
+ diffs = get_word_diffs(current_text, corrected_grammar)
882
+ for d in diffs:
883
+ orig_start, orig_end = map_range_to_original(d['start'], d['end'])
884
+ suggestions.append({
885
+ 'start': orig_start,
886
+ 'end': orig_end,
887
+ 'original': text[orig_start:orig_end],
888
+ 'correction': d['correction'],
889
+ 'type': 'grammar'
890
+ })
891
+ mappers.append(OffsetMapper(current_text, corrected_grammar))
892
+ current_text = corrected_grammar
893
+ except Exception as e:
894
+ logger.error(f"[ANALYZE] Grammar failed: {e}")
 
 
 
895
 
896
  # 3. Punctuation (runs on grammar-corrected text)
897
  has_punctuation = USE_HF_API or punctuation_model
src/nlp/grammar/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Grammar NLP module
src/nlp/grammar/grammar_rules.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ArabicGrammarGuard — Rule-based Arabic grammar post-processing
2
+ # Extracted from Grammer_Rules.py — uses camel-tools for morphological analysis.
3
+ # All classes are imported by grammar_service.py.
4
+
5
+ import re
6
+ import logging
7
+ from camel_tools.tokenizers.word import simple_word_tokenize
8
+ from camel_tools.disambig.mle import MLEDisambiguator
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class ArabicGrammarGuard:
14
+ def __init__(self):
15
+ self.mle = MLEDisambiguator.pretrained()
16
+
17
+ self.number_words = ["واحد", "اثنان", "اثنين", "ثلاث", "أربع", "خمس", "ست", "سبع", "ثمان", "تسع", "عشر",
18
+ "عشرون", "عشرين", "ثلاثون", "ثلاثين", "أربعون", "أربعين", "خمسون", "خمسين",
19
+ "ستون", "ستين", "سبعون", "سبعين", "ثمانون", "ثمانين", "تسعون", "تسعين", "مائة", "ألف"]
20
+
21
+ self.asmaa_khamsa_roots = ['اب', 'اخ', 'حم', 'فو', 'ذو']
22
+
23
+ def preserve_numbers(self, original_text, generated_text):
24
+ orig_digits = re.findall(r'\d+', original_text)
25
+ gen_digits = re.findall(r'\d+', generated_text)
26
+ if orig_digits and gen_digits and orig_digits != gen_digits:
27
+ return original_text
28
+
29
+ orig_words = [w for w in original_text.split() if any(num in w for num in self.number_words)]
30
+ gen_words = [w for w in generated_text.split() if any(num in w for num in self.number_words)]
31
+ if len(orig_words) > 0 and len(gen_words) > 0:
32
+ if not any(orig[:3] in gen for orig in orig_words for gen in gen_words):
33
+ return original_text
34
+ return generated_text
35
+
36
+ def fix_number_and_gender_agreement(self, text):
37
+ tokens = simple_word_tokenize(text)
38
+ disambig_tokens = self.mle.disambiguate(tokens)
39
+ corrected_tokens = list(tokens)
40
+
41
+ for i in range(len(disambig_tokens) - 1):
42
+ w1_info = disambig_tokens[i].analyses[0] if disambig_tokens[i].analyses else None
43
+ w2_info = disambig_tokens[i+1].analyses[0] if disambig_tokens[i+1].analyses else None
44
+ if not w1_info or not w2_info: continue
45
+
46
+ w1_pos = w1_info.analysis.get('pos', 'unknown')
47
+ w2_pos = w2_info.analysis.get('pos', 'unknown')
48
+ w1_word = corrected_tokens[i]
49
+ w2_word = corrected_tokens[i+1]
50
+
51
+ if w1_pos == 'verb' and w2_pos == 'noun':
52
+ if (w1_word.endswith('ون') or w1_word.endswith('وا')) and (w2_word.endswith('ون') or w2_word.endswith('ين')):
53
+ if w1_word.endswith('ون'): corrected_tokens[i] = w1_word[:-2]
54
+ elif w1_word.endswith('وا'): corrected_tokens[i] = w1_word[:-2]
55
+
56
+ elif w1_pos == 'noun' and w2_pos == 'verb':
57
+ if w1_word.endswith('ون') and not (w2_word.endswith('ون') or w2_word.endswith('وا') or w2_word.endswith('ين')):
58
+ if w2_info.analysis.get('num') == 's':
59
+ corrected_tokens[i+1] = w2_word + 'ون'
60
+
61
+ # Match adjectives (adj) only; skip words starting with ب or ending with alef tanween
62
+ elif w1_pos == 'noun' and w2_pos == 'adj':
63
+ if w1_word.endswith('ون') and not w2_word.endswith('ون'):
64
+ if w2_info.analysis.get('num') == 's' and w2_info.analysis.get('gen') == 'm':
65
+ if len(w2_word) > 2 and not w2_word.endswith('ا') and not w2_word.startswith('ب'):
66
+ corrected_tokens[i+1] = w2_word + 'ون'
67
+
68
+ return " ".join(corrected_tokens)
69
+
70
+ def smart_asmaa_khamsa_fix(self, text):
71
+ tokens = simple_word_tokenize(text)
72
+ disambig_tokens = self.mle.disambiguate(tokens)
73
+ corrected_tokens = []
74
+ verb_seen = False
75
+
76
+ for i, token_info in enumerate(disambig_tokens):
77
+ word = tokens[i]
78
+
79
+ pos_tag = token_info.analyses[0].analysis.get('pos', 'unknown') if token_info.analyses else 'unknown'
80
+
81
+ if pos_tag == 'verb':
82
+ verb_seen = True
83
+ corrected_tokens.append(word)
84
+ continue
85
+
86
+ is_asmaa = any(word.startswith(root) or word.startswith('أ' + root[1:]) for root in self.asmaa_khamsa_roots if len(root)>1)
87
+
88
+ if is_asmaa and len(word) >= 3:
89
+ if verb_seen:
90
+ word = word.replace('ا', 'و').replace('ي', 'و')
91
+ verb_seen = False
92
+
93
+ corrected_tokens.append(word)
94
+
95
+ return " ".join(corrected_tokens)
96
+
97
+ def fix_verbs_nasb_and_jazm(self, text):
98
+ tokens = simple_word_tokenize(text)
99
+ disambig_tokens = self.mle.disambiguate(tokens)
100
+
101
+ nasb_particles = ['أن', 'لن', 'كي', 'لكي', 'حتى', 'إذن']
102
+ jazm_particles = ['لم', 'لما', 'لا']
103
+
104
+ corrected_tokens = []
105
+
106
+ for i, token_info in enumerate(disambig_tokens):
107
+ word = tokens[i]
108
+
109
+ pos_tag = token_info.analyses[0].analysis.get('pos', 'unknown') if token_info.analyses else 'unknown'
110
+
111
+ is_nasb_context = False
112
+ is_jazm_context = False
113
+
114
+ if i > 0:
115
+ prev_word = tokens[i-1]
116
+ if prev_word in nasb_particles or word.startswith('ل'):
117
+ is_nasb_context = True
118
+ if prev_word in jazm_particles or word.startswith('ل') or word.startswith('ول'):
119
+ is_jazm_context = True
120
+
121
+ if pos_tag == 'verb' and (is_nasb_context or is_jazm_context):
122
+ if word.endswith('ون'):
123
+ word = word[:-2] + 'وا'
124
+ elif word.endswith('ان'):
125
+ word = word[:-2] + 'ا'
126
+ elif word.endswith('ين'):
127
+ word = word[:-2] + 'ي'
128
+ elif is_jazm_context:
129
+ if word.endswith('و') and len(word) > 3:
130
+ word = word[:-1] + 'ُ'
131
+ elif (word.endswith('i') or word.endswith('ي')) and len(word) > 3:
132
+ if word.endswith('ي'): word = word[:-1] + 'ِ'
133
+ elif (word.endswith('ى') or word.endswith('ا')) and len(word) > 3:
134
+ word = word[:-1] + 'َ'
135
+
136
+ corrected_tokens.append(word)
137
+ return " ".join(corrected_tokens)
138
+
139
+ def fix_gender_agreement(self, text):
140
+ text = re.sub(r'\bهذان\s+(ال[أ-ي]+تان)\b', r'هاتان \1', text)
141
+ text = re.sub(r'\bهاتان\s+(ال[أ-ي]+[^ت]ان)\b', r'هذان \1', text)
142
+ text = re.sub(r'\bهذهن\b', 'هاتان', text)
143
+
144
+ text = re.sub(r'\bأحد عشر\s+([أ-ي]+ة)\b', r'إحدى عشرة \1', text)
145
+ text = re.sub(r'\bأحد عشرة\s+([أ-ي]+ة)\b', r'إحدى عشرة \1', text)
146
+
147
+ text = re.sub(r'\bإحدى عشرة\s+([أ-ي]+ا|رجل[اأ]|طالب[اأ]|مهندس[اأ])\b', r'أحد عشر \1', text)
148
+ text = re.sub(r'\bإحدى عشر\s+([أ-ي]+ا|رجل[اأ]|طالب[اأ]|مهندس[اأ])\b', r'أحد عشر \1', text)
149
+ return text
150
+
151
+ def fix_prepositions_advanced(self, text):
152
+ # Allow conjunctions (و، ف) before prepositions
153
+ # (في المهندسون) -> (في المهندسين)
154
+ text = re.sub(r'\b([وف]?(?:في|من|إلى|على|عن|حتى))\s+([أ-ي]{2,})(ون|ان)\b', r'\1 \2ين', text)
155
+
156
+ # (وبالمبرمجون) -> (وبالمبرمجين)
157
+ text = re.sub(r'\b([وف]?[بلكف])ال([أ-ي]{2,})(ون|ان)\b', r'\1ال\2ين', text)
158
+
159
+ # (ولمهندسون) -> (ولمهندسين)
160
+ text = re.sub(r'\b([وف]?ل)([أ-ي]{2,})(ون|ان)\b', r'\1\2ين', text)
161
+ return text
162
+
163
+ def regex_rules_fallback(self, text):
164
+ # إن وأخواتها
165
+ text = re.sub(r'\b(إن|أن|كأن|لكن|لعل|ليت)\s+(أبوك|أخوك|ذو|فوك)\b',
166
+ lambda m: f"{m.group(1)} {m.group(2).replace('و', 'ا')}", text)
167
+
168
+ # حروف الجر المنفصلة بمسافة (في أخوك -> في أخيك)
169
+ text = re.sub(r'\b([وف]?(?:في|من|إلى|على|عن))\s+(أبوك|أباك|أخوك|أخاك|ذو|ذا)\b',
170
+ lambda m: f"{m.group(1)} {m.group(2).replace('و', 'ي').replace('ا', 'ي')}", text)
171
+
172
+ # حروف الجر المتصلة بدون مسافة (بأخوك، لأبوك -> بأخيك، لأبيك)
173
+ text = re.sub(r'\b([وف]?[بل])(أبوك|أباك|أخوك|أخاك|ذو|ذا)\b',
174
+ lambda m: f"{m.group(1)}{m.group(2).replace('و', 'ي').replace('ا', 'ي')}", text)
175
+ return text
176
+
177
+ def process(self, original_text, generated_text):
178
+ """Apply all grammar rules to model output."""
179
+ text = self.preserve_numbers(original_text, generated_text)
180
+ text = self.fix_number_and_gender_agreement(text)
181
+ text = self.smart_asmaa_khamsa_fix(text)
182
+ text = self.fix_verbs_nasb_and_jazm(text)
183
+ text = self.fix_gender_agreement(text)
184
+ text = self.fix_prepositions_advanced(text)
185
+ text = self.regex_rules_fallback(text)
186
+ text = re.sub(r'\s+', ' ', text).strip()
187
+ return text
src/nlp/grammar/grammar_service.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Grammar Service — Lazy-loaded Arabic grammar correction.
3
+
4
+ Uses:
5
+ 1. Gradio Client → mohammedahmedezz2004/bayan_arabic_grammarly_correction (seq2seq model)
6
+ 2. ArabicGrammarGuard (camel-tools rule-based post-processing)
7
+
8
+ Model + rules loaded on first request and kept in memory.
9
+ """
10
+
11
+ import logging
12
+ import time
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # ── Lazy-loaded singletons ──
17
+ _grammar_checker = None
18
+ _load_error = None
19
+
20
+ GRADIO_SPACE = "mohammedahmedezz2004/bayan_arabic_grammarly_correction"
21
+
22
+
23
+ class GrammarChecker:
24
+ """
25
+ Grammar correction pipeline:
26
+ 1. Gradio model inference (seq2seq grammar correction)
27
+ 2. Rule-based post-processing (camel-tools ArabicGrammarGuard)
28
+ """
29
+
30
+ def __init__(self, client, rules):
31
+ self.client = client
32
+ self.rules = rules
33
+
34
+ def correct(self, text: str) -> str:
35
+ """
36
+ Run grammar correction on text.
37
+ Returns corrected text, or original text if correction fails.
38
+ """
39
+ if not text or not text.strip():
40
+ return text
41
+
42
+ try:
43
+ # 1. Model inference via Gradio
44
+ model_output = self.client.predict(
45
+ text=text,
46
+ api_name="/correct_grammar"
47
+ )
48
+ logger.info(f"Grammar model output: '{model_output[:80]}...' (input: '{text[:80]}...')")
49
+
50
+ if not model_output or not model_output.strip():
51
+ logger.warning("Grammar model returned empty output, returning original")
52
+ return text
53
+
54
+ # 2. Rule-based post-processing
55
+ corrected = self.rules.process(text, model_output)
56
+ logger.info(f"Grammar rules output: '{corrected[:80]}...'")
57
+
58
+ return corrected
59
+
60
+ except Exception as e:
61
+ logger.error(f"Grammar correction failed: {e}")
62
+ # Graceful degradation: return original text
63
+ return text
64
+
65
+
66
+ 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
+
73
+ if _grammar_checker is not None:
74
+ return _grammar_checker
75
+
76
+ if _load_error is not None:
77
+ raise RuntimeError(f"Grammar model previously failed to load: {_load_error}")
78
+
79
+ try:
80
+ t0 = time.time()
81
+ logger.info("Loading Grammar model (lazy init)...")
82
+
83
+ # 1. Initialize Gradio Client
84
+ logger.info(f"Connecting to Gradio Space: {GRADIO_SPACE}")
85
+ from gradio_client import Client
86
+ client = Client(GRADIO_SPACE)
87
+ logger.info("Gradio Client connected")
88
+
89
+ # 2. Initialize rule-based post-processor (camel-tools)
90
+ logger.info("Loading ArabicGrammarGuard (camel-tools MLE disambiguator)...")
91
+ from nlp.grammar.grammar_rules import ArabicGrammarGuard
92
+ rules = ArabicGrammarGuard()
93
+ logger.info("ArabicGrammarGuard loaded")
94
+
95
+ # 3. Create GrammarChecker instance
96
+ _grammar_checker = GrammarChecker(client, rules)
97
+
98
+ elapsed = time.time() - t0
99
+ logger.info(f"Grammar model ready in {elapsed:.1f}s")
100
+ return _grammar_checker
101
+
102
+ except Exception as e:
103
+ import traceback
104
+ _load_error = str(e)
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
+
110
+ def is_loaded() -> bool:
111
+ """Check if the grammar model is loaded."""
112
+ return _grammar_checker is not None
113
+
114
+
115
+ def get_load_error() -> str:
116
+ """Return the last load error, or empty string."""
117
+ return _load_error or ""