EuropaLex / app.py
Takosaga's picture
fix: remove mock cards in app.py
80aa142
Raw
History Blame Contribute Delete
20.2 kB
#!/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}"), (
'<div style="color:#c44; padding:20px;">'
'<strong>Model file not found.</strong><br>'
f'{e}<br><br>'
'Run <code>python models/download_models.py minicpm</code> to download MiniCPM5-1B, '
'or check <code>configs/settings.yaml</code> for the correct path.'
'</div>'
)
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}"), (
'<div style="color:#c44; padding:20px;">'
f'<strong>Failed to initialize engine.</strong><br>{e}<br><br>'
'Check <code>configs/settings.yaml</code> and run the smoke test: '
'<code>python tests/smoke_test.py</code>'
'</div>'
)
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"), (
'<div style="color:#c44; padding:20px;">'
f'<strong>MiniCPM5-1B generation failed.</strong><br>'
f'{err_detail}<br><br>'
'Possible causes:<br>'
'• llama-cpp-python not installed — run: <code>uv pip install llama-cpp-python</code><br>'
'• Model file corrupted or incompatible format<br>'
'• Insufficient VRAM (~1.1 GB required)<br><br>'
'Check the terminal for full error output.'
'</div>'
)
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."), (
'<div style="color:#c44; padding:20px;">'
'No Phase 1 text found. Generate English text first, then click "Generate Cards".'
'</div>'
)
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}"), (
'<div style="color:#c44; padding:20px;">'
'<strong>Model file not found.</strong><br>'
f'{e}<br><br>'
'Run <code>python models/download_models.py tiny_aya</code> to download tiny-aya-water, '
'or check <code>configs/settings.yaml</code> for the correct path.'
'</div>'
)
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}"), (
'<div style="color:#c44; padding:20px;">'
f'<strong>Failed to initialize engine.</strong><br>{e}<br><br>'
'Check <code>configs/settings.yaml</code> and run the smoke test: '
'<code>python tests/smoke_test.py</code>'
'</div>'
)
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}"), (
'<div style="color:#c44; padding:20px;">'
f'<strong>Failed to initialize translation engine.</strong><br>'
f'{err_detail}<br><br>'
'Check <code>configs/settings.yaml</code> for the model path.'
'</div>'
)
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."), (
'<div style="color:#c44; padding:20px;">'
'<strong>Translation failed.</strong><br>No translations were produced. '
'Check the terminal for error details.'
'</div>'
)
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=<relative-path> 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,
)