# Copyright (c) 2025 Patrick Motsch # All rights reserved. """ Public and authenticated routes for UI language sets (DB-backed i18n). Architecture: - xx = base set (meta): key = source plaintext (German or English, as written in the code via ``t("...")``), value = UI context for AI - All languages (incl. de) are AI-generated translations from xx - AI translation pipeline uses context from xx to disambiguate translations; the prompt forces the output language to be exactly the requested target. """ from __future__ import annotations import asyncio import json import logging import math import re from pathlib import Path from typing import Any, Dict, List, Optional, Set from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, Request, UploadFile, status from fastapi.responses import Response from pydantic import BaseModel, Field from modules.auth import getCurrentUser, requireSysAdmin, requirePlatformAdmin from modules.connectors.connectorDbPostgre import _get_cached_connector from modules.datamodels.datamodelAi import ( AiCallOptions, AiCallRequest, AiCallResponse, OperationTypeEnum, PriorityEnum, ) from modules.datamodels.datamodelUiLanguage import I18nEntry, UiLanguageSet from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelRbac import Role from modules.datamodels.datamodelFeatures import Feature from modules.datamodels.datamodelNotification import NotificationType from modules.interfaces.interfaceDbManagement import getInterface as getMgmtInterface from modules.routes.routeNotifications import _createNotification from modules.shared.configuration import APP_CONFIG from modules.shared.i18nRegistry import ( _enforceSourcePlaceholders, _loadCache as _reloadI18nCache, apiRouteContext, ) from modules.shared.timeUtils import getUtcTimestamp routeApiMsg = apiRouteContext("routeI18n") logger = logging.getLogger(__name__) router = APIRouter( prefix="/api/i18n", tags=["i18n"], responses={404: {"description": "Not found"}}, ) _MIN_AI_BILLING_ESTIMATE_CHF = 0.01 _TRANSLATE_BATCH_SIZE = 80 _TRANSLATE_BATCH_PAUSE_S = 2.0 _TRANSLATE_RATE_LIMIT_MAX_RETRIES = 3 _PROTECTED_CODES = frozenset({"xx"}) # In-memory set of language codes currently being updated (sync / create). _UPDATING_CODES: Set[str] = set() # --------------------------------------------------------------------------- # ISO 639-1 label map (used when creating a language without explicit label) # --------------------------------------------------------------------------- _ISO_LABELS: Dict[str, str] = { "de": "Deutsch", "gsw": "Schweizerdeutsch", "en": "English", "fr": "Français", "it": "Italiano", "es": "Español", "pt": "Português", "nl": "Nederlands", "pl": "Polski", "cs": "Čeština", "sk": "Slovenčina", "sv": "Svenska", "no": "Norsk", "da": "Dansk", "fi": "Suomi", "hu": "Magyar", "ro": "Română", "bg": "Български", "hr": "Hrvatski", "sl": "Slovenščina", "et": "Eesti", "lv": "Latviešu", "lt": "Lietuvių", "el": "Ελληνικά", "tr": "Türkçe", "ru": "Русский", "uk": "Українська", "ar": "العربية", "he": "עברית", "zh": "中文", "ja": "日本語", "ko": "한국어", "hi": "हिन्दी", "th": "ไทย", "vi": "Tiếng Việt", "id": "Bahasa Indonesia", "ms": "Bahasa Melayu", "tl": "Filipino", "sw": "Kiswahili", "af": "Afrikaans", "sq": "Shqip", "am": "አማርኛ", "hy": "Հայերեն", "az": "Azərbaycan", "eu": "Euskara", "be": "Беларуская", "bn": "বাংলা", "bs": "Bosanski", "ca": "Català", "cy": "Cymraeg", "eo": "Esperanto", "fa": "فارسی", "ga": "Gaeilge", "gl": "Galego", "gu": "ગુજરાતી", "ha": "Hausa", "is": "Íslenska", "jv": "Basa Jawa", "ka": "ქართული", "kk": "Қазақ", "km": "ខ្មែរ", "kn": "ಕನ್ನಡ", "ku": "Kurdî", "ky": "Кыргызча", "la": "Latina", "lb": "Lëtzebuergesch", "lo": "ລາວ", "mk": "Македонски", "ml": "മലയാളം", "mn": "Монгол", "mr": "मराठी", "mt": "Malti", "my": "မြန်မာ", "ne": "नेपाली", "or": "ଓଡ଼ିଆ", "pa": "ਪੰਜਾਬੀ", "ps": "پښتو", "si": "සිංහල", "so": "Soomaali", "sr": "Српски", "su": "Basa Sunda", "ta": "தமிழ்", "te": "తెలుగు", "tg": "Тоҷикӣ", "tk": "Türkmen", "ur": "اردو", "uz": "Oʻzbek", "yo": "Yorùbá", "zu": "isiZulu", } # --------------------------------------------------------------------------- # DB helpers # --------------------------------------------------------------------------- def _publicMgmtDb(): return _get_cached_connector( dbHost=APP_CONFIG.get("DB_HOST", "localhost"), dbDatabase="poweron_management", dbUser=APP_CONFIG.get("DB_USER"), dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET"), dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), userId="__i18n_public__", ) def _rowEntries(row: dict) -> List[dict]: """Read entries from a DB row, supporting both new (entries) and legacy (keys) format.""" entries = row.get("entries") if isinstance(entries, list) and entries: return entries keys = row.get("keys") if isinstance(keys, dict) and keys: return [{"context": "ui", "key": k, "value": v} for k, v in keys.items()] return [] def _entriesToKeyValueMap(entries: List[dict]) -> Dict[str, str]: """Convert entries list to a flat key->value map (for frontend consumption).""" return {e["key"]: e.get("value", "") for e in entries if e.get("key")} def _row_to_public(row: dict) -> dict: entries = _rowEntries(row) return { "code": row["id"], "label": row.get("label"), "status": row.get("status"), "entries": entries, } def _loadMasterXxEntries(db) -> List[dict]: """Load the xx base set entries from DB.""" rows = db.getRecordset(UiLanguageSet, recordFilter={"id": "xx"}) if not rows: return [] return _rowEntries(rows[0]) def _userMemberMandateIds(currentUser: User) -> List[str]: from modules.interfaces.interfaceDbApp import getRootInterface root = getRootInterface() memberships = root.getUserMandates(str(currentUser.id)) out = [] for um in memberships: mid = getattr(um, "mandateId", None) or ( um.get("mandateId") if isinstance(um, dict) else None ) if mid: out.append(str(mid)) return list(dict.fromkeys(out)) def _mandatePassesAiPoolBilling(currentUser: User, mandateId: str, userId: str) -> bool: from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface bi = getBillingInterface(currentUser, mandateId) res = bi.checkBalance(mandateId, userId, _MIN_AI_BILLING_ESTIMATE_CHF) return bool(res.allowed) # --------------------------------------------------------------------------- # AI Translation helpers # --------------------------------------------------------------------------- _aiObjectsSingleton = None async def _getAiObjects(): global _aiObjectsSingleton if _aiObjectsSingleton is None: from modules.interfaces.interfaceAiObjects import AiObjects _aiObjectsSingleton = await AiObjects.create() return _aiObjectsSingleton def _makeBillingCallback(currentUser: User, mandateId: str): from modules.serviceCenter.services.serviceBilling.mainServiceBilling import getService as getBillingService billingService = getBillingService(currentUser, mandateId) def _cb(response: AiCallResponse) -> None: if not response or getattr(response, "errorCount", 0) > 0: return basePriceCHF = getattr(response, "priceCHF", 0.0) if not basePriceCHF or basePriceCHF <= 0: return provider = getattr(response, "provider", None) or "unknown" modelName = getattr(response, "modelName", None) or "unknown" try: billingService.recordUsage( priceCHF=basePriceCHF, aicoreProvider=provider, aicoreModel=modelName, description=f"i18n translation ({modelName})", processingTime=getattr(response, "processingTime", None), bytesSent=getattr(response, "bytesSent", None), bytesReceived=getattr(response, "bytesReceived", None), ) except Exception as e: logger.error("i18n billing callback failed: %s", e) return _cb async def _translateBatch( keysToTranslate: Dict[str, str], targetLanguageLabel: str, targetCode: str, billingCallback=None, ) -> Dict[str, str]: """Translate German keys into targetLanguageLabel. keysToTranslate: { germanKey: uiContext } Returns: { germanKey: translatedValue } """ if not keysToTranslate: return {} aiObjects = await _getAiObjects() allKeys = list(keysToTranslate.items()) totalBatches = math.ceil(len(allKeys) / _TRANSLATE_BATCH_SIZE) result: Dict[str, str] = {} for batchIdx in range(totalBatches): chunk = allKeys[batchIdx * _TRANSLATE_BATCH_SIZE : (batchIdx + 1) * _TRANSLATE_BATCH_SIZE] payload = [{"key": k, "context": v} for k, v in chunk] jsonPayload = json.dumps(payload, ensure_ascii=False) systemPrompt = ( f"You are a professional translator for software UI texts. " f"You receive a JSON array of objects: {{\"key\": \"source text\", \"context\": \"UI context\"}}. " f"The source text is written in German OR English. " f"The context describes where the text is used in the application (file, component). " f"\n\n" f"HARD REQUIREMENTS (must all be satisfied):\n" f"1. OUTPUT LANGUAGE: every translated value MUST be written in {targetLanguageLabel} " f"(ISO code \"{targetCode}\"). Never output in German or English if that is not " f"the target language. No mixing of languages.\n" f"2. If the source is already in the target language, keep it (do not re-translate, " f"do not paraphrase).\n" f"3. KEEP the exact JSON keys from the input — do NOT translate or modify the keys.\n" f"4. PLACEHOLDERS ARE SACRED. Tokens of the form {{name}}, {{count}}, " f"{{konten}}, {{anyWord}}, %s, %(name)s, %d MUST be copied character-for-" f"character into the translation, EVEN IF the name inside the curly braces " f"looks like a German or English word. Never translate, rename, reorder, " f"add, or remove placeholders. Example: '{{konten}} Konten' translated to " f"English MUST stay '{{konten}} accounts' — NEVER '{{accounts}} accounts'.\n" f"5. Preserve leading/trailing whitespace, punctuation and capitalisation pattern.\n" f"6. Answer ONLY with a JSON object mapping source-key -> translated value in " f"{targetLanguageLabel}. No markdown fences, no comments, no explanations.\n" f"7. If a key cannot be translated (empty, pure symbols, URLs), return the source unchanged." ) aiRequest = AiCallRequest( prompt=( f"Translate the following UI labels into {targetLanguageLabel} " f"(ISO {targetCode}). Source may be German or English. " f"Respond with a pure JSON object only.\n{jsonPayload}" ), context=systemPrompt, options=AiCallOptions( operationType=OperationTypeEnum.DATA_GENERATE, priority=PriorityEnum.BALANCED, compressPrompt=False, compressContext=False, resultFormat="json", temperature=0.2, ), ) if billingCallback: aiObjects.billingCallback = billingCallback batchDone = False for retryAttempt in range(_TRANSLATE_RATE_LIMIT_MAX_RETRIES): try: response = await aiObjects.callWithTextContext(aiRequest) if response and response.content: raw = response.content.strip() if raw.startswith("```"): raw = re.sub(r"^```[a-z]*\n?", "", raw) raw = re.sub(r"\n?```$", "", raw) parsed = json.loads(raw) if isinstance(parsed, dict): result.update(parsed) else: logger.warning("i18n AI batch %d/%d returned non-dict", batchIdx + 1, totalBatches) else: logger.warning("i18n AI batch %d/%d empty response", batchIdx + 1, totalBatches) batchDone = True break except json.JSONDecodeError as je: logger.error("i18n AI batch %d/%d JSON parse error: %s", batchIdx + 1, totalBatches, je) batchDone = True break except Exception as e: errStr = str(e) if "rate_limit" in errStr.lower() or "429" in errStr or "Rate limit" in errStr: waitSec = _TRANSLATE_BATCH_PAUSE_S * (2 ** retryAttempt) logger.warning( "i18n AI batch %d/%d rate-limited (attempt %d/%d), waiting %.1fs", batchIdx + 1, totalBatches, retryAttempt + 1, _TRANSLATE_RATE_LIMIT_MAX_RETRIES, waitSec, ) await asyncio.sleep(waitSec) continue logger.error("i18n AI batch %d/%d failed: %s", batchIdx + 1, totalBatches, e) batchDone = True break finally: aiObjects.billingCallback = None if not batchDone: logger.error("i18n AI batch %d/%d exhausted rate-limit retries", batchIdx + 1, totalBatches) if batchIdx < totalBatches - 1: await asyncio.sleep(_TRANSLATE_BATCH_PAUSE_S) _enforcePlaceholdersOnBatch(result) _matchCapitalization(keysToTranslate, result) return result def _enforcePlaceholdersOnBatch(translations: Dict[str, str]) -> None: """Ensure every translated value preserves the source key's placeholders. See ``_enforceSourcePlaceholders`` for the detailed strategy. Mutates ``translations`` in place; logs a warning per repaired key. """ repaired = 0 for sourceKey, translatedValue in list(translations.items()): fixed, changed = _enforceSourcePlaceholders(sourceKey, translatedValue) if changed: translations[sourceKey] = fixed repaired += 1 logger.warning( "i18n placeholder mismatch repaired: %r -> %r", translatedValue, fixed, ) if repaired: logger.info("i18n batch: repaired placeholders in %d translations", repaired) def _matchCapitalization(originals: Dict[str, str], translations: Dict[str, str]) -> None: """Ensure translations preserve the capitalisation pattern of the original key.""" for key, translated in translations.items(): if not key or not translated: continue if key[0].isupper() and translated[0].islower(): translations[key] = translated[0].upper() + translated[1:] elif key[0].islower() and translated[0].isupper(): translations[key] = translated[0].lower() + translated[1:] def _resolveMandateIdForAiI18n(request: Request, currentUser: User) -> str: userId = str(currentUser.id) memberIds = _userMemberMandateIds(currentUser) if not memberIds: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=routeApiMsg("Mindestens eine Mandats-Mitgliedschaft ist für die AI-Nutzung erforderlich."), ) headerRaw = ( request.headers.get("X-Mandate-Id") or request.headers.get("x-mandate-id") or "" ).strip() if headerRaw: if headerRaw not in memberIds: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("X-Mandate-Id ist kein Mandat Ihrer Mitgliedschaft."), ) if _mandatePassesAiPoolBilling(currentUser, headerRaw, userId): return headerRaw for mid in memberIds: if _mandatePassesAiPoolBilling(currentUser, mid, userId): return mid raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, detail=routeApiMsg("Nicht genügend AI-Guthaben (Mandats-Pool) für diese Aktion."), ) # --------------------------------------------------------------------------- # xx-Master sync from frontend codebase (local dev fallback) # --------------------------------------------------------------------------- _REPO_ROOT = Path(__file__).resolve().parents[3] _FRONTEND_SRC = _REPO_ROOT / "frontend_nyla" / "src" _T_CALL_RE = re.compile(r"""\bt\(\s*'((?:\\.|[^'])+)'\s*(?:,|\))""") def _scanCodebaseKeys() -> List[dict]: """Local dev fallback: scan frontend src for t() calls. Returns entries with context='ui'.""" keys: Set[str] = set() if not _FRONTEND_SRC.is_dir(): logger.warning("i18n codebase scan: %s not found", _FRONTEND_SRC) return [] for ext in ("*.tsx", "*.ts"): for filepath in _FRONTEND_SRC.rglob(ext): try: content = filepath.read_text(encoding="utf-8", errors="replace") except OSError: continue for m in _T_CALL_RE.finditer(content): raw = m.group(1) raw = raw.replace("\\'", "'") if raw: keys.add(raw) return [{"context": "ui", "key": k, "value": ""} for k in sorted(keys)] async def _readOptionalEntriesFromBody(request: Request) -> Optional[List[dict]]: """Read entries from request body. Accepts {entries: [{context, key, value}, ...]}.""" body = await request.body() if not body or not body.strip(): return None try: data = json.loads(body.decode("utf-8")) except (json.JSONDecodeError, UnicodeDecodeError): return None if not isinstance(data, dict) or "entries" not in data: return None entries = data.get("entries") if not isinstance(entries, list): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=routeApiMsg("Feld «entries» muss ein JSON-Array sein."), ) result = [] for e in entries: if not isinstance(e, dict) or not e.get("key"): continue result.append({ "context": str(e.get("context", "ui")), "key": str(e["key"]), "value": str(e.get("value", "")), }) return result if result else None def _syncXxMaster(db, userId: Optional[str], incomingEntries: List[dict]) -> Dict[str, Any]: """Synchronise the xx base set with incoming UI entries. Only touches entries whose context is "ui". Gateway entries (api.*, table.*) written by _syncRegistryToDb at boot are preserved untouched. """ if not incomingEntries: logger.warning("i18n xx-sync: no entries — aborting") return {"added": [], "removed": [], "entriesCount": 0, "error": "No entries to sync"} rows = db.getRecordset(UiLanguageSet, recordFilter={"id": "xx"}) if not rows: now = getUtcTimestamp() rec = { "id": "xx", "label": "Basisset (Meta)", "entries": incomingEntries, "status": "complete", "isDefault": True, "sysCreatedAt": now, "sysCreatedBy": userId, "sysModifiedAt": now, "sysModifiedBy": userId, } db.recordCreate(UiLanguageSet, rec) allKeys = [e["key"] for e in incomingEntries] logger.info("i18n xx-master created: %d entries", len(incomingEntries)) return {"added": allKeys, "removed": [], "entriesCount": len(incomingEntries)} row = dict(rows[0]) curEntries = _rowEntries(row) gatewayEntries = [e for e in curEntries if e.get("context", "ui") != "ui"] curUiByKey = {e["key"]: e for e in curEntries if e.get("context", "ui") == "ui"} incomingByKey = {e["key"]: e for e in incomingEntries} incomingKeys = set(incomingByKey.keys()) dbUiKeys = set(curUiByKey.keys()) added = sorted(incomingKeys - dbUiKeys) removed = sorted(dbUiKeys - incomingKeys) newUiEntries = [ {"context": e["context"], "key": e["key"], "value": e["value"]} for e in incomingEntries ] if not added and not removed and all( curUiByKey.get(e["key"], {}).get("value") == e["value"] and curUiByKey.get(e["key"], {}).get("context") == e["context"] for e in incomingEntries ): total = len(newUiEntries) + len(gatewayEntries) return {"added": [], "removed": [], "entriesCount": total} mergedEntries = gatewayEntries + newUiEntries now = getUtcTimestamp() row["entries"] = mergedEntries if "keys" in row: del row["keys"] row["sysModifiedAt"] = now row["sysModifiedBy"] = userId db.recordModify(UiLanguageSet, "xx", row) logger.info( "i18n xx-master sync: +%d added, -%d removed (ui=%d, gateway=%d, total=%d)", len(added), len(removed), len(newUiEntries), len(gatewayEntries), len(mergedEntries), ) return {"added": added, "removed": removed, "entriesCount": len(mergedEntries)} # --- Public ----------------------------------------------------------------- @router.get("/codes") async def list_language_codes(): db = _publicMgmtDb() rows = db.getRecordset(UiLanguageSet) out = [] for r in rows: entries = _rowEntries(r) uiCount = sum(1 for e in entries if e.get("context", "ui") == "ui") gatewayCount = len(entries) - uiCount code = r["id"] out.append( { "code": code, "label": r.get("label"), "status": r.get("status"), "isDefault": bool(r.get("isDefault")), "entriesCount": len(entries), "uiCount": uiCount, "gatewayCount": gatewayCount, "updating": code in _UPDATING_CODES, } ) return sorted(out, key=lambda x: (not x.get("isDefault"), x["code"])) @router.get("/sets/{code}") async def get_language_set(code: str): db = _publicMgmtDb() rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code}) if not rows: raise HTTPException(status_code=404, detail=routeApiMsg("Sprachset nicht gefunden")) return _row_to_public(rows[0]) # --- Auth user -------------------------------------------------------------- class CreateLanguageBody(BaseModel): code: str = Field(..., min_length=2, max_length=10) label: Optional[str] = Field(default=None, max_length=80) def _validate_iso2_code(code: str) -> str: c = code.strip().lower() if not re.fullmatch(r"[a-z]{2,3}", c): raise HTTPException( status_code=400, detail=routeApiMsg("Nur ISO-639 Sprachcodes (2–3 Buchstaben) erlaubt.") ) return c async def _translateTextMultilingualFields(db, langCode: str, langLabel: str, billingCb=None) -> int: """Batch-translate all TextMultilingual fields (Role.description, Feature.label) for a new language.""" textsToTranslate: Dict[str, str] = {} roles = db.getRecordset(Role) for r in roles: desc = r.get("description") if isinstance(r, dict) else getattr(r, "description", None) if isinstance(desc, dict): sourceText = desc.get("xx", "") if sourceText and not desc.get(langCode): textsToTranslate[f"role:{r.get('id') if isinstance(r, dict) else r.id}:description"] = sourceText features = db.getRecordset(Feature) for f in features: lbl = f.get("label") if isinstance(f, dict) else getattr(f, "label", None) if isinstance(lbl, dict): sourceText = lbl.get("xx", "") if sourceText and not lbl.get(langCode): textsToTranslate[f"feature:{f.get('code') if isinstance(f, dict) else f.code}:label"] = sourceText if not textsToTranslate: return 0 keysForAi = {v: "User-generated content field" for v in textsToTranslate.values()} uniqueTexts = list(set(keysForAi.keys())) keysForAi = {t: "User-generated content field" for t in uniqueTexts} translated = await _translateBatch(keysForAi, langLabel, langCode, billingCallback=billingCb) count = 0 for compositeKey, deText in textsToTranslate.items(): translatedText = translated.get(deText) if not translatedText: continue parts = compositeKey.split(":") if parts[0] == "role": roleId = parts[1] rows = db.getRecordset(Role, recordFilter={"id": roleId}) if rows: rec = dict(rows[0]) if not isinstance(rows[0], dict) else rows[0] desc = rec.get("description", {}) if isinstance(desc, dict): desc[langCode] = translatedText rec["description"] = desc db.recordModify(Role, roleId, rec) count += 1 elif parts[0] == "feature": featureCode = parts[1] rows = db.getRecordset(Feature, recordFilter={"code": featureCode}) if rows: rec = dict(rows[0]) if not isinstance(rows[0], dict) else rows[0] lbl = rec.get("label", {}) if isinstance(lbl, dict): lbl[langCode] = translatedText rec["label"] = lbl db.recordModify(Feature, featureCode, rec) count += 1 logger.info("TextMultilingual batch translate: %d fields translated to %s", count, langCode) return count def _run_create_language_job(userId: str, code: str, label: str, currentUser: User, mandateId: str) -> None: loop = asyncio.new_event_loop() try: loop.run_until_complete(_run_create_language_job_async(userId, code, label, currentUser, mandateId)) finally: loop.close() async def _run_create_language_job_async(userId: str, code: str, label: str, currentUser: User, mandateId: str) -> None: _UPDATING_CODES.add(code) try: db = _publicMgmtDb() rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code}) if not rows: return xxEntries = _loadMasterXxEntries(db) if not xxEntries: logger.error("i18n create job: no xx master entries found") return toTranslate = {e["key"]: e.get("value", "") for e in xxEntries} billingCb = _makeBillingCallback(currentUser, mandateId) translated = await _translateBatch(toTranslate, label, code, billingCallback=billingCb) finalEntries = [] for e in xxEntries: k = e["key"] finalEntries.append({ "context": e["context"], "key": k, "value": translated.get(k, f"[{k}]"), }) missingCount = sum(1 for e in xxEntries if e["key"] not in translated) finalStatus = "complete" if missingCount == 0 else "incomplete" now = getUtcTimestamp() merged = dict(rows[0]) merged["entries"] = finalEntries if "keys" in merged: del merged["keys"] merged["status"] = finalStatus merged["label"] = label merged["sysModifiedAt"] = now merged["sysModifiedBy"] = userId db.recordModify(UiLanguageSet, code, merged) statusHint = "" if finalStatus == "complete" else f" ({missingCount} Keys ohne Übersetzung)" tmCount = await _translateTextMultilingualFields(db, code, label, billingCb) _createNotification( userId, NotificationType.SYSTEM, title="Sprachset erstellt", message=f"Die Sprache «{label}» ({code}) wurde per KI übersetzt{statusHint}. {tmCount} Inhaltsfelder übersetzt.", ) await _reloadI18nCache() logger.info("i18n create job done: code=%s, translated=%d/%d, tm_fields=%d", code, len(translated), len(xxEntries), tmCount) except Exception as e: logger.exception("create language job failed: %s", e) _createNotification( userId, NotificationType.SYSTEM, title="Sprachset fehlgeschlagen", message=f"Fehler bei «{code}»: {e}", ) finally: _UPDATING_CODES.discard(code) @router.post("/sets") async def create_language_set( request: Request, body: CreateLanguageBody, background: BackgroundTasks, currentUser: User = Depends(getCurrentUser), ): mandateId = _resolveMandateIdForAiI18n(request, currentUser) code = _validate_iso2_code(body.code) if code == "xx": raise HTTPException(status_code=400, detail=routeApiMsg("Das Basisset «xx» kann nicht manuell angelegt werden.")) db = _publicMgmtDb() existing = db.getRecordset(UiLanguageSet, recordFilter={"id": code}) if existing: raise HTTPException(status_code=409, detail=routeApiMsg("Dieses Sprachset existiert bereits.")) xxEntries = _loadMasterXxEntries(db) if not xxEntries: raise HTTPException(status_code=503, detail=routeApiMsg("Basisset (xx) nicht vorhanden. Bitte zuerst UI-Keys einlesen.")) resolvedLabel = (body.label or "").strip() if body.label else "" if not resolvedLabel: resolvedLabel = _ISO_LABELS.get(code, code) now = getUtcTimestamp() uid = str(currentUser.id) rec: dict = { "id": code, "label": resolvedLabel, "entries": [], "status": "generating", "isDefault": False, "sysCreatedAt": now, "sysCreatedBy": uid, "sysModifiedAt": now, "sysModifiedBy": uid, } db.recordCreate(UiLanguageSet, rec) background.add_task(_run_create_language_job, uid, code, resolvedLabel, currentUser, mandateId) _createNotification( uid, NotificationType.SYSTEM, title="Sprachset wird erzeugt", message=f"Die Sprache «{code}» wird im Hintergrund per KI übersetzt.", ) return {"status": "accepted", "code": code} def _compute_language_sync_diff(db, code: str) -> dict: """Return key sync metrics before AI translate (no DB writes).""" if code == "xx": raise HTTPException(status_code=400, detail=routeApiMsg("Das xx-Set wird separat synchronisiert.")) rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code}) if not rows: raise HTTPException(status_code=404, detail=routeApiMsg("Sprachset nicht gefunden")) xxEntries = _loadMasterXxEntries(db) if not xxEntries: raise HTTPException(status_code=503, detail=routeApiMsg("Basisset (xx) nicht vorhanden.")) row = dict(rows[0]) curEntries = _rowEntries(row) masterIds = {_entryId(e) for e in xxEntries} currentIds = {_entryId(e) for e in curEntries} return { "code": code, "addedCount": len(masterIds - currentIds), "removedCount": len(currentIds - masterIds), "masterEntryCount": len(masterIds), "currentEntryCount": len(currentIds), } def _entryId(e: dict) -> tuple: """Composite identifier for an i18n entry: (key, context).""" return (e["key"], e.get("context", "ui")) async def _syncLanguageWithXx(db, code: str, userId: Optional[str], adminUser: Optional[User] = None) -> dict: """Synchronise a language set (incl. de) against the xx base set via AI. Entries are identified by (key, context) — the same text can appear with different contexts (e.g. "ui" and "api.routeXyz"). """ if code == "xx": raise HTTPException(status_code=400, detail=routeApiMsg("Das xx-Set wird über 'UI-Keys einlesen' aktualisiert.")) _UPDATING_CODES.add(code) try: rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code}) if not rows: raise HTTPException(status_code=404, detail=routeApiMsg("Sprachset nicht gefunden")) xxEntries = _loadMasterXxEntries(db) if not xxEntries: raise HTTPException(status_code=503, detail=routeApiMsg("Basisset (xx) nicht vorhanden.")) row = dict(rows[0]) curEntries = _rowEntries(row) curById = {_entryId(e): e for e in curEntries} xxById = {_entryId(e): e for e in xxEntries} masterIds = set(xxById.keys()) currentIds = set(curById.keys()) removedIds = currentIds - masterIds addedIds = masterIds - currentIds translatedCount = 0 if addedIds: toTranslate = {xxById[eid]["key"]: xxById[eid].get("value", "") for eid in addedIds} langLabel = row.get("label") or code billingCb = None if adminUser: memberIds = _userMemberMandateIds(adminUser) if memberIds: billingCb = _makeBillingCallback(adminUser, memberIds[0]) try: translated = await _translateBatch(toTranslate, langLabel, code, billingCallback=billingCb) translatedCount = sum(1 for eid in addedIds if xxById[eid]["key"] in translated) except Exception as e: logger.error("AI translation during sync failed for %s: %s", code, e) translated = {} for eid in addedIds: xxEntry = xxById[eid] curById[eid] = { "context": xxEntry["context"], "key": xxEntry["key"], "value": translated.get(xxEntry["key"], f"[{xxEntry['key']}]"), } for eid in removedIds: del curById[eid] for eid in masterIds & currentIds: curById[eid]["context"] = xxById[eid]["context"] newEntries = sorted(curById.values(), key=lambda e: (e["key"].lower(), e.get("context", ""))) now = getUtcTimestamp() untranslated = len(addedIds) - translatedCount row["entries"] = newEntries if "keys" in row: del row["keys"] row["status"] = "complete" if untranslated == 0 else "incomplete" row["sysModifiedAt"] = now row["sysModifiedBy"] = userId db.recordModify(UiLanguageSet, code, row) return { "code": code, "added": sorted({xxById[eid]["key"] for eid in addedIds}), "removed": sorted({eid[0] for eid in removedIds}), "translated": translatedCount, "entriesCount": len(newEntries), } finally: _UPDATING_CODES.discard(code) @router.put("/sets/sync-xx") async def sync_xx_master( request: Request, adminUser: User = Depends(requireSysAdmin), ): """Synchronise the xx base set from the frontend build artefact. Expects JSON body: {"entries": [{"context":"ui","key":"…","value":"…"}, …]} Falls back to local codebase scan if no body provided (dev mode). """ db = getMgmtInterface(adminUser, mandateId=None).db fromBody = await _readOptionalEntriesFromBody(request) entries = fromBody if fromBody is not None else _scanCodebaseKeys() result = _syncXxMaster(db, str(adminUser.id), entries) await _reloadI18nCache() return result def _repairLanguageSetPlaceholders(db, code: str, userId: Optional[str]) -> dict: """Persistently fix placeholder mismatches in one language set. Walks every entry, runs ``_enforceSourcePlaceholders(key, value)`` and persists any changed values back to the row. Only saves if at least one entry was modified. """ rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code}) if not rows: raise HTTPException(status_code=404, detail=routeApiMsg("Sprachset nicht gefunden")) row = dict(rows[0]) entries = _rowEntries(row) repaired: List[Dict[str, str]] = [] for entry in entries: key = entry.get("key", "") val = entry.get("value", "") fixed, changed = _enforceSourcePlaceholders(key, val) if changed: repaired.append({"key": key, "before": val, "after": fixed}) entry["value"] = fixed if repaired: row["entries"] = entries if "keys" in row: del row["keys"] row["sysModifiedAt"] = getUtcTimestamp() row["sysModifiedBy"] = userId db.recordModify(UiLanguageSet, code, row) return { "code": code, "checked": len(entries), "repaired": len(repaired), "examples": repaired[:10], } @router.post("/sets/{code}/repair-placeholders") async def repair_language_set_placeholders( code: str, adminUser: User = Depends(requireSysAdmin), ): """SysAdmin: persistently restore placeholder tokens in one language set. Use this once after the AI translator turned ``{konten}`` into ``{accounts}`` (or similar). Compares each entry's value against its German source key; where the placeholder *names* differ but the *count* matches, restores the source names positionally. Safe and idempotent. """ c = code.strip().lower() if c == "xx": raise HTTPException(status_code=400, detail=routeApiMsg("Das xx-Set hat keine Übersetzungen.")) db = getMgmtInterface(adminUser, mandateId=None).db result = _repairLanguageSetPlaceholders(db, c, str(adminUser.id)) await _reloadI18nCache() return result @router.post("/sets/repair-placeholders-all") async def repair_all_language_sets_placeholders( adminUser: User = Depends(requireSysAdmin), ): """SysAdmin: persistently restore placeholder tokens in ALL language sets.""" db = getMgmtInterface(adminUser, mandateId=None).db rows = db.getRecordset(UiLanguageSet) summary: List[dict] = [] totalRepaired = 0 for row in rows: code = row.get("id", "") if not code or code == "xx": continue try: res = _repairLanguageSetPlaceholders(db, code, str(adminUser.id)) summary.append(res) totalRepaired += res["repaired"] except HTTPException: continue await _reloadI18nCache() return {"languages": len(summary), "totalRepaired": totalRepaired, "details": summary} @router.get("/sets/{code}/sync-diff") async def get_language_sync_diff( code: str, adminUser: User = Depends(requirePlatformAdmin), ): """How many keys would be added/removed vs xx before running a full sync (SysAdmin).""" c = code.strip().lower() if c in ("update-all", "sync-xx", "sync-de"): raise HTTPException(status_code=400, detail=routeApiMsg("Ungültiger Sprachcode.")) db = getMgmtInterface(adminUser, mandateId=None).db return _compute_language_sync_diff(db, c) @router.put("/sets/{code}") async def update_language_set( code: str, adminUser: User = Depends(requirePlatformAdmin), ): c = code.strip().lower() if c in ("update-all", "sync-xx", "sync-de"): raise HTTPException(status_code=400, detail=routeApiMsg("Ungültiger Sprachcode.")) if c == "xx": raise HTTPException(status_code=400, detail=routeApiMsg("Das xx-Set wird über 'UI-Keys einlesen' aktualisiert.")) db = getMgmtInterface(adminUser, mandateId=None).db result = await _syncLanguageWithXx(db, c, str(adminUser.id), adminUser=adminUser) await _reloadI18nCache() return result @router.delete("/sets/{code}") async def delete_language_set( code: str, adminUser: User = Depends(requirePlatformAdmin), ): c = code.strip().lower() if c in _PROTECTED_CODES: raise HTTPException(status_code=400, detail=f"Das Set «{c}» darf nicht gelöscht werden.") db = getMgmtInterface(adminUser, mandateId=None).db ok = db.recordDelete(UiLanguageSet, c) if not ok: raise HTTPException(status_code=404, detail=routeApiMsg("Sprachset nicht gefunden")) await _reloadI18nCache() return {"deleted": c} @router.get("/sets/{code}/download", dependencies=[Depends(getCurrentUser)]) async def download_language_set( code: str, currentUser: User = Depends(getCurrentUser), ): db = _publicMgmtDb() rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code.strip().lower()}) if not rows: raise HTTPException(status_code=404, detail=routeApiMsg("Sprachset nicht gefunden")) payload = _row_to_public(rows[0]) raw = json.dumps(payload, ensure_ascii=False, indent=2) return Response( content=raw, media_type="application/json", headers={ "Content-Disposition": f'attachment; filename="ui-language-{code}.json"' }, ) # --- Export / Import (full DB) ----------------------------------------------- @router.get("/export") async def export_all_language_sets( adminUser: User = Depends(requirePlatformAdmin), ): db = getMgmtInterface(adminUser, mandateId=None).db rows = db.getRecordset(UiLanguageSet) payload = [] for r in rows: entries = _rowEntries(r) payload.append({ "id": r["id"], "label": r.get("label", ""), "entries": entries, "status": r.get("status", "complete"), "isDefault": bool(r.get("isDefault", False)), }) payload.sort(key=lambda x: (not x.get("isDefault"), x["id"])) raw = json.dumps(payload, ensure_ascii=False, indent=2) return Response( content=raw, media_type="application/json", headers={ "Content-Disposition": 'attachment; filename="ui-languages-export.json"' }, ) @router.post("/import") async def import_language_sets( file: UploadFile = File(...), adminUser: User = Depends(requirePlatformAdmin), ): if not file.filename or not file.filename.endswith(".json"): raise HTTPException(status_code=400, detail=routeApiMsg("Nur .json-Dateien erlaubt.")) try: raw = await file.read() data = json.loads(raw.decode("utf-8")) except (json.JSONDecodeError, UnicodeDecodeError) as e: raise HTTPException(status_code=400, detail=f"Ungültiges JSON: {e}") if not isinstance(data, list): raise HTTPException(status_code=400, detail=routeApiMsg("JSON muss ein Array von Sprachsets sein.")) db = getMgmtInterface(adminUser, mandateId=None).db now = getUtcTimestamp() uid = str(adminUser.id) created = [] updated = [] for entry in data: if not isinstance(entry, dict): continue code = str(entry.get("id", "")).strip().lower() if not code or len(code) < 2: continue entries = entry.get("entries") if not isinstance(entries, list): keys = entry.get("keys") if isinstance(keys, dict): entries = [{"context": "ui", "key": k, "value": v} for k, v in keys.items()] else: continue label = str(entry.get("label", code)) entryStatus = str(entry.get("status", "complete")) isDefault = bool(entry.get("isDefault", False)) existing = db.getRecordset(UiLanguageSet, recordFilter={"id": code}) if existing: row = dict(existing[0]) row["entries"] = entries if "keys" in row: del row["keys"] row["label"] = label row["status"] = entryStatus row["isDefault"] = isDefault row["sysModifiedAt"] = now row["sysModifiedBy"] = uid db.recordModify(UiLanguageSet, code, row) updated.append(code) else: rec = { "id": code, "label": label, "entries": entries, "status": entryStatus, "isDefault": isDefault, "sysCreatedAt": now, "sysCreatedBy": uid, "sysModifiedAt": now, "sysModifiedBy": uid, } db.recordCreate(UiLanguageSet, rec) created.append(code) logger.info("i18n import: created=%s, updated=%s", created, updated) await _reloadI18nCache() return {"created": created, "updated": updated, "totalProcessed": len(created) + len(updated)} # --------------------------------------------------------------------------- # Phase 7b: translate-field — on-demand translation for TextMultilingual fields # --------------------------------------------------------------------------- _TRANSLATE_FIELD_MAX_LEN = 2000 class _TargetLang(BaseModel): code: str = Field(..., min_length=2, max_length=10) label: str = Field(default="") class TranslateFieldRequest(BaseModel): sourceText: str = Field(..., min_length=1, max_length=_TRANSLATE_FIELD_MAX_LEN) sourceLang: str = Field(default="de", min_length=2, max_length=5) targetLangs: List[_TargetLang] = Field(..., min_length=1) @router.post("/translate-field") async def translateField( body: TranslateFieldRequest, request: Request, currentUser: User = Depends(getCurrentUser), ): """Translate a single text into one or more target languages (for TextMultilingual fields).""" targets = [t for t in body.targetLangs if t.code != body.sourceLang] if not targets: return {"translations": {}} mandateId = _resolveMandateIdForAiI18n(request, currentUser) billingCb = _makeBillingCallback(currentUser, mandateId) results: Dict[str, str] = {} for target in targets: targetLabel = target.label or _ISO_LABELS.get(target.code, target.code) keysToTranslate = {body.sourceText: "TextMultilingual field"} translated = await _translateBatch(keysToTranslate, targetLabel, target.code, billingCb) val = translated.get(body.sourceText, "") if val: results[target.code] = val return {"translations": results}