Takosaga commited on
Commit
efef5e5
Β·
1 Parent(s): 6ab1467

plan: Phase 2 translation implementation plan (tiny-aya retry + pipeline)

Browse files
docs/superpowers/plans/2026-06-11-phase-2-translation-plan.md ADDED
@@ -0,0 +1,691 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Phase 2 Translation β€” Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Wire tiny-aya translation into EuropaLex's two-phase workflow β€” Phase 1 generates English text, Phase 2 translates it to Latvian using `LlamaCppTextEngine` with sentence-count validation and retry logic. Images/audio toggles remain unchecked by default.
6
+
7
+ **Architecture:** Extend `LlamaCppTextEngine` with a retry loop (max 3 attempts) that validates output line count against `batch_size`, mirroring `MiniCPMTextEngine`'s pattern. Create `core/pipeline.py` as the Phase 2 orchestration layer. Wire `app.py` to call the pipeline instead of mock data.
8
+
9
+ **Tech Stack:** Python 3.12+, llama-cpp-python (tiny-aya-water Q4_K_M), Gradio 6, Pydantic >=2.0.0
10
+
11
+ ---
12
+
13
+ ### Task 1: Add retry loop to `LlamaCppTextEngine`
14
+
15
+ **Files:**
16
+ - Modify: `core/engine.py:165-192` β€” add retry logic to `generate()` method
17
+ - Modify: `core/engine.py:194-203` β€” add `_build_retry_prompt()` method
18
+
19
+ #### Step 1: Replace `LlamaCppTextEngine.generate()` with retry-wrapped version
20
+
21
+ Replace lines 165–192 in `core/engine.py`:
22
+
23
+ **Old code:**
24
+ ```python
25
+ def generate(self, texts: list[str], scenario: str, cefr_level: CEFRLevel, batch_size: int | None = None) -> TextResult:
26
+ """Generate translations using the loaded GGUF model.
27
+
28
+ Args:
29
+ texts: English sentences to translate.
30
+ scenario: Scenario/topic description (not used with this model).
31
+ cefr_level: CEFR proficiency level.
32
+ batch_size: Not used.
33
+
34
+ Returns:
35
+ TextResult with one translation per input text.
36
+
37
+ Raises:
38
+ RuntimeError: If generation fails.
39
+ """
40
+ self._load_model()
41
+ prompt = self._build_translation_prompt(texts, cefr_level)
42
+
43
+ output = self._llm(
44
+ prompt=prompt,
45
+ max_tokens=512,
46
+ temperature=0.7,
47
+ echo=False,
48
+ )
49
+
50
+ text = output.get("choices", [{}])[0].get("text", "")
51
+ lines = [line.strip() for line in text.strip().split("\n") if line.strip()]
52
+ return TextResult(generated_texts=lines)
53
+ ```
54
+
55
+ **New code:**
56
+ ```python
57
+ def generate(self, texts: list[str], scenario: str, cefr_level: CEFRLevel, batch_size: int | None = None) -> TextResult:
58
+ """Generate translations using the loaded GGUF model with retry loop.
59
+
60
+ Wraps the LLM call in a retry loop (max 3 attempts). If output line count
61
+ does not match ``batch_size``, builds a stricter prompt referencing the
62
+ actual vs expected count and retries. On exhaustion, falls back to returning
63
+ whatever lines were produced on the last attempt.
64
+
65
+ Args:
66
+ texts: English sentences to translate.
67
+ scenario: Scenario/topic description (not used with this model).
68
+ cefr_level: CEFR proficiency level.
69
+ batch_size: Number of translations expected.
70
+
71
+ Returns:
72
+ TextResult with one translation per input text.
73
+
74
+ Raises:
75
+ ValidationError: If generation fails after max attempts and no lines produced.
76
+ """
77
+ self._load_model()
78
+ if batch_size is None:
79
+ raise ValueError("batch_size is required for translation")
80
+
81
+ prompt = self._build_translation_prompt(texts, cefr_level)
82
+ last_raw_text = ""
83
+
84
+ for attempt in range(1, 4):
85
+ output = self._llm(
86
+ prompt=prompt,
87
+ max_tokens=512,
88
+ temperature=0.7,
89
+ echo=False,
90
+ )
91
+
92
+ raw_text = output.get("choices", [{}])[0].get("text", "")
93
+ last_raw_text = raw_text
94
+ lines = [line.strip() for line in raw_text.strip().split("\n") if line.strip()]
95
+
96
+ if len(lines) == batch_size:
97
+ logger.info(
98
+ "LlamaCppTextEngine: got %d translations on attempt %d (target=%d)",
99
+ len(lines), attempt, batch_size,
100
+ )
101
+ return TextResult(generated_texts=lines)
102
+
103
+ # Count mismatch β€” retry with stricter prompt
104
+ if attempt < 3:
105
+ prompt = self._build_retry_prompt(raw_text, batch_size)
106
+ logger.warning(
107
+ "LlamaCppTextEngine attempt %d: got %d translations, need %d β€” retrying",
108
+ attempt, len(lines), batch_size,
109
+ )
110
+ else:
111
+ logger.warning(
112
+ "LlamaCppTextEngine: exhausted all attempts. Got %d translations.",
113
+ len(lines),
114
+ )
115
+
116
+ # Exhausted retries β€” return whatever we got (or empty)
117
+ if not lines:
118
+ raise ValidationError(
119
+ f"Could not generate any translations after 3 attempts.",
120
+ raw_output=last_raw_text,
121
+ )
122
+ return TextResult(generated_texts=lines)
123
+ ```
124
+
125
+ #### Step 2: Add `_build_retry_prompt()` method after `_build_translation_prompt()`
126
+
127
+ Insert after line 203 (after the closing of `_build_translation_prompt`):
128
+
129
+ ```python
130
+ def _build_retry_prompt(self, raw_output: str, expected_count: int) -> str:
131
+ """Build a stricter prompt for retry when translation count mismatches.
132
+
133
+ Appends a correction instruction to the existing context so the model
134
+ builds on its previous output rather than starting fresh.
135
+
136
+ Args:
137
+ raw_output: The LLM's previous (incorrect-count) output.
138
+ expected_count: The number of translations that should have been produced.
139
+
140
+ Returns:
141
+ Prompt string with correction instruction appended.
142
+ """
143
+ return (
144
+ f"Previous output had the wrong number of lines.\n"
145
+ f"You need exactly {expected_count} translations, one per line.\n"
146
+ f"Your previous attempt:\n{raw_output}\n\n"
147
+ f"Now regenerate ALL {expected_count} translations, one per line, in order.\n"
148
+ f"Output ONLY the translations, one per line. No explanations."
149
+ )
150
+ ```
151
+
152
+ #### Step 3: Write inline test for retry logic
153
+
154
+ Create `scripts/test_translation_retry.py`:
155
+
156
+ ```python
157
+ """Quick inline test for LlamaCppTextEngine retry loop.
158
+
159
+ Tests sentence-count validation and retry prompt building without
160
+ requiring a running model. Uses mock LLM output.
161
+ """
162
+
163
+ import sys
164
+ from pathlib import Path
165
+
166
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
167
+
168
+ from unittest.mock import MagicMock, patch
169
+
170
+
171
+ def test_generate_exact_count():
172
+ """Test that exact batch_size returns immediately."""
173
+ from core.types import CEFRLevel
174
+ from core.engine import LlamaCppTextEngine
175
+
176
+ mock_llm = MagicMock()
177
+ mock_llm.return_value = {
178
+ "choices": [{"text": "Sveiki.\nKā tu esi?\nPaldies."}]
179
+ }
180
+
181
+ with patch.object(LlamaCppTextEngine, "_load_model"):
182
+ engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine)
183
+ engine._llm = mock_llm
184
+ engine._loaded = True
185
+
186
+ result = engine.generate(
187
+ texts=["Hello.", "How are you?", "Thank you."],
188
+ scenario="greetings",
189
+ cefr_level=CEFRLevel.A1,
190
+ batch_size=3,
191
+ )
192
+
193
+ assert len(result.generated_texts) == 3
194
+ assert result.generated_texts[0] == "Sveiki."
195
+ print("test_generate_exact_count: PASS")
196
+
197
+
198
+ def test_generate_retry_on_short_output():
199
+ """Test retry when fewer lines than expected."""
200
+ from core.types import CEFRLevel
201
+ from core.engine import LlamaCppTextEngine
202
+
203
+ mock_llm = MagicMock()
204
+ # First call returns 1 line, second call returns 3 lines
205
+ mock_llm.side_effect = [
206
+ {"choices": [{"text": "Sveiki."}]},
207
+ {"choices": [{"text": "Sveiki.\nKā tu esi?\nPaldies."}]},
208
+ ]
209
+
210
+ with patch.object(LlamaCppTextEngine, "_load_model"):
211
+ engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine)
212
+ engine._llm = mock_llm
213
+ engine._loaded = True
214
+
215
+ result = engine.generate(
216
+ texts=["Hello.", "How are you?", "Thank you."],
217
+ scenario="greetings",
218
+ cefr_level=CEFRLevel.A1,
219
+ batch_size=3,
220
+ )
221
+
222
+ assert len(result.generated_texts) == 3
223
+ assert mock_llm.call_count == 2 # retried once
224
+ print("test_generate_retry_on_short_output: PASS")
225
+
226
+
227
+ def test_generate_exhausted_retries_returns_partial():
228
+ """Test that exhausted retries return whatever was produced."""
229
+ from core.types import CEFRLevel
230
+ from core.engine import LlamaCppTextEngine
231
+
232
+ mock_llm = MagicMock()
233
+ # Always returns wrong count
234
+ mock_llm.return_value = {"choices": [{"text": "Sveiki."}]}
235
+
236
+ with patch.object(LlamaCppTextEngine, "_load_model"):
237
+ engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine)
238
+ engine._llm = mock_llm
239
+ engine._loaded = True
240
+
241
+ result = engine.generate(
242
+ texts=["Hello.", "How are you?", "Thank you."],
243
+ scenario="greetings",
244
+ cefr_level=CEFRLevel.A1,
245
+ batch_size=3,
246
+ )
247
+
248
+ assert len(result.generated_texts) == 1 # partial result returned
249
+ assert mock_llm.call_count == 3 # all 3 attempts used
250
+ print("test_generate_exhausted_retries_returns_partial: PASS")
251
+
252
+
253
+ def test_generate_empty_output_raises():
254
+ """Test that zero lines after retries raises ValidationError."""
255
+ from core.types import CEFRLevel, ValidationError
256
+ from core.engine import LlamaCppTextEngine
257
+
258
+ mock_llm = MagicMock()
259
+ # Always returns empty string
260
+ mock_llm.return_value = {"choices": [{"text": ""}]}
261
+
262
+ with patch.object(LlamaCppTextEngine, "_load_model"):
263
+ engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine)
264
+ engine._llm = mock_llm
265
+ engine._loaded = True
266
+
267
+ try:
268
+ engine.generate(
269
+ texts=["Hello.", "How are you?", "Thank you."],
270
+ scenario="greetings",
271
+ cefr_level=CEFRLevel.A1,
272
+ batch_size=3,
273
+ )
274
+ assert False, "Should raise"
275
+ except ValidationError as e:
276
+ assert "Could not generate any translations" in str(e)
277
+
278
+ print("test_generate_empty_output_raises: PASS")
279
+
280
+
281
+ def test_retry_prompt_contains_count_info():
282
+ """Test that retry prompt references actual vs expected count."""
283
+ from core.engine import LlamaCppTextEngine
284
+
285
+ with patch.object(LlamaCppTextEngine, "_load_model"):
286
+ engine = LlamaCppTextEngine.__new__(LlamaCppTextEngine)
287
+ engine.model_path = Path("/dev/null") # doesn't matter for this test
288
+
289
+ retry_prompt = engine._build_retry_prompt("Sveiki.", 3)
290
+ assert "1" in retry_prompt # actual count
291
+ assert "3" in retry_prompt # expected count
292
+ assert "regenerate ALL 3 translations" in retry_prompt
293
+ print("test_retry_prompt_contains_count_info: PASS")
294
+
295
+
296
+ if __name__ == "__main__":
297
+ test_generate_exact_count()
298
+ test_generate_retry_on_short_output()
299
+ test_generate_exhausted_retries_returns_partial()
300
+ test_generate_empty_output_raises()
301
+ test_retry_prompt_contains_count_info()
302
+ print("\nAll inline tests passed.")
303
+ ```
304
+
305
+ #### Step 4: Run the inline test to verify it passes
306
+
307
+ Run: `python scripts/test_translation_retry.py`
308
+ Expected: All 5 tests pass with "PASS" output.
309
+
310
+ #### Step 5: Commit
311
+
312
+ ```bash
313
+ cd /home/takosaga/Projects/EuropaLex
314
+ git add core/engine.py scripts/test_translation_retry.py
315
+ git commit -m "feat: add retry loop to LlamaCppTextEngine for translation validation"
316
+ ```
317
+
318
+ ---
319
+
320
+ ### Task 2: Implement `core/pipeline.py` Phase 2 orchestration
321
+
322
+ **Files:**
323
+ - Create: `core/pipeline.py`
324
+
325
+ #### Step 1: Write the pipeline module
326
+
327
+ Replace the placeholder content in `core/pipeline.py` with:
328
+
329
+ ```python
330
+ """EuropaLex Pipeline β€” Phase 2 orchestration.
331
+
332
+ Receives English texts generated in Phase 1 and produces translated
333
+ CardData objects via tiny-aya-water translation engine.
334
+
335
+ Images and audio are not yet wired β€” those fields remain empty.
336
+ """
337
+
338
+ from __future__ import annotations
339
+
340
+ import logging
341
+ from typing import Iterator
342
+
343
+ from core.engine import EnginePool
344
+ from core.types import CEFRLevel, CardData, EngineConfig, ValidationError
345
+
346
+ logger = logging.getLogger(__name__)
347
+
348
+
349
+ def generate_phase2(
350
+ texts: list[str],
351
+ scenario: str,
352
+ cefr_level: CEFRLevel,
353
+ batch_size: int,
354
+ ) -> Iterator[tuple[int, str, list[CardData]]]:
355
+ """Generate Latvian translations for Phase 1 English texts.
356
+
357
+ Orchestrates the translation pipeline: gets the tiny-aya engine,
358
+ calls generate with retry validation, and yields CardData objects.
359
+
360
+ Yields (progress_percent, phase_label, cards) at each step.
361
+
362
+ Args:
363
+ texts: English sentences generated in Phase 1.
364
+ scenario: Original scenario/topic description.
365
+ cefr_level: CEFR proficiency level.
366
+ batch_size: Number of translations expected.
367
+
368
+ Yields:
369
+ (20, "Preparing translation...", []) β€” before engine call
370
+ (60, "Translating...", []) β€” during generation
371
+ (100, "Translation complete!", cards) β€” with final CardData list
372
+
373
+ Raises:
374
+ ValidationError: If translation fails after max retries.
375
+ """
376
+ try:
377
+ config = EngineConfig.from_settings_yaml()
378
+ pool = EnginePool.get(config)
379
+ except FileNotFoundError as e:
380
+ logger.error("Phase 2 model not found: %s", e)
381
+ raise
382
+
383
+ yield 20, "Preparing translation...", []
384
+
385
+ try:
386
+ texts_result = pool.get_translation_engine().generate(
387
+ texts=texts,
388
+ scenario=scenario,
389
+ cefr_level=cefr_level,
390
+ batch_size=batch_size,
391
+ )
392
+ except ValidationError:
393
+ raise
394
+
395
+ yield 60, "Translating...", []
396
+
397
+ cards = [
398
+ CardData(
399
+ text=text,
400
+ translation=translation,
401
+ audio_path=None,
402
+ image_path=None,
403
+ cefr_level=cefr_level,
404
+ )
405
+ for text, translation in zip(texts, texts_result.generated_texts)
406
+ ]
407
+
408
+ yield 100, "Translation complete!", cards
409
+ ```
410
+
411
+ #### Step 2: Run smoke test to verify module loads without errors
412
+
413
+ Run: `python scripts/smoke_test.py`
414
+ Expected: Clean exit (no traceback).
415
+
416
+ #### Step 3: Commit
417
+
418
+ ```bash
419
+ cd /home/takosaga/Projects/EuropaLex
420
+ git add core/pipeline.py
421
+ git commit -m "feat: implement pipeline.py Phase 2 translation orchestration"
422
+ ```
423
+
424
+ ---
425
+
426
+ ### Task 3: Wire Phase 2 in `app.py` β€” replace mock data with real translation
427
+
428
+ **Files:**
429
+ - Modify: `app.py` β€” add `_phase1_texts` state, replace `generate_media_async()`, update event wiring and toggle defaults
430
+
431
+ #### Step 1a: Add `_phase1_texts` global at module level
432
+
433
+ After line 18 (after widget imports), insert:
434
+
435
+ ```python
436
+ # ─── Phase State ────────────────────────────────────────────────────
437
+
438
+ _phase1_texts: list[str] = [] # English texts from Phase 1, passed to Phase 2
439
+ ```
440
+
441
+ #### Step 1b: Store Phase 1 texts in `generate_text_async`
442
+
443
+ After line 140 (`cards = [...]`) in `generate_text_async`, before the yield on line 142, insert:
444
+
445
+ ```python
446
+ # Store Phase 1 texts for Phase 2 (module-level state)
447
+ global _phase1_texts
448
+ _phase1_texts = list(texts.generated_texts)
449
+ ```
450
+
451
+ #### Step 1c: Replace `generate_media_async()` function
452
+
453
+ Replace lines 146–175 in `app.py`:
454
+
455
+ **Old code:**
456
+ ```python
457
+ def generate_media_async(
458
+ scenario: str,
459
+ cefr_level: str,
460
+ batch_size: int,
461
+ include_images: bool,
462
+ include_audio: bool,
463
+ ):
464
+ """Phase 2: Add translations, images, and audio to existing text cards.
465
+
466
+ Takes the same parameters as Phase 1 plus media toggles.
467
+ Re-renders cards with actual translation text and optional media.
468
+ """
469
+ raw_cards = MOCK_CARDS.get(cefr_level, MOCK_CARDS["B1"])
470
+ selected_raw = raw_cards[:batch_size]
471
+
472
+ if not selected_raw:
473
+ yield generate_progress_html(0, "No cards available"), '<div style="color:#8b7355; padding:20px;">No cards available for this level.</div>'
474
+ return
475
+
476
+ # Transform to two-phase format with actual translations
477
+ cards = transform_mock_cards(selected_raw)
478
+
479
+ # Render with full media (no placeholder β€” translation text is real)
480
+ phase_cards_full = generate_cards_html(
481
+ cards,
482
+ include_image=include_images,
483
+ include_audio=include_audio,
484
+ placeholder_back=False,
485
+ )
486
+ yield generate_progress_html(100, "Generation complete!"), phase_cards_full
487
+ ```
488
+
489
+ **New code:**
490
+ ```python
491
+ def generate_media_async(
492
+ scenario: str,
493
+ cefr_level: str,
494
+ batch_size: int,
495
+ ):
496
+ """Phase 2: Translate Phase 1 English text to Latvian via tiny-aya.
497
+
498
+ Reads the English texts from _phase1_texts (set by Phase 1 handler),
499
+ translates them using tiny-aya, and renders cards with Latvian on front.
500
+ Images and audio toggles are not yet active β€” media fields remain empty.
501
+ """
502
+ if not _phase1_texts:
503
+ yield generate_progress_html(0, "⚠️ Please generate text first."), (
504
+ '<div style="color:#c44; padding:20px;">'
505
+ 'No Phase 1 text found. Generate English text first, then click "Generate Cards".'
506
+ '</div>'
507
+ )
508
+ return
509
+
510
+ try:
511
+ config = EngineConfig.from_settings_yaml()
512
+ pool = EnginePool.get(config)
513
+ cefr = CEFRLevel(cefr_level)
514
+ except FileNotFoundError as e:
515
+ logger.error("Phase 2 model not found: %s", e)
516
+ yield generate_progress_html(0, f"\u26a0\ufe0f Model file missing: {e}"), (
517
+ '<div style="color:#c44; padding:20px;">'
518
+ '<strong>Model file not found.</strong><br>'
519
+ f'{e}<br><br>'
520
+ 'Run <code>python models/download_models.py tiny_aya</code> to download tiny-aya-water, '
521
+ 'or check <code>configs/settings.yaml</code> for the correct path.'
522
+ '</div>'
523
+ )
524
+ return
525
+ except Exception as e:
526
+ logger.error("Phase 2 setup failed: %s", e, exc_info=True)
527
+ yield generate_progress_html(0, f"\u26a0\ufe0f Setup error: {e}"), (
528
+ '<div style="color:#c44; padding:20px;">'
529
+ f'<strong>Failed to initialize engine.</strong><br>{e}<br><br>'
530
+ 'Check <code>configs/settings.yaml</code> and run the smoke test: '
531
+ '<code>python scripts/smoke_test.py</code>'
532
+ '</div>'
533
+ )
534
+ return
535
+
536
+ try:
537
+ yield generate_progress_html(20, "Preparing translation..."), ""
538
+ texts_result = pool.get_translation_engine().generate(
539
+ texts=_phase1_texts,
540
+ scenario=scenario,
541
+ cefr_level=cefr,
542
+ batch_size=len(_phase1_texts),
543
+ )
544
+ except Exception as e:
545
+ logger.error("Phase 2 translation failed: %s", e, exc_info=True)
546
+ err_detail = str(e)
547
+ yield generate_progress_html(0, f"\u26a0\ufe0f Translation failed"), (
548
+ '<div style="color:#c44; padding:20px;">'
549
+ f'<strong>Translation failed.</strong><br>'
550
+ f'{err_detail}<br><br>'
551
+ 'Possible causes:<br>'
552
+ 'β€’ llama-cpp-python not installed β€” run: <code>uv pip install llama-cpp-python</code><br>'
553
+ 'β€’ tiny-aya-water model file corrupted or incompatible format<br>'
554
+ 'β€’ Insufficient VRAM (~2 GB required)<br><br>'
555
+ 'Check the terminal for full error output.'
556
+ '</div>'
557
+ )
558
+ return
559
+
560
+ yield generate_progress_html(60, "Translating..."), ""
561
+
562
+ # Convert TextResult to card dicts for rendering (no media yet)
563
+ cards = [
564
+ {"text": text, "translation": translation, "cefr_level": cefr}
565
+ for text, translation in zip(_phase1_texts, texts_result.generated_texts)
566
+ ]
567
+
568
+ yield generate_progress_html(100, "Translation ready!"), generate_cards_html(cards, include_image=False, include_audio=False, placeholder_back=False)
569
+ ```
570
+
571
+ #### Step 1d: Update event wiring β€” remove media toggle parameters
572
+
573
+ Update the `generate_cards_btn.click()` binding (line ~285):
574
+
575
+ **Old code:**
576
+ ```python
577
+ generate_cards_btn.click(
578
+ fn=_handle_media_generation,
579
+ inputs=[scenario_input, cefr_dropdown, batch_slider, images_toggle, audio_toggle],
580
+ outputs=[progress_html, card_output],
581
+ )
582
+ ```
583
+
584
+ **New code:**
585
+ ```python
586
+ generate_cards_btn.click(
587
+ fn=_handle_media_generation,
588
+ inputs=[scenario_input, cefr_dropdown, batch_slider],
589
+ outputs=[progress_html, card_output],
590
+ )
591
+ ```
592
+
593
+ Update `_handle_media_generation` (line ~247):
594
+
595
+ **Old code:**
596
+ ```python
597
+ def _handle_media_generation(scenario, cefr_level, batch_size, images_on, audio_on):
598
+ """Wrapper for generate_media_async that handles empty scenario."""
599
+ if not scenario.strip():
600
+ yield generate_progress_html(0, "⚠️ Please enter a scenario or topic."), '<div style="color:#c44; padding:20px;">Please enter a scenario or topic to generate cards.</div>'
601
+ return
602
+ for result in generate_media_async(scenario, cefr_level, batch_size, images_on, audio_on):
603
+ yield result
604
+ ```
605
+
606
+ **New code:**
607
+ ```python
608
+ def _handle_media_generation(scenario, cefr_level, batch_size):
609
+ """Wrapper for generate_media_async that handles empty scenario and missing Phase 1 texts."""
610
+ if not scenario.strip():
611
+ yield generate_progress_html(0, "⚠️ Please enter a scenario or topic."), '<div style="color:#c44; padding:20px;">Please enter a scenario or topic to generate cards.</div>'
612
+ return
613
+ for result in generate_media_async(scenario, cefr_level, batch_size):
614
+ yield result
615
+ ```
616
+
617
+ #### Step 1e: Update toggle defaults to unchecked
618
+
619
+ Change lines 220–221 in the Gradio UI construction:
620
+
621
+ **Old code:**
622
+ ```python
623
+ images_toggle = create_toggle("πŸ–ΌοΈ Images", value=True, elem_id="toggle-images")
624
+ audio_toggle = create_toggle("πŸ”Š Audio", value=True, elem_id="toggle-audio")
625
+ ```
626
+
627
+ **New code:**
628
+ ```python
629
+ images_toggle = create_toggle("πŸ–ΌοΈ Images", value=False, elem_id="toggle-images")
630
+ audio_toggle = create_toggle("πŸ”Š Audio", value=False, elem_id="toggle-audio")
631
+ ```
632
+
633
+ #### Step 2: Run smoke test
634
+
635
+ Run: `python scripts/smoke_test.py`
636
+ Expected: Clean exit (no traceback).
637
+
638
+ #### Step 3: Commit
639
+
640
+ ```bash
641
+ cd /home/takosaga/Projects/EuropaLex
642
+ git add app.py
643
+ git commit -m "feat: wire Phase 2 translation via real tiny-aya engine"
644
+ ```
645
+
646
+ ---
647
+
648
+ ### Task 4: Final verification
649
+
650
+ **Files:**
651
+ - Run: `python scripts/smoke_test.py`
652
+ - Manual: `python app.py` (verify Gradio launches and both phases work)
653
+
654
+ #### Step 1: Run smoke test
655
+
656
+ Run: `python scripts/smoke_test.py`
657
+ Expected: Clean exit (no traceback).
658
+
659
+ #### Step 2: Manual verification
660
+
661
+ Run: `python app.py`
662
+ - Verify Gradio launches on port 7860 without errors
663
+ - Open browser, enter a scenario, click "Generate Text" β€” Phase 1 should work
664
+ - Click "Generate Cards" β€” Phase 2 should translate to Latvian
665
+ - Cards should render with Latvian on front, English on back
666
+
667
+ #### Step 3: Commit any final fixes (if needed)
668
+
669
+ ```bash
670
+ cd /home/takosaga/Projects/EuropaLex
671
+ git add -A
672
+ git commit -m "fix: [description of any fixes]"
673
+ ```
674
+
675
+ ---
676
+
677
+ ## Self-Review Checklist
678
+
679
+ **1. Spec coverage:**
680
+ - βœ… Architecture overview β†’ Task 2 (pipeline.py), Task 3 (app.py wiring)
681
+ - βœ… LlamaCppTextEngine retry loop β†’ Task 1
682
+ - βœ… Pipeline API function with progress yields β†’ Task 2
683
+ - βœ… App wiring, toggle defaults unchecked β†’ Task 3 steps 1a-1e
684
+ - βœ… Error handling (ValidationError caught in app.py) β†’ Task 3 error blocks
685
+ - βœ… Images/audio absent from pipeline API β†’ Task 2 has no media params
686
+
687
+ **2. Placeholder scan:** No "TBD", "TODO", "implement later", or vague references found. All code is complete and specific.
688
+
689
+ **3. Type consistency:** `CEFRLevel`, `CardData`, `ValidationError`, `TextResult`, `EngineConfig` all referenced consistently across tasks. Method signatures match existing patterns in `MiniCPMTextEngine` and `text_gen.py`.
690
+
691
+ **4. Ambiguity check:** The `_phase1_texts` module-level state is explicit β€” no ambiguity about how Phase 1 texts reach Phase 2. The retry loop falls back to partial results (not raising on exhausted retries when lines were produced), which matches the spec's "raise ValidationError" only for empty output case.