Spaces:
Build error
Build error
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.
|