#!/usr/bin/env python3 """EuropaLex — Gradio Frontend Demo Interactive flashcard generator UI with mock data. No backend connection — visual preview only. Run: uv sync && python app.py """ # ─── Disable Gradio's BrotliMiddleware BEFORE any other imports ──── # Gradio 6.x adds BrotliMiddleware which has a known bug with h11: # For streaming responses (generator yields), the middleware deletes # Content-Length and uses chunked transfer encoding, but h11 can # miscalculate content length from the first compressed chunk, # then reject subsequent chunks as "Too much data for declared # Content-Length". We disable it by replacing the class in routes.py. try: import gradio.routes as _routes_mod from gradio.brotli_middleware import BrotliMiddleware class _PassthroughMiddleware: """Drop-in replacement that passes all requests through uncompressed.""" def __init__(self, app, **kwargs): self.app = app async def __call__(self, scope, receive, send): await self.app(scope, receive, send) _routes_mod.BrotliMiddleware = _PassthroughMiddleware # type: ignore[assignment] except Exception: pass # Non-critical; app will work without this patch # ─── Fix Gradio file download Content-Length bug ────────────────────── # Gradio 6.x uses Starlette FileResponse which can cause "Too little data # for declared Content-Length" with h11 on streaming file downloads. # The root cause: FileResponse.set_stat_headers() sets Content-Length based # on the file size. With h11, this causes a protocol error during chunked # transfer. We patch FileResponse to skip setting Content-Length so h11 # falls back to chunked transfer encoding. # # IMPORTANT: Gradio imports FileResponse directly into its modules (routes.py, # route_utils.py). Simply replacing starlette.responses.FileResponse is not # enough — we must also patch Gradio's cached references. try: from starlette.responses import FileResponse as _FileResponseBase import starlette.responses as _sr_mod class _NoContentLengthFileResponse(_FileResponseBase): """FileResponse that never sets Content-Length to avoid h11 bugs.""" def set_stat_headers(self, stat_result): """Override to skip setting Content-Length (keeps last-modified and etag).""" last_modified = _sr_mod.formatdate(stat_result.st_mtime, usegmt=True) etag_base = str(stat_result.st_mtime) + "-" + str(stat_result.st_size) import hashlib etag = '"' + hashlib.md5(etag_base.encode(), usedforsecurity=False).hexdigest() + '"' self.headers.setdefault("last-modified", last_modified) self.headers.setdefault("etag", etag) # Deliberately NOT setting content-length # Patch Starlette's module-level reference _sr_mod.FileResponse = _NoContentLengthFileResponse # type: ignore[assignment] # Also patch Gradio's cached references (they imported FileResponse directly) import gradio.route_utils as _ru_mod if hasattr(_ru_mod, 'FileResponse'): _ru_mod.FileResponse = _NoContentLengthFileResponse # type: ignore[assignment] import gradio.routes as _rt_mod if hasattr(_rt_mod, 'FileResponse'): _rt_mod.FileResponse = _NoContentLengthFileResponse # type: ignore[assignment] import gradio.static_server as _ss_mod if hasattr(_ss_mod, 'FileResponse'): _ss_mod.FileResponse = _NoContentLengthFileResponse # type: ignore[assignment] except Exception: pass # Non-critical; app will work without this patch import logging import os from pathlib import Path logger = logging.getLogger(__name__) # ─── Auto-download models on first run ──────────────────────── # Ensures the app works out-of-the-box (e.g. HF Spaces) without # baking 26 GB of model weights into git. def _auto_download_models(): """Download all models if none are present yet.""" models_dir = Path( os.environ.get("EUROPALEX_MODELS_DIR", ".local/models") ) # Check for GGUF files (llama-cpp-python models). If missing, download # everything — this covers the fresh-install case. Flux safetensors are # not checked separately because if GGUF is missing but safetensors exist, # the user still needs MiniCPM/tiny-aya and a full download is correct. if not any(models_dir.rglob("*.gguf")): print(f"No models found in {models_dir}. Downloading...") try: from models.download_models import download_all download_all(output_dir=str(models_dir)) except Exception as e: logger.error("Auto-download failed: %s", e) raise RuntimeError( f"Model download failed. Please run:\n" f" python -m models.download_models\n" f"Or set EUROPALEX_MODELS_DIR to a directory with model files." ) from e _auto_download_models() # ─── Phase State ──────────────────────────────────────────────────── _phase1_texts: list[str] = [] # English texts from Phase 1, passed to Phase 2 _current_cards: list[dict] = [] # Full card data after Phase 2 (with media paths) def generate_text_async( scenario: str, cefr_level: str, batch_size: int, ): """Phase 1: Generate English text only using MiniCPM5-1B (no translation). Yields (progress_html, card_output_html) tuples. Cards show English text with dashed placeholder back side. Phase 2 (translation + media) is deferred — stays as mock data. """ # Load config and get engine try: from core.engine import EnginePool, MiniCPMTextEngine from core.types import CEFRLevel, EngineConfig from frontend.ui.cards import generate_progress_html config = EngineConfig.from_settings_yaml() pool = EnginePool.get(config) engine = pool.get_english_engine() cefr = CEFRLevel(cefr_level) except FileNotFoundError as e: logger.error("Phase 1 model not found: %s", e) yield generate_progress_html(0, f"\u26a0\ufe0f Model file missing: {e}"), ( '
' 'Model file not found.
' f'{e}

' 'Run python models/download_models.py minicpm to download MiniCPM5-1B, ' 'or check configs/settings.yaml for the correct path.' '
' ) return except Exception as e: logger.error("Phase 1 setup failed: %s", e, exc_info=True) yield generate_progress_html(0, f"\u26a0\ufe0f Setup error: {e}"), ( '
' f'Failed to initialize engine.
{e}

' 'Check configs/settings.yaml and run the smoke test: ' 'python tests/smoke_test.py' '
' ) return # Generate English text via MiniCPM5-1B try: yield generate_progress_html(20, "Preparing MiniCPM5-1B generation..."), "" texts = engine.generate( texts=[], # empty = generation mode (not translation) scenario=scenario, cefr_level=cefr, batch_size=batch_size, topic_description=scenario, # user's free-form topic description ) except Exception as e: logger.error("Phase 1 generation failed: %s", e, exc_info=True) err_detail = str(e) yield generate_progress_html(0, f"\u26a0\ufe0f Generation failed"), ( '
' f'MiniCPM5-1B generation failed.
' f'{err_detail}

' 'Possible causes:
' '• llama-cpp-python not installed — run: uv pip install llama-cpp-python
' '• Model file corrupted or incompatible format
' '• Insufficient VRAM (~1.1 GB required)

' 'Check the terminal for full error output.' '
' ) return # Store Phase 1 texts for Phase 2 (module-level state) global _phase1_texts _phase1_texts = list(texts.generated_texts) # Convert TextResult to card dicts for rendering from frontend.ui.cards import generate_cards_html cards = [ {"text": t, "translation": "", "cefr_level": cefr, "topic_description": scenario} for t in texts.generated_texts ] yield generate_progress_html(60, "Generating text..."), "" yield generate_progress_html(100, "Text ready! Adjust media toggles and click Generate Cards."), generate_cards_html(cards, include_image=False, include_audio=False, placeholder_back=True) def _progress_pct( translated_idx: int, total: int, start_pct: float = 15.0, end_pct: float = 70.0, ) -> tuple[float, str]: """Calculate progress percentage for translation within a given range. Args: translated_idx: Index of the sentence just completed (0-based). total: Total number of sentences to translate. start_pct: Starting percentage for this phase (default 15% after preparation). end_pct: Ending percentage for this phase (default 70% before next phase). Returns: (percentage, label) tuple. """ if total <= 1: return end_pct, "Translation complete!" pct = start_pct + ((translated_idx + 1) / total) * (end_pct - start_pct) remaining = total - (translated_idx + 1) if pct >= end_pct: return end_pct, "Translation complete!" return round(pct, 1), f"Translated {translated_idx + 1}/{total} — {remaining} remaining..." def generate_media_async( scenario: str, cefr_level: str, batch_size: int, target_language: str = "Latvian", include_audio: bool = False, include_images: bool = False, voice: str = "female, young adult", ): """Phase 2: Translate Phase 1 English text and optionally generate TTS audio. Reads the English texts from _phase1_texts (set by Phase 1 handler), translates each sentence one-by-one via tiny-aya, optionally generates TTS audio for all translations via OmniVoice (voice design mode), and yields progressive card updates so cards appear incrementally. """ global _phase1_texts if not _phase1_texts: from frontend.ui.cards import generate_progress_html yield generate_progress_html(0, "⚠️ Please generate text first."), ( '
' 'No Phase 1 text found. Generate English text first, then click "Generate Cards".' '
' ) return # Save Phase 1 texts for this generation pass. Keep _phase1_texts intact so # the user can change language and regenerate media without re-generating text. _current_texts = list(_phase1_texts) try: from core.engine import EnginePool from core.types import CEFRLevel, EngineConfig from frontend.ui.cards import generate_progress_html, generate_cards_html config = EngineConfig.from_settings_yaml() pool = EnginePool.get(config) cefr = CEFRLevel(cefr_level) except FileNotFoundError as e: logger.error("Phase 2 model not found: %s", e) yield generate_progress_html(0, f"\u26a0\ufe0f Model file missing: {e}"), ( '
' 'Model file not found.
' f'{e}

' 'Run python models/download_models.py tiny_aya to download tiny-aya-water, ' 'or check configs/settings.yaml for the correct path.' '
' ) return except Exception as e: logger.error("Phase 2 setup failed: %s", e, exc_info=True) yield generate_progress_html(0, f"\u26a0\ufe0f Setup error: {e}"), ( '
' f'Failed to initialize engine.
{e}

' 'Check configs/settings.yaml and run the smoke test: ' 'python tests/smoke_test.py' '
' ) return yield generate_progress_html(10, "Preparing translation engine..."), "" # Get the translation engine (lazy-loads tiny-aya) try: from core.engine import LlamaCppTextEngine translation_engine = pool.get_translation_engine() except Exception as e: logger.error("Phase 2 failed to get translation engine: %s", e, exc_info=True) err_detail = str(e) yield generate_progress_html(0, f"\u26a0\ufe0f Engine error: {err_detail}"), ( '
' f'Failed to initialize translation engine.
' f'{err_detail}

' 'Check configs/settings.yaml for the model path.' '
' ) return # Build cards one-by-one — each sentence translated individually cards: list[dict] = [] total = len(_phase1_texts) for i, english_text in enumerate(_current_texts): try: translation = translation_engine._translate_single( english_text, cefr, topic_description=scenario, target_language=target_language, ) except Exception as e: logger.error("Translation failed for sentence %d: %s", i, e, exc_info=True) # Fallback: use English text as translation translation = english_text cards.append({ "text": english_text, "translation": translation, "cefr_level": cefr, "topic_description": scenario, }) pct, label = _progress_pct(i, total, start_pct=15.0, end_pct=70.0) yield generate_progress_html(pct, label), generate_cards_html( cards, include_image=include_images, include_audio=include_audio, placeholder_back=False ) # Generate TTS audio for all translations if requested image_paths: list[str | None] = [None] * len(cards) tts_generated = False if include_audio and cards: yield generate_progress_html(70, "Generating audio..."), generate_cards_html( cards, include_image=include_images, include_audio=True, placeholder_back=False ) try: from core.audio_gen import TTSEngine tts_engine = pool.get_tts_engine() output_dir = Path(config.models_dir) / "output" / "audio" translations_list = [c["translation"] for c in cards] audio_result = tts_engine.synthesize(translations_list, output_dir, language=target_language, instruct=voice) audio_paths = audio_result.audio_paths # Attach audio paths to cards for i, path in enumerate(audio_paths): if path is not None: cards[i]["audio_path"] = path tts_generated = True except Exception as e: logger.error("TTS generation failed: %s", e, exc_info=True) # Cards remain without audio — user can retry tts_generated = False # Generate images for all translations if requested if include_images and cards: yield generate_progress_html(85, "Generating images..."), generate_cards_html( cards, include_image=True, include_audio=tts_generated, placeholder_back=False ) try: from core.image_gen import ImageGenEngine img_engine = pool.get_image_engine() output_dir = Path(config.models_dir) / "output" / "images" # Build prompts from English text + CEFR level prompts = [] for card in cards: prompt = ( f"Simple educational illustration with NO TEXT for language learning for the following text: {card['text']}. " ) prompts.append(prompt) image_result = img_engine.generate(prompts, output_dir) image_paths = image_result.image_paths # Attach image paths to cards for i, path in enumerate(image_paths): if path is not None: cards[i]["image_path"] = path except Exception as e: logger.error("Image generation failed: %s", e, exc_info=True) # Cards remain without images — user can retry # Save cards for export (before final yield) global _current_cards _current_cards = [dict(c) for c in cards] # Final yield with 100% if not cards: yield generate_progress_html(0, "\u26a0\ufe0f No translations produced."), ( '
' 'Translation failed.
No translations were produced. ' 'Check the terminal for error details.' '
' ) else: if include_images: if tts_generated: final_label = "Translation, audio, and images complete!" else: final_label = "Translation and images complete!" else: final_label = "Translation and audio complete!" if tts_generated else "Translation complete!" # Always include generated media regardless of toggle state so previously # generated audio/images remain accessible after toggling off/on. yield generate_progress_html(100, final_label), generate_cards_html( cards, include_image=include_images, include_audio=tts_generated, placeholder_back=False ) def _handle_export_csv( scenario: str, cefr_level: str, target_language: str, ) -> str | None: """Export current cards as a zipped CSV folder. Returns the absolute path to the generated .zip file for Gradio DownloadButton. Returns None if no cards to export or export failed. """ if not _current_cards: logger.warning("CSV export: no cards to export") return None try: from core.types import CEFRLevel from export.csv_export import export_csv_zip cefr = CEFRLevel(cefr_level) zip_path = export_csv_zip(_current_cards, scenario, cefr_level, target_language) return zip_path except Exception as e: logger.error("CSV export failed: %s", e, exc_info=True) return None def _handle_export_csv_for_anki( scenario: str, cefr_level: str, target_language: str, ) -> str | None: """Export current cards as an Anki-compatible .apkg file via genanki. Returns the absolute path to the generated .apkg file for Gradio DownloadButton. Returns None if no cards to export or export failed. """ if not _current_cards: logger.warning("Anki .apkg export: no cards to export") return None try: from core.types import CEFRLevel from export.apkg_export import export_csv_for_anki cefr = CEFRLevel(cefr_level) zip_path = export_csv_for_anki(_current_cards, scenario, cefr_level, target_language) return zip_path except Exception as e: logger.error("Anki CSV export failed: %s", e, exc_info=True) return None if __name__ == "__main__": from frontend.ui.widgets import build_ui css_path = os.path.join(os.path.dirname(__file__), "frontend", "css", "custom.css") with open(css_path, "r") as f: css_content = f.read() # Register the project root as a static directory so generated audio/images are accessible # inside gr.HTML output via /gradio_api/file= URLs. project_root = Path(__file__).resolve().parent import gradio as gr gr.set_static_paths(paths=[project_root]) app = build_ui() app.launch( server_name="0.0.0.0", server_port=7860, css=css_content, )