feat: Professional editor UI redesign - 3-column layout (docs | editor | sidebar) - Documents panel always visible on desktop - Import/export moved to left sidebar - Removed CTA button from nav - Replaced all emojis with SVG icons - Clean toolbar with separator - Mobile: docs stay as overlay sidebar
040f556 | """ | |
| 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 = [] | |
| 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( | |
| '<meta name="supabase-url" content="">', | |
| f'<meta name="supabase-url" content="{SUPABASE_URL}">' | |
| ) | |
| html = html.replace( | |
| '<meta name="supabase-anon-key" content="">', | |
| f'<meta name="supabase-anon-key" content="{SUPABASE_ANON_KEY}">' | |
| ) | |
| return Response(html, mimetype='text/html') | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| def not_found(error): | |
| """Handle 404 errors.""" | |
| return jsonify({ | |
| 'error': 'Endpoint not found', | |
| 'status': 'error' | |
| }), 404 | |
| 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) | |