diff --git a/app.py b/app.py
index 1ce31ae5..1df8510f 100644
--- a/app.py
+++ b/app.py
@@ -380,7 +380,6 @@ async def lifespan(app: FastAPI):
# Register all feature definitions in RBAC catalog (for /api/features/ endpoint)
try:
from modules.security.rbacCatalog import getCatalogService
- from modules.system.registry import registerAllFeaturesInCatalog, syncCatalogFeaturesToDb
catalogService = getCatalogService()
registerAllFeaturesInCatalog(catalogService)
logger.info("Feature catalog registration completed")
@@ -494,7 +493,6 @@ async def lifespan(app: FastAPI):
def _onRunFailed(workflowId, runId, error, mandateId=None, workflowLabel=None):
from modules.serviceCenter import getService
from modules.serviceCenter.context import ServiceCenterContext
- from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelMessaging import MessagingEventParameters
rootInterface = getRootInterface()
@@ -555,6 +553,10 @@ async def lifespan(app: FastAPI):
from modules.serviceCenter.services.serviceSubscription.enterpriseRenewalScheduler import registerEnterpriseRenewalScheduler
registerEnterpriseRenewalScheduler()
+ # Register token and trusted device cleanup scheduler
+ from modules.auth.trustedDeviceService import registerTokenCleanupScheduler
+ registerTokenCleanupScheduler()
+
# Recover background jobs that were RUNNING when the previous worker died
try:
from modules.serviceCenter.services.serviceBackgroundJobs.mainBackgroundJobService import (
@@ -901,6 +903,10 @@ app.include_router(demoConfigRouter)
from modules.routes.routeAdminDatabaseHealth import router as adminDatabaseHealthRouter
app.include_router(adminDatabaseHealthRouter)
+from modules.routes.routeAdminSessions import router as adminSessionsRouter, trustedDeviceRouter as adminTrustedDeviceRouter
+app.include_router(adminSessionsRouter)
+app.include_router(adminTrustedDeviceRouter)
+
from modules.routes.routeGdpr import router as gdprRouter
app.include_router(gdprRouter)
diff --git a/env-dev.env b/env-dev.env
index 457cc7a5..179f7caf 100644
--- a/env-dev.env
+++ b/env-dev.env
@@ -58,7 +58,7 @@ Service_CLICKUP_OAUTH_REDIRECT_URI = http://localhost:8000/api/clickup/auth/conn
# Stripe Billing (both end with _SECRET for encryption script)
STRIPE_SECRET_KEY_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5aHNGejgzQmpTdmprdzQxR19KZkh3MlhYUTNseFN3WnlaWjh2SDZyalN6aU9xSktkbUQwUnZrVnlvbGVRQm4yZFdiRU5aSEk5WVJuUnR4VUwtTm9OVk1WWmJQeU5QaDdib0hfVWV5U1BfYTFXRmdoOWdnOWxkb3JFQmF3bm45UjFUVUxmWGtGRkFKUGd6bmhpQlFnaVI3Q2lLdDlsY1VESk1vOEM0ZFBJNW1qcVZ0N2tPYmRLNmVKajZ2M3o3S05lWnRRVG5LdkRseW4wQ3VjNHNQZTZUdz09
-STRIPE_WEBHOOK_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5dDJMSHBrVk8wTzJhU2xzTTZCZWdvWmU2NGI2WklfRXRJZVUzaVYyOU9GLUZsalUwa2lPdEgtUHo0dVVvRDU1cy1saHJyU0Rxa2xQZjBuakExQzk3bmxBcU9WbEIxUEtpR1JoUFMxZG9ISGRZUXFhdFpSMGxvQUV3a0VLQllfUUtCOHZwTGdteV9rYTFOazBfSlN3ekNWblFpakJlZVlCTmNkWWQ4Sm01a1RCWTlnTlFHWVA0MkZYMlprUExrWFN2V0NVU1BTd1NKczFJbVo3VHpLdlc4UT09
+STRIPE_WEBHOOK_SECRET = DEV_ENC:Z0FBQUFBQnFLeUFlb2dfSjZPaWIyRjZsNjhiSDFQNFpxdW50YmlLUjFLX1lJMGdCWUtBUEdrRGhvSzVVWnkxNVZEdmtkQmk5X05YS0JVU1NyX3VQZTV2VjVwakd0RGM2WUl6TTlzbms1d1NCOTQtdURiVjhxdXZGVlR1ZVNTbUkwOFh1R04yUUxxay0=
STRIPE_API_VERSION = 2026-01-28.clover
STRIPE_AUTOMATIC_TAX_ENABLED = false
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQd14OUoIL0Osj7A0ZQlr0
diff --git a/env-int.env b/env-int.env
index 84e0feb4..a8f67e6f 100644
--- a/env-int.env
+++ b/env-int.env
@@ -60,7 +60,7 @@ Service_CLICKUP_OAUTH_REDIRECT_URI = https://api-int.poweron.swiss/api/clickup/a
# Stripe Billing (both end with _SECRET for encryption script)
STRIPE_SECRET_KEY_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5ekdBaGNGVUlOQUpncTlzLWlTV0V5OWZzQkpDczhCUGw4U1JpTHZ0d3pfYlFNWElLRlNiNlNsaDRYTGZUTkg2OUFrTW1GZXpOUjBVbmRQWjN6ekhHd2ZSQ195OHlaeWh1TmxrUm10V2R3YmdncmFLbFMzVjdqcWJMSUJPR2xuSEozclNoZG1rZVBTaWg3OFQ1Qzdxb0wyQ2RKazc2dG1aZXBUTXlvbDZqLS1KOVI5M3BGc3NQZkZRbnFpRjIwWmh2ZHlVNlpxZVo2dWNmMjQ5eW02QmtzUT09
-STRIPE_WEBHOOK_SECRET = INT_ENC:Z0FBQUFBQnA4UXZiUUVqTl9lREVRWTh1aHFDcFpwcXRkOUx4MS1ham9Ddkl6T0xzMnJuM1hhUHdGNG5CenY1MUg4RlJBOGFQTWl5cVd5MjJ2REItcHYyRmdLX3ZlT2p5Z3BRVkMtQnRoTVkteXlfaU92MVBtOEI0Ni1kbGlfa0NiRmFRRXNHLVE2NHI=
+STRIPE_WEBHOOK_SECRET = INT_ENC:Z0FBQUFBQnFLeUFWUUtMZ25NQ2ZOWE5nRF9CaFNwcXhSU2tKRktLaElLRHJMM295OXNkVEFLekVUMzN0YUpIZHJfWGNqa0xxOFZRVHZEUXVLZ3ItVGZWc2VFQ2thcUlJalY1b0JDSmR6RF96d1A3OGhyd0w1MHZPeFNZRkl0c19kYUJQcHVwR2tsd0s=
STRIPE_API_VERSION = 2026-01-28.clover
STRIPE_AUTOMATIC_TAX_ENABLED = false
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQd14OUoIL0Osj7A0ZQlr0
diff --git a/env-prod.env b/env-prod.env
index 6a2a89d7..686f784b 100644
--- a/env-prod.env
+++ b/env-prod.env
@@ -58,7 +58,7 @@ Service_CLICKUP_OAUTH_REDIRECT_URI = https://api.poweron.swiss/api/clickup/auth/
# Stripe Billing (both end with _SECRET for encryption script)
STRIPE_SECRET_KEY_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6aVA3R3VRS3VHMUgzUEVjYkR4eUZKWFhPUzFTTVlHNnBvT3FienNQaUlBWVpPLXJyVGpGMWk4LXktMXphX0J6ZTVESkJxdjNNa3ZJbF9wX2ppYzdjYlF0cmdVamlEWWJDSmJYYkJseHctTlh4dnNoQWs4SG5haVl2TTNDdXpuaFpqeDBtNkFCbUxMa0RaWG14dmxyOEdILTNrZ2licmNpbXVkN2lFSWoxZW1BODNpV0ZTQ0VaeXRmR1d4RjExMlVFS3MtQU9zZXZlZE1mTmY3OWctUXJHdz09
-STRIPE_WEBHOOK_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGNUpTWldsakYydFhFelBrR1lSaWxYT3kyMENOMUljZTJUZHBWcEhhdWVCMzYxZXQ5b3VlTFVRalFiTVdsbGxrdUx0RDFwSEpsOC1sTDJRTEJNQlA3S3ZaQzBtV1h6bWp5VnlMZUgwUlF3cXYxcnljZVE5SWdzLVg3V0syOWRYS08=
+STRIPE_WEBHOOK_SECRET = PROD_ENC:Z0FBQUFBQnFLeUFNQ1FhVE94ZzM3V3NCVGVVWnltUndsOG1Ra0hQTmJ3QWY5aXVWeTJsX3A4a3VBSnFQd3drWFRZNFVDdWxCeFgyQ0RpNGQ0SlJOcm9tVE5KZmVqQU1WUjFjeDRJeGE5THdmR0g1V2dQUk5SSjcySnAzR245NW5NUFVDT3lJUWpjWFo=
STRIPE_API_VERSION = 2026-01-28.clover
STRIPE_AUTOMATIC_TAX_ENABLED = false
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQZG8WqlVsabrfFEu49pah
diff --git a/modules/auth/trustedDeviceService.py b/modules/auth/trustedDeviceService.py
index a707e70a..d57d5c63 100644
--- a/modules/auth/trustedDeviceService.py
+++ b/modules/auth/trustedDeviceService.py
@@ -169,3 +169,51 @@ def _getClientIp(request: Request) -> Optional[str]:
if request.client:
return request.client.host
return None
+
+
+# --- Scheduler Integration ---
+
+async def _runTokenAndDeviceCleanup() -> None:
+ """Scheduled task: remove expired tokens and trusted devices."""
+ try:
+ from modules.connectors.connectorDbPostgre import ConnectorPostgre
+
+ db = ConnectorPostgre("poweron_app")
+ now = getUtcTimestamp()
+
+ # Expired auth-session tokens
+ tokens = db.getRecordset(
+ Token,
+ recordFilter={"tokenPurpose": TokenPurpose.AUTH_SESSION.value},
+ )
+ expiredCount = 0
+ for t in tokens:
+ if t.get("expiresAt", 0) < now:
+ db.recordDelete(Token, t["id"])
+ expiredCount += 1
+
+ # Expired trusted devices
+ deviceCount = cleanupExpiredDevices(db)
+
+ if expiredCount or deviceCount:
+ logger.info(
+ f"Token cleanup: {expiredCount} expired token(s), "
+ f"{deviceCount} expired trusted device(s) removed"
+ )
+ except Exception as e:
+ logger.error(f"Token/device cleanup failed: {e}")
+
+
+def registerTokenCleanupScheduler() -> None:
+ """Register daily token cleanup job. Call during app startup."""
+ try:
+ from modules.shared.eventManagement import eventManager
+
+ eventManager.registerCron(
+ jobId="token_device_cleanup",
+ func=_runTokenAndDeviceCleanup,
+ cronKwargs={"hour": "4", "minute": "0"},
+ )
+ logger.info("Token/device cleanup scheduler registered (daily 04:00)")
+ except Exception as e:
+ logger.warning(f"Failed to register token cleanup scheduler: {e}")
diff --git a/modules/features/commcoach/interfaceFeatureCommcoach.py b/modules/features/commcoach/interfaceFeatureCommcoach.py
index d4c51a27..a2e18770 100644
--- a/modules/features/commcoach/interfaceFeatureCommcoach.py
+++ b/modules/features/commcoach/interfaceFeatureCommcoach.py
@@ -261,35 +261,29 @@ class CommcoachObjects:
# =========================================================================
def getPersonas(self, userId: str, instanceId: str) -> List[Dict[str, Any]]:
- from .datamodelCommcoach import CoachingPersona
builtins = self.db.getRecordset(CoachingPersona, recordFilter={"userId": "system"})
custom = self.db.getRecordset(CoachingPersona, recordFilter={"userId": userId, "instanceId": instanceId})
all = builtins + custom
return [p for p in all if p.get("isActive", True)]
def getPersona(self, personaId: str) -> Optional[Dict[str, Any]]:
- from .datamodelCommcoach import CoachingPersona
records = self.db.getRecordset(CoachingPersona, recordFilter={"id": personaId})
return records[0] if records else None
def createPersona(self, data: Dict[str, Any]) -> Dict[str, Any]:
- from .datamodelCommcoach import CoachingPersona
data["createdAt"] = getIsoTimestamp()
data["updatedAt"] = getIsoTimestamp()
return self.db.recordCreate(CoachingPersona, data)
def updatePersona(self, personaId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
- from .datamodelCommcoach import CoachingPersona
updates["updatedAt"] = getIsoTimestamp()
return self.db.recordModify(CoachingPersona, personaId, updates)
def deletePersona(self, personaId: str) -> bool:
- from .datamodelCommcoach import CoachingPersona
return self.db.recordDelete(CoachingPersona, personaId)
def getAllPersonas(self, instanceId: str) -> List[Dict[str, Any]]:
"""All personas (builtin + custom for this instance), including inactive."""
- from .datamodelCommcoach import CoachingPersona
builtins = self.db.getRecordset(CoachingPersona, recordFilter={"userId": "system"})
custom = self.db.getRecordset(CoachingPersona, recordFilter={"instanceId": instanceId})
custom = [p for p in custom if p.get("userId") != "system"]
@@ -300,11 +294,9 @@ class CommcoachObjects:
# =========================================================================
def getModulePersonas(self, moduleId: str) -> List[Dict[str, Any]]:
- from .datamodelCommcoach import ModulePersonaMapping
return self.db.getRecordset(ModulePersonaMapping, recordFilter={"moduleId": moduleId})
def setModulePersonas(self, moduleId: str, personaIds: List[str], instanceId: str) -> List[Dict[str, Any]]:
- from .datamodelCommcoach import ModulePersonaMapping
existing = self.db.getRecordset(ModulePersonaMapping, recordFilter={"moduleId": moduleId})
for rec in existing:
self.db.recordDelete(ModulePersonaMapping, rec["id"])
@@ -325,18 +317,15 @@ class CommcoachObjects:
# =========================================================================
def getBadges(self, userId: str, instanceId: str) -> List[Dict[str, Any]]:
- from .datamodelCommcoach import CoachingBadge
records = self.db.getRecordset(CoachingBadge, recordFilter={"userId": userId, "instanceId": instanceId})
records.sort(key=lambda r: r.get("awardedAt") or 0, reverse=True)
return records
def hasBadge(self, userId: str, instanceId: str, badgeKey: str) -> bool:
- from .datamodelCommcoach import CoachingBadge
records = self.db.getRecordset(CoachingBadge, recordFilter={"userId": userId, "instanceId": instanceId, "badgeKey": badgeKey})
return len(records) > 0
def awardBadge(self, data: Dict[str, Any]) -> Dict[str, Any]:
- from .datamodelCommcoach import CoachingBadge
data["awardedAt"] = getUtcTimestamp()
data["createdAt"] = getIsoTimestamp()
return self.db.recordCreate(CoachingBadge, data)
diff --git a/modules/features/commcoach/routeFeatureCommcoach.py b/modules/features/commcoach/routeFeatureCommcoach.py
index 81e1254d..905ffbc9 100644
--- a/modules/features/commcoach/routeFeatureCommcoach.py
+++ b/modules/features/commcoach/routeFeatureCommcoach.py
@@ -333,7 +333,6 @@ async def startSession(
try:
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
voiceInterface = getVoiceInterface(context.user, mandateId)
- from .serviceCommcoach import getUserVoicePrefs, stripMarkdownForTts, buildTtsConfigErrorMessage
language, voiceName = getUserVoicePrefs(userId, mandateId)
ttsResult = await voiceInterface.textToSpeech(
text=stripMarkdownForTts(greetingText),
@@ -378,7 +377,6 @@ async def startSession(
asyncio.create_task(service.processSessionOpening(sessionId, moduleId, interface))
async def _newSessionEventGenerator():
- from modules.shared.timeUtils import getIsoTimestamp
timeoutCount = 0
try:
while True:
@@ -468,7 +466,6 @@ async def cancelSession(
raise HTTPException(status_code=404, detail=routeApiMsg("Session not found"))
_validateOwnership(session, context)
- from modules.shared.timeUtils import getUtcTimestamp
interface.updateSession(sessionId, {
"status": CoachingSessionStatus.CANCELLED.value,
"endedAt": getUtcTimestamp(),
@@ -581,7 +578,6 @@ async def sendAudioStream(
if not audioBody:
raise HTTPException(status_code=400, detail=routeApiMsg("No audio data received"))
- from .serviceCommcoach import getUserVoicePrefs
language, _ = getUserVoicePrefs(str(context.user.id), mandateId)
moduleId = session.get("moduleId")
@@ -765,7 +761,6 @@ async def updateTaskStatus(
updates = {"status": body.status.value}
if body.status == CoachingTaskStatus.DONE:
- from modules.shared.timeUtils import getUtcTimestamp
updates["completedAt"] = getUtcTimestamp()
updated = interface.updateTask(taskId, updates)
diff --git a/modules/features/commcoach/serviceCommcoach.py b/modules/features/commcoach/serviceCommcoach.py
index b3b5ef2a..315a9dff 100644
--- a/modules/features/commcoach/serviceCommcoach.py
+++ b/modules/features/commcoach/serviceCommcoach.py
@@ -98,7 +98,6 @@ def getUserVoicePrefs(userId: str, mandateId: Optional[str] = None) -> tuple:
"""Load voice language and voiceName from central UserVoicePreferences.
Returns (language, voiceName) tuple."""
try:
- from modules.datamodels.datamodelUam import UserVoicePreferences
from modules.interfaces.interfaceDbApp import getRootInterface
rootIf = getRootInterface()
prefs = rootIf.db.getRecordset(
@@ -430,7 +429,6 @@ async def _resolveDocumentIntent(combinedUserPrompt: str, docs: List[Dict[str, A
"""Pre-AI-call: identify which documents the user references and what action is needed."""
if not docs:
return {"read": [], "update": [], "create": [], "noDocumentAction": True}
- from . import serviceCommcoachAi as aiPrompts
docCatalog = [{"id": d.get("id", ""), "title": d.get("summary") or d.get("fileName", ""), "summary": (d.get("summary") or "")[:100]} for d in docs]
prompt = aiPrompts.buildDocumentIntentPrompt(combinedUserPrompt, docCatalog)
try:
@@ -744,7 +742,6 @@ class CommcoachService:
4. Map agent events to CommCoach SSE events
5. Post-processing: store message, TTS, tasks, scores
"""
- from . import interfaceFeatureCommcoach as interfaceDb
# Store user message
userMsg = CoachingMessage(
@@ -907,7 +904,6 @@ class CommcoachService:
)
agentService = getService("agent", serviceContext)
- from modules.datamodels.datamodelAi import PriorityEnum, OperationTypeEnum
config = AgentConfig(
toolSet="commcoach" if useTools else "none",
maxRounds=3 if useTools else 1,
diff --git a/modules/features/neutralization/serviceNeutralization/subProcessList.py b/modules/features/neutralization/serviceNeutralization/subProcessList.py
index a42904ff..ecc14b78 100644
--- a/modules/features/neutralization/serviceNeutralization/subProcessList.py
+++ b/modules/features/neutralization/serviceNeutralization/subProcessList.py
@@ -157,7 +157,6 @@ class ListProcessor:
processedAttrs[attrName] = self.string_parser.mapping[attrValue]
else:
# Check if attribute value matches any data patterns
- from .subPatterns import findPatternsInText, DataPatterns
matches = findPatternsInText(attrValue, DataPatterns.patterns)
if matches:
patternName = matches[0][0]
@@ -191,7 +190,6 @@ class ListProcessor:
# Skip if already a placeholder
if not self.string_parser._isPlaceholder(text):
# Check if text matches any patterns
- from .subPatterns import findPatternsInText, DataPatterns
patternMatches = findPatternsInText(text, DataPatterns.patterns)
if patternMatches:
diff --git a/modules/features/realEstate/interfaceFeatureRealEstate.py b/modules/features/realEstate/interfaceFeatureRealEstate.py
index 24fe4955..5d1ea3a6 100644
--- a/modules/features/realEstate/interfaceFeatureRealEstate.py
+++ b/modules/features/realEstate/interfaceFeatureRealEstate.py
@@ -796,7 +796,6 @@ class RealEstateObjects:
return False
tableName = modelClass.__name__
- from modules.interfaces.interfaceRbac import buildDataObjectKey
objectKey = buildDataObjectKey(tableName, featureCode=self.featureCode if hasattr(self, 'featureCode') else None)
permissions = self.rbac.getUserPermissions(
self.currentUser,
diff --git a/modules/features/teamsbot/routeFeatureTeamsbot.py b/modules/features/teamsbot/routeFeatureTeamsbot.py
index b2ac2980..c4219ac7 100644
--- a/modules/features/teamsbot/routeFeatureTeamsbot.py
+++ b/modules/features/teamsbot/routeFeatureTeamsbot.py
@@ -328,7 +328,6 @@ async def startSession(
if context.isSysAdmin and joinMode == TeamsbotJoinMode.SYSTEM_BOT:
systemBot = interface.getActiveSystemBot(mandateId)
if not systemBot:
- from .datamodelTeamsbot import TeamsbotSystemBot
allBots = interface.db.getRecordset(TeamsbotSystemBot, recordFilter={"isActive": True})
if allBots:
systemBot = allBots[0]
@@ -537,7 +536,6 @@ async def streamSession(
async def _eventGenerator():
"""Generate SSE events from the session event queue."""
- from .service import sessionEvents
# Send initial session state with stats
stats = interface.getSessionStats(sessionId)
@@ -545,7 +543,6 @@ async def streamSession(
# Send current bot WebSocket connection state so the operator UI can
# render the live indicator without waiting for the next connect/disconnect.
- from .service import getActiveService as _getActiveService
yield f"data: {json.dumps({'type': 'botConnectionState', 'data': {'connected': _getActiveService(sessionId) is not None}})}\n\n"
# Stream events
@@ -1040,7 +1037,6 @@ async def submitDirectorPrompt(
detail=routeApiMsg(f"Too many files ({len(fileIds)}); max {DIRECTOR_PROMPT_FILE_LIMIT}"),
)
- from .service import getActiveService
service = getActiveService(sessionId)
if not service:
raise HTTPException(
@@ -1108,7 +1104,6 @@ async def deleteDirectorPrompt(
if not context.isPlatformAdmin and prompt.get("operatorUserId") != str(context.user.id):
raise HTTPException(status_code=404, detail=f"Prompt '{promptId}' not found")
- from .service import getActiveService
service = getActiveService(sessionId)
if service:
await service.removePersistentPrompt(promptId)
@@ -1134,7 +1129,6 @@ async def testVoice(
):
"""Test TTS voice with AI-generated sample text in the correct language."""
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
- from .service import createAiService
from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, PriorityEnum
mandateId = _validateInstanceAccess(instanceId, context)
@@ -1547,7 +1541,6 @@ async def postTranscript(
originalUser = rootUser
# Process transcript through the service pipeline
- from .service import TeamsbotService
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
service = TeamsbotService(originalUser, mandateId, instanceId, config)
@@ -1600,7 +1593,6 @@ async def postBotStatus(
if not originalUser:
originalUser = rootUser
- from .service import TeamsbotService
service = TeamsbotService(originalUser, mandateId, instanceId, config)
interface = interfaceDb.getInterface(originalUser, mandateId=mandateId, featureInstanceId=instanceId)
@@ -1640,7 +1632,6 @@ async def botWebsocket(
# 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.
- from modules.interfaces.interfaceDbApp import getRootInterface
rootInterface = getRootInterface()
rootUser = rootInterface.currentUser
diff --git a/modules/features/trustee/accounting/accountingBridge.py b/modules/features/trustee/accounting/accountingBridge.py
index 51433bbf..d9b9fe73 100644
--- a/modules/features/trustee/accounting/accountingBridge.py
+++ b/modules/features/trustee/accounting/accountingBridge.py
@@ -33,7 +33,6 @@ class AccountingBridge:
async def getActiveConfig(self, featureInstanceId: str) -> Optional[Dict[str, Any]]:
"""Load the active TrusteeAccountingConfig for a feature instance."""
- from modules.features.trustee.datamodelFeatureTrustee import TrusteeAccountingConfig
records = self._trusteeInterface.db.getRecordset(
TrusteeAccountingConfig,
recordFilter={"featureInstanceId": featureInstanceId, "isActive": True},
@@ -128,7 +127,6 @@ class AccountingBridge:
Optional _resolved* params allow pushBatchToAccounting to pass a pre-resolved
connector/config so we don't decrypt per position (avoids rate-limit).
"""
- from modules.features.trustee.datamodelFeatureTrustee import TrusteePosition, TrusteeAccountingSync
connector = _resolvedConnector
plainConfig = _resolvedPlainConfig
@@ -306,7 +304,6 @@ class AccountingBridge:
# Update last sync on config record
if configRecord:
- from modules.features.trustee.datamodelFeatureTrustee import TrusteeAccountingConfig
updatePayload = {
"lastSyncAt": time.time(),
"lastSyncStatus": "success" if result.success else "error",
@@ -335,7 +332,6 @@ class AccountingBridge:
async def refreshChartOfAccounts(self, featureInstanceId: str) -> List[AccountingChart]:
"""Fetch the full chart of accounts from the external system and cache it locally on TrusteeAccountingConfig."""
- from modules.features.trustee.datamodelFeatureTrustee import TrusteeAccountingConfig
connector, plainConfig, configRecord = await self._resolveConnectorAndConfig(featureInstanceId)
if not connector or not plainConfig or not configRecord:
diff --git a/modules/features/trustee/interfaceFeatureTrustee.py b/modules/features/trustee/interfaceFeatureTrustee.py
index 1e13f185..249b2d50 100644
--- a/modules/features/trustee/interfaceFeatureTrustee.py
+++ b/modules/features/trustee/interfaceFeatureTrustee.py
@@ -309,7 +309,6 @@ class TrusteeObjects:
return False
tableName = modelClass.__name__
- from modules.interfaces.interfaceRbac import buildDataObjectKey
objectKey = buildDataObjectKey(tableName, featureCode=self.featureCode if hasattr(self, 'featureCode') else None)
permissions = self.rbac.getUserPermissions(
self.currentUser,
@@ -338,7 +337,6 @@ class TrusteeObjects:
return AccessLevel.NONE
tableName = modelClass.__name__
- from modules.interfaces.interfaceRbac import buildDataObjectKey
objectKey = buildDataObjectKey(tableName, featureCode=self.featureCode if hasattr(self, 'featureCode') else None)
permissions = self.rbac.getUserPermissions(
self.currentUser,
diff --git a/modules/features/trustee/routeFeatureTrustee.py b/modules/features/trustee/routeFeatureTrustee.py
index dbc96013..6e6b9924 100644
--- a/modules/features/trustee/routeFeatureTrustee.py
+++ b/modules/features/trustee/routeFeatureTrustee.py
@@ -170,7 +170,6 @@ def getQuickActions(
if role and role.roleLabel:
userRoleLabels.add(role.roleLabel)
- from modules.shared.i18nRegistry import resolveText
lang = (language or "de").strip() or "de"
@@ -1201,7 +1200,6 @@ def _buildSyncStatusByPosition(interface, instanceId: str) -> Dict[str, Dict[str
``error``, so a successful retry hides an old failure. Any other status
(`pending`, `cancelled`, ...) is kept verbatim.
"""
- from .datamodelFeatureTrustee import TrusteeAccountingSync
syncRecords = interface.db.getRecordset(
TrusteeAccountingSync, recordFilter={"featureInstanceId": instanceId}
@@ -1290,7 +1288,6 @@ def _handlePositionMode(instanceId, mandateId, mode, column, pagination, context
"""Handle mode=filterValues and mode=ids for trustee positions."""
from modules.dbHelpers.paginationHelpers import handleIdsInMemory, handleFilterValuesInMemory
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
- from .datamodelFeatureTrustee import TrusteePositionView
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
if mode == "filterValues":
if not column:
@@ -1507,7 +1504,6 @@ def delete_accounting_config(
"""Remove the accounting integration for this instance."""
mandateId = _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
- from .datamodelFeatureTrustee import TrusteeAccountingConfig
records = interface.db.getRecordset(TrusteeAccountingConfig, recordFilter={"featureInstanceId": instanceId})
for r in records:
interface.db.recordDelete(TrusteeAccountingConfig, r.get("id"))
@@ -1602,7 +1598,6 @@ def get_sync_status(
"""Get sync status of all positions for this instance."""
mandateId = _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
- from .datamodelFeatureTrustee import TrusteeAccountingSync
records = interface.db.getRecordset(TrusteeAccountingSync, recordFilter={"featureInstanceId": instanceId})
return {"items": [{k: v for k, v in r.items() if not k.startswith("_")} for r in records]}
@@ -1618,7 +1613,6 @@ def get_position_sync_status(
"""Get sync status for a specific position."""
mandateId = _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
- from .datamodelFeatureTrustee import TrusteeAccountingSync
records = interface.db.getRecordset(TrusteeAccountingSync, recordFilter={"positionId": positionId, "featureInstanceId": instanceId})
return {"items": [{k: v for k, v in r.items() if not k.startswith("_")} for r in records]}
@@ -1776,7 +1770,6 @@ def _serializeRoleForApi(role) -> Dict[str, Any]:
here (same pattern as ``getQuickActions``). Without this the React tree
crashes with "Objects are not valid as a React child".
"""
- from modules.shared.i18nRegistry import resolveText
payload = role.model_dump()
payload["description"] = resolveText(payload.get("description"))
return payload
diff --git a/modules/features/trustee/workflows/methodTrustee/actions/extractFromFiles.py b/modules/features/trustee/workflows/methodTrustee/actions/extractFromFiles.py
index 240809c1..9574b7c6 100644
--- a/modules/features/trustee/workflows/methodTrustee/actions/extractFromFiles.py
+++ b/modules/features/trustee/workflows/methodTrustee/actions/extractFromFiles.py
@@ -269,7 +269,6 @@ async def _extractWithAi(
) -> Dict[str, Any]:
"""3-step extraction: (1a) OCR/text via Vision AI, (1b) classify text, (2) structure by type."""
await self.services.ai.ensureAiObjectsInitialized()
- from modules.datamodels.datamodelDocref import DocumentReferenceList, DocumentItemReference
docList = DocumentReferenceList(
references=[DocumentItemReference(documentId=chatDocumentId, fileName=fileName)]
diff --git a/modules/features/workspace/routeFeatureWorkspace.py b/modules/features/workspace/routeFeatureWorkspace.py
index c6d143dd..18cf0da8 100644
--- a/modules/features/workspace/routeFeatureWorkspace.py
+++ b/modules/features/workspace/routeFeatureWorkspace.py
@@ -917,7 +917,6 @@ async def _runWorkspaceAgent(
messagePersisted = False
_toolSet = _cfg.get("toolSet", "core")
_agentCfg = _cfg.get("agentConfig")
- from modules.serviceCenter.services.serviceAgent.datamodelAgent import AgentConfig
agentCfgDict = dict(_agentCfg) if isinstance(_agentCfg, dict) else {}
try:
diff --git a/modules/interfaces/interfaceAiObjects.py b/modules/interfaces/interfaceAiObjects.py
index a1800648..77bc1f5e 100644
--- a/modules/interfaces/interfaceAiObjects.py
+++ b/modules/interfaces/interfaceAiObjects.py
@@ -465,7 +465,6 @@ class AiObjects:
toolChoice: Any = None,
) -> AsyncGenerator[Union[str, AiCallResponse], None]:
"""Stream a model call. Yields str deltas, then final AiCallResponse with billing."""
- from modules.datamodels.datamodelAi import AiModelCall, AiModelResponse
inputBytes = sum(len(str(m.get("content", "")).encode("utf-8")) for m in messages)
startTime = time.time()
@@ -537,7 +536,6 @@ class AiObjects:
Returns:
AiCallResponse with metadata["embeddings"] containing the vectors.
"""
- from modules.aicore.aicoreBase import ContextLengthExceededException
if options is None:
options = AiCallOptions(operationType=OperationTypeEnum.EMBEDDING)
diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py
index a3dda3ba..250190fb 100644
--- a/modules/interfaces/interfaceDbApp.py
+++ b/modules/interfaces/interfaceDbApp.py
@@ -220,7 +220,6 @@ class AppObjects:
tableName = modelClass.__name__
# Use buildDataObjectKey for semantic namespace lookup
- from modules.interfaces.interfaceRbac import buildDataObjectKey
objectKey = buildDataObjectKey(tableName)
permissions = self.rbac.getUserPermissions(
self.currentUser,
@@ -1122,8 +1121,6 @@ class AppObjects:
def _deleteUserReferencedData(self, userId: str) -> None:
"""Deletes all data associated with a user (full cascade)."""
try:
- from modules.datamodels.datamodelNotification import UserNotification
- from modules.datamodels.datamodelInvitation import Invitation
# 1. FeatureAccess + FeatureAccessRole
accesses = self.db.getRecordset(FeatureAccess, recordFilter={"userId": userId})
@@ -1560,7 +1557,6 @@ class AppObjects:
# Copy system template roles to new mandate (admin, user, viewer + AccessRules)
try:
- from modules.interfaces.interfaceRbac import copySystemRolesToMandate
copiedCount = copySystemRolesToMandate(self.db, mandateId)
logger.info(f"Copied {copiedCount} system roles to new mandate {mandateId}")
except Exception as e:
@@ -1576,8 +1572,6 @@ class AppObjects:
``mandateLabel`` is the display name (Voller Name); a unique slug ``name`` (Kurzzeichen) is derived.
"""
from modules.datamodels.datamodelSubscription import MandateSubscription, SubscriptionStatusEnum, BUILTIN_PLANS
- from modules.datamodels.datamodelFeatures import FeatureInstance
- from modules.interfaces.interfaceRbac import copySystemRolesToMandate
from modules.interfaces.interfaceFeatures import getFeatureInterface
from modules.shared.featureDiscovery import loadFeatureMainModules
plan = BUILTIN_PLANS.get(planKey)
@@ -1847,7 +1841,6 @@ class AppObjects:
raise PermissionError(f"No permission to delete mandate {mandateId}")
if not force:
- from modules.shared.timeUtils import getUtcTimestamp
self.db.recordModify(Mandate, mandateId, {"enabled": False, "deletedAt": getUtcTimestamp()})
logger.info(f"Soft-deleted mandate {mandateId} (30-day retention)")
return True
@@ -1858,8 +1851,6 @@ class AppObjects:
from modules.datamodels.datamodelFiles import FileItem
from modules.datamodels.datamodelDataSource import DataSource
from modules.datamodels.datamodelKnowledge import FileContentIndex, ContentChunk
- from modules.datamodels.datamodelFeatures import FeatureDataSource
- from modules.datamodels.datamodelFeatures import DataNeutralizerAttributes
instances = self.db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
@@ -1983,7 +1974,6 @@ class AppObjects:
# 3b. Billing data cascade handled by onMandateDelete lifecycle hook (interfaceDbBilling)
# 3c. Delete Invitations for this mandate
- from modules.datamodels.datamodelInvitation import Invitation
invitations = self.db.getRecordset(Invitation, recordFilter={"mandateId": mandateId})
for inv in invitations:
self.db.recordDelete(Invitation, inv.get("id"))
@@ -1991,7 +1981,6 @@ class AppObjects:
logger.info(f"Cascade: deleted {len(invitations)} Invitations for mandate {mandateId}")
# 4. Delete mandate-level Roles
- from modules.datamodels.datamodelRbac import Role, AccessRule
roles = self.db.getRecordset(Role, recordFilter={"mandateId": mandateId})
for role in roles:
rules = self.db.getRecordset(AccessRule, recordFilter={"roleId": role.get("id")})
@@ -3961,7 +3950,6 @@ class AppObjects:
def getTableListViews(self, contextKey: str) -> list:
"""Return all saved views for the current user and contextKey."""
- from modules.datamodels.datamodelPagination import TableListView
try:
rows = self.db.getRecordset(
TableListView,
@@ -3980,7 +3968,6 @@ class AppObjects:
def getTableListView(self, contextKey: str, viewKey: str):
"""Return one view by viewKey or None if not found."""
- from modules.datamodels.datamodelPagination import TableListView
try:
rows = self.db.getRecordset(
TableListView,
@@ -3996,8 +3983,6 @@ class AppObjects:
def createTableListView(self, contextKey: str, viewKey: str, displayName: str, config: dict):
"""Create a new view. Raises ValueError if viewKey already exists for this context."""
- from modules.datamodels.datamodelPagination import TableListView
- from modules.shared.timeUtils import getUtcTimestamp
if self.getTableListView(contextKey=contextKey, viewKey=viewKey) is not None:
raise ValueError(f"View '{viewKey}' already exists for context '{contextKey}'")
data = {
@@ -4018,8 +4003,6 @@ class AppObjects:
def updateTableListView(self, viewId: str, updates: dict):
"""Update an existing view by its primary key id."""
- from modules.datamodels.datamodelPagination import TableListView
- from modules.shared.timeUtils import getUtcTimestamp
try:
updates = {**updates, "updatedAt": getUtcTimestamp()}
self.db.recordModify(TableListView, viewId, updates)
@@ -4034,7 +4017,6 @@ class AppObjects:
def deleteTableListView(self, viewId: str) -> bool:
"""Delete a view by primary key id. Returns True on success."""
- from modules.datamodels.datamodelPagination import TableListView
try:
self.db.recordDelete(TableListView, viewId)
return True
diff --git a/modules/interfaces/interfaceDbBilling.py b/modules/interfaces/interfaceDbBilling.py
index 94600a0c..158f6d86 100644
--- a/modules/interfaces/interfaceDbBilling.py
+++ b/modules/interfaces/interfaceDbBilling.py
@@ -1654,8 +1654,6 @@ class BillingObjects:
`amount` column. Resolves matching mandate/user IDs via the app DB
first, then builds a single SQL query with OR-combined conditions.
"""
- from modules.connectors.connectorDbPostgre import getModelFields, parseRecordFields
- from modules.datamodels.datamodelUam import UserInDB
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
table = BillingTransaction.__name__
diff --git a/modules/interfaces/interfaceDbChat.py b/modules/interfaces/interfaceDbChat.py
index 71ccb774..4bbba04b 100644
--- a/modules/interfaces/interfaceDbChat.py
+++ b/modules/interfaces/interfaceDbChat.py
@@ -393,7 +393,6 @@ class ChatObjects:
tableName = modelClass.__name__
# Use buildDataObjectKey for semantic namespace lookup
- from modules.interfaces.interfaceRbac import buildDataObjectKey
objectKey = buildDataObjectKey(tableName)
permissions = self.rbac.getUserPermissions(
self.currentUser,
@@ -826,7 +825,6 @@ class ChatObjects:
if not effectiveMandateId:
# Fall back to Root mandate (first mandate in system)
try:
- from modules.datamodels.datamodelUam import Mandate
from modules.security.rootAccess import getRootDbAppConnector
dbAppConn = getRootDbAppConnector()
allMandates = dbAppConn.getRecordset(Mandate)
diff --git a/modules/interfaces/interfaceDbKnowledge.py b/modules/interfaces/interfaceDbKnowledge.py
index 9c5a9bd3..dfafebe0 100644
--- a/modules/interfaces/interfaceDbKnowledge.py
+++ b/modules/interfaces/interfaceDbKnowledge.py
@@ -741,7 +741,6 @@ def migrateVectorDimensions():
If it differs from the target, nulls existing embeddings and alters the column type.
Safe to call on every startup — skips when dimensions already match or table doesn't exist.
"""
- from modules.datamodels.datamodelKnowledge import KNOWLEDGE_EMBEDDING_DIMENSIONS
targetDim = KNOWLEDGE_EMBEDDING_DIMENSIONS
interface = getInterface()
diff --git a/modules/interfaces/interfaceDbManagement.py b/modules/interfaces/interfaceDbManagement.py
index 93e2d1c3..66f6485c 100644
--- a/modules/interfaces/interfaceDbManagement.py
+++ b/modules/interfaces/interfaceDbManagement.py
@@ -317,7 +317,6 @@ class ComponentObjects:
return False
tableName = modelClass.__name__
- from modules.interfaces.interfaceRbac import buildDataObjectKey
objectKey = buildDataObjectKey(tableName)
permissions = self.rbac.getUserPermissions(
self.currentUser,
@@ -1066,7 +1065,6 @@ class ComponentObjects:
Owners always can. Non-owners need RBAC ALL level."""
if self._isFolderOwner(folder):
return
- from modules.interfaces.interfaceRbac import buildDataObjectKey
objectKey = buildDataObjectKey("FileFolder")
permissions = self.rbac.getUserPermissions(
self.currentUser, AccessRuleContext.DATA, objectKey,
@@ -1207,7 +1205,6 @@ class ComponentObjects:
self._requireFolderWriteAccess(folder, folderId, "update")
if scope == "global":
- from modules.interfaces.interfaceRbac import buildDataObjectKey
objectKey = buildDataObjectKey("FileFolder")
permissions = self.rbac.getUserPermissions(
self.currentUser, AccessRuleContext.DATA, objectKey,
@@ -1387,8 +1384,6 @@ class ComponentObjects:
Owners always can. Non-owners need RBAC ALL level."""
if self._isFileOwner(file):
return
- from modules.interfaces.interfaceRbac import buildDataObjectKey
- from modules.datamodels.datamodelRbac import AccessRuleContext
objectKey = buildDataObjectKey("FileItem")
permissions = self.rbac.getUserPermissions(
self.currentUser, AccessRuleContext.DATA, objectKey,
diff --git a/modules/interfaces/interfaceRbac.py b/modules/interfaces/interfaceRbac.py
index ebcf8c56..22047ab3 100644
--- a/modules/interfaces/interfaceRbac.py
+++ b/modules/interfaces/interfaceRbac.py
@@ -379,7 +379,6 @@ def getRecordsetWithRBAC(
# Handle JSONB fields and ensure numeric types are correct
# Import the helper function from connector module
- from modules.connectors.connectorDbPostgre import getModelFields
fields = getModelFields(modelClass)
for record in records:
for fieldName, fieldType in fields.items():
@@ -511,7 +510,6 @@ def getRecordsetPaginatedWithRBAC(
whereValues.append(value)
if pagination and pagination.filters:
- from modules.connectors.connectorDbPostgre import getModelFields
fields = getModelFields(modelClass)
validColumns = set(fields.keys())
for key, val in pagination.filters.items():
@@ -545,7 +543,6 @@ def getRecordsetPaginatedWithRBAC(
orderParts: List[str] = []
if pagination and pagination.sort:
- from modules.connectors.connectorDbPostgre import getModelFields
validColumns = set(getModelFields(modelClass).keys())
for sf in pagination.sort:
if sf.field in validColumns:
@@ -569,7 +566,6 @@ def getRecordsetPaginatedWithRBAC(
cursor.execute(dataSql, whereValues)
records = [dict(row) for row in cursor.fetchall()]
- from modules.connectors.connectorDbPostgre import getModelFields, parseRecordFields
fields = getModelFields(modelClass)
for record in records:
parseRecordFields(record, fields, f"table {table}")
@@ -625,7 +621,6 @@ def getDistinctColumnValuesWithRBAC(
if not connector._ensureTableExists(modelClass):
return []
- from modules.connectors.connectorDbPostgre import getModelFields
fields = getModelFields(modelClass)
if column not in fields:
return []
@@ -949,7 +944,6 @@ def buildRbacWhereClause(
# Fall back to Root mandate (first mandate in system) for GROUP access
# This allows system-level tables to be accessed without explicit mandate context
try:
- from modules.datamodels.datamodelUam import Mandate
dbApp = getRootDbAppConnector()
allMandates = dbApp.getRecordset(Mandate)
if allMandates:
diff --git a/modules/interfaces/interfaceTableHelpers.py b/modules/interfaces/interfaceTableHelpers.py
index e7c188c5..84b4cdd0 100644
--- a/modules/interfaces/interfaceTableHelpers.py
+++ b/modules/interfaces/interfaceTableHelpers.py
@@ -85,7 +85,6 @@ def applyViewToParams(params: Optional["PaginationParams"], viewConfig: Optional
Returns the (mutated) params, or a new minimal PaginationParams when
params is None (so callers always get a valid object).
"""
- from modules.datamodels.datamodelPagination import SortField
if not viewConfig:
return params
@@ -264,7 +263,6 @@ def buildGroupLayout(
-------
(page_items, GroupLayout | None)
"""
- from modules.datamodels.datamodelPagination import GroupBand, GroupLayout
if not groupByLevels:
offset = (page - 1) * pageSize
diff --git a/modules/routes/routeAdminFeatures.py b/modules/routes/routeAdminFeatures.py
index e8daa385..ec6d7878 100644
--- a/modules/routes/routeAdminFeatures.py
+++ b/modules/routes/routeAdminFeatures.py
@@ -473,7 +473,6 @@ def list_feature_instances(
items = [inst.model_dump() for inst in instances]
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
- from modules.datamodels.datamodelFeatures import FeatureInstance
enrichRowsWithFkLabels(items, FeatureInstance, db=rootInterface.db)
if mode == "filterValues":
diff --git a/modules/routes/routeAdminRbacRules.py b/modules/routes/routeAdminRbacRules.py
index 83aaef00..cd91f36c 100644
--- a/modules/routes/routeAdminRbacRules.py
+++ b/modules/routes/routeAdminRbacRules.py
@@ -40,7 +40,6 @@ def _getAdminMandateIds(context: RequestContext) -> List[str]:
"""Get mandate IDs where the user has an admin role."""
mandateIds = []
try:
- from modules.interfaces.interfaceDbApp import getRootInterface
rootInterface = getRootInterface()
userMandates = rootInterface.getUserMandates(str(context.user.id))
for um in userMandates:
@@ -64,7 +63,6 @@ def _getAdminMandateIds(context: RequestContext) -> List[str]:
def _isRoleInAdminMandates(roleId: str, adminMandateIds: List[str]) -> bool:
"""Check if a role belongs to one of the admin's mandates."""
try:
- from modules.interfaces.interfaceDbApp import getRootInterface
rootInterface = getRootInterface()
role = rootInterface.getRole(roleId)
if not role:
@@ -1405,7 +1403,6 @@ def cleanup_duplicate_access_rules(
# Phase 2: Fix template role assignments
# UserMandateRole should reference mandate-instance roles, not templates
# =====================================================================
- from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
allUserMandateRoles = rootInterface.db.getRecordset(UserMandateRole)
templateFixDetails = []
diff --git a/modules/routes/routeBilling.py b/modules/routes/routeBilling.py
index ca95f7da..1d1441c4 100644
--- a/modules/routes/routeBilling.py
+++ b/modules/routes/routeBilling.py
@@ -756,7 +756,6 @@ def createOrUpdateSettings(
return result or existingSettings
return existingSettings
else:
- from modules.datamodels.datamodelBilling import BillingSettings
newSettings = BillingSettings(
mandateId=targetMandateId,
@@ -821,7 +820,6 @@ def addCredit(
if creditRequest.amount == 0:
raise HTTPException(status_code=400, detail=routeApiMsg("Amount must not be zero"))
- from modules.datamodels.datamodelBilling import BillingTransaction
isDeduction = creditRequest.amount < 0
txType = TransactionTypeEnum.DEBIT if isDeduction else TransactionTypeEnum.CREDIT
diff --git a/modules/routes/routeDataConnections.py b/modules/routes/routeDataConnections.py
index 2d425d25..b2830a40 100644
--- a/modules/routes/routeDataConnections.py
+++ b/modules/routes/routeDataConnections.py
@@ -161,7 +161,6 @@ async def get_connections(
from modules.interfaces.interfaceTableHelpers import (
resolveView, applyViewToParams, buildGroupLayout, effective_group_by_levels,
)
- from modules.datamodels.datamodelPagination import AppliedViewMeta
CONTEXT_KEY = "connections"
@@ -782,7 +781,6 @@ async def _updateKnowledgeConsent(
if not connection:
raise HTTPException(status_code=404, detail=routeApiMsg("Connection not found"))
- from modules.interfaces.interfaceDbApp import getRootInterface
rootIf = getRootInterface()
rootIf.db.recordModify(UserConnection, connectionId, {"knowledgeIngestionEnabled": enabled})
@@ -861,7 +859,6 @@ def _updateKnowledgePreferences(
cleaned = {k: v for k, v in preferences.items() if k in _ALLOWED_KEYS}
merged = {**existing, **cleaned, "schemaVersion": 1}
- from modules.interfaces.interfaceDbApp import getRootInterface
getRootInterface().db.recordModify(UserConnection, connectionId, {"knowledgePreferences": merged})
logger.info("Knowledge preferences updated for connection %s", connectionId)
diff --git a/modules/routes/routeDataFiles.py b/modules/routes/routeDataFiles.py
index 41625d26..cdb26f31 100644
--- a/modules/routes/routeDataFiles.py
+++ b/modules/routes/routeDataFiles.py
@@ -738,7 +738,6 @@ def get_files(
resolveView, applyViewToParams, buildGroupLayout, effective_group_by_levels,
)
import modules.interfaces.interfaceDbApp as _appIface
- from modules.datamodels.datamodelPagination import AppliedViewMeta
managementInterface = interfaceDbManagement.getInterface(
currentUser,
@@ -1202,7 +1201,6 @@ def bulk_set_neutralize(
managementInterface.updateFile(fid, {"neutralize": neutralize})
if not neutralize:
try:
- from modules.interfaces import interfaceDbKnowledge
kIface = interfaceDbKnowledge.getInterface(currentUser)
kIface.purgeFileKnowledge(fid)
except Exception as ke:
diff --git a/modules/routes/routeDataPrompts.py b/modules/routes/routeDataPrompts.py
index 164d4233..6f11493c 100644
--- a/modules/routes/routeDataPrompts.py
+++ b/modules/routes/routeDataPrompts.py
@@ -55,7 +55,6 @@ def get_prompts(
resolveView, applyViewToParams, buildGroupLayout, effective_group_by_levels,
)
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
- from modules.datamodels.datamodelPagination import AppliedViewMeta
CONTEXT_KEY = "prompts"
diff --git a/modules/routes/routeMfa.py b/modules/routes/routeMfa.py
index 5b13d592..7a2101ec 100644
--- a/modules/routes/routeMfa.py
+++ b/modules/routes/routeMfa.py
@@ -215,7 +215,6 @@ def mfaVerify(
jti = jwt.decode(accessToken, SECRET_KEY, algorithms=[ALGORITHM]).get("jti")
- from modules.interfaces.interfaceDbApp import getInterface
user = User.model_validate(userRecord)
userInterface = getInterface(user)
dbToken = Token(
diff --git a/modules/routes/routeNotifications.py b/modules/routes/routeNotifications.py
index ef63fc1a..30adfef7 100644
--- a/modules/routes/routeNotifications.py
+++ b/modules/routes/routeNotifications.py
@@ -411,7 +411,6 @@ def _handleInvitationAction(
) -> str:
"""Handle accept/decline actions for invitation notifications."""
from modules.datamodels.datamodelInvitation import Invitation
- from modules.datamodels.datamodelUam import Mandate
from modules.datamodels.datamodelMembership import UserMandate
invitationId = notification.referenceId
diff --git a/modules/routes/routeRagInventory.py b/modules/routes/routeRagInventory.py
index 82348d9a..9b2eee2e 100644
--- a/modules/routes/routeRagInventory.py
+++ b/modules/routes/routeRagInventory.py
@@ -485,7 +485,6 @@ def _getInventoryPlatform(
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.interfaces.interfaceDbKnowledge import getInterface as getKnowledgeInterface
from modules.serviceCenter.services.serviceBackgroundJobs import mainBackgroundJobService as jobService
- from modules.datamodels.datamodelUam import UserConnection
rootIf = getRootInterface()
knowledgeIf = getKnowledgeInterface(None)
diff --git a/modules/routes/routeSecurityLocal.py b/modules/routes/routeSecurityLocal.py
index 3107a2b8..177cfaad 100644
--- a/modules/routes/routeSecurityLocal.py
+++ b/modules/routes/routeSecurityLocal.py
@@ -70,7 +70,6 @@ def buildAuthEmailHtml(
operatorLine = ""
try:
- from modules.shared.configuration import APP_CONFIG
parts = [p for p in [
APP_CONFIG.get("Operator_CompanyName", ""),
APP_CONFIG.get("Operator_Address", ""),
@@ -194,7 +193,6 @@ def _ensureHomeMandate(rootInterface, user) -> None:
return
try:
- from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIf
appIf = _getRootIf()
normalizedEmail = (user.email or "").strip().lower() if user.email else None
pendingByUsername = appIf.getInvitationsByTargetUsername(user.username)
@@ -1058,7 +1056,6 @@ def _getNeutralizationMappings(
):
"""List the current user's neutralization placeholder mappings."""
userId = str(context.user.id)
- from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelFeatures import DataNeutralizerAttributes
rootIf = getRootInterface()
records = rootIf.db.getRecordset(DataNeutralizerAttributes, recordFilter={"userId": userId})
@@ -1074,7 +1071,6 @@ def _deleteNeutralizationMapping(
):
"""Delete a specific neutralization mapping owned by the current user."""
userId = str(context.user.id)
- from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelFeatures import DataNeutralizerAttributes
rootIf = getRootInterface()
records = rootIf.db.getRecordset(DataNeutralizerAttributes, recordFilter={"id": mappingId})
diff --git a/modules/routes/routeWorkflowAutomation.py b/modules/routes/routeWorkflowAutomation.py
index 1ea2ba1b..c4e2fa84 100644
--- a/modules/routes/routeWorkflowAutomation.py
+++ b/modules/routes/routeWorkflowAutomation.py
@@ -64,7 +64,6 @@ async def _listWorkflows(
mandateId: Optional[str] = Query(default=None),
):
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
- from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIface
db = _getWorkflowAutomationDb()
try:
db._ensureTableExists(AutoWorkflow)
@@ -174,7 +173,6 @@ async def _listRuns(
workflowId: Optional[str] = Query(default=None),
):
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
- from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIface
db = _getWorkflowAutomationDb()
try:
db._ensureTableExists(AutoRun)
@@ -476,17 +474,14 @@ def _listTemplates(
templates = iface.getTemplates(scope=scope)
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
- from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIface
enrichRowsWithFkLabels(templates, AutoWorkflow, db=_getRootIface().db)
if mode == "filterValues":
if not column:
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
- from modules.dbHelpers.paginationHelpers import handleFilterValuesInMemory
return handleFilterValuesInMemory(templates, column, pagination)
if mode == "ids":
- from modules.dbHelpers.paginationHelpers import handleIdsInMemory
return handleIdsInMemory(templates, pagination)
paginationParams = None
@@ -1328,7 +1323,6 @@ def _getRunDetail(
if tid:
try:
from modules.dbHelpers.fkLabelResolver import resolveInstanceLabels
- from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIface
labelMap = resolveInstanceLabels(_getRootIface().db, [tid])
targetInstanceLabel = labelMap.get(tid)
except Exception:
@@ -1425,7 +1419,6 @@ def _startEmailPollerIfNeeded(result: dict) -> None:
if not isinstance(result, dict) or result.get("waitReason") != "email":
return
try:
- from modules.interfaces.interfaceDbApp import getRootInterface
from modules.workflowAutomation.scheduler.emailPoller import ensureRunning
root = getRootInterface()
eventUser = root.getUserByUsername("event") if root else None
diff --git a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py
index 81fc7f29..cbb576b2 100644
--- a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py
+++ b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py
@@ -425,7 +425,6 @@ class AgentService:
activeToolNames.update(tb.tools)
from modules.serviceCenter.services.serviceAgent.externalToolRegistry import getExternalTools
- from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolDefinition
for tb in activeToolboxes:
extDefs = getExternalTools(tb.id)
if not extDefs:
@@ -459,7 +458,6 @@ class AgentService:
from modules.serviceCenter.services.serviceAgent.toolboxRegistry import (
getToolboxRegistry, buildRequestToolboxDefinition, REQUEST_TOOLBOX_TOOL_NAME,
)
- from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResult
tbRegistry = getToolboxRegistry()
allIds = [tb.id for tb in tbRegistry.getAllToolboxes()]
@@ -488,7 +486,6 @@ class AgentService:
activatedCount += 1
continue
try:
- from modules.serviceCenter.services.serviceAgent.coreTools import registerCoreTools
registerCoreTools(registry, self.services)
if registry.isValidTool(toolName):
activatedCount += 1
@@ -499,9 +496,6 @@ class AgentService:
try:
from modules.workflows.processing.shared.methodDiscovery import discoverMethods
from modules.workflows.processing.core.actionExecutor import ActionExecutor
- from modules.serviceCenter.services.serviceAgent.actionToolAdapter import (
- ActionToolAdapter,
- )
discoverMethods(self.services)
adapter = ActionToolAdapter(ActionExecutor(self.services))
@@ -622,7 +616,6 @@ class AgentService:
def _createPersistRoundMemoryFn(self, workflowId: str):
"""Create callback that persists RoundMemory entries after tool execution."""
- from modules.serviceCenter.services.serviceAgent.agentLoop import classifyToolResult
from modules.datamodels.datamodelKnowledge import RoundMemory
async def _persistRoundMemory(
diff --git a/modules/serviceCenter/services/serviceAi/mainServiceAi.py b/modules/serviceCenter/services/serviceAi/mainServiceAi.py
index 79389b21..75fc77de 100644
--- a/modules/serviceCenter/services/serviceAi/mainServiceAi.py
+++ b/modules/serviceCenter/services/serviceAi/mainServiceAi.py
@@ -335,7 +335,6 @@ class AiService:
Returns:
AiCallResponse with content as JSON string (SpeechTeamsResponse format)
"""
- from modules.datamodels.datamodelAi import AiCallResponse, AiModelCall, AiCallOptions, PriorityEnum
startTime = time.time()
@@ -637,7 +636,6 @@ detectedIntent-Werte:
try:
from modules.aicore.aicoreModelRegistry import modelRegistry
from modules.aicore.aicoreModelSelector import modelSelector as _modSel
- from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum
_models = modelRegistry.getAvailableModels()
_providers = self._calculateEffectiveProviders()
diff --git a/modules/serviceCenter/services/serviceChat/mainServiceChat.py b/modules/serviceCenter/services/serviceChat/mainServiceChat.py
index 44e42583..95a9248a 100644
--- a/modules/serviceCenter/services/serviceChat/mainServiceChat.py
+++ b/modules/serviceCenter/services/serviceChat/mainServiceChat.py
@@ -615,7 +615,6 @@ class ChatService:
def getUserVoicePreferences(self, userId: str, mandateId: str = None) -> Optional[Dict[str, Any]]:
"""Get TTS voice preferences for a user, resolved by mandate scope."""
- from modules.datamodels.datamodelUam import UserVoicePreferences
try:
prefRecords = self.interfaceDbApp.db.getRecordset(
UserVoicePreferences, recordFilter={"userId": userId}
@@ -842,7 +841,6 @@ class ChatService:
"""Create an ActionItem record in the chat DB.
Encapsulates low-level _separateObjectFields + db.recordCreate so callers
never need direct interfaceDbChat access."""
- from modules.datamodels.datamodelChat import ActionItem
simpleFields, _objectFields = self.interfaceDbChat._separateObjectFields(ActionItem, actionData)
return self.interfaceDbChat.db.recordCreate(ActionItem, simpleFields)
diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorContainer.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorContainer.py
index 3e33db52..b71e6d65 100644
--- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorContainer.py
+++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorContainer.py
@@ -204,7 +204,6 @@ def _addFilePart(
entryPath = f"{containerPath}/{fileName}" if containerPath else fileName
detectedMime = _detectMimeType(fileName)
- from ..subRegistry import getExtractorRegistry
registry = getExtractorRegistry()
extractor = registry.resolve(detectedMime, fileName)
diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorEmail.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorEmail.py
index 6180f5d1..20a1fbd4 100644
--- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorEmail.py
+++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorEmail.py
@@ -255,7 +255,6 @@ def _delegateAttachment(attachData: bytes, attachName: str, parentId: str, depth
guessedMime, _ = mimetypes.guess_type(attachName)
detectedMime = guessedMime or "application/octet-stream"
- from ..subRegistry import getExtractorRegistry
registry = getExtractorRegistry()
extractor = registry.resolve(detectedMime, attachName)
diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorFolder.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorFolder.py
index 0f81fce0..fc11d6fe 100644
--- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorFolder.py
+++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorFolder.py
@@ -141,7 +141,6 @@ def _walkFolder(
guessedMime, _ = mimetypes.guess_type(entry.name)
detectedMime = guessedMime or "application/octet-stream"
- from ..subRegistry import ExtractorRegistry
registry = ExtractorRegistry()
extractor = registry.resolve(detectedMime, entry.name)
diff --git a/modules/serviceCenter/services/serviceExtraction/subRegistry.py b/modules/serviceCenter/services/serviceExtraction/subRegistry.py
index 7072ecbb..8f5be299 100644
--- a/modules/serviceCenter/services/serviceExtraction/subRegistry.py
+++ b/modules/serviceCenter/services/serviceExtraction/subRegistry.py
@@ -50,8 +50,6 @@ class Extractor:
precomputedParts: Optional[List[ContentPart]] = None,
) -> "UdmDocument":
"""Build UDM from extracted parts (default: heuristic grouping). Override for format-specific trees."""
- from modules.datamodels.datamodelUdm import contentPartsToUdm, mimeToUdmSourceType
- from modules.datamodels.datamodelExtraction import ContentExtracted
from .subUtils import makeId
parts = precomputedParts if precomputedParts is not None else self.extract(fileBytes, context)
diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py
index d1fe3b20..55fa8c99 100644
--- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py
+++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py
@@ -230,7 +230,6 @@ class RendererPdf(BaseRenderer):
# memory simultaneously. Collected here, deleted after the build.
self._tempImageFiles = []
try:
- from modules.serviceCenter.services.serviceGeneration.styleDefaults import resolveStyle
self._unifiedStyle = unifiedStyle or resolveStyle(None)
styles = self._convertUnifiedStyleToInternal(self._unifiedStyle)
for level in range(1, 7):
diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPptx.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPptx.py
index 1547086f..4a1db42c 100644
--- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPptx.py
+++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPptx.py
@@ -90,7 +90,6 @@ class RendererPptx(BaseRenderer):
from pptx.dml.color import RGBColor
if not style:
- from modules.serviceCenter.services.serviceGeneration.styleDefaults import resolveStyle
style = resolveStyle(None)
internalStyle = self._convertUnifiedStyleToInternal(style)
styles = internalStyle
diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererXlsx.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererXlsx.py
index aaa5d022..86846229 100644
--- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererXlsx.py
+++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererXlsx.py
@@ -137,7 +137,6 @@ class RendererXlsx(BaseRenderer):
self.services.utils.debugLogToFile(f"EXCEL JSON CONTENT KEYS: {list(jsonContent.keys()) if isinstance(jsonContent, dict) else 'Not a dict'}", "EXCEL_RENDERER")
if not style:
- from modules.serviceCenter.services.serviceGeneration.styleDefaults import resolveStyle
style = resolveStyle(None)
self._unifiedStyle = style
diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGmail.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGmail.py
index 8e2f5935..edb347f1 100644
--- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGmail.py
+++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGmail.py
@@ -26,6 +26,7 @@ from typing import Any, Callable, Dict, List, Optional
from modules.serviceCenter.services.serviceKnowledge.subTextClean import cleanEmailBody
from modules.serviceCenter.services.serviceKnowledge.subWalkerHelpers import (
WalkerTimeout,
+ extractWithTimeout as _extractWithTimeout,
ingestWithTimeout,
logItemStart,
)
@@ -564,10 +565,6 @@ async def _ingestAttachments(
attLabel = f"{messageId}/att:{stub['attachmentId']}/{fileName}"
logItemStart("gmail-attachment", attLabel, sizeBytes=stub.get("size") or None, mime=mimeType)
- from modules.serviceCenter.services.serviceKnowledge.subWalkerHelpers import (
- extractWithTimeout as _extractWithTimeout,
- )
-
def _runAttExtraction():
return runExtraction(
extractorRegistry, chunkerRegistry,
diff --git a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py
index 439d9a5b..8714f660 100644
--- a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py
+++ b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py
@@ -414,7 +414,6 @@ class SubscriptionService:
mandateLabel = mandateId
try:
- from modules.datamodels.datamodelUam import Mandate
from modules.security.rootAccess import getRootDbAppConnector
appDb = getRootDbAppConnector()
rows = appDb.getRecordset(Mandate, recordFilter={"id": mandateId})
@@ -937,7 +936,6 @@ def _buildInvoiceSummaryHtml(
) -> str:
"""Build an HTML invoice summary block for inclusion in the activation email."""
import html as htmlmod
- from modules.interfaces.interfaceDbSubscription import getRootInterface as getSubRootInterface
subInterface = getSubRootInterface()
userCount = subInterface.countActiveUsers(mandateId)
diff --git a/modules/system/databaseHealth.py b/modules/system/databaseHealth.py
index 5a9ec8fd..8b1ec19e 100644
--- a/modules/system/databaseHealth.py
+++ b/modules/system/databaseHealth.py
@@ -805,7 +805,6 @@ def _discoverLegacyTables(dbFilter: Optional[str] = None) -> List[dict]:
Returns a list of dicts: {db, table, rowCount, sizeBytes}.
"""
from modules.datamodels.datamodelBase import MODEL_REGISTRY
- from modules.dbHelpers.fkRegistry import ensureModelsLoaded
ensureModelsLoaded()
registeredDbs = getRegisteredDatabases()
@@ -854,7 +853,6 @@ def _dropLegacyTable(dbName: str, tableName: str) -> dict:
Raises ValueError if the table is model-backed (safety guard).
"""
from modules.datamodels.datamodelBase import MODEL_REGISTRY
- from modules.dbHelpers.fkRegistry import ensureModelsLoaded
ensureModelsLoaded()
if tableName in MODEL_REGISTRY:
diff --git a/modules/workflowAutomation/engine/executors/actionNodeExecutor.py b/modules/workflowAutomation/engine/executors/actionNodeExecutor.py
index aa472f15..7e5cb30d 100644
--- a/modules/workflowAutomation/engine/executors/actionNodeExecutor.py
+++ b/modules/workflowAutomation/engine/executors/actionNodeExecutor.py
@@ -305,7 +305,6 @@ def _buildConnectionRefDict(connRef: str, chatService, services) -> Optional[Dic
def _schemaCarriesConnectionProvenance(outputSchema: str) -> bool:
"""True iff the port schema declares ``carriesConnectionProvenance`` in the catalog."""
- from modules.nodeCatalog.portTypes import PORT_TYPE_CATALOG
schema = PORT_TYPE_CATALOG.get(outputSchema)
return bool(getattr(schema, "carriesConnectionProvenance", False))
diff --git a/modules/workflowAutomation/helpers.py b/modules/workflowAutomation/helpers.py
index 9f28f274..a2121b7f 100644
--- a/modules/workflowAutomation/helpers.py
+++ b/modules/workflowAutomation/helpers.py
@@ -203,7 +203,6 @@ def _validateWorkflowAccess(
if action == "execute":
targetInstanceId = workflow.get("targetFeatureInstanceId")
if targetInstanceId:
- from modules.interfaces.interfaceDbApp import getRootInterface
access = getRootInterface().getFeatureAccess(userId, targetInstanceId)
if access and access.get("enabled"):
return
@@ -582,7 +581,6 @@ def _getWorkflowsJoinedPaginated(
paginationParams: PaginationParams,
) -> dict:
"""SQL listing: AutoWorkflow LEFT JOIN aggregated AutoRun stats (one query + count)."""
- from modules.connectors.connectorDbPostgre import getModelFields, parseRecordFields
wfFields = getModelFields(AutoWorkflow)
whereClause, orderClause, limitClause, values = _buildJoinedWorkflowWhereOrderLimit(
diff --git a/modules/workflowAutomation/mainWorkflowAutomation.py b/modules/workflowAutomation/mainWorkflowAutomation.py
index 086530c7..e07673c6 100644
--- a/modules/workflowAutomation/mainWorkflowAutomation.py
+++ b/modules/workflowAutomation/mainWorkflowAutomation.py
@@ -291,7 +291,6 @@ def onInstanceCreate(mandateId: str, instanceId: str, featureCode: str, template
"""Create workflow instances from template definitions when a feature instance is created."""
from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface
from modules.security.rootAccess import getRootUser
- from modules.shared.i18nRegistry import resolveText
rootUser = getRootUser()
waInterface = _getWorkflowAutomationInterface(rootUser, mandateId, instanceId)
diff --git a/modules/workflows/methods/methodAi/actions/process.py b/modules/workflows/methods/methodAi/actions/process.py
index 77adc40f..d943d7da 100644
--- a/modules/workflows/methods/methodAi/actions/process.py
+++ b/modules/workflows/methods/methodAi/actions/process.py
@@ -37,7 +37,6 @@ def _action_docs_to_content_parts(services, docs: List[Any]) -> List[ContentPart
"""Extract content from ActionDocument-like objects in memory (no persistence).
Decodes base64, runs extraction pipeline, returns ContentParts for AI.
"""
- from modules.datamodels.datamodelExtraction import ExtractionOptions, MergeStrategy
all_parts = []
extraction = services.extraction
@@ -78,7 +77,6 @@ def _resolve_file_refs_to_content_parts(services, fileIdRefs) -> List[ContentPar
references, not chat message attachments. In the agent/chat context,
``DocumentItemReference`` holds ChatDocument IDs that must be resolved
via ``getChatDocumentsFromDocumentList`` instead."""
- from modules.datamodels.datamodelExtraction import ExtractionOptions, MergeStrategy
extraction = services.extraction
if not extraction:
diff --git a/scripts/script_analyze_platform_module_graph.py b/scripts/script_analyze_platform_module_graph.py
new file mode 100644
index 00000000..8e8866c4
--- /dev/null
+++ b/scripts/script_analyze_platform_module_graph.py
@@ -0,0 +1,568 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
+"""
+Deep platform-core module import graph analysis.
+
+Output: local/notes/refernce-analysis/import-analysis-platform-modules.md
+
+Usage:
+ python platform-core/scripts/script_analyze_platform_module_graph.py
+"""
+
+from __future__ import annotations
+
+import ast
+import os
+import sys
+from collections import defaultdict
+from dataclasses import dataclass
+from datetime import date
+from pathlib import Path
+from typing import Dict, List, Optional, Set, Tuple
+
+SCRIPT_DIR = Path(__file__).resolve().parent
+sys.path.insert(0, str(SCRIPT_DIR))
+
+from script_analyze_porta_imports import ( # noqa: E402
+ OUTPUT_ROOT,
+ PLATFORM_ROOT,
+ SKIP_DIR_NAMES,
+ _collectPlatformModules,
+ _getPlatformContainer,
+ _platformModuleId,
+ _resolvePlatformImportTarget,
+ _resolvePlatformRelativeImport,
+ _writeText,
+)
+
+
+OUTPUT_FILE = OUTPUT_ROOT / "import-analysis-platform-modules.md"
+
+LAYER_ORDER = {
+ "shared": 0,
+ "datamodels": 1,
+ "connectors": 2,
+ "nodeCatalog": 2,
+ "dbHelpers": 3,
+ "interfaces": 4,
+ "system": 4,
+ "security": 4,
+ "auth": 4,
+ "aicore": 4,
+ "demoConfigs": 4,
+ "serviceCenter": 5,
+ "workflows": 5,
+ "workflowAutomation": 5,
+ "features.commcoach": 5,
+ "features.neutralization": 5,
+ "features.realEstate": 5,
+ "features.realestate": 5,
+ "features.redmine": 5,
+ "features.teamsbot": 5,
+ "features.trustee": 5,
+ "features.workspace": 5,
+ "routes": 6,
+ "app": 7,
+}
+
+
+@dataclass
+class ScopedImport:
+ target: str
+ rawModule: str
+ position: str
+ scope: str
+ isInternal: bool
+ isStdLib: bool
+
+
+def _shortModule(moduleId: str) -> str:
+ parts = moduleId.replace("platform-core.", "").split(".")
+ if len(parts) <= 3:
+ return ".".join(parts)
+ return ".".join(parts[-3:])
+
+
+LIFECYCLE_SCOPE_MARKERS = (
+ "lifespan",
+ "onBootstrap",
+ "onStart",
+ "onStop",
+ "onInstanceCreate",
+ "onMandateDelete",
+ "registerFeature",
+ "preWarm",
+)
+
+
+def _layerOf(moduleId: str) -> Optional[int]:
+ container = _getPlatformContainer(moduleId)
+ if container is None:
+ return None
+ return LAYER_ORDER.get(container)
+
+
+def _isStdLibModule(moduleName: str) -> bool:
+ root = moduleName.split(".")[0]
+ if root.startswith("_"):
+ return False
+ if root in sys.builtin_module_names:
+ return True
+ if hasattr(sys, "stdlib_module_names") and root in sys.stdlib_module_names:
+ return True
+ return False
+
+
+class _DetailedImportVisitor(ast.NodeVisitor):
+ def __init__(self, filePath: Path):
+ self.filePath = filePath
+ self.imports: List[ScopedImport] = []
+ self._scopeStack: List[str] = []
+
+ @property
+ def _currentScope(self) -> str:
+ return self._scopeStack[-1] if self._scopeStack else ""
+
+ def _position(self) -> str:
+ return "code" if self._scopeStack else "header"
+
+ def _add(self, rawModule: str, resolved: str, isInternal: bool) -> None:
+ self.imports.append(
+ ScopedImport(
+ target=resolved,
+ rawModule=rawModule,
+ position=self._position(),
+ scope=self._currentScope,
+ isInternal=isInternal,
+ isStdLib=_isStdLibModule(rawModule) if not isInternal else False,
+ )
+ )
+
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
+ self._scopeStack.append(f"function {node.name}")
+ self.generic_visit(node)
+ self._scopeStack.pop()
+
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
+ self._scopeStack.append(f"function {node.name}")
+ self.generic_visit(node)
+ self._scopeStack.pop()
+
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
+ self._scopeStack.append(f"class {node.name}")
+ self.generic_visit(node)
+ self._scopeStack.pop()
+
+ def visit_Import(self, node: ast.Import) -> None:
+ for alias in node.names:
+ resolved, isInternal = _resolvePlatformImportTarget(self.filePath, alias.name)
+ self._add(alias.name, resolved, isInternal)
+
+ def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
+ if node.level > 0:
+ resolved = _resolvePlatformRelativeImport(self.filePath, node)
+ if resolved:
+ suffix = node.module or ""
+ raw = ("." * node.level) + suffix
+ self._add(raw, resolved, True)
+ return
+ if not node.module:
+ return
+ resolved, isInternal = _resolvePlatformImportTarget(self.filePath, node.module)
+ self._add(node.module, resolved, isInternal)
+
+
+def _collectDetailedImports() -> Dict[str, List[ScopedImport]]:
+ byModule: Dict[str, List[ScopedImport]] = {}
+ pyFiles: List[Path] = []
+ appFile = PLATFORM_ROOT / "app.py"
+ if appFile.exists():
+ pyFiles.append(appFile)
+ for root, dirs, files in os.walk(PLATFORM_ROOT / "modules"):
+ dirs[:] = [d for d in dirs if d not in SKIP_DIR_NAMES]
+ for fileName in files:
+ if fileName.endswith(".py"):
+ pyFiles.append(Path(root) / fileName)
+
+ for filePath in pyFiles:
+ moduleId = _platformModuleId(filePath)
+ if _getPlatformContainer(moduleId) is None:
+ continue
+ try:
+ tree = ast.parse(filePath.read_text(encoding="utf-8"), filename=str(filePath))
+ except (SyntaxError, UnicodeDecodeError):
+ continue
+ visitor = _DetailedImportVisitor(filePath)
+ visitor.visit(tree)
+ byModule[moduleId] = visitor.imports
+ return byModule
+
+
+def _internalGraph(importsByModule: Dict[str, List[ScopedImport]]) -> Dict[str, Set[str]]:
+ graph: Dict[str, Set[str]] = defaultdict(set)
+ for source, items in importsByModule.items():
+ for item in items:
+ if item.isInternal and item.target.startswith("platform-core."):
+ graph[source].add(item.target)
+ return dict(graph)
+
+
+def _mutualPairs(
+ graph: Dict[str, Set[str]],
+ moduleFilter: Optional[Set[str]] = None,
+) -> List[Tuple[str, str]]:
+ pairs: List[Tuple[str, str]] = []
+ seen: Set[Tuple[str, str]] = set()
+ sources = moduleFilter if moduleFilter is not None else set(graph.keys())
+ for source in sorted(sources):
+ for target in graph.get(source, set()):
+ if moduleFilter is not None and target not in moduleFilter:
+ continue
+ if target not in graph or source not in graph[target]:
+ continue
+ key = tuple(sorted((source, target)))
+ if key not in seen:
+ seen.add(key)
+ pairs.append(key)
+ return pairs
+
+
+def _tarjanScc(graph: Dict[str, Set[str]]) -> List[List[str]]:
+ index = 0
+ stack: List[str] = []
+ onStack: Set[str] = set()
+ indices: Dict[str, int] = {}
+ lowLink: Dict[str, int] = {}
+ result: List[List[str]] = []
+
+ nodes = set(graph.keys())
+ for targets in graph.values():
+ nodes.update(targets)
+
+ def strongConnect(node: str) -> None:
+ nonlocal index
+ indices[node] = index
+ lowLink[node] = index
+ index += 1
+ stack.append(node)
+ onStack.add(node)
+
+ for neighbor in graph.get(node, set()):
+ if neighbor not in indices:
+ strongConnect(neighbor)
+ lowLink[node] = min(lowLink[node], lowLink[neighbor])
+ elif neighbor in onStack:
+ lowLink[node] = min(lowLink[node], indices[neighbor])
+
+ if lowLink[node] == indices[node]:
+ component: List[str] = []
+ while True:
+ w = stack.pop()
+ onStack.remove(w)
+ component.append(w)
+ if w == node:
+ break
+ if len(component) > 1 or (len(component) == 1 and component[0] in graph.get(component[0], set())):
+ result.append(sorted(component))
+
+ for node in sorted(nodes):
+ if node not in indices:
+ strongConnect(node)
+ return sorted(result, key=lambda c: (len(c), c[0]), reverse=True)
+
+
+def _canReach(graph: Dict[str, Set[str]], start: str, goal: str, skipEdge: Optional[Tuple[str, str]] = None) -> bool:
+ visited: Set[str] = set()
+
+ def dfs(node: str) -> bool:
+ if node == goal:
+ return True
+ if node in visited:
+ return False
+ visited.add(node)
+ for nxt in graph.get(node, set()):
+ if skipEdge and node == skipEdge[0] and nxt == skipEdge[1]:
+ continue
+ if dfs(nxt):
+ return True
+ return False
+
+ return dfs(start)
+
+
+def _assessMutualPair(a: str, b: str) -> str:
+ containerA = _getPlatformContainer(a)
+ containerB = _getPlatformContainer(b)
+ layerA = _layerOf(a)
+ layerB = _layerOf(b)
+ sameContainer = containerA == containerB
+
+ if sameContainer:
+ if layerA is not None and layerA >= 5:
+ return "Prüfen — Feature/Service-interner Gegenimport; oft Lazy-Import-Workaround, Zyklus im Container."
+ return "Prüfen — gegenseitiger Import im gleichen Container; meist absichtlicher Lazy-Import gegen Zyklus."
+
+ if layerA is not None and layerB is not None:
+ if layerA < layerB and layerB < layerA:
+ pass
+ upward = (layerA > layerB and layerB is not None) or (layerB > layerA and layerA is not None)
+ if upward:
+ return "Refactor-Kandidat — untere Schicht importiert obere und umgekehrt (Layer-Verletzung)."
+ return "Refactor-Kandidat — Cross-Container-Gegenimport; Layer-Grenze prüfen."
+
+
+def _assessCycle(component: List[str]) -> str:
+ if len(component) == 1:
+ return "OK — Package-Reexport/Self-Import (__init__ ↔ Submodul); typisch für Barrel-Module."
+ containers = {_getPlatformContainer(m) for m in component}
+ containers.discard(None)
+ layers = [layer for m in component if (layer := _layerOf(m)) is not None]
+ if len(containers) == 1:
+ container = next(iter(containers))
+ if LAYER_ORDER.get(container or "", 99) >= 5:
+ return "Prüfen — Zyklus innerhalb Feature/Service-Cluster; oft bekanntes Deferred-Coupling."
+ return "Prüfen — Intra-Container-Loop; Lazy-Imports prüfen ob extrahierbar."
+ if layers and max(layers) - min(layers) >= 2:
+ return "Refactor-Kandidat — Loop über mehrere Layer/Container; Architektur-Grenze verletzt."
+ return "Prüfen — Cross-Container-Loop; Abhängigkeit entkoppeln oder Typ/Protocol extrahieren."
+
+
+def _assessLazyStdLib(moduleId: str, item: ScopedImport) -> str:
+ heavy = {"json", "csv", "xml", "pickle", "sqlite3", "subprocess", "multiprocessing"}
+ root = item.rawModule.split(".")[0]
+ if root in heavy:
+ return "OK — schwere Stdlib lazy (Startup/optional)."
+ if "TYPE_CHECKING" in item.scope:
+ return "OK — typing-only Kontext."
+ return "Harmlos — Stdlib lazy in Code-Scope; kein Architektur-Risiko."
+
+
+def _assessMovable(moduleId: str, item: ScopedImport, graph: Dict[str, Set[str]], headerTargets: Set[str]) -> str:
+ if item.target in headerTargets:
+ return "Redundant — bereits im Header importiert; Lazy-Import entfernen."
+ if any(marker in item.scope for marker in LIFECYCLE_SCOPE_MARKERS):
+ return "Beabsichtigt lazy — Startup/Lifecycle-Hook; nicht in Header verschieben."
+ if _canReach(graph, item.target, moduleId):
+ return "Muss lazy bleiben — Header-Import würde Zyklus erzeugen."
+ return "Verschiebbar — kann vermutlich in den Header."
+
+
+def _renderMarkdown(
+ importsByModule: Dict[str, List[ScopedImport]],
+ graph: Dict[str, Set[str]],
+) -> str:
+ modulesByContainer: Dict[str, Set[str]] = defaultdict(set)
+ for moduleId in importsByModule:
+ container = _getPlatformContainer(moduleId)
+ if container:
+ modulesByContainer[container].add(moduleId)
+
+ lines = [
+ "# Import-Analyse Platform — Modul-Graph",
+ "",
+ f"- **Generiert:** {date.today().isoformat()}",
+ "- **Script:** `platform-core/scripts/script_analyze_platform_module_graph.py`",
+ "- **Scope:** interne `modules.*`-Imports (inkl. lazy)",
+ "",
+ "## Legende Beurteilung",
+ "",
+ "| Stufe | Bedeutung |",
+ "|-------|-----------|",
+ "| OK / Harmlos | kein Handlungsbedarf |",
+ "| Verschiebbar | Lazy-Import kann vermutlich in Header |",
+ "| Redundant | doppelter Import (Header + Code) |",
+ "| Prüfen | bekannt möglich, bewusst prüfen |",
+ "| Beabsichtigt lazy | Startup/Lifecycle — nicht in Header |",
+ "| Muss lazy bleiben | Zyklusvermeidung |",
+ "| Refactor-Kandidat | Layer-/Architektur-Thema |",
+ "",
+ ]
+
+ # --- Mutual pairs per container ---
+ lines.extend(["## Gegenseitige Modul-Imports (Paare)", ""])
+ totalPairs = 0
+ for container in sorted(modulesByContainer.keys()):
+ moduleSet = modulesByContainer[container]
+ pairs = _mutualPairs(graph, moduleSet)
+ if not pairs:
+ continue
+ totalPairs += len(pairs)
+ lines.append(f"### Container `{container}`")
+ lines.append("")
+ lines.append("| Modul A | Modul B | Beurteilung |")
+ lines.append("|---------|---------|-------------|")
+ for a, b in pairs:
+ lines.append(
+ f"| `{_shortModule(a)}` | `{_shortModule(b)}` | {_assessMutualPair(a, b)} |"
+ )
+ lines.append("")
+
+ crossPairs = [
+ p for p in _mutualPairs(graph)
+ if _getPlatformContainer(p[0]) != _getPlatformContainer(p[1])
+ ]
+ if crossPairs:
+ lines.extend(["### Cross-Container (gegenseitig)", ""])
+ lines.append("| Modul A | Container A | Modul B | Container B | Beurteilung |")
+ lines.append("|---------|-------------|---------|-------------|-------------|")
+ for a, b in crossPairs:
+ lines.append(
+ f"| `{_shortModule(a)}` | `{_getPlatformContainer(a)}` | "
+ f"`{_shortModule(b)}` | `{_getPlatformContainer(b)}` | {_assessMutualPair(a, b)} |"
+ )
+ lines.append("")
+ if totalPairs == 0 and not crossPairs:
+ lines.append("_Keine gegenseitigen Modul-Paare gefunden._")
+ lines.append("")
+
+ # --- Cycles ---
+ sccList = _tarjanScc(graph)
+ lines.extend(["## Import-Loops (über mehrere Module)", ""])
+ if not sccList:
+ lines.append("_Keine Strongly-Connected Components (>1 Knoten) gefunden._")
+ lines.append("")
+ else:
+ lines.append(f"**{len(sccList)} Loop-Gruppe(n)** (Tarjan SCC, nur interne Module).")
+ lines.append("")
+ for index, component in enumerate(sccList, start=1):
+ containers = sorted({c for m in component if (c := _getPlatformContainer(m))})
+ lines.append(f"### Loop {index} — {len(component)} Module")
+ lines.append("")
+ lines.append(f"- **Container:** {', '.join(f'`{c}`' for c in containers)}")
+ lines.append(f"- **Beurteilung:** {_assessCycle(component)}")
+ lines.append("- **Module:**")
+ for moduleId in component:
+ lines.append(f" - `{moduleId}`")
+ if len(component) <= 8:
+ chainHint = " → ".join(_shortModule(m) for m in component) + f" → `{_shortModule(component[0])}`"
+ lines.append(f"- **Ring (Auszug):** {chainHint}")
+ lines.append("")
+
+ # --- Lazy stdlib ---
+ lines.extend(["## Lazy Stdlib-Imports (in Code-Scope)", ""])
+ stdlibRows: List[Tuple[str, str, str, str, str]] = []
+ for moduleId, items in sorted(importsByModule.items()):
+ for item in items:
+ if item.position == "code" and item.isStdLib:
+ stdlibRows.append(
+ (
+ _shortModule(moduleId),
+ _getPlatformContainer(moduleId) or "",
+ item.rawModule,
+ item.scope or "(class/function)",
+ _assessLazyStdLib(moduleId, item),
+ )
+ )
+ if stdlibRows:
+ lines.append("| Modul | Container | Import | Scope | Beurteilung |")
+ lines.append("|-------|-----------|--------|-------|-------------|")
+ for row in stdlibRows:
+ lines.append(f"| `{row[0]}` | `{row[1]}` | `{row[2]}` | {row[3]} | {row[4]} |")
+ lines.append("")
+ else:
+ lines.append("_Keine lazy Stdlib-Imports in Code-Scope._")
+ lines.append("")
+
+ # --- Lazy internal movable ---
+ lines.extend(["## Lazy interne Imports — Header möglich?", ""])
+ movableRows: List[Tuple[str, str, str, str, str]] = []
+ intentionalRows: List[Tuple[str, str, str, str, str]] = []
+ mustStayRows: List[Tuple[str, str, str, str, str]] = []
+ redundantRows: List[Tuple[str, str, str, str, str]] = []
+
+ for moduleId, items in sorted(importsByModule.items()):
+ headerTargets = {i.target for i in items if i.position == "header" and i.isInternal}
+ for item in items:
+ if item.position != "code" or not item.isInternal:
+ continue
+ verdict = _assessMovable(moduleId, item, graph, headerTargets)
+ row = (
+ _shortModule(moduleId),
+ _getPlatformContainer(moduleId) or "",
+ _shortModule(item.target),
+ item.scope or "(code)",
+ verdict,
+ )
+ if verdict.startswith("Verschiebbar"):
+ movableRows.append(row)
+ elif verdict.startswith("Beabsichtigt"):
+ intentionalRows.append(row)
+ elif verdict.startswith("Redundant"):
+ redundantRows.append(row)
+ elif verdict.startswith("Muss lazy"):
+ mustStayRows.append(row)
+
+ if intentionalRows:
+ lines.append("### Beabsichtigt lazy (Startup/Lifecycle)")
+ lines.append("")
+ lines.append(f"**{len(intentionalRows)}** Einträge — lazy in lifespan/onBootstrap/…; kein Refactor nötig.")
+ lines.append("")
+
+ if movableRows:
+ lines.append("### Verschiebbar in Header")
+ lines.append("")
+ lines.append("| Modul | Container | Import-Ziel | Scope | Beurteilung |")
+ lines.append("|-------|-----------|-------------|-------|-------------|")
+ for row in movableRows:
+ lines.append(f"| `{row[0]}` | `{row[1]}` | `{row[2]}` | {row[3]} | {row[4]} |")
+ lines.append("")
+
+ if mustStayRows:
+ lines.append("### Muss lazy bleiben (Zyklus)")
+ lines.append("")
+ lines.append(f"**{len(mustStayRows)}** Einträge — Auszug (max. 40):")
+ lines.append("")
+ lines.append("| Modul | Container | Import-Ziel | Scope | Beurteilung |")
+ lines.append("|-------|-----------|-------------|-------|-------------|")
+ for row in mustStayRows[:40]:
+ lines.append(f"| `{row[0]}` | `{row[1]}` | `{row[2]}` | {row[3]} | {row[4]} |")
+ if len(mustStayRows) > 40:
+ lines.append("")
+ lines.append(f"_… und {len(mustStayRows) - 40} weitere._")
+ lines.append("")
+
+ if redundantRows:
+ lines.append("### Redundant (Header + Code)")
+ lines.append("")
+ lines.append("| Modul | Container | Import-Ziel | Scope | Beurteilung |")
+ lines.append("|-------|-----------|-------------|-------|-------------|")
+ for row in redundantRows:
+ lines.append(f"| `{row[0]}` | `{row[1]}` | `{row[2]}` | {row[3]} | {row[4]} |")
+ lines.append("")
+
+ if not movableRows and not mustStayRows and not redundantRows and not intentionalRows:
+ lines.append("_Keine lazy internen Imports gefunden._")
+ lines.append("")
+
+ lines.extend(
+ [
+ "## Kurzfassung",
+ "",
+ f"- Gegenseitige Modul-Paare (intra-container): **{totalPairs}**",
+ f"- Gegenseitige Modul-Paare (cross-container): **{len(crossPairs)}**",
+ f"- Import-Loop-Gruppen (SCC): **{len(sccList)}** (davon Self-Loop: **{sum(1 for c in sccList if len(c) == 1)}**)",
+ f"- Lazy Stdlib-Imports: **{len(stdlibRows)}**",
+ f"- Lazy intern / beabsichtigt (Lifecycle): **{len(intentionalRows)}**",
+ f"- Lazy intern / verschiebbar: **{len(movableRows)}**",
+ f"- Lazy intern / Zyklus (muss bleiben): **{len(mustStayRows)}**",
+ f"- Lazy intern / redundant: **{len(redundantRows)}**",
+ "",
+ ]
+ )
+ return "\n".join(lines)
+
+
+def main() -> None:
+ print("Collecting detailed platform imports...")
+ importsByModule = _collectDetailedImports()
+ graph = _internalGraph(importsByModule)
+ print(f" modules: {len(importsByModule)}")
+ print(f" internal edges: {sum(len(v) for v in graph.values())}")
+
+ markdown = _renderMarkdown(importsByModule, graph)
+ _writeText(OUTPUT_FILE, markdown)
+ print(f"Written: {OUTPUT_FILE}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/script_analyze_porta_imports.py b/scripts/script_analyze_porta_imports.py
new file mode 100644
index 00000000..c752d683
--- /dev/null
+++ b/scripts/script_analyze_porta_imports.py
@@ -0,0 +1,898 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
+"""
+Analyze all imports (including lazy/dynamic) for PowerOn PORTA UI and platform-core.
+
+Outputs under local/notes/refernce-analysis/:
+ platform/modules/*.md one file per Python module
+ platform/containers/*.md aggregated stats per container
+ platform/container-network.drawio
+ ui/modules/*.md
+ ui/containers/*.md
+ ui/container-network.drawio
+ README.md
+
+Usage:
+ python platform-core/scripts/script_analyze_porta_imports.py
+"""
+
+from __future__ import annotations
+
+import ast
+import html
+import math
+import os
+import re
+from collections import defaultdict
+from dataclasses import dataclass, field
+from datetime import date
+from pathlib import Path
+from typing import Dict, Iterable, List, Optional, Set, Tuple
+
+SCRIPT_DIR = Path(__file__).resolve().parent
+PLATFORM_ROOT = SCRIPT_DIR.parent
+REPO_ROOT = PLATFORM_ROOT.parent
+UI_ROOT = REPO_ROOT / "ui-nyla"
+OUTPUT_ROOT = REPO_ROOT / "local" / "notes" / "refernce-analysis"
+
+SKIP_DIR_NAMES = {
+ "__pycache__",
+ "node_modules",
+ ".git",
+ "dist",
+ "build",
+ ".venv",
+ "venv",
+ ".tox",
+ ".mypy_cache",
+ ".pytest_cache",
+}
+UI_SKIP_GLOBS = ("**/*.test.ts", "**/*.test.tsx", "test/**")
+
+
+@dataclass
+class ImportRecord:
+ importedModule: str
+ position: str # "header" | "code"
+ isInternal: bool
+ sourceContainer: Optional[str] = None
+ targetContainer: Optional[str] = None
+
+
+@dataclass
+class ModuleAnalysis:
+ context: str # "platform" | "ui"
+ moduleId: str
+ filePath: Path
+ container: str
+ containerPath: str
+ imports: List[ImportRecord] = field(default_factory=list)
+
+
+def _sanitizeFileName(value: str) -> str:
+ return re.sub(r"[^A-Za-z0-9._-]+", "_", value)
+
+
+def _writeText(path: Path, content: str) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content, encoding="utf-8")
+
+
+# ---------------------------------------------------------------------------
+# Platform (Python)
+# ---------------------------------------------------------------------------
+
+def _platformModuleId(filePath: Path) -> str:
+ rel = filePath.relative_to(PLATFORM_ROOT)
+ if filePath.name == "__init__.py":
+ parts = rel.parent.parts
+ else:
+ parts = rel.with_suffix("").parts
+ return "platform-core." + ".".join(parts)
+
+
+def _platformContainerPath(container: str) -> str:
+ if container == "app":
+ return "platform-core/app.py"
+ if container.startswith("features."):
+ featureCode = container.split(".", 1)[1]
+ return f"platform-core/modules/features/{featureCode}"
+ return f"platform-core/modules/{container}"
+
+
+def _getPlatformContainer(moduleId: str) -> Optional[str]:
+ if moduleId == "platform-core.app":
+ return "app"
+
+ if not moduleId.startswith("platform-core."):
+ return None
+
+ parts = moduleId.replace("platform-core.", "").split(".")
+ if not parts:
+ return "app"
+
+ if parts[0] in ("tests", "scripts") or parts[0].startswith("script_"):
+ return None
+ if parts[0] != "modules" or len(parts) < 2:
+ return "app"
+
+ container = parts[1]
+ if container == "features" and len(parts) > 2:
+ return f"features.{parts[2]}"
+ return container
+
+
+def _resolvePlatformRelativeImport(currentFile: Path, importNode: ast.ImportFrom) -> Optional[str]:
+ dotCount = importNode.level
+ moduleSuffix = importNode.module or ""
+ currentDir = currentFile.parent
+
+ baseDir = currentDir
+ for _ in range(dotCount - 1):
+ baseDir = baseDir.parent
+
+ if moduleSuffix:
+ candidate = baseDir / Path(moduleSuffix.replace(".", os.sep))
+ else:
+ candidate = baseDir
+
+ pyFile = candidate.with_suffix(".py")
+ if pyFile.exists():
+ return _platformModuleId(pyFile)
+
+ initFile = candidate / "__init__.py"
+ if initFile.exists():
+ return _platformModuleId(initFile)
+
+ rel = candidate.relative_to(PLATFORM_ROOT) if candidate.is_relative_to(PLATFORM_ROOT) else None
+ if rel is None:
+ return None
+ return "platform-core." + ".".join(rel.with_suffix("").parts)
+
+
+def _resolvePlatformImportTarget(currentFile: Path, importedName: str) -> Tuple[str, bool]:
+ if importedName.startswith("."):
+ return importedName, False
+
+ if importedName.startswith("modules."):
+ parts = importedName.split(".")
+ checkPath = PLATFORM_ROOT
+ for part in parts:
+ checkPath = checkPath / part
+ if checkPath.with_suffix(".py").exists():
+ return _platformModuleId(checkPath.with_suffix(".py")), True
+ if checkPath.is_dir() and (checkPath / "__init__.py").exists():
+ return _platformModuleId(checkPath / "__init__.py"), True
+ return f"platform-core.{importedName.replace('.', '.')}", True
+
+ return importedName, False
+
+
+class _PythonImportVisitor(ast.NodeVisitor):
+ def __init__(self, filePath: Path):
+ self.filePath = filePath
+ self.imports: List[ImportRecord] = []
+ self._inCodeScope = False
+
+ def _addImport(self, importedModule: str, isInternal: bool) -> None:
+ position = "code" if self._inCodeScope else "header"
+ sourceContainer = _getPlatformContainer(_platformModuleId(self.filePath))
+ targetContainer = _getPlatformContainer(importedModule) if isInternal else None
+ self.imports.append(
+ ImportRecord(
+ importedModule=importedModule,
+ position=position,
+ isInternal=isInternal,
+ sourceContainer=sourceContainer,
+ targetContainer=targetContainer,
+ )
+ )
+
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
+ previous = self._inCodeScope
+ self._inCodeScope = True
+ self.generic_visit(node)
+ self._inCodeScope = previous
+
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
+ previous = self._inCodeScope
+ self._inCodeScope = True
+ self.generic_visit(node)
+ self._inCodeScope = previous
+
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
+ previous = self._inCodeScope
+ self._inCodeScope = True
+ self.generic_visit(node)
+ self._inCodeScope = previous
+
+ def visit_Import(self, node: ast.Import) -> None:
+ for alias in node.names:
+ resolved, isInternal = _resolvePlatformImportTarget(self.filePath, alias.name)
+ self._addImport(resolved, isInternal)
+
+ def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
+ if node.level > 0:
+ resolved = _resolvePlatformRelativeImport(self.filePath, node)
+ if resolved:
+ self._addImport(resolved, True)
+ else:
+ suffix = node.module or ""
+ display = ("." * node.level) + suffix
+ self._addImport(f"(relative-unresolved) {display}", False)
+ return
+
+ if not node.module:
+ return
+
+ resolved, isInternal = _resolvePlatformImportTarget(self.filePath, node.module)
+ self._addImport(resolved, isInternal)
+
+
+def _analyzePythonFile(filePath: Path) -> Optional[ModuleAnalysis]:
+ container = _getPlatformContainer(_platformModuleId(filePath))
+ if container is None:
+ return None
+
+ try:
+ source = filePath.read_text(encoding="utf-8")
+ tree = ast.parse(source, filename=str(filePath))
+ except (SyntaxError, UnicodeDecodeError) as error:
+ print(f"WARN parse failed: {filePath}: {error}")
+ return None
+
+ visitor = _PythonImportVisitor(filePath)
+ visitor.visit(tree)
+
+ moduleId = _platformModuleId(filePath)
+ return ModuleAnalysis(
+ context="platform",
+ moduleId=moduleId,
+ filePath=filePath,
+ container=container,
+ containerPath=_platformContainerPath(container),
+ imports=visitor.imports,
+ )
+
+
+def _collectPlatformModules() -> List[ModuleAnalysis]:
+ modules: List[ModuleAnalysis] = []
+ scanRoots = [PLATFORM_ROOT / "modules", PLATFORM_ROOT / "app.py"]
+ pyFiles: List[Path] = []
+ if scanRoots[1].exists():
+ pyFiles.append(scanRoots[1])
+ for root, dirs, files in os.walk(scanRoots[0]):
+ dirs[:] = [d for d in dirs if d not in SKIP_DIR_NAMES]
+ for fileName in files:
+ if fileName.endswith(".py"):
+ pyFiles.append(Path(root) / fileName)
+
+ for filePath in pyFiles:
+ analysis = _analyzePythonFile(filePath)
+ if analysis:
+ modules.append(analysis)
+ return modules
+
+
+# ---------------------------------------------------------------------------
+# UI (TypeScript)
+# ---------------------------------------------------------------------------
+
+TS_IMPORT_FROM_RE = re.compile(
+ r"""(?:^|\n)\s*(?:import|export)\s+(?:type\s+)?(?:[\w*\s{},\n\r]+?\sfrom\s+)?['"]([^'"]+)['"]""",
+ re.MULTILINE,
+)
+TS_SIDE_EFFECT_IMPORT_RE = re.compile(
+ r"""(?:^|\n)\s*import\s+['"]([^'"]+)['"]\s*;""",
+ re.MULTILINE,
+)
+TS_DYNAMIC_IMPORT_RE = re.compile(r"""import\s*\(\s*['"]([^'"]+)['"]\s*\)""")
+
+
+def _uiModuleId(filePath: Path) -> str:
+ rel = filePath.relative_to(UI_ROOT / "src")
+ if filePath.name == "index.ts" or filePath.name == "index.tsx":
+ parts = rel.parent.parts
+ else:
+ parts = rel.with_suffix("").parts
+ return "ui-nyla.src." + ".".join(parts)
+
+
+def _uiContainerPath(container: str) -> str:
+ if container.startswith("pages."):
+ suffix = container.split(".", 1)[1]
+ if suffix in ("admin", "basedata", "billing", "settings", "workflowAutomation"):
+ return f"ui-nyla/src/pages/{suffix}"
+ return f"ui-nyla/src/pages/views/{suffix}"
+ if container.startswith("components."):
+ suffix = container.split(".", 1)[1]
+ return f"ui-nyla/src/components/{suffix}"
+ return f"ui-nyla/src/{container}"
+
+
+def _getUiContainer(moduleId: str) -> Optional[str]:
+ if not moduleId.startswith("ui-nyla.src."):
+ return None
+
+ parts = moduleId.replace("ui-nyla.src.", "").split(".")
+ if not parts:
+ return None
+
+ top = parts[0]
+ if top == "test":
+ return None
+
+ if top == "pages":
+ if len(parts) >= 3 and parts[1] == "views":
+ return f"pages.{parts[2]}"
+ if len(parts) >= 2:
+ return f"pages.{parts[1]}"
+ return "pages"
+
+ if top == "components" and len(parts) >= 2:
+ return f"components.{parts[1]}"
+
+ return top
+
+
+def _resolveUiImport(currentFile: Path, spec: str) -> Tuple[str, bool]:
+ if spec.startswith("."):
+ resolvedPath = (currentFile.parent / spec).resolve()
+ candidates = [
+ resolvedPath,
+ resolvedPath.with_suffix(".ts"),
+ resolvedPath.with_suffix(".tsx"),
+ resolvedPath / "index.ts",
+ resolvedPath / "index.tsx",
+ ]
+ for candidate in candidates:
+ if candidate.exists() and candidate.is_relative_to(UI_ROOT / "src"):
+ return _uiModuleId(candidate), True
+ relDisplay = spec
+ return relDisplay, False
+
+ return spec, False
+
+
+def _findTsImportPosition(source: str, matchStart: int) -> str:
+ depth = 0
+ inFunction = False
+ functionDepth = 0
+ i = 0
+ while i < matchStart:
+ char = source[i]
+ if char == "{":
+ depth += 1
+ elif char == "}":
+ depth = max(0, depth - 1)
+ if inFunction and depth < functionDepth:
+ inFunction = False
+ i += 1
+
+ lookback = source[max(0, matchStart - 400):matchStart]
+ if re.search(r"(?:function\s*\w*\s*\(|=>\s*\{|(?:async\s+)?function\s+\w+\s*\()", lookback):
+ tail = lookback[lookback.rfind("\n") + 1:]
+ if "=>" in tail or "function" in tail:
+ bracePos = source.find("{", max(0, matchStart - 120), matchStart)
+ if bracePos >= 0:
+ return "code"
+
+ return "header" if depth == 0 and not inFunction else "code"
+
+
+def _analyzeTypeScriptFile(filePath: Path) -> Optional[ModuleAnalysis]:
+ container = _getUiContainer(_uiModuleId(filePath))
+ if container is None:
+ return None
+
+ try:
+ source = filePath.read_text(encoding="utf-8")
+ except UnicodeDecodeError as error:
+ print(f"WARN read failed: {filePath}: {error}")
+ return None
+
+ imports: List[ImportRecord] = []
+ seen: Set[Tuple[str, str, str]] = set()
+
+ def _register(spec: str, position: str) -> None:
+ resolved, isInternal = _resolveUiImport(filePath, spec)
+ key = (resolved, position, spec)
+ if key in seen:
+ return
+ seen.add(key)
+ sourceContainer = container
+ targetContainer = _getUiContainer(resolved) if isInternal else None
+ imports.append(
+ ImportRecord(
+ importedModule=resolved,
+ position=position,
+ isInternal=isInternal,
+ sourceContainer=sourceContainer,
+ targetContainer=targetContainer,
+ )
+ )
+
+ for match in TS_IMPORT_FROM_RE.finditer(source):
+ position = _findTsImportPosition(source, match.start())
+ _register(match.group(1), position)
+
+ for match in TS_SIDE_EFFECT_IMPORT_RE.finditer(source):
+ if match.group(1) in {m.group(1) for m in TS_IMPORT_FROM_RE.finditer(source)}:
+ continue
+ position = _findTsImportPosition(source, match.start())
+ _register(match.group(1), position)
+
+ for match in TS_DYNAMIC_IMPORT_RE.finditer(source):
+ position = _findTsImportPosition(source, match.start())
+ _register(match.group(1), position)
+
+ moduleId = _uiModuleId(filePath)
+ return ModuleAnalysis(
+ context="ui",
+ moduleId=moduleId,
+ filePath=filePath,
+ container=container,
+ containerPath=_uiContainerPath(container),
+ imports=imports,
+ )
+
+
+def _collectUiModules() -> List[ModuleAnalysis]:
+ srcRoot = UI_ROOT / "src"
+ modules: List[ModuleAnalysis] = []
+ for filePath in srcRoot.rglob("*"):
+ if not filePath.is_file():
+ continue
+ if filePath.suffix not in (".ts", ".tsx"):
+ continue
+ rel = filePath.relative_to(srcRoot).as_posix()
+ if rel.startswith("test/") or rel.endswith(".test.ts") or rel.endswith(".test.tsx"):
+ continue
+ analysis = _analyzeTypeScriptFile(filePath)
+ if analysis:
+ modules.append(analysis)
+ return modules
+
+
+# ---------------------------------------------------------------------------
+# Markdown output
+# ---------------------------------------------------------------------------
+
+def _renderModuleMarkdown(module: ModuleAnalysis) -> str:
+ lines = [
+ f"# Module Import Analysis: `{module.moduleId}`",
+ "",
+ f"- **Kontext:** {module.context}",
+ f"- **Container:** `{module.container}`",
+ f"- **Container-Pfad:** `{module.containerPath}`",
+ f"- **Datei:** `{module.filePath.relative_to(REPO_ROOT).as_posix()}`",
+ f"- **Import-Anzahl:** {len(module.imports)}",
+ "",
+ "## Imports",
+ "",
+ "| Modul | Position | Intern |",
+ "|-------|----------|--------|",
+ ]
+
+ for item in sorted(module.imports, key=lambda x: (x.importedModule, x.position)):
+ internal = "ja" if item.isInternal else "nein"
+ lines.append(f"| `{item.importedModule}` | {item.position} | {internal} |")
+
+ if not module.imports:
+ lines.append("| _keine_ | | |")
+
+ lines.append("")
+ return "\n".join(lines)
+
+
+@dataclass
+class ContainerStats:
+ container: str
+ containerPath: str
+ importsFrom: Dict[str, int] = field(default_factory=lambda: defaultdict(int))
+ exportedTo: Dict[str, int] = field(default_factory=lambda: defaultdict(int))
+ mixedWith: Dict[str, Tuple[int, int]] = field(default_factory=dict)
+
+
+def _buildContainerStats(modules: Iterable[ModuleAnalysis]) -> Dict[str, ContainerStats]:
+ statsByContainer: Dict[str, ContainerStats] = {}
+
+ for module in modules:
+ if module.container not in statsByContainer:
+ statsByContainer[module.container] = ContainerStats(
+ container=module.container,
+ containerPath=module.containerPath,
+ )
+
+ for item in module.imports:
+ if not item.isInternal:
+ continue
+ if not item.sourceContainer or not item.targetContainer:
+ continue
+ if item.sourceContainer == item.targetContainer:
+ continue
+
+ stats = statsByContainer[item.sourceContainer]
+ stats.importsFrom[item.targetContainer] += 1
+
+ targetStats = statsByContainer.get(item.targetContainer)
+ if targetStats is None:
+ targetStats = ContainerStats(
+ container=item.targetContainer,
+ containerPath=_platformContainerPath(item.targetContainer)
+ if module.context == "platform"
+ else _uiContainerPath(item.targetContainer),
+ )
+ statsByContainer[item.targetContainer] = targetStats
+ targetStats.exportedTo[item.sourceContainer] += 1
+
+ for containerName, stats in statsByContainer.items():
+ mixed: Dict[str, Tuple[int, int]] = {}
+ for other, outCount in stats.importsFrom.items():
+ inCount = stats.exportedTo.get(other, 0)
+ if inCount > 0:
+ mixed[other] = (outCount, inCount)
+ stats.mixedWith = mixed
+
+ return statsByContainer
+
+
+def _renderContainerMarkdown(context: str, stats: ContainerStats) -> str:
+ importsTotal = sum(stats.importsFrom.values())
+ exportsTotal = sum(stats.exportedTo.values())
+ mixedTotal = sum(min(pair[0], pair[1]) for pair in stats.mixedWith.values())
+
+ lines = [
+ f"# Container Import Analysis: `{stats.container}`",
+ "",
+ f"- **Kontext:** {context}",
+ f"- **Container-Pfad:** `{stats.containerPath}`",
+ "",
+ "## Imports aus anderen Containern",
+ "",
+ f"- **Anzahl:** {importsTotal}",
+ f"- **Container ({len(stats.importsFrom)}):** "
+ + (", ".join(f"`{name}` ({count})" for name, count in sorted(stats.importsFrom.items())) or "_keine_"),
+ "",
+ "## Exports zu anderen Containern",
+ "",
+ f"- **Anzahl:** {exportsTotal}",
+ f"- **Container ({len(stats.exportedTo)}):** "
+ + (", ".join(f"`{name}` ({count})" for name, count in sorted(stats.exportedTo.items())) or "_keine_"),
+ "",
+ "## Cross (mixed Import/Export)",
+ "",
+ f"- **Anzahl bidirektionaler Paare:** {len(stats.mixedWith)}",
+ f"- **Mindest-Wechselzahl (min je Richtung):** {mixedTotal}",
+ ]
+
+ if stats.mixedWith:
+ lines.extend(["", "| Container | Importe hinaus | Importe herein |", "|-----------|----------------|----------------|"])
+ for other, (outCount, inCount) in sorted(stats.mixedWith.items()):
+ lines.append(f"| `{other}` | {outCount} | {inCount} |")
+ else:
+ lines.append("- **Container:** _keine_")
+
+ lines.append("")
+ return "\n".join(lines)
+
+
+def _renderReadme(platformModules: int, uiModules: int, platformContainers: int, uiContainers: int) -> str:
+ return f"""# PORTA Import-Analyse
+
+Generiert am {date.today().isoformat()} durch `platform-core/scripts/script_analyze_porta_imports.py`.
+
+## Umfang
+
+| Kontext | Module | Container |
+|---------|--------|-----------|
+| platform | {platformModules} | {platformContainers} |
+| ui | {uiModules} | {uiContainers} |
+
+## Struktur
+
+- `import-analysis-platform.md` — konsolidierte Platform-Übersicht (Tabelle)
+- `import-analysis-platform-modules.md` — Modul-Graph: Gegenimporte, Loops, lazy Imports
+- `import-analysis-ui.md` — konsolidierte UI-Übersicht (Tabelle)
+- `platform/modules/` — ein Markdown pro Python-Modul (alle Imports inkl. lazy)
+- `platform/containers/` — aggregierte Container-Statistik
+- `platform/container-network.drawio` — Container-Vernetzung (schwarz=einweg, rot=mixed)
+- `ui/modules/` — ein Markdown pro TS/TSX-Modul
+- `ui/containers/` — aggregierte Container-Statistik
+- `ui/container-network.drawio` — Container-Vernetzung
+- `container-network.drawio` — kombiniert (2 Diagramm-Tabs: platform + ui)
+
+## Position
+
+- `header` — Import auf Modulebene (Top-Level)
+- `code` — Import innerhalb von Funktion/Klasse oder dynamisch (`import()`)
+
+## Regenerieren
+
+```bash
+python platform-core/scripts/script_analyze_porta_imports.py
+```
+"""
+
+
+# ---------------------------------------------------------------------------
+# draw.io
+# ---------------------------------------------------------------------------
+
+CONTAINER_COLORS = {
+ "app": "#dae8fc",
+ "aicore": "#d5e8d4",
+ "auth": "#ffe6cc",
+ "connectors": "#e1d5e7",
+ "datamodels": "#fff2cc",
+ "interfaces": "#f8cecc",
+ "routes": "#d0cee2",
+ "security": "#fad7ac",
+ "serviceCenter": "#b1ddf0",
+ "shared": "#f0fff0",
+ "workflows": "#f5f5f5",
+ "workflowAutomation": "#e6d0de",
+ "system": "#cce5ff",
+ "dbHelpers": "#fff0f5",
+ "nodeCatalog": "#f5fffa",
+ "pages": "#dae8fc",
+ "components": "#d5e8d4",
+ "hooks": "#ffe6cc",
+ "contexts": "#e1d5e7",
+ "api": "#fff2cc",
+ "layouts": "#f8cecc",
+ "providers": "#d0cee2",
+ "config": "#fad7ac",
+ "utils": "#b1ddf0",
+ "types": "#f0fff0",
+ "locales": "#f5f5f5",
+ "stores": "#e2efda",
+ "styles": "#fce5cd",
+}
+
+
+def _aggregateContainerEdges(statsByContainer: Dict[str, ContainerStats]) -> Dict[Tuple[str, str], Tuple[int, int, bool]]:
+ pairCounts: Dict[Tuple[str, str], Tuple[int, int]] = {}
+
+ for stats in statsByContainer.values():
+ for target, count in stats.importsFrom.items():
+ key = (stats.container, target)
+ outCount, inCount = pairCounts.get(key, (0, 0))
+ pairCounts[key] = (outCount + count, inCount)
+
+ edges: Dict[Tuple[str, str], Tuple[int, int, bool]] = {}
+ processed: Set[Tuple[str, str]] = set()
+
+ for (source, target), (forward, _) in list(pairCounts.items()):
+ pairKey = tuple(sorted((source, target)))
+ if pairKey in processed:
+ continue
+ processed.add(pairKey)
+
+ a, b = pairKey
+ aToB = pairCounts.get((a, b), (0, 0))[0]
+ bToA = pairCounts.get((b, a), (0, 0))[0]
+
+ if aToB == 0 and bToA == 0:
+ continue
+
+ if aToB > 0 and bToA > 0:
+ edges[(a, b)] = (aToB, bToA, True)
+ elif aToB > 0:
+ edges[(a, b)] = (aToB, 0, False)
+ else:
+ edges[(b, a)] = (bToA, 0, False)
+
+ return edges
+
+
+def _generateDrawio(context: str, statsByContainer: Dict[str, ContainerStats]) -> str:
+ containers = sorted(statsByContainer.keys())
+ edges = _aggregateContainerEdges(statsByContainer)
+
+ centerX = 700
+ centerY = 550
+ radius = 430
+ nodeWidth = 170
+ nodeHeight = 62
+
+ containerPositions: Dict[str, Tuple[int, int]] = {}
+ for index, container in enumerate(containers):
+ angle = (2 * math.pi * index / max(len(containers), 1)) - math.pi / 2
+ x = int(centerX + radius * math.cos(angle) - nodeWidth / 2)
+ y = int(centerY + radius * math.sin(angle) - nodeHeight / 2)
+ containerPositions[container] = (x, y)
+
+ cells: List[str] = []
+ for container in containers:
+ x, y = containerPositions[container]
+ base = container.split(".")[0]
+ color = CONTAINER_COLORS.get(base, "#ffffff")
+ label = f"{container}\\n({sum(statsByContainer[container].importsFrom.values())} out / "
+ label += f"{sum(statsByContainer[container].exportedTo.values())} in)"
+ cellId = f"container_{container.replace('.', '_')}"
+ cells.append(
+ f"""
+
+ """
+ )
+
+ edgeId = 1000
+ for (source, target), (forward, backward, isMixed) in sorted(edges.items(), key=lambda item: -(item[1][0] + item[1][1])):
+ sourceId = f"container_{source.replace('.', '_')}"
+ targetId = f"container_{target.replace('.', '_')}"
+ if isMixed:
+ label = f"{forward} / {backward}"
+ strokeColor = "#CC0000"
+ else:
+ label = str(forward)
+ strokeColor = "#000000"
+
+ strokeWidth = min(1 + (forward + backward) // 15, 6)
+ cells.append(
+ f"""
+
+ """
+ )
+ edgeId += 1
+
+ innerXml = f"""
+
+
+
+{chr(10).join(cells)}
+
+ """
+
+ return _wrapDrawioDiagram(context, innerXml)
+
+
+def _wrapDrawioDiagram(context: str, innerXml: str) -> str:
+ return f"""
+
+
+{innerXml}
+
+
+"""
+
+
+def _extractDrawioDiagramBody(drawioXml: str) -> str:
+ start = drawioXml.index("")
+ return drawioXml[start:end]
+
+
+def _combineDrawioFiles(platformDrawio: str, uiDrawio: str) -> str:
+ return f"""
+
+
+{_extractDrawioDiagramBody(platformDrawio)}
+
+
+{_extractDrawioDiagramBody(uiDrawio)}
+
+
+"""
+
+
+SUMMARY_FILE_PLATFORM = "import-analysis-platform.md"
+SUMMARY_FILE_UI = "import-analysis-ui.md"
+
+
+def _renderConsolidatedSummary(
+ title: str,
+ context: str,
+ detailFolder: str,
+ statsByContainer: Dict[str, ContainerStats],
+ diagramPath: str,
+) -> str:
+ lines = [
+ f"# {title}",
+ "",
+ f"- **Kontext:** {context}",
+ f"- **Generiert:** {date.today().isoformat()}",
+ f"- **Detail-Dateien:** `{detailFolder}/`",
+ "",
+ "## Container",
+ "",
+ "| Container | Imports out | Exports in | Mixed | Detail |",
+ "|-----------|------------:|-----------:|------:|--------|",
+ ]
+
+ for containerName in sorted(statsByContainer.keys()):
+ stats = statsByContainer[containerName]
+ importsOut = sum(stats.importsFrom.values())
+ exportsIn = sum(stats.exportedTo.values())
+ mixedCount = len(stats.mixedWith)
+ detailLink = f"[Detail]({detailFolder}/containers/{_sanitizeFileName(containerName)}.md)"
+ lines.append(
+ f"| `{containerName}` | {importsOut} | {exportsIn} | {mixedCount} | {detailLink} |"
+ )
+
+ lines.extend(
+ [
+ "",
+ f"Diagramm: [{diagramPath}]({diagramPath})",
+ "",
+ ]
+ )
+ return "\n".join(lines)
+
+
+def _writeContextOutput(context: str, modules: List[ModuleAnalysis]) -> Tuple[int, str]:
+ contextRoot = OUTPUT_ROOT / context
+ modulesDir = contextRoot / "modules"
+ containersDir = contextRoot / "containers"
+
+ for module in modules:
+ fileName = _sanitizeFileName(module.moduleId) + ".md"
+ _writeText(modulesDir / fileName, _renderModuleMarkdown(module))
+
+ statsByContainer = _buildContainerStats(modules)
+ for containerName, stats in sorted(statsByContainer.items()):
+ fileName = _sanitizeFileName(containerName) + ".md"
+ _writeText(containersDir / fileName, _renderContainerMarkdown(context, stats))
+
+ drawio = _generateDrawio(context, statsByContainer)
+ _writeText(contextRoot / "container-network.drawio", drawio)
+
+ return len(statsByContainer), drawio
+
+
+def main() -> None:
+ print("Analyzing platform-core (Python)...")
+ platformModules = _collectPlatformModules()
+ print(f" modules: {len(platformModules)}")
+
+ print("Analyzing ui-nyla (TypeScript)...")
+ uiModules = _collectUiModules()
+ print(f" modules: {len(uiModules)}")
+
+ platformContainerCount, platformDrawio = _writeContextOutput("platform", platformModules)
+ uiContainerCount, uiDrawio = _writeContextOutput("ui", uiModules)
+
+ combinedDrawio = _combineDrawioFiles(platformDrawio, uiDrawio)
+ _writeText(OUTPUT_ROOT / "container-network.drawio", combinedDrawio)
+
+ readme = _renderReadme(
+ platformModules=len(platformModules),
+ uiModules=len(uiModules),
+ platformContainers=platformContainerCount,
+ uiContainers=uiContainerCount,
+ )
+ _writeText(OUTPUT_ROOT / "README.md", readme)
+
+ platformStats = _buildContainerStats(platformModules)
+ platformSummary = _renderConsolidatedSummary(
+ title="Import-Analyse Platform Core",
+ context="platform",
+ detailFolder="platform",
+ statsByContainer=platformStats,
+ diagramPath="platform/container-network.drawio",
+ )
+ _writeText(OUTPUT_ROOT / SUMMARY_FILE_PLATFORM, platformSummary)
+
+ uiStats = _buildContainerStats(uiModules)
+ uiSummary = _renderConsolidatedSummary(
+ title="Import-Analyse UI Nyla",
+ context="ui",
+ detailFolder="ui",
+ statsByContainer=uiStats,
+ diagramPath="ui/container-network.drawio",
+ )
+ _writeText(OUTPUT_ROOT / SUMMARY_FILE_UI, uiSummary)
+
+ print(f"\nOutput written to: {OUTPUT_ROOT}")
+ print(f" platform containers: {platformContainerCount}")
+ print(f" ui containers: {uiContainerCount}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/script_remove_redundant_platform_imports.py b/scripts/script_remove_redundant_platform_imports.py
new file mode 100644
index 00000000..4081c7e4
--- /dev/null
+++ b/scripts/script_remove_redundant_platform_imports.py
@@ -0,0 +1,165 @@
+# Copyright (c) 2026 PowerOn AG
+# All rights reserved.
+"""
+Remove redundant lazy imports in platform-core when the same internal module
+is already imported at module header level.
+
+Usage:
+ python platform-core/scripts/script_remove_redundant_platform_imports.py
+ python platform-core/scripts/script_remove_redundant_platform_imports.py --dry-run
+"""
+
+from __future__ import annotations
+
+import argparse
+import ast
+import os
+import sys
+from pathlib import Path
+from typing import Dict, List, Set, Tuple
+
+SCRIPT_DIR = Path(__file__).resolve().parent
+sys.path.insert(0, str(SCRIPT_DIR))
+
+from script_analyze_porta_imports import ( # noqa: E402
+ PLATFORM_ROOT,
+ SKIP_DIR_NAMES,
+ _getPlatformContainer,
+ _platformModuleId,
+ _resolvePlatformImportTarget,
+ _resolvePlatformRelativeImport,
+)
+
+
+class _RedundantImportFinder(ast.NodeVisitor):
+ def __init__(self, filePath: Path):
+ self.filePath = filePath
+ self.headerTargets: Set[str] = set()
+ self.linesToRemove: Set[int] = set()
+ self._scopeDepth = 0
+
+ def _resolveImportNode(self, node: ast.Import | ast.ImportFrom) -> List[Tuple[str, bool]]:
+ resolved: List[Tuple[str, bool]] = []
+ if isinstance(node, ast.ImportFrom):
+ if node.level > 0:
+ target = _resolvePlatformRelativeImport(self.filePath, node)
+ if target:
+ resolved.append((target, True))
+ return resolved
+ if not node.module:
+ return resolved
+ target, isInternal = _resolvePlatformImportTarget(self.filePath, node.module)
+ resolved.append((target, isInternal))
+ return resolved
+
+ for alias in node.names:
+ target, isInternal = _resolvePlatformImportTarget(self.filePath, alias.name)
+ resolved.append((target, isInternal))
+ return resolved
+
+ def _handleImportNode(self, node: ast.Import | ast.ImportFrom) -> None:
+ for target, isInternal in self._resolveImportNode(node):
+ if not isInternal or not target.startswith("platform-core."):
+ continue
+ if self._scopeDepth == 0:
+ self.headerTargets.add(target)
+ elif target in self.headerTargets:
+ endLine = getattr(node, "end_lineno", None) or node.lineno
+ for lineNo in range(node.lineno, endLine + 1):
+ self.linesToRemove.add(lineNo)
+
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
+ self._scopeDepth += 1
+ self.generic_visit(node)
+ self._scopeDepth -= 1
+
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
+ self._scopeDepth += 1
+ self.generic_visit(node)
+ self._scopeDepth -= 1
+
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
+ self._scopeDepth += 1
+ self.generic_visit(node)
+ self._scopeDepth -= 1
+
+ def visit_Import(self, node: ast.Import) -> None:
+ self._handleImportNode(node)
+
+ def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
+ self._handleImportNode(node)
+
+
+def _moduleIdToFilePath(moduleId: str) -> Path:
+ rel = moduleId.replace("platform-core.", "")
+ parts = rel.split(".")
+ candidate = PLATFORM_ROOT.joinpath(*parts).with_suffix(".py")
+ if candidate.exists():
+ return candidate
+ initFile = PLATFORM_ROOT.joinpath(*parts, "__init__.py")
+ return initFile
+
+
+def _collectPythonFiles() -> List[Path]:
+ pyFiles: List[Path] = []
+ appFile = PLATFORM_ROOT / "app.py"
+ if appFile.exists():
+ pyFiles.append(appFile)
+ for root, dirs, files in os.walk(PLATFORM_ROOT / "modules"):
+ dirs[:] = [d for d in dirs if d not in SKIP_DIR_NAMES]
+ for fileName in files:
+ if fileName.endswith(".py"):
+ pyFiles.append(Path(root) / fileName)
+ return pyFiles
+
+
+def _removeLines(filePath: Path, linesToRemove: Set[int], dryRun: bool) -> int:
+ if not linesToRemove:
+ return 0
+
+ lines = filePath.read_text(encoding="utf-8").splitlines(keepends=True)
+ newLines = [line for index, line in enumerate(lines, start=1) if index not in linesToRemove]
+
+ if not dryRun:
+ filePath.write_text("".join(newLines), encoding="utf-8")
+ return len(linesToRemove)
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--dry-run", action="store_true")
+ args = parser.parse_args()
+
+ totalRemoved = 0
+ filesChanged = 0
+ details: List[Tuple[str, int]] = []
+
+ for filePath in _collectPythonFiles():
+ moduleId = _platformModuleId(filePath)
+ if _getPlatformContainer(moduleId) is None:
+ continue
+ try:
+ tree = ast.parse(filePath.read_text(encoding="utf-8"), filename=str(filePath))
+ except (SyntaxError, UnicodeDecodeError) as error:
+ print(f"WARN skip {filePath}: {error}")
+ continue
+
+ finder = _RedundantImportFinder(filePath)
+ finder.visit(tree)
+ if not finder.linesToRemove:
+ continue
+
+ removed = _removeLines(filePath, finder.linesToRemove, args.dry_run)
+ totalRemoved += removed
+ filesChanged += 1
+ rel = filePath.relative_to(PLATFORM_ROOT.parent).as_posix()
+ details.append((rel, removed))
+ action = "would remove" if args.dry_run else "removed"
+ print(f"{action} {removed} from {rel}")
+
+ print(f"\nFiles touched: {filesChanged}")
+ print(f"Import lines {('would be ' if args.dry_run else '')}removed: {totalRemoved}")
+
+
+if __name__ == "__main__":
+ main()