Add migration learning platform: Onboarding, Translation, Parent Portal

Phase 1.1 — user_language_api.py: Stores native language preference
per user (TR/AR/UK/RU/PL/DE/EN). Onboarding page with flag-based
language selection for students and parents.

Phase 1.2 — translation_service.py: Batch-translates vocabulary words
into target languages via Ollama LLM. Stores in translations JSONB.
New endpoint POST /vocabulary/translate triggers translation.

Phase 2.1 — Parent Portal (/parent): Simplified UI in parent's native
language showing child's learning progress. Daily tips translated.

Phase 2.2 — Parent Quiz (/parent/quiz/[unitId]): Parents can quiz
their child on vocabulary WITHOUT speaking DE or EN. Shows word in
child's learning language + parent's native language as hint.
Answer hidden by default, revealed on tap.

All UI text translated into 7 languages (DE/EN/TR/AR/UK/RU/PL).
Arabic gets RTL layout support.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-25 20:17:25 +02:00
parent b49ee3467e
commit d4959172a9
8 changed files with 805 additions and 0 deletions

View File

@@ -119,6 +119,10 @@ app.include_router(progress_router, prefix="/api")
from vocabulary_api import router as vocabulary_router
app.include_router(vocabulary_router, prefix="/api")
# --- 4d. User Language Preferences ---
from user_language_api import router as user_language_router
app.include_router(user_language_router, prefix="/api")
from unit_api import router as unit_router
app.include_router(unit_router) # Already has /api/units prefix

View File

@@ -0,0 +1,179 @@
"""
Translation Service — Batch-translates vocabulary words into target languages.
Uses Ollama (local LLM) to translate EN/DE word pairs into TR, AR, UK, RU, PL.
Translations are cached in vocabulary_words.translations JSONB field.
All processing happens locally — no external API calls, GDPR-compliant.
"""
import json
import logging
import os
from typing import Any, Dict, List
import httpx
logger = logging.getLogger(__name__)
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://host.docker.internal:11434")
TRANSLATION_MODEL = os.getenv("TRANSLATION_MODEL", "qwen3:30b-a3b")
LANGUAGE_NAMES = {
"tr": "Turkish",
"ar": "Arabic",
"uk": "Ukrainian",
"ru": "Russian",
"pl": "Polish",
"fr": "French",
"es": "Spanish",
}
async def translate_words_batch(
words: List[Dict[str, str]],
target_language: str,
batch_size: int = 30,
) -> List[Dict[str, str]]:
"""
Translate a batch of EN/DE word pairs into a target language.
Args:
words: List of dicts with 'english' and 'german' keys
target_language: ISO 639-1 code (tr, ar, uk, ru, pl)
batch_size: Words per LLM request
Returns:
List of dicts with 'english', 'translation', 'example' keys
"""
lang_name = LANGUAGE_NAMES.get(target_language, target_language)
all_translations = []
for i in range(0, len(words), batch_size):
batch = words[i:i + batch_size]
word_list = "\n".join(
f"{j+1}. {w['english']} = {w.get('german', '')}"
for j, w in enumerate(batch)
)
prompt = f"""Translate these English/German word pairs into {lang_name}.
For each word, provide the translation and a short example sentence in {lang_name}.
Words:
{word_list}
Reply ONLY with a JSON array, no explanation:
[
{{"english": "word", "translation": "...", "example": "..."}},
...
]"""
try:
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.post(
f"{OLLAMA_BASE_URL}/api/generate",
json={
"model": TRANSLATION_MODEL,
"prompt": prompt,
"stream": False,
"options": {"temperature": 0.2, "num_predict": 4096},
},
)
resp.raise_for_status()
response_text = resp.json().get("response", "")
# Parse JSON from response
import re
match = re.search(r'\[[\s\S]*\]', response_text)
if match:
batch_translations = json.loads(match.group())
all_translations.extend(batch_translations)
logger.info(
f"Translated batch {i//batch_size + 1}: "
f"{len(batch_translations)} words → {lang_name}"
)
else:
logger.warning(f"No JSON array in LLM response for {lang_name}")
except Exception as e:
logger.error(f"Translation batch failed ({lang_name}): {e}")
return all_translations
async def translate_and_store(
word_ids: List[str],
target_language: str,
) -> int:
"""
Translate vocabulary words and store in the database.
Fetches words from DB, translates via LLM, stores in translations JSONB.
Skips words that already have a translation for the target language.
Returns count of newly translated words.
"""
from vocabulary_db import get_pool
pool = await get_pool()
async with pool.acquire() as conn:
# Fetch words that need translation
rows = await conn.fetch(
"""
SELECT id, english, german, translations
FROM vocabulary_words
WHERE id = ANY($1::uuid[])
""",
[__import__('uuid').UUID(wid) for wid in word_ids],
)
words_to_translate = []
word_map = {}
for row in rows:
translations = row["translations"] or {}
if isinstance(translations, str):
translations = json.loads(translations)
if target_language not in translations:
words_to_translate.append({
"english": row["english"],
"german": row["german"],
})
word_map[row["english"].lower()] = str(row["id"])
if not words_to_translate:
logger.info(f"All {len(rows)} words already translated to {target_language}")
return 0
# Translate
results = await translate_words_batch(words_to_translate, target_language)
# Store results
updated = 0
async with pool.acquire() as conn:
for result in results:
en = result.get("english", "").lower()
word_id = word_map.get(en)
if not word_id:
continue
translation = result.get("translation", "")
example = result.get("example", "")
if not translation:
continue
await conn.execute(
"""
UPDATE vocabulary_words
SET translations = translations || $1::jsonb
WHERE id = $2
""",
json.dumps({target_language: {
"text": translation,
"example": example,
}}),
__import__('uuid').UUID(word_id),
)
updated += 1
logger.info(f"Stored {updated} translations for {target_language}")
return updated

View File

@@ -0,0 +1,86 @@
"""
User Language Preferences API — Stores native language + learning level.
Each user (student, parent, teacher) can set their native language.
This drives: UI language, third-language display in flashcards,
parent portal language, and translation generation.
Supported languages: de, en, tr, ar, uk, ru, pl
"""
import logging
import os
from typing import Any, Dict, Optional
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/user", tags=["user-language"])
# Supported native languages with metadata
SUPPORTED_LANGUAGES = {
"de": {"name": "Deutsch", "name_native": "Deutsch", "flag": "de", "rtl": False},
"en": {"name": "English", "name_native": "English", "flag": "gb", "rtl": False},
"tr": {"name": "Tuerkisch", "name_native": "Turkce", "flag": "tr", "rtl": False},
"ar": {"name": "Arabisch", "name_native": "\u0627\u0644\u0639\u0631\u0628\u064a\u0629", "flag": "sy", "rtl": True},
"uk": {"name": "Ukrainisch", "name_native": "\u0423\u043a\u0440\u0430\u0457\u043d\u0441\u044c\u043a\u0430", "flag": "ua", "rtl": False},
"ru": {"name": "Russisch", "name_native": "\u0420\u0443\u0441\u0441\u043a\u0438\u0439", "flag": "ru", "rtl": False},
"pl": {"name": "Polnisch", "name_native": "Polski", "flag": "pl", "rtl": False},
}
# In-memory store (will be replaced with DB later)
_preferences: Dict[str, Dict[str, Any]] = {}
class LanguagePreference(BaseModel):
native_language: str # ISO 639-1 code
role: str = "student" # student, parent, teacher
learning_level: str = "A1" # A1, A2, B1, B2, C1
@router.get("/languages")
def get_supported_languages():
"""List all supported native languages with metadata."""
return {
"languages": [
{"code": code, **meta}
for code, meta in SUPPORTED_LANGUAGES.items()
]
}
@router.get("/language-preference")
def get_language_preference(user_id: str = Query("default")):
"""Get user's language preference."""
pref = _preferences.get(user_id)
if not pref:
return {"user_id": user_id, "native_language": "de", "role": "student", "learning_level": "A1", "is_default": True}
return {**pref, "is_default": False}
@router.put("/language-preference")
def set_language_preference(
pref: LanguagePreference,
user_id: str = Query("default"),
):
"""Set user's native language and learning level."""
if pref.native_language not in SUPPORTED_LANGUAGES:
raise HTTPException(
status_code=400,
detail=f"Sprache '{pref.native_language}' nicht unterstuetzt. "
f"Verfuegbar: {', '.join(SUPPORTED_LANGUAGES.keys())}",
)
_preferences[user_id] = {
"user_id": user_id,
"native_language": pref.native_language,
"role": pref.role,
"learning_level": pref.learning_level,
}
lang_meta = SUPPORTED_LANGUAGES[pref.native_language]
logger.info(f"Language preference set: user={user_id} lang={pref.native_language} ({lang_meta['name']})")
return {**_preferences[user_id], "language_meta": lang_meta}

View File

@@ -324,3 +324,29 @@ async def api_bulk_import(payload: BulkImportPayload):
count = await insert_words_bulk(words)
logger.info(f"Bulk imported {count} vocabulary words")
return {"imported": count}
# ---------------------------------------------------------------------------
# Translation Generation
# ---------------------------------------------------------------------------
class TranslateRequest(BaseModel):
word_ids: List[str]
target_language: str
@router.post("/translate")
async def api_translate_words(payload: TranslateRequest):
"""Generate translations for vocabulary words into a target language.
Uses local LLM (Ollama) for translation. Results are cached in the
vocabulary_words.translations JSONB field.
"""
from translation_service import translate_and_store
if payload.target_language not in {"tr", "ar", "uk", "ru", "pl", "fr", "es"}:
raise HTTPException(status_code=400, detail=f"Sprache '{payload.target_language}' nicht unterstuetzt")
count = await translate_and_store(payload.word_ids, payload.target_language)
return {"translated": count, "target_language": payload.target_language}