""" Flask backend server for Arabic text summarization. Provides API endpoints for the Bayan web application. """ import os import logging import time from flask import Flask, request, jsonify, Response from flask_cors import CORS from pathlib import Path import traceback import difflib import re # Load .env file from project root (one level up from src/) try: from dotenv import load_dotenv _env_path = Path(__file__).parent.parent / '.env' load_dotenv(dotenv_path=_env_path) except ImportError: pass # python-dotenv not installed; rely on environment variables directly SUPABASE_URL = os.environ.get('SUPABASE_URL', '') SUPABASE_ANON_KEY = os.environ.get('SUPABASE_ANON_KEY', '') from model_loader import ( SummarizationModel, SpellingModel, AutocompleteModel, GrammarModel, PunctuationModel, SUMMARIZATION_PATH, SPELLING_PATH, AUTOCOMPLETE_PATH, GRAMMAR_PATH, PUNCTUATION_PATH ) # HuggingFace Inference API — used in production to avoid RAM limits from hf_inference import ( hf_summarize, hf_correct_spelling, hf_add_punctuation, hf_autocomplete, check_hf_api_available, ) HUGGINGFACE_SUMMARIZATION_REPO = os.environ.get( "SUMMARIZATION_REPO_ID", "bayan10/summarization-model", ) # When HF_API_TOKEN is set, use remote HF Inference API instead of local models. # This avoids loading 500MB+ models into RAM on the free tier. HF_API_TOKEN = os.environ.get('HF_API_TOKEN', '') USE_HF_API = bool(HF_API_TOKEN) # Configure logging logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) logger = logging.getLogger(__name__) # Initialize Flask app app = Flask(__name__, static_folder='.', static_url_path='') CORS(app, resources={r"/api/*": {"origins": "*"}}) # CORS for API routes only # Configuration MAX_TEXT_LENGTH = 5000 # Maximum characters for input text MAX_SUMMARY_LENGTH = 512 # Maximum tokens for summary MIN_TEXT_LENGTH = 10 # Minimum characters for summarization # Global model instances summarization_model = None spelling_model = None autocomplete_model = None grammar_model = None punctuation_model = None def load_models(): """Load models. In HF API mode, load summarization locally; other models gracefully degrade.""" global summarization_model, spelling_model, autocomplete_model, grammar_model, punctuation_model if USE_HF_API: logger.info("HF_API_TOKEN is set — HF API mode enabled") logger.info("NOTE: HF Spaces free tier has NO outbound DNS. Loading summarization model locally.") logger.info("Spelling, punctuation, autocomplete will gracefully degrade (return input unchanged).") # Fall through to load summarization model locally loaded = [] failed = [] # Store startup errors for diagnostics global _startup_errors _startup_errors = [] # Load only the Summarization model locally. try: logger.info(f"Loading summarization model from Hugging Face: {HUGGINGFACE_SUMMARIZATION_REPO}") try: summarization_model = SummarizationModel(HUGGINGFACE_SUMMARIZATION_REPO) except Exception as remote_error: logger.warning(f"Remote load failed, falling back to local model: {remote_error}") _startup_errors.append(f"remote_load: {str(remote_error)[:200]}") logger.info(f"Loading summarization model from local path: {SUMMARIZATION_PATH}") summarization_model = SummarizationModel(SUMMARIZATION_PATH) loaded.append("summarization") logger.info("Summarization model loaded successfully") except Exception as e: import traceback err_detail = traceback.format_exc() failed.append(("summarization", str(e))) _startup_errors.append(f"summarization_load_failed: {err_detail[-500:]}") logger.error(f"Failed to load summarization model: {str(e)}") logger.info(f"Models loaded: {loaded}") if failed: logger.warning(f"Models failed to load: {[f[0] for f in failed]}") return len(loaded) > 0 _startup_errors = [] @app.route('/') def index(): """Serve the main HTML file with Supabase credentials injected.""" html_path = Path(__file__).parent / 'index.html' html = html_path.read_text(encoding='utf-8') # Inject Supabase credentials into the meta tags html = html.replace( '', f'' ) html = html.replace( '', f'' ) return Response(html, mimetype='text/html') @app.route('/api/health', methods=['GET']) def health_check(): """Health check endpoint for production monitoring.""" if USE_HF_API: health = { 'status': 'healthy', 'mode': 'hf_spaces_local', 'models': { 'summarization': summarization_model is not None, 'spelling': _spelling_available(), 'autocomplete': False, 'grammar': False, 'punctuation': False }, 'note': 'Free tier: summarization local, other models return input unchanged', 'supabase': { 'configured': bool(SUPABASE_URL and SUPABASE_ANON_KEY), }, 'environment': 'huggingface_spaces', } status_code = 200 if summarization_model is not None else 503 return jsonify(health), status_code health = { 'status': 'healthy', 'mode': 'local_models', 'models': { 'summarization': summarization_model is not None, 'spelling': spelling_model is not None, 'autocomplete': autocomplete_model is not None, 'grammar': grammar_model is not None, 'punctuation': punctuation_model is not None }, 'supabase': { 'configured': bool(SUPABASE_URL and SUPABASE_ANON_KEY), }, 'environment': 'render' if os.environ.get('RENDER') else 'local', } status_code = 200 if health['models']['summarization'] else 503 return jsonify(health), status_code @app.route('/api/debug-models', methods=['GET']) def debug_models(): """Debug endpoint: report model status and startup errors.""" from hf_inference import debug_test_all_models results = debug_test_all_models() # Memory info import os try: import resource mem = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss mem_info = f"{mem} KB" except Exception: mem_info = "N/A" # /proc/meminfo on Linux proc_mem = {} try: with open('/proc/meminfo', 'r') as f: for line in f: if any(k in line for k in ['MemTotal', 'MemFree', 'MemAvailable', 'SwapTotal']): parts = line.split() proc_mem[parts[0].rstrip(':')] = parts[1] + ' ' + (parts[2] if len(parts) > 2 else '') except Exception: proc_mem = {"error": "cannot read /proc/meminfo"} return jsonify({ 'status': 'debug', 'hf_api_token_set': bool(HF_API_TOKEN), 'summarization_model_loaded': summarization_model is not None, 'startup_errors': _startup_errors, 'memory': mem_info, 'proc_meminfo': proc_mem, 'models': results, }), 200 def _spelling_available(): """Check if spelling model is loaded (without triggering lazy load).""" try: from nlp.spelling.araspell_service import is_loaded return is_loaded() except Exception: return False @app.route('/api/spelling', methods=['POST']) def spelling_correction(): """ Correct spelling in Arabic text. Request JSON: { "text": "Arabic text with spelling errors" } Response JSON: { "original_text": "...", "corrected_text": "...", "status": "success" } """ try: if not request.is_json: return jsonify({'error': 'Request must be JSON', 'status': 'error'}), 400 data = request.get_json() text = data.get('text', '').strip() if not text: return jsonify({'error': 'Text is required', 'status': 'error'}), 400 if len(text) > MAX_TEXT_LENGTH: return jsonify({ 'error': f'Text too long. Maximum {MAX_TEXT_LENGTH} characters.', 'status': 'error' }), 400 logger.info(f"Spelling correction request: text_length={len(text)}") from nlp.spelling.araspell_service import get_spelling_model checker = get_spelling_model() corrected = checker.correct(text) return jsonify({ 'original_text': text, 'corrected_text': corrected, 'status': 'success' }), 200 except RuntimeError as e: logger.error(f"Spelling model error: {e}") return jsonify({ 'error': f'Spelling model unavailable: {str(e)[:200]}', 'status': 'error' }), 503 except Exception as e: logger.error(f"Spelling correction error: {e}") return jsonify({ 'error': f'Spelling correction failed: {str(e)[:200]}', 'status': 'error' }), 500 @app.route('/api/summarize', methods=['POST']) def summarize(): """ Summarize Arabic text. Expected JSON payload: { "text": "Arabic text to summarize", "length": 1-3 (1=short, 2=medium, 3=long), "full_text": true/false (whether to summarize full text or just first paragraph) } """ if summarization_model is None: return jsonify({ 'error': 'Summarization model not loaded. Please check server logs.', 'status': 'error' }), 503 try: # Validate request if not request.is_json: return jsonify({ 'error': 'Request must be JSON', 'status': 'error' }), 400 data = request.get_json() # Validate input text text = data.get('text', '').strip() if not text: return jsonify({ 'error': 'Text is required', 'status': 'error' }), 400 if len(text) < MIN_TEXT_LENGTH: return jsonify({ 'error': f'Text must be at least {MIN_TEXT_LENGTH} characters', 'status': 'error' }), 400 if len(text) > MAX_TEXT_LENGTH: return jsonify({ 'error': f'Text must be at most {MAX_TEXT_LENGTH} characters', 'status': 'error' }), 400 # Get parameters length = int(data.get('length', 2)) # Default to medium length = max(1, min(3, length)) # Clamp between 1 and 3 full_text = data.get('full_text', True) # Calculate max_length based on length parameter # Short: ~30% of input, Medium: ~50%, Long: ~70% input_length = len(text.split()) length_multipliers = {1: 0.3, 2: 0.5, 3: 0.7} max_length = max(20, int(input_length * length_multipliers[length])) max_length = min(max_length, MAX_SUMMARY_LENGTH) # Generate summary logger.info(f"Generating summary: length={length}, max_length={max_length}, text_length={len(text)}") # Always use local model (HF Spaces free tier has no outbound DNS for API calls) summary = summarization_model.summarize(text, max_length=max_length, min_length=max(10, max_length // 3)) return jsonify({ 'summary': summary, 'status': 'success', 'original_length': len(text), 'summary_length': len(summary) }) except ValueError as e: logger.error(f"Validation error: {str(e)}") return jsonify({ 'error': f'Invalid input: {str(e)}', 'status': 'error' }), 400 except Exception as e: logger.error(f"Error during summarization: {str(e)}") logger.error(traceback.format_exc()) return jsonify({ 'error': 'An error occurred during summarization. Please try again.', 'status': 'error', 'details': str(e) if app.debug else None }), 500 @app.route('/api/autocomplete', methods=['POST']) def autocomplete(): """ Get autocomplete suggestions for Arabic text. Expected JSON payload: { "text": "Arabic text prefix", "n": 5 (number of suggestions, optional) } """ if not USE_HF_API and autocomplete_model is None: return jsonify({ 'error': 'Autocomplete model not loaded. Please check server logs.', 'status': 'error' }), 503 try: if not request.is_json: return jsonify({'error': 'Request must be JSON', 'status': 'error'}), 400 data = request.get_json() text = data.get('text', '').strip() n = int(data.get('n', 5)) if not text: return jsonify({'error': 'Text is required', 'status': 'error'}), 400 logger.info(f"Getting autocomplete suggestions for: {text[:50]}...") if USE_HF_API: suggestions = hf_autocomplete(text, n=n) else: suggestions = autocomplete_model.predict(text, n=n) logger.info(f"Autocomplete suggestions (n={n}): {suggestions}") return jsonify({ 'suggestions': suggestions, 'status': 'success' }) except Exception as e: logger.error(f"Error during autocomplete: {str(e)}") logger.error(traceback.format_exc()) return jsonify({ 'error': 'An error occurred during autocomplete.', 'status': 'error', 'details': str(e) if app.debug else None }), 500 @app.route('/api/grammar', methods=['POST']) def grammar_correction(): """ Correct grammar in Arabic text. Expected JSON payload: { "text": "Arabic text to correct" } """ if not USE_HF_API and grammar_model is None: return jsonify({ 'error': 'Grammar model not loaded. Please check server logs.', 'status': 'error' }), 503 try: if not request.is_json: return jsonify({'error': 'Request must be JSON', 'status': 'error'}), 400 data = request.get_json() text = data.get('text', '').strip() if not text: return jsonify({'error': 'Text is required', 'status': 'error'}), 400 logger.info(f"Correcting grammar for text of length: {len(text)}") if USE_HF_API: # Grammar uses spelling model as proxy (no dedicated grammar model yet) corrected = hf_correct_spelling(text) else: corrected = grammar_model.correct(text) return jsonify({ 'corrected': corrected, 'status': 'success', 'original_length': len(text), 'corrected_length': len(corrected) }) except Exception as e: logger.error(f"Error during grammar correction: {str(e)}") logger.error(traceback.format_exc()) return jsonify({ 'error': 'An error occurred during grammar correction.', 'status': 'error', 'details': str(e) if app.debug else None }), 500 @app.route('/api/punctuation', methods=['POST']) def add_punctuation(): """ Add punctuation to Arabic text. Expected JSON payload: { "text": "Arabic text without punctuation" } """ if not USE_HF_API and punctuation_model is None: return jsonify({ 'error': 'Punctuation model not loaded. Please check server logs.', 'status': 'error' }), 503 try: if not request.is_json: return jsonify({'error': 'Request must be JSON', 'status': 'error'}), 400 data = request.get_json() text = data.get('text', '').strip() if not text: return jsonify({'error': 'Text is required', 'status': 'error'}), 400 logger.info(f"Adding punctuation for text of length: {len(text)}") if USE_HF_API: punctuated = hf_add_punctuation(text) else: punctuated = punctuation_model.add_punctuation(text) return jsonify({ 'punctuated': punctuated, 'status': 'success', 'original_length': len(text), 'punctuated_length': len(punctuated) }) except Exception as e: logger.error(f"Error during punctuation: {str(e)}") logger.error(traceback.format_exc()) return jsonify({ 'error': 'An error occurred during punctuation.', 'status': 'error', 'details': str(e) if app.debug else None }), 500 def get_word_positions(text): """ Returns a list of tuples (word, start_char_index, end_char_index) for all whitespace-separated words in the text. """ positions = [] for m in re.finditer(r'\S+', text): positions.append((m.group(), m.start(), m.end())) return positions class OffsetMapper: def __init__(self, original, modified): self.original = original self.modified = modified self.mapping = [] # list of (mod_start, mod_end, orig_start, orig_end) self._build_mapping() def _build_mapping(self): s = difflib.SequenceMatcher(None, self.original, self.modified) for tag, i1, i2, j1, j2 in s.get_opcodes(): self.mapping.append((j1, j2, i1, i2)) def map_offset(self, mod_offset): """ Given a character offset in the modified text, return the corresponding character offset in the original text. """ for j1, j2, i1, i2 in self.mapping: if j1 <= mod_offset <= j2: if j2 == j1: # insertion point return i1 # Proportional mapping inside the block ratio = (mod_offset - j1) / (j2 - j1) return int(i1 + ratio * (i2 - i1)) return len(self.original) def get_word_diffs(original, corrected): """ Identify differences between original and corrected text at the word level. Returns a list of suggestions with start and end character offsets. """ orig_words = get_word_positions(original) corr_words = get_word_positions(corrected) s = difflib.SequenceMatcher(None, [w[0] for w in orig_words], [w[0] for w in corr_words]) suggestions = [] for tag, i1, i2, j1, j2 in s.get_opcodes(): if tag == 'replace': if i1 < len(orig_words) and i2 - 1 < len(orig_words): start_char = orig_words[i1][1] end_char = orig_words[i2-1][2] suggestions.append({ 'start': start_char, 'end': end_char, 'original': original[start_char:end_char], 'correction': " ".join([w[0] for w in corr_words[j1:j2]]), 'type': 'generic' }) elif tag == 'delete': if i1 < len(orig_words) and i2 - 1 < len(orig_words): start_char = orig_words[i1][1] end_char = orig_words[i2-1][2] suggestions.append({ 'start': start_char, 'end': end_char, 'original': original[start_char:end_char], 'correction': '', 'type': 'generic' }) elif tag == 'insert': pos = orig_words[i1][1] if i1 < len(orig_words) else len(original) suggestions.append({ 'start': pos, 'end': pos, 'original': '', 'correction': " ".join([w[0] for w in corr_words[j1:j2]]), 'type': 'generic' }) return suggestions def _levenshtein(a, b): """Simple Levenshtein distance for short words.""" m, n = len(a), len(b) if m == 0: return n if n == 0: return m dp = [[0] * (n + 1) for _ in range(m + 1)] for i in range(m + 1): dp[i][0] = i for j in range(n + 1): dp[0][j] = j for i in range(1, m + 1): for j in range(1, n + 1): cost = 0 if a[i - 1] == b[j - 1] else 1 dp[i][j] = min( dp[i - 1][j] + 1, # deletion dp[i][j - 1] + 1, # insertion dp[i - 1][j - 1] + cost, # substitution ) return dp[m][n] def _is_small_spelling_change(orig_word, corr_word): """ Heuristic: only accept small spelling edits and ignore aggressive changes (to avoid over-editing). """ if not orig_word or not corr_word: return False if orig_word == corr_word: return False # Ignore tokens that contain non-letters (numbers / punctuation) # Arabic letters range plus basic Latin letters. if re.search(r'[^ء-يآأإىa-zA-Z]', orig_word): return False if re.search(r'[^ء-يآأإىa-zA-Z]', corr_word): return False dist = _levenshtein(orig_word, corr_word) max_len = max(len(orig_word), len(corr_word)) # Allow at most 3 character edits and at most 50% of the word # AraSpell has its own validation pipeline, so we can be more permissive here return dist <= 3 and (dist / max_len) <= 0.5 @app.route('/api/analyze', methods=['POST']) def analyze_text(): """ Perform sequential analysis (Spelling -> Grammar -> Punctuation) and return word-level suggestions with offsets. """ try: if not request.is_json: return jsonify({'error': 'Request must be JSON', 'status': 'error'}), 400 data = request.get_json() text = data.get('text', '').strip() if not text: return jsonify({'error': 'Text is required', 'status': 'error'}), 400 current_text = text suggestions = [] mappers = [] total_start = time.time() def map_range_to_original(start, end): curr_start, curr_end = start, end for mapper in reversed(mappers): curr_start = mapper.map_offset(curr_start) curr_end = mapper.map_offset(curr_end) return curr_start, curr_end def _get_spelling_alternatives(original_word, best_correction, spell_checker, max_alts=3): """Generate alternative spelling suggestions for a word.""" alts = [] seen = {best_correction, original_word} # 1. Try edit distance 1 candidates from the spell checker's vocabulary try: clean_w = re.sub(r'[^\w]', '', original_word) edit_cands = spell_checker.edit_corrector.known(spell_checker.edit_corrector.edits1(clean_w)) if edit_cands: ranked = sorted(list(edit_cands), key=lambda x: spell_checker.vocab_manager.get_frequency_rank(x)) for c in ranked: if c not in seen and len(alts) < max_alts - 1: alts.append(c) seen.add(c) except Exception: pass # 2. Always include 'keep as-is' as the last alternative # Return: [best_correction, alt1, alt2, ..., original_word(keep)] result = [best_correction] + alts + [original_word] return result[:max_alts + 1] # cap at max_alts + keep-as-is # 1. Spelling (with conservative post-filtering to avoid over-editing) has_spelling = True # Always available via lazy-loaded araspell_service if has_spelling: try: t0 = time.time() logger.info(f"[ANALYZE] Step 1: Spelling correction starting...") from nlp.spelling.araspell_service import get_spelling_model spell_checker = get_spelling_model() raw_corrected = spell_checker.correct(current_text) logger.info(f"[ANALYZE] Step 1: Spelling done in {time.time()-t0:.2f}s") if raw_corrected != current_text: orig_word_positions = get_word_positions(current_text) corr_word_positions = get_word_positions(raw_corrected) orig_word_strings = [w[0] for w in orig_word_positions] corr_word_strings = [w[0] for w in corr_word_positions] s = difflib.SequenceMatcher(None, orig_word_strings, corr_word_strings) new_words = [] for tag, i1, i2, j1, j2 in s.get_opcodes(): if tag == 'equal': start_idx = orig_word_positions[i1][1] end_idx = orig_word_positions[i2-1][2] new_words.append(current_text[start_idx:end_idx]) elif tag == 'replace': o_segment = orig_word_strings[i1:i2] c_segment = corr_word_strings[j1:j2] start_idx = orig_word_positions[i1][1] end_idx = orig_word_positions[i2-1][2] if len(o_segment) == 1 and len(c_segment) == 1: # 1-word → 1-word: accept only small edits (typos) o_word = o_segment[0] c_word = c_segment[0] if _is_small_spelling_change(o_word, c_word): new_words.append(c_word) suggestions.append({ 'start': start_idx, 'end': end_idx, 'original': o_word, 'correction': c_word, 'type': 'spelling', 'alternatives': _get_spelling_alternatives(o_word, c_word, spell_checker), }) else: new_words.append(current_text[start_idx:end_idx]) elif len(o_segment) == 1 and len(c_segment) > 1: # 1-word → N words: accept word splits (e.g. فيالمدرسة → في المدرسة) o_word = o_segment[0] if len(o_word) >= 5 and ' ' not in o_word: corr_str = " ".join(c_segment) new_words.append(corr_str) suggestions.append({ 'start': start_idx, 'end': end_idx, 'original': o_word, 'correction': corr_str, 'type': 'spelling', 'alternatives': [corr_str, o_word], }) else: new_words.append(current_text[start_idx:end_idx]) else: # N→M replacement: process each original word individually # Build a mapping by trying to match original words to corrected words corr_joined = " ".join(c_segment) ci = 0 # cursor into c_segment for oi in range(i1, i2): o_word = orig_word_strings[oi] o_start = orig_word_positions[oi][1] o_end = orig_word_positions[oi][2] if ci < len(c_segment): c_word = c_segment[ci] # Check if this is a 1→1 small edit if _is_small_spelling_change(o_word, c_word): new_words.append(c_word) suggestions.append({ 'start': o_start, 'end': o_end, 'original': o_word, 'correction': c_word, 'type': 'spelling', 'alternatives': _get_spelling_alternatives(o_word, c_word, spell_checker), }) ci += 1 # Check if this is a 1→N word split elif len(o_word) >= 5 and ci + 1 < len(c_segment): # Try to consume multiple corrected words for this one original word split_parts = [c_segment[ci]] temp_ci = ci + 1 joined = c_segment[ci] while temp_ci < len(c_segment) and len(joined) < len(o_word) + 2: joined += c_segment[temp_ci] split_parts.append(c_segment[temp_ci]) temp_ci += 1 # Check if the joined parts roughly match the original corr_str = " ".join(split_parts) joined_no_space = "".join(split_parts) dist = _levenshtein(o_word, joined_no_space) if dist <= 3 and len(split_parts) > 1: new_words.append(corr_str) suggestions.append({ 'start': o_start, 'end': o_end, 'original': o_word, 'correction': corr_str, 'type': 'spelling', 'alternatives': [corr_str, o_word], }) ci = temp_ci else: new_words.append(current_text[o_start:o_end]) ci += 1 else: new_words.append(current_text[o_start:o_end]) ci += 1 else: new_words.append(current_text[o_start:o_end]) elif tag == 'delete': for idx in range(i1, i2): new_words.append(current_text[orig_word_positions[idx][1]:orig_word_positions[idx][2]]) elif tag == 'insert': continue safe_text = " ".join(new_words) mappers.append(OffsetMapper(current_text, safe_text)) current_text = safe_text except Exception as e: logger.error(f"[ANALYZE] Spelling failed: {e}") # 2. Grammar (runs on spelling-corrected text) has_grammar = USE_HF_API or grammar_model if has_grammar: try: t0 = time.time() logger.info(f"[ANALYZE] Step 2: Grammar correction starting...") if USE_HF_API: corrected_grammar = hf_correct_spelling(current_text) else: corrected_grammar = grammar_model.correct(current_text) logger.info(f"[ANALYZE] Step 2: Grammar done in {time.time()-t0:.2f}s") if corrected_grammar != current_text: diffs = get_word_diffs(current_text, corrected_grammar) for d in diffs: orig_start, orig_end = map_range_to_original(d['start'], d['end']) suggestions.append({ 'start': orig_start, 'end': orig_end, 'original': text[orig_start:orig_end], 'correction': d['correction'], 'type': 'grammar' }) mappers.append(OffsetMapper(current_text, corrected_grammar)) current_text = corrected_grammar except Exception as e: logger.error(f"[ANALYZE] Grammar failed: {e}") # 3. Punctuation (runs on grammar-corrected text) has_punctuation = USE_HF_API or punctuation_model if has_punctuation: try: t0 = time.time() logger.info(f"[ANALYZE] Step 3: Punctuation starting...") if USE_HF_API: corrected_punc = hf_add_punctuation(current_text) else: corrected_punc = punctuation_model.add_punctuation(current_text) logger.info(f"[ANALYZE] Step 3: Punctuation done in {time.time()-t0:.2f}s") if corrected_punc != current_text: diffs = get_word_diffs(current_text, corrected_punc) for d in diffs: orig_start, orig_end = map_range_to_original(d['start'], d['end']) suggestions.append({ 'start': orig_start, 'end': orig_end, 'original': text[orig_start:orig_end], 'correction': d['correction'], 'type': 'punctuation' }) current_text = corrected_punc except Exception as e: logger.error(f"[ANALYZE] Punctuation failed: {e}") total_time = time.time() - total_start logger.info(f"[ANALYZE] Total analysis time: {total_time:.2f}s | Suggestions: {len(suggestions)}") return jsonify({ 'original': text, 'corrected': current_text, 'suggestions': suggestions, 'status': 'success' }) except Exception as e: logger.error(f"Error during analysis: {str(e)}") logger.error(traceback.format_exc()) return jsonify({ 'error': 'An error occurred during text analysis.', 'status': 'error', 'details': str(e) if app.debug else None }), 500 @app.errorhandler(404) def not_found(error): """Handle 404 errors.""" return jsonify({ 'error': 'Endpoint not found', 'status': 'error' }), 404 @app.errorhandler(500) def internal_error(error): """Handle 500 errors.""" logger.error(f"Internal server error: {str(error)}") return jsonify({ 'error': 'Internal server error', 'status': 'error' }), 500 # ── Gunicorn startup hook ── # When running under gunicorn, __name__ != '__main__', so we need # to load models eagerly when the module is imported. _models_loaded = False def _ensure_models_loaded(): global _models_loaded if _models_loaded: return _models_loaded = True logger.info("Loading models (production startup)...") if not load_models(): logger.error("Failed to load any models. Server will start but functionality will be limited.") # Load models on import (gunicorn imports this module, __name__ != '__main__') _ensure_models_loaded() if __name__ == '__main__': # Load models on startup (development) _ensure_models_loaded() # Run the app port = int(os.environ.get('PORT', 5000)) debug = os.environ.get('DEBUG', 'False').lower() == 'true' logger.info(f"Starting server on port {port} (debug={debug})") app.run(host='0.0.0.0', port=port, debug=debug)