fixed sysuser and removed redundant fallbacks

This commit is contained in:
ValueOn AG 2026-04-11 22:23:41 +02:00
parent 4dfc0afd06
commit 3adbd1da29
31 changed files with 276 additions and 292 deletions

4
app.py
View file

@ -491,12 +491,12 @@ from modules.auth import (
) )
# i18n language detection middleware (sets per-request language from Accept-Language header) # i18n language detection middleware (sets per-request language from Accept-Language header)
from modules.shared.i18nRegistry import _setLanguage from modules.shared.i18nRegistry import _setLanguage, normalizePrimaryLanguageTag
@app.middleware("http") @app.middleware("http")
async def _i18nMiddleware(request: Request, call_next): async def _i18nMiddleware(request: Request, call_next):
acceptLang = request.headers.get("Accept-Language", "") acceptLang = request.headers.get("Accept-Language", "")
lang = acceptLang[:2].lower() if len(acceptLang) >= 2 and acceptLang[:2].isalpha() else "de" lang = normalizePrimaryLanguageTag(acceptLang, "de")
_setLanguage(lang) _setLanguage(lang)
return await call_next(request) return await call_next(request)

View file

@ -14,7 +14,7 @@ from typing import Optional, List, Dict, Any
from enum import Enum from enum import Enum
from pydantic import BaseModel, Field, EmailStr, field_validator, computed_field from pydantic import BaseModel, Field, EmailStr, field_validator, computed_field
from modules.datamodels.datamodelBase import PowerOnModel from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.i18nRegistry import i18nModel from modules.shared.i18nRegistry import i18nModel, normalizePrimaryLanguageTag
from modules.shared.timeUtils import getUtcTimestamp from modules.shared.timeUtils import getUtcTimestamp
@ -243,17 +243,12 @@ class User(PowerOnModel):
) )
language: str = Field( language: str = Field(
default="de", default="de",
description="Preferred language of the user (ISO 639-1 code: de, en, fr, it)", description="Preferred UI language code (must exist as UiLanguageSet; loaded from /api/i18n/user-language-options).",
json_schema_extra={ json_schema_extra={
"frontend_type": "select", "frontend_type": "select",
"frontend_readonly": False, "frontend_readonly": False,
"frontend_required": True, "frontend_required": True,
"frontend_options": [ "frontend_options": "/api/i18n/user-language-options",
{"value": "de", "label": "Deutsch"},
{"value": "en", "label": "Englisch"},
{"value": "fr", "label": "Französisch"},
{"value": "it", "label": "Italienisch"},
],
"label": "Sprache", "label": "Sprache",
}, },
) )
@ -261,10 +256,9 @@ class User(PowerOnModel):
@field_validator('language', mode='before') @field_validator('language', mode='before')
@classmethod @classmethod
def _normalizeLanguage(cls, v): def _normalizeLanguage(cls, v):
"""Normalize language to valid ISO 639-1 code.""" """Normalize to primary language subtag (28 letters); default remains ``de``."""
if v is None: if v is None:
return "de" return "de"
# Map common variations to standard codes
langMap = { langMap = {
'english': 'en', 'englisch': 'en', 'english': 'en', 'englisch': 'en',
'german': 'de', 'deutsch': 'de', 'german': 'de', 'deutsch': 'de',
@ -274,11 +268,7 @@ class User(PowerOnModel):
normalized = str(v).lower().strip() normalized = str(v).lower().strip()
if normalized in langMap: if normalized in langMap:
return langMap[normalized] return langMap[normalized]
# If already a valid code, return as-is return normalizePrimaryLanguageTag(normalized, "de")
if normalized in ['de', 'en', 'fr', 'it']:
return normalized
# Default fallback
return "de"
enabled: bool = Field( enabled: bool = Field(
default=True, default=True,

View file

@ -2,8 +2,9 @@
# All rights reserved. # All rights reserved.
"""Utility datamodels: Prompt, TextMultilingual.""" """Utility datamodels: Prompt, TextMultilingual."""
import re as _re import json
from typing import Any, Dict, Optional from typing import Any, Dict
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
from modules.datamodels.datamodelBase import PowerOnModel from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.i18nRegistry import i18nModel from modules.shared.i18nRegistry import i18nModel
@ -98,21 +99,6 @@ class TextMultilingual(BaseModel):
return cls(xx=t) return cls(xx=t)
_REPR_PATTERN = _re.compile(r"(\w+)='([^']*)'")
def _parseReprString(s: str) -> Optional[Dict[str, str]]:
"""Parse a Pydantic repr string like "en='text' de=None fr=" into a dict."""
matches = _REPR_PATTERN.findall(s)
if not matches:
return None
result = {}
for code, text in matches:
if len(code) <= 5 and text:
result[code] = text
return result if result else None
def coerce_text_multilingual(val: Any) -> TextMultilingual: def coerce_text_multilingual(val: Any) -> TextMultilingual:
"""Normalize str, dict, or TextMultilingual into a valid TextMultilingual instance.""" """Normalize str, dict, or TextMultilingual into a valid TextMultilingual instance."""
if isinstance(val, TextMultilingual): if isinstance(val, TextMultilingual):
@ -125,10 +111,13 @@ def coerce_text_multilingual(val: Any) -> TextMultilingual:
cleaned["xx"] = cleaned.get("de") or next((v for v in cleaned.values() if v), "") cleaned["xx"] = cleaned.get("de") or next((v for v in cleaned.values() if v), "")
return TextMultilingual(**cleaned) return TextMultilingual(**cleaned)
if isinstance(val, str) and val.strip(): if isinstance(val, str) and val.strip():
parsed = _parseReprString(val) s = val.strip()
if parsed: if s.startswith("{") and s.endswith("}"):
if not parsed.get("xx"): try:
parsed["xx"] = parsed.get("de") or next((v for v in parsed.values() if v), val.strip()) parsed = json.loads(s)
return TextMultilingual(**parsed) if isinstance(parsed, dict):
return TextMultilingual.fromUniform(val) return coerce_text_multilingual(parsed)
except json.JSONDecodeError:
pass
return TextMultilingual.fromUniform(s)
return TextMultilingual.fromUniform("") return TextMultilingual.fromUniform("")

View file

@ -191,8 +191,8 @@ def getChatStreamingHelper():
def __get_placeholder_user(): def __get_placeholder_user():
"""Placeholder user for contexts that only need service resolution (e.g. ChatStreamingHelper).""" """Placeholder user for contexts that only need service resolution (e.g. ChatStreamingHelper)."""
from modules.datamodels.datamodelUam import User from modules.interfaces.interfaceDbApp import getRootInterface
return User(id="system", username="system", email=None, fullName="System Placeholder") return getRootInterface().currentUser
def getEventManager(user, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None): def getEventManager(user, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None):

View file

@ -13,7 +13,7 @@ from modules.datamodels.datamodelUam import User
from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.timeUtils import getIsoTimestamp from modules.shared.timeUtils import getIsoTimestamp
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from modules.shared.i18nRegistry import t from modules.shared.i18nRegistry import resolveText, t
from .datamodelCommcoach import ( from .datamodelCommcoach import (
CoachingContext, CoachingContextStatus, CoachingContext, CoachingContextStatus,
@ -414,16 +414,20 @@ def _calcGoalProgress(goalsRaw) -> Optional[int]:
_LEVELS = [ _LEVELS = [
(50, 5, "master", t("Meister")), (50, 5, "master", "Meister"),
(25, 4, "expert", t("Experte")), (25, 4, "expert", "Experte"),
(10, 3, "advanced", t("Fortgeschritten")), (10, 3, "advanced", "Fortgeschritten"),
(3, 2, "engaged", t("Engagiert")), (3, 2, "engaged", "Engagiert"),
] ]
t("Meister")
t("Experte")
t("Fortgeschritten")
t("Engagiert")
t("Einsteiger") t("Einsteiger")
def _calcLevel(totalSessions: int) -> Dict[str, Any]: def _calcLevel(totalSessions: int) -> Dict[str, Any]:
for threshold, number, code, _label in _LEVELS: for threshold, number, code, labelKey in _LEVELS:
if totalSessions >= threshold: if totalSessions >= threshold:
return {"number": number, "code": code, "label": t(_label), "totalSessions": totalSessions} return {"number": number, "code": code, "label": resolveText(labelKey), "totalSessions": totalSessions}
return {"number": 1, "code": "beginner", "label": t("Einsteiger"), "totalSessions": totalSessions} return {"number": 1, "code": "beginner", "label": resolveText("Einsteiger"), "totalSessions": totalSessions}

View file

@ -236,9 +236,9 @@ def _seedBuiltinPersonas():
try: try:
from .serviceCommcoachPersonas import seedBuiltinPersonas from .serviceCommcoachPersonas import seedBuiltinPersonas
from .interfaceFeatureCommcoach import getInterface from .interfaceFeatureCommcoach import getInterface
from modules.datamodels.datamodelUam import User from modules.interfaces.interfaceDbApp import getRootInterface
systemUser = User(id="system", username="system", email="system@poweron.swiss") systemUser = getRootInterface().currentUser
interface = getInterface(systemUser) interface = getInterface(systemUser)
seedBuiltinPersonas(interface) seedBuiltinPersonas(interface)
except Exception as e: except Exception as e:

View file

@ -7,7 +7,7 @@ Checks and awards badges after each session completion.
import logging import logging
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
from modules.shared.i18nRegistry import t from modules.shared.i18nRegistry import resolveText, t
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -80,9 +80,32 @@ BADGE_DEFINITIONS: Dict[str, Dict[str, Any]] = {
} }
# Register all badge labels/descriptions at import time for i18n xx base set # Register all badge labels/descriptions at import time for i18n xx base set
for _bd in BADGE_DEFINITIONS.values(): t("Erste Session")
t(_bd["label"]) t("Deine erste Coaching-Session abgeschlossen")
t(_bd["description"]) t("3-Tage-Serie")
t("3 Tage in Folge eine Session absolviert")
t("Wochenserie")
t("7 Tage in Folge eine Session absolviert")
t("Monatsserie")
t("30 Tage in Folge eine Session absolviert")
t("Engagiert")
t("5 Sessions abgeschlossen")
t("Fortgeschritten")
t("10 Sessions abgeschlossen")
t("Experte")
t("25 Sessions abgeschlossen")
t("Meister")
t("50 Sessions abgeschlossen")
t("Bestleistung")
t("Durchschnittsscore über 80 in einer Session")
t("Vielseitig")
t("3 verschiedene Coaching-Themen aktiv")
t("Rollenspieler")
t("Erste Roleplay-Session mit einer Persona abgeschlossen")
t("Ganzheitlich")
t("In allen 5 Kompetenz-Dimensionen bewertet")
t("Umsetzer")
t("10 Coaching-Aufgaben erledigt")
async def checkAndAwardBadges(interface, userId: str, mandateId: str, instanceId: str, async def checkAndAwardBadges(interface, userId: str, mandateId: str, instanceId: str,
@ -141,8 +164,8 @@ async def checkAndAwardBadges(interface, userId: str, mandateId: str, instanceId
} }
newBadge = interface.awardBadge(badgeData) newBadge = interface.awardBadge(badgeData)
definition = BADGE_DEFINITIONS.get(badgeKey, {}) definition = BADGE_DEFINITIONS.get(badgeKey, {})
newBadge["label"] = t(definition.get("label", badgeKey)) newBadge["label"] = resolveText(definition.get("label", badgeKey))
newBadge["description"] = t(definition.get("description", "")) newBadge["description"] = resolveText(definition.get("description", ""))
newBadge["icon"] = definition.get("icon", "star") newBadge["icon"] = definition.get("icon", "star")
awarded.append(newBadge) awarded.append(newBadge)
logger.info(f"Badge '{badgeKey}' awarded to user {userId}") logger.info(f"Badge '{badgeKey}' awarded to user {userId}")
@ -154,5 +177,9 @@ def getBadgeDefinitions() -> Dict[str, Dict[str, Any]]:
"""Return all badge definitions for the frontend (labels resolved via i18n).""" """Return all badge definitions for the frontend (labels resolved via i18n)."""
resolved = {} resolved = {}
for key, defn in BADGE_DEFINITIONS.items(): for key, defn in BADGE_DEFINITIONS.items():
resolved[key] = {**defn, "label": t(defn["label"]), "description": t(defn["description"])} resolved[key] = {
**defn,
"label": resolveText(defn["label"]),
"description": resolveText(defn["description"]),
}
return resolved return resolved

View file

@ -36,10 +36,11 @@ def default_manual_entry_point() -> Dict[str, Any]:
} }
def _normalize_title(title: Any, preferredLang: Optional[str] = None) -> str: def _normalize_title(title: Any) -> str:
"""Extract a plain string from a title value for storage (not display)."""
if isinstance(title, dict): if isinstance(title, dict):
picked = (preferredLang and title.get(preferredLang)) or title.get("xx") or next(iter(title.values()), "") picked = title.get("xx") or next((v for v in title.values() if v), None)
return str(picked).strip() if picked is not None else "" return str(picked).strip() if picked else "Start"
if isinstance(title, str) and title.strip(): if isinstance(title, str) and title.strip():
return title.strip() return title.strip()
return "Start" return "Start"

View file

@ -10,13 +10,14 @@ from typing import Dict, List, Any
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG, SYSTEM_VARIABLES from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG, SYSTEM_VARIABLES
from modules.shared.i18nRegistry import normalizePrimaryLanguageTag
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def getNodeTypes( def getNodeTypes(
services: Any = None, services: Any = None,
language: str = "en", language: str = "de",
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
Return static node types. No dynamic I/O derivation from methodDiscovery. Return static node types. No dynamic I/O derivation from methodDiscovery.
@ -25,27 +26,42 @@ def getNodeTypes(
return list(STATIC_NODE_TYPES) return list(STATIC_NODE_TYPES)
def _pickFromLangMap(d: Any, lang: str) -> Any:
"""Resolve multilingual dict: ``lang`` → ``xx`` → ``de`` → ``en`` → first non-empty value."""
if not isinstance(d, dict) or not d:
return None
for k in (lang, "xx", "de", "en"):
v = d.get(k)
if v is not None and v != "":
return v
for v in d.values():
if v is not None and v != "":
return v
return None
def _localizeNode(node: Dict[str, Any], language: str) -> Dict[str, Any]: def _localizeNode(node: Dict[str, Any], language: str) -> Dict[str, Any]:
"""Apply language to label/description/parameters. Keep inputPorts/outputPorts.""" """Apply language to label/description/parameters. Keep inputPorts/outputPorts."""
lang = language if language in ("en", "de", "fr") else "en" lang = normalizePrimaryLanguageTag(language, "en")
out = dict(node) out = dict(node)
for key in list(out.keys()): for key in list(out.keys()):
if key.startswith("_"): if key.startswith("_"):
del out[key] del out[key]
if isinstance(node.get("label"), dict): if isinstance(node.get("label"), dict):
out["label"] = node["label"].get(lang, node["label"].get("en", str(node["label"]))) out["label"] = _pickFromLangMap(node["label"], lang) or node.get("id", "")
if isinstance(node.get("description"), dict): if isinstance(node.get("description"), dict):
out["description"] = node["description"].get(lang, node["description"].get("en", str(node["description"]))) out["description"] = _pickFromLangMap(node["description"], lang) or ""
ol = node.get("outputLabels") ol = node.get("outputLabels")
if isinstance(ol, dict) and ol: if isinstance(ol, dict) and ol:
first = next(iter(ol.values()), None) first = next(iter(ol.values()), None)
if isinstance(first, (list, tuple)): if isinstance(first, (list, tuple)):
out["outputLabels"] = ol.get(lang, ol.get("en", list(first))) picked = _pickFromLangMap(ol, lang)
out["outputLabels"] = picked if picked is not None else list(first)
params = [] params = []
for p in node.get("parameters", []): for p in node.get("parameters", []):
pc = dict(p) pc = dict(p)
if isinstance(p.get("description"), dict): if isinstance(p.get("description"), dict):
pc["description"] = p["description"].get(lang, p["description"].get("en", str(p.get("description", "")))) pc["description"] = _pickFromLangMap(p["description"], lang) or str(p.get("description", ""))
params.append(pc) params.append(pc)
out["parameters"] = params out["parameters"] = params
return out return out
@ -53,7 +69,7 @@ def _localizeNode(node: Dict[str, Any], language: str) -> Dict[str, Any]:
def getNodeTypesForApi( def getNodeTypesForApi(
services: Any, services: Any,
language: str = "en", language: str = "de",
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
API-ready response: nodeTypes with localized strings, plus categories, portTypeCatalog, systemVariables. API-ready response: nodeTypes with localized strings, plus categories, portTypeCatalog, systemVariables.

View file

@ -14,6 +14,8 @@ from typing import Any, Callable, Dict, List, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.shared.i18nRegistry import resolveText
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -480,11 +482,9 @@ def _deriveFormPayloadSchema(node: Dict[str, Any]) -> Optional[PortSchema]:
for f in fields_param: for f in fields_param:
if isinstance(f, dict) and f.get("name"): if isinstance(f, dict) and f.get("name"):
_lab = f.get("label") _lab = f.get("label")
_desc = ( _desc = resolveText(_lab) if _lab is not None else f["name"]
str(_lab.get("xx") or next(iter(_lab.values()), "") or f["name"]) if not _desc.strip():
if isinstance(_lab, dict) _desc = f["name"]
else str(_lab if _lab is not None else f["name"])
)
portFields.append(PortField( portFields.append(PortField(
name=f["name"], name=f["name"],
type=f.get("type", "str"), type=f.get("type", "str"),

View file

@ -26,21 +26,12 @@ from modules.workflows.automation2.runEnvelope import (
normalize_run_envelope, normalize_run_envelope,
) )
from modules.features.graphicalEditor.entryPoints import find_invocation from modules.features.graphicalEditor.entryPoints import find_invocation
from modules.shared.i18nRegistry import apiRouteContext from modules.shared.i18nRegistry import apiRouteContext, resolveText
routeApiMsg = apiRouteContext("routeFeatureGraphicalEditor") routeApiMsg = apiRouteContext("routeFeatureGraphicalEditor")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _pickInvocationTitleLabel(title: Any, requestLang: Optional[str]) -> str:
if isinstance(title, str):
return title
if isinstance(title, dict) and title:
picked = (requestLang and title.get(requestLang)) or title.get("xx") or next(iter(title.values()), "")
return str(picked) if picked else ""
return ""
def _build_execute_run_envelope( def _build_execute_run_envelope(
body: Dict[str, Any], body: Dict[str, Any],
workflow: Optional[Dict[str, Any]], workflow: Optional[Dict[str, Any]],
@ -80,7 +71,7 @@ def _build_execute_run_envelope(
} }
trig = trig_map.get(kind, "manual") trig = trig_map.get(kind, "manual")
title = inv.get("title") or {} title = inv.get("title") or {}
label = _pickInvocationTitleLabel(title, requestLang) label = resolveText(title)
base = default_run_envelope( base = default_run_envelope(
trig, trig,
entry_point_id=inv.get("id"), entry_point_id=inv.get("id"),
@ -1052,10 +1043,7 @@ async def post_workflow_webhook(
discoverMethods(services) discoverMethods(services)
title = inv.get("title") or {} title = inv.get("title") or {}
label = _pickInvocationTitleLabel( label = resolveText(title)
title,
getattr(context.user, "language", None) if context.user else None,
)
pl = body if isinstance(body, dict) else {} pl = body if isinstance(body, dict) else {}
base = default_run_envelope( base = default_run_envelope(
"webhook", "webhook",
@ -1113,10 +1101,7 @@ async def post_workflow_form_submit(
discoverMethods(services) discoverMethods(services)
title = inv.get("title") or {} title = inv.get("title") or {}
label = _pickInvocationTitleLabel( label = resolveText(title)
title,
getattr(context.user, "language", None) if context.user else None,
)
pl = body if isinstance(body, dict) else {} pl = body if isinstance(body, dict) else {}
base = default_run_envelope( base = default_run_envelope(
"form", "form",

View file

@ -1252,18 +1252,16 @@ async def postTranscript(
config = _getInstanceConfig(instanceId) config = _getInstanceConfig(instanceId)
# Load original user context from session # Load original user context from session
from modules.datamodels.datamodelUam import User rootInterface = getRootInterface()
rootUser = rootInterface.currentUser
systemUser = User(id="system", username="system", email="system@poweron.swiss") sessionInterface = interfaceDb.getInterface(rootUser, featureInstanceId=instanceId)
sessionInterface = interfaceDb.getInterface(systemUser, featureInstanceId=instanceId)
session = sessionInterface.getSession(sessionId) session = sessionInterface.getSession(sessionId)
mandateId = session.get("mandateId") if session else None mandateId = session.get("mandateId") if session else None
startedByUserId = session.get("startedByUserId") if session else None startedByUserId = session.get("startedByUserId") if session else None
rootInterface = getRootInterface()
originalUser = rootInterface.getUser(startedByUserId) if startedByUserId else None originalUser = rootInterface.getUser(startedByUserId) if startedByUserId else None
if not originalUser: if not originalUser:
originalUser = systemUser originalUser = rootUser
# Process transcript through the service pipeline # Process transcript through the service pipeline
from .service import TeamsbotService from .service import TeamsbotService
@ -1308,18 +1306,16 @@ async def postBotStatus(
try: try:
config = _getInstanceConfig(instanceId) config = _getInstanceConfig(instanceId)
from modules.datamodels.datamodelUam import User rootInterface = getRootInterface()
rootUser = rootInterface.currentUser
systemUser = User(id="system", username="system", email="system@poweron.swiss") sessionInterface = interfaceDb.getInterface(rootUser, featureInstanceId=instanceId)
sessionInterface = interfaceDb.getInterface(systemUser, featureInstanceId=instanceId)
session = sessionInterface.getSession(sessionId) session = sessionInterface.getSession(sessionId)
mandateId = session.get("mandateId") if session else None mandateId = session.get("mandateId") if session else None
startedByUserId = session.get("startedByUserId") if session else None startedByUserId = session.get("startedByUserId") if session else None
rootInterface = getRootInterface()
originalUser = rootInterface.getUser(startedByUserId) if startedByUserId else None originalUser = rootInterface.getUser(startedByUserId) if startedByUserId else None
if not originalUser: if not originalUser:
originalUser = systemUser originalUser = rootUser
from .service import TeamsbotService from .service import TeamsbotService
service = TeamsbotService(originalUser, mandateId, instanceId, config) service = TeamsbotService(originalUser, mandateId, instanceId, config)
@ -1361,22 +1357,20 @@ async def botWebsocket(
# Load the original user who started the session (has RBAC roles in mandate) # Load the original user who started the session (has RBAC roles in mandate)
# Bot callbacks have no HTTP auth, so we reconstruct the user context from the session record. # Bot callbacks have no HTTP auth, so we reconstruct the user context from the session record.
from modules.datamodels.datamodelUam import User
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
systemUser = User(id="system", username="system", email="system@poweron.swiss") rootInterface = getRootInterface()
sessionInterface = interfaceDb.getInterface(systemUser, featureInstanceId=instanceId) rootUser = rootInterface.currentUser
sessionInterface = interfaceDb.getInterface(rootUser, featureInstanceId=instanceId)
session = sessionInterface.getSession(sessionId) session = sessionInterface.getSession(sessionId)
mandateId = session.get("mandateId") if session else None mandateId = session.get("mandateId") if session else None
startedByUserId = session.get("startedByUserId") if session else None startedByUserId = session.get("startedByUserId") if session else None
# Look up the original user (getRootInterface uses admin context, can load any user)
rootInterface = getRootInterface()
originalUser = rootInterface.getUser(startedByUserId) if startedByUserId else None originalUser = rootInterface.getUser(startedByUserId) if startedByUserId else None
if not originalUser: if not originalUser:
logger.warning(f"Could not load original user {startedByUserId}, falling back to system user") logger.warning(f"Could not load original user {startedByUserId}, falling back to root user")
originalUser = systemUser originalUser = rootUser
# Build effective config with the session's actual bot name. # Build effective config with the session's actual bot name.
# The session stores the resolved bot name (from system bot or user override). # The session stores the resolved bot name (from system bot or user override).

View file

@ -10,7 +10,7 @@ import os
from typing import Dict, List, Optional from typing import Dict, List, Optional
from .accountingConnectorBase import BaseAccountingConnector from .accountingConnectorBase import BaseAccountingConnector
from modules.shared.i18nRegistry import t from modules.shared.i18nRegistry import resolveText
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -62,11 +62,11 @@ class AccountingRegistry:
fields = [] fields = []
for f in connector.getRequiredConfigFields(): for f in connector.getRequiredConfigFields():
fd = f.model_dump() fd = f.model_dump()
fd["label"] = t(f.label) fd["label"] = resolveText(f.label)
fields.append(fd) fields.append(fd)
result.append({ result.append({
"connectorType": connectorType, "connectorType": connectorType,
"label": t(connector.getConnectorLabel()), "label": resolveText(connector.getConnectorLabel()),
"configFields": fields, "configFields": fields,
}) })
return result return result

View file

@ -151,12 +151,7 @@ def getQuickActions(
if role and role.roleLabel: if role and role.roleLabel:
userRoleLabels.add(role.roleLabel) userRoleLabels.add(role.roleLabel)
def _resolveText(multilingual, lang: str) -> str: from modules.shared.i18nRegistry import resolveText
if isinstance(multilingual, str):
return multilingual
if isinstance(multilingual, dict):
return multilingual.get(lang) or multilingual.get("xx") or next(iter(multilingual.values()), "")
return ""
filteredActions = [] filteredActions = []
for action in QUICK_ACTIONS: for action in QUICK_ACTIONS:
@ -166,8 +161,8 @@ def getQuickActions(
if context.hasSysAdminRole or required.intersection(userRoleLabels): if context.hasSysAdminRole or required.intersection(userRoleLabels):
resolved = { resolved = {
"id": action["id"], "id": action["id"],
"label": _resolveText(action.get("label", {}), language), "label": resolveText(action.get("label", {})),
"description": _resolveText(action.get("description", {}), language), "description": resolveText(action.get("description", {})),
"icon": action.get("icon", ""), "icon": action.get("icon", ""),
"color": action.get("color", ""), "color": action.get("color", ""),
"category": action.get("category", ""), "category": action.get("category", ""),
@ -178,14 +173,14 @@ def getQuickActions(
if resolved["actionType"] == "agentPrompt" and "config" in resolved: if resolved["actionType"] == "agentPrompt" and "config" in resolved:
cfg = dict(resolved["config"]) cfg = dict(resolved["config"])
if "uploadHint" in cfg: if "uploadHint" in cfg:
cfg["uploadHint"] = _resolveText(cfg["uploadHint"], language) cfg["uploadHint"] = resolveText(cfg["uploadHint"])
resolved["config"] = cfg resolved["config"] = cfg
filteredActions.append(resolved) filteredActions.append(resolved)
filteredActions.sort(key=lambda a: a["sortOrder"]) filteredActions.sort(key=lambda a: a["sortOrder"])
resolvedCategories = [ resolvedCategories = [
{"id": c["id"], "label": _resolveText(c.get("label", {}), language), "sortOrder": c.get("sortOrder", 99)} {"id": c["id"], "label": resolveText(c.get("label", {})), "sortOrder": c.get("sortOrder", 99)}
for c in QUICK_ACTION_CATEGORIES for c in QUICK_ACTION_CATEGORIES
] ]

View file

@ -29,7 +29,7 @@ from modules.interfaces.interfaceAiObjects import AiObjects
from modules.serviceCenter.core.serviceStreaming import get_event_manager from modules.serviceCenter.core.serviceStreaming import get_event_manager
from modules.serviceCenter.services.serviceAgent.datamodelAgent import AgentEventTypeEnum, PendingFileEdit from modules.serviceCenter.services.serviceAgent.datamodelAgent import AgentEventTypeEnum, PendingFileEdit
from modules.shared.timeUtils import parseTimestamp from modules.shared.timeUtils import parseTimestamp
from modules.shared.i18nRegistry import apiRouteContext, t from modules.shared.i18nRegistry import apiRouteContext, resolveText
routeApiMsg = apiRouteContext("routeFeatureWorkspace") routeApiMsg = apiRouteContext("routeFeatureWorkspace")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -1467,7 +1467,7 @@ async def listFeatureConnectionTables(
node = { node = {
"objectKey": obj.get("objectKey", ""), "objectKey": obj.get("objectKey", ""),
"tableName": meta.get("table", ""), "tableName": meta.get("table", ""),
"label": t(obj.get("label", "")), "label": resolveText(obj.get("label", "")),
"fields": meta.get("fields", []), "fields": meta.get("fields", []),
} }
if meta.get("isParent"): if meta.get("isParent"):

View file

@ -666,7 +666,7 @@ class AppObjects:
password: str = None, password: str = None,
email: str = None, email: str = None,
fullName: str = None, fullName: str = None,
language: str = "en", language: str = "de",
enabled: bool = True, enabled: bool = True,
authenticationAuthority: AuthAuthority = AuthAuthority.LOCAL, authenticationAuthority: AuthAuthority = AuthAuthority.LOCAL,
externalId: str = None, externalId: str = None,

View file

@ -17,6 +17,7 @@ from modules.datamodels.datamodelFeatures import Feature, FeatureInstance
from modules.datamodels.datamodelRbac import Role, AccessRule from modules.datamodels.datamodelRbac import Role, AccessRule
from modules.datamodels.datamodelUtils import coerce_text_multilingual from modules.datamodels.datamodelUtils import coerce_text_multilingual
from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.i18nRegistry import resolveText
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -241,9 +242,9 @@ class FeatureInterface:
return 0 return 0
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
from modules.auth.authModels import SystemUser from modules.interfaces.interfaceDbApp import getRootInterface
systemUser = SystemUser() rootUser = getRootInterface().currentUser
geInterface = getGraphicalEditorInterface(systemUser, mandateId, instanceId) geInterface = getGraphicalEditorInterface(rootUser, mandateId, instanceId)
copied = 0 copied = 0
for template in templateWorkflows: for template in templateWorkflows:
@ -251,11 +252,7 @@ class FeatureInterface:
graphJson = graphJson.replace("{{featureInstanceId}}", instanceId) graphJson = graphJson.replace("{{featureInstanceId}}", instanceId)
graph = json.loads(graphJson) graph = json.loads(graphJson)
labelDict = template.get("label", {}) label = resolveText(template.get("label"))
if isinstance(labelDict, dict):
label = labelDict.get("xx") or next(iter(labelDict.values()), "") or str(labelDict)
else:
label = str(labelDict)
geInterface.createWorkflow({ geInterface.createWorkflow({
"label": label, "label": label,

View file

@ -27,24 +27,12 @@ from modules.interfaces.interfaceDbApp import getRootInterface
from modules.interfaces.interfaceFeatures import getFeatureInterface from modules.interfaces.interfaceFeatures import getFeatureInterface
from modules.security.rbacCatalog import getCatalogService from modules.security.rbacCatalog import getCatalogService
from modules.routes.routeNotifications import create_access_change_notification from modules.routes.routeNotifications import create_access_change_notification
from modules.shared.i18nRegistry import apiRouteContext from modules.shared.i18nRegistry import apiRouteContext, resolveText
routeApiMsg = apiRouteContext("routeAdminFeatures") routeApiMsg = apiRouteContext("routeAdminFeatures")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _featureLabelPlain(label: Union[str, Dict[str, str], None], fallback: str, requestLang: Optional[str] = None) -> str:
"""Catalog feature label as a single display/i18n key string."""
if isinstance(label, str) and label.strip():
return label
if isinstance(label, dict):
picked = (requestLang and label.get(requestLang)) or label.get("xx") or next(iter(label.values()), "")
if picked:
return str(picked)
return fallback
return fallback
def _feature_instance_display_name(instance: Any) -> str: def _feature_instance_display_name(instance: Any) -> str:
if instance is None: if instance is None:
return "" return ""
@ -197,11 +185,7 @@ def get_my_feature_instances(
featureDef = catalogService.getFeatureDefinition(instance.featureCode) featureDef = catalogService.getFeatureDefinition(instance.featureCode)
featuresMap[featureKey] = { featuresMap[featureKey] = {
"code": instance.featureCode, "code": instance.featureCode,
"label": _featureLabelPlain( "label": resolveText(featureDef.get("label") if featureDef else None),
featureDef.get("label") if featureDef else None,
instance.featureCode,
getattr(context.user, "language", None),
),
"icon": featureDef.get("icon", "folder") if featureDef else "folder", "icon": featureDef.get("icon", "folder") if featureDef else "folder",
"instances": [], "instances": [],
"_mandateId": mandateId # Temporary for grouping "_mandateId": mandateId # Temporary for grouping

View file

@ -23,23 +23,12 @@ from modules.datamodels.datamodelRbac import AccessRuleContext, AccessRule, Role
from modules.datamodels.datamodelMembership import UserMandate from modules.datamodels.datamodelMembership import UserMandate
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
from modules.shared.i18nRegistry import apiRouteContext, t, _getLanguage from modules.shared.i18nRegistry import apiRouteContext, t, resolveText
routeApiMsg = apiRouteContext("routeAdminRbacRules") routeApiMsg = apiRouteContext("routeAdminRbacRules")
# Configure logger # Configure logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _resolveTextMultilingual(value) -> str:
"""Resolve a TextMultilingual dict to a single string for the current request language.
Falls back to xx (source text), then any available value."""
if isinstance(value, str):
return value
if isinstance(value, dict):
lang = _getLanguage()
return value.get(lang) or value.get("xx") or next(iter(value.values()), "")
return str(value) if value else ""
router = APIRouter( router = APIRouter(
prefix="/api/rbac", prefix="/api/rbac",
tags=["RBAC"], tags=["RBAC"],
@ -922,7 +911,7 @@ def list_roles(
result.append({ result.append({
"id": role.id, "id": role.id,
"roleLabel": role.roleLabel, "roleLabel": role.roleLabel,
"description": _resolveTextMultilingual(role.description), "description": resolveText(role.description),
"mandateId": role.mandateId, "mandateId": role.mandateId,
"featureInstanceId": role.featureInstanceId, "featureInstanceId": role.featureInstanceId,
"featureCode": role.featureCode, "featureCode": role.featureCode,
@ -1051,7 +1040,7 @@ def get_roles_filter_values(
result.append({ result.append({
"id": role.id, "id": role.id,
"roleLabel": role.roleLabel, "roleLabel": role.roleLabel,
"description": _resolveTextMultilingual(role.description), "description": resolveText(role.description),
"mandateId": role.mandateId, "mandateId": role.mandateId,
"featureInstanceId": role.featureInstanceId, "featureInstanceId": role.featureInstanceId,
"featureCode": role.featureCode, "featureCode": role.featureCode,
@ -1168,7 +1157,7 @@ def get_role(
return { return {
"id": role.id, "id": role.id,
"roleLabel": role.roleLabel, "roleLabel": role.roleLabel,
"description": _resolveTextMultilingual(role.description), "description": resolveText(role.description),
"mandateId": role.mandateId, "mandateId": role.mandateId,
"featureInstanceId": role.featureInstanceId, "featureInstanceId": role.featureInstanceId,
"featureCode": role.featureCode, "featureCode": role.featureCode,
@ -1369,12 +1358,8 @@ def getCatalogObjects(
def _resolveLabels(objects: list) -> list: def _resolveLabels(objects: list) -> list:
for obj in objects: for obj in objects:
raw = obj.get("label") resolved = resolveText(obj.get("label"))
if isinstance(raw, str): obj["label"] = resolved if resolved else f"[{obj.get('objectKey', '?')}]"
obj["label"] = t(raw)
elif isinstance(raw, dict):
key = raw.get("xx") or next(iter(raw.values()), "") or obj.get("objectKey", "")
obj["label"] = t(key) if key else f"[{obj.get('objectKey', '?')}]"
return objects return objects
if context: if context:

View file

@ -24,24 +24,13 @@ from modules.datamodels.datamodelMembership import (
) )
from modules.datamodels.datamodelFeatures import FeatureInstance, Feature from modules.datamodels.datamodelFeatures import FeatureInstance, Feature
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
from modules.shared.i18nRegistry import apiRouteContext, t, _getLanguage from modules.shared.i18nRegistry import apiRouteContext, t, resolveText
routeApiMsg = apiRouteContext("routeAdminUserAccessOverview") routeApiMsg = apiRouteContext("routeAdminUserAccessOverview")
# Configure logger # Configure logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _resolveTextMultilingual(value) -> str:
"""Resolve a TextMultilingual dict to a single string for the current request language.
Falls back to xx (source text), then any available value."""
if isinstance(value, str):
return value
if isinstance(value, dict):
lang = _getLanguage()
return value.get(lang) or value.get("xx") or next(iter(value.values()), "")
return str(value) if value else ""
router = APIRouter( router = APIRouter(
prefix="/api/admin/user-access-overview", prefix="/api/admin/user-access-overview",
tags=["Admin User Access Overview"], tags=["Admin User Access Overview"],
@ -309,7 +298,7 @@ def getUserAccessOverview(
roleInfo = { roleInfo = {
"id": roleId, "id": roleId,
"roleLabel": role.roleLabel, "roleLabel": role.roleLabel,
"description": _resolveTextMultilingual(role.description), "description": resolveText(role.description),
"scope": scope, "scope": scope,
"scopePriority": _getRoleScopePriority(scope), "scopePriority": _getRoleScopePriority(scope),
"mandateId": role.mandateId, "mandateId": role.mandateId,
@ -345,7 +334,7 @@ def getUserAccessOverview(
# Get feature info using interface method # Get feature info using interface method
featureCode = instance.featureCode featureCode = instance.featureCode
feature = interface.getFeatureByCode(featureCode) feature = interface.getFeatureByCode(featureCode)
featureLabel = t(feature.label) if feature and feature.label else "" featureLabel = resolveText(feature.label) if feature and feature.label else ""
# Get roles for this FeatureAccess using interface method # Get roles for this FeatureAccess using interface method
instanceRoleIds = interface.getRoleIdsForFeatureAccess(faId) instanceRoleIds = interface.getRoleIdsForFeatureAccess(faId)
@ -359,7 +348,7 @@ def getUserAccessOverview(
roleInfo = { roleInfo = {
"id": roleId, "id": roleId,
"roleLabel": role.roleLabel, "roleLabel": role.roleLabel,
"description": _resolveTextMultilingual(role.description), "description": resolveText(role.description),
"scope": scope, "scope": scope,
"scopePriority": _getRoleScopePriority(scope), "scopePriority": _getRoleScopePriority(scope),
"mandateId": role.mandateId, "mandateId": role.mandateId,

View file

@ -24,7 +24,7 @@ from modules.auth import limiter, getRequestContext, RequestContext
from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
from modules.shared.i18nRegistry import apiRouteContext from modules.shared.i18nRegistry import apiRouteContext, resolveText
routeApiMsg = apiRouteContext("routeDataUsers") routeApiMsg = apiRouteContext("routeDataUsers")
# Configure logger # Configure logger
@ -87,9 +87,9 @@ def _extractDistinctValues(
elif isinstance(val, (int, float)): elif isinstance(val, (int, float)):
values.add(str(val)) values.add(str(val))
elif isinstance(val, dict): elif isinstance(val, dict):
text = (requestLang and val.get(requestLang)) or val.get("xx") or next(iter(val.values()), None) text = resolveText(val, requestLang)
if text: if text:
values.add(str(text)) values.add(text)
else: else:
values.add(str(val)) values.add(str(val))
return sorted(values, key=lambda v: v.lower()) return sorted(values, key=lambda v: v.lower())
@ -606,7 +606,7 @@ class CreateUserRequest(BaseModel):
username: str username: str
email: Optional[str] = None email: Optional[str] = None
fullName: Optional[str] = None fullName: Optional[str] = None
language: str = "en" language: str = "de"
enabled: bool = True enabled: bool = True
isSysAdmin: bool = False isSysAdmin: bool = False
password: Optional[str] = None password: Optional[str] = None

View file

@ -508,6 +508,24 @@ async def list_language_codes():
return sorted(out, key=lambda x: (not x.get("isDefault"), x["code"])) return sorted(out, key=lambda x: (not x.get("isDefault"), x["code"]))
@router.get("/user-language-options")
async def list_user_language_options():
"""Select options for User.language: all UiLanguageSets except ``xx`` (basis set).
Returns ``[{ \"value\": code, \"label\": name }, ...]`` for FormGenerator ``frontend_options`` URL.
"""
db = _publicMgmtDb()
rows = db.getRecordset(UiLanguageSet)
out: List[Dict[str, str]] = []
for r in rows:
code = r.get("id")
if not code or code == "xx":
continue
lbl = (r.get("label") or "").strip() or code
out.append({"value": code, "label": lbl})
return sorted(out, key=lambda x: (x.get("label") or x["value"]).lower())
@router.get("/sets/{code}") @router.get("/sets/{code}")
async def get_language_set(code: str): async def get_language_set(code: str):
db = _publicMgmtDb() db = _publicMgmtDb()

View file

@ -23,23 +23,11 @@ from modules.interfaces.interfaceFeatures import getFeatureInterface
from modules.security.rbacCatalog import getCatalogService from modules.security.rbacCatalog import getCatalogService
from modules.security.rbac import RbacClass from modules.security.rbac import RbacClass
from modules.security.rootAccess import getRootDbAppConnector from modules.security.rootAccess import getRootDbAppConnector
from modules.shared.i18nRegistry import apiRouteContext from modules.shared.i18nRegistry import apiRouteContext, resolveText
routeApiMsg = apiRouteContext("routeStore") routeApiMsg = apiRouteContext("routeStore")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _storeLabelText(label: Union[str, Dict[str, str], None], fallback: str, requestLang: Optional[str] = None) -> str:
"""Normalize catalog label to a single display/i18n key string."""
if isinstance(label, str) and label.strip():
return label
if isinstance(label, dict):
picked = (requestLang and label.get(requestLang)) or label.get("xx") or next(iter(label.values()), "")
if picked:
return str(picked)
return fallback
return fallback
router = APIRouter( router = APIRouter(
prefix="/api/store", prefix="/api/store",
tags=["Store"], tags=["Store"],
@ -301,9 +289,9 @@ def listStoreFeatures(
instances = _getUserInstancesForFeature(db, userId, featureCode, userMandateIds) instances = _getUserInstancesForFeature(db, userId, featureCode, userMandateIds)
result.append(StoreFeatureResponse( result.append(StoreFeatureResponse(
featureCode=featureCode, featureCode=featureCode,
label=_storeLabelText(featureDef.get("label"), featureCode, getattr(context.user, "language", None)), label=resolveText(featureDef.get("label")),
icon=featureDef.get("icon", "mdi-puzzle"), icon=featureDef.get("icon", "mdi-puzzle"),
description=_storeLabelText(featureDef.get("description"), "", getattr(context.user, "language", None)), description=resolveText(featureDef.get("description")),
instances=instances, instances=instances,
canActivate=True, canActivate=True,
)) ))
@ -391,7 +379,7 @@ def activateStoreFeature(
# ── 3. Provision instance ─────────────────────────────────────── # ── 3. Provision instance ───────────────────────────────────────
featureInterface = getFeatureInterface(db) featureInterface = getFeatureInterface(db)
featureLabel = _storeLabelText(featureDef.get("label"), featureCode, getattr(context.user, "language", None)) featureLabel = resolveText(featureDef.get("label"))
instance = featureInterface.createFeatureInstance( instance = featureInterface.createFeatureInstance(
featureCode=featureCode, featureCode=featureCode,
mandateId=mandateId, mandateId=mandateId,

View file

@ -21,6 +21,7 @@ from modules.serviceCenter.services.serviceAgent.datamodelAgent import (
) )
from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistry from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistry
from modules.serviceCenter.services.serviceAgent.featureDataProvider import FeatureDataProvider from modules.serviceCenter.services.serviceAgent.featureDataProvider import FeatureDataProvider
from modules.shared.i18nRegistry import resolveText
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -305,12 +306,7 @@ def _buildSchemaContext(
meta = obj.get("meta", {}) meta = obj.get("meta", {})
tbl = meta.get("table", "?") tbl = meta.get("table", "?")
fields = meta.get("fields", []) fields = meta.get("fields", [])
label = obj.get("label", {}) labelStr = resolveText(obj.get("label"), requestLang)
if isinstance(label, dict):
picked = (requestLang and label.get(requestLang)) or label.get("xx") or next(iter(label.values()), "")
labelStr = str(picked) if picked else tbl
else:
labelStr = str(label).strip() if isinstance(label, str) and str(label).strip() else tbl
tableNames.append(tbl) tableNames.append(tbl)
block = f" Table: {tbl} ({labelStr})" block = f" Table: {tbl} ({labelStr})"
if fields: if fields:

View file

@ -2051,7 +2051,7 @@ class StructureFiller:
contentPartInstructions: Dict[str, Any], contentPartInstructions: Dict[str, Any],
contentParts: List[ContentPart], contentParts: List[ContentPart],
userPrompt: str, userPrompt: str,
language: str = "en", language: str = "de",
outputFormat: str = "txt" outputFormat: str = "txt"
) -> str: ) -> str:
"""Baue Prompt für Chapter-Sections-Struktur-Generierung, querying renderer for accepted section types.""" """Baue Prompt für Chapter-Sections-Struktur-Generierung, querying renderer for accepted section types."""
@ -2187,7 +2187,7 @@ Return only valid JSON. Do not include any explanatory text outside the JSON.
allSections: Optional[List[Dict[str, Any]]] = None, allSections: Optional[List[Dict[str, Any]]] = None,
sectionIndex: Optional[int] = None, sectionIndex: Optional[int] = None,
isAggregation: bool = False, isAggregation: bool = False,
language: str = "en", language: str = "de",
outputFormat: str = "txt", outputFormat: str = "txt",
preExtractedText: Optional[str] = None preExtractedText: Optional[str] = None
) -> tuple[str, str]: ) -> tuple[str, str]:

View file

@ -14,6 +14,7 @@ from typing import Dict, Any, List, Optional
from modules.datamodels.datamodelExtraction import ContentPart from modules.datamodels.datamodelExtraction import ContentPart
from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum
from modules.workflows.processing.shared.stateTools import checkWorkflowStopped from modules.workflows.processing.shared.stateTools import checkWorkflowStopped
from modules.shared.i18nRegistry import normalizePrimaryLanguageTag
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -262,18 +263,17 @@ CRITICAL:
# Validation 3.5 & 3.6: Document language # Validation 3.5 & 3.6: Document language
# Use validated currentUserLanguage (always valid, validated during user intention analysis) # Use validated currentUserLanguage (always valid, validated during user intention analysis)
# Access via _getUserLanguage() which uses self.services.currentUserLanguage # Access via _getUserLanguage() which uses self.services.currentUserLanguage
userPromptLanguage = self._getUserLanguage() # Uses validated currentUserLanguage infrastructure userPromptLanguage = normalizePrimaryLanguageTag(self._getUserLanguage(), "de")
if "language" not in doc or not isinstance(doc["language"], str) or len(doc["language"]) != 2: raw_lang = doc.get("language")
# AI didn't return language or invalid format - use validated currentUserLanguage if not isinstance(raw_lang, str) or not str(raw_lang).strip():
doc["language"] = userPromptLanguage doc["language"] = userPromptLanguage
if "language" not in doc: if "language" not in doc:
logger.warning(f"Document {doc.get('id')} missing language - using currentUserLanguage: {userPromptLanguage}") logger.warning(f"Document {doc.get('id')} missing language - using currentUserLanguage: {userPromptLanguage}")
else: else:
logger.warning(f"Document {doc.get('id')} has invalid language format from AI: {doc['language']}, using currentUserLanguage") logger.warning(f"Document {doc.get('id')} has invalid language format from AI: {doc.get('language')}, using currentUserLanguage")
else: else:
# AI returned valid language format - normalize doc["language"] = normalizePrimaryLanguageTag(str(raw_lang), userPromptLanguage)
doc["language"] = doc["language"].lower().strip()[:2]
logger.debug(f"Document {doc.get('id')} using AI-determined language: {doc['language']}") logger.debug(f"Document {doc.get('id')} using AI-determined language: {doc['language']}")
# Validation 3.7: Document missing 'chapters' field # Validation 3.7: Document missing 'chapters' field

View file

@ -45,77 +45,53 @@ def _getModelLabelEntry(modelName: str) -> Dict[str, Any]:
return i18nModelLabels.get(modelName) or {} return i18nModelLabels.get(modelName) or {}
def getModelLabels(modelName: str, language: str = "en") -> Dict[str, str]: def getModelLabels(modelName: str) -> Dict[str, str]:
"""Get labels for a model's attributes in the specified language. """Get labels for a model's attributes in the specified language.
Reads @i18nModel registration (German base strings); non-German languages use the i18n cache. Reads @i18nModel registration (German base strings); resolves via resolveText().
Attribute values are strings; dict-shaped entries are still accepted for unusual callers.
""" """
modelData = _getModelLabelEntry(modelName) modelData = _getModelLabelEntry(modelName)
attributeLabels = modelData.get("attributes", {}) attributeLabels = modelData.get("attributes", {})
from modules.shared.i18nRegistry import resolveText
result: Dict[str, str] = {} result: Dict[str, str] = {}
for attr, translations in attributeLabels.items(): for attr, translations in attributeLabels.items():
if isinstance(translations, dict): resolved = resolveText(translations)
germanKey = translations.get("xx") or next(iter(translations.values()), attr) result[attr] = resolved if resolved else f"[{attr}]"
result[attr] = _resolveLabel(germanKey, language)
elif isinstance(translations, str):
result[attr] = _resolveLabel(translations, language)
else:
result[attr] = f"[{attr}]"
return result return result
def _resolveLabel(germanText: str, language: str) -> str: def _resolveOptionLabels(options):
"""Resolve a German base label to the requested language via i18n cache. """Resolve frontend_options label values via resolveText()."""
Returns [germanText] when no translation exists so missing keys are visible in the UI."""
if language == "de":
return germanText
try:
from modules.shared.i18nRegistry import _CACHE
return _CACHE.get(language, {}).get(germanText, f"[{germanText}]")
except ImportError:
return germanText
def _resolveOptionLabels(options, userLanguage: str):
"""Resolve frontend_options label values to the requested language."""
if not isinstance(options, list): if not isinstance(options, list):
return options return options
from modules.shared.i18nRegistry import resolveText
for opt in options: for opt in options:
if not isinstance(opt, dict) or "label" not in opt: if not isinstance(opt, dict) or "label" not in opt:
continue continue
raw = opt["label"] opt["label"] = resolveText(opt["label"])
if isinstance(raw, dict):
germanKey = raw.get("xx") or next(iter(raw.values()), str(opt.get("value", "")))
opt["label"] = _resolveLabel(germanKey, userLanguage)
elif isinstance(raw, str):
opt["label"] = _resolveLabel(raw, userLanguage)
return options return options
def _mergedAttributeLabels(modelClass: Type[BaseModel], userLanguage: str) -> Dict[str, str]: def _mergedAttributeLabels(modelClass: Type[BaseModel]) -> Dict[str, str]:
"""Merge attribute labels from model MRO (base classes first, subclass overrides).""" """Merge attribute labels from model MRO (base classes first, subclass overrides)."""
try: try:
baseIdx = modelClass.__mro__.index(BaseModel) baseIdx = modelClass.__mro__.index(BaseModel)
except ValueError: except ValueError:
return getModelLabels(modelClass.__name__, userLanguage) return getModelLabels(modelClass.__name__)
merged: Dict[str, str] = {} merged: Dict[str, str] = {}
for cls in reversed(modelClass.__mro__[:baseIdx]): for cls in reversed(modelClass.__mro__[:baseIdx]):
merged.update(getModelLabels(cls.__name__, userLanguage)) merged.update(getModelLabels(cls.__name__))
return merged return merged
def getModelLabel(modelName: str, language: str = "de") -> str: def getModelLabel(modelName: str) -> str:
"""Get the label for a model in the specified language (see getModelLabels).""" """Get the label for a model via resolveText()."""
modelData = _getModelLabelEntry(modelName) modelData = _getModelLabelEntry(modelName)
modelLabel = modelData.get("model", {}) modelLabel = modelData.get("model", {})
if isinstance(modelLabel, dict): from modules.shared.i18nRegistry import resolveText
germanKey = modelLabel.get("xx") or next(iter(modelLabel.values()), modelName) resolved = resolveText(modelLabel)
return _resolveLabel(germanKey, language) return resolved if resolved else f"[{modelName}]"
elif isinstance(modelLabel, str):
return _resolveLabel(modelLabel, language)
return f"[{modelName}]"
def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguage: str = "en") -> Dict[str, Any]: def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguage: str = "en") -> Dict[str, Any]:
@ -134,8 +110,8 @@ def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguag
attributes = [] attributes = []
model_name = modelClass.__name__ model_name = modelClass.__name__
labels = _mergedAttributeLabels(modelClass, userLanguage) labels = _mergedAttributeLabels(modelClass)
model_label = getModelLabel(model_name, userLanguage) model_label = getModelLabel(model_name)
# Pydantic v2 only # Pydantic v2 only
fields = modelClass.model_fields fields = modelClass.model_fields
@ -273,7 +249,7 @@ def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguag
"visible": frontend_visible, "visible": frontend_visible,
"order": len(attributes), "order": len(attributes),
"readonly": frontend_readonly, "readonly": frontend_readonly,
"options": _resolveOptionLabels(frontend_options, userLanguage), "options": _resolveOptionLabels(frontend_options),
"default": field_default, "default": field_default,
} }

View file

@ -80,6 +80,50 @@ def t(key: str, context: str = "api", value: str = "") -> str:
return _CACHE.get(lang, {}).get(key, f"[{key}]") return _CACHE.get(lang, {}).get(key, f"[{key}]")
def resolveText(value: Any, lang: Optional[str] = None) -> str:
"""Resolve any value to a translated string for the current request language.
Accepts str, dict, TextMultilingual, or None.
- str: translate via t() (treats as i18n key / German plaintext key)
- dict: multilingual user content pick ``lang`` (or current context), then ``xx``, then first value
- object with model_dump(): convert to dict first (TextMultilingual)
- None/empty: return ""
If ``lang`` is given, it temporarily overrides the context language for this call
(used by schedulers that have an explicit user language).
Missing i18n translations for string keys use t()'s ``[key]`` fallback.
"""
if lang is not None:
token = _CURRENT_LANGUAGE.set(lang)
try:
return _resolveTextImpl(value)
finally:
_CURRENT_LANGUAGE.reset(token)
return _resolveTextImpl(value)
def _resolveTextImpl(value: Any) -> str:
if value is None:
return ""
if isinstance(value, str):
if not value.strip():
return ""
return t(value)
if hasattr(value, "model_dump"):
value = value.model_dump()
if isinstance(value, dict):
if not value:
return ""
lang = _CURRENT_LANGUAGE.get()
text = value.get(lang) or value.get("xx")
if text:
return str(text)
first = next((v for v in value.values() if v), None)
return str(first) if first else ""
return str(value)
def apiRouteContext(routeModuleName: str): def apiRouteContext(routeModuleName: str):
"""Return a callable that registers + translates HTTPException details. """Return a callable that registers + translates HTTPException details.
@ -155,6 +199,23 @@ def _getLanguage() -> str:
return _CURRENT_LANGUAGE.get() return _CURRENT_LANGUAGE.get()
def normalizePrimaryLanguageTag(tag: str, fallback: str = "de") -> str:
"""Primary language subtag from ``Accept-Language`` or a single BCP47 tag.
Supports 2-letter (ISO 639-1) and 3-letter (ISO 639-2/3) primaries such as ``gsw``.
Strips region/variant: ``de-CH`` ``de``, ``zh-Hans-CN`` ``zh``.
"""
if not tag or not isinstance(tag, str):
return fallback
first = tag.split(",")[0].split(";")[0].strip()
if not first:
return fallback
primary = first.split("-")[0].split("_")[0].lower()
if primary.isalpha() and 2 <= len(primary) <= 8:
return primary
return fallback
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Boot: scan route files for routeApiMsg("…") calls → register eagerly # Boot: scan route files for routeApiMsg("…") calls → register eagerly
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -270,15 +331,10 @@ def _registerFeatureUiLabels():
_REGISTRY[fl] = _I18nRegistryEntry(context="nav", value="") _REGISTRY[fl] = _I18nRegistryEntry(context="nav", value="")
added += 1 added += 1
for uiObj in getattr(mod, "UI_OBJECTS", []) or []: for uiObj in getattr(mod, "UI_OBJECTS", []) or []:
lab = uiObj.get("label") base = _extractRegistrySourceText(uiObj.get("label"))
if isinstance(lab, str) and lab and lab not in _REGISTRY: if base and base not in _REGISTRY:
_REGISTRY[lab] = _I18nRegistryEntry(context="nav", value="") _REGISTRY[base] = _I18nRegistryEntry(context="nav", value="")
added += 1 added += 1
elif isinstance(lab, dict):
base = lab.get("xx") or next(iter(lab.values()), "")
if base and base not in _REGISTRY:
_REGISTRY[base] = _I18nRegistryEntry(context="nav", value="")
added += 1
logger.info("i18n feature UI labels: %d new keys (nav context)", added) logger.info("i18n feature UI labels: %d new keys (nav context)", added)

View file

@ -9,6 +9,7 @@ import logging
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from modules.shared.eventManagement import eventManager from modules.shared.eventManagement import eventManager
from modules.shared.i18nRegistry import resolveText
# Main loop reference for scheduling async work from job executor (may run in thread) # Main loop reference for scheduling async work from job executor (may run in thread)
_main_loop = None _main_loop = None
@ -214,13 +215,7 @@ def _create_schedule_handler(
title = (inv or {}).get("title") or {} title = (inv or {}).get("title") or {}
requestLang: Optional[str] = getattr(event_user, "language", None) requestLang: Optional[str] = getattr(event_user, "language", None)
if isinstance(title, dict): label = resolveText(title, requestLang) if title else ""
picked = (requestLang and title.get(requestLang)) or title.get("xx") or next(iter(title.values()), "")
label = str(picked) if picked else ""
elif isinstance(title, str):
label = title
else:
label = ""
run_env = default_run_envelope( run_env = default_run_envelope(
"schedule", "schedule",

View file

@ -7,6 +7,7 @@ from typing import Dict, Any
from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelChat import ActionResult, ActionDocument
from modules.serviceCenter.services.serviceGeneration.subDocumentUtility import markdownToDocumentJson from modules.serviceCenter.services.serviceGeneration.subDocumentUtility import markdownToDocumentJson
from modules.shared.i18nRegistry import normalizePrimaryLanguageTag
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -84,7 +85,10 @@ async def create(self, parameters: Dict[str, Any]) -> ActionResult:
outputFormat = (parameters.get("outputFormat") or "docx").strip().lower().lstrip(".") outputFormat = (parameters.get("outputFormat") or "docx").strip().lower().lstrip(".")
title = (parameters.get("title") or "Document").strip() title = (parameters.get("title") or "Document").strip()
templateName = parameters.get("templateName") templateName = parameters.get("templateName")
language = (parameters.get("language") or "de").strip()[:2] language = normalizePrimaryLanguageTag(
str(parameters.get("language") or "de"),
"de",
)
try: try:
structured_content = markdownToDocumentJson(context, title, language) structured_content = markdownToDocumentJson(context, title, language)

View file

@ -14,6 +14,7 @@ import logging
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from modules.shared.eventManagement import eventManager from modules.shared.eventManagement import eventManager
from modules.shared.i18nRegistry import resolveText
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -232,13 +233,7 @@ class WorkflowScheduler:
title = (inv or {}).get("title") or {} title = (inv or {}).get("title") or {}
requestLang: Optional[str] = getattr(eventUser, "language", None) requestLang: Optional[str] = getattr(eventUser, "language", None)
if isinstance(title, dict): label = resolveText(title, requestLang) if title else ""
picked = (requestLang and title.get(requestLang)) or title.get("xx") or next(iter(title.values()), "")
label = str(picked) if picked else ""
elif isinstance(title, str):
label = title
else:
label = ""
runEnv = default_run_envelope( runEnv = default_run_envelope(
"schedule", "schedule",