gateway/modules/routes/routeI18n.py
2026-04-26 08:31:35 +02:00

1219 lines
46 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 getCachedConnector
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",
}
# Priority order for the language picker: most relevant first, rest sorted by label.
# Single source of truth -- frontend fetches via GET /api/i18n/iso-choices and must
# never duplicate this list.
_ISO_PRIORITY_CODES: List[str] = ["de", "gsw", "en", "fr", "it"]
# ---------------------------------------------------------------------------
# DB helpers
# ---------------------------------------------------------------------------
def _publicMgmtDb():
return getCachedConnector(
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("/iso-choices")
async def list_iso_choices():
"""Return the catalog of supported ISO 639-1/-3 language codes plus their
native labels. Single source of truth for any UI that lets the user pick a
language code (e.g. SysAdmin "add language set" dropdown). The frontend
must NOT keep its own copy of this list.
Response:
{
"priorityCodes": ["de", "gsw", "en", "fr", "it"],
"choices": [{"value": "de", "label": "de — Deutsch"}, ...]
}
"""
choices = [
{"value": code, "label": f"{code}{label}"}
for code, label in _ISO_LABELS.items()
]
def _sortKey(item):
try:
prio = _ISO_PRIORITY_CODES.index(item["value"])
return (0, prio)
except ValueError:
return (1, item["label"].lower())
choices.sort(key=_sortKey)
return {
"priorityCodes": list(_ISO_PRIORITY_CODES),
"choices": choices,
}
@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 (23 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}