From bc7c6fe27cb7f529cea699ab7ec878dfd1b59ed2 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sat, 6 Jun 2026 00:32:45 +0200 Subject: [PATCH 01/16] elimination of technical issues (imports) --- app.py | 58 +- modules/aicore/aicoreBase.py | 6 +- modules/aicore/aicorePluginAnthropic.py | 4 +- modules/aicore/aicorePluginMistral.py | 8 +- modules/aicore/aicorePluginOpenai.py | 8 +- modules/auth/authentication.py | 4 +- modules/auth/mfaService.py | 18 +- modules/auth/tokenRefreshService.py | 2 +- modules/connectors/connectorDbPostgre.py | 59 +- ...Clickup.py => connectorProviderClickup.py} | 119 +- ...onnectorFtp.py => connectorProviderFtp.py} | 0 ...orGoogle.py => connectorProviderGoogle.py} | 50 +- ...niak.py => connectorProviderInfomaniak.py} | 2 +- ...nectorMsft.py => connectorProviderMsft.py} | 42 +- modules/connectors/connectorResolver.py | 10 +- modules/connectors/connectorTicketsClickup.py | 4 +- modules/connectors/connectorVoiceGoogle.py | 4 +- .../connectors/providerClickup/__init__.py | 7 - modules/connectors/providerFtp/__init__.py | 3 - modules/connectors/providerGoogle/__init__.py | 3 - .../connectors/providerInfomaniak/__init__.py | 3 - modules/connectors/providerMsft/__init__.py | 3 - modules/datamodels/__init__.py | 3 +- .../datamodels/datamodelFeatureDataSource.py | 83 - modules/datamodels/datamodelFeatures.py | 152 +- modules/datamodels/datamodelPortTypes.py | 551 + modules/datamodels/datamodelViews.py | 4 +- .../datamodels/datamodelWorkflowAutomation.py | 579 + .../jsonContinuation.py | 2 +- modules/{migrations => dbHelpers}/__init__.py | 0 .../{shared => dbHelpers}/aiAuditLogger.py | 2 +- modules/{shared => dbHelpers}/auditLogger.py | 2 +- .../dbMultiTenantOptimizations.py | 2 +- modules/{shared => dbHelpers}/dbRegistry.py | 2 +- modules/dbHelpers/fkLabelResolver.py | 196 + modules/{shared => dbHelpers}/fkRegistry.py | 10 +- modules/dbHelpers/paginationHelpers.py | 543 + modules/demoConfigs/__init__.py | 12 +- .../{_baseDemoConfig.py => baseDemoConfig.py} | 4 +- modules/demoConfigs/investorDemo2026.py | 4 +- modules/demoConfigs/pwgDemo2026.py | 6 +- modules/features/commcoach/CONCEPT.md | 178 - .../commcoach/interfaceFeatureCommcoach.py | 2 +- modules/features/commcoach/mainCommcoach.py | 70 + .../commcoach/routeFeatureCommcoach.py | 13 +- .../features/commcoach/serviceCommcoach.py | 11 +- .../features/commcoach/serviceCommcoachAi.py | 2 +- .../serviceCommcoachContextRetrieval.py | 2 +- .../commcoach/serviceCommcoachExport.py | 13 +- .../commcoach/serviceCommcoachScheduler.py | 2 +- .../graphicalEditor/adapterValidator.py | 6 +- .../datamodelFeatureGraphicalEditor.py | 596 +- .../interfaceFeatureGraphicalEditor.py | 12 +- .../graphicalEditor/mainGraphicalEditor.py | 268 +- modules/features/graphicalEditor/portTypes.py | 551 +- .../routeFeatureGraphicalEditor.py | 16 +- .../datamodelFeatureNeutralizer.py | 59 +- .../interfaceFeatureNeutralizer.py | 2 +- .../neutralization/mainNeutralization.py | 48 + .../neutralization/neutralizePlayground.py | 2 +- .../mainServiceNeutralization.py | 16 +- .../serviceNeutralization/subProcessList.py | 6 +- .../realEstate/bzoDocumentRetriever.py | 3 +- modules/features/realEstate/bzoExtraction.py | 3 +- .../realEstate/interfaceFeatureRealEstate.py | 7 +- modules/features/realEstate/mainRealEstate.py | 55 +- .../realEstate/routeFeatureRealEstate.py | 14 +- .../redmine/interfaceFeatureRedmine.py | 2 +- modules/features/redmine/mainRedmine.py | 48 + .../features/redmine/routeFeatureRedmine.py | 2 +- modules/features/redmine/serviceRedmine.py | 2 +- .../features/redmine/serviceRedmineStats.py | 64 +- .../features/redmine/serviceRedmineSync.py | 2 +- .../teamsbot/interfaceFeatureTeamsbot.py | 2 +- modules/features/teamsbot/mainTeamsbot.py | 71 +- .../features/teamsbot/routeFeatureTeamsbot.py | 2 +- modules/features/teamsbot/service.py | 2 - .../trustee/accounting/accountingBridge.py | 15 +- .../trustee/accounting/accountingDataSync.py | 16 +- .../trustee/interfaceFeatureTrustee.py | 8 +- modules/features/trustee/mainTrustee.py | 56 + .../features/trustee/routeFeatureTrustee.py | 63 +- .../workspace/interfaceFeatureWorkspace.py | 2 +- modules/features/workspace/mainWorkspace.py | 48 + .../workspace/routeFeatureWorkspace.py | 39 +- .../interfaces/_legacyMigrationTelemetry.py | 2 +- modules/interfaces/interfaceAiObjects.py | 7 +- modules/interfaces/interfaceBootstrap.py | 246 +- modules/interfaces/interfaceDbApp.py | 92 +- modules/interfaces/interfaceDbBilling.py | 17 +- modules/interfaces/interfaceDbChat.py | 6 +- modules/interfaces/interfaceDbKnowledge.py | 10 +- modules/interfaces/interfaceDbManagement.py | 105 +- modules/interfaces/interfaceDbSubscription.py | 8 +- modules/interfaces/interfaceFeatures.py | 55 +- modules/interfaces/interfaceRbac.py | 20 +- modules/interfaces/interfaceTableHelpers.py | 330 + modules/migration/__init__.py | 1 - .../migration/seedData/ui_language_seed.json | 13554 ---------------- modules/migrations/_archive/README.md | 11 - modules/migrations/_archive/__init__.py | 1 - .../_archive/migrate_folders_to_groups.py | 261 - modules/routes/routeAdminDatabaseHealth.py | 22 +- modules/routes/routeAdminFeatures.py | 11 +- modules/routes/routeAdminRbacRules.py | 9 +- modules/routes/routeAttributes.py | 4 +- modules/routes/routeAudit.py | 18 +- modules/routes/routeAutomationWorkspace.py | 12 +- modules/routes/routeBilling.py | 46 +- modules/routes/routeDataConnections.py | 25 +- modules/routes/routeDataFiles.py | 23 +- modules/routes/routeDataMandates.py | 10 +- modules/routes/routeDataPrompts.py | 13 +- modules/routes/routeDataSources.py | 16 +- modules/routes/routeDataUsers.py | 28 +- modules/routes/routeGdpr.py | 6 +- modules/routes/routeHelpers.py | 1024 -- modules/routes/routeI18n.py | 4 +- modules/routes/routeInvitations.py | 9 +- modules/routes/routeMfa.py | 4 +- modules/routes/routeRagInventory.py | 2 +- modules/routes/routeRealEstate.py | 1 - modules/routes/routeSecurityGoogle.py | 16 +- modules/routes/routeSecurityInfomaniak.py | 2 +- modules/routes/routeSecurityLocal.py | 26 +- modules/routes/routeSecurityMsft.py | 16 +- modules/routes/routeStore.py | 4 +- modules/routes/routeSubscription.py | 13 +- modules/routes/routeSystem.py | 2 +- modules/routes/routeTableViews.py | 2 +- modules/routes/routeUdb.py | 6 +- modules/routes/routeWorkflowDashboard.py | 55 +- .../serviceAgent/actionToolAdapter.py | 6 +- .../coreTools/_connectionTools.py | 6 +- .../coreTools/_crossWorkflowTools.py | 9 +- .../coreTools/_dataSourceTools.py | 8 +- .../serviceAgent/coreTools/_documentTools.py | 16 +- .../serviceAgent/coreTools/_emailTools.py | 2 +- .../coreTools/_featureSubAgentTools.py | 4 +- .../serviceAgent/coreTools/_mediaTools.py | 56 +- .../serviceAgent/coreTools/_workspaceTools.py | 17 +- .../serviceAgent/coreTools/registerCore.py | 32 +- .../services/serviceAgent/mainServiceAgent.py | 6 +- .../services/serviceAgent/sandboxExecutor.py | 14 +- .../services/serviceAgent/workflowTools.py | 5 +- .../services/serviceAi/mainServiceAi.py | 4 +- .../services/serviceAi/subAiCallLooping.py | 10 +- .../serviceAi/subContentExtraction.py | 2 +- .../services/serviceAi/subDocumentIntents.py | 2 +- .../services/serviceAi/subLoopingUseCases.py | 8 +- .../services/serviceAi/subStructureFilling.py | 12 +- .../serviceAi/subStructureGeneration.py | 4 +- .../mainBackgroundJobService.py | 2 +- .../serviceBilling/billingExhaustedNotify.py | 2 +- .../serviceBilling/mainServiceBilling.py | 13 +- .../services/serviceChat/mainServiceChat.py | 2 +- .../serviceClickup/mainServiceClickup.py | 127 +- .../extractors/extractorContainer.py | 2 +- .../extractors/extractorEmail.py | 3 +- .../mainServiceExtraction.py | 3 +- .../services/serviceExtraction/subRegistry.py | 7 +- .../mainServiceGeneration.py | 14 +- .../serviceGeneration/paths/codePath.py | 4 +- .../serviceGeneration/paths/documentPath.py | 2 +- .../renderers/documentRendererBaseTemplate.py | 3 +- .../serviceGeneration/renderers/registry.py | 2 +- .../renderers/rendererCsv.py | 2 +- .../renderers/rendererDocx.py | 6 +- .../renderers/rendererHtml.py | 6 +- .../renderers/rendererImage.py | 2 +- .../renderers/rendererMarkdown.py | 4 +- .../renderers/rendererPdf.py | 24 +- .../renderers/rendererPptx.py | 3 - .../renderers/rendererXlsx.py | 2 - .../serviceGeneration/subContentGenerator.py | 2 +- .../serviceGeneration/subDocumentUtility.py | 15 +- .../services/serviceKnowledge/_buildTree.py | 16 +- .../serviceKnowledge/_inheritFlags.py | 18 +- .../{_costEstimate.py => costEstimate.py} | 0 .../serviceKnowledge/mainServiceKnowledge.py | 5 +- .../{_ragLimits.py => ragLimits.py} | 0 .../subConnectorSyncClickup.py | 8 +- .../subConnectorSyncGdrive.py | 8 +- .../serviceKnowledge/subConnectorSyncGmail.py | 4 +- .../subConnectorSyncKdrive.py | 8 +- .../subConnectorSyncOutlook.py | 10 +- .../subConnectorSyncSharepoint.py | 8 +- .../serviceKnowledge/subFeatureBootstrap.py | 2 +- .../services/serviceKnowledge/udbNodes.py | 32 +- .../mainServiceSharepoint.py | 2 +- .../mainServiceSubscription.py | 127 +- .../services/serviceWeb/mainServiceWeb.py | 1 - modules/shared/__init__.py | 3 - modules/shared/configuration.py | 31 +- .../httpResilience.py} | 0 modules/shared/i18nRegistry.py | 584 +- modules/shared/jsonContinuation-logic.md | 164 - modules/shared/jsonUtils.py | 24 +- modules/shared/serviceExceptions.py | 146 + modules/shared/workflowState.py | 47 + modules/system/databaseHealth.py | 47 +- modules/system/databaseMigration.py | 33 +- modules/{shared => system}/gdprDeletion.py | 0 modules/system/i18nBootSync.py | 563 + .../{shared => system}/notifyMandateAdmins.py | 0 .../workflows/automation2/executionEngine.py | 9 +- .../executors/actionNodeExecutor.py | 11 +- .../automation2/executors/flowExecutor.py | 2 +- modules/workflows/automation2/graphUtils.py | 11 +- .../methods/_actionSignatureValidator.py | 8 +- .../methods/methodAi/actions/consolidate.py | 3 +- .../methods/methodAi/actions/generateCode.py | 5 +- .../methodAi/actions/generateDocument.py | 5 +- .../methods/methodAi/actions/process.py | 3 +- .../methods/methodAi/actions/webResearch.py | 3 +- modules/workflows/methods/methodBase.py | 7 +- .../methodContext/actions/extractContent.py | 22 +- .../methodContext/actions/neutralizeData.py | 8 +- .../methods/methodFile/actions/create.py | 4 +- .../methodTrustee/actions/extractFromFiles.py | 12 +- .../methodTrustee/actions/processDocuments.py | 4 +- .../methodTrustee/actions/queryData.py | 6 +- .../actions/refreshAccountingData.py | 8 +- .../processing/adaptive/contentValidator.py | 2 +- .../processing/core/actionExecutor.py | 2 +- .../processing/core/messageCreator.py | 2 +- .../workflows/processing/shared/stateTools.py | 44 +- .../workflows/processing/workflowProcessor.py | 7 +- modules/workflows/scheduler/mainScheduler.py | 5 +- scripts/build_ui_language_seed_json.py | 100 - scripts/exportDbSchemaFromModels.py | 8 +- tests/functional/test12_json_split_merge.py | 2 +- .../functional/test13_json_completion_cuts.py | 2 +- .../test14_json_continuation_context.py | 2 +- tests/integration/rbac/test_rbac_database.py | 17 +- .../services/test_featureDataAgent_schema.py | 2 +- tests/unit/services/test_queryValidator.py | 2 +- tests/unit/services/test_trusteeOntology.py | 2 +- 238 files changed, 4940 insertions(+), 18886 deletions(-) rename modules/connectors/{providerClickup/connectorClickup.py => connectorProviderClickup.py} (68%) rename modules/connectors/{providerFtp/connectorFtp.py => connectorProviderFtp.py} (100%) rename modules/connectors/{providerGoogle/connectorGoogle.py => connectorProviderGoogle.py} (96%) rename modules/connectors/{providerInfomaniak/connectorInfomaniak.py => connectorProviderInfomaniak.py} (99%) rename modules/connectors/{providerMsft/connectorMsft.py => connectorProviderMsft.py} (98%) delete mode 100644 modules/connectors/providerClickup/__init__.py delete mode 100644 modules/connectors/providerFtp/__init__.py delete mode 100644 modules/connectors/providerGoogle/__init__.py delete mode 100644 modules/connectors/providerInfomaniak/__init__.py delete mode 100644 modules/connectors/providerMsft/__init__.py delete mode 100644 modules/datamodels/datamodelFeatureDataSource.py create mode 100644 modules/datamodels/datamodelPortTypes.py create mode 100644 modules/datamodels/datamodelWorkflowAutomation.py rename modules/{shared => datamodels}/jsonContinuation.py (99%) rename modules/{migrations => dbHelpers}/__init__.py (100%) rename modules/{shared => dbHelpers}/aiAuditLogger.py (99%) rename modules/{shared => dbHelpers}/auditLogger.py (99%) rename modules/{shared => dbHelpers}/dbMultiTenantOptimizations.py (99%) rename modules/{shared => dbHelpers}/dbRegistry.py (97%) create mode 100644 modules/dbHelpers/fkLabelResolver.py rename modules/{shared => dbHelpers}/fkRegistry.py (97%) create mode 100644 modules/dbHelpers/paginationHelpers.py rename modules/demoConfigs/{_baseDemoConfig.py => baseDemoConfig.py} (94%) delete mode 100644 modules/features/commcoach/CONCEPT.md create mode 100644 modules/interfaces/interfaceTableHelpers.py delete mode 100644 modules/migration/__init__.py delete mode 100644 modules/migration/seedData/ui_language_seed.json delete mode 100644 modules/migrations/_archive/README.md delete mode 100644 modules/migrations/_archive/__init__.py delete mode 100644 modules/migrations/_archive/migrate_folders_to_groups.py delete mode 100644 modules/routes/routeHelpers.py rename modules/serviceCenter/services/serviceKnowledge/{_costEstimate.py => costEstimate.py} (100%) rename modules/serviceCenter/services/serviceKnowledge/{_ragLimits.py => ragLimits.py} (100%) rename modules/{connectors/_httpResilience.py => shared/httpResilience.py} (100%) delete mode 100644 modules/shared/jsonContinuation-logic.md create mode 100644 modules/shared/serviceExceptions.py create mode 100644 modules/shared/workflowState.py rename modules/{shared => system}/gdprDeletion.py (100%) create mode 100644 modules/system/i18nBootSync.py rename modules/{shared => system}/notifyMandateAdmins.py (100%) delete mode 100644 scripts/build_ui_language_seed_json.py diff --git a/app.py b/app.py index 11068505..f43035c1 100644 --- a/app.py +++ b/app.py @@ -302,7 +302,7 @@ async def lifespan(app: FastAPI): logger.info("Application is starting up") # Validate FK metadata on all Pydantic models (fail-fast, no silent fallbacks) - from modules.shared.fkRegistry import validateFkTargets + from modules.dbHelpers.fkRegistry import validateFkTargets fkErrors = validateFkTargets() if fkErrors: for err in fkErrors: @@ -342,7 +342,7 @@ async def lifespan(app: FastAPI): # Sync gateway i18n registry to DB and load translation cache try: - from modules.shared.i18nRegistry import syncRegistryToDb, loadCache + from modules.system.i18nBootSync import syncRegistryToDb, loadCache await syncRegistryToDb() await loadCache() logger.info("i18n registry sync + cache load completed") @@ -376,6 +376,34 @@ async def lifespan(app: FastAPI): except Exception as e: logger.warning(f"Could not initialize feature containers: {e}") + # Bootstrap Stripe prices for paid plans (composition root — upward import allowed here) + try: + from modules.serviceCenter.services.serviceSubscription.stripeBootstrap import bootstrapStripePrices + bootstrapStripePrices() + except Exception as e: + logger.error(f"Stripe price bootstrap failed: {e}") + + # Bootstrap MIME map into ComponentObjects (composition root — upward import allowed here) + try: + from modules.serviceCenter.services.serviceExtraction.subRegistry import ExtractorRegistry + from modules.interfaces.interfaceDbManagement import ComponentObjects + _mimeRegistry = ExtractorRegistry() + _extensionToMime = _mimeRegistry.getExtensionToMimeMap() + _textMimes: set = set() + _seen: set = set() + for _ext in _mimeRegistry._map.values(): + _eid = id(_ext) + if _eid in _seen: + continue + _seen.add(_eid) + _mimes = _ext.getSupportedMimeTypes() + if any(m.startswith("text/") for m in _mimes): + _textMimes.update(_mimes) + _textMimes.update({"application/json", "application/xml", "application/javascript", "application/sql", "application/x-yaml", "application/x-toml"}) + ComponentObjects.setMimeMap(_extensionToMime, _textMimes) + except Exception as e: + logger.warning(f"MIME map bootstrap failed: {e}") + # --- Init Managers --- import asyncio try: @@ -400,7 +428,7 @@ async def lifespan(app: FastAPI): eventManager.start() # Register audit log cleanup scheduler - from modules.shared.auditLogger import registerAuditLogCleanupScheduler + from modules.dbHelpers.auditLogger import registerAuditLogCleanupScheduler registerAuditLogCleanupScheduler() # Register enterprise subscription auto-renewal scheduler @@ -431,6 +459,26 @@ async def lifespan(app: FastAPI): except Exception as e: logger.warning(f"KnowledgeIngestionConsumer registration failed (non-critical): {e}") + # Install force-exit handler AFTER uvicorn has registered its own SIGINT + # handler. Uvicorn's default timeout-graceful-shutdown is None (wait + # forever), so frontend polling keep-alive connections block the process. + # This wraps uvicorn's handler: on Ctrl+C, start a 3s timer that calls + # os._exit() if the graceful shutdown hasn't completed by then. + import signal as _sig + import threading as _thr + _prevSigint = _sig.getsignal(_sig.SIGINT) + + def _onSigint(signum, frame): + _t = _thr.Timer(3.0, lambda: os._exit(0)) + _t.daemon = True + _t.start() + if callable(_prevSigint) and _prevSigint not in (_sig.SIG_DFL, _sig.SIG_IGN): + _prevSigint(signum, frame) + else: + raise KeyboardInterrupt + + _sig.signal(_sig.SIGINT, _onSigint) + yield # --- Shutdown sequence (protected against CancelledError) --- @@ -474,7 +522,7 @@ async def lifespan(app: FastAPI): # 5. Close shared HTTP sessions (ResilientHttp) to avoid TCP keepalive hang try: - from modules.connectors._httpResilience import closeAllResilientHttp + from modules.shared.httpResilience import closeAllResilientHttp await closeAllResilientHttp() except Exception as e: logger.warning(f"Closing HTTP sessions failed: {e}") @@ -655,8 +703,6 @@ app.include_router(connectionsRouter) from modules.routes.routeRagInventory import router as ragInventoryRouter app.include_router(ragInventoryRouter) - - from modules.routes.routeTableViews import router as tableViewsRouter app.include_router(tableViewsRouter) diff --git a/modules/aicore/aicoreBase.py b/modules/aicore/aicoreBase.py index e107beb3..0908c40d 100644 --- a/modules/aicore/aicoreBase.py +++ b/modules/aicore/aicoreBase.py @@ -11,15 +11,15 @@ IMPORTANT: Model Registration Requirements - If duplicate displayNames are detected during registration, an error will be raised """ -import re as _re +import re from abc import ABC, abstractmethod from typing import List, Dict, Any, Optional, AsyncGenerator, Union from modules.datamodels.datamodelAi import AiModel, AiModelCall, AiModelResponse -_RETRY_AFTER_PATTERN = _re.compile( - r"(?:try again in|retry after)\s+(\d+(?:\.\d+)?)\s*s", _re.IGNORECASE +_RETRY_AFTER_PATTERN = re.compile( + r"(?:try again in|retry after)\s+(\d+(?:\.\d+)?)\s*s", re.IGNORECASE ) diff --git a/modules/aicore/aicorePluginAnthropic.py b/modules/aicore/aicorePluginAnthropic.py index ce6349f0..4e873511 100644 --- a/modules/aicore/aicorePluginAnthropic.py +++ b/modules/aicore/aicorePluginAnthropic.py @@ -1,5 +1,6 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. +import base64 import json import logging import httpx @@ -655,9 +656,8 @@ class AiAnthropic(BaseConnectorAi): base64Data = parts[1] _SUPPORTED = {"image/jpeg", "image/png", "image/gif", "image/webp"} - import base64 as _b64 try: - rawHead = _b64.b64decode(base64Data[:32]) + rawHead = base64.b64decode(base64Data[:32]) if rawHead[:3] == b"\xff\xd8\xff": mimeType = "image/jpeg" elif rawHead[:8] == b"\x89PNG\r\n\x1a\n": diff --git a/modules/aicore/aicorePluginMistral.py b/modules/aicore/aicorePluginMistral.py index d2ad0694..a9805195 100644 --- a/modules/aicore/aicorePluginMistral.py +++ b/modules/aicore/aicorePluginMistral.py @@ -1,7 +1,7 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. import logging -import json as _json +import json import httpx from typing import List, Dict, Any, AsyncGenerator, Union from fastapi import HTTPException @@ -274,7 +274,7 @@ class AiMistral(BaseConnectorAi): bodyStr = body.decode() if response.status_code == 429: try: - errorMsg = _json.loads(bodyStr).get("error", {}).get("message", "Rate limit exceeded") + errorMsg = json.loads(bodyStr).get("error", {}).get("message", "Rate limit exceeded") except (ValueError, KeyError): errorMsg = f"Rate limit exceeded for {model.name}" raise RateLimitExceededException(f"Rate limit exceeded for {model.name}: {errorMsg}") @@ -287,8 +287,8 @@ class AiMistral(BaseConnectorAi): if data.strip() == "[DONE]": break try: - chunk = _json.loads(data) - except _json.JSONDecodeError: + chunk = json.loads(data) + except json.JSONDecodeError: continue delta = chunk.get("choices", [{}])[0].get("delta", {}) diff --git a/modules/aicore/aicorePluginOpenai.py b/modules/aicore/aicorePluginOpenai.py index bfea82f7..78f8ba26 100644 --- a/modules/aicore/aicorePluginOpenai.py +++ b/modules/aicore/aicorePluginOpenai.py @@ -1,7 +1,7 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. import logging -import json as _json +import json import httpx from typing import List, Dict, Any, AsyncGenerator, Union from fastapi import HTTPException @@ -477,7 +477,7 @@ class AiOpenai(BaseConnectorAi): bodyStr = body.decode() if response.status_code == 429: try: - errorMsg = _json.loads(bodyStr).get("error", {}).get("message", "Rate limit exceeded") + errorMsg = json.loads(bodyStr).get("error", {}).get("message", "Rate limit exceeded") except (ValueError, KeyError): errorMsg = f"Rate limit exceeded for {model.name}" raise RateLimitExceededException(f"Rate limit exceeded for {model.name}: {errorMsg}") @@ -490,8 +490,8 @@ class AiOpenai(BaseConnectorAi): if data.strip() == "[DONE]": break try: - chunk = _json.loads(data) - except _json.JSONDecodeError: + chunk = json.loads(data) + except json.JSONDecodeError: continue delta = chunk.get("choices", [{}])[0].get("delta", {}) diff --git a/modules/auth/authentication.py b/modules/auth/authentication.py index 27cf1a31..d641d659 100644 --- a/modules/auth/authentication.py +++ b/modules/auth/authentication.py @@ -437,7 +437,7 @@ def requireSysAdmin(currentUser: User = Depends(getCurrentUser)) -> User: # Audit for all SysAdmin actions try: - from modules.shared.auditLogger import audit_logger + from modules.dbHelpers.auditLogger import audit_logger audit_logger.logSecurityEvent( userId=str(currentUser.id), mandateId="system", @@ -483,7 +483,7 @@ def requirePlatformAdmin(currentUser: User = Depends(getCurrentUser)) -> User: # Audit for all Platform-Admin actions try: - from modules.shared.auditLogger import audit_logger + from modules.dbHelpers.auditLogger import audit_logger audit_logger.logSecurityEvent( userId=str(currentUser.id), mandateId="system", diff --git a/modules/auth/mfaService.py b/modules/auth/mfaService.py index 7ecd7889..3987eab9 100644 --- a/modules/auth/mfaService.py +++ b/modules/auth/mfaService.py @@ -27,7 +27,7 @@ _MFA_INTERVAL = 30 _MFA_VALID_WINDOW = 1 -def _getMfaIssuer() -> str: +def getMfaIssuer() -> str: """Build the TOTP issuer name, e.g. 'PowerOn' or 'PowerOn (Dev)'.""" envType = (APP_CONFIG.get("APP_ENV_TYPE") or "").strip().lower() if envType in ("prod", ""): @@ -44,11 +44,11 @@ def _encryptSecret(plainSecret: str, userId: str = "system") -> str: return encryptValue(plainSecret, userId=userId, keyName="mfa_secret") -def _decryptSecret(encryptedSecret: str, userId: str = "system") -> str: +def decryptSecret(encryptedSecret: str, userId: str = "system") -> str: return decryptValue(encryptedSecret, userId=userId, keyName="mfa_secret") -def _buildTotp(plainSecret: str) -> pyotp.TOTP: +def buildTotp(plainSecret: str) -> pyotp.TOTP: return pyotp.TOTP(plainSecret, digits=_MFA_DIGITS, interval=_MFA_INTERVAL) @@ -61,8 +61,8 @@ def generateSetup(userId: str, username: str) -> dict: """ plain = _generateSecret() encrypted = _encryptSecret(plain, userId=userId) - totp = _buildTotp(plain) - uri = totp.provisioning_uri(name=username, issuer_name=_getMfaIssuer()) + totp = buildTotp(plain) + uri = totp.provisioning_uri(name=username, issuer_name=getMfaIssuer()) return { "encryptedSecret": encrypted, "provisioningUri": uri, @@ -72,8 +72,8 @@ def generateSetup(userId: str, username: str) -> dict: def confirmSetup(encryptedSecret: str, code: str, userId: str = "system") -> bool: """Verify a TOTP code against an encrypted secret (enrolment confirmation).""" try: - plain = _decryptSecret(encryptedSecret, userId=userId) - totp = _buildTotp(plain) + plain = decryptSecret(encryptedSecret, userId=userId) + totp = buildTotp(plain) return totp.verify(code, valid_window=_MFA_VALID_WINDOW) except Exception: logger.exception("MFA confirmSetup failed for userId=%s", userId) @@ -83,8 +83,8 @@ def confirmSetup(encryptedSecret: str, code: str, userId: str = "system") -> boo def verifyCode(encryptedSecret: str, code: str, userId: str = "system") -> bool: """Verify a TOTP code during login.""" try: - plain = _decryptSecret(encryptedSecret, userId=userId) - totp = _buildTotp(plain) + plain = decryptSecret(encryptedSecret, userId=userId) + totp = buildTotp(plain) return totp.verify(code, valid_window=_MFA_VALID_WINDOW) except Exception: logger.exception("MFA verifyCode failed for userId=%s", userId) diff --git a/modules/auth/tokenRefreshService.py b/modules/auth/tokenRefreshService.py index 5f243b3f..bc471e6f 100644 --- a/modules/auth/tokenRefreshService.py +++ b/modules/auth/tokenRefreshService.py @@ -12,7 +12,7 @@ import logging from typing import Dict, Any from modules.datamodels.datamodelUam import UserConnection, AuthAuthority from modules.shared.timeUtils import getUtcTimestamp -from modules.shared.auditLogger import audit_logger +from modules.dbHelpers.auditLogger import audit_logger logger = logging.getLogger(__name__) diff --git a/modules/connectors/connectorDbPostgre.py b/modules/connectors/connectorDbPostgre.py index 1f37e24a..493a3862 100644 --- a/modules/connectors/connectorDbPostgre.py +++ b/modules/connectors/connectorDbPostgre.py @@ -1,6 +1,9 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. import contextvars +import copy +import json +import math import re import time import psycopg2 @@ -8,6 +11,7 @@ import psycopg2.extras import psycopg2.pool import logging from contextlib import contextmanager +from datetime import datetime, timezone from typing import List, Dict, Any, Optional, Union, get_origin, get_args, Type import uuid from pydantic import BaseModel, Field @@ -16,8 +20,6 @@ import threading from modules.shared.timeUtils import getUtcTimestamp from modules.shared.configuration import APP_CONFIG from modules.datamodels.datamodelBase import PowerOnModel -from modules.datamodels.datamodelUam import User, AccessLevel, UserPermissions -from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext logger = logging.getLogger(__name__) @@ -149,7 +151,6 @@ def getModelFields(model_class) -> Dict[str, str]: def parseRecordFields(record: Dict[str, Any], fields: Dict[str, str], context: str = "") -> None: """Parse record fields in-place: numeric typing, vector parsing, JSONB deserialization.""" - import json as _json for fieldName, fieldType in fields.items(): if fieldName not in record: @@ -177,10 +178,10 @@ def parseRecordFields(record: Dict[str, Any], fields: Dict[str, str], context: s elif fieldType == "JSONB" and value is not None: try: if isinstance(value, str): - record[fieldName] = _json.loads(value) + record[fieldName] = json.loads(value) elif not isinstance(value, (dict, list)): - record[fieldName] = _json.loads(str(value)) - except (_json.JSONDecodeError, TypeError, ValueError): + record[fieldName] = json.loads(str(value)) + except (json.JSONDecodeError, TypeError, ValueError): logger.warning(f"Could not parse JSONB field {fieldName}, keeping as string ({context})") @@ -995,8 +996,6 @@ class DatabaseConnector: # Handle JSONB fields - ensure proper JSON format for PostgreSQL elif col in fields and fields[col] == "JSONB" and value is not None: - import json - if isinstance(value, (dict, list)): value = json.dumps(value) elif isinstance(value, str): @@ -1173,25 +1172,6 @@ class DatabaseConnector: logger.error(f"Error removing initial ID for table {table}: {e}") return False - def buildRbacWhereClause( - self, - permissions: UserPermissions, - currentUser: User, - table: str, - mandateId: Optional[str] = None, - featureInstanceId: Optional[str] = None, - ) -> Optional[Dict[str, Any]]: - """Delegate to interfaceRbac.buildRbacWhereClause (tests and call sites use connector as entry).""" - from modules.interfaces.interfaceRbac import buildRbacWhereClause as _buildRbacWhereClause - - return _buildRbacWhereClause( - permissions, - currentUser, - table, - self, - mandateId=mandateId, - featureInstanceId=featureInstanceId, - ) def updateContext(self, userId: str) -> None: """Updates the context of the database connector. @@ -1412,18 +1392,17 @@ class DatabaseConnector: isDateVal = bool(fromVal and re.match(r'^\d{4}-\d{2}-\d{2}$', str(fromVal))) or \ bool(toVal and re.match(r'^\d{4}-\d{2}-\d{2}$', str(toVal))) if isNumericCol and isDateVal: - from datetime import datetime as _dt, timezone as _tz if fromVal and toVal: - fromTs = _dt.strptime(str(fromVal), '%Y-%m-%d').replace(tzinfo=_tz.utc).timestamp() - toTs = _dt.strptime(str(toVal), '%Y-%m-%d').replace(hour=23, minute=59, second=59, tzinfo=_tz.utc).timestamp() + fromTs = datetime.strptime(str(fromVal), '%Y-%m-%d').replace(tzinfo=timezone.utc).timestamp() + toTs = datetime.strptime(str(toVal), '%Y-%m-%d').replace(hour=23, minute=59, second=59, tzinfo=timezone.utc).timestamp() where_parts.append(f'"{key}" >= %s AND "{key}" <= %s') values.extend([fromTs, toTs]) elif fromVal: - fromTs = _dt.strptime(str(fromVal), '%Y-%m-%d').replace(tzinfo=_tz.utc).timestamp() + fromTs = datetime.strptime(str(fromVal), '%Y-%m-%d').replace(tzinfo=timezone.utc).timestamp() where_parts.append(f'"{key}" >= %s') values.append(fromTs) else: - toTs = _dt.strptime(str(toVal), '%Y-%m-%d').replace(hour=23, minute=59, second=59, tzinfo=_tz.utc).timestamp() + toTs = datetime.strptime(str(toVal), '%Y-%m-%d').replace(hour=23, minute=59, second=59, tzinfo=timezone.utc).timestamp() where_parts.append(f'"{key}" <= %s') values.append(toTs) elif isNumericCol: @@ -1498,7 +1477,6 @@ class DatabaseConnector: If pagination is None, returns all records (no LIMIT/OFFSET). """ from modules.datamodels.datamodelPagination import PaginationParams - import math table = model_class.__name__ @@ -1540,9 +1518,6 @@ class DatabaseConnector: if fieldFilter and isinstance(fieldFilter, list): records = [{f: r[f] for f in fieldFilter if f in r} for r in records] - from modules.routes.routeHelpers import enrichRowsWithFkLabels - enrichRowsWithFkLabels(records, model_class) - pageSize = pagination.pageSize if pagination else max(totalItems, 1) totalPages = math.ceil(totalItems / pageSize) if totalItems > 0 else 0 @@ -1578,7 +1553,6 @@ class DatabaseConnector: return [] if pagination: - import copy pagination = copy.deepcopy(pagination) if pagination.filters and column in pagination.filters: pagination.filters.pop(column, None) @@ -1812,7 +1786,6 @@ class DatabaseConnector: single inserts produce identical on-disk values (timestamps as floats, enums as strings, vectors as pgvector text, JSONB as JSON strings). """ - import json as _json out = [] for col in columns: value = record.get(col) @@ -1829,16 +1802,16 @@ class DatabaseConnector: value = f"[{','.join(str(v) for v in value)}]" elif col in fields and fields[col] == "JSONB" and value is not None: if isinstance(value, (dict, list)): - value = _json.dumps(value) + value = json.dumps(value) elif isinstance(value, str): try: - _json.loads(value) + json.loads(value) except (ValueError, TypeError): - value = _json.dumps(value) + value = json.dumps(value) elif hasattr(value, "model_dump"): - value = _json.dumps(value.model_dump()) + value = json.dumps(value.model_dump()) else: - value = _json.dumps(value) + value = json.dumps(value) out.append(value) return tuple(out) diff --git a/modules/connectors/providerClickup/connectorClickup.py b/modules/connectors/connectorProviderClickup.py similarity index 68% rename from modules/connectors/providerClickup/connectorClickup.py rename to modules/connectors/connectorProviderClickup.py index 10517db2..2a2f2ba1 100644 --- a/modules/connectors/providerClickup/connectorClickup.py +++ b/modules/connectors/connectorProviderClickup.py @@ -13,10 +13,13 @@ Path convention (leading slash, no trailing slash except root): from __future__ import annotations +import asyncio import json import logging import re -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Union + +import aiohttp from modules.connectors.connectorProviderBase import ( ProviderConnector, @@ -24,11 +27,11 @@ from modules.connectors.connectorProviderBase import ( DownloadResult, ) from modules.datamodels.datamodelDataSource import ExternalEntry -from modules.serviceCenter.services.serviceClickup.mainServiceClickup import ClickupService logger = logging.getLogger(__name__) -# type metadata for ExternalEntry.metadata["cuType"] +_CLICKUP_API_BASE = "https://api.clickup.com/api/v2" + _CU_TEAM = "team" _CU_SPACE = "space" _CU_FOLDER = "folder" @@ -45,14 +48,118 @@ def _norm(path: str) -> str: return p +def clickupAuthorizationHeader(token: str) -> str: + """ClickUp: personal tokens are `pk_...` without Bearer; OAuth uses Bearer.""" + t = (token or "").strip() + if t.startswith("pk_"): + return t + return f"Bearer {t}" + + +class ClickupApiClient: + """Low-level ClickUp REST API v2 client. Pure HTTP — no service dependencies.""" + + def __init__(self, accessToken: str): + self.accessToken = accessToken + + async def _request( + self, + method: str, + path: str, + *, + params: Optional[Dict[str, Any]] = None, + json_body: Optional[Dict[str, Any]] = None, + data: Optional[aiohttp.FormData] = None, + ) -> Union[Dict[str, Any], List[Any], bytes, None]: + if not self.accessToken: + return {"error": "Access token is not set."} + url = f"{_CLICKUP_API_BASE}/{path.lstrip('/')}" + headers: Dict[str, str] = { + "Authorization": clickupAuthorizationHeader(self.accessToken), + } + if json_body is not None: + headers["Content-Type"] = "application/json" + + timeout = aiohttp.ClientTimeout(total=60) + try: + async with aiohttp.ClientSession(timeout=timeout) as session: + kwargs: Dict[str, Any] = {"headers": headers, "params": params} + if json_body is not None: + kwargs["json"] = json_body + if data is not None: + kwargs["data"] = data + async with session.request(method.upper(), url, **kwargs) as resp: + if resp.status == 204: + return {} + text = await resp.text() + if resp.status >= 400: + log = logger.warning if resp.status == 404 else logger.error + log(f"ClickUp API {method} {url} -> {resp.status}: {text[:500]}") + return {"error": f"HTTP {resp.status}", "body": text} + if not text: + return {} + try: + return json.loads(text) + except Exception: + return {"raw": text} + except asyncio.TimeoutError: + return {"error": f"ClickUp API timeout: {path}"} + except Exception as e: + logger.error(f"ClickUp API error: {e}") + return {"error": str(e)} + + async def getAuthorizedTeams(self) -> Dict[str, Any]: + return await self._request("GET", "/team") + + async def getSpaces(self, teamId: str) -> Dict[str, Any]: + return await self._request("GET", f"/team/{teamId}/space") + + async def getFolders(self, spaceId: str) -> Dict[str, Any]: + return await self._request("GET", f"/space/{spaceId}/folder") + + async def getFolderlessLists(self, spaceId: str) -> Dict[str, Any]: + return await self._request("GET", f"/space/{spaceId}/list") + + async def getListsInFolder(self, folderId: str) -> Dict[str, Any]: + return await self._request("GET", f"/folder/{folderId}/list") + + async def getTasksInList(self, listId: str, *, page: int = 0) -> Dict[str, Any]: + params: Dict[str, Any] = {"page": page, "subtasks": "true", "include_closed": "false"} + return await self._request("GET", f"/list/{listId}/task", params=params) + + async def getTask(self, taskId: str) -> Dict[str, Any]: + params = {"include_subtasks": "true"} + return await self._request("GET", f"/task/{taskId}", params=params) + + async def searchTeamTasks(self, teamId: str, *, query: str, page: int = 0) -> Dict[str, Any]: + params = {"query": query, "page": page} + return await self._request("GET", f"/team/{teamId}/task", params=params) + + async def uploadTaskAttachment(self, taskId: str, fileBytes: bytes, fileName: str) -> Dict[str, Any]: + if not self.accessToken: + return {"error": "Access token is not set."} + url = f"{_CLICKUP_API_BASE}/task/{taskId}/attachment" + headers = {"Authorization": clickupAuthorizationHeader(self.accessToken)} + formData = aiohttp.FormData() + formData.add_field("attachment", fileBytes, filename=fileName, content_type="application/octet-stream") + timeout = aiohttp.ClientTimeout(total=120) + try: + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.post(url, headers=headers, data=formData) as resp: + text = await resp.text() + if resp.status >= 400: + return {"error": f"HTTP {resp.status}", "body": text} + return json.loads(text) if text else {} + except Exception as e: + return {"error": str(e)} + + class ClickupListsAdapter(ServiceAdapter): """Maps ClickUp hierarchy + list tasks to browse/download/upload/search.""" def __init__(self, access_token: str): self._token = access_token - # Minimal service instance for API calls (no ServiceCenter context) - self._svc = ClickupService(context=None, get_service=lambda _: None) - self._svc.setAccessToken(access_token) + self._svc = ClickupApiClient(access_token) async def browse( self, diff --git a/modules/connectors/providerFtp/connectorFtp.py b/modules/connectors/connectorProviderFtp.py similarity index 100% rename from modules/connectors/providerFtp/connectorFtp.py rename to modules/connectors/connectorProviderFtp.py diff --git a/modules/connectors/providerGoogle/connectorGoogle.py b/modules/connectors/connectorProviderGoogle.py similarity index 96% rename from modules/connectors/providerGoogle/connectorGoogle.py rename to modules/connectors/connectorProviderGoogle.py index a1f02a03..acce4935 100644 --- a/modules/connectors/providerGoogle/connectorGoogle.py +++ b/modules/connectors/connectorProviderGoogle.py @@ -3,14 +3,17 @@ """Google ProviderConnector -- Drive and Gmail via Google OAuth.""" import asyncio +import base64 import logging +import re import urllib.parse +from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional import aiohttp from modules.connectors.connectorProviderBase import ProviderConnector, ServiceAdapter, DownloadResult -from modules.connectors._httpResilience import ResilientHttp +from modules.shared.httpResilience import ResilientHttp from modules.datamodels.datamodelDataSource import ExternalEntry logger = logging.getLogger(__name__) @@ -29,8 +32,6 @@ def _parseGoogleDateRange(text: Optional[str]) -> tuple: Supports two ISO dates, a single ISO date (~31 day window) or a YYYY-MM month pattern. Returns RFC3339 UTC strings (timeMin, timeMax) or (None, None). """ - import re - from datetime import datetime, timedelta if not text: return (None, None) @@ -58,7 +59,7 @@ def _parseGoogleDateRange(text: Optional[str]) -> tuple: return (None, None) -async def _googleGet(token: str, url: str) -> Dict[str, Any]: +async def googleGet(token: str, url: str) -> Dict[str, Any]: headers = {"Authorization": f"Bearer {token}"} return await _http.getJson(url, headers=headers) @@ -92,7 +93,7 @@ class DriveAdapter(ServiceAdapter): pageSize = max(1, min(int(limit or 100), 1000)) url = f"{_DRIVE_BASE}/files?q={query}&fields={fields}&pageSize={pageSize}&orderBy=folder,name" - result = await _googleGet(self._token, url) + result = await googleGet(self._token, url) if "error" in result: _raiseGoogleError(result, "Google Drive browse") @@ -184,7 +185,7 @@ class DriveAdapter(ServiceAdapter): if pageToken: params["pageToken"] = pageToken url = f"{_DRIVE_BASE}/files?{urllib.parse.urlencode(params)}" - result = await _googleGet(self._token, url) + result = await googleGet(self._token, url) if "error" in result: if not entries: _raiseGoogleError(result, "Google Drive search") @@ -228,7 +229,7 @@ class GmailAdapter(ServiceAdapter): if not cleanPath: url = f"{_GMAIL_BASE}/users/me/labels" - result = await _googleGet(self._token, url) + result = await googleGet(self._token, url) if "error" in result: _raiseGoogleError(result, "Gmail labels") _SYSTEM_LABELS = {"INBOX", "SENT", "DRAFT", "TRASH", "SPAM", "STARRED", "IMPORTANT"} @@ -281,7 +282,7 @@ class GmailAdapter(ServiceAdapter): if not ref: return None r = ref.strip() - result = await _googleGet(self._token, f"{_GMAIL_BASE}/users/me/labels") + result = await googleGet(self._token, f"{_GMAIL_BASE}/users/me/labels") if "error" in result: _raiseGoogleError(result, "Gmail labels") labels = result.get("labels", []) @@ -319,7 +320,7 @@ class GmailAdapter(ServiceAdapter): if pageToken: p["pageToken"] = pageToken url = f"{_GMAIL_BASE}/users/me/messages?{urllib.parse.urlencode(p)}" - result = await _googleGet(self._token, url) + result = await googleGet(self._token, url) if "error" in result: if not msgIds: _raiseGoogleError(result, "Gmail list messages") @@ -350,7 +351,7 @@ class GmailAdapter(ServiceAdapter): f"{_GMAIL_BASE}/users/me/messages/{msgId}" f"?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=Date" ) - detail = await _googleGet(self._token, detailUrl) + detail = await googleGet(self._token, detailUrl) if "error" in detail: return ExternalEntry(name=f"Message {msgId}", path=f"{pathPrefix}/{msgId}", isFolder=False, metadata={"id": msgId}) @@ -371,15 +372,13 @@ class GmailAdapter(ServiceAdapter): async def download(self, path: str) -> DownloadResult: """Download a Gmail message as RFC 822 EML via format=raw.""" - import base64 - import re cleanPath = (path or "").strip("/") msgId = cleanPath.split("/")[-1] if cleanPath else "" if not msgId: return DownloadResult() url = f"{_GMAIL_BASE}/users/me/messages/{msgId}?format=raw" - result = await _googleGet(self._token, url) + result = await googleGet(self._token, url) if "error" in result: return DownloadResult() @@ -390,7 +389,7 @@ class GmailAdapter(ServiceAdapter): emlBytes = base64.urlsafe_b64decode(rawB64) metaUrl = f"{_GMAIL_BASE}/users/me/messages/{msgId}?format=metadata&metadataHeaders=Subject" - meta = await _googleGet(self._token, metaUrl) + meta = await googleGet(self._token, metaUrl) subject = msgId if "error" not in meta: for h in meta.get("payload", {}).get("headers", []): @@ -469,7 +468,7 @@ class CalendarAdapter(ServiceAdapter): cleanPath = (path or "").strip("/") if not cleanPath: url = f"{_CALENDAR_BASE}/users/me/calendarList?maxResults=250" - result = await _googleGet(self._token, url) + result = await googleGet(self._token, url) if "error" in result: _raiseGoogleError(result, "Google Calendar list") calendars = result.get("items", []) @@ -504,7 +503,7 @@ class CalendarAdapter(ServiceAdapter): timeMin, timeMax = _parseGoogleDateRange(filter) if timeMin and timeMax: url += f"&timeMin={quote(timeMin, safe='')}&timeMax={quote(timeMax, safe='')}" - result = await _googleGet(self._token, url) + result = await googleGet(self._token, url) if "error" in result: _raiseGoogleError(result, "Google Calendar events") events = result.get("items", []) @@ -534,7 +533,7 @@ class CalendarAdapter(ServiceAdapter): return DownloadResult() calendarId, eventId = cleanPath.split("/", 1) url = f"{_CALENDAR_BASE}/calendars/{quote(calendarId, safe='')}/events/{quote(eventId, safe='')}" - ev = await _googleGet(self._token, url) + ev = await googleGet(self._token, url) if "error" in ev: logger.warning(f"Google Calendar event fetch failed: {ev['error']}") return DownloadResult() @@ -573,7 +572,7 @@ class CalendarAdapter(ServiceAdapter): f"{_CALENDAR_BASE}/calendars/{quote(calendarId, safe='')}/events" f"?q={quote(query, safe='')}&maxResults={effectiveLimit}&singleEvents=true" ) - result = await _googleGet(self._token, url) + result = await googleGet(self._token, url) if "error" in result: _raiseGoogleError(result, "Google Calendar search") return [ @@ -629,7 +628,7 @@ class ContactsAdapter(ServiceAdapter): ), ] url = f"{_PEOPLE_BASE}/contactGroups?pageSize=200" - result = await _googleGet(self._token, url) + result = await googleGet(self._token, url) if "error" not in result: for grp in result.get("contactGroups", []): name = grp.get("formattedName") or grp.get("name") or "" @@ -659,7 +658,7 @@ class ContactsAdapter(ServiceAdapter): f"{_PEOPLE_BASE}/people/me/connections" f"?pageSize={min(effectiveLimit, 1000)}&personFields={self._PERSON_FIELDS}" ) - result = await _googleGet(self._token, url) + result = await googleGet(self._token, url) if "error" in result: _raiseGoogleError(result, "Google People connections") people = result.get("connections", []) @@ -669,7 +668,7 @@ class ContactsAdapter(ServiceAdapter): f"{_PEOPLE_BASE}/{quote(groupResource, safe='/')}" f"?maxMembers={min(effectiveLimit, 1000)}" ) - grpResult = await _googleGet(self._token, grpUrl) + grpResult = await googleGet(self._token, grpUrl) if "error" in grpResult: _raiseGoogleError(grpResult, "Google contactGroup detail") memberResourceNames = grpResult.get("memberResourceNames") or [] @@ -681,7 +680,7 @@ class ContactsAdapter(ServiceAdapter): chunk = memberResourceNames[i : i + chunkSize] params = "&".join(f"resourceNames={quote(rn, safe='/')}" for rn in chunk) batchUrl = f"{_PEOPLE_BASE}/people:batchGet?{params}&personFields={self._PERSON_FIELDS}" - batchResult = await _googleGet(self._token, batchUrl) + batchResult = await googleGet(self._token, batchUrl) if "error" in batchResult: logger.warning(f"Google People batchGet failed: {batchResult['error']}") continue @@ -717,7 +716,7 @@ class ContactsAdapter(ServiceAdapter): if not personSuffix: return DownloadResult() url = f"{_PEOPLE_BASE}/people/{quote(personSuffix, safe='')}?personFields={self._PERSON_FIELDS}" - person = await _googleGet(self._token, url) + person = await googleGet(self._token, url) if "error" in person: logger.warning(f"Google People fetch failed: {person['error']}") return DownloadResult() @@ -746,7 +745,7 @@ class ContactsAdapter(ServiceAdapter): f"?query={quote(query, safe='')}&pageSize={min(effectiveLimit, 30)}" f"&readMask={self._PERSON_FIELDS}" ) - result = await _googleGet(self._token, url) + result = await googleGet(self._token, url) if "error" in result: _raiseGoogleError(result, "Google Contacts search") entries: List[ExternalEntry] = [] @@ -770,7 +769,6 @@ class ContactsAdapter(ServiceAdapter): def _googleSafeFileName(name: str) -> str: - import re return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name or "")[:80].strip(". ") @@ -790,7 +788,6 @@ def _googleIcsDateTime(value: Optional[str]) -> Optional[str]: """Convert a Google Calendar dateTime/date string to RFC 5545 format (UTC).""" if not value: return None - from datetime import datetime, timezone try: if "T" not in value: dt = datetime.strptime(value, "%Y-%m-%d") @@ -806,7 +803,6 @@ def _googleIcsDateTime(value: Optional[str]) -> Optional[str]: def _googleEventToIcs(event: Dict[str, Any]) -> bytes: """Build a minimal RFC 5545 VCALENDAR/VEVENT for a Google Calendar event.""" - from datetime import datetime, timezone uid = event.get("iCalUID") or event.get("id") or "unknown@poweron" summary = _googleIcsEscape(event.get("summary") or "") location = _googleIcsEscape(event.get("location") or "") diff --git a/modules/connectors/providerInfomaniak/connectorInfomaniak.py b/modules/connectors/connectorProviderInfomaniak.py similarity index 99% rename from modules/connectors/providerInfomaniak/connectorInfomaniak.py rename to modules/connectors/connectorProviderInfomaniak.py index 9aa3ea9c..661fdb64 100644 --- a/modules/connectors/providerInfomaniak/connectorInfomaniak.py +++ b/modules/connectors/connectorProviderInfomaniak.py @@ -45,7 +45,7 @@ from modules.connectors.connectorProviderBase import ( ServiceAdapter, DownloadResult, ) -from modules.connectors._httpResilience import ResilientHttp +from modules.shared.httpResilience import ResilientHttp from modules.datamodels.datamodelDataSource import ExternalEntry logger = logging.getLogger(__name__) diff --git a/modules/connectors/providerMsft/connectorMsft.py b/modules/connectors/connectorProviderMsft.py similarity index 98% rename from modules/connectors/providerMsft/connectorMsft.py rename to modules/connectors/connectorProviderMsft.py index 0830e6ed..266f9deb 100644 --- a/modules/connectors/providerMsft/connectorMsft.py +++ b/modules/connectors/connectorProviderMsft.py @@ -6,14 +6,17 @@ All ServiceAdapters share the same OAuth access token obtained from the UserConnection (authority=msft). """ +import json import logging +import re import aiohttp import asyncio import urllib.parse +from datetime import datetime, timedelta, timezone from typing import Dict, Any, List, Optional from modules.connectors.connectorProviderBase import ProviderConnector, ServiceAdapter, DownloadResult -from modules.connectors._httpResilience import ResilientHttp +from modules.shared.httpResilience import ResilientHttp from modules.datamodels.datamodelDataSource import ExternalEntry logger = logging.getLogger(__name__) @@ -79,7 +82,7 @@ async def _handleResponse(resp: aiohttp.ClientResponse) -> Dict[str, Any]: return {"error": f"{resp.status}: {errorText}"} -def _stripGraphBase(url: str) -> str: +def stripGraphBase(url: str) -> str: """Convert an absolute Graph URL (used by @odata.nextLink) into the relative endpoint that ``_makeGraphCall`` expects.""" if not url: @@ -176,7 +179,7 @@ class SharepointAdapter(_GraphApiMixin, ServiceAdapter): if effectiveLimit is not None and len(items) >= effectiveLimit: break nextLink = result.get("@odata.nextLink") - endpoint = _stripGraphBase(nextLink) if nextLink else None + endpoint = stripGraphBase(nextLink) if nextLink else None entries = [_graphItemToExternalEntry(item, path) for item in items] if filter: @@ -257,7 +260,7 @@ class SharepointAdapter(_GraphApiMixin, ServiceAdapter): if effectiveLimit is not None and len(items) >= effectiveLimit: break nextLink = result.get("@odata.nextLink") - endpoint = _stripGraphBase(nextLink) if nextLink else None + endpoint = stripGraphBase(nextLink) if nextLink else None entries = [_graphItemToExternalEntry(item) for item in items] if effectiveLimit is not None: entries = entries[: max(1, effectiveLimit)] @@ -278,8 +281,6 @@ def _parseDateRange(filterStr: Optional[str]) -> tuple: (treated as a ~31 day window), or a YYYY-MM month pattern. Returns (startDateTime, endDateTime) ISO strings, or (None, None) if not parseable. """ - import re - from datetime import datetime, timedelta if not filterStr: return (None, None) isoMatch = re.findall(r'\d{4}-\d{2}-\d{2}(?:T[\d:]+)?', filterStr) @@ -368,7 +369,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter): if not nextLink: endpoint = None else: - endpoint = _stripGraphBase(nextLink) + endpoint = stripGraphBase(nextLink) # Guarantee Inbox is present (well-known name, locale-independent) if not any((f.get("displayName") or "").lower() in ("inbox", "posteingang") for f in folders): @@ -445,7 +446,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter): if len(messages) >= effectiveLimit: break nextLink = result.get("@odata.nextLink") - endpoint = _stripGraphBase(nextLink) if nextLink else None + endpoint = stripGraphBase(nextLink) if nextLink else None entries = [ ExternalEntry( name=m.get("subject", "(no subject)"), @@ -470,7 +471,6 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter): async def download(self, path: str) -> DownloadResult: """Download a mail message as RFC 822 EML via Graph API $value endpoint.""" - import re messageId = path.strip("/").split("/")[-1] meta = await self._graphGet(f"me/messages/{messageId}?$select=subject") @@ -572,7 +572,6 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter): attachments: Optional[List[Dict]] = None, ) -> Dict[str, Any]: """Send an email via Microsoft Graph. bodyType: 'Text' or 'HTML'.""" - import json message = self._buildMessage(to, subject, body, bodyType, cc, attachments) payload = json.dumps({"message": message, "saveToSentItems": True}).encode("utf-8") result = await self._graphPost("me/sendMail", payload) @@ -587,7 +586,6 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter): attachments: Optional[List[Dict]] = None, ) -> Dict[str, Any]: """Create a draft email in the user's Drafts folder via Microsoft Graph.""" - import json message = self._buildMessage(to, subject, body, bodyType, cc, attachments) payload = json.dumps(message).encode("utf-8") result = await self._graphPost("me/messages", payload) @@ -617,7 +615,6 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter): Preserves the conversation thread and the ``AW:`` prefix in Outlook -- unlike sendMail() which creates a brand-new conversation. """ - import json endpointAction = "replyAll" if replyAll else "reply" payload = json.dumps({"comment": comment}).encode("utf-8") result = await self._graphPost(f"me/messages/{messageId}/{endpointAction}", payload) @@ -629,7 +626,6 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter): self, messageId: str, to: List[str], comment: str = "", ) -> Dict[str, Any]: """Forward an existing message to new recipients.""" - import json payload = json.dumps({ "comment": comment, "toRecipients": [{"emailAddress": {"address": addr}} for addr in to], @@ -644,7 +640,6 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter): replyAll: bool = False, ) -> Dict[str, Any]: """Create a reply-draft (in the Drafts folder) that the user can edit before sending.""" - import json endpointAction = "createReplyAll" if replyAll else "createReply" payload = json.dumps({"comment": comment}).encode("utf-8") if comment else b"{}" result = await self._graphPost(f"me/messages/{messageId}/{endpointAction}", payload) @@ -656,7 +651,6 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter): self, messageId: str, to: Optional[List[str]] = None, comment: str = "", ) -> Dict[str, Any]: """Create a forward-draft (in the Drafts folder) that the user can edit before sending.""" - import json body: Dict[str, Any] = {} if comment: body["comment"] = comment @@ -727,7 +721,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter): "childFolderCount": f.get("childFolderCount", 0), }) nextLink = result.get("@odata.nextLink") - endpoint = _stripGraphBase(nextLink) if nextLink else None + endpoint = stripGraphBase(nextLink) if nextLink else None return folders async def _resolveFolderId(self, folderRef: str) -> Optional[str]: @@ -764,7 +758,6 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter): self, messageId: str, destinationFolder: str, ) -> Dict[str, Any]: """Move a message to another folder (well-known name, displayName, or folder id).""" - import json destId = await self._resolveFolderId(destinationFolder) if not destId: return {"error": f"Folder not found: '{destinationFolder}'. Use listMailFolders to inspect available folders."} @@ -778,7 +771,6 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter): self, messageId: str, destinationFolder: str, ) -> Dict[str, Any]: """Copy a message into another folder (original stays in place).""" - import json destId = await self._resolveFolderId(destinationFolder) if not destId: return {"error": f"Folder not found: '{destinationFolder}'. Use listMailFolders to inspect available folders."} @@ -818,7 +810,6 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter): async def markMailAsRead(self, messageId: str) -> Dict[str, Any]: """Mark a message as read (sets ``isRead=true``).""" - import json payload = json.dumps({"isRead": True}).encode("utf-8") result = await self._graphPatch(f"me/messages/{messageId}", payload) if "error" in result: @@ -827,7 +818,6 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter): async def markMailAsUnread(self, messageId: str) -> Dict[str, Any]: """Mark a message as unread (sets ``isRead=false``).""" - import json payload = json.dumps({"isRead": False}).encode("utf-8") result = await self._graphPatch(f"me/messages/{messageId}", payload) if "error" in result: @@ -845,7 +835,6 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter): ``"notFlagged"`` -- the three values Microsoft Graph recognises for ``followupFlag.flagStatus``. """ - import json if flagStatus not in ("flagged", "complete", "notFlagged"): return {"error": f"Invalid flagStatus '{flagStatus}'. Use one of: flagged, complete, notFlagged."} payload = json.dumps({"flag": {"flagStatus": flagStatus}}).encode("utf-8") @@ -952,7 +941,7 @@ class OneDriveAdapter(_GraphApiMixin, ServiceAdapter): if effectiveLimit is not None and len(items) >= effectiveLimit: break nextLink = result.get("@odata.nextLink") - endpoint = _stripGraphBase(nextLink) if nextLink else None + endpoint = stripGraphBase(nextLink) if nextLink else None entries = [_graphItemToExternalEntry(item, path) for item in items] if filter: @@ -1003,7 +992,7 @@ class OneDriveAdapter(_GraphApiMixin, ServiceAdapter): if effectiveLimit is not None and len(items) >= effectiveLimit: break nextLink = result.get("@odata.nextLink") - endpoint = _stripGraphBase(nextLink) if nextLink else None + endpoint = stripGraphBase(nextLink) if nextLink else None entries = [_graphItemToExternalEntry(item) for item in items] if effectiveLimit is not None: entries = entries[: max(1, effectiveLimit)] @@ -1099,7 +1088,7 @@ class CalendarAdapter(_GraphApiMixin, ServiceAdapter): if len(events) >= effectiveLimit: break nextLink = result.get("@odata.nextLink") - endpoint = _stripGraphBase(nextLink) if nextLink else None + endpoint = stripGraphBase(nextLink) if nextLink else None return [ ExternalEntry( @@ -1296,7 +1285,7 @@ class ContactsAdapter(_GraphApiMixin, ServiceAdapter): if len(contacts) >= effectiveLimit: break nextLink = result.get("@odata.nextLink") - endpoint = _stripGraphBase(nextLink) if nextLink else None + endpoint = stripGraphBase(nextLink) if nextLink else None return [ ExternalEntry( @@ -1448,7 +1437,6 @@ def _matchFilter(entry: ExternalEntry, pattern: str) -> bool: def _safeFileName(name: str) -> str: """Strip path-unsafe characters and trim length so the result is a usable file name.""" - import re return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name or "")[:80].strip(". ") @@ -1478,7 +1466,6 @@ def _icsDateTime(value: Optional[str]) -> Optional[str]: """Convert an ISO datetime string to an RFC 5545 DATE-TIME value (UTC).""" if not value: return None - from datetime import datetime, timezone try: normalized = value.replace("Z", "+00:00") if value.endswith("Z") else value dt = datetime.fromisoformat(normalized) @@ -1491,7 +1478,6 @@ def _icsDateTime(value: Optional[str]) -> Optional[str]: def _eventToIcs(event: Dict[str, Any]) -> bytes: """Build a minimal RFC 5545 VCALENDAR/VEVENT for a Graph event payload.""" - from datetime import datetime, timezone uid = event.get("iCalUId") or event.get("id") or "unknown@poweron" summary = _icsEscape(event.get("subject") or "") location = _icsEscape((event.get("location") or {}).get("displayName") or "") diff --git a/modules/connectors/connectorResolver.py b/modules/connectors/connectorResolver.py index a8b9fd23..a6b559a0 100644 --- a/modules/connectors/connectorResolver.py +++ b/modules/connectors/connectorResolver.py @@ -44,31 +44,31 @@ class ConnectorResolver: if ConnectorResolver._providerRegistry: return try: - from modules.connectors.providerMsft.connectorMsft import MsftConnector + from modules.connectors.connectorProviderMsft import MsftConnector ConnectorResolver._providerRegistry["msft"] = MsftConnector except ImportError: logger.warning("MsftConnector not available") try: - from modules.connectors.providerGoogle.connectorGoogle import GoogleConnector + from modules.connectors.connectorProviderGoogle import GoogleConnector ConnectorResolver._providerRegistry["google"] = GoogleConnector except ImportError: logger.debug("GoogleConnector not available (stub)") try: - from modules.connectors.providerFtp.connectorFtp import FtpConnector + from modules.connectors.connectorProviderFtp import FtpConnector ConnectorResolver._providerRegistry["local:ftp"] = FtpConnector except ImportError: logger.debug("FtpConnector not available (stub)") try: - from modules.connectors.providerClickup.connectorClickup import ClickupConnector + from modules.connectors.connectorProviderClickup import ClickupConnector ConnectorResolver._providerRegistry["clickup"] = ClickupConnector except ImportError: logger.warning("ClickupConnector not available") try: - from modules.connectors.providerInfomaniak.connectorInfomaniak import InfomaniakConnector + from modules.connectors.connectorProviderInfomaniak import InfomaniakConnector ConnectorResolver._providerRegistry["infomaniak"] = InfomaniakConnector except ImportError: logger.warning("InfomaniakConnector not available") diff --git a/modules/connectors/connectorTicketsClickup.py b/modules/connectors/connectorTicketsClickup.py index af02b44a..bb43ceac 100644 --- a/modules/connectors/connectorTicketsClickup.py +++ b/modules/connectors/connectorTicketsClickup.py @@ -9,7 +9,7 @@ from typing import Optional import logging import aiohttp from modules.datamodels.datamodelTickets import TicketBase, TicketFieldAttribute -from modules.serviceCenter.services.serviceClickup.mainServiceClickup import clickup_authorization_header +from modules.connectors.connectorProviderClickup import clickupAuthorizationHeader logger = logging.getLogger(__name__) @@ -31,7 +31,7 @@ class ConnectorTicketClickup(TicketBase): def _headers(self) -> dict: return { - "Authorization": clickup_authorization_header(self.apiToken), + "Authorization": clickupAuthorizationHeader(self.apiToken), "Content-Type": "application/json", } diff --git a/modules/connectors/connectorVoiceGoogle.py b/modules/connectors/connectorVoiceGoogle.py index 3dd3221d..7ae8e54b 100644 --- a/modules/connectors/connectorVoiceGoogle.py +++ b/modules/connectors/connectorVoiceGoogle.py @@ -15,7 +15,7 @@ from google.cloud import speech from google.cloud import translate_v2 as translate from google.cloud import texttospeech from modules.shared.configuration import APP_CONFIG -from modules.shared.voiceCatalog import getDefaultVoice as _catalogDefaultVoice +from modules.shared.voiceCatalog import getDefaultVoice logger = logging.getLogger(__name__) @@ -1097,7 +1097,7 @@ class ConnectorGoogleSpeech: voice exists, in which case the caller omits `name` and Google auto-selects based on languageCode + ssml_gender. """ - return _catalogDefaultVoice(languageCode) + return getDefaultVoice(languageCode) async def getAvailableVoices(self, languageCode: Optional[str] = None) -> Dict[str, Any]: """ diff --git a/modules/connectors/providerClickup/__init__.py b/modules/connectors/providerClickup/__init__.py deleted file mode 100644 index 12439593..00000000 --- a/modules/connectors/providerClickup/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -"""ClickUp provider connector.""" - -from .connectorClickup import ClickupConnector - -__all__ = ["ClickupConnector"] diff --git a/modules/connectors/providerFtp/__init__.py b/modules/connectors/providerFtp/__init__.py deleted file mode 100644 index ee198298..00000000 --- a/modules/connectors/providerFtp/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -"""FTP/SFTP Provider Connector stub.""" diff --git a/modules/connectors/providerGoogle/__init__.py b/modules/connectors/providerGoogle/__init__.py deleted file mode 100644 index 0e09a79e..00000000 --- a/modules/connectors/providerGoogle/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -"""Google Provider Connector -- 1 Connection : n Services (Drive, Gmail).""" diff --git a/modules/connectors/providerInfomaniak/__init__.py b/modules/connectors/providerInfomaniak/__init__.py deleted file mode 100644 index 87482425..00000000 --- a/modules/connectors/providerInfomaniak/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -"""Infomaniak Provider Connector -- 1 Connection : n Services (kDrive, Mail).""" diff --git a/modules/connectors/providerMsft/__init__.py b/modules/connectors/providerMsft/__init__.py deleted file mode 100644 index 2229ecb3..00000000 --- a/modules/connectors/providerMsft/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -"""Microsoft Provider Connector -- 1 Connection : n Services (SharePoint, Outlook, Teams, OneDrive).""" diff --git a/modules/datamodels/__init__.py b/modules/datamodels/__init__.py index 4bc577ee..40adbebb 100644 --- a/modules/datamodels/__init__.py +++ b/modules/datamodels/__init__.py @@ -13,4 +13,5 @@ from . import datamodelSecurity as security from . import datamodelChat as chat from . import datamodelFiles as files from . import datamodelVoice as voice -from . import datamodelUtils as utils \ No newline at end of file +from . import datamodelUtils as utils +from . import jsonContinuation \ No newline at end of file diff --git a/modules/datamodels/datamodelFeatureDataSource.py b/modules/datamodels/datamodelFeatureDataSource.py deleted file mode 100644 index 2f234742..00000000 --- a/modules/datamodels/datamodelFeatureDataSource.py +++ /dev/null @@ -1,83 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -"""FeatureDataSource model for exposing feature instance data to the AI workspace. - -A FeatureDataSource links a FeatureInstance table (DATA_OBJECT) to a workspace -so the agent can query structured feature data (e.g. TrusteePosition rows). -""" - -from typing import Any, Dict, List, Optional -from pydantic import BaseModel, Field -from modules.datamodels.datamodelBase import PowerOnModel -from modules.shared.i18nRegistry import i18nModel -import uuid - - -@i18nModel("Feature-Datenquelle") -class FeatureDataSource(PowerOnModel): - """Feature-Instanz-Tabelle als Datenquelle im AI-Workspace.""" - id: str = Field( - default_factory=lambda: str(uuid.uuid4()), - description="Primary key", - json_schema_extra={"label": "ID"}, - ) - featureInstanceId: str = Field( - description="FK to FeatureInstance", - json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}}, - ) - featureCode: str = Field( - description="Feature code (e.g. trustee, commcoach)", - json_schema_extra={"label": "Feature", "fk_target": {"db": "poweron_app", "table": "Feature", "column": "code", "labelField": "code"}}, - ) - tableName: str = Field( - description="Table name from DATA_OBJECTS meta (e.g. TrusteePosition)", - json_schema_extra={"label": "Tabelle"}, - ) - objectKey: str = Field( - description="RBAC object key (e.g. data.feature.trustee.TrusteePosition)", - json_schema_extra={"label": "Objekt-Schluessel"}, - ) - label: str = Field( - description="User-visible label", - json_schema_extra={"label": "Bezeichnung"}, - ) - mandateId: str = Field( - default="", - description="Mandate scope (set automatically from featureInstance.mandateId on create).", - json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}}, - ) - neutralize: Optional[bool] = Field( - default=None, - description=( - "Three-state neutralization flag with cascade-inherit semantics. " - "None = inherit; True/False = explicit. Cascade-reset on parent toggle." - ), - json_schema_extra={"label": "Neutralisieren", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}, - ) - ragIndexEnabled: Optional[bool] = Field( - default=None, - description=( - "Three-state RAG-indexing flag with cascade-inherit semantics. " - "None = inherit; True/False = explicit. Cascade-reset on parent toggle." - ), - json_schema_extra={"label": "RAG-Indexierung", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}, - ) - neutralizeFields: Optional[List[str]] = Field( - default=None, - description="Column names whose values are replaced with placeholders before AI processing", - json_schema_extra={"label": "Zu neutralisierende Felder", "frontend_type": "multiselect", "frontend_readonly": False, "frontend_required": False}, - ) - recordFilter: Optional[Dict[str, str]] = Field( - default=None, - description="Record-level filter applied when querying this table, e.g. {'sessionId': 'abc-123'}", - json_schema_extra={"label": "Datensatzfilter"}, - ) - settings: Optional[Dict[str, Any]] = Field( - default=None, - description=( - "FeatureDataSource-scoped settings (JSON). Currently used keys: " - "ragLimits.{maxBytes,maxFileSize,maxItems,maxDepth}. " - "Mirror of DataSource.settings so the UDB settings modal can target both." - ), - json_schema_extra={"label": "Einstellungen", "frontend_type": "json", "frontend_readonly": True, "frontend_required": False}, - ) diff --git a/modules/datamodels/datamodelFeatures.py b/modules/datamodels/datamodelFeatures.py index 67532fe3..e43569b1 100644 --- a/modules/datamodels/datamodelFeatures.py +++ b/modules/datamodels/datamodelFeatures.py @@ -1,15 +1,19 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. -"""Feature models: Feature, FeatureInstance.""" +"""Feature models: Feature definitions, instances, data sources, and shared feature types.""" import uuid -from typing import Optional, Dict, Any +from typing import Optional, Dict, Any, List from pydantic import BaseModel, Field from modules.datamodels.datamodelBase import PowerOnModel from modules.shared.i18nRegistry import i18nModel from modules.datamodels.datamodelUtils import TextMultilingual +# --------------------------------------------------------------------------- +# Feature & FeatureInstance +# --------------------------------------------------------------------------- + @i18nModel("Feature") class Feature(PowerOnModel): """Feature-Definition (global, z.B. 'trustee', 'commcoach'). Verfuegbare Funktionalitaeten der Plattform.""" @@ -71,3 +75,147 @@ class FeatureInstance(PowerOnModel): description="Instance-specific configuration (JSONB). Structure depends on featureCode.", json_schema_extra={"label": "Konfiguration", "frontend_type": "json", "frontend_readonly": False, "frontend_required": False} ) + + +# --------------------------------------------------------------------------- +# FeatureDataSource +# --------------------------------------------------------------------------- + +@i18nModel("Feature-Datenquelle") +class FeatureDataSource(PowerOnModel): + """Feature-Instanz-Tabelle als Datenquelle im AI-Workspace.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + json_schema_extra={"label": "ID"}, + ) + featureInstanceId: str = Field( + description="FK to FeatureInstance", + json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}}, + ) + featureCode: str = Field( + description="Feature code (e.g. trustee, commcoach)", + json_schema_extra={"label": "Feature", "fk_target": {"db": "poweron_app", "table": "Feature", "column": "code", "labelField": "code"}}, + ) + tableName: str = Field( + description="Table name from DATA_OBJECTS meta (e.g. TrusteePosition)", + json_schema_extra={"label": "Tabelle"}, + ) + objectKey: str = Field( + description="RBAC object key (e.g. data.feature.trustee.TrusteePosition)", + json_schema_extra={"label": "Objekt-Schluessel"}, + ) + label: str = Field( + description="User-visible label", + json_schema_extra={"label": "Bezeichnung"}, + ) + mandateId: str = Field( + default="", + description="Mandate scope (set automatically from featureInstance.mandateId on create).", + json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}}, + ) + neutralize: Optional[bool] = Field( + default=None, + description=( + "Three-state neutralization flag with cascade-inherit semantics. " + "None = inherit; True/False = explicit. Cascade-reset on parent toggle." + ), + json_schema_extra={"label": "Neutralisieren", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}, + ) + ragIndexEnabled: Optional[bool] = Field( + default=None, + description=( + "Three-state RAG-indexing flag with cascade-inherit semantics. " + "None = inherit; True/False = explicit. Cascade-reset on parent toggle." + ), + json_schema_extra={"label": "RAG-Indexierung", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}, + ) + neutralizeFields: Optional[List[str]] = Field( + default=None, + description="Column names whose values are replaced with placeholders before AI processing", + json_schema_extra={"label": "Zu neutralisierende Felder", "frontend_type": "multiselect", "frontend_readonly": False, "frontend_required": False}, + ) + recordFilter: Optional[Dict[str, str]] = Field( + default=None, + description="Record-level filter applied when querying this table, e.g. {'sessionId': 'abc-123'}", + json_schema_extra={"label": "Datensatzfilter"}, + ) + settings: Optional[Dict[str, Any]] = Field( + default=None, + description=( + "FeatureDataSource-scoped settings (JSON). Currently used keys: " + "ragLimits.{maxBytes,maxFileSize,maxItems,maxDepth}. " + "Mirror of DataSource.settings so the UDB settings modal can target both." + ), + json_schema_extra={"label": "Einstellungen", "frontend_type": "json", "frontend_readonly": True, "frontend_required": False}, + ) + + +# --------------------------------------------------------------------------- +# DataNeutralizerAttributes +# --------------------------------------------------------------------------- + +@i18nModel("Neutralisiertes Datenattribut") +class DataNeutralizerAttributes(PowerOnModel): + """Zuordnung Originaltext zu Platzhalter fuer neutralisierte Daten.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Unique ID of the attribute mapping (used as UID in neutralized files)", + json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}, + ) + mandateId: str = Field( + description="ID of the mandate this attribute belongs to", + json_schema_extra={ + "label": "Mandanten-ID", + "frontend_type": "text", + "frontend_readonly": True, + "frontend_required": True, + "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}, + }, + ) + featureInstanceId: str = Field( + description="ID of the feature instance this attribute belongs to", + json_schema_extra={ + "label": "Feature-Instanz-ID", + "frontend_type": "text", + "frontend_readonly": True, + "frontend_required": True, + "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}, + }, + ) + userId: str = Field( + description="ID of the user who created this attribute", + json_schema_extra={ + "label": "Benutzer-ID", + "frontend_type": "text", + "frontend_readonly": True, + "frontend_required": True, + "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}, + }, + ) + originalText: str = Field( + description="Original text that was neutralized", + json_schema_extra={"label": "Originaltext", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}, + ) + fileId: Optional[str] = Field( + default=None, + description="ID of the file this attribute belongs to", + json_schema_extra={ + "label": "Datei-ID", + "frontend_type": "text", + "frontend_readonly": True, + "frontend_required": False, + "fk_target": {"db": "poweron_management", "table": "FileItem", "labelField": "fileName"}, + }, + ) + patternType: str = Field( + description="Type of pattern that matched (email, phone, name, etc.)", + json_schema_extra={"label": "Mustertyp", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}, + ) + + +# --------------------------------------------------------------------------- +# AutoWorkflow — re-exported from canonical location (datamodelWorkflowAutomation) +# --------------------------------------------------------------------------- + +from modules.datamodels.datamodelWorkflowAutomation import AutoWorkflow # noqa: F401 diff --git a/modules/datamodels/datamodelPortTypes.py b/modules/datamodels/datamodelPortTypes.py new file mode 100644 index 00000000..6d87c25e --- /dev/null +++ b/modules/datamodels/datamodelPortTypes.py @@ -0,0 +1,551 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +"""Port type catalog and primitive types for the Graphical Editor workflow system.""" + +from typing import Dict, List, Optional + +from pydantic import BaseModel, ConfigDict, Field + +from modules.shared.i18nRegistry import t + + +class PortField(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + name: str + type: str # str, int, bool, List[str], List[Document], Dict[str,Any], ConnectionRef, … + description: str = "" + required: bool = True + enumValues: Optional[List[str]] = None + # Marks this field as the discriminator for a Ref-Schema (e.g. ConnectionRef.authority, + # FeatureInstanceRef.featureCode). Pickers/validators use it to filter compatible + # producers by sub-type. Type must be "str" when discriminator is True. + discriminator: bool = False + # Surfaces this field at the top of the DataPicker list as the most common pick. + recommended: bool = False + # Human DataPicker title (camelCase JSON for frontend). Omit for technical paths-only. + picker_label: Optional[str] = Field(default=None, serialization_alias="pickerLabel") + # For List[T] fields: segment between parent and inner field (iteration / one list item). + picker_item_label: Optional[str] = Field(default=None, serialization_alias="pickerItemLabel") + + +class PortSchema(BaseModel): + name: str # e.g. "EmailDraft", "AiResult", "Transit" + fields: List[PortField] + # Declarative flag for the engine: when True, the executor attaches + # connection provenance ({id, authority, label}) onto the output. Replaces + # hard-coded schema lists in actionNodeExecutor._attachConnectionProvenance. + carriesConnectionProvenance: bool = False + + +# --------------------------------------------------------------------------- +# PORT_TYPE_CATALOG +# --------------------------------------------------------------------------- + +PORT_TYPE_CATALOG: Dict[str, PortSchema] = { + # ----------------------------------------------------------------- + # Refs (handles to external resources, pickable by user) + # ----------------------------------------------------------------- + "ConnectionRef": PortSchema(name="ConnectionRef", fields=[ + PortField(name="id", type="str", description="UserConnection.id (UUID)"), + PortField(name="authority", type="str", discriminator=True, + description="Auth-Provider-Code: msft | clickup | google | …"), + PortField(name="label", type="str", required=False, description="Anzeigename"), + ]), + "FeatureInstanceRef": PortSchema(name="FeatureInstanceRef", fields=[ + PortField(name="id", type="str", description="FeatureInstance.id (UUID)"), + PortField(name="featureCode", type="str", discriminator=True, + description="Feature-Modul-Code: trustee | redmine | clickup | sharepoint | …"), + PortField(name="label", type="str", required=False, description="Anzeigename"), + PortField(name="mandateId", type="str", required=False, description="Zugehöriger Mandant"), + ]), + "ClickUpListRef": PortSchema(name="ClickUpListRef", fields=[ + PortField(name="listId", type="str", description="ClickUp-Listen-ID"), + PortField(name="name", type="str", required=False, description="Listenname"), + PortField(name="spaceId", type="str", required=False, description="Space-ID"), + PortField(name="groupId", type="str", required=False, description="Gruppen-ID für die Gruppierungszuordnung"), + PortField(name="connection", type="ConnectionRef", required=False, + description="ClickUp-Verbindung"), + ]), + "PromptTemplateRef": PortSchema(name="PromptTemplateRef", fields=[ + PortField(name="id", type="str", description="Prompt-Template-ID"), + PortField(name="name", type="str", required=False, description="Anzeigename"), + PortField(name="version", type="str", required=False, description="Version / Tag"), + ]), + "SharePointFolderRef": PortSchema(name="SharePointFolderRef", fields=[ + PortField(name="siteUrl", type="str", required=False, description="SharePoint Site"), + PortField(name="driveId", type="str", required=False, description="Drive ID"), + PortField(name="folderPath", type="str", required=False, description="Ordnerpfad"), + PortField(name="label", type="str", required=False, description="Kurzlabel für Picker"), + ]), + "SharePointFileRef": PortSchema(name="SharePointFileRef", fields=[ + PortField(name="siteUrl", type="str", required=False, description="SharePoint Site"), + PortField(name="driveId", type="str", required=False, description="Drive ID"), + PortField(name="filePath", type="str", required=False, description="Dateipfad"), + PortField(name="fileName", type="str", required=False, description="Dateiname"), + PortField(name="label", type="str", required=False, description="Kurzlabel"), + ]), + "Document": PortSchema(name="Document", fields=[ + PortField(name="id", type="str", required=False, description="Dokument-/Datei-ID"), + PortField(name="name", type="str", required=False, description="Anzeigename"), + PortField(name="mimeType", type="str", required=False, description="MIME-Typ"), + PortField(name="sizeBytes", type="int", required=False, description="Grösse"), + PortField(name="downloadUrl", type="str", required=False, description="Download-URL"), + PortField(name="filePath", type="str", required=False, description="Logischer Pfad"), + ]), + "FileItem": PortSchema(name="FileItem", fields=[ + PortField(name="id", type="str", required=False, description="Datei-ID"), + PortField(name="name", type="str", required=False, description="Name"), + PortField(name="path", type="str", required=False, description="Pfad"), + PortField(name="mimeType", type="str", required=False, description="MIME"), + PortField(name="sizeBytes", type="int", required=False, description="Grösse"), + ]), + "EmailItem": PortSchema(name="EmailItem", fields=[ + PortField(name="id", type="str", required=False, description="Message-ID"), + PortField(name="subject", type="str", required=False, description="Betreff"), + PortField(name="fromAddress", type="str", required=False, description="Absender"), + PortField(name="toAddresses", type="List[str]", required=False, description="Empfänger"), + PortField(name="receivedAt", type="str", required=False, description="Empfangen am"), + PortField(name="hasAttachments", type="bool", required=False, description="Hat Anhänge"), + PortField(name="bodyPreview", type="str", required=False, description="Vorschau"), + ]), + "TaskItem": PortSchema(name="TaskItem", fields=[ + PortField(name="id", type="str", required=False, description="Task-ID"), + PortField(name="title", type="str", required=False, description="Titel"), + PortField(name="status", type="str", required=False, description="Status"), + PortField(name="assignee", type="str", required=False, description="Assignee"), + PortField(name="dueDate", type="str", required=False, description="Fälligkeit"), + PortField(name="listId", type="str", required=False, description="ClickUp-Liste"), + ]), + "QueryResult": PortSchema(name="QueryResult", fields=[ + PortField(name="rows", type="List[Any]", description="Ergebniszeilen"), + PortField(name="columns", type="List[str]", required=False, description="Spaltennamen"), + PortField(name="count", type="int", required=False, description="Zeilenanzahl"), + ]), + "UdmPage": PortSchema(name="UdmPage", fields=[ + PortField(name="pageNumber", type="int", required=False, description="Seitennummer"), + PortField(name="blocks", type="List[Any]", required=False, description="ContentBlocks"), + ]), + "UdmBlock": PortSchema(name="UdmBlock", fields=[ + PortField(name="kind", type="str", required=False, description="Block-Typ"), + PortField(name="text", type="str", required=False, description="Textinhalt"), + PortField(name="children", type="List[Any]", required=False, description="Unterblöcke"), + ]), + "DocumentList": PortSchema(name="DocumentList", carriesConnectionProvenance=True, fields=[ + PortField(name="documents", type="List[Document]", + description="Dokumente aus vorherigen Schritten", recommended=True), + PortField(name="connection", type="ConnectionRef", required=False, + description="Verbindung, mit der die Liste erzeugt wurde"), + PortField(name="source", type="SharePointFolderRef", required=False, + description="Herkunftsordner / Quelle"), + PortField(name="count", type="int", required=False, + description="Anzahl Dokumente"), + ]), + "FileList": PortSchema(name="FileList", carriesConnectionProvenance=True, fields=[ + PortField(name="files", type="List[FileItem]", + description="Dateiliste"), + PortField(name="connection", type="ConnectionRef", required=False, + description="Verbindung"), + PortField(name="source", type="SharePointFolderRef", required=False, + description="Listen-Kontext"), + PortField(name="count", type="int", required=False, + description="Anzahl Dateien"), + ]), + "EmailDraft": PortSchema(name="EmailDraft", carriesConnectionProvenance=True, fields=[ + PortField(name="subject", type="str", + description="Betreff"), + PortField(name="body", type="str", + description="Inhalt"), + PortField(name="to", type="List[str]", + description="Empfänger"), + PortField(name="cc", type="List[str]", required=False, + description="CC"), + PortField(name="attachments", type="List[Document]", required=False, + description="Anhänge"), + PortField(name="connection", type="ConnectionRef", required=False, + description="Outlook-/Graph-Verbindung"), + ]), + "EmailList": PortSchema(name="EmailList", carriesConnectionProvenance=True, fields=[ + PortField(name="emails", type="List[EmailItem]", + description="E-Mails"), + PortField(name="connection", type="ConnectionRef", required=False, + description="Verbindung"), + PortField(name="count", type="int", required=False, + description="Anzahl"), + ]), + "TaskList": PortSchema(name="TaskList", carriesConnectionProvenance=True, fields=[ + PortField(name="tasks", type="List[TaskItem]", + description="Aufgaben"), + PortField(name="connection", type="ConnectionRef", required=False, + description="Verbindung"), + PortField(name="listId", type="str", required=False, + description="ClickUp-Listen-ID"), + PortField(name="count", type="int", required=False, + description="Anzahl"), + ]), + "TaskResult": PortSchema(name="TaskResult", fields=[ + PortField(name="success", type="bool", + description="Erfolg"), + PortField(name="taskId", type="str", + description="Aufgaben-ID"), + PortField(name="task", type="Dict", + description="Aufgabendaten"), + ]), + "FormPayload": PortSchema(name="FormPayload", fields=[ + PortField(name="payload", type="Dict[str,Any]", + description="Formulardaten"), + ]), + "AiResult": PortSchema(name="AiResult", fields=[ + PortField(name="prompt", type="str", + description="Prompt", + picker_label=t("Eingabe (Prompt des Schritts)"), + ), + PortField(name="response", type="str", + description=( + "Antworttext (Modell-Fließtext o. ä.; Bilder liegen in documents, nicht hier)." + ), + recommended=True, + picker_label=t("Ausgabetext (Modell)"), + ), + PortField(name="responseData", type="Dict", required=False, + description="Strukturierte Antwort (nur bei JSON-Ausgabe)", + picker_label=t("Strukturierte Antwortdaten")), + PortField(name="context", type="str", + description="Kontext", + picker_label=t("Eingabe-Kontext")), + PortField(name="documents", type="List[Document]", + description=( + "Erzeugte oder mitgegebene Dateien (z. B. Bilder); documentData = Nutzlast pro Eintrag." + ), + picker_label=t("Alle Ausgabe-Dateien (Liste)"), + picker_item_label=t("je Datei"), + ), + PortField(name="data", type="Dict", required=False, + description=( + "Internes Payload-Objekt (entspricht ``ActionResult.data``-Semantik). " + "Wird vom Executor gesetzt und enthält denselben Inhalt wie ``response`` " + "in strukturierter Form; primär für nachgelagerte Kontext-Nodes." + ), + picker_label=t("Technische Detaildaten (data)")), + PortField(name="imageDocumentsOnly", type="List[Document]", required=False, + description="Nur Bild-bezogene Einträge aus documents.", + picker_label=t("Nur Bilder (Liste)")), + ]), + "BoolResult": PortSchema(name="BoolResult", fields=[ + PortField(name="result", type="bool", + description="Ergebnis"), + PortField(name="reason", type="str", required=False, + description="Begründung"), + ]), + "TextResult": PortSchema(name="TextResult", fields=[ + PortField(name="text", type="str", + description="Text", + picker_label=t("Text (Schrittausgabe)")), + ]), + "LoopItem": PortSchema(name="LoopItem", fields=[ + PortField(name="currentItem", type="Any", + description="Aktuelles Element"), + PortField(name="currentIndex", type="int", + description="Aktueller Index"), + PortField(name="items", type="List[Any]", + description="Alle Elemente"), + PortField(name="count", type="int", + description="Gesamtanzahl"), + ]), + "AggregateResult": PortSchema(name="AggregateResult", fields=[ + PortField(name="items", type="List[Any]", + description="Gesammelte Elemente"), + PortField(name="count", type="int", + description="Anzahl"), + ]), + "MergeResult": PortSchema(name="MergeResult", fields=[ + PortField(name="inputs", type="Dict[int,Any]", + description="Eingaben nach Port"), + PortField(name="first", type="Any", + description="Erstes verfügbares"), + PortField(name="merged", type="Dict", + description="Zusammengeführte Daten"), + ]), + "ContextBranch": PortSchema(name="ContextBranch", fields=[ + PortField(name="items", type="List[Any]", + description="Schleifen-fertige Elemente aus dem (gefilterten) Kontext", + recommended=True, + picker_label=t("Gefilterte Elemente")), + PortField(name="data", type="Dict", required=False, + description="Gefilterter Presentation-Umschlag oder Eingabe-Spiegel", + picker_label=t("Kontext (data)")), + PortField(name="filterApplied", type="bool", required=False, + description="True wenn ein Kontext-Inhaltsfilter angewendet wurde"), + PortField(name="contentType", type="str", required=False, + description="Angewendeter Inhaltstyp-Filter (z. B. image)"), + PortField(name="match", type="int", required=False, + description="Aktiver Ausgangs-Index (Fall oder Sonst)"), + ]), + "ActionDocument": PortSchema(name="ActionDocument", fields=[ + PortField(name="documentName", type="str", + description="Dokumentname", + picker_label=t("Dateiname")), + PortField(name="documentData", type="Any", + description="Inhalt / Rohdaten (z.B. JSON-String, Bytes)", + picker_label=t("Dateiinhalt (JSON, Text oder Bild)"), + recommended=True), + PortField(name="mimeType", type="str", + description="MIME-Typ", + picker_label=t("Dateityp (MIME)")), + PortField(name="fileId", type="str", required=False, + description="Persistierte FileItem.id (vom Engine ergänzt)"), + PortField(name="fileName", type="str", required=False, + description="Persistierter Dateiname (vom Engine ergänzt)"), + ]), + "ActionResult": PortSchema(name="ActionResult", fields=[ + PortField(name="success", type="bool", + description="Erfolg"), + PortField(name="error", type="str", required=False, + description="Fehler"), + # `documents` is populated for every action that returns ActionResult + # (see datamodelChat.ActionResult.documents and actionNodeExecutor.out). + # Without it in the catalog the DataPicker cannot offer downstream + # bindings like `processDocuments → documents → *` for syncToAccounting. + PortField(name="documents", type="List[ActionDocument]", required=False, + description=( + "Dokumentliste für Actions mit echten Artefakt-Dokumenten. " + "Beim Knoten „Inhalt extrahieren“ fehlt dieses Feld in der Knotenausgabe." + ), + picker_label=t("Alle Ausgabe-Dokumente"), + picker_item_label=t("je Dokument"), + ), + PortField(name="data", type="Dict", required=False, + description=( + "Strukturierter Inhalt. Bei **context.extractContent**: **Presentation**-Root " + "(`schemaVersion`, `kind`, `fileOrder`, `files`) plus **`_meta`** — ohne " + "zusätzliches `response`/`contentExtracted`-Duplikat." + ), + picker_label=t("Technische Detaildaten (data)")), + # Mirror AiResult primary text fields so DataPicker / primaryTextRef behave the same + PortField(name="prompt", type="str", required=False, + description="Optional: auslösender Prompt / Schrittname", + picker_label=t("Auslöser / Prompt (falls vorhanden)")), + PortField(name="response", type="str", required=False, + description=( + "Fließtext wo die Action einen liefert. Bei **„Inhalt extrahieren“** absichtlich leer — " + "Inhalt liegt in ``data``.``files``." + ), + recommended=True, + picker_label=t("Nur Fließtext (gesamt)")), + PortField(name="context", type="str", required=False, + description="Optional: Eingabe-Kontext", + picker_label=t("Mitgegebener Kontext")), + PortField(name="imageDocumentsOnly", type="List[ActionDocument]", required=False, + description=( + "Nur Bild-bezogene Einträge. Bei „Inhalt extrahieren“: synthetische " + "Einträge mit ``fileId`` aus persistierten Extrakt-Bildern (kein separates JSON-Dokument)." + ), + picker_label=t("Nur Bilder (Liste)")), + PortField(name="responseData", type="Dict", required=False, + description="Optional: strukturierte Zusatzdaten", + picker_label=t("Strukturierte Zusatzdaten")), + PortField(name="presentation", type="Dict", required=False, + description=( + "Selten: Top-Level-Spiegel von Präsentationsdaten andere Actions. " + "Bei „Inhalt extrahieren“ liegt alles direkt unter ``data`` (kein zusätzlicher Spiegel)." + ), + picker_label=t("Presentation (Top-Level-Spiegel)")), + PortField(name="presentationSummary", type="Dict", required=False, + description=( + "Kompakte Metadaten zu ``presentation`` (Debugging / traces)." + ), + picker_label=t("Presentation-Zusammenfassung")), + PortField(name="presentationConfig", type="Dict", required=False, + description=( + "Optional: Debugging-Konfiguration; bei Extract liegt die Primärquelle in ``validationMetadata`` des JSON-Dokuments." + ), + picker_label=t("Presentation-Konfiguration")), + ]), + "Transit": PortSchema(name="Transit", fields=[]), + "UdmDocument": PortSchema(name="UdmDocument", carriesConnectionProvenance=True, fields=[ + PortField(name="id", type="str", description="Dokument-ID"), + PortField(name="sourceType", type="str", description="Quellformat (pdf, docx, …)"), + PortField(name="sourcePath", type="str", description="Quellpfad"), + PortField(name="children", type="List[Any]", description="StructuralNodes / Seiten"), + PortField(name="connection", type="ConnectionRef", required=False, + description="Optionale Verbindungsreferenz"), + PortField(name="source", type="SharePointFileRef", required=False, + description="Optionale Datei-Herkunft"), + ]), + "UdmNodeList": PortSchema(name="UdmNodeList", fields=[ + PortField(name="nodes", type="List[Any]", description="UDM StructuralNodes oder ContentBlocks"), + PortField(name="count", type="int", description="Anzahl"), + ]), + "ConsolidateResult": PortSchema(name="ConsolidateResult", fields=[ + PortField(name="result", type="Any", description="Konsolidiertes Ergebnis"), + PortField(name="mode", type="str", description="Konsolidierungsmodus"), + PortField(name="count", type="int", description="Anzahl verarbeiteter Elemente"), + ]), + + # ----------------------------------------------------------------- + # Shared sub-types (used inside Result schemas) + # ----------------------------------------------------------------- + "ProcessError": PortSchema(name="ProcessError", fields=[ + PortField(name="documentId", type="str", required=False, + description="Betroffenes Dokument (falls zuordbar)"), + PortField(name="stage", type="str", + description="Pipeline-Stufe: extract | parse | sync | validate | …"), + PortField(name="message", type="str", description="Fehlermeldung"), + PortField(name="code", type="str", required=False, description="Fehler-Code"), + ]), + "JournalLine": PortSchema(name="JournalLine", fields=[ + PortField(name="id", type="str", required=False, description="Buchungszeilen-ID"), + PortField(name="bookingDate", type="str", description="Buchungsdatum (ISO)"), + PortField(name="account", type="str", description="Konto"), + PortField(name="contraAccount", type="str", required=False, description="Gegenkonto"), + PortField(name="amount", type="float", description="Betrag"), + PortField(name="currency", type="str", required=False, description="Währung"), + PortField(name="text", type="str", required=False, description="Buchungstext"), + PortField(name="reference", type="str", required=False, description="Beleg-Referenz"), + ]), + + # ----------------------------------------------------------------- + # Trustee Action Results + # ----------------------------------------------------------------- + "TrusteeRefreshResult": PortSchema(name="TrusteeRefreshResult", fields=[ + PortField(name="syncCounts", type="Dict[str,int]", + description="Tabellen → Anzahl synchronisierter Datensätze"), + PortField(name="oldestBookingDate", type="str", required=False, + description="Ältestes Buchungsdatum (ISO)"), + PortField(name="newestBookingDate", type="str", required=False, + description="Neuestes Buchungsdatum (ISO)"), + PortField(name="durationMs", type="int", required=False, + description="Dauer in Millisekunden"), + PortField(name="featureInstance", type="FeatureInstanceRef", required=False, + description="Trustee-Instanz"), + PortField(name="errors", type="List[ProcessError]", required=False, + description="Fehler-Liste"), + ]), + "TrusteeProcessResult": PortSchema(name="TrusteeProcessResult", fields=[ + PortField(name="documents", type="List[Document]", + description="Verarbeitete Dokumente mit angereicherten Daten"), + PortField(name="processedCount", type="int", required=False, + description="Anzahl erfolgreich verarbeiteter Dokumente"), + PortField(name="failedCount", type="int", required=False, + description="Anzahl fehlgeschlagener Dokumente"), + PortField(name="featureInstance", type="FeatureInstanceRef", required=False, + description="Trustee-Instanz"), + PortField(name="errors", type="List[ProcessError]", required=False, + description="Fehler-Liste"), + ]), + "TrusteeSyncResult": PortSchema(name="TrusteeSyncResult", fields=[ + PortField(name="syncedCount", type="int", + description="Erfolgreich in das Buchhaltungssystem übertragene Datensätze"), + PortField(name="failedCount", type="int", required=False, + description="Fehlgeschlagene Übertragungen"), + PortField(name="journalLines", type="List[JournalLine]", required=False, + description="Erzeugte Buchungszeilen"), + PortField(name="featureInstance", type="FeatureInstanceRef", required=False, + description="Ziel-Trustee-Instanz"), + PortField(name="errors", type="List[ProcessError]", required=False, + description="Fehler-Liste"), + ]), + + # ----------------------------------------------------------------- + # Redmine Action Results + # ----------------------------------------------------------------- + "RedmineTicket": PortSchema(name="RedmineTicket", fields=[ + PortField(name="id", type="str", description="Ticket-ID"), + PortField(name="subject", type="str", description="Betreff"), + PortField(name="description", type="str", required=False, description="Beschreibung"), + PortField(name="status", type="str", description="Status-Name"), + PortField(name="tracker", type="str", required=False, + description="Tracker (Bug, Feature, Task, …)"), + PortField(name="priority", type="str", required=False, description="Priorität"), + PortField(name="assignee", type="str", required=False, description="Zugewiesen an"), + PortField(name="author", type="str", required=False, description="Autor"), + PortField(name="project", type="str", required=False, description="Projekt"), + PortField(name="createdOn", type="str", required=False, description="Erstellt (ISO)"), + PortField(name="updatedOn", type="str", required=False, description="Aktualisiert (ISO)"), + PortField(name="dueDate", type="str", required=False, description="Fälligkeitsdatum"), + PortField(name="featureInstance", type="FeatureInstanceRef", required=False, + description="Redmine-Instanz"), + ]), + "RedmineTicketList": PortSchema(name="RedmineTicketList", fields=[ + PortField(name="tickets", type="List[RedmineTicket]", description="Ticket-Liste"), + PortField(name="count", type="int", required=False, description="Anzahl Tickets"), + PortField(name="filters", type="Dict[str,Any]", required=False, + description="Angewendete Filter"), + PortField(name="featureInstance", type="FeatureInstanceRef", required=False, + description="Redmine-Instanz"), + ]), + "RedmineRelationList": PortSchema(name="RedmineRelationList", fields=[ + PortField(name="relations", type="List[Any]", description="Relationen"), + PortField(name="count", type="int", required=False, description="Anzahl in dieser Seite"), + PortField(name="totalMatched", type="int", required=False, + description="Gesamtanzahl nach Filter"), + PortField(name="offset", type="int", required=False, description="Pagination-Offset"), + PortField(name="hasMore", type="bool", required=False, description="Weitere Seiten verfügbar"), + ]), + "RedmineStats": PortSchema(name="RedmineStats", fields=[ + PortField(name="kpis", type="Dict[str,Any]", + description="Key Performance Indicators"), + PortField(name="throughput", type="Dict[str,Any]", required=False, + description="Durchsatz pro Zeitraum"), + PortField(name="statusDistribution", type="Dict[str,int]", required=False, + description="Tickets pro Status"), + PortField(name="backlog", type="Dict[str,Any]", required=False, + description="Backlog-Statistik"), + PortField(name="featureInstance", type="FeatureInstanceRef", required=False, + description="Redmine-Instanz"), + ]), + + # ----------------------------------------------------------------- + # ClickUp / SharePoint / Email helper results + # ----------------------------------------------------------------- + "TaskAttachmentRef": PortSchema(name="TaskAttachmentRef", fields=[ + PortField(name="taskId", type="str", description="Aufgaben-ID"), + PortField(name="attachmentId", type="str", required=False, description="Attachment-ID"), + PortField(name="fileName", type="str", required=False, description="Dateiname"), + PortField(name="url", type="str", required=False, description="Download-URL"), + ]), + "AttachmentSpec": PortSchema(name="AttachmentSpec", fields=[ + PortField(name="source", type="str", + description="Quellart: path | document | url", + enumValues=["path", "document", "url"]), + PortField(name="ref", type="str", + description="Referenzwert (Pfad / Document.id / URL)"), + PortField(name="fileName", type="str", required=False, + description="Override-Dateiname"), + PortField(name="mimeType", type="str", required=False, description="MIME-Override"), + ]), + + # ----------------------------------------------------------------- + # Expressions (replace string-typed condition / cron params) + # ----------------------------------------------------------------- + "CronExpression": PortSchema(name="CronExpression", fields=[ + PortField(name="expression", type="str", + description="Cron-Ausdruck (5 oder 6 Felder)"), + PortField(name="timezone", type="str", required=False, + description="IANA Timezone (z.B. Europe/Zurich)"), + ]), + "ConditionExpression": PortSchema(name="ConditionExpression", fields=[ + PortField(name="expression", type="str", description="Boolescher Ausdruck"), + PortField(name="syntax", type="str", required=False, + description="jmespath | jsonlogic | python | template", + enumValues=["jmespath", "jsonlogic", "python", "template"]), + ]), + + # ----------------------------------------------------------------- + # Semantic primitives (give meaning to scalar str values) + # ----------------------------------------------------------------- + "DateTime": PortSchema(name="DateTime", fields=[ + PortField(name="iso", type="str", description="ISO-8601 Datum/Zeit"), + PortField(name="timezone", type="str", required=False, + description="IANA Timezone"), + ]), + "Url": PortSchema(name="Url", fields=[ + PortField(name="url", type="str", description="Vollständige URL"), + PortField(name="label", type="str", required=False, description="Anzeigename"), + ]), +} + +# Primitives accepted as PortField.type in addition to catalog schema names. +PRIMITIVE_TYPES: frozenset = frozenset({ + "str", "int", "bool", "float", "Any", "Dict", "List", +}) diff --git a/modules/datamodels/datamodelViews.py b/modules/datamodels/datamodelViews.py index 7a327fd8..03a5a27f 100644 --- a/modules/datamodels/datamodelViews.py +++ b/modules/datamodels/datamodelViews.py @@ -24,7 +24,7 @@ from modules.datamodels.datamodelBilling import BillingTransaction from modules.datamodels.datamodelSubscription import MandateSubscription from modules.datamodels.datamodelUiLanguage import UiLanguageSet from modules.datamodels.datamodelRbac import Role -from modules.features.neutralization.datamodelFeatureNeutralizer import DataNeutralizerAttributes +from modules.datamodels.datamodelFeatures import DataNeutralizerAttributes from modules.shared.i18nRegistry import i18nModel @@ -243,7 +243,7 @@ class RoleView(Role): # Automation Workflow — dashboard view with synthesized fields # ============================================================================ -from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow +from modules.datamodels.datamodelFeatures import AutoWorkflow @i18nModel("Workflow (Ansicht)") diff --git a/modules/datamodels/datamodelWorkflowAutomation.py b/modules/datamodels/datamodelWorkflowAutomation.py new file mode 100644 index 00000000..5f9cb7b2 --- /dev/null +++ b/modules/datamodels/datamodelWorkflowAutomation.py @@ -0,0 +1,579 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +"""Workflow Automation models: AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask. + +Canonical location for all workflow-engine data models used across the platform. +""" + +from enum import Enum +from typing import Dict, Any, List, Optional +from pydantic import Field +from modules.datamodels.datamodelBase import PowerOnModel +from modules.shared.i18nRegistry import i18nModel +import uuid + + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + +class AutoWorkflowStatus(str, Enum): + DRAFT = "draft" + PUBLISHED = "published" + ARCHIVED = "archived" + + +class AutoRunStatus(str, Enum): + RUNNING = "running" + PAUSED = "paused" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + +class AutoStepStatus(str, Enum): + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + SKIPPED = "skipped" + + +class AutoTaskStatus(str, Enum): + PENDING = "pending" + COMPLETED = "completed" + CANCELLED = "cancelled" + EXPIRED = "expired" + + +class AutoTemplateScope(str, Enum): + USER = "user" + INSTANCE = "instance" + MANDATE = "mandate" + SYSTEM = "system" + + +GRAPHICAL_EDITOR_DATABASE = "poweron_graphicaleditor" + + +# --------------------------------------------------------------------------- +# AutoWorkflow +# --------------------------------------------------------------------------- + +@i18nModel("Workflow") +class AutoWorkflow(PowerOnModel): + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"}, + ) + mandateId: str = Field( + description="Mandate ID", + json_schema_extra={ + "frontend_type": "text", + "frontend_readonly": True, + "frontend_required": False, + "label": "Mandanten-ID", + "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}, + }, + ) + featureInstanceId: str = Field( + description="Feature instance ID (GE owner instance / RBAC scope)", + json_schema_extra={ + "frontend_type": "text", + "frontend_readonly": True, + "frontend_required": False, + "label": "Feature-Instanz-ID", + "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}, + }, + ) + targetFeatureInstanceId: Optional[str] = Field( + default=None, + description="Target feature instance for execution data scope. NULL for templates, mandatory for non-templates.", + json_schema_extra={ + "frontend_type": "select", + "frontend_readonly": False, + "frontend_required": False, + "label": "Ziel-Instanz", + "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}, + }, + ) + label: str = Field( + description="User-friendly workflow name", + json_schema_extra={"frontend_type": "text", "frontend_required": True, "label": "Bezeichnung"}, + ) + description: Optional[str] = Field( + default=None, + description="Workflow description", + json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Beschreibung"}, + ) + tags: List[str] = Field( + default_factory=list, + description="Tags for categorization", + json_schema_extra={"frontend_type": "tags", "frontend_required": False, "label": "Tags"}, + ) + isTemplate: bool = Field( + default=False, + description="Whether this workflow is a template", + json_schema_extra={ + "frontend_type": "checkbox", + "frontend_required": False, + "label": "Ist Vorlage", + "frontend_format_labels": ["Ja", "-", "Nein"], + }, + ) + templateSourceId: Optional[str] = Field( + default=None, + description="ID of the template this workflow was created from", + json_schema_extra={ + "frontend_type": "text", + "frontend_readonly": True, + "frontend_required": False, + "label": "Vorlagen-Quelle", + "fk_target": { + "db": "poweron_graphicaleditor", + "table": "AutoWorkflow", + "labelField": "label", + "softFk": True, + }, + }, + ) + templateScope: Optional[str] = Field( + default=None, + description="Template scope: user, instance, mandate, system (AutoTemplateScope)", + json_schema_extra={ + "frontend_type": "select", + "frontend_required": False, + "label": "Vorlagen-Bereich", + "frontend_options": [ + {"value": "user", "label": "Meine"}, + {"value": "instance", "label": "Instanz"}, + {"value": "mandate", "label": "Mandant"}, + {"value": "system", "label": "System"}, + ], + }, + ) + sharedReadOnly: bool = Field( + default=False, + description="If true, shared template is read-only for non-owners", + json_schema_extra={ + "frontend_type": "checkbox", + "frontend_required": False, + "label": "Freigabe nur-lesen", + "frontend_format_labels": ["Ja", "-", "Nein"], + }, + ) + currentVersionId: Optional[str] = Field( + default=None, + description="ID of the currently published AutoVersion", + json_schema_extra={ + "frontend_type": "text", + "frontend_readonly": True, + "frontend_required": False, + "label": "Aktuelle Version", + "fk_target": {"db": "poweron_graphicaleditor", "table": "AutoVersion", "labelField": "versionNumber"}, + }, + ) + active: bool = Field( + default=True, + description="Whether workflow is active", + json_schema_extra={ + "frontend_type": "checkbox", + "frontend_required": False, + "label": "Aktiv", + "frontend_format_labels": ["Ja", "-", "Nein"], + }, + ) + eventId: Optional[str] = Field( + default=None, + description="Scheduler event ID for incremental sync", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Event-ID"}, + ) + notifyOnFailure: bool = Field( + default=True, + description="Send notification (in-app + email) when a run fails", + json_schema_extra={ + "frontend_type": "checkbox", + "frontend_required": False, + "label": "Bei Fehler benachrichtigen", + "frontend_format_labels": ["Ja", "-", "Nein"], + }, + ) + graph: Dict[str, Any] = Field( + default_factory=dict, + description="Graph with nodes and connections (legacy; prefer AutoVersion.graph)", + json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Graph"}, + ) + invocations: List[Dict[str, Any]] = Field( + default_factory=list, + description="Entry points / starts (manual, form, schedule, webhook, ...)", + json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Starts / Einstiegspunkte"}, + ) + + +# --------------------------------------------------------------------------- +# AutoVersion +# --------------------------------------------------------------------------- + +@i18nModel("Workflow-Version") +class AutoVersion(PowerOnModel): + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"}, + ) + workflowId: str = Field( + description="FK -> AutoWorkflow", + json_schema_extra={ + "frontend_type": "text", + "frontend_readonly": True, + "frontend_required": True, + "label": "Workflow-ID", + "fk_target": {"db": "poweron_graphicaleditor", "table": "AutoWorkflow", "labelField": "label"}, + }, + ) + versionNumber: int = Field( + default=1, + description="Incrementing version number", + json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Version"}, + ) + status: str = Field( + default=AutoWorkflowStatus.DRAFT.value, + description="Version status: draft, published, archived", + json_schema_extra={ + "frontend_type": "select", + "frontend_required": False, + "label": "Status", + "frontend_options": [ + {"value": "draft", "label": "Entwurf"}, + {"value": "published", "label": "Veröffentlicht"}, + {"value": "archived", "label": "Archiviert"}, + ], + }, + ) + graph: Dict[str, Any] = Field( + default_factory=dict, + description="Graph with nodes and connections (incl. node parameters)", + json_schema_extra={"frontend_type": "textarea", "frontend_required": True, "label": "Graph"}, + ) + invocations: List[Dict[str, Any]] = Field( + default_factory=list, + description="Entry points / starts for this version", + json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Einstiegspunkte"}, + ) + publishedAt: Optional[float] = Field( + default=None, + description="Timestamp when version was published", + json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Veröffentlicht am"}, + ) + publishedBy: Optional[str] = Field( + default=None, + description="User ID who published this version", + json_schema_extra={ + "frontend_type": "text", + "frontend_readonly": True, + "frontend_required": False, + "label": "Veröffentlicht von", + "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}, + }, + ) + + +# --------------------------------------------------------------------------- +# AutoRun +# --------------------------------------------------------------------------- + +@i18nModel("Workflow-Ausführung") +class AutoRun(PowerOnModel): + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"}, + ) + workflowId: str = Field( + description="Workflow ID", + json_schema_extra={ + "frontend_type": "text", + "frontend_readonly": True, + "frontend_required": True, + "label": "Workflow-ID", + "fk_target": {"db": "poweron_graphicaleditor", "table": "AutoWorkflow", "labelField": "label"}, + }, + ) + label: Optional[str] = Field( + default=None, + description="Human-readable run label, set at creation from workflow name or caller", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Bezeichnung"}, + ) + mandateId: Optional[str] = Field( + default=None, + description="Mandate ID for cross-feature querying", + json_schema_extra={ + "frontend_type": "text", + "frontend_readonly": True, + "frontend_required": False, + "label": "Mandanten-ID", + "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}, + }, + ) + ownerId: Optional[str] = Field( + default=None, + description="User ID who triggered this run", + json_schema_extra={ + "frontend_type": "text", + "frontend_readonly": True, + "frontend_required": False, + "label": "Auslöser", + "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}, + }, + ) + versionId: Optional[str] = Field( + default=None, + description="AutoVersion ID used for this run", + json_schema_extra={ + "frontend_type": "text", + "frontend_readonly": True, + "frontend_required": False, + "label": "Versions-ID", + "fk_target": {"db": "poweron_graphicaleditor", "table": "AutoVersion", "labelField": "versionNumber"}, + }, + ) + status: str = Field( + default=AutoRunStatus.RUNNING.value, + description="Status: running, paused, completed, failed, cancelled", + json_schema_extra={ + "frontend_type": "select", + "frontend_required": False, + "label": "Status", + "frontend_options": [ + {"value": "running", "label": "Läuft"}, + {"value": "paused", "label": "Pausiert"}, + {"value": "completed", "label": "Abgeschlossen"}, + {"value": "failed", "label": "Fehlgeschlagen"}, + {"value": "cancelled", "label": "Abgebrochen"}, + ], + }, + ) + trigger: Dict[str, Any] = Field( + default_factory=dict, + description="Trigger info (type, entryPointId, payload, etc.)", + json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Auslöser"}, + ) + startedAt: Optional[float] = Field( + default=None, + description="Run start timestamp", + json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Gestartet am"}, + ) + completedAt: Optional[float] = Field( + default=None, + description="Run completion timestamp", + json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Abgeschlossen am"}, + ) + nodeOutputs: Dict[str, Any] = Field( + default_factory=dict, + description="Outputs from executed nodes", + json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Node-Ausgaben"}, + ) + currentNodeId: Optional[str] = Field( + default=None, + description="Node ID when paused (human task / email wait)", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Aktueller Knoten"}, + ) + resumeContext: Dict[str, Any] = Field( + default_factory=dict, + description="Context for resume (connectionMap, inputSources, etc.)", + json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Wiederaufnahme-Kontext"}, + ) + error: Optional[str] = Field( + default=None, + description="Error message if failed", + json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False, "label": "Fehler"}, + ) + costTokens: int = Field( + default=0, + description="Total tokens consumed by AI nodes", + json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Verbrauchte Tokens"}, + ) + costCredits: float = Field( + default=0.0, + description="Total credits consumed", + json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Verbrauchte Credits"}, + ) + + +# --------------------------------------------------------------------------- +# AutoStepLog +# --------------------------------------------------------------------------- + +@i18nModel("Schritt-Protokoll") +class AutoStepLog(PowerOnModel): + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"}, + ) + runId: str = Field( + description="FK -> AutoRun", + json_schema_extra={ + "frontend_type": "text", + "frontend_readonly": True, + "frontend_required": True, + "label": "Lauf-ID", + "fk_target": {"db": "poweron_graphicaleditor", "table": "AutoRun", "labelField": "label"}, + }, + ) + nodeId: str = Field( + description="Node ID in the graph", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Knoten-ID"}, + ) + nodeType: str = Field( + description="Node type (e.g. ai.chat, email.send)", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Knotentyp"}, + ) + status: str = Field( + default=AutoStepStatus.PENDING.value, + description="Step status: pending, running, completed, failed, skipped", + json_schema_extra={ + "frontend_type": "select", + "frontend_required": False, + "label": "Status", + "frontend_options": [ + {"value": "pending", "label": "Wartend"}, + {"value": "running", "label": "Läuft"}, + {"value": "completed", "label": "Abgeschlossen"}, + {"value": "failed", "label": "Fehlgeschlagen"}, + {"value": "skipped", "label": "Übersprungen"}, + ], + }, + ) + inputSnapshot: Dict[str, Any] = Field( + default_factory=dict, + description="Snapshot of inputs at execution time", + json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Eingabe-Snapshot"}, + ) + output: Dict[str, Any] = Field( + default_factory=dict, + description="Node output", + json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Ausgabe"}, + ) + error: Optional[str] = Field( + default=None, + description="Error message if step failed", + json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False, "label": "Fehler"}, + ) + startedAt: Optional[float] = Field( + default=None, + description="Step start timestamp", + json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Gestartet am"}, + ) + completedAt: Optional[float] = Field( + default=None, + description="Step completion timestamp", + json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Abgeschlossen am"}, + ) + durationMs: Optional[int] = Field( + default=None, + description="Execution duration in milliseconds", + json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Dauer (ms)"}, + ) + tokensUsed: int = Field( + default=0, + description="Tokens consumed by this step", + json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Verbrauchte Tokens"}, + ) + retryCount: int = Field( + default=0, + description="Number of retries executed", + json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Wiederholungen"}, + ) + + +# --------------------------------------------------------------------------- +# AutoTask +# --------------------------------------------------------------------------- + +@i18nModel("Aufgabe") +class AutoTask(PowerOnModel): + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"}, + ) + runId: str = Field( + description="FK -> AutoRun", + json_schema_extra={ + "frontend_type": "text", + "frontend_readonly": True, + "frontend_required": True, + "label": "Lauf-ID", + "fk_target": {"db": "poweron_graphicaleditor", "table": "AutoRun", "labelField": "label"}, + }, + ) + workflowId: str = Field( + description="Workflow ID", + json_schema_extra={ + "frontend_type": "text", + "frontend_readonly": True, + "frontend_required": True, + "label": "Workflow-ID", + "fk_target": {"db": "poweron_graphicaleditor", "table": "AutoWorkflow", "labelField": "label"}, + }, + ) + nodeId: str = Field( + description="Node ID in the graph", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Knoten-ID"}, + ) + nodeType: str = Field( + description="Node type: form, approval, upload, comment, review, selection, confirmation", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Knotentyp"}, + ) + config: Dict[str, Any] = Field( + default_factory=dict, + description="Node config (form schema, approval text, etc.)", + json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Konfiguration"}, + ) + assigneeId: Optional[str] = Field( + default=None, + description="User ID assigned to complete the task", + json_schema_extra={ + "frontend_type": "text", + "frontend_readonly": False, + "frontend_required": False, + "label": "Zugewiesen an", + "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}, + }, + ) + status: str = Field( + default=AutoTaskStatus.PENDING.value, + description="Status: pending, completed, cancelled, expired", + json_schema_extra={ + "frontend_type": "select", + "frontend_required": False, + "label": "Status", + "frontend_options": [ + {"value": "pending", "label": "Wartend"}, + {"value": "completed", "label": "Abgeschlossen"}, + {"value": "cancelled", "label": "Abgebrochen"}, + {"value": "expired", "label": "Abgelaufen"}, + ], + }, + ) + result: Optional[Dict[str, Any]] = Field( + default=None, + description="Task result (form data, approval decision, etc.)", + json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Ergebnis"}, + ) + expiresAt: Optional[float] = Field( + default=None, + description="Expiration timestamp for the task", + json_schema_extra={"frontend_type": "timestamp", "frontend_required": False, "label": "Läuft ab am"}, + ) + + +# --------------------------------------------------------------------------- +# Backward-compatible aliases +# --------------------------------------------------------------------------- + +Automation2Workflow = AutoWorkflow +Automation2WorkflowRun = AutoRun +Automation2HumanTask = AutoTask diff --git a/modules/shared/jsonContinuation.py b/modules/datamodels/jsonContinuation.py similarity index 99% rename from modules/shared/jsonContinuation.py rename to modules/datamodels/jsonContinuation.py index 9d282c62..d4ee81f9 100644 --- a/modules/shared/jsonContinuation.py +++ b/modules/datamodels/jsonContinuation.py @@ -21,7 +21,7 @@ Modulkonstanten: Maximale Zeichen für den Overlap Context Verwendung: - >>> from modules.shared.jsonContinuation import getContexts + >>> from modules.datamodels.jsonContinuation import getContexts >>> jsonStr = '{"users": [{"name": "John", "bio": "Hello Wor' >>> contexts = getContexts(jsonStr) >>> print(contexts.overlapContext) diff --git a/modules/migrations/__init__.py b/modules/dbHelpers/__init__.py similarity index 100% rename from modules/migrations/__init__.py rename to modules/dbHelpers/__init__.py diff --git a/modules/shared/aiAuditLogger.py b/modules/dbHelpers/aiAuditLogger.py similarity index 99% rename from modules/shared/aiAuditLogger.py rename to modules/dbHelpers/aiAuditLogger.py index 5da105a8..060ace33 100644 --- a/modules/shared/aiAuditLogger.py +++ b/modules/dbHelpers/aiAuditLogger.py @@ -3,7 +3,7 @@ """AI Audit Logger — records every AI provider call for compliance reporting. Usage: - from modules.shared.aiAuditLogger import aiAuditLogger + from modules.dbHelpers.aiAuditLogger import aiAuditLogger aiAuditLogger.logAiCall(userId=..., mandateId=..., ...) """ diff --git a/modules/shared/auditLogger.py b/modules/dbHelpers/auditLogger.py similarity index 99% rename from modules/shared/auditLogger.py rename to modules/dbHelpers/auditLogger.py index 0f9c2b39..a5b0ec9e 100644 --- a/modules/shared/auditLogger.py +++ b/modules/dbHelpers/auditLogger.py @@ -14,6 +14,7 @@ GDPR Requirements Addressed: """ import logging +import time from datetime import datetime from typing import Optional, Dict, Any @@ -395,7 +396,6 @@ class AuditLogger: try: from modules.datamodels.datamodelAudit import AuditLogEntry - import time # Calculate cutoff timestamp cutoffTimestamp = time.time() - (retentionDays * 24 * 60 * 60) diff --git a/modules/shared/dbMultiTenantOptimizations.py b/modules/dbHelpers/dbMultiTenantOptimizations.py similarity index 99% rename from modules/shared/dbMultiTenantOptimizations.py rename to modules/dbHelpers/dbMultiTenantOptimizations.py index 9b5a15b4..4b8a5e78 100644 --- a/modules/shared/dbMultiTenantOptimizations.py +++ b/modules/dbHelpers/dbMultiTenantOptimizations.py @@ -7,7 +7,7 @@ Applies indexes, immutable triggers, and foreign key constraints for the junction tables used in the multi-tenant mandate model. Usage: - from modules.shared.dbMultiTenantOptimizations import applyMultiTenantOptimizations + from modules.dbHelpers.dbMultiTenantOptimizations import applyMultiTenantOptimizations # Call after database tables are created applyMultiTenantOptimizations(dbConnector) diff --git a/modules/shared/dbRegistry.py b/modules/dbHelpers/dbRegistry.py similarity index 97% rename from modules/shared/dbRegistry.py rename to modules/dbHelpers/dbRegistry.py index 4626a100..8c24d664 100644 --- a/modules/shared/dbRegistry.py +++ b/modules/dbHelpers/dbRegistry.py @@ -4,7 +4,7 @@ Dynamic database registry — each interface self-registers its DB on import. Usage in any interfaceDb*.py / interfaceFeature*.py: - from modules.shared.dbRegistry import registerDatabase + from modules.dbHelpers.dbRegistry import registerDatabase registerDatabase("poweron_xyz") """ diff --git a/modules/dbHelpers/fkLabelResolver.py b/modules/dbHelpers/fkLabelResolver.py new file mode 100644 index 00000000..940866d5 --- /dev/null +++ b/modules/dbHelpers/fkLabelResolver.py @@ -0,0 +1,196 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +FK label resolution: resolve foreign-key IDs to human-readable labels. + +Works with the fk_target annotations on Pydantic models (see fkRegistry.py) +to auto-build label resolvers for paginated record sets. +""" + +import logging +from functools import partial +from typing import Any, Callable, Dict, List, Optional + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Individual FK label resolvers (db, ids) -> {id: label} +# --------------------------------------------------------------------------- + +def resolveMandateLabels(db, ids: List[str]) -> Dict[str, Optional[str]]: + """Resolve mandate IDs to labels. Returns None (not the ID!) for + unresolvable entries so the caller can distinguish "resolved" from "missing". + """ + from modules.datamodels.datamodelUam import Mandate + uniqueIds = list(set(ids)) + records = db.getRecordset(Mandate, recordFilter={"id": uniqueIds}) or [] + found: Dict[str, dict] = {} + for rec in records: + mid = rec.get("id", "") + found[mid] = rec + result: Dict[str, Optional[str]] = {} + for mid in ids: + m = found.get(mid) + label = (m.get("label") or m.get("name")) if m else None + if not label: + logger.debug("resolveMandateLabels: no label for id=%s (found=%s)", mid, m is not None) + result[mid] = label or None + return result + + +def resolveInstanceLabels(db, ids: List[str]) -> Dict[str, Optional[str]]: + """Resolve feature-instance IDs to labels. Returns None for unresolvable.""" + from modules.datamodels.datamodelFeatures import FeatureInstance + result: Dict[str, Optional[str]] = {} + for iid in ids: + records = db.getRecordset(FeatureInstance, recordFilter={"id": iid}) + if records: + label = records[0].get("label") or None + result[iid] = label + else: + logger.debug("resolveInstanceLabels: no label for id=%s", iid) + result[iid] = None + return result + + +def resolveUserLabels(db, ids: List[str]) -> Dict[str, Optional[str]]: + """Resolve user IDs to display names. Returns None for unresolvable.""" + from modules.datamodels.datamodelUam import UserInDB as _UserInDB + uniqueIds = list(set(ids)) + users = db.getRecordset( + _UserInDB, + recordFilter={"id": uniqueIds}, + ) + result: Dict[str, Optional[str]] = {} + found: Dict[str, dict] = {} + for u in (users or []): + uid = u.get("id", "") + found[uid] = u + for uid in ids: + u = found.get(uid) + if u: + result[uid] = u.get("displayName") or u.get("username") or u.get("email") or None + else: + result[uid] = None + return result + + +def resolveRoleLabels(db, ids: List[str]) -> Dict[str, Optional[str]]: + """Resolve Role.id to roleLabel. Returns None for unresolvable.""" + if not ids: + return {} + from modules.datamodels.datamodelRbac import Role as _Role + recs = db.getRecordset( + _Role, + recordFilter={"id": list(set(ids))}, + ) or [] + out: Dict[str, Optional[str]] = {i: None for i in ids} + for r in recs: + rid = r.get("id") + if rid: + out[rid] = r.get("roleLabel") or None + for rid in ids: + if out.get(rid) is None: + logger.debug("resolveRoleLabels: no label for id=%s", rid) + return out + + +# --------------------------------------------------------------------------- +# Resolver registry +# --------------------------------------------------------------------------- + +_BUILTIN_FK_RESOLVERS: Dict[str, Callable] = { + "Mandate": resolveMandateLabels, + "FeatureInstance": resolveInstanceLabels, + "UserInDB": resolveUserLabels, + "Role": resolveRoleLabels, +} + + +def buildLabelResolversFromModel( + modelClass: type, + db=None, +) -> Dict[str, Callable[[List[str]], Dict[str, str]]]: + """ + Auto-build labelResolvers dict from ``json_schema_extra.fk_target`` on a Pydantic model. + Maps field names to resolver functions when the target table has a registered builtin + resolver and ``fk_target.labelField`` is set (non-None). + + When ``db`` is provided, the returned resolvers are pre-bound with partial(resolver, db) + so they can be called as resolver(ids). + """ + resolvers: Dict[str, Callable[[List[str]], Dict[str, str]]] = {} + for name, fieldInfo in modelClass.model_fields.items(): + extra = fieldInfo.json_schema_extra + if not extra or not isinstance(extra, dict): + continue + tgt = extra.get("fk_target") + if not isinstance(tgt, dict): + continue + if tgt.get("labelField") is None: + continue + fkModel = tgt.get("table") + if fkModel and fkModel in _BUILTIN_FK_RESOLVERS: + fn = _BUILTIN_FK_RESOLVERS[fkModel] + resolvers[name] = partial(fn, db) if db else fn + return resolvers + + +def enrichRowsWithFkLabels( + rows: List[Dict[str, Any]], + modelClass: type = None, + *, + db=None, + labelResolvers: Optional[Dict[str, Callable[[List[str]], Dict[str, Optional[str]]]]] = None, + extraResolvers: Optional[Dict[str, Callable[[List[str]], Dict[str, Optional[str]]]]] = None, +) -> List[Dict[str, Any]]: + """Add ``{field}Label`` columns to each row for every FK field that has a + registered resolver. + + ``modelClass`` — if provided, resolvers are auto-built from ``fk_target`` + annotations on the Pydantic model (via ``buildLabelResolversFromModel``). + Requires ``db`` to be passed. + + ``labelResolvers`` — explicit resolver map that overrides auto-built ones. + Each resolver has signature ``(ids: List[str]) -> Dict[str, Optional[str]]``. + + ``extraResolvers`` — merged on top of auto-built / explicit resolvers. Use + for ad-hoc fields that are not FK-annotated on the model (e.g. + ``createdByUserId`` on billing transactions). + + If a label cannot be resolved the ``{field}Label`` value is ``None`` + (never the raw ID — that would reintroduce the silent-truncation bug). + """ + resolvers: Dict[str, Callable] = {} + + if modelClass is not None and labelResolvers is None: + resolvers = buildLabelResolversFromModel(modelClass, db) + elif labelResolvers is not None: + resolvers = dict(labelResolvers) + + if extraResolvers: + resolvers.update(extraResolvers) + + if not resolvers or not rows: + return rows + + for field, resolver in resolvers.items(): + ids = list({str(r.get(field)) for r in rows if r.get(field)}) + if not ids: + continue + try: + labelMap = resolver(ids) + except Exception as e: + logger.error("enrichRowsWithFkLabels: resolver for '%s' raised: %s", field, e) + labelMap = {} + + labelKey = f"{field}Label" + for r in rows: + fkVal = r.get(field) + if fkVal: + r[labelKey] = labelMap.get(str(fkVal)) + else: + r[labelKey] = None + + return rows diff --git a/modules/shared/fkRegistry.py b/modules/dbHelpers/fkRegistry.py similarity index 97% rename from modules/shared/fkRegistry.py rename to modules/dbHelpers/fkRegistry.py index 9f3d63c4..9ca5b1ec 100644 --- a/modules/shared/fkRegistry.py +++ b/modules/dbHelpers/fkRegistry.py @@ -14,7 +14,7 @@ for the *target* side. By collecting all such declarations we know which DB each table lives in — no extra registration step needed. Usage: - from modules.shared.fkRegistry import getFkRelationships + from modules.dbHelpers.fkRegistry import getFkRelationships rels = getFkRelationships() """ @@ -32,7 +32,7 @@ logger = logging.getLogger(__name__) _modelsLoaded = False -def _ensureModelsLoaded() -> None: +def ensureModelsLoaded() -> None: """Import all datamodel modules so that __init_subclass__ fills MODEL_REGISTRY. In a running server the interfaces import the datamodels automatically. @@ -98,7 +98,7 @@ def _buildTableToDbMap() -> Dict[str, str]: 2. For models still unmapped, query each registered database's catalog (information_schema) to find the table there. """ - _ensureModelsLoaded() + ensureModelsLoaded() mapping: Dict[str, str] = {} for modelCls in MODEL_REGISTRY.values(): @@ -117,7 +117,7 @@ def _buildTableToDbMap() -> Dict[str, str]: unmapped = [name for name in MODEL_REGISTRY if name not in mapping] if unmapped: try: - from modules.shared.dbRegistry import getRegisteredDatabases + from modules.dbHelpers.dbRegistry import getRegisteredDatabases _resolveUnmappedTablesFromCatalog(mapping, unmapped, getRegisteredDatabases()) except Exception as e: logger.warning(f"Could not resolve unmapped tables from catalog: {e}") @@ -260,7 +260,7 @@ def validateFkTargets() -> List[str]: Each ``fk_target`` must contain exactly ``db``, ``table``, and ``labelField`` (``labelField`` may be ``None``). """ - _ensureModelsLoaded() + ensureModelsLoaded() errors: List[str] = [] for tableName, modelCls in MODEL_REGISTRY.items(): for fieldName, fieldInfo in modelCls.model_fields.items(): diff --git a/modules/dbHelpers/paginationHelpers.py b/modules/dbHelpers/paginationHelpers.py new file mode 100644 index 00000000..981cd411 --- /dev/null +++ b/modules/dbHelpers/paginationHelpers.py @@ -0,0 +1,543 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Pagination, filtering and sorting helpers for paginated record sets. + +Provides unified logic for: +- mode=filterValues: distinct column values for filter dropdowns (cross-filtered) +- mode=ids: all IDs matching current filters (for bulk selection) +- In-memory equivalents for enriched/non-SQL routes +- FK-label-aware sorting (cross-DB) +""" + +import copy +import json +import logging +import math +from datetime import datetime, timezone +from typing import Any, Callable, Dict, List, Optional + +from fastapi.responses import JSONResponse + +from modules.datamodels.datamodelPagination import ( + PaginationParams, + normalize_pagination_dict, +) +from modules.shared.i18nRegistry import resolveText + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Cross-filter pagination parsing +# --------------------------------------------------------------------------- + +def parseCrossFilterPagination( + column: str, + paginationJson: Optional[str], +) -> Optional[PaginationParams]: + """ + Parse pagination JSON, remove the requested column from filters (cross-filtering), + and drop sort — used for filter-values requests. + """ + if not paginationJson: + return None + try: + paginationDict = json.loads(paginationJson) + if not paginationDict: + return None + paginationDict = normalize_pagination_dict(paginationDict) + filters = paginationDict.get("filters", {}) + filters.pop(column, None) + paginationDict["filters"] = filters + paginationDict.pop("sort", None) + return PaginationParams(**paginationDict) + except (json.JSONDecodeError, ValueError, TypeError): + return None + + +def parsePaginationForIds( + paginationJson: Optional[str], +) -> Optional[PaginationParams]: + """ + Parse pagination JSON for mode=ids — keep filters, drop sort and page/pageSize. + """ + if not paginationJson: + return None + try: + paginationDict = json.loads(paginationJson) + if not paginationDict: + return None + paginationDict = normalize_pagination_dict(paginationDict) + paginationDict.pop("sort", None) + return PaginationParams(**paginationDict) + except (json.JSONDecodeError, ValueError, TypeError): + return None + + +# --------------------------------------------------------------------------- +# SQL-based helpers (delegate to DB connector) +# --------------------------------------------------------------------------- + +def handleFilterValuesMode( + db, + modelClass: type, + column: str, + paginationJson: Optional[str] = None, + recordFilter: Optional[Dict[str, Any]] = None, + enrichFn: Optional[Callable[[str, Optional[PaginationParams], Optional[Dict[str, Any]]], List[str]]] = None, +) -> List[str]: + """ + SQL-based distinct column values with cross-filtering. + + If enrichFn is provided and the column is enriched (computed/joined), + enrichFn(column, crossPagination, recordFilter) is called instead of SQL DISTINCT. + """ + crossPagination = parseCrossFilterPagination(column, paginationJson) + + if enrichFn: + try: + result = enrichFn(column, crossPagination, recordFilter) + if result is not None: + return JSONResponse(content=result) + except Exception as e: + logger.warning(f"handleFilterValuesMode enrichFn failed for {column}: {e}") + + try: + values = db.getDistinctColumnValues( + modelClass, column, + pagination=crossPagination, + recordFilter=recordFilter, + ) or [] + return JSONResponse(content=values) + except Exception as e: + logger.error(f"handleFilterValuesMode SQL failed for {modelClass.__name__}.{column}: {e}") + return JSONResponse(content=[]) + + +def handleIdsMode( + db, + modelClass: type, + paginationJson: Optional[str] = None, + recordFilter: Optional[Dict[str, Any]] = None, + idField: str = "id", +) -> List[str]: + """ + Return all IDs matching the current filters (no LIMIT/OFFSET). + Uses the same WHERE clause as getRecordsetPaginated. + """ + pagination = parsePaginationForIds(paginationJson) + table = modelClass.__name__ + + try: + if not db._ensureTableExists(modelClass): + return JSONResponse(content=[]) + + where_clause, _, _, values, _ = db._buildPaginationClauses( + modelClass, pagination, recordFilter, + ) + + sql = f'SELECT "{idField}"::TEXT AS val FROM "{table}"{where_clause} ORDER BY "{idField}"' + + with db.borrowCursor() as cursor: + cursor.execute(sql, values) + return JSONResponse(content=[row["val"] for row in cursor.fetchall()]) + except Exception as e: + logger.error(f"handleIdsMode failed for {table}: {e}") + return JSONResponse(content=[]) + + +# --------------------------------------------------------------------------- +# In-memory helpers (for enriched / non-SQL routes) +# --------------------------------------------------------------------------- + +def applyFiltersAndSort( + items: List[Dict[str, Any]], + paginationParams: Optional[PaginationParams], +) -> List[Dict[str, Any]]: + """ + Apply filters and sorting to a list of dicts in-memory. + Does NOT paginate (no page/pageSize slicing). + """ + if not paginationParams: + return items + + result = list(items) + + if paginationParams.filters: + filters = paginationParams.filters + searchTerm = filters.get("search", "").lower() if filters.get("search") else None + + if searchTerm: + result = [ + item for item in result + if any( + searchTerm in str(v).lower() + for v in item.values() + if v is not None + ) + ] + + for field, filterValue in filters.items(): + if field == "search": + continue + + if isinstance(filterValue, dict) and "operator" in filterValue: + operator = filterValue.get("operator", "equals") + value = filterValue.get("value") + else: + operator = "equals" + value = filterValue + + if value is None: + result = [ + item for item in result + if item.get(field) is None or item.get(field) == "" + ] + continue + + if value == "": + continue + + result = [ + item for item in result + if _matchesFilter(item, field, operator, value) + ] + + if paginationParams.sort: + for sortField in reversed(paginationParams.sort): + fieldName = sortField.field + ascending = sortField.direction == "asc" + + noneItems = [item for item in result if item.get(fieldName) is None] + nonNoneItems = [item for item in result if item.get(fieldName) is not None] + + def _getSortKey(item: Dict[str, Any], _fn=fieldName): + value = item.get(_fn) + if isinstance(value, bool): + return (0, int(value), "") + if isinstance(value, (int, float)): + return (0, value, "") + return (1, 0, str(value).lower()) + + nonNoneItems = sorted(nonNoneItems, key=_getSortKey, reverse=not ascending) + result = nonNoneItems + noneItems + + return result + + +def _matchesFilter(item: Dict[str, Any], field: str, operator: str, value: Any) -> bool: + """Single-field filter match for in-memory filtering.""" + itemValue = item.get(field) + if itemValue is None: + return False + + itemStr = str(itemValue).lower() + valueStr = str(value).lower() + + if operator in ("equals", "eq"): + return itemStr == valueStr + if operator == "contains": + return valueStr in itemStr + if operator == "startsWith": + return itemStr.startswith(valueStr) + if operator == "endsWith": + return itemStr.endswith(valueStr) + if operator in ("gt", "gte", "lt", "lte"): + try: + itemNum = float(itemValue) + valueNum = float(value) + if operator == "gt": + return itemNum > valueNum + if operator == "gte": + return itemNum >= valueNum + if operator == "lt": + return itemNum < valueNum + return itemNum <= valueNum + except (ValueError, TypeError): + return False + if operator == "between": + return _matchesBetween(itemValue, itemStr, value) + if operator == "in": + if isinstance(value, list): + return itemStr in [str(x).lower() for x in value] + return False + if operator == "notIn": + if isinstance(value, list): + return itemStr not in [str(x).lower() for x in value] + return True + return True + + +def _matchesBetween(itemValue: Any, itemStr: str, value: Any) -> bool: + """Handle 'between' operator for date ranges and numeric ranges.""" + if not isinstance(value, dict): + return True + fromVal = value.get("from", "") + toVal = value.get("to", "") + if not fromVal and not toVal: + return True + try: + fromTs = None + toTs = None + if fromVal: + fromTs = datetime.strptime(str(fromVal), "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp() + if toVal: + toTs = datetime.strptime(str(toVal), "%Y-%m-%d").replace( + hour=23, minute=59, second=59, tzinfo=timezone.utc + ).timestamp() + itemNum = float(itemValue) if not isinstance(itemValue, (int, float)) else itemValue + if itemNum > 10000000000: + itemNum = itemNum / 1000 + if fromTs is not None and toTs is not None: + return fromTs <= itemNum <= toTs + if fromTs is not None: + return itemNum >= fromTs + if toTs is not None: + return itemNum <= toTs + except (ValueError, TypeError): + try: + itemNum = float(itemValue) + fromNum = float(fromVal) if fromVal not in (None, "") else None + toNum = float(toVal) if toVal not in (None, "") else None + if fromNum is not None and toNum is not None: + return fromNum <= itemNum <= toNum + if fromNum is not None: + return itemNum >= fromNum + if toNum is not None: + return itemNum <= toNum + except (ValueError, TypeError): + pass + fromStr = str(fromVal).lower() if fromVal else "" + toStr = str(toVal).lower() if toVal else "" + if fromStr and toStr: + return fromStr <= itemStr <= toStr + if fromStr: + return itemStr >= fromStr + if toStr: + return itemStr <= toStr + return True + + +def _extractDistinctValues( + items: List[Dict[str, Any]], + columnKey: str, + requestLang: Optional[str] = None, +) -> list: + """Extract sorted distinct display values for a column from enriched items. + + When the items contain a ``{columnKey}Label`` field (FK enrichment convention), + returns ``{value, label}`` objects so the frontend shows human-readable + labels in filter dropdowns. Otherwise returns plain strings. + + Includes ``None`` as the last entry when at least one row has a null/empty + value — this enables the "(Leer)" filter option in the frontend. + """ + _MISSING = object() + labelKey = f"{columnKey}Label" + hasFkLabels = any(labelKey in item for item in items[:20]) + + if hasFkLabels: + byVal: Dict[str, str] = {} + hasEmpty = False + for item in items: + val = item.get(columnKey, _MISSING) + if val is _MISSING: + continue + if val is None or val == "": + hasEmpty = True + continue + strVal = str(val) + if strVal not in byVal: + label = item.get(labelKey) + byVal[strVal] = str(label) if label else f"NA({strVal[:8]})" + result: list = sorted( + [{"value": v, "label": l} for v, l in byVal.items()], + key=lambda x: x["label"].lower(), + ) + if hasEmpty: + result.append(None) + return result + + values = set() + hasEmpty = False + for item in items: + val = item.get(columnKey, _MISSING) + if val is _MISSING: + continue + if val is None or val == "": + hasEmpty = True + continue + if isinstance(val, bool): + values.add("true" if val else "false") + elif isinstance(val, (int, float)): + values.add(str(val)) + elif isinstance(val, dict): + text = resolveText(val, requestLang) + if text: + values.add(text) + else: + values.add(str(val)) + result = sorted(values, key=lambda v: v.lower()) + if hasEmpty: + result.append(None) + return result + + +def handleFilterValuesInMemory( + items: List[Dict[str, Any]], + column: str, + paginationJson: Optional[str] = None, + requestLang: Optional[str] = None, +) -> JSONResponse: + """ + In-memory filter-values: apply cross-filters, then extract distinct values. + For routes that build enriched in-memory lists. + Returns JSONResponse to bypass FastAPI response_model validation. + """ + crossFilterParams = parseCrossFilterPagination(column, paginationJson) + crossFiltered = applyFiltersAndSort(items, crossFilterParams) + return JSONResponse(content=_extractDistinctValues(crossFiltered, column, requestLang)) + + +def handleIdsInMemory( + items: List[Dict[str, Any]], + paginationJson: Optional[str] = None, + idField: str = "id", +) -> JSONResponse: + """ + In-memory IDs: apply filters, return all IDs. + For routes that build enriched in-memory lists. + Returns JSONResponse to bypass FastAPI response_model validation. + """ + pagination = parsePaginationForIds(paginationJson) + filtered = applyFiltersAndSort(items, pagination) + ids = [] + for item in filtered: + val = item.get(idField) + if val is not None: + ids.append(str(val)) + return JSONResponse(content=ids) + + +def getRecordsetPaginatedWithFkSort( + db, + modelClass: type, + pagination, + recordFilter: Optional[Dict[str, Any]] = None, + labelResolvers: Optional[Dict[str, Callable[[List[str]], Dict[str, str]]]] = None, + fieldFilter: Optional[List[str]] = None, + idField: str = "id", +) -> Dict[str, Any]: + """ + Wrapper around db.getRecordsetPaginated that handles FK-label sorting. + + If the current sort field is a FK with a registered labelResolver, the + function fetches all filtered IDs + FK values, resolves labels cross-DB, + sorts in-memory by label, and returns only the requested page. + + If no FK sort is active, delegates directly to db.getRecordsetPaginated. + """ + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels, buildLabelResolversFromModel + + if not pagination or not pagination.sort: + result = db.getRecordsetPaginated(modelClass, pagination, recordFilter, fieldFilter) + enrichRowsWithFkLabels(result.get("items", []), modelClass, db=db) + return result + + if labelResolvers is None: + labelResolvers = buildLabelResolversFromModel(modelClass, db) + + if not labelResolvers: + result = db.getRecordsetPaginated(modelClass, pagination, recordFilter, fieldFilter) + enrichRowsWithFkLabels(result.get("items", []), modelClass, db=db) + return result + + fkSortField = None + fkSortDir = "asc" + for sf in pagination.sort: + sfField = sf.get("field") if isinstance(sf, dict) else getattr(sf, "field", None) + sfDir = sf.get("direction", "asc") if isinstance(sf, dict) else getattr(sf, "direction", "asc") + if sfField and sfField in labelResolvers: + fkSortField = sfField + fkSortDir = str(sfDir).lower() + break + + if not fkSortField: + result = db.getRecordsetPaginated(modelClass, pagination, recordFilter, fieldFilter) + enrichRowsWithFkLabels(result.get("items", []), modelClass, db=db) + return result + + try: + distinctIds = db.getDistinctColumnValues( + modelClass, fkSortField, recordFilter=recordFilter, + ) or [] + + labelMap = {} + if distinctIds: + try: + labelMap = labelResolvers[fkSortField](distinctIds) + except Exception as e: + logger.warning(f"getRecordsetPaginatedWithFkSort: resolver for {fkSortField} failed: {e}") + + filterOnlyPagination = copy.deepcopy(pagination) + filterOnlyPagination.sort = [] + filterOnlyPagination.page = 1 + filterOnlyPagination.pageSize = 999999 + + lightRows = db.getRecordsetPaginated( + modelClass, filterOnlyPagination, recordFilter, + fieldFilter=[idField, fkSortField], + ) + allRows = lightRows.get("items", []) + totalItems = len(allRows) + + if totalItems == 0: + return {"items": [], "totalItems": 0, "totalPages": 0} + + def _sortKey(row): + fkVal = row.get(fkSortField, "") or "" + label = labelMap.get(str(fkVal), str(fkVal)).lower() + return label + + reverse = fkSortDir == "desc" + allRows.sort(key=_sortKey, reverse=reverse) + + pageSize = pagination.pageSize + offset = (pagination.page - 1) * pageSize + pageSlice = allRows[offset:offset + pageSize] + pageIds = [row[idField] for row in pageSlice if row.get(idField)] + + if not pageIds: + return {"items": [], "totalItems": totalItems, "totalPages": math.ceil(totalItems / pageSize)} + + pageItems = db.getRecordset(modelClass, recordFilter={idField: pageIds}, fieldFilter=fieldFilter) + + idOrder = {pid: idx for idx, pid in enumerate(pageIds)} + pageItems.sort(key=lambda r: idOrder.get(r.get(idField), 999999)) + + enrichRowsWithFkLabels(pageItems, modelClass, db=db) + totalPages = math.ceil(totalItems / pageSize) if totalItems > 0 else 0 + return {"items": pageItems, "totalItems": totalItems, "totalPages": totalPages} + + except Exception as e: + logger.error(f"getRecordsetPaginatedWithFkSort failed for {modelClass.__name__}: {e}") + result = db.getRecordsetPaginated(modelClass, pagination, recordFilter, fieldFilter) + enrichRowsWithFkLabels(result.get("items", []), modelClass, db=db) + return result + + +def paginateInMemory( + items: List[Dict[str, Any]], + paginationParams: Optional[PaginationParams], +) -> tuple: + """ + Apply pagination (page/pageSize slicing) to an already-filtered+sorted list. + Returns (pageItems, totalItems). + """ + totalItems = len(items) + if not paginationParams: + return items, totalItems + offset = (paginationParams.page - 1) * paginationParams.pageSize + pageItems = items[offset:offset + paginationParams.pageSize] + return pageItems, totalItems diff --git a/modules/demoConfigs/__init__.py b/modules/demoConfigs/__init__.py index 5395f71b..6ac5054f 100644 --- a/modules/demoConfigs/__init__.py +++ b/modules/demoConfigs/__init__.py @@ -1,7 +1,7 @@ """ Demo Configs — Auto-Discovery Module -Scans this folder for Python files that contain subclasses of _BaseDemoConfig +Scans this folder for Python files that contain subclasses of BaseDemoConfig and exposes them via getAvailableDemoConfigs(). """ @@ -11,14 +11,14 @@ import logging import pkgutil from typing import Dict -from modules.demoConfigs._baseDemoConfig import _BaseDemoConfig +from modules.demoConfigs.baseDemoConfig import BaseDemoConfig logger = logging.getLogger(__name__) -_configCache: Dict[str, _BaseDemoConfig] = {} +_configCache: Dict[str, BaseDemoConfig] = {} -def getAvailableDemoConfigs() -> Dict[str, _BaseDemoConfig]: +def getAvailableDemoConfigs() -> Dict[str, BaseDemoConfig]: """Return a dict of code -> instance for every discovered demo config.""" if _configCache: return _configCache @@ -32,7 +32,7 @@ def getAvailableDemoConfigs() -> Dict[str, _BaseDemoConfig]: try: module = importlib.import_module(f"{package}.{moduleName}") for name, obj in inspect.getmembers(module, inspect.isclass): - if issubclass(obj, _BaseDemoConfig) and obj is not _BaseDemoConfig: + if issubclass(obj, BaseDemoConfig) and obj is not BaseDemoConfig: instance = obj() if instance.code: _configCache[instance.code] = instance @@ -43,7 +43,7 @@ def getAvailableDemoConfigs() -> Dict[str, _BaseDemoConfig]: return _configCache -def getDemoConfigByCode(code: str) -> _BaseDemoConfig | None: +def getDemoConfigByCode(code: str) -> BaseDemoConfig | None: """Get a specific demo config by its code.""" configs = getAvailableDemoConfigs() return configs.get(code) diff --git a/modules/demoConfigs/_baseDemoConfig.py b/modules/demoConfigs/baseDemoConfig.py similarity index 94% rename from modules/demoConfigs/_baseDemoConfig.py rename to modules/demoConfigs/baseDemoConfig.py index d20d4315..604c7a78 100644 --- a/modules/demoConfigs/_baseDemoConfig.py +++ b/modules/demoConfigs/baseDemoConfig.py @@ -1,7 +1,7 @@ """ Base class for demo configurations. -Each demo config file in this folder extends _BaseDemoConfig and provides +Each demo config file in this folder extends BaseDemoConfig and provides idempotent load() and remove() methods for setting up / tearing down a complete demo environment (mandates, users, features, test data, etc.). @@ -18,7 +18,7 @@ from typing import Any, Dict, List logger = logging.getLogger(__name__) -class _BaseDemoConfig(ABC): +class BaseDemoConfig(ABC): """Abstract base for demo configurations.""" code: str = "" diff --git a/modules/demoConfigs/investorDemo2026.py b/modules/demoConfigs/investorDemo2026.py index d807921d..6855a63c 100644 --- a/modules/demoConfigs/investorDemo2026.py +++ b/modules/demoConfigs/investorDemo2026.py @@ -17,7 +17,7 @@ import logging import uuid from typing import Dict, Any, Optional, List -from modules.demoConfigs._baseDemoConfig import _BaseDemoConfig +from modules.demoConfigs.baseDemoConfig import BaseDemoConfig logger = logging.getLogger(__name__) @@ -57,7 +57,7 @@ _FEATURES_ALPINA = [ ] -class InvestorDemo2026(_BaseDemoConfig): +class InvestorDemo2026(BaseDemoConfig): code = "investor-demo-2026" label = "Investor Demo April 2026" description = ( diff --git a/modules/demoConfigs/pwgDemo2026.py b/modules/demoConfigs/pwgDemo2026.py index 4a6491a3..968aabf8 100644 --- a/modules/demoConfigs/pwgDemo2026.py +++ b/modules/demoConfigs/pwgDemo2026.py @@ -15,7 +15,7 @@ Bootstraps a complete PWG-Pilot demo environment in an empty dev/demo install: Idempotent: ``load()`` skips anything that already exists; ``remove()`` deletes mandate, user, seed data and imported workflow cleanly. -Pattern: subclass of :class:`_BaseDemoConfig`, auto-discovered by +Pattern: subclass of :class:`BaseDemoConfig`, auto-discovered by ``demoConfigs/__init__.py``. See ``investorDemo2026.py`` for the reference implementation we mirror here. """ @@ -27,7 +27,7 @@ from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional -from modules.demoConfigs._baseDemoConfig import _BaseDemoConfig +from modules.demoConfigs.baseDemoConfig import BaseDemoConfig logger = logging.getLogger(__name__) @@ -59,7 +59,7 @@ _PILOT_WORKFLOW_FILE = "pwg-mietzinsbestaetigung-pilot.workflow.json" _SEED_TRUSTEE_FILE = "_seedTrusteeData.json" -class PwgDemo2026(_BaseDemoConfig): +class PwgDemo2026(BaseDemoConfig): code = "pwg-demo-2026" label = "PWG Pilot Demo (Mietzinsbestätigungen)" description = ( diff --git a/modules/features/commcoach/CONCEPT.md b/modules/features/commcoach/CONCEPT.md deleted file mode 100644 index 7a8fcd00..00000000 --- a/modules/features/commcoach/CONCEPT.md +++ /dev/null @@ -1,178 +0,0 @@ -# CommCoach – Communication Coach for Leaders - -## Product Goal - -An AI coaching agent for executives that: -- Captures topics, concerns, and questions -- Asks active diagnostic follow-up questions -- Builds a continuable context per topic (Dossier) -- Conducts daily training conversations -- Makes progress visible (Gamification) -- Supports voice natively (STT/TTS, voice selection) - -## Architecture - -### Layers - -``` -Transport (REST/SSE) → routeFeatureCommcoach.py -Orchestration → serviceCommcoach.py -AI Pipeline → serviceCommcoachAi.py -Scheduler → serviceCommcoachScheduler.py -Domain / Storage → interfaceFeatureCommcoach.py -Data Models → datamodelCommcoach.py -Feature Registration → mainCommcoach.py -``` - -### Reuse from Existing Codebase - -| Component | Source | Usage | -|-----------|--------|-------| -| Feature Plug&Play | `registry.py` | Auto-discovery via `routeFeature*.py` | -| RequestContext + RBAC | `authentication.py`, `interfaceRbac.py` | Auth + ownership | -| DatabaseConnector | `connectorDbPostgre.py` | New DB `poweron_commcoach` | -| VoiceObjects (STT/TTS) | `interfaceVoiceObjects.py` | Voice pipeline | -| MessagingInterface | `interfaceMessaging.py` | Email summaries | -| SSE Pattern | workspace `routeFeatureWorkspace.py` | Chat streaming | -| PDF Renderer | `rendererPdf.py` | Dossier export (Iteration 2) | -| EventManagement | `eventManagement.py` | Scheduled reminders | - -## Domain Model - -### Entities - -``` -User (1) ──── owns ──── (N) CoachingContext - │ -CoachingContext (1) ────── (N) CoachingSession - │ -CoachingSession (1) ───── (N) CoachingMessage - │ -CoachingContext (1) ────── (N) CoachingTask -CoachingContext (1) ────── (N) CoachingScore -User (1) ──────────────── (1) CoachingUserProfile -``` - -### Status Models - -``` -CoachingContext: active → paused → active | archived → active | completed -CoachingSession: active → completed | cancelled -CoachingTask: open → in_progress → done | skipped -``` - -## API Design - -``` -PREFIX: /api/commcoach/{instanceId} - -# Contexts (Dossier) -GET /contexts -POST /contexts -GET /contexts/{contextId} -PUT /contexts/{contextId} -DELETE /contexts/{contextId} -POST /contexts/{contextId}/archive -POST /contexts/{contextId}/activate - -# Sessions -GET /contexts/{contextId}/sessions -POST /contexts/{contextId}/sessions/start -GET /sessions/{sessionId} -POST /sessions/{sessionId}/complete -POST /sessions/{sessionId}/cancel - -# Streaming Chat -POST /sessions/{sessionId}/message/stream -POST /sessions/{sessionId}/audio/stream -GET /sessions/{sessionId}/stream - -# Tasks -GET /contexts/{contextId}/tasks -POST /contexts/{contextId}/tasks -PUT /tasks/{taskId} -PUT /tasks/{taskId}/status -DELETE /tasks/{taskId} - -# Dashboard -GET /dashboard - -# User Profile -GET /profile -PUT /profile - -# Voice -GET /voice/languages -GET /voice/voices -POST /voice/tts -``` - -### SSE Event Types - -- `message` – Complete message -- `messageChunk` – Streaming token -- `sessionState` – Status update -- `taskCreated` – New task from coach -- `insightGenerated` – New insight -- `scoreUpdate` – Score change -- `status` – UI status label -- `complete` – Stream ended -- `error` – Error -- `ping` – Keepalive - -## RBAC Model - -### Ownership Rules (Critical) -- **Strict MY-only**: User sees only own contexts/sessions/messages/tasks/scores -- **SysAdmin**: Only technical monitoring, NO content access -- **No admin override** on userId filter - -### Template Roles -- `commcoach-user`: DATA=MY on all entities, UI=ALL, RESOURCE=ALL -- `commcoach-admin`: DATA=MY (intentionally not ALL), UI=ALL, RESOURCE=ALL - -### Audit Events -- `commcoach.context.created/archived` -- `commcoach.session.started/completed` -- `commcoach.export.requested` - -## Iterations - -### Iteration 1 (MVP) -- Context management (create, switch, archive) -- Chat + SSE streaming -- STT/TTS with language/voice selection -- Coaching session with active diagnostic questions -- Auto session protocol -- Tasks/Checklist per context -- Session summary via email -- RBAC + strict ownership -- Basic dashboard: continuity, competence score, goal progress -- Long-session compression: ab 25 Nachrichten wird der aeltere Verlauf per AI zusammengefasst, letzte 15 Nachrichten bleiben vollstaendig (Teamsbot-Pattern) -- Context Memory (Phasen 1-7): previousSessionSummaries im Chat, keyTopics bei completeSession, Intent-Erkennung (summarize_all, recall_session, recall_topic), Datums-Lookup, Topic-Suche, Rolling Overview, RAG-Platzhalter - -### Iteration 2 -- Roleplay personas (critical CFO, difficult employee, etc.) -- Document upload + context binding -- Exports (Markdown/PDF) -- Extended gamification (streaks, levels, badges) -- Better scoring/insights - -## Database - -- Database name: `poweron_commcoach` -- Tables auto-created from Pydantic models via `DatabaseConnector` - -## Frontend - -### Views -- `CommcoachDashboardView` – KPIs, streaks, quick start -- `CommcoachCoachingView` – Chat UI with voice + context tabs -- `CommcoachDossierView` – Dossier: timeline, tasks, scores -- `CommcoachSettingsView` – Voice, reminder, profile settings - -### UX -- Multiple active contexts as quick-switch tabs/chips -- "Daily Coach" entry point prominent -- Voice first, always with text fallback -- Dossier view: timeline, learnings, tasks, next exercise diff --git a/modules/features/commcoach/interfaceFeatureCommcoach.py b/modules/features/commcoach/interfaceFeatureCommcoach.py index a6fd41ec..8341ec1b 100644 --- a/modules/features/commcoach/interfaceFeatureCommcoach.py +++ b/modules/features/commcoach/interfaceFeatureCommcoach.py @@ -11,7 +11,7 @@ from typing import Dict, Any, List, Optional from modules.datamodels.datamodelUam import User from modules.connectors.connectorDbPostgre import DatabaseConnector -from modules.shared.dbRegistry import registerDatabase +from modules.dbHelpers.dbRegistry import registerDatabase from modules.shared.timeUtils import getIsoTimestamp, getUtcTimestamp from modules.shared.configuration import APP_CONFIG from modules.shared.i18nRegistry import resolveText, t diff --git a/modules/features/commcoach/mainCommcoach.py b/modules/features/commcoach/mainCommcoach.py index 999f940c..7050a078 100644 --- a/modules/features/commcoach/mainCommcoach.py +++ b/modules/features/commcoach/mainCommcoach.py @@ -537,3 +537,73 @@ def _ensureAccessRulesForRole(rootInterface, roleId: str, ruleTemplates: List[Di logger.debug(f"Created {createdCount} AccessRules for role {roleId}") return createdCount + + +# --------------------------------------------------------------------------- +# Feature Lifecycle Hooks +# --------------------------------------------------------------------------- + +def onMandateDelete(mandateId: str, instances: list) -> None: + """Cascade-delete all commcoach data for deleted mandate.""" + from modules.connectors.connectorDbPostgre import DatabaseConnector + from modules.shared.configuration import APP_CONFIG + from modules.features.commcoach.datamodelCommcoach import ( + TrainingModule, CoachingSession, CoachingMessage, CoachingTask, + CoachingScore, CoachingUserProfile, CoachingPersona, + ModulePersonaMapping, CoachingBadge, + ) + + try: + featureInstances = [ + inst for inst in instances + if (inst.get("featureCode") if isinstance(inst, dict) else getattr(inst, "featureCode", "")) == FEATURE_CODE + ] + if not featureInstances: + return + + db = DatabaseConnector( + dbHost=APP_CONFIG.get("DB_HOST", "localhost"), + dbDatabase="poweron_commcoach", + dbUser=APP_CONFIG.get("DB_USER"), + dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), + dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), + userId=None, + ) + + totalDeleted = 0 + for inst in featureInstances: + instId = inst.get("id") if isinstance(inst, dict) else getattr(inst, "id", None) + if not instId: + continue + + # Models scoped by instanceId + for ModelClass in [ + TrainingModule, CoachingSession, CoachingUserProfile, + ModulePersonaMapping, CoachingBadge, + ]: + records = db.getRecordset(ModelClass, recordFilter={"instanceId": instId}) or [] + for rec in records: + db.recordDelete(ModelClass, rec.get("id")) + totalDeleted += len(records) + + # CoachingPersona: only delete mandate-scoped (not builtin with null mandateId) + records = db.getRecordset(CoachingPersona, recordFilter={"instanceId": instId, "mandateId": mandateId}) or [] + for rec in records: + db.recordDelete(CoachingPersona, rec.get("id")) + totalDeleted += len(records) + + # Models scoped by mandateId only (no instanceId) + for ModelClass in [CoachingTask, CoachingScore]: + records = db.getRecordset(ModelClass, recordFilter={"mandateId": mandateId}) or [] + for rec in records: + db.recordDelete(ModelClass, rec.get("id")) + totalDeleted += len(records) + + # CoachingMessage: scoped via sessionId (orphans cleaned up when sessions are deleted) + + if totalDeleted: + logger.info(f"Cascade: deleted {totalDeleted} commcoach record(s) for mandate {mandateId}") + db.close() + except Exception as e: + logger.warning(f"Failed to cascade-delete commcoach data for mandate {mandateId}: {e}") + diff --git a/modules/features/commcoach/routeFeatureCommcoach.py b/modules/features/commcoach/routeFeatureCommcoach.py index 45075ae9..a60db504 100644 --- a/modules/features/commcoach/routeFeatureCommcoach.py +++ b/modules/features/commcoach/routeFeatureCommcoach.py @@ -7,6 +7,7 @@ Implements training module management, session streaming, tasks, and dashboard. import logging import json +import math import asyncio import base64 import uuid @@ -43,7 +44,7 @@ _activeProcessTasks: dict = {} def _audit(context: RequestContext, action: str, resourceType: str = None, resourceId: str = None, details: str = ""): """Log an audit event for CommCoach. Non-blocking, best-effort.""" try: - from modules.shared.auditLogger import audit_logger + from modules.dbHelpers.auditLogger import audit_logger audit_logger.logEvent( userId=str(context.user.id), mandateId=str(context.mandateId) if context.mandateId else None, @@ -941,24 +942,22 @@ async def listPersonas( allPersonas = interface.getAllPersonas(instanceId) if mode == "filterValues": - from modules.routes.routeHelpers import handleFilterValuesInMemory + from modules.dbHelpers.paginationHelpers import handleFilterValuesInMemory if not column: raise HTTPException(status_code=400, detail=routeApiMsg("column parameter required")) return handleFilterValuesInMemory(allPersonas, column, pagination) if mode == "ids": - from modules.routes.routeHelpers import handleIdsInMemory + from modules.dbHelpers.paginationHelpers import handleIdsInMemory return handleIdsInMemory(allPersonas, pagination) if pagination: - import json as _json from modules.datamodels.datamodelPagination import PaginationParams, PaginationMetadata, normalize_pagination_dict - from modules.routes.routeHelpers import applyFiltersAndSort, paginateInMemory - paginationDict = _json.loads(pagination) + from modules.dbHelpers.paginationHelpers import applyFiltersAndSort, paginateInMemory + paginationDict = json.loads(pagination) paginationDict = normalize_pagination_dict(paginationDict) paginationParams = PaginationParams(**paginationDict) filtered = applyFiltersAndSort(allPersonas, paginationParams) pageItems, totalItems = paginateInMemory(filtered, paginationParams) - import math return { "items": pageItems, "pagination": PaginationMetadata( diff --git a/modules/features/commcoach/serviceCommcoach.py b/modules/features/commcoach/serviceCommcoach.py index 5ac3af23..d7a79d1f 100644 --- a/modules/features/commcoach/serviceCommcoach.py +++ b/modules/features/commcoach/serviceCommcoach.py @@ -5,11 +5,14 @@ CommCoach Service - Coaching Orchestration. Manages the coaching pipeline: message processing, AI calls, scoring, task extraction. """ +import base64 +import os import re import html import logging import json import asyncio +from datetime import datetime, timezone from typing import Optional, Dict, Any, List from modules.datamodels.datamodelUam import User @@ -344,7 +347,6 @@ async def _generateAndEmitTts(sessionId: str, speechText: str, currentUser, mand return try: from modules.interfaces.interfaceVoiceObjects import getVoiceInterface - import base64 voiceInterface = getVoiceInterface(currentUser, mandateId) language, voiceName = getUserVoicePrefs(str(currentUser.id), mandateId) ttsResult = await voiceInterface.textToSpeech( @@ -377,7 +379,6 @@ async def _generateAndEmitTts(sessionId: str, speechText: str, currentUser, mand def _resolveFileNameAndMime(title: str) -> tuple: """Derive fileName and mimeType from a document title. Only appends .md if no known extension present.""" - import os knownExtensions = { ".md": "text/markdown", ".txt": "text/plain", ".html": "text/html", ".htm": "text/html", ".pdf": "application/pdf", ".json": "application/json", @@ -1269,7 +1270,6 @@ class CommcoachService: startedAt = session.get("startedAt") durationSeconds = 0 if startedAt: - from datetime import datetime, timezone start = datetime.fromtimestamp(startedAt, tz=timezone.utc) end = datetime.now(timezone.utc) durationSeconds = int((end - start).total_seconds()) @@ -1335,8 +1335,6 @@ class CommcoachService: if not profile: profile = interface.getOrCreateProfile(self.userId, self.mandateId, self.instanceId) - from datetime import datetime, timezone - lastSessionAt = profile.get("lastSessionAt") currentStreak = profile.get("streakDays", 0) longestStreak = profile.get("longestStreak", 0) @@ -1381,7 +1379,7 @@ class CommcoachService: from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface from modules.interfaces.interfaceDbApp import getRootInterface - from modules.shared.notifyMandateAdmins import renderHtmlEmail, resolveMandateName + from modules.system.notifyMandateAdmins import renderHtmlEmail, resolveMandateName rootInterface = getRootInterface() user = rootInterface.getUser(self.userId) @@ -1428,7 +1426,6 @@ class CommcoachService: for s in completedSessions: startedAt = s.get("startedAt") if startedAt: - from datetime import datetime, timezone dt = datetime.fromtimestamp(startedAt, tz=timezone.utc) s["date"] = dt.strftime("%d.%m.%Y") else: diff --git a/modules/features/commcoach/serviceCommcoachAi.py b/modules/features/commcoach/serviceCommcoachAi.py index e3394125..1b9baca8 100644 --- a/modules/features/commcoach/serviceCommcoachAi.py +++ b/modules/features/commcoach/serviceCommcoachAi.py @@ -7,6 +7,7 @@ Handles system prompts, diagnostic question generation, session summarization, a import logging import json +from datetime import datetime, timezone from typing import Optional, Dict, Any, List, Tuple logger = logging.getLogger(__name__) @@ -208,7 +209,6 @@ Tool-Nutzung: dateStr = "" startedAt = retrievedSession.get("startedAt") if startedAt: - from datetime import datetime, timezone dt = datetime.fromtimestamp(startedAt, tz=timezone.utc) dateStr = dt.strftime("%d.%m.%Y") prompt += f"\n\nVom Benutzer angefragte Session ({dateStr}):" diff --git a/modules/features/commcoach/serviceCommcoachContextRetrieval.py b/modules/features/commcoach/serviceCommcoachContextRetrieval.py index e841dec4..98673cc6 100644 --- a/modules/features/commcoach/serviceCommcoachContextRetrieval.py +++ b/modules/features/commcoach/serviceCommcoachContextRetrieval.py @@ -5,6 +5,7 @@ CommCoach Context Retrieval. Intent detection, retrieval strategies, and context assembly for intelligent session continuity. """ +import json import re import logging from datetime import datetime, timezone @@ -146,7 +147,6 @@ def searchSessionsByTopic( keyTopics = [] if keyTopicsRaw: try: - import json parsed = json.loads(keyTopicsRaw) if isinstance(keyTopicsRaw, str) else keyTopicsRaw keyTopics = [t.lower() if isinstance(t, str) else str(t).lower() for t in parsed] if isinstance(parsed, list) else [] except Exception: diff --git a/modules/features/commcoach/serviceCommcoachExport.py b/modules/features/commcoach/serviceCommcoachExport.py index 614a3fe6..5f8e9356 100644 --- a/modules/features/commcoach/serviceCommcoachExport.py +++ b/modules/features/commcoach/serviceCommcoachExport.py @@ -5,8 +5,10 @@ CommCoach Export Service. Generates Markdown and PDF exports for dossiers and sessions. """ +import io import logging import json +import re from typing import Dict, Any, List, Optional from datetime import datetime, timezone @@ -161,8 +163,6 @@ async def renderSessionPdf(session: Dict[str, Any], messages: List[Dict[str, Any def _markdownToPdf(markdownText: str, title: str) -> bytes: """Convert markdown text to a styled PDF using reportlab. Raises on failure.""" - import re as _re - import io from reportlab.lib.pagesizes import A4 from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle @@ -217,12 +217,11 @@ def _escXml(text: str) -> str: def _mdToXml(text: str) -> str: """Convert markdown inline formatting to reportlab XML. Bold, italic, escape the rest.""" - import re as _re text = text.replace("&", "&").replace("<", "<").replace(">", ">") - text = _re.sub(r'\*\*(.+?)\*\*', r'\1', text) - text = _re.sub(r'__(.+?)__', r'\1', text) - text = _re.sub(r'\*(.+?)\*', r'\1', text) - text = _re.sub(r'_(.+?)_', r'\1', text) + text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) + text = re.sub(r'__(.+?)__', r'\1', text) + text = re.sub(r'\*(.+?)\*', r'\1', text) + text = re.sub(r'_(.+?)_', r'\1', text) return text diff --git a/modules/features/commcoach/serviceCommcoachScheduler.py b/modules/features/commcoach/serviceCommcoachScheduler.py index 72e253d6..51a3491d 100644 --- a/modules/features/commcoach/serviceCommcoachScheduler.py +++ b/modules/features/commcoach/serviceCommcoachScheduler.py @@ -64,7 +64,7 @@ async def _runDailyReminders(): from modules.connectors.connectorDbPostgre import DatabaseConnector from .datamodelCommcoach import CoachingUserProfile, TrainingModuleStatus from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface - from modules.shared.notifyMandateAdmins import renderHtmlEmail, resolveMandateName + from modules.system.notifyMandateAdmins import renderHtmlEmail, resolveMandateName dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data") db = DatabaseConnector( diff --git a/modules/features/graphicalEditor/adapterValidator.py b/modules/features/graphicalEditor/adapterValidator.py index 7f760896..08e25232 100644 --- a/modules/features/graphicalEditor/adapterValidator.py +++ b/modules/features/graphicalEditor/adapterValidator.py @@ -31,7 +31,7 @@ from modules.features.graphicalEditor.nodeAdapter import ( _adapterFromLegacyNode, _isMethodBoundNode, ) -from modules.workflows.methods._actionSignatureValidator import _validateTypeRef +from modules.workflows.methods._actionSignatureValidator import validateTypeRef @dataclass @@ -91,14 +91,14 @@ def _validateAdapterAgainstAction( f"action '{adapter.bindsAction}.{paramName}': missing 'type' on parameter" ) continue - for err in _validateTypeRef(typeRef): + for err in validateTypeRef(typeRef): report.errors.append( f"action '{adapter.bindsAction}.{paramName}': {err}" ) # Rule 4: Action outputType exists in catalog (or is a generic fire-and-forget type) if outputType not in {"ActionResult", "Transit"}: - for err in _validateTypeRef(outputType): + for err in validateTypeRef(outputType): report.errors.append( f"action '{adapter.bindsAction}'.outputType: {err}" ) diff --git a/modules/features/graphicalEditor/datamodelFeatureGraphicalEditor.py b/modules/features/graphicalEditor/datamodelFeatureGraphicalEditor.py index 10d1f47f..1e701716 100644 --- a/modules/features/graphicalEditor/datamodelFeatureGraphicalEditor.py +++ b/modules/features/graphicalEditor/datamodelFeatureGraphicalEditor.py @@ -1,579 +1,25 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. -"""GraphicalEditor models with Auto-prefix: AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask.""" +"""GraphicalEditor models — re-exports from canonical datamodels.datamodelWorkflowAutomation.""" -from enum import Enum -from typing import Dict, Any, List, Optional -from pydantic import BaseModel, Field -from modules.datamodels.datamodelBase import PowerOnModel -from modules.shared.i18nRegistry import i18nModel -import uuid +# All models and enums re-exported for backward compatibility. +# Canonical location: modules.datamodels.datamodelWorkflowAutomation +from modules.datamodels.datamodelWorkflowAutomation import ( # noqa: F401 + AutoWorkflowStatus, + AutoRunStatus, + AutoStepStatus, + AutoTaskStatus, + AutoTemplateScope, + GRAPHICAL_EDITOR_DATABASE, + AutoWorkflow, + AutoVersion, + AutoRun, + AutoStepLog, + AutoTask, + Automation2Workflow, + Automation2WorkflowRun, + Automation2HumanTask, +) - -# --------------------------------------------------------------------------- -# Enums -# --------------------------------------------------------------------------- - -class AutoWorkflowStatus(str, Enum): - DRAFT = "draft" - PUBLISHED = "published" - ARCHIVED = "archived" - - -class AutoRunStatus(str, Enum): - RUNNING = "running" - PAUSED = "paused" - COMPLETED = "completed" - FAILED = "failed" - CANCELLED = "cancelled" - - -class AutoStepStatus(str, Enum): - PENDING = "pending" - RUNNING = "running" - COMPLETED = "completed" - FAILED = "failed" - SKIPPED = "skipped" - - -class AutoTaskStatus(str, Enum): - PENDING = "pending" - COMPLETED = "completed" - CANCELLED = "cancelled" - EXPIRED = "expired" - - -class AutoTemplateScope(str, Enum): - USER = "user" - INSTANCE = "instance" - MANDATE = "mandate" - SYSTEM = "system" - - -# --------------------------------------------------------------------------- -# AutoWorkflow -# --------------------------------------------------------------------------- - -@i18nModel("Workflow") -class AutoWorkflow(PowerOnModel): - id: str = Field( - default_factory=lambda: str(uuid.uuid4()), - description="Primary key", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"}, - ) - mandateId: str = Field( - description="Mandate ID", - json_schema_extra={ - "frontend_type": "text", - "frontend_readonly": True, - "frontend_required": False, - "label": "Mandanten-ID", - "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}, - }, - ) - featureInstanceId: str = Field( - description="Feature instance ID (GE owner instance / RBAC scope)", - json_schema_extra={ - "frontend_type": "text", - "frontend_readonly": True, - "frontend_required": False, - "label": "Feature-Instanz-ID", - "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}, - }, - ) - targetFeatureInstanceId: Optional[str] = Field( - default=None, - description="Target feature instance for execution data scope. NULL for templates, mandatory for non-templates.", - json_schema_extra={ - "frontend_type": "select", - "frontend_readonly": False, - "frontend_required": False, - "label": "Ziel-Instanz", - "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}, - }, - ) - label: str = Field( - description="User-friendly workflow name", - json_schema_extra={"frontend_type": "text", "frontend_required": True, "label": "Bezeichnung"}, - ) - description: Optional[str] = Field( - default=None, - description="Workflow description", - json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Beschreibung"}, - ) - tags: List[str] = Field( - default_factory=list, - description="Tags for categorization", - json_schema_extra={"frontend_type": "tags", "frontend_required": False, "label": "Tags"}, - ) - isTemplate: bool = Field( - default=False, - description="Whether this workflow is a template", - json_schema_extra={ - "frontend_type": "checkbox", - "frontend_required": False, - "label": "Ist Vorlage", - "frontend_format_labels": ["Ja", "-", "Nein"], - }, - ) - templateSourceId: Optional[str] = Field( - default=None, - description="ID of the template this workflow was created from", - json_schema_extra={ - "frontend_type": "text", - "frontend_readonly": True, - "frontend_required": False, - "label": "Vorlagen-Quelle", - # Soft FK: holds either a real AutoWorkflow.id (UUID, when copied - # from a stored template) OR an in-code sentinel like - # "trustee-receipt-import" (when bootstrapped from - # featureModule.getTemplateWorkflows()). Sentinel values do not - # exist as DB rows by design — orphan cleanup MUST skip this column. - "fk_target": { - "db": "poweron_graphicaleditor", - "table": "AutoWorkflow", - "labelField": "label", - "softFk": True, - }, - }, - ) - templateScope: Optional[str] = Field( - default=None, - description="Template scope: user, instance, mandate, system (AutoTemplateScope)", - json_schema_extra={ - "frontend_type": "select", - "frontend_required": False, - "label": "Vorlagen-Bereich", - "frontend_options": [ - {"value": "user", "label": "Meine"}, - {"value": "instance", "label": "Instanz"}, - {"value": "mandate", "label": "Mandant"}, - {"value": "system", "label": "System"}, - ], - }, - ) - sharedReadOnly: bool = Field( - default=False, - description="If true, shared template is read-only for non-owners", - json_schema_extra={ - "frontend_type": "checkbox", - "frontend_required": False, - "label": "Freigabe nur-lesen", - "frontend_format_labels": ["Ja", "-", "Nein"], - }, - ) - currentVersionId: Optional[str] = Field( - default=None, - description="ID of the currently published AutoVersion", - json_schema_extra={ - "frontend_type": "text", - "frontend_readonly": True, - "frontend_required": False, - "label": "Aktuelle Version", - "fk_target": {"db": "poweron_graphicaleditor", "table": "AutoVersion", "labelField": "versionNumber"}, - }, - ) - active: bool = Field( - default=True, - description="Whether workflow is active", - json_schema_extra={ - "frontend_type": "checkbox", - "frontend_required": False, - "label": "Aktiv", - "frontend_format_labels": ["Ja", "-", "Nein"], - }, - ) - eventId: Optional[str] = Field( - default=None, - description="Scheduler event ID for incremental sync", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Event-ID"}, - ) - notifyOnFailure: bool = Field( - default=True, - description="Send notification (in-app + email) when a run fails", - json_schema_extra={ - "frontend_type": "checkbox", - "frontend_required": False, - "label": "Bei Fehler benachrichtigen", - "frontend_format_labels": ["Ja", "-", "Nein"], - }, - ) - # Legacy fields kept for backward compatibility during transition - graph: Dict[str, Any] = Field( - default_factory=dict, - description="Graph with nodes and connections (legacy; prefer AutoVersion.graph)", - json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Graph"}, - ) - invocations: List[Dict[str, Any]] = Field( - default_factory=list, - description="Entry points / starts (manual, form, schedule, webhook, ...)", - json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Starts / Einstiegspunkte"}, - ) - - -# --------------------------------------------------------------------------- -# AutoVersion -# --------------------------------------------------------------------------- - -@i18nModel("Workflow-Version") -class AutoVersion(PowerOnModel): - id: str = Field( - default_factory=lambda: str(uuid.uuid4()), - description="Primary key", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"}, - ) - workflowId: str = Field( - description="FK -> AutoWorkflow", - json_schema_extra={ - "frontend_type": "text", - "frontend_readonly": True, - "frontend_required": True, - "label": "Workflow-ID", - "fk_target": {"db": "poweron_graphicaleditor", "table": "AutoWorkflow", "labelField": "label"}, - }, - ) - versionNumber: int = Field( - default=1, - description="Incrementing version number", - json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Version"}, - ) - status: str = Field( - default=AutoWorkflowStatus.DRAFT.value, - description="Version status: draft, published, archived", - json_schema_extra={ - "frontend_type": "select", - "frontend_required": False, - "label": "Status", - "frontend_options": [ - {"value": "draft", "label": "Entwurf"}, - {"value": "published", "label": "Veröffentlicht"}, - {"value": "archived", "label": "Archiviert"}, - ], - }, - ) - graph: Dict[str, Any] = Field( - default_factory=dict, - description="Graph with nodes and connections (incl. node parameters)", - json_schema_extra={"frontend_type": "textarea", "frontend_required": True, "label": "Graph"}, - ) - invocations: List[Dict[str, Any]] = Field( - default_factory=list, - description="Entry points / starts for this version", - json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Einstiegspunkte"}, - ) - publishedAt: Optional[float] = Field( - default=None, - description="Timestamp when version was published", - json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Veröffentlicht am"}, - ) - publishedBy: Optional[str] = Field( - default=None, - description="User ID who published this version", - json_schema_extra={ - "frontend_type": "text", - "frontend_readonly": True, - "frontend_required": False, - "label": "Veröffentlicht von", - "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}, - }, - ) - - -# --------------------------------------------------------------------------- -# AutoRun -# --------------------------------------------------------------------------- - -@i18nModel("Workflow-Ausführung") -class AutoRun(PowerOnModel): - id: str = Field( - default_factory=lambda: str(uuid.uuid4()), - description="Primary key", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"}, - ) - workflowId: str = Field( - description="Workflow ID", - json_schema_extra={ - "frontend_type": "text", - "frontend_readonly": True, - "frontend_required": True, - "label": "Workflow-ID", - "fk_target": {"db": "poweron_graphicaleditor", "table": "AutoWorkflow", "labelField": "label"}, - }, - ) - label: Optional[str] = Field( - default=None, - description="Human-readable run label, set at creation from workflow name or caller", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Bezeichnung"}, - ) - mandateId: Optional[str] = Field( - default=None, - description="Mandate ID for cross-feature querying", - json_schema_extra={ - "frontend_type": "text", - "frontend_readonly": True, - "frontend_required": False, - "label": "Mandanten-ID", - "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}, - }, - ) - ownerId: Optional[str] = Field( - default=None, - description="User ID who triggered this run", - json_schema_extra={ - "frontend_type": "text", - "frontend_readonly": True, - "frontend_required": False, - "label": "Auslöser", - "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}, - }, - ) - versionId: Optional[str] = Field( - default=None, - description="AutoVersion ID used for this run", - json_schema_extra={ - "frontend_type": "text", - "frontend_readonly": True, - "frontend_required": False, - "label": "Versions-ID", - "fk_target": {"db": "poweron_graphicaleditor", "table": "AutoVersion", "labelField": "versionNumber"}, - }, - ) - status: str = Field( - default=AutoRunStatus.RUNNING.value, - description="Status: running, paused, completed, failed, cancelled", - json_schema_extra={ - "frontend_type": "select", - "frontend_required": False, - "label": "Status", - "frontend_options": [ - {"value": "running", "label": "Läuft"}, - {"value": "paused", "label": "Pausiert"}, - {"value": "completed", "label": "Abgeschlossen"}, - {"value": "failed", "label": "Fehlgeschlagen"}, - {"value": "cancelled", "label": "Abgebrochen"}, - ], - }, - ) - trigger: Dict[str, Any] = Field( - default_factory=dict, - description="Trigger info (type, entryPointId, payload, etc.)", - json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Auslöser"}, - ) - startedAt: Optional[float] = Field( - default=None, - description="Run start timestamp", - json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Gestartet am"}, - ) - completedAt: Optional[float] = Field( - default=None, - description="Run completion timestamp", - json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Abgeschlossen am"}, - ) - nodeOutputs: Dict[str, Any] = Field( - default_factory=dict, - description="Outputs from executed nodes", - json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Node-Ausgaben"}, - ) - currentNodeId: Optional[str] = Field( - default=None, - description="Node ID when paused (human task / email wait)", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Aktueller Knoten"}, - ) - resumeContext: Dict[str, Any] = Field( - default_factory=dict, - description="Context for resume (connectionMap, inputSources, etc.)", - json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Wiederaufnahme-Kontext"}, - ) - error: Optional[str] = Field( - default=None, - description="Error message if failed", - json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False, "label": "Fehler"}, - ) - costTokens: int = Field( - default=0, - description="Total tokens consumed by AI nodes", - json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Verbrauchte Tokens"}, - ) - costCredits: float = Field( - default=0.0, - description="Total credits consumed", - json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Verbrauchte Credits"}, - ) - - -# --------------------------------------------------------------------------- -# AutoStepLog -# --------------------------------------------------------------------------- - -@i18nModel("Schritt-Protokoll") -class AutoStepLog(PowerOnModel): - id: str = Field( - default_factory=lambda: str(uuid.uuid4()), - description="Primary key", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"}, - ) - runId: str = Field( - description="FK -> AutoRun", - json_schema_extra={ - "frontend_type": "text", - "frontend_readonly": True, - "frontend_required": True, - "label": "Lauf-ID", - "fk_target": {"db": "poweron_graphicaleditor", "table": "AutoRun", "labelField": "label"}, - }, - ) - nodeId: str = Field( - description="Node ID in the graph", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Knoten-ID"}, - ) - nodeType: str = Field( - description="Node type (e.g. ai.chat, email.send)", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Knotentyp"}, - ) - status: str = Field( - default=AutoStepStatus.PENDING.value, - description="Step status: pending, running, completed, failed, skipped", - json_schema_extra={ - "frontend_type": "select", - "frontend_required": False, - "label": "Status", - "frontend_options": [ - {"value": "pending", "label": "Wartend"}, - {"value": "running", "label": "Läuft"}, - {"value": "completed", "label": "Abgeschlossen"}, - {"value": "failed", "label": "Fehlgeschlagen"}, - {"value": "skipped", "label": "Übersprungen"}, - ], - }, - ) - inputSnapshot: Dict[str, Any] = Field( - default_factory=dict, - description="Snapshot of inputs at execution time", - json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Eingabe-Snapshot"}, - ) - output: Dict[str, Any] = Field( - default_factory=dict, - description="Node output", - json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Ausgabe"}, - ) - error: Optional[str] = Field( - default=None, - description="Error message if step failed", - json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False, "label": "Fehler"}, - ) - startedAt: Optional[float] = Field( - default=None, - description="Step start timestamp", - json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Gestartet am"}, - ) - completedAt: Optional[float] = Field( - default=None, - description="Step completion timestamp", - json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Abgeschlossen am"}, - ) - durationMs: Optional[int] = Field( - default=None, - description="Execution duration in milliseconds", - json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Dauer (ms)"}, - ) - tokensUsed: int = Field( - default=0, - description="Tokens consumed by this step", - json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Verbrauchte Tokens"}, - ) - retryCount: int = Field( - default=0, - description="Number of retries executed", - json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Wiederholungen"}, - ) - - -# --------------------------------------------------------------------------- -# AutoTask -# --------------------------------------------------------------------------- - -@i18nModel("Aufgabe") -class AutoTask(PowerOnModel): - id: str = Field( - default_factory=lambda: str(uuid.uuid4()), - description="Primary key", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"}, - ) - runId: str = Field( - description="FK -> AutoRun", - json_schema_extra={ - "frontend_type": "text", - "frontend_readonly": True, - "frontend_required": True, - "label": "Lauf-ID", - "fk_target": {"db": "poweron_graphicaleditor", "table": "AutoRun", "labelField": "label"}, - }, - ) - workflowId: str = Field( - description="Workflow ID", - json_schema_extra={ - "frontend_type": "text", - "frontend_readonly": True, - "frontend_required": True, - "label": "Workflow-ID", - "fk_target": {"db": "poweron_graphicaleditor", "table": "AutoWorkflow", "labelField": "label"}, - }, - ) - nodeId: str = Field( - description="Node ID in the graph", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Knoten-ID"}, - ) - nodeType: str = Field( - description="Node type: form, approval, upload, comment, review, selection, confirmation", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Knotentyp"}, - ) - config: Dict[str, Any] = Field( - default_factory=dict, - description="Node config (form schema, approval text, etc.)", - json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Konfiguration"}, - ) - assigneeId: Optional[str] = Field( - default=None, - description="User ID assigned to complete the task", - json_schema_extra={ - "frontend_type": "text", - "frontend_readonly": False, - "frontend_required": False, - "label": "Zugewiesen an", - "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}, - }, - ) - status: str = Field( - default=AutoTaskStatus.PENDING.value, - description="Status: pending, completed, cancelled, expired", - json_schema_extra={ - "frontend_type": "select", - "frontend_required": False, - "label": "Status", - "frontend_options": [ - {"value": "pending", "label": "Wartend"}, - {"value": "completed", "label": "Abgeschlossen"}, - {"value": "cancelled", "label": "Abgebrochen"}, - {"value": "expired", "label": "Abgelaufen"}, - ], - }, - ) - result: Optional[Dict[str, Any]] = Field( - default=None, - description="Task result (form data, approval decision, etc.)", - json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Ergebnis"}, - ) - expiresAt: Optional[float] = Field( - default=None, - description="Expiration timestamp for the task", - json_schema_extra={"frontend_type": "timestamp", "frontend_required": False, "label": "Läuft ab am"}, - ) - - -# --------------------------------------------------------------------------- -# Backward-compatible aliases for transition period -# --------------------------------------------------------------------------- - -Automation2Workflow = AutoWorkflow -Automation2WorkflowRun = AutoRun -Automation2HumanTask = AutoTask +# Legacy alias +graphicalEditorDatabase = GRAPHICAL_EDITOR_DATABASE diff --git a/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py b/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py index 09192d2e..e58c7b18 100644 --- a/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py +++ b/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py @@ -39,24 +39,22 @@ def _make_json_serializable(obj: Any, _depth: int = 0) -> Any: return obj from modules.datamodels.datamodelUam import User -from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import ( +from modules.datamodels.datamodelWorkflowAutomation import ( + GRAPHICAL_EDITOR_DATABASE, AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask, - AutoWorkflow as Automation2Workflow, - AutoRun as Automation2WorkflowRun, - AutoTask as Automation2HumanTask, ) from modules.features.graphicalEditor.entryPoints import invocations_synced_with_graph from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.shared.configuration import APP_CONFIG -from modules.shared.dbRegistry import registerDatabase +from modules.dbHelpers.dbRegistry import registerDatabase logger = logging.getLogger(__name__) -graphicalEditorDatabase = "poweron_graphicaleditor" +graphicalEditorDatabase = GRAPHICAL_EDITOR_DATABASE registerDatabase(graphicalEditorDatabase) _CALLBACK_WORKFLOW_CHANGED = "graphicalEditor.workflow.changed" @@ -524,7 +522,6 @@ class GraphicalEditorObjects: return None existing = self.getVersions(workflowId) nextNumber = max((v.get("versionNumber", 0) for v in existing), default=0) + 1 - import time data = { "id": str(uuid.uuid4()), "workflowId": workflowId, @@ -546,7 +543,6 @@ class GraphicalEditorObjects: for v in existing: if v.get("status") == "published" and v.get("id") != versionId: self.db.recordModify(AutoVersion, v["id"], {"status": "archived"}) - import time updated = self.db.recordModify(AutoVersion, versionId, { "status": "published", "publishedAt": time.time(), diff --git a/modules/features/graphicalEditor/mainGraphicalEditor.py b/modules/features/graphicalEditor/mainGraphicalEditor.py index d3d70381..44cb890e 100644 --- a/modules/features/graphicalEditor/mainGraphicalEditor.py +++ b/modules/features/graphicalEditor/mainGraphicalEditor.py @@ -5,7 +5,9 @@ GraphicalEditor Feature - n8n-style flow automation. Minimal bootstrap for feature instance creation. Build from here. """ +import json import logging +import uuid from typing import Dict, List, Any, Optional from modules.shared.i18nRegistry import t @@ -119,11 +121,10 @@ def getGraphicalEditorServices( _workflow = workflow if _workflow is None: - import uuid as _uuid _workflow = type( "_Placeholder", (), - {"featureCode": FEATURE_CODE, "id": f"transient-{_uuid.uuid4().hex[:12]}", "workflowMode": None, "messages": []}, + {"featureCode": FEATURE_CODE, "id": f"transient-{uuid.uuid4().hex[:12]}", "workflowMode": None, "messages": []}, )() ctx = ServiceCenterContext( @@ -209,6 +210,269 @@ def getFeatureDefinition() -> Dict[str, Any]: } +# --------------------------------------------------------------------------- +# Feature Lifecycle Hooks (called dynamically by core via loadFeatureMainModules) +# --------------------------------------------------------------------------- + +def onMandateDelete(mandateId: str, instances: list) -> None: + """Cascade-delete all AutoWorkflow data in the Greenfield DB for this mandate.""" + from modules.datamodels.datamodelWorkflowAutomation import ( + GRAPHICAL_EDITOR_DATABASE, AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask, + ) + from modules.connectors.connectorDbPostgre import DatabaseConnector + from modules.shared.configuration import APP_CONFIG + + try: + geDb = DatabaseConnector( + dbHost=APP_CONFIG.get("DB_HOST", "localhost"), + dbDatabase=GRAPHICAL_EDITOR_DATABASE, + dbUser=APP_CONFIG.get("DB_USER"), + dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), + dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), + userId=None, + ) + + if not geDb._ensureTableExists(AutoWorkflow): + return + + geInstances = [ + inst for inst in instances + if (inst.get("featureCode") if isinstance(inst, dict) else getattr(inst, "featureCode", "")) == "graphicalEditor" + ] + + totalDeleted = 0 + for inst in geInstances: + instId = inst.get("id") if isinstance(inst, dict) else getattr(inst, "id", None) + if not instId: + continue + + workflows = geDb.getRecordset(AutoWorkflow, recordFilter={ + "mandateId": mandateId, + "featureInstanceId": instId, + }) or [] + + for wf in workflows: + wfId = wf.get("id") + if not wfId: + continue + + for v in geDb.getRecordset(AutoVersion, recordFilter={"workflowId": wfId}) or []: + geDb.recordDelete(AutoVersion, v.get("id")) + + for run in geDb.getRecordset(AutoRun, recordFilter={"workflowId": wfId}) or []: + runId = run.get("id") + for sl in geDb.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []: + geDb.recordDelete(AutoStepLog, sl.get("id")) + geDb.recordDelete(AutoRun, runId) + + for task in geDb.getRecordset(AutoTask, recordFilter={"workflowId": wfId}) or []: + geDb.recordDelete(AutoTask, task.get("id")) + + geDb.recordDelete(AutoWorkflow, wfId) + totalDeleted += 1 + + if totalDeleted: + logger.info(f"Cascade: deleted {totalDeleted} AutoWorkflow(s) in Greenfield DB for mandate {mandateId}") + geDb.close() + except Exception as e: + logger.warning(f"Failed to cascade-delete graphical editor data for mandate {mandateId}: {e}") + + +def onBootstrap() -> None: + """Seed system workflow templates and sync feature template workflows on boot.""" + from modules.datamodels.datamodelWorkflowAutomation import GRAPHICAL_EDITOR_DATABASE, AutoWorkflow + from modules.connectors.connectorDbPostgre import DatabaseConnector + from modules.shared.configuration import APP_CONFIG + + try: + greenfieldDb = DatabaseConnector( + dbHost=APP_CONFIG.get("DB_HOST", "localhost"), + dbDatabase=GRAPHICAL_EDITOR_DATABASE, + dbUser=APP_CONFIG.get("DB_USER"), + dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), + ) + greenfieldDb._ensureTableExists(AutoWorkflow) + + # --- Seed system templates --- + existing = greenfieldDb.getRecordset(AutoWorkflow, recordFilter={ + "isTemplate": True, + "templateScope": "system", + }) + existingLabels = {r.get("label") if isinstance(r, dict) else getattr(r, "label", "") for r in (existing or [])} + + templates = _buildSystemTemplates() + created = 0 + for tpl in templates: + if tpl["label"] in existingLabels: + continue + tpl["id"] = str(uuid.uuid4()) + greenfieldDb.recordCreate(AutoWorkflow, tpl) + created += 1 + + if created: + logger.info(f"Bootstrapped {created} system workflow template(s)") + + # --- Sync feature template workflows --- + from modules.system.registry import loadFeatureMainModules + + mainModules = loadFeatureMainModules() + templatesBySourceId: dict = {} + for featureCode, mod in mainModules.items(): + getTemplateWorkflowsFn = getattr(mod, "getTemplateWorkflows", None) + if not getTemplateWorkflowsFn: + continue + try: + featureTemplates = getTemplateWorkflowsFn() or [] + except Exception: + continue + for tpl in featureTemplates: + tplId = tpl.get("id") + if tplId: + templatesBySourceId[tplId] = tpl + + if templatesBySourceId: + updated = 0 + for sourceId, tpl in templatesBySourceId.items(): + instances = greenfieldDb.getRecordset(AutoWorkflow, recordFilter={ + "templateSourceId": sourceId, + "isTemplate": False, + }) + if not instances: + continue + + canonicalGraph = tpl.get("graph", {}) + for inst in instances: + instId = inst.get("id") if isinstance(inst, dict) else getattr(inst, "id", None) + targetInstanceId = ( + inst.get("targetFeatureInstanceId") if isinstance(inst, dict) + else getattr(inst, "targetFeatureInstanceId", None) + ) or "" + + graphJson = json.dumps(canonicalGraph) + graphJson = graphJson.replace("{{featureInstanceId}}", targetInstanceId) + newGraph = json.loads(graphJson) + + existingGraph = inst.get("graph") if isinstance(inst, dict) else getattr(inst, "graph", None) + if isinstance(existingGraph, str): + try: + existingGraph = json.loads(existingGraph) + except Exception: + existingGraph = None + + if existingGraph == newGraph: + continue + greenfieldDb.recordModify(AutoWorkflow, instId, {"graph": newGraph}) + updated += 1 + + if updated: + logger.info(f"Synced {updated} workflow(s) with current feature templates") + + greenfieldDb.close() + except Exception as e: + logger.warning(f"GraphicalEditor bootstrap failed: {e}") + + +def onInstanceCreate(mandateId: str, instanceId: str, featureCode: str, templateWorkflows: list) -> int: + """Create workflow instances from template definitions when a feature instance is created.""" + from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface + from modules.security.rootAccess import getRootUser + from modules.shared.i18nRegistry import resolveText + + rootUser = getRootUser() + geInterface = getGraphicalEditorInterface(rootUser, mandateId, instanceId) + + copied = 0 + for template in templateWorkflows: + templateId = template.get("id", "") + try: + graphJson = json.dumps(template.get("graph", {})) + graphJson = graphJson.replace("{{featureInstanceId}}", instanceId) + graph = json.loads(graphJson) + + label = resolveText(template.get("label")) + + geInterface.createWorkflow({ + "label": label, + "graph": graph, + "tags": template.get("tags", [f"feature:{featureCode}"]), + "isTemplate": False, + "templateSourceId": templateId, + "templateScope": "instance", + "active": True, + "targetFeatureInstanceId": instanceId, + "invocations": template.get("invocations", []), + }) + copied += 1 + except Exception as e: + logger.error(f"onInstanceCreate: failed to copy template '{templateId}': {e}") + + return copied + + +def _buildSystemTemplates(): + """Build the graph definitions for platform system templates.""" + return [ + { + "label": "Personal Assistant: E-Mail-Antwort-Drafting", + "mandateId": None, + "featureInstanceId": None, + "isTemplate": True, + "templateScope": "system", + "sharedReadOnly": True, + "active": False, + "graph": { + "nodes": [ + {"id": "n1", "type": "trigger.schedule", "x": 50, "y": 200, "title": "Täglicher Check", "parameters": {}}, + {"id": "n2", "type": "email.checkEmail", "x": 300, "y": 200, "title": "Mailbox prüfen", "parameters": {}}, + {"id": "n3", "type": "flow.loop", "x": 550, "y": 200, "title": "Pro E-Mail", "parameters": {"items": {"type": "ref", "nodeId": "n2", "path": ["emails"]}, "concurrency": 1}}, + {"id": "n4", "type": "ai.prompt", "x": 800, "y": 200, "title": "Analyse: Antwort nötig?", "parameters": {}}, + {"id": "n5", "type": "flow.ifElse", "x": 1050, "y": 200, "title": "Antwort nötig?", "parameters": {}}, + {"id": "n6", "type": "ai.prompt", "x": 1300, "y": 100, "title": "Kontext abrufen & Antwort formulieren", "parameters": {}}, + {"id": "n7", "type": "email.draftEmail", "x": 1550, "y": 100, "title": "Draft erstellen", "parameters": {}}, + ], + "connections": [ + {"source": "n1", "target": "n2", "sourceOutput": 0, "targetInput": 0}, + {"source": "n2", "target": "n3", "sourceOutput": 0, "targetInput": 0}, + {"source": "n3", "target": "n4", "sourceOutput": 0, "targetInput": 0}, + {"source": "n4", "target": "n5", "sourceOutput": 0, "targetInput": 0}, + {"source": "n5", "target": "n6", "sourceOutput": 0, "targetInput": 0}, + {"source": "n6", "target": "n7", "sourceOutput": 0, "targetInput": 0}, + ], + }, + "invocations": [{"type": "schedule", "cronExpression": "0 8 * * 1-5"}], + }, + { + "label": "Treuhand: PDF-Klassifizierung & Trustee-Import", + "mandateId": None, + "featureInstanceId": None, + "isTemplate": True, + "templateScope": "system", + "sharedReadOnly": True, + "active": False, + "graph": { + "nodes": [ + {"id": "n1", "type": "trigger.schedule", "x": 50, "y": 200, "title": "Geplanter Import", "parameters": {}}, + {"id": "n2", "type": "sharepoint.listFiles", "x": 300, "y": 200, "title": "SharePoint Ordner lesen", "parameters": {}}, + {"id": "n3", "type": "flow.loop", "x": 550, "y": 200, "title": "Pro Dokument", "parameters": {"items": {"type": "ref", "nodeId": "n2", "path": ["files"]}, "concurrency": 1}}, + {"id": "n4", "type": "sharepoint.readFile", "x": 800, "y": 200, "title": "PDF-Inhalt lesen", "parameters": {}}, + {"id": "n5", "type": "ai.prompt", "x": 1050, "y": 200, "title": "Typ klassifizieren (Rechnung, Beleg, Bankauszug, Vertrag, etc.)", "parameters": {}}, + {"id": "n6", "type": "trustee.extractFromFiles", "x": 1300, "y": 200, "title": "Dokument extrahieren", "parameters": {}}, + {"id": "n7", "type": "trustee.processDocuments", "x": 1550, "y": 200, "title": "In Trustee einlesen", "parameters": {}}, + ], + "connections": [ + {"source": "n1", "target": "n2", "sourceOutput": 0, "targetInput": 0}, + {"source": "n2", "target": "n3", "sourceOutput": 0, "targetInput": 0}, + {"source": "n3", "target": "n4", "sourceOutput": 0, "targetInput": 0}, + {"source": "n4", "target": "n5", "sourceOutput": 0, "targetInput": 0}, + {"source": "n5", "target": "n6", "sourceOutput": 0, "targetInput": 0}, + {"source": "n6", "target": "n7", "sourceOutput": 0, "targetInput": 0}, + ], + }, + "invocations": [{"type": "schedule", "cronExpression": "0 7 * * 1-5"}], + }, + ] + + def getUiObjects() -> List[Dict[str, Any]]: """Return UI objects for RBAC catalog registration.""" return UI_OBJECTS diff --git a/modules/features/graphicalEditor/portTypes.py b/modules/features/graphicalEditor/portTypes.py index 12c2d90f..a7eb0f3f 100644 --- a/modules/features/graphicalEditor/portTypes.py +++ b/modules/features/graphicalEditor/portTypes.py @@ -11,11 +11,18 @@ output normalizers, and Transit helpers. import logging import time import uuid +from datetime import datetime, timezone from typing import Any, Dict, List, Optional from pydantic import BaseModel, ConfigDict, Field from modules.shared.i18nRegistry import resolveText, t +from modules.datamodels.datamodelPortTypes import ( + PortField, + PortSchema, + PORT_TYPE_CATALOG, + PRIMITIVE_TYPES, +) logger = logging.getLogger(__name__) @@ -24,35 +31,6 @@ logger = logging.getLogger(__name__) # Pydantic models # --------------------------------------------------------------------------- -class PortField(BaseModel): - model_config = ConfigDict(populate_by_name=True) - - name: str - type: str # str, int, bool, List[str], List[Document], Dict[str,Any], ConnectionRef, … - description: str = "" - required: bool = True - enumValues: Optional[List[str]] = None - # Marks this field as the discriminator for a Ref-Schema (e.g. ConnectionRef.authority, - # FeatureInstanceRef.featureCode). Pickers/validators use it to filter compatible - # producers by sub-type. Type must be "str" when discriminator is True. - discriminator: bool = False - # Surfaces this field at the top of the DataPicker list as the most common pick. - recommended: bool = False - # Human DataPicker title (camelCase JSON for frontend). Omit for technical paths-only. - picker_label: Optional[str] = Field(default=None, serialization_alias="pickerLabel") - # For List[T] fields: segment between parent and inner field (iteration / one list item). - picker_item_label: Optional[str] = Field(default=None, serialization_alias="pickerItemLabel") - - -class PortSchema(BaseModel): - name: str # e.g. "EmailDraft", "AiResult", "Transit" - fields: List[PortField] - # Declarative flag for the engine: when True, the executor attaches - # connection provenance ({id, authority, label}) onto the output. Replaces - # hard-coded schema lists in actionNodeExecutor._attachConnectionProvenance. - carriesConnectionProvenance: bool = False - - class InputPortDef(BaseModel): accepts: List[str] # list of accepted schema names @@ -70,523 +48,10 @@ class OutputPortDef(BaseModel): return d -# --------------------------------------------------------------------------- -# PORT_TYPE_CATALOG -# --------------------------------------------------------------------------- - -PORT_TYPE_CATALOG: Dict[str, PortSchema] = { - # ----------------------------------------------------------------- - # Refs (handles to external resources, pickable by user) - # ----------------------------------------------------------------- - "ConnectionRef": PortSchema(name="ConnectionRef", fields=[ - PortField(name="id", type="str", description="UserConnection.id (UUID)"), - PortField(name="authority", type="str", discriminator=True, - description="Auth-Provider-Code: msft | clickup | google | …"), - PortField(name="label", type="str", required=False, description="Anzeigename"), - ]), - "FeatureInstanceRef": PortSchema(name="FeatureInstanceRef", fields=[ - PortField(name="id", type="str", description="FeatureInstance.id (UUID)"), - PortField(name="featureCode", type="str", discriminator=True, - description="Feature-Modul-Code: trustee | redmine | clickup | sharepoint | …"), - PortField(name="label", type="str", required=False, description="Anzeigename"), - PortField(name="mandateId", type="str", required=False, description="Zugehöriger Mandant"), - ]), - "ClickUpListRef": PortSchema(name="ClickUpListRef", fields=[ - PortField(name="listId", type="str", description="ClickUp-Listen-ID"), - PortField(name="name", type="str", required=False, description="Listenname"), - PortField(name="spaceId", type="str", required=False, description="Space-ID"), - PortField(name="groupId", type="str", required=False, description="Gruppen-ID für die Gruppierungszuordnung"), - PortField(name="connection", type="ConnectionRef", required=False, - description="ClickUp-Verbindung"), - ]), - "PromptTemplateRef": PortSchema(name="PromptTemplateRef", fields=[ - PortField(name="id", type="str", description="Prompt-Template-ID"), - PortField(name="name", type="str", required=False, description="Anzeigename"), - PortField(name="version", type="str", required=False, description="Version / Tag"), - ]), - "SharePointFolderRef": PortSchema(name="SharePointFolderRef", fields=[ - PortField(name="siteUrl", type="str", required=False, description="SharePoint Site"), - PortField(name="driveId", type="str", required=False, description="Drive ID"), - PortField(name="folderPath", type="str", required=False, description="Ordnerpfad"), - PortField(name="label", type="str", required=False, description="Kurzlabel für Picker"), - ]), - "SharePointFileRef": PortSchema(name="SharePointFileRef", fields=[ - PortField(name="siteUrl", type="str", required=False, description="SharePoint Site"), - PortField(name="driveId", type="str", required=False, description="Drive ID"), - PortField(name="filePath", type="str", required=False, description="Dateipfad"), - PortField(name="fileName", type="str", required=False, description="Dateiname"), - PortField(name="label", type="str", required=False, description="Kurzlabel"), - ]), - "Document": PortSchema(name="Document", fields=[ - PortField(name="id", type="str", required=False, description="Dokument-/Datei-ID"), - PortField(name="name", type="str", required=False, description="Anzeigename"), - PortField(name="mimeType", type="str", required=False, description="MIME-Typ"), - PortField(name="sizeBytes", type="int", required=False, description="Grösse"), - PortField(name="downloadUrl", type="str", required=False, description="Download-URL"), - PortField(name="filePath", type="str", required=False, description="Logischer Pfad"), - ]), - "FileItem": PortSchema(name="FileItem", fields=[ - PortField(name="id", type="str", required=False, description="Datei-ID"), - PortField(name="name", type="str", required=False, description="Name"), - PortField(name="path", type="str", required=False, description="Pfad"), - PortField(name="mimeType", type="str", required=False, description="MIME"), - PortField(name="sizeBytes", type="int", required=False, description="Grösse"), - ]), - "EmailItem": PortSchema(name="EmailItem", fields=[ - PortField(name="id", type="str", required=False, description="Message-ID"), - PortField(name="subject", type="str", required=False, description="Betreff"), - PortField(name="fromAddress", type="str", required=False, description="Absender"), - PortField(name="toAddresses", type="List[str]", required=False, description="Empfänger"), - PortField(name="receivedAt", type="str", required=False, description="Empfangen am"), - PortField(name="hasAttachments", type="bool", required=False, description="Hat Anhänge"), - PortField(name="bodyPreview", type="str", required=False, description="Vorschau"), - ]), - "TaskItem": PortSchema(name="TaskItem", fields=[ - PortField(name="id", type="str", required=False, description="Task-ID"), - PortField(name="title", type="str", required=False, description="Titel"), - PortField(name="status", type="str", required=False, description="Status"), - PortField(name="assignee", type="str", required=False, description="Assignee"), - PortField(name="dueDate", type="str", required=False, description="Fälligkeit"), - PortField(name="listId", type="str", required=False, description="ClickUp-Liste"), - ]), - "QueryResult": PortSchema(name="QueryResult", fields=[ - PortField(name="rows", type="List[Any]", description="Ergebniszeilen"), - PortField(name="columns", type="List[str]", required=False, description="Spaltennamen"), - PortField(name="count", type="int", required=False, description="Zeilenanzahl"), - ]), - "UdmPage": PortSchema(name="UdmPage", fields=[ - PortField(name="pageNumber", type="int", required=False, description="Seitennummer"), - PortField(name="blocks", type="List[Any]", required=False, description="ContentBlocks"), - ]), - "UdmBlock": PortSchema(name="UdmBlock", fields=[ - PortField(name="kind", type="str", required=False, description="Block-Typ"), - PortField(name="text", type="str", required=False, description="Textinhalt"), - PortField(name="children", type="List[Any]", required=False, description="Unterblöcke"), - ]), - "DocumentList": PortSchema(name="DocumentList", carriesConnectionProvenance=True, fields=[ - PortField(name="documents", type="List[Document]", - description="Dokumente aus vorherigen Schritten", recommended=True), - PortField(name="connection", type="ConnectionRef", required=False, - description="Verbindung, mit der die Liste erzeugt wurde"), - PortField(name="source", type="SharePointFolderRef", required=False, - description="Herkunftsordner / Quelle"), - PortField(name="count", type="int", required=False, - description="Anzahl Dokumente"), - ]), - "FileList": PortSchema(name="FileList", carriesConnectionProvenance=True, fields=[ - PortField(name="files", type="List[FileItem]", - description="Dateiliste"), - PortField(name="connection", type="ConnectionRef", required=False, - description="Verbindung"), - PortField(name="source", type="SharePointFolderRef", required=False, - description="Listen-Kontext"), - PortField(name="count", type="int", required=False, - description="Anzahl Dateien"), - ]), - "EmailDraft": PortSchema(name="EmailDraft", carriesConnectionProvenance=True, fields=[ - PortField(name="subject", type="str", - description="Betreff"), - PortField(name="body", type="str", - description="Inhalt"), - PortField(name="to", type="List[str]", - description="Empfänger"), - PortField(name="cc", type="List[str]", required=False, - description="CC"), - PortField(name="attachments", type="List[Document]", required=False, - description="Anhänge"), - PortField(name="connection", type="ConnectionRef", required=False, - description="Outlook-/Graph-Verbindung"), - ]), - "EmailList": PortSchema(name="EmailList", carriesConnectionProvenance=True, fields=[ - PortField(name="emails", type="List[EmailItem]", - description="E-Mails"), - PortField(name="connection", type="ConnectionRef", required=False, - description="Verbindung"), - PortField(name="count", type="int", required=False, - description="Anzahl"), - ]), - "TaskList": PortSchema(name="TaskList", carriesConnectionProvenance=True, fields=[ - PortField(name="tasks", type="List[TaskItem]", - description="Aufgaben"), - PortField(name="connection", type="ConnectionRef", required=False, - description="Verbindung"), - PortField(name="listId", type="str", required=False, - description="ClickUp-Listen-ID"), - PortField(name="count", type="int", required=False, - description="Anzahl"), - ]), - "TaskResult": PortSchema(name="TaskResult", fields=[ - PortField(name="success", type="bool", - description="Erfolg"), - PortField(name="taskId", type="str", - description="Aufgaben-ID"), - PortField(name="task", type="Dict", - description="Aufgabendaten"), - ]), - "FormPayload": PortSchema(name="FormPayload", fields=[ - PortField(name="payload", type="Dict[str,Any]", - description="Formulardaten"), - ]), - "AiResult": PortSchema(name="AiResult", fields=[ - PortField(name="prompt", type="str", - description="Prompt", - picker_label=t("Eingabe (Prompt des Schritts)"), - ), - PortField(name="response", type="str", - description=( - "Antworttext (Modell-Fließtext o. ä.; Bilder liegen in documents, nicht hier)." - ), - recommended=True, - picker_label=t("Ausgabetext (Modell)"), - ), - PortField(name="responseData", type="Dict", required=False, - description="Strukturierte Antwort (nur bei JSON-Ausgabe)", - picker_label=t("Strukturierte Antwortdaten")), - PortField(name="context", type="str", - description="Kontext", - picker_label=t("Eingabe-Kontext")), - PortField(name="documents", type="List[Document]", - description=( - "Erzeugte oder mitgegebene Dateien (z. B. Bilder); documentData = Nutzlast pro Eintrag." - ), - picker_label=t("Alle Ausgabe-Dateien (Liste)"), - picker_item_label=t("je Datei"), - ), - PortField(name="data", type="Dict", required=False, - description=( - "Internes Payload-Objekt (entspricht ``ActionResult.data``-Semantik). " - "Wird vom Executor gesetzt und enthält denselben Inhalt wie ``response`` " - "in strukturierter Form; primär für nachgelagerte Kontext-Nodes." - ), - picker_label=t("Technische Detaildaten (data)")), - PortField(name="imageDocumentsOnly", type="List[Document]", required=False, - description="Nur Bild-bezogene Einträge aus documents.", - picker_label=t("Nur Bilder (Liste)")), - ]), - "BoolResult": PortSchema(name="BoolResult", fields=[ - PortField(name="result", type="bool", - description="Ergebnis"), - PortField(name="reason", type="str", required=False, - description="Begründung"), - ]), - "TextResult": PortSchema(name="TextResult", fields=[ - PortField(name="text", type="str", - description="Text", - picker_label=t("Text (Schrittausgabe)")), - ]), - "LoopItem": PortSchema(name="LoopItem", fields=[ - PortField(name="currentItem", type="Any", - description="Aktuelles Element"), - PortField(name="currentIndex", type="int", - description="Aktueller Index"), - PortField(name="items", type="List[Any]", - description="Alle Elemente"), - PortField(name="count", type="int", - description="Gesamtanzahl"), - ]), - "AggregateResult": PortSchema(name="AggregateResult", fields=[ - PortField(name="items", type="List[Any]", - description="Gesammelte Elemente"), - PortField(name="count", type="int", - description="Anzahl"), - ]), - "MergeResult": PortSchema(name="MergeResult", fields=[ - PortField(name="inputs", type="Dict[int,Any]", - description="Eingaben nach Port"), - PortField(name="first", type="Any", - description="Erstes verfügbares"), - PortField(name="merged", type="Dict", - description="Zusammengeführte Daten"), - ]), - "ContextBranch": PortSchema(name="ContextBranch", fields=[ - PortField(name="items", type="List[Any]", - description="Schleifen-fertige Elemente aus dem (gefilterten) Kontext", - recommended=True, - picker_label=t("Gefilterte Elemente")), - PortField(name="data", type="Dict", required=False, - description="Gefilterter Presentation-Umschlag oder Eingabe-Spiegel", - picker_label=t("Kontext (data)")), - PortField(name="filterApplied", type="bool", required=False, - description="True wenn ein Kontext-Inhaltsfilter angewendet wurde"), - PortField(name="contentType", type="str", required=False, - description="Angewendeter Inhaltstyp-Filter (z. B. image)"), - PortField(name="match", type="int", required=False, - description="Aktiver Ausgangs-Index (Fall oder Sonst)"), - ]), - "ActionDocument": PortSchema(name="ActionDocument", fields=[ - PortField(name="documentName", type="str", - description="Dokumentname", - picker_label=t("Dateiname")), - PortField(name="documentData", type="Any", - description="Inhalt / Rohdaten (z.B. JSON-String, Bytes)", - picker_label=t("Dateiinhalt (JSON, Text oder Bild)"), - recommended=True), - PortField(name="mimeType", type="str", - description="MIME-Typ", - picker_label=t("Dateityp (MIME)")), - PortField(name="fileId", type="str", required=False, - description="Persistierte FileItem.id (vom Engine ergänzt)"), - PortField(name="fileName", type="str", required=False, - description="Persistierter Dateiname (vom Engine ergänzt)"), - ]), - "ActionResult": PortSchema(name="ActionResult", fields=[ - PortField(name="success", type="bool", - description="Erfolg"), - PortField(name="error", type="str", required=False, - description="Fehler"), - # `documents` is populated for every action that returns ActionResult - # (see datamodelChat.ActionResult.documents and actionNodeExecutor.out). - # Without it in the catalog the DataPicker cannot offer downstream - # bindings like `processDocuments → documents → *` for syncToAccounting. - PortField(name="documents", type="List[ActionDocument]", required=False, - description=( - "Dokumentliste für Actions mit echten Artefakt-Dokumenten. " - "Beim Knoten „Inhalt extrahieren“ fehlt dieses Feld in der Knotenausgabe." - ), - picker_label=t("Alle Ausgabe-Dokumente"), - picker_item_label=t("je Dokument"), - ), - PortField(name="data", type="Dict", required=False, - description=( - "Strukturierter Inhalt. Bei **context.extractContent**: **Presentation**-Root " - "(`schemaVersion`, `kind`, `fileOrder`, `files`) plus **`_meta`** — ohne " - "zusätzliches `response`/`contentExtracted`-Duplikat." - ), - picker_label=t("Technische Detaildaten (data)")), - # Mirror AiResult primary text fields so DataPicker / primaryTextRef behave the same - PortField(name="prompt", type="str", required=False, - description="Optional: auslösender Prompt / Schrittname", - picker_label=t("Auslöser / Prompt (falls vorhanden)")), - PortField(name="response", type="str", required=False, - description=( - "Fließtext wo die Action einen liefert. Bei **„Inhalt extrahieren“** absichtlich leer — " - "Inhalt liegt in ``data``.``files``." - ), - recommended=True, - picker_label=t("Nur Fließtext (gesamt)")), - PortField(name="context", type="str", required=False, - description="Optional: Eingabe-Kontext", - picker_label=t("Mitgegebener Kontext")), - PortField(name="imageDocumentsOnly", type="List[ActionDocument]", required=False, - description=( - "Nur Bild-bezogene Einträge. Bei „Inhalt extrahieren“: synthetische " - "Einträge mit ``fileId`` aus persistierten Extrakt-Bildern (kein separates JSON-Dokument)." - ), - picker_label=t("Nur Bilder (Liste)")), - PortField(name="responseData", type="Dict", required=False, - description="Optional: strukturierte Zusatzdaten", - picker_label=t("Strukturierte Zusatzdaten")), - PortField(name="presentation", type="Dict", required=False, - description=( - "Selten: Top-Level-Spiegel von Präsentationsdaten andere Actions. " - "Bei „Inhalt extrahieren“ liegt alles direkt unter ``data`` (kein zusätzlicher Spiegel)." - ), - picker_label=t("Presentation (Top-Level-Spiegel)")), - PortField(name="presentationSummary", type="Dict", required=False, - description=( - "Kompakte Metadaten zu ``presentation`` (Debugging / traces)." - ), - picker_label=t("Presentation-Zusammenfassung")), - PortField(name="presentationConfig", type="Dict", required=False, - description=( - "Optional: Debugging-Konfiguration; bei Extract liegt die Primärquelle in ``validationMetadata`` des JSON-Dokuments." - ), - picker_label=t("Presentation-Konfiguration")), - ]), - "Transit": PortSchema(name="Transit", fields=[]), - "UdmDocument": PortSchema(name="UdmDocument", carriesConnectionProvenance=True, fields=[ - PortField(name="id", type="str", description="Dokument-ID"), - PortField(name="sourceType", type="str", description="Quellformat (pdf, docx, …)"), - PortField(name="sourcePath", type="str", description="Quellpfad"), - PortField(name="children", type="List[Any]", description="StructuralNodes / Seiten"), - PortField(name="connection", type="ConnectionRef", required=False, - description="Optionale Verbindungsreferenz"), - PortField(name="source", type="SharePointFileRef", required=False, - description="Optionale Datei-Herkunft"), - ]), - "UdmNodeList": PortSchema(name="UdmNodeList", fields=[ - PortField(name="nodes", type="List[Any]", description="UDM StructuralNodes oder ContentBlocks"), - PortField(name="count", type="int", description="Anzahl"), - ]), - "ConsolidateResult": PortSchema(name="ConsolidateResult", fields=[ - PortField(name="result", type="Any", description="Konsolidiertes Ergebnis"), - PortField(name="mode", type="str", description="Konsolidierungsmodus"), - PortField(name="count", type="int", description="Anzahl verarbeiteter Elemente"), - ]), - - # ----------------------------------------------------------------- - # Shared sub-types (used inside Result schemas) - # ----------------------------------------------------------------- - "ProcessError": PortSchema(name="ProcessError", fields=[ - PortField(name="documentId", type="str", required=False, - description="Betroffenes Dokument (falls zuordbar)"), - PortField(name="stage", type="str", - description="Pipeline-Stufe: extract | parse | sync | validate | …"), - PortField(name="message", type="str", description="Fehlermeldung"), - PortField(name="code", type="str", required=False, description="Fehler-Code"), - ]), - "JournalLine": PortSchema(name="JournalLine", fields=[ - PortField(name="id", type="str", required=False, description="Buchungszeilen-ID"), - PortField(name="bookingDate", type="str", description="Buchungsdatum (ISO)"), - PortField(name="account", type="str", description="Konto"), - PortField(name="contraAccount", type="str", required=False, description="Gegenkonto"), - PortField(name="amount", type="float", description="Betrag"), - PortField(name="currency", type="str", required=False, description="Währung"), - PortField(name="text", type="str", required=False, description="Buchungstext"), - PortField(name="reference", type="str", required=False, description="Beleg-Referenz"), - ]), - - # ----------------------------------------------------------------- - # Trustee Action Results - # ----------------------------------------------------------------- - "TrusteeRefreshResult": PortSchema(name="TrusteeRefreshResult", fields=[ - PortField(name="syncCounts", type="Dict[str,int]", - description="Tabellen → Anzahl synchronisierter Datensätze"), - PortField(name="oldestBookingDate", type="str", required=False, - description="Ältestes Buchungsdatum (ISO)"), - PortField(name="newestBookingDate", type="str", required=False, - description="Neuestes Buchungsdatum (ISO)"), - PortField(name="durationMs", type="int", required=False, - description="Dauer in Millisekunden"), - PortField(name="featureInstance", type="FeatureInstanceRef", required=False, - description="Trustee-Instanz"), - PortField(name="errors", type="List[ProcessError]", required=False, - description="Fehler-Liste"), - ]), - "TrusteeProcessResult": PortSchema(name="TrusteeProcessResult", fields=[ - PortField(name="documents", type="List[Document]", - description="Verarbeitete Dokumente mit angereicherten Daten"), - PortField(name="processedCount", type="int", required=False, - description="Anzahl erfolgreich verarbeiteter Dokumente"), - PortField(name="failedCount", type="int", required=False, - description="Anzahl fehlgeschlagener Dokumente"), - PortField(name="featureInstance", type="FeatureInstanceRef", required=False, - description="Trustee-Instanz"), - PortField(name="errors", type="List[ProcessError]", required=False, - description="Fehler-Liste"), - ]), - "TrusteeSyncResult": PortSchema(name="TrusteeSyncResult", fields=[ - PortField(name="syncedCount", type="int", - description="Erfolgreich in das Buchhaltungssystem übertragene Datensätze"), - PortField(name="failedCount", type="int", required=False, - description="Fehlgeschlagene Übertragungen"), - PortField(name="journalLines", type="List[JournalLine]", required=False, - description="Erzeugte Buchungszeilen"), - PortField(name="featureInstance", type="FeatureInstanceRef", required=False, - description="Ziel-Trustee-Instanz"), - PortField(name="errors", type="List[ProcessError]", required=False, - description="Fehler-Liste"), - ]), - - # ----------------------------------------------------------------- - # Redmine Action Results - # ----------------------------------------------------------------- - "RedmineTicket": PortSchema(name="RedmineTicket", fields=[ - PortField(name="id", type="str", description="Ticket-ID"), - PortField(name="subject", type="str", description="Betreff"), - PortField(name="description", type="str", required=False, description="Beschreibung"), - PortField(name="status", type="str", description="Status-Name"), - PortField(name="tracker", type="str", required=False, - description="Tracker (Bug, Feature, Task, …)"), - PortField(name="priority", type="str", required=False, description="Priorität"), - PortField(name="assignee", type="str", required=False, description="Zugewiesen an"), - PortField(name="author", type="str", required=False, description="Autor"), - PortField(name="project", type="str", required=False, description="Projekt"), - PortField(name="createdOn", type="str", required=False, description="Erstellt (ISO)"), - PortField(name="updatedOn", type="str", required=False, description="Aktualisiert (ISO)"), - PortField(name="dueDate", type="str", required=False, description="Fälligkeitsdatum"), - PortField(name="featureInstance", type="FeatureInstanceRef", required=False, - description="Redmine-Instanz"), - ]), - "RedmineTicketList": PortSchema(name="RedmineTicketList", fields=[ - PortField(name="tickets", type="List[RedmineTicket]", description="Ticket-Liste"), - PortField(name="count", type="int", required=False, description="Anzahl Tickets"), - PortField(name="filters", type="Dict[str,Any]", required=False, - description="Angewendete Filter"), - PortField(name="featureInstance", type="FeatureInstanceRef", required=False, - description="Redmine-Instanz"), - ]), - "RedmineRelationList": PortSchema(name="RedmineRelationList", fields=[ - PortField(name="relations", type="List[Any]", description="Relationen"), - PortField(name="count", type="int", required=False, description="Anzahl in dieser Seite"), - PortField(name="totalMatched", type="int", required=False, - description="Gesamtanzahl nach Filter"), - PortField(name="offset", type="int", required=False, description="Pagination-Offset"), - PortField(name="hasMore", type="bool", required=False, description="Weitere Seiten verfügbar"), - ]), - "RedmineStats": PortSchema(name="RedmineStats", fields=[ - PortField(name="kpis", type="Dict[str,Any]", - description="Key Performance Indicators"), - PortField(name="throughput", type="Dict[str,Any]", required=False, - description="Durchsatz pro Zeitraum"), - PortField(name="statusDistribution", type="Dict[str,int]", required=False, - description="Tickets pro Status"), - PortField(name="backlog", type="Dict[str,Any]", required=False, - description="Backlog-Statistik"), - PortField(name="featureInstance", type="FeatureInstanceRef", required=False, - description="Redmine-Instanz"), - ]), - - # ----------------------------------------------------------------- - # ClickUp / SharePoint / Email helper results - # ----------------------------------------------------------------- - "TaskAttachmentRef": PortSchema(name="TaskAttachmentRef", fields=[ - PortField(name="taskId", type="str", description="Aufgaben-ID"), - PortField(name="attachmentId", type="str", required=False, description="Attachment-ID"), - PortField(name="fileName", type="str", required=False, description="Dateiname"), - PortField(name="url", type="str", required=False, description="Download-URL"), - ]), - "AttachmentSpec": PortSchema(name="AttachmentSpec", fields=[ - PortField(name="source", type="str", - description="Quellart: path | document | url", - enumValues=["path", "document", "url"]), - PortField(name="ref", type="str", - description="Referenzwert (Pfad / Document.id / URL)"), - PortField(name="fileName", type="str", required=False, - description="Override-Dateiname"), - PortField(name="mimeType", type="str", required=False, description="MIME-Override"), - ]), - - # ----------------------------------------------------------------- - # Expressions (replace string-typed condition / cron params) - # ----------------------------------------------------------------- - "CronExpression": PortSchema(name="CronExpression", fields=[ - PortField(name="expression", type="str", - description="Cron-Ausdruck (5 oder 6 Felder)"), - PortField(name="timezone", type="str", required=False, - description="IANA Timezone (z.B. Europe/Zurich)"), - ]), - "ConditionExpression": PortSchema(name="ConditionExpression", fields=[ - PortField(name="expression", type="str", description="Boolescher Ausdruck"), - PortField(name="syntax", type="str", required=False, - description="jmespath | jsonlogic | python | template", - enumValues=["jmespath", "jsonlogic", "python", "template"]), - ]), - - # ----------------------------------------------------------------- - # Semantic primitives (give meaning to scalar str values) - # ----------------------------------------------------------------- - "DateTime": PortSchema(name="DateTime", fields=[ - PortField(name="iso", type="str", description="ISO-8601 Datum/Zeit"), - PortField(name="timezone", type="str", required=False, - description="IANA Timezone"), - ]), - "Url": PortSchema(name="Url", fields=[ - PortField(name="url", type="str", description="Vollständige URL"), - PortField(name="label", type="str", required=False, description="Anzeigename"), - ]), -} - - # --------------------------------------------------------------------------- # Catalog validator # --------------------------------------------------------------------------- -# Primitives accepted as PortField.type in addition to catalog schema names. -PRIMITIVE_TYPES: frozenset = frozenset({ - "str", "int", "bool", "float", "Any", "Dict", "List", -}) - def _stripContainer(typeStr: str) -> List[str]: """ @@ -744,8 +209,6 @@ PRIMARY_TEXT_HANDOVER_REF_PATH: Dict[str, List[Any]] = { def resolveSystemVariable(variable: str, context: Dict[str, Any]) -> Any: """Resolve a system variable name to its runtime value.""" - from datetime import datetime, timezone - now = datetime.now(timezone.utc) mapping = { "system.timestamp": lambda: int(now.timestamp() * 1000), diff --git a/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py b/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py index 663f87e4..20a2708b 100644 --- a/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py +++ b/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py @@ -8,13 +8,14 @@ import asyncio import json import logging import math +import uuid from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, Path, Query, Body, Request, HTTPException from fastapi.responses import JSONResponse, StreamingResponse, Response from modules.auth import limiter, getRequestContext, RequestContext from modules.datamodels.datamodelPagination import PaginationParams, PaginationMetadata, normalize_pagination_dict -from modules.routes.routeHelpers import applyFiltersAndSort +from modules.dbHelpers.paginationHelpers import applyFiltersAndSort from modules.features.graphicalEditor.mainGraphicalEditor import getGraphicalEditorServices from modules.features.graphicalEditor.nodeRegistry import getNodeTypesForApi @@ -422,7 +423,6 @@ async def post_execute( workflow_for_envelope = wf targetFeatureInstanceId = wf.get("targetFeatureInstanceId") if not workflowId: - import uuid workflowId = f"transient-{uuid.uuid4().hex[:12]}" logger.info("graphicalEditor execute: using transient workflowId=%s", workflowId) @@ -642,18 +642,18 @@ def get_templates( iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) templates = iface.getTemplates(scope=scope) - from modules.routes.routeHelpers import enrichRowsWithFkLabels + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow - enrichRowsWithFkLabels(templates, AutoWorkflow) + enrichRowsWithFkLabels(templates, AutoWorkflow, db=iface.db) if mode == "filterValues": if not column: raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") - from modules.routes.routeHelpers import handleFilterValuesInMemory + from modules.dbHelpers.paginationHelpers import handleFilterValuesInMemory return handleFilterValuesInMemory(templates, column, pagination) if mode == "ids": - from modules.routes.routeHelpers import handleIdsInMemory + from modules.dbHelpers.paginationHelpers import handleIdsInMemory return handleIdsInMemory(templates, pagination) paginationParams = None @@ -1411,11 +1411,11 @@ def get_workflows( if mode == "filterValues": if not column: raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") - from modules.routes.routeHelpers import handleFilterValuesInMemory + from modules.dbHelpers.paginationHelpers import handleFilterValuesInMemory return handleFilterValuesInMemory(enriched, column, pagination) if mode == "ids": - from modules.routes.routeHelpers import handleIdsInMemory + from modules.dbHelpers.paginationHelpers import handleIdsInMemory return handleIdsInMemory(enriched, pagination) paginationParams = None diff --git a/modules/features/neutralization/datamodelFeatureNeutralizer.py b/modules/features/neutralization/datamodelFeatureNeutralizer.py index 9465667c..a308faa3 100644 --- a/modules/features/neutralization/datamodelFeatureNeutralizer.py +++ b/modules/features/neutralization/datamodelFeatureNeutralizer.py @@ -92,63 +92,8 @@ class DataNeutraliserConfig(PowerOnModel): ) -@i18nModel("Neutralisiertes Datenattribut") -class DataNeutralizerAttributes(PowerOnModel): - """Zuordnung Originaltext zu Platzhalter fuer neutralisierte Daten.""" - id: str = Field( - default_factory=lambda: str(uuid.uuid4()), - description="Unique ID of the attribute mapping (used as UID in neutralized files)", - json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}, - ) - mandateId: str = Field( - description="ID of the mandate this attribute belongs to", - json_schema_extra={ - "label": "Mandanten-ID", - "frontend_type": "text", - "frontend_readonly": True, - "frontend_required": True, - "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}, - }, - ) - featureInstanceId: str = Field( - description="ID of the feature instance this attribute belongs to", - json_schema_extra={ - "label": "Feature-Instanz-ID", - "frontend_type": "text", - "frontend_readonly": True, - "frontend_required": True, - "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}, - }, - ) - userId: str = Field( - description="ID of the user who created this attribute", - json_schema_extra={ - "label": "Benutzer-ID", - "frontend_type": "text", - "frontend_readonly": True, - "frontend_required": True, - "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}, - }, - ) - originalText: str = Field( - description="Original text that was neutralized", - json_schema_extra={"label": "Originaltext", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}, - ) - fileId: Optional[str] = Field( - default=None, - description="ID of the file this attribute belongs to", - json_schema_extra={ - "label": "Datei-ID", - "frontend_type": "text", - "frontend_readonly": True, - "frontend_required": False, - "fk_target": {"db": "poweron_management", "table": "FileItem", "labelField": "fileName"}, - }, - ) - patternType: str = Field( - description="Type of pattern that matched (email, phone, name, etc.)", - json_schema_extra={"label": "Mustertyp", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}, - ) +# Re-exported from canonical location (moved to datamodels layer) +from modules.datamodels.datamodelFeatures import DataNeutralizerAttributes # noqa: F401 @i18nModel("Neutralisierungs-Snapshot") diff --git a/modules/features/neutralization/interfaceFeatureNeutralizer.py b/modules/features/neutralization/interfaceFeatureNeutralizer.py index 1575ed85..3d5c9129 100644 --- a/modules/features/neutralization/interfaceFeatureNeutralizer.py +++ b/modules/features/neutralization/interfaceFeatureNeutralizer.py @@ -14,7 +14,7 @@ from modules.features.neutralization.datamodelFeatureNeutralizer import ( DataNeutralizationSnapshot, ) from modules.connectors.connectorDbPostgre import DatabaseConnector -from modules.shared.dbRegistry import registerDatabase +from modules.dbHelpers.dbRegistry import registerDatabase from modules.interfaces.interfaceRbac import getRecordsetWithRBAC from modules.shared.configuration import APP_CONFIG from modules.shared.timeUtils import getUtcTimestamp diff --git a/modules/features/neutralization/mainNeutralization.py b/modules/features/neutralization/mainNeutralization.py index 0fe11aea..42f74cf3 100644 --- a/modules/features/neutralization/mainNeutralization.py +++ b/modules/features/neutralization/mainNeutralization.py @@ -231,3 +231,51 @@ def _ensureAccessRulesForRole(rootInterface, roleId: str, ruleTemplates: List[Di createdCount += 1 return createdCount + + +# --------------------------------------------------------------------------- +# Feature Lifecycle Hooks +# --------------------------------------------------------------------------- + +def onMandateDelete(mandateId: str, instances: list) -> None: + """Cascade-delete all neutralization data for deleted mandate.""" + from modules.connectors.connectorDbPostgre import DatabaseConnector + from modules.shared.configuration import APP_CONFIG + from modules.features.neutralization.datamodelFeatureNeutralizer import ( + DataNeutraliserConfig, DataNeutralizationSnapshot, + ) + + try: + featureInstances = [ + inst for inst in instances + if (inst.get("featureCode") if isinstance(inst, dict) else getattr(inst, "featureCode", "")) == FEATURE_CODE + ] + if not featureInstances: + return + + db = DatabaseConnector( + dbHost=APP_CONFIG.get("DB_HOST", "localhost"), + dbDatabase="poweron_neutralization", + dbUser=APP_CONFIG.get("DB_USER"), + dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), + dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), + userId=None, + ) + + totalDeleted = 0 + for inst in featureInstances: + instId = inst.get("id") if isinstance(inst, dict) else getattr(inst, "id", None) + if not instId: + continue + for ModelClass in [DataNeutraliserConfig, DataNeutralizationSnapshot]: + records = db.getRecordset(ModelClass, recordFilter={"featureInstanceId": instId}) or [] + for rec in records: + db.recordDelete(ModelClass, rec.get("id")) + totalDeleted += len(records) + + if totalDeleted: + logger.info(f"Cascade: deleted {totalDeleted} neutralization record(s) for mandate {mandateId}") + db.close() + except Exception as e: + logger.warning(f"Failed to cascade-delete neutralization data for mandate {mandateId}: {e}") + diff --git a/modules/features/neutralization/neutralizePlayground.py b/modules/features/neutralization/neutralizePlayground.py index 500cc1ba..eab0bdeb 100644 --- a/modules/features/neutralization/neutralizePlayground.py +++ b/modules/features/neutralization/neutralizePlayground.py @@ -1,5 +1,6 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. +import base64 import logging import asyncio from typing import Any, Dict, List, Optional @@ -28,7 +29,6 @@ class NeutralizationPlayground: async def processUploadedFileAsync(self, file_bytes: bytes, filename: str) -> Dict[str, Any]: """Process an uploaded file (bytes + filename). Returns neutralized result for text or binary. Saves both original and neutralized files to user files (component storage) when available.""" - import base64 name_lower = (filename or '').lower() mime_map = { '.pdf': 'application/pdf', diff --git a/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py b/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py index 74509118..809d6be5 100644 --- a/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py +++ b/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py @@ -9,9 +9,11 @@ Mehrsprachig: DE, EN, FR, IT """ import asyncio +import base64 import logging import re import json +import uuid from typing import Dict, List, Any, Optional from modules.features.neutralization.datamodelFeatureNeutralizer import DataNeutraliserConfig, DataNeutralizerAttributes @@ -120,12 +122,12 @@ class NeutralizationService: Returns the model object (with contextLength etc.) or None.""" try: from modules.aicore.aicoreModelRegistry import modelRegistry - from modules.aicore.aicoreModelSelector import modelSelector as _modSel + from modules.aicore.aicoreModelSelector import modelSelector from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum _models = modelRegistry.getAvailableModels() _opts = AiCallOptions(operationType=OperationTypeEnum.NEUTRALIZATION_TEXT) - _failover = _modSel.getFailoverModelList("x", "", _opts, _models) + _failover = modelSelector.getFailoverModelList("x", "", _opts, _models) return _failover[0] if _failover else None except Exception as _e: logger.warning(f"_resolveNeutModel failed: {_e}") @@ -219,8 +221,6 @@ class NeutralizationService: Regex patterns run as a supplementary pass to catch anything the model missed. """ - import uuid as _uuid - aiService = None if self._getService: try: @@ -262,7 +262,7 @@ class NeutralizationService: continue if _origText in aiMapping: continue - _uid = str(_uuid.uuid4()) + _uid = str(uuid.uuid4()) _placeholder = f"[{_patType}.{_uid}]" aiMapping[_origText] = _placeholder @@ -430,7 +430,6 @@ class NeutralizationService: Uses NEUTRALIZATION_IMAGE operation type → only internal Private-LLM models. If no internal model available → returns 'blocked'. """ - import base64 try: aiService = None if self._getService: @@ -494,7 +493,6 @@ class NeutralizationService: def processImage(self, imageBytes: bytes, fileName: str, mimeType: str = "image/png") -> Dict[str, Any]: """Sync wrapper for processImageAsync. Uses asyncio.run when no event loop is running.""" - import asyncio try: return asyncio.run(self.processImageAsync(imageBytes, fileName, mimeType)) except RuntimeError: @@ -554,7 +552,6 @@ class NeutralizationService: """Persist mapping to DB for resolve to work. mapping: originalText -> placeholder e.g. '[email.uuid]'""" if not self.interfaceNeutralizer or not mapping: return - import re placeholder_re = re.compile(r'^\[([a-z]+)\.([a-f0-9-]{36})\]$') for original_text, placeholder in mapping.items(): m = placeholder_re.match(placeholder) @@ -615,9 +612,8 @@ class NeutralizationService: neutralized_parts.append(part) continue if type_group == 'image': - import base64 as _b64img try: - _imgBytes = _b64img.b64decode(str(data)) + _imgBytes = base64.b64decode(str(data)) _imgResult = await self.processImageAsync(_imgBytes, fileName) if _imgResult.get("status") == "ok": neutralized_parts.append(part) diff --git a/modules/features/neutralization/serviceNeutralization/subProcessList.py b/modules/features/neutralization/serviceNeutralization/subProcessList.py index 8f815e1e..021cec2b 100644 --- a/modules/features/neutralization/serviceNeutralization/subProcessList.py +++ b/modules/features/neutralization/serviceNeutralization/subProcessList.py @@ -6,6 +6,7 @@ Handles structured data with headers (CSV, JSON, XML) """ import json +import uuid import pandas as pd import xml.etree.ElementTree as ET from typing import Dict, List, Any, Union @@ -58,7 +59,6 @@ class ListProcessor: original = str(row[i]) if original not in self.string_parser.mapping: # Generate a UUID for the placeholder - import uuid placeholderId = str(uuid.uuid4()) self.string_parser.mapping[original] = pattern.replacement_template.format(len(self.string_parser.mapping) + 1) row[i] = self.string_parser.mapping[original] @@ -143,7 +143,6 @@ class ListProcessor: if pattern: if attrValue not in self.string_parser.mapping: # Generate a UUID for the placeholder - import uuid placeholderId = str(uuid.uuid4()) # Create placeholder in format [type.uuid] typeMapping = { @@ -166,7 +165,6 @@ class ListProcessor: if pattern: if attrValue not in self.string_parser.mapping: # Generate a UUID for the placeholder - import uuid placeholderId = str(uuid.uuid4()) # Create placeholder in format [type.uuid] typeMapping = { @@ -202,7 +200,6 @@ class ListProcessor: if pattern: if text not in self.string_parser.mapping: # Generate a UUID for the placeholder - import uuid placeholder_id = str(uuid.uuid4()) # Create placeholder in format [type.uuid] type_mapping = { @@ -223,7 +220,6 @@ class ListProcessor: if text.lower().strip() == name.lower().strip(): if text not in self.string_parser.mapping: # Generate a UUID for the placeholder - import uuid placeholder_id = str(uuid.uuid4()) self.string_parser.mapping[text] = f"[name.{placeholder_id}]" text = self.string_parser.mapping[text] diff --git a/modules/features/realEstate/bzoDocumentRetriever.py b/modules/features/realEstate/bzoDocumentRetriever.py index a9356301..9b271cda 100644 --- a/modules/features/realEstate/bzoDocumentRetriever.py +++ b/modules/features/realEstate/bzoDocumentRetriever.py @@ -4,6 +4,7 @@ Queries Dokument table and retrieves PDF content from ComponentObjects. """ import logging +import re from typing import List, Dict, Any, Optional from .datamodelFeatureRealEstate import Dokument, DokumentTyp, Gemeinde from .interfaceFeatureRealEstate import RealEstateObjects @@ -182,8 +183,6 @@ class BZODocumentRetriever: Returns: Year as integer if found, None otherwise """ - import re - # Try to extract year from label if dokument.label: year_match = re.search(r'\b(19|20)\d{2}\b', dokument.label) diff --git a/modules/features/realEstate/bzoExtraction.py b/modules/features/realEstate/bzoExtraction.py index f56405ed..3eace0f2 100644 --- a/modules/features/realEstate/bzoExtraction.py +++ b/modules/features/realEstate/bzoExtraction.py @@ -8,6 +8,7 @@ directly (no external workflow-orchestration framework). import logging import re +import uuid from typing import TypedDict, List, Dict, Any, Optional from dataclasses import dataclass @@ -1310,8 +1311,6 @@ def run_extraction(pdf_bytes: bytes, pdf_id: str = None, dokument_id: str = None "warnings": [...] } """ - import uuid - if not pdf_id: pdf_id = f"pdf_{uuid.uuid4().hex[:8]}" diff --git a/modules/features/realEstate/interfaceFeatureRealEstate.py b/modules/features/realEstate/interfaceFeatureRealEstate.py index 0637d0e9..9219a842 100644 --- a/modules/features/realEstate/interfaceFeatureRealEstate.py +++ b/modules/features/realEstate/interfaceFeatureRealEstate.py @@ -5,6 +5,8 @@ Handles CRUD operations on Real Estate entities (Projekt, Parzelle, etc.). """ import logging +import re +import time from typing import Dict, Any, List, Optional, Union from .datamodelFeatureRealEstate import ( Projekt, @@ -21,7 +23,7 @@ from .datamodelFeatureRealEstate import ( from modules.datamodels.datamodelUam import User from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.shared.configuration import APP_CONFIG -from modules.shared.dbRegistry import registerDatabase +from modules.dbHelpers.dbRegistry import registerDatabase from modules.security.rbac import RbacClass from modules.datamodels.datamodelRbac import AccessRuleContext from modules.datamodels.datamodelUam import AccessLevel @@ -322,7 +324,6 @@ class RealEstateObjects: def _isUUID(self, value: str) -> bool: """Check if a string looks like a UUID.""" - import re uuid_pattern = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.IGNORECASE) return bool(uuid_pattern.match(value)) @@ -832,8 +833,6 @@ class RealEstateObjects: Dictionary with 'rows' (list of dicts), 'columns' (list of column names), 'rowCount' (int), and 'executionTime' (float) """ - import time - try: start_time = time.time() diff --git a/modules/features/realEstate/mainRealEstate.py b/modules/features/realEstate/mainRealEstate.py index d825ec50..7ab7d8d5 100644 --- a/modules/features/realEstate/mainRealEstate.py +++ b/modules/features/realEstate/mainRealEstate.py @@ -7,6 +7,7 @@ This module also handles feature initialization and RBAC catalog registration. """ import logging +import re from modules.shared.i18nRegistry import t @@ -1351,7 +1352,6 @@ async def executeIntentBasedOperation( location_id = None try: # Check if it's already a UUID - import re uuid_pattern = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.IGNORECASE) if not uuid_pattern.match(location_filter): # Try to resolve name to ID @@ -3071,3 +3071,56 @@ CRITICAL: You MUST include the actual numeric values from the tables in your sum # Return a basic summary if AI fails return f"Summary generation failed: {str(e)}. Found {len(relevant_rules)} relevant rules and {len(relevant_zones)} zones for Bauzone {bauzone}." + +# --------------------------------------------------------------------------- +# Feature Lifecycle Hooks +# --------------------------------------------------------------------------- + +def onMandateDelete(mandateId: str, instances: list) -> None: + """Cascade-delete all realEstate data for deleted mandate.""" + from modules.connectors.connectorDbPostgre import DatabaseConnector + from modules.shared.configuration import APP_CONFIG + from modules.features.realEstate.datamodelFeatureRealEstate import ( + Dokument, Kontext, Land, Kanton, Gemeinde, Parzelle, Projekt, + ) + + try: + featureInstances = [ + inst for inst in instances + if (inst.get("featureCode") if isinstance(inst, dict) else getattr(inst, "featureCode", "")) == FEATURE_CODE + ] + if not featureInstances: + return + + db = DatabaseConnector( + dbHost=APP_CONFIG.get("DB_HOST", "localhost"), + dbDatabase="poweron_realestate", + dbUser=APP_CONFIG.get("DB_USER"), + dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), + dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), + userId=None, + ) + + totalDeleted = 0 + for inst in featureInstances: + instId = inst.get("id") if isinstance(inst, dict) else getattr(inst, "id", None) + if not instId: + continue + for ModelClass in [Dokument, Kontext, Land, Kanton, Gemeinde, Parzelle, Projekt]: + try: + records = db.getRecordset(ModelClass, recordFilter={"featureInstanceId": instId}) or [] + if not records: + records = db.getRecordset(ModelClass, recordFilter={"mandateId": mandateId}) or [] + for rec in records: + db.recordDelete(ModelClass, rec.get("id")) + totalDeleted += len(records) + except Exception: + pass + + if totalDeleted: + logger.info(f"Cascade: deleted {totalDeleted} realEstate record(s) for mandate {mandateId}") + db.close() + except Exception as e: + logger.warning(f"Failed to cascade-delete realEstate data for mandate {mandateId}: {e}") + + diff --git a/modules/features/realEstate/routeFeatureRealEstate.py b/modules/features/realEstate/routeFeatureRealEstate.py index 230c5d80..a1cfdb8b 100644 --- a/modules/features/realEstate/routeFeatureRealEstate.py +++ b/modules/features/realEstate/routeFeatureRealEstate.py @@ -228,20 +228,21 @@ def get_projects( recordFilter = {"featureInstanceId": instanceId} if mode in ("filterValues", "ids"): - from modules.routes.routeHelpers import handleFilterValuesInMemory, handleIdsInMemory, enrichRowsWithFkLabels + from modules.dbHelpers.paginationHelpers import handleFilterValuesInMemory, handleIdsInMemory + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels items = interface.getProjekte(recordFilter=recordFilter) itemDicts = [i.model_dump() if hasattr(i, 'model_dump') else i for i in items] if mode == "filterValues": if not column: raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") - enrichRowsWithFkLabels(itemDicts, Projekt) + enrichRowsWithFkLabels(itemDicts, Projekt, db=interface.db) return handleFilterValuesInMemory(itemDicts, column, pagination) return handleIdsInMemory(itemDicts, pagination) items = interface.getProjekte(recordFilter=recordFilter) paginationParams = _parsePagination(pagination) if paginationParams: - from modules.routes.routeHelpers import applyFiltersAndSort + from modules.dbHelpers.paginationHelpers import applyFiltersAndSort itemDicts = [i.model_dump() if hasattr(i, 'model_dump') else i for i in items] filtered = applyFiltersAndSort(itemDicts, paginationParams) total_items = len(filtered) @@ -369,20 +370,21 @@ def get_parcels( recordFilter = {"featureInstanceId": instanceId} if mode in ("filterValues", "ids"): - from modules.routes.routeHelpers import handleFilterValuesInMemory, handleIdsInMemory, enrichRowsWithFkLabels + from modules.dbHelpers.paginationHelpers import handleFilterValuesInMemory, handleIdsInMemory + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels items = interface.getParzellen(recordFilter=recordFilter) itemDicts = [i.model_dump() if hasattr(i, 'model_dump') else i for i in items] if mode == "filterValues": if not column: raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") - enrichRowsWithFkLabels(itemDicts, Parzelle) + enrichRowsWithFkLabels(itemDicts, Parzelle, db=interface.db) return handleFilterValuesInMemory(itemDicts, column, pagination) return handleIdsInMemory(itemDicts, pagination) items = interface.getParzellen(recordFilter=recordFilter) paginationParams = _parsePagination(pagination) if paginationParams: - from modules.routes.routeHelpers import applyFiltersAndSort + from modules.dbHelpers.paginationHelpers import applyFiltersAndSort itemDicts = [i.model_dump() if hasattr(i, 'model_dump') else i for i in items] filtered = applyFiltersAndSort(itemDicts, paginationParams) total_items = len(filtered) diff --git a/modules/features/redmine/interfaceFeatureRedmine.py b/modules/features/redmine/interfaceFeatureRedmine.py index 0f3991a2..88855501 100644 --- a/modules/features/redmine/interfaceFeatureRedmine.py +++ b/modules/features/redmine/interfaceFeatureRedmine.py @@ -27,7 +27,7 @@ from modules.features.redmine.datamodelRedmine import ( ) from modules.security.rbac import RbacClass from modules.shared.configuration import APP_CONFIG, decryptValue, encryptValue -from modules.shared.dbRegistry import registerDatabase +from modules.dbHelpers.dbRegistry import registerDatabase logger = logging.getLogger(__name__) diff --git a/modules/features/redmine/mainRedmine.py b/modules/features/redmine/mainRedmine.py index 919361c1..fe893cef 100644 --- a/modules/features/redmine/mainRedmine.py +++ b/modules/features/redmine/mainRedmine.py @@ -333,3 +333,51 @@ def _ensureAccessRulesForRole( rootInterface.db.recordCreate(AccessRule, newRule.model_dump()) createdCount += 1 return createdCount + + +# --------------------------------------------------------------------------- +# Feature Lifecycle Hooks +# --------------------------------------------------------------------------- + +def onMandateDelete(mandateId: str, instances: list) -> None: + """Cascade-delete all redmine data for deleted mandate.""" + from modules.connectors.connectorDbPostgre import DatabaseConnector + from modules.shared.configuration import APP_CONFIG + from modules.features.redmine.datamodelRedmine import ( + RedmineInstanceConfig, RedmineTicketMirror, RedmineRelationMirror, + ) + + try: + featureInstances = [ + inst for inst in instances + if (inst.get("featureCode") if isinstance(inst, dict) else getattr(inst, "featureCode", "")) == FEATURE_CODE + ] + if not featureInstances: + return + + db = DatabaseConnector( + dbHost=APP_CONFIG.get("DB_HOST", "localhost"), + dbDatabase="poweron_redmine", + dbUser=APP_CONFIG.get("DB_USER"), + dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), + dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), + userId=None, + ) + + totalDeleted = 0 + for inst in featureInstances: + instId = inst.get("id") if isinstance(inst, dict) else getattr(inst, "id", None) + if not instId: + continue + for ModelClass in [RedmineInstanceConfig, RedmineTicketMirror, RedmineRelationMirror]: + records = db.getRecordset(ModelClass, recordFilter={"featureInstanceId": instId}) or [] + for rec in records: + db.recordDelete(ModelClass, rec.get("id")) + totalDeleted += len(records) + + if totalDeleted: + logger.info(f"Cascade: deleted {totalDeleted} redmine record(s) for mandate {mandateId}") + db.close() + except Exception as e: + logger.warning(f"Failed to cascade-delete redmine data for mandate {mandateId}: {e}") + diff --git a/modules/features/redmine/routeFeatureRedmine.py b/modules/features/redmine/routeFeatureRedmine.py index ff4f1391..d973e690 100644 --- a/modules/features/redmine/routeFeatureRedmine.py +++ b/modules/features/redmine/routeFeatureRedmine.py @@ -63,7 +63,7 @@ def _audit( errorMessage: Optional[str] = None, ) -> None: try: - from modules.shared.auditLogger import audit_logger + from modules.dbHelpers.auditLogger import audit_logger audit_logger.logEvent( userId=str(context.user.id), mandateId=str(context.mandateId) if context.mandateId else None, diff --git a/modules/features/redmine/serviceRedmine.py b/modules/features/redmine/serviceRedmine.py index 2aea0918..b4a3d137 100644 --- a/modules/features/redmine/serviceRedmine.py +++ b/modules/features/redmine/serviceRedmine.py @@ -26,6 +26,7 @@ from __future__ import annotations import logging import time +from datetime import datetime from typing import Any, Dict, List, Optional, Tuple from modules.connectors.connectorTicketsRedmine import ( @@ -253,7 +254,6 @@ def _isoToEpoch(value: Optional[str]) -> Optional[float]: if not value: return None try: - from datetime import datetime return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() except Exception: return None diff --git a/modules/features/redmine/serviceRedmineStats.py b/modules/features/redmine/serviceRedmineStats.py index 33a83aa7..1c289181 100644 --- a/modules/features/redmine/serviceRedmineStats.py +++ b/modules/features/redmine/serviceRedmineStats.py @@ -21,7 +21,7 @@ The whole result is cached in :mod:`serviceRedmineStatsCache` keyed by from __future__ import annotations import bisect -import datetime as _dt +import datetime import logging from collections import Counter, defaultdict from typing import Any, Dict, Iterable, List, Optional, Tuple @@ -170,8 +170,8 @@ def _aggregate( def _kpis( tickets: List[RedmineTicketDto], rootTrackerId: Optional[int], - periodFrom: Optional[_dt.datetime], - periodTo: Optional[_dt.datetime], + periodFrom: Optional[datetime.datetime], + periodTo: Optional[datetime.datetime], ) -> RedmineStatsKpis: total = len(tickets) open_count = sum(1 for t in tickets if not t.isClosed) @@ -260,8 +260,8 @@ def _statusByTracker( def _throughput( tickets: List[RedmineTicketDto], - periodFrom: Optional[_dt.datetime], - periodTo: Optional[_dt.datetime], + periodFrom: Optional[datetime.datetime], + periodTo: Optional[datetime.datetime], bucket: str, ) -> List[RedmineThroughputBucket]: """Build per-bucket snapshots: how many tickets exist at the END of @@ -276,7 +276,7 @@ def _throughput( # If no period is set, span the lifetime of the data. if periodFrom is None or periodTo is None: - all_dates: List[_dt.datetime] = [] + all_dates: List[datetime.datetime] = [] for t in tickets: for s in (t.createdOn, t.updatedOn): d = _parseIsoDate(s) @@ -309,8 +309,8 @@ def _throughput( # open = total - #closed with closedTs <= bucket end. We compute # against ALL tickets (not just the period-windowed counters) so # pre-period tickets are correctly counted in the snapshot. - created_dates: List[_dt.datetime] = [] - closed_dates: List[_dt.datetime] = [] + created_dates: List[datetime.datetime] = [] + closed_dates: List[datetime.datetime] = [] for t in tickets: c = _parseIsoDate(t.createdOn) if c: @@ -341,13 +341,13 @@ def _throughput( return out -def _countLE(sortedDates: List[_dt.datetime], edge: _dt.datetime) -> int: +def _countLE(sortedDates: List[datetime.datetime], edge: datetime.datetime) -> int: """Binary search: how many entries in ``sortedDates`` are <= ``edge``.""" return bisect.bisect_right(sortedDates, edge) def _bucketKeysBetween( - fromD: _dt.datetime, toD: _dt.datetime, bucket: str + fromD: datetime.datetime, toD: datetime.datetime, bucket: str ) -> List[str]: """Inclusive list of bucket keys covering ``[fromD, toD]``.""" if toD < fromD: @@ -357,9 +357,9 @@ def _bucketKeysBetween( cursor = fromD safety = 0 step = ( - _dt.timedelta(days=1) if bucket == "day" - else _dt.timedelta(days=7) if bucket == "week" - else _dt.timedelta(days=27) # month: walk in <31d steps so we never skip + datetime.timedelta(days=1) if bucket == "day" + else datetime.timedelta(days=7) if bucket == "week" + else datetime.timedelta(days=27) # month: walk in <31d steps so we never skip ) while cursor <= toD and safety < 5000: k = _bucketKey(cursor, bucket) @@ -377,27 +377,27 @@ def _bucketKeysBetween( return keys -def _bucketEnd(key: str, bucket: str) -> _dt.datetime: +def _bucketEnd(key: str, bucket: str) -> datetime.datetime: """Last-instant timestamp covered by the given bucket key.""" if bucket == "day": - d = _dt.datetime.strptime(key, "%Y-%m-%d") + d = datetime.datetime.strptime(key, "%Y-%m-%d") return d.replace(hour=23, minute=59, second=59) if bucket == "month": - d = _dt.datetime.strptime(key, "%Y-%m") + d = datetime.datetime.strptime(key, "%Y-%m") # First of next month minus one second. if d.month == 12: nxt = d.replace(year=d.year + 1, month=1) else: nxt = d.replace(month=d.month + 1) - return nxt - _dt.timedelta(seconds=1) + return nxt - datetime.timedelta(seconds=1) # week: ISO format ``YYYY-Www``. End = Sunday 23:59:59 of that week. try: year_str, week_str = key.split("-W") year = int(year_str) week = int(week_str) # ``%G-%V-%u`` parses ISO year/week/day; %u=1 is Monday. - monday = _dt.datetime.strptime(f"{year}-{week:02d}-1", "%G-%V-%u") - return monday + _dt.timedelta(days=6, hours=23, minutes=59, seconds=59) + monday = datetime.datetime.strptime(f"{year}-{week:02d}-1", "%G-%V-%u") + return monday + datetime.timedelta(days=6, hours=23, minutes=59, seconds=59) except Exception: return _utcNow() @@ -436,7 +436,7 @@ def _relationDistribution( def _backlogAging( - tickets: List[RedmineTicketDto], *, now: Optional[_dt.datetime] = None + tickets: List[RedmineTicketDto], *, now: Optional[datetime.datetime] = None ) -> List[RedmineAgingBucket]: if now is None: now = _utcNow() @@ -467,40 +467,40 @@ def _backlogAging( # Date helpers (no external deps) # --------------------------------------------------------------------------- -def _utcNow() -> _dt.datetime: +def _utcNow() -> datetime.datetime: """Naive UTC ``datetime`` -- the rest of the helpers compare naive objects, so we strip tz info on purpose.""" - return _dt.datetime.now(_dt.timezone.utc).replace(tzinfo=None) + return datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) -def _parseIsoDate(value: Optional[str]) -> Optional[_dt.datetime]: +def _parseIsoDate(value: Optional[str]) -> Optional[datetime.datetime]: if not value: return None try: s = value.replace("Z", "+00:00") if isinstance(value, str) else value if isinstance(s, str) and "T" not in s and len(s) == 10: - return _dt.datetime.strptime(s, "%Y-%m-%d") - return _dt.datetime.fromisoformat(s).replace(tzinfo=None) + return datetime.datetime.strptime(s, "%Y-%m-%d") + return datetime.datetime.fromisoformat(s).replace(tzinfo=None) except Exception: try: - return _dt.datetime.strptime(str(value)[:10], "%Y-%m-%d") + return datetime.datetime.strptime(str(value)[:10], "%Y-%m-%d") except Exception: return None def _inPeriod( - when: _dt.datetime, - fromDate: Optional[_dt.datetime], - toDate: Optional[_dt.datetime], + when: datetime.datetime, + fromDate: Optional[datetime.datetime], + toDate: Optional[datetime.datetime], ) -> bool: if fromDate and when < fromDate: return False - if toDate and when > toDate + _dt.timedelta(days=1): + if toDate and when > toDate + datetime.timedelta(days=1): return False return True -def _bucketKey(when: _dt.datetime, bucket: str) -> str: +def _bucketKey(when: datetime.datetime, bucket: str) -> str: if bucket == "day": return when.strftime("%Y-%m-%d") if bucket == "month": @@ -514,7 +514,7 @@ def _bucketLabel(key: str, bucket: str) -> str: return key if bucket == "month": try: - d = _dt.datetime.strptime(key, "%Y-%m") + d = datetime.datetime.strptime(key, "%Y-%m") return d.strftime("%b %Y") except Exception: return key diff --git a/modules/features/redmine/serviceRedmineSync.py b/modules/features/redmine/serviceRedmineSync.py index 32cd5a09..37507973 100644 --- a/modules/features/redmine/serviceRedmineSync.py +++ b/modules/features/redmine/serviceRedmineSync.py @@ -26,6 +26,7 @@ from __future__ import annotations import asyncio import logging import time +from datetime import datetime from typing import Any, Dict, List, Optional from modules.connectors.connectorTicketsRedmine import RedmineApiError @@ -354,7 +355,6 @@ def _parseRedmineDateToEpoch(value: Optional[str]) -> Optional[float]: if not value: return None try: - from datetime import datetime s = value.replace("Z", "+00:00") return datetime.fromisoformat(s).timestamp() except Exception: diff --git a/modules/features/teamsbot/interfaceFeatureTeamsbot.py b/modules/features/teamsbot/interfaceFeatureTeamsbot.py index 2bfe77ff..5afeea69 100644 --- a/modules/features/teamsbot/interfaceFeatureTeamsbot.py +++ b/modules/features/teamsbot/interfaceFeatureTeamsbot.py @@ -11,7 +11,7 @@ from typing import Dict, Any, List, Optional from modules.datamodels.datamodelUam import User from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.shared.configuration import APP_CONFIG -from modules.shared.dbRegistry import registerDatabase +from modules.dbHelpers.dbRegistry import registerDatabase from .datamodelTeamsbot import ( TeamsbotSession, diff --git a/modules/features/teamsbot/mainTeamsbot.py b/modules/features/teamsbot/mainTeamsbot.py index 850135d6..5a003182 100644 --- a/modules/features/teamsbot/mainTeamsbot.py +++ b/modules/features/teamsbot/mainTeamsbot.py @@ -6,6 +6,8 @@ Handles feature initialization and RBAC catalog registration. """ import logging +import time +import uuid from typing import Dict, List, Any from modules.shared.i18nRegistry import t @@ -261,7 +263,6 @@ def _runMigrations(): from modules.shared.configuration import APP_CONFIG import psycopg2 from psycopg2.extras import RealDictCursor - import uuid conn = psycopg2.connect( host=APP_CONFIG.get("DB_HOST", "localhost"), @@ -320,8 +321,7 @@ def _runMigrations(): continue adhocId = str(uuid.uuid4()) - import time as _time - now = _time.time() + now = time.time() cur.execute(""" INSERT INTO "TeamsbotMeetingModule" (id, "instanceId", "mandateId", "ownerUserId", title, "seriesType", status, "sysCreatedAt") VALUES (%s, %s, %s, 'system', 'Adhoc', 'adhoc', 'active', %s) @@ -439,3 +439,68 @@ def _ensureAccessRulesForRole(rootInterface, roleId: str, ruleTemplates: List[Di logger.debug(f"Created {createdCount} AccessRules for role {roleId}") return createdCount + + +# --------------------------------------------------------------------------- +# Feature Lifecycle Hooks +# --------------------------------------------------------------------------- + +def onMandateDelete(mandateId: str, instances: list) -> None: + """Cascade-delete all teamsbot data for deleted mandate.""" + from modules.connectors.connectorDbPostgre import DatabaseConnector + from modules.shared.configuration import APP_CONFIG + from modules.features.teamsbot.datamodelTeamsbot import ( + TeamsbotMeetingModule, TeamsbotSession, TeamsbotTranscript, + TeamsbotBotResponse, TeamsbotSystemBot, TeamsbotUserAccount, + TeamsbotUserSettings, TeamsbotDirectorPrompt, + ) + + try: + featureInstances = [ + inst for inst in instances + if (inst.get("featureCode") if isinstance(inst, dict) else getattr(inst, "featureCode", "")) == FEATURE_CODE + ] + if not featureInstances: + return + + db = DatabaseConnector( + dbHost=APP_CONFIG.get("DB_HOST", "localhost"), + dbDatabase="poweron_teamsbot", + dbUser=APP_CONFIG.get("DB_USER"), + dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), + dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), + userId=None, + ) + + totalDeleted = 0 + for inst in featureInstances: + instId = inst.get("id") if isinstance(inst, dict) else getattr(inst, "id", None) + if not instId: + continue + + # Models scoped by instanceId + for ModelClass in [ + TeamsbotMeetingModule, TeamsbotSession, + TeamsbotUserSettings, TeamsbotDirectorPrompt, + ]: + records = db.getRecordset(ModelClass, recordFilter={"instanceId": instId}) or [] + for rec in records: + db.recordDelete(ModelClass, rec.get("id")) + totalDeleted += len(records) + + # Models scoped by mandateId only (no instanceId) + for ModelClass in [TeamsbotSystemBot, TeamsbotUserAccount]: + records = db.getRecordset(ModelClass, recordFilter={"mandateId": mandateId}) or [] + for rec in records: + db.recordDelete(ModelClass, rec.get("id")) + totalDeleted += len(records) + + # TeamsbotTranscript + TeamsbotBotResponse: scoped via sessionId + # (orphans cleaned up when sessions are deleted above) + + if totalDeleted: + logger.info(f"Cascade: deleted {totalDeleted} teamsbot record(s) for mandate {mandateId}") + db.close() + except Exception as e: + logger.warning(f"Failed to cascade-delete teamsbot data for mandate {mandateId}: {e}") + diff --git a/modules/features/teamsbot/routeFeatureTeamsbot.py b/modules/features/teamsbot/routeFeatureTeamsbot.py index f07c98c5..c0862ba1 100644 --- a/modules/features/teamsbot/routeFeatureTeamsbot.py +++ b/modules/features/teamsbot/routeFeatureTeamsbot.py @@ -5,6 +5,7 @@ Teamsbot routes for the backend API. Implements Teams Bot session management, live streaming, and configuration endpoints. """ +import base64 import logging import json import re @@ -1170,7 +1171,6 @@ async def testVoice( ) if result and isinstance(result, dict): - import base64 audioContent = result.get("audioContent") if audioContent: audioB64 = base64.b64encode( diff --git a/modules/features/teamsbot/service.py b/modules/features/teamsbot/service.py index bba2bab1..edf5af94 100644 --- a/modules/features/teamsbot/service.py +++ b/modules/features/teamsbot/service.py @@ -795,7 +795,6 @@ class TeamsbotService: def _loadAvatarFileData(self, fileId, _teamsbotInterface): """Load avatar file as base64 data + mime type. Returns (data, mimeType) or (None, None).""" - import base64 from modules.interfaces import interfaceDbManagement try: mgmt = interfaceDbManagement.getInterface(self.currentUser, self.mandateId, featureInstanceId=self.instanceId) @@ -1239,7 +1238,6 @@ class TeamsbotService: voiced chunks. Here we apply a minimum-duration safety net: very short chunks (<1s) are buffered until they reach 1s; everything else goes straight to STT. A wall-clock timeout flushes stale buffers.""" - import base64 _MIN_CHUNK_SEC = 1.0 _STALE_TIMEOUT_SEC = 3.0 diff --git a/modules/features/trustee/accounting/accountingBridge.py b/modules/features/trustee/accounting/accountingBridge.py index 4e0a4d59..7fb26b3a 100644 --- a/modules/features/trustee/accounting/accountingBridge.py +++ b/modules/features/trustee/accounting/accountingBridge.py @@ -8,7 +8,8 @@ Encapsulates: config loading -> connector resolution -> duplicate check -> push import json import logging import time -from datetime import datetime as _dt, timezone as _tz +import uuid +from datetime import datetime, timezone from typing import List, Dict, Any, Optional from .accountingConnectorBase import ( @@ -18,6 +19,7 @@ from .accountingConnectorBase import ( SyncResult, ) from .accountingRegistry import getAccountingRegistry +from modules.features.trustee.datamodelFeatureTrustee import TrusteeDocument logger = logging.getLogger(__name__) @@ -44,7 +46,6 @@ class AccountingBridge: def _decryptConfig(self, encryptedConfig: str) -> Dict[str, Any]: """Decrypt the stored connector config JSON.""" from modules.shared.configuration import decryptValue - import json try: if not encryptedConfig: logger.error("Accounting config encryptedConfig is empty") @@ -105,7 +106,7 @@ class AccountingBridge: )) valutaTs = position.get("valuta") - bookingDateStr = _dt.fromtimestamp(valutaTs, tz=_tz.utc).strftime("%Y-%m-%d") if valutaTs else "" + bookingDateStr = datetime.fromtimestamp(valutaTs, tz=timezone.utc).strftime("%Y-%m-%d") if valutaTs else "" return AccountingBooking( reference=position.get("bookingReference") or position.get("id", ""), @@ -163,7 +164,6 @@ class AccountingBridge: # 1) Pre-booking document upload (RMA-style: upload first, link via belegId) if documentIds and not postBookingAttach: - from modules.features.trustee.datamodelFeatureTrustee import TrusteeDocument as TrusteeDocumentModel logger.info("Accounting sync: positionId=%s, uploading %s document(s) pre-booking ...", positionId, len(documentIds)) belegIds = [] belegLabels = [] @@ -197,7 +197,7 @@ class AccountingBridge: return SyncResult(success=False, errorMessage=f"Dokument-Upload fehlgeschlagen: {uploadResult.errorMessage}") belegId = uploadResult.externalId if belegId: - self._trusteeInterface.db.recordModify(TrusteeDocumentModel, documentId, {"externalBelegId": belegId}) + self._trusteeInterface.db.recordModify(TrusteeDocument, documentId, {"externalBelegId": belegId}) logger.info("Accounting sync: document uploaded & belegId=%s stored on document %s", belegId, documentId) belegIds.append(belegId) belegLabels.append(fileName) @@ -208,7 +208,6 @@ class AccountingBridge: # 1b) Post-booking flow: collect raw doc data now, attach after pushBooking if documentIds and postBookingAttach: - from modules.features.trustee.datamodelFeatureTrustee import TrusteeDocument as TrusteeDocumentModel for documentId in documentIds: doc = self._trusteeInterface.getDocument(documentId) if not doc: @@ -263,7 +262,6 @@ class AccountingBridge: # 3) Post-booking document attach (Abacus-style: entry must exist before attaching docs) if result.success and pendingDocs and result.externalId: - from modules.features.trustee.datamodelFeatureTrustee import TrusteeDocument as TrusteeDocumentModel logger.info("Accounting sync: positionId=%s, attaching %s document(s) to entry %s ...", positionId, len(pendingDocs), result.externalId) for documentId, fileName, docData, mimeType in pendingDocs: attachResult = await connector.attachDocumentToEntry( @@ -280,11 +278,10 @@ class AccountingBridge: ) continue if attachResult.externalId: - self._trusteeInterface.db.recordModify(TrusteeDocumentModel, documentId, {"externalBelegId": attachResult.externalId}) + self._trusteeInterface.db.recordModify(TrusteeDocument, documentId, {"externalBelegId": attachResult.externalId}) logger.info("Accounting sync: document attached, externalId=%s stored on document %s", attachResult.externalId, documentId) # Save sync record - import uuid syncRecord = { "id": str(uuid.uuid4()), "positionId": positionId, diff --git a/modules/features/trustee/accounting/accountingDataSync.py b/modules/features/trustee/accounting/accountingDataSync.py index db50d657..8ee3b431 100644 --- a/modules/features/trustee/accounting/accountingDataSync.py +++ b/modules/features/trustee/accounting/accountingDataSync.py @@ -16,12 +16,13 @@ froze every other request (chat, health-check, etc.) for minutes. See """ import asyncio -import json as _json +import json import logging import os import time +import uuid from collections import defaultdict -from datetime import datetime as _dt, timezone as _tz +from datetime import datetime, timezone from pathlib import Path from typing import Callable, Dict, Any, List, Optional, Type @@ -46,7 +47,7 @@ def _isoDateToTimestamp(raw: Any) -> Optional[float]: if not s: return None try: - return _dt.strptime(s, "%Y-%m-%d").replace(tzinfo=_tz.utc).timestamp() + return datetime.strptime(s, "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp() except ValueError: raise ValueError(f"Cannot parse bookingDate '{raw}' as YYYY-MM-DD") @@ -174,7 +175,7 @@ def _dumpSyncData(tag: str, rows: list) -> None: else: serializable.append(str(r)) with open(path, "w", encoding="utf-8") as f: - _json.dump({"count": len(serializable), "rows": serializable}, f, ensure_ascii=False, indent=2, default=str) + json.dump({"count": len(serializable), "rows": serializable}, f, ensure_ascii=False, indent=2, default=str) logger.info(f"Debug sync dump: {path.name} ({len(serializable)} rows)") except Exception as e: logger.warning(f"Failed to write debug sync dump for {tag}: {e}") @@ -253,7 +254,7 @@ class AccountingDataSync: try: plainJson = decryptValue(encryptedConfig) - connConfig = _json.loads(plainJson) if plainJson else {} + connConfig = json.loads(plainJson) if plainJson else {} except Exception as e: summary["errors"].append(f"Failed to decrypt config: {e}") return summary @@ -444,7 +445,6 @@ class AccountingDataSync: Returns ``(entriesCount, linesCount, oldestBookingDate, newestBookingDate)`` where the date strings are ISO ``YYYY-MM-DD`` (or ``None`` if no entries). """ - import uuid as _uuid t0 = time.time() self._bulkClear(modelEntry, featureInstanceId) self._bulkClear(modelLine, featureInstanceId) @@ -454,7 +454,7 @@ class AccountingDataSync: oldestDate: Optional[str] = None newestDate: Optional[str] = None for raw in rawEntries: - entryId = str(_uuid.uuid4()) + entryId = str(uuid.uuid4()) rawDate = raw.get("bookingDate") bookingTs = _isoDateToTimestamp(rawDate) if rawDate: @@ -603,7 +603,7 @@ class AccountingDataSync: if not accNo or not bdate: continue try: - dt = _dt.fromtimestamp(float(bdate), tz=_tz.utc) + dt = datetime.fromtimestamp(float(bdate), tz=timezone.utc) year = dt.year month = dt.month except (ValueError, TypeError, OSError): diff --git a/modules/features/trustee/interfaceFeatureTrustee.py b/modules/features/trustee/interfaceFeatureTrustee.py index 2f8aabf6..4efaaaef 100644 --- a/modules/features/trustee/interfaceFeatureTrustee.py +++ b/modules/features/trustee/interfaceFeatureTrustee.py @@ -7,6 +7,7 @@ Manages trustee organisations, roles, access, contracts, documents, and position import logging import math +import re import uuid from datetime import datetime, timezone from typing import Dict, Any, List, Optional, Union @@ -14,7 +15,7 @@ from pydantic import ValidationError from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.shared.configuration import APP_CONFIG -from modules.shared.dbRegistry import registerDatabase +from modules.dbHelpers.dbRegistry import registerDatabase from modules.interfaces.interfaceRbac import getRecordsetWithRBAC, getRecordsetPaginatedWithRBAC, getDistinctColumnValuesWithRBAC from modules.security.rbac import RbacClass from modules.datamodels.datamodelUam import User, AccessLevel @@ -562,7 +563,6 @@ class TrusteeObjects: logger.error(f"Invalid organisation ID length: {len(orgId)}") return None - import re if not re.match(r'^[a-zA-Z0-9_-]+$', orgId): logger.error(f"Invalid organisation ID format: {orgId}") return None @@ -739,7 +739,6 @@ class TrusteeObjects: if "featureInstanceId" not in data: data["featureInstanceId"] = self.featureInstanceId - import uuid accessId = data.get("id") or str(uuid.uuid4()) data["id"] = accessId @@ -936,7 +935,6 @@ class TrusteeObjects: if "featureInstanceId" not in data: data["featureInstanceId"] = self.featureInstanceId - import uuid contractId = data.get("id") or str(uuid.uuid4()) data["id"] = contractId @@ -1047,7 +1045,6 @@ class TrusteeObjects: data["mandateId"] = self.mandateId data["featureInstanceId"] = self.featureInstanceId - import uuid documentId = data.get("id") or str(uuid.uuid4()) data["id"] = documentId @@ -1263,7 +1260,6 @@ class TrusteeObjects: vatPercentage = data.get("vatPercentage", 0) data["vatAmount"] = bookingAmount * vatPercentage / 100 - import uuid positionId = data.get("id") or str(uuid.uuid4()) data["id"] = positionId diff --git a/modules/features/trustee/mainTrustee.py b/modules/features/trustee/mainTrustee.py index b33aaf74..7c686f3e 100644 --- a/modules/features/trustee/mainTrustee.py +++ b/modules/features/trustee/mainTrustee.py @@ -1041,3 +1041,59 @@ def _ensureAccessRulesForRole(rootInterface, roleId: str, ruleTemplates: List[Di logger.debug(f"Created {createdCount} AccessRules for role {roleId}") return createdCount + + +# --------------------------------------------------------------------------- +# Feature Lifecycle Hooks +# --------------------------------------------------------------------------- + +def onMandateDelete(mandateId: str, instances: list) -> None: + """Cascade-delete all trustee data for deleted mandate.""" + from modules.connectors.connectorDbPostgre import DatabaseConnector + from modules.shared.configuration import APP_CONFIG + from modules.features.trustee.datamodelFeatureTrustee import ( + TrusteeOrganisation, TrusteeRole, TrusteeAccess, TrusteeContract, + TrusteeDocument, TrusteePosition, TrusteeDataAccount, + TrusteeDataJournalEntry, TrusteeDataJournalLine, TrusteeDataContact, + TrusteeDataAccountBalance, TrusteeAccountingConfig, TrusteeAccountingSync, + ) + + try: + featureInstances = [ + inst for inst in instances + if (inst.get("featureCode") if isinstance(inst, dict) else getattr(inst, "featureCode", "")) == FEATURE_CODE + ] + if not featureInstances: + return + + db = DatabaseConnector( + dbHost=APP_CONFIG.get("DB_HOST", "localhost"), + dbDatabase="poweron_trustee", + dbUser=APP_CONFIG.get("DB_USER"), + dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), + dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), + userId=None, + ) + + totalDeleted = 0 + for inst in featureInstances: + instId = inst.get("id") if isinstance(inst, dict) else getattr(inst, "id", None) + if not instId: + continue + for ModelClass in [ + TrusteeOrganisation, TrusteeRole, TrusteeAccess, TrusteeContract, + TrusteeDocument, TrusteePosition, TrusteeDataAccount, + TrusteeDataJournalEntry, TrusteeDataJournalLine, TrusteeDataContact, + TrusteeDataAccountBalance, TrusteeAccountingConfig, TrusteeAccountingSync, + ]: + records = db.getRecordset(ModelClass, recordFilter={"featureInstanceId": instId}) or [] + for rec in records: + db.recordDelete(ModelClass, rec.get("id")) + totalDeleted += len(records) + + if totalDeleted: + logger.info(f"Cascade: deleted {totalDeleted} trustee record(s) for mandate {mandateId}") + db.close() + except Exception as e: + logger.warning(f"Failed to cascade-delete trustee data for mandate {mandateId}: {e}") + diff --git a/modules/features/trustee/routeFeatureTrustee.py b/modules/features/trustee/routeFeatureTrustee.py index a71b508f..45a37ca3 100644 --- a/modules/features/trustee/routeFeatureTrustee.py +++ b/modules/features/trustee/routeFeatureTrustee.py @@ -18,6 +18,9 @@ import logging import json import io import base64 +import time +import uuid +from datetime import datetime, timezone from modules.auth import limiter, getRequestContext, RequestContext from .interfaceFeatureTrustee import getInterface @@ -395,10 +398,9 @@ def get_position_options( items = result.items if hasattr(result, 'items') else result def _makePositionLabel(p: TrusteePosition) -> str: - from datetime import datetime as _dt, timezone as _tz parts = [] if p.valuta: - parts.append(_dt.fromtimestamp(p.valuta, tz=_tz.utc).strftime("%Y-%m-%d")) + parts.append(datetime.fromtimestamp(p.valuta, tz=timezone.utc).strftime("%Y-%m-%d")) if p.company: parts.append(p.company[:30]) if p.desc: @@ -424,7 +426,7 @@ def get_organisations( context: RequestContext = Depends(getRequestContext) ): """Get all organisations for a feature instance with optional pagination.""" - from modules.routes.routeHelpers import enrichRowsWithFkLabels + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels mandateId = _validateInstanceAccess(instanceId, context) paginationParams = _parsePagination(pagination) @@ -435,7 +437,7 @@ def get_organisations( return [r.model_dump() if hasattr(r, "model_dump") else r for r in items] if paginationParams and hasattr(result, 'items'): - enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeOrganisation) + enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeOrganisation, db=interface.db) return { "items": enriched, "pagination": PaginationMetadata( @@ -448,7 +450,7 @@ def get_organisations( ).model_dump(), } items = result if isinstance(result, list) else result.items - enriched = enrichRowsWithFkLabels(_toDicts(items), TrusteeOrganisation) + enriched = enrichRowsWithFkLabels(_toDicts(items), TrusteeOrganisation, db=interface.db) return {"items": enriched, "pagination": None} @@ -544,7 +546,7 @@ def get_roles( context: RequestContext = Depends(getRequestContext) ): """Get all roles with optional pagination.""" - from modules.routes.routeHelpers import enrichRowsWithFkLabels + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels mandateId = _validateInstanceAccess(instanceId, context) paginationParams = _parsePagination(pagination) @@ -555,7 +557,7 @@ def get_roles( return [r.model_dump() if hasattr(r, "model_dump") else r for r in items] if paginationParams and hasattr(result, 'items'): - enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeRole) + enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeRole, db=interface.db) return { "items": enriched, "pagination": PaginationMetadata( @@ -568,7 +570,7 @@ def get_roles( ).model_dump(), } items = result if isinstance(result, list) else result.items - enriched = enrichRowsWithFkLabels(_toDicts(items), TrusteeRole) + enriched = enrichRowsWithFkLabels(_toDicts(items), TrusteeRole, db=interface.db) return {"items": enriched, "pagination": None} @@ -664,7 +666,7 @@ def get_all_access( context: RequestContext = Depends(getRequestContext) ): """Get all access records with optional pagination.""" - from modules.routes.routeHelpers import enrichRowsWithFkLabels + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels mandateId = _validateInstanceAccess(instanceId, context) paginationParams = _parsePagination(pagination) @@ -675,7 +677,7 @@ def get_all_access( return [r.model_dump() if hasattr(r, "model_dump") else r for r in items] if paginationParams and hasattr(result, 'items'): - enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeAccess) + enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeAccess, db=interface.db) return { "items": enriched, "pagination": PaginationMetadata( @@ -688,7 +690,7 @@ def get_all_access( ).model_dump(), } items = result if isinstance(result, list) else result.items - enriched = enrichRowsWithFkLabels(_toDicts(items), TrusteeAccess) + enriched = enrichRowsWithFkLabels(_toDicts(items), TrusteeAccess, db=interface.db) return {"items": enriched, "pagination": None} @@ -814,7 +816,7 @@ def get_contracts( context: RequestContext = Depends(getRequestContext) ): """Get all contracts with optional pagination.""" - from modules.routes.routeHelpers import enrichRowsWithFkLabels + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels mandateId = _validateInstanceAccess(instanceId, context) paginationParams = _parsePagination(pagination) @@ -825,7 +827,7 @@ def get_contracts( return [r.model_dump() if hasattr(r, "model_dump") else r for r in items] if paginationParams and hasattr(result, 'items'): - enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeContract) + enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeContract, db=interface.db) return { "items": enriched, "pagination": PaginationMetadata( @@ -838,7 +840,7 @@ def get_contracts( ).model_dump(), } items = result if isinstance(result, list) else result.items - enriched = enrichRowsWithFkLabels(_toDicts(items), TrusteeContract) + enriched = enrichRowsWithFkLabels(_toDicts(items), TrusteeContract, db=interface.db) return {"items": enriched, "pagination": None} @@ -981,14 +983,15 @@ def get_documents( def _handleDocumentMode(instanceId, mandateId, mode, column, pagination, context): """Handle mode=filterValues and mode=ids for trustee documents.""" - from modules.routes.routeHelpers import handleIdsInMemory, handleFilterValuesInMemory, enrichRowsWithFkLabels + from modules.dbHelpers.paginationHelpers import handleIdsInMemory, handleFilterValuesInMemory + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) if mode == "filterValues": if not column: raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") result = interface.getAllDocuments(None) items = [r.model_dump() if hasattr(r, 'model_dump') else r for r in (result.items if hasattr(result, 'items') else result)] - enrichRowsWithFkLabels(items, TrusteeDocument) + enrichRowsWithFkLabels(items, TrusteeDocument, db=interface.db) return handleFilterValuesInMemory(items, column, pagination) if mode == "ids": result = interface.getAllDocuments(None) @@ -1260,7 +1263,8 @@ def get_positions( def _handlePositionMode(instanceId, mandateId, mode, column, pagination, context): """Handle mode=filterValues and mode=ids for trustee positions.""" - from modules.routes.routeHelpers import handleIdsInMemory, handleFilterValuesInMemory, enrichRowsWithFkLabels + 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": @@ -1269,8 +1273,7 @@ def _handlePositionMode(instanceId, mandateId, mode, column, pagination, context result = interface.getAllPositions(None) items = [r.model_dump() if hasattr(r, 'model_dump') else r for r in (result.items if hasattr(result, 'items') else result)] _enrichPositionsWithSyncStatus(items, interface, instanceId) - # Use the view model so FK labels for the synthetic columns also resolve. - enrichRowsWithFkLabels(items, TrusteePositionView) + enrichRowsWithFkLabels(items, TrusteePositionView, db=interface.db) return handleFilterValuesInMemory(items, column, pagination) if mode == "ids": result = interface.getAllPositions(None) @@ -1442,7 +1445,6 @@ def get_accounting_config( record["configured"] = True if encryptedConfig: try: - import json plain = json.loads(decryptValue(encryptedConfig, keyName="accountingConfig")) record["configMasked"] = _getConfigMasked(record.get("connectorType", ""), plain) except Exception: @@ -1477,7 +1479,6 @@ async def save_accounting_config( from .datamodelFeatureTrustee import TrusteeAccountingConfig from modules.shared.configuration import encryptValue - import uuid as _uuid plainConfig = body.config if isinstance(body.config, dict) else {} # When updating, empty config is normal (frontend never receives credentials from GET). @@ -1524,7 +1525,7 @@ async def save_accounting_config( encryptedConfig = encryptValue(json.dumps(plainConfig), keyName="accountingConfig") configRecord = { - "id": str(_uuid.uuid4()), + "id": str(uuid.uuid4()), "featureInstanceId": instanceId, "connectorType": body.connectorType or "", "displayLabel": body.displayLabel or "", @@ -2027,8 +2028,6 @@ def export_accounting_data( TrusteeDataAccountBalance, TrusteeAccountingConfig, ) - import time as _time - interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) _filter = {"featureInstanceId": instanceId} @@ -2057,7 +2056,7 @@ def export_accounting_data( } payload = { - "exportedAt": _time.time(), + "exportedAt": time.time(), "featureInstanceId": instanceId, "mandateId": mandateId, "syncInfo": syncInfo, @@ -2404,7 +2403,6 @@ def _buildFeatureInternalResolvers(modelClass, db) -> Dict[str, Any]: val = row.get(col) if val is not None and val != "": if col == "bookingDate" and isinstance(val, (int, float)): - from datetime import datetime, timezone try: parts.append(datetime.fromtimestamp(val, tz=timezone.utc).strftime("%Y-%m-%d")) except Exception: @@ -2440,11 +2438,8 @@ def _paginatedReadEndpoint( from modules.interfaces.interfaceRbac import ( getRecordsetPaginatedWithRBAC, ) - from modules.routes.routeHelpers import ( - handleIdsInMemory, - handleFilterValuesInMemory, - enrichRowsWithFkLabels, - ) + from modules.dbHelpers.paginationHelpers import handleIdsInMemory, handleFilterValuesInMemory + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels mandateId = _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) @@ -2465,7 +2460,7 @@ def _paginatedReadEndpoint( rawItems = result.items if hasattr(result, "items") else result items = [r.model_dump() if hasattr(r, "model_dump") else r for r in rawItems] featureResolvers = _buildFeatureInternalResolvers(modelClass, interface.db) - enrichRowsWithFkLabels(items, modelClass, extraResolvers=featureResolvers or None) + enrichRowsWithFkLabels(items, modelClass, db=interface.db, extraResolvers=featureResolvers or None) return handleFilterValuesInMemory(items, column, pagination) if mode == "ids": @@ -2503,7 +2498,7 @@ def _paginatedReadEndpoint( if paginationParams and hasattr(result, "items"): enriched = enrichRowsWithFkLabels( _itemsToDicts(result.items), modelClass, - extraResolvers=featureResolvers or None, + db=interface.db, extraResolvers=featureResolvers or None, ) return { "items": enriched, @@ -2519,7 +2514,7 @@ def _paginatedReadEndpoint( items = result.items if hasattr(result, "items") else result enriched = enrichRowsWithFkLabels( _itemsToDicts(items), modelClass, - extraResolvers=featureResolvers or None, + db=interface.db, extraResolvers=featureResolvers or None, ) return {"items": enriched, "pagination": None} diff --git a/modules/features/workspace/interfaceFeatureWorkspace.py b/modules/features/workspace/interfaceFeatureWorkspace.py index 984bf942..e2d16521 100644 --- a/modules/features/workspace/interfaceFeatureWorkspace.py +++ b/modules/features/workspace/interfaceFeatureWorkspace.py @@ -9,7 +9,7 @@ import logging from typing import Dict, Any, Optional from modules.connectors.connectorDbPostgre import DatabaseConnector -from modules.shared.dbRegistry import registerDatabase +from modules.dbHelpers.dbRegistry import registerDatabase from modules.datamodels.datamodelUam import User from modules.features.workspace.datamodelFeatureWorkspace import WorkspaceUserSettings from modules.interfaces.interfaceRbac import getRecordsetWithRBAC diff --git a/modules/features/workspace/mainWorkspace.py b/modules/features/workspace/mainWorkspace.py index 77f5b290..1a96a852 100644 --- a/modules/features/workspace/mainWorkspace.py +++ b/modules/features/workspace/mainWorkspace.py @@ -311,3 +311,51 @@ def _ensureAccessRulesForRole(rootInterface, roleId: str, ruleTemplates: List[Di logger.debug(f"Created {createdCount} AccessRules for role {roleId}") return createdCount + + +# --------------------------------------------------------------------------- +# Feature Lifecycle Hooks +# --------------------------------------------------------------------------- + +def onMandateDelete(mandateId: str, instances: list) -> None: + """Cascade-delete all workspace data for deleted mandate.""" + from modules.connectors.connectorDbPostgre import DatabaseConnector + from modules.shared.configuration import APP_CONFIG + from modules.features.workspace.datamodelFeatureWorkspace import ( + WorkspaceUserSettings, + ) + + try: + featureInstances = [ + inst for inst in instances + if (inst.get("featureCode") if isinstance(inst, dict) else getattr(inst, "featureCode", "")) == FEATURE_CODE + ] + if not featureInstances: + return + + db = DatabaseConnector( + dbHost=APP_CONFIG.get("DB_HOST", "localhost"), + dbDatabase="poweron_workspace", + dbUser=APP_CONFIG.get("DB_USER"), + dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), + dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), + userId=None, + ) + + totalDeleted = 0 + for inst in featureInstances: + instId = inst.get("id") if isinstance(inst, dict) else getattr(inst, "id", None) + if not instId: + continue + for ModelClass in [WorkspaceUserSettings]: + records = db.getRecordset(ModelClass, recordFilter={"featureInstanceId": instId}) or [] + for rec in records: + db.recordDelete(ModelClass, rec.get("id")) + totalDeleted += len(records) + + if totalDeleted: + logger.info(f"Cascade: deleted {totalDeleted} workspace record(s) for mandate {mandateId}") + db.close() + except Exception as e: + logger.warning(f"Failed to cascade-delete workspace data for mandate {mandateId}: {e}") + diff --git a/modules/features/workspace/routeFeatureWorkspace.py b/modules/features/workspace/routeFeatureWorkspace.py index 55526c55..d9fc3f4d 100644 --- a/modules/features/workspace/routeFeatureWorkspace.py +++ b/modules/features/workspace/routeFeatureWorkspace.py @@ -8,6 +8,7 @@ SSE-based endpoints for the agent-driven AI Workspace. import logging import json import asyncio +import os import uuid from typing import Any, Dict, Optional, List @@ -145,25 +146,6 @@ def _getChatInterface(context: RequestContext, featureInstanceId: str = None, ma ) -def _buildResolverDbInterface(chatService): - """Build a DB adapter that ConnectorResolver can use to load UserConnections. - - ConnectorResolver calls db.getUserConnection(connectionId). - interfaceDbApp provides getUserConnectionById(connectionId). - This adapter bridges the method name difference. - """ - class _ResolverDbAdapter: - def __init__(self, appInterface): - self._app = appInterface - def getUserConnection(self, connectionId: str): - if hasattr(self._app, "getUserConnectionById"): - return self._app.getUserConnectionById(connectionId) - return None - appIf = getattr(chatService, "interfaceDbApp", None) - if appIf: - return _ResolverDbAdapter(appIf) - return getattr(chatService, "interfaceDbComponent", None) - def _getDbManagement(context: RequestContext, featureInstanceId: str = None): return interfaceDbManagement.getInterface( @@ -236,7 +218,7 @@ def buildDataSourceContext(chatService, dataSourceIds: List[str]) -> str: def buildFeatureDataSourceContext(featureDataSourceIds: List[str]) -> str: """Build a description of attached feature data sources for the agent prompt.""" - from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + from modules.datamodels.datamodelFeatures import FeatureDataSource from modules.security.rbacCatalog import getCatalogService from modules.interfaces.interfaceDbApp import getRootInterface @@ -343,7 +325,7 @@ def _buildWorkspaceAttachmentLabel(chatService: Any, dataSourceIds: List[str], f fdsLabels: List[str] = [] for fdsId in featureDataSourceIds or []: try: - from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + from modules.datamodels.datamodelFeatures import FeatureDataSource from modules.interfaces.interfaceDbApp import getRootInterface rootIf = getRootInterface() records = rootIf.db.getRecordset(FeatureDataSource, recordFilter={"id": fdsId}) @@ -1170,10 +1152,10 @@ async def getWorkspaceMessages( from modules.interfaces.interfaceDbApp import getRootInterface rootIf = getRootInterface() if attachedDsIds: - from modules.datamodels.datamodelDataSource import DataSource as _DS + from modules.datamodels.datamodelDataSource import DataSource for dsId in attachedDsIds: try: - records = rootIf.db.getRecordset(_DS, recordFilter={"id": dsId}) + records = rootIf.db.getRecordset(DataSource, recordFilter={"id": dsId}) if records: lbl = records[0].get("label") or records[0].get("path") or "" if lbl: @@ -1181,10 +1163,10 @@ async def getWorkspaceMessages( except Exception: pass if attachedFdsIds: - from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource as _FDS + from modules.datamodels.datamodelFeatures import FeatureDataSource for fdsId in attachedFdsIds: try: - records = rootIf.db.getRecordset(_FDS, recordFilter={"id": fdsId}) + records = rootIf.db.getRecordset(FeatureDataSource, recordFilter={"id": fdsId}) if records: tbl = records[0].get("tableName") or "" lbl = records[0].get("label") or tbl @@ -1298,7 +1280,6 @@ async def getFileContent( filePath = fileData.get("filePath") if not filePath: raise HTTPException(status_code=404, detail=routeApiMsg("File has no stored path")) - import os if not os.path.isfile(filePath): raise HTTPException(status_code=404, detail=routeApiMsg("File not found on disk")) mimeType = fileData.get("mimeType", "application/octet-stream") @@ -1438,7 +1419,7 @@ async def createFeatureDataSource( """ _validateInstanceAccess(instanceId, context) from modules.interfaces.interfaceDbApp import getRootInterface - from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + from modules.datamodels.datamodelFeatures import FeatureDataSource rootIf = getRootInterface() if not rootIf.getFeatureAccess(str(context.user.id), body.featureInstanceId): @@ -1482,7 +1463,7 @@ async def listFeatureDataSources( the mandate.""" wsMandateId, _ = _validateInstanceAccess(instanceId, context) from modules.interfaces.interfaceDbApp import getRootInterface - from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + from modules.datamodels.datamodelFeatures import FeatureDataSource from modules.serviceCenter.services.serviceKnowledge._inheritFlags import buildEffectiveByWorkspaceFds rootIf = getRootInterface() @@ -1514,7 +1495,7 @@ async def deleteFeatureDataSource( """Delete a FeatureDataSource.""" _mandateId, _ = _validateInstanceAccess(instanceId, context) from modules.interfaces.interfaceDbApp import getRootInterface - from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + from modules.datamodels.datamodelFeatures import FeatureDataSource rootIf = getRootInterface() rootIf.db.recordDelete(FeatureDataSource, featureDataSourceId) diff --git a/modules/interfaces/_legacyMigrationTelemetry.py b/modules/interfaces/_legacyMigrationTelemetry.py index 4a0db04c..02c2c184 100644 --- a/modules/interfaces/_legacyMigrationTelemetry.py +++ b/modules/interfaces/_legacyMigrationTelemetry.py @@ -158,7 +158,7 @@ def _backfillTargetFeatureInstanceId() -> None: """ def _do() -> None: from modules.shared.configuration import APP_CONFIG - from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow + from modules.datamodels.datamodelWorkflowAutomation import AutoWorkflow dbHost = APP_CONFIG.get("DB_HOST", "localhost") dbUser = APP_CONFIG.get("DB_USER") diff --git a/modules/interfaces/interfaceAiObjects.py b/modules/interfaces/interfaceAiObjects.py index 2d13439c..13f5d8a7 100644 --- a/modules/interfaces/interfaceAiObjects.py +++ b/modules/interfaces/interfaceAiObjects.py @@ -4,6 +4,7 @@ import logging import asyncio import uuid import base64 +import json from typing import Dict, Any, List, Union, Tuple, Optional, Callable, AsyncGenerator from dataclasses import dataclass, field import time @@ -316,8 +317,6 @@ class AiObjects: tools: List[Dict[str, Any]] = None, toolChoice: Any = None) -> AiCallResponse: """Call a model with pre-built messages (agent mode). Supports tools for native function calling.""" - import json as _json - inputBytes = sum(len(str(m.get("content", "")).encode("utf-8")) for m in messages) startTime = time.time() @@ -536,7 +535,7 @@ class AiObjects: Returns: AiCallResponse with metadata["embeddings"] containing the vectors. """ - from modules.aicore.aicoreBase import ContextLengthExceededException as _CtxExc + from modules.aicore.aicoreBase import ContextLengthExceededException if options is None: options = AiCallOptions(operationType=OperationTypeEnum.EMBEDDING) @@ -622,7 +621,7 @@ class AiObjects: return response - except _CtxExc as e: + except ContextLengthExceededException as e: logger.error(f"ContextLengthExceeded for {model.name} despite batching – aborting failover: {e}") return AiCallResponse( content=str(e), modelName=model.name, priceCHF=0.0, diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py index 1f450d0c..287e60df 100644 --- a/modules/interfaces/interfaceBootstrap.py +++ b/modules/interfaces/interfaceBootstrap.py @@ -11,6 +11,7 @@ Multi-Tenant Design: """ import logging +import uuid from typing import Optional, Dict from passlib.context import CryptContext from modules.connectors.connectorDbPostgre import DatabaseConnector @@ -101,9 +102,6 @@ def initBootstrap(db: DatabaseConnector) -> None: if mandateId: _initRootMandateSubscription(mandateId) - # Auto-provision Stripe Products/Prices for paid plans (idempotent) - _bootstrapStripePrices() - # Purge soft-deleted mandates past 30-day retention try: from modules.interfaces.interfaceDbApp import getRootInterface @@ -112,12 +110,15 @@ def initBootstrap(db: DatabaseConnector) -> None: except Exception as e: logger.warning(f"Mandate retention purge failed: {e}") - # Bootstrap system workflow templates for graphical editor - _bootstrapSystemTemplates(db) - - # Sync feature template workflows (update graph of existing instance workflows - # whose templateSourceId matches a current code-defined template) - _syncFeatureTemplateWorkflows() + # Let features run their own bootstrap logic via lifecycle hooks + from modules.system.registry import loadFeatureMainModules + for _fCode, _fMod in loadFeatureMainModules().items(): + _bootHook = getattr(_fMod, "onBootstrap", None) + if _bootHook: + try: + _bootHook() + except Exception as _bootErr: + logger.warning(f"onBootstrap hook for '{_fCode}' failed: {_bootErr}") # Ensure billing settings and accounts exist for all mandates _bootstrapBilling() @@ -154,219 +155,10 @@ def _bootstrapBilling() -> None: logger.warning(f"Billing bootstrap failed (non-critical): {e}") -def _bootstrapSystemTemplates(db: DatabaseConnector) -> None: - """ - Seed platform-wide workflow templates (templateScope='system', mandateId=None). - Idempotent: skips if templates with the same label already exist. - """ - try: - from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow - from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import graphicalEditorDatabase - import uuid - - greenfieldDb = DatabaseConnector( - dbHost=APP_CONFIG.get("DB_HOST", "localhost"), - dbDatabase=graphicalEditorDatabase, - dbUser=APP_CONFIG.get("DB_USER"), - dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), - ) - greenfieldDb._ensureTableExists(AutoWorkflow) - - existing = greenfieldDb.getRecordset(AutoWorkflow, recordFilter={ - "isTemplate": True, - "templateScope": "system", - }) - existingLabels = {r.get("label") if isinstance(r, dict) else getattr(r, "label", "") for r in (existing or [])} - - templates = _buildSystemTemplates() - created = 0 - for tpl in templates: - if tpl["label"] in existingLabels: - continue - tpl["id"] = str(uuid.uuid4()) - greenfieldDb.recordCreate(AutoWorkflow, tpl) - created += 1 - - if created: - logger.info(f"Bootstrapped {created} system workflow template(s)") - greenfieldDb.close() - except Exception as e: - logger.warning(f"System workflow template bootstrap failed: {e}") -def _syncFeatureTemplateWorkflows() -> None: - """Sync existing instance-scoped workflows with current code-defined templates. - - For each feature that exposes getTemplateWorkflows(), find all AutoWorkflow - rows whose templateSourceId matches a template ID and update their graph - if the code-defined version has changed. Preserves instance-specific - fields (label, tags, targetFeatureInstanceId, invocations, active). - Idempotent, runs on every boot. - """ - import json - - try: - from modules.system.registry import loadFeatureMainModules - from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow - from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import graphicalEditorDatabase - - mainModules = loadFeatureMainModules() - - templatesBySourceId: dict = {} - for featureCode, mod in mainModules.items(): - getTemplateWorkflows = getattr(mod, "getTemplateWorkflows", None) - if not getTemplateWorkflows: - continue - try: - templates = getTemplateWorkflows() or [] - except Exception: - continue - for tpl in templates: - tplId = tpl.get("id") - if tplId: - templatesBySourceId[tplId] = tpl - - if not templatesBySourceId: - logger.info("_syncFeatureTemplateWorkflows: no templates found, skipping") - return - logger.info(f"_syncFeatureTemplateWorkflows: found {len(templatesBySourceId)} template(s): {list(templatesBySourceId.keys())}") - - greenfieldDb = DatabaseConnector( - dbHost=APP_CONFIG.get("DB_HOST", "localhost"), - dbDatabase=graphicalEditorDatabase, - dbUser=APP_CONFIG.get("DB_USER"), - dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), - ) - - updated = 0 - for sourceId, tpl in templatesBySourceId.items(): - instances = greenfieldDb.getRecordset(AutoWorkflow, recordFilter={ - "templateSourceId": sourceId, - "isTemplate": False, - }) - if not instances: - continue - - canonicalGraph = tpl.get("graph", {}) - - for inst in instances: - instId = inst.get("id") if isinstance(inst, dict) else getattr(inst, "id", None) - targetInstanceId = ( - inst.get("targetFeatureInstanceId") if isinstance(inst, dict) - else getattr(inst, "targetFeatureInstanceId", None) - ) or "" - - graphJson = json.dumps(canonicalGraph) - graphJson = graphJson.replace("{{featureInstanceId}}", targetInstanceId) - newGraph = json.loads(graphJson) - - existingGraph = inst.get("graph") if isinstance(inst, dict) else getattr(inst, "graph", None) - if isinstance(existingGraph, str): - try: - existingGraph = json.loads(existingGraph) - except Exception: - existingGraph = None - - if existingGraph == newGraph: - logger.debug(f"_syncFeatureTemplateWorkflows: graph unchanged for workflow {instId} (template={sourceId})") - continue - logger.debug(f"_syncFeatureTemplateWorkflows: graph DIFFERS for workflow {instId} (template={sourceId}), updating") - - greenfieldDb.recordModify(AutoWorkflow, instId, {"graph": newGraph}) - updated += 1 - logger.info(f"_syncFeatureTemplateWorkflows: updated graph for workflow {instId} (template={sourceId})") - - if updated: - logger.info(f"_syncFeatureTemplateWorkflows: synced {updated} workflow(s) with current templates") - else: - logger.info("_syncFeatureTemplateWorkflows: all instance graphs already match current templates") - greenfieldDb.close() - except Exception as e: - logger.warning(f"Feature template workflow sync failed: {e}") -def _buildSystemTemplates(): - """Build the graph definitions for platform system templates.""" - return [ - { - "label": "Personal Assistant: E-Mail-Antwort-Drafting", - "mandateId": None, - "featureInstanceId": None, - "isTemplate": True, - "templateScope": "system", - "sharedReadOnly": True, - "active": False, - "graph": { - "nodes": [ - {"id": "n1", "type": "trigger.schedule", "x": 50, "y": 200, "title": "Täglicher Check", "parameters": {}}, - {"id": "n2", "type": "email.checkEmail", "x": 300, "y": 200, "title": "Mailbox prüfen", "parameters": {}}, - { - "id": "n3", - "type": "flow.loop", - "x": 550, - "y": 200, - "title": "Pro E-Mail", - "parameters": { - "items": {"type": "ref", "nodeId": "n2", "path": ["emails"]}, - "concurrency": 1, - }, - }, - {"id": "n4", "type": "ai.prompt", "x": 800, "y": 200, "title": "Analyse: Antwort nötig?", "parameters": {}}, - {"id": "n5", "type": "flow.ifElse", "x": 1050, "y": 200, "title": "Antwort nötig?", "parameters": {}}, - {"id": "n6", "type": "ai.prompt", "x": 1300, "y": 100, "title": "Kontext abrufen & Antwort formulieren", "parameters": {}}, - {"id": "n7", "type": "email.draftEmail", "x": 1550, "y": 100, "title": "Draft erstellen", "parameters": {}}, - ], - "connections": [ - {"source": "n1", "target": "n2", "sourceOutput": 0, "targetInput": 0}, - {"source": "n2", "target": "n3", "sourceOutput": 0, "targetInput": 0}, - {"source": "n3", "target": "n4", "sourceOutput": 0, "targetInput": 0}, - {"source": "n4", "target": "n5", "sourceOutput": 0, "targetInput": 0}, - {"source": "n5", "target": "n6", "sourceOutput": 0, "targetInput": 0}, - {"source": "n6", "target": "n7", "sourceOutput": 0, "targetInput": 0}, - ], - }, - "invocations": [{"type": "schedule", "cronExpression": "0 8 * * 1-5"}], - }, - { - "label": "Treuhand: PDF-Klassifizierung & Trustee-Import", - "mandateId": None, - "featureInstanceId": None, - "isTemplate": True, - "templateScope": "system", - "sharedReadOnly": True, - "active": False, - "graph": { - "nodes": [ - {"id": "n1", "type": "trigger.schedule", "x": 50, "y": 200, "title": "Geplanter Import", "parameters": {}}, - {"id": "n2", "type": "sharepoint.listFiles", "x": 300, "y": 200, "title": "SharePoint Ordner lesen", "parameters": {}}, - { - "id": "n3", - "type": "flow.loop", - "x": 550, - "y": 200, - "title": "Pro Dokument", - "parameters": { - "items": {"type": "ref", "nodeId": "n2", "path": ["files"]}, - "concurrency": 1, - }, - }, - {"id": "n4", "type": "sharepoint.readFile", "x": 800, "y": 200, "title": "PDF-Inhalt lesen", "parameters": {}}, - {"id": "n5", "type": "ai.prompt", "x": 1050, "y": 200, "title": "Typ klassifizieren (Rechnung, Beleg, Bankauszug, Vertrag, etc.)", "parameters": {}}, - {"id": "n6", "type": "trustee.extractFromFiles", "x": 1300, "y": 200, "title": "Dokument extrahieren", "parameters": {}}, - {"id": "n7", "type": "trustee.processDocuments", "x": 1550, "y": 200, "title": "In Trustee einlesen", "parameters": {}}, - ], - "connections": [ - {"source": "n1", "target": "n2", "sourceOutput": 0, "targetInput": 0}, - {"source": "n2", "target": "n3", "sourceOutput": 0, "targetInput": 0}, - {"source": "n3", "target": "n4", "sourceOutput": 0, "targetInput": 0}, - {"source": "n4", "target": "n5", "sourceOutput": 0, "targetInput": 0}, - {"source": "n5", "target": "n6", "sourceOutput": 0, "targetInput": 0}, - {"source": "n6", "target": "n7", "sourceOutput": 0, "targetInput": 0}, - ], - }, - "invocations": [{"type": "schedule", "cronExpression": "0 7 * * 1-5"}], - }, - ] def initRootMandateFeatures(db: DatabaseConnector, mandateId: str) -> None: @@ -749,8 +541,6 @@ def copySystemRolesToMandate(db: DatabaseConnector, mandateId: str) -> int: Returns: Number of roles copied """ - import uuid as _uuid - # Find system template roles (global: mandateId=NULL, isSystemRole=True) templateRoles = db.getRecordset( Role, @@ -785,7 +575,7 @@ def copySystemRolesToMandate(db: DatabaseConnector, mandateId: str) -> int: logger.debug(f"Mandate {mandateId} already has role '{roleLabel}', skipping") continue - newRoleId = str(_uuid.uuid4()) + newRoleId = str(uuid.uuid4()) # Create mandate-instance role newRole = Role( @@ -803,7 +593,7 @@ def copySystemRolesToMandate(db: DatabaseConnector, mandateId: str) -> int: templateRules = rulesByRoleId.get(templateRole.get("id"), []) for rule in templateRules: newRule = AccessRule( - id=str(_uuid.uuid4()), + id=str(uuid.uuid4()), roleId=newRoleId, context=rule.get("context"), item=rule.get("item"), @@ -1929,16 +1719,6 @@ def _initRootMandateSubscription(mandateId: str) -> None: logger.warning(f"Failed to initialize root mandate subscription (non-critical): {e}") -def _bootstrapStripePrices() -> None: - """Auto-create Stripe Products and Prices for all paid plans. - Idempotent — safe on every startup. IDs are persisted in the StripePlanPrice table.""" - try: - from modules.serviceCenter.services.serviceSubscription.stripeBootstrap import bootstrapStripePrices - bootstrapStripePrices() - except Exception as e: - logger.error(f"Stripe price bootstrap failed (subscriptions will not work for paid plans): {e}") - - def assignInitialUserMemberships( db: DatabaseConnector, mandateId: str, @@ -2034,7 +1814,7 @@ def _applyDatabaseOptimizations(db: DatabaseConnector) -> None: db: Database connector instance """ try: - from modules.shared.dbMultiTenantOptimizations import applyMultiTenantOptimizations + from modules.dbHelpers.dbMultiTenantOptimizations import applyMultiTenantOptimizations result = applyMultiTenantOptimizations(db) diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py index 76bdf14d..60aac0e8 100644 --- a/modules/interfaces/interfaceDbApp.py +++ b/modules/interfaces/interfaceDbApp.py @@ -11,13 +11,15 @@ Multi-Tenant Design: import logging import math +import time +from datetime import datetime, timezone, timedelta from typing import Dict, Any, List, Optional, Union from passlib.context import CryptContext import uuid from modules.connectors.connectorDbPostgre import DatabaseConnector, getCachedConnector from modules.shared.configuration import APP_CONFIG -from modules.shared.dbRegistry import registerDatabase +from modules.dbHelpers.dbRegistry import registerDatabase from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp from modules.shared.i18nRegistry import resolveText from modules.interfaces.interfaceRbac import getRecordsetWithRBAC @@ -498,6 +500,9 @@ class AppObjects: recordFilter={"id": userIds} ) + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels + enrichRowsWithFkLabels(result.get("items", []), UserInDB, db=self.db) + items = [] for record in result["items"]: cleanedUser = dict(record) @@ -1611,7 +1616,6 @@ class AppObjects: from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot from modules.interfaces.interfaceDbBilling import getRootInterface as _getBillingRoot - from datetime import datetime, timezone, timedelta now = datetime.now(timezone.utc) nowTs = now.timestamp() @@ -1710,7 +1714,6 @@ class AppObjects: SubscriptionStatusEnum, BUILTIN_PLANS, ) from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot - from datetime import datetime, timezone, timedelta activated = 0 subInterface = _getSubRoot() @@ -1861,14 +1864,21 @@ class AppObjects: from modules.datamodels.datamodelFiles import FileItem from modules.datamodels.datamodelDataSource import DataSource from modules.datamodels.datamodelKnowledge import FileContentIndex, ContentChunk - from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + from modules.datamodels.datamodelFeatures import FeatureDataSource from modules.datamodels.datamodelBilling import BillingSettings, BillingAccount, BillingTransaction - from modules.features.neutralization.datamodelFeatureNeutralizer import DataNeutralizerAttributes + from modules.datamodels.datamodelFeatures import DataNeutralizerAttributes instances = self.db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId}) - # 0-pre. Delete AutoWorkflow data in Greenfield DB (poweron_graphicaleditor) - self._cascadeDeleteGraphicalEditorData(mandateId, instances) + # 0-pre. Let features cascade-delete their own data via lifecycle hooks + from modules.system.registry import loadFeatureMainModules + for _fCode, _fMod in loadFeatureMainModules().items(): + _hook = getattr(_fMod, "onMandateDelete", None) + if _hook: + try: + _hook(mandateId, instances) + except Exception as _hookErr: + logger.warning(f"onMandateDelete hook for '{_fCode}' failed: {_hookErr}") # 0. Delete instance-scoped data for each FeatureInstance for inst in instances: @@ -2011,67 +2021,6 @@ class AppObjects: logger.error(f"Error deleting mandate: {str(e)}") raise ValueError(f"Failed to delete mandate: {str(e)}") - def _cascadeDeleteGraphicalEditorData(self, mandateId: str, instances) -> None: - """Delete AutoWorkflow + related data in the Greenfield DB for all graphicalEditor instances.""" - try: - from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import ( - AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask, - ) - from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import graphicalEditorDatabase - from modules.connectors.connectorDbPostgre import DatabaseConnector - - geDb = DatabaseConnector( - dbHost=APP_CONFIG.get("DB_HOST", "localhost"), - dbDatabase=graphicalEditorDatabase, - dbUser=APP_CONFIG.get("DB_USER"), - dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), - dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), - userId=None, - ) - - if not geDb._ensureTableExists(AutoWorkflow): - return - - geInstances = [ - inst for inst in instances - if (inst.get("featureCode") if isinstance(inst, dict) else getattr(inst, "featureCode", "")) == "graphicalEditor" - ] - - totalDeleted = 0 - for inst in geInstances: - instId = inst.get("id") if isinstance(inst, dict) else getattr(inst, "id", None) - if not instId: - continue - - workflows = geDb.getRecordset(AutoWorkflow, recordFilter={ - "mandateId": mandateId, - "featureInstanceId": instId, - }) or [] - - for wf in workflows: - wfId = wf.get("id") - if not wfId: - continue - - for v in geDb.getRecordset(AutoVersion, recordFilter={"workflowId": wfId}) or []: - geDb.recordDelete(AutoVersion, v.get("id")) - - for run in geDb.getRecordset(AutoRun, recordFilter={"workflowId": wfId}) or []: - runId = run.get("id") - for sl in geDb.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []: - geDb.recordDelete(AutoStepLog, sl.get("id")) - geDb.recordDelete(AutoRun, runId) - - for task in geDb.getRecordset(AutoTask, recordFilter={"workflowId": wfId}) or []: - geDb.recordDelete(AutoTask, task.get("id")) - - geDb.recordDelete(AutoWorkflow, wfId) - totalDeleted += 1 - - if totalDeleted: - logger.info(f"Cascade: deleted {totalDeleted} AutoWorkflow(s) in Greenfield DB for mandate {mandateId}") - except Exception as e: - logger.warning(f"Failed to cascade-delete graphical editor data for mandate {mandateId}: {e}") def restoreMandate(self, mandateId: str) -> bool: """Restore a soft-deleted mandate (undo soft-delete within the 30-day retention window).""" @@ -2084,7 +2033,6 @@ class AppObjects: def purgeExpiredMandates(self, retentionDays: int = 30) -> int: """Hard-delete all mandates whose soft-delete timestamp exceeds the retention period.""" - import time cutoff = time.time() - (retentionDays * 86400) allMandates = self.db.getRecordset(Mandate) purged = 0 @@ -2914,6 +2862,9 @@ class AppObjects: try: result = self.db.getRecordsetPaginated(UserInDB, pagination=pagination) + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels + enrichRowsWithFkLabels(result.get("items", []), UserInDB, db=self.db) + items = [] for record in result["items"]: user = User.model_validate(record) @@ -3919,6 +3870,9 @@ class AppObjects: try: result = self.db.getRecordsetPaginated(Role, pagination=pagination) + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels + enrichRowsWithFkLabels(result.get("items", []), Role, db=self.db) + items = [] for record in result["items"]: cleanedRole = dict(record) diff --git a/modules/interfaces/interfaceDbBilling.py b/modules/interfaces/interfaceDbBilling.py index 273583d9..d51813d8 100644 --- a/modules/interfaces/interfaceDbBilling.py +++ b/modules/interfaces/interfaceDbBilling.py @@ -8,13 +8,15 @@ All billing data is stored in the poweron_billing database. """ import logging +import copy +import math from typing import Dict, Any, List, Optional, Union from datetime import date, datetime, timedelta, timezone import uuid from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.shared.configuration import APP_CONFIG -from modules.shared.dbRegistry import registerDatabase +from modules.dbHelpers.dbRegistry import registerDatabase from modules.shared.timeUtils import getUtcTimestamp from modules.datamodels.datamodelUam import User, Mandate from modules.datamodels.datamodelMembership import UserMandate @@ -632,6 +634,8 @@ class BillingObjects: pagination=pagination, recordFilter=recordFilter ) + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels + enrichRowsWithFkLabels(result.get("items", []), BillingTransaction, db=self.db) _logBillingTransactionsMissingSysCreatedAt( result["items"], "getTransactions(accountId) paginated", @@ -702,6 +706,8 @@ class BillingObjects: pagination=pagination, recordFilter={"accountId": accountIds} ) + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels + enrichRowsWithFkLabels(result.get("items", []), BillingTransaction, db=self.db) return PaginatedResult( items=result["items"], totalItems=result["totalItems"], @@ -1499,7 +1505,6 @@ class BillingObjects: """Remap frontend column names to DB column names in filters and sort.""" _COL_MAP: dict = {} _ENRICHED_COLS = {"mandateName", "userName", "mandateId", "userId"} - import copy p = copy.deepcopy(pagination) if p.filters: mapped = {} @@ -1578,6 +1583,12 @@ class BillingObjects: pagination=mappedPagination, recordFilter=recordFilter, ) + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels + enrichRowsWithFkLabels( + result.get("items", []) if isinstance(result, dict) else result.items, + BillingTransaction, + db=self.db, + ) pageItems = result.get("items", []) if isinstance(result, dict) else result.items totalItems = result.get("totalItems", 0) if isinstance(result, dict) else result.totalItems totalPages = result.get("totalPages", 0) if isinstance(result, dict) else result.totalPages @@ -1643,7 +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. """ - import math from modules.connectors.connectorDbPostgre import getModelFields, parseRecordFields from modules.datamodels.datamodelUam import UserInDB from modules.interfaces.interfaceDbApp import getInterface as getAppInterface @@ -1701,7 +1711,6 @@ class BillingObjects: # Apply non-search filters from pagination (reuse existing builder for # everything except the `search` key which we handle explicitly). - import copy paginationWithoutSearch = copy.deepcopy(pagination) if pagination else None if paginationWithoutSearch and paginationWithoutSearch.filters: paginationWithoutSearch.filters = { diff --git a/modules/interfaces/interfaceDbChat.py b/modules/interfaces/interfaceDbChat.py index 1b7ec59a..432769bd 100644 --- a/modules/interfaces/interfaceDbChat.py +++ b/modules/interfaces/interfaceDbChat.py @@ -6,8 +6,10 @@ Uses the JSON connector for data access with added language support. """ import logging +import os import uuid import math +from datetime import datetime, UTC from typing import Dict, Any, List, Optional, Union import asyncio @@ -29,7 +31,7 @@ from modules.datamodels.datamodelUam import User # DYNAMIC PART: Connectors to the Interface from modules.connectors.connectorDbPostgre import DatabaseConnector -from modules.shared.dbRegistry import registerDatabase +from modules.dbHelpers.dbRegistry import registerDatabase from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResult from modules.interfaces.interfaceRbac import getRecordsetWithRBAC @@ -60,8 +62,6 @@ def storeDebugMessageAndDocuments(message, currentUser, mandateId=None, featureI featureInstanceId: Feature instance ID for RBAC context """ try: - import os - from datetime import datetime, UTC from modules.shared.debugLogger import getBaseDebugDir, ensureDir from modules.interfaces.interfaceDbManagement import getInterface diff --git a/modules/interfaces/interfaceDbKnowledge.py b/modules/interfaces/interfaceDbKnowledge.py index d7a445bd..e979bbd3 100644 --- a/modules/interfaces/interfaceDbKnowledge.py +++ b/modules/interfaces/interfaceDbKnowledge.py @@ -12,7 +12,7 @@ from datetime import datetime, timezone, timedelta from typing import Dict, Any, List, Optional from modules.connectors.connectorDbPostgre import getCachedConnector -from modules.shared.dbRegistry import registerDatabase +from modules.dbHelpers.dbRegistry import registerDatabase from modules.datamodels.datamodelKnowledge import FileContentIndex, ContentChunk, RoundMemory, WorkflowMemory from modules.datamodels.datamodelUam import User from modules.shared.configuration import APP_CONFIG @@ -125,9 +125,9 @@ class KnowledgeObjects: for mid in mandateIds: try: - from modules.interfaces.interfaceDbBilling import _getRootInterface + from modules.interfaces.interfaceDbBilling import getRootInterface as getBillingRoot - _getRootInterface().reconcileMandateStorageBilling(mid) + getBillingRoot().reconcileMandateStorageBilling(mid) except Exception as ex: logger.warning("reconcileMandateStorageBilling after connection purge failed: %s", ex) @@ -168,8 +168,8 @@ class KnowledgeObjects: for mid in mandateIds: try: - from modules.interfaces.interfaceDbBilling import _getRootInterface - _getRootInterface().reconcileMandateStorageBilling(mid) + from modules.interfaces.interfaceDbBilling import getRootInterface as getBillingRoot + getBillingRoot().reconcileMandateStorageBilling(mid) except Exception as ex: logger.warning("reconcileMandateStorageBilling after datasource purge failed: %s", ex) diff --git a/modules/interfaces/interfaceDbManagement.py b/modules/interfaces/interfaceDbManagement.py index 3b87611d..46289b7e 100644 --- a/modules/interfaces/interfaceDbManagement.py +++ b/modules/interfaces/interfaceDbManagement.py @@ -11,10 +11,11 @@ import base64 import hashlib import math import mimetypes +import re from typing import Dict, Any, List, Optional, Union from modules.connectors.connectorDbPostgre import DatabaseConnector, getCachedConnector -from modules.shared.dbRegistry import registerDatabase +from modules.dbHelpers.dbRegistry import registerDatabase from modules.interfaces.interfaceRbac import getRecordsetWithRBAC, getRecordsetPaginatedWithRBAC from modules.security.rbac import RbacClass from modules.datamodels.datamodelRbac import AccessRuleContext @@ -194,7 +195,6 @@ class ComponentObjects: try: # Initialize standard prompts self._initializeStandardPrompts() - self._seedUiLanguageSetsIfEmpty() # Add other record initializations here @@ -204,47 +204,6 @@ class ComponentObjects: # Don't raise the error, just log it # This allows the interface to be created even if initialization fails - def _seedUiLanguageSetsIfEmpty(self) -> None: - try: - import json - from pathlib import Path - - from modules.datamodels.datamodelUiLanguage import UiLanguageSet - - existing = self.db.getRecordset(UiLanguageSet) - if existing: - return - seedPath = ( - Path(__file__).resolve().parent.parent - / "migration" - / "seedData" - / "ui_language_seed.json" - ) - if not seedPath.is_file(): - logger.warning("ui_language_seed.json not found, skipping UI i18n seed") - return - payload = json.loads(seedPath.read_text(encoding="utf-8")) - now = getUtcTimestamp() - for row in payload: - entries = row.get("entries") - if not isinstance(entries, list): - keys = row.get("keys") or {} - entries = [{"context": "ui", "key": k, "value": v} for k, v in keys.items()] - rec = { - "id": row["id"], - "label": row["label"], - "entries": entries, - "status": row.get("status") or "complete", - "isDefault": bool(row.get("isDefault", False)), - "sysCreatedAt": now, - "sysModifiedBy": None, - "sysCreatedBy": None, - "sysModifiedAt": now, - } - self.db.recordCreate(UiLanguageSet, rec) - logger.info("Seeded UiLanguageSet rows from ui_language_seed.json") - except Exception as e: - logger.error(f"UI i18n seed failed: {e}") def _initializeStandardPrompts(self): """Initializes standard prompts if they don't exist yet.""" @@ -606,7 +565,6 @@ class ComponentObjects: size_str = size_str.replace(",", "").replace(" ", "") # Extract number and unit - handle both "MB" and "M" formats - import re # Match: number (with optional decimal) followed by optional unit (K/M/G/T with optional B) match = re.match(r"^([\d.]+)([KMGT]?B?)$", size_str) if not match: @@ -900,36 +858,21 @@ class ComponentObjects: _extensionToMime: Optional[Dict[str, str]] = None _textMimeTypes: Optional[set] = None + @classmethod + def setMimeMap(cls, extensionToMime: dict, textMimeTypes: set): + """Set MIME maps from external bootstrap (avoids upward import to serviceCenter).""" + cls._extensionToMime = extensionToMime + cls._textMimeTypes = textMimeTypes + @classmethod def _ensureMimeMaps(cls): - """Lazily build extension→MIME and text-MIME-set from the ExtractorRegistry.""" + """Use MIME maps previously injected via setMimeMap (called from app.py at startup). + Falls back to empty maps if bootstrap has not run yet.""" if cls._extensionToMime is not None: return - try: - from modules.serviceCenter.services.serviceExtraction.subRegistry import ExtractorRegistry - registry = ExtractorRegistry() - cls._extensionToMime = registry.getExtensionToMimeMap() - - # Collect all MIME types declared by the TextExtractor (and other text-ish extractors) - textMimes: set = set() - seen: set = set() - for ext in registry._map.values(): - eid = id(ext) - if eid in seen: - continue - seen.add(eid) - mimes = ext.getSupportedMimeTypes() - if any(m.startswith("text/") for m in mimes): - textMimes.update(mimes) - # Always include common text types - textMimes.update({ - "application/json", "application/xml", "application/javascript", - "application/sql", "application/x-yaml", "application/x-toml", - }) - cls._textMimeTypes = textMimes - except Exception: - cls._extensionToMime = {} - cls._textMimeTypes = set() + # Fallback: maps not yet injected from bootstrap + cls._extensionToMime = {} + cls._textMimeTypes = set() def getMimeType(self, fileName: str) -> str: """Determines the MIME type based on the file extension. @@ -2314,4 +2257,24 @@ def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = else: logger.info("Returning interface without user context") - return interface \ No newline at end of file + return interface + + +def buildResolverDbInterface(chatService): + """Build a DB adapter that ConnectorResolver can use to load UserConnections. + + ConnectorResolver calls db.getUserConnection(connectionId). + interfaceDbApp provides getUserConnectionById(connectionId). + This adapter bridges the method name difference. + """ + class _ResolverDbAdapter: + def __init__(self, appInterface): + self._app = appInterface + def getUserConnection(self, connectionId: str): + if hasattr(self._app, "getUserConnectionById"): + return self._app.getUserConnectionById(connectionId) + return None + appIf = getattr(chatService, "interfaceDbApp", None) + if appIf: + return _ResolverDbAdapter(appIf) + return getattr(chatService, "interfaceDbComponent", None) \ No newline at end of file diff --git a/modules/interfaces/interfaceDbSubscription.py b/modules/interfaces/interfaceDbSubscription.py index a0a69315..bdcaeb2b 100644 --- a/modules/interfaces/interfaceDbSubscription.py +++ b/modules/interfaces/interfaceDbSubscription.py @@ -13,7 +13,7 @@ from datetime import datetime, timezone from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.shared.configuration import APP_CONFIG -from modules.shared.dbRegistry import registerDatabase +from modules.dbHelpers.dbRegistry import registerDatabase from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelMembership import UserMandate from modules.datamodels.datamodelSubscription import ( @@ -30,6 +30,8 @@ from modules.datamodels.datamodelSubscription import ( getEffectiveLimits, ) +from modules.shared.serviceExceptions import SubscriptionCapacityException + logger = logging.getLogger(__name__) SUBSCRIPTION_DATABASE = "poweron_billing" @@ -270,7 +272,6 @@ class SubscriptionObjects: def assertCapacity(self, mandateId: str, resourceType: str, delta: int = 1) -> bool: sub = self.getOperativeForMandate(mandateId) if not sub: - from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException raise SubscriptionCapacityException( resourceType=resourceType, currentCount=0, maxAllowed=0, message="No active subscription for this mandate.", @@ -286,7 +287,6 @@ class SubscriptionObjects: return True current = self.countActiveUsers(mandateId) if current + delta > cap: - from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException raise SubscriptionCapacityException( resourceType=resourceType, currentCount=current, maxAllowed=cap, isEnterprise=isEnterprise, @@ -297,7 +297,6 @@ class SubscriptionObjects: return True current = self.countActiveFeatureInstances(mandateId) if current + delta > cap: - from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException raise SubscriptionCapacityException( resourceType=resourceType, currentCount=current, maxAllowed=cap, isEnterprise=isEnterprise, @@ -308,7 +307,6 @@ class SubscriptionObjects: return True currentMB = self.getMandateDataVolumeMB(mandateId) if currentMB + delta > cap: - from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException raise SubscriptionCapacityException( resourceType=resourceType, currentCount=int(currentMB), maxAllowed=cap, isEnterprise=isEnterprise, diff --git a/modules/interfaces/interfaceFeatures.py b/modules/interfaces/interfaceFeatures.py index 0f7d20e1..4c1b29b8 100644 --- a/modules/interfaces/interfaceFeatures.py +++ b/modules/interfaces/interfaceFeatures.py @@ -287,8 +287,6 @@ class FeatureInterface: RuntimeError: If templates exist but cannot be copied. Caller decides whether to swallow or re-raise. """ - import json - from modules.system.registry import loadFeatureMainModules mainModules = loadFeatureMainModules() featureModule = mainModules.get(featureCode) @@ -323,49 +321,26 @@ class FeatureInterface: f"for feature '{featureCode}' to instance {instanceId} (mandate={mandateId})" ) - from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface - from modules.security.rootAccess import getRootUser - rootUser = getRootUser() - geInterface = getGraphicalEditorInterface(rootUser, mandateId, instanceId) + geMod = mainModules.get("graphicalEditor") + onInstanceCreateHook = getattr(geMod, "onInstanceCreate", None) if geMod else None + if not onInstanceCreateHook: + logger.warning("_copyTemplateWorkflows: graphicalEditor.onInstanceCreate hook not available") + return 0 - copied = 0 - failed = 0 - for template in templateWorkflows: - templateId = template.get("id", "") - try: - graphJson = json.dumps(template.get("graph", {})) - graphJson = graphJson.replace("{{featureInstanceId}}", instanceId) - graph = json.loads(graphJson) - - label = resolveText(template.get("label")) - - geInterface.createWorkflow({ - "label": label, - "graph": graph, - "tags": template.get("tags", [f"feature:{featureCode}"]), - "isTemplate": False, - "templateSourceId": templateId, - "templateScope": "instance", - "active": True, - "targetFeatureInstanceId": instanceId, - }) - copied += 1 - except Exception as e: - failed += 1 - logger.error( - f"_copyTemplateWorkflows: failed to create workflow '{templateId}' for " - f"feature '{featureCode}' instance {instanceId}: {e}", - exc_info=True, - ) + try: + copied = onInstanceCreateHook(mandateId, instanceId, featureCode, templateWorkflows) + except Exception as e: + logger.error( + f"_copyTemplateWorkflows: onInstanceCreate hook failed for '{featureCode}': {e}", + exc_info=True, + ) + raise RuntimeError( + f"_copyTemplateWorkflows: onInstanceCreate failed for feature '{featureCode}': {e}" + ) if copied: logger.info( f"_copyTemplateWorkflows: copied {copied}/{len(templateWorkflows)} workflow(s) " - f"for feature '{featureCode}' instance {instanceId} (failed={failed})" - ) - if failed: - raise RuntimeError( - f"_copyTemplateWorkflows: {failed}/{len(templateWorkflows)} workflow(s) failed " f"for feature '{featureCode}' instance {instanceId}" ) return copied diff --git a/modules/interfaces/interfaceRbac.py b/modules/interfaces/interfaceRbac.py index 29182ae2..8d886cfd 100644 --- a/modules/interfaces/interfaceRbac.py +++ b/modules/interfaces/interfaceRbac.py @@ -26,6 +26,8 @@ import logging import json import math import re +import copy +from datetime import datetime, timezone from typing import List, Dict, Any, Optional, Type, Union from pydantic import BaseModel from modules.datamodels.datamodelRbac import AccessRuleContext @@ -107,21 +109,20 @@ def _rbacAppendPaginationDictFilter( toVal and _ISO_DATE_RE.match(str(toVal)) ) if isNumericCol and isDateVal: - from datetime import datetime as _dt, timezone as _tz if fromVal and toVal: - fromTs = _dt.strptime(str(fromVal), "%Y-%m-%d").replace(tzinfo=_tz.utc).timestamp() - toTs = _dt.strptime(str(toVal), "%Y-%m-%d").replace( - hour=23, minute=59, second=59, tzinfo=_tz.utc + fromTs = datetime.strptime(str(fromVal), "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp() + toTs = datetime.strptime(str(toVal), "%Y-%m-%d").replace( + hour=23, minute=59, second=59, tzinfo=timezone.utc ).timestamp() whereConditions.append(f'"{key}" >= %s AND "{key}" <= %s') whereValues.extend([fromTs, toTs]) elif fromVal: - fromTs = _dt.strptime(str(fromVal), "%Y-%m-%d").replace(tzinfo=_tz.utc).timestamp() + fromTs = datetime.strptime(str(fromVal), "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp() whereConditions.append(f'"{key}" >= %s') whereValues.append(fromTs) else: - toTs = _dt.strptime(str(toVal), "%Y-%m-%d").replace( - hour=23, minute=59, second=59, tzinfo=_tz.utc + toTs = datetime.strptime(str(toVal), "%Y-%m-%d").replace( + hour=23, minute=59, second=59, tzinfo=timezone.utc ).timestamp() whereConditions.append(f'"{key}" <= %s') whereValues.append(toTs) @@ -585,8 +586,8 @@ def getRecordsetPaginatedWithRBAC( if enrichPermissions: records = _enrichRecordsWithPermissions(records, permissions, currentUser) - from modules.routes.routeHelpers import enrichRowsWithFkLabels - enrichRowsWithFkLabels(records, modelClass) + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels + enrichRowsWithFkLabels(records, modelClass, db=connector) if pagination: pageSize = pagination.pageSize @@ -614,7 +615,6 @@ def getDistinctColumnValuesWithRBAC( Get sorted distinct values for a column with RBAC filtering at SQL level. Cross-filtering: removes the requested column from active filters. """ - import copy table = modelClass.__name__ objectKey = buildDataObjectKey(table, featureCode) diff --git a/modules/interfaces/interfaceTableHelpers.py b/modules/interfaces/interfaceTableHelpers.py new file mode 100644 index 00000000..81336fed --- /dev/null +++ b/modules/interfaces/interfaceTableHelpers.py @@ -0,0 +1,330 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Table/list presentation helpers: view resolution, grouping, Strategy B. + +These helpers orchestrate how paginated table data is grouped, filtered +and sorted according to saved TableListView configurations. +""" + +import logging +from collections import defaultdict +from functools import cmp_to_key +from typing import Any, Dict, List, Optional + +from modules.datamodels.datamodelPagination import PaginationParams + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# View resolution +# --------------------------------------------------------------------------- + +def resolveView(interface, contextKey: str, viewKey: Optional[str]): + """ + Load a TableListView for the current user and contextKey. + + Returns (config_dict, display_name): + - (None, None) when viewKey is None / empty + - (config, str | None) otherwise — config may be {}; display_name from the row + + Raises HTTPException(404) when viewKey is explicitly set but the view + does not exist (prevents silent fallback to ungrouped behaviour). + """ + from fastapi import HTTPException + if not viewKey: + return None, None + try: + view = interface.getTableListView(contextKey=contextKey, viewKey=viewKey) + except Exception as e: + logger.warning(f"resolveView: store lookup failed for key={viewKey!r} context={contextKey!r}: {e}") + view = None + if view is None: + raise HTTPException(status_code=404, detail=f"View '{viewKey}' not found for context '{contextKey}'") + cfg = view.config or {} + dname = getattr(view, "displayName", None) or None + return cfg, dname + + +def effective_group_by_levels( + pagination_params: Optional["PaginationParams"], + view_config: Optional[dict], +) -> List[Dict[str, Any]]: + """ + Choose grouping levels for this request. + + If the client sends ``groupByLevels`` (including ``[]``), it wins over the + saved view. If the key is omitted (``None``), use the view's levels. + """ + if pagination_params is not None: + req = getattr(pagination_params, "groupByLevels", None) + if req is not None: + out: List[Dict[str, Any]] = [] + for lvl in req: + if hasattr(lvl, "model_dump"): + out.append(lvl.model_dump()) + elif isinstance(lvl, dict): + out.append(dict(lvl)) + else: + out.append(dict(lvl)) # type: ignore[arg-type] + return out + vc = (view_config or {}).get("groupByLevels") if view_config else None + return list(vc or []) + + +def applyViewToParams(params: Optional["PaginationParams"], viewConfig: Optional[dict]) -> Optional["PaginationParams"]: + """ + Merge a view's saved configuration into PaginationParams. + + Priority: explicit request fields win over view defaults. + - sort: use request sort if non-empty, otherwise view sort + - filters: deep-merge (request filters win per-key) + - pageSize: use request value (already set by normalize_pagination_dict) + + 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 + + if params is None: + params = PaginationParams(page=1, pageSize=25) + + if not params.sort and viewConfig.get("sort"): + try: + params.sort = [ + SortField(**s) if isinstance(s, dict) else s + for s in viewConfig["sort"] + ] + except Exception as e: + logger.warning(f"applyViewToParams: could not parse view sort: {e}") + + viewFilters = viewConfig.get("filters") or {} + if viewFilters: + merged = dict(viewFilters) + if params.filters: + merged.update(params.filters) + params.filters = merged + + return params + + +def apply_strategy_b_filters_and_sort( + items: List[Dict[str, Any]], + pagination_params: Optional[PaginationParams], + current_user: Any, +) -> List[Dict[str, Any]]: + """ + Shared in-memory filter + sort pass for Strategy B (files/prompts/connections lists). + """ + if not pagination_params: + return list(items) + from modules.interfaces.interfaceDbManagement import ComponentObjects + + comp = ComponentObjects() + comp.setUserContext(current_user) + out = list(items) + if pagination_params.filters: + out = comp._applyFilters(out, pagination_params.filters) + if pagination_params.sort: + out = comp._applySorting(out, pagination_params.sort) + return out + + +def build_group_summary_groups( + items: List[Dict[str, Any]], + field: str, + null_label: str = "\u2014", + groupByLevels: List[Dict[str, Any]] | None = None, +) -> List[Dict[str, Any]]: + """ + Build {"value", "label", "totalCount"} summaries for mode=groupSummary. + + When *groupByLevels* contains more than one level the function produces one + entry per unique combination of all level values (flat permutations). + ``value`` becomes a ``///``-joined composite key and ``label`` the ``/``-joined + human-readable label so the frontend can split them back. + """ + + fields: list[dict] = [] + if groupByLevels and len(groupByLevels) > 1: + for lvl in groupByLevels: + f = lvl.get("field", "") + nl = str(lvl.get("nullLabel") or null_label) + if f: + fields.append({"field": f, "nullLabel": nl}) + if not fields: + fields = [{"field": field, "nullLabel": null_label}] + + nullKey = "\x00NULL" + + if len(fields) == 1: + f = fields[0]["field"] + nl = fields[0]["nullLabel"] + counts: Dict[str, int] = defaultdict(int) + displayByKey: Dict[str, str] = {} + labelAttr = f"{f}Label" + for item in items: + raw = item.get(f) + if raw is None or raw == "": + nk = nullKey + display = nl + else: + nk = str(raw) + display = None + lbl = item.get(labelAttr) + if lbl is not None and lbl != "": + display = str(lbl) + if display is None: + display = nk + counts[nk] += 1 + if nk not in displayByKey: + displayByKey[nk] = display + orderedKeys = sorted( + counts.keys(), + key=lambda x: (x == nullKey, str(displayByKey.get(x, x)).lower()), + ) + return [ + { + "value": None if nk == nullKey else nk, + "label": displayByKey.get(nk, nk), + "totalCount": counts[nk], + } + for nk in orderedKeys + ] + + counts = defaultdict(int) + displayByComposite: Dict[str, list] = {} + filtersByComposite: Dict[str, dict] = {} + for item in items: + parts: list[str] = [] + labels: list[str] = [] + filterMap: dict = {} + for fd in fields: + f = fd["field"] + nl = fd["nullLabel"] + labelAttr = f"{f}Label" + raw = item.get(f) + if raw is None or raw == "": + parts.append(nullKey) + labels.append(nl) + filterMap[f] = None + else: + parts.append(str(raw)) + lbl = item.get(labelAttr) + labels.append(str(lbl) if lbl not in (None, "") else str(raw)) + filterMap[f] = str(raw) + compositeKey = "///".join(parts) + counts[compositeKey] += 1 + if compositeKey not in displayByComposite: + displayByComposite[compositeKey] = labels + filtersByComposite[compositeKey] = filterMap + + orderedKeys = sorted( + counts.keys(), + key=lambda x: tuple( + (seg == nullKey, seg.lower()) for seg in x.split("///") + ), + ) + return [ + { + "value": ck.replace(nullKey, "__null__") if nullKey in ck else ck, + "label": " / ".join(displayByComposite[ck]), + "totalCount": counts[ck], + "filters": filtersByComposite[ck], + } + for ck in orderedKeys + ] + + +def buildGroupLayout( + all_items: List[Dict[str, Any]], + groupByLevels: List[Dict[str, Any]], + page: int, + pageSize: int, +) -> tuple: + """ + Apply multi-level grouping to all_items, slice to the requested page, + and return (page_items, GroupLayout | None). + + Strategy B: grouping operates on the full filtered+sorted candidate list. + Items are stably re-sorted by the group path so that members of the same + group are always contiguous (preserving the existing per-group sort order + from the caller). + + Parameters + ---------- + all_items: fully filtered and user-sorted list of row dicts. + groupByLevels: list of {"field": str, "nullLabel": str, "direction": "asc"|"desc"} dicts. + page, pageSize: 1-based page index and page size. + + Returns + ------- + (page_items, GroupLayout | None) + """ + from modules.datamodels.datamodelPagination import GroupBand, GroupLayout + + if not groupByLevels: + offset = (page - 1) * pageSize + return all_items[offset:offset + pageSize], None + + levels = [lvl.get("field", "") for lvl in groupByLevels if lvl.get("field")] + if not levels: + offset = (page - 1) * pageSize + return all_items[offset:offset + pageSize], None + + nullLabels = {lvl.get("field", ""): lvl.get("nullLabel", "\u2014") for lvl in groupByLevels} + + def _path_key(item: dict) -> tuple: + return tuple( + str(item.get(f) or "") if item.get(f) is not None else nullLabels.get(f, "\u2014") + for f in levels + ) + + def _item_cmp(a: dict, b: dict) -> int: + pa, pb = _path_key(a), _path_key(b) + for i in range(len(levels)): + if pa[i] != pb[i]: + asc = (groupByLevels[i].get("direction") or "asc").lower() != "desc" + if pa[i] < pb[i]: + return -1 if asc else 1 + return 1 if asc else -1 + return 0 + + all_items.sort(key=cmp_to_key(_item_cmp)) + + bands_global: List[dict] = [] + current_path: Optional[tuple] = None + current_start = 0 + for i, item in enumerate(all_items): + path = _path_key(item) + if path != current_path: + if current_path is not None: + bands_global.append({"path": list(current_path), "startIdx": current_start, "endIdx": i}) + current_path = path + current_start = i + if current_path is not None: + bands_global.append({"path": list(current_path), "startIdx": current_start, "endIdx": len(all_items)}) + + page_start = (page - 1) * pageSize + page_end = page_start + pageSize + page_items = all_items[page_start:page_end] + + bands_on_page: List[GroupBand] = [] + for band in bands_global: + inter_start = max(band["startIdx"], page_start) + inter_end = min(band["endIdx"], page_end) + if inter_start >= inter_end: + continue + path_list = band["path"] + bands_on_page.append(GroupBand( + path=path_list, + label=path_list[-1] if path_list else "\u2014", + startRowIndex=inter_start - page_start, + rowCount=inter_end - inter_start, + )) + + group_layout = GroupLayout(levels=levels, bands=bands_on_page) if bands_on_page else GroupLayout(levels=levels, bands=[]) + return page_items, group_layout diff --git a/modules/migration/__init__.py b/modules/migration/__init__.py deleted file mode 100644 index 7639be60..00000000 --- a/modules/migration/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Migration modules diff --git a/modules/migration/seedData/ui_language_seed.json b/modules/migration/seedData/ui_language_seed.json deleted file mode 100644 index 060e6c51..00000000 --- a/modules/migration/seedData/ui_language_seed.json +++ /dev/null @@ -1,13554 +0,0 @@ -[ - { - "id": "xx", - "label": "Basisset (Meta)", - "entries": [ - { - "context": "ui", - "key": "+41 123 456 789", - "value": "" - }, - { - "context": "ui", - "key": "1 Benutzer ausgewählt", - "value": "" - }, - { - "context": "ui", - "key": "ABGEBROCHEN", - "value": "" - }, - { - "context": "ui", - "key": "ABGESCHLOSSEN", - "value": "" - }, - { - "context": "ui", - "key": "Abbrechen", - "value": "" - }, - { - "context": "ui", - "key": "Abgeschlossen", - "value": "" - }, - { - "context": "ui", - "key": "Abmelden", - "value": "" - }, - { - "context": "ui", - "key": "Admin-Einstellungen", - "value": "" - }, - { - "context": "ui", - "key": "Administrative Einstellungen", - "value": "" - }, - { - "context": "ui", - "key": "Administrator", - "value": "" - }, - { - "context": "ui", - "key": "Adresse", - "value": "" - }, - { - "context": "ui", - "key": "Agent Assist (AA)", - "value": "" - }, - { - "context": "ui", - "key": "Aktionen", - "value": "" - }, - { - "context": "ui", - "key": "Aktiv", - "value": "" - }, - { - "context": "ui", - "key": "Aktiviert", - "value": "" - }, - { - "context": "ui", - "key": "Aktualisieren", - "value": "" - }, - { - "context": "ui", - "key": "Aktuelle Transkripte", - "value": "" - }, - { - "context": "ui", - "key": "Alle", - "value": "" - }, - { - "context": "ui", - "key": "Alle Dateien", - "value": "" - }, - { - "context": "ui", - "key": "Alle Daten als CSV exportieren", - "value": "" - }, - { - "context": "ui", - "key": "Alle Elemente auswählen", - "value": "" - }, - { - "context": "ui", - "key": "Alle Nicht-Standard-Sprachsets jetzt mit dem deutschen Master synchronisieren?", - "value": "" - }, - { - "context": "ui", - "key": "Alle abwählen", - "value": "" - }, - { - "context": "ui", - "key": "Alle aktualisieren", - "value": "" - }, - { - "context": "ui", - "key": "Alle auswählen", - "value": "" - }, - { - "context": "ui", - "key": "Alle {count} Elemente löschen", - "value": "" - }, - { - "context": "ui", - "key": "Analysiere Workflow...", - "value": "" - }, - { - "context": "ui", - "key": "Anmelden", - "value": "" - }, - { - "context": "ui", - "key": "Anrufer", - "value": "" - }, - { - "context": "ui", - "key": "Anzeigen", - "value": "" - }, - { - "context": "ui", - "key": "Anzeigename", - "value": "" - }, - { - "context": "ui", - "key": "Audio", - "value": "" - }, - { - "context": "ui", - "key": "Auf Standard zurücksetzen", - "value": "" - }, - { - "context": "ui", - "key": "Aufgaben", - "value": "" - }, - { - "context": "ui", - "key": "Ausführen", - "value": "" - }, - { - "context": "ui", - "key": "Ausgewählte Datei:", - "value": "" - }, - { - "context": "ui", - "key": "Auth-Anbieter", - "value": "" - }, - { - "context": "ui", - "key": "Authentifizierungsanbieter", - "value": "" - }, - { - "context": "ui", - "key": "Authentifizierungstoken abgelaufen oder ungültig. Bitte verbinden Sie Ihr Microsoft-Konto erneut.", - "value": "" - }, - { - "context": "ui", - "key": "Automatisierung erfolgreich erstellt", - "value": "" - }, - { - "context": "ui", - "key": "Automatisierungen", - "value": "" - }, - { - "context": "ui", - "key": "Basisdaten", - "value": "" - }, - { - "context": "ui", - "key": "Bearbeiten", - "value": "" - }, - { - "context": "ui", - "key": "Befehl eingeben (z.B., \"Erstelle ein neues Projekt namens 'Hauptstrasse 42'\")", - "value": "" - }, - { - "context": "ui", - "key": "Beginne ein Gespräch, indem du eine Nachricht eingibst, eine Vorlage auswählst oder einen vorherigen Workflow fortsetzt …", - "value": "" - }, - { - "context": "ui", - "key": "Beginnen Sie mit:", - "value": "" - }, - { - "context": "ui", - "key": "Bei Genehmigung planen wir einen Einrichtungsanruf zur Konfiguration Ihrer Integration.", - "value": "" - }, - { - "context": "ui", - "key": "Beim Hochladen ist ein Fehler aufgetreten.", - "value": "" - }, - { - "context": "ui", - "key": "Beim Hochladen ist ein unerwarteter Fehler aufgetreten.", - "value": "" - }, - { - "context": "ui", - "key": "Belege verwalten", - "value": "" - }, - { - "context": "ui", - "key": "Benutzer", - "value": "" - }, - { - "context": "ui", - "key": "Benutzer auswählen", - "value": "" - }, - { - "context": "ui", - "key": "Benutzer bearbeiten", - "value": "" - }, - { - "context": "ui", - "key": "Benutzer erstellen", - "value": "" - }, - { - "context": "ui", - "key": "Benutzer hinzufügen", - "value": "" - }, - { - "context": "ui", - "key": "Benutzer löschen", - "value": "" - }, - { - "context": "ui", - "key": "Benutzer werden geladen...", - "value": "" - }, - { - "context": "ui", - "key": "Benutzer-Zugriff verwalten", - "value": "" - }, - { - "context": "ui", - "key": "Benutzerdefinierter Titel (optional)", - "value": "" - }, - { - "context": "ui", - "key": "Benutzerinformationen", - "value": "" - }, - { - "context": "ui", - "key": "Benutzerinformationen erfolgreich aktualisiert", - "value": "" - }, - { - "context": "ui", - "key": "Benutzerinformationen werden geladen...", - "value": "" - }, - { - "context": "ui", - "key": "Benutzername", - "value": "" - }, - { - "context": "ui", - "key": "Benutzerverwaltung - Teammitglieder und Berechtigungen verwalten", - "value": "" - }, - { - "context": "ui", - "key": "Berechtigung", - "value": "" - }, - { - "context": "ui", - "key": "Berechtigungsstufe", - "value": "" - }, - { - "context": "ui", - "key": "Beschreibung", - "value": "" - }, - { - "context": "ui", - "key": "Beschreibung der Rolle", - "value": "" - }, - { - "context": "ui", - "key": "Betrachter", - "value": "" - }, - { - "context": "ui", - "key": "Betreff", - "value": "" - }, - { - "context": "ui", - "key": "Bezeichnung", - "value": "" - }, - { - "context": "ui", - "key": "Bieten Sie Unterstützung im Live-Chat und setzen Sie intelligente Chatbots in allen Kanälen ein.", - "value": "" - }, - { - "context": "ui", - "key": "Bild", - "value": "" - }, - { - "context": "ui", - "key": "Bis", - "value": "" - }, - { - "context": "ui", - "key": "Bitte geben Sie eine gültige E-Mail-Adresse ein", - "value": "" - }, - { - "context": "ui", - "key": "Bitte wählen Sie mindestens einen Benutzer aus", - "value": "" - }, - { - "context": "ui", - "key": "Branche", - "value": "" - }, - { - "context": "ui", - "key": "Branche ist erforderlich", - "value": "" - }, - { - "context": "ui", - "key": "Buchungsbetrag", - "value": "" - }, - { - "context": "ui", - "key": "Buchungspositionen verwalten", - "value": "" - }, - { - "context": "ui", - "key": "Buchungswährung", - "value": "" - }, - { - "context": "ui", - "key": "Chat Platform (CP)", - "value": "" - }, - { - "context": "ui", - "key": "Chat leeren...", - "value": "" - }, - { - "context": "ui", - "key": "Chatbereich", - "value": "" - }, - { - "context": "ui", - "key": "Darstellung", - "value": "" - }, - { - "context": "ui", - "key": "Datei", - "value": "" - }, - { - "context": "ui", - "key": "Datei anhängen", - "value": "" - }, - { - "context": "ui", - "key": "Datei bearbeiten", - "value": "" - }, - { - "context": "ui", - "key": "Datei bereits vorhanden", - "value": "" - }, - { - "context": "ui", - "key": "Datei entfernen", - "value": "" - }, - { - "context": "ui", - "key": "Datei erfolgreich hochgeladen!", - "value": "" - }, - { - "context": "ui", - "key": "Datei herunterladen", - "value": "" - }, - { - "context": "ui", - "key": "Datei hier ablegen...", - "value": "" - }, - { - "context": "ui", - "key": "Datei hinzufügen", - "value": "" - }, - { - "context": "ui", - "key": "Datei hochladen", - "value": "" - }, - { - "context": "ui", - "key": "Datei löschen", - "value": "" - }, - { - "context": "ui", - "key": "Datei vorschauen", - "value": "" - }, - { - "context": "ui", - "key": "Datei-Ablage während Workflow deaktiviert", - "value": "" - }, - { - "context": "ui", - "key": "Dateien", - "value": "" - }, - { - "context": "ui", - "key": "Dateien anhängen", - "value": "" - }, - { - "context": "ui", - "key": "Dateien auswählen", - "value": "" - }, - { - "context": "ui", - "key": "Dateien hier ablegen", - "value": "" - }, - { - "context": "ui", - "key": "Dateien hier ablegen zum Anhängen", - "value": "" - }, - { - "context": "ui", - "key": "Dateien hierher ziehen", - "value": "" - }, - { - "context": "ui", - "key": "Dateien hochladen", - "value": "" - }, - { - "context": "ui", - "key": "Dateien werden geladen...", - "value": "" - }, - { - "context": "ui", - "key": "Dateien werden verarbeitet...", - "value": "" - }, - { - "context": "ui", - "key": "Dateigröße", - "value": "" - }, - { - "context": "ui", - "key": "Dateiname", - "value": "" - }, - { - "context": "ui", - "key": "Dateityp", - "value": "" - }, - { - "context": "ui", - "key": "Dateiverwaltung - Dokumente hochladen und organisieren", - "value": "" - }, - { - "context": "ui", - "key": "Dateivorschau", - "value": "" - }, - { - "context": "ui", - "key": "Daten aktualisieren", - "value": "" - }, - { - "context": "ui", - "key": "Daten empfangen", - "value": "" - }, - { - "context": "ui", - "key": "Daten gesendet", - "value": "" - }, - { - "context": "ui", - "key": "Datenverwaltung", - "value": "" - }, - { - "context": "ui", - "key": "Datenverwaltung - Datenimporte und -exporte verwalten", - "value": "" - }, - { - "context": "ui", - "key": "Datenverwaltung mit Tabellen", - "value": "" - }, - { - "context": "ui", - "key": "Datum", - "value": "" - }, - { - "context": "ui", - "key": "Dauer", - "value": "" - }, - { - "context": "ui", - "key": "Details", - "value": "" - }, - { - "context": "ui", - "key": "Deutsch", - "value": "" - }, - { - "context": "ui", - "key": "Die Datei \"{fileName}\" existiert bereits mit identischem Inhalt. Die vorhandene Datei wird wiederverwendet.", - "value": "" - }, - { - "context": "ui", - "key": "Die Erstellung einer neuen Sprache kann AI-Guthaben auf Ihrem Mandats-Pool belasten. Fortfahren?", - "value": "" - }, - { - "context": "ui", - "key": "Dies ist Ihr Ausgangspunkt für den Zugriff auf alle Arbeitsbereich-Features und -Tools.", - "value": "" - }, - { - "context": "ui", - "key": "Diese Aktion kann nicht rückgängig gemacht werden.", - "value": "" - }, - { - "context": "ui", - "key": "Diese Datei scheint beschädigt zu sein. Sie hat eine PDF-Erweiterung, enthält aber Textinhalte. Bitte laden Sie die Datei erneut hoch, falls möglich.", - "value": "" - }, - { - "context": "ui", - "key": "Dieser Bereich enthält alle Verwaltungs- und Management-Tools für Ihren Arbeitsbereich.", - "value": "" - }, - { - "context": "ui", - "key": "Dieses Element auswählen", - "value": "" - }, - { - "context": "ui", - "key": "Dieses Element kann nicht ausgewählt werden", - "value": "" - }, - { - "context": "ui", - "key": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden", - "value": "" - }, - { - "context": "ui", - "key": "Dossiers", - "value": "UDB tab label for chat workflows / cases" - }, - { - "context": "ui", - "key": "Dokument", - "value": "" - }, - { - "context": "ui", - "key": "Dokument erfolgreich erstellt", - "value": "" - }, - { - "context": "ui", - "key": "Dokument herunterladen", - "value": "" - }, - { - "context": "ui", - "key": "Dokument vorschauen", - "value": "" - }, - { - "context": "ui", - "key": "Dokumente", - "value": "" - }, - { - "context": "ui", - "key": "Dokumente auflisten", - "value": "" - }, - { - "context": "ui", - "key": "Dokumentname", - "value": "" - }, - { - "context": "ui", - "key": "Dunkel", - "value": "" - }, - { - "context": "ui", - "key": "Durchsuchen", - "value": "" - }, - { - "context": "ui", - "key": "E-Mail", - "value": "" - }, - { - "context": "ui", - "key": "E-Mail-Adresse", - "value": "" - }, - { - "context": "ui", - "key": "E-Mail-Adresse ist erforderlich", - "value": "" - }, - { - "context": "ui", - "key": "E-Mail-Bestätigung", - "value": "" - }, - { - "context": "ui", - "key": "Echtzeit-Datensynchronisation:", - "value": "" - }, - { - "context": "ui", - "key": "Eigenschaften", - "value": "" - }, - { - "context": "ui", - "key": "Eingereichte Daten:", - "value": "" - }, - { - "context": "ui", - "key": "Einrichtungsanruf", - "value": "" - }, - { - "context": "ui", - "key": "Einstellungen", - "value": "" - }, - { - "context": "ui", - "key": "Einstellungen erfolgreich gespeichert!", - "value": "" - }, - { - "context": "ui", - "key": "Einstellungen werden in zukünftigen Updates hinzugefügt.", - "value": "" - }, - { - "context": "ui", - "key": "Einstellungen wurden erfolgreich zurückgesetzt.", - "value": "" - }, - { - "context": "ui", - "key": "Einträge", - "value": "" - }, - { - "context": "ui", - "key": "Einträge pro Seite:", - "value": "" - }, - { - "context": "ui", - "key": "Empfänger", - "value": "" - }, - { - "context": "ui", - "key": "Endzeit", - "value": "" - }, - { - "context": "ui", - "key": "English", - "value": "" - }, - { - "context": "ui", - "key": "Entdeckte Sites", - "value": "" - }, - { - "context": "ui", - "key": "Entfernen", - "value": "" - }, - { - "context": "ui", - "key": "Erfolgreich", - "value": "" - }, - { - "context": "ui", - "key": "Erfolgsrate", - "value": "" - }, - { - "context": "ui", - "key": "Erleben Sie die Zukunft der Mandantenkommunikation durch unsere strategische Partnerschaft mit Spitch.ai. Diese bahnbrechende Integration verwandelt Ihre PowerOn-Plattform in ein intelligentes Telefonie-System, das externe Mandanten nahtlos mit Unternehmen verbindet.", - "value": "" - }, - { - "context": "ui", - "key": "Erneut versuchen", - "value": "" - }, - { - "context": "ui", - "key": "Erste Seite", - "value": "" - }, - { - "context": "ui", - "key": "Erstellen", - "value": "" - }, - { - "context": "ui", - "key": "Erstellen und verwalten Sie RBAC-Rollen und deren Berechtigungen.", - "value": "" - }, - { - "context": "ui", - "key": "Erstellen...", - "value": "" - }, - { - "context": "ui", - "key": "Erstellt", - "value": "" - }, - { - "context": "ui", - "key": "Erstellte Dateien", - "value": "" - }, - { - "context": "ui", - "key": "Erstellungsdatum", - "value": "" - }, - { - "context": "ui", - "key": "Exportiere...", - "value": "" - }, - { - "context": "ui", - "key": "Externe E-Mail", - "value": "" - }, - { - "context": "ui", - "key": "Externe E-Mail-Adresse eingeben", - "value": "" - }, - { - "context": "ui", - "key": "Externen Benutzernamen eingeben", - "value": "" - }, - { - "context": "ui", - "key": "Externer Benutzername", - "value": "" - }, - { - "context": "ui", - "key": "FEHLER", - "value": "" - }, - { - "context": "ui", - "key": "FEHLGESCHLAGEN", - "value": "" - }, - { - "context": "ui", - "key": "Falls Sie Fragen zu Ihrem Mandat oder dem Integrationsprozess haben, zögern Sie nicht, unser Support-Team zu kontaktieren.", - "value": "" - }, - { - "context": "ui", - "key": "Fehler", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Aktualisieren der Benutzerinformationen", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der Automatisierung", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der Organisation", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der Position", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der RBAC-Regel", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der Rolle", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Dokuments", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Mandats", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Prompts", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Team-Mitglieds", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Vertrags", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Zugriffs", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Benutzer", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Benutzer:", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Benutzerinformationen", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Dateien:", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Logs", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Nachrichten:", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Prompts", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Prompts:", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Laden der SharePoint Dokumente:", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Vorschau", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Workflows:", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Löschen", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Speichern der Einstellungen. Bitte versuchen Sie es erneut.", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Teilen des Prompts", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Verarbeiten der Dateien", - "value": "" - }, - { - "context": "ui", - "key": "Fehler:", - "value": "" - }, - { - "context": "ui", - "key": "Fehlgeschlagen", - "value": "" - }, - { - "context": "ui", - "key": "Filter", - "value": "" - }, - { - "context": "ui", - "key": "Filter löschen", - "value": "" - }, - { - "context": "ui", - "key": "Filter: {value}", - "value": "" - }, - { - "context": "ui", - "key": "Firma", - "value": "" - }, - { - "context": "ui", - "key": "Firmenname", - "value": "" - }, - { - "context": "ui", - "key": "Firmenname ist erforderlich", - "value": "" - }, - { - "context": "ui", - "key": "Folgenachricht wird gesendet...", - "value": "" - }, - { - "context": "ui", - "key": "Fortfahren", - "value": "" - }, - { - "context": "ui", - "key": "Fortsetzen", - "value": "" - }, - { - "context": "ui", - "key": "Fragen?", - "value": "" - }, - { - "context": "ui", - "key": "Français", - "value": "" - }, - { - "context": "ui", - "key": "Fügen Sie eine Nachricht für die Empfänger hinzu", - "value": "" - }, - { - "context": "ui", - "key": "GESTOPPT", - "value": "" - }, - { - "context": "ui", - "key": "Geben Sie Ihren Firmennamen ein", - "value": "" - }, - { - "context": "ui", - "key": "Geben Sie Kunden einen schnellen und effizienten Selbstservice für Sprach- und Textanfragen, der 24/7 verfügbar ist.", - "value": "" - }, - { - "context": "ui", - "key": "Geben Sie den Inhalt des Prompts ein", - "value": "" - }, - { - "context": "ui", - "key": "Geben Sie einen Namen für den Prompt ein", - "value": "" - }, - { - "context": "ui", - "key": "Geben Sie einen benutzerdefinierten Titel ein", - "value": "" - }, - { - "context": "ui", - "key": "Geplante und automatisierte Workflows", - "value": "" - }, - { - "context": "ui", - "key": "Geschäftszeiten", - "value": "" - }, - { - "context": "ui", - "key": "Geschäftszeiten & Zeitzone", - "value": "" - }, - { - "context": "ui", - "key": "Gespräch fortsetzen...", - "value": "" - }, - { - "context": "ui", - "key": "Gestartet", - "value": "" - }, - { - "context": "ui", - "key": "Gestartet:", - "value": "" - }, - { - "context": "ui", - "key": "Gestoppt", - "value": "" - }, - { - "context": "ui", - "key": "Geteilt", - "value": "" - }, - { - "context": "ui", - "key": "Geteilte Dateien", - "value": "" - }, - { - "context": "ui", - "key": "Globale Sprachsets verwalten (SysAdmin).", - "value": "" - }, - { - "context": "ui", - "key": "Google", - "value": "" - }, - { - "context": "ui", - "key": "Google verbinden", - "value": "" - }, - { - "context": "ui", - "key": "Google-Verbindung erstellen", - "value": "" - }, - { - "context": "ui", - "key": "Google-Verbindung hinzufügen", - "value": "" - }, - { - "context": "ui", - "key": "Grundlegende Daten und Ressourcen", - "value": "" - }, - { - "context": "ui", - "key": "Größe", - "value": "" - }, - { - "context": "ui", - "key": "Hell", - "value": "" - }, - { - "context": "ui", - "key": "Herunterladen", - "value": "" - }, - { - "context": "ui", - "key": "Hinzufügen", - "value": "" - }, - { - "context": "ui", - "key": "Hochgeladen", - "value": "" - }, - { - "context": "ui", - "key": "Hochladen", - "value": "" - }, - { - "context": "ui", - "key": "ID", - "value": "" - }, - { - "context": "ui", - "key": "INFO", - "value": "" - }, - { - "context": "ui", - "key": "Identifizieren und authentifizieren Sie Anrufer in Sekunden mit kontinuierlicher Verifizierung und Sicherheit.", - "value": "" - }, - { - "context": "ui", - "key": "Ihre Anfrage wird verarbeitet...", - "value": "" - }, - { - "context": "ui", - "key": "In die Zwischenablage kopiert", - "value": "" - }, - { - "context": "ui", - "key": "Inaktiv", - "value": "" - }, - { - "context": "ui", - "key": "Information", - "value": "" - }, - { - "context": "ui", - "key": "Inhalt", - "value": "" - }, - { - "context": "ui", - "key": "Inhalt ist erforderlich", - "value": "" - }, - { - "context": "ui", - "key": "Ja", - "value": "" - }, - { - "context": "ui", - "key": "Jetzt anmelden", - "value": "" - }, - { - "context": "ui", - "key": "Jetzt überspringen", - "value": "" - }, - { - "context": "ui", - "key": "KI-erstellt", - "value": "" - }, - { - "context": "ui", - "key": "KI-gestützte Dokumentengenerierung:", - "value": "" - }, - { - "context": "ui", - "key": "Kein Auth-Anbieter", - "value": "" - }, - { - "context": "ui", - "key": "Kein Benutzername", - "value": "" - }, - { - "context": "ui", - "key": "Kein Nachrichteninhalt verfügbar", - "value": "" - }, - { - "context": "ui", - "key": "Kein Name", - "value": "" - }, - { - "context": "ui", - "key": "Kein Workflow ausgewählt", - "value": "" - }, - { - "context": "ui", - "key": "Keine", - "value": "" - }, - { - "context": "ui", - "key": "Keine Benutzer verfügbar", - "value": "" - }, - { - "context": "ui", - "key": "Keine Berechtigung", - "value": "" - }, - { - "context": "ui", - "key": "Keine Berechtigung zum Löschen des Prompts", - "value": "" - }, - { - "context": "ui", - "key": "Keine Dateien gefunden.", - "value": "" - }, - { - "context": "ui", - "key": "Keine Daten verfügbar", - "value": "" - }, - { - "context": "ui", - "key": "Keine E-Mail", - "value": "" - }, - { - "context": "ui", - "key": "Keine Einträge", - "value": "" - }, - { - "context": "ui", - "key": "Keine Logs für diesen Workflow verfügbar", - "value": "" - }, - { - "context": "ui", - "key": "Keine Microsoft-Verbindungen gefunden. Bitte erstellen Sie zuerst eine Verbindung.", - "value": "" - }, - { - "context": "ui", - "key": "Keine Optionen verfügbar", - "value": "" - }, - { - "context": "ui", - "key": "Keine Prompts verfügbar", - "value": "" - }, - { - "context": "ui", - "key": "Keine SharePoint-Sites gefunden", - "value": "" - }, - { - "context": "ui", - "key": "Keine Sprach-Integrations-Daten gefunden. Bitte melden Sie sich zuerst an, um auf die Einstellungen zuzugreifen.", - "value": "" - }, - { - "context": "ui", - "key": "Keine Sprache", - "value": "" - }, - { - "context": "ui", - "key": "Keine Transkripte vorhanden", - "value": "" - }, - { - "context": "ui", - "key": "Keine Vorschau verfügbar", - "value": "" - }, - { - "context": "ui", - "key": "Keine Workflows gefunden", - "value": "" - }, - { - "context": "ui", - "key": "Keine Workflows verfügbar", - "value": "" - }, - { - "context": "ui", - "key": "Keine hochgeladenen Dateien gefunden.", - "value": "" - }, - { - "context": "ui", - "key": "Keine mit Ihnen geteilten Dateien gefunden.", - "value": "" - }, - { - "context": "ui", - "key": "Keine von der KI erstellten Dateien gefunden.", - "value": "" - }, - { - "context": "ui", - "key": "Klicken Sie erneut zum Bestätigen", - "value": "" - }, - { - "context": "ui", - "key": "Klicken Sie erneut zum Bestätigen der Löschung", - "value": "" - }, - { - "context": "ui", - "key": "Klicken Sie, um zu öffnen", - "value": "" - }, - { - "context": "ui", - "key": "Knowledge Agent (KA)", - "value": "" - }, - { - "context": "ui", - "key": "Konfigurieren Sie administrative Einstellungen und Systempräferenzen.", - "value": "" - }, - { - "context": "ui", - "key": "Konfigurieren und verwalten Sie rollenbasierte Zugriffssteuerungsregeln.", - "value": "" - }, - { - "context": "ui", - "key": "Kontakte einrichten", - "value": "" - }, - { - "context": "ui", - "key": "Kontaktinformationen", - "value": "" - }, - { - "context": "ui", - "key": "Kontostatus", - "value": "" - }, - { - "context": "ui", - "key": "Kopieren", - "value": "" - }, - { - "context": "ui", - "key": "Kopiert", - "value": "" - }, - { - "context": "ui", - "key": "Kosteneinsparungen & Effizienz:", - "value": "" - }, - { - "context": "ui", - "key": "Kundenverträge verwalten", - "value": "" - }, - { - "context": "ui", - "key": "Lade Filterwerte...", - "value": "" - }, - { - "context": "ui", - "key": "Lade Fortschritt...", - "value": "" - }, - { - "context": "ui", - "key": "Laden...", - "value": "" - }, - { - "context": "ui", - "key": "Land", - "value": "" - }, - { - "context": "ui", - "key": "Land ist erforderlich", - "value": "" - }, - { - "context": "ui", - "key": "Leer = Zugriff auf alle Verträge", - "value": "" - }, - { - "context": "ui", - "key": "Letzte Aktivität", - "value": "" - }, - { - "context": "ui", - "key": "Letzte Aktivität:", - "value": "" - }, - { - "context": "ui", - "key": "Letzte Aktivitäten - Sehen Sie Ihre neueste Arbeit", - "value": "" - }, - { - "context": "ui", - "key": "Letzte Seite", - "value": "" - }, - { - "context": "ui", - "key": "Link konnte nicht gesendet werden", - "value": "" - }, - { - "context": "ui", - "key": "Log", - "value": "" - }, - { - "context": "ui", - "key": "Logs konnten nicht geladen werden", - "value": "" - }, - { - "context": "ui", - "key": "Logs werden geladen...", - "value": "" - }, - { - "context": "ui", - "key": "Lokal", - "value": "" - }, - { - "context": "ui", - "key": "LÄUFT", - "value": "" - }, - { - "context": "ui", - "key": "Lädt hoch...", - "value": "" - }, - { - "context": "ui", - "key": "Läuft", - "value": "" - }, - { - "context": "ui", - "key": "Läuft ab am", - "value": "" - }, - { - "context": "ui", - "key": "Löschen", - "value": "" - }, - { - "context": "ui", - "key": "Löschen ({count})", - "value": "" - }, - { - "context": "ui", - "key": "Löschen...", - "value": "" - }, - { - "context": "ui", - "key": "MIME-Typ", - "value": "" - }, - { - "context": "ui", - "key": "Management-Tools umfassen:", - "value": "" - }, - { - "context": "ui", - "key": "Mandanten können jederzeit auf die technische SIP-Nummer umstellen und dabei erhebliche Telefoniekosten sparen. Die Integration funktioniert wie ein weiterer Connector (Outlook, SharePoint) und wird nahtlos in Ihren bestehenden Workflow integriert.", - "value": "" - }, - { - "context": "ui", - "key": "Mandat erfolgreich eingereicht!", - "value": "" - }, - { - "context": "ui", - "key": "Mandat erfolgreich erstellt", - "value": "" - }, - { - "context": "ui", - "key": "Mandat erstellen", - "value": "" - }, - { - "context": "ui", - "key": "Mandat hinzufügen", - "value": "" - }, - { - "context": "ui", - "key": "Mandat-ID", - "value": "" - }, - { - "context": "ui", - "key": "Mandate", - "value": "" - }, - { - "context": "ui", - "key": "Mandate und Berechtigungen verwalten", - "value": "" - }, - { - "context": "ui", - "key": "Mandatsverwaltung", - "value": "" - }, - { - "context": "ui", - "key": "Mehr erfahren", - "value": "" - }, - { - "context": "ui", - "key": "Meine Uploads", - "value": "" - }, - { - "context": "ui", - "key": "Microsoft", - "value": "" - }, - { - "context": "ui", - "key": "Microsoft Verbindungen", - "value": "" - }, - { - "context": "ui", - "key": "Microsoft verbinden", - "value": "" - }, - { - "context": "ui", - "key": "Microsoft-Verbindung erstellen", - "value": "" - }, - { - "context": "ui", - "key": "Microsoft-Verbindung hinzufügen", - "value": "" - }, - { - "context": "ui", - "key": "Mitglied hinzufügen", - "value": "" - }, - { - "context": "ui", - "key": "MwSt %", - "value": "" - }, - { - "context": "ui", - "key": "MwSt Betrag", - "value": "" - }, - { - "context": "ui", - "key": "Möchten Sie jetzt Kontakte für Ihr Mandat einrichten? Sie können dies auch später in den Einstellungen tun.", - "value": "" - }, - { - "context": "ui", - "key": "Nach unten scrollen", - "value": "" - }, - { - "context": "ui", - "key": "Nachricht (optional)", - "value": "" - }, - { - "context": "ui", - "key": "Nachricht eingeben...", - "value": "" - }, - { - "context": "ui", - "key": "Nachricht wird gesendet...", - "value": "" - }, - { - "context": "ui", - "key": "Nachrichten", - "value": "" - }, - { - "context": "ui", - "key": "Nahtloser Mandanten-Workflow:", - "value": "" - }, - { - "context": "ui", - "key": "Name", - "value": "" - }, - { - "context": "ui", - "key": "Name des Unternehmens", - "value": "" - }, - { - "context": "ui", - "key": "Name ist erforderlich", - "value": "" - }, - { - "context": "ui", - "key": "Navigation - Erkunden Sie alle verfügbaren Tools", - "value": "" - }, - { - "context": "ui", - "key": "Nein", - "value": "" - }, - { - "context": "ui", - "key": "Neu starten", - "value": "" - }, - { - "context": "ui", - "key": "Neue Automatisierung", - "value": "" - }, - { - "context": "ui", - "key": "Neue Automatisierung erstellen", - "value": "" - }, - { - "context": "ui", - "key": "Neue Datei hochladen", - "value": "" - }, - { - "context": "ui", - "key": "Neue Organisation", - "value": "" - }, - { - "context": "ui", - "key": "Neue Organisation erstellen", - "value": "" - }, - { - "context": "ui", - "key": "Neue Position", - "value": "" - }, - { - "context": "ui", - "key": "Neue Position erstellen", - "value": "" - }, - { - "context": "ui", - "key": "Neue RBAC-Regel erstellen", - "value": "" - }, - { - "context": "ui", - "key": "Neue Rolle", - "value": "" - }, - { - "context": "ui", - "key": "Neue Rolle erstellen", - "value": "" - }, - { - "context": "ui", - "key": "Neue Sprache", - "value": "" - }, - { - "context": "ui", - "key": "Neuen Prompt erstellen", - "value": "" - }, - { - "context": "ui", - "key": "Neuen Vertrag erstellen", - "value": "" - }, - { - "context": "ui", - "key": "Neuen Zugriff erstellen", - "value": "" - }, - { - "context": "ui", - "key": "Neuer Prompt", - "value": "" - }, - { - "context": "ui", - "key": "Neuer Vertrag", - "value": "" - }, - { - "context": "ui", - "key": "Neuer Zugriff", - "value": "" - }, - { - "context": "ui", - "key": "Neues Dokument", - "value": "" - }, - { - "context": "ui", - "key": "Neues Dokument erstellen", - "value": "" - }, - { - "context": "ui", - "key": "Neues Mandat erstellen", - "value": "" - }, - { - "context": "ui", - "key": "Neues Team-Mitglied erstellen", - "value": "" - }, - { - "context": "ui", - "key": "Neues Transkript", - "value": "" - }, - { - "context": "ui", - "key": "Nicht verfügbar", - "value": "" - }, - { - "context": "ui", - "key": "Noch keine Befehle ausgeführt. Senden Sie einen Befehl, um Ergebnisse hier zu sehen.", - "value": "" - }, - { - "context": "ui", - "key": "Noch keinen Workflow ausgewählt", - "value": "" - }, - { - "context": "ui", - "key": "Nochmal versuchen", - "value": "" - }, - { - "context": "ui", - "key": "Nächste Seite", - "value": "" - }, - { - "context": "ui", - "key": "Oder geben Sie Ihre Nachricht ein...", - "value": "" - }, - { - "context": "ui", - "key": "Ordnerpfade", - "value": "" - }, - { - "context": "ui", - "key": "Organisation", - "value": "" - }, - { - "context": "ui", - "key": "Organisation erfolgreich erstellt", - "value": "" - }, - { - "context": "ui", - "key": "Organisationen", - "value": "" - }, - { - "context": "ui", - "key": "Originalbetrag", - "value": "" - }, - { - "context": "ui", - "key": "Originalwährung", - "value": "" - }, - { - "context": "ui", - "key": "PDF", - "value": "" - }, - { - "context": "ui", - "key": "Passwort", - "value": "" - }, - { - "context": "ui", - "key": "Passwort eingeben", - "value": "" - }, - { - "context": "ui", - "key": "Passwort-Link gesendet!", - "value": "" - }, - { - "context": "ui", - "key": "Passwort-Link senden", - "value": "" - }, - { - "context": "ui", - "key": "Pfad", - "value": "" - }, - { - "context": "ui", - "key": "Position erfolgreich erstellt", - "value": "" - }, - { - "context": "ui", - "key": "Positionen", - "value": "" - }, - { - "context": "ui", - "key": "Postleitzahl", - "value": "" - }, - { - "context": "ui", - "key": "Postleitzahl ist erforderlich", - "value": "" - }, - { - "context": "ui", - "key": "Projekte", - "value": "" - }, - { - "context": "ui", - "key": "Projektverwaltung", - "value": "" - }, - { - "context": "ui", - "key": "Projektverwaltung und -organisation", - "value": "" - }, - { - "context": "ui", - "key": "Prompt", - "value": "" - }, - { - "context": "ui", - "key": "Prompt Einstellungen", - "value": "" - }, - { - "context": "ui", - "key": "Prompt Vorlage", - "value": "" - }, - { - "context": "ui", - "key": "Prompt ausführen", - "value": "" - }, - { - "context": "ui", - "key": "Prompt auswählen...", - "value": "" - }, - { - "context": "ui", - "key": "Prompt bearbeiten", - "value": "" - }, - { - "context": "ui", - "key": "Prompt erfolgreich erstellt", - "value": "" - }, - { - "context": "ui", - "key": "Prompt erstellen", - "value": "" - }, - { - "context": "ui", - "key": "Prompt hinzufügen", - "value": "" - }, - { - "context": "ui", - "key": "Prompt löschen", - "value": "" - }, - { - "context": "ui", - "key": "Prompt teilen", - "value": "" - }, - { - "context": "ui", - "key": "Prompt wird gelöscht...", - "value": "" - }, - { - "context": "ui", - "key": "Prompt-Inhalt", - "value": "" - }, - { - "context": "ui", - "key": "Prompt-Inhalt darf 10.000 Zeichen nicht überschreiten", - "value": "" - }, - { - "context": "ui", - "key": "Prompt-Inhalt darf nicht leer sein", - "value": "" - }, - { - "context": "ui", - "key": "Prompt-Name", - "value": "" - }, - { - "context": "ui", - "key": "Prompt-Name darf 100 Zeichen nicht überschreiten", - "value": "" - }, - { - "context": "ui", - "key": "Prompt-Name darf nicht leer sein", - "value": "" - }, - { - "context": "ui", - "key": "Prompts", - "value": "" - }, - { - "context": "ui", - "key": "Prompts für Ihren KI-Assistenten erstellen und verwalten", - "value": "" - }, - { - "context": "ui", - "key": "Prompts verwalten", - "value": "" - }, - { - "context": "ui", - "key": "Prompts werden geladen...", - "value": "" - }, - { - "context": "ui", - "key": "Python", - "value": "" - }, - { - "context": "ui", - "key": "Quelle", - "value": "" - }, - { - "context": "ui", - "key": "RBAC-Regel erfolgreich erstellt", - "value": "" - }, - { - "context": "ui", - "key": "RBAC-Regel hinzufügen", - "value": "" - }, - { - "context": "ui", - "key": "RBAC-Regeln", - "value": "" - }, - { - "context": "ui", - "key": "RBAC-Regelverwaltung", - "value": "" - }, - { - "context": "ui", - "key": "RBAC-Rollen", - "value": "" - }, - { - "context": "ui", - "key": "RBAC-Rollenverwaltung", - "value": "" - }, - { - "context": "ui", - "key": "Registrieren", - "value": "" - }, - { - "context": "ui", - "key": "Revolutionäre Telefonie-Integration mit Spitch.ai", - "value": "" - }, - { - "context": "ui", - "key": "Rohtext in die Zwischenablage kopieren", - "value": "" - }, - { - "context": "ui", - "key": "Rolle", - "value": "" - }, - { - "context": "ui", - "key": "Rolle erfolgreich erstellt", - "value": "" - }, - { - "context": "ui", - "key": "Rolle hinzufügen", - "value": "" - }, - { - "context": "ui", - "key": "Rollen", - "value": "" - }, - { - "context": "ui", - "key": "Rollen-ID", - "value": "" - }, - { - "context": "ui", - "key": "Rollenbasierte Zugriffssteuerungsregeln", - "value": "" - }, - { - "context": "ui", - "key": "Rollenverwaltung", - "value": "" - }, - { - "context": "ui", - "key": "Rufname am Telefon", - "value": "" - }, - { - "context": "ui", - "key": "Runde", - "value": "" - }, - { - "context": "ui", - "key": "Runden", - "value": "" - }, - { - "context": "ui", - "key": "Schließen", - "value": "" - }, - { - "context": "ui", - "key": "Schnellzugriff", - "value": "" - }, - { - "context": "ui", - "key": "Schnellzugriff - Springen Sie zu häufig verwendeten Features", - "value": "" - }, - { - "context": "ui", - "key": "Seite", - "value": "" - }, - { - "context": "ui", - "key": "Seite {page} von {total} ({count} Einträge)", - "value": "" - }, - { - "context": "ui", - "key": "Senden", - "value": "" - }, - { - "context": "ui", - "key": "Service", - "value": "" - }, - { - "context": "ui", - "key": "Service-Verbindungen", - "value": "" - }, - { - "context": "ui", - "key": "SharePoint Dokumente", - "value": "" - }, - { - "context": "ui", - "key": "SharePoint Site URL", - "value": "" - }, - { - "context": "ui", - "key": "SharePoint Test", - "value": "" - }, - { - "context": "ui", - "key": "Sie erhalten in den nächsten Minuten eine Bestätigungs-E-Mail.", - "value": "" - }, - { - "context": "ui", - "key": "Sie können auch auf den Upload-Button klicken", - "value": "" - }, - { - "context": "ui", - "key": "Sie müssen sich zuerst für die Sprach-Integration anmelden, um auf die Transkriptverwaltung zuzugreifen.", - "value": "" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie \"{name}\" löschen möchten?", - "value": "" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie Workflow \"{id}...\" löschen möchten?", - "value": "" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie alle Sprach-Integrations-Einstellungen zurücksetzen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", - "value": "" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie das ausgewählte Element löschen möchten?", - "value": "" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie den Workflow \"{name}\" löschen möchten?", - "value": "" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie die Datei \"{name}\" löschen möchten?", - "value": "" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie die {count} ausgewählten Elemente löschen möchten?", - "value": "" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie die {service} Verbindung löschen möchten?", - "value": "" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie diesen Benutzer löschen möchten?", - "value": "" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie {count} Benutzer löschen möchten?", - "value": "" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie {count} Prompts löschen möchten?", - "value": "" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie {count} Verbindungen löschen möchten?", - "value": "" - }, - { - "context": "ui", - "key": "Sites entdecken", - "value": "" - }, - { - "context": "ui", - "key": "Sortierung {position}: {direction}", - "value": "" - }, - { - "context": "ui", - "key": "Speech Analytics (SA)", - "value": "" - }, - { - "context": "ui", - "key": "Speichern", - "value": "" - }, - { - "context": "ui", - "key": "Speichern...", - "value": "" - }, - { - "context": "ui", - "key": "Spitch prüft vor jedem Anruf die Mandantenberechtigung bei PowerOn, während alle Datenänderungen zentral von PowerOn initiiert werden. Call-Transkripte werden in Echtzeit in Ihrer PowerOn-Datenbank gespeichert, mit vollständiger Mandantenisolation und Sicherheit. Bei Ausfällen werden Anrufe automatisch blockiert, um die Integrität zu gewährleisten.", - "value": "" - }, - { - "context": "ui", - "key": "Sprach Integration", - "value": "" - }, - { - "context": "ui", - "key": "Sprach-Einstellungen", - "value": "" - }, - { - "context": "ui", - "key": "Sprach-Integration Einstellungen", - "value": "" - }, - { - "context": "ui", - "key": "Sprache", - "value": "" - }, - { - "context": "ui", - "key": "Sprachset {code} wirklich löschen?", - "value": "" - }, - { - "context": "ui", - "key": "Stadt", - "value": "" - }, - { - "context": "ui", - "key": "Stadt ist erforderlich", - "value": "" - }, - { - "context": "ui", - "key": "Start", - "value": "" - }, - { - "context": "ui", - "key": "Startzeit", - "value": "" - }, - { - "context": "ui", - "key": "Status", - "value": "" - }, - { - "context": "ui", - "key": "Stellen Sie alles, was Ihre Agenten benötigen, in ihren Händen bereit, mit einem einheitlichen Agent-Desktop.", - "value": "" - }, - { - "context": "ui", - "key": "Stoppen", - "value": "" - }, - { - "context": "ui", - "key": "Straße", - "value": "" - }, - { - "context": "ui", - "key": "Straße ist erforderlich", - "value": "" - }, - { - "context": "ui", - "key": "Suchen Sie nach Standorten über Adresse oder Koordinaten, oder verwenden Sie natürliche Sprache, um Projekte zu erstellen und zu verwalten.", - "value": "" - }, - { - "context": "ui", - "key": "Suchen...", - "value": "" - }, - { - "context": "ui", - "key": "Systemadministrator", - "value": "" - }, - { - "context": "ui", - "key": "Systemeinstellungen - Arbeitsbereich-Einstellungen konfigurieren", - "value": "" - }, - { - "context": "ui", - "key": "Tabelle", - "value": "" - }, - { - "context": "ui", - "key": "Tags", - "value": "" - }, - { - "context": "ui", - "key": "Team-Bereich", - "value": "" - }, - { - "context": "ui", - "key": "Team-Mitglied erfolgreich erstellt", - "value": "" - }, - { - "context": "ui", - "key": "Team-Mitglieder", - "value": "" - }, - { - "context": "ui", - "key": "Team-Mitglieder verwalten", - "value": "" - }, - { - "context": "ui", - "key": "Team-Mitglieder verwalten, Berechtigungen festlegen und Zusammenarbeitseinstellungen konfigurieren", - "value": "" - }, - { - "context": "ui", - "key": "Teilen", - "value": "" - }, - { - "context": "ui", - "key": "Telefon", - "value": "" - }, - { - "context": "ui", - "key": "Telefonnummer", - "value": "" - }, - { - "context": "ui", - "key": "Telefonnummer ist erforderlich", - "value": "" - }, - { - "context": "ui", - "key": "Text", - "value": "" - }, - { - "context": "ui", - "key": "Textvorschau", - "value": "" - }, - { - "context": "ui", - "key": "Theme", - "value": "" - }, - { - "context": "ui", - "key": "Token", - "value": "" - }, - { - "context": "ui", - "key": "Transkript", - "value": "" - }, - { - "context": "ui", - "key": "Transkript wird verarbeitet...", - "value": "" - }, - { - "context": "ui", - "key": "Transkriptverwaltung", - "value": "" - }, - { - "context": "ui", - "key": "Trennungsfehler", - "value": "" - }, - { - "context": "ui", - "key": "Treuhand", - "value": "" - }, - { - "context": "ui", - "key": "Treuhandverwaltung", - "value": "" - }, - { - "context": "ui", - "key": "Trustee-Organisationen verwalten", - "value": "" - }, - { - "context": "ui", - "key": "Trustee-Rollen verwalten", - "value": "" - }, - { - "context": "ui", - "key": "Typ", - "value": "" - }, - { - "context": "ui", - "key": "UI-Sprachen", - "value": "" - }, - { - "context": "ui", - "key": "Unbekannt", - "value": "" - }, - { - "context": "ui", - "key": "Unbekannte Größe", - "value": "" - }, - { - "context": "ui", - "key": "Unbekanntes Datum", - "value": "" - }, - { - "context": "ui", - "key": "Unbenannt", - "value": "" - }, - { - "context": "ui", - "key": "Unbenannter Workflow", - "value": "" - }, - { - "context": "ui", - "key": "Ungültige Auswahl", - "value": "" - }, - { - "context": "ui", - "key": "Ungültige URL", - "value": "" - }, - { - "context": "ui", - "key": "Ungültiges Datum", - "value": "" - }, - { - "context": "ui", - "key": "Ungültiges Datumsformat", - "value": "" - }, - { - "context": "ui", - "key": "Ungültiges E-Mail-Format", - "value": "" - }, - { - "context": "ui", - "key": "Ungültiges JSON", - "value": "" - }, - { - "context": "ui", - "key": "Unser Team wird Ihr Mandat innerhalb von 1-2 Werktagen überprüfen.", - "value": "" - }, - { - "context": "ui", - "key": "Unsere bereits aktive Dokumenten-Extraktions-Engine generiert automatisch personalisierte Dokumente für Spitch, basierend auf Mandantenspezifischen Daten. Die KI nutzt FAQ-Datenbanken, Mitarbeiterinformationen und Service-Details, um jeden Anruf kontextuell und hochpersonalisiert zu gestalten.", - "value": "" - }, - { - "context": "ui", - "key": "Unternehmensinformationen", - "value": "" - }, - { - "context": "ui", - "key": "Unterstützt von", - "value": "" - }, - { - "context": "ui", - "key": "Upload fehlgeschlagen. Bitte versuchen Sie es erneut.", - "value": "" - }, - { - "context": "ui", - "key": "VERARBEITUNG", - "value": "" - }, - { - "context": "ui", - "key": "Valutadatum", - "value": "" - }, - { - "context": "ui", - "key": "Verarbeitung", - "value": "" - }, - { - "context": "ui", - "key": "Verbinden", - "value": "" - }, - { - "context": "ui", - "key": "Verbindung aktualisieren", - "value": "" - }, - { - "context": "ui", - "key": "Verbindung testen", - "value": "" - }, - { - "context": "ui", - "key": "Verbindungen", - "value": "" - }, - { - "context": "ui", - "key": "Verbindungen werden geladen...", - "value": "" - }, - { - "context": "ui", - "key": "Verbindungsfehler", - "value": "" - }, - { - "context": "ui", - "key": "Verbunden am", - "value": "" - }, - { - "context": "ui", - "key": "Vereinheitlichen und liefern Sie Informationen an Ihre Kunden und Mitarbeiter, wann und wo sie sie benötigen.", - "value": "" - }, - { - "context": "ui", - "key": "Verfügbare Tools", - "value": "" - }, - { - "context": "ui", - "key": "Verfügbare Workflows", - "value": "" - }, - { - "context": "ui", - "key": "Version", - "value": "" - }, - { - "context": "ui", - "key": "Versuchen Sie, Ihr Microsoft-Konto auf der Verbindungsseite erneut zu verbinden.", - "value": "" - }, - { - "context": "ui", - "key": "Vertrag", - "value": "" - }, - { - "context": "ui", - "key": "Vertrag (optional)", - "value": "" - }, - { - "context": "ui", - "key": "Vertrag erfolgreich erstellt", - "value": "" - }, - { - "context": "ui", - "key": "Verträge", - "value": "" - }, - { - "context": "ui", - "key": "Verwalten Sie Daten über Tabellen. Wählen Sie eine Tabelle aus oder verwenden Sie natürliche Sprache, um Befehle auszuführen.", - "value": "" - }, - { - "context": "ui", - "key": "Verwalten Sie Ihre Kontoinformationen", - "value": "" - }, - { - "context": "ui", - "key": "Verwalten Sie Ihre Service-Verbindungen", - "value": "" - }, - { - "context": "ui", - "key": "Verwalten Sie Ihre Sprach-Integrations-Konfiguration und Einstellungen.", - "value": "" - }, - { - "context": "ui", - "key": "Verwalten Sie Mandate und deren zugehörige Berechtigungen.", - "value": "" - }, - { - "context": "ui", - "key": "Verwaltet von {provider}", - "value": "" - }, - { - "context": "ui", - "key": "Verwaltung der Benutzerzugriffe auf Organisationen", - "value": "" - }, - { - "context": "ui", - "key": "Verwaltung der Buchungspositionen (Speseneinträge)", - "value": "" - }, - { - "context": "ui", - "key": "Verwaltung der Dokumente und Belege", - "value": "" - }, - { - "context": "ui", - "key": "Verwaltung der Feature-spezifischen Rollen", - "value": "" - }, - { - "context": "ui", - "key": "Verwaltung der Kundenverträge", - "value": "" - }, - { - "context": "ui", - "key": "Verwaltung der Treuhand-Organisationen", - "value": "" - }, - { - "context": "ui", - "key": "Verwaltung von Treuhand-Organisationen, Verträgen und Buchungen", - "value": "" - }, - { - "context": "ui", - "key": "Verwaltungs- und Management-Tools", - "value": "" - }, - { - "context": "ui", - "key": "Verwende Vorlage:", - "value": "" - }, - { - "context": "ui", - "key": "Video", - "value": "" - }, - { - "context": "ui", - "key": "Vielen Dank für Ihr Interesse an unserer Sprach Integration powered by Spitch.ai. Wir haben Ihr Mandat erhalten und werden es in Kürze überprüfen.", - "value": "" - }, - { - "context": "ui", - "key": "Virtual Assistant (VA)", - "value": "" - }, - { - "context": "ui", - "key": "Voice Biometrics (VB)", - "value": "" - }, - { - "context": "ui", - "key": "Vollständiger Name", - "value": "" - }, - { - "context": "ui", - "key": "Von", - "value": "" - }, - { - "context": "ui", - "key": "Von der Registrierung bis zur technischen Einrichtung - Ihr Mandant registriert sich bei PowerOn für Telefonie-Services, lädt Dokumente hoch und erhält automatisch eine technische SIP-Nummer von Spitch. Die Call-Weiterleitung kann jederzeit aktiviert oder deaktiviert werden, was maximale Flexibilität und BCM-Sicherheit gewährleistet.", - "value": "" - }, - { - "context": "ui", - "key": "Vorherige Seite", - "value": "" - }, - { - "context": "ui", - "key": "Vorschau", - "value": "" - }, - { - "context": "ui", - "key": "Vorschau für diesen Dateityp nicht verfügbar", - "value": "" - }, - { - "context": "ui", - "key": "Vorschau schließen", - "value": "" - }, - { - "context": "ui", - "key": "Vorschau wird geladen...", - "value": "" - }, - { - "context": "ui", - "key": "WARTEND", - "value": "" - }, - { - "context": "ui", - "key": "Wartend", - "value": "" - }, - { - "context": "ui", - "key": "Was passiert als nächstes?", - "value": "" - }, - { - "context": "ui", - "key": "Wechseln Sie zwischen hellem und dunklem Modus", - "value": "" - }, - { - "context": "ui", - "key": "Werkzeuge", - "value": "" - }, - { - "context": "ui", - "key": "Werkzeuge und Hilfsmittel", - "value": "" - }, - { - "context": "ui", - "key": "Wie möchten Sie am Telefon genannt werden?", - "value": "" - }, - { - "context": "ui", - "key": "Wiederholen", - "value": "" - }, - { - "context": "ui", - "key": "Willkommen in Ihrem Arbeitsbereich", - "value": "" - }, - { - "context": "ui", - "key": "Wird gesendet...", - "value": "" - }, - { - "context": "ui", - "key": "Wird gestoppt...", - "value": "" - }, - { - "context": "ui", - "key": "Wird geteilt...", - "value": "" - }, - { - "context": "ui", - "key": "Wird hochgeladen...", - "value": "" - }, - { - "context": "ui", - "key": "Wird verarbeitet...", - "value": "" - }, - { - "context": "ui", - "key": "Workflow", - "value": "" - }, - { - "context": "ui", - "key": "Workflow Fortschritt", - "value": "" - }, - { - "context": "ui", - "key": "Workflow auswählen", - "value": "" - }, - { - "context": "ui", - "key": "Workflow fehlgeschlagen.", - "value": "" - }, - { - "context": "ui", - "key": "Workflow fortsetzen", - "value": "" - }, - { - "context": "ui", - "key": "Workflow läuft... Warte auf Logs...", - "value": "" - }, - { - "context": "ui", - "key": "Workflow löschen", - "value": "" - }, - { - "context": "ui", - "key": "Workflow stoppen", - "value": "" - }, - { - "context": "ui", - "key": "Workflow wird fortgesetzt", - "value": "" - }, - { - "context": "ui", - "key": "Workflow wird gelöscht...", - "value": "" - }, - { - "context": "ui", - "key": "Workflow-Automatisierungen verwalten", - "value": "" - }, - { - "context": "ui", - "key": "Workflow-Nachrichten werden geladen...", - "value": "" - }, - { - "context": "ui", - "key": "Workflow-Verlauf", - "value": "" - }, - { - "context": "ui", - "key": "Workflows", - "value": "" - }, - { - "context": "ui", - "key": "Workflows werden geladen...", - "value": "" - }, - { - "context": "ui", - "key": "Wähle einen Workflow aus der Liste aus oder starte einen neuen Workflow", - "value": "" - }, - { - "context": "ui", - "key": "Wählen Sie Ihre bevorzugte Sprache", - "value": "" - }, - { - "context": "ui", - "key": "You", - "value": "" - }, - { - "context": "ui", - "key": "Zeitzone", - "value": "" - }, - { - "context": "ui", - "key": "Zentrale", - "value": "" - }, - { - "context": "ui", - "key": "Zu dunklem Modus wechseln", - "value": "" - }, - { - "context": "ui", - "key": "Zu hellem Modus wechseln", - "value": "" - }, - { - "context": "ui", - "key": "Zugriff", - "value": "" - }, - { - "context": "ui", - "key": "Zugriff erfolgreich erstellt", - "value": "" - }, - { - "context": "ui", - "key": "Zugriff verweigert", - "value": "" - }, - { - "context": "ui", - "key": "Zuletzt geprüft", - "value": "" - }, - { - "context": "ui", - "key": "Zum Bestätigen klicken", - "value": "" - }, - { - "context": "ui", - "key": "Zum Bestätigen klicken...", - "value": "" - }, - { - "context": "ui", - "key": "Zum Ein-/Ausklappen klicken", - "value": "" - }, - { - "context": "ui", - "key": "Zum Filtern klicken", - "value": "" - }, - { - "context": "ui", - "key": "Zum Sortieren klicken", - "value": "" - }, - { - "context": "ui", - "key": "Zurück zur Sprach Integration", - "value": "" - }, - { - "context": "ui", - "key": "angehängt", - "value": "" - }, - { - "context": "ui", - "key": "ausgewählt", - "value": "" - }, - { - "context": "ui", - "key": "k. A.", - "value": "" - }, - { - "context": "ui", - "key": "kontakt@firma.com", - "value": "" - }, - { - "context": "ui", - "key": "oder", - "value": "" - }, - { - "context": "ui", - "key": "z.B. Beleg.pdf", - "value": "" - }, - { - "context": "ui", - "key": "z.B. Finanzdienstleistungen, Technologie, etc.", - "value": "" - }, - { - "context": "ui", - "key": "z.B. Muster AG 2026", - "value": "" - }, - { - "context": "ui", - "key": "z.B. Treuhand AG Zürich", - "value": "" - }, - { - "context": "ui", - "key": "z.B. admin, operate, userreport", - "value": "" - }, - { - "context": "ui", - "key": "z.B. treuhand-ag-zuerich", - "value": "" - }, - { - "context": "ui", - "key": "{authority} Verbindung bearbeiten", - "value": "" - }, - { - "context": "ui", - "key": "{column} filtern", - "value": "" - }, - { - "context": "ui", - "key": "{count} Benutzer ausgewählt", - "value": "" - }, - { - "context": "ui", - "key": "{fieldLabel} ist erforderlich", - "value": "" - }, - { - "context": "ui", - "key": "{fieldLabel} muss eine gültige Ganzzahl sein", - "value": "" - }, - { - "context": "ui", - "key": "{fieldLabel} muss eine gültige Zahl sein", - "value": "" - }, - { - "context": "ui", - "key": "Änderungen speichern", - "value": "" - }, - { - "context": "ui", - "key": "Über", - "value": "" - }, - { - "context": "ui", - "key": "Überprüfungsprozess", - "value": "" - }, - { - "context": "ui", - "key": "Übersicht - Sehen Sie den Arbeitsbereich-Status und Updates", - "value": "" - }, - { - "context": "ui", - "key": "Überwachen Sie automatisch 100% der Gespräche, um wertvolle Einblicke für Ihr Unternehmen zu erhalten.", - "value": "" - }, - { - "context": "ui", - "key": "(gefiltert nach {name})", - "value": "" - }, - { - "context": "ui", - "key": "({count} gefiltert)", - "value": "" - }, - { - "context": "ui", - "key": "Abonnement, Einstellungen und Guthaben pro Mandant", - "value": "" - }, - { - "context": "ui", - "key": "Abrechnung", - "value": "" - }, - { - "context": "ui", - "key": "Aktion", - "value": "" - }, - { - "context": "ui", - "key": "Benutzer-Billing", - "value": "" - }, - { - "context": "ui", - "key": "Benutzer-Guthaben", - "value": "" - }, - { - "context": "ui", - "key": "Benutzer:", - "value": "" - }, - { - "context": "ui", - "key": "Deaktiviert", - "value": "" - }, - { - "context": "ui", - "key": "Du hast Zugriff auf {instanceCount} {instanceWord} in {mandateCount} {mandateWord}.", - "value": "" - }, - { - "context": "ui", - "key": "Einstellungen gespeichert!", - "value": "" - }, - { - "context": "ui", - "key": "Feature-Instanz", - "value": "" - }, - { - "context": "ui", - "key": "Feature-Instanzen", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Speichern", - "value": "" - }, - { - "context": "ui", - "key": "Gesamtguthaben", - "value": "" - }, - { - "context": "ui", - "key": "Mandant:", - "value": "" - }, - { - "context": "ui", - "key": "Mandanten", - "value": "" - }, - { - "context": "ui", - "key": "Mandanten-Billing", - "value": "" - }, - { - "context": "ui", - "key": "Mandanten-Guthaben", - "value": "" - }, - { - "context": "ui", - "key": "Mandant", - "value": "" - }, - { - "context": "ui", - "key": "Niedrig", - "value": "" - }, - { - "context": "ui", - "key": "Transaktionen", - "value": "" - }, - { - "context": "ui", - "key": "Warnschwelle", - "value": "" - }, - { - "context": "ui", - "key": "Ansicht an Fenster anpassen", - "value": "" - }, - { - "context": "ui", - "key": "Ansicht zurücksetzen", - "value": "" - }, - { - "context": "ui", - "key": "Auswahl löschen", - "value": "" - }, - { - "context": "ui", - "key": "Canvas bearbeiten", - "value": "" - }, - { - "context": "ui", - "key": "Klicken Sie auf einen Ausgang, dann auf einen Eingang", - "value": "" - }, - { - "context": "ui", - "key": "Klicken Sie auf einen Eingang, um die Verbindung zu erstellen", - "value": "" - }, - { - "context": "ui", - "key": "Kommentar (optional)", - "value": "" - }, - { - "context": "ui", - "key": "Kommentar bearbeiten", - "value": "" - }, - { - "context": "ui", - "key": "Knoten duplizieren", - "value": "" - }, - { - "context": "ui", - "key": "Rückgängig", - "value": "" - }, - { - "context": "ui", - "key": "Verbindungen zeichnen", - "value": "" - }, - { - "context": "ui", - "key": "Vergrößern", - "value": "" - }, - { - "context": "ui", - "key": "Verkleinern", - "value": "" - }, - { - "context": "ui", - "key": "Wiederholen", - "value": "" - }, - { - "context": "ui", - "key": "Zoom-Voreinstellungen", - "value": "" - }, - { - "context": "ui", - "key": "Zoomstufe (Prozent)", - "value": "" - }, - { - "context": "ui", - "key": "Doppelklick zum Bearbeiten", - "value": "" - }, - { - "context": "ui", - "key": "Kommentar auf dem Canvas einfügen", - "value": "" - }, - { - "context": "ui", - "key": "Kommentar eingeben …", - "value": "" - }, - { - "context": "ui", - "key": "Canvas-Notiz verschieben", - "value": "" - }, - { - "context": "ui", - "key": "Notizfarbe", - "value": "" - }, - { - "context": "ui", - "key": "Notizgröße ändern", - "value": "" - }, - { - "context": "ui", - "key": "✓ Mandat eingereicht", - "value": "" - } - ], - "status": "complete", - "isDefault": true - }, - { - "id": "de", - "label": "Deutsch", - "entries": [ - { - "context": "ui", - "key": "+41 123 456 789", - "value": "+41 123 456 789" - }, - { - "context": "ui", - "key": "1 Benutzer ausgewählt", - "value": "1 Benutzer ausgewählt" - }, - { - "context": "ui", - "key": "ABGEBROCHEN", - "value": "ABGEBROCHEN" - }, - { - "context": "ui", - "key": "ABGESCHLOSSEN", - "value": "ABGESCHLOSSEN" - }, - { - "context": "ui", - "key": "Abbrechen", - "value": "Abbrechen" - }, - { - "context": "ui", - "key": "Abgeschlossen", - "value": "Abgeschlossen" - }, - { - "context": "ui", - "key": "Abmelden", - "value": "Abmelden" - }, - { - "context": "ui", - "key": "Admin-Einstellungen", - "value": "Admin-Einstellungen" - }, - { - "context": "ui", - "key": "Administrative Einstellungen", - "value": "Administrative Einstellungen" - }, - { - "context": "ui", - "key": "Administrator", - "value": "Administrator" - }, - { - "context": "ui", - "key": "Adresse", - "value": "Adresse" - }, - { - "context": "ui", - "key": "Agent Assist (AA)", - "value": "Agent Assist (AA)" - }, - { - "context": "ui", - "key": "Aktionen", - "value": "Aktionen" - }, - { - "context": "ui", - "key": "Aktiv", - "value": "Aktiv" - }, - { - "context": "ui", - "key": "Aktiviert", - "value": "Aktiviert" - }, - { - "context": "ui", - "key": "Aktualisieren", - "value": "Aktualisieren" - }, - { - "context": "ui", - "key": "Aktuelle Transkripte", - "value": "Aktuelle Transkripte" - }, - { - "context": "ui", - "key": "Alle", - "value": "Alle" - }, - { - "context": "ui", - "key": "Alle Dateien", - "value": "Alle Dateien" - }, - { - "context": "ui", - "key": "Alle Daten als CSV exportieren", - "value": "Alle Daten als CSV exportieren" - }, - { - "context": "ui", - "key": "Alle Elemente auswählen", - "value": "Alle Elemente auswählen" - }, - { - "context": "ui", - "key": "Alle Nicht-Standard-Sprachsets jetzt mit dem deutschen Master synchronisieren?", - "value": "Alle Nicht-Standard-Sprachsets jetzt mit dem deutschen Master synchronisieren?" - }, - { - "context": "ui", - "key": "Alle abwählen", - "value": "Alle abwählen" - }, - { - "context": "ui", - "key": "Alle aktualisieren", - "value": "Alle aktualisieren" - }, - { - "context": "ui", - "key": "Alle auswählen", - "value": "Alle auswählen" - }, - { - "context": "ui", - "key": "Alle {count} Elemente löschen", - "value": "Alle {count} Elemente löschen" - }, - { - "context": "ui", - "key": "Analysiere Workflow...", - "value": "Analysiere Workflow..." - }, - { - "context": "ui", - "key": "Anmelden", - "value": "Anmelden" - }, - { - "context": "ui", - "key": "Anrufer", - "value": "Anrufer" - }, - { - "context": "ui", - "key": "Anzeigen", - "value": "Anzeigen" - }, - { - "context": "ui", - "key": "Anzeigename", - "value": "Anzeigename" - }, - { - "context": "ui", - "key": "Audio", - "value": "Audio" - }, - { - "context": "ui", - "key": "Auf Standard zurücksetzen", - "value": "Auf Standard zurücksetzen" - }, - { - "context": "ui", - "key": "Aufgaben", - "value": "Aufgaben" - }, - { - "context": "ui", - "key": "Ausführen", - "value": "Ausführen" - }, - { - "context": "ui", - "key": "Ausgewählte Datei:", - "value": "Ausgewählte Datei:" - }, - { - "context": "ui", - "key": "Auth-Anbieter", - "value": "Auth-Anbieter" - }, - { - "context": "ui", - "key": "Authentifizierungsanbieter", - "value": "Authentifizierungsanbieter" - }, - { - "context": "ui", - "key": "Authentifizierungstoken abgelaufen oder ungültig. Bitte verbinden Sie Ihr Microsoft-Konto erneut.", - "value": "Authentifizierungstoken abgelaufen oder ungültig. Bitte verbinden Sie Ihr Microsoft-Konto erneut." - }, - { - "context": "ui", - "key": "Automatisierung erfolgreich erstellt", - "value": "Automatisierung erfolgreich erstellt" - }, - { - "context": "ui", - "key": "Automatisierungen", - "value": "Automatisierungen" - }, - { - "context": "ui", - "key": "Basisdaten", - "value": "Basisdaten" - }, - { - "context": "ui", - "key": "Bearbeiten", - "value": "Bearbeiten" - }, - { - "context": "ui", - "key": "Befehl eingeben (z.B., \"Erstelle ein neues Projekt namens 'Hauptstrasse 42'\")", - "value": "Befehl eingeben (z.B., \"Erstelle ein neues Projekt namens 'Hauptstrasse 42'\")" - }, - { - "context": "ui", - "key": "Beginne ein Gespräch, indem du eine Nachricht eingibst, eine Vorlage auswählst oder einen vorherigen Workflow fortsetzt …", - "value": "Beginne ein Gespräch, indem du eine Nachricht eingibst, eine Vorlage auswählst oder einen vorherigen Workflow fortsetzt …" - }, - { - "context": "ui", - "key": "Beginnen Sie mit:", - "value": "Beginnen Sie mit:" - }, - { - "context": "ui", - "key": "Bei Genehmigung planen wir einen Einrichtungsanruf zur Konfiguration Ihrer Integration.", - "value": "Bei Genehmigung planen wir einen Einrichtungsanruf zur Konfiguration Ihrer Integration." - }, - { - "context": "ui", - "key": "Beim Hochladen ist ein Fehler aufgetreten.", - "value": "Beim Hochladen ist ein Fehler aufgetreten." - }, - { - "context": "ui", - "key": "Beim Hochladen ist ein unerwarteter Fehler aufgetreten.", - "value": "Beim Hochladen ist ein unerwarteter Fehler aufgetreten." - }, - { - "context": "ui", - "key": "Belege verwalten", - "value": "Belege verwalten" - }, - { - "context": "ui", - "key": "Benutzer", - "value": "Benutzer" - }, - { - "context": "ui", - "key": "Benutzer auswählen", - "value": "Benutzer auswählen" - }, - { - "context": "ui", - "key": "Benutzer bearbeiten", - "value": "Benutzer bearbeiten" - }, - { - "context": "ui", - "key": "Benutzer erstellen", - "value": "Benutzer erstellen" - }, - { - "context": "ui", - "key": "Benutzer hinzufügen", - "value": "Benutzer hinzufügen" - }, - { - "context": "ui", - "key": "Benutzer löschen", - "value": "Benutzer löschen" - }, - { - "context": "ui", - "key": "Benutzer werden geladen...", - "value": "Benutzer werden geladen..." - }, - { - "context": "ui", - "key": "Benutzer-Zugriff verwalten", - "value": "Benutzer-Zugriff verwalten" - }, - { - "context": "ui", - "key": "Benutzerdefinierter Titel (optional)", - "value": "Benutzerdefinierter Titel (optional)" - }, - { - "context": "ui", - "key": "Benutzerinformationen", - "value": "Benutzerinformationen" - }, - { - "context": "ui", - "key": "Benutzerinformationen erfolgreich aktualisiert", - "value": "Benutzerinformationen erfolgreich aktualisiert" - }, - { - "context": "ui", - "key": "Benutzerinformationen werden geladen...", - "value": "Benutzerinformationen werden geladen..." - }, - { - "context": "ui", - "key": "Benutzername", - "value": "Benutzername" - }, - { - "context": "ui", - "key": "Benutzerverwaltung - Teammitglieder und Berechtigungen verwalten", - "value": "Benutzerverwaltung - Teammitglieder und Berechtigungen verwalten" - }, - { - "context": "ui", - "key": "Berechtigung", - "value": "Berechtigung" - }, - { - "context": "ui", - "key": "Berechtigungsstufe", - "value": "Berechtigungsstufe" - }, - { - "context": "ui", - "key": "Beschreibung", - "value": "Beschreibung" - }, - { - "context": "ui", - "key": "Beschreibung der Rolle", - "value": "Beschreibung der Rolle" - }, - { - "context": "ui", - "key": "Betrachter", - "value": "Betrachter" - }, - { - "context": "ui", - "key": "Betreff", - "value": "Betreff" - }, - { - "context": "ui", - "key": "Bezeichnung", - "value": "Bezeichnung" - }, - { - "context": "ui", - "key": "Bieten Sie Unterstützung im Live-Chat und setzen Sie intelligente Chatbots in allen Kanälen ein.", - "value": "Bieten Sie Unterstützung im Live-Chat und setzen Sie intelligente Chatbots in allen Kanälen ein." - }, - { - "context": "ui", - "key": "Bild", - "value": "Bild" - }, - { - "context": "ui", - "key": "Bis", - "value": "Bis" - }, - { - "context": "ui", - "key": "Bitte geben Sie eine gültige E-Mail-Adresse ein", - "value": "Bitte geben Sie eine gültige E-Mail-Adresse ein" - }, - { - "context": "ui", - "key": "Bitte wählen Sie mindestens einen Benutzer aus", - "value": "Bitte wählen Sie mindestens einen Benutzer aus" - }, - { - "context": "ui", - "key": "Branche", - "value": "Branche" - }, - { - "context": "ui", - "key": "Branche ist erforderlich", - "value": "Branche ist erforderlich" - }, - { - "context": "ui", - "key": "Buchungsbetrag", - "value": "Buchungsbetrag" - }, - { - "context": "ui", - "key": "Buchungspositionen verwalten", - "value": "Buchungspositionen verwalten" - }, - { - "context": "ui", - "key": "Buchungswährung", - "value": "Buchungswährung" - }, - { - "context": "ui", - "key": "Chat Platform (CP)", - "value": "Chat Platform (CP)" - }, - { - "context": "ui", - "key": "Chat leeren...", - "value": "Chat leeren..." - }, - { - "context": "ui", - "key": "Chatbereich", - "value": "Chatbereich" - }, - { - "context": "ui", - "key": "Darstellung", - "value": "Darstellung" - }, - { - "context": "ui", - "key": "Datei", - "value": "Datei" - }, - { - "context": "ui", - "key": "Datei anhängen", - "value": "Datei anhängen" - }, - { - "context": "ui", - "key": "Datei bearbeiten", - "value": "Datei bearbeiten" - }, - { - "context": "ui", - "key": "Datei bereits vorhanden", - "value": "Datei bereits vorhanden" - }, - { - "context": "ui", - "key": "Datei entfernen", - "value": "Datei entfernen" - }, - { - "context": "ui", - "key": "Datei erfolgreich hochgeladen!", - "value": "Datei erfolgreich hochgeladen!" - }, - { - "context": "ui", - "key": "Datei herunterladen", - "value": "Datei herunterladen" - }, - { - "context": "ui", - "key": "Datei hier ablegen...", - "value": "Datei hier ablegen..." - }, - { - "context": "ui", - "key": "Datei hinzufügen", - "value": "Datei hinzufügen" - }, - { - "context": "ui", - "key": "Datei hochladen", - "value": "Datei hochladen" - }, - { - "context": "ui", - "key": "Datei löschen", - "value": "Datei löschen" - }, - { - "context": "ui", - "key": "Datei vorschauen", - "value": "Datei vorschauen" - }, - { - "context": "ui", - "key": "Datei-Ablage während Workflow deaktiviert", - "value": "Datei-Ablage während Workflow deaktiviert" - }, - { - "context": "ui", - "key": "Dateien", - "value": "Dateien" - }, - { - "context": "ui", - "key": "Dateien anhängen", - "value": "Dateien anhängen" - }, - { - "context": "ui", - "key": "Dateien auswählen", - "value": "Dateien auswählen" - }, - { - "context": "ui", - "key": "Dateien hier ablegen", - "value": "Dateien hier ablegen" - }, - { - "context": "ui", - "key": "Dateien hier ablegen zum Anhängen", - "value": "Dateien hier ablegen zum Anhängen" - }, - { - "context": "ui", - "key": "Dateien hierher ziehen", - "value": "Dateien hierher ziehen" - }, - { - "context": "ui", - "key": "Dateien hochladen", - "value": "Dateien hochladen" - }, - { - "context": "ui", - "key": "Dateien werden geladen...", - "value": "Dateien werden geladen..." - }, - { - "context": "ui", - "key": "Dateien werden verarbeitet...", - "value": "Dateien werden verarbeitet..." - }, - { - "context": "ui", - "key": "Dateigröße", - "value": "Dateigröße" - }, - { - "context": "ui", - "key": "Dateiname", - "value": "Dateiname" - }, - { - "context": "ui", - "key": "Dateityp", - "value": "Dateityp" - }, - { - "context": "ui", - "key": "Dateiverwaltung - Dokumente hochladen und organisieren", - "value": "Dateiverwaltung - Dokumente hochladen und organisieren" - }, - { - "context": "ui", - "key": "Dateivorschau", - "value": "Dateivorschau" - }, - { - "context": "ui", - "key": "Daten aktualisieren", - "value": "Daten aktualisieren" - }, - { - "context": "ui", - "key": "Daten empfangen", - "value": "Daten empfangen" - }, - { - "context": "ui", - "key": "Daten gesendet", - "value": "Daten gesendet" - }, - { - "context": "ui", - "key": "Datenverwaltung", - "value": "Datenverwaltung" - }, - { - "context": "ui", - "key": "Datenverwaltung - Datenimporte und -exporte verwalten", - "value": "Datenverwaltung - Datenimporte und -exporte verwalten" - }, - { - "context": "ui", - "key": "Datenverwaltung mit Tabellen", - "value": "Datenverwaltung mit Tabellen" - }, - { - "context": "ui", - "key": "Datum", - "value": "Datum" - }, - { - "context": "ui", - "key": "Dauer", - "value": "Dauer" - }, - { - "context": "ui", - "key": "Details", - "value": "Details" - }, - { - "context": "ui", - "key": "Deutsch", - "value": "Deutsch" - }, - { - "context": "ui", - "key": "Die Datei \"{fileName}\" existiert bereits mit identischem Inhalt. Die vorhandene Datei wird wiederverwendet.", - "value": "Die Datei \"{fileName}\" existiert bereits mit identischem Inhalt. Die vorhandene Datei wird wiederverwendet." - }, - { - "context": "ui", - "key": "Die Erstellung einer neuen Sprache kann AI-Guthaben auf Ihrem Mandats-Pool belasten. Fortfahren?", - "value": "Die Erstellung einer neuen Sprache kann AI-Guthaben auf Ihrem Mandats-Pool belasten. Fortfahren?" - }, - { - "context": "ui", - "key": "Dies ist Ihr Ausgangspunkt für den Zugriff auf alle Arbeitsbereich-Features und -Tools.", - "value": "Dies ist Ihr Ausgangspunkt für den Zugriff auf alle Arbeitsbereich-Features und -Tools." - }, - { - "context": "ui", - "key": "Diese Aktion kann nicht rückgängig gemacht werden.", - "value": "Diese Aktion kann nicht rückgängig gemacht werden." - }, - { - "context": "ui", - "key": "Diese Datei scheint beschädigt zu sein. Sie hat eine PDF-Erweiterung, enthält aber Textinhalte. Bitte laden Sie die Datei erneut hoch, falls möglich.", - "value": "Diese Datei scheint beschädigt zu sein. Sie hat eine PDF-Erweiterung, enthält aber Textinhalte. Bitte laden Sie die Datei erneut hoch, falls möglich." - }, - { - "context": "ui", - "key": "Dieser Bereich enthält alle Verwaltungs- und Management-Tools für Ihren Arbeitsbereich.", - "value": "Dieser Bereich enthält alle Verwaltungs- und Management-Tools für Ihren Arbeitsbereich." - }, - { - "context": "ui", - "key": "Dieses Element auswählen", - "value": "Dieses Element auswählen" - }, - { - "context": "ui", - "key": "Dieses Element kann nicht ausgewählt werden", - "value": "Dieses Element kann nicht ausgewählt werden" - }, - { - "context": "ui", - "key": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden", - "value": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden" - }, - { - "context": "ui", - "key": "Dossiers", - "value": "Dossiers" - }, - { - "context": "ui", - "key": "Dokument", - "value": "Dokument" - }, - { - "context": "ui", - "key": "Dokument erfolgreich erstellt", - "value": "Dokument erfolgreich erstellt" - }, - { - "context": "ui", - "key": "Dokument herunterladen", - "value": "Dokument herunterladen" - }, - { - "context": "ui", - "key": "Dokument vorschauen", - "value": "Dokument vorschauen" - }, - { - "context": "ui", - "key": "Dokumente", - "value": "Dokumente" - }, - { - "context": "ui", - "key": "Dokumente auflisten", - "value": "Dokumente auflisten" - }, - { - "context": "ui", - "key": "Dokumentname", - "value": "Dokumentname" - }, - { - "context": "ui", - "key": "Dunkel", - "value": "Dunkel" - }, - { - "context": "ui", - "key": "Durchsuchen", - "value": "Durchsuchen" - }, - { - "context": "ui", - "key": "E-Mail", - "value": "E-Mail" - }, - { - "context": "ui", - "key": "E-Mail-Adresse", - "value": "E-Mail-Adresse" - }, - { - "context": "ui", - "key": "E-Mail-Adresse ist erforderlich", - "value": "E-Mail-Adresse ist erforderlich" - }, - { - "context": "ui", - "key": "E-Mail-Bestätigung", - "value": "E-Mail-Bestätigung" - }, - { - "context": "ui", - "key": "Echtzeit-Datensynchronisation:", - "value": "Echtzeit-Datensynchronisation:" - }, - { - "context": "ui", - "key": "Eigenschaften", - "value": "Eigenschaften" - }, - { - "context": "ui", - "key": "Eingereichte Daten:", - "value": "Eingereichte Daten:" - }, - { - "context": "ui", - "key": "Einrichtungsanruf", - "value": "Einrichtungsanruf" - }, - { - "context": "ui", - "key": "Einstellungen", - "value": "Einstellungen" - }, - { - "context": "ui", - "key": "Einstellungen erfolgreich gespeichert!", - "value": "Einstellungen erfolgreich gespeichert!" - }, - { - "context": "ui", - "key": "Einstellungen werden in zukünftigen Updates hinzugefügt.", - "value": "Einstellungen werden in zukünftigen Updates hinzugefügt." - }, - { - "context": "ui", - "key": "Einstellungen wurden erfolgreich zurückgesetzt.", - "value": "Einstellungen wurden erfolgreich zurückgesetzt." - }, - { - "context": "ui", - "key": "Einträge", - "value": "Einträge" - }, - { - "context": "ui", - "key": "Einträge pro Seite:", - "value": "Einträge pro Seite:" - }, - { - "context": "ui", - "key": "Empfänger", - "value": "Empfänger" - }, - { - "context": "ui", - "key": "Endzeit", - "value": "Endzeit" - }, - { - "context": "ui", - "key": "English", - "value": "English" - }, - { - "context": "ui", - "key": "Entdeckte Sites", - "value": "Entdeckte Sites" - }, - { - "context": "ui", - "key": "Entfernen", - "value": "Entfernen" - }, - { - "context": "ui", - "key": "Erfolgreich", - "value": "Erfolgreich" - }, - { - "context": "ui", - "key": "Erfolgsrate", - "value": "Erfolgsrate" - }, - { - "context": "ui", - "key": "Erleben Sie die Zukunft der Mandantenkommunikation durch unsere strategische Partnerschaft mit Spitch.ai. Diese bahnbrechende Integration verwandelt Ihre PowerOn-Plattform in ein intelligentes Telefonie-System, das externe Mandanten nahtlos mit Unternehmen verbindet.", - "value": "Erleben Sie die Zukunft der Mandantenkommunikation durch unsere strategische Partnerschaft mit Spitch.ai. Diese bahnbrechende Integration verwandelt Ihre PowerOn-Plattform in ein intelligentes Telefonie-System, das externe Mandanten nahtlos mit Unternehmen verbindet." - }, - { - "context": "ui", - "key": "Erneut versuchen", - "value": "Erneut versuchen" - }, - { - "context": "ui", - "key": "Erste Seite", - "value": "Erste Seite" - }, - { - "context": "ui", - "key": "Erstellen", - "value": "Erstellen" - }, - { - "context": "ui", - "key": "Erstellen und verwalten Sie RBAC-Rollen und deren Berechtigungen.", - "value": "Erstellen und verwalten Sie RBAC-Rollen und deren Berechtigungen." - }, - { - "context": "ui", - "key": "Erstellen...", - "value": "Erstellen..." - }, - { - "context": "ui", - "key": "Erstellt", - "value": "Erstellt" - }, - { - "context": "ui", - "key": "Erstellte Dateien", - "value": "Erstellte Dateien" - }, - { - "context": "ui", - "key": "Erstellungsdatum", - "value": "Erstellungsdatum" - }, - { - "context": "ui", - "key": "Exportiere...", - "value": "Exportiere..." - }, - { - "context": "ui", - "key": "Externe E-Mail", - "value": "Externe E-Mail" - }, - { - "context": "ui", - "key": "Externe E-Mail-Adresse eingeben", - "value": "Externe E-Mail-Adresse eingeben" - }, - { - "context": "ui", - "key": "Externen Benutzernamen eingeben", - "value": "Externen Benutzernamen eingeben" - }, - { - "context": "ui", - "key": "Externer Benutzername", - "value": "Externer Benutzername" - }, - { - "context": "ui", - "key": "FEHLER", - "value": "FEHLER" - }, - { - "context": "ui", - "key": "FEHLGESCHLAGEN", - "value": "FEHLGESCHLAGEN" - }, - { - "context": "ui", - "key": "Falls Sie Fragen zu Ihrem Mandat oder dem Integrationsprozess haben, zögern Sie nicht, unser Support-Team zu kontaktieren.", - "value": "Falls Sie Fragen zu Ihrem Mandat oder dem Integrationsprozess haben, zögern Sie nicht, unser Support-Team zu kontaktieren." - }, - { - "context": "ui", - "key": "Fehler", - "value": "Fehler" - }, - { - "context": "ui", - "key": "Fehler beim Aktualisieren der Benutzerinformationen", - "value": "Fehler beim Aktualisieren der Benutzerinformationen" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der Automatisierung", - "value": "Fehler beim Erstellen der Automatisierung" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der Organisation", - "value": "Fehler beim Erstellen der Organisation" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der Position", - "value": "Fehler beim Erstellen der Position" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der RBAC-Regel", - "value": "Fehler beim Erstellen der RBAC-Regel" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der Rolle", - "value": "Fehler beim Erstellen der Rolle" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Dokuments", - "value": "Fehler beim Erstellen des Dokuments" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Mandats", - "value": "Fehler beim Erstellen des Mandats" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Prompts", - "value": "Fehler beim Erstellen des Prompts" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Team-Mitglieds", - "value": "Fehler beim Erstellen des Team-Mitglieds" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Vertrags", - "value": "Fehler beim Erstellen des Vertrags" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Zugriffs", - "value": "Fehler beim Erstellen des Zugriffs" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Benutzer", - "value": "Fehler beim Laden der Benutzer" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Benutzer:", - "value": "Fehler beim Laden der Benutzer:" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Benutzerinformationen", - "value": "Fehler beim Laden der Benutzerinformationen" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Dateien:", - "value": "Fehler beim Laden der Dateien:" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Logs", - "value": "Fehler beim Laden der Logs" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Nachrichten:", - "value": "Fehler beim Laden der Nachrichten:" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Prompts", - "value": "Fehler beim Laden der Prompts" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Prompts:", - "value": "Fehler beim Laden der Prompts:" - }, - { - "context": "ui", - "key": "Fehler beim Laden der SharePoint Dokumente:", - "value": "Fehler beim Laden der SharePoint Dokumente:" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Vorschau", - "value": "Fehler beim Laden der Vorschau" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Workflows:", - "value": "Fehler beim Laden der Workflows:" - }, - { - "context": "ui", - "key": "Fehler beim Löschen", - "value": "Fehler beim Löschen" - }, - { - "context": "ui", - "key": "Fehler beim Speichern der Einstellungen. Bitte versuchen Sie es erneut.", - "value": "Fehler beim Speichern der Einstellungen. Bitte versuchen Sie es erneut." - }, - { - "context": "ui", - "key": "Fehler beim Teilen des Prompts", - "value": "Fehler beim Teilen des Prompts" - }, - { - "context": "ui", - "key": "Fehler beim Verarbeiten der Dateien", - "value": "Fehler beim Verarbeiten der Dateien" - }, - { - "context": "ui", - "key": "Fehler:", - "value": "Fehler:" - }, - { - "context": "ui", - "key": "Fehlgeschlagen", - "value": "Fehlgeschlagen" - }, - { - "context": "ui", - "key": "Filter", - "value": "Filter" - }, - { - "context": "ui", - "key": "Filter löschen", - "value": "Filter löschen" - }, - { - "context": "ui", - "key": "Filter: {value}", - "value": "Filter: {value}" - }, - { - "context": "ui", - "key": "Firma", - "value": "Firma" - }, - { - "context": "ui", - "key": "Firmenname", - "value": "Firmenname" - }, - { - "context": "ui", - "key": "Firmenname ist erforderlich", - "value": "Firmenname ist erforderlich" - }, - { - "context": "ui", - "key": "Folgenachricht wird gesendet...", - "value": "Folgenachricht wird gesendet..." - }, - { - "context": "ui", - "key": "Fortfahren", - "value": "Fortfahren" - }, - { - "context": "ui", - "key": "Fortsetzen", - "value": "Fortsetzen" - }, - { - "context": "ui", - "key": "Fragen?", - "value": "Fragen?" - }, - { - "context": "ui", - "key": "Français", - "value": "Français" - }, - { - "context": "ui", - "key": "Fügen Sie eine Nachricht für die Empfänger hinzu", - "value": "Fügen Sie eine Nachricht für die Empfänger hinzu" - }, - { - "context": "ui", - "key": "GESTOPPT", - "value": "GESTOPPT" - }, - { - "context": "ui", - "key": "Geben Sie Ihren Firmennamen ein", - "value": "Geben Sie Ihren Firmennamen ein" - }, - { - "context": "ui", - "key": "Geben Sie Kunden einen schnellen und effizienten Selbstservice für Sprach- und Textanfragen, der 24/7 verfügbar ist.", - "value": "Geben Sie Kunden einen schnellen und effizienten Selbstservice für Sprach- und Textanfragen, der 24/7 verfügbar ist." - }, - { - "context": "ui", - "key": "Geben Sie den Inhalt des Prompts ein", - "value": "Geben Sie den Inhalt des Prompts ein" - }, - { - "context": "ui", - "key": "Geben Sie einen Namen für den Prompt ein", - "value": "Geben Sie einen Namen für den Prompt ein" - }, - { - "context": "ui", - "key": "Geben Sie einen benutzerdefinierten Titel ein", - "value": "Geben Sie einen benutzerdefinierten Titel ein" - }, - { - "context": "ui", - "key": "Geplante und automatisierte Workflows", - "value": "Geplante und automatisierte Workflows" - }, - { - "context": "ui", - "key": "Geschäftszeiten", - "value": "Geschäftszeiten" - }, - { - "context": "ui", - "key": "Geschäftszeiten & Zeitzone", - "value": "Geschäftszeiten & Zeitzone" - }, - { - "context": "ui", - "key": "Gespräch fortsetzen...", - "value": "Gespräch fortsetzen..." - }, - { - "context": "ui", - "key": "Gestartet", - "value": "Gestartet" - }, - { - "context": "ui", - "key": "Gestartet:", - "value": "Gestartet:" - }, - { - "context": "ui", - "key": "Gestoppt", - "value": "Gestoppt" - }, - { - "context": "ui", - "key": "Geteilt", - "value": "Geteilt" - }, - { - "context": "ui", - "key": "Geteilte Dateien", - "value": "Geteilte Dateien" - }, - { - "context": "ui", - "key": "Globale Sprachsets verwalten (SysAdmin).", - "value": "Globale Sprachsets verwalten (SysAdmin)." - }, - { - "context": "ui", - "key": "Google", - "value": "Google" - }, - { - "context": "ui", - "key": "Google verbinden", - "value": "Google verbinden" - }, - { - "context": "ui", - "key": "Google-Verbindung erstellen", - "value": "Google-Verbindung erstellen" - }, - { - "context": "ui", - "key": "Google-Verbindung hinzufügen", - "value": "Google-Verbindung hinzufügen" - }, - { - "context": "ui", - "key": "Grundlegende Daten und Ressourcen", - "value": "Grundlegende Daten und Ressourcen" - }, - { - "context": "ui", - "key": "Größe", - "value": "Größe" - }, - { - "context": "ui", - "key": "Hell", - "value": "Hell" - }, - { - "context": "ui", - "key": "Herunterladen", - "value": "Herunterladen" - }, - { - "context": "ui", - "key": "Hinzufügen", - "value": "Hinzufügen" - }, - { - "context": "ui", - "key": "Hochgeladen", - "value": "Hochgeladen" - }, - { - "context": "ui", - "key": "Hochladen", - "value": "Hochladen" - }, - { - "context": "ui", - "key": "ID", - "value": "ID" - }, - { - "context": "ui", - "key": "INFO", - "value": "INFO" - }, - { - "context": "ui", - "key": "Identifizieren und authentifizieren Sie Anrufer in Sekunden mit kontinuierlicher Verifizierung und Sicherheit.", - "value": "Identifizieren und authentifizieren Sie Anrufer in Sekunden mit kontinuierlicher Verifizierung und Sicherheit." - }, - { - "context": "ui", - "key": "Ihre Anfrage wird verarbeitet...", - "value": "Ihre Anfrage wird verarbeitet..." - }, - { - "context": "ui", - "key": "In die Zwischenablage kopiert", - "value": "In die Zwischenablage kopiert" - }, - { - "context": "ui", - "key": "Inaktiv", - "value": "Inaktiv" - }, - { - "context": "ui", - "key": "Information", - "value": "Information" - }, - { - "context": "ui", - "key": "Inhalt", - "value": "Inhalt" - }, - { - "context": "ui", - "key": "Inhalt ist erforderlich", - "value": "Inhalt ist erforderlich" - }, - { - "context": "ui", - "key": "Ja", - "value": "Ja" - }, - { - "context": "ui", - "key": "Jetzt anmelden", - "value": "Jetzt anmelden" - }, - { - "context": "ui", - "key": "Jetzt überspringen", - "value": "Jetzt überspringen" - }, - { - "context": "ui", - "key": "KI-erstellt", - "value": "KI-erstellt" - }, - { - "context": "ui", - "key": "KI-gestützte Dokumentengenerierung:", - "value": "KI-gestützte Dokumentengenerierung:" - }, - { - "context": "ui", - "key": "Kein Auth-Anbieter", - "value": "Kein Auth-Anbieter" - }, - { - "context": "ui", - "key": "Kein Benutzername", - "value": "Kein Benutzername" - }, - { - "context": "ui", - "key": "Kein Nachrichteninhalt verfügbar", - "value": "Kein Nachrichteninhalt verfügbar" - }, - { - "context": "ui", - "key": "Kein Name", - "value": "Kein Name" - }, - { - "context": "ui", - "key": "Kein Workflow ausgewählt", - "value": "Kein Workflow ausgewählt" - }, - { - "context": "ui", - "key": "Keine", - "value": "Keine" - }, - { - "context": "ui", - "key": "Keine Benutzer verfügbar", - "value": "Keine Benutzer verfügbar" - }, - { - "context": "ui", - "key": "Keine Berechtigung", - "value": "Keine Berechtigung" - }, - { - "context": "ui", - "key": "Keine Berechtigung zum Löschen des Prompts", - "value": "Keine Berechtigung zum Löschen des Prompts" - }, - { - "context": "ui", - "key": "Keine Dateien gefunden.", - "value": "Keine Dateien gefunden." - }, - { - "context": "ui", - "key": "Keine Daten verfügbar", - "value": "Keine Daten verfügbar" - }, - { - "context": "ui", - "key": "Keine E-Mail", - "value": "Keine E-Mail" - }, - { - "context": "ui", - "key": "Keine Einträge", - "value": "Keine Einträge" - }, - { - "context": "ui", - "key": "Keine Logs für diesen Workflow verfügbar", - "value": "Keine Logs für diesen Workflow verfügbar" - }, - { - "context": "ui", - "key": "Keine Microsoft-Verbindungen gefunden. Bitte erstellen Sie zuerst eine Verbindung.", - "value": "Keine Microsoft-Verbindungen gefunden. Bitte erstellen Sie zuerst eine Verbindung." - }, - { - "context": "ui", - "key": "Keine Optionen verfügbar", - "value": "Keine Optionen verfügbar" - }, - { - "context": "ui", - "key": "Keine Prompts verfügbar", - "value": "Keine Prompts verfügbar" - }, - { - "context": "ui", - "key": "Keine SharePoint-Sites gefunden", - "value": "Keine SharePoint-Sites gefunden" - }, - { - "context": "ui", - "key": "Keine Sprach-Integrations-Daten gefunden. Bitte melden Sie sich zuerst an, um auf die Einstellungen zuzugreifen.", - "value": "Keine Sprach-Integrations-Daten gefunden. Bitte melden Sie sich zuerst an, um auf die Einstellungen zuzugreifen." - }, - { - "context": "ui", - "key": "Keine Sprache", - "value": "Keine Sprache" - }, - { - "context": "ui", - "key": "Keine Transkripte vorhanden", - "value": "Keine Transkripte vorhanden" - }, - { - "context": "ui", - "key": "Keine Vorschau verfügbar", - "value": "Keine Vorschau verfügbar" - }, - { - "context": "ui", - "key": "Keine Workflows gefunden", - "value": "Keine Workflows gefunden" - }, - { - "context": "ui", - "key": "Keine Workflows verfügbar", - "value": "Keine Workflows verfügbar" - }, - { - "context": "ui", - "key": "Keine hochgeladenen Dateien gefunden.", - "value": "Keine hochgeladenen Dateien gefunden." - }, - { - "context": "ui", - "key": "Keine mit Ihnen geteilten Dateien gefunden.", - "value": "Keine mit Ihnen geteilten Dateien gefunden." - }, - { - "context": "ui", - "key": "Keine von der KI erstellten Dateien gefunden.", - "value": "Keine von der KI erstellten Dateien gefunden." - }, - { - "context": "ui", - "key": "Klicken Sie erneut zum Bestätigen", - "value": "Klicken Sie erneut zum Bestätigen" - }, - { - "context": "ui", - "key": "Klicken Sie erneut zum Bestätigen der Löschung", - "value": "Klicken Sie erneut zum Bestätigen der Löschung" - }, - { - "context": "ui", - "key": "Klicken Sie, um zu öffnen", - "value": "Klicken Sie, um zu öffnen" - }, - { - "context": "ui", - "key": "Knowledge Agent (KA)", - "value": "Knowledge Agent (KA)" - }, - { - "context": "ui", - "key": "Konfigurieren Sie administrative Einstellungen und Systempräferenzen.", - "value": "Konfigurieren Sie administrative Einstellungen und Systempräferenzen." - }, - { - "context": "ui", - "key": "Konfigurieren und verwalten Sie rollenbasierte Zugriffssteuerungsregeln.", - "value": "Konfigurieren und verwalten Sie rollenbasierte Zugriffssteuerungsregeln." - }, - { - "context": "ui", - "key": "Kontakte einrichten", - "value": "Kontakte einrichten" - }, - { - "context": "ui", - "key": "Kontaktinformationen", - "value": "Kontaktinformationen" - }, - { - "context": "ui", - "key": "Kontostatus", - "value": "Kontostatus" - }, - { - "context": "ui", - "key": "Kopieren", - "value": "Kopieren" - }, - { - "context": "ui", - "key": "Kopiert", - "value": "Kopiert" - }, - { - "context": "ui", - "key": "Kosteneinsparungen & Effizienz:", - "value": "Kosteneinsparungen & Effizienz:" - }, - { - "context": "ui", - "key": "Kundenverträge verwalten", - "value": "Kundenverträge verwalten" - }, - { - "context": "ui", - "key": "Lade Filterwerte...", - "value": "Lade Filterwerte..." - }, - { - "context": "ui", - "key": "Lade Fortschritt...", - "value": "Lade Fortschritt..." - }, - { - "context": "ui", - "key": "Laden...", - "value": "Laden..." - }, - { - "context": "ui", - "key": "Land", - "value": "Land" - }, - { - "context": "ui", - "key": "Land ist erforderlich", - "value": "Land ist erforderlich" - }, - { - "context": "ui", - "key": "Leer = Zugriff auf alle Verträge", - "value": "Leer = Zugriff auf alle Verträge" - }, - { - "context": "ui", - "key": "Letzte Aktivität", - "value": "Letzte Aktivität" - }, - { - "context": "ui", - "key": "Letzte Aktivität:", - "value": "Letzte Aktivität:" - }, - { - "context": "ui", - "key": "Letzte Aktivitäten - Sehen Sie Ihre neueste Arbeit", - "value": "Letzte Aktivitäten - Sehen Sie Ihre neueste Arbeit" - }, - { - "context": "ui", - "key": "Letzte Seite", - "value": "Letzte Seite" - }, - { - "context": "ui", - "key": "Link konnte nicht gesendet werden", - "value": "Link konnte nicht gesendet werden" - }, - { - "context": "ui", - "key": "Log", - "value": "Log" - }, - { - "context": "ui", - "key": "Logs konnten nicht geladen werden", - "value": "Logs konnten nicht geladen werden" - }, - { - "context": "ui", - "key": "Logs werden geladen...", - "value": "Logs werden geladen..." - }, - { - "context": "ui", - "key": "Lokal", - "value": "Lokal" - }, - { - "context": "ui", - "key": "LÄUFT", - "value": "LÄUFT" - }, - { - "context": "ui", - "key": "Lädt hoch...", - "value": "Lädt hoch..." - }, - { - "context": "ui", - "key": "Läuft", - "value": "Läuft" - }, - { - "context": "ui", - "key": "Läuft ab am", - "value": "Läuft ab am" - }, - { - "context": "ui", - "key": "Löschen", - "value": "Löschen" - }, - { - "context": "ui", - "key": "Löschen ({count})", - "value": "Löschen ({count})" - }, - { - "context": "ui", - "key": "Löschen...", - "value": "Löschen..." - }, - { - "context": "ui", - "key": "MIME-Typ", - "value": "MIME-Typ" - }, - { - "context": "ui", - "key": "Management-Tools umfassen:", - "value": "Management-Tools umfassen:" - }, - { - "context": "ui", - "key": "Mandanten können jederzeit auf die technische SIP-Nummer umstellen und dabei erhebliche Telefoniekosten sparen. Die Integration funktioniert wie ein weiterer Connector (Outlook, SharePoint) und wird nahtlos in Ihren bestehenden Workflow integriert.", - "value": "Mandanten können jederzeit auf die technische SIP-Nummer umstellen und dabei erhebliche Telefoniekosten sparen. Die Integration funktioniert wie ein weiterer Connector (Outlook, SharePoint) und wird nahtlos in Ihren bestehenden Workflow integriert." - }, - { - "context": "ui", - "key": "Mandat erfolgreich eingereicht!", - "value": "Mandat erfolgreich eingereicht!" - }, - { - "context": "ui", - "key": "Mandat erfolgreich erstellt", - "value": "Mandat erfolgreich erstellt" - }, - { - "context": "ui", - "key": "Mandat erstellen", - "value": "Mandat erstellen" - }, - { - "context": "ui", - "key": "Mandat hinzufügen", - "value": "Mandat hinzufügen" - }, - { - "context": "ui", - "key": "Mandat-ID", - "value": "Mandat-ID" - }, - { - "context": "ui", - "key": "Mandate", - "value": "Mandate" - }, - { - "context": "ui", - "key": "Mandate und Berechtigungen verwalten", - "value": "Mandate und Berechtigungen verwalten" - }, - { - "context": "ui", - "key": "Mandatsverwaltung", - "value": "Mandatsverwaltung" - }, - { - "context": "ui", - "key": "Mehr erfahren", - "value": "Mehr erfahren" - }, - { - "context": "ui", - "key": "Meine Uploads", - "value": "Meine Uploads" - }, - { - "context": "ui", - "key": "Microsoft", - "value": "Microsoft" - }, - { - "context": "ui", - "key": "Microsoft Verbindungen", - "value": "Microsoft Verbindungen" - }, - { - "context": "ui", - "key": "Microsoft verbinden", - "value": "Microsoft verbinden" - }, - { - "context": "ui", - "key": "Microsoft-Verbindung erstellen", - "value": "Microsoft-Verbindung erstellen" - }, - { - "context": "ui", - "key": "Microsoft-Verbindung hinzufügen", - "value": "Microsoft-Verbindung hinzufügen" - }, - { - "context": "ui", - "key": "Mitglied hinzufügen", - "value": "Mitglied hinzufügen" - }, - { - "context": "ui", - "key": "MwSt %", - "value": "MwSt %" - }, - { - "context": "ui", - "key": "MwSt Betrag", - "value": "MwSt Betrag" - }, - { - "context": "ui", - "key": "Möchten Sie jetzt Kontakte für Ihr Mandat einrichten? Sie können dies auch später in den Einstellungen tun.", - "value": "Möchten Sie jetzt Kontakte für Ihr Mandat einrichten? Sie können dies auch später in den Einstellungen tun." - }, - { - "context": "ui", - "key": "Nach unten scrollen", - "value": "Nach unten scrollen" - }, - { - "context": "ui", - "key": "Nachricht (optional)", - "value": "Nachricht (optional)" - }, - { - "context": "ui", - "key": "Nachricht eingeben...", - "value": "Nachricht eingeben..." - }, - { - "context": "ui", - "key": "Nachricht wird gesendet...", - "value": "Nachricht wird gesendet..." - }, - { - "context": "ui", - "key": "Nachrichten", - "value": "Nachrichten" - }, - { - "context": "ui", - "key": "Nahtloser Mandanten-Workflow:", - "value": "Nahtloser Mandanten-Workflow:" - }, - { - "context": "ui", - "key": "Name", - "value": "Name" - }, - { - "context": "ui", - "key": "Name des Unternehmens", - "value": "Name des Unternehmens" - }, - { - "context": "ui", - "key": "Name ist erforderlich", - "value": "Name ist erforderlich" - }, - { - "context": "ui", - "key": "Navigation - Erkunden Sie alle verfügbaren Tools", - "value": "Navigation - Erkunden Sie alle verfügbaren Tools" - }, - { - "context": "ui", - "key": "Nein", - "value": "Nein" - }, - { - "context": "ui", - "key": "Neu starten", - "value": "Neu starten" - }, - { - "context": "ui", - "key": "Neue Automatisierung", - "value": "Neue Automatisierung" - }, - { - "context": "ui", - "key": "Neue Automatisierung erstellen", - "value": "Neue Automatisierung erstellen" - }, - { - "context": "ui", - "key": "Neue Datei hochladen", - "value": "Neue Datei hochladen" - }, - { - "context": "ui", - "key": "Neue Organisation", - "value": "Neue Organisation" - }, - { - "context": "ui", - "key": "Neue Organisation erstellen", - "value": "Neue Organisation erstellen" - }, - { - "context": "ui", - "key": "Neue Position", - "value": "Neue Position" - }, - { - "context": "ui", - "key": "Neue Position erstellen", - "value": "Neue Position erstellen" - }, - { - "context": "ui", - "key": "Neue RBAC-Regel erstellen", - "value": "Neue RBAC-Regel erstellen" - }, - { - "context": "ui", - "key": "Neue Rolle", - "value": "Neue Rolle" - }, - { - "context": "ui", - "key": "Neue Rolle erstellen", - "value": "Neue Rolle erstellen" - }, - { - "context": "ui", - "key": "Neue Sprache", - "value": "Neue Sprache" - }, - { - "context": "ui", - "key": "Neuen Prompt erstellen", - "value": "Neuen Prompt erstellen" - }, - { - "context": "ui", - "key": "Neuen Vertrag erstellen", - "value": "Neuen Vertrag erstellen" - }, - { - "context": "ui", - "key": "Neuen Zugriff erstellen", - "value": "Neuen Zugriff erstellen" - }, - { - "context": "ui", - "key": "Neuer Prompt", - "value": "Neuer Prompt" - }, - { - "context": "ui", - "key": "Neuer Vertrag", - "value": "Neuer Vertrag" - }, - { - "context": "ui", - "key": "Neuer Zugriff", - "value": "Neuer Zugriff" - }, - { - "context": "ui", - "key": "Neues Dokument", - "value": "Neues Dokument" - }, - { - "context": "ui", - "key": "Neues Dokument erstellen", - "value": "Neues Dokument erstellen" - }, - { - "context": "ui", - "key": "Neues Mandat erstellen", - "value": "Neues Mandat erstellen" - }, - { - "context": "ui", - "key": "Neues Team-Mitglied erstellen", - "value": "Neues Team-Mitglied erstellen" - }, - { - "context": "ui", - "key": "Neues Transkript", - "value": "Neues Transkript" - }, - { - "context": "ui", - "key": "Nicht verfügbar", - "value": "Nicht verfügbar" - }, - { - "context": "ui", - "key": "Noch keine Befehle ausgeführt. Senden Sie einen Befehl, um Ergebnisse hier zu sehen.", - "value": "Noch keine Befehle ausgeführt. Senden Sie einen Befehl, um Ergebnisse hier zu sehen." - }, - { - "context": "ui", - "key": "Noch keinen Workflow ausgewählt", - "value": "Noch keinen Workflow ausgewählt" - }, - { - "context": "ui", - "key": "Nochmal versuchen", - "value": "Nochmal versuchen" - }, - { - "context": "ui", - "key": "Nächste Seite", - "value": "Nächste Seite" - }, - { - "context": "ui", - "key": "Oder geben Sie Ihre Nachricht ein...", - "value": "Oder geben Sie Ihre Nachricht ein..." - }, - { - "context": "ui", - "key": "Ordnerpfade", - "value": "Ordnerpfade" - }, - { - "context": "ui", - "key": "Organisation", - "value": "Organisation" - }, - { - "context": "ui", - "key": "Organisation erfolgreich erstellt", - "value": "Organisation erfolgreich erstellt" - }, - { - "context": "ui", - "key": "Organisationen", - "value": "Organisationen" - }, - { - "context": "ui", - "key": "Originalbetrag", - "value": "Originalbetrag" - }, - { - "context": "ui", - "key": "Originalwährung", - "value": "Originalwährung" - }, - { - "context": "ui", - "key": "PDF", - "value": "PDF" - }, - { - "context": "ui", - "key": "Passwort", - "value": "Passwort" - }, - { - "context": "ui", - "key": "Passwort eingeben", - "value": "Passwort eingeben" - }, - { - "context": "ui", - "key": "Passwort-Link gesendet!", - "value": "Passwort-Link gesendet!" - }, - { - "context": "ui", - "key": "Passwort-Link senden", - "value": "Passwort-Link senden" - }, - { - "context": "ui", - "key": "Pfad", - "value": "Pfad" - }, - { - "context": "ui", - "key": "Position erfolgreich erstellt", - "value": "Position erfolgreich erstellt" - }, - { - "context": "ui", - "key": "Positionen", - "value": "Positionen" - }, - { - "context": "ui", - "key": "Postleitzahl", - "value": "Postleitzahl" - }, - { - "context": "ui", - "key": "Postleitzahl ist erforderlich", - "value": "Postleitzahl ist erforderlich" - }, - { - "context": "ui", - "key": "Projekte", - "value": "Projekte" - }, - { - "context": "ui", - "key": "Projektverwaltung", - "value": "Projektverwaltung" - }, - { - "context": "ui", - "key": "Projektverwaltung und -organisation", - "value": "Projektverwaltung und -organisation" - }, - { - "context": "ui", - "key": "Prompt", - "value": "Prompt" - }, - { - "context": "ui", - "key": "Prompt Einstellungen", - "value": "Prompt Einstellungen" - }, - { - "context": "ui", - "key": "Prompt Vorlage", - "value": "Prompt Vorlage" - }, - { - "context": "ui", - "key": "Prompt ausführen", - "value": "Prompt ausführen" - }, - { - "context": "ui", - "key": "Prompt auswählen...", - "value": "Prompt auswählen..." - }, - { - "context": "ui", - "key": "Prompt bearbeiten", - "value": "Prompt bearbeiten" - }, - { - "context": "ui", - "key": "Prompt erfolgreich erstellt", - "value": "Prompt erfolgreich erstellt" - }, - { - "context": "ui", - "key": "Prompt erstellen", - "value": "Prompt erstellen" - }, - { - "context": "ui", - "key": "Prompt hinzufügen", - "value": "Prompt hinzufügen" - }, - { - "context": "ui", - "key": "Prompt löschen", - "value": "Prompt löschen" - }, - { - "context": "ui", - "key": "Prompt teilen", - "value": "Prompt teilen" - }, - { - "context": "ui", - "key": "Prompt wird gelöscht...", - "value": "Prompt wird gelöscht..." - }, - { - "context": "ui", - "key": "Prompt-Inhalt", - "value": "Prompt-Inhalt" - }, - { - "context": "ui", - "key": "Prompt-Inhalt darf 10.000 Zeichen nicht überschreiten", - "value": "Prompt-Inhalt darf 10.000 Zeichen nicht überschreiten" - }, - { - "context": "ui", - "key": "Prompt-Inhalt darf nicht leer sein", - "value": "Prompt-Inhalt darf nicht leer sein" - }, - { - "context": "ui", - "key": "Prompt-Name", - "value": "Prompt-Name" - }, - { - "context": "ui", - "key": "Prompt-Name darf 100 Zeichen nicht überschreiten", - "value": "Prompt-Name darf 100 Zeichen nicht überschreiten" - }, - { - "context": "ui", - "key": "Prompt-Name darf nicht leer sein", - "value": "Prompt-Name darf nicht leer sein" - }, - { - "context": "ui", - "key": "Prompts", - "value": "Prompts" - }, - { - "context": "ui", - "key": "Prompts für Ihren KI-Assistenten erstellen und verwalten", - "value": "Prompts für Ihren KI-Assistenten erstellen und verwalten" - }, - { - "context": "ui", - "key": "Prompts verwalten", - "value": "Prompts verwalten" - }, - { - "context": "ui", - "key": "Prompts werden geladen...", - "value": "Prompts werden geladen..." - }, - { - "context": "ui", - "key": "Python", - "value": "Python" - }, - { - "context": "ui", - "key": "Quelle", - "value": "Quelle" - }, - { - "context": "ui", - "key": "RBAC-Regel erfolgreich erstellt", - "value": "RBAC-Regel erfolgreich erstellt" - }, - { - "context": "ui", - "key": "RBAC-Regel hinzufügen", - "value": "RBAC-Regel hinzufügen" - }, - { - "context": "ui", - "key": "RBAC-Regeln", - "value": "RBAC-Regeln" - }, - { - "context": "ui", - "key": "RBAC-Regelverwaltung", - "value": "RBAC-Regelverwaltung" - }, - { - "context": "ui", - "key": "RBAC-Rollen", - "value": "RBAC-Rollen" - }, - { - "context": "ui", - "key": "RBAC-Rollenverwaltung", - "value": "RBAC-Rollenverwaltung" - }, - { - "context": "ui", - "key": "Registrieren", - "value": "Registrieren" - }, - { - "context": "ui", - "key": "Revolutionäre Telefonie-Integration mit Spitch.ai", - "value": "Revolutionäre Telefonie-Integration mit Spitch.ai" - }, - { - "context": "ui", - "key": "Rohtext in die Zwischenablage kopieren", - "value": "Rohtext in die Zwischenablage kopieren" - }, - { - "context": "ui", - "key": "Rolle", - "value": "Rolle" - }, - { - "context": "ui", - "key": "Rolle erfolgreich erstellt", - "value": "Rolle erfolgreich erstellt" - }, - { - "context": "ui", - "key": "Rolle hinzufügen", - "value": "Rolle hinzufügen" - }, - { - "context": "ui", - "key": "Rollen", - "value": "Rollen" - }, - { - "context": "ui", - "key": "Rollen-ID", - "value": "Rollen-ID" - }, - { - "context": "ui", - "key": "Rollenbasierte Zugriffssteuerungsregeln", - "value": "Rollenbasierte Zugriffssteuerungsregeln" - }, - { - "context": "ui", - "key": "Rollenverwaltung", - "value": "Rollenverwaltung" - }, - { - "context": "ui", - "key": "Rufname am Telefon", - "value": "Rufname am Telefon" - }, - { - "context": "ui", - "key": "Runde", - "value": "Runde" - }, - { - "context": "ui", - "key": "Runden", - "value": "Runden" - }, - { - "context": "ui", - "key": "Schließen", - "value": "Schließen" - }, - { - "context": "ui", - "key": "Schnellzugriff", - "value": "Schnellzugriff" - }, - { - "context": "ui", - "key": "Schnellzugriff - Springen Sie zu häufig verwendeten Features", - "value": "Schnellzugriff - Springen Sie zu häufig verwendeten Features" - }, - { - "context": "ui", - "key": "Seite", - "value": "Seite" - }, - { - "context": "ui", - "key": "Seite {page} von {total} ({count} Einträge)", - "value": "Seite {page} von {total} ({count} Einträge)" - }, - { - "context": "ui", - "key": "Senden", - "value": "Senden" - }, - { - "context": "ui", - "key": "Service", - "value": "Service" - }, - { - "context": "ui", - "key": "Service-Verbindungen", - "value": "Service-Verbindungen" - }, - { - "context": "ui", - "key": "SharePoint Dokumente", - "value": "SharePoint Dokumente" - }, - { - "context": "ui", - "key": "SharePoint Site URL", - "value": "SharePoint Site URL" - }, - { - "context": "ui", - "key": "SharePoint Test", - "value": "SharePoint Test" - }, - { - "context": "ui", - "key": "Sie erhalten in den nächsten Minuten eine Bestätigungs-E-Mail.", - "value": "Sie erhalten in den nächsten Minuten eine Bestätigungs-E-Mail." - }, - { - "context": "ui", - "key": "Sie können auch auf den Upload-Button klicken", - "value": "Sie können auch auf den Upload-Button klicken" - }, - { - "context": "ui", - "key": "Sie müssen sich zuerst für die Sprach-Integration anmelden, um auf die Transkriptverwaltung zuzugreifen.", - "value": "Sie müssen sich zuerst für die Sprach-Integration anmelden, um auf die Transkriptverwaltung zuzugreifen." - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie \"{name}\" löschen möchten?", - "value": "Sind Sie sicher, dass Sie \"{name}\" löschen möchten?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie Workflow \"{id}...\" löschen möchten?", - "value": "Sind Sie sicher, dass Sie Workflow \"{id}...\" löschen möchten?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie alle Sprach-Integrations-Einstellungen zurücksetzen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", - "value": "Sind Sie sicher, dass Sie alle Sprach-Integrations-Einstellungen zurücksetzen möchten? Diese Aktion kann nicht rückgängig gemacht werden." - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie das ausgewählte Element löschen möchten?", - "value": "Sind Sie sicher, dass Sie das ausgewählte Element löschen möchten?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie den Workflow \"{name}\" löschen möchten?", - "value": "Sind Sie sicher, dass Sie den Workflow \"{name}\" löschen möchten?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie die Datei \"{name}\" löschen möchten?", - "value": "Sind Sie sicher, dass Sie die Datei \"{name}\" löschen möchten?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie die {count} ausgewählten Elemente löschen möchten?", - "value": "Sind Sie sicher, dass Sie die {count} ausgewählten Elemente löschen möchten?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie die {service} Verbindung löschen möchten?", - "value": "Sind Sie sicher, dass Sie die {service} Verbindung löschen möchten?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie diesen Benutzer löschen möchten?", - "value": "Sind Sie sicher, dass Sie diesen Benutzer löschen möchten?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie {count} Benutzer löschen möchten?", - "value": "Sind Sie sicher, dass Sie {count} Benutzer löschen möchten?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie {count} Prompts löschen möchten?", - "value": "Sind Sie sicher, dass Sie {count} Prompts löschen möchten?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie {count} Verbindungen löschen möchten?", - "value": "Sind Sie sicher, dass Sie {count} Verbindungen löschen möchten?" - }, - { - "context": "ui", - "key": "Sites entdecken", - "value": "Sites entdecken" - }, - { - "context": "ui", - "key": "Sortierung {position}: {direction}", - "value": "Sortierung {position}: {direction}" - }, - { - "context": "ui", - "key": "Speech Analytics (SA)", - "value": "Speech Analytics (SA)" - }, - { - "context": "ui", - "key": "Speichern", - "value": "Speichern" - }, - { - "context": "ui", - "key": "Speichern...", - "value": "Speichern..." - }, - { - "context": "ui", - "key": "Spitch prüft vor jedem Anruf die Mandantenberechtigung bei PowerOn, während alle Datenänderungen zentral von PowerOn initiiert werden. Call-Transkripte werden in Echtzeit in Ihrer PowerOn-Datenbank gespeichert, mit vollständiger Mandantenisolation und Sicherheit. Bei Ausfällen werden Anrufe automatisch blockiert, um die Integrität zu gewährleisten.", - "value": "Spitch prüft vor jedem Anruf die Mandantenberechtigung bei PowerOn, während alle Datenänderungen zentral von PowerOn initiiert werden. Call-Transkripte werden in Echtzeit in Ihrer PowerOn-Datenbank gespeichert, mit vollständiger Mandantenisolation und Sicherheit. Bei Ausfällen werden Anrufe automatisch blockiert, um die Integrität zu gewährleisten." - }, - { - "context": "ui", - "key": "Sprach Integration", - "value": "Sprach Integration" - }, - { - "context": "ui", - "key": "Sprach-Einstellungen", - "value": "Sprach-Einstellungen" - }, - { - "context": "ui", - "key": "Sprach-Integration Einstellungen", - "value": "Sprach-Integration Einstellungen" - }, - { - "context": "ui", - "key": "Sprache", - "value": "Sprache" - }, - { - "context": "ui", - "key": "Sprachset {code} wirklich löschen?", - "value": "Sprachset {code} wirklich löschen?" - }, - { - "context": "ui", - "key": "Stadt", - "value": "Stadt" - }, - { - "context": "ui", - "key": "Stadt ist erforderlich", - "value": "Stadt ist erforderlich" - }, - { - "context": "ui", - "key": "Start", - "value": "Start" - }, - { - "context": "ui", - "key": "Startzeit", - "value": "Startzeit" - }, - { - "context": "ui", - "key": "Status", - "value": "Status" - }, - { - "context": "ui", - "key": "Stellen Sie alles, was Ihre Agenten benötigen, in ihren Händen bereit, mit einem einheitlichen Agent-Desktop.", - "value": "Stellen Sie alles, was Ihre Agenten benötigen, in ihren Händen bereit, mit einem einheitlichen Agent-Desktop." - }, - { - "context": "ui", - "key": "Stoppen", - "value": "Stoppen" - }, - { - "context": "ui", - "key": "Straße", - "value": "Straße" - }, - { - "context": "ui", - "key": "Straße ist erforderlich", - "value": "Straße ist erforderlich" - }, - { - "context": "ui", - "key": "Suchen Sie nach Standorten über Adresse oder Koordinaten, oder verwenden Sie natürliche Sprache, um Projekte zu erstellen und zu verwalten.", - "value": "Suchen Sie nach Standorten über Adresse oder Koordinaten, oder verwenden Sie natürliche Sprache, um Projekte zu erstellen und zu verwalten." - }, - { - "context": "ui", - "key": "Suchen...", - "value": "Suchen..." - }, - { - "context": "ui", - "key": "Systemadministrator", - "value": "Systemadministrator" - }, - { - "context": "ui", - "key": "Systemeinstellungen - Arbeitsbereich-Einstellungen konfigurieren", - "value": "Systemeinstellungen - Arbeitsbereich-Einstellungen konfigurieren" - }, - { - "context": "ui", - "key": "Tabelle", - "value": "Tabelle" - }, - { - "context": "ui", - "key": "Tags", - "value": "Tags" - }, - { - "context": "ui", - "key": "Team-Bereich", - "value": "Team-Bereich" - }, - { - "context": "ui", - "key": "Team-Mitglied erfolgreich erstellt", - "value": "Team-Mitglied erfolgreich erstellt" - }, - { - "context": "ui", - "key": "Team-Mitglieder", - "value": "Team-Mitglieder" - }, - { - "context": "ui", - "key": "Team-Mitglieder verwalten", - "value": "Team-Mitglieder verwalten" - }, - { - "context": "ui", - "key": "Team-Mitglieder verwalten, Berechtigungen festlegen und Zusammenarbeitseinstellungen konfigurieren", - "value": "Team-Mitglieder verwalten, Berechtigungen festlegen und Zusammenarbeitseinstellungen konfigurieren" - }, - { - "context": "ui", - "key": "Teilen", - "value": "Teilen" - }, - { - "context": "ui", - "key": "Telefon", - "value": "Telefon" - }, - { - "context": "ui", - "key": "Telefonnummer", - "value": "Telefonnummer" - }, - { - "context": "ui", - "key": "Telefonnummer ist erforderlich", - "value": "Telefonnummer ist erforderlich" - }, - { - "context": "ui", - "key": "Text", - "value": "Text" - }, - { - "context": "ui", - "key": "Textvorschau", - "value": "Textvorschau" - }, - { - "context": "ui", - "key": "Theme", - "value": "Theme" - }, - { - "context": "ui", - "key": "Token", - "value": "Token" - }, - { - "context": "ui", - "key": "Transkript", - "value": "Transkript" - }, - { - "context": "ui", - "key": "Transkript wird verarbeitet...", - "value": "Transkript wird verarbeitet..." - }, - { - "context": "ui", - "key": "Transkriptverwaltung", - "value": "Transkriptverwaltung" - }, - { - "context": "ui", - "key": "Trennungsfehler", - "value": "Trennungsfehler" - }, - { - "context": "ui", - "key": "Treuhand", - "value": "Treuhand" - }, - { - "context": "ui", - "key": "Treuhandverwaltung", - "value": "Treuhandverwaltung" - }, - { - "context": "ui", - "key": "Trustee-Organisationen verwalten", - "value": "Trustee-Organisationen verwalten" - }, - { - "context": "ui", - "key": "Trustee-Rollen verwalten", - "value": "Trustee-Rollen verwalten" - }, - { - "context": "ui", - "key": "Typ", - "value": "Typ" - }, - { - "context": "ui", - "key": "UI-Sprachen", - "value": "UI-Sprachen" - }, - { - "context": "ui", - "key": "Unbekannt", - "value": "Unbekannt" - }, - { - "context": "ui", - "key": "Unbekannte Größe", - "value": "Unbekannte Größe" - }, - { - "context": "ui", - "key": "Unbekanntes Datum", - "value": "Unbekanntes Datum" - }, - { - "context": "ui", - "key": "Unbenannt", - "value": "Unbenannt" - }, - { - "context": "ui", - "key": "Unbenannter Workflow", - "value": "Unbenannter Workflow" - }, - { - "context": "ui", - "key": "Ungültige Auswahl", - "value": "Ungültige Auswahl" - }, - { - "context": "ui", - "key": "Ungültige URL", - "value": "Ungültige URL" - }, - { - "context": "ui", - "key": "Ungültiges Datum", - "value": "Ungültiges Datum" - }, - { - "context": "ui", - "key": "Ungültiges Datumsformat", - "value": "Ungültiges Datumsformat" - }, - { - "context": "ui", - "key": "Ungültiges E-Mail-Format", - "value": "Ungültiges E-Mail-Format" - }, - { - "context": "ui", - "key": "Ungültiges JSON", - "value": "Ungültiges JSON" - }, - { - "context": "ui", - "key": "Unser Team wird Ihr Mandat innerhalb von 1-2 Werktagen überprüfen.", - "value": "Unser Team wird Ihr Mandat innerhalb von 1-2 Werktagen überprüfen." - }, - { - "context": "ui", - "key": "Unsere bereits aktive Dokumenten-Extraktions-Engine generiert automatisch personalisierte Dokumente für Spitch, basierend auf Mandantenspezifischen Daten. Die KI nutzt FAQ-Datenbanken, Mitarbeiterinformationen und Service-Details, um jeden Anruf kontextuell und hochpersonalisiert zu gestalten.", - "value": "Unsere bereits aktive Dokumenten-Extraktions-Engine generiert automatisch personalisierte Dokumente für Spitch, basierend auf Mandantenspezifischen Daten. Die KI nutzt FAQ-Datenbanken, Mitarbeiterinformationen und Service-Details, um jeden Anruf kontextuell und hochpersonalisiert zu gestalten." - }, - { - "context": "ui", - "key": "Unternehmensinformationen", - "value": "Unternehmensinformationen" - }, - { - "context": "ui", - "key": "Unterstützt von", - "value": "Unterstützt von" - }, - { - "context": "ui", - "key": "Upload fehlgeschlagen. Bitte versuchen Sie es erneut.", - "value": "Upload fehlgeschlagen. Bitte versuchen Sie es erneut." - }, - { - "context": "ui", - "key": "VERARBEITUNG", - "value": "VERARBEITUNG" - }, - { - "context": "ui", - "key": "Valutadatum", - "value": "Valutadatum" - }, - { - "context": "ui", - "key": "Verarbeitung", - "value": "Verarbeitung" - }, - { - "context": "ui", - "key": "Verbinden", - "value": "Verbinden" - }, - { - "context": "ui", - "key": "Verbindung aktualisieren", - "value": "Verbindung aktualisieren" - }, - { - "context": "ui", - "key": "Verbindung testen", - "value": "Verbindung testen" - }, - { - "context": "ui", - "key": "Verbindungen", - "value": "Verbindungen" - }, - { - "context": "ui", - "key": "Verbindungen werden geladen...", - "value": "Verbindungen werden geladen..." - }, - { - "context": "ui", - "key": "Verbindungsfehler", - "value": "Verbindungsfehler" - }, - { - "context": "ui", - "key": "Verbunden am", - "value": "Verbunden am" - }, - { - "context": "ui", - "key": "Vereinheitlichen und liefern Sie Informationen an Ihre Kunden und Mitarbeiter, wann und wo sie sie benötigen.", - "value": "Vereinheitlichen und liefern Sie Informationen an Ihre Kunden und Mitarbeiter, wann und wo sie sie benötigen." - }, - { - "context": "ui", - "key": "Verfügbare Tools", - "value": "Verfügbare Tools" - }, - { - "context": "ui", - "key": "Verfügbare Workflows", - "value": "Verfügbare Workflows" - }, - { - "context": "ui", - "key": "Version", - "value": "Version" - }, - { - "context": "ui", - "key": "Versuchen Sie, Ihr Microsoft-Konto auf der Verbindungsseite erneut zu verbinden.", - "value": "Versuchen Sie, Ihr Microsoft-Konto auf der Verbindungsseite erneut zu verbinden." - }, - { - "context": "ui", - "key": "Vertrag", - "value": "Vertrag" - }, - { - "context": "ui", - "key": "Vertrag (optional)", - "value": "Vertrag (optional)" - }, - { - "context": "ui", - "key": "Vertrag erfolgreich erstellt", - "value": "Vertrag erfolgreich erstellt" - }, - { - "context": "ui", - "key": "Verträge", - "value": "Verträge" - }, - { - "context": "ui", - "key": "Verwalten Sie Daten über Tabellen. Wählen Sie eine Tabelle aus oder verwenden Sie natürliche Sprache, um Befehle auszuführen.", - "value": "Verwalten Sie Daten über Tabellen. Wählen Sie eine Tabelle aus oder verwenden Sie natürliche Sprache, um Befehle auszuführen." - }, - { - "context": "ui", - "key": "Verwalten Sie Ihre Kontoinformationen", - "value": "Verwalten Sie Ihre Kontoinformationen" - }, - { - "context": "ui", - "key": "Verwalten Sie Ihre Service-Verbindungen", - "value": "Verwalten Sie Ihre Service-Verbindungen" - }, - { - "context": "ui", - "key": "Verwalten Sie Ihre Sprach-Integrations-Konfiguration und Einstellungen.", - "value": "Verwalten Sie Ihre Sprach-Integrations-Konfiguration und Einstellungen." - }, - { - "context": "ui", - "key": "Verwalten Sie Mandate und deren zugehörige Berechtigungen.", - "value": "Verwalten Sie Mandate und deren zugehörige Berechtigungen." - }, - { - "context": "ui", - "key": "Verwaltet von {provider}", - "value": "Verwaltet von {provider}" - }, - { - "context": "ui", - "key": "Verwaltung der Benutzerzugriffe auf Organisationen", - "value": "Verwaltung der Benutzerzugriffe auf Organisationen" - }, - { - "context": "ui", - "key": "Verwaltung der Buchungspositionen (Speseneinträge)", - "value": "Verwaltung der Buchungspositionen (Speseneinträge)" - }, - { - "context": "ui", - "key": "Verwaltung der Dokumente und Belege", - "value": "Verwaltung der Dokumente und Belege" - }, - { - "context": "ui", - "key": "Verwaltung der Feature-spezifischen Rollen", - "value": "Verwaltung der Feature-spezifischen Rollen" - }, - { - "context": "ui", - "key": "Verwaltung der Kundenverträge", - "value": "Verwaltung der Kundenverträge" - }, - { - "context": "ui", - "key": "Verwaltung der Treuhand-Organisationen", - "value": "Verwaltung der Treuhand-Organisationen" - }, - { - "context": "ui", - "key": "Verwaltung von Treuhand-Organisationen, Verträgen und Buchungen", - "value": "Verwaltung von Treuhand-Organisationen, Verträgen und Buchungen" - }, - { - "context": "ui", - "key": "Verwaltungs- und Management-Tools", - "value": "Verwaltungs- und Management-Tools" - }, - { - "context": "ui", - "key": "Verwende Vorlage:", - "value": "Verwende Vorlage:" - }, - { - "context": "ui", - "key": "Video", - "value": "Video" - }, - { - "context": "ui", - "key": "Vielen Dank für Ihr Interesse an unserer Sprach Integration powered by Spitch.ai. Wir haben Ihr Mandat erhalten und werden es in Kürze überprüfen.", - "value": "Vielen Dank für Ihr Interesse an unserer Sprach Integration powered by Spitch.ai. Wir haben Ihr Mandat erhalten und werden es in Kürze überprüfen." - }, - { - "context": "ui", - "key": "Virtual Assistant (VA)", - "value": "Virtual Assistant (VA)" - }, - { - "context": "ui", - "key": "Voice Biometrics (VB)", - "value": "Voice Biometrics (VB)" - }, - { - "context": "ui", - "key": "Vollständiger Name", - "value": "Vollständiger Name" - }, - { - "context": "ui", - "key": "Von", - "value": "Von" - }, - { - "context": "ui", - "key": "Von der Registrierung bis zur technischen Einrichtung - Ihr Mandant registriert sich bei PowerOn für Telefonie-Services, lädt Dokumente hoch und erhält automatisch eine technische SIP-Nummer von Spitch. Die Call-Weiterleitung kann jederzeit aktiviert oder deaktiviert werden, was maximale Flexibilität und BCM-Sicherheit gewährleistet.", - "value": "Von der Registrierung bis zur technischen Einrichtung - Ihr Mandant registriert sich bei PowerOn für Telefonie-Services, lädt Dokumente hoch und erhält automatisch eine technische SIP-Nummer von Spitch. Die Call-Weiterleitung kann jederzeit aktiviert oder deaktiviert werden, was maximale Flexibilität und BCM-Sicherheit gewährleistet." - }, - { - "context": "ui", - "key": "Vorherige Seite", - "value": "Vorherige Seite" - }, - { - "context": "ui", - "key": "Vorschau", - "value": "Vorschau" - }, - { - "context": "ui", - "key": "Vorschau für diesen Dateityp nicht verfügbar", - "value": "Vorschau für diesen Dateityp nicht verfügbar" - }, - { - "context": "ui", - "key": "Vorschau schließen", - "value": "Vorschau schließen" - }, - { - "context": "ui", - "key": "Vorschau wird geladen...", - "value": "Vorschau wird geladen..." - }, - { - "context": "ui", - "key": "WARTEND", - "value": "WARTEND" - }, - { - "context": "ui", - "key": "Wartend", - "value": "Wartend" - }, - { - "context": "ui", - "key": "Was passiert als nächstes?", - "value": "Was passiert als nächstes?" - }, - { - "context": "ui", - "key": "Wechseln Sie zwischen hellem und dunklem Modus", - "value": "Wechseln Sie zwischen hellem und dunklem Modus" - }, - { - "context": "ui", - "key": "Werkzeuge", - "value": "Werkzeuge" - }, - { - "context": "ui", - "key": "Werkzeuge und Hilfsmittel", - "value": "Werkzeuge und Hilfsmittel" - }, - { - "context": "ui", - "key": "Wie möchten Sie am Telefon genannt werden?", - "value": "Wie möchten Sie am Telefon genannt werden?" - }, - { - "context": "ui", - "key": "Wiederholen", - "value": "Wiederholen" - }, - { - "context": "ui", - "key": "Willkommen in Ihrem Arbeitsbereich", - "value": "Willkommen in Ihrem Arbeitsbereich" - }, - { - "context": "ui", - "key": "Wird gesendet...", - "value": "Wird gesendet..." - }, - { - "context": "ui", - "key": "Wird gestoppt...", - "value": "Wird gestoppt..." - }, - { - "context": "ui", - "key": "Wird geteilt...", - "value": "Wird geteilt..." - }, - { - "context": "ui", - "key": "Wird hochgeladen...", - "value": "Wird hochgeladen..." - }, - { - "context": "ui", - "key": "Wird verarbeitet...", - "value": "Wird verarbeitet..." - }, - { - "context": "ui", - "key": "Workflow", - "value": "Workflow" - }, - { - "context": "ui", - "key": "Workflow Fortschritt", - "value": "Workflow Fortschritt" - }, - { - "context": "ui", - "key": "Workflow auswählen", - "value": "Workflow auswählen" - }, - { - "context": "ui", - "key": "Workflow fehlgeschlagen.", - "value": "Workflow fehlgeschlagen." - }, - { - "context": "ui", - "key": "Workflow fortsetzen", - "value": "Workflow fortsetzen" - }, - { - "context": "ui", - "key": "Workflow läuft... Warte auf Logs...", - "value": "Workflow läuft... Warte auf Logs..." - }, - { - "context": "ui", - "key": "Workflow löschen", - "value": "Workflow löschen" - }, - { - "context": "ui", - "key": "Workflow stoppen", - "value": "Workflow stoppen" - }, - { - "context": "ui", - "key": "Workflow wird fortgesetzt", - "value": "Workflow wird fortgesetzt" - }, - { - "context": "ui", - "key": "Workflow wird gelöscht...", - "value": "Workflow wird gelöscht..." - }, - { - "context": "ui", - "key": "Workflow-Automatisierungen verwalten", - "value": "Workflow-Automatisierungen verwalten" - }, - { - "context": "ui", - "key": "Workflow-Nachrichten werden geladen...", - "value": "Workflow-Nachrichten werden geladen..." - }, - { - "context": "ui", - "key": "Workflow-Verlauf", - "value": "Workflow-Verlauf" - }, - { - "context": "ui", - "key": "Workflows", - "value": "Workflows" - }, - { - "context": "ui", - "key": "Workflows werden geladen...", - "value": "Workflows werden geladen..." - }, - { - "context": "ui", - "key": "Wähle einen Workflow aus der Liste aus oder starte einen neuen Workflow", - "value": "Wähle einen Workflow aus der Liste aus oder starte einen neuen Workflow" - }, - { - "context": "ui", - "key": "Wählen Sie Ihre bevorzugte Sprache", - "value": "Wählen Sie Ihre bevorzugte Sprache" - }, - { - "context": "ui", - "key": "You", - "value": "You" - }, - { - "context": "ui", - "key": "Zeitzone", - "value": "Zeitzone" - }, - { - "context": "ui", - "key": "Zentrale", - "value": "Zentrale" - }, - { - "context": "ui", - "key": "Zu dunklem Modus wechseln", - "value": "Zu dunklem Modus wechseln" - }, - { - "context": "ui", - "key": "Zu hellem Modus wechseln", - "value": "Zu hellem Modus wechseln" - }, - { - "context": "ui", - "key": "Zugriff", - "value": "Zugriff" - }, - { - "context": "ui", - "key": "Zugriff erfolgreich erstellt", - "value": "Zugriff erfolgreich erstellt" - }, - { - "context": "ui", - "key": "Zugriff verweigert", - "value": "Zugriff verweigert" - }, - { - "context": "ui", - "key": "Zuletzt geprüft", - "value": "Zuletzt geprüft" - }, - { - "context": "ui", - "key": "Zum Bestätigen klicken", - "value": "Zum Bestätigen klicken" - }, - { - "context": "ui", - "key": "Zum Bestätigen klicken...", - "value": "Zum Bestätigen klicken..." - }, - { - "context": "ui", - "key": "Zum Ein-/Ausklappen klicken", - "value": "Zum Ein-/Ausklappen klicken" - }, - { - "context": "ui", - "key": "Zum Filtern klicken", - "value": "Zum Filtern klicken" - }, - { - "context": "ui", - "key": "Zum Sortieren klicken", - "value": "Zum Sortieren klicken" - }, - { - "context": "ui", - "key": "Zurück zur Sprach Integration", - "value": "Zurück zur Sprach Integration" - }, - { - "context": "ui", - "key": "angehängt", - "value": "angehängt" - }, - { - "context": "ui", - "key": "ausgewählt", - "value": "ausgewählt" - }, - { - "context": "ui", - "key": "k. A.", - "value": "k. A." - }, - { - "context": "ui", - "key": "kontakt@firma.com", - "value": "kontakt@firma.com" - }, - { - "context": "ui", - "key": "oder", - "value": "oder" - }, - { - "context": "ui", - "key": "z.B. Beleg.pdf", - "value": "z.B. Beleg.pdf" - }, - { - "context": "ui", - "key": "z.B. Finanzdienstleistungen, Technologie, etc.", - "value": "z.B. Finanzdienstleistungen, Technologie, etc." - }, - { - "context": "ui", - "key": "z.B. Muster AG 2026", - "value": "z.B. Muster AG 2026" - }, - { - "context": "ui", - "key": "z.B. Treuhand AG Zürich", - "value": "z.B. Treuhand AG Zürich" - }, - { - "context": "ui", - "key": "z.B. admin, operate, userreport", - "value": "z.B. admin, operate, userreport" - }, - { - "context": "ui", - "key": "z.B. treuhand-ag-zuerich", - "value": "z.B. treuhand-ag-zuerich" - }, - { - "context": "ui", - "key": "{authority} Verbindung bearbeiten", - "value": "{authority} Verbindung bearbeiten" - }, - { - "context": "ui", - "key": "{column} filtern", - "value": "{column} filtern" - }, - { - "context": "ui", - "key": "{count} Benutzer ausgewählt", - "value": "{count} Benutzer ausgewählt" - }, - { - "context": "ui", - "key": "{fieldLabel} ist erforderlich", - "value": "{fieldLabel} ist erforderlich" - }, - { - "context": "ui", - "key": "{fieldLabel} muss eine gültige Ganzzahl sein", - "value": "{fieldLabel} muss eine gültige Ganzzahl sein" - }, - { - "context": "ui", - "key": "{fieldLabel} muss eine gültige Zahl sein", - "value": "{fieldLabel} muss eine gültige Zahl sein" - }, - { - "context": "ui", - "key": "Änderungen speichern", - "value": "Änderungen speichern" - }, - { - "context": "ui", - "key": "Über", - "value": "Über" - }, - { - "context": "ui", - "key": "Überprüfungsprozess", - "value": "Überprüfungsprozess" - }, - { - "context": "ui", - "key": "Übersicht - Sehen Sie den Arbeitsbereich-Status und Updates", - "value": "Übersicht - Sehen Sie den Arbeitsbereich-Status und Updates" - }, - { - "context": "ui", - "key": "Überwachen Sie automatisch 100% der Gespräche, um wertvolle Einblicke für Ihr Unternehmen zu erhalten.", - "value": "Überwachen Sie automatisch 100% der Gespräche, um wertvolle Einblicke für Ihr Unternehmen zu erhalten." - }, - { - "context": "ui", - "key": "(gefiltert nach {name})", - "value": "(gefiltert nach {name})" - }, - { - "context": "ui", - "key": "({count} gefiltert)", - "value": "({count} gefiltert)" - }, - { - "context": "ui", - "key": "Abonnement, Einstellungen und Guthaben pro Mandant", - "value": "Abonnement, Einstellungen und Guthaben pro Mandant" - }, - { - "context": "ui", - "key": "Abrechnung", - "value": "Abrechnung" - }, - { - "context": "ui", - "key": "Aktion", - "value": "Aktion" - }, - { - "context": "ui", - "key": "Benutzer-Billing", - "value": "Benutzer-Billing" - }, - { - "context": "ui", - "key": "Benutzer-Guthaben", - "value": "Benutzer-Guthaben" - }, - { - "context": "ui", - "key": "Benutzer:", - "value": "Benutzer:" - }, - { - "context": "ui", - "key": "Deaktiviert", - "value": "Deaktiviert" - }, - { - "context": "ui", - "key": "Du hast Zugriff auf {instanceCount} {instanceWord} in {mandateCount} {mandateWord}.", - "value": "Du hast Zugriff auf {instanceCount} {instanceWord} in {mandateCount} {mandateWord}." - }, - { - "context": "ui", - "key": "Einstellungen gespeichert!", - "value": "Einstellungen gespeichert!" - }, - { - "context": "ui", - "key": "Feature-Instanz", - "value": "Feature-Instanz" - }, - { - "context": "ui", - "key": "Feature-Instanzen", - "value": "Feature-Instanzen" - }, - { - "context": "ui", - "key": "Fehler beim Speichern", - "value": "Fehler beim Speichern" - }, - { - "context": "ui", - "key": "Gesamtguthaben", - "value": "Gesamtguthaben" - }, - { - "context": "ui", - "key": "Mandant:", - "value": "Mandant:" - }, - { - "context": "ui", - "key": "Mandanten", - "value": "Mandanten" - }, - { - "context": "ui", - "key": "Mandanten-Billing", - "value": "Mandanten-Billing" - }, - { - "context": "ui", - "key": "Mandanten-Guthaben", - "value": "Mandanten-Guthaben" - }, - { - "context": "ui", - "key": "Mandant", - "value": "Mandant" - }, - { - "context": "ui", - "key": "Niedrig", - "value": "Niedrig" - }, - { - "context": "ui", - "key": "Transaktionen", - "value": "Transaktionen" - }, - { - "context": "ui", - "key": "Warnschwelle", - "value": "Warnschwelle" - }, - { - "context": "ui", - "key": "Ansicht an Fenster anpassen", - "value": "Ansicht an Fenster anpassen" - }, - { - "context": "ui", - "key": "Ansicht zurücksetzen", - "value": "Ansicht zurücksetzen" - }, - { - "context": "ui", - "key": "Auswahl löschen", - "value": "Auswahl löschen" - }, - { - "context": "ui", - "key": "Canvas bearbeiten", - "value": "Canvas bearbeiten" - }, - { - "context": "ui", - "key": "Klicken Sie auf einen Ausgang, dann auf einen Eingang", - "value": "Klicken Sie auf einen Ausgang, dann auf einen Eingang" - }, - { - "context": "ui", - "key": "Klicken Sie auf einen Eingang, um die Verbindung zu erstellen", - "value": "Klicken Sie auf einen Eingang, um die Verbindung zu erstellen" - }, - { - "context": "ui", - "key": "Kommentar (optional)", - "value": "Kommentar (optional)" - }, - { - "context": "ui", - "key": "Kommentar bearbeiten", - "value": "Kommentar bearbeiten" - }, - { - "context": "ui", - "key": "Knoten duplizieren", - "value": "Knoten duplizieren" - }, - { - "context": "ui", - "key": "Rückgängig", - "value": "Rückgängig" - }, - { - "context": "ui", - "key": "Verbindungen zeichnen", - "value": "Verbindungen zeichnen" - }, - { - "context": "ui", - "key": "Vergrößern", - "value": "Vergrößern" - }, - { - "context": "ui", - "key": "Verkleinern", - "value": "Verkleinern" - }, - { - "context": "ui", - "key": "Wiederholen", - "value": "Wiederholen" - }, - { - "context": "ui", - "key": "Zoom-Voreinstellungen", - "value": "Zoom-Voreinstellungen" - }, - { - "context": "ui", - "key": "Zoomstufe (Prozent)", - "value": "Zoomstufe (Prozent)" - }, - { - "context": "ui", - "key": "Doppelklick zum Bearbeiten", - "value": "Doppelklick zum Bearbeiten" - }, - { - "context": "ui", - "key": "Kommentar auf dem Canvas einfügen", - "value": "Kommentar auf dem Canvas einfügen" - }, - { - "context": "ui", - "key": "Kommentar eingeben …", - "value": "Kommentar eingeben …" - }, - { - "context": "ui", - "key": "Canvas-Notiz verschieben", - "value": "Zum Verschieben greifen" - }, - { - "context": "ui", - "key": "Notizfarbe", - "value": "Notizfarbe" - }, - { - "context": "ui", - "key": "Notizgröße ändern", - "value": "Notizgröße ändern" - }, - { - "context": "ui", - "key": "✓ Mandat eingereicht", - "value": "✓ Mandat eingereicht" - } - ], - "status": "complete", - "isDefault": false - }, - { - "id": "en", - "label": "English", - "entries": [ - { - "context": "ui", - "key": "+41 123 456 789", - "value": "+41 123 456 789" - }, - { - "context": "ui", - "key": "1 Benutzer ausgewählt", - "value": "1 user selected" - }, - { - "context": "ui", - "key": "ABGEBROCHEN", - "value": "CANCELLED" - }, - { - "context": "ui", - "key": "ABGESCHLOSSEN", - "value": "COMPLETED" - }, - { - "context": "ui", - "key": "Abbrechen", - "value": "Cancel" - }, - { - "context": "ui", - "key": "Abgeschlossen", - "value": "Completed" - }, - { - "context": "ui", - "key": "Abmelden", - "value": "Logout" - }, - { - "context": "ui", - "key": "Admin-Einstellungen", - "value": "Admin Settings" - }, - { - "context": "ui", - "key": "Administrative Einstellungen", - "value": "Administrative settings" - }, - { - "context": "ui", - "key": "Administrator", - "value": "Admin" - }, - { - "context": "ui", - "key": "Adresse", - "value": "Address" - }, - { - "context": "ui", - "key": "Agent Assist (AA)", - "value": "Agent Assist (AA)" - }, - { - "context": "ui", - "key": "Aktionen", - "value": "Actions" - }, - { - "context": "ui", - "key": "Aktiv", - "value": "Active" - }, - { - "context": "ui", - "key": "Aktiviert", - "value": "Enabled" - }, - { - "context": "ui", - "key": "Aktualisieren", - "value": "Update" - }, - { - "context": "ui", - "key": "Aktuelle Transkripte", - "value": "Recent Transcripts" - }, - { - "context": "ui", - "key": "Alle Dateien", - "value": "All Files" - }, - { - "context": "ui", - "key": "Alle Elemente auswählen", - "value": "Select all items" - }, - { - "context": "ui", - "key": "Alle Nicht-Standard-Sprachsets jetzt mit dem deutschen Master synchronisieren?", - "value": "Synchronize all non-default language sets with the German master now?" - }, - { - "context": "ui", - "key": "Alle abwählen", - "value": "Deselect all" - }, - { - "context": "ui", - "key": "Alle aktualisieren", - "value": "Update all" - }, - { - "context": "ui", - "key": "Alle auswählen", - "value": "Select all" - }, - { - "context": "ui", - "key": "Analysiere Workflow...", - "value": "Analyzing workflow..." - }, - { - "context": "ui", - "key": "Anmelden", - "value": "Login" - }, - { - "context": "ui", - "key": "Anrufer", - "value": "Caller" - }, - { - "context": "ui", - "key": "Anzeigen", - "value": "View" - }, - { - "context": "ui", - "key": "Anzeigename", - "value": "Display name" - }, - { - "context": "ui", - "key": "Audio", - "value": "Audio" - }, - { - "context": "ui", - "key": "Auf Standard zurücksetzen", - "value": "Reset to Default" - }, - { - "context": "ui", - "key": "Aufgaben", - "value": "Tasks" - }, - { - "context": "ui", - "key": "Ausführen", - "value": "Execute" - }, - { - "context": "ui", - "key": "Ausgewählte Datei:", - "value": "Selected file:" - }, - { - "context": "ui", - "key": "Auth-Anbieter", - "value": "Auth Authority" - }, - { - "context": "ui", - "key": "Authentifizierungsanbieter", - "value": "Authentication Provider" - }, - { - "context": "ui", - "key": "Authentifizierungstoken abgelaufen oder ungültig. Bitte verbinden Sie Ihr Microsoft-Konto erneut.", - "value": "Authentication token expired or invalid. Please reconnect your Microsoft account." - }, - { - "context": "ui", - "key": "Automatisierung erfolgreich erstellt", - "value": "Automation created successfully" - }, - { - "context": "ui", - "key": "Automatisierungen", - "value": "Automations" - }, - { - "context": "ui", - "key": "Basisdaten", - "value": "Base Data" - }, - { - "context": "ui", - "key": "Bearbeiten", - "value": "Edit" - }, - { - "context": "ui", - "key": "Befehl eingeben (z.B., \"Erstelle ein neues Projekt namens 'Hauptstrasse 42'\")", - "value": "Enter a command (e.g., \"Create a new project named 'Main Street 42'\")" - }, - { - "context": "ui", - "key": "Beginne ein Gespräch, indem du eine Nachricht eingibst, eine Vorlage auswählst oder einen vorherigen Workflow fortsetzt …", - "value": "Start a conversation by entering a message, selecting a template, or continuing a previous workflow..." - }, - { - "context": "ui", - "key": "Beginnen Sie mit:", - "value": "Get started with:" - }, - { - "context": "ui", - "key": "Bei Genehmigung planen wir einen Einrichtungsanruf zur Konfiguration Ihrer Integration.", - "value": "If approved, we'll schedule a setup call to configure your integration." - }, - { - "context": "ui", - "key": "Beim Hochladen ist ein Fehler aufgetreten.", - "value": "An error occurred while uploading." - }, - { - "context": "ui", - "key": "Beim Hochladen ist ein unerwarteter Fehler aufgetreten.", - "value": "An unexpected error occurred while uploading." - }, - { - "context": "ui", - "key": "Belege verwalten", - "value": "Manage receipts" - }, - { - "context": "ui", - "key": "Benutzer", - "value": "User" - }, - { - "context": "ui", - "key": "Benutzer auswählen", - "value": "Select Users" - }, - { - "context": "ui", - "key": "Benutzer bearbeiten", - "value": "Edit User" - }, - { - "context": "ui", - "key": "Benutzer erstellen", - "value": "Create User" - }, - { - "context": "ui", - "key": "Benutzer hinzufügen", - "value": "Add User" - }, - { - "context": "ui", - "key": "Benutzer löschen", - "value": "Delete User" - }, - { - "context": "ui", - "key": "Benutzer werden geladen...", - "value": "Loading users..." - }, - { - "context": "ui", - "key": "Benutzer-Zugriff verwalten", - "value": "Manage user access" - }, - { - "context": "ui", - "key": "Benutzerdefinierter Titel (optional)", - "value": "Custom Title (optional)" - }, - { - "context": "ui", - "key": "Benutzerinformationen", - "value": "User Information" - }, - { - "context": "ui", - "key": "Benutzerinformationen erfolgreich aktualisiert", - "value": "User information updated successfully" - }, - { - "context": "ui", - "key": "Benutzerinformationen werden geladen...", - "value": "Loading user information..." - }, - { - "context": "ui", - "key": "Benutzername", - "value": "Username" - }, - { - "context": "ui", - "key": "Benutzerverwaltung - Teammitglieder und Berechtigungen verwalten", - "value": "User Management - Manage team members and permissions" - }, - { - "context": "ui", - "key": "Berechtigung", - "value": "Privilege" - }, - { - "context": "ui", - "key": "Berechtigungsstufe", - "value": "Privilege Level" - }, - { - "context": "ui", - "key": "Beschreibung", - "value": "Description" - }, - { - "context": "ui", - "key": "Beschreibung der Rolle", - "value": "Role description" - }, - { - "context": "ui", - "key": "Betrachter", - "value": "Viewer" - }, - { - "context": "ui", - "key": "Betreff", - "value": "Subject" - }, - { - "context": "ui", - "key": "Bezeichnung", - "value": "Label" - }, - { - "context": "ui", - "key": "Bieten Sie Unterstützung im Live-Chat und setzen Sie intelligente Chatbots in allen Kanälen ein.", - "value": "Deliver assistance in live chat and deploy intelligent chatbots in all channels." - }, - { - "context": "ui", - "key": "Bild", - "value": "Image" - }, - { - "context": "ui", - "key": "Bitte geben Sie eine gültige E-Mail-Adresse ein", - "value": "Please enter a valid email address" - }, - { - "context": "ui", - "key": "Bitte wählen Sie mindestens einen Benutzer aus", - "value": "Please select at least one user" - }, - { - "context": "ui", - "key": "Branche", - "value": "Industry" - }, - { - "context": "ui", - "key": "Branche ist erforderlich", - "value": "Industry is required" - }, - { - "context": "ui", - "key": "Buchungsbetrag", - "value": "Booking Amount" - }, - { - "context": "ui", - "key": "Buchungspositionen verwalten", - "value": "Manage booking positions" - }, - { - "context": "ui", - "key": "Buchungswährung", - "value": "Booking Currency" - }, - { - "context": "ui", - "key": "Chat Platform (CP)", - "value": "Chat Platform (CP)" - }, - { - "context": "ui", - "key": "Chat leeren...", - "value": "New Chat" - }, - { - "context": "ui", - "key": "Chatbereich", - "value": "Chat Area" - }, - { - "context": "ui", - "key": "Darstellung", - "value": "Appearance" - }, - { - "context": "ui", - "key": "Datei", - "value": "File" - }, - { - "context": "ui", - "key": "Datei anhängen", - "value": "Attach file" - }, - { - "context": "ui", - "key": "Datei bereits vorhanden", - "value": "File Already Exists" - }, - { - "context": "ui", - "key": "Datei entfernen", - "value": "Remove file" - }, - { - "context": "ui", - "key": "Datei erfolgreich hochgeladen!", - "value": "File uploaded successfully!" - }, - { - "context": "ui", - "key": "Datei herunterladen", - "value": "Download file" - }, - { - "context": "ui", - "key": "Datei hier ablegen...", - "value": "Drop file here..." - }, - { - "context": "ui", - "key": "Datei hinzufügen", - "value": "Add File" - }, - { - "context": "ui", - "key": "Datei hochladen", - "value": "Upload file" - }, - { - "context": "ui", - "key": "Datei löschen", - "value": "Delete file" - }, - { - "context": "ui", - "key": "Datei vorschauen", - "value": "Preview file" - }, - { - "context": "ui", - "key": "Datei-Ablage während Workflow deaktiviert", - "value": "File drop disabled during workflow" - }, - { - "context": "ui", - "key": "Dateien", - "value": "Files" - }, - { - "context": "ui", - "key": "Dateien anhängen", - "value": "Attach Files" - }, - { - "context": "ui", - "key": "Dateien auswählen", - "value": "Select files" - }, - { - "context": "ui", - "key": "Dateien hier ablegen", - "value": "Drop files here" - }, - { - "context": "ui", - "key": "Dateien hier ablegen zum Anhängen", - "value": "Drop files here to attach" - }, - { - "context": "ui", - "key": "Dateien hierher ziehen", - "value": "Drag files here" - }, - { - "context": "ui", - "key": "Dateien hochladen", - "value": "Upload files" - }, - { - "context": "ui", - "key": "Dateien werden geladen...", - "value": "Loading files..." - }, - { - "context": "ui", - "key": "Dateien werden verarbeitet...", - "value": "Processing files..." - }, - { - "context": "ui", - "key": "Dateigröße", - "value": "File Size" - }, - { - "context": "ui", - "key": "Dateiname", - "value": "File Name" - }, - { - "context": "ui", - "key": "Dateityp", - "value": "File Type" - }, - { - "context": "ui", - "key": "Dateiverwaltung - Dokumente hochladen und organisieren", - "value": "File Management - Upload and organize documents" - }, - { - "context": "ui", - "key": "Dateivorschau", - "value": "File Preview" - }, - { - "context": "ui", - "key": "Daten aktualisieren", - "value": "Refresh data" - }, - { - "context": "ui", - "key": "Daten empfangen", - "value": "Data Received" - }, - { - "context": "ui", - "key": "Daten gesendet", - "value": "Data Sent" - }, - { - "context": "ui", - "key": "Datenverwaltung", - "value": "Data Management" - }, - { - "context": "ui", - "key": "Datenverwaltung - Datenimporte und -exporte verwalten", - "value": "Data Management - Handle data imports and exports" - }, - { - "context": "ui", - "key": "Datenverwaltung mit Tabellen", - "value": "Data management with tables" - }, - { - "context": "ui", - "key": "Datum", - "value": "Date" - }, - { - "context": "ui", - "key": "Dauer", - "value": "Duration" - }, - { - "context": "ui", - "key": "Deutsch", - "value": "Deutsch" - }, - { - "context": "ui", - "key": "Die Datei \"{fileName}\" existiert bereits mit identischem Inhalt. Die vorhandene Datei wird wiederverwendet.", - "value": "The file \"{fileName}\" already exists with identical content. The existing file will be reused." - }, - { - "context": "ui", - "key": "Die Erstellung einer neuen Sprache kann AI-Guthaben auf Ihrem Mandats-Pool belasten. Fortfahren?", - "value": "Creating a new language may consume AI credits from your mandate pool. Continue?" - }, - { - "context": "ui", - "key": "Dies ist Ihr Ausgangspunkt für den Zugriff auf alle Arbeitsbereich-Features und -Tools.", - "value": "This is your starting point for accessing all workspace features and tools." - }, - { - "context": "ui", - "key": "Diese Aktion kann nicht rückgängig gemacht werden.", - "value": "This action cannot be undone." - }, - { - "context": "ui", - "key": "Diese Datei scheint beschädigt zu sein. Sie hat eine PDF-Erweiterung, enthält aber Textinhalte. Bitte laden Sie die Datei erneut hoch, falls möglich.", - "value": "This file appears to be corrupted. It has a PDF extension but contains text content. Please re-upload the file if possible." - }, - { - "context": "ui", - "key": "Dieser Bereich enthält alle Verwaltungs- und Management-Tools für Ihren Arbeitsbereich.", - "value": "This section contains all administration and management tools for your workspace." - }, - { - "context": "ui", - "key": "Dieses Element auswählen", - "value": "Select this item" - }, - { - "context": "ui", - "key": "Dieses Element kann nicht ausgewählt werden", - "value": "This item cannot be selected" - }, - { - "context": "ui", - "key": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden", - "value": "This field is managed by {provider} and cannot be changed" - }, - { - "context": "ui", - "key": "Dossiers", - "value": "Dossiers" - }, - { - "context": "ui", - "key": "Dokument", - "value": "Document" - }, - { - "context": "ui", - "key": "Dokument erfolgreich erstellt", - "value": "Document created successfully" - }, - { - "context": "ui", - "key": "Dokument herunterladen", - "value": "Download document" - }, - { - "context": "ui", - "key": "Dokument vorschauen", - "value": "Preview document" - }, - { - "context": "ui", - "key": "Dokumente", - "value": "Documents" - }, - { - "context": "ui", - "key": "Dokumente auflisten", - "value": "List Documents" - }, - { - "context": "ui", - "key": "Dokumentname", - "value": "Document Name" - }, - { - "context": "ui", - "key": "Dunkel", - "value": "Dark" - }, - { - "context": "ui", - "key": "Durchsuchen", - "value": "Browse" - }, - { - "context": "ui", - "key": "E-Mail", - "value": "Email" - }, - { - "context": "ui", - "key": "E-Mail-Adresse", - "value": "Email Address" - }, - { - "context": "ui", - "key": "E-Mail-Adresse ist erforderlich", - "value": "Email address is required" - }, - { - "context": "ui", - "key": "E-Mail-Bestätigung", - "value": "Email Confirmation" - }, - { - "context": "ui", - "key": "Echtzeit-Datensynchronisation:", - "value": "Real-time Data Synchronization:" - }, - { - "context": "ui", - "key": "Eingereichte Daten:", - "value": "Submitted Data:" - }, - { - "context": "ui", - "key": "Einrichtungsanruf", - "value": "Setup Call" - }, - { - "context": "ui", - "key": "Einstellungen", - "value": "Settings" - }, - { - "context": "ui", - "key": "Einstellungen erfolgreich gespeichert!", - "value": "Settings saved successfully!" - }, - { - "context": "ui", - "key": "Einstellungen werden in zukünftigen Updates hinzugefügt.", - "value": "Settings content will be added here in future updates." - }, - { - "context": "ui", - "key": "Einstellungen wurden erfolgreich zurückgesetzt.", - "value": "Settings have been reset successfully." - }, - { - "context": "ui", - "key": "Einträge pro Seite:", - "value": "Items per page:" - }, - { - "context": "ui", - "key": "Empfänger", - "value": "Recipient" - }, - { - "context": "ui", - "key": "Endzeit", - "value": "End Time" - }, - { - "context": "ui", - "key": "English", - "value": "English" - }, - { - "context": "ui", - "key": "Entdeckte Sites", - "value": "Discovered Sites" - }, - { - "context": "ui", - "key": "Erfolgreich", - "value": "Success" - }, - { - "context": "ui", - "key": "Erfolgsrate", - "value": "Success Rate" - }, - { - "context": "ui", - "key": "Erleben Sie die Zukunft der Mandantenkommunikation durch unsere strategische Partnerschaft mit Spitch.ai. Diese bahnbrechende Integration verwandelt Ihre PowerOn-Plattform in ein intelligentes Telefonie-System, das externe Mandanten nahtlos mit Unternehmen verbindet.", - "value": "Experience the future of client communication through our strategic partnership with Spitch.ai. This groundbreaking integration transforms your PowerOn platform into an intelligent telephony system that seamlessly connects external clients with companies." - }, - { - "context": "ui", - "key": "Erneut versuchen", - "value": "Try again" - }, - { - "context": "ui", - "key": "Erste Seite", - "value": "First page" - }, - { - "context": "ui", - "key": "Erstellen", - "value": "Create" - }, - { - "context": "ui", - "key": "Erstellen und verwalten Sie RBAC-Rollen und deren Berechtigungen.", - "value": "Create and manage RBAC roles and their permissions." - }, - { - "context": "ui", - "key": "Erstellen...", - "value": "Creating..." - }, - { - "context": "ui", - "key": "Erstellt", - "value": "Created" - }, - { - "context": "ui", - "key": "Erstellte Dateien", - "value": "Created Files" - }, - { - "context": "ui", - "key": "Erstellungsdatum", - "value": "Creation Date" - }, - { - "context": "ui", - "key": "Externe E-Mail", - "value": "External Email" - }, - { - "context": "ui", - "key": "Externe E-Mail-Adresse eingeben", - "value": "Enter external email address" - }, - { - "context": "ui", - "key": "Externen Benutzernamen eingeben", - "value": "Enter external username" - }, - { - "context": "ui", - "key": "Externer Benutzername", - "value": "External Username" - }, - { - "context": "ui", - "key": "FEHLER", - "value": "ERROR" - }, - { - "context": "ui", - "key": "FEHLGESCHLAGEN", - "value": "FAILED" - }, - { - "context": "ui", - "key": "Falls Sie Fragen zu Ihrem Mandat oder dem Integrationsprozess haben, zögern Sie nicht, unser Support-Team zu kontaktieren.", - "value": "If you have any questions about your mandate or the integration process, please don't hesitate to contact our support team." - }, - { - "context": "ui", - "key": "Fehler", - "value": "Error" - }, - { - "context": "ui", - "key": "Fehler beim Aktualisieren der Benutzerinformationen", - "value": "Error updating user information" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der Automatisierung", - "value": "Error creating automation" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der Organisation", - "value": "Error creating organisation" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der Position", - "value": "Error creating position" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der RBAC-Regel", - "value": "Error creating RBAC rule" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der Rolle", - "value": "Error creating role" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Dokuments", - "value": "Error creating document" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Mandats", - "value": "Error creating mandate" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Prompts", - "value": "Error creating prompt" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Team-Mitglieds", - "value": "Error creating team member" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Vertrags", - "value": "Error creating contract" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Zugriffs", - "value": "Error creating access" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Benutzer", - "value": "Error loading users" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Benutzer:", - "value": "Error loading users:" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Benutzerinformationen", - "value": "Error loading user information" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Dateien:", - "value": "Error loading files:" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Logs", - "value": "Error loading logs" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Nachrichten:", - "value": "Error loading messages:" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Prompts", - "value": "Error loading prompts" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Prompts:", - "value": "Error loading prompts:" - }, - { - "context": "ui", - "key": "Fehler beim Laden der SharePoint Dokumente:", - "value": "Error loading SharePoint documents:" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Vorschau", - "value": "Error loading preview" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Workflows:", - "value": "Error loading workflows:" - }, - { - "context": "ui", - "key": "Fehler beim Löschen", - "value": "Error deleting" - }, - { - "context": "ui", - "key": "Fehler beim Speichern der Einstellungen. Bitte versuchen Sie es erneut.", - "value": "Failed to save settings. Please try again." - }, - { - "context": "ui", - "key": "Fehler beim Teilen des Prompts", - "value": "Error sharing prompt" - }, - { - "context": "ui", - "key": "Fehler beim Verarbeiten der Dateien", - "value": "Error processing files" - }, - { - "context": "ui", - "key": "Fehler:", - "value": "Error:" - }, - { - "context": "ui", - "key": "Fehlgeschlagen", - "value": "Failed" - }, - { - "context": "ui", - "key": "Filter löschen", - "value": "Clear filter" - }, - { - "context": "ui", - "key": "Firma", - "value": "Company" - }, - { - "context": "ui", - "key": "Firmenname", - "value": "Company Name" - }, - { - "context": "ui", - "key": "Firmenname ist erforderlich", - "value": "Company name is required" - }, - { - "context": "ui", - "key": "Folgenachricht wird gesendet...", - "value": "Sending follow-up message..." - }, - { - "context": "ui", - "key": "Fortfahren", - "value": "Continue" - }, - { - "context": "ui", - "key": "Fortsetzen", - "value": "Continue" - }, - { - "context": "ui", - "key": "Fragen?", - "value": "Questions?" - }, - { - "context": "ui", - "key": "Français", - "value": "Français" - }, - { - "context": "ui", - "key": "Fügen Sie eine Nachricht für die Empfänger hinzu", - "value": "Add a message for recipients" - }, - { - "context": "ui", - "key": "GESTOPPT", - "value": "STOPPED" - }, - { - "context": "ui", - "key": "Geben Sie Ihren Firmennamen ein", - "value": "Enter your company name" - }, - { - "context": "ui", - "key": "Geben Sie Kunden einen schnellen und effizienten Selbstservice für Sprach- und Textanfragen, der 24/7 verfügbar ist.", - "value": "Give customers a fast and efficient self-service for voice and text queries that's available 24/7." - }, - { - "context": "ui", - "key": "Geben Sie den Inhalt des Prompts ein", - "value": "Enter the prompt content" - }, - { - "context": "ui", - "key": "Geben Sie einen Namen für den Prompt ein", - "value": "Enter a name for the prompt" - }, - { - "context": "ui", - "key": "Geben Sie einen benutzerdefinierten Titel ein", - "value": "Enter a custom title" - }, - { - "context": "ui", - "key": "Geplante und automatisierte Workflows", - "value": "Scheduled and automated workflows" - }, - { - "context": "ui", - "key": "Geschäftszeiten", - "value": "Business Hours" - }, - { - "context": "ui", - "key": "Geschäftszeiten & Zeitzone", - "value": "Business Hours & Timezone" - }, - { - "context": "ui", - "key": "Gespräch fortsetzen...", - "value": "Continue the conversation..." - }, - { - "context": "ui", - "key": "Gestartet", - "value": "Started" - }, - { - "context": "ui", - "key": "Gestartet:", - "value": "Started:" - }, - { - "context": "ui", - "key": "Gestoppt", - "value": "Stopped" - }, - { - "context": "ui", - "key": "Geteilt", - "value": "Shared" - }, - { - "context": "ui", - "key": "Geteilte Dateien", - "value": "Shared Files" - }, - { - "context": "ui", - "key": "Globale Sprachsets verwalten (SysAdmin).", - "value": "Manage global UI language sets (SysAdmin)." - }, - { - "context": "ui", - "key": "Google", - "value": "Google" - }, - { - "context": "ui", - "key": "Google verbinden", - "value": "Connect Google" - }, - { - "context": "ui", - "key": "Google-Verbindung erstellen", - "value": "Create Google Connection" - }, - { - "context": "ui", - "key": "Google-Verbindung hinzufügen", - "value": "Add Google Connection" - }, - { - "context": "ui", - "key": "Grundlegende Daten und Ressourcen", - "value": "Basic data and resources" - }, - { - "context": "ui", - "key": "Größe", - "value": "Size" - }, - { - "context": "ui", - "key": "Hell", - "value": "Light" - }, - { - "context": "ui", - "key": "Herunterladen", - "value": "Download" - }, - { - "context": "ui", - "key": "Hinzufügen", - "value": "Add" - }, - { - "context": "ui", - "key": "Hochgeladen", - "value": "Uploaded" - }, - { - "context": "ui", - "key": "Hochladen", - "value": "Upload" - }, - { - "context": "ui", - "key": "ID", - "value": "ID" - }, - { - "context": "ui", - "key": "INFO", - "value": "INFO" - }, - { - "context": "ui", - "key": "Identifizieren und authentifizieren Sie Anrufer in Sekunden mit kontinuierlicher Verifizierung und Sicherheit.", - "value": "Identify and authenticate callers in seconds with continuous verification and security." - }, - { - "context": "ui", - "key": "Ihre Anfrage wird verarbeitet...", - "value": "Processing your request..." - }, - { - "context": "ui", - "key": "Inaktiv", - "value": "Inactive" - }, - { - "context": "ui", - "key": "Information", - "value": "Information" - }, - { - "context": "ui", - "key": "Inhalt", - "value": "Content" - }, - { - "context": "ui", - "key": "Inhalt ist erforderlich", - "value": "Content is required" - }, - { - "context": "ui", - "key": "Ja", - "value": "Yes" - }, - { - "context": "ui", - "key": "Jetzt anmelden", - "value": "Sign Up Now" - }, - { - "context": "ui", - "key": "Jetzt überspringen", - "value": "Skip for Now" - }, - { - "context": "ui", - "key": "KI-erstellt", - "value": "AI-created" - }, - { - "context": "ui", - "key": "KI-gestützte Dokumentengenerierung:", - "value": "AI-Powered Document Generation:" - }, - { - "context": "ui", - "key": "Kein Auth-Anbieter", - "value": "No Auth Authority" - }, - { - "context": "ui", - "key": "Kein Benutzername", - "value": "No Username" - }, - { - "context": "ui", - "key": "Kein Nachrichteninhalt verfügbar", - "value": "No message content available" - }, - { - "context": "ui", - "key": "Kein Name", - "value": "No Name" - }, - { - "context": "ui", - "key": "Kein Workflow ausgewählt", - "value": "No workflow selected" - }, - { - "context": "ui", - "key": "Keine Benutzer verfügbar", - "value": "No users available" - }, - { - "context": "ui", - "key": "Keine Berechtigung", - "value": "No Privilege" - }, - { - "context": "ui", - "key": "Keine Berechtigung zum Löschen des Prompts", - "value": "No permission to delete prompt" - }, - { - "context": "ui", - "key": "Keine Dateien gefunden.", - "value": "No files found." - }, - { - "context": "ui", - "key": "Keine E-Mail", - "value": "No Email" - }, - { - "context": "ui", - "key": "Keine Einträge", - "value": "No entries" - }, - { - "context": "ui", - "key": "Keine Logs für diesen Workflow verfügbar", - "value": "No logs available for this workflow" - }, - { - "context": "ui", - "key": "Keine Microsoft-Verbindungen gefunden. Bitte erstellen Sie zuerst eine Verbindung.", - "value": "No Microsoft connections found. Please create a connection first." - }, - { - "context": "ui", - "key": "Keine Prompts verfügbar", - "value": "No prompts available" - }, - { - "context": "ui", - "key": "Keine SharePoint-Sites gefunden", - "value": "No SharePoint sites found" - }, - { - "context": "ui", - "key": "Keine Sprach-Integrations-Daten gefunden. Bitte melden Sie sich zuerst an, um auf die Einstellungen zuzugreifen.", - "value": "No speech integration data found. Please sign up first to access settings." - }, - { - "context": "ui", - "key": "Keine Sprache", - "value": "No Language" - }, - { - "context": "ui", - "key": "Keine Transkripte vorhanden", - "value": "No transcripts available" - }, - { - "context": "ui", - "key": "Keine Vorschau verfügbar", - "value": "No preview available" - }, - { - "context": "ui", - "key": "Keine Workflows gefunden", - "value": "No workflows found" - }, - { - "context": "ui", - "key": "Keine Workflows verfügbar", - "value": "No workflows available" - }, - { - "context": "ui", - "key": "Keine hochgeladenen Dateien gefunden.", - "value": "No uploaded files found." - }, - { - "context": "ui", - "key": "Keine mit Ihnen geteilten Dateien gefunden.", - "value": "No shared files found." - }, - { - "context": "ui", - "key": "Keine von der KI erstellten Dateien gefunden.", - "value": "No AI-created files found." - }, - { - "context": "ui", - "key": "Klicken Sie erneut zum Bestätigen", - "value": "Click again to confirm" - }, - { - "context": "ui", - "key": "Klicken Sie erneut zum Bestätigen der Löschung", - "value": "Click again to confirm deletion" - }, - { - "context": "ui", - "key": "Klicken Sie, um zu öffnen", - "value": "Click to open" - }, - { - "context": "ui", - "key": "Knowledge Agent (KA)", - "value": "Knowledge Agent (KA)" - }, - { - "context": "ui", - "key": "Konfigurieren Sie administrative Einstellungen und Systempräferenzen.", - "value": "Configure administrative settings and system preferences." - }, - { - "context": "ui", - "key": "Konfigurieren und verwalten Sie rollenbasierte Zugriffssteuerungsregeln.", - "value": "Configure and manage Role-Based Access Control rules." - }, - { - "context": "ui", - "key": "Kontakte einrichten", - "value": "Setup Contacts" - }, - { - "context": "ui", - "key": "Kontaktinformationen", - "value": "Contact Information" - }, - { - "context": "ui", - "key": "Kontostatus", - "value": "Account Status" - }, - { - "context": "ui", - "key": "Kopieren", - "value": "Copy" - }, - { - "context": "ui", - "key": "Kosteneinsparungen & Effizienz:", - "value": "Cost Savings & Efficiency:" - }, - { - "context": "ui", - "key": "Kundenverträge verwalten", - "value": "Manage customer contracts" - }, - { - "context": "ui", - "key": "Lade Fortschritt...", - "value": "Loading progress..." - }, - { - "context": "ui", - "key": "Laden...", - "value": "Downloading..." - }, - { - "context": "ui", - "key": "Land", - "value": "Country" - }, - { - "context": "ui", - "key": "Land ist erforderlich", - "value": "Country is required" - }, - { - "context": "ui", - "key": "Leer = Zugriff auf alle Verträge", - "value": "Empty = Access to all contracts" - }, - { - "context": "ui", - "key": "Letzte Aktivität", - "value": "Last Activity" - }, - { - "context": "ui", - "key": "Letzte Aktivität:", - "value": "Last Activity:" - }, - { - "context": "ui", - "key": "Letzte Aktivitäten - Sehen Sie Ihre neueste Arbeit", - "value": "Recent Activities - View your latest work" - }, - { - "context": "ui", - "key": "Letzte Seite", - "value": "Last page" - }, - { - "context": "ui", - "key": "Link konnte nicht gesendet werden", - "value": "Failed to send link" - }, - { - "context": "ui", - "key": "Log", - "value": "Log" - }, - { - "context": "ui", - "key": "Logs konnten nicht geladen werden", - "value": "Failed to fetch logs" - }, - { - "context": "ui", - "key": "Logs werden geladen...", - "value": "Loading logs..." - }, - { - "context": "ui", - "key": "Lokal", - "value": "Local" - }, - { - "context": "ui", - "key": "LÄUFT", - "value": "RUNNING" - }, - { - "context": "ui", - "key": "Lädt hoch...", - "value": "Uploading..." - }, - { - "context": "ui", - "key": "Läuft", - "value": "Running" - }, - { - "context": "ui", - "key": "Läuft ab am", - "value": "Expires At" - }, - { - "context": "ui", - "key": "Löschen", - "value": "Delete" - }, - { - "context": "ui", - "key": "Löschen ({count})", - "value": "Delete ({count})" - }, - { - "context": "ui", - "key": "Löschen...", - "value": "Deleting..." - }, - { - "context": "ui", - "key": "MIME-Typ", - "value": "MIME Type" - }, - { - "context": "ui", - "key": "Management-Tools umfassen:", - "value": "Management tools include:" - }, - { - "context": "ui", - "key": "Mandanten können jederzeit auf die technische SIP-Nummer umstellen und dabei erhebliche Telefoniekosten sparen. Die Integration funktioniert wie ein weiterer Connector (Outlook, SharePoint) und wird nahtlos in Ihren bestehenden Workflow integriert.", - "value": "Clients can switch to the technical SIP number at any time and save significant telephony costs. The integration works like another connector (Outlook, SharePoint) and is seamlessly integrated into your existing workflow." - }, - { - "context": "ui", - "key": "Mandat erfolgreich eingereicht!", - "value": "Mandate Submitted Successfully!" - }, - { - "context": "ui", - "key": "Mandat erfolgreich erstellt", - "value": "Mandate created successfully" - }, - { - "context": "ui", - "key": "Mandat erstellen", - "value": "Create Mandate" - }, - { - "context": "ui", - "key": "Mandat hinzufügen", - "value": "Add Mandate" - }, - { - "context": "ui", - "key": "Mandat-ID", - "value": "Mandate ID" - }, - { - "context": "ui", - "key": "Mandate", - "value": "Mandates" - }, - { - "context": "ui", - "key": "Mandate und Berechtigungen verwalten", - "value": "Manage mandates and permissions" - }, - { - "context": "ui", - "key": "Mandatsverwaltung", - "value": "Mandate management" - }, - { - "context": "ui", - "key": "Mehr erfahren", - "value": "Learn more" - }, - { - "context": "ui", - "key": "Meine Uploads", - "value": "My Uploads" - }, - { - "context": "ui", - "key": "Microsoft", - "value": "Microsoft" - }, - { - "context": "ui", - "key": "Microsoft Verbindungen", - "value": "Microsoft Connections" - }, - { - "context": "ui", - "key": "Microsoft verbinden", - "value": "Connect Microsoft" - }, - { - "context": "ui", - "key": "Microsoft-Verbindung erstellen", - "value": "Create Microsoft Connection" - }, - { - "context": "ui", - "key": "Microsoft-Verbindung hinzufügen", - "value": "Add Microsoft Connection" - }, - { - "context": "ui", - "key": "Mitglied hinzufügen", - "value": "Add Member" - }, - { - "context": "ui", - "key": "MwSt %", - "value": "VAT %" - }, - { - "context": "ui", - "key": "MwSt Betrag", - "value": "VAT Amount" - }, - { - "context": "ui", - "key": "Möchten Sie jetzt Kontakte für Ihr Mandat einrichten? Sie können dies auch später in den Einstellungen tun.", - "value": "Would you like to setup contacts for your mandate now? You can also do this later in settings." - }, - { - "context": "ui", - "key": "Nach unten scrollen", - "value": "Scroll to bottom" - }, - { - "context": "ui", - "key": "Nachricht (optional)", - "value": "Message (optional)" - }, - { - "context": "ui", - "key": "Nachricht eingeben...", - "value": "Enter message..." - }, - { - "context": "ui", - "key": "Nachricht wird gesendet...", - "value": "Sending message..." - }, - { - "context": "ui", - "key": "Nachrichten", - "value": "Messages" - }, - { - "context": "ui", - "key": "Nahtloser Mandanten-Workflow:", - "value": "Seamless Client Workflow:" - }, - { - "context": "ui", - "key": "Name", - "value": "Name" - }, - { - "context": "ui", - "key": "Name des Unternehmens", - "value": "Company name" - }, - { - "context": "ui", - "key": "Name ist erforderlich", - "value": "Name is required" - }, - { - "context": "ui", - "key": "Navigation - Erkunden Sie alle verfügbaren Tools", - "value": "Navigation - Explore all available tools" - }, - { - "context": "ui", - "key": "Nein", - "value": "No" - }, - { - "context": "ui", - "key": "Neu starten", - "value": "Start Over" - }, - { - "context": "ui", - "key": "Neue Automatisierung", - "value": "New Automation" - }, - { - "context": "ui", - "key": "Neue Automatisierung erstellen", - "value": "Create New Automation" - }, - { - "context": "ui", - "key": "Neue Datei hochladen", - "value": "Upload new file" - }, - { - "context": "ui", - "key": "Neue Organisation", - "value": "New Organisation" - }, - { - "context": "ui", - "key": "Neue Organisation erstellen", - "value": "Create New Organisation" - }, - { - "context": "ui", - "key": "Neue Position", - "value": "New Position" - }, - { - "context": "ui", - "key": "Neue Position erstellen", - "value": "Create New Position" - }, - { - "context": "ui", - "key": "Neue RBAC-Regel erstellen", - "value": "Create New RBAC Rule" - }, - { - "context": "ui", - "key": "Neue Rolle", - "value": "New Role" - }, - { - "context": "ui", - "key": "Neue Rolle erstellen", - "value": "Create New Role" - }, - { - "context": "ui", - "key": "Neue Sprache", - "value": "New language" - }, - { - "context": "ui", - "key": "Neuen Prompt erstellen", - "value": "Create New Prompt" - }, - { - "context": "ui", - "key": "Neuen Vertrag erstellen", - "value": "Create New Contract" - }, - { - "context": "ui", - "key": "Neuen Zugriff erstellen", - "value": "Create New Access" - }, - { - "context": "ui", - "key": "Neuer Prompt", - "value": "New Prompt" - }, - { - "context": "ui", - "key": "Neuer Vertrag", - "value": "New Contract" - }, - { - "context": "ui", - "key": "Neuer Zugriff", - "value": "New Access" - }, - { - "context": "ui", - "key": "Neues Dokument", - "value": "New Document" - }, - { - "context": "ui", - "key": "Neues Dokument erstellen", - "value": "Create New Document" - }, - { - "context": "ui", - "key": "Neues Mandat erstellen", - "value": "Create New Mandate" - }, - { - "context": "ui", - "key": "Neues Team-Mitglied erstellen", - "value": "Create New Team Member" - }, - { - "context": "ui", - "key": "Neues Transkript", - "value": "New Transcript" - }, - { - "context": "ui", - "key": "Nicht verfügbar", - "value": "N/A" - }, - { - "context": "ui", - "key": "Noch keine Befehle ausgeführt. Senden Sie einen Befehl, um Ergebnisse hier zu sehen.", - "value": "No commands executed yet. Send a command to see results here." - }, - { - "context": "ui", - "key": "Noch keinen Workflow ausgewählt", - "value": "No workflow selected" - }, - { - "context": "ui", - "key": "Nochmal versuchen", - "value": "Try Again" - }, - { - "context": "ui", - "key": "Nächste Seite", - "value": "Next page" - }, - { - "context": "ui", - "key": "Oder geben Sie Ihre Nachricht ein...", - "value": "Or enter your message..." - }, - { - "context": "ui", - "key": "Ordnerpfade", - "value": "Folder Paths" - }, - { - "context": "ui", - "key": "Organisation", - "value": "Organisation" - }, - { - "context": "ui", - "key": "Organisation erfolgreich erstellt", - "value": "Organisation created successfully" - }, - { - "context": "ui", - "key": "Organisationen", - "value": "Organisations" - }, - { - "context": "ui", - "key": "Originalbetrag", - "value": "Original Amount" - }, - { - "context": "ui", - "key": "Originalwährung", - "value": "Original Currency" - }, - { - "context": "ui", - "key": "PDF", - "value": "PDF" - }, - { - "context": "ui", - "key": "Passwort", - "value": "Password" - }, - { - "context": "ui", - "key": "Passwort eingeben", - "value": "Enter password" - }, - { - "context": "ui", - "key": "Passwort-Link gesendet!", - "value": "Password link sent!" - }, - { - "context": "ui", - "key": "Passwort-Link senden", - "value": "Send password setup link" - }, - { - "context": "ui", - "key": "Pfad", - "value": "Path" - }, - { - "context": "ui", - "key": "Position erfolgreich erstellt", - "value": "Position created successfully" - }, - { - "context": "ui", - "key": "Positionen", - "value": "Positions" - }, - { - "context": "ui", - "key": "Postleitzahl", - "value": "Postal Code" - }, - { - "context": "ui", - "key": "Postleitzahl ist erforderlich", - "value": "Postal code is required" - }, - { - "context": "ui", - "key": "Projekte", - "value": "Projects" - }, - { - "context": "ui", - "key": "Projektverwaltung", - "value": "Project Management" - }, - { - "context": "ui", - "key": "Projektverwaltung und -organisation", - "value": "Project management and organization" - }, - { - "context": "ui", - "key": "Prompt", - "value": "Prompt" - }, - { - "context": "ui", - "key": "Prompt Einstellungen", - "value": "Prompt Settings" - }, - { - "context": "ui", - "key": "Prompt Vorlage", - "value": "Prompt Template" - }, - { - "context": "ui", - "key": "Prompt ausführen", - "value": "Run prompt" - }, - { - "context": "ui", - "key": "Prompt auswählen...", - "value": "Select a prompt..." - }, - { - "context": "ui", - "key": "Prompt bearbeiten", - "value": "Edit Prompt" - }, - { - "context": "ui", - "key": "Prompt erfolgreich erstellt", - "value": "Prompt created successfully" - }, - { - "context": "ui", - "key": "Prompt erstellen", - "value": "Create Prompt" - }, - { - "context": "ui", - "key": "Prompt hinzufügen", - "value": "Add Prompt" - }, - { - "context": "ui", - "key": "Prompt löschen", - "value": "Clear prompt" - }, - { - "context": "ui", - "key": "Prompt teilen", - "value": "Share Prompt" - }, - { - "context": "ui", - "key": "Prompt wird gelöscht...", - "value": "Deleting prompt..." - }, - { - "context": "ui", - "key": "Prompt-Inhalt", - "value": "Prompt Content" - }, - { - "context": "ui", - "key": "Prompt-Inhalt darf 10.000 Zeichen nicht überschreiten", - "value": "Prompt content cannot exceed 10,000 characters" - }, - { - "context": "ui", - "key": "Prompt-Inhalt darf nicht leer sein", - "value": "Prompt content cannot be empty" - }, - { - "context": "ui", - "key": "Prompt-Name", - "value": "Prompt Name" - }, - { - "context": "ui", - "key": "Prompt-Name darf 100 Zeichen nicht überschreiten", - "value": "Prompt name cannot exceed 100 characters" - }, - { - "context": "ui", - "key": "Prompt-Name darf nicht leer sein", - "value": "Prompt name cannot be empty" - }, - { - "context": "ui", - "key": "Prompts", - "value": "Prompts" - }, - { - "context": "ui", - "key": "Prompts für Ihren KI-Assistenten erstellen und verwalten", - "value": "Create and manage prompts for your AI assistant" - }, - { - "context": "ui", - "key": "Prompts verwalten", - "value": "Manage your prompts" - }, - { - "context": "ui", - "key": "Prompts werden geladen...", - "value": "Loading prompts..." - }, - { - "context": "ui", - "key": "Python", - "value": "Python" - }, - { - "context": "ui", - "key": "Quelle", - "value": "Source" - }, - { - "context": "ui", - "key": "RBAC-Regel erfolgreich erstellt", - "value": "RBAC rule created successfully" - }, - { - "context": "ui", - "key": "RBAC-Regel hinzufügen", - "value": "Add RBAC Rule" - }, - { - "context": "ui", - "key": "RBAC-Regeln", - "value": "RBAC Rules" - }, - { - "context": "ui", - "key": "RBAC-Regelverwaltung", - "value": "RBAC rules management" - }, - { - "context": "ui", - "key": "RBAC-Rollen", - "value": "RBAC Roles" - }, - { - "context": "ui", - "key": "RBAC-Rollenverwaltung", - "value": "RBAC role management" - }, - { - "context": "ui", - "key": "Registrieren", - "value": "Register" - }, - { - "context": "ui", - "key": "Revolutionäre Telefonie-Integration mit Spitch.ai", - "value": "Revolutionary Telephony Integration with Spitch.ai" - }, - { - "context": "ui", - "key": "Rolle", - "value": "Role" - }, - { - "context": "ui", - "key": "Rolle erfolgreich erstellt", - "value": "Role created successfully" - }, - { - "context": "ui", - "key": "Rolle hinzufügen", - "value": "Add Role" - }, - { - "context": "ui", - "key": "Rollen", - "value": "Roles" - }, - { - "context": "ui", - "key": "Rollen-ID", - "value": "Role ID" - }, - { - "context": "ui", - "key": "Rollenbasierte Zugriffssteuerungsregeln", - "value": "Role-Based Access Control rules" - }, - { - "context": "ui", - "key": "Rollenverwaltung", - "value": "Role management" - }, - { - "context": "ui", - "key": "Rufname am Telefon", - "value": "Phone Name" - }, - { - "context": "ui", - "key": "Runde", - "value": "Round" - }, - { - "context": "ui", - "key": "Runden", - "value": "Rounds" - }, - { - "context": "ui", - "key": "Schließen", - "value": "Close" - }, - { - "context": "ui", - "key": "Schnellzugriff", - "value": "Quick Access" - }, - { - "context": "ui", - "key": "Schnellzugriff - Springen Sie zu häufig verwendeten Features", - "value": "Quick Access - Jump to frequently used features" - }, - { - "context": "ui", - "key": "Seite {page} von {total} ({count} Einträge)", - "value": "Page {page} of {total} ({count} items)" - }, - { - "context": "ui", - "key": "Senden", - "value": "Send" - }, - { - "context": "ui", - "key": "Service", - "value": "Service" - }, - { - "context": "ui", - "key": "Service-Verbindungen", - "value": "Service Connections" - }, - { - "context": "ui", - "key": "SharePoint Dokumente", - "value": "SharePoint Documents" - }, - { - "context": "ui", - "key": "SharePoint Site URL", - "value": "SharePoint Site URL" - }, - { - "context": "ui", - "key": "SharePoint Test", - "value": "SharePoint Test" - }, - { - "context": "ui", - "key": "Sie erhalten in den nächsten Minuten eine Bestätigungs-E-Mail.", - "value": "You will receive a confirmation email within the next few minutes." - }, - { - "context": "ui", - "key": "Sie können auch auf den Upload-Button klicken", - "value": "You can also click the upload button" - }, - { - "context": "ui", - "key": "Sie müssen sich zuerst für die Sprach-Integration anmelden, um auf die Transkriptverwaltung zuzugreifen.", - "value": "You must first sign up for speech integration to access transcript management." - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie \"{name}\" löschen möchten?", - "value": "Are you sure you want to delete \"{name}\"?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie Workflow \"{id}...\" löschen möchten?", - "value": "Are you sure you want to delete workflow \"{id}...\"?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie alle Sprach-Integrations-Einstellungen zurücksetzen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", - "value": "Are you sure you want to reset all speech integration settings? This action cannot be undone." - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie den Workflow \"{name}\" löschen möchten?", - "value": "Are you sure you want to delete workflow \"{name}\"?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie die Datei \"{name}\" löschen möchten?", - "value": "Are you sure you want to delete the file \"{name}\"?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie die {count} ausgewählten Elemente löschen möchten?", - "value": "Are you sure you want to delete the {count} selected items?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie die {service} Verbindung löschen möchten?", - "value": "Are you sure you want to delete the {service} connection?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie diesen Benutzer löschen möchten?", - "value": "Are you sure you want to delete this user?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie {count} Benutzer löschen möchten?", - "value": "Are you sure you want to delete {count} users?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie {count} Prompts löschen möchten?", - "value": "Are you sure you want to delete {count} prompts?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie {count} Verbindungen löschen möchten?", - "value": "Are you sure you want to delete {count} connections?" - }, - { - "context": "ui", - "key": "Sites entdecken", - "value": "Discover Sites" - }, - { - "context": "ui", - "key": "Speech Analytics (SA)", - "value": "Speech Analytics (SA)" - }, - { - "context": "ui", - "key": "Speichern", - "value": "Save" - }, - { - "context": "ui", - "key": "Speichern...", - "value": "Saving..." - }, - { - "context": "ui", - "key": "Spitch prüft vor jedem Anruf die Mandantenberechtigung bei PowerOn, während alle Datenänderungen zentral von PowerOn initiiert werden. Call-Transkripte werden in Echtzeit in Ihrer PowerOn-Datenbank gespeichert, mit vollständiger Mandantenisolation und Sicherheit. Bei Ausfällen werden Anrufe automatisch blockiert, um die Integrität zu gewährleisten.", - "value": "Spitch checks client authorization with PowerOn before each call, while all data changes are centrally initiated by PowerOn. Call transcripts are stored in real-time in your PowerOn database with complete client isolation and security. In case of failures, calls are automatically blocked to ensure integrity." - }, - { - "context": "ui", - "key": "Sprach Integration", - "value": "Speech Integration" - }, - { - "context": "ui", - "key": "Sprach-Einstellungen", - "value": "Speech Settings" - }, - { - "context": "ui", - "key": "Sprach-Integration Einstellungen", - "value": "Speech Integration Settings" - }, - { - "context": "ui", - "key": "Sprache", - "value": "Language" - }, - { - "context": "ui", - "key": "Sprachset {code} wirklich löschen?", - "value": "Really delete language set {code}?" - }, - { - "context": "ui", - "key": "Stadt", - "value": "City" - }, - { - "context": "ui", - "key": "Stadt ist erforderlich", - "value": "City is required" - }, - { - "context": "ui", - "key": "Start", - "value": "Start" - }, - { - "context": "ui", - "key": "Startzeit", - "value": "Start Time" - }, - { - "context": "ui", - "key": "Status", - "value": "Status" - }, - { - "context": "ui", - "key": "Stellen Sie alles, was Ihre Agenten benötigen, in ihren Händen bereit, mit einem einheitlichen Agent-Desktop.", - "value": "Put everything your agents need at their fingertips, with a unified agent desktop." - }, - { - "context": "ui", - "key": "Stoppen", - "value": "Stop" - }, - { - "context": "ui", - "key": "Straße", - "value": "Street" - }, - { - "context": "ui", - "key": "Straße ist erforderlich", - "value": "Street is required" - }, - { - "context": "ui", - "key": "Suchen Sie nach Standorten über Adresse oder Koordinaten, oder verwenden Sie natürliche Sprache, um Projekte zu erstellen und zu verwalten.", - "value": "Search for locations by address or coordinates, or use natural language to create and manage projects." - }, - { - "context": "ui", - "key": "Suchen...", - "value": "Search..." - }, - { - "context": "ui", - "key": "Systemadministrator", - "value": "Sysadmin" - }, - { - "context": "ui", - "key": "Systemeinstellungen - Arbeitsbereich-Einstellungen konfigurieren", - "value": "System Settings - Configure workspace settings" - }, - { - "context": "ui", - "key": "Tabelle", - "value": "Spreadsheet" - }, - { - "context": "ui", - "key": "Tags", - "value": "Tags" - }, - { - "context": "ui", - "key": "Team-Bereich", - "value": "Team Area" - }, - { - "context": "ui", - "key": "Team-Mitglied erfolgreich erstellt", - "value": "Team member created successfully" - }, - { - "context": "ui", - "key": "Team-Mitglieder", - "value": "Team Members" - }, - { - "context": "ui", - "key": "Team-Mitglieder verwalten", - "value": "Manage your team members" - }, - { - "context": "ui", - "key": "Team-Mitglieder verwalten, Berechtigungen festlegen und Zusammenarbeitseinstellungen konfigurieren", - "value": "Manage team members, set permissions, and configure collaboration settings" - }, - { - "context": "ui", - "key": "Teilen", - "value": "Share" - }, - { - "context": "ui", - "key": "Telefon", - "value": "Phone" - }, - { - "context": "ui", - "key": "Telefonnummer", - "value": "Phone Number" - }, - { - "context": "ui", - "key": "Telefonnummer ist erforderlich", - "value": "Phone number is required" - }, - { - "context": "ui", - "key": "Text", - "value": "Text" - }, - { - "context": "ui", - "key": "Textvorschau", - "value": "Text Preview" - }, - { - "context": "ui", - "key": "Theme", - "value": "Theme" - }, - { - "context": "ui", - "key": "Token", - "value": "Tokens" - }, - { - "context": "ui", - "key": "Transkript", - "value": "Transcript" - }, - { - "context": "ui", - "key": "Transkript wird verarbeitet...", - "value": "Processing transcript..." - }, - { - "context": "ui", - "key": "Transkriptverwaltung", - "value": "Transcript Management" - }, - { - "context": "ui", - "key": "Trennungsfehler", - "value": "Disconnect Error" - }, - { - "context": "ui", - "key": "Treuhand", - "value": "Trustee" - }, - { - "context": "ui", - "key": "Treuhandverwaltung", - "value": "Trustee Management" - }, - { - "context": "ui", - "key": "Trustee-Organisationen verwalten", - "value": "Manage trustee organisations" - }, - { - "context": "ui", - "key": "Trustee-Rollen verwalten", - "value": "Manage trustee roles" - }, - { - "context": "ui", - "key": "Typ", - "value": "Type" - }, - { - "context": "ui", - "key": "UI-Sprachen", - "value": "UI languages" - }, - { - "context": "ui", - "key": "Unbekannt", - "value": "Unknown" - }, - { - "context": "ui", - "key": "Unbekannte Größe", - "value": "Unknown Size" - }, - { - "context": "ui", - "key": "Unbekanntes Datum", - "value": "Unknown Date" - }, - { - "context": "ui", - "key": "Unbenannt", - "value": "Unnamed" - }, - { - "context": "ui", - "key": "Unbenannter Workflow", - "value": "Unnamed Workflow" - }, - { - "context": "ui", - "key": "Ungültiges Datum", - "value": "Invalid date" - }, - { - "context": "ui", - "key": "Unser Team wird Ihr Mandat innerhalb von 1-2 Werktagen überprüfen.", - "value": "Our team will review your mandate within 1-2 business days." - }, - { - "context": "ui", - "key": "Unsere bereits aktive Dokumenten-Extraktions-Engine generiert automatisch personalisierte Dokumente für Spitch, basierend auf Mandantenspezifischen Daten. Die KI nutzt FAQ-Datenbanken, Mitarbeiterinformationen und Service-Details, um jeden Anruf kontextuell und hochpersonalisiert zu gestalten.", - "value": "Our already active document extraction engine automatically generates personalized documents for Spitch based on client-specific data. The AI uses FAQ databases, employee information, and service details to make every call contextual and highly personalized." - }, - { - "context": "ui", - "key": "Unternehmensinformationen", - "value": "Company Information" - }, - { - "context": "ui", - "key": "Unterstützt von", - "value": "Powered by" - }, - { - "context": "ui", - "key": "Upload fehlgeschlagen. Bitte versuchen Sie es erneut.", - "value": "Upload failed. Please try again." - }, - { - "context": "ui", - "key": "VERARBEITUNG", - "value": "PROCESSING" - }, - { - "context": "ui", - "key": "Valutadatum", - "value": "Value Date" - }, - { - "context": "ui", - "key": "Verarbeitung", - "value": "Processing" - }, - { - "context": "ui", - "key": "Verbinden", - "value": "Connect" - }, - { - "context": "ui", - "key": "Verbindung aktualisieren", - "value": "Update Connection" - }, - { - "context": "ui", - "key": "Verbindung testen", - "value": "Test Connection" - }, - { - "context": "ui", - "key": "Verbindungen", - "value": "Connections" - }, - { - "context": "ui", - "key": "Verbindungen werden geladen...", - "value": "Loading connections..." - }, - { - "context": "ui", - "key": "Verbindungsfehler", - "value": "Connection Error" - }, - { - "context": "ui", - "key": "Verbunden am", - "value": "Connected At" - }, - { - "context": "ui", - "key": "Vereinheitlichen und liefern Sie Informationen an Ihre Kunden und Mitarbeiter, wann und wo sie sie benötigen.", - "value": "Unify and deliver info to your customers and staff wherever and whenever they need it." - }, - { - "context": "ui", - "key": "Verfügbare Tools", - "value": "Available Tools" - }, - { - "context": "ui", - "key": "Verfügbare Workflows", - "value": "Available Workflows" - }, - { - "context": "ui", - "key": "Version", - "value": "Version" - }, - { - "context": "ui", - "key": "Versuchen Sie, Ihr Microsoft-Konto auf der Verbindungsseite erneut zu verbinden.", - "value": "Try reconnecting your Microsoft account in the Connections page." - }, - { - "context": "ui", - "key": "Vertrag", - "value": "Contract" - }, - { - "context": "ui", - "key": "Vertrag (optional)", - "value": "Contract (optional)" - }, - { - "context": "ui", - "key": "Vertrag erfolgreich erstellt", - "value": "Contract created successfully" - }, - { - "context": "ui", - "key": "Verträge", - "value": "Contracts" - }, - { - "context": "ui", - "key": "Verwalten Sie Daten über Tabellen. Wählen Sie eine Tabelle aus oder verwenden Sie natürliche Sprache, um Befehle auszuführen.", - "value": "Manage data through tables. Select a table or use natural language to execute commands." - }, - { - "context": "ui", - "key": "Verwalten Sie Ihre Kontoinformationen", - "value": "Manage your account information" - }, - { - "context": "ui", - "key": "Verwalten Sie Ihre Service-Verbindungen", - "value": "Manage your service connections" - }, - { - "context": "ui", - "key": "Verwalten Sie Ihre Sprach-Integrations-Konfiguration und Einstellungen.", - "value": "Manage your speech integration configuration and preferences." - }, - { - "context": "ui", - "key": "Verwalten Sie Mandate und deren zugehörige Berechtigungen.", - "value": "Manage mandates and their associated permissions." - }, - { - "context": "ui", - "key": "Verwaltet von {provider}", - "value": "Managed by {provider}" - }, - { - "context": "ui", - "key": "Verwaltung der Benutzerzugriffe auf Organisationen", - "value": "Management of user access to organisations" - }, - { - "context": "ui", - "key": "Verwaltung der Buchungspositionen (Speseneinträge)", - "value": "Management of booking positions (expense entries)" - }, - { - "context": "ui", - "key": "Verwaltung der Dokumente und Belege", - "value": "Management of documents and receipts" - }, - { - "context": "ui", - "key": "Verwaltung der Feature-spezifischen Rollen", - "value": "Management of feature-specific roles" - }, - { - "context": "ui", - "key": "Verwaltung der Kundenverträge", - "value": "Management of customer contracts" - }, - { - "context": "ui", - "key": "Verwaltung der Treuhand-Organisationen", - "value": "Management of trustee organisations" - }, - { - "context": "ui", - "key": "Verwaltung von Treuhand-Organisationen, Verträgen und Buchungen", - "value": "Manage trustee organisations, contracts, and bookings" - }, - { - "context": "ui", - "key": "Verwaltungs- und Management-Tools", - "value": "Administration and management tools" - }, - { - "context": "ui", - "key": "Verwende Vorlage:", - "value": "Using prompt:" - }, - { - "context": "ui", - "key": "Video", - "value": "Video" - }, - { - "context": "ui", - "key": "Vielen Dank für Ihr Interesse an unserer Sprach Integration powered by Spitch.ai. Wir haben Ihr Mandat erhalten und werden es in Kürze überprüfen.", - "value": "Thank you for your interest in our Speech Integration powered by Spitch.ai. We have received your mandate and will review it shortly." - }, - { - "context": "ui", - "key": "Virtual Assistant (VA)", - "value": "Virtual Assistant (VA)" - }, - { - "context": "ui", - "key": "Voice Biometrics (VB)", - "value": "Voice Biometrics (VB)" - }, - { - "context": "ui", - "key": "Vollständiger Name", - "value": "Full Name" - }, - { - "context": "ui", - "key": "Von der Registrierung bis zur technischen Einrichtung - Ihr Mandant registriert sich bei PowerOn für Telefonie-Services, lädt Dokumente hoch und erhält automatisch eine technische SIP-Nummer von Spitch. Die Call-Weiterleitung kann jederzeit aktiviert oder deaktiviert werden, was maximale Flexibilität und BCM-Sicherheit gewährleistet.", - "value": "From registration to technical setup - your client registers with PowerOn for telephony services, uploads documents, and automatically receives a technical SIP number from Spitch. Call forwarding can be activated or deactivated at any time, ensuring maximum flexibility and BCM safety." - }, - { - "context": "ui", - "key": "Vorherige Seite", - "value": "Previous page" - }, - { - "context": "ui", - "key": "Vorschau", - "value": "Preview" - }, - { - "context": "ui", - "key": "Vorschau für diesen Dateityp nicht verfügbar", - "value": "Preview not available for this file type" - }, - { - "context": "ui", - "key": "Vorschau schließen", - "value": "Close preview" - }, - { - "context": "ui", - "key": "Vorschau wird geladen...", - "value": "Loading preview..." - }, - { - "context": "ui", - "key": "WARTEND", - "value": "PENDING" - }, - { - "context": "ui", - "key": "Wartend", - "value": "Pending" - }, - { - "context": "ui", - "key": "Was passiert als nächstes?", - "value": "What happens next?" - }, - { - "context": "ui", - "key": "Wechseln Sie zwischen hellem und dunklem Modus", - "value": "Switch between light and dark mode" - }, - { - "context": "ui", - "key": "Werkzeuge", - "value": "Utils" - }, - { - "context": "ui", - "key": "Werkzeuge und Hilfsmittel", - "value": "Utilities and tools" - }, - { - "context": "ui", - "key": "Wie möchten Sie am Telefon genannt werden?", - "value": "How would you like to be called on the phone?" - }, - { - "context": "ui", - "key": "Wiederholen", - "value": "Retry" - }, - { - "context": "ui", - "key": "Willkommen in Ihrem Arbeitsbereich", - "value": "Welcome to your workspace" - }, - { - "context": "ui", - "key": "Wird gesendet...", - "value": "Sending..." - }, - { - "context": "ui", - "key": "Wird gestoppt...", - "value": "Stopping..." - }, - { - "context": "ui", - "key": "Wird geteilt...", - "value": "Sharing..." - }, - { - "context": "ui", - "key": "Wird hochgeladen...", - "value": "Uploading..." - }, - { - "context": "ui", - "key": "Wird verarbeitet...", - "value": "Processing..." - }, - { - "context": "ui", - "key": "Workflow", - "value": "Workflow" - }, - { - "context": "ui", - "key": "Workflow Fortschritt", - "value": "Workflow Progress" - }, - { - "context": "ui", - "key": "Workflow auswählen", - "value": "Select Workflow" - }, - { - "context": "ui", - "key": "Workflow fehlgeschlagen.", - "value": "Workflow failed." - }, - { - "context": "ui", - "key": "Workflow fortsetzen", - "value": "Resume workflow" - }, - { - "context": "ui", - "key": "Workflow läuft... Warte auf Logs...", - "value": "Workflow running... Waiting for logs..." - }, - { - "context": "ui", - "key": "Workflow löschen", - "value": "Delete workflow" - }, - { - "context": "ui", - "key": "Workflow stoppen", - "value": "Stop workflow" - }, - { - "context": "ui", - "key": "Workflow wird fortgesetzt", - "value": "Continuing workflow" - }, - { - "context": "ui", - "key": "Workflow wird gelöscht...", - "value": "Deleting workflow..." - }, - { - "context": "ui", - "key": "Workflow-Automatisierungen verwalten", - "value": "Manage workflow automations" - }, - { - "context": "ui", - "key": "Workflow-Nachrichten werden geladen...", - "value": "Loading workflow messages..." - }, - { - "context": "ui", - "key": "Workflow-Verlauf", - "value": "Workflow History" - }, - { - "context": "ui", - "key": "Workflows", - "value": "Workflows" - }, - { - "context": "ui", - "key": "Workflows werden geladen...", - "value": "Loading workflows..." - }, - { - "context": "ui", - "key": "Wähle einen Workflow aus der Liste aus oder starte einen neuen Workflow", - "value": "Select a workflow from the list or start a new workflow" - }, - { - "context": "ui", - "key": "Wählen Sie Ihre bevorzugte Sprache", - "value": "Choose your preferred language" - }, - { - "context": "ui", - "key": "You", - "value": "You" - }, - { - "context": "ui", - "key": "Zeitzone", - "value": "Timezone" - }, - { - "context": "ui", - "key": "Zentrale", - "value": "Dashboard" - }, - { - "context": "ui", - "key": "Zu dunklem Modus wechseln", - "value": "Switch to dark mode" - }, - { - "context": "ui", - "key": "Zu hellem Modus wechseln", - "value": "Switch to light mode" - }, - { - "context": "ui", - "key": "Zugriff", - "value": "Access" - }, - { - "context": "ui", - "key": "Zugriff erfolgreich erstellt", - "value": "Access created successfully" - }, - { - "context": "ui", - "key": "Zugriff verweigert", - "value": "Access Denied" - }, - { - "context": "ui", - "key": "Zuletzt geprüft", - "value": "Last Checked" - }, - { - "context": "ui", - "key": "Zum Bestätigen klicken", - "value": "Click to confirm" - }, - { - "context": "ui", - "key": "Zum Bestätigen klicken...", - "value": "Click to confirm..." - }, - { - "context": "ui", - "key": "Zurück zur Sprach Integration", - "value": "Back to Speech Integration" - }, - { - "context": "ui", - "key": "angehängt", - "value": "attached" - }, - { - "context": "ui", - "key": "ausgewählt", - "value": "selected" - }, - { - "context": "ui", - "key": "kontakt@firma.com", - "value": "contact@company.com" - }, - { - "context": "ui", - "key": "oder", - "value": "or" - }, - { - "context": "ui", - "key": "z.B. Beleg.pdf", - "value": "e.g. Receipt.pdf" - }, - { - "context": "ui", - "key": "z.B. Finanzdienstleistungen, Technologie, etc.", - "value": "e.g. Financial Services, Technology, etc." - }, - { - "context": "ui", - "key": "z.B. Muster AG 2026", - "value": "e.g. Muster AG 2026" - }, - { - "context": "ui", - "key": "z.B. Treuhand AG Zürich", - "value": "e.g. Trustee AG Zurich" - }, - { - "context": "ui", - "key": "z.B. admin, operate, userreport", - "value": "e.g. admin, operate, userreport" - }, - { - "context": "ui", - "key": "z.B. treuhand-ag-zuerich", - "value": "e.g. trustee-ag-zurich" - }, - { - "context": "ui", - "key": "{authority} Verbindung bearbeiten", - "value": "Edit {authority} Connection" - }, - { - "context": "ui", - "key": "{column} filtern", - "value": "Filter {column}" - }, - { - "context": "ui", - "key": "{count} Benutzer ausgewählt", - "value": "{count} users selected" - }, - { - "context": "ui", - "key": "Änderungen speichern", - "value": "Save Changes" - }, - { - "context": "ui", - "key": "Über", - "value": "About" - }, - { - "context": "ui", - "key": "Überprüfungsprozess", - "value": "Review Process" - }, - { - "context": "ui", - "key": "Übersicht - Sehen Sie den Arbeitsbereich-Status und Updates", - "value": "Overview - See workspace status and updates" - }, - { - "context": "ui", - "key": "Überwachen Sie automatisch 100% der Gespräche, um wertvolle Einblicke für Ihr Unternehmen zu erhalten.", - "value": "Automatically monitor 100% of conversations to get valuable insights for your business." - }, - { - "context": "ui", - "key": "(gefiltert nach {name})", - "value": "(filtered by {name})" - }, - { - "context": "ui", - "key": "({count} gefiltert)", - "value": "({count} filtered)" - }, - { - "context": "ui", - "key": "Abonnement, Einstellungen und Guthaben pro Mandant", - "value": "Subscription, settings, and credit per tenant" - }, - { - "context": "ui", - "key": "Abrechnung", - "value": "Billing" - }, - { - "context": "ui", - "key": "Aktion", - "value": "Action" - }, - { - "context": "ui", - "key": "Benutzer-Billing", - "value": "User billing" - }, - { - "context": "ui", - "key": "Benutzer-Guthaben", - "value": "User credits" - }, - { - "context": "ui", - "key": "Benutzer:", - "value": "User:" - }, - { - "context": "ui", - "key": "Deaktiviert", - "value": "Disabled" - }, - { - "context": "ui", - "key": "Du hast Zugriff auf {instanceCount} {instanceWord} in {mandateCount} {mandateWord}.", - "value": "You have access to {instanceCount} {instanceWord} in {mandateCount} {mandateWord}." - }, - { - "context": "ui", - "key": "Einstellungen gespeichert!", - "value": "Settings saved!" - }, - { - "context": "ui", - "key": "Feature-Instanz", - "value": "Feature instance" - }, - { - "context": "ui", - "key": "Feature-Instanzen", - "value": "Feature instances" - }, - { - "context": "ui", - "key": "Fehler beim Speichern", - "value": "Error saving" - }, - { - "context": "ui", - "key": "Gesamtguthaben", - "value": "Total credit" - }, - { - "context": "ui", - "key": "Mandant:", - "value": "Tenant:" - }, - { - "context": "ui", - "key": "Mandanten", - "value": "Tenants" - }, - { - "context": "ui", - "key": "Mandanten-Billing", - "value": "Tenant billing" - }, - { - "context": "ui", - "key": "Mandanten-Guthaben", - "value": "Tenant credits" - }, - { - "context": "ui", - "key": "Mandant", - "value": "Tenant" - }, - { - "context": "ui", - "key": "Niedrig", - "value": "Low" - }, - { - "context": "ui", - "key": "Transaktionen", - "value": "Transactions" - }, - { - "context": "ui", - "key": "Warnschwelle", - "value": "Warning threshold" - }, - { - "context": "ui", - "key": "Ansicht an Fenster anpassen", - "value": "Fit to window" - }, - { - "context": "ui", - "key": "Ansicht zurücksetzen", - "value": "Reset view" - }, - { - "context": "ui", - "key": "Auswahl löschen", - "value": "Delete selection" - }, - { - "context": "ui", - "key": "Canvas bearbeiten", - "value": "Edit canvas" - }, - { - "context": "ui", - "key": "Klicken Sie auf einen Ausgang, dann auf einen Eingang", - "value": "Click an output, then an input" - }, - { - "context": "ui", - "key": "Klicken Sie auf einen Eingang, um die Verbindung zu erstellen", - "value": "Click an input to create the connection" - }, - { - "context": "ui", - "key": "Kommentar (optional)", - "value": "Comment (optional)" - }, - { - "context": "ui", - "key": "Kommentar bearbeiten", - "value": "Edit comment" - }, - { - "context": "ui", - "key": "Knoten duplizieren", - "value": "Duplicate node" - }, - { - "context": "ui", - "key": "Rückgängig", - "value": "Undo" - }, - { - "context": "ui", - "key": "Verbindungen zeichnen", - "value": "Draw connections" - }, - { - "context": "ui", - "key": "Vergrößern", - "value": "Zoom in" - }, - { - "context": "ui", - "key": "Verkleinern", - "value": "Zoom out" - }, - { - "context": "ui", - "key": "Wiederholen", - "value": "Redo" - }, - { - "context": "ui", - "key": "Zoom-Voreinstellungen", - "value": "Zoom presets" - }, - { - "context": "ui", - "key": "Zoomstufe (Prozent)", - "value": "Zoom level (percent)" - }, - { - "context": "ui", - "key": "Doppelklick zum Bearbeiten", - "value": "Double-click to edit" - }, - { - "context": "ui", - "key": "Kommentar auf dem Canvas einfügen", - "value": "Add comment on canvas" - }, - { - "context": "ui", - "key": "Kommentar eingeben …", - "value": "Enter a comment…" - }, - { - "context": "ui", - "key": "Canvas-Notiz verschieben", - "value": "Drag to move note" - }, - { - "context": "ui", - "key": "Notizfarbe", - "value": "Note color" - }, - { - "context": "ui", - "key": "Notizgröße ändern", - "value": "Resize note" - }, - { - "context": "ui", - "key": "✓ Mandat eingereicht", - "value": "✓ Mandate Submitted" - } - ], - "status": "complete", - "isDefault": false - }, - { - "id": "fr", - "label": "Français", - "entries": [ - { - "context": "ui", - "key": "+41 123 456 789", - "value": "+41 123 456 789" - }, - { - "context": "ui", - "key": "1 Benutzer ausgewählt", - "value": "1 utilisateur sélectionné" - }, - { - "context": "ui", - "key": "ABGEBROCHEN", - "value": "ANNULÉ" - }, - { - "context": "ui", - "key": "ABGESCHLOSSEN", - "value": "TERMINÉ" - }, - { - "context": "ui", - "key": "Abbrechen", - "value": "Annuler" - }, - { - "context": "ui", - "key": "Abgeschlossen", - "value": "Terminé" - }, - { - "context": "ui", - "key": "Abmelden", - "value": "Se déconnecter" - }, - { - "context": "ui", - "key": "Admin-Einstellungen", - "value": "Paramètres Admin" - }, - { - "context": "ui", - "key": "Administrative Einstellungen", - "value": "Paramètres administratifs" - }, - { - "context": "ui", - "key": "Administrator", - "value": "Administrateur" - }, - { - "context": "ui", - "key": "Adresse", - "value": "Adresse" - }, - { - "context": "ui", - "key": "Agent Assist (AA)", - "value": "Assistance Agent (AA)" - }, - { - "context": "ui", - "key": "Aktionen", - "value": "Actions" - }, - { - "context": "ui", - "key": "Aktiv", - "value": "Actif" - }, - { - "context": "ui", - "key": "Aktiviert", - "value": "Activé" - }, - { - "context": "ui", - "key": "Aktualisieren", - "value": "Mettre à jour" - }, - { - "context": "ui", - "key": "Aktuelle Transkripte", - "value": "Transcriptions Récentes" - }, - { - "context": "ui", - "key": "Alle Dateien", - "value": "Tous les fichiers" - }, - { - "context": "ui", - "key": "Alle Elemente auswählen", - "value": "Sélectionner tous les éléments" - }, - { - "context": "ui", - "key": "Alle Nicht-Standard-Sprachsets jetzt mit dem deutschen Master synchronisieren?", - "value": "Synchroniser maintenant tous les jeux (sauf défaut) avec l’allemand ?" - }, - { - "context": "ui", - "key": "Alle abwählen", - "value": "Tout désélectionner" - }, - { - "context": "ui", - "key": "Alle aktualisieren", - "value": "Tout mettre à jour" - }, - { - "context": "ui", - "key": "Alle auswählen", - "value": "Tout sélectionner" - }, - { - "context": "ui", - "key": "Analysiere Workflow...", - "value": "Analyse du workflow..." - }, - { - "context": "ui", - "key": "Anmelden", - "value": "Se connecter" - }, - { - "context": "ui", - "key": "Anrufer", - "value": "Appelant" - }, - { - "context": "ui", - "key": "Anzeigen", - "value": "Voir" - }, - { - "context": "ui", - "key": "Anzeigename", - "value": "Nom d’affichage" - }, - { - "context": "ui", - "key": "Audio", - "value": "Audio" - }, - { - "context": "ui", - "key": "Auf Standard zurücksetzen", - "value": "Réinitialiser par Défaut" - }, - { - "context": "ui", - "key": "Aufgaben", - "value": "Tâches" - }, - { - "context": "ui", - "key": "Ausführen", - "value": "Exécuter" - }, - { - "context": "ui", - "key": "Ausgewählte Datei:", - "value": "Fichier sélectionné:" - }, - { - "context": "ui", - "key": "Auth-Anbieter", - "value": "Autorité d'authentification" - }, - { - "context": "ui", - "key": "Authentifizierungsanbieter", - "value": "Fournisseur d'authentification" - }, - { - "context": "ui", - "key": "Authentifizierungstoken abgelaufen oder ungültig. Bitte verbinden Sie Ihr Microsoft-Konto erneut.", - "value": "Token d'authentification expiré ou invalide. Veuillez reconnecter votre compte Microsoft." - }, - { - "context": "ui", - "key": "Automatisierung erfolgreich erstellt", - "value": "Automatisation créée avec succès" - }, - { - "context": "ui", - "key": "Automatisierungen", - "value": "Automatisations" - }, - { - "context": "ui", - "key": "Basisdaten", - "value": "Données de Base" - }, - { - "context": "ui", - "key": "Bearbeiten", - "value": "Modifier" - }, - { - "context": "ui", - "key": "Befehl eingeben (z.B., \"Erstelle ein neues Projekt namens 'Hauptstrasse 42'\")", - "value": "Entrez une commande (par exemple, \"Créer un nouveau projet nommé 'Rue Principale 42'\")" - }, - { - "context": "ui", - "key": "Beginne ein Gespräch, indem du eine Nachricht eingibst, eine Vorlage auswählst oder einen vorherigen Workflow fortsetzt …", - "value": "Commencez une conversation en entrant un message, en sélectionnant un modèle ou en continuant un workflow précédent..." - }, - { - "context": "ui", - "key": "Beginnen Sie mit:", - "value": "Commencez avec :" - }, - { - "context": "ui", - "key": "Bei Genehmigung planen wir einen Einrichtungsanruf zur Konfiguration Ihrer Integration.", - "value": "Si approuvé, nous planifierons un appel de configuration pour configurer votre intégration." - }, - { - "context": "ui", - "key": "Beim Hochladen ist ein Fehler aufgetreten.", - "value": "Une erreur s'est produite lors du téléchargement." - }, - { - "context": "ui", - "key": "Beim Hochladen ist ein unerwarteter Fehler aufgetreten.", - "value": "Une erreur inattendue s'est produite lors du téléchargement." - }, - { - "context": "ui", - "key": "Belege verwalten", - "value": "Gérer les pièces justificatives" - }, - { - "context": "ui", - "key": "Benutzer", - "value": "Utilisateur" - }, - { - "context": "ui", - "key": "Benutzer auswählen", - "value": "Sélectionner les utilisateurs" - }, - { - "context": "ui", - "key": "Benutzer bearbeiten", - "value": "Modifier l'utilisateur" - }, - { - "context": "ui", - "key": "Benutzer erstellen", - "value": "Créer l'utilisateur" - }, - { - "context": "ui", - "key": "Benutzer hinzufügen", - "value": "Ajouter un utilisateur" - }, - { - "context": "ui", - "key": "Benutzer löschen", - "value": "Supprimer l'utilisateur" - }, - { - "context": "ui", - "key": "Benutzer werden geladen...", - "value": "Chargement des utilisateurs..." - }, - { - "context": "ui", - "key": "Benutzer-Zugriff verwalten", - "value": "Gérer les accès utilisateurs" - }, - { - "context": "ui", - "key": "Benutzerdefinierter Titel (optional)", - "value": "Titre personnalisé (facultatif)" - }, - { - "context": "ui", - "key": "Benutzerinformationen", - "value": "Informations utilisateur" - }, - { - "context": "ui", - "key": "Benutzerinformationen erfolgreich aktualisiert", - "value": "Informations utilisateur mises à jour avec succès" - }, - { - "context": "ui", - "key": "Benutzerinformationen werden geladen...", - "value": "Chargement des informations utilisateur..." - }, - { - "context": "ui", - "key": "Benutzername", - "value": "Nom d'utilisateur" - }, - { - "context": "ui", - "key": "Benutzerverwaltung - Teammitglieder und Berechtigungen verwalten", - "value": "Gestion des Utilisateurs - Gérer les membres de l'équipe et les permissions" - }, - { - "context": "ui", - "key": "Berechtigung", - "value": "Privilège" - }, - { - "context": "ui", - "key": "Berechtigungsstufe", - "value": "Niveau de privilège" - }, - { - "context": "ui", - "key": "Beschreibung", - "value": "Description" - }, - { - "context": "ui", - "key": "Beschreibung der Rolle", - "value": "Description du rôle" - }, - { - "context": "ui", - "key": "Betrachter", - "value": "Observateur" - }, - { - "context": "ui", - "key": "Betreff", - "value": "Sujet" - }, - { - "context": "ui", - "key": "Bezeichnung", - "value": "Libellé" - }, - { - "context": "ui", - "key": "Bieten Sie Unterstützung im Live-Chat und setzen Sie intelligente Chatbots in allen Kanälen ein.", - "value": "Offrez une assistance en chat en direct et déployez des chatbots intelligents sur tous les canaux." - }, - { - "context": "ui", - "key": "Bild", - "value": "Image" - }, - { - "context": "ui", - "key": "Bitte geben Sie eine gültige E-Mail-Adresse ein", - "value": "Veuillez entrer une adresse email valide" - }, - { - "context": "ui", - "key": "Bitte wählen Sie mindestens einen Benutzer aus", - "value": "Veuillez sélectionner au moins un utilisateur" - }, - { - "context": "ui", - "key": "Branche", - "value": "Secteur" - }, - { - "context": "ui", - "key": "Branche ist erforderlich", - "value": "Le secteur d'activité est requis" - }, - { - "context": "ui", - "key": "Buchungsbetrag", - "value": "Montant de comptabilisation" - }, - { - "context": "ui", - "key": "Buchungspositionen verwalten", - "value": "Gérer les positions de réservation" - }, - { - "context": "ui", - "key": "Buchungswährung", - "value": "Devise de comptabilisation" - }, - { - "context": "ui", - "key": "Chat Platform (CP)", - "value": "Plateforme de Chat (CP)" - }, - { - "context": "ui", - "key": "Chat leeren...", - "value": "Nouveau Chat" - }, - { - "context": "ui", - "key": "Chatbereich", - "value": "Zone de chat" - }, - { - "context": "ui", - "key": "Darstellung", - "value": "Apparence" - }, - { - "context": "ui", - "key": "Datei", - "value": "Fichier" - }, - { - "context": "ui", - "key": "Datei anhängen", - "value": "Joindre un fichier" - }, - { - "context": "ui", - "key": "Datei bereits vorhanden", - "value": "Fichier Déjà Existant" - }, - { - "context": "ui", - "key": "Datei entfernen", - "value": "Supprimer le fichier" - }, - { - "context": "ui", - "key": "Datei erfolgreich hochgeladen!", - "value": "Fichier téléchargé avec succès !" - }, - { - "context": "ui", - "key": "Datei herunterladen", - "value": "Télécharger le fichier" - }, - { - "context": "ui", - "key": "Datei hier ablegen...", - "value": "Déposer le fichier ici..." - }, - { - "context": "ui", - "key": "Datei hinzufügen", - "value": "Ajouter un fichier" - }, - { - "context": "ui", - "key": "Datei hochladen", - "value": "Télécharger un fichier" - }, - { - "context": "ui", - "key": "Datei löschen", - "value": "Supprimer le fichier" - }, - { - "context": "ui", - "key": "Datei vorschauen", - "value": "Aperçu du fichier" - }, - { - "context": "ui", - "key": "Datei-Ablage während Workflow deaktiviert", - "value": "Dépôt de fichiers désactivé pendant le workflow" - }, - { - "context": "ui", - "key": "Dateien", - "value": "Fichiers" - }, - { - "context": "ui", - "key": "Dateien anhängen", - "value": "Joindre des fichiers" - }, - { - "context": "ui", - "key": "Dateien auswählen", - "value": "Sélectionner des fichiers" - }, - { - "context": "ui", - "key": "Dateien hier ablegen", - "value": "Déposer les fichiers ici" - }, - { - "context": "ui", - "key": "Dateien hier ablegen zum Anhängen", - "value": "Déposez les fichiers ici pour les joindre" - }, - { - "context": "ui", - "key": "Dateien hierher ziehen", - "value": "Glisser les fichiers ici" - }, - { - "context": "ui", - "key": "Dateien hochladen", - "value": "Télécharger des fichiers" - }, - { - "context": "ui", - "key": "Dateien werden geladen...", - "value": "Chargement des fichiers..." - }, - { - "context": "ui", - "key": "Dateien werden verarbeitet...", - "value": "Traitement des fichiers..." - }, - { - "context": "ui", - "key": "Dateigröße", - "value": "Taille du fichier" - }, - { - "context": "ui", - "key": "Dateiname", - "value": "Nom du fichier" - }, - { - "context": "ui", - "key": "Dateityp", - "value": "Type de fichier" - }, - { - "context": "ui", - "key": "Dateiverwaltung - Dokumente hochladen und organisieren", - "value": "Gestion des Fichiers - Télécharger et organiser les documents" - }, - { - "context": "ui", - "key": "Dateivorschau", - "value": "Aperçu du fichier" - }, - { - "context": "ui", - "key": "Daten aktualisieren", - "value": "Actualiser les données" - }, - { - "context": "ui", - "key": "Daten empfangen", - "value": "Données reçues" - }, - { - "context": "ui", - "key": "Daten gesendet", - "value": "Données envoyées" - }, - { - "context": "ui", - "key": "Datenverwaltung", - "value": "Gestion des données" - }, - { - "context": "ui", - "key": "Datenverwaltung - Datenimporte und -exporte verwalten", - "value": "Gestion des Données - Gérer les imports et exports de données" - }, - { - "context": "ui", - "key": "Datenverwaltung mit Tabellen", - "value": "Gestion des données avec des tableaux" - }, - { - "context": "ui", - "key": "Datum", - "value": "Date" - }, - { - "context": "ui", - "key": "Dauer", - "value": "Durée" - }, - { - "context": "ui", - "key": "Deutsch", - "value": "Deutsch" - }, - { - "context": "ui", - "key": "Die Datei \"{fileName}\" existiert bereits mit identischem Inhalt. Die vorhandene Datei wird wiederverwendet.", - "value": "Le fichier \"{fileName}\" existe déjà avec un contenu identique. Le fichier existant sera réutilisé." - }, - { - "context": "ui", - "key": "Die Erstellung einer neuen Sprache kann AI-Guthaben auf Ihrem Mandats-Pool belasten. Fortfahren?", - "value": "Créer une nouvelle langue peut consommer des crédits IA sur le pool du mandat. Continuer ?" - }, - { - "context": "ui", - "key": "Dies ist Ihr Ausgangspunkt für den Zugriff auf alle Arbeitsbereich-Features und -Tools.", - "value": "Ceci est votre point de départ pour accéder à toutes les fonctionnalités et outils de votre espace de travail." - }, - { - "context": "ui", - "key": "Diese Aktion kann nicht rückgängig gemacht werden.", - "value": "Cette action ne peut pas être annulée." - }, - { - "context": "ui", - "key": "Diese Datei scheint beschädigt zu sein. Sie hat eine PDF-Erweiterung, enthält aber Textinhalte. Bitte laden Sie die Datei erneut hoch, falls möglich.", - "value": "Ce fichier semble être corrompu. Il a une extension PDF mais contient du contenu texte. Veuillez le télécharger à nouveau si possible." - }, - { - "context": "ui", - "key": "Dieser Bereich enthält alle Verwaltungs- und Management-Tools für Ihren Arbeitsbereich.", - "value": "Cette section contient tous les outils d'administration et de gestion pour votre espace de travail." - }, - { - "context": "ui", - "key": "Dieses Element auswählen", - "value": "Sélectionner cet élément" - }, - { - "context": "ui", - "key": "Dieses Element kann nicht ausgewählt werden", - "value": "Cet élément ne peut pas être sélectionné" - }, - { - "context": "ui", - "key": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden", - "value": "Ce champ est géré par {provider} et ne peut pas être modifié" - }, - { - "context": "ui", - "key": "Dossiers", - "value": "Dossiers" - }, - { - "context": "ui", - "key": "Dokument", - "value": "Document" - }, - { - "context": "ui", - "key": "Dokument erfolgreich erstellt", - "value": "Document créé avec succès" - }, - { - "context": "ui", - "key": "Dokument herunterladen", - "value": "Télécharger le document" - }, - { - "context": "ui", - "key": "Dokument vorschauen", - "value": "Aperçu du document" - }, - { - "context": "ui", - "key": "Dokumente", - "value": "Documents" - }, - { - "context": "ui", - "key": "Dokumente auflisten", - "value": "Lister les documents" - }, - { - "context": "ui", - "key": "Dokumentname", - "value": "Nom du document" - }, - { - "context": "ui", - "key": "Dunkel", - "value": "Sombre" - }, - { - "context": "ui", - "key": "Durchsuchen", - "value": "Parcourir" - }, - { - "context": "ui", - "key": "E-Mail", - "value": "Email" - }, - { - "context": "ui", - "key": "E-Mail-Adresse", - "value": "Adresse Email" - }, - { - "context": "ui", - "key": "E-Mail-Adresse ist erforderlich", - "value": "L'adresse email est requise" - }, - { - "context": "ui", - "key": "E-Mail-Bestätigung", - "value": "Confirmation par Email" - }, - { - "context": "ui", - "key": "Echtzeit-Datensynchronisation:", - "value": "Synchronisation de Données en Temps Réel:" - }, - { - "context": "ui", - "key": "Eingereichte Daten:", - "value": "Données Soumises :" - }, - { - "context": "ui", - "key": "Einrichtungsanruf", - "value": "Appel de Configuration" - }, - { - "context": "ui", - "key": "Einstellungen", - "value": "Paramètres" - }, - { - "context": "ui", - "key": "Einstellungen erfolgreich gespeichert!", - "value": "Paramètres sauvegardés avec succès !" - }, - { - "context": "ui", - "key": "Einstellungen werden in zukünftigen Updates hinzugefügt.", - "value": "Le contenu des paramètres sera ajouté dans les futures mises à jour." - }, - { - "context": "ui", - "key": "Einstellungen wurden erfolgreich zurückgesetzt.", - "value": "Les paramètres ont été réinitialisés avec succès." - }, - { - "context": "ui", - "key": "Einträge pro Seite:", - "value": "Éléments par page:" - }, - { - "context": "ui", - "key": "Empfänger", - "value": "Destinataire" - }, - { - "context": "ui", - "key": "Endzeit", - "value": "Heure de Fin" - }, - { - "context": "ui", - "key": "English", - "value": "English" - }, - { - "context": "ui", - "key": "Entdeckte Sites", - "value": "Sites découverts" - }, - { - "context": "ui", - "key": "Erfolgreich", - "value": "Succès" - }, - { - "context": "ui", - "key": "Erfolgsrate", - "value": "Taux de succès" - }, - { - "context": "ui", - "key": "Erleben Sie die Zukunft der Mandantenkommunikation durch unsere strategische Partnerschaft mit Spitch.ai. Diese bahnbrechende Integration verwandelt Ihre PowerOn-Plattform in ein intelligentes Telefonie-System, das externe Mandanten nahtlos mit Unternehmen verbindet.", - "value": "Découvrez l'avenir de la communication client grâce à notre partenariat stratégique avec Spitch.ai. Cette intégration révolutionnaire transforme votre plateforme PowerOn en un système téléphonique intelligent qui connecte de manière transparente les clients externes avec les entreprises." - }, - { - "context": "ui", - "key": "Erneut versuchen", - "value": "Réessayer" - }, - { - "context": "ui", - "key": "Erste Seite", - "value": "Première page" - }, - { - "context": "ui", - "key": "Erstellen", - "value": "Créer" - }, - { - "context": "ui", - "key": "Erstellen und verwalten Sie RBAC-Rollen und deren Berechtigungen.", - "value": "Créez et gérez les rôles RBAC et leurs permissions." - }, - { - "context": "ui", - "key": "Erstellen...", - "value": "Création..." - }, - { - "context": "ui", - "key": "Erstellt", - "value": "Créé" - }, - { - "context": "ui", - "key": "Erstellte Dateien", - "value": "Fichiers créés" - }, - { - "context": "ui", - "key": "Erstellungsdatum", - "value": "Date de création" - }, - { - "context": "ui", - "key": "Externe E-Mail", - "value": "E-mail externe" - }, - { - "context": "ui", - "key": "Externe E-Mail-Adresse eingeben", - "value": "Entrez l'adresse e-mail externe" - }, - { - "context": "ui", - "key": "Externen Benutzernamen eingeben", - "value": "Entrez le nom d'utilisateur externe" - }, - { - "context": "ui", - "key": "Externer Benutzername", - "value": "Nom d'utilisateur externe" - }, - { - "context": "ui", - "key": "FEHLER", - "value": "ERREUR" - }, - { - "context": "ui", - "key": "FEHLGESCHLAGEN", - "value": "ÉCHEC" - }, - { - "context": "ui", - "key": "Falls Sie Fragen zu Ihrem Mandat oder dem Integrationsprozess haben, zögern Sie nicht, unser Support-Team zu kontaktieren.", - "value": "Si vous avez des questions sur votre mandat ou le processus d'intégration, n'hésitez pas à contacter notre équipe de support." - }, - { - "context": "ui", - "key": "Fehler", - "value": "Erreur" - }, - { - "context": "ui", - "key": "Fehler beim Aktualisieren der Benutzerinformationen", - "value": "Erreur lors de la mise à jour des informations utilisateur" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der Automatisierung", - "value": "Erreur lors de la création de l'automatisation" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der Organisation", - "value": "Erreur lors de la création de l'organisation" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der Position", - "value": "Erreur lors de la création de la position" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der RBAC-Regel", - "value": "Erreur lors de la création de la règle RBAC" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der Rolle", - "value": "Erreur lors de la création du rôle" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Dokuments", - "value": "Erreur lors de la création du document" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Mandats", - "value": "Erreur lors de la création du mandat" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Prompts", - "value": "Erreur lors de la création du prompt" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Team-Mitglieds", - "value": "Erreur lors de la création du membre de l'équipe" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Vertrags", - "value": "Erreur lors de la création du contrat" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Zugriffs", - "value": "Erreur lors de la création de l'accès" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Benutzer", - "value": "Erreur lors du chargement des utilisateurs" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Benutzer:", - "value": "Erreur lors du chargement des utilisateurs:" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Benutzerinformationen", - "value": "Erreur lors du chargement des informations utilisateur" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Dateien:", - "value": "Erreur lors du chargement des fichiers:" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Logs", - "value": "Erreur lors du chargement des logs" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Nachrichten:", - "value": "Erreur lors du chargement des messages:" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Prompts", - "value": "Erreur lors du chargement des prompts" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Prompts:", - "value": "Erreur lors du chargement des prompts:" - }, - { - "context": "ui", - "key": "Fehler beim Laden der SharePoint Dokumente:", - "value": "Erreur lors du chargement des documents SharePoint:" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Vorschau", - "value": "Erreur lors du chargement de l'aperçu" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Workflows:", - "value": "Erreur lors du chargement des workflows:" - }, - { - "context": "ui", - "key": "Fehler beim Löschen", - "value": "Erreur lors de la suppression" - }, - { - "context": "ui", - "key": "Fehler beim Speichern der Einstellungen. Bitte versuchen Sie es erneut.", - "value": "Échec de la sauvegarde des paramètres. Veuillez réessayer." - }, - { - "context": "ui", - "key": "Fehler beim Teilen des Prompts", - "value": "Erreur lors du partage du prompt" - }, - { - "context": "ui", - "key": "Fehler beim Verarbeiten der Dateien", - "value": "Erreur lors du traitement des fichiers" - }, - { - "context": "ui", - "key": "Fehler:", - "value": "Erreur:" - }, - { - "context": "ui", - "key": "Fehlgeschlagen", - "value": "Échoué" - }, - { - "context": "ui", - "key": "Filter löschen", - "value": "Effacer le filtre" - }, - { - "context": "ui", - "key": "Firma", - "value": "Entreprise" - }, - { - "context": "ui", - "key": "Firmenname", - "value": "Nom de l'Entreprise" - }, - { - "context": "ui", - "key": "Firmenname ist erforderlich", - "value": "Le nom de l'entreprise est requis" - }, - { - "context": "ui", - "key": "Folgenachricht wird gesendet...", - "value": "Envoi du message de suivi..." - }, - { - "context": "ui", - "key": "Fortfahren", - "value": "Continuer" - }, - { - "context": "ui", - "key": "Fortsetzen", - "value": "Continuer" - }, - { - "context": "ui", - "key": "Fragen?", - "value": "Questions ?" - }, - { - "context": "ui", - "key": "Français", - "value": "Français" - }, - { - "context": "ui", - "key": "Fügen Sie eine Nachricht für die Empfänger hinzu", - "value": "Ajoutez un message pour les destinataires" - }, - { - "context": "ui", - "key": "GESTOPPT", - "value": "ARRÊTÉ" - }, - { - "context": "ui", - "key": "Geben Sie Ihren Firmennamen ein", - "value": "Entrez le nom de votre entreprise" - }, - { - "context": "ui", - "key": "Geben Sie Kunden einen schnellen und effizienten Selbstservice für Sprach- und Textanfragen, der 24/7 verfügbar ist.", - "value": "Offrez aux clients un libre-service rapide et efficace pour les requêtes vocales et textuelles disponible 24h/24." - }, - { - "context": "ui", - "key": "Geben Sie den Inhalt des Prompts ein", - "value": "Entrez le contenu du prompt" - }, - { - "context": "ui", - "key": "Geben Sie einen Namen für den Prompt ein", - "value": "Entrez un nom pour le prompt" - }, - { - "context": "ui", - "key": "Geben Sie einen benutzerdefinierten Titel ein", - "value": "Entrez un titre personnalisé" - }, - { - "context": "ui", - "key": "Geplante und automatisierte Workflows", - "value": "Workflows planifiés et automatisés" - }, - { - "context": "ui", - "key": "Geschäftszeiten", - "value": "Heures d'Ouverture" - }, - { - "context": "ui", - "key": "Geschäftszeiten & Zeitzone", - "value": "Heures d'Ouverture et Fuseau Horaire" - }, - { - "context": "ui", - "key": "Gespräch fortsetzen...", - "value": "Continuer la conversation..." - }, - { - "context": "ui", - "key": "Gestartet", - "value": "Démarré" - }, - { - "context": "ui", - "key": "Gestartet:", - "value": "Démarré:" - }, - { - "context": "ui", - "key": "Gestoppt", - "value": "Arrêté" - }, - { - "context": "ui", - "key": "Geteilt", - "value": "Partagés" - }, - { - "context": "ui", - "key": "Geteilte Dateien", - "value": "Fichiers partagés" - }, - { - "context": "ui", - "key": "Globale Sprachsets verwalten (SysAdmin).", - "value": "Gérer les jeux de langue globaux (SysAdmin)." - }, - { - "context": "ui", - "key": "Google", - "value": "Google" - }, - { - "context": "ui", - "key": "Google verbinden", - "value": "Connecter Google" - }, - { - "context": "ui", - "key": "Google-Verbindung erstellen", - "value": "Créer une connexion Google" - }, - { - "context": "ui", - "key": "Google-Verbindung hinzufügen", - "value": "Ajouter une connexion Google" - }, - { - "context": "ui", - "key": "Grundlegende Daten und Ressourcen", - "value": "Données et ressources de base" - }, - { - "context": "ui", - "key": "Größe", - "value": "Taille" - }, - { - "context": "ui", - "key": "Hell", - "value": "Clair" - }, - { - "context": "ui", - "key": "Herunterladen", - "value": "Télécharger" - }, - { - "context": "ui", - "key": "Hinzufügen", - "value": "Ajouter" - }, - { - "context": "ui", - "key": "Hochgeladen", - "value": "Téléchargés" - }, - { - "context": "ui", - "key": "Hochladen", - "value": "Télécharger" - }, - { - "context": "ui", - "key": "ID", - "value": "ID" - }, - { - "context": "ui", - "key": "INFO", - "value": "INFO" - }, - { - "context": "ui", - "key": "Identifizieren und authentifizieren Sie Anrufer in Sekunden mit kontinuierlicher Verifizierung und Sicherheit.", - "value": "Identifiez et authentifiez les appelants en quelques secondes avec une vérification et sécurité continues." - }, - { - "context": "ui", - "key": "Ihre Anfrage wird verarbeitet...", - "value": "Traitement de votre demande..." - }, - { - "context": "ui", - "key": "Inaktiv", - "value": "Inactif" - }, - { - "context": "ui", - "key": "Information", - "value": "Information" - }, - { - "context": "ui", - "key": "Inhalt", - "value": "Contenu" - }, - { - "context": "ui", - "key": "Inhalt ist erforderlich", - "value": "Le contenu est requis" - }, - { - "context": "ui", - "key": "Ja", - "value": "Oui" - }, - { - "context": "ui", - "key": "Jetzt anmelden", - "value": "S'inscrire Maintenant" - }, - { - "context": "ui", - "key": "Jetzt überspringen", - "value": "Ignorer pour l'Instant" - }, - { - "context": "ui", - "key": "KI-erstellt", - "value": "Créés par IA" - }, - { - "context": "ui", - "key": "KI-gestützte Dokumentengenerierung:", - "value": "Génération de Documents alimentée par l'IA:" - }, - { - "context": "ui", - "key": "Kein Auth-Anbieter", - "value": "Aucune autorité d'authentification" - }, - { - "context": "ui", - "key": "Kein Benutzername", - "value": "Aucun nom d'utilisateur" - }, - { - "context": "ui", - "key": "Kein Nachrichteninhalt verfügbar", - "value": "Aucun contenu de message disponible" - }, - { - "context": "ui", - "key": "Kein Name", - "value": "Aucun nom" - }, - { - "context": "ui", - "key": "Kein Workflow ausgewählt", - "value": "Aucun workflow sélectionné" - }, - { - "context": "ui", - "key": "Keine Benutzer verfügbar", - "value": "Aucun utilisateur disponible" - }, - { - "context": "ui", - "key": "Keine Berechtigung", - "value": "Aucun privilège" - }, - { - "context": "ui", - "key": "Keine Berechtigung zum Löschen des Prompts", - "value": "Aucune permission de supprimer l'invite" - }, - { - "context": "ui", - "key": "Keine Dateien gefunden.", - "value": "Aucun fichier trouvé." - }, - { - "context": "ui", - "key": "Keine E-Mail", - "value": "Aucun e-mail" - }, - { - "context": "ui", - "key": "Keine Einträge", - "value": "Aucune entrée" - }, - { - "context": "ui", - "key": "Keine Logs für diesen Workflow verfügbar", - "value": "Aucun log disponible pour ce workflow" - }, - { - "context": "ui", - "key": "Keine Microsoft-Verbindungen gefunden. Bitte erstellen Sie zuerst eine Verbindung.", - "value": "Aucune connexion Microsoft trouvée. Veuillez d'abord créer une connexion." - }, - { - "context": "ui", - "key": "Keine Prompts verfügbar", - "value": "Aucun prompt disponible" - }, - { - "context": "ui", - "key": "Keine SharePoint-Sites gefunden", - "value": "Aucun site SharePoint trouvé" - }, - { - "context": "ui", - "key": "Keine Sprach-Integrations-Daten gefunden. Bitte melden Sie sich zuerst an, um auf die Einstellungen zuzugreifen.", - "value": "Aucune donnée d'intégration vocale trouvée. Veuillez d'abord vous inscrire pour accéder aux paramètres." - }, - { - "context": "ui", - "key": "Keine Sprache", - "value": "Aucune langue" - }, - { - "context": "ui", - "key": "Keine Transkripte vorhanden", - "value": "Aucune transcription disponible" - }, - { - "context": "ui", - "key": "Keine Vorschau verfügbar", - "value": "Aucun aperçu disponible" - }, - { - "context": "ui", - "key": "Keine Workflows gefunden", - "value": "Aucun workflow trouvé" - }, - { - "context": "ui", - "key": "Keine Workflows verfügbar", - "value": "Aucun workflow disponible" - }, - { - "context": "ui", - "key": "Keine hochgeladenen Dateien gefunden.", - "value": "Aucun fichier téléchargé trouvé." - }, - { - "context": "ui", - "key": "Keine mit Ihnen geteilten Dateien gefunden.", - "value": "Aucun fichier partagé trouvé." - }, - { - "context": "ui", - "key": "Keine von der KI erstellten Dateien gefunden.", - "value": "Aucun fichier créé par IA trouvé." - }, - { - "context": "ui", - "key": "Klicken Sie erneut zum Bestätigen", - "value": "Cliquez à nouveau pour confirmer" - }, - { - "context": "ui", - "key": "Klicken Sie erneut zum Bestätigen der Löschung", - "value": "Cliquez à nouveau pour confirmer la suppression" - }, - { - "context": "ui", - "key": "Klicken Sie, um zu öffnen", - "value": "Cliquez pour ouvrir" - }, - { - "context": "ui", - "key": "Knowledge Agent (KA)", - "value": "Agent de Connaissance (KA)" - }, - { - "context": "ui", - "key": "Konfigurieren Sie administrative Einstellungen und Systempräferenzen.", - "value": "Configurez les paramètres administratifs et les préférences système." - }, - { - "context": "ui", - "key": "Konfigurieren und verwalten Sie rollenbasierte Zugriffssteuerungsregeln.", - "value": "Configurez et gérez les règles de contrôle d'accès basé sur les rôles." - }, - { - "context": "ui", - "key": "Kontakte einrichten", - "value": "Configurer les Contacts" - }, - { - "context": "ui", - "key": "Kontaktinformationen", - "value": "Informations de Contact" - }, - { - "context": "ui", - "key": "Kontostatus", - "value": "Statut du compte" - }, - { - "context": "ui", - "key": "Kopieren", - "value": "Copier" - }, - { - "context": "ui", - "key": "Kosteneinsparungen & Effizienz:", - "value": "Économies de Coûts & Efficacité:" - }, - { - "context": "ui", - "key": "Kundenverträge verwalten", - "value": "Gérer les contrats clients" - }, - { - "context": "ui", - "key": "Lade Fortschritt...", - "value": "Chargement du progrès..." - }, - { - "context": "ui", - "key": "Laden...", - "value": "Téléchargement..." - }, - { - "context": "ui", - "key": "Land", - "value": "Pays" - }, - { - "context": "ui", - "key": "Land ist erforderlich", - "value": "Le pays est requis" - }, - { - "context": "ui", - "key": "Leer = Zugriff auf alle Verträge", - "value": "Vide = Accès à tous les contrats" - }, - { - "context": "ui", - "key": "Letzte Aktivität", - "value": "Dernière activité" - }, - { - "context": "ui", - "key": "Letzte Aktivität:", - "value": "Dernière activité:" - }, - { - "context": "ui", - "key": "Letzte Aktivitäten - Sehen Sie Ihre neueste Arbeit", - "value": "Activités Récentes - Consultez votre travail le plus récent" - }, - { - "context": "ui", - "key": "Letzte Seite", - "value": "Dernière page" - }, - { - "context": "ui", - "key": "Link konnte nicht gesendet werden", - "value": "Échec de l'envoi du lien" - }, - { - "context": "ui", - "key": "Log", - "value": "Journal" - }, - { - "context": "ui", - "key": "Logs konnten nicht geladen werden", - "value": "Échec du chargement des logs" - }, - { - "context": "ui", - "key": "Logs werden geladen...", - "value": "Chargement des logs..." - }, - { - "context": "ui", - "key": "Lokal", - "value": "Local" - }, - { - "context": "ui", - "key": "LÄUFT", - "value": "EN COURS" - }, - { - "context": "ui", - "key": "Lädt hoch...", - "value": "Téléchargement..." - }, - { - "context": "ui", - "key": "Läuft", - "value": "En cours" - }, - { - "context": "ui", - "key": "Läuft ab am", - "value": "Expire le" - }, - { - "context": "ui", - "key": "Löschen", - "value": "Supprimer" - }, - { - "context": "ui", - "key": "Löschen ({count})", - "value": "Supprimer ({count})" - }, - { - "context": "ui", - "key": "Löschen...", - "value": "Suppression..." - }, - { - "context": "ui", - "key": "MIME-Typ", - "value": "Type MIME" - }, - { - "context": "ui", - "key": "Management-Tools umfassen:", - "value": "Les outils de gestion incluent:" - }, - { - "context": "ui", - "key": "Mandanten können jederzeit auf die technische SIP-Nummer umstellen und dabei erhebliche Telefoniekosten sparen. Die Integration funktioniert wie ein weiterer Connector (Outlook, SharePoint) und wird nahtlos in Ihren bestehenden Workflow integriert.", - "value": "Les clients peuvent basculer sur le numéro SIP technique à tout moment et économiser des coûts téléphoniques significatifs. L'intégration fonctionne comme un autre connecteur (Outlook, SharePoint) et est intégrée de manière transparente dans votre workflow existant." - }, - { - "context": "ui", - "key": "Mandat erfolgreich eingereicht!", - "value": "Mandat Soumis avec Succès !" - }, - { - "context": "ui", - "key": "Mandat erfolgreich erstellt", - "value": "Mandat créé avec succès" - }, - { - "context": "ui", - "key": "Mandat erstellen", - "value": "Créer le Mandat" - }, - { - "context": "ui", - "key": "Mandat hinzufügen", - "value": "Ajouter un mandat" - }, - { - "context": "ui", - "key": "Mandat-ID", - "value": "ID Mandat" - }, - { - "context": "ui", - "key": "Mandate", - "value": "Mandats" - }, - { - "context": "ui", - "key": "Mandate und Berechtigungen verwalten", - "value": "Gérer les mandats et les permissions" - }, - { - "context": "ui", - "key": "Mandatsverwaltung", - "value": "Gestion des mandats" - }, - { - "context": "ui", - "key": "Mehr erfahren", - "value": "En savoir plus" - }, - { - "context": "ui", - "key": "Meine Uploads", - "value": "Mes téléchargements" - }, - { - "context": "ui", - "key": "Microsoft", - "value": "Microsoft" - }, - { - "context": "ui", - "key": "Microsoft Verbindungen", - "value": "Connexions Microsoft" - }, - { - "context": "ui", - "key": "Microsoft verbinden", - "value": "Connecter Microsoft" - }, - { - "context": "ui", - "key": "Microsoft-Verbindung erstellen", - "value": "Créer une connexion Microsoft" - }, - { - "context": "ui", - "key": "Microsoft-Verbindung hinzufügen", - "value": "Ajouter une connexion Microsoft" - }, - { - "context": "ui", - "key": "Mitglied hinzufügen", - "value": "Ajouter un membre" - }, - { - "context": "ui", - "key": "MwSt %", - "value": "TVA %" - }, - { - "context": "ui", - "key": "MwSt Betrag", - "value": "Montant TVA" - }, - { - "context": "ui", - "key": "Möchten Sie jetzt Kontakte für Ihr Mandat einrichten? Sie können dies auch später in den Einstellungen tun.", - "value": "Souhaitez-vous configurer les contacts pour votre mandat maintenant ? Vous pouvez également le faire plus tard dans les paramètres." - }, - { - "context": "ui", - "key": "Nach unten scrollen", - "value": "Faire défiler vers le bas" - }, - { - "context": "ui", - "key": "Nachricht (optional)", - "value": "Message (facultatif)" - }, - { - "context": "ui", - "key": "Nachricht eingeben...", - "value": "Entrez votre message..." - }, - { - "context": "ui", - "key": "Nachricht wird gesendet...", - "value": "Envoi du message..." - }, - { - "context": "ui", - "key": "Nachrichten", - "value": "Messages" - }, - { - "context": "ui", - "key": "Nahtloser Mandanten-Workflow:", - "value": "Workflow Client Transparent:" - }, - { - "context": "ui", - "key": "Name", - "value": "Nom" - }, - { - "context": "ui", - "key": "Name des Unternehmens", - "value": "Nom de l'entreprise" - }, - { - "context": "ui", - "key": "Name ist erforderlich", - "value": "Le nom est requis" - }, - { - "context": "ui", - "key": "Navigation - Erkunden Sie alle verfügbaren Tools", - "value": "Navigation - Explorez tous les outils disponibles" - }, - { - "context": "ui", - "key": "Nein", - "value": "Non" - }, - { - "context": "ui", - "key": "Neu starten", - "value": "Recommencer" - }, - { - "context": "ui", - "key": "Neue Automatisierung", - "value": "Nouvelle Automatisation" - }, - { - "context": "ui", - "key": "Neue Automatisierung erstellen", - "value": "Créer une Nouvelle Automatisation" - }, - { - "context": "ui", - "key": "Neue Datei hochladen", - "value": "Télécharger un nouveau fichier" - }, - { - "context": "ui", - "key": "Neue Organisation", - "value": "Nouvelle Organisation" - }, - { - "context": "ui", - "key": "Neue Organisation erstellen", - "value": "Créer une nouvelle organisation" - }, - { - "context": "ui", - "key": "Neue Position", - "value": "Nouvelle Position" - }, - { - "context": "ui", - "key": "Neue Position erstellen", - "value": "Créer une nouvelle position" - }, - { - "context": "ui", - "key": "Neue RBAC-Regel erstellen", - "value": "Créer une nouvelle règle RBAC" - }, - { - "context": "ui", - "key": "Neue Rolle", - "value": "Nouveau Rôle" - }, - { - "context": "ui", - "key": "Neue Rolle erstellen", - "value": "Créer un nouveau rôle" - }, - { - "context": "ui", - "key": "Neue Sprache", - "value": "Nouvelle langue" - }, - { - "context": "ui", - "key": "Neuen Prompt erstellen", - "value": "Créer un nouveau prompt" - }, - { - "context": "ui", - "key": "Neuen Vertrag erstellen", - "value": "Créer un nouveau contrat" - }, - { - "context": "ui", - "key": "Neuen Zugriff erstellen", - "value": "Créer un nouvel accès" - }, - { - "context": "ui", - "key": "Neuer Prompt", - "value": "Nouveau prompt" - }, - { - "context": "ui", - "key": "Neuer Vertrag", - "value": "Nouveau Contrat" - }, - { - "context": "ui", - "key": "Neuer Zugriff", - "value": "Nouvel Accès" - }, - { - "context": "ui", - "key": "Neues Dokument", - "value": "Nouveau Document" - }, - { - "context": "ui", - "key": "Neues Dokument erstellen", - "value": "Créer un nouveau document" - }, - { - "context": "ui", - "key": "Neues Mandat erstellen", - "value": "Créer un nouveau mandat" - }, - { - "context": "ui", - "key": "Neues Team-Mitglied erstellen", - "value": "Créer un nouveau membre de l'équipe" - }, - { - "context": "ui", - "key": "Neues Transkript", - "value": "Nouvelle Transcription" - }, - { - "context": "ui", - "key": "Nicht verfügbar", - "value": "N/D" - }, - { - "context": "ui", - "key": "Noch keine Befehle ausgeführt. Senden Sie einen Befehl, um Ergebnisse hier zu sehen.", - "value": "Aucune commande exécutée pour le moment. Envoyez une commande pour voir les résultats ici." - }, - { - "context": "ui", - "key": "Noch keinen Workflow ausgewählt", - "value": "Aucun workflow sélectionné" - }, - { - "context": "ui", - "key": "Nochmal versuchen", - "value": "Réessayer" - }, - { - "context": "ui", - "key": "Nächste Seite", - "value": "Page suivante" - }, - { - "context": "ui", - "key": "Oder geben Sie Ihre Nachricht ein...", - "value": "Ou entrez votre message..." - }, - { - "context": "ui", - "key": "Ordnerpfade", - "value": "Chemins des dossiers" - }, - { - "context": "ui", - "key": "Organisation", - "value": "Organisation" - }, - { - "context": "ui", - "key": "Organisation erfolgreich erstellt", - "value": "Organisation créée avec succès" - }, - { - "context": "ui", - "key": "Organisationen", - "value": "Organisations" - }, - { - "context": "ui", - "key": "Originalbetrag", - "value": "Montant d'origine" - }, - { - "context": "ui", - "key": "Originalwährung", - "value": "Devise d'origine" - }, - { - "context": "ui", - "key": "PDF", - "value": "PDF" - }, - { - "context": "ui", - "key": "Passwort", - "value": "Mot de passe" - }, - { - "context": "ui", - "key": "Passwort eingeben", - "value": "Entrez le mot de passe" - }, - { - "context": "ui", - "key": "Passwort-Link gesendet!", - "value": "Lien de mot de passe envoyé!" - }, - { - "context": "ui", - "key": "Passwort-Link senden", - "value": "Envoyer le lien de mot de passe" - }, - { - "context": "ui", - "key": "Pfad", - "value": "Chemin" - }, - { - "context": "ui", - "key": "Position erfolgreich erstellt", - "value": "Position créée avec succès" - }, - { - "context": "ui", - "key": "Positionen", - "value": "Positions" - }, - { - "context": "ui", - "key": "Postleitzahl", - "value": "Code Postal" - }, - { - "context": "ui", - "key": "Postleitzahl ist erforderlich", - "value": "Le code postal est requis" - }, - { - "context": "ui", - "key": "Projekte", - "value": "Projets" - }, - { - "context": "ui", - "key": "Projektverwaltung", - "value": "Gestion de projets" - }, - { - "context": "ui", - "key": "Projektverwaltung und -organisation", - "value": "Gestion et organisation de projets" - }, - { - "context": "ui", - "key": "Prompt", - "value": "Prompt" - }, - { - "context": "ui", - "key": "Prompt Einstellungen", - "value": "Paramètres de prompt" - }, - { - "context": "ui", - "key": "Prompt Vorlage", - "value": "Modèle de prompt" - }, - { - "context": "ui", - "key": "Prompt ausführen", - "value": "Exécuter le prompt" - }, - { - "context": "ui", - "key": "Prompt auswählen...", - "value": "Sélectionner un prompt..." - }, - { - "context": "ui", - "key": "Prompt bearbeiten", - "value": "Modifier le prompt" - }, - { - "context": "ui", - "key": "Prompt erfolgreich erstellt", - "value": "Prompt créé avec succès" - }, - { - "context": "ui", - "key": "Prompt erstellen", - "value": "Créer le prompt" - }, - { - "context": "ui", - "key": "Prompt hinzufügen", - "value": "Ajouter un prompt" - }, - { - "context": "ui", - "key": "Prompt löschen", - "value": "Effacer le prompt" - }, - { - "context": "ui", - "key": "Prompt teilen", - "value": "Partager le prompt" - }, - { - "context": "ui", - "key": "Prompt wird gelöscht...", - "value": "Suppression du prompt..." - }, - { - "context": "ui", - "key": "Prompt-Inhalt", - "value": "Contenu du prompt" - }, - { - "context": "ui", - "key": "Prompt-Inhalt darf 10.000 Zeichen nicht überschreiten", - "value": "Le contenu du prompt ne peut pas dépasser 10 000 caractères" - }, - { - "context": "ui", - "key": "Prompt-Inhalt darf nicht leer sein", - "value": "Le contenu du prompt ne peut pas être vide" - }, - { - "context": "ui", - "key": "Prompt-Name", - "value": "Nom du prompt" - }, - { - "context": "ui", - "key": "Prompt-Name darf 100 Zeichen nicht überschreiten", - "value": "Le nom du prompt ne peut pas dépasser 100 caractères" - }, - { - "context": "ui", - "key": "Prompt-Name darf nicht leer sein", - "value": "Le nom du prompt ne peut pas être vide" - }, - { - "context": "ui", - "key": "Prompts", - "value": "Prompts" - }, - { - "context": "ui", - "key": "Prompts für Ihren KI-Assistenten erstellen und verwalten", - "value": "Créer et gérer des prompts pour votre assistant IA" - }, - { - "context": "ui", - "key": "Prompts verwalten", - "value": "Gérer vos prompts" - }, - { - "context": "ui", - "key": "Prompts werden geladen...", - "value": "Chargement des prompts..." - }, - { - "context": "ui", - "key": "Python", - "value": "Python" - }, - { - "context": "ui", - "key": "Quelle", - "value": "Source" - }, - { - "context": "ui", - "key": "RBAC-Regel erfolgreich erstellt", - "value": "Règle RBAC créée avec succès" - }, - { - "context": "ui", - "key": "RBAC-Regel hinzufügen", - "value": "Ajouter une règle RBAC" - }, - { - "context": "ui", - "key": "RBAC-Regeln", - "value": "Règles RBAC" - }, - { - "context": "ui", - "key": "RBAC-Regelverwaltung", - "value": "Gestion des règles RBAC" - }, - { - "context": "ui", - "key": "RBAC-Rollen", - "value": "Rôles RBAC" - }, - { - "context": "ui", - "key": "RBAC-Rollenverwaltung", - "value": "Gestion des rôles RBAC" - }, - { - "context": "ui", - "key": "Registrieren", - "value": "S'inscrire" - }, - { - "context": "ui", - "key": "Revolutionäre Telefonie-Integration mit Spitch.ai", - "value": "Intégration Téléphonique Révolutionnaire avec Spitch.ai" - }, - { - "context": "ui", - "key": "Rolle", - "value": "Rôle" - }, - { - "context": "ui", - "key": "Rolle erfolgreich erstellt", - "value": "Rôle créé avec succès" - }, - { - "context": "ui", - "key": "Rolle hinzufügen", - "value": "Ajouter un rôle" - }, - { - "context": "ui", - "key": "Rollen", - "value": "Rôles" - }, - { - "context": "ui", - "key": "Rollen-ID", - "value": "ID du rôle" - }, - { - "context": "ui", - "key": "Rollenbasierte Zugriffssteuerungsregeln", - "value": "Règles de contrôle d'accès basé sur les rôles" - }, - { - "context": "ui", - "key": "Rollenverwaltung", - "value": "Gestion des rôles" - }, - { - "context": "ui", - "key": "Rufname am Telefon", - "value": "Nom au téléphone" - }, - { - "context": "ui", - "key": "Runde", - "value": "Tour" - }, - { - "context": "ui", - "key": "Runden", - "value": "Tours" - }, - { - "context": "ui", - "key": "Schließen", - "value": "Fermer" - }, - { - "context": "ui", - "key": "Schnellzugriff", - "value": "Accès Rapide" - }, - { - "context": "ui", - "key": "Schnellzugriff - Springen Sie zu häufig verwendeten Features", - "value": "Accès Rapide - Accédez rapidement aux fonctionnalités fréquemment utilisées" - }, - { - "context": "ui", - "key": "Seite {page} von {total} ({count} Einträge)", - "value": "Page {page} sur {total} ({count} éléments)" - }, - { - "context": "ui", - "key": "Senden", - "value": "Envoyer" - }, - { - "context": "ui", - "key": "Service", - "value": "Service" - }, - { - "context": "ui", - "key": "Service-Verbindungen", - "value": "Connexions de service" - }, - { - "context": "ui", - "key": "SharePoint Dokumente", - "value": "Documents SharePoint" - }, - { - "context": "ui", - "key": "SharePoint Site URL", - "value": "URL du site SharePoint" - }, - { - "context": "ui", - "key": "SharePoint Test", - "value": "Test SharePoint" - }, - { - "context": "ui", - "key": "Sie erhalten in den nächsten Minuten eine Bestätigungs-E-Mail.", - "value": "Vous recevrez un email de confirmation dans les prochaines minutes." - }, - { - "context": "ui", - "key": "Sie können auch auf den Upload-Button klicken", - "value": "Vous pouvez aussi cliquer sur le bouton de téléchargement" - }, - { - "context": "ui", - "key": "Sie müssen sich zuerst für die Sprach-Integration anmelden, um auf die Transkriptverwaltung zuzugreifen.", - "value": "Vous devez d'abord vous inscrire à l'intégration vocale pour accéder à la gestion des transcriptions." - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie \"{name}\" löschen möchten?", - "value": "Êtes-vous sûr de vouloir supprimer \"{name}\" ?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie Workflow \"{id}...\" löschen möchten?", - "value": "Êtes-vous sûr de vouloir supprimer le workflow \"{id}...\"?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie alle Sprach-Integrations-Einstellungen zurücksetzen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", - "value": "Êtes-vous sûr de vouloir réinitialiser tous les paramètres d'intégration vocale ? Cette action ne peut pas être annulée." - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie den Workflow \"{name}\" löschen möchten?", - "value": "Êtes-vous sûr de vouloir supprimer le workflow \"{name}\"?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie die Datei \"{name}\" löschen möchten?", - "value": "Êtes-vous sûr de vouloir supprimer le fichier \"{name}\"?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie die {count} ausgewählten Elemente löschen möchten?", - "value": "Êtes-vous sûr de vouloir supprimer les {count} éléments sélectionnés ?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie die {service} Verbindung löschen möchten?", - "value": "Êtes-vous sûr de vouloir supprimer la connexion {service} ?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie diesen Benutzer löschen möchten?", - "value": "Êtes-vous sûr de vouloir supprimer cet utilisateur ?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie {count} Benutzer löschen möchten?", - "value": "Êtes-vous sûr de vouloir supprimer {count} utilisateurs ?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie {count} Prompts löschen möchten?", - "value": "Êtes-vous sûr de vouloir supprimer {count} prompts ?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie {count} Verbindungen löschen möchten?", - "value": "Êtes-vous sûr de vouloir supprimer {count} connexions ?" - }, - { - "context": "ui", - "key": "Sites entdecken", - "value": "Découvrir les sites" - }, - { - "context": "ui", - "key": "Speech Analytics (SA)", - "value": "Analyse Vocale (SA)" - }, - { - "context": "ui", - "key": "Speichern", - "value": "Enregistrer" - }, - { - "context": "ui", - "key": "Speichern...", - "value": "Sauvegarde..." - }, - { - "context": "ui", - "key": "Spitch prüft vor jedem Anruf die Mandantenberechtigung bei PowerOn, während alle Datenänderungen zentral von PowerOn initiiert werden. Call-Transkripte werden in Echtzeit in Ihrer PowerOn-Datenbank gespeichert, mit vollständiger Mandantenisolation und Sicherheit. Bei Ausfällen werden Anrufe automatisch blockiert, um die Integrität zu gewährleisten.", - "value": "Spitch vérifie l'autorisation client avec PowerOn avant chaque appel, tandis que tous les changements de données sont initiés centralement par PowerOn. Les transcriptions d'appels sont stockées en temps réel dans votre base de données PowerOn avec une isolation complète du client et la sécurité. En cas de panne, les appels sont automatiquement bloqués pour assurer l'intégrité." - }, - { - "context": "ui", - "key": "Sprach Integration", - "value": "Intégration Vocale" - }, - { - "context": "ui", - "key": "Sprach-Einstellungen", - "value": "Paramètres Vocaux" - }, - { - "context": "ui", - "key": "Sprach-Integration Einstellungen", - "value": "Paramètres d'Intégration Vocale" - }, - { - "context": "ui", - "key": "Sprache", - "value": "Langue" - }, - { - "context": "ui", - "key": "Sprachset {code} wirklich löschen?", - "value": "Supprimer vraiment le jeu de langue {code} ?" - }, - { - "context": "ui", - "key": "Stadt", - "value": "Ville" - }, - { - "context": "ui", - "key": "Stadt ist erforderlich", - "value": "La ville est requise" - }, - { - "context": "ui", - "key": "Start", - "value": "Démarrage" - }, - { - "context": "ui", - "key": "Startzeit", - "value": "Heure de Début" - }, - { - "context": "ui", - "key": "Status", - "value": "Statut" - }, - { - "context": "ui", - "key": "Stellen Sie alles, was Ihre Agenten benötigen, in ihren Händen bereit, mit einem einheitlichen Agent-Desktop.", - "value": "Mettez tout ce dont vos agents ont besoin à portée de main, avec un bureau d'agent unifié." - }, - { - "context": "ui", - "key": "Stoppen", - "value": "Arrêter" - }, - { - "context": "ui", - "key": "Straße", - "value": "Rue" - }, - { - "context": "ui", - "key": "Straße ist erforderlich", - "value": "La rue est requise" - }, - { - "context": "ui", - "key": "Suchen Sie nach Standorten über Adresse oder Koordinaten, oder verwenden Sie natürliche Sprache, um Projekte zu erstellen und zu verwalten.", - "value": "Recherchez des emplacements par adresse ou coordonnées, ou utilisez le langage naturel pour créer et gérer des projets." - }, - { - "context": "ui", - "key": "Suchen...", - "value": "Rechercher..." - }, - { - "context": "ui", - "key": "Systemadministrator", - "value": "Administrateur système" - }, - { - "context": "ui", - "key": "Systemeinstellungen - Arbeitsbereich-Einstellungen konfigurieren", - "value": "Paramètres Système - Configurer les paramètres de l'espace de travail" - }, - { - "context": "ui", - "key": "Tabelle", - "value": "Feuille de calcul" - }, - { - "context": "ui", - "key": "Tags", - "value": "Étiquettes" - }, - { - "context": "ui", - "key": "Team-Bereich", - "value": "Espace équipe" - }, - { - "context": "ui", - "key": "Team-Mitglied erfolgreich erstellt", - "value": "Membre de l'équipe créé avec succès" - }, - { - "context": "ui", - "key": "Team-Mitglieder", - "value": "Membres de l'équipe" - }, - { - "context": "ui", - "key": "Team-Mitglieder verwalten", - "value": "Gérer les membres de votre équipe" - }, - { - "context": "ui", - "key": "Team-Mitglieder verwalten, Berechtigungen festlegen und Zusammenarbeitseinstellungen konfigurieren", - "value": "Gérer les membres de l'équipe, définir les permissions et configurer les paramètres de collaboration" - }, - { - "context": "ui", - "key": "Teilen", - "value": "Partager" - }, - { - "context": "ui", - "key": "Telefon", - "value": "Téléphone" - }, - { - "context": "ui", - "key": "Telefonnummer", - "value": "Numéro de Téléphone" - }, - { - "context": "ui", - "key": "Telefonnummer ist erforderlich", - "value": "Le numéro de téléphone est requis" - }, - { - "context": "ui", - "key": "Text", - "value": "Texte" - }, - { - "context": "ui", - "key": "Textvorschau", - "value": "Aperçu du texte" - }, - { - "context": "ui", - "key": "Theme", - "value": "Thème" - }, - { - "context": "ui", - "key": "Token", - "value": "Jetons" - }, - { - "context": "ui", - "key": "Transkript", - "value": "Transcription" - }, - { - "context": "ui", - "key": "Transkript wird verarbeitet...", - "value": "Traitement de la transcription..." - }, - { - "context": "ui", - "key": "Transkriptverwaltung", - "value": "Gestion des Transcriptions" - }, - { - "context": "ui", - "key": "Trennungsfehler", - "value": "Erreur de déconnexion" - }, - { - "context": "ui", - "key": "Treuhand", - "value": "Fiduciaire" - }, - { - "context": "ui", - "key": "Treuhandverwaltung", - "value": "Gestion Fiduciaire" - }, - { - "context": "ui", - "key": "Trustee-Organisationen verwalten", - "value": "Gérer les organisations fiduciaires" - }, - { - "context": "ui", - "key": "Trustee-Rollen verwalten", - "value": "Gérer les rôles fiduciaires" - }, - { - "context": "ui", - "key": "Typ", - "value": "Type" - }, - { - "context": "ui", - "key": "UI-Sprachen", - "value": "Langues de l’UI" - }, - { - "context": "ui", - "key": "Unbekannt", - "value": "Inconnu" - }, - { - "context": "ui", - "key": "Unbekannte Größe", - "value": "Taille inconnue" - }, - { - "context": "ui", - "key": "Unbekanntes Datum", - "value": "Date inconnue" - }, - { - "context": "ui", - "key": "Unbenannt", - "value": "Sans nom" - }, - { - "context": "ui", - "key": "Unbenannter Workflow", - "value": "Workflow sans nom" - }, - { - "context": "ui", - "key": "Ungültiges Datum", - "value": "Date invalide" - }, - { - "context": "ui", - "key": "Unser Team wird Ihr Mandat innerhalb von 1-2 Werktagen überprüfen.", - "value": "Notre équipe examinera votre mandat dans les 1-2 jours ouvrables." - }, - { - "context": "ui", - "key": "Unsere bereits aktive Dokumenten-Extraktions-Engine generiert automatisch personalisierte Dokumente für Spitch, basierend auf Mandantenspezifischen Daten. Die KI nutzt FAQ-Datenbanken, Mitarbeiterinformationen und Service-Details, um jeden Anruf kontextuell und hochpersonalisiert zu gestalten.", - "value": "Notre moteur d'extraction de documents déjà actif génère automatiquement des documents personnalisés pour Spitch basés sur les données spécifiques au client. L'IA utilise les bases de données FAQ, les informations employés et les détails de service pour rendre chaque appel contextuel et hautement personnalisé." - }, - { - "context": "ui", - "key": "Unternehmensinformationen", - "value": "Informations de l'Entreprise" - }, - { - "context": "ui", - "key": "Unterstützt von", - "value": "Alimenté par" - }, - { - "context": "ui", - "key": "Upload fehlgeschlagen. Bitte versuchen Sie es erneut.", - "value": "Échec du téléchargement. Veuillez réessayer." - }, - { - "context": "ui", - "key": "VERARBEITUNG", - "value": "TRAITEMENT" - }, - { - "context": "ui", - "key": "Valutadatum", - "value": "Date de valeur" - }, - { - "context": "ui", - "key": "Verarbeitung", - "value": "En cours" - }, - { - "context": "ui", - "key": "Verbinden", - "value": "Connecter" - }, - { - "context": "ui", - "key": "Verbindung aktualisieren", - "value": "Mettre à jour la connexion" - }, - { - "context": "ui", - "key": "Verbindung testen", - "value": "Tester la connexion" - }, - { - "context": "ui", - "key": "Verbindungen", - "value": "Connexions" - }, - { - "context": "ui", - "key": "Verbindungen werden geladen...", - "value": "Chargement des connexions..." - }, - { - "context": "ui", - "key": "Verbindungsfehler", - "value": "Erreur de connexion" - }, - { - "context": "ui", - "key": "Verbunden am", - "value": "Connecté le" - }, - { - "context": "ui", - "key": "Vereinheitlichen und liefern Sie Informationen an Ihre Kunden und Mitarbeiter, wann und wo sie sie benötigen.", - "value": "Unifiez et livrez des informations à vos clients et employés où et quand ils en ont besoin." - }, - { - "context": "ui", - "key": "Verfügbare Tools", - "value": "Outils Disponibles" - }, - { - "context": "ui", - "key": "Verfügbare Workflows", - "value": "Workflows disponibles" - }, - { - "context": "ui", - "key": "Version", - "value": "Version" - }, - { - "context": "ui", - "key": "Versuchen Sie, Ihr Microsoft-Konto auf der Verbindungsseite erneut zu verbinden.", - "value": "Essayez de reconnecter votre compte Microsoft dans la page Connexions." - }, - { - "context": "ui", - "key": "Vertrag", - "value": "Contrat" - }, - { - "context": "ui", - "key": "Vertrag (optional)", - "value": "Contrat (optionnel)" - }, - { - "context": "ui", - "key": "Vertrag erfolgreich erstellt", - "value": "Contrat créé avec succès" - }, - { - "context": "ui", - "key": "Verträge", - "value": "Contrats" - }, - { - "context": "ui", - "key": "Verwalten Sie Daten über Tabellen. Wählen Sie eine Tabelle aus oder verwenden Sie natürliche Sprache, um Befehle auszuführen.", - "value": "Gérez les données via des tableaux. Sélectionnez un tableau ou utilisez le langage naturel pour exécuter des commandes." - }, - { - "context": "ui", - "key": "Verwalten Sie Ihre Kontoinformationen", - "value": "Gérez vos informations de compte" - }, - { - "context": "ui", - "key": "Verwalten Sie Ihre Service-Verbindungen", - "value": "Gérez vos connexions de service" - }, - { - "context": "ui", - "key": "Verwalten Sie Ihre Sprach-Integrations-Konfiguration und Einstellungen.", - "value": "Gérez votre configuration et vos préférences d'intégration vocale." - }, - { - "context": "ui", - "key": "Verwalten Sie Mandate und deren zugehörige Berechtigungen.", - "value": "Gérez les mandats et leurs permissions associées." - }, - { - "context": "ui", - "key": "Verwaltet von {provider}", - "value": "Géré par {provider}" - }, - { - "context": "ui", - "key": "Verwaltung der Benutzerzugriffe auf Organisationen", - "value": "Gestion des accès utilisateurs aux organisations" - }, - { - "context": "ui", - "key": "Verwaltung der Buchungspositionen (Speseneinträge)", - "value": "Gestion des positions de réservation (entrées de dépenses)" - }, - { - "context": "ui", - "key": "Verwaltung der Dokumente und Belege", - "value": "Gestion des documents et pièces justificatives" - }, - { - "context": "ui", - "key": "Verwaltung der Feature-spezifischen Rollen", - "value": "Gestion des rôles spécifiques à la fonctionnalité" - }, - { - "context": "ui", - "key": "Verwaltung der Kundenverträge", - "value": "Gestion des contrats clients" - }, - { - "context": "ui", - "key": "Verwaltung der Treuhand-Organisationen", - "value": "Gestion des organisations fiduciaires" - }, - { - "context": "ui", - "key": "Verwaltung von Treuhand-Organisationen, Verträgen und Buchungen", - "value": "Gestion des organisations fiduciaires, contrats et réservations" - }, - { - "context": "ui", - "key": "Verwaltungs- und Management-Tools", - "value": "Outils d'administration et de gestion" - }, - { - "context": "ui", - "key": "Verwende Vorlage:", - "value": "Utilisation du modèle:" - }, - { - "context": "ui", - "key": "Video", - "value": "Vidéo" - }, - { - "context": "ui", - "key": "Vielen Dank für Ihr Interesse an unserer Sprach Integration powered by Spitch.ai. Wir haben Ihr Mandat erhalten und werden es in Kürze überprüfen.", - "value": "Merci pour votre intérêt pour notre Intégration Vocale powered by Spitch.ai. Nous avons reçu votre mandat et l'examinerons sous peu." - }, - { - "context": "ui", - "key": "Virtual Assistant (VA)", - "value": "Assistant Virtuel (VA)" - }, - { - "context": "ui", - "key": "Voice Biometrics (VB)", - "value": "Biométrie Vocale (VB)" - }, - { - "context": "ui", - "key": "Vollständiger Name", - "value": "Nom complet" - }, - { - "context": "ui", - "key": "Von der Registrierung bis zur technischen Einrichtung - Ihr Mandant registriert sich bei PowerOn für Telefonie-Services, lädt Dokumente hoch und erhält automatisch eine technische SIP-Nummer von Spitch. Die Call-Weiterleitung kann jederzeit aktiviert oder deaktiviert werden, was maximale Flexibilität und BCM-Sicherheit gewährleistet.", - "value": "De l'inscription à la configuration technique - votre client s'inscrit auprès de PowerOn pour les services téléphoniques, télécharge des documents et reçoit automatiquement un numéro SIP technique de Spitch. Le transfert d'appel peut être activé ou désactivé à tout moment, garantissant une flexibilité maximale et la sécurité BCM." - }, - { - "context": "ui", - "key": "Vorherige Seite", - "value": "Page précédente" - }, - { - "context": "ui", - "key": "Vorschau", - "value": "Aperçu" - }, - { - "context": "ui", - "key": "Vorschau für diesen Dateityp nicht verfügbar", - "value": "Aperçu non disponible pour ce type de fichier" - }, - { - "context": "ui", - "key": "Vorschau schließen", - "value": "Fermer l'aperçu" - }, - { - "context": "ui", - "key": "Vorschau wird geladen...", - "value": "Chargement de l'aperçu..." - }, - { - "context": "ui", - "key": "WARTEND", - "value": "EN ATTENTE" - }, - { - "context": "ui", - "key": "Wartend", - "value": "En attente" - }, - { - "context": "ui", - "key": "Was passiert als nächstes?", - "value": "Que se passe-t-il ensuite ?" - }, - { - "context": "ui", - "key": "Wechseln Sie zwischen hellem und dunklem Modus", - "value": "Basculer entre le mode clair et sombre" - }, - { - "context": "ui", - "key": "Werkzeuge", - "value": "Outils" - }, - { - "context": "ui", - "key": "Werkzeuge und Hilfsmittel", - "value": "Outils et utilitaires" - }, - { - "context": "ui", - "key": "Wie möchten Sie am Telefon genannt werden?", - "value": "Comment souhaitez-vous être appelé au téléphone ?" - }, - { - "context": "ui", - "key": "Wiederholen", - "value": "Réessayer" - }, - { - "context": "ui", - "key": "Willkommen in Ihrem Arbeitsbereich", - "value": "Bienvenue dans votre espace de travail" - }, - { - "context": "ui", - "key": "Wird gesendet...", - "value": "Envoi..." - }, - { - "context": "ui", - "key": "Wird gestoppt...", - "value": "Arrêt..." - }, - { - "context": "ui", - "key": "Wird geteilt...", - "value": "Partage en cours..." - }, - { - "context": "ui", - "key": "Wird hochgeladen...", - "value": "Téléchargement..." - }, - { - "context": "ui", - "key": "Wird verarbeitet...", - "value": "Traitement..." - }, - { - "context": "ui", - "key": "Workflow", - "value": "Workflow" - }, - { - "context": "ui", - "key": "Workflow Fortschritt", - "value": "Progression du workflow" - }, - { - "context": "ui", - "key": "Workflow auswählen", - "value": "Sélectionner un workflow" - }, - { - "context": "ui", - "key": "Workflow fehlgeschlagen.", - "value": "Échec du workflow." - }, - { - "context": "ui", - "key": "Workflow fortsetzen", - "value": "Reprendre le workflow" - }, - { - "context": "ui", - "key": "Workflow läuft... Warte auf Logs...", - "value": "Workflow en cours... En attente des logs..." - }, - { - "context": "ui", - "key": "Workflow löschen", - "value": "Supprimer le workflow" - }, - { - "context": "ui", - "key": "Workflow stoppen", - "value": "Arrêter le workflow" - }, - { - "context": "ui", - "key": "Workflow wird fortgesetzt", - "value": "Workflow en cours" - }, - { - "context": "ui", - "key": "Workflow wird gelöscht...", - "value": "Suppression du workflow..." - }, - { - "context": "ui", - "key": "Workflow-Automatisierungen verwalten", - "value": "Gérer les automatisations de workflow" - }, - { - "context": "ui", - "key": "Workflow-Nachrichten werden geladen...", - "value": "Chargement des messages de workflow..." - }, - { - "context": "ui", - "key": "Workflow-Verlauf", - "value": "Historique des workflows" - }, - { - "context": "ui", - "key": "Workflows", - "value": "Workflows" - }, - { - "context": "ui", - "key": "Workflows werden geladen...", - "value": "Chargement des workflows..." - }, - { - "context": "ui", - "key": "Wähle einen Workflow aus der Liste aus oder starte einen neuen Workflow", - "value": "Sélectionnez un workflow dans la liste ou démarrez un nouveau workflow" - }, - { - "context": "ui", - "key": "Wählen Sie Ihre bevorzugte Sprache", - "value": "Choisissez votre langue préférée" - }, - { - "context": "ui", - "key": "You", - "value": "Vous" - }, - { - "context": "ui", - "key": "Zeitzone", - "value": "Fuseau Horaire" - }, - { - "context": "ui", - "key": "Zentrale", - "value": "Centre d'activité" - }, - { - "context": "ui", - "key": "Zu dunklem Modus wechseln", - "value": "Passer en mode sombre" - }, - { - "context": "ui", - "key": "Zu hellem Modus wechseln", - "value": "Passer en mode clair" - }, - { - "context": "ui", - "key": "Zugriff", - "value": "Accès" - }, - { - "context": "ui", - "key": "Zugriff erfolgreich erstellt", - "value": "Accès créé avec succès" - }, - { - "context": "ui", - "key": "Zugriff verweigert", - "value": "Accès Refusé" - }, - { - "context": "ui", - "key": "Zuletzt geprüft", - "value": "Dernière vérification" - }, - { - "context": "ui", - "key": "Zum Bestätigen klicken", - "value": "Cliquez pour confirmer" - }, - { - "context": "ui", - "key": "Zum Bestätigen klicken...", - "value": "Cliquez pour confirmer..." - }, - { - "context": "ui", - "key": "Zurück zur Sprach Integration", - "value": "Retour à l'Intégration Vocale" - }, - { - "context": "ui", - "key": "angehängt", - "value": "attaché" - }, - { - "context": "ui", - "key": "ausgewählt", - "value": "sélectionné(s)" - }, - { - "context": "ui", - "key": "kontakt@firma.com", - "value": "contact@entreprise.com" - }, - { - "context": "ui", - "key": "oder", - "value": "ou" - }, - { - "context": "ui", - "key": "z.B. Beleg.pdf", - "value": "ex. Justificatif.pdf" - }, - { - "context": "ui", - "key": "z.B. Finanzdienstleistungen, Technologie, etc.", - "value": "ex. Services Financiers, Technologie, etc." - }, - { - "context": "ui", - "key": "z.B. Muster AG 2026", - "value": "ex. Muster AG 2026" - }, - { - "context": "ui", - "key": "z.B. Treuhand AG Zürich", - "value": "ex. Fiduciaire AG Zurich" - }, - { - "context": "ui", - "key": "z.B. admin, operate, userreport", - "value": "ex. admin, operate, userreport" - }, - { - "context": "ui", - "key": "z.B. treuhand-ag-zuerich", - "value": "ex. fiduciaire-ag-zurich" - }, - { - "context": "ui", - "key": "{authority} Verbindung bearbeiten", - "value": "Modifier la connexion {authority}" - }, - { - "context": "ui", - "key": "{column} filtern", - "value": "Filtrer {column}" - }, - { - "context": "ui", - "key": "{count} Benutzer ausgewählt", - "value": "{count} utilisateurs sélectionnés" - }, - { - "context": "ui", - "key": "Änderungen speichern", - "value": "Sauvegarder les Modifications" - }, - { - "context": "ui", - "key": "Über", - "value": "À propos" - }, - { - "context": "ui", - "key": "Überprüfungsprozess", - "value": "Processus de Révision" - }, - { - "context": "ui", - "key": "Übersicht - Sehen Sie den Arbeitsbereich-Status und Updates", - "value": "Aperçu - Consultez le statut et les mises à jour de l'espace de travail" - }, - { - "context": "ui", - "key": "Überwachen Sie automatisch 100% der Gespräche, um wertvolle Einblicke für Ihr Unternehmen zu erhalten.", - "value": "Surveillez automatiquement 100% des conversations pour obtenir des insights précieux pour votre entreprise." - }, - { - "context": "ui", - "key": "(gefiltert nach {name})", - "value": "(filtré par {name})" - }, - { - "context": "ui", - "key": "({count} gefiltert)", - "value": "({count} filtrés)" - }, - { - "context": "ui", - "key": "Abonnement, Einstellungen und Guthaben pro Mandant", - "value": "Abonnement, paramètres et crédits par client" - }, - { - "context": "ui", - "key": "Abrechnung", - "value": "Facturation" - }, - { - "context": "ui", - "key": "Aktion", - "value": "Action" - }, - { - "context": "ui", - "key": "Benutzer-Billing", - "value": "Facturation utilisateurs" - }, - { - "context": "ui", - "key": "Benutzer-Guthaben", - "value": "Crédits utilisateur" - }, - { - "context": "ui", - "key": "Benutzer:", - "value": "Utilisateur :" - }, - { - "context": "ui", - "key": "Deaktiviert", - "value": "Désactivé" - }, - { - "context": "ui", - "key": "Du hast Zugriff auf {instanceCount} {instanceWord} in {mandateCount} {mandateWord}.", - "value": "Vous avez accès à {instanceCount} {instanceWord} sur {mandateCount} {mandateWord}." - }, - { - "context": "ui", - "key": "Einstellungen gespeichert!", - "value": "Paramètres enregistrés !" - }, - { - "context": "ui", - "key": "Feature-Instanz", - "value": "instance de fonctionnalité" - }, - { - "context": "ui", - "key": "Feature-Instanzen", - "value": "instances de fonctionnalité" - }, - { - "context": "ui", - "key": "Fehler beim Speichern", - "value": "Erreur lors de l'enregistrement" - }, - { - "context": "ui", - "key": "Gesamtguthaben", - "value": "Crédit total" - }, - { - "context": "ui", - "key": "Mandant:", - "value": "Client :" - }, - { - "context": "ui", - "key": "Mandanten", - "value": "Clients" - }, - { - "context": "ui", - "key": "Mandanten-Billing", - "value": "Facturation clients" - }, - { - "context": "ui", - "key": "Mandanten-Guthaben", - "value": "Crédits clients" - }, - { - "context": "ui", - "key": "Mandant", - "value": "Client" - }, - { - "context": "ui", - "key": "Niedrig", - "value": "Faible" - }, - { - "context": "ui", - "key": "Transaktionen", - "value": "Transactions" - }, - { - "context": "ui", - "key": "Warnschwelle", - "value": "Seuil d'alerte" - }, - { - "context": "ui", - "key": "✓ Mandat eingereicht", - "value": "✓ Mandat Soumis" - } - ], - "status": "complete", - "isDefault": false - } -] diff --git a/modules/migrations/_archive/README.md b/modules/migrations/_archive/README.md deleted file mode 100644 index c488801a..00000000 --- a/modules/migrations/_archive/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Archived one-off migrations - -`migrate_folders_to_groups.py` copies `FileFolder` + `FileItem.folderId` into `TableGrouping` (`files/list`). It was used during an experimental UI path; **product choice** is to keep physical folders (`FileFolder`, `folderId`) and recover `FormGeneratorTree` (see `wiki/c-work/1-plan/2026-05-formgenerator-tree-and-folder-recovery.md`). - -Run only if you need a historical data rescue: - -```bash -cd gateway -python -m modules.migrations._archive.migrate_folders_to_groups --verbose -python -m modules.migrations._archive.migrate_folders_to_groups --execute --verbose -``` diff --git a/modules/migrations/_archive/__init__.py b/modules/migrations/_archive/__init__.py deleted file mode 100644 index a733bae9..00000000 --- a/modules/migrations/_archive/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Subpackage for archived one-off migration scripts (not part of normal app startup). diff --git a/modules/migrations/_archive/migrate_folders_to_groups.py b/modules/migrations/_archive/migrate_folders_to_groups.py deleted file mode 100644 index 6beed744..00000000 --- a/modules/migrations/_archive/migrate_folders_to_groups.py +++ /dev/null @@ -1,261 +0,0 @@ -""" -One-time migration: Convert FileFolder tree + FileItem.folderId to table_groupings. - -Archived per wiki plan 2026-05-formgenerator-tree-and-folder-recovery (Stage 1.A). -Product direction: keep FileFolder + folderId; do not run DROP migrations. -This script remains for audit / one-off data rescue only. - -Run this BEFORE dropping the physical FileFolder table and FileItem.folderId column -from the database (those would be separate Alembic/SQL steps -- not part of current product path). - -Usage (from gateway working directory): - python -m modules.migrations._archive.migrate_folders_to_groups [--dry-run] [--verbose] - python -m modules.migrations._archive.migrate_folders_to_groups --execute --verbose - -Steps: - 1. For each distinct (userId, mandateId) combination that has FileFolder records: - a. Build the full folder tree (recursive) - b. Write it as a TableGroupNode tree into table_groupings (contextKey='files/list') - – merges with any existing groups rather than overwriting - c. For each FileItem with a folderId that maps into this tree, - add its id to the matching group's itemIds - 2. Print a summary (rows migrated, groups created, files assigned) - 3. If not --dry-run: commits the inserts/updates - NOTE: Schema changes (ALTER TABLE DROP COLUMN, DROP TABLE) are intentionally - NOT performed by this script. Run the corresponding Alembic migration - (migrations/versions/xxxx_drop_folder_columns.py) afterwards. -""" - -import argparse -import json -import logging -import uuid -from typing import Optional - -logger = logging.getLogger(__name__) - - -def _scalarRow(row): - if row is None: - return None - if isinstance(row, dict): - return next(iter(row.values())) - return row[0] - - -# ── Helpers ────────────────────────────────────────────────────────────────── - -def _build_tree(folders: list, parent_id: Optional[str]) -> list: - """Recursively build TableGroupNode-compatible dicts from a flat folder list.""" - children = [f for f in folders if f.get("parentId") == parent_id] - result = [] - for folder in children: - node = { - "id": str(uuid.uuid4()), - "name": folder["name"], - "itemIds": [], - "subGroups": _build_tree(folders, folder["id"]), - "meta": {"migratedFromFolderId": folder["id"]}, - } - result.append(node) - return result - - -def _assign_files_to_nodes(nodes: list, files_by_folder: dict) -> list: - """Recursively assign file IDs to group nodes based on folder mapping.""" - for node in nodes: - folder_id = (node.get("meta") or {}).get("migratedFromFolderId") - if folder_id and folder_id in files_by_folder: - node["itemIds"] = list(files_by_folder[folder_id]) - node["subGroups"] = _assign_files_to_nodes(node.get("subGroups", []), files_by_folder) - return nodes - - -def _count_items(nodes: list) -> int: - total = 0 - for node in nodes: - total += len(node.get("itemIds", [])) - total += _count_items(node.get("subGroups", [])) - return total - - -def _now_ts() -> str: - from modules.shared.timeUtils import getUtcTimestamp - return getUtcTimestamp() - - -# ── Main migration ──────────────────────────────────────────────────────────── - -def run_migration(dry_run: bool = True, verbose: bool = False): - """Main migration entry point.""" - logging.basicConfig(level=logging.DEBUG if verbose else logging.INFO) - logger.info(f"Starting folder to group migration (dry_run={dry_run})") - - from modules.connectors.connectorDbPostgre import getCachedConnector - from modules.shared.configuration import APP_CONFIG - - connector = getCachedConnector( - dbHost=APP_CONFIG.get("DB_HOST", "_no_config_default_data"), - dbDatabase="poweron_management", - dbUser=APP_CONFIG.get("DB_USER"), - dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET"), - dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), - userId=None, - ) - if not connector or not connector.connection: - logger.error("Could not obtain a DB connection. Aborting.") - return - - conn = connector.connection - cur = conn.cursor() - - # ── 1. Check that the source tables still exist ─────────────────────────── - cur.execute(""" - SELECT EXISTS ( - SELECT 1 FROM information_schema.tables - WHERE table_name = 'FileFolder' - ) AS ok - """) - folder_table_exists = bool(_scalarRow(cur.fetchone())) - - cur.execute(""" - SELECT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_name = 'FileItem' AND column_name = 'folderId' - ) AS ok - """) - folder_column_exists = bool(_scalarRow(cur.fetchone())) - - if not folder_table_exists and not folder_column_exists: - logger.info("FileFolder table and FileItem.folderId column not found — migration already applied or not needed.") - return - - if not folder_table_exists: - logger.warning("FileFolder table missing but FileItem.folderId column still present. Only file assignments will be migrated.") - if not folder_column_exists: - logger.warning("FileItem.folderId column missing but FileFolder table still present. Only group tree structure will be migrated.") - - # ── 2. Load all folders ─────────────────────────────────────────────────── - folders_by_user: dict = {} - if folder_table_exists: - cur.execute('SELECT "id", "name", "parentId", "sysCreatedBy", "mandateId" FROM "FileFolder"') - for row in cur.fetchall(): - fid, fname, parent_id, user_id, mandate_id = row - key = (str(user_id), str(mandate_id) if mandate_id else "") - folders_by_user.setdefault(key, []).append({ - "id": fid, "name": fname, "parentId": parent_id, - }) - logger.info(f"Loaded folders for {len(folders_by_user)} (user, mandate) combinations") - - # ── 3. Load file to folder assignments ──────────────────────────────────── - files_by_key: dict = {} - if folder_column_exists: - cur.execute( - 'SELECT "id", "folderId", "sysCreatedBy", "mandateId" FROM "FileItem" WHERE "folderId" IS NOT NULL AND "folderId" != \'\'' - ) - for row in cur.fetchall(): - file_id, folder_id, user_id, mandate_id = row - key = (str(user_id), str(mandate_id) if mandate_id else "") - files_by_key.setdefault(key, {}).setdefault(folder_id, []).append(file_id) - total_files = sum( - sum(len(v) for v in d.values()) for d in files_by_key.values() - ) - logger.info(f"Found {total_files} file to folder assignments across {len(files_by_key)} (user, mandate) combos") - - # ── 4. Combine and upsert groupings ────────────────────────────────────── - all_keys = set(folders_by_user.keys()) | set(files_by_key.keys()) - stats = {"groups_created": 0, "groupings_upserted": 0, "files_assigned": 0} - - for key in all_keys: - user_id, mandate_id = key - folders = folders_by_user.get(key, []) - files_by_folder = files_by_key.get(key, {}) - - # Build tree - roots = _build_tree(folders, None) - roots = _assign_files_to_nodes(roots, files_by_folder) - - # Handle files in unknown folders (folder no longer in tree) - known_folder_ids = {f["id"] for f in folders} - for folder_id, file_ids in files_by_folder.items(): - if folder_id not in known_folder_ids: - # Orphaned files: put them in an "Orphaned" group - roots.append({ - "id": str(uuid.uuid4()), - "name": f"Orphaned (folder {folder_id[:8]}…)", - "itemIds": file_ids, - "subGroups": [], - "meta": {"migratedFromFolderId": folder_id, "orphaned": True}, - }) - - if not roots: - continue - - n_items = _count_items(roots) - stats["groups_created"] += len(roots) - stats["files_assigned"] += n_items - - context_key = "files/list" - if verbose: - logger.debug(f" user={user_id} mandate={mandate_id}: {len(roots)} root groups, {n_items} files") - - if not dry_run: - # Check for existing grouping - cur.execute( - 'SELECT "id", "rootGroups" FROM "TableGrouping" WHERE "userId" = %s AND "contextKey" = %s', - (user_id, context_key), - ) - existing_row = cur.fetchone() - - if existing_row: - existing_id, existing_raw = existing_row - existing_roots = json.loads(existing_raw) if isinstance(existing_raw, str) else (existing_raw or []) - # Merge: append migrated groups (avoid duplicates by migratedFromFolderId) - existing_meta_ids = { - (n.get("meta") or {}).get("migratedFromFolderId") - for n in existing_roots - if (n.get("meta") or {}).get("migratedFromFolderId") - } - new_roots = existing_roots + [ - r for r in roots - if (r.get("meta") or {}).get("migratedFromFolderId") not in existing_meta_ids - ] - cur.execute( - 'UPDATE "TableGrouping" SET "rootGroups" = %s, "updatedAt" = %s WHERE "id" = %s', - (json.dumps(new_roots), _now_ts(), existing_id), - ) - else: - new_id = str(uuid.uuid4()) - cur.execute( - 'INSERT INTO "TableGrouping" ("id", "userId", "contextKey", "rootGroups", "updatedAt") VALUES (%s, %s, %s, %s, %s)', - (new_id, user_id, context_key, json.dumps(roots), _now_ts()), - ) - stats["groupings_upserted"] += 1 - - # ── 5. Summary ──────────────────────────────────────────────────────────── - if not dry_run: - conn.commit() - logger.info("Migration committed.") - else: - logger.info("DRY RUN — no changes written.") - - logger.info( - f"Summary: groupings_upserted={stats['groupings_upserted']}, " - f"groups_created={stats['groups_created']}, " - f"files_assigned={stats['files_assigned']}" - ) - logger.info( - "Next steps (run after verifying data):\n" - " 1. Run Alembic migration to DROP COLUMN FileItem.folderId\n" - " 2. Run Alembic migration to DROP TABLE FileFolder" - ) - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Migrate FileFolder tree to table_groupings (archived script)") - parser.add_argument("--dry-run", action="store_true", default=True, help="Preview only, no DB writes (default)") - parser.add_argument("--execute", action="store_true", help="Actually write to DB (disables dry-run)") - parser.add_argument("--verbose", action="store_true", help="Show per-user details") - args = parser.parse_args() - dry_run = not args.execute - run_migration(dry_run=dry_run, verbose=args.verbose) diff --git a/modules/routes/routeAdminDatabaseHealth.py b/modules/routes/routeAdminDatabaseHealth.py index c547cc76..f2f866d9 100644 --- a/modules/routes/routeAdminDatabaseHealth.py +++ b/modules/routes/routeAdminDatabaseHealth.py @@ -8,6 +8,10 @@ and database migration (backup / restore). import json import logging import os +import tempfile +import threading +import uuid +from datetime import datetime, timezone from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status @@ -337,8 +341,6 @@ def getMigrationExport( detail=f"Export failed: {e}", ) from e - from datetime import datetime, timezone - ts = datetime.now(timezone.utc).strftime("%Y-%m-%d_%H-%M") filename = f"migration_backup_{ts}.json" @@ -452,7 +454,7 @@ def getMigrationExportSingle( ) -> Dict[str, Any]: """Export a single database. Returns full payload so the frontend can assemble the final JSON client-side (no server-side state needed).""" - from modules.shared.dbRegistry import getRegisteredDatabases + from modules.dbHelpers.dbRegistry import getRegisteredDatabases if database not in getRegisteredDatabases(): raise HTTPException( @@ -494,8 +496,7 @@ def getMigrationExportStream( Uses server-side cursors and row-by-row serialization so that neither backend memory nor browser JS heap is exhausted — works for any DB size. """ - from datetime import datetime, timezone - from modules.shared.dbRegistry import getRegisteredDatabases + from modules.dbHelpers.dbRegistry import getRegisteredDatabases registeredDbs = getRegisteredDatabases() @@ -542,10 +543,6 @@ async def postMigrationUploadImport( """Upload a backup file to disk (chunked). Returns a token that the frontend passes to ``/process-import-stream`` for streaming validation. """ - import os - import tempfile - import uuid - token = str(uuid.uuid4()) tmpDir = tempfile.gettempdir() filePath = os.path.join(tmpDir, f"poweron_import_{token}.json") @@ -577,7 +574,6 @@ async def postMigrationUploadImport( def _tokenMetaPath(token: str, kind: str) -> str: - import tempfile return os.path.join(tempfile.gettempdir(), f"poweron_{kind}_{token}.meta.json") @@ -616,9 +612,7 @@ def getProcessImportStream( - ``{"phase":"done","result":{valid, databases, warnings, ...}}`` - ``{"phase":"error","detail":"..."}`` """ - import os import queue - import threading pending = _readTokenMeta(token, "processing", pop=True) if not pending: @@ -717,8 +711,6 @@ def postMigrationImportSingle( Body: ``{token, database, mode}`` """ - import os - token = body.get("token", "") database = body.get("database", "") mode = body.get("mode", "merge") @@ -768,8 +760,6 @@ def postMigrationImportDone( currentUser: User = Depends(requireSysAdmin), ) -> Dict[str, Any]: """Clean up the per-DB / per-table temp files.""" - import os - token = body.get("token", "") pending = _readTokenMeta(token, "import", pop=True) if pending: diff --git a/modules/routes/routeAdminFeatures.py b/modules/routes/routeAdminFeatures.py index 2b1a928e..b3072edc 100644 --- a/modules/routes/routeAdminFeatures.py +++ b/modules/routes/routeAdminFeatures.py @@ -18,7 +18,7 @@ import json import math from pydantic import BaseModel, Field from modules.datamodels.datamodelPagination import PaginationParams, PaginationMetadata, normalize_pagination_dict -from modules.routes.routeHelpers import applyFiltersAndSort, handleFilterValuesInMemory, handleIdsInMemory +from modules.dbHelpers.paginationHelpers import applyFiltersAndSort, handleFilterValuesInMemory, handleIdsInMemory from modules.auth import limiter, getRequestContext, RequestContext, requirePlatformAdmin from modules.datamodels.datamodelUam import User, UserInDB @@ -475,9 +475,9 @@ def list_feature_instances( if mode == "filterValues": if not column: raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") - from modules.routes.routeHelpers import enrichRowsWithFkLabels + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels from modules.datamodels.datamodelFeatures import FeatureInstance - enrichRowsWithFkLabels(items, FeatureInstance) + enrichRowsWithFkLabels(items, FeatureInstance, db=rootInterface.db) return handleFilterValuesInMemory(items, column, pagination) if mode == "ids": @@ -933,10 +933,9 @@ def _syncInstanceWorkflows( skipped += 1 continue - import json as _json - graphJson = _json.dumps(template.get("graph", {})) + graphJson = json.dumps(template.get("graph", {})) graphJson = graphJson.replace("{{featureInstanceId}}", instanceId) - graph = _json.loads(graphJson) + graph = json.loads(graphJson) label = resolveText(template.get("label")) diff --git a/modules/routes/routeAdminRbacRules.py b/modules/routes/routeAdminRbacRules.py index 3eb45f1b..3b09d7eb 100644 --- a/modules/routes/routeAdminRbacRules.py +++ b/modules/routes/routeAdminRbacRules.py @@ -929,16 +929,17 @@ def list_roles( if mode == "filterValues": if not column: raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") - from modules.routes.routeHelpers import handleFilterValuesInMemory, enrichRowsWithFkLabels - enrichRowsWithFkLabels(result, Role) + from modules.dbHelpers.paginationHelpers import handleFilterValuesInMemory + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels + enrichRowsWithFkLabels(result, Role, db=interface.db) return handleFilterValuesInMemory(result, column, pagination) if mode == "ids": - from modules.routes.routeHelpers import handleIdsInMemory + from modules.dbHelpers.paginationHelpers import handleIdsInMemory return handleIdsInMemory(result, pagination) if paginationParams: - from modules.routes.routeHelpers import applyFiltersAndSort + from modules.dbHelpers.paginationHelpers import applyFiltersAndSort sortedResult = applyFiltersAndSort(result, paginationParams) totalItems = len(sortedResult) totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0 diff --git a/modules/routes/routeAttributes.py b/modules/routes/routeAttributes.py index 0118fecf..ae0cd6f4 100644 --- a/modules/routes/routeAttributes.py +++ b/modules/routes/routeAttributes.py @@ -9,7 +9,7 @@ from modules.auth import limiter # Import the attribute definition and helper functions from modules.shared.attributeUtils import getModelClasses, getModelAttributeDefinitions, AttributeResponse, AttributeDefinition -from modules.shared.i18nRegistry import apiRouteContext, _CURRENT_LANGUAGE +from modules.shared.i18nRegistry import apiRouteContext, getCurrentLanguage routeApiMsg = apiRouteContext("routeAttributes") @@ -51,7 +51,7 @@ def get_entity_attributes( # Get model class and derive attributes from it modelClass = modelClasses[entityType] - userLanguage = _CURRENT_LANGUAGE.get() + userLanguage = getCurrentLanguage() try: attribute_defs = getModelAttributeDefinitions(modelClass, userLanguage=userLanguage) except Exception as e: diff --git a/modules/routes/routeAudit.py b/modules/routes/routeAudit.py index d7d58728..c9888339 100644 --- a/modules/routes/routeAudit.py +++ b/modules/routes/routeAudit.py @@ -42,7 +42,7 @@ def _applySortFilterSearch( date-range filters (``between`` operator) and null/empty filters work consistently across all in-memory routes. """ - from modules.routes.routeHelpers import applyFiltersAndSort + from modules.dbHelpers.paginationHelpers import applyFiltersAndSort from modules.datamodels.datamodelPagination import PaginationParams, SortField filtersDict: Optional[Dict[str, Any]] = None @@ -112,7 +112,9 @@ def _enrichUserAndInstanceLabels( Uses the central resolvers from routeHelpers. Falls back to ``NA()`` for unresolvable entries so filter dropdowns still show an entry. """ - from modules.routes.routeHelpers import resolveUserLabels, resolveInstanceLabels + from modules.dbHelpers.fkLabelResolver import resolveUserLabels, resolveInstanceLabels + from modules.interfaces.interfaceDbApp import getRootInterface + db = getRootInterface().db userIds = list({r.get(userKey) for r in items if r.get(userKey) and not r.get(usernameKey)}) instanceIds = list({r.get(instanceKey) for r in items if r.get(instanceKey)}) @@ -121,9 +123,9 @@ def _enrichUserAndInstanceLabels( instanceMap: Dict[str, Optional[str]] = {} if userIds: - userMap = resolveUserLabels(userIds) + userMap = resolveUserLabels(db, userIds) if instanceIds: - instanceMap = resolveInstanceLabels(instanceIds) + instanceMap = resolveInstanceLabels(db, instanceIds) for r in items: uid = r.get(userKey) @@ -179,7 +181,7 @@ async def getAiAuditLog( if not mandateId: raise HTTPException(status_code=400, detail=routeApiMsg("Mandanten-ID erforderlich")) - from modules.shared.aiAuditLogger import aiAuditLogger + from modules.dbHelpers.aiAuditLogger import aiAuditLogger result = aiAuditLogger.getAiAuditLogs( mandateId, userId=userId, @@ -221,7 +223,7 @@ async def getAiAuditEntryContent( _requireAuditAccess(context) mandateId = str(context.mandateId) if context.mandateId else "" - from modules.shared.aiAuditLogger import aiAuditLogger + from modules.dbHelpers.aiAuditLogger import aiAuditLogger result = aiAuditLogger.getAiAuditEntryContent(entryId, mandateId) if not result: raise HTTPException(status_code=404, detail=routeApiMsg("Audit-Eintrag nicht gefunden")) @@ -273,7 +275,7 @@ async def getAuditLog( _requireAuditAccess(context) mandateId = str(context.mandateId) if context.mandateId else None - from modules.shared.auditLogger import audit_logger + from modules.dbHelpers.auditLogger import audit_logger records = audit_logger.getAuditLogs( userId=userId, mandateId=mandateId, @@ -320,7 +322,7 @@ async def getAuditStats( if not mandateId: raise HTTPException(status_code=400, detail=routeApiMsg("Mandanten-ID erforderlich")) - from modules.shared.aiAuditLogger import aiAuditLogger + from modules.dbHelpers.aiAuditLogger import aiAuditLogger from modules.shared.dateRange import isoDateRangeToLocalEpoch fromTs, toTs = isoDateRangeToLocalEpoch(dateFrom, dateTo) diff --git a/modules/routes/routeAutomationWorkspace.py b/modules/routes/routeAutomationWorkspace.py index 32624363..09c5238c 100644 --- a/modules/routes/routeAutomationWorkspace.py +++ b/modules/routes/routeAutomationWorkspace.py @@ -11,6 +11,7 @@ Nutzung > Automation. import logging import math +from functools import partial from typing import Optional from fastapi import APIRouter, Depends, Request, Query, Path, HTTPException @@ -167,7 +168,7 @@ def listWorkspaceRuns( total = len(filtered) page = filtered[offset: offset + limit] - from modules.routes.routeHelpers import enrichRowsWithFkLabels, resolveMandateLabels, resolveInstanceLabels + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels, resolveMandateLabels, resolveInstanceLabels for row in page: wf = wfMap.get(row.get("workflowId"), {}) @@ -176,9 +177,10 @@ def listWorkspaceRuns( enrichRowsWithFkLabels( page, + db=db, labelResolvers={ - "mandateId": resolveMandateLabels, - "targetFeatureInstanceId": resolveInstanceLabels, + "mandateId": partial(resolveMandateLabels, db), + "targetFeatureInstanceId": partial(resolveInstanceLabels, db), }, ) for row in page: @@ -285,8 +287,8 @@ def getWorkspaceRunDetail( targetInstanceLabel = None if tid: try: - from modules.routes.routeHelpers import resolveInstanceLabels - labelMap = resolveInstanceLabels([tid]) + from modules.dbHelpers.fkLabelResolver import resolveInstanceLabels + labelMap = resolveInstanceLabels(db, [tid]) targetInstanceLabel = labelMap.get(tid) except Exception: pass diff --git a/modules/routes/routeBilling.py b/modules/routes/routeBilling.py index 04251e09..19341394 100644 --- a/modules/routes/routeBilling.py +++ b/modules/routes/routeBilling.py @@ -13,7 +13,7 @@ from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Resp from fastapi.responses import JSONResponse from typing import List, Dict, Any, Optional import logging -from datetime import date, datetime, timezone +from datetime import date, datetime, timedelta, timezone from pydantic import BaseModel, Field # Import auth module @@ -486,13 +486,11 @@ def getBalanceForMandate( def _normalize_billing_tx_dict(t: Dict[str, Any]) -> Dict[str, Any]: """Make billing transaction rows JSON/grouping-safe (datetimes → str, enums → str).""" - from datetime import date as date_cls, datetime as dt_cls - r = dict(t) for k, v in list(r.items()): - if isinstance(v, dt_cls): + if isinstance(v, datetime): r[k] = v.isoformat() - elif isinstance(v, date_cls): + elif isinstance(v, date): r[k] = v.isoformat() for ek in ("transactionType", "referenceType"): if ek in r and r[ek] is not None and not isinstance(r[ek], str): @@ -567,13 +565,15 @@ def getTransactions( ) if pagination: - from modules.routes.routeHelpers import ( + from modules.interfaces.interfaceTableHelpers import ( applyViewToParams, buildGroupLayout, effective_group_by_levels, + resolveView, + ) + from modules.dbHelpers.paginationHelpers import ( handleFilterValuesInMemory, handleIdsInMemory, - resolveView, ) from modules.interfaces.interfaceDbApp import getInterface as getAppInterface from modules.interfaces.interfaceDbManagement import ComponentObjects @@ -699,8 +699,7 @@ def getStatistics( startDate, toDateInclusive = parseIsoDateRange(dateFrom, dateTo) # `calculateStatisticsFromTransactions` expects a half-open # [startDate, endDate) interval, so widen the upper bound by one day. - from datetime import timedelta as _td - endDate = toDateInclusive + _td(days=1) + endDate = toDateInclusive + timedelta(days=1) billingInterface = getBillingInterface(ctx.user, ctx.mandateId) settings = billingInterface.getSettings(ctx.mandateId) @@ -1158,7 +1157,6 @@ def handleSubscriptionCheckoutCompleted(session, eventId: str) -> None: _notifySubscriptionChange, ) from modules.security.rootAccess import getRootUser - from datetime import datetime, timezone if not isinstance(session, dict): from modules.shared.stripeClient import stripeToDict @@ -1327,7 +1325,6 @@ def _handleSubscriptionWebhook(event) -> None: _notifySubscriptionChange, ) from modules.security.rootAccess import getRootUser - from datetime import datetime, timezone obj = event.data.object rawSub = obj.get("id") if event.type.startswith("customer.subscription") else obj.get("subscription") @@ -1424,7 +1421,7 @@ def _handleSubscriptionWebhook(event) -> None: elif event.type == "customer.subscription.trial_will_end": logger.info("Trial ending soon for sub %s (mandate %s)", subId, mandateId) try: - from modules.shared.notifyMandateAdmins import notifyMandateAdmins + from modules.system.notifyMandateAdmins import notifyMandateAdmins notifyMandateAdmins( mandateId, "[PowerOn] Testphase endet bald", @@ -1574,12 +1571,14 @@ def _attachCreatedByUserNamesToTransactionRows(rows: List[Dict[str, Any]]) -> No Returns None (not a truncated UUID) for unresolvable IDs so the frontend renders an explicit NA() indicator instead of a misleading 8-char snippet. """ - from modules.routes.routeHelpers import resolveUserLabels + from modules.dbHelpers.fkLabelResolver import resolveUserLabels + from modules.interfaces.interfaceDbApp import getRootInterface userIds = list({r.get("createdByUserId") for r in rows if r.get("createdByUserId")}) userMap: Dict[str, Optional[str]] = {} if userIds: - userMap = resolveUserLabels(userIds) + db = getRootInterface().db + userMap = resolveUserLabels(db, userIds) for row in rows: uid = row.get("createdByUserId") @@ -1871,9 +1870,9 @@ def getUserViewStatistics( for acc in allAccounts: accountToMandate[acc.get("id", "")] = acc.get("mandateId", "") - from modules.routes.routeHelpers import resolveMandateLabels + from modules.dbHelpers.fkLabelResolver import resolveMandateLabels mandateIdsForLookup = list({v for v in accountToMandate.values() if v}) - mandateMap: Dict[str, Optional[str]] = resolveMandateLabels(mandateIdsForLookup) if mandateIdsForLookup else {} + mandateMap: Dict[str, Optional[str]] = resolveMandateLabels(billingInterface.db, mandateIdsForLookup) if mandateIdsForLookup else {} def _mandateName(accountId: str) -> str: mid = accountToMandate.get(accountId, "") @@ -1934,7 +1933,7 @@ def getUserViewTransactions( - mandateId: required when scope='mandate' - onlyMine: true to restrict to current user's data within the scope """ - from modules.routes.routeHelpers import parseCrossFilterPagination + from modules.dbHelpers.paginationHelpers import parseCrossFilterPagination try: billingInterface = getBillingInterface(ctx.user, ctx.mandateId) @@ -1970,8 +1969,7 @@ def getUserViewTransactions( if mode == "ids": paginationParams = None if pagination: - import json as _json - paginationDict = _json.loads(pagination) + paginationDict = json.loads(pagination) paginationDict = normalize_pagination_dict(paginationDict) paginationParams = PaginationParams(**paginationDict) ids = billingInterface.getTransactionIds( @@ -1985,16 +1983,15 @@ def getUserViewTransactions( if mode == "groupSummary": if not pagination: raise HTTPException(status_code=400, detail="pagination required for groupSummary") - import json as _json from modules.interfaces.interfaceDbApp import getInterface as getAppInterface - from modules.routes.routeHelpers import ( + from modules.interfaces.interfaceTableHelpers import ( applyViewToParams, build_group_summary_groups, effective_group_by_levels, resolveView, ) - pagination_dict = _json.loads(pagination) + pagination_dict = json.loads(pagination) pagination_dict = normalize_pagination_dict(pagination_dict) summary_params = PaginationParams(**pagination_dict) CONTEXT_KEY = "billing/view/users/transactions" @@ -2023,8 +2020,7 @@ def getUserViewTransactions( paginationParams = None if pagination: - import json as _json - paginationDict = _json.loads(pagination) + paginationDict = json.loads(pagination) paginationDict = normalize_pagination_dict(paginationDict) paginationParams = PaginationParams(**paginationDict) @@ -2034,7 +2030,7 @@ def getUserViewTransactions( paginationParams = PaginationParams(page=1, pageSize=50) from modules.interfaces.interfaceDbApp import getInterface as getAppInterface - from modules.routes.routeHelpers import ( + from modules.interfaces.interfaceTableHelpers import ( applyViewToParams, buildGroupLayout, effective_group_by_levels, diff --git a/modules/routes/routeDataConnections.py b/modules/routes/routeDataConnections.py index 7ab0f6d7..5de77a9b 100644 --- a/modules/routes/routeDataConnections.py +++ b/modules/routes/routeDataConnections.py @@ -154,8 +154,11 @@ async def get_connections( - GET /api/connections/?mode=filterValues&column=status - GET /api/connections/?mode=ids """ - from modules.routes.routeHelpers import ( - handleFilterValuesInMemory, handleIdsInMemory, enrichRowsWithFkLabels, + from modules.dbHelpers.paginationHelpers import ( + handleFilterValuesInMemory, handleIdsInMemory, + ) + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels + from modules.interfaces.interfaceTableHelpers import ( resolveView, applyViewToParams, buildGroupLayout, effective_group_by_levels, ) from modules.datamodels.datamodelPagination import AppliedViewMeta @@ -209,7 +212,7 @@ async def get_connections( raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") try: items = _buildEnhancedItems() - enrichRowsWithFkLabels(items, UserConnection) + enrichRowsWithFkLabels(items, UserConnection, db=interface.db) return handleFilterValuesInMemory(items, column, pagination) except Exception as e: logger.error(f"Error getting filter values for connections: {str(e)}") @@ -225,7 +228,7 @@ async def get_connections( if mode == "groupSummary": if not pagination: raise HTTPException(status_code=400, detail="pagination required for groupSummary") - from modules.routes.routeHelpers import ( + from modules.interfaces.interfaceTableHelpers import ( apply_strategy_b_filters_and_sort, build_group_summary_groups, ) @@ -265,7 +268,7 @@ async def get_connections( "tokenStatus": tokenStatus, "tokenExpiresAt": tokenExpiresAt }) - enrichRowsWithFkLabels(enhanced_connections_dict, UserConnection) + enrichRowsWithFkLabels(enhanced_connections_dict, UserConnection, db=interface.db) filtered = apply_strategy_b_filters_and_sort(enhanced_connections_dict, paginationParams, currentUser) groups_out = build_group_summary_groups(filtered, field, null_label, groupByLevels=groupByLevels) return JSONResponse(content={"groups": groups_out}) @@ -300,7 +303,7 @@ async def get_connections( "tokenExpiresAt": tokenExpiresAt }) - enrichRowsWithFkLabels(enhanced_connections_dict, UserConnection) + enrichRowsWithFkLabels(enhanced_connections_dict, UserConnection, db=interface.db) if paginationParams is None: return {"items": enhanced_connections_dict, "pagination": None} @@ -811,15 +814,14 @@ async def _updateKnowledgeConsent( ) bootstrapEnqueued = True - import json as _json - from modules.shared.auditLogger import audit_logger + from modules.dbHelpers.auditLogger import audit_logger from modules.datamodels.datamodelAudit import AuditCategory audit_logger.logEvent( userId=str(currentUser.id), mandateId=str(getattr(connection, "mandateId", "") or ""), category=AuditCategory.PERMISSION.value, action="knowledge_consent_changed", - details=_json.dumps({"connectionId": connectionId, "enabled": enabled}), + details=json.dumps({"connectionId": connectionId, "enabled": enabled}), ) logger.info("Knowledge consent %s for connection %s by user %s", @@ -888,15 +890,14 @@ def _stopKnowledgeJobs( from modules.serviceCenter.services.serviceBackgroundJobs import cancelJobsByConnection cancelled = cancelJobsByConnection(connectionId) - import json as _json - from modules.shared.auditLogger import audit_logger + from modules.dbHelpers.auditLogger import audit_logger from modules.datamodels.datamodelAudit import AuditCategory audit_logger.logEvent( userId=str(currentUser.id), mandateId=str(getattr(connection, "mandateId", "") or ""), category=AuditCategory.PERMISSION.value, action="knowledge_jobs_stopped", - details=_json.dumps({"connectionId": connectionId, "cancelledCount": cancelled}), + details=json.dumps({"connectionId": connectionId, "cancelledCount": cancelled}), ) logger.info("Stopped %d knowledge jobs for connection %s", cancelled, connectionId) diff --git a/modules/routes/routeDataFiles.py b/modules/routes/routeDataFiles.py index 74886380..52a4b98a 100644 --- a/modules/routes/routeDataFiles.py +++ b/modules/routes/routeDataFiles.py @@ -3,21 +3,25 @@ from fastapi import APIRouter, HTTPException, Depends, File, UploadFile, Form, Path, Request, status, Query, Response, Body, BackgroundTasks from fastapi.responses import JSONResponse from typing import List, Dict, Any, Optional +import asyncio +import io import logging import json import math +import urllib.parse +import zipfile # Import auth module from modules.auth import limiter, getCurrentUser, getRequestContext, RequestContext # Import interfaces -import modules.interfaces.interfaceDbManagement as interfaceDbManagement +from modules.interfaces import interfaceDbManagement from modules.datamodels.datamodelFiles import FileItem, FilePreview, FileFolder from modules.shared.attributeUtils import getModelAttributeDefinitions from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict from modules.shared.i18nRegistry import apiRouteContext -from modules.routes.routeHelpers import enrichRowsWithFkLabels +from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels routeApiMsg = apiRouteContext("routeDataFiles") # Configure logger @@ -725,9 +729,11 @@ def get_files( detail=f"Invalid pagination parameter: {str(e)}" ) - from modules.routes.routeHelpers import ( + from modules.dbHelpers.paginationHelpers import ( handleIdsMode, handleFilterValuesInMemory, + ) + from modules.interfaces.interfaceTableHelpers import ( resolveView, applyViewToParams, buildGroupLayout, effective_group_by_levels, ) import modules.interfaces.interfaceDbApp as _appIface @@ -753,7 +759,7 @@ def get_files( if mode == "groupSummary": if not pagination: raise HTTPException(status_code=400, detail="pagination required for groupSummary") - from modules.routes.routeHelpers import ( + from modules.interfaces.interfaceTableHelpers import ( apply_strategy_b_filters_and_sort, build_group_summary_groups, ) @@ -768,6 +774,7 @@ def get_files( allItems = enrichRowsWithFkLabels( _filesToDicts(allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else [])), FileItem, + db=managementInterface.db, ) filtered = apply_strategy_b_filters_and_sort(allItems, paginationParams, currentUser) groups_out = build_group_summary_groups(filtered, field, null_label, groupByLevels=groupByLevels) @@ -814,9 +821,10 @@ def get_files( allItems = enrichRowsWithFkLabels( _filesToDicts(allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else [])), FileItem, + db=managementInterface.db, ) - from modules.routes.routeHelpers import apply_strategy_b_filters_and_sort + from modules.interfaces.interfaceTableHelpers import apply_strategy_b_filters_and_sort if paginationParams.filters or paginationParams.sort: allItems = apply_strategy_b_filters_and_sort(allItems, paginationParams, currentUser) @@ -934,7 +942,6 @@ async def upload_file( if shouldIndex: try: - import asyncio asyncio.ensure_future(_autoIndexFile( fileId=fileItem.id, fileName=fileItem.fileName, @@ -1016,8 +1023,6 @@ def batchDownload( ): """Download multiple files and/or folders as a single ZIP archive, preserving the folder hierarchy as ZIP paths.""" - import io, zipfile - fileIds = body.get("fileIds") or [] folderIds = body.get("folderIds") or [] @@ -1203,7 +1208,6 @@ async def bulk_download_zip( context: RequestContext = Depends(getRequestContext), ): """Download a list of files as a ZIP archive.""" - import io, zipfile fileIds: list = body.get("fileIds") or [] if not fileIds: raise HTTPException(status_code=400, detail="fileIds is required") @@ -1546,7 +1550,6 @@ def download_file( # Return file as response # Properly encode filename for Content-Disposition header to handle Unicode characters - import urllib.parse encoded_filename = urllib.parse.quote(fileData.fileName) return Response( diff --git a/modules/routes/routeDataMandates.py b/modules/routes/routeDataMandates.py index 2c9885ef..668c16ed 100644 --- a/modules/routes/routeDataMandates.py +++ b/modules/routes/routeDataMandates.py @@ -15,16 +15,17 @@ from typing import List, Dict, Any, Optional from fastapi import status import logging import json +from datetime import datetime, timezone, timedelta from pydantic import BaseModel, Field # Import auth module from modules.auth import limiter, requirePlatformAdmin, getRequestContext, getCurrentUser, RequestContext # Import interfaces -import modules.interfaces.interfaceDbApp as interfaceDbApp +from modules.interfaces import interfaceDbApp from modules.interfaces.interfaceDbBilling import getRootInterface as _getBillingRootInterface from modules.shared.attributeUtils import getModelAttributeDefinitions -from modules.shared.auditLogger import audit_logger +from modules.dbHelpers.auditLogger import audit_logger # Import the model classes from modules.datamodels.datamodelUam import Mandate, User @@ -127,7 +128,7 @@ def get_mandates( detail=f"Invalid pagination parameter: {str(e)}" ) - from modules.routes.routeHelpers import ( + from modules.dbHelpers.paginationHelpers import ( handleFilterValuesInMemory, handleIdsInMemory, handleFilterValuesMode, handleIdsMode, parseCrossFilterPagination, @@ -302,7 +303,6 @@ def create_mandate( MandateSubscription, SubscriptionStatusEnum, BUILTIN_PLANS, ) from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot - from datetime import datetime, timezone, timedelta planKey = mandateData.get("planKey", "TRIAL_14D") plan = BUILTIN_PLANS.get(planKey) @@ -642,7 +642,7 @@ def list_mandate_users( "enabled": um.enabled }) - from modules.routes.routeHelpers import ( + from modules.dbHelpers.paginationHelpers import ( handleFilterValuesInMemory, handleIdsInMemory, applyFiltersAndSort as _sharedApplyFiltersAndSort, paginateInMemory, diff --git a/modules/routes/routeDataPrompts.py b/modules/routes/routeDataPrompts.py index 406e8d59..4d46630c 100644 --- a/modules/routes/routeDataPrompts.py +++ b/modules/routes/routeDataPrompts.py @@ -13,7 +13,7 @@ from copy import deepcopy from modules.auth import limiter, getCurrentUser # Import interfaces -import modules.interfaces.interfaceDbManagement as interfaceDbManagement +from modules.interfaces import interfaceDbManagement from modules.datamodels.datamodelUtils import Prompt from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict @@ -47,8 +47,11 @@ def get_prompts( - filterValues: distinct values for a column (cross-filtered) - ids: all IDs matching current filters """ - from modules.routes.routeHelpers import ( - handleFilterValuesInMemory, handleIdsInMemory, enrichRowsWithFkLabels, + from modules.dbHelpers.paginationHelpers import ( + handleFilterValuesInMemory, handleIdsInMemory, + ) + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels + from modules.interfaces.interfaceTableHelpers import ( resolveView, applyViewToParams, buildGroupLayout, effective_group_by_levels, ) from modules.interfaces.interfaceDbApp import getInterface as getAppInterface @@ -117,7 +120,7 @@ def get_prompts( def _promptsToEnrichedDicts(promptItems): dicts = [r.model_dump() if hasattr(r, 'model_dump') else (dict(r) if not isinstance(r, dict) else r) for r in promptItems] - enrichRowsWithFkLabels(dicts, Prompt) + enrichRowsWithFkLabels(dicts, Prompt, db=managementInterface.db) return dicts managementInterface = interfaceDbManagement.getInterface(currentUser) @@ -125,7 +128,7 @@ def get_prompts( if mode == "groupSummary": if not pagination: raise HTTPException(status_code=400, detail="pagination required for groupSummary") - from modules.routes.routeHelpers import ( + from modules.interfaces.interfaceTableHelpers import ( apply_strategy_b_filters_and_sort, build_group_summary_groups, ) diff --git a/modules/routes/routeDataSources.py b/modules/routes/routeDataSources.py index e448523b..e1a6ee39 100644 --- a/modules/routes/routeDataSources.py +++ b/modules/routes/routeDataSources.py @@ -7,13 +7,14 @@ generic UDB router (`POST /api/udb/node/{key}/flag/{flag}`); see `modules/routes/routeUdb.py` and the wiki UDB reference page. """ +import json import logging from typing import Any, Dict, List, Optional from fastapi import APIRouter, HTTPException, Depends, Path, Request, Body from modules.auth import limiter, getRequestContext, RequestContext from modules.datamodels.datamodelDataSource import DataSource -from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource +from modules.datamodels.datamodelFeatures import FeatureDataSource from modules.datamodels.datamodelUam import UserConnection from modules.shared.i18nRegistry import apiRouteContext routeApiMsg = apiRouteContext("routeDataSources") @@ -149,9 +150,9 @@ def _updateDataSourceSettings( if ownerId and ownerId != currentUserId and not context.isSysAdmin: raise HTTPException(status_code=403, detail="Not allowed to modify this DataSource's settings") else: - from modules.serviceCenter.services.serviceKnowledge.udbNodes import _isFeatureAdmin + from modules.serviceCenter.services.serviceKnowledge.udbNodes import isFeatureAdmin featureInstanceId = str(rec.get("featureInstanceId") or "") - if not (context.isSysAdmin or _isFeatureAdmin(rootIf, currentUserId, featureInstanceId)): + if not (context.isSysAdmin or isFeatureAdmin(rootIf, currentUserId, featureInstanceId)): raise HTTPException(status_code=403, detail="Not allowed to modify this FeatureDataSource's settings") kind = _kindForSource(rec, model) @@ -169,8 +170,7 @@ def _updateDataSourceSettings( rootIf.db.recordModify(model, sourceId, {"settings": newSettings}) - import json - from modules.shared.auditLogger import audit_logger + from modules.dbHelpers.auditLogger import audit_logger from modules.datamodels.datamodelAudit import AuditCategory audit_logger.logEvent( userId=currentUserId, @@ -208,15 +208,15 @@ def _getDataSourceCostEstimate( """ try: from modules.interfaces.interfaceDbApp import getRootInterface - from modules.serviceCenter.services.serviceKnowledge import _ragLimits, _costEstimate + from modules.serviceCenter.services.serviceKnowledge import ragLimits, costEstimate rootIf = getRootInterface() rec, model = _findSourceRecord(rootIf.db, sourceId) if not rec: raise HTTPException(status_code=404, detail=f"DataSource {sourceId} not found") kind = _kindForSource(rec, model) - effective = _ragLimits.getRagLimits(rec, kind) - estimate = _costEstimate.estimateBootstrapCost(effective, kind=kind) + effective = ragLimits.getRagLimits(rec, kind) + estimate = costEstimate.estimateBootstrapCost(effective, kind=kind) estimate["sourceId"] = sourceId return estimate except HTTPException: diff --git a/modules/routes/routeDataUsers.py b/modules/routes/routeDataUsers.py index 671a8ca2..e371b547 100644 --- a/modules/routes/routeDataUsers.py +++ b/modules/routes/routeDataUsers.py @@ -15,9 +15,10 @@ from fastapi import status from pydantic import BaseModel import logging import json +import math # Import interfaces and models -import modules.interfaces.interfaceDbApp as interfaceDbApp +from modules.interfaces import interfaceDbApp from modules.auth import limiter, getRequestContext, RequestContext # Import the attribute definition and helper functions @@ -25,7 +26,7 @@ from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority from modules.interfaces.interfaceDbApp import getRootInterface from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict from modules.shared.i18nRegistry import apiRouteContext -from modules.routes.routeHelpers import enrichRowsWithFkLabels +from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels routeApiMsg = apiRouteContext("routeDataUsers") # Configure logger @@ -78,7 +79,7 @@ def _isAdminForUser(context: RequestContext, targetUserId: str) -> bool: def _getUserFilterOrIds(context, paginationJson, column=None, idsMode=False): """Unified handler for mode=filterValues and mode=ids across all user scoping branches.""" - from modules.routes.routeHelpers import ( + from modules.dbHelpers.paginationHelpers import ( handleFilterValuesInMemory, handleIdsInMemory, handleFilterValuesMode, handleIdsMode, parseCrossFilterPagination, @@ -233,7 +234,7 @@ def get_users( if context.mandateId: result = appInterface.getUsersByMandate(str(context.mandateId), paginationParams) if paginationParams and hasattr(result, 'items'): - enriched = enrichRowsWithFkLabels(_usersToDicts(result.items), User) + enriched = enrichRowsWithFkLabels(_usersToDicts(result.items), User, db=getRootInterface().db) return { "items": enriched, "pagination": PaginationMetadata( @@ -247,11 +248,11 @@ def get_users( } else: users = result if isinstance(result, list) else result.items if hasattr(result, 'items') else [] - return {"items": enrichRowsWithFkLabels(_usersToDicts(users), User), "pagination": None} + return {"items": enrichRowsWithFkLabels(_usersToDicts(users), User, db=getRootInterface().db), "pagination": None} elif context.isPlatformAdmin: result = appInterface.getAllUsers(paginationParams) if paginationParams and hasattr(result, 'items'): - enriched = enrichRowsWithFkLabels(_usersToDicts(result.items), User) + enriched = enrichRowsWithFkLabels(_usersToDicts(result.items), User, db=getRootInterface().db) return { "items": enriched, "pagination": PaginationMetadata( @@ -265,7 +266,7 @@ def get_users( } else: users = result if isinstance(result, list) else (result.items if hasattr(result, 'items') else []) - return {"items": enrichRowsWithFkLabels(_usersToDicts(users), User), "pagination": None} + return {"items": enrichRowsWithFkLabels(_usersToDicts(users), User, db=getRootInterface().db), "pagination": None} else: rootInterface = getRootInterface() userMandates = rootInterface.getUserMandates(str(context.user.id)) @@ -295,12 +296,11 @@ def get_users( batchUsers = rootInterface.getUsersByIds(uniqueUserIds) if uniqueUserIds else {} allUsers = [u.model_dump() if hasattr(u, 'model_dump') else vars(u) for u in batchUsers.values()] - from modules.routes.routeHelpers import applyFiltersAndSort as _applyFiltersAndSortHelper - filteredUsers = _applyFiltersAndSortHelper(allUsers, paginationParams) - enriched = enrichRowsWithFkLabels(filteredUsers, User) + from modules.dbHelpers.paginationHelpers import applyFiltersAndSort + filteredUsers = applyFiltersAndSort(allUsers, paginationParams) + enriched = enrichRowsWithFkLabels(filteredUsers, User, db=rootInterface.db) if paginationParams: - import math totalItems = len(enriched) totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0 startIdx = (paginationParams.page - 1) * paginationParams.pageSize @@ -560,7 +560,7 @@ def reset_user_password( # Log password reset try: - from modules.shared.auditLogger import audit_logger + from modules.dbHelpers.auditLogger import audit_logger audit_logger.logSecurityEvent( userId=str(context.user.id), mandateId=str(context.mandateId) if context.mandateId else "system", @@ -640,7 +640,7 @@ def change_password( # Log password change try: - from modules.shared.auditLogger import audit_logger + from modules.dbHelpers.auditLogger import audit_logger audit_logger.logSecurityEvent( userId=str(context.user.id), mandateId=str(context.mandateId) if context.mandateId else "system", @@ -770,7 +770,7 @@ def send_password_link( # Log the action try: - from modules.shared.auditLogger import audit_logger + from modules.dbHelpers.auditLogger import audit_logger audit_logger.logSecurityEvent( userId=str(context.user.id), mandateId=str(context.mandateId) if context.mandateId else "system", diff --git a/modules/routes/routeGdpr.py b/modules/routes/routeGdpr.py index fce8ab69..d07df8a8 100644 --- a/modules/routes/routeGdpr.py +++ b/modules/routes/routeGdpr.py @@ -17,14 +17,15 @@ from typing import List, Dict, Any, Optional from fastapi import status import logging import json +from datetime import datetime, timezone from pydantic import BaseModel, Field from modules.auth import limiter, getCurrentUser from modules.datamodels.datamodelUam import User, UserInDB, Mandate, UserConnection from modules.interfaces.interfaceDbApp import getRootInterface from modules.shared.timeUtils import getUtcTimestamp -from modules.shared.auditLogger import audit_logger -from modules.shared.gdprDeletion import deleteUserDataAcrossAllDatabases, buildDeletionSummary +from modules.dbHelpers.auditLogger import audit_logger +from modules.system.gdprDeletion import deleteUserDataAcrossAllDatabases, buildDeletionSummary from modules.shared.i18nRegistry import apiRouteContext routeApiMsg = apiRouteContext("routeGdpr") @@ -437,6 +438,5 @@ def get_consent_info( def _timestampToIso(timestamp: float) -> str: """Convert Unix timestamp to ISO 8601 format""" - from datetime import datetime, timezone dt = datetime.fromtimestamp(timestamp, tz=timezone.utc) return dt.isoformat() diff --git a/modules/routes/routeHelpers.py b/modules/routes/routeHelpers.py deleted file mode 100644 index b58ffc6d..00000000 --- a/modules/routes/routeHelpers.py +++ /dev/null @@ -1,1024 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -""" -Shared helpers for route handlers. - -Provides unified logic for: -- mode=filterValues: distinct column values for filter dropdowns (cross-filtered) -- mode=ids: all IDs matching current filters (for bulk selection) -- In-memory equivalents for enriched/non-SQL routes -""" - -import copy -import json -import logging -from typing import Any, Dict, List, Optional, Callable, Union - -from fastapi.responses import JSONResponse - -from modules.datamodels.datamodelPagination import ( - PaginationParams, - normalize_pagination_dict, -) -from modules.shared.i18nRegistry import resolveText - -logger = logging.getLogger(__name__) - - -# --------------------------------------------------------------------------- -# Central FK label resolvers (cross-DB) -# --------------------------------------------------------------------------- - -def resolveMandateLabels(ids: List[str]) -> Dict[str, Optional[str]]: - """Resolve mandate IDs to labels. Returns None (not the ID!) for - unresolvable entries so the caller can distinguish "resolved" from "missing". - """ - from modules.interfaces.interfaceDbApp import getRootInterface - rootIface = getRootInterface() - mMap = rootIface.getMandatesByIds(ids) - result: Dict[str, Optional[str]] = {} - for mid in ids: - m = mMap.get(mid) - label = (getattr(m, "label", None) or getattr(m, "name", None)) if m else None - if not label: - logger.debug("resolveMandateLabels: no label for id=%s (found=%s)", mid, m is not None) - result[mid] = label or None - return result - - -def resolveInstanceLabels(ids: List[str]) -> Dict[str, Optional[str]]: - """Resolve feature-instance IDs to labels. Returns None for unresolvable.""" - from modules.interfaces.interfaceDbApp import getRootInterface - from modules.interfaces.interfaceFeatures import getFeatureInterface - rootIface = getRootInterface() - featureIface = getFeatureInterface(rootIface.db) - result: Dict[str, Optional[str]] = {} - for iid in ids: - fi = featureIface.getFeatureInstance(iid) - label = fi.label if fi and fi.label else None - if not label: - logger.debug("resolveInstanceLabels: no label for id=%s (found=%s)", iid, fi is not None) - result[iid] = label - return result - - -def resolveUserLabels(ids: List[str]) -> Dict[str, Optional[str]]: - """Resolve user IDs to display names. Returns None for unresolvable.""" - from modules.interfaces.interfaceDbApp import getRootInterface - rootIface = getRootInterface() - from modules.datamodels.datamodelUam import UserInDB as _UserInDB - uniqueIds = list(set(ids)) - users = rootIface.db.getRecordset( - _UserInDB, - recordFilter={"id": uniqueIds}, - ) - result: Dict[str, Optional[str]] = {} - found: Dict[str, dict] = {} - for u in (users or []): - uid = u.get("id", "") - found[uid] = u - for uid in ids: - u = found.get(uid) - if u: - result[uid] = u.get("displayName") or u.get("username") or u.get("email") or None - else: - result[uid] = None - return result - - -def resolveRoleLabels(ids: List[str]) -> Dict[str, Optional[str]]: - """Resolve Role.id to roleLabel. Returns None for unresolvable.""" - if not ids: - return {} - from modules.interfaces.interfaceDbApp import getRootInterface - from modules.datamodels.datamodelRbac import Role as _Role - rootIface = getRootInterface() - recs = rootIface.db.getRecordset( - _Role, - recordFilter={"id": list(set(ids))}, - ) or [] - out: Dict[str, Optional[str]] = {i: None for i in ids} - for r in recs: - rid = r.get("id") - if rid: - out[rid] = r.get("roleLabel") or None - for rid in ids: - if out.get(rid) is None: - logger.debug("resolveRoleLabels: no label for id=%s", rid) - return out - - -_BUILTIN_FK_RESOLVERS: Dict[str, Callable[[List[str]], Dict[str, str]]] = { - "Mandate": resolveMandateLabels, - "FeatureInstance": resolveInstanceLabels, - "UserInDB": resolveUserLabels, - "Role": resolveRoleLabels, -} - - -def _buildLabelResolversFromModel(modelClass: type) -> Dict[str, Callable[[List[str]], Dict[str, str]]]: - """ - Auto-build labelResolvers dict from ``json_schema_extra.fk_target`` on a Pydantic model. - Maps field names to resolver functions when the target table has a registered builtin - resolver and ``fk_target.labelField`` is set (non-None). - """ - resolvers: Dict[str, Callable[[List[str]], Dict[str, str]]] = {} - for name, fieldInfo in modelClass.model_fields.items(): - extra = fieldInfo.json_schema_extra - if not extra or not isinstance(extra, dict): - continue - tgt = extra.get("fk_target") - if not isinstance(tgt, dict): - continue - if tgt.get("labelField") is None: - continue - fkModel = tgt.get("table") - if fkModel and fkModel in _BUILTIN_FK_RESOLVERS: - resolvers[name] = _BUILTIN_FK_RESOLVERS[fkModel] - return resolvers - - -def enrichRowsWithFkLabels( - rows: List[Dict[str, Any]], - modelClass: type = None, - *, - labelResolvers: Optional[Dict[str, Callable[[List[str]], Dict[str, Optional[str]]]]] = None, - extraResolvers: Optional[Dict[str, Callable[[List[str]], Dict[str, Optional[str]]]]] = None, -) -> List[Dict[str, Any]]: - """Add ``{field}Label`` columns to each row for every FK field that has a - registered resolver. - - ``modelClass`` — if provided, resolvers are auto-built from ``fk_target`` - annotations on the Pydantic model (via ``_buildLabelResolversFromModel``). - - ``labelResolvers`` — explicit resolver map that overrides auto-built ones. - - ``extraResolvers`` — merged on top of auto-built / explicit resolvers. Use - for ad-hoc fields that are not FK-annotated on the model (e.g. - ``createdByUserId`` on billing transactions). - - If a label cannot be resolved the ``{field}Label`` value is ``None`` - (never the raw ID — that would reintroduce the silent-truncation bug). - """ - resolvers: Dict[str, Callable] = {} - - if modelClass is not None and labelResolvers is None: - resolvers = _buildLabelResolversFromModel(modelClass) - elif labelResolvers is not None: - resolvers = dict(labelResolvers) - - if extraResolvers: - resolvers.update(extraResolvers) - - if not resolvers or not rows: - return rows - - for field, resolver in resolvers.items(): - ids = list({str(r.get(field)) for r in rows if r.get(field)}) - if not ids: - continue - try: - labelMap = resolver(ids) - except Exception as e: - logger.error("enrichRowsWithFkLabels: resolver for '%s' raised: %s", field, e) - labelMap = {} - - labelKey = f"{field}Label" - for r in rows: - fkVal = r.get(field) - if fkVal: - r[labelKey] = labelMap.get(str(fkVal)) - else: - r[labelKey] = None - - return rows - - -# --------------------------------------------------------------------------- -# Cross-filter pagination parsing -# --------------------------------------------------------------------------- - -def parseCrossFilterPagination( - column: str, - paginationJson: Optional[str], -) -> Optional[PaginationParams]: - """ - Parse pagination JSON, remove the requested column from filters (cross-filtering), - and drop sort — used for filter-values requests. - """ - if not paginationJson: - return None - try: - paginationDict = json.loads(paginationJson) - if not paginationDict: - return None - paginationDict = normalize_pagination_dict(paginationDict) - filters = paginationDict.get("filters", {}) - filters.pop(column, None) - paginationDict["filters"] = filters - paginationDict.pop("sort", None) - return PaginationParams(**paginationDict) - except (json.JSONDecodeError, ValueError, TypeError): - return None - - -def parsePaginationForIds( - paginationJson: Optional[str], -) -> Optional[PaginationParams]: - """ - Parse pagination JSON for mode=ids — keep filters, drop sort and page/pageSize. - """ - if not paginationJson: - return None - try: - paginationDict = json.loads(paginationJson) - if not paginationDict: - return None - paginationDict = normalize_pagination_dict(paginationDict) - paginationDict.pop("sort", None) - return PaginationParams(**paginationDict) - except (json.JSONDecodeError, ValueError, TypeError): - return None - - -# --------------------------------------------------------------------------- -# SQL-based helpers (delegate to DB connector) -# --------------------------------------------------------------------------- - -def handleFilterValuesMode( - db, - modelClass: type, - column: str, - paginationJson: Optional[str] = None, - recordFilter: Optional[Dict[str, Any]] = None, - enrichFn: Optional[Callable[[str, Optional[PaginationParams], Optional[Dict[str, Any]]], List[str]]] = None, -) -> List[str]: - """ - SQL-based distinct column values with cross-filtering. - - If enrichFn is provided and the column is enriched (computed/joined), - enrichFn(column, crossPagination, recordFilter) is called instead of SQL DISTINCT. - """ - crossPagination = parseCrossFilterPagination(column, paginationJson) - - if enrichFn: - try: - result = enrichFn(column, crossPagination, recordFilter) - if result is not None: - return JSONResponse(content=result) - except Exception as e: - logger.warning(f"handleFilterValuesMode enrichFn failed for {column}: {e}") - - try: - values = db.getDistinctColumnValues( - modelClass, column, - pagination=crossPagination, - recordFilter=recordFilter, - ) or [] - return JSONResponse(content=values) - except Exception as e: - logger.error(f"handleFilterValuesMode SQL failed for {modelClass.__name__}.{column}: {e}") - return JSONResponse(content=[]) - - -def handleIdsMode( - db, - modelClass: type, - paginationJson: Optional[str] = None, - recordFilter: Optional[Dict[str, Any]] = None, - idField: str = "id", -) -> List[str]: - """ - Return all IDs matching the current filters (no LIMIT/OFFSET). - Uses the same WHERE clause as getRecordsetPaginated. - """ - pagination = parsePaginationForIds(paginationJson) - table = modelClass.__name__ - - try: - if not db._ensureTableExists(modelClass): - return JSONResponse(content=[]) - - where_clause, _, _, values, _ = db._buildPaginationClauses( - modelClass, pagination, recordFilter, - ) - - sql = f'SELECT "{idField}"::TEXT AS val FROM "{table}"{where_clause} ORDER BY "{idField}"' - - with db.borrowCursor() as cursor: - cursor.execute(sql, values) - return JSONResponse(content=[row["val"] for row in cursor.fetchall()]) - except Exception as e: - logger.error(f"handleIdsMode failed for {table}: {e}") - return JSONResponse(content=[]) - - -# --------------------------------------------------------------------------- -# In-memory helpers (for enriched / non-SQL routes) -# --------------------------------------------------------------------------- - -def applyFiltersAndSort( - items: List[Dict[str, Any]], - paginationParams: Optional[PaginationParams], -) -> List[Dict[str, Any]]: - """ - Apply filters and sorting to a list of dicts in-memory. - Does NOT paginate (no page/pageSize slicing). - """ - if not paginationParams: - return items - - result = list(items) - - if paginationParams.filters: - filters = paginationParams.filters - searchTerm = filters.get("search", "").lower() if filters.get("search") else None - - if searchTerm: - result = [ - item for item in result - if any( - searchTerm in str(v).lower() - for v in item.values() - if v is not None - ) - ] - - for field, filterValue in filters.items(): - if field == "search": - continue - - if isinstance(filterValue, dict) and "operator" in filterValue: - operator = filterValue.get("operator", "equals") - value = filterValue.get("value") - else: - operator = "equals" - value = filterValue - - if value is None: - result = [ - item for item in result - if item.get(field) is None or item.get(field) == "" - ] - continue - - if value == "": - continue - - result = [ - item for item in result - if _matchesFilter(item, field, operator, value) - ] - - if paginationParams.sort: - for sortField in reversed(paginationParams.sort): - fieldName = sortField.field - ascending = sortField.direction == "asc" - - noneItems = [item for item in result if item.get(fieldName) is None] - nonNoneItems = [item for item in result if item.get(fieldName) is not None] - - def _getSortKey(item: Dict[str, Any], _fn=fieldName): - value = item.get(_fn) - if isinstance(value, bool): - return (0, int(value), "") - if isinstance(value, (int, float)): - return (0, value, "") - return (1, 0, str(value).lower()) - - nonNoneItems = sorted(nonNoneItems, key=_getSortKey, reverse=not ascending) - result = nonNoneItems + noneItems - - return result - - -def _matchesFilter(item: Dict[str, Any], field: str, operator: str, value: Any) -> bool: - """Single-field filter match for in-memory filtering.""" - itemValue = item.get(field) - if itemValue is None: - return False - - itemStr = str(itemValue).lower() - valueStr = str(value).lower() - - if operator in ("equals", "eq"): - return itemStr == valueStr - if operator == "contains": - return valueStr in itemStr - if operator == "startsWith": - return itemStr.startswith(valueStr) - if operator == "endsWith": - return itemStr.endswith(valueStr) - if operator in ("gt", "gte", "lt", "lte"): - try: - itemNum = float(itemValue) - valueNum = float(value) - if operator == "gt": - return itemNum > valueNum - if operator == "gte": - return itemNum >= valueNum - if operator == "lt": - return itemNum < valueNum - return itemNum <= valueNum - except (ValueError, TypeError): - return False - if operator == "between": - return _matchesBetween(itemValue, itemStr, value) - if operator == "in": - if isinstance(value, list): - return itemStr in [str(x).lower() for x in value] - return False - if operator == "notIn": - if isinstance(value, list): - return itemStr not in [str(x).lower() for x in value] - return True - return True - - -def _matchesBetween(itemValue: Any, itemStr: str, value: Any) -> bool: - """Handle 'between' operator for date ranges and numeric ranges.""" - if not isinstance(value, dict): - return True - fromVal = value.get("from", "") - toVal = value.get("to", "") - if not fromVal and not toVal: - return True - try: - from datetime import datetime, timezone - fromTs = None - toTs = None - if fromVal: - fromTs = datetime.strptime(str(fromVal), "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp() - if toVal: - toTs = datetime.strptime(str(toVal), "%Y-%m-%d").replace( - hour=23, minute=59, second=59, tzinfo=timezone.utc - ).timestamp() - itemNum = float(itemValue) if not isinstance(itemValue, (int, float)) else itemValue - if itemNum > 10000000000: - itemNum = itemNum / 1000 - if fromTs is not None and toTs is not None: - return fromTs <= itemNum <= toTs - if fromTs is not None: - return itemNum >= fromTs - if toTs is not None: - return itemNum <= toTs - except (ValueError, TypeError): - # Numeric range (e.g. FormGeneratorTable column filters on INTEGER/FLOAT) - try: - itemNum = float(itemValue) - fromNum = float(fromVal) if fromVal not in (None, "") else None - toNum = float(toVal) if toVal not in (None, "") else None - if fromNum is not None and toNum is not None: - return fromNum <= itemNum <= toNum - if fromNum is not None: - return itemNum >= fromNum - if toNum is not None: - return itemNum <= toNum - except (ValueError, TypeError): - pass - fromStr = str(fromVal).lower() if fromVal else "" - toStr = str(toVal).lower() if toVal else "" - if fromStr and toStr: - return fromStr <= itemStr <= toStr - if fromStr: - return itemStr >= fromStr - if toStr: - return itemStr <= toStr - return True - - -def _extractDistinctValues( - items: List[Dict[str, Any]], - columnKey: str, - requestLang: Optional[str] = None, -) -> list: - """Extract sorted distinct display values for a column from enriched items. - - When the items contain a ``{columnKey}Label`` field (FK enrichment convention), - returns ``{value, label}`` objects so the frontend shows human-readable - labels in filter dropdowns. Otherwise returns plain strings. - - Includes ``None`` as the last entry when at least one row has a null/empty - value — this enables the "(Leer)" filter option in the frontend. - """ - _MISSING = object() - labelKey = f"{columnKey}Label" - hasFkLabels = any(labelKey in item for item in items[:20]) - - if hasFkLabels: - byVal: Dict[str, str] = {} - hasEmpty = False - for item in items: - val = item.get(columnKey, _MISSING) - if val is _MISSING: - continue - if val is None or val == "": - hasEmpty = True - continue - strVal = str(val) - if strVal not in byVal: - label = item.get(labelKey) - byVal[strVal] = str(label) if label else f"NA({strVal[:8]})" - result: list = sorted( - [{"value": v, "label": l} for v, l in byVal.items()], - key=lambda x: x["label"].lower(), - ) - if hasEmpty: - result.append(None) - return result - - values = set() - hasEmpty = False - for item in items: - val = item.get(columnKey, _MISSING) - if val is _MISSING: - continue - if val is None or val == "": - hasEmpty = True - continue - if isinstance(val, bool): - values.add("true" if val else "false") - elif isinstance(val, (int, float)): - values.add(str(val)) - elif isinstance(val, dict): - text = resolveText(val, requestLang) - if text: - values.add(text) - else: - values.add(str(val)) - result = sorted(values, key=lambda v: v.lower()) - if hasEmpty: - result.append(None) - return result - - -def handleFilterValuesInMemory( - items: List[Dict[str, Any]], - column: str, - paginationJson: Optional[str] = None, - requestLang: Optional[str] = None, -) -> JSONResponse: - """ - In-memory filter-values: apply cross-filters, then extract distinct values. - For routes that build enriched in-memory lists. - Returns JSONResponse to bypass FastAPI response_model validation. - """ - crossFilterParams = parseCrossFilterPagination(column, paginationJson) - crossFiltered = applyFiltersAndSort(items, crossFilterParams) - return JSONResponse(content=_extractDistinctValues(crossFiltered, column, requestLang)) - - -def handleIdsInMemory( - items: List[Dict[str, Any]], - paginationJson: Optional[str] = None, - idField: str = "id", -) -> JSONResponse: - """ - In-memory IDs: apply filters, return all IDs. - For routes that build enriched in-memory lists. - Returns JSONResponse to bypass FastAPI response_model validation. - """ - pagination = parsePaginationForIds(paginationJson) - filtered = applyFiltersAndSort(items, pagination) - ids = [] - for item in filtered: - val = item.get(idField) - if val is not None: - ids.append(str(val)) - return JSONResponse(content=ids) - - -def getRecordsetPaginatedWithFkSort( - db, - modelClass: type, - pagination, - recordFilter: Optional[Dict[str, Any]] = None, - labelResolvers: Optional[Dict[str, Callable[[List[str]], Dict[str, str]]]] = None, - fieldFilter: Optional[List[str]] = None, - idField: str = "id", -) -> Dict[str, Any]: - """ - Wrapper around db.getRecordsetPaginated that handles FK-label sorting. - - If the current sort field is a FK with a registered labelResolver, the - function fetches all filtered IDs + FK values, resolves labels cross-DB, - sorts in-memory by label, and returns only the requested page. - - If no FK sort is active, delegates directly to db.getRecordsetPaginated. - """ - import math - - if not pagination or not pagination.sort: - return db.getRecordsetPaginated(modelClass, pagination, recordFilter, fieldFilter) - - if labelResolvers is None: - labelResolvers = _buildLabelResolversFromModel(modelClass) - - if not labelResolvers: - return db.getRecordsetPaginated(modelClass, pagination, recordFilter, fieldFilter) - - fkSortField = None - fkSortDir = "asc" - for sf in pagination.sort: - sfField = sf.get("field") if isinstance(sf, dict) else getattr(sf, "field", None) - sfDir = sf.get("direction", "asc") if isinstance(sf, dict) else getattr(sf, "direction", "asc") - if sfField and sfField in labelResolvers: - fkSortField = sfField - fkSortDir = str(sfDir).lower() - break - - if not fkSortField: - return db.getRecordsetPaginated(modelClass, pagination, recordFilter, fieldFilter) - - try: - distinctIds = db.getDistinctColumnValues( - modelClass, fkSortField, recordFilter=recordFilter, - ) or [] - - labelMap = {} - if distinctIds: - try: - labelMap = labelResolvers[fkSortField](distinctIds) - except Exception as e: - logger.warning(f"getRecordsetPaginatedWithFkSort: resolver for {fkSortField} failed: {e}") - - filterOnlyPagination = copy.deepcopy(pagination) - filterOnlyPagination.sort = [] - filterOnlyPagination.page = 1 - filterOnlyPagination.pageSize = 999999 - - lightRows = db.getRecordsetPaginated( - modelClass, filterOnlyPagination, recordFilter, - fieldFilter=[idField, fkSortField], - ) - allRows = lightRows.get("items", []) - totalItems = len(allRows) - - if totalItems == 0: - return {"items": [], "totalItems": 0, "totalPages": 0} - - def _sortKey(row): - fkVal = row.get(fkSortField, "") or "" - label = labelMap.get(str(fkVal), str(fkVal)).lower() - return label - - reverse = fkSortDir == "desc" - allRows.sort(key=_sortKey, reverse=reverse) - - pageSize = pagination.pageSize - offset = (pagination.page - 1) * pageSize - pageSlice = allRows[offset:offset + pageSize] - pageIds = [row[idField] for row in pageSlice if row.get(idField)] - - if not pageIds: - return {"items": [], "totalItems": totalItems, "totalPages": math.ceil(totalItems / pageSize)} - - pageItems = db.getRecordset(modelClass, recordFilter={idField: pageIds}, fieldFilter=fieldFilter) - - idOrder = {pid: idx for idx, pid in enumerate(pageIds)} - pageItems.sort(key=lambda r: idOrder.get(r.get(idField), 999999)) - - enrichRowsWithFkLabels(pageItems, modelClass) - totalPages = math.ceil(totalItems / pageSize) if totalItems > 0 else 0 - return {"items": pageItems, "totalItems": totalItems, "totalPages": totalPages} - - except Exception as e: - logger.error(f"getRecordsetPaginatedWithFkSort failed for {modelClass.__name__}: {e}") - return db.getRecordsetPaginated(modelClass, pagination, recordFilter, fieldFilter) - - -def paginateInMemory( - items: List[Dict[str, Any]], - paginationParams: Optional[PaginationParams], -) -> tuple: - """ - Apply pagination (page/pageSize slicing) to an already-filtered+sorted list. - Returns (pageItems, totalItems). - """ - totalItems = len(items) - if not paginationParams: - return items, totalItems - offset = (paginationParams.page - 1) * paginationParams.pageSize - pageItems = items[offset:offset + paginationParams.pageSize] - return pageItems, totalItems - - -# --------------------------------------------------------------------------- -# View resolution and Strategy B grouping engine -# --------------------------------------------------------------------------- - -def resolveView(interface, contextKey: str, viewKey: Optional[str]): - """ - Load a TableListView for the current user and contextKey. - - Returns (config_dict, display_name): - - (None, None) when viewKey is None / empty - - (config, str | None) otherwise — config may be {}; display_name from the row - - Raises HTTPException(404) when viewKey is explicitly set but the view - does not exist (prevents silent fallback to ungrouped behaviour). - """ - from fastapi import HTTPException - if not viewKey: - return None, None - try: - view = interface.getTableListView(contextKey=contextKey, viewKey=viewKey) - except Exception as e: - logger.warning(f"resolveView: store lookup failed for key={viewKey!r} context={contextKey!r}: {e}") - view = None - if view is None: - raise HTTPException(status_code=404, detail=f"View '{viewKey}' not found for context '{contextKey}'") - cfg = view.config or {} - dname = getattr(view, "displayName", None) or None - return cfg, dname - - -def effective_group_by_levels( - pagination_params: Optional["PaginationParams"], - view_config: Optional[dict], -) -> List[Dict[str, Any]]: - """ - Choose grouping levels for this request. - - If the client sends ``groupByLevels`` (including ``[]``), it wins over the - saved view. If the key is omitted (``None``), use the view's levels. - """ - if pagination_params is not None: - req = getattr(pagination_params, "groupByLevels", None) - if req is not None: - out: List[Dict[str, Any]] = [] - for lvl in req: - if hasattr(lvl, "model_dump"): - out.append(lvl.model_dump()) - elif isinstance(lvl, dict): - out.append(dict(lvl)) - else: - out.append(dict(lvl)) # type: ignore[arg-type] - return out - vc = (view_config or {}).get("groupByLevels") if view_config else None - return list(vc or []) - - -def applyViewToParams(params: Optional["PaginationParams"], viewConfig: Optional[dict]) -> Optional["PaginationParams"]: - """ - Merge a view's saved configuration into PaginationParams. - - Priority: explicit request fields win over view defaults. - - sort: use request sort if non-empty, otherwise view sort - - filters: deep-merge (request filters win per-key) - - pageSize: use request value (already set by normalize_pagination_dict) - - 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 PaginationParams, SortField - if not viewConfig: - return params - - if params is None: - params = PaginationParams(page=1, pageSize=25) - - # Sort: request wins if non-empty - if not params.sort and viewConfig.get("sort"): - try: - params.sort = [ - SortField(**s) if isinstance(s, dict) else s - for s in viewConfig["sort"] - ] - except Exception as e: - logger.warning(f"applyViewToParams: could not parse view sort: {e}") - - # Filters: deep-merge (request filters take priority per-key) - viewFilters = viewConfig.get("filters") or {} - if viewFilters: - merged = dict(viewFilters) - if params.filters: - merged.update(params.filters) - params.filters = merged - - return params - - -def apply_strategy_b_filters_and_sort( - items: List[Dict[str, Any]], - pagination_params: Optional[PaginationParams], - current_user: Any, -) -> List[Dict[str, Any]]: - """ - Shared in-memory filter + sort pass for Strategy B (files/prompts/connections lists). - """ - if not pagination_params: - return list(items) - from modules.interfaces.interfaceDbManagement import ComponentObjects - - comp = ComponentObjects() - comp.setUserContext(current_user) - out = list(items) - if pagination_params.filters: - out = comp._applyFilters(out, pagination_params.filters) - if pagination_params.sort: - out = comp._applySorting(out, pagination_params.sort) - return out - - -def build_group_summary_groups( - items: List[Dict[str, Any]], - field: str, - null_label: str = "—", - groupByLevels: List[Dict[str, Any]] | None = None, -) -> List[Dict[str, Any]]: - """ - Build {"value", "label", "totalCount"} summaries for mode=groupSummary. - - When *groupByLevels* contains more than one level the function produces one - entry per unique combination of all level values (flat permutations). - ``value`` becomes a ``///``-joined composite key and ``label`` the ``/``-joined - human-readable label so the frontend can split them back. - """ - from collections import defaultdict - - fields: list[dict] = [] - if groupByLevels and len(groupByLevels) > 1: - for lvl in groupByLevels: - f = lvl.get("field", "") - nl = str(lvl.get("nullLabel") or null_label) - if f: - fields.append({"field": f, "nullLabel": nl}) - if not fields: - fields = [{"field": field, "nullLabel": null_label}] - - nullKey = "\x00NULL" - - if len(fields) == 1: - f = fields[0]["field"] - nl = fields[0]["nullLabel"] - counts: Dict[str, int] = defaultdict(int) - displayByKey: Dict[str, str] = {} - labelAttr = f"{f}Label" - for item in items: - raw = item.get(f) - if raw is None or raw == "": - nk = nullKey - display = nl - else: - nk = str(raw) - display = None - lbl = item.get(labelAttr) - if lbl is not None and lbl != "": - display = str(lbl) - if display is None: - display = nk - counts[nk] += 1 - if nk not in displayByKey: - displayByKey[nk] = display - orderedKeys = sorted( - counts.keys(), - key=lambda x: (x == nullKey, str(displayByKey.get(x, x)).lower()), - ) - return [ - { - "value": None if nk == nullKey else nk, - "label": displayByKey.get(nk, nk), - "totalCount": counts[nk], - } - for nk in orderedKeys - ] - - counts = defaultdict(int) - displayByComposite: Dict[str, list] = {} - filtersByComposite: Dict[str, dict] = {} - for item in items: - parts: list[str] = [] - labels: list[str] = [] - filterMap: dict = {} - for fd in fields: - f = fd["field"] - nl = fd["nullLabel"] - labelAttr = f"{f}Label" - raw = item.get(f) - if raw is None or raw == "": - parts.append(nullKey) - labels.append(nl) - filterMap[f] = None - else: - parts.append(str(raw)) - lbl = item.get(labelAttr) - labels.append(str(lbl) if lbl not in (None, "") else str(raw)) - filterMap[f] = str(raw) - compositeKey = "///".join(parts) - counts[compositeKey] += 1 - if compositeKey not in displayByComposite: - displayByComposite[compositeKey] = labels - filtersByComposite[compositeKey] = filterMap - - orderedKeys = sorted( - counts.keys(), - key=lambda x: tuple( - (seg == nullKey, seg.lower()) for seg in x.split("///") - ), - ) - return [ - { - "value": ck.replace(nullKey, "__null__") if nullKey in ck else ck, - "label": " / ".join(displayByComposite[ck]), - "totalCount": counts[ck], - "filters": filtersByComposite[ck], - } - for ck in orderedKeys - ] - - -def buildGroupLayout( - all_items: List[Dict[str, Any]], - groupByLevels: List[Dict[str, Any]], - page: int, - pageSize: int, -) -> tuple: - """ - Apply multi-level grouping to all_items, slice to the requested page, - and return (page_items, GroupLayout | None). - - Strategy B: grouping operates on the full filtered+sorted candidate list. - Items are stably re-sorted by the group path so that members of the same - group are always contiguous (preserving the existing per-group sort order - from the caller). - - Parameters - ---------- - all_items: fully filtered and user-sorted list of row dicts. - groupByLevels: list of {"field": str, "nullLabel": str, "direction": "asc"|"desc"} dicts. - page, pageSize: 1-based page index and page size. - - Returns - ------- - (page_items, GroupLayout | None) - """ - from functools import cmp_to_key - from modules.datamodels.datamodelPagination import GroupBand, GroupLayout - - if not groupByLevels: - offset = (page - 1) * pageSize - return all_items[offset:offset + pageSize], None - - levels = [lvl.get("field", "") for lvl in groupByLevels if lvl.get("field")] - if not levels: - offset = (page - 1) * pageSize - return all_items[offset:offset + pageSize], None - - nullLabels = {lvl.get("field", ""): lvl.get("nullLabel", "—") for lvl in groupByLevels} - - def _path_key(item: dict) -> tuple: - return tuple( - str(item.get(f) or "") if item.get(f) is not None else nullLabels.get(f, "—") - for f in levels - ) - - def _item_cmp(a: dict, b: dict) -> int: - pa, pb = _path_key(a), _path_key(b) - for i in range(len(levels)): - if pa[i] != pb[i]: - asc = (groupByLevels[i].get("direction") or "asc").lower() != "desc" - if pa[i] < pb[i]: - return -1 if asc else 1 - return 1 if asc else -1 - return 0 - - # Sort by group path (per-level asc/desc); order within same path stays stable in Py3.12+ - all_items.sort(key=cmp_to_key(_item_cmp)) - - # Build global band list from the full sorted list - bands_global: List[dict] = [] - current_path: Optional[tuple] = None - current_start = 0 - for i, item in enumerate(all_items): - path = _path_key(item) - if path != current_path: - if current_path is not None: - bands_global.append({"path": list(current_path), "startIdx": current_start, "endIdx": i}) - current_path = path - current_start = i - if current_path is not None: - bands_global.append({"path": list(current_path), "startIdx": current_start, "endIdx": len(all_items)}) - - # Slice to page - page_start = (page - 1) * pageSize - page_end = page_start + pageSize - page_items = all_items[page_start:page_end] - - # Find bands that have at least one row on this page - bands_on_page: List[GroupBand] = [] - for band in bands_global: - inter_start = max(band["startIdx"], page_start) - inter_end = min(band["endIdx"], page_end) - if inter_start >= inter_end: - continue - path_list = band["path"] - bands_on_page.append(GroupBand( - path=path_list, - label=path_list[-1] if path_list else "—", - startRowIndex=inter_start - page_start, - rowCount=inter_end - inter_start, - )) - - group_layout = GroupLayout(levels=levels, bands=bands_on_page) if bands_on_page else GroupLayout(levels=levels, bands=[]) - return page_items, group_layout diff --git a/modules/routes/routeI18n.py b/modules/routes/routeI18n.py index 927d1bf2..8b1b46d5 100644 --- a/modules/routes/routeI18n.py +++ b/modules/routes/routeI18n.py @@ -44,9 +44,9 @@ from modules.routes.routeNotifications import createNotification from modules.shared.configuration import APP_CONFIG from modules.shared.i18nRegistry import ( _enforceSourcePlaceholders, - loadCache as _reloadI18nCache, apiRouteContext, ) +from modules.system.i18nBootSync import loadCache as _reloadI18nCache from modules.shared.timeUtils import getUtcTimestamp routeApiMsg = apiRouteContext("routeI18n") @@ -404,7 +404,7 @@ def _resolveMandateIdForAiI18n(request: Request, currentUser: User) -> str: # --------------------------------------------------------------------------- _REPO_ROOT = Path(__file__).resolve().parents[3] -_FRONTEND_SRC = _REPO_ROOT / "frontend_nyla" / "src" +_FRONTEND_SRC = _REPO_ROOT / "ui-nyla" / "src" _T_CALL_RE = re.compile(r"""\bt\(\s*'((?:\\.|[^'])+)'\s*(?:,|\))""") diff --git a/modules/routes/routeInvitations.py b/modules/routes/routeInvitations.py index 7651afe0..25049227 100644 --- a/modules/routes/routeInvitations.py +++ b/modules/routes/routeInvitations.py @@ -21,7 +21,8 @@ from pydantic import BaseModel, Field, model_validator from modules.auth import limiter, getRequestContext, RequestContext, getCurrentUser from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelPagination import PaginationParams, PaginationMetadata, normalize_pagination_dict -from modules.routes.routeHelpers import applyFiltersAndSort, handleFilterValuesInMemory, handleIdsInMemory, enrichRowsWithFkLabels +from modules.dbHelpers.paginationHelpers import applyFiltersAndSort, handleFilterValuesInMemory, handleIdsInMemory +from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels from modules.datamodels.datamodelInvitation import Invitation from modules.interfaces.interfaceDbApp import getRootInterface from modules.shared.timeUtils import getUtcTimestamp @@ -477,7 +478,7 @@ def list_invitations( raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") try: items = _buildInvitationItems() - enrichRowsWithFkLabels(items, Invitation) + enrichRowsWithFkLabels(items, Invitation, db=getRootInterface().db) return handleFilterValuesInMemory(items, column, pagination) except Exception as e: logger.error(f"Error getting filter values for invitations: {e}") @@ -509,7 +510,7 @@ def list_invitations( totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0 startIdx = (paginationParams.page - 1) * paginationParams.pageSize endIdx = startIdx + paginationParams.pageSize - enriched = enrichRowsWithFkLabels(filtered[startIdx:endIdx], Invitation) + enriched = enrichRowsWithFkLabels(filtered[startIdx:endIdx], Invitation, db=getRootInterface().db) return { "items": enriched, "pagination": PaginationMetadata( @@ -518,7 +519,7 @@ def list_invitations( sort=paginationParams.sort, filters=paginationParams.filters, ).model_dump(), } - enriched = enrichRowsWithFkLabels(result, Invitation) + enriched = enrichRowsWithFkLabels(result, Invitation, db=getRootInterface().db) return {"items": enriched, "pagination": None} except HTTPException: diff --git a/modules/routes/routeMfa.py b/modules/routes/routeMfa.py index 551faa37..cb681fe0 100644 --- a/modules/routes/routeMfa.py +++ b/modules/routes/routeMfa.py @@ -47,7 +47,7 @@ router = APIRouter(prefix="/api/mfa", tags=["MFA"]) _MFA_PENDING_EXPIRE_MINUTES = 5 -def _createMfaPendingToken(userId: str, username: str, authority: str, sessionId: str) -> str: +def createMfaPendingToken(userId: str, username: str, authority: str, sessionId: str) -> str: """Short-lived JWT that authorises only the /mfa/verify endpoint.""" payload = { "sub": username, @@ -233,7 +233,7 @@ def mfaVerify( logger.info("MFA verify successful for user %s", username) try: - from modules.shared.auditLogger import audit_logger + from modules.dbHelpers.auditLogger import audit_logger audit_logger.logUserAccess( userId=userId, mandateId="system", diff --git a/modules/routes/routeRagInventory.py b/modules/routes/routeRagInventory.py index 0ca7fade..f7219c60 100644 --- a/modules/routes/routeRagInventory.py +++ b/modules/routes/routeRagInventory.py @@ -234,7 +234,7 @@ def _buildFeatureInstanceInventory(featureInstanceIds, rootIf, knowledgeIf) -> L Includes feature.bootstrap job status (running/success/error). """ from modules.datamodels.datamodelKnowledge import FileContentIndex - from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + from modules.datamodels.datamodelFeatures import FeatureDataSource from modules.interfaces.interfaceFeatures import getFeatureInterface from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlagFds from modules.serviceCenter.services.serviceBackgroundJobs import mainBackgroundJobService as jobService diff --git a/modules/routes/routeRealEstate.py b/modules/routes/routeRealEstate.py index 247d3b8b..81550de2 100644 --- a/modules/routes/routeRealEstate.py +++ b/modules/routes/routeRealEstate.py @@ -1281,7 +1281,6 @@ async def search_parcel( "sr": "2056" } import aiohttp - import ssl ssl_context = ssl.create_default_context() ssl_context.check_hostname = False ssl_context.verify_mode = ssl.CERT_NONE diff --git a/modules/routes/routeSecurityGoogle.py b/modules/routes/routeSecurityGoogle.py index 853d4067..2f1eabd2 100644 --- a/modules/routes/routeSecurityGoogle.py +++ b/modules/routes/routeSecurityGoogle.py @@ -11,6 +11,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse import logging import json import time +import uuid from typing import Dict, Any, Optional from requests_oauthlib import OAuth2Session import httpx @@ -221,7 +222,7 @@ async def auth_login_callback( # --- MFA gate -------------------------------------------------------- from modules.auth.mfaService import isMfaRequired as _isMfaRequired - from modules.routes.routeMfa import _createMfaPendingToken + from modules.routes.routeMfa import createMfaPendingToken userRecord = rootInterface._getUserForAuthentication(user.username) userMandates = rootInterface.getUserMandates(str(user.id)) @@ -239,9 +240,8 @@ async def auth_login_callback( hasMfaSetup = bool(userRecord and userRecord.get("mfaSecret") and getattr(user, "mfaEnabled", False)) if mfaRequired or hasMfaSetup: - import uuid as _uuid - _sid = str(_uuid.uuid4()) - pendingToken = _createMfaPendingToken( + _sid = str(uuid.uuid4()) + pendingToken = createMfaPendingToken( userId=str(user.id), username=user.username, authority=AuthAuthority.GOOGLE.value, @@ -255,9 +255,9 @@ async def auth_login_callback( from modules.auth.mfaService import generateSetup as _generateSetup existingSecret = userRecord.get("mfaSecret") if userRecord else None if existingSecret: - from modules.auth.mfaService import _decryptSecret, _buildTotp, _getMfaIssuer - _plain = _decryptSecret(existingSecret, userId=str(user.id)) - _uri = _buildTotp(_plain).provisioning_uri(name=user.username, issuer_name=_getMfaIssuer()) + from modules.auth.mfaService import decryptSecret, buildTotp, getMfaIssuer + _plain = decryptSecret(existingSecret, userId=str(user.id)) + _uri = buildTotp(_plain).provisioning_uri(name=user.username, issuer_name=getMfaIssuer()) setupResult = {"provisioningUri": _uri} else: setupResult = _generateSetup(userId=str(user.id), username=user.username) @@ -652,7 +652,7 @@ def logout( revoked = 1 try: - from modules.shared.auditLogger import audit_logger + from modules.dbHelpers.auditLogger import audit_logger audit_logger.logUserAccess( userId=str(currentUser.id), diff --git a/modules/routes/routeSecurityInfomaniak.py b/modules/routes/routeSecurityInfomaniak.py index d938b45e..4026f4e9 100644 --- a/modules/routes/routeSecurityInfomaniak.py +++ b/modules/routes/routeSecurityInfomaniak.py @@ -46,7 +46,7 @@ from modules.datamodels.datamodelSecurity import Token, TokenPurpose from modules.auth import getCurrentUser, limiter from modules.shared.timeUtils import getUtcTimestamp, createExpirationTimestamp from modules.shared.i18nRegistry import apiRouteContext -from modules.connectors.providerInfomaniak.connectorInfomaniak import ( +from modules.connectors.connectorProviderInfomaniak import ( resolveOwnerIdentity, listAccessibleDrives, InfomaniakIdentityError, diff --git a/modules/routes/routeSecurityLocal.py b/modules/routes/routeSecurityLocal.py index c8efa0a8..6a25ce04 100644 --- a/modules/routes/routeSecurityLocal.py +++ b/modules/routes/routeSecurityLocal.py @@ -256,7 +256,7 @@ def login( # --- MFA gate -------------------------------------------------------- from modules.auth.mfaService import isMfaRequired as _isMfaRequired - from modules.routes.routeMfa import _createMfaPendingToken + from modules.routes.routeMfa import createMfaPendingToken userRecord = rootInterface._getUserForAuthentication(user.username) userMandates = rootInterface.getUserMandates(str(user.id)) @@ -275,7 +275,7 @@ def login( if mfaRequired or hasMfaSetup: _sid = str(uuid.uuid4()) - pendingToken = _createMfaPendingToken( + pendingToken = createMfaPendingToken( userId=str(user.id), username=user.username, authority=AuthAuthority.LOCAL.value, @@ -287,11 +287,11 @@ def login( from modules.auth.mfaService import generateSetup as _generateSetup existingSecret = userRecord.get("mfaSecret") if userRecord else None if existingSecret: - from modules.auth.mfaService import _decryptSecret, _buildTotp - _plain = _decryptSecret(existingSecret, userId=str(user.id)) - _totp = _buildTotp(_plain) - from modules.auth.mfaService import _getMfaIssuer - _uri = _totp.provisioning_uri(name=user.username, issuer_name=_getMfaIssuer()) + from modules.auth.mfaService import decryptSecret, buildTotp + _plain = decryptSecret(existingSecret, userId=str(user.id)) + _totp = buildTotp(_plain) + from modules.auth.mfaService import getMfaIssuer + _uri = _totp.provisioning_uri(name=user.username, issuer_name=getMfaIssuer()) setupResult = {"provisioningUri": _uri} else: setupResult = _generateSetup(userId=str(user.id), username=user.username) @@ -374,7 +374,7 @@ def login( # Log successful login (app log file + audit DB for traceability) logger.info("Login successful for username=%s (userId=%s)", formData.username, str(user.id)) try: - from modules.shared.auditLogger import audit_logger + from modules.dbHelpers.auditLogger import audit_logger audit_logger.logUserAccess( userId=str(user.id), mandateId="system", @@ -409,7 +409,7 @@ def login( # Log failed login attempt try: - from modules.shared.auditLogger import audit_logger + from modules.dbHelpers.auditLogger import audit_logger audit_logger.logUserAccess( userId=formData.username or "unknown", mandateId="system", @@ -732,7 +732,7 @@ def logout(request: Request, response: Response, currentUser: User = Depends(get # Log successful logout # MULTI-TENANT: Logout is a system-level function, no mandate context try: - from modules.shared.auditLogger import audit_logger + from modules.dbHelpers.auditLogger import audit_logger audit_logger.logUserAccess( userId=str(currentUser.id), mandateId="system", @@ -1001,7 +1001,7 @@ def password_reset( # Log success try: - from modules.shared.auditLogger import audit_logger + from modules.dbHelpers.auditLogger import audit_logger audit_logger.logSecurityEvent( userId="unknown", mandateId="unknown", @@ -1036,7 +1036,7 @@ def _getNeutralizationMappings( """List the current user's neutralization placeholder mappings.""" userId = str(context.user.id) from modules.interfaces.interfaceDbApp import getRootInterface - from modules.features.neutralization.datamodelFeatureNeutralizer import DataNeutralizerAttributes + from modules.datamodels.datamodelFeatures import DataNeutralizerAttributes rootIf = getRootInterface() records = rootIf.db.getRecordset(DataNeutralizerAttributes, recordFilter={"userId": userId}) return {"mappings": records} @@ -1052,7 +1052,7 @@ 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.features.neutralization.datamodelFeatureNeutralizer import DataNeutralizerAttributes + from modules.datamodels.datamodelFeatures import DataNeutralizerAttributes rootIf = getRootInterface() records = rootIf.db.getRecordset(DataNeutralizerAttributes, recordFilter={"id": mappingId}) if not records: diff --git a/modules/routes/routeSecurityMsft.py b/modules/routes/routeSecurityMsft.py index 80e0d19a..c26503ef 100644 --- a/modules/routes/routeSecurityMsft.py +++ b/modules/routes/routeSecurityMsft.py @@ -11,6 +11,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse import logging import json import time +import uuid from typing import Dict, Any, Optional from urllib.parse import quote import msal @@ -194,7 +195,7 @@ async def auth_login_callback( # --- MFA gate -------------------------------------------------------- from modules.auth.mfaService import isMfaRequired as _isMfaRequired - from modules.routes.routeMfa import _createMfaPendingToken + from modules.routes.routeMfa import createMfaPendingToken userRecord = rootInterface._getUserForAuthentication(user.username) userMandates = rootInterface.getUserMandates(str(user.id)) @@ -212,9 +213,8 @@ async def auth_login_callback( hasMfaSetup = bool(userRecord and userRecord.get("mfaSecret") and getattr(user, "mfaEnabled", False)) if mfaRequired or hasMfaSetup: - import uuid as _uuid - _sid = str(_uuid.uuid4()) - pendingToken = _createMfaPendingToken( + _sid = str(uuid.uuid4()) + pendingToken = createMfaPendingToken( userId=str(user.id), username=user.username, authority=AuthAuthority.MSFT.value, @@ -228,9 +228,9 @@ async def auth_login_callback( from modules.auth.mfaService import generateSetup as _generateSetup existingSecret = userRecord.get("mfaSecret") if userRecord else None if existingSecret: - from modules.auth.mfaService import _decryptSecret, _buildTotp, _getMfaIssuer - _plain = _decryptSecret(existingSecret, userId=str(user.id)) - _uri = _buildTotp(_plain).provisioning_uri(name=user.username, issuer_name=_getMfaIssuer()) + from modules.auth.mfaService import decryptSecret, buildTotp, getMfaIssuer + _plain = decryptSecret(existingSecret, userId=str(user.id)) + _uri = buildTotp(_plain).provisioning_uri(name=user.username, issuer_name=getMfaIssuer()) setupResult = {"provisioningUri": _uri} else: setupResult = _generateSetup(userId=str(user.id), username=user.username) @@ -725,7 +725,7 @@ def logout( revoked = 1 try: - from modules.shared.auditLogger import audit_logger + from modules.dbHelpers.auditLogger import audit_logger audit_logger.logUserAccess( userId=str(currentUser.id), diff --git a/modules/routes/routeStore.py b/modules/routes/routeStore.py index a433c1ed..7356f1aa 100644 --- a/modules/routes/routeStore.py +++ b/modules/routes/routeStore.py @@ -10,6 +10,7 @@ from fastapi import APIRouter, HTTPException, Depends, Request from typing import List, Dict, Any, Optional, Union from fastapi import status import logging +from datetime import datetime, timezone, timedelta from pydantic import BaseModel, Field from modules.auth import limiter, getRequestContext, RequestContext @@ -98,7 +99,6 @@ def _isUserAdminInMandate(db, userId: str, mandateId: str) -> bool: def _autoActivatePending(subInterface, pendingSub: Dict[str, Any]) -> None: """Auto-activate a PENDING subscription to its target operative status.""" from modules.datamodels.datamodelSubscription import SubscriptionStatusEnum, BUILTIN_PLANS - from datetime import datetime, timezone, timedelta subId = pendingSub.get("id") planKey = pendingSub.get("planKey", "") @@ -539,7 +539,7 @@ def _notifyFeatureActivation( ) -> None: """Send email notification to mandate admins about a newly activated feature.""" try: - from modules.shared.notifyMandateAdmins import notifyMandateAdmins + from modules.system.notifyMandateAdmins import notifyMandateAdmins priceLine = "" if plan and plan.pricePerFeatureInstanceCHF: diff --git a/modules/routes/routeSubscription.py b/modules/routes/routeSubscription.py index 9c5ecb01..87d34836 100644 --- a/modules/routes/routeSubscription.py +++ b/modules/routes/routeSubscription.py @@ -22,7 +22,7 @@ from pydantic import BaseModel, Field from modules.auth import limiter, getRequestContext, RequestContext from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict -from modules.routes.routeHelpers import applyFiltersAndSort, handleFilterValuesInMemory, handleIdsInMemory +from modules.dbHelpers.paginationHelpers import applyFiltersAndSort, handleFilterValuesInMemory, handleIdsInMemory from modules.shared.i18nRegistry import apiRouteContext, resolveText routeApiMsg = apiRouteContext("routeSubscription") @@ -409,9 +409,11 @@ def _buildEnrichedSubscriptions() -> List[Dict[str, Any]]: subInterface = getSubRootInterface() allSubs = subInterface.listAll() - from modules.routes.routeHelpers import resolveMandateLabels + from modules.dbHelpers.fkLabelResolver import resolveMandateLabels + from modules.interfaces.interfaceDbApp import getRootInterface allMandateIds = list({sub.get("mandateId") for sub in allSubs if sub.get("mandateId")}) - mandateNames: Dict[str, Optional[str]] = resolveMandateLabels(allMandateIds) if allMandateIds else {} + db = getRootInterface().db + mandateNames: Dict[str, Optional[str]] = resolveMandateLabels(db, allMandateIds) if allMandateIds else {} operativeValues = {s.value for s in OPERATIVE_STATUSES} @@ -491,10 +493,11 @@ def getAllSubscriptions( if mode == "filterValues": if not column: raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") - from modules.routes.routeHelpers import enrichRowsWithFkLabels + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels from modules.datamodels.datamodelSubscription import MandateSubscription + from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIf items = _buildEnrichedSubscriptions() - enrichRowsWithFkLabels(items, MandateSubscription) + enrichRowsWithFkLabels(items, MandateSubscription, db=_getRootIf().db) return handleFilterValuesInMemory(items, column, pagination) if mode == "ids": diff --git a/modules/routes/routeSystem.py b/modules/routes/routeSystem.py index 772e5018..56568cd9 100644 --- a/modules/routes/routeSystem.py +++ b/modules/routes/routeSystem.py @@ -685,7 +685,7 @@ def _buildIntegrationsOverviewPayload(userId: str, user=None) -> Dict[str, Any]: # --- DataSource & FeatureDataSource --- try: from modules.datamodels.datamodelDataSource import DataSource - from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + from modules.datamodels.datamodelFeatures import FeatureDataSource seen_ds: Set[str] = set() diff --git a/modules/routes/routeTableViews.py b/modules/routes/routeTableViews.py index 1b4b2d04..32a4cf7d 100644 --- a/modules/routes/routeTableViews.py +++ b/modules/routes/routeTableViews.py @@ -19,7 +19,7 @@ from fastapi import status from modules.auth import limiter, getCurrentUser from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelPagination import TableListView -import modules.interfaces.interfaceDbApp as interfaceDbApp +from modules.interfaces import interfaceDbApp logger = logging.getLogger(__name__) diff --git a/modules/routes/routeUdb.py b/modules/routes/routeUdb.py index dfec38e9..73c7b55c 100644 --- a/modules/routes/routeUdb.py +++ b/modules/routes/routeUdb.py @@ -24,6 +24,7 @@ model and the rationale behind the hard cut from the previous feature-instance-scoped endpoints. """ +import json import logging from typing import Any, Dict, List, Optional @@ -151,8 +152,7 @@ async def _udbNodeFlag( effective = _computeEffectiveAfterWrite(rootIf, context, node, flag) - import json - from modules.shared.auditLogger import audit_logger + from modules.dbHelpers.auditLogger import audit_logger from modules.datamodels.datamodelAudit import AuditCategory audit_logger.logEvent( userId=str(context.user.id), @@ -200,7 +200,7 @@ def _computeEffectiveAfterWrite(rootIf: Any, context: RequestContext, Re-loads the relevant recordsets so the cascade resets are visible. """ from modules.datamodels.datamodelDataSource import DataSource - from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + from modules.datamodels.datamodelFeatures import FeatureDataSource userId = str(context.user.id) allDs = rootIf.db.getRecordset(DataSource, recordFilter={"userId": userId}) or [] fdsFilter: Dict[str, Any] = {} diff --git a/modules/routes/routeWorkflowDashboard.py b/modules/routes/routeWorkflowDashboard.py index ea4b8854..f29fc557 100644 --- a/modules/routes/routeWorkflowDashboard.py +++ b/modules/routes/routeWorkflowDashboard.py @@ -14,6 +14,8 @@ import logging import math import re import time +from datetime import datetime, timezone +from functools import partial from typing import Optional, List from fastapi import APIRouter, Depends, Request, Query, Path, HTTPException from fastapi.responses import StreamingResponse @@ -220,10 +222,10 @@ _RUN_STATS_SUBQUERY = """ def _firstFkSortFieldForWorkflows(pagination) -> Optional[str]: """First sort field that requires FK label resolution (cross-DB), or None.""" - from modules.routes.routeHelpers import _buildLabelResolversFromModel + from modules.dbHelpers.fkLabelResolver import buildLabelResolversFromModel if not pagination or not pagination.sort: return None - resolvers = _buildLabelResolversFromModel(AutoWorkflow) + resolvers = buildLabelResolversFromModel(AutoWorkflow) if not resolvers: return None for sf in pagination.sort: @@ -287,8 +289,6 @@ def _listingOrderExpr(key: str, wfFieldNames: set, wfFields: dict) -> Optional[s def _appendJoinedListingFilters(whereParts: list, values: list, pagination, wfFields: dict) -> None: """Append WHERE fragments for joined workflow listing (w + rs).""" - from datetime import datetime as _dt, timezone as _tz - wfFieldNames = set(wfFields.keys()) validCols = wfFieldNames | {"lastStartedAt", "runCount", "isRunning"} @@ -389,19 +389,19 @@ def _appendJoinedListingFilters(whereParts: list, values: list, pagination, wfFi ) if isNumericCol and isDateVal: if fromVal and toVal: - fromTs = _dt.strptime(str(fromVal), "%Y-%m-%d").replace(tzinfo=_tz.utc).timestamp() - toTs = _dt.strptime(str(toVal), "%Y-%m-%d").replace( - hour=23, minute=59, second=59, tzinfo=_tz.utc + fromTs = datetime.strptime(str(fromVal), "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp() + toTs = datetime.strptime(str(toVal), "%Y-%m-%d").replace( + hour=23, minute=59, second=59, tzinfo=timezone.utc ).timestamp() whereParts.append(f"({colRef} >= %s AND {colRef} <= %s)") values.extend([fromTs, toTs]) elif fromVal: - fromTs = _dt.strptime(str(fromVal), "%Y-%m-%d").replace(tzinfo=_tz.utc).timestamp() + fromTs = datetime.strptime(str(fromVal), "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp() whereParts.append(f"({colRef} >= %s)") values.append(fromTs) else: - toTs = _dt.strptime(str(toVal), "%Y-%m-%d").replace( - hour=23, minute=59, second=59, tzinfo=_tz.utc + toTs = datetime.strptime(str(toVal), "%Y-%m-%d").replace( + hour=23, minute=59, second=59, tzinfo=timezone.utc ).timestamp() whereParts.append(f"({colRef} <= %s)") values.append(toTs) @@ -577,12 +577,11 @@ def get_workflow_runs( if mode == "filterValues": if not column: - from fastapi import HTTPException as _H - raise _H(status_code=400, detail="column parameter required for mode=filterValues") + raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") return _enrichedFilterValues(db, context, AutoRun, _scopedRunFilter, column) if mode == "ids": - from modules.routes.routeHelpers import handleIdsMode + from modules.dbHelpers.paginationHelpers import handleIdsMode baseFilter = _scopedRunFilter(context) recordFilter = dict(baseFilter) if baseFilter else {} return handleIdsMode(db, AutoRun, pagination, recordFilter) @@ -604,7 +603,7 @@ def get_workflow_runs( sort=[{"field": "startedAt", "direction": "desc"}], ) - from modules.routes.routeHelpers import getRecordsetPaginatedWithFkSort + from modules.dbHelpers.paginationHelpers import getRecordsetPaginatedWithFkSort result = getRecordsetPaginatedWithFkSort( db, AutoRun, pagination=paginationParams, @@ -620,7 +619,7 @@ def get_workflow_runs( for wf in (wfs or []): wfMap[wf.get("id")] = wf - from modules.routes.routeHelpers import enrichRowsWithFkLabels, resolveMandateLabels, resolveInstanceLabels + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels, resolveMandateLabels, resolveInstanceLabels runs = [] for r in pageRuns: @@ -638,9 +637,10 @@ def get_workflow_runs( enrichRowsWithFkLabels( runs, + db=db, labelResolvers={ - "mandateId": resolveMandateLabels, - "featureInstanceId": resolveInstanceLabels, + "mandateId": partial(resolveMandateLabels, db), + "featureInstanceId": partial(resolveInstanceLabels, db), }, ) for row in runs: @@ -755,12 +755,11 @@ def get_system_workflows( if mode == "filterValues": if not column: - from fastapi import HTTPException as _H - raise _H(status_code=400, detail="column parameter required for mode=filterValues") + raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") return _enrichedFilterValues(db, context, AutoWorkflow, _scopedWorkflowFilter, column) if mode == "ids": - from modules.routes.routeHelpers import handleIdsMode + from modules.dbHelpers.paginationHelpers import handleIdsMode baseFilter = _scopedWorkflowFilter(context) recordFilter = dict(baseFilter) if baseFilter else {} recordFilter["isTemplate"] = False @@ -783,7 +782,7 @@ def get_system_workflows( sort=[{"field": "sysCreatedAt", "direction": "desc"}], ) - from modules.routes.routeHelpers import enrichRowsWithFkLabels, resolveMandateLabels, resolveInstanceLabels + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels, resolveMandateLabels, resolveInstanceLabels featureCodeMap: dict = {} @@ -811,7 +810,7 @@ def get_system_workflows( fkSortField = _firstFkSortFieldForWorkflows(paginationParams) if fkSortField: - from modules.routes.routeHelpers import getRecordsetPaginatedWithFkSort, applyFiltersAndSort + from modules.dbHelpers.paginationHelpers import getRecordsetPaginatedWithFkSort, applyFiltersAndSort _COMPUTED_FIELDS = {"lastStartedAt", "runCount", "isRunning"} hasComputedFilter = bool( paginationParams.filters @@ -872,8 +871,9 @@ def get_system_workflows( items.append(row) enrichRowsWithFkLabels( items, + db=db, labelResolvers={ - "mandateId": resolveMandateLabels, + "mandateId": partial(resolveMandateLabels, db), "featureInstanceId": _resolveInstanceLabelsWithFeatureCode, }, ) @@ -934,8 +934,9 @@ def get_system_workflows( items.append(row) enrichRowsWithFkLabels( items, + db=db, labelResolvers={ - "mandateId": resolveMandateLabels, + "mandateId": partial(resolveMandateLabels, db), "featureInstanceId": _resolveInstanceLabelsWithFeatureCode, }, ) @@ -1047,7 +1048,7 @@ def _enrichedFilterValues( Returns JSONResponse to bypass FastAPI response_model validation. """ from fastapi.responses import JSONResponse - from modules.routes.routeHelpers import resolveMandateLabels, resolveInstanceLabels + from modules.dbHelpers.fkLabelResolver import resolveMandateLabels, resolveInstanceLabels if _isTimestampColumn(modelClass, column): return JSONResponse(content=[]) @@ -1061,7 +1062,7 @@ def _enrichedFilterValues( allVals = {r.get("mandateId") for r in items} mandateIds = sorted(v for v in allVals if v) hasEmpty = None in allVals or "" in allVals - labelMap = resolveMandateLabels(mandateIds) if mandateIds else {} + labelMap = resolveMandateLabels(db, mandateIds) if mandateIds else {} result = [{"value": mid, "label": labelMap.get(mid) or f"NA({mid})"} for mid in mandateIds] if hasEmpty: result.append(None) @@ -1086,7 +1087,7 @@ def _enrichedFilterValues( allVals = {w.get("featureInstanceId") for w in wfs} instanceIds = sorted(v for v in allVals if v) hasEmpty = None in allVals or "" in allVals - labelMap = resolveInstanceLabels(instanceIds) if instanceIds else {} + labelMap = resolveInstanceLabels(db, instanceIds) if instanceIds else {} result = [{"value": iid, "label": labelMap.get(iid) or f"NA({iid})"} for iid in instanceIds] if hasEmpty: result.append(None) diff --git a/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py b/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py index a23688e5..9389ee85 100644 --- a/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py +++ b/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py @@ -9,6 +9,7 @@ from modules.serviceCenter.services.serviceAgent.datamodelAgent import ( ToolDefinition, ToolResult ) from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistry +import time logger = logging.getLogger(__name__) @@ -124,7 +125,7 @@ def _catalogTypeToJsonSchema(typeStr: str, _depth: int = 0) -> Dict[str, Any]: `_depth` guards against pathological recursion in case of a cyclic catalog. """ - from modules.features.graphicalEditor.portTypes import ( + from modules.datamodels.datamodelPortTypes import ( PORT_TYPE_CATALOG, PRIMITIVE_TYPES, ) @@ -204,8 +205,7 @@ def _createDispatchHandler(actionExecutor, methodName: str, actionName: str, ser if "mandateId" not in args and context.get("mandateId"): args["mandateId"] = context["mandateId"] if "parentOperationId" not in args: - import time as _time - toolOpId = f"agentTool_{methodName}_{actionName}_{int(_time.time())}" + toolOpId = f"agentTool_{methodName}_{actionName}_{int(time.time())}" chatSvc = getattr(services, "chat", None) if services else None if chatSvc: try: diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_connectionTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_connectionTools.py index 06a76d28..4bb97de9 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_connectionTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_connectionTools.py @@ -8,6 +8,7 @@ from typing import Any, Dict, List, Optional from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResult from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistry +import base64 from modules.serviceCenter.services.serviceAgent.coreTools._helpers import ( _buildResolverDbFromServices, _getOrCreateTempFolder, @@ -19,7 +20,7 @@ from modules.serviceCenter.services.serviceAgent.coreTools._helpers import ( logger = logging.getLogger(__name__) -def _registerConnectionTools(registry: ToolRegistry, services): +def registerConnectionTools(registry: ToolRegistry, services): """Auto-extracted from registerCoreTools.""" # ---- Connection tools (external data sources) ---- @@ -72,7 +73,6 @@ def _registerConnectionTools(registry: ToolRegistry, services): return ToolResult(toolCallId="", toolName="uploadToExternal", success=False, error=str(e)) async def _sendMail(args: Dict[str, Any], context: Dict[str, Any]): - import base64 as _b64 connectionId = args.get("connectionId", "") to = args.get("to", []) @@ -98,7 +98,7 @@ def _registerConnectionTools(registry: ToolRegistry, services): return ToolResult(toolCallId="", toolName="sendMail", success=False, error=f"Attachment file has no data: {fid}") graphAttachments.append({ "name": fileRow.fileName, - "contentBytes": _b64.b64encode(rawBytes).decode("ascii"), + "contentBytes": base64.b64encode(rawBytes).decode("ascii"), "contentType": getattr(fileRow, "mimeType", "application/octet-stream"), }) diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_crossWorkflowTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_crossWorkflowTools.py index 7307e019..055a4055 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_crossWorkflowTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_crossWorkflowTools.py @@ -8,6 +8,7 @@ from typing import Any, Dict, List, Optional from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResult from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistry +import json from modules.serviceCenter.services.serviceAgent.coreTools._helpers import ( _getOrCreateTempFolder, _looksLikeBinary, @@ -18,13 +19,12 @@ from modules.serviceCenter.services.serviceAgent.coreTools._helpers import ( logger = logging.getLogger(__name__) -def _registerCrossWorkflowTools(registry: ToolRegistry, services): +def registerCrossWorkflowTools(registry: ToolRegistry, services): """Auto-extracted from registerCoreTools.""" # ---- Cross-workflow tools ---- async def _listWorkflowHistory(args: Dict[str, Any], context: Dict[str, Any]) -> ToolResult: """List all chat workflows in this workspace with metadata.""" - import json as _json try: chatService = services.chat chatInterface = chatService.interfaceDbChat @@ -64,7 +64,7 @@ def _registerCrossWorkflowTools(registry: ToolRegistry, services): return ToolResult( toolCallId="", toolName="listWorkflowHistory", - success=True, data=_json.dumps(items, ensure_ascii=False), + success=True, data=json.dumps(items, ensure_ascii=False), ) except Exception as e: return ToolResult( @@ -90,7 +90,6 @@ def _registerCrossWorkflowTools(registry: ToolRegistry, services): async def _readWorkflowMessages(args: Dict[str, Any], context: Dict[str, Any]) -> ToolResult: """Read messages from a specific workflow.""" - import json as _json targetWorkflowId = args.get("workflowId", "") limit = int(args.get("limit", 20)) offset = int(args.get("offset", 0)) @@ -128,7 +127,7 @@ def _registerCrossWorkflowTools(registry: ToolRegistry, services): return ToolResult( toolCallId="", toolName="readWorkflowMessages", success=True, - data=header + "\n" + _json.dumps(items, ensure_ascii=False), + data=header + "\n" + json.dumps(items, ensure_ascii=False), ) except Exception as e: return ToolResult( diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py index a853301f..291f33dc 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py @@ -8,6 +8,7 @@ from typing import Any, Dict, List, Optional from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResult from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistry +from datetime import datetime, timezone from modules.serviceCenter.services.serviceAgent.coreTools._helpers import ( _attachFileAsChatDocument, _buildResolverDbFromServices, @@ -79,7 +80,6 @@ def _formatTaskLine(entry) -> str: dueMs = task.get("due_date") if dueMs: try: - from datetime import datetime, timezone due = datetime.fromtimestamp(int(dueMs) / 1000, tz=timezone.utc).strftime("%Y-%m-%d") parts.append(f"due: {due}") except (TypeError, ValueError, OverflowError): @@ -105,7 +105,7 @@ def _buildCountLine(entries, limit) -> str: return line -def _registerDataSourceTools(registry: ToolRegistry, services): +def registerDataSourceTools(registry: ToolRegistry, services): """Auto-extracted from registerCoreTools.""" def _buildResolverDb(): @@ -314,7 +314,7 @@ def _registerDataSourceTools(registry: ToolRegistry, services): error="Provide either dataSourceId OR connectionId+service") try: from modules.connectors.connectorResolver import ConnectorResolver - from modules.connectors.connectorProviderBase import DownloadResult as _DR + from modules.connectors.connectorProviderBase import DownloadResult _sourceNeutralize = False if dsId: connectionId, service, basePath, _sourceNeutralize = await _resolveDataSource(dsId) @@ -328,7 +328,7 @@ def _registerDataSourceTools(registry: ToolRegistry, services): adapter = await resolver.resolveService(connectionId, service) result = await adapter.download(fullPath) - if isinstance(result, _DR): + if isinstance(result, DownloadResult): fileBytes = result.data resolvedName = result.fileName or fileName if resolvedName != fileName: diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_documentTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_documentTools.py index 62413103..a79f5995 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_documentTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_documentTools.py @@ -9,6 +9,7 @@ from typing import Any, Dict, List, Optional from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResult from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistry +import base64 from modules.serviceCenter.services.serviceAgent.coreTools._helpers import ( _getOrCreateTempFolder, _MAX_TOOL_RESULT_CHARS, @@ -87,7 +88,7 @@ def _filterUdmByTypeImpl(udm: Dict[str, Any], content_type: str) -> Dict[str, An return {"nodes": hits, "count": len(hits), "contentType": content_type} -def _registerDocumentTools(registry: ToolRegistry, services): +def registerDocumentTools(registry: ToolRegistry, services): """Auto-extracted from registerCoreTools.""" # ---- Document tools (Smart Documents / Container Handling) ---- @@ -371,7 +372,6 @@ def _registerDocumentTools(registry: ToolRegistry, services): return ToolResult(toolCallId="", toolName="describeImage", success=False, error="fileId is required") try: - import base64 as _b64 imageData = None mimeType = "image/png" @@ -411,7 +411,7 @@ def _registerDocumentTools(registry: ToolRegistry, services): rawContent = chatService.getFileContent(fileId) if not fileContent else fileContent rawData = rawContent.get("data", "") if rawContent else "" if isinstance(rawData, str) and len(rawData) > 100: - pdfBytes = _b64.b64decode(rawData) + pdfBytes = base64.b64decode(rawData) elif isinstance(rawData, bytes): pdfBytes = rawData else: @@ -422,7 +422,7 @@ def _registerDocumentTools(registry: ToolRegistry, services): if 0 <= targetPage < len(doc): page = doc[targetPage] pix = page.get_pixmap(dpi=200) - imageData = _b64.b64encode(pix.tobytes("png")).decode("ascii") + imageData = base64.b64encode(pix.tobytes("png")).decode("ascii") mimeType = "image/png" logger.info("describeImage: rendered PDF page %d as image (%dx%d)", targetPage, pix.width, pix.height) doc.close() @@ -439,7 +439,7 @@ def _registerDocumentTools(registry: ToolRegistry, services): f"This file likely contains text, not images. Use readFile(fileId=\"{fileId}\") to access its text content.") try: - rawHead = _b64.b64decode(imageData[:32]) + rawHead = base64.b64decode(imageData[:32]) if rawHead[:3] == b"\xff\xd8\xff": mimeType = "image/jpeg" elif rawHead[:8] == b"\x89PNG\r\n\x1a\n": @@ -455,9 +455,9 @@ def _registerDocumentTools(registry: ToolRegistry, services): _opType = OTE.IMAGE_ANALYSE try: - from modules.datamodels.datamodelFiles import FileItem as _FileItemModel - from modules.interfaces.interfaceDbManagement import ComponentObjects as _CO - _fRow = _CO().db._loadRecord(_FileItemModel, fileId) + from modules.datamodels.datamodelFiles import FileItem + from modules.interfaces.interfaceDbManagement import ComponentObjects + _fRow = ComponentObjects().db._loadRecord(FileItem, fileId) if _fRow: _fGet = (lambda k, d=None: _fRow.get(k, d)) if isinstance(_fRow, dict) else (lambda k, d=None: getattr(_fRow, k, d)) if bool(_fGet("neutralize", False)): diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_emailTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_emailTools.py index a49403cd..d36a2727 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_emailTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_emailTools.py @@ -59,7 +59,7 @@ def _extractMessageId(args: Dict[str, Any]) -> str: return "" -def _registerEmailTools(registry: ToolRegistry, services): +def registerEmailTools(registry: ToolRegistry, services): """Register Outlook reply/forward/move/delete/flag tools on ``registry``.""" # ------------------------------------------------------------------ diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py index 12732a4b..e6efad99 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py @@ -61,7 +61,7 @@ def clearFeatureQueryCache(featureInstanceId: Optional[str] = None) -> int: return len(keys) -def _registerFeatureSubAgentTools(registry: ToolRegistry, services): +def registerFeatureSubAgentTools(registry: ToolRegistry, services): """Auto-extracted from registerCoreTools.""" # ---- Feature Data Sub-Agent tool ---- @@ -76,7 +76,7 @@ def _registerFeatureSubAgentTools(registry: ToolRegistry, services): ) try: from modules.serviceCenter.services.serviceAgent.featureDataAgent import runFeatureDataAgent - from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + from modules.datamodels.datamodelFeatures import FeatureDataSource from modules.interfaces.interfaceDbApp import getRootInterface rootIf = getRootInterface() diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_mediaTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_mediaTools.py index c7e292e2..380c9950 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_mediaTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_mediaTools.py @@ -8,6 +8,9 @@ from typing import Any, Dict, List, Optional from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResult from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistry +import base64 +import io +import re from modules.serviceCenter.services.serviceAgent.coreTools._helpers import ( _attachFileAsChatDocument, _formatToolFileResult, @@ -20,7 +23,7 @@ from modules.serviceCenter.services.serviceAgent.coreTools._helpers import ( logger = logging.getLogger(__name__) -def _registerMediaTools(registry: ToolRegistry, services): +def registerMediaTools(registry: ToolRegistry, services): """Auto-extracted from registerCoreTools.""" # ---- Document rendering tool ---- @@ -33,7 +36,6 @@ def _registerMediaTools(registry: ToolRegistry, services): async def _renderDocument(args: Dict[str, Any], context: Dict[str, Any]): """Render agent-produced markdown content into any document format via the RendererRegistry.""" - import re as _re sourceFileId = (args.get("sourceFileId") or "").strip() content = args.get("content", "") if not isinstance(content, str): @@ -130,8 +132,7 @@ def _registerMediaTools(registry: ToolRegistry, services): chunks = knowledgeService._knowledgeDb.getContentChunks(fileId) imageChunks = [c for c in (chunks or []) if c.get("contentType") == "image"] if imageChunks and imageChunks[0].get("data"): - import base64 as _b64 - return _b64.b64decode(imageChunks[0]["data"]) + return base64.b64decode(imageChunks[0]["data"]) except Exception as e: logger.warning(f"renderDocument: lazy knowledge image fetch failed for {fileId}: {e}") try: @@ -158,8 +159,7 @@ def _registerMediaTools(registry: ToolRegistry, services): try: rawBytes = services.chat.getFileData(fileRef) if rawBytes: - import base64 as _b64 - targetObj["base64Data"] = _b64.b64encode(rawBytes).decode("ascii") + targetObj["base64Data"] = base64.b64encode(rawBytes).decode("ascii") targetObj["mimeType"] = "image/png" resolvedImages += 1 except Exception as e: @@ -234,7 +234,7 @@ def _registerMediaTools(registry: ToolRegistry, services): sideEvents = [] chatService = services.chat - sanitizedTitle = _re.sub(r'[^\w._-]', '_', title, flags=_re.UNICODE).strip('_') or "document" + sanitizedTitle = re.sub(r'[^\w._-]', '_', title, flags=re.UNICODE).strip('_') or "document" for doc in documents: docData = doc.documentData if hasattr(doc, "documentData") else b"" @@ -337,24 +337,22 @@ def _registerMediaTools(registry: ToolRegistry, services): # ── textToSpeech tool ────────────────────────────────────────────── def _stripMarkdownForTts(text: str) -> str: """Strip markdown formatting so TTS reads clean speech text.""" - import re as _re t = text - t = _re.sub(r'\*\*(.+?)\*\*', r'\1', t) - t = _re.sub(r'\*(.+?)\*', r'\1', t) - t = _re.sub(r'__(.+?)__', r'\1', t) - t = _re.sub(r'_(.+?)_', r'\1', t) - t = _re.sub(r'`[^`]+`', lambda m: m.group(0)[1:-1], t) - t = _re.sub(r'^#{1,6}\s*', '', t, flags=_re.MULTILINE) - t = _re.sub(r'^\s*[-*+]\s+', '', t, flags=_re.MULTILINE) - t = _re.sub(r'^\s*\d+\.\s+', '', t, flags=_re.MULTILINE) - t = _re.sub(r'\[(.+?)\]\(.+?\)', r'\1', t) - t = _re.sub(r'!\[.*?\]\(.*?\)', '', t) - t = _re.sub(r'\n{3,}', '\n\n', t) + t = re.sub(r'\*\*(.+?)\*\*', r'\1', t) + t = re.sub(r'\*(.+?)\*', r'\1', t) + t = re.sub(r'__(.+?)__', r'\1', t) + t = re.sub(r'_(.+?)_', r'\1', t) + t = re.sub(r'`[^`]+`', lambda m: m.group(0)[1:-1], t) + t = re.sub(r'^#{1,6}\s*', '', t, flags=re.MULTILINE) + t = re.sub(r'^\s*[-*+]\s+', '', t, flags=re.MULTILINE) + t = re.sub(r'^\s*\d+\.\s+', '', t, flags=re.MULTILINE) + t = re.sub(r'\[(.+?)\]\(.+?\)', r'\1', t) + t = re.sub(r'!\[.*?\]\(.*?\)', '', t) + t = re.sub(r'\n{3,}', '\n\n', t) return t.strip() async def _textToSpeech(args: Dict[str, Any], context: Dict[str, Any]): """Convert text to speech using Google Cloud TTS, deliver audio via SSE.""" - import base64 as _b64 text = args.get("text", "") language = args.get("language", "auto") voiceName = args.get("voiceName") @@ -455,7 +453,7 @@ def _registerMediaTools(registry: ToolRegistry, services): return ToolResult(toolCallId="", toolName="textToSpeech", success=False, error="TTS returned no audio") if isinstance(audioContent, bytes): - audioB64 = _b64.b64encode(audioContent).decode("ascii") + audioB64 = base64.b64encode(audioContent).decode("ascii") elif isinstance(audioContent, str): audioB64 = audioContent else: @@ -510,7 +508,6 @@ def _registerMediaTools(registry: ToolRegistry, services): async def _generateImage(args: Dict[str, Any], context: Dict[str, Any]): """Generate an image from a text prompt using AI (DALL-E).""" - import re as _re prompt = (args.get("prompt") or "").strip() style = (args.get("style") or "").strip() or None @@ -537,7 +534,7 @@ def _registerMediaTools(registry: ToolRegistry, services): sideEvents = [] savedFiles = [] chatService = services.chat - sanitizedTitle = _re.sub(r'[^\w._-]', '_', title, flags=_re.UNICODE).strip('_') or "generated_image" + sanitizedTitle = re.sub(r'[^\w._-]', '_', title, flags=re.UNICODE).strip('_') or "generated_image" for doc in aiResponse.documents: docData = doc.documentData if hasattr(doc, "documentData") else b"" @@ -615,7 +612,6 @@ def _registerMediaTools(registry: ToolRegistry, services): async def _createChart(args: Dict[str, Any], context: Dict[str, Any]): """Create a data chart as PNG image using matplotlib.""" - import re as _re chartType = (args.get("chartType") or "bar").strip().lower() title = (args.get("title") or "Chart").strip() @@ -633,10 +629,8 @@ def _registerMediaTools(registry: ToolRegistry, services): try: import matplotlib matplotlib.use("Agg") - import logging as _mpllog - _mpllog.getLogger("matplotlib").setLevel(_mpllog.WARNING) + logging.getLogger("matplotlib").setLevel(logging.WARNING) import matplotlib.pyplot as plt - import io _DEFAULT_COLORS = [ "#4285F4", "#EA4335", "#FBBC04", "#34A853", "#FF6D01", @@ -712,7 +706,7 @@ def _registerMediaTools(registry: ToolRegistry, services): pngData = buf.getvalue() chatService = services.chat - sanitizedTitle = _re.sub(r'[^\w._-]', '_', title, flags=_re.UNICODE).strip('_') or "chart" + sanitizedTitle = re.sub(r'[^\w._-]', '_', title, flags=re.UNICODE).strip('_') or "chart" fileName = f"{sanitizedTitle}.png" if hasattr(chatService.interfaceDbComponent, "saveGeneratedFile"): @@ -883,8 +877,6 @@ def _registerMediaTools(registry: ToolRegistry, services): confirmation -- not the revealed cleartext. Resolution uses ONLY the private local placeholder mapping (no external LLM). """ - import base64 as _b64 - import re as _re text = args.get("text", "") fileId = (args.get("fileId") or "").strip() fileName = (args.get("fileName") or "").strip() @@ -927,13 +919,13 @@ def _registerMediaTools(registry: ToolRegistry, services): fileName = (info.get("fileName") if info else None) or f"{fileId}.txt" # Resolve placeholders locally (private mapping, no LLM). Count for the audit message. - placeholderCount = len(_re.findall(r'\[[a-z]+\.[a-f0-9-]{36}\]', text)) + placeholderCount = len(re.findall(r'\[[a-z]+\.[a-f0-9-]{36}\]', text)) revealed = neutralizationService.resolveText(text) if not fileName: fileName = "revealed.txt" mimeType = "text/markdown" if fileName.lower().endswith((".md", ".markdown")) else "text/plain" - contentB64 = _b64.b64encode(revealed.encode("utf-8")).decode("ascii") + contentB64 = base64.b64encode(revealed.encode("utf-8")).decode("ascii") return ToolResult( toolCallId="", toolName="revealDocument", success=True, diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_workspaceTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_workspaceTools.py index 8aa83732..9b4d2818 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_workspaceTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_workspaceTools.py @@ -8,6 +8,7 @@ from typing import Any, Dict, List, Optional from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResult from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistry +import re from modules.serviceCenter.services.serviceAgent.coreTools._helpers import ( _attachFileAsChatDocument, _formatToolFileResult, @@ -34,11 +35,10 @@ def _isStaleExtractionResult(text: str) -> bool: return any(p in textLower for p in _STALE_EXTRACTION_PATTERNS) -import uuid as _uuid +import uuid -def _registerWorkspaceTools(registry: ToolRegistry, services): +def registerWorkspaceTools(registry: ToolRegistry, services): """Auto-extracted from registerCoreTools.""" - import uuid as _uuid # ---- Read-only tools ---- @@ -152,9 +152,9 @@ def _registerWorkspaceTools(registry: ToolRegistry, services): if text.strip(): _fileNeedNeutralize = False try: - from modules.datamodels.datamodelFiles import FileItem as _FI - from modules.interfaces.interfaceDbManagement import ComponentObjects as _CO - _fRec = _CO().db._loadRecord(_FI, fileId) + from modules.datamodels.datamodelFiles import FileItem + from modules.interfaces.interfaceDbManagement import ComponentObjects + _fRec = ComponentObjects().db._loadRecord(FileItem, fileId) if _fRec: _fG = (lambda k, d=None: _fRec.get(k, d)) if isinstance(_fRec, dict) else (lambda k, d=None: getattr(_fRec, k, d)) _fileNeedNeutralize = bool(_fG("neutralize", False)) @@ -217,7 +217,6 @@ def _registerWorkspaceTools(registry: ToolRegistry, services): return ToolResult(toolCallId="", toolName="listFiles", success=False, error=str(e)) async def _searchInFileContent(args: Dict[str, Any], context: Dict[str, Any]): - import re as _re fileId = args.get("fileId", "") query = args.get("query", "") contextLines = args.get("contextLines", 2) @@ -234,7 +233,7 @@ def _registerWorkspaceTools(registry: ToolRegistry, services): content = rawBytes.decode("latin-1", errors="replace") lines = content.split("\n") - pattern = _re.compile(_re.escape(query), _re.IGNORECASE) + pattern = re.compile(re.escape(query), re.IGNORECASE) matches = [] for i, line in enumerate(lines): if pattern.search(line): @@ -708,7 +707,7 @@ def _registerWorkspaceTools(registry: ToolRegistry, services): newContent = oldContent.replace(oldText, newText) if replaceAll else oldContent.replace(oldText, newText, 1) - editId = str(_uuid.uuid4()) + editId = str(uuid.uuid4()) label = f"all {count} occurrences" if replaceAll else "1 occurrence" return ToolResult( toolCallId="", toolName="replaceInFile", success=True, diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/registerCore.py b/modules/serviceCenter/services/serviceAgent/coreTools/registerCore.py index f740f276..d2a76c9f 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/registerCore.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/registerCore.py @@ -4,14 +4,14 @@ from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistry -from modules.serviceCenter.services.serviceAgent.coreTools._workspaceTools import _registerWorkspaceTools -from modules.serviceCenter.services.serviceAgent.coreTools._connectionTools import _registerConnectionTools -from modules.serviceCenter.services.serviceAgent.coreTools._dataSourceTools import _registerDataSourceTools -from modules.serviceCenter.services.serviceAgent.coreTools._documentTools import _registerDocumentTools -from modules.serviceCenter.services.serviceAgent.coreTools._emailTools import _registerEmailTools -from modules.serviceCenter.services.serviceAgent.coreTools._mediaTools import _registerMediaTools -from modules.serviceCenter.services.serviceAgent.coreTools._featureSubAgentTools import _registerFeatureSubAgentTools -from modules.serviceCenter.services.serviceAgent.coreTools._crossWorkflowTools import _registerCrossWorkflowTools +from modules.serviceCenter.services.serviceAgent.coreTools._workspaceTools import registerWorkspaceTools +from modules.serviceCenter.services.serviceAgent.coreTools._connectionTools import registerConnectionTools +from modules.serviceCenter.services.serviceAgent.coreTools._dataSourceTools import registerDataSourceTools +from modules.serviceCenter.services.serviceAgent.coreTools._documentTools import registerDocumentTools +from modules.serviceCenter.services.serviceAgent.coreTools._emailTools import registerEmailTools +from modules.serviceCenter.services.serviceAgent.coreTools._mediaTools import registerMediaTools +from modules.serviceCenter.services.serviceAgent.coreTools._featureSubAgentTools import registerFeatureSubAgentTools +from modules.serviceCenter.services.serviceAgent.coreTools._crossWorkflowTools import registerCrossWorkflowTools def registerCoreTools(registry: ToolRegistry, services): @@ -19,11 +19,11 @@ def registerCoreTools(registry: ToolRegistry, services): Delegates to domain-specific modules under coreTools/. """ - _registerWorkspaceTools(registry, services) - _registerConnectionTools(registry, services) - _registerDataSourceTools(registry, services) - _registerDocumentTools(registry, services) - _registerEmailTools(registry, services) - _registerMediaTools(registry, services) - _registerFeatureSubAgentTools(registry, services) - _registerCrossWorkflowTools(registry, services) + registerWorkspaceTools(registry, services) + registerConnectionTools(registry, services) + registerDataSourceTools(registry, services) + registerDocumentTools(registry, services) + registerEmailTools(registry, services) + registerMediaTools(registry, services) + registerFeatureSubAgentTools(registry, services) + registerCrossWorkflowTools(registry, services) diff --git a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py index 83f9de41..6620f219 100644 --- a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py +++ b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py @@ -15,6 +15,8 @@ from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistr from modules.serviceCenter.services.serviceAgent.agentLoop import runAgentLoop from modules.serviceCenter.services.serviceAgent.actionToolAdapter import ActionToolAdapter from modules.serviceCenter.services.serviceAgent.coreTools import registerCoreTools +import json +import time from modules.serviceCenter.services.serviceBilling.mainServiceBilling import ( getService as getBillingService, InsufficientBalanceException, @@ -530,7 +532,6 @@ class AgentService: userId = self.services.user.id if self.services.user else "" featureInstanceId = self.services.featureInstanceId or "" - import json traceValue = json.dumps(summaryData, default=str) await knowledgeService.storeEntity( @@ -683,8 +684,7 @@ def _buildWorkflowHintItems( if not others: return [] - import time as _time - now = _time.time() + now = time.time() others.sort(key=lambda w: w.get("sysCreatedAt") or w.get("startedAt") or 0, reverse=True) others = others[:10] diff --git a/modules/serviceCenter/services/serviceAgent/sandboxExecutor.py b/modules/serviceCenter/services/serviceAgent/sandboxExecutor.py index 4c747e64..395c674e 100644 --- a/modules/serviceCenter/services/serviceAgent/sandboxExecutor.py +++ b/modules/serviceCenter/services/serviceAgent/sandboxExecutor.py @@ -7,6 +7,8 @@ import sys import io import traceback from typing import Dict, Any +import asyncio +import zipfile logger = logging.getLogger(__name__) @@ -135,30 +137,28 @@ class SafeZipFile: Does not expose extract/write -- only namelist, infolist, and in-memory read.""" def __init__(self, data: bytes): - import zipfile as _zf - self._zf = _zf.ZipFile(io.BytesIO(data), 'r') + self._zf = zipfile.ZipFile(io.BytesIO(data), 'r') def namelist(self): - return self._zf.namelist() + return self.zipfile.namelist() def infolist(self): return [{"filename": i.filename, "file_size": i.file_size, "compress_size": i.compress_size, "date_time": i.date_time} - for i in self._zf.infolist()] + for i in self.zipfile.infolist()] def read(self, name: str) -> bytes: - return self._zf.read(name) + return self.zipfile.read(name) def __enter__(self): return self def __exit__(self, *args): - self._zf.close() + self.zipfile.close() async def executePython(code: str, *, services=None) -> Dict[str, Any]: """Execute Python code in a restricted sandbox. Returns {success, output, error}.""" - import asyncio def _run(): restrictedGlobals = _buildRestrictedGlobals() diff --git a/modules/serviceCenter/services/serviceAgent/workflowTools.py b/modules/serviceCenter/services/serviceAgent/workflowTools.py index 7f01ee79..82eda22d 100644 --- a/modules/serviceCenter/services/serviceAgent/workflowTools.py +++ b/modules/serviceCenter/services/serviceAgent/workflowTools.py @@ -16,12 +16,12 @@ Conventions enforced here (matches coreTools / actionToolAdapter): omits them — the editor agent always runs in exactly one workflow. """ -import json import logging import uuid from typing import Dict, Any, List, Tuple from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResult +import json logger = logging.getLogger(__name__) @@ -712,7 +712,7 @@ async def _readWorkflowMessages(params: Dict[str, Any], context: Any) -> ToolRes if not workflowId or not instanceId: return _err(name, "workflowId and instanceId required") iface = _getInterface(context, instanceId) - from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoRun + from modules.datamodels.datamodelWorkflowAutomation import AutoRun runs = iface.db.getRecordset(AutoRun, recordFilter={"workflowId": workflowId}) or [] runSummaries = [] for r in sorted(runs, key=lambda x: x.get("startedAt") or 0, reverse=True)[:10]: @@ -912,7 +912,6 @@ def _loadEnvelopeFromUdb(fileId: str, context: Any): Returns ``None`` if the file cannot be read or is not valid JSON — the caller turns that into a tool error message. """ - import json try: import modules.interfaces.interfaceDbManagement as interfaceDbManagement user = _resolveUser(context) diff --git a/modules/serviceCenter/services/serviceAi/mainServiceAi.py b/modules/serviceCenter/services/serviceAi/mainServiceAi.py index afbde59a..0fd10678 100644 --- a/modules/serviceCenter/services/serviceAi/mainServiceAi.py +++ b/modules/serviceCenter/services/serviceAi/mainServiceAi.py @@ -1158,7 +1158,7 @@ detectedIntent-Werte: def _writeAuditEntry(self, request, response, wasNeutralized: bool = False): """Write a rich AI audit entry with input, output, and neutralization metadata.""" try: - from modules.shared.aiAuditLogger import aiAuditLogger + from modules.dbHelpers.aiAuditLogger import aiAuditLogger user = self.services.user mandateId = self.services.mandateId @@ -1909,8 +1909,6 @@ Respond with ONLY a JSON object in this exact format: - DYNAMIC mode: Intent analysis (clarifyDocumentIntents) runs first; extraction and processing use the intents and AI-derived extractionPrompt. """ - import time - # Create operation ID workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}" extractOperationId = f"data_extract_{workflowId}_{int(time.time())}" diff --git a/modules/serviceCenter/services/serviceAi/subAiCallLooping.py b/modules/serviceCenter/services/serviceAi/subAiCallLooping.py index 3ef22535..ea218e11 100644 --- a/modules/serviceCenter/services/serviceAi/subAiCallLooping.py +++ b/modules/serviceCenter/services/serviceAi/subAiCallLooping.py @@ -50,13 +50,13 @@ import logging from typing import Dict, Any, List, Optional, Callable from modules.datamodels.datamodelAi import ( - AiCallRequest, AiCallOptions + AiCallRequest, AiCallOptions, ContinuationContext ) from modules.datamodels.datamodelExtraction import ContentPart from .subJsonResponseHandling import JsonResponseHandler from .subLoopingUseCases import LoopingUseCaseRegistry -from modules.workflows.processing.shared.stateTools import checkWorkflowStopped -from modules.shared.jsonContinuation import getContexts +from modules.shared.workflowState import checkWorkflowStopped +from modules.datamodels.jsonContinuation import getContexts from modules.shared.jsonUtils import buildContinuationContext, tryParseJson from modules.shared.jsonUtils import closeJsonStructures from modules.shared.jsonUtils import stripCodeFences, normalizeJsonText @@ -186,7 +186,9 @@ class AiCallLooper: # This is a continuation - build continuation context with raw JSON and rebuild prompt continuationContext = buildContinuationContext( - allSections, lastRawResponse, useCaseId, templateStructure + allSections, lastRawResponse, useCaseId, templateStructure, + continuationContextClass=ContinuationContext, + getContextsFn=getContexts ) if not lastRawResponse: logger.warning(f"Iteration {iteration}: No previous response available for continuation!") diff --git a/modules/serviceCenter/services/serviceAi/subContentExtraction.py b/modules/serviceCenter/services/serviceAi/subContentExtraction.py index e050bb67..6e5ddd42 100644 --- a/modules/serviceCenter/services/serviceAi/subContentExtraction.py +++ b/modules/serviceCenter/services/serviceAi/subContentExtraction.py @@ -16,7 +16,7 @@ from typing import Dict, Any, List, Optional from modules.datamodels.datamodelChat import ChatDocument from modules.datamodels.datamodelExtraction import ContentPart, DocumentIntent, ExtractionOptions, MergeStrategy -from modules.workflows.processing.shared.stateTools import checkWorkflowStopped +from modules.shared.workflowState import checkWorkflowStopped logger = logging.getLogger(__name__) diff --git a/modules/serviceCenter/services/serviceAi/subDocumentIntents.py b/modules/serviceCenter/services/serviceAi/subDocumentIntents.py index 7a462177..d4d7fae7 100644 --- a/modules/serviceCenter/services/serviceAi/subDocumentIntents.py +++ b/modules/serviceCenter/services/serviceAi/subDocumentIntents.py @@ -14,7 +14,7 @@ from typing import Dict, Any, List, Optional from modules.datamodels.datamodelChat import ChatDocument from modules.datamodels.datamodelExtraction import DocumentIntent -from modules.workflows.processing.shared.stateTools import checkWorkflowStopped +from modules.shared.workflowState import checkWorkflowStopped logger = logging.getLogger(__name__) diff --git a/modules/serviceCenter/services/serviceAi/subLoopingUseCases.py b/modules/serviceCenter/services/serviceAi/subLoopingUseCases.py index f6a7c620..fa52fdac 100644 --- a/modules/serviceCenter/services/serviceAi/subLoopingUseCases.py +++ b/modules/serviceCenter/services/serviceAi/subLoopingUseCases.py @@ -9,6 +9,7 @@ Provides parametrized looping infrastructure supporting different JSON formats a import logging from dataclasses import dataclass, field from typing import Dict, Any, List, Optional, Callable +import json logger = logging.getLogger(__name__) @@ -27,7 +28,6 @@ def _handleSectionContentFinalResult(result: str, parsedJsonForUseCase: Any, ext def _handleChapterStructureFinalResult(result: str, parsedJsonForUseCase: Any, extractedJsonForUseCase: str, debugPrefix: str, services: Any) -> str: """Handle final result for chapter_structure: format JSON and write debug file.""" - import json final_json = json.dumps(parsedJsonForUseCase, indent=2, ensure_ascii=False) if parsedJsonForUseCase else (extractedJsonForUseCase or result) # Write final result for chapter structure if services and hasattr(services, 'utils') and hasattr(services.utils, 'writeDebugFile'): @@ -38,7 +38,6 @@ def _handleChapterStructureFinalResult(result: str, parsedJsonForUseCase: Any, e def _handleCodeStructureFinalResult(result: str, parsedJsonForUseCase: Any, extractedJsonForUseCase: str, debugPrefix: str, services: Any) -> str: """Handle final result for code_structure: format JSON and write debug file.""" - import json final_json = json.dumps(parsedJsonForUseCase, indent=2, ensure_ascii=False) if parsedJsonForUseCase else (extractedJsonForUseCase or result) # Write final result for code structure if services and hasattr(services, 'utils') and hasattr(services.utils, 'writeDebugFile'): @@ -49,7 +48,6 @@ def _handleCodeStructureFinalResult(result: str, parsedJsonForUseCase: Any, extr def _handleCodeContentFinalResult(result: str, parsedJsonForUseCase: Any, extractedJsonForUseCase: str, debugPrefix: str, services: Any) -> str: """Handle final result for code_content: format JSON.""" - import json final_json = json.dumps(parsedJsonForUseCase, indent=2, ensure_ascii=False) if parsedJsonForUseCase else (extractedJsonForUseCase or result) return final_json @@ -63,7 +61,7 @@ def _lift_section_plain_text(d: Dict[str, Any]) -> Optional[str]: return None -def _normalizeSectionContentJson(parsed: Any, useCaseId: str) -> Any: +def normalizeSectionContentJson(parsed: Any, useCaseId: str) -> Any: """Normalize JSON structure for section_content use case.""" # For section_content, expect {"elements": [...]} structure if isinstance(parsed, list): @@ -235,7 +233,7 @@ class LoopingUseCaseRegistry: continuationContextBuilder=None, # Will use default continuation context resultBuilder=None, # Return JSON directly finalResultHandler=_handleSectionContentFinalResult, - jsonNormalizer=_normalizeSectionContentJson, + jsonNormalizer=normalizeSectionContentJson, supportsAccumulation=False, requiresExtraction=False )) diff --git a/modules/serviceCenter/services/serviceAi/subStructureFilling.py b/modules/serviceCenter/services/serviceAi/subStructureFilling.py index 2baf0a84..c2e580a4 100644 --- a/modules/serviceCenter/services/serviceAi/subStructureFilling.py +++ b/modules/serviceCenter/services/serviceAi/subStructureFilling.py @@ -16,7 +16,9 @@ from typing import Dict, Any, List, Optional, Tuple from modules.datamodels.datamodelExtraction import ContentPart from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum -from modules.workflows.processing.shared.stateTools import checkWorkflowStopped +from modules.shared.workflowState import checkWorkflowStopped +import base64 +import io class _AiResponseFallback: @@ -44,7 +46,7 @@ def _normalizeImageElement(element: Dict[str, Any]) -> None: def _elements_from_section_content_ai_json(parsed: Any) -> List[Any]: """Normalize section_content AI JSON (incl. models that return {\"text\": ...}) into elements.""" - from modules.serviceCenter.services.serviceAi.subLoopingUseCases import _normalizeSectionContentJson + from modules.serviceCenter.services.serviceAi.subLoopingUseCases import normalizeSectionContentJson if parsed is None: return [] @@ -65,7 +67,7 @@ def _elements_from_section_content_ai_json(parsed: Any) -> List[Any]: and isinstance(parsed["sections"][0], dict) ): parsed = parsed["sections"][0] - norm = _normalizeSectionContentJson(parsed, "section_content") + norm = normalizeSectionContentJson(parsed, "section_content") if isinstance(norm, dict): els = norm.get("elements") return list(els) if isinstance(els, list) else [] @@ -498,7 +500,6 @@ class StructureFiller: # Handle IMAGE_GENERATE differently - returns image data directly if contentType == "image" and operationType == OperationTypeEnum.IMAGE_GENERATE: - import base64 base64Data = "" # Convert image data to base64 string if needed @@ -2528,7 +2529,7 @@ Output requirements: unifiedContext = "" if lastRawJson: # Get contexts directly from jsonContinuation - from modules.shared.jsonContinuation import getContexts + from modules.datamodels.jsonContinuation import getContexts contexts = getContexts(lastRawJson) overlapContext = contexts.overlapContext unifiedContext = contexts.hierarchyContextForPrompt @@ -2715,7 +2716,6 @@ CRITICAL: def _normalizeTableContentString(self, text: str, sectionId: str, elemIndex: int) -> Dict[str, Any]: """Convert a string table content (CSV, markdown, pipe-delimited) into {headers, rows}.""" import csv - import io lines = [l for l in text.strip().splitlines() if l.strip()] if not lines: diff --git a/modules/serviceCenter/services/serviceAi/subStructureGeneration.py b/modules/serviceCenter/services/serviceAi/subStructureGeneration.py index 16cbb786..cee66f60 100644 --- a/modules/serviceCenter/services/serviceAi/subStructureGeneration.py +++ b/modules/serviceCenter/services/serviceAi/subStructureGeneration.py @@ -13,7 +13,7 @@ from typing import Dict, Any, List, Optional from modules.datamodels.datamodelExtraction import ContentPart from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum -from modules.workflows.processing.shared.stateTools import checkWorkflowStopped +from modules.shared.workflowState import checkWorkflowStopped from modules.shared.i18nRegistry import normalizePrimaryLanguageTag logger = logging.getLogger(__name__) @@ -127,7 +127,7 @@ class StructureGenerator: unifiedContext = "" if lastRawJson: # Get contexts directly from jsonContinuation - from modules.shared.jsonContinuation import getContexts + from modules.datamodels.jsonContinuation import getContexts contexts = getContexts(lastRawJson) overlapContext = contexts.overlapContext unifiedContext = contexts.hierarchyContextForPrompt diff --git a/modules/serviceCenter/services/serviceBackgroundJobs/mainBackgroundJobService.py b/modules/serviceCenter/services/serviceBackgroundJobs/mainBackgroundJobService.py index 90b69bce..6ac1cbee 100644 --- a/modules/serviceCenter/services/serviceBackgroundJobs/mainBackgroundJobService.py +++ b/modules/serviceCenter/services/serviceBackgroundJobs/mainBackgroundJobService.py @@ -36,7 +36,7 @@ from typing import Any, Awaitable, Callable, Dict, List, Optional from modules.connectors.connectorDbPostgre import DatabaseConnector, getCachedConnector from modules.shared.configuration import APP_CONFIG -from modules.shared.dbRegistry import registerDatabase +from modules.dbHelpers.dbRegistry import registerDatabase from modules.datamodels.datamodelBackgroundJob import ( BackgroundJob, BackgroundJobStatusEnum, diff --git a/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py b/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py index 9e891c6c..9076f9e0 100644 --- a/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py +++ b/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py @@ -55,7 +55,7 @@ def maybeEmailMandatePoolExhausted( return try: - from modules.shared.notifyMandateAdmins import notifyMandateAdmins + from modules.system.notifyMandateAdmins import notifyMandateAdmins sent = notifyMandateAdmins( mandateId, diff --git a/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py b/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py index 794f7dec..dd9fac2c 100644 --- a/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py +++ b/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py @@ -524,18 +524,7 @@ class ProviderNotAllowedException(Exception): super().__init__(self.message) -class BillingContextError(Exception): - """Raised when billing context is incomplete (missing mandateId, user, etc.). - - This is a FAIL-SAFE error: AI calls MUST NOT proceed without valid billing context. - Acts like a 0 CHF credit card pre-authorization check - validates that billing - CAN be recorded before any expensive AI operation starts. - """ - - def __init__(self, message: str = None): - self.message = message or "Billing context incomplete - AI call blocked" - super().__init__(self.message) - +from modules.shared.serviceExceptions import BillingContextError # Expose exception classes on BillingService so consumers can use service.InsufficientBalanceException # instead of importing from this module diff --git a/modules/serviceCenter/services/serviceChat/mainServiceChat.py b/modules/serviceCenter/services/serviceChat/mainServiceChat.py index 22eefeed..3e3d9f15 100644 --- a/modules/serviceCenter/services/serviceChat/mainServiceChat.py +++ b/modules/serviceCenter/services/serviceChat/mainServiceChat.py @@ -7,6 +7,7 @@ from modules.datamodels.datamodelUam import User, UserConnection from modules.datamodels.datamodelChat import ChatDocument, ChatMessage, ChatLog from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum from modules.shared.progressLogger import ProgressLogger +import json logger = logging.getLogger(__name__) @@ -694,7 +695,6 @@ class ChatService: int: Size in bytes """ try: - import json import sys if obj is None: diff --git a/modules/serviceCenter/services/serviceClickup/mainServiceClickup.py b/modules/serviceCenter/services/serviceClickup/mainServiceClickup.py index 5bcd1d52..df216810 100644 --- a/modules/serviceCenter/services/serviceClickup/mainServiceClickup.py +++ b/modules/serviceCenter/services/serviceClickup/mainServiceClickup.py @@ -1,14 +1,19 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. -"""ClickUp API service (OAuth or personal token via UserConnection).""" +"""ClickUp API service (OAuth or personal token via UserConnection). + +Extends the low-level ClickupApiClient from connectors with service-layer +concerns (token resolution via SecurityService, extended query params). +""" import json import logging -import asyncio from typing import Any, Callable, Dict, List, Optional, Union import aiohttp +from modules.connectors.connectorProviderClickup import ClickupApiClient, clickupAuthorizationHeader + logger = logging.getLogger(__name__) _CLICKUP_API_BASE = "https://api.clickup.com/api/v2" @@ -16,19 +21,16 @@ _CLICKUP_API_BASE = "https://api.clickup.com/api/v2" def clickup_authorization_header(token: str) -> str: """ClickUp: personal tokens are `pk_...` without Bearer; OAuth uses Bearer.""" - t = (token or "").strip() - if t.startswith("pk_"): - return t - return f"Bearer {t}" + return clickupAuthorizationHeader(token) -class ClickupService: - """ClickUp REST API v2 — teams, hierarchy, lists as tables (tasks + custom fields).""" +class ClickupService(ClickupApiClient): + """ClickUp service — adds token resolution and extended API methods on top of the API client.""" def __init__(self, context, get_service: Callable[[str], Any]): + super().__init__(accessToken="") self._context = context self._get_service = get_service - self.accessToken: Optional[str] = None def setAccessTokenFromConnection(self, userConnection) -> bool: """Load OAuth/personal token from SecurityService for this UserConnection.""" @@ -61,54 +63,6 @@ class ClickupService: """Set token directly (e.g. connector adapter).""" self.accessToken = token - async def _request( - self, - method: str, - path: str, - *, - params: Optional[Dict[str, Any]] = None, - json_body: Optional[Dict[str, Any]] = None, - data: Optional[aiohttp.FormData] = None, - ) -> Union[Dict[str, Any], List[Any], bytes, None]: - if not self.accessToken: - return {"error": "Access token is not set. Call setAccessTokenFromConnection first."} - url = f"{_CLICKUP_API_BASE}/{path.lstrip('/')}" - headers: Dict[str, str] = { - "Authorization": clickup_authorization_header(self.accessToken), - } - if json_body is not None: - headers["Content-Type"] = "application/json" - - timeout = aiohttp.ClientTimeout(total=60) - try: - async with aiohttp.ClientSession(timeout=timeout) as session: - kwargs: Dict[str, Any] = {"headers": headers, "params": params} - if json_body is not None: - kwargs["json"] = json_body - if data is not None: - kwargs["data"] = data - - async with session.request(method.upper(), url, **kwargs) as resp: - if resp.status == 204: - return {} - text = await resp.text() - if resp.status >= 400: - # 404 on GET is common (wrong id / preview) — avoid ERROR noise in logs - log = logger.warning if resp.status == 404 else logger.error - log(f"ClickUp API {method} {url} -> {resp.status}: {text[:500]}") - return {"error": f"HTTP {resp.status}", "body": text} - if not text: - return {} - try: - return json.loads(text) - except Exception: - return {"raw": text} - except asyncio.TimeoutError: - return {"error": f"ClickUp API timeout: {path}"} - except Exception as e: - logger.error(f"ClickUp API error: {e}") - return {"error": str(e)} - async def requestRaw( self, method: str, @@ -120,45 +74,26 @@ class ClickupService: """Escape hatch: call any v2 path under /api/v2 (path without leading /api/v2).""" return await self._request(method, path, params=params, json_body=json_body) - # --- Teams / user --- + # --- Extended API methods (beyond base ClickupApiClient) --- async def getAuthorizedUser(self) -> Dict[str, Any]: return await self._request("GET", "/user") - async def getAuthorizedTeams(self) -> Dict[str, Any]: - return await self._request("GET", "/team") - async def getTeam(self, team_id: str) -> Dict[str, Any]: return await self._request("GET", f"/team/{team_id}") - # --- Hierarchy --- - - async def getSpaces(self, team_id: str) -> Dict[str, Any]: - return await self._request("GET", f"/team/{team_id}/space") - async def getSpace(self, space_id: str) -> Dict[str, Any]: return await self._request("GET", f"/space/{space_id}") - async def getFolders(self, space_id: str) -> Dict[str, Any]: - return await self._request("GET", f"/space/{space_id}/folder") - async def getFolder(self, folder_id: str) -> Dict[str, Any]: return await self._request("GET", f"/folder/{folder_id}") - async def getListsInFolder(self, folder_id: str) -> Dict[str, Any]: - return await self._request("GET", f"/folder/{folder_id}/list") - - async def getFolderlessLists(self, space_id: str) -> Dict[str, Any]: - return await self._request("GET", f"/space/{space_id}/list") - async def getList(self, list_id: str) -> Dict[str, Any]: return await self._request("GET", f"/list/{list_id}") async def getListFields(self, list_id: str) -> Dict[str, Any]: return await self._request("GET", f"/list/{list_id}/field") - # --- Tasks (rows) --- - async def getTasksInList( self, list_id: str, @@ -186,8 +121,7 @@ class ClickupService: if dateUpdatedLt is not None: params["date_updated_lt"] = dateUpdatedLt if customFields: - import json as _json - params["custom_fields"] = _json.dumps(customFields) + params["custom_fields"] = json.dumps(customFields) return await self._request("GET", f"/list/{list_id}/task", params=params) async def getTask(self, task_id: str, *, include_subtasks: bool = True) -> Dict[str, Any]: @@ -202,38 +136,3 @@ class ClickupService: async def deleteTask(self, task_id: str) -> Dict[str, Any]: return await self._request("DELETE", f"/task/{task_id}") - - async def searchTeamTasks( - self, - team_id: str, - *, - query: str, - page: int = 0, - ) -> Dict[str, Any]: - """Search tasks in a workspace (team).""" - params = {"query": query, "page": page} - return await self._request("GET", f"/team/{team_id}/task", params=params) - - async def uploadTaskAttachment(self, task_id: str, file_bytes: bytes, file_name: str) -> Dict[str, Any]: - """Upload a file attachment to a task (multipart).""" - if not self.accessToken: - return {"error": "Access token is not set."} - url = f"{_CLICKUP_API_BASE}/task/{task_id}/attachment" - headers = {"Authorization": clickup_authorization_header(self.accessToken)} - data = aiohttp.FormData() - data.add_field( - "attachment", - file_bytes, - filename=file_name, - content_type="application/octet-stream", - ) - timeout = aiohttp.ClientTimeout(total=120) - try: - async with aiohttp.ClientSession(timeout=timeout) as session: - async with session.post(url, headers=headers, data=data) as resp: - text = await resp.text() - if resp.status >= 400: - return {"error": f"HTTP {resp.status}", "body": text} - return json.loads(text) if text else {} - except Exception as e: - return {"error": str(e)} diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorContainer.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorContainer.py index a7b06266..a69b2e35 100644 --- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorContainer.py +++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorContainer.py @@ -23,6 +23,7 @@ from ..subUtils import makeId from modules.datamodels.datamodelExtraction import ContentPart from modules.datamodels.datamodelContent import ContainerLimitError, ContentContextRef from ..subRegistry import Extractor +import base64 logger = logging.getLogger(__name__) @@ -225,7 +226,6 @@ def _addFilePart( except Exception as e: logger.warning(f"Type-extractor failed for {fileName} in container: {e}") - import base64 encodedData = base64.b64encode(data).decode("utf-8") if data else "" return [ContentPart( diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorEmail.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorEmail.py index 7f750835..b557172f 100644 --- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorEmail.py +++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorEmail.py @@ -19,6 +19,7 @@ import mimetypes from modules.datamodels.datamodelExtraction import ContentPart from ..subUtils import makeId from ..subRegistry import Extractor +import base64 logger = logging.getLogger(__name__) @@ -210,7 +211,6 @@ def _delegateAttachment(attachData: bytes, attachName: str, parentId: str, depth """ if depth >= _MAX_CASCADE_DEPTH: logger.warning(f"Cascade depth {depth} reached for {attachName}, skipping extraction") - import base64 encodedData = base64.b64encode(attachData).decode("utf-8") if attachData else "" return [ContentPart( id=makeId(), parentId=parentId, label=attachName, @@ -242,7 +242,6 @@ def _delegateAttachment(attachData: bytes, attachName: str, parentId: str, depth except Exception as e: logger.warning(f"Extractor failed for email attachment {attachName}: {e}") - import base64 encodedData = base64.b64encode(attachData).decode("utf-8") if attachData else "" return [ContentPart( id=makeId(), parentId=parentId, label=attachName, diff --git a/modules/serviceCenter/services/serviceExtraction/mainServiceExtraction.py b/modules/serviceCenter/services/serviceExtraction/mainServiceExtraction.py index 8747c552..a3fb0baf 100644 --- a/modules/serviceCenter/services/serviceExtraction/mainServiceExtraction.py +++ b/modules/serviceCenter/services/serviceExtraction/mainServiceExtraction.py @@ -1820,9 +1820,8 @@ class ExtractionService: def _sniffImageMime(data) -> Optional[str]: """Detect image format from magic bytes. Returns None if unrecognised.""" - import base64 as _b64 try: - raw = data if isinstance(data, bytes) else _b64.b64decode(data[:32]) + raw = data if isinstance(data, bytes) else base64.b64decode(data[:32]) if raw[:3] == b"\xff\xd8\xff": return "image/jpeg" if raw[:8] == b"\x89PNG\r\n\x1a\n": diff --git a/modules/serviceCenter/services/serviceExtraction/subRegistry.py b/modules/serviceCenter/services/serviceExtraction/subRegistry.py index 422b4b50..864afe65 100644 --- a/modules/serviceCenter/services/serviceExtraction/subRegistry.py +++ b/modules/serviceCenter/services/serviceExtraction/subRegistry.py @@ -4,6 +4,9 @@ from typing import Any, Dict, List, Optional, TYPE_CHECKING import logging from modules.datamodels.datamodelExtraction import ContentPart +import os +import traceback +from pathlib import Path if TYPE_CHECKING: from modules.datamodels.datamodelUdm import UdmDocument @@ -80,9 +83,7 @@ class ExtractorRegistry: def _auto_discover_extractors(self): """Auto-discover and register all extractors from the extractors directory.""" try: - import os import importlib - from pathlib import Path # Get the extractors directory current_dir = Path(__file__).parent @@ -132,7 +133,6 @@ class ExtractorRegistry: except Exception as e: logger.error(f"ExtractorRegistry: Failed to auto-discover extractors: {str(e)}") - import traceback traceback.print_exc() def _auto_register_extractor(self, extractor: Extractor): @@ -262,7 +262,6 @@ class ChunkerRegistry: self.register("videostream", TextChunker()) except Exception as e: logger.error(f"ChunkerRegistry: Failed to register chunkers: {str(e)}") - import traceback traceback.print_exc() def register(self, typeGroup: str, chunker: Chunker): diff --git a/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py b/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py index dbbe61c3..a7e9a36a 100644 --- a/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py +++ b/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py @@ -15,6 +15,8 @@ from .subDocumentUtility import ( convertDocumentDataToString ) from .styleDefaults import resolveStyle, deepMerge +import json +import re logger = logging.getLogger(__name__) @@ -105,7 +107,6 @@ class GenerationService: document_data_dict = document_data elif isinstance(document_data, str): # JSON-String: parsen und als dict speichern (z.B. von outlook.composeAndDraftEmailWithContext) - import json try: document_data_dict = json.loads(document_data) except json.JSONDecodeError: @@ -390,14 +391,13 @@ class GenerationService: """ try: from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum - import json as _json, re as _re metadata = extractedContent.get("metadata", {}) if isinstance(extractedContent, dict) else {} docTitle = metadata.get("title", "") if isinstance(metadata, dict) else "" docType = metadata.get("documentType", "") if isinstance(metadata, dict) else "" userHint = (userPrompt or "")[:300] - styleJson = _json.dumps(resolvedStyle, indent=2, default=str) + styleJson = json.dumps(resolvedStyle, indent=2, default=str) prompt = ( "You are a document styling expert. Given the document context below, " @@ -431,12 +431,12 @@ class GenerationService: if not raw: return resolvedStyle - jsonMatch = _re.search(r'```json\s*\n(.*?)\n```', raw, _re.DOTALL) + jsonMatch = re.search(r'```json\s*\n(.*?)\n```', raw, re.DOTALL) if jsonMatch: raw = jsonMatch.group(1).strip() elif raw.startswith('```'): - raw = _re.sub(r'^```\w*\s*', '', raw) - raw = _re.sub(r'\s*```$', '', raw) + raw = re.sub(r'^```\w*\s*', '', raw) + raw = re.sub(r'\s*```$', '', raw) jsonStart = raw.find('{') jsonEnd = raw.rfind('}') @@ -444,7 +444,7 @@ class GenerationService: return resolvedStyle raw = raw[jsonStart:jsonEnd + 1] - delta = _json.loads(raw) + delta = json.loads(raw) if not isinstance(delta, dict) or not delta: return resolvedStyle diff --git a/modules/serviceCenter/services/serviceGeneration/paths/codePath.py b/modules/serviceCenter/services/serviceGeneration/paths/codePath.py index aab4591a..c7f76689 100644 --- a/modules/serviceCenter/services/serviceGeneration/paths/codePath.py +++ b/modules/serviceCenter/services/serviceGeneration/paths/codePath.py @@ -343,7 +343,7 @@ Return ONLY valid JSON matching the request above. unifiedContext = "" if lastRawJson: # Get contexts directly from jsonContinuation - from modules.shared.jsonContinuation import getContexts + from modules.datamodels.jsonContinuation import getContexts contexts = getContexts(lastRawJson) overlapContext = contexts.overlapContext unifiedContext = contexts.hierarchyContextForPrompt @@ -790,7 +790,7 @@ Return ONLY valid JSON in this format: unifiedContext = "" if lastRawJson: # Get contexts directly from jsonContinuation - from modules.shared.jsonContinuation import getContexts + from modules.datamodels.jsonContinuation import getContexts contexts = getContexts(lastRawJson) overlapContext = contexts.overlapContext unifiedContext = contexts.hierarchyContextForPrompt diff --git a/modules/serviceCenter/services/serviceGeneration/paths/documentPath.py b/modules/serviceCenter/services/serviceGeneration/paths/documentPath.py index 4fc6c9d5..b74286eb 100644 --- a/modules/serviceCenter/services/serviceGeneration/paths/documentPath.py +++ b/modules/serviceCenter/services/serviceGeneration/paths/documentPath.py @@ -15,7 +15,7 @@ from modules.datamodels.datamodelWorkflow import AiResponse, AiResponseMetadata, from modules.datamodels.datamodelExtraction import ContentPart, DocumentIntent from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum from modules.datamodels.datamodelDocument import RenderedDocument -from modules.workflows.processing.shared.stateTools import checkWorkflowStopped +from modules.shared.workflowState import checkWorkflowStopped logger = logging.getLogger(__name__) diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/documentRendererBaseTemplate.py b/modules/serviceCenter/services/serviceGeneration/renderers/documentRendererBaseTemplate.py index 519a2697..9fc4d94b 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/documentRendererBaseTemplate.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/documentRendererBaseTemplate.py @@ -16,6 +16,7 @@ import base64 import io from PIL import Image from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum +import threading logger = logging.getLogger(__name__) @@ -408,7 +409,6 @@ class BaseRenderer(ABC): def _determineFilename(self, title: str, mimeType: str) -> str: """Determine filename from title and mimeType.""" - import re # Get extension from mimeType extensionMap = { "text/html": "html", @@ -651,7 +651,6 @@ class BaseRenderer(ABC): # Save styling prompt and response to debug (fire and forget - don't block on slow file I/O) # The writeDebugFile calls os.listdir() which can be slow with many files # Run in background thread to avoid blocking rendering - import threading def _writeDebugFiles(): try: self.services.utils.writeDebugFile(styleTemplate, "renderer_styling_prompt") diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/registry.py b/modules/serviceCenter/services/serviceGeneration/renderers/registry.py index 553c16a1..f0cea780 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/registry.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/registry.py @@ -12,6 +12,7 @@ import importlib from typing import Dict, Type, List, Optional, Tuple from .documentRendererBaseTemplate import BaseRenderer from .codeRendererBaseTemplate import BaseCodeRenderer +from pathlib import Path logger = logging.getLogger(__name__) @@ -36,7 +37,6 @@ class RendererRegistry: return try: - from pathlib import Path currentDir = Path(__file__).parent packageName = __name__.rsplit('.', 1)[0] diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererCsv.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererCsv.py index a8b2c346..d08fc1fe 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererCsv.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererCsv.py @@ -7,6 +7,7 @@ CSV renderer for report generation. from .documentRendererBaseTemplate import BaseRenderer from modules.datamodels.datamodelDocument import RenderedDocument from typing import Dict, Any, List, Optional +import io class RendererCsv(BaseRenderer): """Renders content to CSV format with format-specific extraction.""" @@ -399,7 +400,6 @@ class RendererCsv(BaseRenderer): def _convertRowsToCsv(self, rows: List[List[str]]) -> str: """Convert rows to CSV string.""" import csv - import io output = io.StringIO() writer = csv.writer(output) diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererDocx.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererDocx.py index 28e6fd65..7e427dd4 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererDocx.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererDocx.py @@ -10,6 +10,8 @@ from typing import Dict, Any, List, Optional import io import base64 import re +import math +import time try: from docx import Document @@ -113,7 +115,6 @@ class RendererDocx(BaseRenderer): async def _generateDocxFromJson(self, json_content: Dict[str, Any], title: str, userPrompt: str = None, aiService=None, unifiedStyle: Dict[str, Any] = None) -> str: """Generate DOCX content from structured JSON document.""" - import time start_time = time.time() try: self.logger.debug("_generateDocxFromJson: Starting document generation") @@ -385,7 +386,6 @@ class RendererDocx(BaseRenderer): By building the XML directly, we achieve 100-1000x faster performance. """ - import time from modules.serviceCenter.services.serviceGeneration.styleDefaults import deepMerge tableStart = time.time() @@ -428,7 +428,6 @@ class RendererDocx(BaseRenderer): This bypasses python-docx's slow high-level API and builds the table XML structure directly using lxml, which is 100-1000x faster. """ - import time from docx.oxml.shared import OxmlElement, qn from docx.oxml.ns import nsmap from lxml import etree @@ -955,7 +954,6 @@ class RendererDocx(BaseRenderer): streams = [s for s in (self._imageStreamFromContent(i) for i in images) if s is not None] if not streams: return - import math nrows = math.ceil(len(streams) / columns) table = doc.add_table(rows=nrows, cols=columns) cellWidthInches = max(1.0, 6.5 / columns - 0.1) diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererHtml.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererHtml.py index 1b3fc952..fe624723 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererHtml.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererHtml.py @@ -7,6 +7,8 @@ HTML renderer for report generation. from .documentRendererBaseTemplate import BaseRenderer from modules.datamodels.datamodelDocument import RenderedDocument from typing import Dict, Any, List, Optional +import base64 +import re class RendererHtml(BaseRenderer): """Renders content to HTML format with format-specific extraction.""" @@ -45,7 +47,6 @@ class RendererHtml(BaseRenderer): Render HTML document with images as separate files. Returns list of documents: [HTML document, image1, image2, ...] """ - import base64 # Extract images first images = self._extractImages(extractedContent) @@ -837,7 +838,6 @@ class RendererHtml(BaseRenderer): url = element.get("url", "") or (content.get("url", "") if isinstance(content, dict) else "") if url and isinstance(url, str) and url.startswith("data:image/"): # Extract base64 from data URI: data:image/png;base64, - import re match = re.match(r'data:image/[^;]+;base64,(.+)', url) if match: base64Data = match.group(1) @@ -901,8 +901,6 @@ class RendererHtml(BaseRenderer): HTML content with relative file paths """ try: - import base64 - import re # Find entire img tags with data URIs and replace them # Pattern: diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererImage.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererImage.py index 58c0d04f..2c8524e3 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererImage.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererImage.py @@ -9,6 +9,7 @@ from modules.datamodels.datamodelDocument import RenderedDocument from typing import Dict, Any, List, Optional import logging import base64 +import json logger = logging.getLogger(__name__) @@ -120,7 +121,6 @@ class RendererImage(BaseRenderer): # Format prompt as JSON with image generation parameters from modules.datamodels.datamodelAi import AiCallPromptImage, AiCallOptions, OperationTypeEnum - import json promptModel = AiCallPromptImage( prompt=imagePrompt, diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererMarkdown.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererMarkdown.py index b70c9dbb..b2458f19 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererMarkdown.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererMarkdown.py @@ -7,6 +7,7 @@ Markdown renderer for report generation. from .documentRendererBaseTemplate import BaseRenderer from modules.datamodels.datamodelDocument import RenderedDocument from typing import Any, Dict, List, Optional +import base64 class RendererMarkdown(BaseRenderer): """Renders content to Markdown format with format-specific extraction.""" @@ -44,7 +45,6 @@ class RendererMarkdown(BaseRenderer): def _collectImageDocuments(self, jsonContent: Dict[str, Any]) -> List[Dict[str, Any]]: """Extract image sections into sidecar file payloads for markdown export.""" - import base64 as _b64 out: List[Dict[str, Any]] = [] documents = jsonContent.get("documents") @@ -88,7 +88,7 @@ class RendererMarkdown(BaseRenderer): if not safe_name: raise ValueError(f"image fileName sanitized to empty: {fname!r}") - blob = _b64.b64decode(b64, validate=True) + blob = base64.b64decode(b64, validate=True) if not blob: raise ValueError(f"image base64Data decoded to empty bytes ({fname!r})") diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py index a82a4866..a7df6875 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py @@ -25,10 +25,12 @@ try: except ImportError: REPORTLAB_AVAILABLE = False -import re as _re_pdf +import re from ._pdfFontFallback import wrapEmojiSpansInXml as _wrapEmojiSpansInXml -from modules.serviceCenter.services.serviceGeneration.styleDefaults import deepMerge as _deepMergeStyle +from modules.serviceCenter.services.serviceGeneration.styleDefaults import deepMerge +import os +import tempfile # A4 width in pt; margins must match SimpleDocTemplate(leftMargin/rightMargin) _PDF_MARGIN_LR_PT = 72.0 @@ -74,7 +76,6 @@ def _resolveFontFamily(fontName: str, bold: bool = False) -> str: try: from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont - import os winFontsDir = os.path.join(os.environ.get("WINDIR", r"C:\Windows"), "Fonts") candidates = [ os.path.join(winFontsDir, f"{fontName}.ttf"), @@ -303,7 +304,6 @@ class RendererPdf(BaseRenderer): def _cleanupTempImageFiles(self) -> None: """Delete temp image files created for streamed (file-backed) PDF images.""" - import os for path in getattr(self, "_tempImageFiles", []) or []: try: os.unlink(path) @@ -541,10 +541,10 @@ class RendererPdf(BaseRenderer): return "" s = self._escapeReportlabXml(text) s = s.replace("\n", "
") - s = _re_pdf.sub(r"\*\*(.+?)\*\*", r"\1", s, flags=_re_pdf.DOTALL) - s = _re_pdf.sub(r"__(.+?)__", r"\1", s, flags=_re_pdf.DOTALL) - s = _re_pdf.sub(r"(?\1", s) - s = _re_pdf.sub(r"(?\1", s) + s = re.sub(r"\*\*(.+?)\*\*", r"\1", s, flags=re.DOTALL) + s = re.sub(r"__(.+?)__", r"\1", s, flags=re.DOTALL) + s = re.sub(r"(?\1", s) + s = re.sub(r"(?\1", s) return s def _markdownInlineToReportlabXml(self, text: str) -> str: @@ -561,7 +561,7 @@ class RendererPdf(BaseRenderer): monoFont = _resolveFontFamily(us["fonts"]["monospace"] if us else "Courier") out: List[str] = [] pos = 0 - for m in _re_pdf.finditer(r"`([^`]*)`", text): + for m in re.finditer(r"`([^`]*)`", text): before = text[pos:m.start()] out.append(self._applyInlineMarkdownToEscapedPlain(before)) code = m.group(1) @@ -749,7 +749,7 @@ class RendererPdf(BaseRenderer): us = getattr(self, '_unifiedStyle', None) or {} globalTableStyle = us.get("table", {}) perTableOverride = content.get("tableStyle", {}) - mergedTableStyle = _deepMergeStyle(globalTableStyle, perTableOverride) if perTableOverride else dict(globalTableStyle) + mergedTableStyle = deepMerge(globalTableStyle, perTableOverride) if perTableOverride else dict(globalTableStyle) numCols = len(headers) colWidth = _PDF_CONTENT_WIDTH_PT / max(numCols, 1) @@ -1139,7 +1139,6 @@ class RendererPdf(BaseRenderer): url = image_data.get("url", "") or (content.get("url", "") if isinstance(content, dict) else "") if url and isinstance(url, str) and url.startswith("data:image/"): # Extract base64 from data URI: data:image/png;base64, - import re match = re.match(r'data:image/[^;]+;base64,(.+)', url) if match: base64_data = match.group(1) @@ -1165,8 +1164,6 @@ class RendererPdf(BaseRenderer): try: from reportlab.platypus import Image as ReportLabImage from reportlab.lib.units import inch - import base64 - import io # Decode base64 image data imageBytes = base64.b64decode(base64_data) @@ -1241,7 +1238,6 @@ class RendererPdf(BaseRenderer): # Create reportlab Image from a TEMP FILE rather than the in-memory # stream: reportlab reads file-backed images lazily at build time, so # the bytes of all images are not held in memory at once (large-doc path). - import tempfile imageStream.seek(0) tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".img") try: diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPptx.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPptx.py index 112f1bf0..0b502e79 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPptx.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPptx.py @@ -89,7 +89,6 @@ class RendererPptx(BaseRenderer): from pptx.util import Inches, Pt from pptx.enum.text import PP_ALIGN from pptx.dml.color import RGBColor - import re if not style: from modules.serviceCenter.services.serviceGeneration.styleDefaults import resolveStyle @@ -1248,8 +1247,6 @@ class RendererPptx(BaseRenderer): try: from pptx.util import Inches, Pt from pptx.enum.text import PP_ALIGN - import base64 - import io if not images: logger.debug("No images to render in frame") diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererXlsx.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererXlsx.py index 44d491d7..d82e4a55 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererXlsx.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererXlsx.py @@ -880,8 +880,6 @@ class RendererXlsx(BaseRenderer): try: from openpyxl.drawing.image import Image as OpenpyxlImage - import base64 - import io # Decode base64 image data imageBytes = base64.b64decode(base64Data) diff --git a/modules/serviceCenter/services/serviceGeneration/subContentGenerator.py b/modules/serviceCenter/services/serviceGeneration/subContentGenerator.py index fd19a4cd..c8713fee 100644 --- a/modules/serviceCenter/services/serviceGeneration/subContentGenerator.py +++ b/modules/serviceCenter/services/serviceGeneration/subContentGenerator.py @@ -13,7 +13,7 @@ import re import traceback from typing import Dict, Any, Optional, List, Callable from .subContentIntegrator import ContentIntegrator -from modules.workflows.processing.shared.stateTools import checkWorkflowStopped +from modules.shared.workflowState import checkWorkflowStopped logger = logging.getLogger(__name__) diff --git a/modules/serviceCenter/services/serviceGeneration/subDocumentUtility.py b/modules/serviceCenter/services/serviceGeneration/subDocumentUtility.py index 6d5404e8..da1194c4 100644 --- a/modules/serviceCenter/services/serviceGeneration/subDocumentUtility.py +++ b/modules/serviceCenter/services/serviceGeneration/subDocumentUtility.py @@ -5,6 +5,7 @@ import logging import os import re from typing import Any, Dict, List, Optional +import io logger = logging.getLogger(__name__) @@ -75,7 +76,7 @@ def enhancePlainTextWithMarkdownTables(body: str) -> str: return "\n\n".join(out_parts) -def _parseInlineRuns(text: str) -> list: +def parseInlineRuns(text: str) -> list: """ Parse inline markdown formatting into a list of InlineRun dicts. Handles: images, links, bold, italic, inline code, plain text. @@ -262,11 +263,11 @@ def markdownToDocumentJson(markdown: str, title: str, language: str = "de") -> D # Tables - cells are List[InlineRun] tableMatch = re.match(r"^\|(.+)\|$", line) if tableMatch and (i + 1) < len(lines) and re.match(r"^\|[\s\-:|]+\|$", lines[i + 1]): - headerCells = [_parseInlineRuns(c.strip()) for c in tableMatch.group(1).split("|")] + headerCells = [parseInlineRuns(c.strip()) for c in tableMatch.group(1).split("|")] i += 2 rows = [] while i < len(lines) and re.match(r"^\|(.+)\|$", lines[i]): - rowCells = [_parseInlineRuns(c.strip()) for c in lines[i][1:-1].split("|")] + rowCells = [parseInlineRuns(c.strip()) for c in lines[i][1:-1].split("|")] rows.append(rowCells) i += 1 sections.append({ @@ -282,7 +283,7 @@ def markdownToDocumentJson(markdown: str, title: str, language: str = "de") -> D items = [] while i < len(lines) and re.match(r"^(\s*)([-*+]|\d+[.)]) (.+)", lines[i]): m = re.match(r"^(\s*)([-*+]|\d+[.)]) (.+)", lines[i]) - items.append(_parseInlineRuns(m.group(3).strip())) + items.append(parseInlineRuns(m.group(3).strip())) i += 1 sections.append({ "id": _nextId(), "content_type": "bullet_list", "order": order, @@ -328,7 +329,7 @@ def markdownToDocumentJson(markdown: str, title: str, language: str = "de") -> D combinedText = " ".join(paraLines) sections.append({ "id": _nextId(), "content_type": "paragraph", "order": order, - "elements": [{"content": {"inlineRuns": _parseInlineRuns(combinedText)}}], + "elements": [{"content": {"inlineRuns": parseInlineRuns(combinedText)}}], }) continue @@ -338,7 +339,7 @@ def markdownToDocumentJson(markdown: str, title: str, language: str = "de") -> D fallbackText = markdown.strip() or "(empty)" sections.append({ "id": _nextId(), "content_type": "paragraph", "order": order, - "elements": [{"content": {"inlineRuns": _parseInlineRuns(fallbackText)}}], + "elements": [{"content": {"inlineRuns": parseInlineRuns(fallbackText)}}], }) return { @@ -564,7 +565,6 @@ def convertDocumentDataToString(document_data: Any, file_extension: str) -> str: elif isinstance(content, list): if content and isinstance(content[0], (list, dict)): import csv - import io output = io.StringIO() if isinstance(content[0], dict): if content: @@ -582,7 +582,6 @@ def convertDocumentDataToString(document_data: Any, file_extension: str) -> str: elif isinstance(document_data, list): if file_extension == 'csv': import csv - import io output = io.StringIO() if document_data and isinstance(document_data[0], dict): fieldnames = document_data[0].keys() diff --git a/modules/serviceCenter/services/serviceKnowledge/_buildTree.py b/modules/serviceCenter/services/serviceKnowledge/_buildTree.py index e4aad028..87021f9d 100644 --- a/modules/serviceCenter/services/serviceKnowledge/_buildTree.py +++ b/modules/serviceCenter/services/serviceKnowledge/_buildTree.py @@ -112,13 +112,13 @@ def _findDsRecord( sourceType: str, path: str, ) -> Optional[Dict[str, Any]]: - from modules.serviceCenter.services.serviceKnowledge._inheritFlags import _normalisePath - norm = _normalisePath(path) + from modules.serviceCenter.services.serviceKnowledge._inheritFlags import normalisePath + norm = normalisePath(path) for ds in allDs: if ( ds.get("connectionId") == connectionId and ds.get("sourceType") == sourceType - and _normalisePath(ds.get("path")) == norm + and normalisePath(ds.get("path")) == norm ): return ds return None @@ -300,8 +300,8 @@ async def _connectionServiceNodes( ) chatService = getService("chat", ctx) securityService = getService("security", ctx) - from modules.features.workspace.routeFeatureWorkspace import _buildResolverDbInterface - dbInterface = _buildResolverDbInterface(chatService) + from modules.interfaces.interfaceDbManagement import buildResolverDbInterface + dbInterface = buildResolverDbInterface(chatService) resolver = ConnectorResolver(securityService, dbInterface) try: provider = await resolver.resolve(connectionId) @@ -352,8 +352,8 @@ async def _browseChildNodes( ) chatService = getService("chat", ctx) securityService = getService("security", ctx) - from modules.features.workspace.routeFeatureWorkspace import _buildResolverDbInterface - dbInterface = _buildResolverDbInterface(chatService) + from modules.interfaces.interfaceDbManagement import buildResolverDbInterface + dbInterface = buildResolverDbInterface(chatService) resolver = ConnectorResolver(securityService, dbInterface) try: adapter = await resolver.resolveService(connectionId, service) @@ -595,7 +595,7 @@ async def getChildrenForParents( """ from modules.interfaces.interfaceDbApp import getRootInterface from modules.datamodels.datamodelDataSource import DataSource - from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + from modules.datamodels.datamodelFeatures import FeatureDataSource rootIf = getRootInterface() diff --git a/modules/serviceCenter/services/serviceKnowledge/_inheritFlags.py b/modules/serviceCenter/services/serviceKnowledge/_inheritFlags.py index 35de8409..69edc3c2 100644 --- a/modules/serviceCenter/services/serviceKnowledge/_inheritFlags.py +++ b/modules/serviceCenter/services/serviceKnowledge/_inheritFlags.py @@ -44,7 +44,7 @@ Mode = Literal["walk", "aggregate"] # Internal helpers # --------------------------------------------------------------------------- -def _normalisePath(path: Optional[str]) -> str: +def normalisePath(path: Optional[str]) -> str: """Normalize a DataSource path to '/'-prefixed, no trailing slash (except root).""" if not path: return "/" @@ -106,7 +106,7 @@ def _findAncestorChain( The connection-root is always the most distant ancestor. """ - recPath = _normalisePath(_getRecordValue(rec, "path")) + recPath = normalisePath(_getRecordValue(rec, "path")) recSourceType = _getRecordValue(rec, "sourceType") recConnectionId = _getRecordValue(rec, "connectionId") sameTypeCandidates: List[Tuple[int, Dict[str, Any]]] = [] @@ -118,7 +118,7 @@ def _findAncestorChain( if _getRecordValue(cand, "connectionId") != recConnectionId: continue candSourceType = _getRecordValue(cand, "sourceType") - candPath = _normalisePath(_getRecordValue(cand, "path")) + candPath = normalisePath(_getRecordValue(cand, "path")) if candSourceType == recSourceType: if candPath == recPath or not _isAncestorPath(candPath, recPath): continue @@ -139,7 +139,7 @@ def _findAncestorChain( def _isDescendantDs(parentRec: Dict[str, Any], candidate: Dict[str, Any]) -> bool: """True iff `candidate` is a descendant of `parentRec` in the DS hierarchy.""" parentSourceType = _getRecordValue(parentRec, "sourceType") - parentPath = _normalisePath(_getRecordValue(parentRec, "path")) + parentPath = normalisePath(_getRecordValue(parentRec, "path")) parentConnectionId = _getRecordValue(parentRec, "connectionId") parentId = _getRecordValue(parentRec, "id") @@ -150,7 +150,7 @@ def _isDescendantDs(parentRec: Dict[str, Any], candidate: Dict[str, Any]) -> boo return False candSourceType = _getRecordValue(candidate, "sourceType") - candPath = _normalisePath(_getRecordValue(candidate, "path")) + candPath = normalisePath(_getRecordValue(candidate, "path")) parentIsConnectionRoot = ( parentSourceType in _AUTHORITY_SOURCE_TYPES and parentPath == "/" @@ -269,7 +269,7 @@ def cascadeResetDescendants( if not _isExplicit(sibVal): continue sibId = _getRecordValue(sib, "id") - sibPath = _normalisePath(_getRecordValue(sib, "path")) + sibPath = normalisePath(_getRecordValue(sib, "path")) toReset.append((_pathDepth(sibPath), sibId)) # Sort deepest first (bottom-up) @@ -455,7 +455,7 @@ def cascadeResetDescendantsFds( """ if flag not in _INHERITABLE_FDS_FLAGS: raise ValueError(f"Unknown inheritable FDS flag: {flag}") - from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + from modules.datamodels.datamodelFeatures import FeatureDataSource featureInstanceId = _getRecordValue(parentRec, "featureInstanceId") if not featureInstanceId: @@ -547,13 +547,13 @@ def resolveEffectiveForPath( Returns dict with effectiveNeutralize, effectiveRagIndexEnabled. (effectiveScope removed 2026-06 — personal sources have no scope.) """ - normPath = _normalisePath(path) + normPath = normalisePath(path) exactRecord = None for ds in allDs: if ( _getRecordValue(ds, "connectionId") == connectionId and _getRecordValue(ds, "sourceType") == sourceType - and _normalisePath(_getRecordValue(ds, "path")) == normPath + and normalisePath(_getRecordValue(ds, "path")) == normPath ): exactRecord = ds break diff --git a/modules/serviceCenter/services/serviceKnowledge/_costEstimate.py b/modules/serviceCenter/services/serviceKnowledge/costEstimate.py similarity index 100% rename from modules/serviceCenter/services/serviceKnowledge/_costEstimate.py rename to modules/serviceCenter/services/serviceKnowledge/costEstimate.py diff --git a/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py b/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py index 01c585d8..291dd9a6 100644 --- a/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py +++ b/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py @@ -16,6 +16,7 @@ from modules.datamodels.datamodelKnowledge import ( from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum from modules.interfaces.interfaceDbKnowledge import getInterface as getKnowledgeInterface from modules.shared.timeUtils import getUtcTimestamp +import base64 logger = logging.getLogger(__name__) @@ -544,7 +545,6 @@ class KnowledgeService: # 4. Store non-text content objects (images, etc.) without embedding nonTextObjects = [o for o in contentObjects if o.get("contentType") != "text"] if _shouldNeutralize and nonTextObjects and _neutralSvc: - import base64 as _b64 _filteredNonText = [] for _obj in nonTextObjects: if _obj.get("contentType") != "image": @@ -555,7 +555,7 @@ class KnowledgeService: _filteredNonText.append(_obj) continue try: - _imgBytes = _b64.b64decode(_imgData) + _imgBytes = base64.b64decode(_imgData) _imgResult = await _neutralSvc.processImageAsync(_imgBytes, fileName) if _imgResult.get("status") == "ok": _filteredNonText.append(_obj) @@ -903,7 +903,6 @@ class KnowledgeService: fileName = fileContent.get("fileName", "") if isinstance(fileData, str): - import base64 fileData = base64.b64decode(fileData) if mimeType != "application/pdf": diff --git a/modules/serviceCenter/services/serviceKnowledge/_ragLimits.py b/modules/serviceCenter/services/serviceKnowledge/ragLimits.py similarity index 100% rename from modules/serviceCenter/services/serviceKnowledge/_ragLimits.py rename to modules/serviceCenter/services/serviceKnowledge/ragLimits.py diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncClickup.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncClickup.py index 7e14e13e..ac886099 100644 --- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncClickup.py +++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncClickup.py @@ -33,9 +33,9 @@ from modules.serviceCenter.services.serviceKnowledge.subWalkerHelpers import ( logger = logging.getLogger(__name__) -from modules.serviceCenter.services.serviceKnowledge import _ragLimits as _ragLimitsHelper +from modules.serviceCenter.services.serviceKnowledge import ragLimits -_CLICKUP_DEFAULTS = _ragLimitsHelper.CLICKUP_LIMITS_DEFAULT +_CLICKUP_DEFAULTS = ragLimits.CLICKUP_LIMITS_DEFAULT MAX_TASKS_DEFAULT = _CLICKUP_DEFAULTS["maxTasks"] MAX_WORKSPACES_DEFAULT = _CLICKUP_DEFAULTS["maxWorkspaces"] MAX_LISTS_PER_WORKSPACE_DEFAULT = _CLICKUP_DEFAULTS["maxListsPerWorkspace"] @@ -45,7 +45,7 @@ MAX_AGE_DAYS_DEFAULT = 180 def _resolveDataSourceLimits(dsId: str, ds: Dict[str, Any]) -> Dict[str, int]: """Return explicit RAG-limit overrides stored on the DataSource (or {}).""" - return _ragLimitsHelper.getStoredOverrides(ds, "clickup") + return ragLimits.getStoredOverrides(ds, "clickup") @dataclass @@ -294,7 +294,7 @@ async def bootstrapClickup( async def _resolveDependencies(connectionId: str): from modules.interfaces.interfaceDbApp import getRootInterface from modules.auth import TokenManager - from modules.connectors.providerClickup.connectorClickup import ClickupConnector + from modules.connectors.connectorProviderClickup import ClickupConnector from modules.serviceCenter import getService from modules.serviceCenter.context import ServiceCenterContext from modules.security.rootAccess import getRootUser diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGdrive.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGdrive.py index 08f51ecd..9857bfb7 100644 --- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGdrive.py +++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGdrive.py @@ -31,9 +31,9 @@ from modules.serviceCenter.services.serviceKnowledge.subWalkerHelpers import ( logger = logging.getLogger(__name__) -from modules.serviceCenter.services.serviceKnowledge import _ragLimits as _ragLimitsHelper +from modules.serviceCenter.services.serviceKnowledge import ragLimits -_FILES_DEFAULTS = _ragLimitsHelper.FILES_LIMITS_DEFAULT +_FILES_DEFAULTS = ragLimits.FILES_LIMITS_DEFAULT MAX_ITEMS_DEFAULT = _FILES_DEFAULTS["maxItems"] MAX_BYTES_DEFAULT = _FILES_DEFAULTS["maxBytes"] MAX_FILE_SIZE_DEFAULT = _FILES_DEFAULTS["maxFileSize"] @@ -44,7 +44,7 @@ MAX_AGE_DAYS_DEFAULT = 365 def _resolveDataSourceLimits(dsId: str, ds: Dict[str, Any]) -> Dict[str, int]: """Return explicit RAG-limit overrides stored on the DataSource (or {}).""" - return _ragLimitsHelper.getStoredOverrides(ds, "files") + return ragLimits.getStoredOverrides(ds, "files") FOLDER_MIME = "application/vnd.google-apps.folder" @@ -224,7 +224,7 @@ async def bootstrapGdrive( async def _resolveDependencies(connectionId: str): from modules.interfaces.interfaceDbApp import getRootInterface from modules.auth import TokenManager - from modules.connectors.providerGoogle.connectorGoogle import GoogleConnector + from modules.connectors.connectorProviderGoogle import GoogleConnector from modules.serviceCenter import getService from modules.serviceCenter.context import ServiceCenterContext from modules.security.rootAccess import getRootUser diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGmail.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGmail.py index 96f9cecf..150fe839 100644 --- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGmail.py +++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGmail.py @@ -217,7 +217,7 @@ async def bootstrapGmail( adapter, connection, knowledgeService = await _resolveDependencies(connectionId) if googleGetFn is None: - from modules.connectors.providerGoogle.connectorGoogle import _googleGet as _defaultGet + from modules.connectors.connectorProviderGoogle import googleGet as _defaultGet token = getattr(adapter, "_token", "") @@ -277,7 +277,7 @@ async def bootstrapGmail( async def _resolveDependencies(connectionId: str): from modules.interfaces.interfaceDbApp import getRootInterface from modules.auth import TokenManager - from modules.connectors.providerGoogle.connectorGoogle import GoogleConnector + from modules.connectors.connectorProviderGoogle import GoogleConnector from modules.serviceCenter import getService from modules.serviceCenter.context import ServiceCenterContext from modules.security.rootAccess import getRootUser diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncKdrive.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncKdrive.py index 1235ed18..1c50070e 100644 --- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncKdrive.py +++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncKdrive.py @@ -27,9 +27,9 @@ from modules.serviceCenter.services.serviceKnowledge.subWalkerHelpers import ( logger = logging.getLogger(__name__) -from modules.serviceCenter.services.serviceKnowledge import _ragLimits as _ragLimitsHelper +from modules.serviceCenter.services.serviceKnowledge import ragLimits -_FILES_DEFAULTS = _ragLimitsHelper.FILES_LIMITS_DEFAULT +_FILES_DEFAULTS = ragLimits.FILES_LIMITS_DEFAULT MAX_ITEMS_DEFAULT = _FILES_DEFAULTS["maxItems"] MAX_BYTES_DEFAULT = _FILES_DEFAULTS["maxBytes"] MAX_FILE_SIZE_DEFAULT = _FILES_DEFAULTS["maxFileSize"] @@ -39,7 +39,7 @@ SKIP_MIME_PREFIXES_DEFAULT = ("video/", "audio/") def _resolveDataSourceLimits(dsId: str, ds: Dict[str, Any]) -> Dict[str, int]: """Return explicit RAG-limit overrides stored on the DataSource (or {}).""" - return _ragLimitsHelper.getStoredOverrides(ds, "files") + return ragLimits.getStoredOverrides(ds, "files") @dataclass @@ -191,7 +191,7 @@ async def bootstrapKdrive( async def _resolveDependencies(connectionId: str): from modules.interfaces.interfaceDbApp import getRootInterface from modules.auth import TokenManager - from modules.connectors.providerInfomaniak.connectorInfomaniak import InfomaniakConnector + from modules.connectors.connectorProviderInfomaniak import InfomaniakConnector from modules.serviceCenter import getService from modules.serviceCenter.context import ServiceCenterContext from modules.security.rootAccess import getRootUser diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncOutlook.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncOutlook.py index e676b156..c27a5039 100644 --- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncOutlook.py +++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncOutlook.py @@ -21,6 +21,8 @@ from dataclasses import dataclass, field from typing import Any, Dict, List, Optional from modules.serviceCenter.services.serviceKnowledge.subTextClean import cleanEmailBody +import base64 +from datetime import datetime, timezone, timedelta from modules.serviceCenter.services.serviceKnowledge.subWalkerHelpers import ( WalkerTimeout, extractWithTimeout, @@ -234,7 +236,7 @@ async def bootstrapOutlook( async def _resolveDependencies(connectionId: str): from modules.interfaces.interfaceDbApp import getRootInterface from modules.auth import TokenManager - from modules.connectors.providerMsft.connectorMsft import MsftConnector + from modules.connectors.connectorProviderMsft import MsftConnector from modules.serviceCenter import getService from modules.serviceCenter.context import ServiceCenterContext from modules.security.rootAccess import getRootUser @@ -321,7 +323,6 @@ async def _ingestFolder( # Keep header-based age filter in Graph itself to avoid shipping ancient # messages we'd discard client-side. if limits.maxAgeDays: - from datetime import datetime, timezone, timedelta cutoff = datetime.now(timezone.utc) - timedelta(days=limits.maxAgeDays) cutoffIso = cutoff.strftime("%Y-%m-%dT%H:%M:%SZ") @@ -362,9 +363,9 @@ async def _ingestFolder( if not nextLink: break # Strip Graph base so adapter._graphGet accepts the relative path. - from modules.connectors.providerMsft.connectorMsft import _stripGraphBase + from modules.connectors.connectorProviderMsft import stripGraphBase - endpoint = _stripGraphBase(nextLink) + endpoint = stripGraphBase(nextLink) async def _ingestMessage( @@ -504,7 +505,6 @@ async def _ingestAttachments( from modules.serviceCenter.services.serviceExtraction.subRegistry import ( ExtractorRegistry, ChunkerRegistry, ) - import base64 page = await adapter._graphGet(f"me/messages/{messageId}/attachments") if not isinstance(page, dict) or "error" in page: diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncSharepoint.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncSharepoint.py index 6f69d171..86d61f60 100644 --- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncSharepoint.py +++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncSharepoint.py @@ -30,9 +30,9 @@ from modules.serviceCenter.services.serviceKnowledge.subWalkerHelpers import ( logger = logging.getLogger(__name__) -from modules.serviceCenter.services.serviceKnowledge import _ragLimits as _ragLimitsHelper +from modules.serviceCenter.services.serviceKnowledge import ragLimits -_FILES_DEFAULTS = _ragLimitsHelper.FILES_LIMITS_DEFAULT +_FILES_DEFAULTS = ragLimits.FILES_LIMITS_DEFAULT MAX_ITEMS_DEFAULT = _FILES_DEFAULTS["maxItems"] MAX_BYTES_DEFAULT = _FILES_DEFAULTS["maxBytes"] MAX_FILE_SIZE_DEFAULT = _FILES_DEFAULTS["maxFileSize"] @@ -48,7 +48,7 @@ def _resolveDataSourceLimits(dsId: str, ds: Dict[str, Any]) -> Dict[str, int]: defaults. Used to merge per-DataSource user settings on top of the walker's runtime limits. """ - return _ragLimitsHelper.getStoredOverrides(ds, "files") + return ragLimits.getStoredOverrides(ds, "files") @dataclass @@ -225,7 +225,7 @@ async def _resolveDependencies(connectionId: str): """ from modules.interfaces.interfaceDbApp import getRootInterface from modules.auth import TokenManager - from modules.connectors.providerMsft.connectorMsft import MsftConnector + from modules.connectors.connectorProviderMsft import MsftConnector from modules.serviceCenter import getService from modules.serviceCenter.context import ServiceCenterContext from modules.security.rootAccess import getRootUser diff --git a/modules/serviceCenter/services/serviceKnowledge/subFeatureBootstrap.py b/modules/serviceCenter/services/serviceKnowledge/subFeatureBootstrap.py index 88a59408..4d58933c 100644 --- a/modules/serviceCenter/services/serviceKnowledge/subFeatureBootstrap.py +++ b/modules/serviceCenter/services/serviceKnowledge/subFeatureBootstrap.py @@ -29,7 +29,7 @@ def _loadRagEnabledFds(featureInstanceId: str, featureDataSourceIds: Optional[Li Returns dicts with resolved flags so downstream code can read them directly. """ from modules.interfaces.interfaceDbApp import getRootInterface - from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + from modules.datamodels.datamodelFeatures import FeatureDataSource from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlagFds rootIf = getRootInterface() diff --git a/modules/serviceCenter/services/serviceKnowledge/udbNodes.py b/modules/serviceCenter/services/serviceKnowledge/udbNodes.py index d0678e99..d46292ce 100644 --- a/modules/serviceCenter/services/serviceKnowledge/udbNodes.py +++ b/modules/serviceCenter/services/serviceKnowledge/udbNodes.py @@ -411,7 +411,7 @@ class _FdsFamilyNode(UdbNode): return flag in ("neutralize", "ragIndexEnabled") def canEdit(self, context: Any, rootIf: Any) -> bool: - return _isFeatureAdmin(rootIf, str(context.user.id), self.featureInstanceId) + return isFeatureAdmin(rootIf, str(context.user.id), self.featureInstanceId) def getEffectiveFlag(self, flag, allDs, allFds, mode="aggregate") -> Any: if not self.supportsFlag(flag): @@ -427,7 +427,7 @@ class _FdsFamilyNode(UdbNode): def setFlag(self, flag, value, rootIf) -> List[str]: if not self.supportsFlag(flag): raise ValueError(f"FDS does not support flag {flag!r}") - from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + from modules.datamodels.datamodelFeatures import FeatureDataSource from modules.serviceCenter.services.serviceKnowledge._inheritFlags import ( cascadeResetDescendantsFds, ) @@ -489,7 +489,7 @@ class FdsWorkspaceNode(_FdsFamilyNode): table aggregate stays 'mixed' because some field children still read True from the list while others inherit the new value. """ - from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + from modules.datamodels.datamodelFeatures import FeatureDataSource allFds = rootIf.db.getRecordset( FeatureDataSource, recordFilter={"featureInstanceId": self.featureInstanceId}, @@ -657,7 +657,7 @@ class FdsFieldNode(UdbNode): return flag == "neutralize" def canEdit(self, context: Any, rootIf: Any) -> bool: - return _isFeatureAdmin(rootIf, str(context.user.id), self.featureInstanceId) + return isFeatureAdmin(rootIf, str(context.user.id), self.featureInstanceId) def getEffectiveFlag(self, flag, allDs, allFds, mode="aggregate") -> Any: if flag != "neutralize": @@ -681,7 +681,7 @@ class FdsFieldNode(UdbNode): def setFlag(self, flag, value, rootIf) -> List[str]: if flag != "neutralize": raise ValueError(f"FdsFieldNode does not support flag {flag!r}") - from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + from modules.datamodels.datamodelFeatures import FeatureDataSource # Resolve or auto-create the underlying table-record FDS so we # have somewhere to persist the neutralizeFields entry. rec = self.tableRec @@ -753,15 +753,15 @@ def _findOrCreateDs(rootIf: Any, connectionId: str, sourceType: str, """ from modules.datamodels.datamodelDataSource import DataSource from modules.datamodels.datamodelUam import UserConnection - from modules.serviceCenter.services.serviceKnowledge._inheritFlags import _normalisePath + from modules.serviceCenter.services.serviceKnowledge._inheritFlags import normalisePath - normPath = _normalisePath(path) + normPath = normalisePath(path) existing = rootIf.db.getRecordset(DataSource, recordFilter={ "connectionId": connectionId, "sourceType": sourceType, }) or [] for rec in existing: - if _normalisePath(rec.get("path")) == normPath: + if normalisePath(rec.get("path")) == normPath: return rec conn = rootIf.db.getRecord(UserConnection, connectionId) @@ -791,7 +791,7 @@ def _findOrCreateDs(rootIf: Any, connectionId: str, sourceType: str, return stub.model_dump() -def _isFeatureAdmin(rootIf: Any, userId: str, featureInstanceId: str) -> bool: +def isFeatureAdmin(rootIf: Any, userId: str, featureInstanceId: str) -> bool: """Return True iff the user holds a `*-admin` role on this feature instance. Convention: feature-specific admin role labels end with `-admin` @@ -846,7 +846,7 @@ def _findOrCreateTableFds(rootIf: Any, featureInstanceId: str, tableName: str, coordinate; its `objectKey`/`label` are filled from the RBAC catalog so list endpoints still render it correctly. """ - from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + from modules.datamodels.datamodelFeatures import FeatureDataSource existing = rootIf.db.getRecordset(FeatureDataSource, recordFilter={ "featureInstanceId": featureInstanceId, "tableName": tableName, @@ -939,7 +939,7 @@ def buildNodeForKey(key: str, context: Any, rootIf: Any) -> Optional[UdbNode]: if rootIf is None: rootIf = getRootInterface() from modules.datamodels.datamodelDataSource import DataSource - from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + from modules.datamodels.datamodelFeatures import FeatureDataSource kind, parts = _decode(key) @@ -1007,26 +1007,26 @@ def buildNodeForKey(key: str, context: Any, rootIf: Any) -> Optional[UdbNode]: def _findDsByCoord(rootIf: Any, connectionId: str, sourceType: Optional[str], path: str) -> Optional[Dict[str, Any]]: from modules.datamodels.datamodelDataSource import DataSource - from modules.serviceCenter.services.serviceKnowledge._inheritFlags import _normalisePath + from modules.serviceCenter.services.serviceKnowledge._inheritFlags import normalisePath rf = {"connectionId": connectionId} if sourceType is not None: rf["sourceType"] = sourceType records = rootIf.db.getRecordset(DataSource, recordFilter=rf) or [] - norm = _normalisePath(path) + norm = normalisePath(path) if sourceType is None: # connection-root: any record with path='/' on this connection for r in records: - if _normalisePath(r.get("path")) == "/": + if normalisePath(r.get("path")) == "/": return r return None for r in records: - if _normalisePath(r.get("path")) == norm: + if normalisePath(r.get("path")) == norm: return r return None def _loadAllFds(rootIf: Any, featureInstanceId: str) -> List[Dict[str, Any]]: - from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + from modules.datamodels.datamodelFeatures import FeatureDataSource return rootIf.db.getRecordset( FeatureDataSource, recordFilter={"featureInstanceId": featureInstanceId} ) or [] diff --git a/modules/serviceCenter/services/serviceSharepoint/mainServiceSharepoint.py b/modules/serviceCenter/services/serviceSharepoint/mainServiceSharepoint.py index 4fd1fb36..8456dc52 100644 --- a/modules/serviceCenter/services/serviceSharepoint/mainServiceSharepoint.py +++ b/modules/serviceCenter/services/serviceSharepoint/mainServiceSharepoint.py @@ -7,6 +7,7 @@ import aiohttp import asyncio import time from typing import Dict, Any, List, Optional, Callable +from datetime import datetime, timedelta, timezone logger = logging.getLogger(__name__) @@ -585,7 +586,6 @@ class SharepointService: async def getFolderUsageAnalytics(self, siteId: str, driveId: str, itemId: str, startDateTime: Optional[str] = None, endDateTime: Optional[str] = None, interval: str = "day") -> Dict[str, Any]: """Get usage analytics for a folder or file.""" try: - from datetime import datetime, timedelta, timezone if not endDateTime: endDateTime = datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z') diff --git a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py index 0d3ae954..1eaebf56 100644 --- a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py +++ b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py @@ -27,6 +27,7 @@ from modules.interfaces.interfaceDbSubscription import ( InvalidTransitionError, ) from modules.shared.i18nRegistry import t +from datetime import datetime, timezone logger = logging.getLogger(__name__) @@ -742,7 +743,7 @@ class SubscriptionService: def _notifyEnterpriseInvoice(mandateId: str, subRecord: Dict[str, Any]) -> None: """Send enterprise invoice email to mandate admins.""" try: - from modules.shared.notifyMandateAdmins import notifyMandateAdmins + from modules.system.notifyMandateAdmins import notifyMandateAdmins rawHtml = _buildEnterpriseInvoiceHtml(subRecord) flatPrice = subRecord.get("enterpriseFlatPriceCHF") or 0 @@ -778,7 +779,6 @@ def _buildEnterpriseInvoiceHtml(subRecord: Dict[str, Any]) -> str: def _fmtDate(ts: Optional[float]) -> str: if not ts: return "—" - from datetime import datetime, timezone return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%d.%m.%Y") detailRows = "" @@ -845,7 +845,7 @@ def _notifySubscriptionChange( platformUrl: str = "", ) -> None: try: - from modules.shared.notifyMandateAdmins import notifyMandateAdmins + from modules.system.notifyMandateAdmins import notifyMandateAdmins planLabel = (plan.title or plan.planKey) if plan else "\u2014" platformHint = f"Plattform: {platformUrl}" if platformUrl else "" @@ -1036,117 +1036,20 @@ def _buildCancelSummaryHtml(subRecord: Dict[str, Any], platformUrl: str = "") -> # ============================================================================ -# Exception Classes +# Exception Classes (defined in shared, re-exported here for backward compat) # ============================================================================ -SUBSCRIPTION_USER_ACTION_UPGRADE = "UPGRADE_SUBSCRIPTION" -SUBSCRIPTION_USER_ACTION_REACTIVATE = "REACTIVATE_SUBSCRIPTION" -SUBSCRIPTION_USER_ACTION_ADD_PAYMENT = "ADD_PAYMENT_METHOD" - -SUBSCRIPTION_REASONS = { - "SUBSCRIPTION_INACTIVE", - "SUBSCRIPTION_PAYMENT_REQUIRED", - "SUBSCRIPTION_PAYMENT_PENDING", - "SUBSCRIPTION_EXPIRED", -} - - -def _subscriptionReasonForStatus(status: SubscriptionStatusEnum) -> str: - if status == SubscriptionStatusEnum.PENDING: - return "SUBSCRIPTION_PAYMENT_PENDING" - if status == SubscriptionStatusEnum.PAST_DUE: - return "SUBSCRIPTION_PAYMENT_REQUIRED" - if status == SubscriptionStatusEnum.EXPIRED: - return "SUBSCRIPTION_EXPIRED" - return "SUBSCRIPTION_INACTIVE" - - -def _subscriptionUserActionForStatus(status: SubscriptionStatusEnum) -> str: - if status in (SubscriptionStatusEnum.PAST_DUE, SubscriptionStatusEnum.PENDING): - return SUBSCRIPTION_USER_ACTION_ADD_PAYMENT - return SUBSCRIPTION_USER_ACTION_UPGRADE - - -class SubscriptionInactiveException(Exception): - def __init__(self, status: SubscriptionStatusEnum, mandateId: str = "", message: Optional[str] = None): - self.status = status - self.mandateId = mandateId - self.reason = _subscriptionReasonForStatus(status) - self.userAction = _subscriptionUserActionForStatus(status) - self.message = message or t( - "Kein aktives Abonnement für diesen Mandanten. Bitte wählen Sie einen Plan unter Billing." - ) - super().__init__(self.message) - - def toClientDict(self) -> Dict[str, Any]: - out: Dict[str, Any] = { - "error": self.reason, "message": self.message, - "userAction": self.userAction, "subscriptionUiPath": "/admin/billing?tab=subscription", - } - if self.mandateId: - out["mandateId"] = self.mandateId - return out - - -SUBSCRIPTION_USER_ACTION_CONTACT_ADMIN = "CONTACT_ADMIN" - - -def _subscriptionLimitsHint() -> str: - return " " + t( - "Details zu Ihrem Abonnement, den enthaltenen Limits und Upgrade-Optionen: " - "Menü «Administration» → «Billing» → Registerkarte «Abonnement»." - ) - - -def _enterpriseLimitsHint() -> str: - return " " + t( - "Ihr Enterprise-Abonnement wird vom Plattform-Administrator verwaltet. " - "Bitte kontaktieren Sie den Administrator für eine Anpassung der Limiten." - ) - - -class SubscriptionCapacityException(Exception): - def __init__(self, resourceType: str, currentCount: int, maxAllowed: int, - message: Optional[str] = None, isEnterprise: bool = False): - self.resourceType = resourceType - self.currentCount = currentCount - self.maxAllowed = maxAllowed - self.isEnterprise = isEnterprise - hint = _enterpriseLimitsHint() if isEnterprise else _subscriptionLimitsHint() - if message is not None: - self.message = message - elif resourceType == "users": - self.message = t( - "Mit dem aktuellen Abonnement sind für diesen Mandanten höchstens {maxAllowed} " - "Benutzer zulässig (derzeit {currentCount}). " - "Ohne Planwechsel können keine weiteren Benutzer hinzugefügt werden." - ).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint - elif resourceType == "featureInstances": - self.message = t( - "Es sind höchstens {maxAllowed} aktive Module erlaubt (derzeit {currentCount}). " - "Bitte Abonnement erweitern oder ein Modul entfernen." - ).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint - elif resourceType == "dataVolumeMB": - self.message = t( - "Das im Abonnement enthaltene Datenvolumen ({maxAllowed} MB) reicht nicht " - "(aktuell ca. {currentCount} MB). Bitte Speicher-Limit oder Plan anpassen." - ).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint - else: - self.message = t( - "Abonnement-Limit überschritten (Ressource «{resourceType}»: " - "aktuell {currentCount}, erlaubt {maxAllowed})." - ).format(resourceType=resourceType, currentCount=currentCount, maxAllowed=maxAllowed) + hint - super().__init__(self.message) - - def toClientDict(self) -> Dict[str, Any]: - action = SUBSCRIPTION_USER_ACTION_CONTACT_ADMIN if self.isEnterprise else SUBSCRIPTION_USER_ACTION_UPGRADE - return { - "error": f"SUBSCRIPTION_{self.resourceType.upper()}_LIMIT", - "currentCount": self.currentCount, "maxAllowed": self.maxAllowed, - "message": self.message, "userAction": action, - "subscriptionUiPath": "/admin/billing?tab=subscription", - } - +from modules.shared.serviceExceptions import ( + SubscriptionInactiveException, + SubscriptionCapacityException, + SUBSCRIPTION_USER_ACTION_UPGRADE, + SUBSCRIPTION_USER_ACTION_REACTIVATE, + SUBSCRIPTION_USER_ACTION_ADD_PAYMENT, + SUBSCRIPTION_USER_ACTION_CONTACT_ADMIN, + SUBSCRIPTION_REASONS, + _subscriptionReasonForStatus, + _subscriptionUserActionForStatus, +) SubscriptionService.SubscriptionInactiveException = SubscriptionInactiveException SubscriptionService.SubscriptionCapacityException = SubscriptionCapacityException diff --git a/modules/serviceCenter/services/serviceWeb/mainServiceWeb.py b/modules/serviceCenter/services/serviceWeb/mainServiceWeb.py index c4e24947..3445839e 100644 --- a/modules/serviceCenter/services/serviceWeb/mainServiceWeb.py +++ b/modules/serviceCenter/services/serviceWeb/mainServiceWeb.py @@ -925,7 +925,6 @@ Return ONLY valid JSON, no additional text: Returns: List of processed crawl results """ - import time results = [] # Handle list of results diff --git a/modules/shared/__init__.py b/modules/shared/__init__.py index 64b042b8..6d67ce5c 100644 --- a/modules/shared/__init__.py +++ b/modules/shared/__init__.py @@ -11,9 +11,6 @@ from . import attributeUtils from . import frontendTypes from . import configuration from . import eventManagement -from . import auditLogger from . import debugLogger from . import progressLogger from . import callbackRegistry -from . import jsonContinuation -from . import dbMultiTenantOptimizations diff --git a/modules/shared/configuration.py b/modules/shared/configuration.py index 15646962..fc7578f2 100644 --- a/modules/shared/configuration.py +++ b/modules/shared/configuration.py @@ -18,7 +18,6 @@ from pathlib import Path from cryptography.fernet import Fernet from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC -# audit_logger imported lazily to avoid circular import # Set up basic logging for configuration loading logging.basicConfig( @@ -201,15 +200,11 @@ class Configuration: if key.endswith("_SECRET"): # Log audit event for secret key access try: - from modules.shared.auditLogger import audit_logger - audit_logger.logKeyAccess( - userId=user_id, - mandateId="system", - keyName=key, - action="decode" + logging.getLogger("audit.fallback").info( + "AUDIT | %s | %s | system | key | %s | Key: %s", + time.time(), user_id, "decode", key ) except Exception: - # Don't fail if audit logging fails pass if value.startswith("{") and value.endswith("}"): @@ -478,15 +473,11 @@ def encryptValue(value: str, envType: str = None, userId: str = "system", keyNam # Log audit event for encryption try: - from modules.shared.auditLogger import audit_logger - audit_logger.logKeyAccess( - userId=userId, - mandateId="system", - keyName=keyName, - action="encrypt" + logging.getLogger("audit.fallback").info( + "AUDIT | %s | %s | system | key | %s | Key: %s", + time.time(), userId, "encrypt", keyName ) except Exception: - # Don't fail if audit logging fails pass return encryptedValue @@ -568,15 +559,11 @@ def decryptValue(encryptedValue: str, userId: str = "system", keyName: str = "un # Log audit event for decryption try: - from modules.shared.auditLogger import audit_logger - audit_logger.logKeyAccess( - userId=userId, - mandateId="system", - keyName=keyName, - action="decrypt" + logging.getLogger("audit.fallback").info( + "AUDIT | %s | %s | system | key | %s | Key: %s", + time.time(), userId, "decrypt", keyName ) except Exception: - # Don't fail if audit logging fails pass # Populate cache so subsequent reads of the same ciphertext don't diff --git a/modules/connectors/_httpResilience.py b/modules/shared/httpResilience.py similarity index 100% rename from modules/connectors/_httpResilience.py rename to modules/shared/httpResilience.py diff --git a/modules/shared/i18nRegistry.py b/modules/shared/i18nRegistry.py index cb2d070f..a72dcd9c 100644 --- a/modules/shared/i18nRegistry.py +++ b/modules/shared/i18nRegistry.py @@ -1,12 +1,14 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. """ -Gateway i18n registry: t(), @i18nModel, boot-sync, in-memory cache. +Gateway i18n registry: t(), @i18nModel, runtime translation cache. All UI-visible texts in the gateway (HTTPException details, model labels, API messages) are tagged with t() and registered at import time. -At boot, the registry is synced to the xx base set in the DB. At runtime, t() returns the cached translation for the current request language. + +Boot-time DB sync and label discovery live in i18nBootSync.py (called by app.py). +This module has ZERO dependencies on other platform-core modules outside shared/. """ from __future__ import annotations @@ -316,7 +318,7 @@ def setLanguage(lang: str): _CURRENT_LANGUAGE.set(lang) -def _getLanguage() -> str: +def getCurrentLanguage() -> str: """Get the language for the current request context.""" return _CURRENT_LANGUAGE.get() @@ -337,579 +339,3 @@ def normalizePrimaryLanguageTag(tag: str, fallback: str = "de") -> str: return primary return fallback - -# --------------------------------------------------------------------------- -# Boot: scan route files for routeApiMsg("…") calls → register eagerly -# --------------------------------------------------------------------------- - -_ROUTE_API_MSG_RE = None # compiled lazily - -def _scanRouteApiMsgKeys(): - """Scan all gateway route/feature Python files for routeApiMsg("…") calls - and register the keys in _REGISTRY so they appear in the boot DB sync. - """ - import re - from pathlib import Path - - global _ROUTE_API_MSG_RE - if _ROUTE_API_MSG_RE is None: - _ROUTE_API_MSG_RE = re.compile( - r"""routeApiMsg\(\s*(['"])((?:\\.|(?!\1).)+)\1""", - ) - - gatewayRoot = Path(__file__).resolve().parents[1] - scanDirs = [gatewayRoot / "routes", gatewayRoot / "features"] - - _ctxRe = re.compile(r'''apiRouteContext\(\s*['"]([^'"]+)['"]\s*\)''') - - for scanDir in scanDirs: - if not scanDir.is_dir(): - continue - for pyFile in scanDir.rglob("*.py"): - try: - src = pyFile.read_text(encoding="utf-8", errors="replace") - except OSError: - continue - ctxMatch = _ctxRe.search(src) - if not ctxMatch: - continue - ctx = f"api.{ctxMatch.group(1)}" - for m in _ROUTE_API_MSG_RE.finditer(src): - key = m.group(2).replace("\\'", "'").replace('\\"', '"') - if key and key not in _REGISTRY: - _REGISTRY[key] = _I18nRegistryEntry(context=ctx, value="") - - logger.info("i18n route scan: %d api.* keys in registry after scan", - sum(1 for e in _REGISTRY.values() if e.context.startswith("api."))) - - -def _registerNavLabels(): - """Register all navigation labels from NAVIGATION_SECTIONS as i18n keys. - - Called at boot before DB sync so that nav labels appear in the xx base set - and can be translated via the Admin UI. - """ - try: - from modules.system.mainSystem import NAVIGATION_SECTIONS - except ImportError: - logger.warning("i18n: could not import NAVIGATION_SECTIONS for nav label registration") - return - - count = 0 - for section in NAVIGATION_SECTIONS: - title = section.get("title", "") - if title and title not in _REGISTRY: - _REGISTRY[title] = _I18nRegistryEntry(context="nav", value="") - count += 1 - - for item in section.get("items", []): - label = item.get("label", "") - if label and label not in _REGISTRY: - _REGISTRY[label] = _I18nRegistryEntry(context="nav", value="") - count += 1 - - for subgroup in section.get("subgroups", []): - sgTitle = subgroup.get("title", "") - if sgTitle and sgTitle not in _REGISTRY: - _REGISTRY[sgTitle] = _I18nRegistryEntry(context="nav", value="") - count += 1 - for item in subgroup.get("items", []): - label = item.get("label", "") - if label and label not in _REGISTRY: - _REGISTRY[label] = _I18nRegistryEntry(context="nav", value="") - count += 1 - - logger.info("i18n nav labels: registered %d nav keys", count) - - -def _registerFeatureUiLabels(): - """Register FEATURE_LABEL and UI_OBJECTS labels from all feature modules (German i18n keys).""" - try: - from modules.system import mainSystem as _mainSystem - _fl = getattr(_mainSystem, "FEATURE_LABEL", None) - if isinstance(_fl, str) and _fl and _fl not in _REGISTRY: - _REGISTRY[_fl] = _I18nRegistryEntry(context="nav", value="") - except ImportError: - pass - - _featureModulePaths = ( - "modules.features.trustee.mainTrustee", - "modules.features.graphicalEditor.mainGraphicalEditor", - "modules.features.commcoach.mainCommcoach", - "modules.features.teamsbot.mainTeamsbot", - "modules.features.workspace.mainWorkspace", - "modules.features.realEstate.mainRealEstate", - "modules.features.neutralization.mainNeutralization", - ) - added = 0 - for modPath in _featureModulePaths: - try: - mod = __import__(modPath, fromlist=["FEATURE_LABEL", "UI_OBJECTS"]) - except ImportError: - continue - fl = getattr(mod, "FEATURE_LABEL", None) - if isinstance(fl, str) and fl and fl not in _REGISTRY: - _REGISTRY[fl] = _I18nRegistryEntry(context="nav", value="") - added += 1 - for uiObj in getattr(mod, "UI_OBJECTS", []) or []: - base = _extractRegistrySourceText(uiObj.get("label")) - if base and base not in _REGISTRY: - _REGISTRY[base] = _I18nRegistryEntry(context="nav", value="") - added += 1 - logger.info("i18n feature UI labels: %d new keys (nav context)", added) - - -def _registerRbacLabels(): - """Register DATA_OBJECTS, RESOURCE_OBJECTS labels and TEMPLATE_ROLES descriptions - from all feature modules and system module as i18n keys. - - context mapping: - - DATA_OBJECTS → rbac.data - - RESOURCE_OBJECTS → rbac.resource - - TEMPLATE_ROLES[].description (xx source) → rbac.role - - QUICK_ACTIONS[].label/description (xx source) → rbac.quickaction - - QUICK_ACTION_CATEGORIES[].label (xx source) → rbac.quickaction - """ - _systemModule = "modules.system.mainSystem" - _featureModulePaths = ( - _systemModule, - "modules.features.trustee.mainTrustee", - "modules.features.graphicalEditor.mainGraphicalEditor", - "modules.features.commcoach.mainCommcoach", - "modules.features.teamsbot.mainTeamsbot", - "modules.features.workspace.mainWorkspace", - "modules.features.realEstate.mainRealEstate", - "modules.features.neutralization.mainNeutralization", - ) - - added = 0 - for modPath in _featureModulePaths: - try: - mod = __import__(modPath, fromlist=[ - "DATA_OBJECTS", "RESOURCE_OBJECTS", "TEMPLATE_ROLES", - "QUICK_ACTIONS", "QUICK_ACTION_CATEGORIES", - ]) - except ImportError: - continue - - for dataObj in getattr(mod, "DATA_OBJECTS", []) or []: - key = _extractRegistrySourceText(dataObj.get("label")) - if key and key not in _REGISTRY: - _REGISTRY[key] = _I18nRegistryEntry(context="rbac.data", value="") - added += 1 - - for resObj in getattr(mod, "RESOURCE_OBJECTS", []) or []: - key = _extractRegistrySourceText(resObj.get("label")) - if key and key not in _REGISTRY: - _REGISTRY[key] = _I18nRegistryEntry(context="rbac.resource", value="") - added += 1 - - for role in getattr(mod, "TEMPLATE_ROLES", []) or []: - key = _extractRegistrySourceText(role.get("description")) - if key and key not in _REGISTRY: - _REGISTRY[key] = _I18nRegistryEntry(context="rbac.role", value="") - added += 1 - - for qa in getattr(mod, "QUICK_ACTIONS", []) or []: - for field in ("label", "description"): - key = _extractRegistrySourceText(qa.get(field)) - if key and key not in _REGISTRY: - _REGISTRY[key] = _I18nRegistryEntry(context="rbac.quickaction", value="") - added += 1 - - for cat in getattr(mod, "QUICK_ACTION_CATEGORIES", []) or []: - key = _extractRegistrySourceText(cat.get("label")) - if key and key not in _REGISTRY: - _REGISTRY[key] = _I18nRegistryEntry(context="rbac.quickaction", value="") - added += 1 - - logger.info("i18n rbac labels: %d new keys (rbac.* context)", added) - - -def _registerServiceCenterLabels(): - """Register service-center category labels and bootstrap role descriptions.""" - added = 0 - - try: - from modules.serviceCenter.registry import IMPORTABLE_SERVICES - for svc in IMPORTABLE_SERVICES.values(): - key = _extractRegistrySourceText(svc.get("label")) - if key and key not in _REGISTRY: - _REGISTRY[key] = _I18nRegistryEntry(context="service", value="") - added += 1 - except ImportError: - pass - - _bootstrapRoleDescriptions = [ - "Administrator - Benutzer und Ressourcen im Mandanten verwalten", - "Benutzer - Standard-Benutzer mit Zugriff auf eigene Datensätze", - "Betrachter - Nur-Lese-Zugriff auf Gruppen-Datensätze", - "System-Administrator - Vollständiger administrativer Zugriff über alle Mandanten", - ] - for desc in _bootstrapRoleDescriptions: - if desc not in _REGISTRY: - _REGISTRY[desc] = _I18nRegistryEntry(context="rbac.role", value="") - added += 1 - - logger.info("i18n service/bootstrap labels: %d new keys", added) - - -def _registerNodeLabels(): - """Register all graph-editor node labels, descriptions, parameter descriptions, - output labels, port descriptions, category labels, and entry-point titles.""" - added = 0 - - def _reg(key: str, ctx: str): - nonlocal added - if key and key not in _REGISTRY: - _REGISTRY[key] = _I18nRegistryEntry(context=ctx, value="") - added += 1 - - try: - from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES - for nd in STATIC_NODE_TYPES: - _reg(_extractRegistrySourceText(nd.get("label")), "node.label") - _reg(_extractRegistrySourceText(nd.get("description")), "node.desc") - - for param in nd.get("parameters", []) or []: - _reg(_extractRegistrySourceText(param.get("description")), "node.param") - _reg(_extractRegistrySourceText(param.get("label")), "node.param") - - outLabels = nd.get("outputLabels") - if isinstance(outLabels, dict): - sourceList = outLabels.get("xx") or next(iter(outLabels.values()), []) - if not isinstance(sourceList, list): - sourceList = [] - for lbl in sourceList: - _reg(lbl, "node.output") - elif isinstance(outLabels, list): - for lbl in outLabels: - _reg(lbl, "node.output") - except ImportError: - pass - - try: - from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG - for schema in PORT_TYPE_CATALOG.values(): - for field in getattr(schema, "fields", []) or []: - desc = getattr(field, "description", None) - if desc: - _reg(_extractRegistrySourceText(desc if isinstance(desc, (str, dict)) else None), "port.desc") - except ImportError: - pass - - _nodeCategoryLabels = [ - "Trigger", "Eingabe/Mensch", "Ablauf", "Daten", "KI", - "Datei", "E-Mail", "SharePoint", "ClickUp", "Treuhand", - ] - for lbl in _nodeCategoryLabels: - _reg(lbl, "node.category") - - _entryPointTitles = ["Jetzt ausführen", "Start"] - for lbl in _entryPointTitles: - _reg(lbl, "node.entry") - - logger.info("i18n node labels: %d new keys (node.*/port.* context)", added) - - -def _registerAccountingConnectorLabels(): - """Register all accounting connector configField labels (label) at boot time. - - Connector ``getRequiredConfigFields()`` is normally invoked lazily at first - request, which is too late for the boot-sync. We discover the connectors - here so their ``t()`` calls register the keys before they are written to the - ``xx`` set and AI-translated for every active language set. - """ - added = 0 - try: - from modules.features.trustee.accounting.accountingRegistry import getAccountingRegistry - except ImportError: - logger.debug("i18n accounting connectors: registry not importable") - return - - try: - registry = getAccountingRegistry() - except Exception as e: - logger.warning("i18n accounting connectors: registry init failed: %s", e) - return - - for connectorType, connector in (registry._connectors or {}).items(): - try: - for field in connector.getRequiredConfigFields(): - key = getattr(field, "label", "") or "" - if not isinstance(key, str) or not key: - continue - if key not in _REGISTRY: - _REGISTRY[key] = _I18nRegistryEntry( - context=f"connector.accounting.{connectorType}", - value="", - ) - added += 1 - except Exception as e: - logger.warning( - "i18n accounting connector %s: failed to read fields: %s", - connectorType, e, - ) - - logger.info("i18n accounting connector labels: %d new keys", added) - - -def _registerDatamodelOptionLabels(): - """Register all frontend_options labels from Pydantic datamodels and subscription plans.""" - added = 0 - - def _reg(key: str, ctx: str): - nonlocal added - if key and key not in _REGISTRY: - _REGISTRY[key] = _I18nRegistryEntry(context=ctx, value="") - added += 1 - - _datamodelModules = ( - "modules.datamodels.datamodelRbac", - "modules.datamodels.datamodelChat", - "modules.datamodels.datamodelMessaging", - "modules.datamodels.datamodelNotification", - "modules.datamodels.datamodelUam", - "modules.datamodels.datamodelFiles", - "modules.datamodels.datamodelDataSource", - "modules.datamodels.datamodelFeatureDataSource", - "modules.datamodels.datamodelUiLanguage", - "modules.datamodels.datamodelViews", - "modules.features.trustee.datamodelFeatureTrustee", - "modules.features.neutralization.datamodelFeatureNeutralizer", - ) - - for modPath in _datamodelModules: - try: - mod = __import__(modPath, fromlist=["__all__"]) - except ImportError: - continue - for attrName in dir(mod): - cls = getattr(mod, attrName, None) - if not isinstance(cls, type) or not issubclass(cls, BaseModel): - continue - for fieldName, fieldInfo in cls.model_fields.items(): - extra = (fieldInfo.json_schema_extra or {}) if hasattr(fieldInfo, "json_schema_extra") else {} - if not isinstance(extra, dict): - continue - options = extra.get("frontend_options") - if not isinstance(options, list): - continue - ctx = f"option.{cls.__name__}.{fieldName}" - for opt in options: - if isinstance(opt, dict): - _reg(_extractRegistrySourceText(opt.get("label")), ctx) - - try: - from modules.datamodels.datamodelSubscription import BUILTIN_PLANS - for plan in BUILTIN_PLANS.values(): - _reg(_extractRegistrySourceText(getattr(plan, "title", None)), "subscription.title") - _reg(_extractRegistrySourceText(getattr(plan, "description", None)), "subscription.desc") - except (ImportError, AttributeError): - pass - - logger.info("i18n datamodel option labels: %d new keys", added) - - -# --------------------------------------------------------------------------- -# Boot: sync registry to DB -# --------------------------------------------------------------------------- - -async def syncRegistryToDb(): - """Boot hook: write all registered keys into UiLanguageSet(xx). - - 1. Scans route files for routeApiMsg("…") to eagerly register api.* keys. - 2. Registers navigation labels as nav.* keys. - 3. Registers feature UI labels (FEATURE_LABEL, UI_OBJECTS). - 4. Registers RBAC labels (DATA/RESOURCE/ROLE/QuickAction). - 5. Merges with existing UI keys (context="ui"), only touches gateway keys. - """ - _scanRouteApiMsgKeys() - _registerNavLabels() - _registerFeatureUiLabels() - _registerRbacLabels() - _registerServiceCenterLabels() - _registerNodeLabels() - _registerDatamodelOptionLabels() - _registerAccountingConnectorLabels() - - if not _REGISTRY: - logger.info("i18n registry: no keys to sync (empty registry)") - return - - from modules.datamodels.datamodelUiLanguage import UiLanguageSet - from modules.shared.configuration import APP_CONFIG - from modules.connectors.connectorDbPostgre import getCachedConnector - from modules.shared.timeUtils import getUtcTimestamp - - db = getCachedConnector( - dbHost=APP_CONFIG.get("DB_HOST", "localhost"), - dbDatabase="poweron_management", - dbUser=APP_CONFIG.get("DB_USER"), - dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET"), - dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), - userId="__i18n_boot__", - ) - - rows = db.getRecordset(UiLanguageSet, recordFilter={"id": "xx"}) - - gatewayEntries = [ - {"context": entry.context, "key": key, "value": entry.value} - for key, entry in _REGISTRY.items() - ] - gatewayKeys = set(_REGISTRY.keys()) - - if not rows: - now = getUtcTimestamp() - rec = { - "id": "xx", - "label": "Basisset (Meta)", - "entries": gatewayEntries, - "status": "complete", - "isDefault": True, - "sysCreatedAt": now, - "sysCreatedBy": "__i18n_boot__", - "sysModifiedAt": now, - "sysModifiedBy": "__i18n_boot__", - } - db.recordCreate(UiLanguageSet, rec) - logger.info("i18n boot-sync: created xx set with %d gateway keys", len(gatewayEntries)) - return - - row = dict(rows[0]) - existingEntries: List[dict] = row.get("entries") or [] - if not isinstance(existingEntries, list): - existingEntries = [] - - uiEntries = [e for e in existingEntries if e.get("context", "") == "ui"] - - oldGatewayEntries = [ - e for e in existingEntries - if e.get("context", "") != "ui" - ] - oldGatewayByKey = {e["key"]: e for e in oldGatewayEntries} - - added = 0 - updated = 0 - removed = 0 - - newGatewayEntries: List[dict] = [] - for key, entry in _REGISTRY.items(): - newEntry = {"context": entry.context, "key": key, "value": entry.value} - old = oldGatewayByKey.get(key) - if old is None: - added += 1 - elif old.get("context") != entry.context or old.get("value") != entry.value: - updated += 1 - newGatewayEntries.append(newEntry) - - removed = len(set(oldGatewayByKey.keys()) - gatewayKeys) - - mergedEntries = uiEntries + newGatewayEntries - - if added == 0 and updated == 0 and removed == 0: - logger.info("i18n boot-sync: xx set up-to-date (%d gateway + %d ui keys)", len(newGatewayEntries), len(uiEntries)) - return - - now = getUtcTimestamp() - row["entries"] = mergedEntries - if "keys" in row: - del row["keys"] - row["sysModifiedAt"] = now - row["sysModifiedBy"] = "__i18n_boot__" - db.recordModify(UiLanguageSet, "xx", row) - - logger.info( - "i18n boot-sync: xx updated (+%d added, ~%d updated, -%d removed, total=%d gateway + %d ui)", - added, updated, removed, len(newGatewayEntries), len(uiEntries), - ) - - -# --------------------------------------------------------------------------- -# Boot: load translation cache -# --------------------------------------------------------------------------- - -async def loadCache(): - """Boot hook: load all UiLanguageSets into the in-memory cache. - - Also persistently repairs placeholder mismatches in the DB: - if an entry's value has placeholder *names* that differ from the - source key (typical AI translation mishap, e.g. ``{konten}`` -> - ``{accounts}``), the source names are restored positionally and the - row is written back to the DB. Idempotent and safe -- only mutates - when the placeholder count matches and the names actually differ. - - After this, t() lookups are O(1) dict access with no DB calls. - """ - from modules.datamodels.datamodelUiLanguage import UiLanguageSet - from modules.shared.configuration import APP_CONFIG - from modules.connectors.connectorDbPostgre import getCachedConnector - - db = getCachedConnector( - dbHost=APP_CONFIG.get("DB_HOST", "localhost"), - dbDatabase="poweron_management", - dbUser=APP_CONFIG.get("DB_USER"), - dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET"), - dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), - userId="__i18n_cache__", - ) - - rows = db.getRecordset(UiLanguageSet) - _CACHE.clear() - - repairedTotal = 0 - persistedLanguages = 0 - for row in rows: - code = row.get("id", "") - if code == "xx": - continue - entries = row.get("entries") - if not isinstance(entries, list): - continue - langDict: Dict[str, str] = {} - repairedInLang = 0 - # Walk a mutable copy so we can write the corrected entries back to - # the row without re-reading from the DB. - for entry in entries: - key = entry.get("key", "") - val = entry.get("value", "") - if not key or not val: - continue - fixed, changed = _enforceSourcePlaceholders(key, val) - if changed: - entry["value"] = fixed - repairedInLang += 1 - langDict[key] = fixed - if langDict: - _CACHE[code] = langDict - if repairedInLang: - repairedTotal += repairedInLang - try: - rowToSave = dict(row) - rowToSave["entries"] = entries - if "keys" in rowToSave: - del rowToSave["keys"] - db.recordModify(UiLanguageSet, code, rowToSave) - persistedLanguages += 1 - logger.info( - "i18n boot repair: fixed and persisted %d placeholder mismatches in language '%s'", - repairedInLang, code, - ) - except Exception as ex: - # Persistence is best-effort -- the in-memory cache is - # already correct (langDict above contains the fixed - # values), so the UI works either way. Log and move on. - logger.warning( - "i18n boot repair: in-memory fixed %d entries in '%s' but DB persist failed: %s", - repairedInLang, code, ex, - ) - - logger.info( - "i18n cache loaded: %d languages, %d total keys%s", - len(_CACHE), sum(len(v) for v in _CACHE.values()), - ( - f" (boot-repaired {repairedTotal} placeholders, " - f"persisted to {persistedLanguages} language sets)" - if repairedTotal else "" - ), - ) diff --git a/modules/shared/jsonContinuation-logic.md b/modules/shared/jsonContinuation-logic.md deleted file mode 100644 index b7e93cb4..00000000 --- a/modules/shared/jsonContinuation-logic.md +++ /dev/null @@ -1,164 +0,0 @@ -# JSON Continuation Context Module - -Ein Python-Modul zur Generierung von Kontextinformationen für abgeschnittene JSON-Strings, um AI-Modellen die Fortsetzung zu ermöglichen. - -## Problem - -Wenn eine AI-Antwort als JSON abgeschnitten wird (z.B. Token-Limit erreicht), muss die nächste Iteration wissen: -- **Wo** der JSON abgeschnitten wurde -- **Was** bereits generiert wurde -- **Was** als nächstes geliefert werden soll - -## Lösung: Drei Kontexte - -### 1. Overlap Context -- Zeigt das **innerste Objekt/Array-Element**, das den Cut-Punkt enthält -- Wird verwendet, um den abgeschnittenen Teil mit dem neuen Teil zu **mergen** -- Exakt so wie im Original-String (für String-Matching beim Merge) - -### 2. Hierarchy Context -- Zeigt die **hierarchische Struktur** vom Root bis zum Cut-Punkt -- Mit **Budget-Logik**: Näher am Cut = vollständige Werte, weiter weg = `"..."` Platzhalter -- Gibt der AI den Kontext der gesamten JSON-Struktur - -### 3. Complete Part (NEU) -- Der **vollständige, valide JSON** bis zum Cut-Punkt -- Alle offenen Strukturen werden geschlossen (`}`, `]`, `"`) -- Unvollständige Keys werden entfernt -- Kann direkt als valides JSON geparst werden - -## Installation - -```bash -# Keine externen Abhängigkeiten erforderlich -cp json_continuation.py /your/project/ -``` - -## Modulkonstanten - -```python -# Diese Konstanten können vor dem Import angepasst werden -BUDGET_LIMIT: int = 500 # Zeichen-Budget für Datenwerte -OVERLAP_MAX_CHARS: int = 1000 # Max Zeichen für Overlap Context -``` - -## Verwendung - -### Grundlegende Verwendung - -```python -from json_continuation import extract_continuation_contexts - -truncated_json = '''{"customers": [ - {"id": 1, "name": "John"}, - {"id": 2, "name": "Jane", "email": "jane@exa''' - -overlap, hierarchy, complete = extract_continuation_contexts(truncated_json) - -print("Overlap Context:") -print(overlap) -# {"id": 2, "name": "Jane", "email": "jane@exa - -print("Hierarchy Context:") -print(hierarchy) -# {"customers": [...structure with budget logic...] - -print("Complete Part (valid JSON):") -print(complete) -# {"customers": [{"id": 1, "name": "John"}, {"id": 2, "name": "Jane", "email": "jane@exa"}]} - -import json -parsed = json.loads(complete) # ✓ Funktioniert! -``` - -### Mit Dictionary-Interface - -```python -from json_continuation import get_contexts - -contexts = get_contexts(truncated_json) - -print(contexts['overlap']) -print(contexts['hierarchy']) -print(contexts['complete_part']) -``` - -### Konstanten anpassen - -```python -import json_continuation - -# Budget anpassen bevor Funktionen aufgerufen werden -json_continuation.BUDGET_LIMIT = 200 -json_continuation.OVERLAP_MAX_CHARS = 500 - -overlap, hierarchy, complete = json_continuation.extract_continuation_contexts(truncated_json) -``` - -## Rückgabewerte - -| Rückgabe | Typ | Beschreibung | -|----------|-----|--------------| -| `overlap` | str | Innerstes Element mit Cut-Punkt (für Merge) | -| `hierarchy` | str | Volle Struktur mit Budget-Logik | -| `complete_part` | str | Valides JSON mit geschlossenen Strukturen | - -## Beispiele - -### Verschachtelte Objekte - -```python -json_str = '{"user": {"profile": {"bio": "Hello Wor' - -overlap, hierarchy, complete = extract_continuation_contexts(json_str) - -# Overlap: {"bio": "Hello Wor -# Hierarchy: {"user": {"profile": {"bio": "Hello Wor -# Complete: {"user": {"profile": {"bio": "Hello Wor"}}} ← Valides JSON! -``` - -### Array von Objekten mit unvollständigem Key - -```python -json_str = '''{ - "items": [ - {"id": 1, "name": "First"}, - {"id": 2, "name": "Second"}, - {"id": 3, "name": "Third", "add''' - -overlap, hierarchy, complete = extract_continuation_contexts(json_str) - -# Complete entfernt den unvollständigen Key "add": -# {"items": [{"id": 1, ...}, {"id": 2, ...}, {"id": 3, "name": "Third"}]} -``` - -## Budget-Logik - -Die Budget-Logik funktioniert wie folgt: - -1. **Sammeln**: Alle String-Werte werden mit ihrer Position gesammelt -2. **Sortieren**: Nach Entfernung zum Cut-Punkt (näher = höhere Priorität) -3. **Zuweisen**: Budget wird von hinten nach vorne aufgebraucht -4. **Ersetzen**: Werte außerhalb des Budgets werden durch `"..."` ersetzt - -## Tests ausführen - -```bash -python -m unittest test_json_continuation -v -``` - -## API Referenz - -### `extract_continuation_contexts(truncated_json: str) -> Tuple[str, str, str]` - -Hauptfunktion. Gibt `(overlap, hierarchy, complete_part)` zurück. - -### `get_contexts(truncated_json: str) -> dict` - -Convenience-Funktion. Gibt Dictionary mit Keys `'overlap'`, `'hierarchy'`, `'complete_part'` zurück. - -### Modulkonstanten - -- `BUDGET_LIMIT`: int (default: 500) - Zeichen-Budget für Hierarchy-Context -- `OVERLAP_MAX_CHARS`: int (default: 1000) - Max Zeichen für Overlap-Context - diff --git a/modules/shared/jsonUtils.py b/modules/shared/jsonUtils.py index 37c1ae36..ea3c0200 100644 --- a/modules/shared/jsonUtils.py +++ b/modules/shared/jsonUtils.py @@ -865,8 +865,10 @@ def buildContinuationContext( allSections: List[Dict[str, Any]], lastRawResponse: Optional[str] = None, useCaseId: Optional[str] = None, - templateStructure: Optional[str] = None -) -> "ContinuationContext": + templateStructure: Optional[str] = None, + continuationContextClass=None, + getContextsFn=None +): """ Build context information from accumulated sections for continuation prompt. @@ -877,12 +879,12 @@ def buildContinuationContext( lastRawResponse: Raw JSON response from last iteration (can be broken/incomplete) useCaseId: Optional use case ID to determine expected JSON structure templateStructure: JSON structure template from initial prompt (MUST be identical) + continuationContextClass: Pydantic model class to construct the result (e.g. ContinuationContext) + getContextsFn: Function to extract continuation contexts from JSON string (from jsonContinuation) Returns: - ContinuationContext: Pydantic model with all continuation context information + Instance of continuationContextClass if provided, otherwise a plain dict """ - # Lazy import to avoid circular dependency - from modules.datamodels.datamodelAi import ContinuationContext section_count = len(allSections) # Build summary of delivered data (per-section counts) @@ -1010,10 +1012,8 @@ def buildContinuationContext( overlap_context = "" hierarchy_context = "" - if lastRawResponse: + if lastRawResponse and getContextsFn is not None: try: - from modules.shared.jsonContinuation import getContexts - # Normalize JSON string normalized = stripCodeFences(normalizeJsonText(lastRawResponse)).strip() if normalized: @@ -1026,7 +1026,7 @@ def buildContinuationContext( if startIdx >= 0: jsonContent = normalized[startIdx:] - contexts = getContexts(jsonContent) + contexts = getContextsFn(jsonContent) # Store all contexts from centralized module last_complete_part = contexts.completePart @@ -1036,8 +1036,7 @@ def buildContinuationContext( except Exception as e: logger.warning(f"Error extracting JSON continuation contexts: {e}", exc_info=True) - # Return ContinuationContext Pydantic model - return ContinuationContext( + contextData = dict( section_count=section_count, delivered_summary=delivered_summary, template_structure=templateStructure, @@ -1047,6 +1046,9 @@ def buildContinuationContext( overlap_context=overlap_context, hierarchy_context=hierarchy_context ) + if continuationContextClass is not None: + return continuationContextClass(**contextData) + return contextData def parseJsonWithModel(jsonString: str, modelClass: Type[T]) -> T: """ diff --git a/modules/shared/serviceExceptions.py b/modules/shared/serviceExceptions.py new file mode 100644 index 00000000..2aa94d95 --- /dev/null +++ b/modules/shared/serviceExceptions.py @@ -0,0 +1,146 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Shared service exception classes. + +Centralises the three cross-layer exception types so that both the +serviceCenter layer and the workflows/interfaces layers can import them +from one place without creating circular dependencies. +""" + +from typing import Dict, Any, Optional + +from modules.shared.i18nRegistry import t +from modules.datamodels.datamodelSubscription import SubscriptionStatusEnum + + +# ============================================================================ +# Subscription action / reason constants +# ============================================================================ + +SUBSCRIPTION_USER_ACTION_UPGRADE = "UPGRADE_SUBSCRIPTION" +SUBSCRIPTION_USER_ACTION_REACTIVATE = "REACTIVATE_SUBSCRIPTION" +SUBSCRIPTION_USER_ACTION_ADD_PAYMENT = "ADD_PAYMENT_METHOD" +SUBSCRIPTION_USER_ACTION_CONTACT_ADMIN = "CONTACT_ADMIN" + +SUBSCRIPTION_REASONS = { + "SUBSCRIPTION_INACTIVE", + "SUBSCRIPTION_PAYMENT_REQUIRED", + "SUBSCRIPTION_PAYMENT_PENDING", + "SUBSCRIPTION_EXPIRED", +} + + +# ============================================================================ +# Subscription helper functions +# ============================================================================ + +def _subscriptionReasonForStatus(status: SubscriptionStatusEnum) -> str: + if status == SubscriptionStatusEnum.PENDING: + return "SUBSCRIPTION_PAYMENT_PENDING" + if status == SubscriptionStatusEnum.PAST_DUE: + return "SUBSCRIPTION_PAYMENT_REQUIRED" + if status == SubscriptionStatusEnum.EXPIRED: + return "SUBSCRIPTION_EXPIRED" + return "SUBSCRIPTION_INACTIVE" + + +def _subscriptionUserActionForStatus(status: SubscriptionStatusEnum) -> str: + if status in (SubscriptionStatusEnum.PAST_DUE, SubscriptionStatusEnum.PENDING): + return SUBSCRIPTION_USER_ACTION_ADD_PAYMENT + return SUBSCRIPTION_USER_ACTION_UPGRADE + + +def _subscriptionLimitsHint() -> str: + return " " + t( + "Details zu Ihrem Abonnement, den enthaltenen Limits und Upgrade-Optionen: " + "Menü «Administration» → «Billing» → Registerkarte «Abonnement»." + ) + + +def _enterpriseLimitsHint() -> str: + return " " + t( + "Ihr Enterprise-Abonnement wird vom Plattform-Administrator verwaltet. " + "Bitte kontaktieren Sie den Administrator für eine Anpassung der Limiten." + ) + + +# ============================================================================ +# Exception classes +# ============================================================================ + +class SubscriptionInactiveException(Exception): + def __init__(self, status: SubscriptionStatusEnum, mandateId: str = "", message: Optional[str] = None): + self.status = status + self.mandateId = mandateId + self.reason = _subscriptionReasonForStatus(status) + self.userAction = _subscriptionUserActionForStatus(status) + self.message = message or t( + "Kein aktives Abonnement für diesen Mandanten. Bitte wählen Sie einen Plan unter Billing." + ) + super().__init__(self.message) + + def toClientDict(self) -> Dict[str, Any]: + out: Dict[str, Any] = { + "error": self.reason, "message": self.message, + "userAction": self.userAction, "subscriptionUiPath": "/admin/billing?tab=subscription", + } + if self.mandateId: + out["mandateId"] = self.mandateId + return out + + +class SubscriptionCapacityException(Exception): + def __init__(self, resourceType: str, currentCount: int, maxAllowed: int, + message: Optional[str] = None, isEnterprise: bool = False): + self.resourceType = resourceType + self.currentCount = currentCount + self.maxAllowed = maxAllowed + self.isEnterprise = isEnterprise + hint = _enterpriseLimitsHint() if isEnterprise else _subscriptionLimitsHint() + if message is not None: + self.message = message + elif resourceType == "users": + self.message = t( + "Mit dem aktuellen Abonnement sind für diesen Mandanten höchstens {maxAllowed} " + "Benutzer zulässig (derzeit {currentCount}). " + "Ohne Planwechsel können keine weiteren Benutzer hinzugefügt werden." + ).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint + elif resourceType == "featureInstances": + self.message = t( + "Es sind höchstens {maxAllowed} aktive Module erlaubt (derzeit {currentCount}). " + "Bitte Abonnement erweitern oder ein Modul entfernen." + ).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint + elif resourceType == "dataVolumeMB": + self.message = t( + "Das im Abonnement enthaltene Datenvolumen ({maxAllowed} MB) reicht nicht " + "(aktuell ca. {currentCount} MB). Bitte Speicher-Limit oder Plan anpassen." + ).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint + else: + self.message = t( + "Abonnement-Limit überschritten (Ressource «{resourceType}»: " + "aktuell {currentCount}, erlaubt {maxAllowed})." + ).format(resourceType=resourceType, currentCount=currentCount, maxAllowed=maxAllowed) + hint + super().__init__(self.message) + + def toClientDict(self) -> Dict[str, Any]: + action = SUBSCRIPTION_USER_ACTION_CONTACT_ADMIN if self.isEnterprise else SUBSCRIPTION_USER_ACTION_UPGRADE + return { + "error": f"SUBSCRIPTION_{self.resourceType.upper()}_LIMIT", + "currentCount": self.currentCount, "maxAllowed": self.maxAllowed, + "message": self.message, "userAction": action, + "subscriptionUiPath": "/admin/billing?tab=subscription", + } + + +class BillingContextError(Exception): + """Raised when billing context is incomplete (missing mandateId, user, etc.). + + This is a FAIL-SAFE error: AI calls MUST NOT proceed without valid billing context. + Acts like a 0 CHF credit card pre-authorization check - validates that billing + CAN be recorded before any expensive AI operation starts. + """ + + def __init__(self, message: str = None): + self.message = message or "Billing context incomplete - AI call blocked" + super().__init__(self.message) diff --git a/modules/shared/workflowState.py b/modules/shared/workflowState.py new file mode 100644 index 00000000..069645b9 --- /dev/null +++ b/modules/shared/workflowState.py @@ -0,0 +1,47 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Workflow State +Shared utilities for workflow state management and validation. +""" + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +class WorkflowStoppedException(Exception): + """Exception raised when a workflow is stopped by the user.""" + pass + + +def checkWorkflowStopped(services: Any) -> None: + """ + Check if workflow has been stopped by user and raise exception if so. + + Args: + services: Services object with workflow and interfaceDbChat for fresh status check + + Raises: + WorkflowStoppedException: If workflow status is "stopped" + """ + workflow = getattr(services, 'workflow', None) + if not workflow or not hasattr(workflow, 'id') or workflow.id is None: + return + + try: + # Get the current workflow status from the database to avoid stale data + currentWorkflow = services.interfaceDbChat.getWorkflow(workflow.id) + if currentWorkflow and currentWorkflow.status == "stopped": + logger.info("Workflow stopped by user, aborting operation") + raise WorkflowStoppedException("Workflow was stopped by user") + except WorkflowStoppedException: + # Re-raise the stop signal immediately + raise + except Exception as e: + # If we can't get the current status due to other database issues, fall back to the in-memory object + logger.warning(f"Could not check current workflow status from database: {str(e)}") + if workflow and workflow.status == "stopped": + logger.info("Workflow stopped by user (from in-memory object), aborting operation") + raise WorkflowStoppedException("Workflow was stopped by user") diff --git a/modules/system/databaseHealth.py b/modules/system/databaseHealth.py index 80a19ac9..111cc592 100644 --- a/modules/system/databaseHealth.py +++ b/modules/system/databaseHealth.py @@ -6,9 +6,12 @@ Database health utilities — table statistics and orphan detection/cleanup. All functions are intended for SysAdmin use only (access control in the route layer). """ +import datetime +import decimal import logging -import time import threading +import time +import uuid from dataclasses import dataclass, asdict from typing import Dict, List, Optional, Set @@ -16,8 +19,8 @@ import psycopg2 import psycopg2.extras from modules.shared.configuration import APP_CONFIG -from modules.shared.dbRegistry import getRegisteredDatabases -from modules.shared.fkRegistry import getFkRelationships, FkRelationship +from modules.dbHelpers.dbRegistry import getRegisteredDatabases +from modules.dbHelpers.fkRegistry import getFkRelationships, FkRelationship logger = logging.getLogger(__name__) @@ -92,7 +95,7 @@ class OrphanCleanupRefused(Exception): # Low-level DB helpers (read-only, lightweight connections) # --------------------------------------------------------------------------- -def _getConnection(dbName: str): +def getConnection(dbName: str): """Open a psycopg2 connection to the given registered database.""" registeredDbs = getRegisteredDatabases() configPrefix = registeredDbs.get(dbName) @@ -133,7 +136,7 @@ def _getTableStats(dbFilter: Optional[str] = None) -> List[dict]: results: List[dict] = [] for dbName in sorted(registeredDbs): try: - conn = _getConnection(dbName) + conn = getConnection(dbName) try: with conn.cursor() as cur: cur.execute(""" @@ -309,7 +312,7 @@ def _scanOrphans(dbFilter: Optional[str] = None) -> List[dict]: def _ensureConn(dbName: str): if dbName not in connCache: - connCache[dbName] = _getConnection(dbName) + connCache[dbName] = getConnection(dbName) return connCache[dbName] def _existingTables(dbName: str) -> Set[str]: @@ -473,14 +476,14 @@ def _cleanOrphans(db: str, table: str, column: str, force: bool = False) -> int: f"excluded from orphan deletion." ) - conn = _getConnection(rel.sourceDb) + conn = getConnection(rel.sourceDb) targetConn = None try: if rel.sourceDb == rel.targetDb: targetRowCount = _countRows(conn, rel.targetTable) parentIds: Optional[Set[str]] = None else: - targetConn = _getConnection(rel.targetDb) + targetConn = getConnection(rel.targetDb) targetRowCount = _countRows(targetConn, rel.targetTable) parentIds = _loadParentIds(targetConn, rel.targetTable, rel.targetColumn) @@ -690,7 +693,7 @@ def _listOrphans( safeLimit = max(1, min(int(limit), 10000)) - sourceConn = _getConnection(rel.sourceDb) + sourceConn = getConnection(rel.sourceDb) targetConn = None try: sourceColumns = _loadPhysicalColumns(sourceConn, rel.sourceTable) @@ -715,7 +718,7 @@ def _listOrphans( """, (safeLimit,)) rows = cur.fetchall() else: - targetConn = _getConnection(rel.targetDb) + targetConn = getConnection(rel.targetDb) targetColumns = _loadPhysicalColumns(targetConn, rel.targetTable) if rel.targetColumn not in targetColumns: return [] @@ -751,7 +754,7 @@ def _listOrphans( out: List[dict] = [] for row in rows: - rowDict = {k: _jsonSafe(v) for k, v in dict(row).items()} + rowDict = {k: jsonSafe(v) for k, v in dict(row).items()} out.append(asdict(OrphanRecord( sourceDb=rel.sourceDb, sourceTable=rel.sourceTable, @@ -766,12 +769,8 @@ def _listOrphans( return out -def _jsonSafe(v): +def jsonSafe(v): """Coerce psycopg2 row values into JSON-serialisable primitives.""" - import datetime - import decimal - import uuid - if v is None or isinstance(v, (str, int, float, bool)): return v if isinstance(v, (datetime.datetime, datetime.date, datetime.time)): @@ -781,9 +780,9 @@ def _jsonSafe(v): if isinstance(v, uuid.UUID): return str(v) if isinstance(v, (list, tuple)): - return [_jsonSafe(x) for x in v] + return [jsonSafe(x) for x in v] if isinstance(v, dict): - return {str(k): _jsonSafe(val) for k, val in v.items()} + return {str(k): jsonSafe(val) for k, val in v.items()} if isinstance(v, (bytes, bytearray, memoryview)): try: return bytes(v).decode("utf-8", errors="replace") @@ -806,9 +805,9 @@ 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.shared.fkRegistry import _ensureModelsLoaded + from modules.dbHelpers.fkRegistry import ensureModelsLoaded - _ensureModelsLoaded() + ensureModelsLoaded() registeredDbs = getRegisteredDatabases() results: List[dict] = [] @@ -816,7 +815,7 @@ def _discoverLegacyTables(dbFilter: Optional[str] = None) -> List[dict]: if dbFilter and dbName != dbFilter: continue try: - conn = _getConnection(dbName) + conn = getConnection(dbName) except Exception as e: logger.warning("Legacy scan: cannot connect to %s: %s", dbName, e) continue @@ -855,15 +854,15 @@ 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.shared.fkRegistry import _ensureModelsLoaded + from modules.dbHelpers.fkRegistry import ensureModelsLoaded - _ensureModelsLoaded() + ensureModelsLoaded() if tableName in MODEL_REGISTRY: raise ValueError( f"Table '{dbName}.{tableName}' is backed by a Pydantic model and cannot be dropped via legacy cleanup." ) - conn = _getConnection(dbName) + conn = getConnection(dbName) try: with conn.cursor() as cur: cur.execute(""" diff --git a/modules/system/databaseMigration.py b/modules/system/databaseMigration.py index 2607eb6b..4227529e 100644 --- a/modules/system/databaseMigration.py +++ b/modules/system/databaseMigration.py @@ -14,6 +14,7 @@ All functions are intended for SysAdmin use only (access control in the route la import json import logging +import os from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Set, Tuple @@ -21,10 +22,10 @@ import psycopg2 import psycopg2.extras from modules.shared.configuration import APP_CONFIG -from modules.shared.dbRegistry import getRegisteredDatabases -from modules.shared.fkRegistry import getFkRelationships +from modules.dbHelpers.dbRegistry import getRegisteredDatabases +from modules.dbHelpers.fkRegistry import getFkRelationships from modules.datamodels.datamodelBase import MODEL_REGISTRY -from modules.system.databaseHealth import _getConnection, _jsonSafe +from modules.system.databaseHealth import getConnection, jsonSafe logger = logging.getLogger(__name__) @@ -58,7 +59,7 @@ def _getAvailableDatabases() -> List[dict]: continue entry: dict = {"name": dbName, "tableCount": 0, "recordCount": 0} try: - conn = _getConnection(dbName) + conn = getConnection(dbName) try: with conn.cursor() as cur: cur.execute(""" @@ -137,7 +138,7 @@ def _getModelTablesForDb(dbName: str, physicalTables: List[str]) -> List[str]: def _exportSingleDb(dbName: str) -> dict: - conn = _getConnection(dbName) + conn = getConnection(dbName) excluded = _EXCLUDED_TABLES.get(dbName, set()) try: allTables = _listTables(conn) @@ -178,7 +179,7 @@ def _listTables(conn) -> List[str]: def _readTableRows(conn, tableName: str) -> List[dict]: with conn.cursor() as cur: cur.execute(f'SELECT * FROM "{tableName}"') - return [{k: _jsonSafe(v) for k, v in dict(row).items()} for row in cur.fetchall()] + return [{k: jsonSafe(v) for k, v in dict(row).items()} for row in cur.fetchall()] # --------------------------------------------------------------------------- @@ -195,8 +196,6 @@ def streamExportGenerator(databases: List[str], instanceLabel: str = ""): The output format is identical to the non-streaming _exportDatabases(): {"meta": {...}, "databases": {"dbName": {"tables": {"tbl": [rows]}, ...}}} """ - import json - registeredDbs = getRegisteredDatabases() validDbs = [db for db in databases if db in registeredDbs] @@ -219,7 +218,7 @@ def streamExportGenerator(databases: List[str], instanceLabel: str = ""): excluded = _EXCLUDED_TABLES.get(dbName, set()) conn = None try: - conn = _getConnection(dbName) + conn = getConnection(dbName) allTables = _listTables(conn) modelTables = _getModelTablesForDb(dbName, allTables) @@ -255,7 +254,7 @@ def streamExportGenerator(databases: List[str], instanceLabel: str = ""): if not firstRow: yield ',' firstRow = False - safeRow = {k: _jsonSafe(v) for k, v in dict(row).items()} + safeRow = {k: jsonSafe(v) for k, v in dict(row).items()} yield json.dumps(safeRow, ensure_ascii=False, default=str) rowCount += 1 @@ -379,7 +378,7 @@ def _loadLiveSystemObjectIds() -> Dict[str, str]: return {} result: Dict[str, str] = {} - conn = _getConnection("poweron_app") + conn = getConnection("poweron_app") try: with conn.cursor() as cur: cur.execute("""SELECT id FROM "Mandate" WHERE "name" = 'root' AND "isSystem" = true LIMIT 1""") @@ -528,7 +527,7 @@ def _importDatabases(payload: dict, mode: str) -> dict: tables = dbData.get("tables", {}) dbResult: Dict[str, int] = {} - conn = _getConnection(dbName) + conn = getConnection(dbName) try: conn.autocommit = False existingTables = set(_listTables(conn)) @@ -649,12 +648,10 @@ def _insertRows( def _pgSafe(v: Any) -> Any: """Convert Python values to psycopg2-compatible types.""" - import json as _json - if v is None or isinstance(v, (str, int, float, bool)): return v if isinstance(v, (dict, list)): - return _json.dumps(v) + return json.dumps(v) return str(v) @@ -856,7 +853,7 @@ def _importSingleDb(payload: dict, dbName: str, mode: str, protectedIds: List[st if dbCreated: warnings.append(f"Datenbank '{dbName}' wurde neu erstellt") - conn = _getConnection(dbName) + conn = getConnection(dbName) try: existingTables = set(_listTables(conn)) conn.rollback() @@ -1150,8 +1147,6 @@ def _streamSplitToFiles( Returns ``{dbName: {tableName: filePath}}``. """ - import os - remapSet = set(remap.keys()) if remap else set() dbFiles: Dict[str, Dict[str, str]] = {} writers: Dict[Tuple[str, str], Any] = {} @@ -1236,7 +1231,7 @@ def _importSingleDbFromFiles( if dbCreated: warnings.append(f"Datenbank '{dbName}' wurde neu erstellt") - conn = _getConnection(dbName) + conn = getConnection(dbName) try: existingTables = set(_listTables(conn)) conn.rollback() diff --git a/modules/shared/gdprDeletion.py b/modules/system/gdprDeletion.py similarity index 100% rename from modules/shared/gdprDeletion.py rename to modules/system/gdprDeletion.py diff --git a/modules/system/i18nBootSync.py b/modules/system/i18nBootSync.py new file mode 100644 index 00000000..e60aa4d3 --- /dev/null +++ b/modules/system/i18nBootSync.py @@ -0,0 +1,563 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +i18n boot-time logic: label discovery, DB sync, and cache loading. + +Called once at app startup (from app.py). This module MAY import from +system, features, serviceCenter, connectors — it runs after all modules +are importable and is never imported at module-level by datamodels or shared. +""" + +from __future__ import annotations + +import logging +import re +from pathlib import Path +from typing import Any, Dict, List, Type + +from pydantic import BaseModel + +from modules.shared.i18nRegistry import ( + _CACHE, + _CURRENT_LANGUAGE, + _enforceSourcePlaceholders, + _extractRegistrySourceText, + _I18nRegistryEntry, + _REGISTRY, + t, +) + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Boot: scan route files for routeApiMsg("…") calls → register eagerly +# --------------------------------------------------------------------------- + +_ROUTE_API_MSG_RE = None # compiled lazily + + +def _scanRouteApiMsgKeys(): + """Scan all gateway route/feature Python files for routeApiMsg("…") calls + and register the keys in _REGISTRY so they appear in the boot DB sync. + """ + global _ROUTE_API_MSG_RE + if _ROUTE_API_MSG_RE is None: + _ROUTE_API_MSG_RE = re.compile( + r"""routeApiMsg\(\s*(['"])((?:\\.|(?!\1).)+)\1""", + ) + + gatewayRoot = Path(__file__).resolve().parents[1] + scanDirs = [gatewayRoot / "routes", gatewayRoot / "features"] + + _ctxRe = re.compile(r'''apiRouteContext\(\s*['"]([^'"]+)['"]\s*\)''') + + for scanDir in scanDirs: + if not scanDir.is_dir(): + continue + for pyFile in scanDir.rglob("*.py"): + try: + src = pyFile.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + ctxMatch = _ctxRe.search(src) + if not ctxMatch: + continue + ctx = f"api.{ctxMatch.group(1)}" + for m in _ROUTE_API_MSG_RE.finditer(src): + key = m.group(2).replace("\\'", "'").replace('\\"', '"') + if key and key not in _REGISTRY: + _REGISTRY[key] = _I18nRegistryEntry(context=ctx, value="") + + logger.info("i18n route scan: %d api.* keys in registry after scan", + sum(1 for e in _REGISTRY.values() if e.context.startswith("api."))) + + +def _registerNavLabels(): + """Register all navigation labels from NAVIGATION_SECTIONS as i18n keys.""" + try: + from modules.system.mainSystem import NAVIGATION_SECTIONS + except ImportError: + logger.warning("i18n: could not import NAVIGATION_SECTIONS for nav label registration") + return + + count = 0 + for section in NAVIGATION_SECTIONS: + title = section.get("title", "") + if title and title not in _REGISTRY: + _REGISTRY[title] = _I18nRegistryEntry(context="nav", value="") + count += 1 + + for item in section.get("items", []): + label = item.get("label", "") + if label and label not in _REGISTRY: + _REGISTRY[label] = _I18nRegistryEntry(context="nav", value="") + count += 1 + + for subgroup in section.get("subgroups", []): + sgTitle = subgroup.get("title", "") + if sgTitle and sgTitle not in _REGISTRY: + _REGISTRY[sgTitle] = _I18nRegistryEntry(context="nav", value="") + count += 1 + for item in subgroup.get("items", []): + label = item.get("label", "") + if label and label not in _REGISTRY: + _REGISTRY[label] = _I18nRegistryEntry(context="nav", value="") + count += 1 + + logger.info("i18n nav labels: registered %d nav keys", count) + + +def _registerFeatureUiLabels(): + """Register FEATURE_LABEL and UI_OBJECTS labels from all feature modules.""" + try: + from modules.system import mainSystem as _mainSystem + _fl = getattr(_mainSystem, "FEATURE_LABEL", None) + if isinstance(_fl, str) and _fl and _fl not in _REGISTRY: + _REGISTRY[_fl] = _I18nRegistryEntry(context="nav", value="") + except ImportError: + pass + + _featureModulePaths = ( + "modules.features.trustee.mainTrustee", + "modules.features.graphicalEditor.mainGraphicalEditor", + "modules.features.commcoach.mainCommcoach", + "modules.features.teamsbot.mainTeamsbot", + "modules.features.workspace.mainWorkspace", + "modules.features.realEstate.mainRealEstate", + "modules.features.neutralization.mainNeutralization", + ) + added = 0 + for modPath in _featureModulePaths: + try: + mod = __import__(modPath, fromlist=["FEATURE_LABEL", "UI_OBJECTS"]) + except ImportError: + continue + fl = getattr(mod, "FEATURE_LABEL", None) + if isinstance(fl, str) and fl and fl not in _REGISTRY: + _REGISTRY[fl] = _I18nRegistryEntry(context="nav", value="") + added += 1 + for uiObj in getattr(mod, "UI_OBJECTS", []) or []: + base = _extractRegistrySourceText(uiObj.get("label")) + if base and base not in _REGISTRY: + _REGISTRY[base] = _I18nRegistryEntry(context="nav", value="") + added += 1 + logger.info("i18n feature UI labels: %d new keys (nav context)", added) + + +def _registerRbacLabels(): + """Register DATA_OBJECTS, RESOURCE_OBJECTS labels and TEMPLATE_ROLES descriptions.""" + _featureModulePaths = ( + "modules.system.mainSystem", + "modules.features.trustee.mainTrustee", + "modules.features.graphicalEditor.mainGraphicalEditor", + "modules.features.commcoach.mainCommcoach", + "modules.features.teamsbot.mainTeamsbot", + "modules.features.workspace.mainWorkspace", + "modules.features.realEstate.mainRealEstate", + "modules.features.neutralization.mainNeutralization", + ) + + added = 0 + for modPath in _featureModulePaths: + try: + mod = __import__(modPath, fromlist=[ + "DATA_OBJECTS", "RESOURCE_OBJECTS", "TEMPLATE_ROLES", + "QUICK_ACTIONS", "QUICK_ACTION_CATEGORIES", + ]) + except ImportError: + continue + + for dataObj in getattr(mod, "DATA_OBJECTS", []) or []: + key = _extractRegistrySourceText(dataObj.get("label")) + if key and key not in _REGISTRY: + _REGISTRY[key] = _I18nRegistryEntry(context="rbac.data", value="") + added += 1 + + for resObj in getattr(mod, "RESOURCE_OBJECTS", []) or []: + key = _extractRegistrySourceText(resObj.get("label")) + if key and key not in _REGISTRY: + _REGISTRY[key] = _I18nRegistryEntry(context="rbac.resource", value="") + added += 1 + + for role in getattr(mod, "TEMPLATE_ROLES", []) or []: + key = _extractRegistrySourceText(role.get("description")) + if key and key not in _REGISTRY: + _REGISTRY[key] = _I18nRegistryEntry(context="rbac.role", value="") + added += 1 + + for qa in getattr(mod, "QUICK_ACTIONS", []) or []: + for field in ("label", "description"): + key = _extractRegistrySourceText(qa.get(field)) + if key and key not in _REGISTRY: + _REGISTRY[key] = _I18nRegistryEntry(context="rbac.quickaction", value="") + added += 1 + + for cat in getattr(mod, "QUICK_ACTION_CATEGORIES", []) or []: + key = _extractRegistrySourceText(cat.get("label")) + if key and key not in _REGISTRY: + _REGISTRY[key] = _I18nRegistryEntry(context="rbac.quickaction", value="") + added += 1 + + logger.info("i18n rbac labels: %d new keys (rbac.* context)", added) + + +def _registerServiceCenterLabels(): + """Register service-center category labels and bootstrap role descriptions.""" + added = 0 + + try: + from modules.serviceCenter.registry import IMPORTABLE_SERVICES + for svc in IMPORTABLE_SERVICES.values(): + key = _extractRegistrySourceText(svc.get("label")) + if key and key not in _REGISTRY: + _REGISTRY[key] = _I18nRegistryEntry(context="service", value="") + added += 1 + except ImportError: + pass + + _bootstrapRoleDescriptions = [ + "Administrator - Benutzer und Ressourcen im Mandanten verwalten", + "Benutzer - Standard-Benutzer mit Zugriff auf eigene Datensätze", + "Betrachter - Nur-Lese-Zugriff auf Gruppen-Datensätze", + "System-Administrator - Vollständiger administrativer Zugriff über alle Mandanten", + ] + for desc in _bootstrapRoleDescriptions: + if desc not in _REGISTRY: + _REGISTRY[desc] = _I18nRegistryEntry(context="rbac.role", value="") + added += 1 + + logger.info("i18n service/bootstrap labels: %d new keys", added) + + +def _registerNodeLabels(): + """Register all graph-editor node labels, descriptions, parameter descriptions, + output labels, port descriptions, category labels, and entry-point titles.""" + added = 0 + + def _reg(key: str, ctx: str): + nonlocal added + if key and key not in _REGISTRY: + _REGISTRY[key] = _I18nRegistryEntry(context=ctx, value="") + added += 1 + + try: + from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES + for nd in STATIC_NODE_TYPES: + _reg(_extractRegistrySourceText(nd.get("label")), "node.label") + _reg(_extractRegistrySourceText(nd.get("description")), "node.desc") + + for param in nd.get("parameters", []) or []: + _reg(_extractRegistrySourceText(param.get("description")), "node.param") + _reg(_extractRegistrySourceText(param.get("label")), "node.param") + + outLabels = nd.get("outputLabels") + if isinstance(outLabels, dict): + sourceList = outLabels.get("xx") or next(iter(outLabels.values()), []) + if not isinstance(sourceList, list): + sourceList = [] + for lbl in sourceList: + _reg(lbl, "node.output") + elif isinstance(outLabels, list): + for lbl in outLabels: + _reg(lbl, "node.output") + except ImportError: + pass + + try: + from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG + for schema in PORT_TYPE_CATALOG.values(): + for field in getattr(schema, "fields", []) or []: + desc = getattr(field, "description", None) + if desc: + _reg(_extractRegistrySourceText(desc if isinstance(desc, (str, dict)) else None), "port.desc") + except ImportError: + pass + + _nodeCategoryLabels = [ + "Trigger", "Eingabe/Mensch", "Ablauf", "Daten", "KI", + "Datei", "E-Mail", "SharePoint", "ClickUp", "Treuhand", + ] + for lbl in _nodeCategoryLabels: + _reg(lbl, "node.category") + + _entryPointTitles = ["Jetzt ausführen", "Start"] + for lbl in _entryPointTitles: + _reg(lbl, "node.entry") + + logger.info("i18n node labels: %d new keys (node.*/port.* context)", added) + + +def _registerAccountingConnectorLabels(): + """Register all accounting connector configField labels at boot time.""" + added = 0 + try: + from modules.features.trustee.accounting.accountingRegistry import getAccountingRegistry + except ImportError: + logger.debug("i18n accounting connectors: registry not importable") + return + + try: + registry = getAccountingRegistry() + except Exception as e: + logger.warning("i18n accounting connectors: registry init failed: %s", e) + return + + for connectorType, connector in (registry._connectors or {}).items(): + try: + for field in connector.getRequiredConfigFields(): + key = getattr(field, "label", "") or "" + if not isinstance(key, str) or not key: + continue + if key not in _REGISTRY: + _REGISTRY[key] = _I18nRegistryEntry( + context=f"connector.accounting.{connectorType}", + value="", + ) + added += 1 + except Exception as e: + logger.warning( + "i18n accounting connector %s: failed to read fields: %s", + connectorType, e, + ) + + logger.info("i18n accounting connector labels: %d new keys", added) + + +def _registerDatamodelOptionLabels(): + """Register all frontend_options labels from Pydantic datamodels and subscription plans.""" + added = 0 + + def _reg(key: str, ctx: str): + nonlocal added + if key and key not in _REGISTRY: + _REGISTRY[key] = _I18nRegistryEntry(context=ctx, value="") + added += 1 + + _datamodelModules = ( + "modules.datamodels.datamodelRbac", + "modules.datamodels.datamodelChat", + "modules.datamodels.datamodelMessaging", + "modules.datamodels.datamodelNotification", + "modules.datamodels.datamodelUam", + "modules.datamodels.datamodelFiles", + "modules.datamodels.datamodelDataSource", + "modules.datamodels.datamodelFeatures", + "modules.datamodels.datamodelUiLanguage", + "modules.datamodels.datamodelViews", + "modules.features.trustee.datamodelFeatureTrustee", + "modules.features.neutralization.datamodelFeatureNeutralizer", + ) + + for modPath in _datamodelModules: + try: + mod = __import__(modPath, fromlist=["__all__"]) + except ImportError: + continue + for attrName in dir(mod): + cls = getattr(mod, attrName, None) + if not isinstance(cls, type) or not issubclass(cls, BaseModel): + continue + for fieldName, fieldInfo in cls.model_fields.items(): + extra = (fieldInfo.json_schema_extra or {}) if hasattr(fieldInfo, "json_schema_extra") else {} + if not isinstance(extra, dict): + continue + options = extra.get("frontend_options") + if not isinstance(options, list): + continue + ctx = f"option.{cls.__name__}.{fieldName}" + for opt in options: + if isinstance(opt, dict): + _reg(_extractRegistrySourceText(opt.get("label")), ctx) + + try: + from modules.datamodels.datamodelSubscription import BUILTIN_PLANS + for plan in BUILTIN_PLANS.values(): + _reg(_extractRegistrySourceText(getattr(plan, "title", None)), "subscription.title") + _reg(_extractRegistrySourceText(getattr(plan, "description", None)), "subscription.desc") + except (ImportError, AttributeError): + pass + + logger.info("i18n datamodel option labels: %d new keys", added) + + +# --------------------------------------------------------------------------- +# Public boot API (called by app.py) +# --------------------------------------------------------------------------- + +async def syncRegistryToDb(): + """Boot hook: discover all i18n keys and write them into UiLanguageSet(xx).""" + _scanRouteApiMsgKeys() + _registerNavLabels() + _registerFeatureUiLabels() + _registerRbacLabels() + _registerServiceCenterLabels() + _registerNodeLabels() + _registerDatamodelOptionLabels() + _registerAccountingConnectorLabels() + + if not _REGISTRY: + logger.info("i18n registry: no keys to sync (empty registry)") + return + + from modules.datamodels.datamodelUiLanguage import UiLanguageSet + from modules.shared.configuration import APP_CONFIG + from modules.connectors.connectorDbPostgre import getCachedConnector + from modules.shared.timeUtils import getUtcTimestamp + + db = getCachedConnector( + dbHost=APP_CONFIG.get("DB_HOST", "localhost"), + dbDatabase="poweron_management", + dbUser=APP_CONFIG.get("DB_USER"), + dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET"), + dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), + userId="__i18n_boot__", + ) + + rows = db.getRecordset(UiLanguageSet, recordFilter={"id": "xx"}) + + gatewayEntries = [ + {"context": entry.context, "key": key, "value": entry.value} + for key, entry in _REGISTRY.items() + ] + gatewayKeys = set(_REGISTRY.keys()) + + if not rows: + now = getUtcTimestamp() + rec = { + "id": "xx", + "label": "Basisset (Meta)", + "entries": gatewayEntries, + "status": "complete", + "isDefault": True, + "sysCreatedAt": now, + "sysCreatedBy": "__i18n_boot__", + "sysModifiedAt": now, + "sysModifiedBy": "__i18n_boot__", + } + db.recordCreate(UiLanguageSet, rec) + logger.info("i18n boot-sync: created xx set with %d gateway keys", len(gatewayEntries)) + return + + row = dict(rows[0]) + existingEntries: List[dict] = row.get("entries") or [] + if not isinstance(existingEntries, list): + existingEntries = [] + + uiEntries = [e for e in existingEntries if e.get("context", "") == "ui"] + + oldGatewayEntries = [ + e for e in existingEntries + if e.get("context", "") != "ui" + ] + oldGatewayByKey = {e["key"]: e for e in oldGatewayEntries} + + added = 0 + updated = 0 + removed = 0 + + newGatewayEntries: List[dict] = [] + for key, entry in _REGISTRY.items(): + newEntry = {"context": entry.context, "key": key, "value": entry.value} + old = oldGatewayByKey.get(key) + if old is None: + added += 1 + elif old.get("context") != entry.context or old.get("value") != entry.value: + updated += 1 + newGatewayEntries.append(newEntry) + + removed = len(set(oldGatewayByKey.keys()) - gatewayKeys) + + mergedEntries = uiEntries + newGatewayEntries + + if added == 0 and updated == 0 and removed == 0: + logger.info("i18n boot-sync: xx set up-to-date (%d gateway + %d ui keys)", len(newGatewayEntries), len(uiEntries)) + return + + now = getUtcTimestamp() + row["entries"] = mergedEntries + if "keys" in row: + del row["keys"] + row["sysModifiedAt"] = now + row["sysModifiedBy"] = "__i18n_boot__" + db.recordModify(UiLanguageSet, "xx", row) + + logger.info( + "i18n boot-sync: xx updated (+%d added, ~%d updated, -%d removed, total=%d gateway + %d ui)", + added, updated, removed, len(newGatewayEntries), len(uiEntries), + ) + + +async def loadCache(): + """Boot hook: load all UiLanguageSets into the in-memory translation cache. + + Also persistently repairs placeholder mismatches in the DB. + After this, t() lookups are O(1) dict access with no DB calls. + """ + from modules.datamodels.datamodelUiLanguage import UiLanguageSet + from modules.shared.configuration import APP_CONFIG + from modules.connectors.connectorDbPostgre import getCachedConnector + + db = getCachedConnector( + dbHost=APP_CONFIG.get("DB_HOST", "localhost"), + dbDatabase="poweron_management", + dbUser=APP_CONFIG.get("DB_USER"), + dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET"), + dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), + userId="__i18n_cache__", + ) + + rows = db.getRecordset(UiLanguageSet) + _CACHE.clear() + + repairedTotal = 0 + persistedLanguages = 0 + for row in rows: + code = row.get("id", "") + if code == "xx": + continue + entries = row.get("entries") + if not isinstance(entries, list): + continue + langDict: Dict[str, str] = {} + repairedInLang = 0 + for entry in entries: + key = entry.get("key", "") + val = entry.get("value", "") + if not key or not val: + continue + fixed, changed = _enforceSourcePlaceholders(key, val) + if changed: + entry["value"] = fixed + repairedInLang += 1 + langDict[key] = fixed + if langDict: + _CACHE[code] = langDict + if repairedInLang: + repairedTotal += repairedInLang + try: + rowToSave = dict(row) + rowToSave["entries"] = entries + if "keys" in rowToSave: + del rowToSave["keys"] + db.recordModify(UiLanguageSet, code, rowToSave) + persistedLanguages += 1 + logger.info( + "i18n boot repair: fixed and persisted %d placeholder mismatches in language '%s'", + repairedInLang, code, + ) + except Exception as ex: + logger.warning( + "i18n boot repair: in-memory fixed %d entries in '%s' but DB persist failed: %s", + repairedInLang, code, ex, + ) + + logger.info( + "i18n cache loaded: %d languages, %d total keys%s", + len(_CACHE), sum(len(v) for v in _CACHE.values()), + ( + f" (boot-repaired {repairedTotal} placeholders, " + f"persisted to {persistedLanguages} language sets)" + if repairedTotal else "" + ), + ) diff --git a/modules/shared/notifyMandateAdmins.py b/modules/system/notifyMandateAdmins.py similarity index 100% rename from modules/shared/notifyMandateAdmins.py rename to modules/system/notifyMandateAdmins.py diff --git a/modules/workflows/automation2/executionEngine.py b/modules/workflows/automation2/executionEngine.py index 8efe9339..f8d95f1c 100644 --- a/modules/workflows/automation2/executionEngine.py +++ b/modules/workflows/automation2/executionEngine.py @@ -2,6 +2,7 @@ # Main execution engine for automation2 graphs. import asyncio +import json import logging import time import uuid @@ -30,8 +31,7 @@ from modules.workflows.automation2.executors import ( ) from modules.features.graphicalEditor.portTypes import normalizeToSchema, wrapTransit, unwrapTransit from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES -from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionInactiveException as _SubscriptionInactiveException -from modules.serviceCenter.services.serviceBilling.mainServiceBilling import BillingContextError as _BillingContextError +from modules.shared.serviceExceptions import SubscriptionInactiveException as _SubscriptionInactiveException, BillingContextError as _BillingContextError from modules.workflows.automation2.graphicalEditorRunFileLogger import ( GraphicalEditorRunFileLogger, graphical_editor_run_file_logging_enabled, @@ -440,8 +440,7 @@ def _substituteFeatureInstancePlaceholders( concrete UUIDs (pre-baked by ``_copyTemplateWorkflows``) are left untouched because the placeholder literal ``{{featureInstanceId}}`` will not match. """ - import json as _json - raw = _json.dumps(graph) + raw = json.dumps(graph) if "{{featureInstanceId}}" not in raw: return graph replaced = raw.replace("{{featureInstanceId}}", targetFeatureInstanceId) @@ -450,7 +449,7 @@ def _substituteFeatureInstancePlaceholders( raw.count("{{featureInstanceId}}"), targetFeatureInstanceId, ) - return _json.loads(replaced) + return json.loads(replaced) async def _run_post_loop_done_nodes( diff --git a/modules/workflows/automation2/executors/actionNodeExecutor.py b/modules/workflows/automation2/executors/actionNodeExecutor.py index 5783b108..20fed58a 100644 --- a/modules/workflows/automation2/executors/actionNodeExecutor.py +++ b/modules/workflows/automation2/executors/actionNodeExecutor.py @@ -12,14 +12,14 @@ import binascii import json import logging import re +import time from typing import Any, Dict, Optional from modules.features.graphicalEditor.portTypes import ( _normalizeError, normalizeToSchema, ) -from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionInactiveException as _SubscriptionInactiveException -from modules.serviceCenter.services.serviceBilling.mainServiceBilling import BillingContextError as _BillingContextError +from modules.shared.serviceExceptions import SubscriptionInactiveException as _SubscriptionInactiveException, BillingContextError as _BillingContextError from modules.workflows.automation2.executors.inputExecutor import PauseForHumanTaskError from modules.workflows.methods.methodContext.actions.extractContent import ( PRESENTATION_KIND, @@ -132,7 +132,7 @@ def _looks_like_ascii_base64_payload(s: str) -> bool: return bool(re.fullmatch(r"[A-Za-z0-9+/]+=*", t)) and len(t) % 4 == 0 -def _coerce_document_data_to_bytes(raw: Any) -> Optional[bytes]: +def coerceDocumentDataToBytes(raw: Any) -> Optional[bytes]: """Normalize documentData for DB file persistence. ActionDocument conventions (see methodFile.create): binary bodies are carried as ASCII @@ -624,8 +624,7 @@ class ActionNodeExecutor: raise PauseForEmailWaitError(runId=runId, nodeId=nodeId, waitConfig=waitConfig) # 6. Create progress parent so nested actions have a hierarchy - import time as _time - nodeOperationId = f"node_{nodeId}_{context.get('_runId', 'x')}_{int(_time.time())}" + nodeOperationId = f"node_{nodeId}_{context.get('_runId', 'x')}_{int(time.time())}" chatService = getattr(self.services, "chat", None) if chatService: try: @@ -675,7 +674,7 @@ class ActionNodeExecutor: continue rawData = getattr(d, "documentData", None) if hasattr(d, "documentData") else (dumped.get("documentData") if isinstance(dumped, dict) else None) - rawBytes = _coerce_document_data_to_bytes(rawData) + rawBytes = coerceDocumentDataToBytes(rawData) if isinstance(dumped, dict) and rawBytes: try: from modules.interfaces.interfaceDbManagement import getInterface as _getMgmtInterface diff --git a/modules/workflows/automation2/executors/flowExecutor.py b/modules/workflows/automation2/executors/flowExecutor.py index 00ede971..3da89a87 100644 --- a/modules/workflows/automation2/executors/flowExecutor.py +++ b/modules/workflows/automation2/executors/flowExecutor.py @@ -2,6 +2,7 @@ # Flow control node executor (ifElse, switch, loop, merge). import logging +from datetime import datetime from typing import Any, Dict, List, Optional from modules.features.graphicalEditor.conditionOperators import apply_condition_operator, resolve_value_kind @@ -160,7 +161,6 @@ class FlowExecutor: s = str(v).strip() if not s: return None - from datetime import datetime for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"): try: diff --git a/modules/workflows/automation2/graphUtils.py b/modules/workflows/automation2/graphUtils.py index b31dd7bb..9130f023 100644 --- a/modules/workflows/automation2/graphUtils.py +++ b/modules/workflows/automation2/graphUtils.py @@ -1,7 +1,10 @@ # Copyright (c) 2025 Patrick Motsch # Graph parsing, validation, and topological sort for automation2. +import json import logging +import re +from collections import deque from typing import Dict, List, Any, Tuple, Set, Optional logger = logging.getLogger(__name__) @@ -53,8 +56,6 @@ def getLoopBodyNodeIds(loopNodeId: str, connectionMap: Dict[str, List[Tuple[str, Edges vom Rumpf zurück in den Loop-Knoten (gleicher Eingang wie der Hauptfluss) beenden die Expansion am Loop-Knoten — der Loop-Knoten selbst ist nie Teil des Rumpfes. """ - from collections import deque - body: Set[str] = set() rev: Dict[str, List[Tuple[str, int, int]]] = {} for tgt, pairs in connectionMap.items(): @@ -105,8 +106,6 @@ def getLoopPrimaryInputSource( def getLoopDoneNodeIds(loopNodeId: str, connectionMap: Dict[str, List[Tuple[str, int, int]]]) -> Set[str]: """Nodes reachable from flow.loop output port 1 (runs once after all iterations).""" - from collections import deque - done: Set[str] = set() rev: Dict[str, List[Tuple[str, int, int]]] = {} for tgt, pairs in connectionMap.items(): @@ -297,7 +296,6 @@ def topoSort(nodes: List[Dict], connectionMap: Dict[str, List[Tuple[str, int, in order: List[Dict] = [] def bfs(startIds: List[str]) -> None: - from collections import deque q = deque(startIds) for nid in startIds: visited.add(nid) @@ -430,9 +428,6 @@ def resolveParameterReferences( When ``consumer_node_id`` and ``input_sources`` are set, refs to the wired upstream switch use that connection's output port (per-branch payload). """ - import json - import re - if isinstance(value, dict): # Phase-5 Schicht-4: typed-ref envelopes (FeatureInstanceRef etc.) on # disk get unwrapped to their canonical primitive (e.g. ``id``) so diff --git a/modules/workflows/methods/_actionSignatureValidator.py b/modules/workflows/methods/_actionSignatureValidator.py index 942ccb8a..25be8175 100644 --- a/modules/workflows/methods/_actionSignatureValidator.py +++ b/modules/workflows/methods/_actionSignatureValidator.py @@ -44,7 +44,7 @@ def _isKnownType(typeName: str) -> bool: return typeName in PRIMITIVE_TYPES or typeName in PORT_TYPE_CATALOG -def _validateTypeRef(typeStr: str) -> List[str]: +def validateTypeRef(typeStr: str) -> List[str]: """ Validate a single type reference string (the value of `type` on a WorkflowActionParameter or `outputType` on a WorkflowActionDefinition). @@ -81,7 +81,7 @@ def _validateActionParameter( ) -> List[str]: """Validate a single parameter; returns prefixed error messages.""" out: List[str] = [] - for err in _validateTypeRef(param.type): + for err in validateTypeRef(param.type): out.append(f"{actionId}.{paramName}: {err}") return out @@ -98,7 +98,7 @@ def _validateActionDefinition( outputType = actionDef.outputType if outputType not in _ALLOWED_GENERIC_OUTPUTS: - for err in _validateTypeRef(outputType): + for err in validateTypeRef(outputType): errors.append(f"{actionId}.: {err}") return errors @@ -171,7 +171,7 @@ __all__ = [ "_validateActionsDict", "_validateActionDefinition", "_validateActionParameter", - "_validateTypeRef", + "validateTypeRef", "_formatValidationReport", "_logValidationReport", ] diff --git a/modules/workflows/methods/methodAi/actions/consolidate.py b/modules/workflows/methods/methodAi/actions/consolidate.py index 7483507e..0dced074 100644 --- a/modules/workflows/methods/methodAi/actions/consolidate.py +++ b/modules/workflows/methods/methodAi/actions/consolidate.py @@ -7,8 +7,7 @@ from typing import Any, Dict, List from modules.datamodels.datamodelAi import AiCallOptions, AiCallRequest, OperationTypeEnum from modules.datamodels.datamodelChat import ActionResult -from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionInactiveException -from modules.serviceCenter.services.serviceBilling.mainServiceBilling import BillingContextError +from modules.shared.serviceExceptions import SubscriptionInactiveException, BillingContextError logger = logging.getLogger(__name__) diff --git a/modules/workflows/methods/methodAi/actions/generateCode.py b/modules/workflows/methods/methodAi/actions/generateCode.py index bc7a5a64..66a1d0bf 100644 --- a/modules/workflows/methods/methodAi/actions/generateCode.py +++ b/modules/workflows/methods/methodAi/actions/generateCode.py @@ -2,14 +2,14 @@ # All rights reserved. import logging +import re import time from typing import Dict, Any, Optional, List from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelExtraction import ContentPart from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum from modules.datamodels.datamodelWorkflow import AiResponse, DocumentData -from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionInactiveException -from modules.serviceCenter.services.serviceBilling.mainServiceBilling import BillingContextError +from modules.shared.serviceExceptions import SubscriptionInactiveException, BillingContextError logger = logging.getLogger(__name__) @@ -99,7 +99,6 @@ async def generateCode(self, parameters: Dict[str, Any]) -> ActionResult: if aiResponse.metadata and aiResponse.metadata.filename: docName = aiResponse.metadata.filename elif aiResponse.metadata and aiResponse.metadata.title: - import re sanitized = re.sub(r"[^a-zA-Z0-9._-]", "_", aiResponse.metadata.title) sanitized = re.sub(r"_+", "_", sanitized).strip("_") if sanitized: diff --git a/modules/workflows/methods/methodAi/actions/generateDocument.py b/modules/workflows/methods/methodAi/actions/generateDocument.py index 5a1ff0eb..2006ba96 100644 --- a/modules/workflows/methods/methodAi/actions/generateDocument.py +++ b/modules/workflows/methods/methodAi/actions/generateDocument.py @@ -2,14 +2,14 @@ # All rights reserved. import logging +import re import time from typing import Dict, Any, Optional, List from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelExtraction import ContentPart from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum from modules.datamodels.datamodelWorkflow import AiResponse, DocumentData -from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionInactiveException -from modules.serviceCenter.services.serviceBilling.mainServiceBilling import BillingContextError +from modules.shared.serviceExceptions import SubscriptionInactiveException, BillingContextError logger = logging.getLogger(__name__) @@ -113,7 +113,6 @@ async def generateDocument(self, parameters: Dict[str, Any]) -> ActionResult: if aiResponse.metadata and aiResponse.metadata.filename: docName = aiResponse.metadata.filename elif aiResponse.metadata and aiResponse.metadata.title: - import re sanitized = re.sub(r"[^a-zA-Z0-9._-]", "_", aiResponse.metadata.title) sanitized = re.sub(r"_+", "_", sanitized).strip("_") if sanitized: diff --git a/modules/workflows/methods/methodAi/actions/process.py b/modules/workflows/methods/methodAi/actions/process.py index 62955b12..47774eb1 100644 --- a/modules/workflows/methods/methodAi/actions/process.py +++ b/modules/workflows/methods/methodAi/actions/process.py @@ -10,8 +10,7 @@ from typing import Dict, Any, List, Optional from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, ProcessingModeEnum from modules.datamodels.datamodelExtraction import ContentPart -from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionInactiveException -from modules.serviceCenter.services.serviceBilling.mainServiceBilling import BillingContextError +from modules.shared.serviceExceptions import SubscriptionInactiveException, BillingContextError logger = logging.getLogger(__name__) diff --git a/modules/workflows/methods/methodAi/actions/webResearch.py b/modules/workflows/methods/methodAi/actions/webResearch.py index e32f8e65..0dfdeeab 100644 --- a/modules/workflows/methods/methodAi/actions/webResearch.py +++ b/modules/workflows/methods/methodAi/actions/webResearch.py @@ -8,8 +8,7 @@ import json from typing import Dict, Any from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.serviceCenter import ServiceCenterContext, getService, can_access_service -from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionInactiveException -from modules.serviceCenter.services.serviceBilling.mainServiceBilling import BillingContextError +from modules.shared.serviceExceptions import SubscriptionInactiveException, BillingContextError logger = logging.getLogger(__name__) diff --git a/modules/workflows/methods/methodBase.py b/modules/workflows/methods/methodBase.py index 5a766563..57670f61 100644 --- a/modules/workflows/methods/methodBase.py +++ b/modules/workflows/methods/methodBase.py @@ -1,8 +1,9 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. -from typing import Dict, List, Optional, Any -from datetime import datetime, UTC import logging +import re +from datetime import datetime, UTC +from typing import Dict, List, Optional, Any from functools import wraps @@ -305,7 +306,6 @@ class MethodBase: raise ValueError(f"String length must be <= {rules['max']}") if 'pattern' in rules: - import re if not re.match(rules['pattern'], str(value)): raise ValueError(f"Value does not match required pattern: {rules['pattern']}") @@ -478,7 +478,6 @@ class MethodBase: # Clean base name (remove special characters, spaces) clean_base = base_name.lower().replace(' ', '_').replace('-', '_') # Remove any non-alphanumeric characters except underscores - import re clean_base = re.sub(r'[^a-z0-9_]', '', clean_base) # Add action name if provided diff --git a/modules/workflows/methods/methodContext/actions/extractContent.py b/modules/workflows/methods/methodContext/actions/extractContent.py index 52d07b34..44309888 100644 --- a/modules/workflows/methods/methodContext/actions/extractContent.py +++ b/modules/workflows/methods/methodContext/actions/extractContent.py @@ -12,8 +12,8 @@ internally when ``_runContext`` enables image uploads. Older ``kind: context.extractContent.handover.v1`` is legacy-only (merge/tests), not produced here.""" -import base64 as _b64 -import binascii as _binascii +import base64 +import binascii import copy import csv import json @@ -69,8 +69,6 @@ def _apply_content_filter(payload: Dict[str, Any], content_filter: str) -> Dict[ - noImages: blacklist — every typeGroup except image (wider than textOnly; future non-image types are retained). """ - import copy - if content_filter == "all": return payload result = copy.deepcopy(payload) @@ -1239,8 +1237,8 @@ def _persist_extracted_image_parts( continue raw_s = raw_data.strip() if isinstance(raw_data, str) else "" try: - img_bytes = _b64.b64decode(raw_s, validate=True) if raw_s else b"" - except (_binascii.Error, TypeError, ValueError): + img_bytes = base64.b64decode(raw_s, validate=True) if raw_s else b"" + except (binascii.Error, TypeError, ValueError): new_parts.append(p) continue if not img_bytes: @@ -1288,7 +1286,7 @@ def _persist_extracted_image_parts( return content_extracted_serial, artifacts -def _one_file_bucket(ec: ContentExtracted, source_file_name: str) -> Dict[str, Any]: +def oneFileBucket(ec: ContentExtracted, source_file_name: str) -> Dict[str, Any]: parts_ser = _serialize_parts(ec.parts) ud = getattr(ec, "udm", None) @@ -1401,7 +1399,7 @@ def _load_image_bytes_by_file_id(services: Any, file_id: str) -> Optional[bytes] def _inline_runs_from_presentation_lines(lines: List[Any]) -> List[Dict[str, Any]]: """Map presentation ``lines`` to inline runs, preserving line order with explicit breaks.""" - from modules.serviceCenter.services.serviceGeneration.subDocumentUtility import _parseInlineRuns + from modules.serviceCenter.services.serviceGeneration.subDocumentUtility import parseInlineRuns runs: List[Dict[str, Any]] = [] first = True @@ -1412,7 +1410,7 @@ def _inline_runs_from_presentation_lines(lines: List[Any]) -> List[Dict[str, Any piece = str(ln) if ln is not None else "" if not piece: continue - runs.extend(_parseInlineRuns(piece)) + runs.extend(parseInlineRuns(piece)) return runs if runs else [{"type": "text", "value": ""}] @@ -1539,7 +1537,7 @@ def presentation_envelopes_to_document_json( services: Any = None, ) -> Dict[str, Any]: """Map presentation envelope(s) to ``renderReport`` ``extractedContent`` (documents/sections).""" - from modules.serviceCenter.services.serviceGeneration.subDocumentUtility import _parseInlineRuns + from modules.serviceCenter.services.serviceGeneration.subDocumentUtility import parseInlineRuns envelopes = normalize_presentation_envelopes(raw) if not envelopes: @@ -1576,7 +1574,7 @@ def presentation_envelopes_to_document_json( "id": _next_id(), "content_type": "paragraph", "order": order, - "elements": [{"content": {"inlineRuns": _parseInlineRuns(t)}}], + "elements": [{"content": {"inlineRuns": parseInlineRuns(t)}}], }) def _resolve_image_file_id(slot: Dict[str, Any]) -> Optional[str]: @@ -1633,7 +1631,7 @@ def presentation_envelopes_to_document_json( "elements": [{ "content": { "altText": str(name), - "base64Data": _b64.b64encode(blob).decode("ascii"), + "base64Data": base64.b64encode(blob).decode("ascii"), "fileId": str(fid), "fileName": str(name), "mimeType": mime, diff --git a/modules/workflows/methods/methodContext/actions/neutralizeData.py b/modules/workflows/methods/methodContext/actions/neutralizeData.py index 8efc7954..5bd1eb34 100644 --- a/modules/workflows/methods/methodContext/actions/neutralizeData.py +++ b/modules/workflows/methods/methodContext/actions/neutralizeData.py @@ -1,7 +1,7 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. -import base64 as _b64 +import base64 import logging import time from typing import Any, Dict @@ -10,7 +10,7 @@ from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelDocref import coerceDocumentReferenceList from modules.datamodels.datamodelExtraction import ContentExtracted, ContentPart -from .extractContent import _one_file_bucket +from .extractContent import oneFileBucket logger = logging.getLogger(__name__) @@ -50,7 +50,7 @@ async def _neutralize_one_content_extracted( prog, f"Checking image part {len(neutralized_parts) + 1}", ) - _img_bytes = _b64.b64decode(str(part.data)) + _img_bytes = base64.b64decode(str(part.data)) _img_result = await svc.services.neutralization.processImageAsync(_img_bytes, f"part_{part.id}") if _img_result.get("status") == "ok": neutralized_parts.append(part) @@ -227,7 +227,7 @@ async def neutralizeData(self, parameters: Dict[str, Any]) -> ActionResult: chat_doc_slot=i, chat_documents_len=max(len(chat_documents), 1), ) - new_files[fk] = _one_file_bucket(ce_out, str(bucket.get("sourceFileName") or fk)) + new_files[fk] = oneFileBucket(ce_out, str(bucket.get("sourceFileName") or fk)) bundle["files"] = new_files original_filename = getattr(chat_doc, "fileName", f"neutralized_bundle_{workflow_id}.json") diff --git a/modules/workflows/methods/methodFile/actions/create.py b/modules/workflows/methods/methodFile/actions/create.py index 9342767f..cc5550ca 100644 --- a/modules/workflows/methods/methodFile/actions/create.py +++ b/modules/workflows/methods/methodFile/actions/create.py @@ -13,7 +13,7 @@ import re from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.shared.i18nRegistry import normalizePrimaryLanguageTag -from modules.workflows.automation2.executors.actionNodeExecutor import _coerce_document_data_to_bytes +from modules.workflows.automation2.executors.actionNodeExecutor import coerceDocumentDataToBytes from modules.workflows.methods.methodAi._common import is_image_action_document_list from modules.workflows.methods.methodContext.actions.extractContent import ( presentation_envelopes_to_document_json, @@ -139,7 +139,7 @@ def _get_management_interface(services) -> Optional[Any]: def _load_image_bytes_from_action_doc(doc: dict, services) -> Optional[bytes]: raw = doc.get("documentData") - blob = _coerce_document_data_to_bytes(raw) + blob = coerceDocumentDataToBytes(raw) if blob: return blob fid = doc.get("fileId") diff --git a/modules/workflows/methods/methodTrustee/actions/extractFromFiles.py b/modules/workflows/methods/methodTrustee/actions/extractFromFiles.py index 37dd133d..fa677e2b 100644 --- a/modules/workflows/methods/methodTrustee/actions/extractFromFiles.py +++ b/modules/workflows/methods/methodTrustee/actions/extractFromFiles.py @@ -7,19 +7,19 @@ Output: ActionResult with one ActionDocument per file: { documentType, extracted """ import asyncio -import json -import logging -import uuid import csv import io +import json +import logging +import re +import uuid from datetime import datetime, timezone from typing import Dict, Any, List, Optional, Tuple from modules.datamodels.datamodelChat import ActionResult, ActionDocument, ChatDocument, ChatMessage from modules.datamodels.datamodelDocref import DocumentReferenceList, DocumentItemReference from modules.datamodels.datamodelAi import AiCallOptions, AiCallRequest, OperationTypeEnum -from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionInactiveException as _SubscriptionInactiveException -from modules.serviceCenter.services.serviceBilling.mainServiceBilling import BillingContextError as _BillingContextError +from modules.shared.serviceExceptions import SubscriptionInactiveException as _SubscriptionInactiveException, BillingContextError as _BillingContextError logger = logging.getLogger(__name__) @@ -219,8 +219,6 @@ def _parseCsvToRecords(csvContent: str) -> List[Dict[str, Any]]: def _estimateBankTransactionLineCount(rawText: str) -> int: """Estimate how many transaction rows exist in bank statement OCR text.""" - import re - lines = (rawText or "").splitlines() datePattern = re.compile(r"\b(\d{2}[./-]\d{2}[./-]\d{2,4}|\d{4}-\d{2}-\d{2})\b") amountPattern = re.compile(r"[-+]?\d{1,3}(?:[ '\u00A0]\d{3})*(?:[.,]\d{2})\b") diff --git a/modules/workflows/methods/methodTrustee/actions/processDocuments.py b/modules/workflows/methods/methodTrustee/actions/processDocuments.py index a5c9ce74..ab738a14 100644 --- a/modules/workflows/methods/methodTrustee/actions/processDocuments.py +++ b/modules/workflows/methods/methodTrustee/actions/processDocuments.py @@ -15,6 +15,7 @@ syncToAccounting (via DataRef on documents[0]). import json import logging +import re from datetime import datetime, timezone from typing import Dict, Any, List, Optional @@ -37,7 +38,6 @@ def _extractAccountNumber(value) -> Optional[str]: """Extract the leading numeric account number from AI output like '6200 Fahrzeugaufwand' -> '6200'.""" if not value or not isinstance(value, str): return None - import re match = re.match(r"(\d+)", value.strip()) return match.group(1) if match else value.strip() or None @@ -64,7 +64,6 @@ def _normaliseRef(value: Any) -> Optional[str]: raw = _cleanStr(value) if not raw: return None - import re return re.sub(r"[^A-Z0-9]", "", raw.upper()) or None @@ -114,7 +113,6 @@ def _normaliseCompany(value: Any) -> Optional[str]: raw = _cleanStr(value) if not raw: return None - import re cleaned = re.sub(r"[^A-Z0-9]", "", raw.upper()) return cleaned or None diff --git a/modules/workflows/methods/methodTrustee/actions/queryData.py b/modules/workflows/methods/methodTrustee/actions/queryData.py index 9b2e3e10..b30c9390 100644 --- a/modules/workflows/methods/methodTrustee/actions/queryData.py +++ b/modules/workflows/methods/methodTrustee/actions/queryData.py @@ -20,7 +20,7 @@ This action does NOT trigger an external sync — use import json import logging import re -from datetime import datetime as _dt, timezone as _tz +from datetime import datetime, timezone from typing import Any, Dict, List, Optional from modules.datamodels.datamodelChat import ActionResult @@ -33,7 +33,7 @@ def _isoToTs(isoDate: Optional[str]) -> Optional[float]: if not isoDate: return None try: - return _dt.strptime(isoDate.strip()[:10], "%Y-%m-%d").replace(tzinfo=_tz.utc).timestamp() + return datetime.strptime(isoDate.strip()[:10], "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp() except (ValueError, AttributeError): return None @@ -43,7 +43,7 @@ def _tsToIso(ts) -> Optional[str]: if ts is None: return None try: - return _dt.fromtimestamp(float(ts), tz=_tz.utc).strftime("%Y-%m-%d") + return datetime.fromtimestamp(float(ts), tz=timezone.utc).strftime("%Y-%m-%d") except (ValueError, TypeError, OSError): return None diff --git a/modules/workflows/methods/methodTrustee/actions/refreshAccountingData.py b/modules/workflows/methods/methodTrustee/actions/refreshAccountingData.py index 0d6e737c..817d229a 100644 --- a/modules/workflows/methods/methodTrustee/actions/refreshAccountingData.py +++ b/modules/workflows/methods/methodTrustee/actions/refreshAccountingData.py @@ -8,7 +8,7 @@ Checks lastSyncAt to avoid redundant imports unless forceRefresh is set. import json import logging import time -from datetime import datetime as _dt, timezone as _tz +from datetime import datetime, timezone from typing import Dict, Any, Optional from modules.datamodels.datamodelChat import ActionResult @@ -21,7 +21,7 @@ def _isoToTs(isoDate: Optional[str]) -> Optional[float]: if not isoDate: return None try: - return _dt.strptime(isoDate.strip()[:10], "%Y-%m-%d").replace(tzinfo=_tz.utc).timestamp() + return datetime.strptime(isoDate.strip()[:10], "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp() except (ValueError, AttributeError): return None @@ -31,7 +31,7 @@ def _tsToIso(ts) -> Optional[str]: if ts is None: return None try: - return _dt.fromtimestamp(float(ts), tz=_tz.utc).strftime("%Y-%m-%d") + return datetime.fromtimestamp(float(ts), tz=timezone.utc).strftime("%Y-%m-%d") except (ValueError, TypeError, OSError): return None @@ -208,7 +208,7 @@ def _exportAccountingData(trusteeInterface, featureInstanceId: str, dateFrom: st balances = trusteeInterface.db.getRecordset(TrusteeDataAccountBalance, recordFilter=baseFilter) or [] - currentYear = _dt.now(tz=_tz.utc).year + currentYear = datetime.now(tz=timezone.utc).year accountSummary = _buildAccountSummary(accountMap, balances, currentYear) entries = trusteeInterface.db.getRecordset(TrusteeDataJournalEntry, recordFilter=baseFilter) or [] diff --git a/modules/workflows/processing/adaptive/contentValidator.py b/modules/workflows/processing/adaptive/contentValidator.py index e8ba106b..15e1dc65 100644 --- a/modules/workflows/processing/adaptive/contentValidator.py +++ b/modules/workflows/processing/adaptive/contentValidator.py @@ -4,6 +4,7 @@ # Content validation for adaptive Dynamic mode # Generic, document-aware validation system +import io import logging import json import base64 @@ -583,7 +584,6 @@ class ContentValidator: if formatExt == "csv": import csv - import io try: reader = csv.reader(io.StringIO(content)) rows = list(reader) diff --git a/modules/workflows/processing/core/actionExecutor.py b/modules/workflows/processing/core/actionExecutor.py index 3d4ed7fc..1a162922 100644 --- a/modules/workflows/processing/core/actionExecutor.py +++ b/modules/workflows/processing/core/actionExecutor.py @@ -4,6 +4,7 @@ # Action execution functionality for workflows import logging +import time from typing import Dict, Any, List from modules.datamodels.datamodelChat import ActionResult, ActionItem, TaskStep from modules.datamodels.datamodelChat import ChatWorkflow @@ -119,7 +120,6 @@ class ActionExecutor: logger.error(f"Error getting task operation ID: {str(e)}") # Create action operationId entry - Action is child of Task - import time actionOperationId = f"action_{action.execMethod}_{action.execAction}_{workflow.id}_{taskNum}_{actionNum}_{int(time.time())}" try: diff --git a/modules/workflows/processing/core/messageCreator.py b/modules/workflows/processing/core/messageCreator.py index e0c49a52..cb8e344f 100644 --- a/modules/workflows/processing/core/messageCreator.py +++ b/modules/workflows/processing/core/messageCreator.py @@ -4,6 +4,7 @@ # Generic message creation for all workflow phases import logging +import re from typing import Dict, Any, Optional, List from modules.datamodels.datamodelChat import TaskPlan, TaskStep, ActionResult, ReviewResult from modules.datamodels.datamodelChat import ChatWorkflow @@ -338,7 +339,6 @@ class MessageCreator: if not label or not isinstance(label, str): return 0 - import re pattern = rf'{prefix}(\d+)' match = re.search(pattern, label) return int(match.group(1)) if match else 0 diff --git a/modules/workflows/processing/shared/stateTools.py b/modules/workflows/processing/shared/stateTools.py index 70259b3c..c1614b69 100644 --- a/modules/workflows/processing/shared/stateTools.py +++ b/modules/workflows/processing/shared/stateTools.py @@ -2,47 +2,9 @@ # All rights reserved. """ State Tools -Shared utilities for workflow state management and validation. +Re-exports from modules.shared.workflowState for backward compatibility. """ -import logging -from typing import Any - -logger = logging.getLogger(__name__) - - -class WorkflowStoppedException(Exception): - """Exception raised when a workflow is stopped by the user.""" - pass - - -def checkWorkflowStopped(services: Any) -> None: - """ - Check if workflow has been stopped by user and raise exception if so. - - Args: - services: Services object with workflow and interfaceDbChat for fresh status check - - Raises: - WorkflowStoppedException: If workflow status is "stopped" - """ - workflow = getattr(services, 'workflow', None) - if not workflow or not hasattr(workflow, 'id') or workflow.id is None: - return - - try: - # Get the current workflow status from the database to avoid stale data - currentWorkflow = services.interfaceDbChat.getWorkflow(workflow.id) - if currentWorkflow and currentWorkflow.status == "stopped": - logger.info("Workflow stopped by user, aborting operation") - raise WorkflowStoppedException("Workflow was stopped by user") - except WorkflowStoppedException: - # Re-raise the stop signal immediately - raise - except Exception as e: - # If we can't get the current status due to other database issues, fall back to the in-memory object - logger.warning(f"Could not check current workflow status from database: {str(e)}") - if workflow and workflow.status == "stopped": - logger.info("Workflow stopped by user (from in-memory object), aborting operation") - raise WorkflowStoppedException("Workflow was stopped by user") +from modules.shared.workflowState import checkWorkflowStopped, WorkflowStoppedException +__all__ = ["checkWorkflowStopped", "WorkflowStoppedException"] diff --git a/modules/workflows/processing/workflowProcessor.py b/modules/workflows/processing/workflowProcessor.py index 99d8fd63..7f63fc62 100644 --- a/modules/workflows/processing/workflowProcessor.py +++ b/modules/workflows/processing/workflowProcessor.py @@ -5,6 +5,8 @@ import logging import json +import time +import traceback from typing import Dict, Any, Optional, List, TYPE_CHECKING from modules.datamodels import datamodelChat from modules.datamodels.datamodelChat import TaskStep, TaskContext, TaskPlan, ActionResult, ActionDocument, ChatDocument, ChatMessage @@ -48,8 +50,6 @@ class WorkflowProcessor: async def generateTaskPlan(self, userInput: str, workflow: ChatWorkflow) -> TaskPlan: """Generate a high-level task plan for the workflow""" - import time - # Init progress logger operationId = f"taskPlan_{workflow.id}_{int(time.time())}" @@ -111,8 +111,6 @@ class WorkflowProcessor: async def executeTask(self, taskStep: TaskStep, workflow: ChatWorkflow, context: TaskContext) -> datamodelChat.ChatTaskResult: """Execute a task step using the appropriate mode""" - import time - # Get task index from workflow state taskIndex = workflow.getTaskIndex() @@ -511,7 +509,6 @@ class WorkflowProcessor: return result except Exception as e: - import traceback errorDetails = f"{type(e).__name__}: {str(e)}" logger.error(f"Error in fastPathExecute: {errorDetails}") logger.debug(f"Fast path error traceback:\n{traceback.format_exc()}") diff --git a/modules/workflows/scheduler/mainScheduler.py b/modules/workflows/scheduler/mainScheduler.py index 0dce2ec5..ef89f821 100644 --- a/modules/workflows/scheduler/mainScheduler.py +++ b/modules/workflows/scheduler/mainScheduler.py @@ -11,6 +11,8 @@ Replaces subAutomation2Schedule with v1-style incremental sync patterns: import asyncio import logging +import threading +import time from typing import Any, Dict, Optional from modules.shared.eventManagement import eventManager @@ -268,12 +270,9 @@ class WorkflowScheduler: def _delayedSync(self) -> None: """Delayed sync (5s) in case DB was not ready at startup.""" - import threading - eventUser = self._eventUser def _run(): - import time time.sleep(5) try: self._syncScheduledWorkflows() diff --git a/scripts/build_ui_language_seed_json.py b/scripts/build_ui_language_seed_json.py deleted file mode 100644 index e610ea11..00000000 --- a/scripts/build_ui_language_seed_json.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Build ui_language_seed.json from frontend_nyla locale TS files (one-off / CI).""" - -from __future__ import annotations - -import json -import re -from pathlib import Path - -_REPO = Path(__file__).resolve().parents[2] -_SRC = _REPO / "frontend_nyla" / "src" / "locales" -_OUT = _REPO / "gateway" / "modules" / "migration" / "seedData" / "ui_language_seed.json" - - -def _unescape_ts_single_quoted(raw: str) -> str: - out: list[str] = [] - i = 0 - while i < len(raw): - c = raw[i] - if c == "\\" and i + 1 < len(raw): - n = raw[i + 1] - if n == "n": - out.append("\n") - i += 2 - continue - if n == "r": - out.append("\r") - i += 2 - continue - if n == "t": - out.append("\t") - i += 2 - continue - out.append(n) - i += 2 - continue - out.append(c) - i += 1 - return "".join(out) - - -def _parse_locale(path: Path) -> dict[str, str]: - text = path.read_text(encoding="utf-8") - mapping: dict[str, str] = {} - line_re = re.compile( - r"^\s*'((?:\\.|[^'])*)':\s*'((?:\\.|[^'])*)'\s*,?\s*(//.*)?$" - ) - for line in text.splitlines(): - m = line_re.match(line.strip()) - if not m: - continue - key = _unescape_ts_single_quoted(m.group(1)) - val = _unescape_ts_single_quoted(m.group(2)) - mapping[key] = val - return mapping - - -def main() -> None: - deMap = _parse_locale(_SRC / "de.ts") - enMap = _parse_locale(_SRC / "en.ts") - frMap = _parse_locale(_SRC / "fr.ts") - - dePlain = {v: v for v in deMap.values()} - enPlain: dict[str, str] = {} - frPlain: dict[str, str] = {} - for dotKey, germanText in deMap.items(): - if dotKey in enMap: - enPlain[germanText] = enMap[dotKey] - if dotKey in frMap: - frPlain[germanText] = frMap[dotKey] - - payload = [ - { - "id": "de", - "label": "Deutsch", - "keys": dePlain, - "status": "complete", - "isDefault": True, - }, - { - "id": "en", - "label": "English", - "keys": enPlain, - "status": "complete", - "isDefault": False, - }, - { - "id": "fr", - "label": "Français", - "keys": frPlain, - "status": "complete", - "isDefault": False, - }, - ] - _OUT.parent.mkdir(parents=True, exist_ok=True) - _OUT.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") - print("Wrote", _OUT, "keys de/en/fr", len(dePlain), len(enPlain), len(frPlain)) - - -if __name__ == "__main__": - main() diff --git a/scripts/exportDbSchemaFromModels.py b/scripts/exportDbSchemaFromModels.py index be715c80..dc3e4ab8 100644 --- a/scripts/exportDbSchemaFromModels.py +++ b/scripts/exportDbSchemaFromModels.py @@ -51,13 +51,13 @@ def _buildCompleteTableToDbMap() -> Dict[str, str]: More reliable than fkRegistry._buildTableToDbMap() for the schema script because it catches ALL tables, not just FK targets. """ - from modules.shared.dbRegistry import getRegisteredDatabases - from modules.system.databaseHealth import _getConnection + from modules.dbHelpers.dbRegistry import getRegisteredDatabases + from modules.system.databaseHealth import getConnection mapping: Dict[str, str] = {} for dbName in getRegisteredDatabases(): try: - conn = _getConnection(dbName) + conn = getConnection(dbName) try: with conn.cursor() as cur: cur.execute(""" @@ -154,7 +154,7 @@ def _resolveTypeName(annotation) -> str: def _renderMarkdown(schema: Dict[str, List[dict]]) -> str: """Render the schema as markdown.""" - from modules.shared.dbRegistry import getRegisteredDatabases + from modules.dbHelpers.dbRegistry import getRegisteredDatabases registeredDbs = getRegisteredDatabases() now = datetime.now().strftime("%Y-%m-%d %H:%M") diff --git a/tests/functional/test12_json_split_merge.py b/tests/functional/test12_json_split_merge.py index 368066c4..6e10c58c 100644 --- a/tests/functional/test12_json_split_merge.py +++ b/tests/functional/test12_json_split_merge.py @@ -21,7 +21,7 @@ if _gateway_path not in sys.path: # Import JSON merger from workflow tools from modules.serviceCenter.services.serviceAi.subJsonMerger import ModularJsonMerger, JsonMergeLogger -from modules.shared.jsonContinuation import getContexts +from modules.datamodels.jsonContinuation import getContexts class JsonSplitMergeTester12: diff --git a/tests/functional/test13_json_completion_cuts.py b/tests/functional/test13_json_completion_cuts.py index 4ff05014..494678fc 100644 --- a/tests/functional/test13_json_completion_cuts.py +++ b/tests/functional/test13_json_completion_cuts.py @@ -19,7 +19,7 @@ if _gateway_path not in sys.path: sys.path.insert(0, _gateway_path) # Import JSON continuation module -from modules.shared.jsonContinuation import getContexts +from modules.datamodels.jsonContinuation import getContexts class JsonCompletionTester13: diff --git a/tests/functional/test14_json_continuation_context.py b/tests/functional/test14_json_continuation_context.py index 805e2ae7..ae7ea00e 100644 --- a/tests/functional/test14_json_continuation_context.py +++ b/tests/functional/test14_json_continuation_context.py @@ -18,7 +18,7 @@ if _gateway_path not in sys.path: sys.path.insert(0, _gateway_path) # Import jsonContinuation -from modules.shared.jsonContinuation import getContexts +from modules.datamodels.jsonContinuation import getContexts class JsonContinuationContextTester14: diff --git a/tests/integration/rbac/test_rbac_database.py b/tests/integration/rbac/test_rbac_database.py index fc444411..dbf56dd3 100644 --- a/tests/integration/rbac/test_rbac_database.py +++ b/tests/integration/rbac/test_rbac_database.py @@ -10,6 +10,7 @@ import psycopg2 import pytest from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.datamodels.datamodelUam import User, AccessLevel, UserPermissions +from modules.interfaces.interfaceRbac import buildRbacWhereClause def _dbConfig(): @@ -112,7 +113,7 @@ class TestRbacDatabaseFiltering: roleLabels=["sysadmin"], ) - whereClause = db.buildRbacWhereClause(permissions, user, "SomeTable") + whereClause = buildRbacWhereClause(permissions, user, "SomeTable", db) # ALL access should return None (no filtering) assert whereClause is None @@ -134,7 +135,7 @@ class TestRbacDatabaseFiltering: roleLabels=["user"], ) - whereClause = db.buildRbacWhereClause(permissions, user, "SomeTable") + whereClause = buildRbacWhereClause(permissions, user, "SomeTable", db) assert whereClause is not None assert whereClause["condition"] == '"sysCreatedBy" = %s' @@ -158,8 +159,8 @@ class TestRbacDatabaseFiltering: roleLabels=["admin"], ) - whereClause = db.buildRbacWhereClause( - permissions, user, "SomeTable", mandateId=mandate_id + whereClause = buildRbacWhereClause( + permissions, user, "SomeTable", db, mandateId=mandate_id ) assert whereClause is not None @@ -183,7 +184,7 @@ class TestRbacDatabaseFiltering: roleLabels=["viewer"], ) - whereClause = db.buildRbacWhereClause(permissions, user, "SomeTable") + whereClause = buildRbacWhereClause(permissions, user, "SomeTable", db) assert whereClause is not None assert whereClause["condition"] == "1 = 0" # Always false @@ -206,7 +207,7 @@ class TestRbacDatabaseFiltering: roleLabels=["user"], ) - whereClause = db.buildRbacWhereClause(permissions, user, "UserInDB") + whereClause = buildRbacWhereClause(permissions, user, "UserInDB", db) # UserInDB with MY access should filter by id field assert whereClause is not None @@ -271,8 +272,8 @@ class TestRbacDatabaseFiltering: roleLabels=["admin"], ) - whereClause = db.buildRbacWhereClause( - permissions, user, "UserConnection", mandateId=testMandateId + whereClause = buildRbacWhereClause( + permissions, user, "UserConnection", db, mandateId=testMandateId ) assert whereClause is not None diff --git a/tests/unit/services/test_featureDataAgent_schema.py b/tests/unit/services/test_featureDataAgent_schema.py index 2b84e08c..0e852c70 100644 --- a/tests/unit/services/test_featureDataAgent_schema.py +++ b/tests/unit/services/test_featureDataAgent_schema.py @@ -39,7 +39,7 @@ from modules.serviceCenter.services.serviceAgent.featureDataAgent import ( @pytest.fixture(scope="module", autouse=True) def _ensureModels(): - fkRegistry._ensureModelsLoaded() + fkRegistry.ensureModelsLoaded() def _trusteeAccountBalanceObj(): diff --git a/tests/unit/services/test_queryValidator.py b/tests/unit/services/test_queryValidator.py index 40c8f444..ed3235f0 100644 --- a/tests/unit/services/test_queryValidator.py +++ b/tests/unit/services/test_queryValidator.py @@ -27,7 +27,7 @@ from modules.serviceCenter.services.serviceAgent.queryValidator import QueryVali @pytest.fixture(scope="module", autouse=True) def _ensureModels(): - fkRegistry._ensureModelsLoaded() + fkRegistry.ensureModelsLoaded() @pytest.fixture() diff --git a/tests/unit/services/test_trusteeOntology.py b/tests/unit/services/test_trusteeOntology.py index 887f69a4..c9945ea4 100644 --- a/tests/unit/services/test_trusteeOntology.py +++ b/tests/unit/services/test_trusteeOntology.py @@ -40,7 +40,7 @@ from modules.shared import fkRegistry @pytest.fixture(scope="module", autouse=True) def _ensureModels(): - fkRegistry._ensureModelsLoaded() + fkRegistry.ensureModelsLoaded() # --------------------------------------------------------------------------- From cf0233f193cc8694c202acf8d1261f6192fcd3c9 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sun, 7 Jun 2026 07:59:31 +0200 Subject: [PATCH 02/16] refactor: architecture cleanup + fix scheduler Automation2Workflow error Fix: add missing Automation2Workflow/Automation2WorkflowRun imports to interfaceFeatureGraphicalEditor.py (caused scheduler crash on boot) Refactor: gdprDeletion via onUserDelete lifecycle hooks Refactor: i18nBootSync accounting labels via app.py parameter injection Refactor: serviceHub moved to serviceCenter/serviceHub.py Split: teamsbot/service.py, realEstate/main, routeTrustee, routeBilling Cleanup: remove obsolete methodTrustee, serviceExceptions shim Co-authored-by: Cursor --- app.py | 59 +- modules/datamodels/datamodelNavigation.py | 348 ++ .../serviceExceptions.py | 0 .../interfaceFeatureGraphicalEditor.py | 2 + .../neutralization/mainNeutralization.py | 24 + .../neutralization/neutralizePlayground.py | 2 +- .../features/realEstate/handlerRealEstate.py | 949 ++++++ modules/features/realEstate/mainRealEstate.py | 2900 +---------------- .../realEstate/routeFeatureRealEstate.py | 1369 +------- .../features/realEstate/serviceAiIntent.py | 1087 ++++++ modules/features/realEstate/serviceBzo.py | 725 +++++ .../features/realEstate/serviceGeometry.py | 817 +++++ modules/features/teamsbot/service.py | 2497 +------------- modules/features/teamsbot/serviceCommands.py | 305 ++ .../features/teamsbot/serviceConversation.py | 996 ++++++ modules/features/teamsbot/serviceWebSocket.py | 545 ++++ .../trustee/handlerTrusteeAccounting.py | 371 +++ modules/features/trustee/mainTrustee.py | 24 + .../features/trustee/routeFeatureTrustee.py | 451 +-- .../features/trustee/workflows/__init__.py | 3 + .../workflows}/methodTrustee/__init__.py | 0 .../methodTrustee/actions/extractFromFiles.py | 2 +- .../methodTrustee/actions/processDocuments.py | 0 .../methodTrustee/actions/queryData.py | 0 .../actions/refreshAccountingData.py | 0 .../methodTrustee/actions/syncToAccounting.py | 0 .../workflows}/methodTrustee/methodTrustee.py | 0 modules/interfaces/interfaceBootstrap.py | 10 +- modules/interfaces/interfaceDbApp.py | 4 +- modules/interfaces/interfaceDbSubscription.py | 2 +- modules/interfaces/interfaceFeatures.py | 2 +- modules/routes/billingWebhookHandler.py | 399 +++ modules/routes/routeBilling.py | 389 +-- modules/routes/routeClickup.py | 2 +- modules/routes/routeSharepoint.py | 2 +- modules/security/rootAccess.py | 44 +- .../core/serviceStreaming/eventManager.py | 224 +- modules/serviceCenter/serviceHub.py | 189 ++ .../serviceBilling/mainServiceBilling.py | 2 +- .../serviceGeneration/subDocumentUtility.py | 58 +- .../mainServiceSubscription.py | 2 +- modules/serviceHub/__init__.py | 196 +- modules/shared/documentUtils.py | 64 + modules/shared/eventManager.py | 167 + modules/shared/featureDiscovery.py | 59 + modules/system/gdprDeletion.py | 46 +- modules/system/i18nBootSync.py | 85 +- modules/system/mainSystem.py | 346 +- modules/system/registry.py | 52 +- .../workflows/automation2/executionEngine.py | 4 +- .../executors/actionNodeExecutor.py | 2 +- .../methods/methodAi/actions/consolidate.py | 2 +- .../methods/methodAi/actions/generateCode.py | 2 +- .../methodAi/actions/generateDocument.py | 2 +- .../methods/methodAi/actions/process.py | 2 +- .../methods/methodAi/actions/webResearch.py | 5 +- .../methodContext/actions/extractContent.py | 4 +- .../processing/shared/methodDiscovery.py | 103 +- modules/workflows/scheduler/mainScheduler.py | 55 +- 59 files changed, 7662 insertions(+), 8339 deletions(-) create mode 100644 modules/datamodels/datamodelNavigation.py rename modules/{shared => datamodels}/serviceExceptions.py (100%) create mode 100644 modules/features/realEstate/handlerRealEstate.py create mode 100644 modules/features/realEstate/serviceAiIntent.py create mode 100644 modules/features/realEstate/serviceBzo.py create mode 100644 modules/features/realEstate/serviceGeometry.py create mode 100644 modules/features/teamsbot/serviceCommands.py create mode 100644 modules/features/teamsbot/serviceConversation.py create mode 100644 modules/features/teamsbot/serviceWebSocket.py create mode 100644 modules/features/trustee/handlerTrusteeAccounting.py create mode 100644 modules/features/trustee/workflows/__init__.py rename modules/{workflows/methods => features/trustee/workflows}/methodTrustee/__init__.py (100%) rename modules/{workflows/methods => features/trustee/workflows}/methodTrustee/actions/extractFromFiles.py (99%) rename modules/{workflows/methods => features/trustee/workflows}/methodTrustee/actions/processDocuments.py (100%) rename modules/{workflows/methods => features/trustee/workflows}/methodTrustee/actions/queryData.py (100%) rename modules/{workflows/methods => features/trustee/workflows}/methodTrustee/actions/refreshAccountingData.py (100%) rename modules/{workflows/methods => features/trustee/workflows}/methodTrustee/actions/syncToAccounting.py (100%) rename modules/{workflows/methods => features/trustee/workflows}/methodTrustee/methodTrustee.py (100%) create mode 100644 modules/routes/billingWebhookHandler.py create mode 100644 modules/serviceCenter/serviceHub.py create mode 100644 modules/shared/documentUtils.py create mode 100644 modules/shared/eventManager.py create mode 100644 modules/shared/featureDiscovery.py diff --git a/app.py b/app.py index f43035c1..c91212e3 100644 --- a/app.py +++ b/app.py @@ -329,6 +329,14 @@ async def lifespan(app: FastAPI): catalogService = getCatalogService() registerAllFeaturesInCatalog(catalogService) logger.info("Feature catalog registration completed") + + # Register service center RBAC objects (Composition Root — avoids system→serviceCenter import) + try: + from modules.serviceCenter import registerServiceObjects + registerServiceObjects(catalogService) + except Exception as e: + logger.warning(f"Service center RBAC registration failed: {e}") + # Persist the in-memory feature registry into the Feature DB-table so # the FeatureInstance.featureCode FK has real targets. Without this # every FeatureInstance row would be flagged as orphan by the @@ -343,7 +351,22 @@ async def lifespan(app: FastAPI): # Sync gateway i18n registry to DB and load translation cache try: from modules.system.i18nBootSync import syncRegistryToDb, loadCache - await syncRegistryToDb() + from modules.serviceCenter.registry import IMPORTABLE_SERVICES + serviceLabels = [svc.get("label") for svc in IMPORTABLE_SERVICES.values()] + + accountingLabels = [] + try: + from modules.features.trustee.accounting.accountingRegistry import getAccountingRegistry + registry = getAccountingRegistry() + for connectorType, connector in (registry._connectors or {}).items(): + for field in connector.getRequiredConfigFields(): + label = getattr(field, "label", "") or "" + if label: + accountingLabels.append({"label": label, "connectorType": connectorType}) + except Exception: + pass + + await syncRegistryToDb(serviceLabels=serviceLabels, accountingLabels=accountingLabels) await loadCache() logger.info("i18n registry sync + cache load completed") except Exception as e: @@ -409,9 +432,41 @@ async def lifespan(app: FastAPI): try: main_loop = asyncio.get_running_loop() eventManager.set_event_loop(main_loop) - from modules.workflows.scheduler.mainScheduler import setMainLoop as setSchedulerMainLoop + from modules.workflows.scheduler.mainScheduler import setMainLoop as setSchedulerMainLoop, setOnRunFailedCallback setSchedulerMainLoop(main_loop) + # Inject run-failed notification callback (Composition Root — avoids workflows→serviceCenter import) + 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() + if not rootInterface: + return + eventUser = rootInterface.getUserByUsername("event") + if not eventUser: + return + ctx = ServiceCenterContext( + user=eventUser, + mandate_id=mandateId or "", + feature_instance_id="", + feature_code="graphicalEditor", + ) + messagingService = getService("messaging", ctx) + subscriptionId = "GraphicalEditorRunFailed" + eventParams = MessagingEventParameters(triggerData={ + "workflowId": workflowId, + "workflowLabel": workflowLabel or workflowId, + "runId": runId, + "error": error, + "mandateId": mandateId or "", + }) + messagingService.executeSubscription(subscriptionId, eventParams) + + setOnRunFailedCallback(_onRunFailed) + # Suppress noisy ConnectionResetError from ProactorEventLoop on Windows # when clients (browsers) close connections abruptly. This is a known # asyncio issue on Windows: https://bugs.python.org/issue39010 diff --git a/modules/datamodels/datamodelNavigation.py b/modules/datamodels/datamodelNavigation.py new file mode 100644 index 00000000..2fc278ef --- /dev/null +++ b/modules/datamodels/datamodelNavigation.py @@ -0,0 +1,348 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Navigation structure data (Layer L1 - datamodels). +Single source of truth for UI navigation sections used by RBAC and frontend. +""" + +from modules.shared.i18nRegistry import t + +# ============================================================================= +# Navigation Structure (Single Source of Truth) +# ============================================================================= +# +# Block Order (gemaess Navigation-API-Konzept): +# - System: 10 +# - : 15 (wird in routeSystem.py eingefuegt) +# - Basisdaten: 30 +# - Administration: 200 +# +# NOTE: Workflows and Migrate sections removed - now handled as features +# +# Item Order: Default-Abstand 10 pro Item +# uiComponent: Abgeleitet von objectKey (ui.system.home -> page.system.home) +# icon: Wird intern gehalten aber NICHT in der API Response zurueckgegeben + +NAVIGATION_SECTIONS = [ + # --- Meine Sicht (with top-level item + subgroups) --- + { + "id": "system", + "title": t("Meine Sicht"), + "order": 10, + "items": [ + { + "id": "home", + "objectKey": "ui.system.home", + "label": t("Start"), + "icon": "FaHome", + "path": "/", + "order": 10, + "public": True, + }, + ], + "subgroups": [ + { + "id": "system-overviews", + "title": t("Übersichten"), + "order": 15, + "items": [ + { + "id": "integrations", + "objectKey": "ui.system.integrations", + "label": t("Integrationen"), + "icon": "FaProjectDiagram", + "path": "/integrations", + "order": 10, + "public": True, + }, + { + "id": "compliance-audit", + "objectKey": "ui.system.complianceAudit", + "label": t("Compliance & Audit"), + "icon": "FaShieldAlt", + "path": "/compliance-audit", + "order": 20, + }, + ], + }, + { + "id": "system-basedata", + "title": t("Basisdaten"), + "order": 20, + "items": [ + { + "id": "connections", + "objectKey": "ui.system.connections", + "label": t("Verbindungen"), + "icon": "FaLink", + "path": "/basedata/connections", + "order": 10, + "public": True, + }, + { + "id": "files", + "objectKey": "ui.system.files", + "label": t("Dateien"), + "icon": "FaRegFileAlt", + "path": "/basedata/files", + "order": 20, + "public": True, + }, + { + "id": "prompts", + "objectKey": "ui.system.prompts", + "label": t("Prompts"), + "icon": "FaLightbulb", + "path": "/basedata/prompts", + "order": 30, + "public": True, + }, + ], + }, + { + "id": "system-usage", + "title": t("Nutzung"), + "order": 30, + "items": [ + { + "id": "billing-admin", + "objectKey": "ui.system.billingAdmin", + "label": t("Abrechnung"), + "icon": "FaMoneyBillAlt", + "path": "/billing/admin", + "order": 10, + }, + { + "id": "statistics", + "objectKey": "ui.system.statistics", + "label": t("Statistiken"), + "icon": "FaChartBar", + "path": "/billing/transactions", + "order": 20, + }, + { + "id": "automations", + "objectKey": "ui.system.automations", + "label": t("Automations"), + "icon": "FaRobot", + "path": "/automations", + "order": 30, + }, + { + "id": "rag-inventory", + "objectKey": "ui.system.ragInventory", + "label": t("RAG-Inventar"), + "icon": "FaDatabase", + "path": "/rag-inventory", + "order": 35, + }, + { + "id": "store", + "objectKey": "ui.system.store", + "label": t("Store"), + "icon": "FaStore", + "path": "/store", + "order": 40, + "public": True, + }, + { + "id": "settings", + "objectKey": "ui.system.settings", + "label": t("Einstellungen"), + "icon": "FaCog", + "path": "/settings", + "order": 50, + "public": True, + }, + ], + }, + ], + }, + # --- Administration (with subgroups) --- + { + "id": "admin", + "title": t("Administration"), + "order": 200, + "subgroups": [ + { + "id": "admin-wizards", + "title": t("Wizards"), + "order": 10, + "items": [ + { + "id": "admin-mandate-wizard", + "objectKey": "ui.admin.mandateWizard", + "label": t("Mandanten-Wizard"), + "icon": "FaMagic", + "path": "/admin/mandate-wizard", + "order": 10, + "adminOnly": True, + }, + { + "id": "admin-invitation-wizard", + "objectKey": "ui.admin.invitationWizard", + "label": t("Einladungs-Wizard"), + "icon": "FaEnvelopeOpenText", + "path": "/admin/invitation-wizard", + "order": 20, + "adminOnly": True, + }, + ], + }, + { + "id": "admin-users-group", + "title": t("Benutzer"), + "order": 20, + "items": [ + { + "id": "admin-users", + "objectKey": "ui.admin.users", + "label": t("Benutzer"), + "icon": "FaUsers", + "path": "/admin/users", + "order": 10, + "adminOnly": True, + }, + { + "id": "admin-invitations", + "objectKey": "ui.admin.invitations", + "label": t("Benutzer-Einladungen"), + "icon": "FaEnvelopeOpenText", + "path": "/admin/invitations", + "order": 20, + "adminOnly": True, + }, + { + "id": "admin-user-access-overview", + "objectKey": "ui.admin.userAccessOverview", + "label": t("Benutzer-Zugriffsübersicht"), + "icon": "FaClipboardList", + "path": "/admin/user-access-overview", + "order": 30, + "adminOnly": True, + }, + { + "id": "admin-subscriptions", + "objectKey": "ui.admin.subscriptions", + "label": t("Abonnements"), + "icon": "FaFileContract", + "path": "/admin/subscriptions", + "order": 40, + "adminOnly": True, + }, + ], + }, + { + "id": "admin-system-group", + "title": t("System"), + "order": 30, + "items": [ + { + "id": "admin-roles", + "objectKey": "ui.admin.roles", + "label": t("Rollen"), + "icon": "FaUserTag", + "path": "/admin/mandate-roles", + "order": 10, + "adminOnly": True, + }, + { + "id": "admin-mandate-role-permissions", + "objectKey": "ui.admin.mandateRolePermissions", + "label": t("Rollen-Berechtigungen"), + "icon": "FaKey", + "path": "/admin/mandate-role-permissions", + "order": 20, + "adminOnly": True, + }, + { + "id": "admin-mandates", + "objectKey": "ui.admin.mandates", + "label": t("Mandanten"), + "icon": "FaBuilding", + "path": "/admin/mandates", + "order": 30, + "adminOnly": True, + }, + { + "id": "admin-user-mandates", + "objectKey": "ui.admin.userMandates", + "label": t("Mandanten-Mitglieder"), + "icon": "FaUserFriends", + "path": "/admin/user-mandates", + "order": 40, + "adminOnly": True, + }, + { + "id": "admin-access", + "objectKey": "ui.admin.access", + "label": t("Zugriffsverwaltung"), + "icon": "FaBuilding", + "path": "/admin/access", + "order": 50, + "adminOnly": True, + }, + { + "id": "admin-feature-instances", + "objectKey": "ui.admin.featureInstances", + "label": t("Feature-Instanzen"), + "icon": "FaCubes", + "path": "/admin/feature-instances", + "order": 60, + "adminOnly": True, + }, + { + "id": "admin-feature-roles", + "objectKey": "ui.admin.featureRoles", + "label": t("Features Rollen-Vorlagen"), + "icon": "FaShieldAlt", + "path": "/admin/feature-roles", + "order": 70, + "adminOnly": True, + "sysAdminOnly": True, + }, + { + "id": "admin-logs", + "objectKey": "ui.admin.logs", + "label": t("Logs"), + "icon": "FaFileAlt", + "path": "/admin/logs", + "order": 90, + "adminOnly": True, + "sysAdminOnly": True, + }, + { + "id": "admin-languages", + "objectKey": "ui.admin.languages", + "label": t("UI-Sprachen"), + "icon": "FaGlobe", + "path": "/admin/languages", + "order": 95, + "adminOnly": True, + "sysAdminOnly": True, + }, + { + "id": "admin-database-health", + "objectKey": "ui.admin.databaseHealth", + "label": t("Datenbank-Gesundheit"), + "icon": "FaDatabase", + "path": "/admin/database-health", + "order": 98, + "adminOnly": True, + "sysAdminOnly": True, + }, + { + "id": "admin-demo-config", + "objectKey": "ui.admin.demoConfig", + "label": t("Demo Config"), + "icon": "FaCubes", + "path": "/admin/demo-config", + "order": 100, + "adminOnly": True, + "sysAdminOnly": True, + }, + ], + }, + ], + }, +] diff --git a/modules/shared/serviceExceptions.py b/modules/datamodels/serviceExceptions.py similarity index 100% rename from modules/shared/serviceExceptions.py rename to modules/datamodels/serviceExceptions.py diff --git a/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py b/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py index e58c7b18..aacc9e45 100644 --- a/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py +++ b/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py @@ -46,6 +46,8 @@ from modules.datamodels.datamodelWorkflowAutomation import ( AutoRun, AutoStepLog, AutoTask, + Automation2Workflow, + Automation2WorkflowRun, ) from modules.features.graphicalEditor.entryPoints import invocations_synced_with_graph from modules.connectors.connectorDbPostgre import DatabaseConnector diff --git a/modules/features/neutralization/mainNeutralization.py b/modules/features/neutralization/mainNeutralization.py index 42f74cf3..2a8a6e19 100644 --- a/modules/features/neutralization/mainNeutralization.py +++ b/modules/features/neutralization/mainNeutralization.py @@ -279,3 +279,27 @@ def onMandateDelete(mandateId: str, instances: list) -> None: except Exception as e: logger.warning(f"Failed to cascade-delete neutralization data for mandate {mandateId}: {e}") + +def onUserDelete(userId: str, currentUser) -> dict: + """Delete/anonymize user data from the neutralization database (GDPR).""" + from modules.system.gdprDeletion import deleteUserDataFromDatabase + from modules.connectors.connectorDbPostgre import DatabaseConnector + from modules.shared.configuration import APP_CONFIG + + dbName = "poweron_neutralization" + try: + db = DatabaseConnector( + dbHost=APP_CONFIG.get("DB_HOST", "localhost"), + dbDatabase=dbName, + dbUser=APP_CONFIG.get("DB_USER"), + dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), + dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), + userId=None, + ) + stats = deleteUserDataFromDatabase(db, userId, dbName) + db.close() + return stats + except Exception as e: + logger.warning(f"onUserDelete neutralization failed: {e}") + return {"database": dbName, "tablesProcessed": 0, "recordsDeleted": 0, "recordsAnonymized": 0, "errors": [str(e)]} + diff --git a/modules/features/neutralization/neutralizePlayground.py b/modules/features/neutralization/neutralizePlayground.py index eab0bdeb..0bd50b49 100644 --- a/modules/features/neutralization/neutralizePlayground.py +++ b/modules/features/neutralization/neutralizePlayground.py @@ -9,7 +9,7 @@ from urllib.parse import urlparse, unquote from modules.datamodels.datamodelUam import User from .datamodelFeatureNeutralizer import DataNeutralizerAttributes, DataNeutraliserConfig, DataNeutralizationSnapshot from .interfaceFeatureNeutralizer import getInterface as _getNeutralizerInterface -from modules.serviceHub import getInterface as getServices +from modules.serviceCenter.serviceHub import getInterface as getServices logger = logging.getLogger(__name__) diff --git a/modules/features/realEstate/handlerRealEstate.py b/modules/features/realEstate/handlerRealEstate.py new file mode 100644 index 00000000..e08ff6aa --- /dev/null +++ b/modules/features/realEstate/handlerRealEstate.py @@ -0,0 +1,949 @@ +""" +Handler functions for Real Estate feature routes. +Contains extracted business logic from route handlers. +""" + +import json +import logging +import re +import aiohttp +from typing import Optional, Dict, Any, List + +from fastapi import HTTPException, status + +from modules.datamodels.datamodelPagination import ( + PaginationParams, + PaginatedResponse, + PaginationMetadata, +) +from modules.interfaces.interfaceDbApp import getRootInterface +from modules.interfaces.interfaceFeatures import getFeatureInterface +from .datamodelFeatureRealEstate import ( + Projekt, + Parzelle, + Dokument, + DokumentTyp, + Gemeinde, + Kanton, + Land, + Kontext, + StatusProzess, +) +from .interfaceFeatureRealEstate import getInterface as getRealEstateInterface +from .mainRealEstate import ( + create_project_with_parcel_data, + extract_bzo_information, +) +from .parcelSelectionService import is_parcel_adjacent_to_selection + +from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerConnector +from modules.connectors.connectorOerebWfs import OerebWfsConnector +from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface +from modules.shared.i18nRegistry import apiRouteContext + +routeApiMsg = apiRouteContext("routeFeatureRealEstate") +logger = logging.getLogger(__name__) + + +# ============================================================================ +# GEMEINDE / BZO HANDLERS +# ============================================================================ + +async def processGemeindenSync(interface, instanceId: str, mandateId: str, onlyCurrent: bool = True) -> Dict[str, Any]: + """ + Fetch all Gemeinden from Swiss Topo and save to DB for an instance. + Creates Kantone as needed. + """ + try: + oerebConnector = OerebWfsConnector() + connector = SwissTopoMapServerConnector(oereb_connector=oerebConnector) + gemeindenData = await connector.get_all_gemeinden(only_current=onlyCurrent) + except Exception as e: + logger.error(f"Error fetching Gemeinden from Swiss Topo: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"Error fetching Gemeinden: {str(e)}") + + gemeindenCreated = 0 + gemeindenSkipped = 0 + kantoneCreated = 0 + errors: List[str] = [] + kantonCache: Dict[str, str] = {} + + def _findGemeindeByBfsNummer(bfsNummer: str) -> Optional[Any]: + try: + gemeinden = interface.getGemeinden(recordFilter={"mandateId": mandateId}) + for g in gemeinden: + for k in (g.kontextInformationen or []): + try: + data = json.loads(k.inhalt) if isinstance(k.inhalt, str) else k.inhalt + if isinstance(data, dict) and str(data.get("bfs_nummer")) == str(bfsNummer): + return g + except (json.JSONDecodeError, AttributeError): + continue + except Exception as ex: + logger.error(f"Error finding Gemeinde by BFS {bfsNummer}: {ex}", exc_info=True) + return None + + def _getOrCreateKanton(kantonAbk: str) -> Optional[str]: + nonlocal kantoneCreated, errors + if not kantonAbk: + return None + if kantonAbk in kantonCache: + return kantonCache[kantonAbk] + kantone = interface.getKantone(recordFilter={"mandateId": mandateId, "abk": kantonAbk}) + if kantone: + kantonCache[kantonAbk] = kantone[0].id + return kantone[0].id + kantonNames = { + "AG": "Aargau", "AI": "Appenzell Innerrhoden", "AR": "Appenzell Ausserrhoden", + "BE": "Bern", "BL": "Basel-Landschaft", "BS": "Basel-Stadt", + "FR": "Freiburg", "GE": "Genf", "GL": "Glarus", "GR": "Graubünden", + "JU": "Jura", "LU": "Luzern", "NE": "Neuenburg", "NW": "Nidwalden", + "OW": "Obwalden", "SG": "St. Gallen", "SH": "Schaffhausen", "SO": "Solothurn", + "SZ": "Schwyz", "TG": "Thurgau", "TI": "Tessin", "UR": "Uri", + "VD": "Waadt", "VS": "Wallis", "ZG": "Zug", "ZH": "Zürich", + } + try: + kantonLabel = kantonNames.get(kantonAbk, kantonAbk) + kanton = Kanton( + mandateId=mandateId, + featureInstanceId=instanceId, + label=kantonLabel, + abk=kantonAbk, + ) + created = interface.createKanton(kanton) + if created and created.id: + kantonCache[kantonAbk] = created.id + kantoneCreated += 1 + return created.id + except Exception as ex: + errors.append(f"Error creating Kanton {kantonAbk}: {ex}") + return None + + savedGemeinden: List[Dict[str, Any]] = [] + for gd in gemeindenData: + try: + gemeindeName = gd.get("name") + bfsNummer = gd.get("bfs_nummer") + kantonAbk = gd.get("kanton") + if not gemeindeName or bfsNummer is None: + gemeindenSkipped += 1 + continue + existing = _findGemeindeByBfsNummer(str(bfsNummer)) + if existing: + gemeindenSkipped += 1 + savedGemeinden.append(existing.model_dump() if hasattr(existing, "model_dump") else existing) + continue + kantonId = _getOrCreateKanton(kantonAbk) if kantonAbk else None + gemeinde = Gemeinde( + mandateId=mandateId, + featureInstanceId=instanceId, + label=gemeindeName, + id_kanton=kantonId, + kontextInformationen=[ + Kontext(thema="BFS Nummer", inhalt=json.dumps({"bfs_nummer": bfsNummer}, ensure_ascii=False)) + ], + ) + created = interface.createGemeinde(gemeinde) + if created and created.id: + gemeindenCreated += 1 + savedGemeinden.append(created.model_dump() if hasattr(created, "model_dump") else created) + else: + errors.append(f"Failed to create Gemeinde {gemeindeName}") + gemeindenSkipped += 1 + except Exception as ex: + errors.append(f"Error processing {gd.get('name', 'Unknown')}: {str(ex)}") + gemeindenSkipped += 1 + + return { + "gemeinden": savedGemeinden, + "count": len(savedGemeinden), + "stats": { + "gemeinden_created": gemeindenCreated, + "gemeinden_skipped": gemeindenSkipped, + "kantone_created": kantoneCreated, + "error_count": len(errors), + "errors": errors[:10], + }, + } + + +async def processBzoDocumentsFetch(interface, componentInterface, mandateId: str, instanceId: str) -> Dict[str, Any]: + """Search for and download BZO documents for all Gemeinden of an instance.""" + from modules.features.realEstate.realEstateGemeindeService import fetch_bzo_for_gemeinde + + gemeinden = interface.getGemeinden(recordFilter={"mandateId": mandateId}) + stats = {"gemeinden_processed": 0, "documents_created": 0, "documents_skipped": 0, "errors": []} + results: List[Dict[str, Any]] = [] + + for gemeinde in gemeinden: + gr = {"gemeinde_id": gemeinde.id, "gemeinde_label": gemeinde.label, "status": None, "dokument_ids": [], "error": None} + try: + stats["gemeinden_processed"] += 1 + fetched = await fetch_bzo_for_gemeinde( + interface, componentInterface, gemeinde, mandateId, instanceId + ) + if fetched: + gr["status"] = "created" + stats["documents_created"] += 1 + refreshed = interface.getGemeinde(gemeinde.id) + if refreshed and refreshed.dokumente: + for doc in refreshed.dokumente: + docId = getattr(doc, "id", None) or (doc.get("id") if isinstance(doc, dict) else None) + if docId: + gr["dokument_ids"].append(docId) + else: + gr["status"] = "skipped" + stats["documents_skipped"] += 1 + except Exception as ex: + gr["status"] = "error" + gr["error"] = str(ex) + stats["errors"].append(f"{gemeinde.label}: {str(ex)}") + results.append(gr) + + return {"success": True, "stats": stats, "results": results} + + +async def processParcelDocuments(interface, componentInterface, gemeindeName: str, bauzone: str, mandateId: str, instanceId: str) -> Dict[str, Any]: + """ + Ensure BZO document exists for Gemeinde, return documents for parcel info display. + Creates Gemeinde (Swiss Topo) and BZO (Tavily) if not in DB. + """ + from modules.features.realEstate.realEstateGemeindeService import ( + ensure_single_gemeinde, + fetch_bzo_for_gemeinde, + ) + + gemeindeObj = None + byLabel = interface.getGemeinden(recordFilter={"label": gemeindeName, "mandateId": mandateId}) + gemeindeObj = byLabel[0] if byLabel else None + + if not gemeindeObj: + allG = interface.getGemeinden(recordFilter={"mandateId": mandateId}) + gNorm = gemeindeName.strip().lower() + for g in allG: + gl = (g.label or "").strip().lower() + if gl == gNorm or gNorm in gl or gl in gNorm: + gemeindeObj = g + logger.debug(f"parcel-documents: Found Gemeinde by label match '{gemeindeName}' -> '{g.label}'") + break + + if gemeindeObj: + logger.debug(f"parcel-documents: Gemeinde '{gemeindeName}' resolved: {gemeindeObj.id}") + + if not gemeindeObj: + logger.info(f"parcel-documents: No Gemeinde for label '{gemeindeName}', ensuring via Swiss Topo...") + gemeindeObj = await ensure_single_gemeinde(interface, mandateId, instanceId, gemeinde_name=gemeindeName) + + if not gemeindeObj: + logger.warning(f"parcel-documents: Gemeinde '{gemeindeName}' nicht gefunden (mandateId={mandateId[:8]}...)") + return {"documents": [], "error": f"Gemeinde '{gemeindeName}' nicht gefunden"} + + bzoDocs = [] + if gemeindeObj.dokumente: + for doc in gemeindeObj.dokumente: + typ = getattr(doc, "dokumentTyp", None) or (doc.get("dokumentTyp") if isinstance(doc, dict) else None) + if typ in [DokumentTyp.GEMEINDE_BZO_AKTUELL, DokumentTyp.GEMEINDE_BZO_REVISION] or str(typ) in ["gemeindeBzoAktuell", "gemeindeBzoRevision"]: + docId = doc.id if hasattr(doc, "id") else doc.get("id") + if docId: + full = interface.getDokument(docId) + if full and full.dokumentReferenz: + bzoDocs.append(full) + + if not bzoDocs: + logger.info(f"parcel-documents: No BZO for {gemeindeName}, fetching...") + fetched = await fetch_bzo_for_gemeinde(interface, componentInterface, gemeindeObj, mandateId, instanceId) + if fetched: + gemeindeObj = interface.getGemeinde(gemeindeObj.id) + if gemeindeObj and gemeindeObj.dokumente: + for doc in gemeindeObj.dokumente: + typ = getattr(doc, "dokumentTyp", None) or (doc.get("dokumentTyp") if isinstance(doc, dict) else None) + if typ in [DokumentTyp.GEMEINDE_BZO_AKTUELL, DokumentTyp.GEMEINDE_BZO_REVISION]: + docId = doc.id if hasattr(doc, "id") else doc.get("id") + if docId: + full = interface.getDokument(docId) + if full and full.dokumentReferenz: + bzoDocs.append(full) + + result = [] + for d in bzoDocs: + result.append({ + "id": d.id, + "label": d.label, + "fileId": d.dokumentReferenz, + "fileName": (d.label or "BZO") + ".pdf", + "mimeType": d.mimeType or "application/pdf", + }) + return {"documents": result, "gemeinde": gemeindeName, "bauzone": bauzone} + + +# ============================================================================ +# LEGACY TABLE HANDLERS +# ============================================================================ + +def processTableData(user, mandateId: Optional[str], table: str, pagination: Optional[str]) -> PaginatedResponse: + """Fetch and paginate table data for a real estate entity table.""" + tableMapping = { + "Projekt": (Projekt, "getProjekte"), + "Parzelle": (Parzelle, "getParzellen"), + "Dokument": (Dokument, "getDokumente"), + "Gemeinde": (Gemeinde, "getGemeinden"), + "Kanton": (Kanton, "getKantone"), + "Land": (Land, "getLaender"), + } + + if table not in tableMapping: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid table name '{table}'. Available tables: {', '.join(tableMapping.keys())}" + ) + + realEstateInterface = getRealEstateInterface(user, mandateId=mandateId) + modelClass, methodName = tableMapping[table] + getterMethod = getattr(realEstateInterface, methodName) + items = getterMethod(recordFilter=None) + + paginationParams = None + if pagination: + try: + paginationDict = json.loads(pagination) + paginationParams = PaginationParams(**paginationDict) if paginationDict else None + except (json.JSONDecodeError, ValueError) as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid pagination parameter: {str(e)}" + ) + + if paginationParams: + if paginationParams.sort: + for sortField in reversed(paginationParams.sort): + fieldName = sortField.field + direction = sortField.direction.lower() + + def _sortKey(item, _fieldName=fieldName): + value = getattr(item, _fieldName, None) + if value is None: + return (1, None) + return (0, value) + + items.sort(key=_sortKey, reverse=(direction == "desc")) + + totalItems = len(items) + totalPages = (totalItems + paginationParams.pageSize - 1) // paginationParams.pageSize + startIdx = (paginationParams.page - 1) * paginationParams.pageSize + endIdx = startIdx + paginationParams.pageSize + paginatedItems = items[startIdx:endIdx] + + return PaginatedResponse( + items=paginatedItems, + pagination=PaginationMetadata( + currentPage=paginationParams.page, + pageSize=paginationParams.pageSize, + totalItems=totalItems, + totalPages=totalPages, + sort=paginationParams.sort, + filters=paginationParams.filters + ) + ) + + return PaginatedResponse(items=items, pagination=None) + + +async def processCreateTableRecord(user, mandateId: Optional[str], table: str, data: Dict[str, Any]) -> Dict[str, Any]: + """Create a record in a real estate table, with special handling for Projekt+parcel.""" + if table == "Projekt" and ("parzelle" in data or "parzellen" in data): + logger.info(f"Creating Projekt with parcel data for user {user.id}") + + label = data.get("label") + if not label: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=routeApiMsg("label is required") + ) + + statusProzess = data.get("statusProzess", "Eingang") + + parzellenData = [] + if "parzellen" in data: + parzellenData = data.get("parzellen", []) + if not isinstance(parzellenData, list): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=routeApiMsg("parzellen must be an array") + ) + elif "parzelle" in data: + parzelleData = data.get("parzelle") + if parzelleData: + parzellenData = [parzelleData] + + if not parzellenData: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=routeApiMsg("parzelle or parzellen data is required") + ) + + result = await create_project_with_parcel_data( + currentUser=user, + mandateId=mandateId, + projekt_label=label, + parzellen_data=parzellenData, + status_prozess=statusProzess, + ) + return result.get("projekt", {}) + + tableMapping = { + "Projekt": (Projekt, "createProjekt"), + "Parzelle": (Parzelle, "createParzelle"), + "Dokument": (Dokument, "createDokument"), + "Gemeinde": (Gemeinde, "createGemeinde"), + "Kanton": (Kanton, "createKanton"), + "Land": (Land, "createLand"), + } + + if table not in tableMapping: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid table name '{table}'. Available tables: {', '.join(tableMapping.keys())}" + ) + + realEstateInterface = getRealEstateInterface(user, mandateId=mandateId) + modelClass, methodName = tableMapping[table] + createMethod = getattr(realEstateInterface, methodName) + + if "mandateId" not in data: + data["mandateId"] = mandateId + + try: + modelInstance = modelClass(**data) + except Exception as e: + logger.error(f"Error creating {table} model instance: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid data for {table}: {str(e)}" + ) + + createdRecord = createMethod(modelInstance) + if hasattr(createdRecord, 'model_dump'): + return createdRecord.model_dump() + return createdRecord + + +# ============================================================================ +# PARCEL SEARCH HANDLER +# ============================================================================ + +async def processParcelSearch(user, mandateId: Optional[str], location: str, includeBauzone: bool, includeAdjacent: bool) -> Dict[str, Any]: + """ + Search for parcel information by address or coordinates. + Resolves address, calculates geometry, optionally fetches adjacent parcels and bauzone. + """ + connector = SwissTopoMapServerConnector() + parcelData = await connector.search_parcel(location) + + if not parcelData: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"No parcel found for location: {location}" + ) + + extractedAttributes = connector.extract_parcel_attributes(parcelData) + attributes = parcelData.get("attributes", {}) + geometry = parcelData.get("geometry", {}) + + areaM2 = None + centroid = None + if extractedAttributes.get("perimeter"): + perimeter = extractedAttributes["perimeter"] + points = perimeter.get("punkte", []) + if len(points) >= 3: + area = 0 + for i in range(len(points)): + j = (i + 1) % len(points) + area += points[i]["x"] * points[j]["y"] + area -= points[j]["x"] * points[i]["y"] + areaM2 = abs(area / 2) + sumX = sum(p["x"] for p in points) + sumY = sum(p["y"] for p in points) + centroid = {"x": sumX / len(points), "y": sumY / len(points)} + + canton = attributes.get("ak", "") + municipalityName = None + fullAddress = None + plz = None + + geocodedAddress = parcelData.get('geocoded_address') + if geocodedAddress: + fullAddress = geocodedAddress.get('full_address') + plz = geocodedAddress.get('plz') + municipalityName = geocodedAddress.get('municipality') + logger.debug(f"Using geocoded address: {fullAddress}") + + queryCoords = parcelData.get('query_coordinates') + addressQueryCoords = queryCoords if queryCoords else centroid + + if not fullAddress and addressQueryCoords: + queryX = addressQueryCoords['x'] + queryY = addressQueryCoords['y'] + logger.debug(f"Querying address layer at query coordinates: ({queryX}, {queryY})") + + isCoordinateSearch = ',' in location and not any(c.isalpha() for c in location.split(',')[0]) + buildingTolerance = 1 if isCoordinateSearch else 10 + buildingResult = await connector._query_building_layer(queryX, queryY, tolerance=buildingTolerance, buffer=25) + + if buildingResult: + addrAttrs = buildingResult.get("attributes", {}) + logger.debug(f"Address layer attributes: {addrAttrs}") + addressInfo = connector._extract_address_from_building_attrs(addrAttrs) + fullAddress = addressInfo.get('full_address') + plz = addressInfo.get('plz') + municipalityName = addressInfo.get('municipality') + if fullAddress: + logger.debug(f"Constructed address: {fullAddress}") + + if not fullAddress: + if location and any(c.isalpha() for c in location) and "CH" not in location: + fullAddress = location + logger.debug(f"Using location as address: {fullAddress}") + + if not municipalityName and fullAddress: + plzMunicipalityMatch = re.search(r"\b(\d{4})\s+([A-ZÄÖÜ][a-zäöüß\s-]+)", fullAddress) + if plzMunicipalityMatch: + extractedMunicipality = plzMunicipalityMatch.group(2).strip() + extractedMunicipality = re.sub(r"[,;\.]+$", "", extractedMunicipality).strip() + if extractedMunicipality: + municipalityName = extractedMunicipality + if not plz: + plz = plzMunicipalityMatch.group(1) + logger.debug(f"Extracted municipality from address: {municipalityName}") + + bfsnr = attributes.get("bfsnr") + if not municipalityName and bfsnr and canton and mandateId: + try: + interface = getRealEstateInterface(user, mandateId=mandateId, featureInstanceId=None) + gemeinden = interface.getGemeinden(recordFilter={"mandateId": mandateId}) + for g in gemeinden: + for k in (g.kontextInformationen or []): + try: + data = json.loads(k.inhalt) if isinstance(k.inhalt, str) else k.inhalt + if isinstance(data, dict): + bfs = data.get("bfs_nummer") or data.get("bfsnr") or data.get("municipality_code") + if str(bfs) == str(bfsnr): + municipalityName = g.label + logger.debug(f"Found Gemeinde by BFS {bfsnr} in DB: {municipalityName}") + break + except (json.JSONDecodeError, AttributeError): + continue + if municipalityName: + break + except Exception as e: + logger.debug(f"Error querying Gemeinde by BFS: {e}") + + if not municipalityName and centroid and canton: + try: + geocodeUrl = "https://api3.geo.admin.ch/rest/services/api/MapServer/identify" + params = { + "geometry": f"{centroid['x']},{centroid['y']}", + "geometryType": "esriGeometryPoint", + "layers": "all:ch.swisstopo.swissboundaries3d-gemeinde-flaeche.fill", + "tolerance": "0", + "returnGeometry": "false", + "sr": "2056", + "f": "json", + } + async with aiohttp.ClientSession() as session: + async with session.get(geocodeUrl, params=params) as resp: + if resp.status == 200: + data = await resp.json() + results = data.get("results", []) + if results: + attrs = results[0].get("attributes", {}) + geoName = attrs.get("name") or attrs.get("gemeindename") or attrs.get("label") + if geoName: + municipalityName = connector._clean_municipality_name(str(geoName)) + logger.debug(f"Found municipality via Swiss Topo geocoding: {municipalityName}") + except Exception as e: + logger.debug(f"Error querying Swiss Topo geocoding: {e}") + + if not municipalityName and bfsnr: + commonMunicipalities = { + 261: "Zürich", 198: "Pfäffikon", 191: "Uster", 3203: "Winterthur", + 351: "Bern", 2701: "Basel", 6621: "Genève", 5586: "Lausanne", + 1061: "Luzern", 230: "St. Gallen", 5192: "Lugano", 1367: "Schwyz", + } + if bfsnr in commonMunicipalities: + municipalityName = commonMunicipalities[bfsnr] + logger.debug(f"Looked up municipality from common list: {municipalityName}") + elif canton and bfsnr: + municipalityName = f"{canton}-{bfsnr}" + logger.debug(f"Using fallback municipality: {municipalityName}") + + if fullAddress and fullAddress.startswith("CH") and len(fullAddress) == 14 and fullAddress[2:].isdigit(): + fullAddress = None + logger.debug("Removed EGRID from address field") + + bauzone = None + hasGeometry = geometry and (geometry.get("rings") or geometry.get("coordinates")) + if includeBauzone and canton and hasGeometry and centroid: + try: + logger.debug(f"Querying zone information for parcel {attributes.get('label')} in canton {canton}") + oerebConnector = OerebWfsConnector() + zoneResults = await oerebConnector.query_zone_layer( + egrid=attributes.get("egris_egrid", "") or "", + x=centroid["x"], + y=centroid["y"], + canton=canton, + geometry=geometry, + ) + if zoneResults and len(zoneResults) > 0: + zoneAttrs = zoneResults[0].get("attributes", {}) + typGdeAbkuerzung = zoneAttrs.get("typ_gde_abkuerzung") + if typGdeAbkuerzung: + bauzone = typGdeAbkuerzung + logger.debug(f"Found bauzone: {bauzone} for parcel {attributes.get('label')}") + except Exception as e: + logger.warning(f"Error querying zone information: {e}", exc_info=True) + + parcelInfo = { + "id": attributes.get("label") or attributes.get("number"), + "egrid": attributes.get("egris_egrid"), + "number": attributes.get("number"), + "name": attributes.get("name"), + "identnd": attributes.get("identnd"), + "canton": attributes.get("ak"), + "municipality_code": attributes.get("bfsnr"), + "municipality_name": municipalityName, + "address": fullAddress, + "plz": plz, + "perimeter": extractedAttributes.get("perimeter"), + "area_m2": areaM2, + "centroid": centroid, + "geoportal_url": attributes.get("geoportal_url"), + "realestate_type": attributes.get("realestate_type"), + "bauzone": bauzone, + } + + bbox = parcelData.get("bbox", []) + mapView = { + "center": centroid, + "zoom_bounds": { + "min_x": bbox[0] if len(bbox) >= 4 else None, + "min_y": bbox[1] if len(bbox) >= 4 else None, + "max_x": bbox[2] if len(bbox) >= 4 else None, + "max_y": bbox[3] if len(bbox) >= 4 else None + }, + "geometry_geojson": { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [[p["x"], p["y"]] for p in extractedAttributes["perimeter"]["punkte"]] + ] if extractedAttributes.get("perimeter") else [] + }, + "properties": { + "id": parcelInfo["id"], + "egrid": parcelInfo["egrid"], + "number": parcelInfo["number"] + } + } + } + + responseData = { + "parcel": parcelInfo, + "map_view": mapView + } + + if includeAdjacent and parcelData and parcelData.get("geometry"): + try: + selectedParcelId = parcelInfo["id"] + adjacentParcelsRaw = await connector.find_neighboring_parcels( + parcel_data=parcelData, + selected_parcel_id=selectedParcelId, + sample_distance=20.0, + max_sample_points=30, + max_neighbors=15, + max_concurrent=50, + ) + adjacentParcels = [_convertParcelGeometry(adjParcel) for adjParcel in adjacentParcelsRaw] + responseData["adjacent_parcels"] = adjacentParcels + logger.info(f"Found {len(adjacentParcels)} neighboring parcels for parcel {selectedParcelId}") + except Exception as e: + logger.warning(f"Error fetching adjacent parcels: {e}", exc_info=True) + responseData["adjacent_parcels"] = [] + + return responseData + + +def _convertParcelGeometry(adjParcel: Dict[str, Any]) -> Dict[str, Any]: + """Convert an adjacent parcel to include GeoJSON geometry.""" + adjParcelWithGeo = { + "id": adjParcel["id"], + "egrid": adjParcel.get("egrid"), + "number": adjParcel.get("number"), + "perimeter": adjParcel.get("perimeter") + } + + adjGeometry = adjParcel.get("geometry") + adjPerimeter = adjParcel.get("perimeter") + + if adjGeometry: + if "rings" in adjGeometry and adjGeometry["rings"]: + ring = adjGeometry["rings"][0] + coordinates = [[[p[0], p[1]] for p in ring]] + adjParcelWithGeo["geometry_geojson"] = { + "type": "Feature", + "geometry": {"type": "Polygon", "coordinates": coordinates}, + "properties": {"id": adjParcel["id"], "egrid": adjParcel.get("egrid"), "number": adjParcel.get("number")} + } + elif adjGeometry.get("type") == "Polygon": + adjParcelWithGeo["geometry_geojson"] = { + "type": "Feature", + "geometry": adjGeometry, + "properties": {"id": adjParcel["id"], "egrid": adjParcel.get("egrid"), "number": adjParcel.get("number")} + } + + if "geometry_geojson" not in adjParcelWithGeo and adjPerimeter and adjPerimeter.get("punkte"): + punkte = adjPerimeter["punkte"] + coordinates = [[[p["x"], p["y"]] for p in punkte]] + adjParcelWithGeo["geometry_geojson"] = { + "type": "Feature", + "geometry": {"type": "Polygon", "coordinates": coordinates}, + "properties": {"id": adjParcel["id"], "egrid": adjParcel.get("egrid"), "number": adjParcel.get("number")} + } + + return adjParcelWithGeo + + +# ============================================================================ +# ADD ADJACENT PARCEL HANDLER +# ============================================================================ + +async def processAddAdjacentParcel(location: Dict[str, Any], selectedParcels: List[Dict[str, Any]]) -> Dict[str, Any]: + """ + Add an adjacent parcel to the selection. Validates adjacency. + Returns parcel response with geometry. + """ + locStr = f"{location['x']},{location['y']}" + connector = SwissTopoMapServerConnector() + parcelData = await connector.search_parcel(locStr) + + if not parcelData: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=routeApiMsg("No parcel found at this location") + ) + + extracted = connector.extract_parcel_attributes(parcelData) + attributes = parcelData.get("attributes", {}) + geometry = parcelData.get("geometry", {}) + + areaM2 = None + centroid = None + if extracted.get("perimeter"): + perimeter = extracted["perimeter"] + points = perimeter.get("punkte", []) + if len(points) >= 3: + area = 0 + for i in range(len(points)): + j = (i + 1) % len(points) + area += points[i]["x"] * points[j]["y"] + area -= points[j]["x"] * points[i]["y"] + areaM2 = abs(area / 2) + sumX = sum(p["x"] for p in points) + sumY = sum(p["y"] for p in points) + centroid = {"x": sumX / len(points), "y": sumY / len(points)} + + parcelInfo = { + "id": attributes.get("label") or attributes.get("number"), + "egrid": attributes.get("egris_egrid"), + "number": attributes.get("number"), + "name": attributes.get("name"), + "identnd": attributes.get("identnd"), + "canton": attributes.get("ak"), + "municipality_code": attributes.get("bfsnr"), + "municipality_name": None, + "address": None, + "plz": None, + "perimeter": extracted.get("perimeter"), + "area_m2": areaM2, + "centroid": centroid, + "geoportal_url": attributes.get("geoportal_url"), + "realestate_type": attributes.get("realestate_type"), + "bauzone": None, + } + + mapView = { + "center": centroid, + "zoom_bounds": parcelData.get("bbox", []) and { + "min_x": parcelData["bbox"][0], + "min_y": parcelData["bbox"][1], + "max_x": parcelData["bbox"][2], + "max_y": parcelData["bbox"][3], + } or None, + "geometry_geojson": _buildGeometryGeojson(extracted, parcelInfo), + } + + newParcelResponse = {"parcel": parcelInfo, "map_view": mapView} + + if not is_parcel_adjacent_to_selection(newParcelResponse, selectedParcels): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=routeApiMsg("Nur angrenzende Parzellen können hinzugefügt werden") + ) + + bbox = parcelData.get("bbox", []) + mapView["zoom_bounds"] = { + "min_x": bbox[0], "min_y": bbox[1], "max_x": bbox[2], "max_y": bbox[3] + } if len(bbox) >= 4 else None + + geocodedAddress = parcelData.get("geocoded_address") + if geocodedAddress: + parcelInfo["municipality_name"] = geocodedAddress.get("municipality") + parcelInfo["address"] = geocodedAddress.get("full_address") + parcelInfo["plz"] = geocodedAddress.get("plz") + + if centroid and attributes.get("ak"): + try: + oereb = OerebWfsConnector() + zoneResults = await oereb.query_zone_layer( + egrid=attributes.get("egris_egrid", "") or "", + x=centroid["x"], y=centroid["y"], + canton=attributes.get("ak"), + geometry=geometry, + ) + if zoneResults and len(zoneResults) > 0: + parcelInfo["bauzone"] = zoneResults[0].get("attributes", {}).get("typ_gde_abkuerzung") + except Exception as oe: + logger.debug(f"ÖREB zone query failed: {oe}") + + return newParcelResponse + + +def _buildGeometryGeojson(extracted: Dict[str, Any], parcelInfo: Dict[str, Any]) -> Dict[str, Any]: + """Build geometry_geojson from extracted perimeter.""" + coords = [] + if extracted.get("perimeter", {}).get("punkte"): + coords = [[[p["x"], p["y"]] for p in extracted["perimeter"]["punkte"]]] + return { + "type": "Feature", + "geometry": {"type": "Polygon", "coordinates": coords}, + "properties": {"id": parcelInfo["id"], "egrid": parcelInfo["egrid"], "number": parcelInfo["number"]}, + } + + +# ============================================================================ +# ADD PARCEL TO PROJECT HANDLER +# ============================================================================ + +async def processAddParcelToProject(user, mandateId: Optional[str], projektId: str, body: Dict[str, Any]) -> Dict[str, Any]: + """ + Add a parcel to an existing project. + Supports linking existing, creating from location, or creating from custom data. + """ + realEstateInterface = getRealEstateInterface(user, mandateId=mandateId) + + recordFilter = {"id": projektId} + if mandateId: + recordFilter["mandateId"] = mandateId + projekte = realEstateInterface.getProjekte(recordFilter=recordFilter) + if not projekte: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Projekt {projektId} not found" + ) + projekt = projekte[0] + + parcelId = body.get("parcelId") + location = body.get("location") + parcelDataDict = body.get("parcelData") + parzelle = None + + if parcelId: + logger.info(f"Linking existing parcel {parcelId}") + parcelFilter = {"id": parcelId} + if mandateId: + parcelFilter["mandateId"] = mandateId + parcels = realEstateInterface.getParzellen(recordFilter=parcelFilter) + if not parcels: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Parzelle {parcelId} not found" + ) + parzelle = parcels[0] + + elif location: + logger.info(f"Creating parcel from location: {location}") + connector = SwissTopoMapServerConnector() + parcelData = await connector.search_parcel(location) + if not parcelData: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"No parcel found at location: {location}" + ) + extractedAttributes = connector.extract_parcel_attributes(parcelData) + attributes = parcelData.get("attributes", {}) + + parzelleCreateData = { + "mandateId": mandateId, + "label": extractedAttributes.get("label") or attributes.get("number") or "Unknown", + "parzellenAliasTags": [attributes.get("egris_egrid")] if attributes.get("egris_egrid") else [], + "eigentuemerschaft": None, + "strasseNr": location if not location.replace(",", "").replace(".", "").replace(" ", "").isdigit() else None, + "plz": None, + "perimeter": extractedAttributes.get("perimeter"), + "baulinie": None, + "kontextGemeinde": None, + "bauzone": None, + "az": None, + "bz": None, + "vollgeschossZahl": None, + "anrechenbarDachgeschoss": None, + "anrechenbarUntergeschoss": None, + "gebaeudehoeheMax": None, + "regelnGrenzabstand": [], + "regelnMehrlaengenzuschlag": [], + "regelnMehrhoehenzuschlag": [], + "parzelleBebaut": None, + "parzelleErschlossen": None, + "parzelleHanglage": None, + "laermschutzzone": None, + "hochwasserschutzzone": None, + "grundwasserschutzzone": None, + "parzellenNachbarschaft": [], + "dokumente": [], + "kontextInformationen": [ + Kontext( + thema="Swiss Topo Data", + inhalt=json.dumps({ + "egrid": attributes.get("egris_egrid"), + "identnd": attributes.get("identnd"), + "canton": attributes.get("ak"), + "municipality_code": attributes.get("bfsnr"), + "geoportal_url": attributes.get("geoportal_url") + }, ensure_ascii=False) + ) + ] + } + parzelleInstance = Parzelle(**parzelleCreateData) + parzelle = realEstateInterface.createParzelle(parzelleInstance) + + elif parcelDataDict: + logger.info(f"Creating parcel from custom data") + parcelDataDict["mandateId"] = mandateId + parzelleInstance = Parzelle(**parcelDataDict) + parzelle = realEstateInterface.createParzelle(parzelleInstance) + + else: + raise ValueError("One of 'parcelId', 'location', or 'parcelData' is required") + + if parzelle not in projekt.parzellen: + projekt.parzellen.append(parzelle) + + if not projekt.perimeter and parzelle.perimeter: + projekt.perimeter = parzelle.perimeter + + updatedProjekt = realEstateInterface.updateProjekt(projekt) + logger.info(f"Added Parzelle {parzelle.id} to Projekt {projektId}") + + return { + "projekt": updatedProjekt.model_dump(), + "parzelle": parzelle.model_dump() + } diff --git a/modules/features/realEstate/mainRealEstate.py b/modules/features/realEstate/mainRealEstate.py index 7ab7d8d5..b9af4827 100644 --- a/modules/features/realEstate/mainRealEstate.py +++ b/modules/features/realEstate/mainRealEstate.py @@ -1,13 +1,14 @@ """ -Real Estate feature main logic. -Handles database operations with AI-powered natural language processing. -Stateless implementation without session management. +Real Estate feature main entry point. +Handles feature definition, RBAC registration, and lifecycle hooks. -This module also handles feature initialization and RBAC catalog registration. +Service logic is split into dedicated modules: +- serviceGeometry: Geometry utilities and project creation with parcel data +- serviceAiIntent: AI-based intent recognition and CRUD operations +- serviceBzo: BZO information extraction and filtering """ import logging -import re from modules.shared.i18nRegistry import t @@ -77,6 +78,12 @@ TEMPLATE_ROLES = [ }, ] +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Feature Definition & RBAC Registration +# --------------------------------------------------------------------------- def getFeatureDefinition(): """Return the feature definition for registration.""" @@ -121,15 +128,18 @@ def registerFeature(catalogService) -> bool: meta=resObj.get("meta") ) - # Sync template roles to database (with AccessRules) _syncTemplateRolesToDb() return True except Exception as e: - logging.getLogger(__name__).error(f"Failed to register feature '{FEATURE_CODE}': {e}") + logger.error(f"Failed to register feature '{FEATURE_CODE}': {e}") return False +# --------------------------------------------------------------------------- +# Internal RBAC Helpers +# --------------------------------------------------------------------------- + def _syncTemplateRolesToDb() -> int: """ Sync template roles and their AccessRules to the database. @@ -169,16 +179,16 @@ def _syncTemplateRolesToDb() -> int: roleId = createdRole.get("id") existingRoleLabels[roleLabel] = roleId _ensureAccessRulesForRole(rootInterface, roleId, roleTemplate.get("accessRules", [])) - logging.getLogger(__name__).info(f"Created template role '{roleLabel}' with ID {roleId}") + logger.info(f"Created template role '{roleLabel}' with ID {roleId}") createdCount += 1 if createdCount > 0: - logging.getLogger(__name__).info(f"Feature '{FEATURE_CODE}': Created {createdCount} template roles") + logger.info(f"Feature '{FEATURE_CODE}': Created {createdCount} template roles") _repairInstanceRolesAccessRules(rootInterface, existingRoleLabels) return createdCount except Exception as e: - logging.getLogger(__name__).error(f"Error syncing template roles for feature '{FEATURE_CODE}': {e}") + logger.error(f"Error syncing template roles for feature '{FEATURE_CODE}': {e}") return 0 @@ -256,2822 +266,6 @@ def _ensureAccessRulesForRole(rootInterface, roleId: str, ruleTemplates: list) - return createdCount -import json -from typing import Optional, Dict, Any, List -from fastapi import HTTPException, status -from shapely.geometry import Polygon -from shapely.ops import unary_union -from modules.datamodels.datamodelUam import User -from .datamodelFeatureRealEstate import ( - Projekt, - Parzelle, - StatusProzess, - GeoPolylinie, - GeoPunkt, - Kontext, - Gemeinde, - Kanton, - Land, - DokumentTyp, -) -from modules.serviceHub import getInterface as getServices -from .interfaceFeatureRealEstate import getInterface as getRealEstateInterface -from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface -from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerConnector -from modules.features.realEstate.bzoDocumentRetriever import BZODocumentRetriever -from modules.features.realEstate.bzoExtraction import run_extraction, run_bzo_params_extraction -from modules.features.realEstate.parcelSelectionService import compute_selection_summary -from modules.features.realEstate.realEstateGemeindeService import ( - ensure_single_gemeinde, - fetch_bzo_for_gemeinde, -) - -logger = logging.getLogger(__name__) - - -# ===== Geometry Utilities ===== - -def geopolylinie_to_shapely_polygon(geopolylinie: GeoPolylinie) -> Polygon: - """ - Convert GeoPolylinie to Shapely Polygon. - - Args: - geopolylinie: GeoPolylinie instance with punkte list - - Returns: - Shapely Polygon object - """ - if not geopolylinie or not geopolylinie.punkte: - raise ValueError("GeoPolylinie must have at least one point") - - # Extract coordinates from punkte - coordinates = [] - for punkt in geopolylinie.punkte: - coordinates.append((punkt.x, punkt.y)) - - # Ensure polygon is closed (first point == last point) - if len(coordinates) < 3: - raise ValueError("Polygon must have at least 3 points") - - # Close polygon if not already closed - if coordinates[0] != coordinates[-1]: - coordinates.append(coordinates[0]) - - return Polygon(coordinates) - - -def shapely_polygon_to_geopolylinie(polygon: Polygon) -> GeoPolylinie: - """ - Convert Shapely Polygon to GeoPolylinie. - - Args: - polygon: Shapely Polygon object - - Returns: - GeoPolylinie instance with LV95 coordinate system - """ - if not polygon or polygon.is_empty: - raise ValueError("Polygon must not be empty") - - # Extract exterior coordinates - exterior_coords = list(polygon.exterior.coords) - - # Remove duplicate last point if present (Shapely includes it) - if len(exterior_coords) > 1 and exterior_coords[0] == exterior_coords[-1]: - exterior_coords = exterior_coords[:-1] - - # Convert to GeoPunkt list - punkte = [] - for coord in exterior_coords: - punkt = GeoPunkt( - koordinatensystem="LV95", - x=float(coord[0]), - y=float(coord[1]), - z=None - ) - punkte.append(punkt) - - return GeoPolylinie( - closed=True, - punkte=punkte - ) - - -def combine_parcel_geometries(geometries: List[GeoPolylinie]) -> GeoPolylinie: - """ - Combine multiple parcel geometries into a single outer outline. - - Uses Shapely union operation to merge polygons and automatically - removes internal edges. The result is a clean outer boundary. - - Args: - geometries: List of GeoPolylinie instances to combine - - Returns: - Combined GeoPolylinie representing the outer outline - - Raises: - ValueError: If geometries list is empty or invalid - """ - if not geometries or len(geometries) == 0: - raise ValueError("At least one geometry is required") - - if len(geometries) == 1: - # Single geometry - return as-is - return geometries[0] - - # Convert all geometries to Shapely Polygons - shapely_polygons = [] - for geo in geometries: - try: - polygon = geopolylinie_to_shapely_polygon(geo) - if not polygon.is_empty: - shapely_polygons.append(polygon) - except Exception as e: - logger.warning(f"Error converting geometry to Shapely Polygon: {e}") - continue - - if not shapely_polygons: - raise ValueError("No valid geometries to combine") - - if len(shapely_polygons) == 1: - # Only one valid polygon - convert back - return shapely_polygon_to_geopolylinie(shapely_polygons[0]) - - # Perform union operation - automatically removes internal edges - try: - combined = unary_union(shapely_polygons) - - # Handle MultiPolygon case (disconnected parcels) - if hasattr(combined, 'geoms'): - # Multiple separate polygons - combine their exteriors - # For now, take the largest polygon or combine all exteriors - # In practice, we might want to keep them separate or combine differently - largest = max(combined.geoms, key=lambda p: p.area) - combined = largest - - # Extract outer boundary - if combined.is_empty: - raise ValueError("Union resulted in empty geometry") - - # Convert back to GeoPolylinie - result = shapely_polygon_to_geopolylinie(combined) - logger.info(f"Combined {len(geometries)} geometries into single outline with {len(result.punkte)} points") - return result - - except Exception as e: - logger.error(f"Error combining geometries: {e}", exc_info=True) - raise ValueError(f"Failed to combine geometries: {str(e)}") - - -def filter_neighbor_parcels( - neighbors: List[Dict[str, Any]], - selected_geometries: List[GeoPolylinie] -) -> List[Dict[str, Any]]: - """ - Filter neighbor parcels to exclude those that are part of the selected parcels. - - Uses geometric comparison to check if neighbor parcels intersect or touch - any of the selected parcel geometries. - - Args: - neighbors: List of neighbor parcel dictionaries (must have 'perimeter' or 'geometry_geojson') - selected_geometries: List of GeoPolylinie instances representing selected parcels - - Returns: - Filtered list of neighbor parcels (excluding selected ones) - """ - if not neighbors or not selected_geometries: - return neighbors - - # Convert selected geometries to Shapely Polygons for comparison - selected_polygons = [] - for geo in selected_geometries: - try: - polygon = geopolylinie_to_shapely_polygon(geo) - if not polygon.is_empty: - selected_polygons.append(polygon) - except Exception as e: - logger.warning(f"Error converting selected geometry for filtering: {e}") - continue - - if not selected_polygons: - # No valid selected geometries - return all neighbors - return neighbors - - # Filter neighbors - filtered_neighbors = [] - for neighbor in neighbors: - try: - # Try to get geometry from neighbor - neighbor_geometry = None - - # Check for perimeter (GeoPolylinie format) - if neighbor.get("perimeter"): - perimeter = neighbor["perimeter"] - if isinstance(perimeter, dict) and perimeter.get("punkte"): - # Convert to GeoPolylinie - punkte = [] - for p in perimeter["punkte"]: - punkt = GeoPunkt( - koordinatensystem=p.get("koordinatensystem", "LV95"), - x=float(p.get("x", 0)), - y=float(p.get("y", 0)), - z=p.get("z") - ) - punkte.append(punkt) - neighbor_geometry = GeoPolylinie(closed=True, punkte=punkte) - - # Check for geometry_geojson - elif neighbor.get("geometry_geojson"): - geo_json = neighbor["geometry_geojson"] - geometry = geo_json.get("geometry") if isinstance(geo_json, dict) else geo_json - - if geometry and geometry.get("type") == "Polygon": - coordinates = geometry.get("coordinates", []) - if coordinates and len(coordinates) > 0: - ring = coordinates[0] # Outer ring - punkte = [] - for coord in ring: - if len(coord) >= 2: - punkt = GeoPunkt( - koordinatensystem="LV95", - x=float(coord[0]), - y=float(coord[1]), - z=float(coord[2]) if len(coord) > 2 else None - ) - punkte.append(punkt) - neighbor_geometry = GeoPolylinie(closed=True, punkte=punkte) - - if not neighbor_geometry: - # No geometry available - include neighbor (can't filter without geometry) - filtered_neighbors.append(neighbor) - continue - - # Convert neighbor geometry to Shapely Polygon - neighbor_polygon = geopolylinie_to_shapely_polygon(neighbor_geometry) - - # Check if neighbor intersects or touches any selected parcel - is_selected = False - for selected_polygon in selected_polygons: - if neighbor_polygon.intersects(selected_polygon) or neighbor_polygon.touches(selected_polygon): - # Check if they're actually the same (within tolerance) - # If areas are very similar, it's likely the same parcel - area_diff = abs(neighbor_polygon.area - selected_polygon.area) - if area_diff < 1.0: # Less than 1 m² difference - is_selected = True - break - # Also check if one contains the other (shouldn't happen for neighbors, but check anyway) - if neighbor_polygon.contains(selected_polygon) or selected_polygon.contains(neighbor_polygon): - is_selected = True - break - - if not is_selected: - filtered_neighbors.append(neighbor) - else: - logger.debug(f"Filtered out neighbor parcel {neighbor.get('id')} - part of selected parcels") - - except Exception as e: - logger.warning(f"Error filtering neighbor parcel {neighbor.get('id')}: {e}") - # On error, include neighbor (better to show too many than too few) - filtered_neighbors.append(neighbor) - - logger.info(f"Filtered {len(neighbors)} neighbors to {len(filtered_neighbors)} (removed {len(neighbors) - len(filtered_neighbors)} selected parcels)") - return filtered_neighbors - - -# ===== Swisstopo Integration ===== - -async def fetch_parcel_polygon_from_swisstopo( - gemeinde: str, - parzellen_nr: str, - sr: int = 2056 -) -> Optional[Dict[str, Any]]: - """ - Holt die vollständige Polygon-Geometrie einer Parzelle von Swisstopo API. - - Args: - gemeinde: Name der Gemeinde (z.B. "Bern") - parzellen_nr: Parzellennummer (z.B. "1234") - sr: Koordinatensystem (2056=LV95, 4326=WGS84) - - Returns: - Dictionary mit GeoPolylinie-Format für perimeter-Feld, oder None wenn nicht gefunden - Format: {"closed": True, "punkte": [{"koordinatensystem": "LV95", "x": ..., "y": ..., "z": None}, ...]} - """ - try: - connector = SwissTopoMapServerConnector() - - # Get GeoJSON feature from Swisstopo - feature = await connector.get_parcel_polygon(gemeinde, parzellen_nr, sr) - - if not feature: - logger.warning(f"Parzelle {gemeinde} {parzellen_nr} nicht gefunden in Swisstopo") - return None - - # Convert GeoJSON to GeoPolylinie format - geometry = feature.get("geometry", {}) - if geometry.get("type") == "Polygon": - coordinates = geometry.get("coordinates", []) - if coordinates and len(coordinates) > 0: - ring = coordinates[0] # Outer ring - - punkte = [] - for coord in ring: - if len(coord) >= 2: - punkt = { - "koordinatensystem": "LV95" if sr == 2056 else "WGS84", - "x": coord[0], # GeoJSON: [x, y] = [easting, northing] - "y": coord[1], - "z": coord[2] if len(coord) > 2 else None - } - punkte.append(punkt) - - logger.info(f"Successfully fetched polygon with {len(punkte)} points for {gemeinde} {parzellen_nr}") - - return { - "closed": True, - "punkte": punkte - } - - logger.warning(f"Unexpected geometry type in Swisstopo response: {geometry.get('type')}") - return None - - except Exception as e: - logger.error(f"Error fetching parcel polygon from Swisstopo: {e}", exc_info=True) - return None - - -# ===== Direkte Query-Ausführung (stateless) ===== - -async def executeDirectQuery( - currentUser: User, - mandateId: str, - queryText: str, - parameters: Optional[Dict[str, Any]] = None, -) -> Dict[str, Any]: - """ - Execute a database query directly without session management. - - Args: - currentUser: Current authenticated user - mandateId: Mandate context (from RequestContext / X-Mandate-Id header) - queryText: SQL query text - parameters: Optional parameters for parameterized queries - - Returns: - Dictionary containing query result (rows, columns, rowCount) - - Note: - - No session or query history is saved - - Query is executed directly and result is returned - - For production, validate and sanitize queries before execution - """ - try: - logger.info(f"Executing direct query for user {currentUser.id} (mandate: {mandateId})") - logger.debug(f"Query text: {queryText}") - if parameters: - logger.debug(f"Query parameters: {parameters}") - - # Execute query via Real Estate interface (stateless) - realEstateInterface = getRealEstateInterface(currentUser, mandateId=mandateId) - result = realEstateInterface.executeQuery(queryText, parameters) - - logger.info( - f"Query executed successfully: {result['rowCount']} rows in {result.get('executionTime', 0):.3f}s" - ) - - return { - "status": "success", - "rows": result["rows"], - "columns": result["columns"], - "rowCount": result["rowCount"], - "executionTime": result.get("executionTime", 0), - } - - except Exception as e: - logger.error(f"Error executing query: {str(e)}", exc_info=True) - raise - - -# ===== AI-basierte Intent-Erkennung und CRUD-Operationen ===== - -def _formatEntitySummary(entity_type: str, items: List[Dict[str, Any]], filters: Dict[str, Any]) -> str: - """ - Format a human-readable summary of query results. - - Args: - entity_type: Type of entity (Projekt, Parzelle, etc.) - items: List of entity data dictionaries - filters: Filter parameters used in the query - - Returns: - Human-readable summary string - """ - if not items: - return f"Keine {entity_type} gefunden" - - count = len(items) - filter_desc = "" - if filters: - # Format filter description - if "kontextGemeinde" in filters: - filter_desc = f" in {filters['kontextGemeinde']}" - elif "plz" in filters: - filter_desc = f" mit PLZ {filters['plz']}" - elif "location_filter" in filters: - filter_desc = f" in {filters['location_filter']}" - - # Start with count - summary = f"Gefunden: {count} {entity_type}{filter_desc}" - - # Add details based on entity type - if entity_type == "Parzelle": - summary += "\n\nDetails:" - for i, item in enumerate(items[:10], 1): # Limit to first 10 - parts = [] - - # Add label or ID - if item.get("label"): - parts.append(f"Parzelle '{item['label']}'") - elif item.get("id"): - parts.append(f"Parzelle {item['id'][:8]}...") - - # Add address - if item.get("strasseNr"): - parts.append(item["strasseNr"]) - - # Add PLZ and municipality - location_parts = [] - if item.get("plz"): - location_parts.append(item["plz"]) - if item.get("kontextGemeinde"): - location_parts.append(item["kontextGemeinde"]) - if location_parts: - parts.append(" ".join(location_parts)) - - # Add building zone - if item.get("bauzone"): - parts.append(f"Bauzone: {item['bauzone']}") - - summary += f"\n{i}. {', '.join(parts)}" - - if count > 10: - summary += f"\n... und {count - 10} weitere" - - elif entity_type == "Projekt": - summary += "\n\nDetails:" - for i, item in enumerate(items[:10], 1): - parts = [] - - # Add label - if item.get("label"): - parts.append(f"'{item['label']}'") - - # Add status - if item.get("statusProzess"): - parts.append(f"Status: {item['statusProzess']}") - - # Add parcel count - parzellen = item.get("parzellen", []) - if parzellen: - parts.append(f"{len(parzellen)} Parzelle(n)") - - summary += f"\n{i}. {' - '.join(parts)}" - - if count > 10: - summary += f"\n... und {count - 10} weitere" - - elif entity_type == "Gemeinde": - summary += "\n\nDetails:" - for i, item in enumerate(items[:10], 1): - parts = [] - - if item.get("label"): - parts.append(item["label"]) - if item.get("plz"): - parts.append(f"PLZ: {item['plz']}") - if item.get("abk"): - parts.append(f"Abk: {item['abk']}") - - summary += f"\n{i}. {', '.join(parts)}" - - if count > 10: - summary += f"\n... und {count - 10} weitere" - - elif entity_type == "Dokument": - summary += "\n\nDetails:" - for i, item in enumerate(items[:10], 1): - parts = [] - - if item.get("label"): - parts.append(item["label"]) - if item.get("dokumentTyp"): - parts.append(f"Typ: {item['dokumentTyp']}") - if item.get("quelle"): - parts.append(f"Quelle: {item['quelle']}") - - summary += f"\n{i}. {', '.join(parts)}" - - if count > 10: - summary += f"\n... und {count - 10} weitere" - - else: - # Generic format for other entity types - if count <= 5: - summary += "\n\nDetails:" - for i, item in enumerate(items, 1): - label = item.get("label") or item.get("id", "") - if label: - summary += f"\n{i}. {label}" - - return summary - - -async def processNaturalLanguageCommand( - currentUser: User, - mandateId: str, - userInput: str, -) -> Dict[str, Any]: - """ - Process natural language user input and execute corresponding CRUD operations. - - Uses AI to analyze user intent and extract parameters, then executes the appropriate - CRUD operation through the interface. Works stateless without session management. - - Args: - currentUser: Current authenticated user - mandateId: Mandate context (from RequestContext / X-Mandate-Id header) - userInput: Natural language command from user - - Returns: - Dictionary containing operation result and metadata - - Example user inputs: - - "Erstelle ein neues Projekt namens 'Hauptstrasse 42'" - - "Zeige mir alle Projekte in Zürich" - - "Aktualisiere Projekt XYZ mit Status 'Planung'" - - "Lösche Parzelle ABC" - - "SELECT * FROM Projekt WHERE plz = '8000'" - """ - try: - logger.info(f"Processing natural language command for user {currentUser.id} (mandate: {mandateId})") - logger.debug(f"User input: {userInput}") - - # Initialize services for AI access - services = getServices(currentUser, workflow=None, mandateId=mandateId) - aiService = services.ai - - # Step 1: Analyze user intent with AI - intentAnalysis = await analyzeUserIntent(aiService, userInput) - - logger.info(f"Intent analysis result: intent={intentAnalysis.get('intent')}, entity={intentAnalysis.get('entity')}") - - # Step 2: Execute CRUD operation based on intent - result = await executeIntentBasedOperation( - currentUser=currentUser, - mandateId=mandateId, - intent=intentAnalysis["intent"], - entity=intentAnalysis.get("entity"), - parameters=intentAnalysis.get("parameters", {}), - ) - - # Build user-friendly response - response = { - "success": True, - "intent": intentAnalysis["intent"], - "entity": intentAnalysis.get("entity"), - "result": result, - } - - # Add human-readable summary for operations - if intentAnalysis["intent"] == "CREATE" and isinstance(result, dict): - # Add confirmation message for CREATE operations - operation_result = result.get("result") - if isinstance(operation_result, dict): - entity_name = intentAnalysis.get('entity', 'Eintrag') - label = operation_result.get("label", operation_result.get("id", "")) - - # Build detailed message - msg_parts = [f"✅ {entity_name} '{label}' erfolgreich erstellt"] - - if entity_name == "Parzelle": - if operation_result.get("plz"): - msg_parts.append(f"PLZ: {operation_result['plz']}") - if operation_result.get("kontextGemeinde"): - msg_parts.append(f"Gemeinde: {operation_result['kontextGemeinde']}") - if operation_result.get("bauzone"): - msg_parts.append(f"Bauzone: {operation_result['bauzone']}") - - kontext_items = operation_result.get("kontextInformationen", []) - if kontext_items: - msg_parts.append(f"\n📋 {len(kontext_items)} Kontextinformationen gespeichert:") - for kontext in kontext_items[:5]: # Show first 5 - thema = kontext.get("thema", "") - inhalt = kontext.get("inhalt", "") - if thema and inhalt: - msg_parts.append(f" • {thema}: {inhalt}") - if len(kontext_items) > 5: - msg_parts.append(f" • ... und {len(kontext_items) - 5} weitere") - - elif entity_name == "Projekt": - if operation_result.get("statusProzess"): - msg_parts.append(f"Status: {operation_result['statusProzess']}") - parzellen = operation_result.get("parzellen", []) - if parzellen: - msg_parts.append(f"{len(parzellen)} Parzelle(n)") - - response["message"] = "\n".join(msg_parts) - - elif intentAnalysis["intent"] == "READ" and isinstance(result, dict): - operation_result = result.get("result") - if isinstance(operation_result, list): - response["count"] = len(operation_result) - entity_name = intentAnalysis.get('entity', 'Einträge') - - if len(operation_result) == 0: - # Provide helpful message for empty results - filter_info = intentAnalysis.get('parameters', {}) - if filter_info: - filter_desc = ", ".join([f"{k}={v}" for k, v in filter_info.items()]) - response["message"] = f"Keine {entity_name} gefunden mit Filter: {filter_desc}. Möglicherweise sind noch keine Daten vorhanden oder der Filter ist zu spezifisch." - else: - response["message"] = f"Keine {entity_name} vorhanden. Erstellen Sie zuerst neue Einträge." - else: - # Create detailed summary based on entity type - response["message"] = _formatEntitySummary( - entity_name, - operation_result, - intentAnalysis.get('parameters', {}) - ) - elif isinstance(operation_result, dict): - response["count"] = 1 - # Format single entity - entity_name = intentAnalysis.get('entity', 'Eintrag') - response["message"] = _formatEntitySummary(entity_name, [operation_result], {}) - - return response - - except Exception as e: - logger.error(f"Error processing natural language command: {str(e)}", exc_info=True) - raise - - -async def analyzeUserIntent( - aiService, - userInput: str -) -> Dict[str, Any]: - """ - Use AI to analyze user input and extract intent, entity, and parameters. - - Args: - aiService: AI service instance - userInput: Natural language user input - - Returns: - Dictionary with 'intent', 'entity', and 'parameters' - """ - # Create a structured prompt for intent analysis with accurate field information - intentPrompt = f""" -Analyze the following user command and extract the intent, entity, and parameters. - -User Command: "{userInput}" - -Available intents: -- CREATE: User wants to create a new entity -- READ: User wants to read/query entities -- UPDATE: User wants to update an existing entity -- DELETE: User wants to delete an entity -- QUERY: User wants to execute a database query (SQL statements) - -Available entities and their fields: - -**Projekt** (Real estate project): -- id: string (primary key) -- mandateId: string (mandate ID) -- label: string (project designation/name) -- statusProzess: string enum (Eingang, Analyse, Studie, Planung, Baurechtsverfahren, Umsetzung, Archiv) -- perimeter: GeoPolylinie (geographic boundary, JSONB) -- baulinie: GeoPolylinie (building line, JSONB) -- parzellen: List[Parzelle] (plots belonging to project, JSONB) -- dokumente: List[Dokument] (documents, JSONB) -- kontextInformationen: List[Kontext] (context info, JSONB) - -**Parzelle** (Plot/parcel): -- id: string (primary key) -- mandateId: string (mandate ID) -- label: string (plot designation) -- strasseNr: string (street and house number) -- plz: string (postal code) -- kontextGemeinde: string (municipality ID, Foreign Key to Gemeinde table) -- bauzone: string (building zone, e.g. W3, WG2) -- az: float (Ausnützungsziffer) -- bz: float (Bebauungsziffer) -- vollgeschossZahl: int (number of allowed full floors) -- gebaeudehoeheMax: float (maximum building height in meters) -- laermschutzzone: string (noise protection zone) -- hochwasserschutzzone: string (flood protection zone) -- grundwasserschutzzone: string (groundwater protection zone) -- parzelleBebaut: JaNein enum (is plot built) -- parzelleErschlossen: JaNein enum (is plot developed) -- parzelleHanglage: JaNein enum (is plot on slope) -- kontextInformationen: List[Kontext] (metadata - each item has 'thema' and 'inhalt' fields only) - -**Kontext** (Context information for metadata): -- thema: string (topic/subject, e.g. "EGRID", "Fläche", "Zentrum") -- inhalt: string (content as text, e.g. "CH887199917793", "6514.99 m²", "X: 123, Y: 456") - -**Important relationships:** -- Projekte contain Parzellen (projects have plots) -- Parzelle links to Gemeinde (via kontextGemeinde) -- Gemeinde links to Kanton (via id_kanton) -- Kanton links to Land (via id_land) -- Location queries (city, postal code) should use Parzelle.kontextGemeinde (municipality name will be resolved to ID) -- Projekt does NOT have location fields directly - location is stored in associated Parzellen - -Return a JSON object with the following structure: -{{ - "intent": "CREATE|READ|UPDATE|DELETE|QUERY", - "entity": "Projekt|Parzelle|Dokument|Kanton|Gemeinde|null", - "parameters": {{ - // Extracted parameters from user input - // For CREATE/UPDATE: include all relevant fields using EXACT field names from above - // For READ: include filter criteria using EXACT field names (id, label, plz, kontextGemeinde, etc.) - // For DELETE: include entity ID if mentioned - // For QUERY: include queryText if SQL is detected - // IMPORTANT: Use only field names that exist in the entity definition above - }}, - "confidence": 0.0-1.0 // Confidence score for the analysis -}} - -Examples: -- Input: "Erstelle ein neues Projekt namens 'Hauptstrasse 42'" - Output: {{"intent": "CREATE", "entity": "Projekt", "parameters": {{"label": "Hauptstrasse 42"}}, "confidence": 0.95}} - -- Input: "Erstelle eine Parzelle mit Label 123, PLZ 8000, Gemeinde Zürich, Bauzone W3" - Output: {{"intent": "CREATE", "entity": "Parzelle", "parameters": {{"label": "123", "plz": "8000", "kontextGemeinde": "Zürich", "bauzone": "W3"}}, "confidence": 0.95}} - -- Input: "Parzellen-Informationen: ID:AA1704, Nummer:AA1704, EGRID:CH887199917793, Kanton:ZH, Gemeinde:Zürich, Gemeinde-Code:261, Fläche:6514.99 m², Zentrum:2682951.44,1247622.91" - Output: {{ - "intent": "CREATE", - "entity": "Parzelle", - "parameters": {{ - "label": "AA1704", - "parzellenAliasTags": ["AA1704"], - "kontextGemeinde": "Zürich", - "kontextInformationen": [ - {{"thema": "EGRID", "inhalt": "CH887199917793"}}, - {{"thema": "Kanton", "inhalt": "ZH"}}, - {{"thema": "BFS-Nummer", "inhalt": "261"}}, - {{"thema": "Fläche", "inhalt": "6514.99 m²"}}, - {{"thema": "Zentrum (LV95)", "inhalt": "X: 2682951.44 m, Y: 1247622.91 m (EPSG:2056)"}} - ] - }}, - "confidence": 0.9 - }} - Note: Extract structured data from detailed input. Use kontextInformationen for metadata. Each item has 'thema' (topic) and 'inhalt' (content as text). - -- Input: "Zeige mir alle Projekte" - Output: {{"intent": "READ", "entity": "Projekt", "parameters": {{}}, "confidence": 0.9}} - -- Input: "Zeige mir Projekte in Zürich" or "Wie viele Projekte in Zürich" - Output: {{"intent": "READ", "entity": "Projekt", "parameters": {{"location_filter": "Zürich"}}, "confidence": 0.9}} - Note: For project location queries, use Projekt entity with location_filter parameter - -- Input: "Zeige mir Parzellen mit PLZ 8000" - Output: {{"intent": "READ", "entity": "Parzelle", "parameters": {{"plz": "8000"}}, "confidence": 0.95}} - -- Input: "Aktualisiere Projekt XYZ mit Status 'Planung'" - Output: {{"intent": "UPDATE", "entity": "Projekt", "parameters": {{"id": "XYZ", "statusProzess": "Planung"}}, "confidence": 0.85}} - -- Input: "SELECT * FROM Projekt WHERE label = 'Test'" - Output: {{"intent": "QUERY", "entity": null, "parameters": {{"queryText": "SELECT * FROM Projekt WHERE label = 'Test'", "queryType": "sql"}}, "confidence": 1.0}} - -- Input: "Lösche Parzelle ABC" - Output: {{"intent": "DELETE", "entity": "Parzelle", "parameters": {{"id": "ABC"}}, "confidence": 0.9}} - -IMPORTANT EXTRACTION RULES: -1. For CREATE operations, extract ALL mentioned data fields from the user input -2. Use kontextInformationen array for metadata that doesn't have dedicated fields (EGRID, BFS numbers, area, coordinates, etc.) -3. Each kontextInformationen item MUST have exactly two fields: 'thema' (topic/subject) and 'inhalt' (content as text string) -4. Format kontextInformationen values as readable text strings, including units (e.g., "6514.99 m²", "X: 123, Y: 456") -5. Match field names EXACTLY to the entity definition above -6. Convert data types correctly (strings for text, numbers for numeric values) -7. Extract coordinates, areas, and other numeric values from text -8. When multiple values are mentioned for the same concept (ID, Nummer, Name), use the most relevant one for 'label' and put alternatives in parzellenAliasTags -""" - - try: - # Use AI planning call for structured JSON response - response = await aiService.callAiPlanning( - prompt=intentPrompt, - debugType="intentanalysis" - ) - - # Extract JSON from response (handles markdown code blocks) - jsonStart = response.find('{') - jsonEnd = response.rfind('}') + 1 - - if jsonStart == -1 or jsonEnd == 0: - raise ValueError("No JSON found in AI response") - - jsonStr = response[jsonStart:jsonEnd] - - # Parse JSON response - intentData = json.loads(jsonStr) - - # Validate response structure - if "intent" not in intentData: - raise ValueError("Invalid intent analysis response: missing 'intent' field") - - # Ensure parameters exists - if "parameters" not in intentData: - intentData["parameters"] = {} - - logger.debug(f"Parsed intent analysis: {intentData}") - - return intentData - - except json.JSONDecodeError as e: - logger.error(f"Failed to parse AI intent analysis response: {e}") - logger.error(f"Raw response: {response}") - raise ValueError(f"AI returned invalid JSON: {str(e)}") - except Exception as e: - logger.error(f"Error analyzing user intent: {str(e)}", exc_info=True) - raise - - -async def executeIntentBasedOperation( - currentUser: User, - mandateId: str, - intent: str, - entity: Optional[str], - parameters: Dict[str, Any], -) -> Dict[str, Any]: - """ - Execute CRUD operation based on analyzed intent. - - Args: - currentUser: Current authenticated user - mandateId: Mandate context (from RequestContext / X-Mandate-Id header) - intent: Intent from AI analysis (CREATE, READ, UPDATE, DELETE, QUERY) - entity: Entity type from AI analysis - parameters: Extracted parameters from AI analysis - - Returns: - Operation result - - Note: - - Supports CREATE, READ, UPDATE, DELETE, QUERY intents - - Entity types: Projekt, Parzelle, Gemeinde, Kanton, Land, Dokument - """ - try: - logger.info(f"Executing intent-based operation: intent={intent}, entity={entity}") - logger.debug(f"Parameters: {parameters}") - - if intent == "QUERY": - # Execute database query directly (stateless) - queryText = parameters.get("queryText", "") - - if not queryText: - raise ValueError("QUERY intent requires queryText in parameters") - - result = await executeDirectQuery( - currentUser=currentUser, - mandateId=mandateId, - queryText=queryText, - parameters=parameters.get("queryParameters"), - ) - return result - - elif intent == "CREATE": - # Create new entity - realEstateInterface = getRealEstateInterface(currentUser, mandateId=mandateId) - - if entity == "Projekt": - # Create Projekt from parameters - projekt = Projekt( - mandateId=mandateId, - label=parameters.get("label", ""), - statusProzess=StatusProzess(parameters.get("statusProzess", "EINGANG")) if parameters.get("statusProzess") else None, - ) - created = realEstateInterface.createProjekt(projekt) - return { - "operation": "CREATE", - "entity": "Projekt", - "result": created.model_dump() - } - - elif entity == "Parzelle": - # Create Parzelle from parameters - # Import Kontext for kontextInformationen - from modules.features.realestate.datamodelFeatureRealEstate import Kontext, GeoPolylinie - - # Build parzelle data with all extracted parameters - parzelle_data = { - "mandateId": mandateId, - "label": parameters.get("label", ""), - } - - # Add optional fields if present - optional_fields = [ - "parzellenAliasTags", "eigentuemerschaft", "strasseNr", "plz", - "bauzone", "az", "bz", "vollgeschossZahl", "anrechenbarDachgeschoss", - "anrechenbarUntergeschoss", "gebaeudehoeheMax", "kontextGemeinde", - "regelnGrenzabstand", "regelnMehrlaengenzuschlag", "regelnMehrhoehenzuschlag", - "parzelleBebaut", "parzelleErschlossen", "parzelleHanglage", - "laermschutzzone", "hochwasserschutzzone", "grundwasserschutzzone" - ] - - for field in optional_fields: - if field in parameters and parameters[field] is not None: - parzelle_data[field] = parameters[field] - - # Handle complex objects - if "perimeter" in parameters and parameters["perimeter"]: - parzelle_data["perimeter"] = GeoPolylinie(**parameters["perimeter"]) - elif "kontextGemeinde" in parameters and parameters.get("kontextGemeinde"): - # Try to fetch polygon from Swisstopo if gemeinde and parzellen_nr are available - gemeinde = parameters.get("kontextGemeinde") - parzellen_nr = parameters.get("label") or parameters.get("parzellen_nr") or parameters.get("parzellennummer") - - if gemeinde and parzellen_nr: - logger.info(f"Attempting to fetch polygon from Swisstopo for {gemeinde} {parzellen_nr}") - try: - # Try to resolve gemeinde name if it's an ID - gemeinde_name = gemeinde - if len(gemeinde) == 36: # UUID format - # Try to get gemeinde name from interface (realEstateInterface already initialized above) - gemeinde_obj = realEstateInterface.getGemeinde(gemeinde) - if gemeinde_obj: - gemeinde_name = gemeinde_obj.label - - polygon_data = await fetch_parcel_polygon_from_swisstopo( - gemeinde=gemeinde_name, - parzellen_nr=str(parzellen_nr), - sr=2056 - ) - - if polygon_data: - parzelle_data["perimeter"] = GeoPolylinie(**polygon_data) - logger.info(f"Successfully fetched and set perimeter from Swisstopo") - else: - logger.warning(f"Could not fetch polygon from Swisstopo for {gemeinde_name} {parzellen_nr}") - except Exception as e: - logger.warning(f"Error fetching polygon from Swisstopo (continuing without): {e}") - - if "baulinie" in parameters and parameters["baulinie"]: - parzelle_data["baulinie"] = GeoPolylinie(**parameters["baulinie"]) - - # Handle kontextInformationen (convert dicts to Kontext objects) - if "kontextInformationen" in parameters and parameters["kontextInformationen"]: - kontext_list = [] - for kontext_data in parameters["kontextInformationen"]: - if isinstance(kontext_data, dict): - # Ensure only thema and inhalt are passed (Kontext model only has these fields) - kontext_obj = Kontext( - thema=kontext_data.get("thema", ""), - inhalt=kontext_data.get("inhalt", "") - ) - kontext_list.append(kontext_obj) - else: - kontext_list.append(kontext_data) - parzelle_data["kontextInformationen"] = kontext_list - - parzelle = Parzelle(**parzelle_data) - created = realEstateInterface.createParzelle(parzelle) - - logger.info(f"Created Parzelle '{created.label}' with {len(created.kontextInformationen)} context items") - - return { - "operation": "CREATE", - "entity": "Parzelle", - "result": created.model_dump() - } - elif entity == "Gemeinde": - # Create Gemeinde from parameters - from modules.features.realestate.datamodelFeatureRealEstate import Gemeinde - gemeinde = Gemeinde( - mandateId=mandateId, - label=parameters.get("label", ""), - id_kanton=parameters.get("id_kanton"), - plz=parameters.get("plz"), - ) - created = realEstateInterface.createGemeinde(gemeinde) - return { - "operation": "CREATE", - "entity": "Gemeinde", - "result": created.model_dump() - } - elif entity == "Kanton": - # Create Kanton from parameters - from modules.features.realestate.datamodelFeatureRealEstate import Kanton - kanton = Kanton( - mandateId=mandateId, - label=parameters.get("label", ""), - id_land=parameters.get("id_land"), - abk=parameters.get("abk"), - ) - created = realEstateInterface.createKanton(kanton) - return { - "operation": "CREATE", - "entity": "Kanton", - "result": created.model_dump() - } - elif entity == "Land": - # Create Land from parameters - from modules.features.realestate.datamodelFeatureRealEstate import Land - land = Land( - mandateId=mandateId, - label=parameters.get("label", ""), - abk=parameters.get("abk"), - ) - created = realEstateInterface.createLand(land) - return { - "operation": "CREATE", - "entity": "Land", - "result": created.model_dump() - } - elif entity == "Dokument": - # Create Dokument from parameters - from modules.features.realestate.datamodelFeatureRealEstate import Dokument - dokument = Dokument( - mandateId=mandateId, - label=parameters.get("label", ""), - dokumentReferenz=parameters.get("dokumentReferenz", ""), - versionsbezeichnung=parameters.get("versionsbezeichnung"), - dokumentTyp=parameters.get("dokumentTyp"), - quelle=parameters.get("quelle"), - mimeType=parameters.get("mimeType"), - ) - created = realEstateInterface.createDokument(dokument) - return { - "operation": "CREATE", - "entity": "Dokument", - "result": created.model_dump() - } - else: - raise ValueError(f"CREATE operation not supported for entity: {entity}") - - elif intent == "READ": - # Read entities - realEstateInterface = getRealEstateInterface(currentUser) - - if entity == "Projekt": - projektId = parameters.get("id") - if projektId: - # Get single Projekt by ID - projekt = realEstateInterface.getProjekt(projektId) - if not projekt: - raise ValueError(f"Projekt {projektId} not found") - return { - "operation": "READ", - "entity": "Projekt", - "result": projekt.model_dump() - } - else: - # List all Projekte (with optional filters) - # Validate filter fields against Projekt model - validProjektFields = {"id", "mandateId", "label", "statusProzess"} - recordFilter = { - k: v for k, v in parameters.items() - if k != "id" and k in validProjektFields - } - - # Handle location_filter specially (filter projects by parcel location) - location_filter = parameters.get("location_filter") - - # Get all projects first - projekte = realEstateInterface.getProjekte(recordFilter=recordFilter if recordFilter else None) - - # If location filter is present, filter by parcels in that location - if location_filter: - logger.info(f"Filtering projects by location: {location_filter}") - - # Try to resolve location to Gemeinde ID for UUID comparison - location_id = None - try: - # Check if it's already a UUID - uuid_pattern = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.IGNORECASE) - if not uuid_pattern.match(location_filter): - # Try to resolve name to ID - gemeinde_records = realEstateInterface.getGemeinden(recordFilter={"label": location_filter}) - if gemeinde_records: - location_id = gemeinde_records[0].id - logger.debug(f"Resolved location '{location_filter}' to ID '{location_id}'") - except Exception as e: - logger.debug(f"Could not resolve location filter: {e}") - - filtered_projekte = [] - - for projekt in projekte: - # Check if any parcel in the project matches the location - for parzelle in projekt.parzellen: - # Check kontextGemeinde (both UUID and string), plz, or strasseNr for location match - location_lower = location_filter.lower() - matches = False - - # Check if kontextGemeinde matches (as UUID or string) - if parzelle.kontextGemeinde: - if (parzelle.kontextGemeinde == location_id or # UUID match - parzelle.kontextGemeinde == location_filter or # Exact match - location_lower in parzelle.kontextGemeinde.lower()): # Partial string match - matches = True - - # Check PLZ or address - if not matches and ( - (parzelle.plz and location_lower in parzelle.plz) or - (parzelle.strasseNr and location_lower in parzelle.strasseNr.lower()) - ): - matches = True - - if matches: - filtered_projekte.append(projekt) - break # Found a matching parcel, no need to check more - - projekte = filtered_projekte - logger.info(f"Found {len(projekte)} projects in location '{location_filter}'") - - return { - "operation": "READ", - "entity": "Projekt", - "result": [p.model_dump() for p in projekte], - "count": len(projekte) - } - elif entity == "Parzelle": - parzelleId = parameters.get("id") - if parzelleId: - # Get single Parzelle by ID - parzelle = realEstateInterface.getParzelle(parzelleId) - if not parzelle: - raise ValueError(f"Parzelle {parzelleId} not found") - return { - "operation": "READ", - "entity": "Parzelle", - "result": parzelle.model_dump() - } - else: - # List all Parzellen (with optional filters) - # Validate filter fields against Parzelle model - # Note: kontextKanton and kontextLand are NOT direct fields on Parzelle - # Parzelle links to Gemeinde, Gemeinde links to Kanton, Kanton links to Land - validParzelleFields = { - "id", "mandateId", "label", "strasseNr", "plz", - "kontextGemeinde", # Only direct link - Gemeinde → Kanton → Land - "bauzone", "az", "bz", "vollgeschossZahl", "gebaeudehoeheMax", - "laermschutzzone", "hochwasserschutzzone", "grundwasserschutzzone", - "parzelleBebaut", "parzelleErschlossen", "parzelleHanglage" - } - recordFilter = { - k: v for k, v in parameters.items() - if k != "id" and k in validParzelleFields - } - # Warn about invalid fields - invalidFields = {k: v for k, v in parameters.items() if k not in validParzelleFields and k != "id"} - if invalidFields: - logger.warning(f"Invalid filter fields for Parzelle ignored: {list(invalidFields.keys())}") - - parzellen = realEstateInterface.getParzellen(recordFilter=recordFilter if recordFilter else None) - - # Debug logging for empty results - if not parzellen and recordFilter: - logger.info(f"No Parzellen found matching filter: {recordFilter}") - # Get total count to help debug - all_parzellen = realEstateInterface.getParzellen(recordFilter=None) - logger.info(f"Total Parzellen in database: {len(all_parzellen)}") - if all_parzellen: - # Show some sample kontextGemeinde values - sample_gemeinden = set() - for p in all_parzellen[:10]: - if p.kontextGemeinde: - sample_gemeinden.add(p.kontextGemeinde) - logger.info(f"Sample kontextGemeinde values in database: {sample_gemeinden}") - - return { - "operation": "READ", - "entity": "Parzelle", - "result": [p.model_dump() for p in parzellen], - "count": len(parzellen) - } - elif entity == "Gemeinde": - from modules.features.realestate.datamodelFeatureRealEstate import Gemeinde - gemeindeId = parameters.get("id") - if gemeindeId: - gemeinde = realEstateInterface.getGemeinde(gemeindeId) - if not gemeinde: - raise ValueError(f"Gemeinde {gemeindeId} not found") - return { - "operation": "READ", - "entity": "Gemeinde", - "result": gemeinde.model_dump() - } - else: - recordFilter = {k: v for k, v in parameters.items() if k != "id"} - gemeinden = realEstateInterface.getGemeinden(recordFilter=recordFilter if recordFilter else None) - return { - "operation": "READ", - "entity": "Gemeinde", - "result": [g.model_dump() for g in gemeinden], - "count": len(gemeinden) - } - elif entity == "Kanton": - from modules.features.realestate.datamodelFeatureRealEstate import Kanton - kantonId = parameters.get("id") - if kantonId: - kanton = realEstateInterface.getKanton(kantonId) - if not kanton: - raise ValueError(f"Kanton {kantonId} not found") - return { - "operation": "READ", - "entity": "Kanton", - "result": kanton.model_dump() - } - else: - recordFilter = {k: v for k, v in parameters.items() if k != "id"} - kantone = realEstateInterface.getKantone(recordFilter=recordFilter if recordFilter else None) - return { - "operation": "READ", - "entity": "Kanton", - "result": [k.model_dump() for k in kantone], - "count": len(kantone) - } - elif entity == "Land": - from modules.features.realestate.datamodelFeatureRealEstate import Land - landId = parameters.get("id") - if landId: - land = realEstateInterface.getLand(landId) - if not land: - raise ValueError(f"Land {landId} not found") - return { - "operation": "READ", - "entity": "Land", - "result": land.model_dump() - } - else: - recordFilter = {k: v for k, v in parameters.items() if k != "id"} - laender = realEstateInterface.getLaender(recordFilter=recordFilter if recordFilter else None) - return { - "operation": "READ", - "entity": "Land", - "result": [l.model_dump() for l in laender], - "count": len(laender) - } - elif entity == "Dokument": - from modules.features.realestate.datamodelFeatureRealEstate import Dokument - dokumentId = parameters.get("id") - if dokumentId: - dokument = realEstateInterface.getDokument(dokumentId) - if not dokument: - raise ValueError(f"Dokument {dokumentId} not found") - return { - "operation": "READ", - "entity": "Dokument", - "result": dokument.model_dump() - } - else: - recordFilter = {k: v for k, v in parameters.items() if k != "id"} - dokumente = realEstateInterface.getDokumente(recordFilter=recordFilter if recordFilter else None) - return { - "operation": "READ", - "entity": "Dokument", - "result": [d.model_dump() for d in dokumente], - "count": len(dokumente) - } - else: - raise ValueError(f"READ operation not supported for entity: {entity}") - - elif intent == "UPDATE": - # Update existing entity - realEstateInterface = getRealEstateInterface(currentUser) - - if entity == "Projekt": - projektId = parameters.get("id") - if not projektId: - raise ValueError("UPDATE operation requires entity ID") - - # Get existing projekt - projekt = realEstateInterface.getProjekt(projektId) - if not projekt: - raise ValueError(f"Projekt {projektId} not found") - - # Update fields - updateData = {k: v for k, v in parameters.items() if k != "id"} - updated = realEstateInterface.updateProjekt(projektId, updateData) - return { - "operation": "UPDATE", - "entity": "Projekt", - "result": updated.model_dump() - } - elif entity == "Parzelle": - parzelleId = parameters.get("id") - if not parzelleId: - raise ValueError("UPDATE operation requires entity ID") - - # Get existing parzelle - parzelle = realEstateInterface.getParzelle(parzelleId) - if not parzelle: - raise ValueError(f"Parzelle {parzelleId} not found") - - # Update fields - updateData = {k: v for k, v in parameters.items() if k != "id"} - updated = realEstateInterface.updateParzelle(parzelleId, updateData) - return { - "operation": "UPDATE", - "entity": "Parzelle", - "result": updated.model_dump() - } - elif entity == "Gemeinde": - from modules.features.realestate.datamodelFeatureRealEstate import Gemeinde - gemeindeId = parameters.get("id") - if not gemeindeId: - raise ValueError("UPDATE operation requires entity ID") - - gemeinde = realEstateInterface.getGemeinde(gemeindeId) - if not gemeinde: - raise ValueError(f"Gemeinde {gemeindeId} not found") - - updateData = {k: v for k, v in parameters.items() if k != "id"} - updated = realEstateInterface.updateGemeinde(gemeindeId, updateData) - return { - "operation": "UPDATE", - "entity": "Gemeinde", - "result": updated.model_dump() - } - elif entity == "Kanton": - from modules.features.realestate.datamodelFeatureRealEstate import Kanton - kantonId = parameters.get("id") - if not kantonId: - raise ValueError("UPDATE operation requires entity ID") - - kanton = realEstateInterface.getKanton(kantonId) - if not kanton: - raise ValueError(f"Kanton {kantonId} not found") - - updateData = {k: v for k, v in parameters.items() if k != "id"} - updated = realEstateInterface.updateKanton(kantonId, updateData) - return { - "operation": "UPDATE", - "entity": "Kanton", - "result": updated.model_dump() - } - elif entity == "Land": - from modules.features.realestate.datamodelFeatureRealEstate import Land - landId = parameters.get("id") - if not landId: - raise ValueError("UPDATE operation requires entity ID") - - land = realEstateInterface.getLand(landId) - if not land: - raise ValueError(f"Land {landId} not found") - - updateData = {k: v for k, v in parameters.items() if k != "id"} - updated = realEstateInterface.updateLand(landId, updateData) - return { - "operation": "UPDATE", - "entity": "Land", - "result": updated.model_dump() - } - elif entity == "Dokument": - from modules.features.realestate.datamodelFeatureRealEstate import Dokument - dokumentId = parameters.get("id") - if not dokumentId: - raise ValueError("UPDATE operation requires entity ID") - - dokument = realEstateInterface.getDokument(dokumentId) - if not dokument: - raise ValueError(f"Dokument {dokumentId} not found") - - updateData = {k: v for k, v in parameters.items() if k != "id"} - updated = realEstateInterface.updateDokument(dokumentId, updateData) - return { - "operation": "UPDATE", - "entity": "Dokument", - "result": updated.model_dump() - } - else: - raise ValueError(f"UPDATE operation not supported for entity: {entity}") - - elif intent == "DELETE": - # Delete entity - realEstateInterface = getRealEstateInterface(currentUser) - - if entity == "Projekt": - projektId = parameters.get("id") - if not projektId: - raise ValueError("DELETE operation requires entity ID") - - success = realEstateInterface.deleteProjekt(projektId) - return { - "operation": "DELETE", - "entity": "Projekt", - "success": success - } - elif entity == "Parzelle": - parzelleId = parameters.get("id") - if not parzelleId: - raise ValueError("DELETE operation requires entity ID") - - success = realEstateInterface.deleteParzelle(parzelleId) - return { - "operation": "DELETE", - "entity": "Parzelle", - "success": success - } - elif entity == "Gemeinde": - from modules.features.realestate.datamodelFeatureRealEstate import Gemeinde - gemeindeId = parameters.get("id") - if not gemeindeId: - raise ValueError("DELETE operation requires entity ID") - - success = realEstateInterface.deleteGemeinde(gemeindeId) - return { - "operation": "DELETE", - "entity": "Gemeinde", - "success": success - } - elif entity == "Kanton": - from modules.features.realestate.datamodelFeatureRealEstate import Kanton - kantonId = parameters.get("id") - if not kantonId: - raise ValueError("DELETE operation requires entity ID") - - success = realEstateInterface.deleteKanton(kantonId) - return { - "operation": "DELETE", - "entity": "Kanton", - "success": success - } - elif entity == "Land": - from modules.features.realestate.datamodelFeatureRealEstate import Land - landId = parameters.get("id") - if not landId: - raise ValueError("DELETE operation requires entity ID") - - success = realEstateInterface.deleteLand(landId) - return { - "operation": "DELETE", - "entity": "Land", - "success": success - } - elif entity == "Dokument": - from modules.features.realestate.datamodelFeatureRealEstate import Dokument - dokumentId = parameters.get("id") - if not dokumentId: - raise ValueError("DELETE operation requires entity ID") - - success = realEstateInterface.deleteDokument(dokumentId) - return { - "operation": "DELETE", - "entity": "Dokument", - "success": success - } - else: - raise ValueError(f"DELETE operation not supported for entity: {entity}") - - else: - raise ValueError(f"Unknown intent: {intent}") - - except Exception as e: - logger.error(f"Error executing intent-based operation: {str(e)}", exc_info=True) - raise - - -# ===== Project Creation with Parcel Data ===== - -async def create_project_with_parcel_data( - currentUser: User, - mandateId: str, - projekt_label: str, - parzellen_data: List[Dict[str, Any]], - status_prozess: Optional[str] = None, -) -> Dict[str, Any]: - """ - Create a Projekt with one or more Parzellen from provided parcel data. - - Args: - currentUser: Current authenticated user - mandateId: Mandate context (from RequestContext / X-Mandate-Id header) - projekt_label: Label for the Projekt - parzellen_data: List of dictionaries containing parcel information from request - status_prozess: Optional project status (defaults to "Eingang") - - Returns: - Dictionary containing created Projekt and list of Parzellen - - Raises: - HTTPException: If Gemeinde or Kanton not found, or validation fails - """ - try: - logger.info(f"Creating project '{projekt_label}' with {len(parzellen_data)} parcel(s) for user {currentUser.id}") - - # Get interface with mandate context - realEstateInterface = getRealEstateInterface(currentUser, mandateId=mandateId) - - # Validate required fields - if not projekt_label: - raise ValueError("Projekt label is required") - - if not parzellen_data or len(parzellen_data) == 0: - raise ValueError("At least one Parzelle data is required") - - # Validate all parcels have required fields - for idx, parzelle_data in enumerate(parzellen_data): - if not parzelle_data.get("perimeter"): - raise ValueError(f"Parzelle {idx + 1} perimeter is required") - - # Helper function to convert GeoJSON geometry to GeoPolylinie (defined early for use in geometry collection) - def convert_geojson_to_geopolylinie(geometry_data: Dict[str, Any]) -> Optional[GeoPolylinie]: - """Convert GeoJSON geometry to GeoPolylinie format.""" - if not geometry_data: - return None - - # Handle nested geometry structure (geometry.geometry.coordinates) - if "geometry" in geometry_data: - geometry_data = geometry_data["geometry"] - - geometry_type = geometry_data.get("type") - coordinates = geometry_data.get("coordinates") - - if not coordinates or geometry_type != "Polygon": - return None - - # Extract outer ring (first array of coordinates) - if not coordinates or len(coordinates) == 0: - return None - - ring = coordinates[0] # Outer ring - - # Convert coordinates to GeoPunkt list - punkte = [] - for coord in ring: - if len(coord) >= 2: - punkt = GeoPunkt( - koordinatensystem="LV95", - x=float(coord[0]), - y=float(coord[1]), - z=float(coord[2]) if len(coord) > 2 else None - ) - punkte.append(punkt) - - if not punkte: - return None - - return GeoPolylinie( - closed=True, - punkte=punkte - ) - - # First pass: Collect all parcel geometries for neighbor filtering - # Convert all perimeters to GeoPolylinie format - all_parcel_geometries = [] - for parzelle_data in parzellen_data: - perimeter = parzelle_data.get("perimeter") - if perimeter: - # Convert to GeoPolylinie if needed - if isinstance(perimeter, dict): - if "punkte" in perimeter and "closed" in perimeter: - try: - geo_perimeter = GeoPolylinie(**perimeter) - all_parcel_geometries.append(geo_perimeter) - except Exception as e: - logger.warning(f"Error converting perimeter to GeoPolylinie: {e}") - else: - # Try GeoJSON conversion - converted = convert_geojson_to_geopolylinie(perimeter) - if converted: - all_parcel_geometries.append(converted) - elif isinstance(perimeter, GeoPolylinie): - all_parcel_geometries.append(perimeter) - - # Process all parcels - create each one or use existing - created_parzellen = [] - parcel_perimeters = [] # Collect all parcel perimeters for baulinie calculation - - for idx, parzelle_data in enumerate(parzellen_data): - logger.info(f"Processing Parzelle {idx + 1}/{len(parzellen_data)}") - - # Determine parcel label for uniqueness check - parcel_label = parzelle_data.get("id") or parzelle_data.get("number") or parzelle_data.get("label") or "Unknown" - - # Check if Parzelle with this label already exists - existing_parzellen = realEstateInterface.getParzellen( - recordFilter={"label": parcel_label, "mandateId": mandateId} - ) - - if existing_parzellen and len(existing_parzellen) > 0: - # Parzelle already exists - use existing one - existing_parzelle = existing_parzellen[0] - logger.info(f"Parzelle with label '{parcel_label}' already exists (ID: {existing_parzelle.id}), reusing it") - - # Collect perimeter for baulinie calculation - if existing_parzelle.perimeter: - parcel_perimeters.append(existing_parzelle.perimeter) - - # Add to list of created parcels (actually existing) - created_parzellen.append(existing_parzelle) - continue # Skip creation, use existing - - # Parzelle does not exist - create new one - logger.info(f"Parzelle with label '{parcel_label}' does not exist, creating new one") - - # Resolve Gemeinde and Kanton for this parcel (create if not found) - gemeinde_id = None - canton_abk = parzelle_data.get("canton") - municipality_name = parzelle_data.get("municipality_name") - - logger.debug(f"Resolving Gemeinde/Kanton: canton='{canton_abk}', municipality='{municipality_name}'") - - if municipality_name and canton_abk: - # Mapping of canton abbreviations to full names - canton_names = { - "ZH": "Zürich", "BE": "Bern", "LU": "Luzern", "UR": "Uri", "SZ": "Schwyz", - "OW": "Obwalden", "NW": "Nidwalden", "GL": "Glarus", "ZG": "Zug", "FR": "Freiburg", - "SO": "Solothurn", "BS": "Basel-Stadt", "BL": "Basel-Landschaft", "SH": "Schaffhausen", - "AR": "Appenzell Ausserrhoden", "AI": "Appenzell Innerrhoden", "SG": "St. Gallen", - "GR": "Graubünden", "AG": "Aargau", "TG": "Thurgau", "TI": "Tessin", - "VD": "Waadt", "VS": "Wallis", "NE": "Neuenburg", "GE": "Genf", "JU": "Jura" - } - - # First, ensure Land "Schweiz" exists - logger.debug("Ensuring Land 'Schweiz' exists") - laender = realEstateInterface.getLaender(recordFilter={"label": "Schweiz"}) - if not laender: - logger.info("Creating Land 'Schweiz'") - land = Land( - mandateId=mandateId, - label="Schweiz", - abk="CH" - ) - land = realEstateInterface.createLand(land) - logger.info(f"Created Land 'Schweiz' with ID: {land.id}") - else: - land = laender[0] - logger.debug(f"Found Land 'Schweiz' with ID: {land.id}") - - # Then, lookup or create Kanton - logger.debug(f"Looking up Kanton with abk='{canton_abk}'") - kantone = realEstateInterface.getKantone(recordFilter={"abk": canton_abk}) - logger.debug(f"Found {len(kantone)} Kanton(e) with abk='{canton_abk}'") - if not kantone: - logger.info(f"Kanton '{canton_abk}' not found, creating it") - kanton_label = canton_names.get(canton_abk, canton_abk) # Use mapping or fallback to abk - kanton = Kanton( - mandateId=mandateId, - label=kanton_label, - abk=canton_abk, - id_land=land.id - ) - kanton = realEstateInterface.createKanton(kanton) - logger.info(f"Created Kanton '{kanton_label}' ({canton_abk}) with ID: {kanton.id}") - else: - kanton = kantone[0] - logger.debug(f"Found Kanton: ID={kanton.id}, Label={kanton.label}, abk={kanton.abk}") - - # Then, lookup or create Gemeinde - logger.debug(f"Looking up Gemeinde with label='{municipality_name}' and id_kanton='{kanton.id}'") - gemeinden = realEstateInterface.getGemeinden( - recordFilter={"label": municipality_name, "id_kanton": kanton.id} - ) - logger.debug(f"Found {len(gemeinden)} Gemeinde(n) with label='{municipality_name}' and id_kanton='{kanton.id}'") - if not gemeinden: - logger.info(f"Gemeinde '{municipality_name}' in Kanton '{canton_abk}' not found, creating it") - gemeinde = Gemeinde( - mandateId=mandateId, - label=municipality_name, - id_kanton=kanton.id, - plz=parzelle_data.get("plz") # Use PLZ directly from Swiss Topo API - ) - gemeinde = realEstateInterface.createGemeinde(gemeinde) - logger.info(f"Created Gemeinde '{municipality_name}' with ID: {gemeinde.id}") - else: - gemeinde = gemeinden[0] - logger.debug(f"Found Gemeinde: ID={gemeinde.id}, Label={gemeinde.label}") - - gemeinde_id = gemeinde.id - logger.info(f"Resolved Gemeinde '{municipality_name}' to ID '{gemeinde_id}'") - else: - logger.warning(f"Missing Gemeinde/Kanton data: municipality_name={municipality_name}, canton={canton_abk}") - - # Build parzellenAliasTags - alias_tags = [] - if parzelle_data.get("egrid"): - alias_tags.append(parzelle_data["egrid"]) - if parzelle_data.get("number") and parzelle_data["number"] != parzelle_data.get("id"): - alias_tags.append(parzelle_data["number"]) - - # Extract address information from Swiss Topo API data - # Each parcel should have its own address data from Swiss Topo API - # The address comes from the parcel search API response for THIS specific parcel - strasse_nr = None - plz = None - - # Use address from Swiss Topo API - this is specific to THIS parcel - # The address field contains the full address string from Swiss Topo - address = parzelle_data.get("address") - if address: - # Swiss Topo provides full address string like "Street Number, PLZ City" - # Parse to extract street and number (before comma) - parts = address.split(",") - if len(parts) >= 1: - strasse_nr = parts[0].strip() - # PLZ is provided separately by Swiss Topo API - plz = parzelle_data.get("plz") - - # Log address info for debugging - logger.debug(f"Parzelle {idx + 1} address data: strasse_nr='{strasse_nr}', plz='{plz}', full_address='{address}'") - - # If no address found, log warning but continue - if not strasse_nr and not plz: - logger.warning(f"No address data found for Parzelle {idx + 1} (label: {parcel_label})") - - # Build kontextInformationen - kontext_items = [] - - if parzelle_data.get("egrid"): - kontext_items.append(Kontext( - thema="EGRID", - inhalt=parzelle_data["egrid"] - )) - - if parzelle_data.get("identnd"): - kontext_items.append(Kontext( - thema="IdentND", - inhalt=parzelle_data["identnd"] - )) - - if parzelle_data.get("area_m2"): - kontext_items.append(Kontext( - thema="Fläche", - inhalt=f"{parzelle_data['area_m2']} m²" - )) - - if parzelle_data.get("centroid"): - centroid = parzelle_data["centroid"] - kontext_items.append(Kontext( - thema="Zentrum (LV95)", - inhalt=f"X: {centroid.get('x')} m, Y: {centroid.get('y')} m (EPSG:2056)" - )) - - if parzelle_data.get("geoportal_url"): - kontext_items.append(Kontext( - thema="Geoportal URL", - inhalt=parzelle_data["geoportal_url"] - )) - - if parzelle_data.get("municipality_code"): - kontext_items.append(Kontext( - thema="BFS-Nummer", - inhalt=str(parzelle_data["municipality_code"]) - )) - - # Handle adjacent parcels - filter out selected parcels geometrically - adjacent_parcel_refs = [] - if parzelle_data.get("adjacent_parcels"): - # Filter neighbors to exclude selected parcels - neighbors_to_filter = [] - for adj_parcel in parzelle_data["adjacent_parcels"]: - if isinstance(adj_parcel, dict): - neighbors_to_filter.append(adj_parcel) - elif isinstance(adj_parcel, str): - neighbors_to_filter.append({"id": adj_parcel}) - - # Filter using geometry comparison if we have geometries - if all_parcel_geometries and neighbors_to_filter: - try: - filtered_neighbors = filter_neighbor_parcels( - neighbors_to_filter, - all_parcel_geometries - ) - # Extract IDs from filtered neighbors - for filtered_neighbor in filtered_neighbors: - adj_id = filtered_neighbor.get("id") - if adj_id: - adjacent_parcel_refs.append({"id": adj_id}) - except Exception as e: - logger.warning(f"Error filtering neighbor parcels: {e}, including all neighbors") - # Fallback: include all neighbors if filtering fails - for adj_parcel in parzelle_data["adjacent_parcels"]: - if isinstance(adj_parcel, dict): - adj_id = adj_parcel.get("id") - if adj_id: - adjacent_parcel_refs.append({"id": adj_id}) - elif isinstance(adj_parcel, str): - adjacent_parcel_refs.append({"id": adj_parcel}) - else: - # No geometries available - include all neighbors - for adj_parcel in parzelle_data["adjacent_parcels"]: - if isinstance(adj_parcel, dict): - adj_id = adj_parcel.get("id") - if adj_id: - adjacent_parcel_refs.append({"id": adj_id}) - elif isinstance(adj_parcel, str): - adjacent_parcel_refs.append({"id": adj_parcel}) - - # Convert perimeter to GeoPolylinie if needed - perimeter = parzelle_data.get("perimeter") - if isinstance(perimeter, dict): - # Check if it's already in GeoPolylinie format (has punkte and closed) - if "punkte" in perimeter and "closed" in perimeter: - try: - perimeter = GeoPolylinie(**perimeter) - except Exception as e: - raise ValueError(f"Invalid perimeter format: {str(e)}") - else: - # Try to convert from GeoJSON format - converted = convert_geojson_to_geopolylinie(perimeter) - if converted: - perimeter = converted - else: - raise ValueError("Invalid perimeter format: cannot convert to GeoPolylinie") - elif isinstance(perimeter, GeoPolylinie): - # Already a GeoPolylinie instance, use as-is - pass - else: - raise ValueError("Invalid perimeter type: must be dict or GeoPolylinie") - - # Extract baulinie from geometry if provided - baulinie = None - geometry = parzelle_data.get("geometry") - logger.debug(f"Geometry present: {geometry is not None}") - if geometry: - logger.debug(f"Geometry type: {type(geometry)}, keys: {list(geometry.keys()) if isinstance(geometry, dict) else 'not a dict'}") - baulinie = convert_geojson_to_geopolylinie(geometry) - if baulinie: - logger.info(f"Extracted baulinie from geometry with {len(baulinie.punkte)} points") - else: - logger.warning("Failed to extract baulinie from geometry") - else: - logger.warning("No geometry found in parzelle_data") - - # Build Parzelle data - parzelle_create_data = { - "mandateId": mandateId, - "label": parcel_label, # Use the label we determined earlier for uniqueness check - "parzellenAliasTags": alias_tags, - "eigentuemerschaft": None, - "strasseNr": strasse_nr, - "plz": plz, - "perimeter": perimeter, - "baulinie": baulinie, - "kontextGemeinde": gemeinde_id, - "bauzone": None, - "az": None, - "bz": None, - "vollgeschossZahl": None, - "anrechenbarDachgeschoss": None, - "anrechenbarUntergeschoss": None, - "gebaeudehoeheMax": None, - "regelnGrenzabstand": [], - "regelnMehrlaengenzuschlag": [], - "regelnMehrhoehenzuschlag": [], - "parzelleBebaut": None, - "parzelleErschlossen": None, - "parzelleHanglage": None, - "laermschutzzone": None, - "hochwasserschutzzone": None, - "grundwasserschutzzone": None, - "parzellenNachbarschaft": adjacent_parcel_refs, - "dokumente": [], - "kontextInformationen": kontext_items, - } - - # Create Parzelle instance - logger.debug(f"Creating Parzelle with label: {parzelle_create_data.get('label')}") - logger.debug(f"Parzelle mandateId: {parzelle_create_data.get('mandateId')}") - logger.debug(f"Parzelle perimeter present: {parzelle_create_data.get('perimeter') is not None}") - - try: - parzelle_instance = Parzelle(**parzelle_create_data) - logger.debug(f"Parzelle instance created successfully with ID: {parzelle_instance.id}") - except Exception as e: - logger.error(f"Error creating Parzelle instance: {str(e)}", exc_info=True) - raise - - # Create Parzelle in database - try: - logger.info(f"Calling createParzelle for Parzelle '{parzelle_instance.label}' (ID: {parzelle_instance.id})") - logger.debug(f"Parzelle instance before createParzelle: {parzelle_instance.model_dump(mode='json', exclude={'perimeter', 'baulinie', 'kontextInformationen'})}") - - # Use model_dump with mode='json' to ensure nested Pydantic models are serialized - parzelle_dict = parzelle_instance.model_dump(mode='json') - logger.debug(f"Parzelle dict keys: {list(parzelle_dict.keys())}") - - # Create Parzelle using the interface, which will handle serialization - created_parzelle = realEstateInterface.createParzelle(parzelle_instance) - - logger.info(f"createParzelle returned: ID={created_parzelle.id if created_parzelle else 'None'}, Label={created_parzelle.label if created_parzelle else 'None'}") - - # Verify Parzelle was created successfully - if not created_parzelle: - raise ValueError("Failed to create Parzelle - createParzelle returned None") - - if not created_parzelle.id: - raise ValueError("Failed to create Parzelle - no ID returned") - - logger.info(f"Parzelle created with ID: {created_parzelle.id}") - - # Verify Parzelle exists in database by fetching it - logger.debug(f"Verifying Parzelle {created_parzelle.id} exists in database...") - verify_parzelle = realEstateInterface.getParzelle(created_parzelle.id) - if not verify_parzelle: - logger.error(f"Parzelle {created_parzelle.id} was not found in database after creation") - # Try to get all Parzellen to see what's in the database - all_parzellen = realEstateInterface.getParzellen(recordFilter=None) - logger.error(f"Total Parzellen in database: {len(all_parzellen)}") - if all_parzellen: - logger.error(f"Sample Parzelle IDs: {[p.id for p in all_parzellen[:5]]}") - raise ValueError(f"Parzelle {created_parzelle.id} was not found in database after creation") - - logger.info(f"Verified Parzelle {created_parzelle.id} exists in database") - # Use the verified Parzelle from database to ensure it has all fields - created_parzelle = verify_parzelle - - # Collect perimeter for baulinie calculation - if created_parzelle.perimeter: - parcel_perimeters.append(created_parzelle.perimeter) - - # Add to list of created parcels - created_parzellen.append(created_parzelle) - - except Exception as e: - logger.error(f"Error creating Parzelle {idx + 1}: {str(e)}", exc_info=True) - raise - - if not created_parzellen: - raise ValueError("No Parzellen were successfully created") - - logger.info(f"Successfully created {len(created_parzellen)} Parzelle(n)") - - # Calculate combined baulinie from all parcel perimeters - project_baulinie = None - if len(parcel_perimeters) > 0: - try: - if len(parcel_perimeters) == 1: - # Single parcel - use its perimeter as baulinie - project_baulinie = parcel_perimeters[0] - logger.info("Using single parcel perimeter as baulinie") - else: - # Multiple parcels - combine geometries to create outer outline - logger.info(f"Combining {len(parcel_perimeters)} parcel geometries to create baulinie") - project_baulinie = combine_parcel_geometries(parcel_perimeters) - logger.info(f"Created combined baulinie with {len(project_baulinie.punkte)} points") - except Exception as e: - logger.error(f"Error combining parcel geometries for baulinie: {e}", exc_info=True) - # Fallback: use first parcel's perimeter - if parcel_perimeters: - project_baulinie = parcel_perimeters[0] - logger.warning("Using first parcel perimeter as fallback baulinie") - - # Convert status_prozess to enum - status_prozess_enum = None - if status_prozess: - try: - # Try to convert string to enum - if isinstance(status_prozess, str): - status_prozess_enum = StatusProzess(status_prozess) - elif isinstance(status_prozess, StatusProzess): - status_prozess_enum = status_prozess - except (ValueError, KeyError): - logger.warning(f"Invalid statusProzess '{status_prozess}', using default 'Eingang'") - status_prozess_enum = StatusProzess.EINGANG - else: - status_prozess_enum = StatusProzess.EINGANG - - # Create Projekt with combined baulinie - # Use the verified Parzelle instance (from database) to ensure it has all fields properly set - logger.debug(f"Preparing Projekt creation with baulinie: {project_baulinie is not None}") - if project_baulinie: - logger.debug(f"Baulinie has {len(project_baulinie.punkte)} points") - - # Use first parcel's perimeter for project perimeter (or combine if needed) - project_perimeter = created_parzellen[0].perimeter if created_parzellen else None - - projekt_create_data = { - "mandateId": mandateId, - "label": projekt_label, - "statusProzess": status_prozess_enum, - "perimeter": project_perimeter, # Use first parcel perimeter as project perimeter - "baulinie": project_baulinie, # Set baulinie from first parcel geometry - "parzellen": created_parzellen, # Link all created Parzelle instances - "dokumente": [], - "kontextInformationen": [], - } - - logger.debug(f"Projekt data prepared: label={projekt_label}, parzellen_count={len(projekt_create_data['parzellen'])}, baulinie={'present' if project_baulinie else 'None'}") - - try: - projekt_instance = Projekt(**projekt_create_data) - logger.debug(f"Projekt instance created successfully with ID: {projekt_instance.id}") - except Exception as e: - logger.error(f"Error creating Projekt instance: {str(e)}", exc_info=True) - raise - - # Log before creation for debugging - logger.debug(f"Creating Projekt with {len(projekt_instance.parzellen)} Parzelle(n)") - if projekt_instance.parzellen: - for idx, p in enumerate(projekt_instance.parzellen): - logger.debug(f" Parzelle {idx}: ID={p.id}, Label={p.label}") - - logger.debug(f"Projekt baulinie before save: {projekt_instance.baulinie is not None}") - if projekt_instance.baulinie: - logger.debug(f"Projekt baulinie has {len(projekt_instance.baulinie.punkte)} points") - - try: - created_projekt = realEstateInterface.createProjekt(projekt_instance) - logger.info(f"Created Projekt '{created_projekt.label}' (ID: {created_projekt.id})") - logger.debug(f"Created Projekt baulinie: {created_projekt.baulinie is not None}") - except Exception as e: - logger.error(f"Error calling createProjekt: {str(e)}", exc_info=True) - raise - - # Verify Projekt was created - if not created_projekt or not created_projekt.id: - raise ValueError("Failed to create Projekt - no ID returned") - - # Verify Parzelle is linked in the created Projekt - if not created_projekt.parzellen or len(created_projekt.parzellen) == 0: - logger.warning(f"Projekt {created_projekt.id} created but no Parzellen linked") - # Try to fetch the Projekt from database to see if Parzellen are there - verify_projekt = realEstateInterface.getProjekt(created_projekt.id) - if verify_projekt and verify_projekt.parzellen: - logger.info(f"Parzellen found when fetching Projekt from database: {len(verify_projekt.parzellen)}") - created_projekt = verify_projekt - else: - raise ValueError(f"Projekt {created_projekt.id} has no Parzellen linked after creation") - else: - logger.info(f"Projekt {created_projekt.id} successfully linked to {len(created_projekt.parzellen)} Parzelle(n)") - # Log Parzelle details - for idx, p in enumerate(created_projekt.parzellen): - logger.debug(f" Linked Parzelle {idx}: ID={p.id if hasattr(p, 'id') else 'NO ID'}, Label={p.label if hasattr(p, 'label') else 'NO LABEL'}") - - return { - "projekt": created_projekt.model_dump(), - "parzellen": [p.model_dump() for p in created_parzellen], - } - - except HTTPException: - raise - except Exception as e: - logger.error(f"Error creating project with parcel data: {str(e)}", exc_info=True) - raise - - -# ===== BZO Information Extraction for Parcels ===== - -async def extract_bzo_information( - currentUser: User, - gemeinde: str, - bauzone: str, - mandateId: Optional[str] = None, - featureInstanceId: Optional[str] = None, - total_area_m2: Optional[float] = None, - parcels: Optional[List[Dict[str, Any]]] = None, -) -> Dict[str, Any]: - """ - Extract BZO information from PDF documents for a specific Bauzone in a Gemeinde. - - Retrieves BZO documents for the specified Gemeinde, extracts content using - the BZO extraction pipeline, filters by Bauzone, and uses AI to find relevant information. - When total_area_m2 or parcels are provided, runs Machbarkeitsstudie for structured output. - - Args: - currentUser: Current authenticated user - gemeinde: Gemeinde name (e.g., "Zürich") or ID - bauzone: Bauzone code (e.g., "W3", "W2/30") - mandateId: Optional mandate ID for instance-scoped data (defaults to currentUser.mandateId) - featureInstanceId: Optional feature instance ID for instance-scoped data - total_area_m2: Optional total parcel area (m²) for Machbarkeitsstudie - parcels: Optional list of parcel dicts; total area computed via compute_selection_summary if not total_area_m2 - - Returns: - Dictionary containing: - - bauzone, gemeinde, extracted_content, ai_summary, relevant_rules, documents_processed - - machbarkeitsstudie: Structured Machbarkeitsstudie output when total_area_m2/parcels provided - """ - try: - _mandateId = mandateId or (str(currentUser.mandateId) if currentUser.mandateId else None) - logger.info(f"Extracting BZO information for Gemeinde '{gemeinde}', Bauzone '{bauzone}' (user: {currentUser.id}, mandate: {_mandateId})") - - # Get interfaces (instance-scoped when mandateId/featureInstanceId provided) - realEstateInterface = getRealEstateInterface( - currentUser, mandateId=_mandateId, featureInstanceId=featureInstanceId - ) - componentInterface = getComponentInterface( - currentUser, mandateId=_mandateId, featureInstanceId=featureInstanceId - ) - - # Get Gemeinde - try by ID first, then by label - logger.debug(f"Attempting to retrieve Gemeinde '{gemeinde}' for mandate {_mandateId}") - gemeinde_obj = realEstateInterface.getGemeinde(gemeinde) - - # If not found by ID, try searching by label - if not gemeinde_obj: - logger.debug(f"Gemeinde not found by ID, trying to search by label: {gemeinde}") - record_filter = {"label": gemeinde} - if _mandateId: - record_filter["mandateId"] = _mandateId - gemeinden_by_label = realEstateInterface.getGemeinden( - recordFilter=record_filter - ) - if gemeinden_by_label and len(gemeinden_by_label) > 0: - gemeinde_obj = gemeinden_by_label[0] - logger.info(f"Found Gemeinde by label '{gemeinde}' with ID: {gemeinde_obj.id}") - - # If still not found: fetch only this Gemeinde from Swiss Topo and create it - if not gemeinde_obj and _mandateId and featureInstanceId: - logger.info(f"Gemeinde '{gemeinde}' not in DB - fetching from Swiss Topo (this Gemeinde only)") - gemeinde_obj = await ensure_single_gemeinde( - realEstateInterface, _mandateId, featureInstanceId, gemeinde_name=gemeinde - ) - - if not gemeinde_obj: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Gemeinde '{gemeinde}' not found or not accessible" - ) - - gemeinde_id = gemeinde_obj.id - - # Get BZO documents directly from Gemeinde's dokumente field - bzo_documents = [] - if gemeinde_obj.dokumente: - for doc in gemeinde_obj.dokumente: - # Handle both dict and object formats - if isinstance(doc, dict): - doc_id = doc.get("id") - doc_typ = doc.get("dokumentTyp") - else: - doc_id = doc.id if hasattr(doc, "id") else None - doc_typ = doc.dokumentTyp if hasattr(doc, "dokumentTyp") else None - - # Check if it's a BZO document type - if doc_typ: - # Handle enum, string, or dict formats - if isinstance(doc_typ, DokumentTyp): - is_bzo = doc_typ in [DokumentTyp.GEMEINDE_BZO_AKTUELL, DokumentTyp.GEMEINDE_BZO_REVISION] - elif isinstance(doc_typ, str): - is_bzo = doc_typ in ["gemeindeBzoAktuell", "gemeindeBzoRevision", "GEMEINDE_BZO_AKTUELL", "GEMEINDE_BZO_REVISION"] - else: - doc_typ_str = str(doc_typ) - is_bzo = doc_typ_str in ["gemeindeBzoAktuell", "gemeindeBzoRevision", "GEMEINDE_BZO_AKTUELL", "GEMEINDE_BZO_REVISION"] - - if is_bzo: - # Get full document object - if doc_id: - full_doc = realEstateInterface.getDokument(doc_id) - if full_doc: - bzo_documents.append(full_doc) - else: - logger.warning(f"Document {doc_id} referenced in Gemeinde but not found in database") - - # If no BZO documents: auto-fetch from Tavily, then retry - if not bzo_documents and _mandateId and featureInstanceId: - logger.info(f"No BZO documents for Gemeinde '{gemeinde_obj.label}' - fetching from web") - fetched = await fetch_bzo_for_gemeinde( - realEstateInterface, componentInterface, gemeinde_obj, _mandateId, featureInstanceId - ) - if fetched: - # Reload Gemeinde to get updated dokumente - gemeinde_obj = realEstateInterface.getGemeinde(gemeinde_obj.id) - bzo_documents = [] - if gemeinde_obj and gemeinde_obj.dokumente: - for doc in gemeinde_obj.dokumente: - if isinstance(doc, dict): - doc_id = doc.get("id") - doc_typ = doc.get("dokumentTyp") - else: - doc_id = doc.id if hasattr(doc, "id") else None - doc_typ = doc.dokumentTyp if hasattr(doc, "dokumentTyp") else None - if doc_typ: - if isinstance(doc_typ, DokumentTyp): - is_bzo = doc_typ in [DokumentTyp.GEMEINDE_BZO_AKTUELL, DokumentTyp.GEMEINDE_BZO_REVISION] - elif isinstance(doc_typ, str): - is_bzo = doc_typ in ["gemeindeBzoAktuell", "gemeindeBzoRevision", "GEMEINDE_BZO_AKTUELL", "GEMEINDE_BZO_REVISION"] - else: - is_bzo = str(doc_typ) in ["gemeindeBzoAktuell", "gemeindeBzoRevision", "GEMEINDE_BZO_AKTUELL", "GEMEINDE_BZO_REVISION"] - if is_bzo and doc_id: - full_doc = realEstateInterface.getDokument(doc_id) - if full_doc: - bzo_documents.append(full_doc) - - if not bzo_documents: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"No BZO documents found for Gemeinde '{gemeinde_obj.label}'" - ) - - logger.info(f"Found {len(bzo_documents)} BZO document(s) for Gemeinde '{gemeinde_obj.label}'") - - # Initialize document retriever - document_retriever = BZODocumentRetriever(realEstateInterface, componentInterface) - - # Extract content from all documents - all_extracted_content = { - "articles": [], - "zones": [], - "rules": [], - "zone_parameter_tables": [], - "errors": [], - "warnings": [] - } - documents_processed = [] - - for dokument in bzo_documents: - try: - logger.info(f"Processing document {dokument.id}: {dokument.label}") - - # Retrieve PDF content - pdf_bytes = document_retriever.retrieve_pdf_content(dokument) - if not pdf_bytes: - logger.warning(f"Could not retrieve PDF content for dokument {dokument.id}") - all_extracted_content["warnings"].append( - f"Could not retrieve PDF content for document '{dokument.label}'" - ) - continue - - # Run extraction using the BZO extraction pipeline - extraction_result = run_extraction( - pdf_bytes=pdf_bytes, - pdf_id=dokument.dokumentReferenz or f"dok_{dokument.id}", - dokument_id=dokument.id - ) - - # Combine results - all_extracted_content["articles"].extend(extraction_result.get("articles", [])) - all_extracted_content["zones"].extend(extraction_result.get("zones", [])) - all_extracted_content["rules"].extend(extraction_result.get("rules", [])) - all_extracted_content["zone_parameter_tables"].extend(extraction_result.get("zone_parameter_tables", [])) - all_extracted_content["errors"].extend(extraction_result.get("errors", [])) - all_extracted_content["warnings"].extend(extraction_result.get("warnings", [])) - - documents_processed.append({ - "id": dokument.id, - "label": dokument.label, - "dokumentTyp": dokument.dokumentTyp.value if dokument.dokumentTyp else None - }) - - except Exception as e: - logger.error(f"Error processing document {dokument.id}: {str(e)}", exc_info=True) - all_extracted_content["errors"].append( - f"Error processing document '{dokument.label}': {str(e)}" - ) - continue - - # Filter rules by Bauzone - only rules explicitly associated with this zone - relevant_rules = filter_rules_by_bauzone( - all_extracted_content["rules"], - bauzone - ) - logger.info(f"Extracting for Bauzone {bauzone}: {len(relevant_rules)} zone-specific rules, " - f"{len([t for t in all_extracted_content.get('zone_parameter_tables', []) if bauzone.upper() in str(t.get('zones', [])).upper()])} tables with zone data") - - # Filter zones by Bauzone - relevant_zones = filter_zones_by_bauzone( - all_extracted_content["zones"], - bauzone - ) - - # Filter articles that mention the Bauzone - relevant_articles = filter_articles_by_bauzone( - all_extracted_content.get("articles", []), - bauzone - ) - - # Compute total_area_m2 from parcels if not provided - _total_area_m2 = total_area_m2 - if _total_area_m2 is None and parcels: - selection_summary = compute_selection_summary(parcels) - _total_area_m2 = selection_summary.get("total_area_m2") or 0.0 - - # Extract BZO parameters for Wohnzone via LLM (bullet list with sources) - bzo_params_result = None - try: - services = getServices( - currentUser, workflow=None, mandateId=_mandateId, featureInstanceId=featureInstanceId - ) - ai_service = services.ai - bzo_params_result = await run_bzo_params_extraction( - extracted_content=all_extracted_content, - bauzone=bauzone, - ai_service=ai_service, - gemeinde=gemeinde_obj.label, - relevant_rules=relevant_rules, - relevant_articles=relevant_articles, - total_area_m2=_total_area_m2, - ) - except Exception as me: - logger.warning(f"BZO parameter extraction failed: {me}", exc_info=True) - all_extracted_content["warnings"] = all_extracted_content.get("warnings", []) + [ - f"BZO-Parameter konnten nicht extrahiert werden: {str(me)}" - ] - - # Use AI to generate summary and find additional information - ai_summary = await generate_bauzone_ai_summary( - currentUser=currentUser, - bauzone=bauzone, - gemeinde=gemeinde_obj.label, - extracted_content=all_extracted_content, - relevant_rules=relevant_rules, - relevant_zones=relevant_zones, - mandateId=_mandateId, - featureInstanceId=featureInstanceId, - ) - - # Build unified summary that includes zones and articles - unified_summary = ai_summary - - # Append zone and article information to the summary if not already included - # The AI should have integrated this, but we add it as backup if needed - summary_lower = unified_summary.lower() - - # Check if zones are mentioned in summary - zones_mentioned = any(zone.get("zone_code", "").upper() in summary_lower for zone in relevant_zones) - if not zones_mentioned and relevant_zones: - unified_summary += "\n\n=== ZONENDEFINITIONEN ===\n" - for zone in relevant_zones: - zone_code = zone.get("zone_code", "") - zone_name = zone.get("zone_name", "") - zone_category = zone.get("zone_category", "") - geschosszahl = zone.get("geschosszahl") - gewerbeerleichterung = zone.get("gewerbeerleichterung", False) - page_num = zone.get("page", 0) - source_article = zone.get("source_article", "") - - zone_info = f"{zone_code}: {zone_name}" - if zone_category: - zone_info += f"\nKategorie: {zone_category}" - if geschosszahl: - zone_info += f"\nGeschosszahl: {geschosszahl}" - if gewerbeerleichterung: - zone_info += "\nGewerbeerleichterung: Ja" - if source_article: - zone_info += f"\nQuelle: {source_article} (Seite {page_num})" - unified_summary += zone_info + "\n\n" - - # Check if articles are mentioned in summary - articles_mentioned = any(article.get("article_label", "") in summary_lower for article in relevant_articles) - if not articles_mentioned and relevant_articles: - unified_summary += "\n\n=== RELEVANTE ARTIKEL ===\n" - for article in relevant_articles: - article_label = article.get("article_label", "") - article_title = article.get("article_title", "") - article_text = article.get("text", "") - page_start = article.get("page_start", 0) - page_end = article.get("page_end", 0) - page_range = f"Seite {page_start}" if page_start == page_end else f"Seiten {page_start}-{page_end}" - - unified_summary += f"{article_label}" - if article_title: - unified_summary += f": {article_title}" - unified_summary += f" ({page_range})\n" - # Include first 500 chars of article text - if article_text: - preview = article_text[:500] + "..." if len(article_text) > 500 else article_text - unified_summary += f"{preview}\n\n" - - return { - "bauzone": bauzone, - "gemeinde": { - "id": gemeinde_obj.id, - "label": gemeinde_obj.label, - "plz": gemeinde_obj.plz - }, - "extracted_content": { - "zones": relevant_zones, - "rules": relevant_rules, - "articles": relevant_articles, - "zone_parameter_tables": _filter_tables_by_bauzone( - all_extracted_content.get("zone_parameter_tables", []), - bauzone - ), - "total_zones": len(all_extracted_content.get("zones", [])), - "total_rules": len(all_extracted_content.get("rules", [])), - "total_articles": len(all_extracted_content.get("articles", [])), - "total_tables": len(all_extracted_content.get("zone_parameter_tables", [])) - }, - "ai_summary": unified_summary, - "relevant_rules": relevant_rules, - "documents_processed": documents_processed, - "errors": all_extracted_content.get("errors", []), - "warnings": all_extracted_content.get("warnings", []), - "machbarkeitsstudie": bzo_params_result, # Same key for frontend compatibility - } - - except HTTPException: - raise - except Exception as e: - logger.error(f"Error extracting BZO information for Gemeinde '{gemeinde}', Bauzone '{bauzone}': {str(e)}", exc_info=True) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error extracting BZO information: {str(e)}" - ) - - -def filter_rules_by_bauzone(rules: List[Dict[str, Any]], bauzone: str) -> List[Dict[str, Any]]: - """ - Filter rules by Bauzone code. Only keeps rules from SINGLE-zone articles to avoid - wrong values (e.g. article with W2,W3,W5 has different values per zone - we cannot - associate a rule value with a specific zone from article text alone). - """ - relevant_rules = [] - bauzone_upper = bauzone.upper() - - def _zone_matches(z: str) -> bool: - zu = (z or "").upper().strip() - if not zu: - return False - if bauzone_upper in zu: - return True - if zu in bauzone_upper and len(zu) >= 2: - return True - return False - - for rule in rules: - table_zones = rule.get("table_zones", []) or [] - zone_raw = rule.get("zone_raw") - - # Rule must be zone-associated - has_zone = bool(zone_raw) or bool(table_zones) - if not has_zone: - continue - - # CRITICAL: Only use rules from single-zone articles. Multi-zone articles - # (e.g. table with W2,W3,W5) have different values per zone - we cannot - # know which value applies to our zone from article text. - if len(table_zones) > 1: - # Check if ALL zones in article match our bauzone (e.g. W5, W5/50) - unlikely - matches_all = all(_zone_matches(str(z)) for z in table_zones) - if not matches_all: - continue # Ambiguous: exclude - - # Zone must match our bauzone - matches = False - if zone_raw and _zone_matches(zone_raw): - matches = True - if not matches and table_zones: - for tz in table_zones: - if _zone_matches(str(tz)): - matches = True - break - if not matches: - ts = (rule.get("text_snippet") or "").upper() - if bauzone_upper in ts and len(table_zones) <= 1: - matches = True - - if matches: - relevant_rules.append(rule) - - logger.info(f"Filtered {len(relevant_rules)} rules for Bauzone {bauzone} from {len(rules)} total (multi-zone articles excluded)") - return relevant_rules - - -def filter_zones_by_bauzone(zones: List[Dict[str, Any]], bauzone: str) -> List[Dict[str, Any]]: - """ - Filter zones by Bauzone code. - - Args: - zones: List of zone dictionaries from extraction - bauzone: Bauzone code to filter by - - Returns: - Filtered list of zones that match the Bauzone - """ - relevant_zones = [] - bauzone_upper = bauzone.upper() - - for zone in zones: - zone_code = zone.get("zone_code", "") - if bauzone_upper in zone_code.upper(): - relevant_zones.append(zone) - - logger.info(f"Filtered {len(relevant_zones)} zones for Bauzone {bauzone} from {len(zones)} total zones") - return relevant_zones - - -def filter_articles_by_bauzone(articles: List[Dict[str, Any]], bauzone: str) -> List[Dict[str, Any]]: - """ - Filter articles that mention the Bauzone. - - Args: - articles: List of article dictionaries from extraction - bauzone: Bauzone code to filter by - - Returns: - Filtered list of articles that mention the Bauzone - """ - relevant_articles = [] - bauzone_upper = bauzone.upper() - - for article in articles: - text = article.get("text", "") - zone_raw = article.get("zone_raw") - - # Check if article mentions the Bauzone - text_matches = bauzone_upper in text.upper() if text else False - zone_matches = bauzone_upper in zone_raw.upper() if zone_raw else False - - if text_matches or zone_matches: - relevant_articles.append(article) - - logger.info(f"Filtered {len(relevant_articles)} articles for Bauzone {bauzone} from {len(articles)} total articles") - return relevant_articles - - -def _filter_tables_by_bauzone(tables: List[Dict[str, Any]], bauzone: str) -> List[Dict[str, Any]]: - """ - Filter zone-parameter tables to include only those containing the specified Bauzone. - - Args: - tables: List of zone-parameter table dictionaries - bauzone: Bauzone code to filter by - - Returns: - Filtered list of tables containing the Bauzone - """ - relevant_tables = [] - bauzone_upper = bauzone.upper() - - for table in tables: - zones = table.get("zones", []) - # Check if any zone in the table matches the Bauzone - matching_zones = [z for z in zones if bauzone_upper in str(z).upper()] - - if matching_zones: - # Create filtered version with only relevant zone columns - filtered_table = { - "page": table.get("page"), - "zones": matching_zones, - "parameters": [] - } - - # Filter parameters to only include values for matching zones - for param in table.get("parameters", []): - values_by_zone = param.get("values_by_zone", {}) - filtered_values = { - zone: values_by_zone[zone] - for zone in matching_zones - if zone in values_by_zone - } - - if filtered_values: - filtered_table["parameters"].append({ - "parameter": param.get("parameter"), - "values_by_zone": filtered_values - }) - - if filtered_table["parameters"]: - relevant_tables.append(filtered_table) - - logger.info(f"Filtered {len(relevant_tables)} tables for Bauzone {bauzone} from {len(tables)} total tables") - return relevant_tables - - -async def generate_bauzone_ai_summary( - currentUser: User, - bauzone: str, - gemeinde: str, - extracted_content: Dict[str, Any], - relevant_rules: List[Dict[str, Any]], - relevant_zones: List[Dict[str, Any]], - mandateId: Optional[str] = None, - featureInstanceId: Optional[str] = None, -) -> str: - """ - Use AI to generate a summary of relevant information for a Bauzone. - - Args: - currentUser: Current authenticated user - bauzone: Bauzone code - gemeinde: Gemeinde name - extracted_content: All extracted content from PDFs - relevant_rules: Rules filtered by Bauzone - relevant_zones: Zones filtered by Bauzone - - Returns: - AI-generated summary string - """ - try: - # Initialize AI service (mandateId required for billing) - services = getServices( - currentUser, workflow=None, mandateId=mandateId, featureInstanceId=featureInstanceId - ) - aiService = services.ai - - # Build context from extracted content, prioritizing zone-parameter tables - context_parts = [] - - # Extract and format zone-parameter table values for the specific Bauzone - zone_parameter_tables = extracted_content.get("zone_parameter_tables", []) - table_values_for_bauzone = [] - - if zone_parameter_tables: - context_parts.append("=== BUILDING REGULATIONS TABLE VALUES FOR BAUZONE (INCLUDE THESE EXACT VALUES IN YOUR SUMMARY) ===") - for table in zone_parameter_tables: - page_num = table.get("page", 0) - article_ref = table.get("article", "Unknown article") - zones_in_table = table.get("zones", []) - - # Check if this table contains the requested Bauzone - matching_zones = [z for z in zones_in_table if bauzone.upper() in str(z).upper()] - - if matching_zones: - context_parts.append(f"\nTabelle aus {article_ref} (Seite {page_num}):") - - for param in table.get("parameters", []): - param_name = param.get("parameter", "") - values_by_zone = param.get("values_by_zone", {}) - - # Extract values for the requested Bauzone - for zone, values in values_by_zone.items(): - if bauzone.upper() in zone.upper(): - if isinstance(values, list) and len(values) > 0: - # Take the first value (most relevant) - val_entry = values[0] - value = val_entry.get("value", "") - unit = val_entry.get("unit", "") - unit_str = f" {unit}" if unit else "" - - # Format parameter name nicely - formatted_param = param_name - if "Ausnützungsziffer" in param_name or "ausnützungsziffer" in param_name.lower(): - formatted_param = "Ausnützungsziffer max." - elif "Vollgeschosse" in param_name or "vollgeschosse" in param_name.lower(): - formatted_param = "Vollgeschosse max." - elif "Gebäudelänge" in param_name or "gebäudelänge" in param_name.lower(): - formatted_param = "Gebäudelänge max." - elif ("Grenzabstand" in param_name or "grenzabstand" in param_name.lower()) and ("Grundabstand" in param_name or "grundabstand" in param_name.lower()): - formatted_param = "Grenzabstand - Grundabstand min." - elif ("Grenzabstand" in param_name or "grenzabstand" in param_name.lower()) and ("Mehrlängen" in param_name or "mehrlängen" in param_name.lower()): - formatted_param = "Grenzabstand - Mehrlängen-zuschlag" - elif ("Grenzabstand" in param_name or "grenzabstand" in param_name.lower()) and ("Höchstmass" in param_name or "höchstmass" in param_name.lower() or "Höchstmaß" in param_name): - formatted_param = "Grenzabstand - Höchstmass max." - elif "Fassadenhöhen" in param_name or "fassadenhöhen" in param_name.lower(): - formatted_param = "Fassadenhöhen max." - elif "Dachgeschosse" in param_name or "dachgeschosse" in param_name.lower(): - formatted_param = "anrechenbare Dachgeschosse max." - elif "Attikageschoss" in param_name or "attikageschoss" in param_name.lower(): - formatted_param = "anrechenbares Attikageschoss max." - elif "Untergeschoss" in param_name or "untergeschoss" in param_name.lower(): - formatted_param = "anrechenbares Untergeschoss max." - - table_values_for_bauzone.append({ - "parameter": formatted_param, - "value": value, - "unit": unit_str, - "article": article_ref, - "page": page_num - }) - context_parts.append(f" • {formatted_param}: {value}{unit_str} (Quelle: {article_ref}, Seite {page_num})") - - # Also check for multiple values (e.g., Fassadenhöhen with footnote values) - if len(values) > 1: - for idx, val_entry in enumerate(values[1:], 1): - value_extra = val_entry.get("value", "") - unit_extra = val_entry.get("unit", "") - unit_str_extra = f" {unit_extra}" if unit_extra else "" - context_parts.append(f" (Alternative: {value_extra}{unit_str_extra})") - - # Add zone information with all details - if relevant_zones: - context_parts.append("\n=== ZONE DEFINITIONS ===") - for zone in relevant_zones: - zone_code = zone.get("zone_code", "") - zone_name = zone.get("zone_name", "") - zone_category = zone.get("zone_category", "") - geschosszahl = zone.get("geschosszahl") - gewerbeerleichterung = zone.get("gewerbeerleichterung", False) - page_num = zone.get("page", 0) - source_article = zone.get("source_article", "") - - zone_info = f"- {zone_code}: {zone_name}" - if zone_category: - zone_info += f" (Kategorie: {zone_category})" - if geschosszahl: - zone_info += f", Geschosszahl: {geschosszahl}" - if gewerbeerleichterung: - zone_info += ", Gewerbeerleichterung: Ja" - if source_article: - zone_info += f" - Quelle: {source_article} (Seite {page_num})" - context_parts.append(zone_info) - - # Add article information with full text previews - relevant_articles = filter_articles_by_bauzone(extracted_content.get("articles", []), bauzone) - if relevant_articles: - context_parts.append("\n=== RELEVANT ARTICLES (full content) ===") - for article in relevant_articles: - article_label = article.get("article_label", "") - article_title = article.get("article_title", "") - article_text = article.get("text", "") - page_start = article.get("page_start", 0) - page_end = article.get("page_end", 0) - page_range = f"Seite {page_start}" if page_start == page_end else f"Seiten {page_start}-{page_end}" - - context_parts.append(f"\n{article_label}: {article_title or 'Kein Titel'}") - context_parts.append(f"Lage: {page_range}") - # Include full article text (truncated if too long) - if len(article_text) > 1000: - context_parts.append(f"Inhalt: {article_text[:1000]}...") - else: - context_parts.append(f"Inhalt: {article_text}") - - # Add relevant rules (only if not already covered in tables) - if relevant_rules: - # Filter out rules that are likely already in tables - table_parameter_names = set() - for table in zone_parameter_tables: - for param in table.get("parameters", []): - param_name = param.get("parameter", "").lower() - table_parameter_names.add(param_name) - - unique_rules = [] - for rule in relevant_rules[:15]: - rule_type = rule.get("rule_type", "").lower() - # Skip if this rule type is likely in tables - if not any(tp in rule_type for tp in table_parameter_names): - unique_rules.append(rule) - - if unique_rules: - context_parts.append("\n=== ADDITIONAL BUILDING REGULATIONS (from text) ===") - for rule in unique_rules[:8]: - rule_type = rule.get("rule_type", "") - value_numeric = rule.get("value_numeric") - value_text = rule.get("value_text", "") - unit = rule.get("unit", "") - page_num = rule.get("page", 0) - - rule_desc = f"- {rule_type}: " - if value_numeric is not None: - rule_desc += f"{value_numeric}" - if unit: - rule_desc += f" {unit}" - else: - rule_desc += value_text - rule_desc += f" (Seite {page_num})" - - context_parts.append(rule_desc) - - context = "\n".join(context_parts) - - # Create AI prompt with explicit instructions to include all table values - prompt = f""" -Analyze the following building zone (Bauzone) information extracted from BZO (Bau- und Zonenordnung) documents for {gemeinde}, specifically for Bauzone {bauzone}. - -Extracted Content: -{context} - -CRITICAL INSTRUCTIONS: -1. You MUST include ALL actual values from the tables in your summary - do NOT just say "see tables on page X" -2. List ALL parameters with their actual values: Ausnützungsziffer, Vollgeschosse, Gebäudelänge, Grenzabstand (Grundabstand, Mehrlängen-zuschlag, Höchstmass), Fassadenhöhen, etc. -3. Integrate zone definitions and article information INTO the summary text - do NOT create separate sections -4. Always cite WHERE each piece of information was found (article number and page number) -5. Combine everything into ONE unified, flowing summary - no separate sections for zones/articles -6. Be comprehensive - include all relevant details from zones, articles, and tables -7. Format as a single, well-structured German text document - -Please provide a comprehensive, unified summary that includes: - -1. General description of Bauzone {bauzone}: - - Zone category (Wohnzonen, Zentrumszonen, etc.) - - Geschosszahl (number of full storeys) - - Gewerbeerleichterung status (Ja/Nein) - - Where defined (article and page number) - -2. ALL building regulations with ACTUAL VALUES from tables (you MUST include the exact values): - - Ausnützungsziffer max.: [ACTUAL PERCENTAGE VALUE]% (from article, page) - - Vollgeschosse max.: [ACTUAL NUMBER] (from article, page) - - anrechenbare Dachgeschosse max.: [ACTUAL NUMBER] (from article, page) - - anrechenbares Attikageschoss max.: [ACTUAL NUMBER] (from article, page) - - anrechenbares Untergeschoss max.: [ACTUAL NUMBER] (from article, page) - - Gebäudelänge max.: [ACTUAL VALUE] m (from article, page) - - Grenzabstand - Grundabstand min.: [ACTUAL VALUE] m (from article, page) - - Grenzabstand - Mehrlängen-zuschlag: [ACTUAL FRACTION] (from article, page) - - Grenzabstand - Höchstmass max.: [ACTUAL VALUE] m (from article, page) - - Fassadenhöhen max.: [ACTUAL VALUE] m (from article, page, include footnote values if present) - -3. Zone definitions: Integrate information about where this zone is defined (which articles mention it, with page numbers) - -4. Relevant articles: Integrate key content from relevant articles naturally into the summary, citing article numbers and page numbers - -5. Special conditions: Any special requirements or exceptions mentioned in articles - -CRITICAL: You MUST include the actual numeric values from the tables in your summary. Do NOT say "see tables" - list the actual values. Format everything as ONE unified, flowing German text document without separate sections. Integrate zones and articles naturally into the narrative. -""" - - # Call AI service - logger.info(f"Generating AI summary for Bauzone {bauzone} in {gemeinde}") - ai_response = await aiService.callAiPlanning( - prompt=prompt, - debugType="bzo_summary" - ) - - return ai_response.strip() - - except Exception as e: - logger.error(f"Error generating AI summary: {str(e)}", exc_info=True) - # Return a basic summary if AI fails - return f"Summary generation failed: {str(e)}. Found {len(relevant_rules)} relevant rules and {len(relevant_zones)} zones for Bauzone {bauzone}." - - # --------------------------------------------------------------------------- # Feature Lifecycle Hooks # --------------------------------------------------------------------------- @@ -3124,3 +318,57 @@ def onMandateDelete(mandateId: str, instances: list) -> None: logger.warning(f"Failed to cascade-delete realEstate data for mandate {mandateId}: {e}") +def onUserDelete(userId: str, currentUser) -> dict: + """Delete/anonymize user data from the realEstate database (GDPR).""" + from modules.system.gdprDeletion import deleteUserDataFromDatabase + from modules.connectors.connectorDbPostgre import DatabaseConnector + from modules.shared.configuration import APP_CONFIG + + dbName = "poweron_realestate" + try: + db = DatabaseConnector( + dbHost=APP_CONFIG.get("DB_HOST", "localhost"), + dbDatabase=dbName, + dbUser=APP_CONFIG.get("DB_USER"), + dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), + dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), + userId=None, + ) + stats = deleteUserDataFromDatabase(db, userId, dbName) + db.close() + return stats + except Exception as e: + logger.warning(f"onUserDelete realEstate failed: {e}") + return {"database": dbName, "tablesProcessed": 0, "recordsDeleted": 0, "recordsAnonymized": 0, "errors": [str(e)]} + + +# --------------------------------------------------------------------------- +# Re-exports for backward compatibility +# --------------------------------------------------------------------------- + +from .serviceGeometry import ( # noqa: F401, E402 + geopolylinie_to_shapely_polygon, + shapely_polygon_to_geopolylinie, + combine_parcel_geometries, + filter_neighbor_parcels, + fetch_parcel_polygon_from_swisstopo, + create_project_with_parcel_data, + convert_geojson_to_geopolylinie, +) + +from .serviceAiIntent import ( # noqa: F401, E402 + executeDirectQuery, + _formatEntitySummary, + processNaturalLanguageCommand, + analyzeUserIntent, + executeIntentBasedOperation, +) + +from .serviceBzo import ( # noqa: F401, E402 + extract_bzo_information, + filter_rules_by_bauzone, + filter_zones_by_bauzone, + filter_articles_by_bauzone, + _filter_tables_by_bauzone, + generate_bauzone_ai_summary, +) diff --git a/modules/features/realEstate/routeFeatureRealEstate.py b/modules/features/realEstate/routeFeatureRealEstate.py index a1cfdb8b..b78ee2ee 100644 --- a/modules/features/realEstate/routeFeatureRealEstate.py +++ b/modules/features/realEstate/routeFeatureRealEstate.py @@ -3,13 +3,9 @@ Real Estate routes for the backend API. Implements stateless endpoints for real estate database operations with AI-powered natural language processing. """ -import asyncio import json import logging -import re -import aiohttp -import requests -from typing import Optional, Dict, Any, List, Union +from typing import Optional, Dict, Any, List from fastapi import APIRouter, HTTPException, Depends, Body, Request, Query, Path, status from fastapi.responses import JSONResponse @@ -29,12 +25,9 @@ from .datamodelFeatureRealEstate import ( Projekt, Parzelle, Dokument, - DokumentTyp, Gemeinde, Kanton, Land, - Kontext, - StatusProzess, ) # Import interfaces @@ -43,19 +36,27 @@ from .interfaceFeatureRealEstate import getInterface as getRealEstateInterface # Import feature logic for AI-powered commands from .mainRealEstate import ( processNaturalLanguageCommand, - create_project_with_parcel_data, extract_bzo_information, ) -from .parcelSelectionService import compute_selection_summary, is_parcel_adjacent_to_selection +from .parcelSelectionService import compute_selection_summary -# Import Swiss Topo MapServer, ÖREB and Zurich WFS connectors -from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerConnector -from modules.connectors.connectorOerebWfs import OerebWfsConnector +# Import connectors still used directly in route file from modules.connectors.connectorZhWfsParcels import ZhWfsParcelsConnector -# Import ComponentObjects and Tavily for BZO document fetch +# Import ComponentObjects interface for BZO routes from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface -from modules.aicore.aicorePluginTavily import AiTavily + +# Import handler functions for complex business logic +from .handlerRealEstate import ( + processGemeindenSync, + processBzoDocumentsFetch, + processParcelDocuments, + processTableData, + processCreateTableRecord, + processParcelSearch, + processAddAdjacentParcel, + processAddParcelToProject, +) # Import attribute utilities for model schema from modules.shared.attributeUtils import getModelAttributeDefinitions @@ -141,6 +142,31 @@ _REALESTATE_ENTITY_MODELS = { } +def _validateCsrfToken(request: "Request", routePath: str, userId: str) -> None: + """Validate CSRF token from request headers (format + hex check).""" + csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token") + if not csrf_token: + logger.warning(f"CSRF token missing for {routePath} from user {userId}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.") + ) + if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: + logger.warning(f"Invalid CSRF token format for {routePath} from user {userId}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=routeApiMsg("Invalid CSRF token format") + ) + try: + int(csrf_token, 16) + except ValueError: + logger.warning(f"CSRF token is not a valid hex string for {routePath} from user {userId}") + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=routeApiMsg("Invalid CSRF token format") + ) + + # ============================================================================ # INSTANCE-ID ROUTES (backend-driven, analog to Trustee) # ============================================================================ @@ -535,116 +561,7 @@ async def get_instance_gemeinden( interface = getRealEstateInterface( context.user, mandateId=mandateId, featureInstanceId=instanceId ) - try: - oereb_connector = OerebWfsConnector() - connector = SwissTopoMapServerConnector(oereb_connector=oereb_connector) - gemeinden_data = await connector.get_all_gemeinden(only_current=only_current) - except Exception as e: - logger.error(f"Error fetching Gemeinden from Swiss Topo: {e}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Error fetching Gemeinden: {str(e)}") - gemeinden_created = 0 - gemeinden_skipped = 0 - kantone_created = 0 - errors: List[str] = [] - kanton_cache: Dict[str, str] = {} - - def find_gemeinde_by_bfs_nummer(bfs_nummer: str) -> Optional[Any]: - try: - gemeinden = interface.getGemeinden(recordFilter={"mandateId": mandateId}) - for g in gemeinden: - for k in (g.kontextInformationen or []): - try: - data = json.loads(k.inhalt) if isinstance(k.inhalt, str) else k.inhalt - if isinstance(data, dict) and str(data.get("bfs_nummer")) == str(bfs_nummer): - return g - except (json.JSONDecodeError, AttributeError): - continue - except Exception as ex: - logger.error(f"Error finding Gemeinde by BFS {bfs_nummer}: {ex}", exc_info=True) - return None - - def get_or_create_kanton(kanton_abk: str) -> Optional[str]: - nonlocal kantone_created, errors - if not kanton_abk: - return None - if kanton_abk in kanton_cache: - return kanton_cache[kanton_abk] - kantone = interface.getKantone(recordFilter={"mandateId": mandateId, "abk": kanton_abk}) - if kantone: - kanton_cache[kanton_abk] = kantone[0].id - return kantone[0].id - kanton_names = { - "AG": "Aargau", "AI": "Appenzell Innerrhoden", "AR": "Appenzell Ausserrhoden", - "BE": "Bern", "BL": "Basel-Landschaft", "BS": "Basel-Stadt", - "FR": "Freiburg", "GE": "Genf", "GL": "Glarus", "GR": "Graubünden", - "JU": "Jura", "LU": "Luzern", "NE": "Neuenburg", "NW": "Nidwalden", - "OW": "Obwalden", "SG": "St. Gallen", "SH": "Schaffhausen", "SO": "Solothurn", - "SZ": "Schwyz", "TG": "Thurgau", "TI": "Tessin", "UR": "Uri", - "VD": "Waadt", "VS": "Wallis", "ZG": "Zug", "ZH": "Zürich", - } - try: - kanton_label = kanton_names.get(kanton_abk, kanton_abk) - kanton = Kanton( - mandateId=mandateId, - featureInstanceId=instanceId, - label=kanton_label, - abk=kanton_abk, - ) - created = interface.createKanton(kanton) - if created and created.id: - kanton_cache[kanton_abk] = created.id - kantone_created += 1 - return created.id - except Exception as ex: - errors.append(f"Error creating Kanton {kanton_abk}: {ex}") - return None - - saved_gemeinden: List[Dict[str, Any]] = [] - for gd in gemeinden_data: - try: - gemeinde_name = gd.get("name") - bfs_nummer = gd.get("bfs_nummer") - kanton_abk = gd.get("kanton") - if not gemeinde_name or bfs_nummer is None: - gemeinden_skipped += 1 - continue - existing = find_gemeinde_by_bfs_nummer(str(bfs_nummer)) - if existing: - gemeinden_skipped += 1 - saved_gemeinden.append(existing.model_dump() if hasattr(existing, "model_dump") else existing) - continue - kanton_id = get_or_create_kanton(kanton_abk) if kanton_abk else None - gemeinde = Gemeinde( - mandateId=mandateId, - featureInstanceId=instanceId, - label=gemeinde_name, - id_kanton=kanton_id, - kontextInformationen=[ - Kontext(thema="BFS Nummer", inhalt=json.dumps({"bfs_nummer": bfs_nummer}, ensure_ascii=False)) - ], - ) - created = interface.createGemeinde(gemeinde) - if created and created.id: - gemeinden_created += 1 - saved_gemeinden.append(created.model_dump() if hasattr(created, "model_dump") else created) - else: - errors.append(f"Failed to create Gemeinde {gemeinde_name}") - gemeinden_skipped += 1 - except Exception as ex: - errors.append(f"Error processing {gd.get('name', 'Unknown')}: {str(ex)}") - gemeinden_skipped += 1 - - return { - "gemeinden": saved_gemeinden, - "count": len(saved_gemeinden), - "stats": { - "gemeinden_created": gemeinden_created, - "gemeinden_skipped": gemeinden_skipped, - "kantone_created": kantone_created, - "error_count": len(errors), - "errors": errors[:10], - }, - } + return await processGemeindenSync(interface, instanceId, mandateId, onlyCurrent=only_current) @router.post("/{instanceId}/gemeinden/fetch-bzo-documents", response_model=Dict[str, Any]) @@ -662,38 +579,7 @@ async def fetch_instance_bzo_documents( componentInterface = getComponentInterface( context.user, mandateId=mandateId, featureInstanceId=instanceId ) - from modules.features.realEstate.realEstateGemeindeService import fetch_bzo_for_gemeinde - - gemeinden = interface.getGemeinden(recordFilter={"mandateId": mandateId}) - stats = {"gemeinden_processed": 0, "documents_created": 0, "documents_skipped": 0, "errors": []} - results: List[Dict[str, Any]] = [] - - for gemeinde in gemeinden: - gr = {"gemeinde_id": gemeinde.id, "gemeinde_label": gemeinde.label, "status": None, "dokument_ids": [], "error": None} - try: - stats["gemeinden_processed"] += 1 - fetched = await fetch_bzo_for_gemeinde( - interface, componentInterface, gemeinde, mandateId, instanceId - ) - if fetched: - gr["status"] = "created" - stats["documents_created"] += 1 - refreshed = interface.getGemeinde(gemeinde.id) - if refreshed and refreshed.dokumente: - for doc in refreshed.dokumente: - doc_id = getattr(doc, "id", None) or (doc.get("id") if isinstance(doc, dict) else None) - if doc_id: - gr["dokument_ids"].append(doc_id) - else: - gr["status"] = "skipped" - stats["documents_skipped"] += 1 - except Exception as ex: - gr["status"] = "error" - gr["error"] = str(ex) - stats["errors"].append(f"{gemeinde.label}: {str(ex)}") - results.append(gr) - - return {"success": True, "stats": stats, "results": results} + return await processBzoDocumentsFetch(interface, componentInterface, mandateId, instanceId) @router.get("/{instanceId}/parcel-documents", response_model=Dict[str, Any]) @@ -717,65 +603,7 @@ async def get_parcel_documents( componentInterface = getComponentInterface( context.user, mandateId=mandateId, featureInstanceId=instanceId ) - from modules.features.realEstate.realEstateGemeindeService import ( - ensure_single_gemeinde, - fetch_bzo_for_gemeinde, - ) - gemeinde_obj = None - by_label = interface.getGemeinden(recordFilter={"label": gemeinde, "mandateId": mandateId}) - gemeinde_obj = by_label[0] if by_label else None - if not gemeinde_obj: - # Fallback: match by normalized label (e.g. DB has "Stadt Uster", request has "Uster") - all_g = interface.getGemeinden(recordFilter={"mandateId": mandateId}) - g_norm = gemeinde.strip().lower() - for g in all_g: - gl = (g.label or "").strip().lower() - if gl == g_norm or g_norm in gl or gl in g_norm: - gemeinde_obj = g - logger.debug(f"parcel-documents: Found Gemeinde by label match '{gemeinde}' -> '{g.label}'") - break - if gemeinde_obj: - logger.debug(f"parcel-documents: Gemeinde '{gemeinde}' resolved: {gemeinde_obj.id}") - if not gemeinde_obj: - logger.info(f"parcel-documents: No Gemeinde for label '{gemeinde}', ensuring via Swiss Topo...") - gemeinde_obj = await ensure_single_gemeinde(interface, mandateId, instanceId, gemeinde_name=gemeinde) - if not gemeinde_obj: - logger.warning(f"parcel-documents: Gemeinde '{gemeinde}' nicht gefunden (mandateId={mandateId[:8]}...)") - return {"documents": [], "error": f"Gemeinde '{gemeinde}' nicht gefunden"} - bzo_docs = [] - if gemeinde_obj.dokumente: - for doc in gemeinde_obj.dokumente: - typ = getattr(doc, "dokumentTyp", None) or (doc.get("dokumentTyp") if isinstance(doc, dict) else None) - if typ in [DokumentTyp.GEMEINDE_BZO_AKTUELL, DokumentTyp.GEMEINDE_BZO_REVISION] or str(typ) in ["gemeindeBzoAktuell", "gemeindeBzoRevision"]: - doc_id = doc.id if hasattr(doc, "id") else doc.get("id") - if doc_id: - full = interface.getDokument(doc_id) - if full and full.dokumentReferenz: - bzo_docs.append(full) - if not bzo_docs: - logger.info(f"parcel-documents: No BZO for {gemeinde}, fetching...") - fetched = await fetch_bzo_for_gemeinde(interface, componentInterface, gemeinde_obj, mandateId, instanceId) - if fetched: - gemeinde_obj = interface.getGemeinde(gemeinde_obj.id) - if gemeinde_obj and gemeinde_obj.dokumente: - for doc in gemeinde_obj.dokumente: - typ = getattr(doc, "dokumentTyp", None) or (doc.get("dokumentTyp") if isinstance(doc, dict) else None) - if typ in [DokumentTyp.GEMEINDE_BZO_AKTUELL, DokumentTyp.GEMEINDE_BZO_REVISION]: - doc_id = doc.id if hasattr(doc, "id") else doc.get("id") - if doc_id: - full = interface.getDokument(doc_id) - if full and full.dokumentReferenz: - bzo_docs.append(full) - result = [] - for d in bzo_docs: - result.append({ - "id": d.id, - "label": d.label, - "fileId": d.dokumentReferenz, - "fileName": (d.label or "BZO") + ".pdf", - "mimeType": d.mimeType or "application/pdf", - }) - return {"documents": result, "gemeinde": gemeinde, "bauzone": bauzone} + return await processParcelDocuments(interface, componentInterface, gemeinde, bauzone, mandateId, instanceId) @router.get("/{instanceId}/bzo-information", response_model=Dict[str, Any]) @@ -831,57 +659,13 @@ async def process_command( Uses AI to analyze user intent and extract parameters, then executes the appropriate CRUD operation. Works stateless without session management. - - Example user inputs: - - "Erstelle ein neues Projekt namens 'Hauptstrasse 42'" - - "Zeige mir alle Projekte in Zuerich" - - "Aktualisiere Projekt XYZ mit Status 'Planung'" - - "Loesche Parzelle ABC" - - "SELECT * FROM Projekt WHERE plz = '8000'" - - Headers: - - X-CSRF-Token: CSRF token (required for security) - - Returns: - { - "success": true, - "intent": "CREATE|READ|UPDATE|DELETE|QUERY", - "entity": "Projekt|Parzelle|...|null", - "result": {...} - } """ try: - # Validate CSRF token (middleware also checks, but explicit validation for better error messages) - csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token") - if not csrf_token: - logger.warning(f"CSRF token missing for POST /api/realestate/command from user {context.user.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.") - ) - - # Basic CSRF token format validation - if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: - logger.warning(f"Invalid CSRF token format for POST /api/realestate/command from user {context.user.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("Invalid CSRF token format") - ) - - # Validate token is hex string - try: - int(csrf_token, 16) - except ValueError: - logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/command from user {context.user.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("Invalid CSRF token format") - ) + _validateCsrfToken(request, "POST /api/realestate/command", str(context.user.id)) logger.info(f"Processing command request from user {context.user.id} (mandate: {context.mandateId})") logger.debug(f"User input: {userInput}") - # Process natural language command with AI result = await processNaturalLanguageCommand( currentUser=context.user, mandateId=str(context.mandateId), @@ -911,85 +695,22 @@ def get_available_tables( request: Request, context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: - """ - Get all available real estate tables. - - Returns a list of available table names with their descriptions. - - Headers: - - X-CSRF-Token: CSRF token (required for security) - - Example: - - GET /api/realestate/tables - """ + """Get all available real estate tables.""" try: - # Validate CSRF token if provided - csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token") - if not csrf_token: - logger.warning(f"CSRF token missing for GET /api/realestate/tables from user {context.user.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.") - ) - - # Basic CSRF token format validation - if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: - logger.warning(f"Invalid CSRF token format for GET /api/realestate/tables from user {context.user.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("Invalid CSRF token format") - ) - - # Validate token is hex string - try: - int(csrf_token, 16) - except ValueError: - logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/tables from user {context.user.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("Invalid CSRF token format") - ) + _validateCsrfToken(request, "GET /api/realestate/tables", str(context.user.id)) logger.info(f"Getting available tables for user {context.user.id} (mandate: {context.mandateId})") - # Define available tables with descriptions tables = [ - { - "name": "Projekt", - "description": "Real estate projects", - "model": "Projekt" - }, - { - "name": "Parzelle", - "description": "Plots/parcels", - "model": "Parzelle" - }, - { - "name": "Dokument", - "description": "Documents", - "model": "Dokument" - }, - { - "name": "Gemeinde", - "description": "Municipalities", - "model": "Gemeinde" - }, - { - "name": "Kanton", - "description": "Cantons", - "model": "Kanton" - }, - { - "name": "Land", - "description": "Countries", - "model": "Land" - }, + {"name": "Projekt", "description": "Real estate projects", "model": "Projekt"}, + {"name": "Parzelle", "description": "Plots/parcels", "model": "Parzelle"}, + {"name": "Dokument", "description": "Documents", "model": "Dokument"}, + {"name": "Gemeinde", "description": "Municipalities", "model": "Gemeinde"}, + {"name": "Kanton", "description": "Cantons", "model": "Kanton"}, + {"name": "Land", "description": "Countries", "model": "Land"}, ] - return { - "tables": tables, - "count": len(tables) - } + return {"tables": tables, "count": len(tables)} except HTTPException: raise @@ -1009,142 +730,12 @@ def get_table_data( pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), context: RequestContext = Depends(getRequestContext) ) -> PaginatedResponse[Dict[str, Any]]: - """ - Get all data from a specific real estate table with optional pagination. - - Available tables: - - Projekt: Real estate projects - - Parzelle: Plots/parcels - - Dokument: Documents - - Gemeinde: Municipalities - - Kanton: Cantons - - Land: Countries - - Query Parameters: - - pagination: JSON-encoded PaginationParams object, or None for no pagination - - Headers: - - X-CSRF-Token: CSRF token (required for security) - - Examples: - - GET /api/realestate/table/Projekt (no pagination - returns all items) - - GET /api/realestate/table/Parzelle?pagination={"page":1,"pageSize":10,"sort":[]} - - GET /api/realestate/table/Gemeinde?pagination={"page":2,"pageSize":20,"sort":[{"field":"label","direction":"asc"}]} - """ + """Get all data from a specific real estate table with optional pagination.""" try: - # Validate CSRF token if provided - csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token") - if not csrf_token: - logger.warning(f"CSRF token missing for GET /api/realestate/table/{table} from user {context.user.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.") - ) - - # Basic CSRF token format validation - if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: - logger.warning(f"Invalid CSRF token format for GET /api/realestate/table/{table} from user {context.user.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("Invalid CSRF token format") - ) - - # Validate token is hex string - try: - int(csrf_token, 16) - except ValueError: - logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/table/{table} from user {context.user.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("Invalid CSRF token format") - ) - + _validateCsrfToken(request, f"GET /api/realestate/table/{table}", str(context.user.id)) logger.info(f"Getting table data for '{table}' from user {context.user.id} (mandate: {context.mandateId})") - - # Map table names to model classes and getter methods - table_mapping = { - "Projekt": (Projekt, "getProjekte"), - "Parzelle": (Parzelle, "getParzellen"), - "Dokument": (Dokument, "getDokumente"), - "Gemeinde": (Gemeinde, "getGemeinden"), - "Kanton": (Kanton, "getKantone"), - "Land": (Land, "getLaender"), - } - - # Validate table name - if table not in table_mapping: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid table name '{table}'. Available tables: {', '.join(table_mapping.keys())}" - ) - - # Get interface and fetch data - realEstateInterface = getRealEstateInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None) - model_class, method_name = table_mapping[table] - getter_method = getattr(realEstateInterface, method_name) - - # Fetch all records (no filter for now) - records = getter_method(recordFilter=None) - - # Keep records as model instances (like routeDataFiles does with FileItem) - # FastAPI will automatically serialize Pydantic models to JSON - items = records - - # Parse pagination parameter - paginationParams = None - if pagination: - try: - paginationDict = json.loads(pagination) - paginationParams = PaginationParams(**paginationDict) if paginationDict else None - except (json.JSONDecodeError, ValueError) as e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid pagination parameter: {str(e)}" - ) - - # Apply pagination if requested - if paginationParams: - # Apply sorting if specified - if paginationParams.sort: - for sort_field in reversed(paginationParams.sort): # Reverse to apply in priority order - field_name = sort_field.field - direction = sort_field.direction.lower() - - def sort_key(item): - # Access attribute from model instance - value = getattr(item, field_name, None) - # Handle None values - put them at the end for asc, at the start for desc - if value is None: - return (1, None) # Use tuple to ensure None values sort consistently - return (0, value) - - items.sort(key=sort_key, reverse=(direction == "desc")) - - # Apply pagination - total_items = len(items) - total_pages = (total_items + paginationParams.pageSize - 1) // paginationParams.pageSize # Ceiling division - start_idx = (paginationParams.page - 1) * paginationParams.pageSize - end_idx = start_idx + paginationParams.pageSize - paginated_items = items[start_idx:end_idx] - - return PaginatedResponse( - items=paginated_items, - pagination=PaginationMetadata( - currentPage=paginationParams.page, - pageSize=paginationParams.pageSize, - totalItems=total_items, - totalPages=total_pages, - sort=paginationParams.sort, - filters=paginationParams.filters - ) - ) - else: - # No pagination - return all items (as model instances, like routeDataFiles) - return PaginatedResponse( - items=items, - pagination=None - ) - + mandateId = str(context.mandateId) if context.mandateId else None + return processTableData(context.user, mandateId, table, pagination) except HTTPException: raise except Exception as e: @@ -1163,186 +754,12 @@ async def create_table_record( data: Dict[str, Any] = Body(..., description="Record data to create"), context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: - """ - Create a new record in a specific real estate table. - - Available tables: - - Projekt: Real estate projects (with parcel data support) - - Parzelle: Plots/parcels - - Dokument: Documents - - Gemeinde: Municipalities - - Kanton: Cantons - - Land: Countries - - Request Body: - For Projekt: - { - "label": "Projekt Bezeichnung", - "statusProzess": "Eingang", // Optional - "parzelle": { - "id": "OE5913", - "egrid": "CH252699779137", - "perimeter": {...}, - "geometry": {...}, // Used for baulinie - ... - } - } - - For other tables: - - JSON object with fields matching the table's data model - - Headers: - - X-CSRF-Token: CSRF token (required for security) - - Examples: - - POST /api/realestate/table/Projekt - Body: {"label": "Hauptstrasse 42", "parzelle": {...}} - - POST /api/realestate/table/Parzelle - Body: {"label": "Parzelle 1", "strasseNr": "Hauptstrasse 42", "plz": "8000", "bauzone": "W3"} - """ + """Create a new record in a specific real estate table.""" try: - # Validate CSRF token - csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token") - if not csrf_token: - logger.warning(f"CSRF token missing for POST /api/realestate/table/{table} from user {context.user.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.") - ) - - # Basic CSRF token format validation - if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: - logger.warning(f"Invalid CSRF token format for POST /api/realestate/table/{table} from user {context.user.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("Invalid CSRF token format") - ) - - # Validate token is hex string - try: - int(csrf_token, 16) - except ValueError: - logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/table/{table} from user {context.user.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("Invalid CSRF token format") - ) - - # Special handling for Projekt with parcel data - if table == "Projekt" and ("parzelle" in data or "parzellen" in data): - logger.info(f"Creating Projekt with parcel data for user {context.user.id} (mandate: {context.mandateId})") - - # Extract fields - label = data.get("label") - if not label: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=routeApiMsg("label is required") - ) - - status_prozess = data.get("statusProzess", "Eingang") - - # Support both single parzelle and multiple parzellen - parzellen_data = [] - if "parzellen" in data: - # Multiple parcels - parzellen_data = data.get("parzellen", []) - if not isinstance(parzellen_data, list): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=routeApiMsg("parzellen must be an array") - ) - elif "parzelle" in data: - # Single parcel - parzelle_data = data.get("parzelle") - if parzelle_data: - parzellen_data = [parzelle_data] - - if not parzellen_data: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=routeApiMsg("parzelle or parzellen data is required") - ) - - # Use helper function to create project with parcel data - try: - result = await create_project_with_parcel_data( - currentUser=context.user, - mandateId=str(context.mandateId), - projekt_label=label, - parzellen_data=parzellen_data, - status_prozess=status_prozess, - ) - - # Return in format expected by frontend (single record, not nested) - return result.get("projekt", {}) - except HTTPException: - # Re-raise HTTPExceptions directly - raise - except Exception as e: - logger.error(f"Error creating Projekt with parcel data: {str(e)}", exc_info=True) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error creating Projekt: {str(e)}" - ) - - # Standard handling for other tables or Projekt without parcel data + _validateCsrfToken(request, f"POST /api/realestate/table/{table}", str(context.user.id)) logger.info(f"Creating record in table '{table}' for user {context.user.id} (mandate: {context.mandateId})") - logger.debug(f"Record data: {data}") - - # Map table names to model classes and create methods - table_mapping = { - "Projekt": (Projekt, "createProjekt"), - "Parzelle": (Parzelle, "createParzelle"), - "Dokument": (Dokument, "createDokument"), - "Gemeinde": (Gemeinde, "createGemeinde"), - "Kanton": (Kanton, "createKanton"), - "Land": (Land, "createLand"), - } - - # Validate table name - if table not in table_mapping: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid table name '{table}'. Available tables: {', '.join(table_mapping.keys())}" - ) - - # Get interface - realEstateInterface = getRealEstateInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None) - model_class, method_name = table_mapping[table] - create_method = getattr(realEstateInterface, method_name) - - # Ensure mandateId is set from context - if "mandateId" not in data: - data["mandateId"] = str(context.mandateId) if context.mandateId else None - - # Create model instance from data - try: - model_instance = model_class(**data) - except Exception as e: - logger.error(f"Error creating {table} model instance: {str(e)}", exc_info=True) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid data for {table}: {str(e)}" - ) - - # Create record - try: - created_record = create_method(model_instance) - - # Convert to dictionary for response - if hasattr(created_record, 'model_dump'): - return created_record.model_dump() - else: - return created_record - - except Exception as e: - logger.error(f"Error creating {table} record: {str(e)}", exc_info=True) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error creating {table} record: {str(e)}" - ) - + mandateId = str(context.mandateId) if context.mandateId else None + return await processCreateTableRecord(context.user, mandateId, table, data) except HTTPException: raise except Exception as e: @@ -1389,380 +806,13 @@ async def search_parcel( ) -> Dict[str, Any]: """ Search for parcel information by address or coordinates. - - Returns comprehensive parcel information including: - - Parcel identification (number, EGRID, etc.) - - Precise boundary geometry for map display - - Administrative context (canton, municipality) - - Bauzone (zone code from ÖREB WFS when include_bauzone=True) - - Link to official cadastral map - - Optional: Adjacent parcels - - Query Parameters: - - location: Either coordinates as "x,y" (LV95/EPSG:2056) or address string - - include_adjacent: If true, fetches information about adjacent parcels (slower) - - include_bauzone: If true, queries ÖREB WFS for zone info (Bauzone/Wohnzone) - - Headers: - - X-CSRF-Token: CSRF token (required for security) + Returns comprehensive parcel information including geometry, administrative context, and bauzone. """ try: - # Validate CSRF token - csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token") - if not csrf_token: - logger.warning(f"CSRF token missing for GET /api/realestate/parcel/search from user {context.user.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.") - ) - + _validateCsrfToken(request, "GET /api/realestate/parcel/search", str(context.user.id)) logger.info(f"Searching parcel for user {context.user.id} (mandate: {context.mandateId}) with location: {location}") - - # Initialize connector - connector = SwissTopoMapServerConnector() - - # Search for parcel - parcel_data = await connector.search_parcel(location) - - if not parcel_data: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"No parcel found for location: {location}" - ) - - # Extract and normalize attributes - extracted_attributes = connector.extract_parcel_attributes(parcel_data) - attributes = parcel_data.get("attributes", {}) - geometry = parcel_data.get("geometry", {}) - - # Calculate parcel area from perimeter - area_m2 = None - centroid = None - if extracted_attributes.get("perimeter"): - perimeter = extracted_attributes["perimeter"] - points = perimeter.get("punkte", []) - - # Calculate area using shoelace formula - if len(points) >= 3: - area = 0 - for i in range(len(points)): - j = (i + 1) % len(points) - area += points[i]["x"] * points[j]["y"] - area -= points[j]["x"] * points[i]["y"] - area_m2 = abs(area / 2) - - # Calculate centroid - sum_x = sum(p["x"] for p in points) - sum_y = sum(p["y"] for p in points) - centroid = { - "x": sum_x / len(points), - "y": sum_y / len(points) - } - - # Extract canton early (needed for bauzone query and municipality resolution) - canton = attributes.get("ak", "") - - # Extract municipality name and address from Swiss Topo data - municipality_name = None - full_address = None - plz = None - - # First, try to use geocoded address info if available (more accurate than centroid query) - geocoded_address = parcel_data.get('geocoded_address') - if geocoded_address: - full_address = geocoded_address.get('full_address') - plz = geocoded_address.get('plz') - municipality_name = geocoded_address.get('municipality') - logger.debug(f"Using geocoded address: {full_address}") - - # If geocoded address not available, try to get address by querying the address layer - # Use query coordinates (where user clicked/geocoded) instead of parcel centroid - # This ensures we get the address at the exact location, not at the parcel center - query_coords = parcel_data.get('query_coordinates') - address_query_coords = query_coords if query_coords else centroid - - if not full_address and address_query_coords: - query_x = address_query_coords['x'] - query_y = address_query_coords['y'] - logger.debug(f"Querying address layer at query coordinates: ({query_x}, {query_y})") - - # Check if this was a coordinate search (not geocoded address) - is_coordinate_search = ',' in location and not any(c.isalpha() for c in location.split(',')[0]) - - # Use connector's helper method to query building layer - # Use tolerance=1 (minimum) for coordinate searches to get exact building - building_tolerance = 1 if is_coordinate_search else 10 - building_result = await connector._query_building_layer(query_x, query_y, tolerance=building_tolerance, buffer=25) - - if building_result: - addr_attrs = building_result.get("attributes", {}) - logger.debug(f"Address layer attributes: {addr_attrs}") - - # Extract address using connector's helper method - address_info = connector._extract_address_from_building_attrs(addr_attrs) - full_address = address_info.get('full_address') - plz = address_info.get('plz') - municipality_name = address_info.get('municipality') - - if full_address: - logger.debug(f"Constructed address: {full_address}") - - # If address not found via building layer, try to construct from available data - if not full_address: - # Check if location was provided as an address string - if location and any(c.isalpha() for c in location) and "CH" not in location: - # Location looks like an address (not an EGRID) - full_address = location - logger.debug(f"Using location as address: {full_address}") - - # Try to extract municipality name from address string (e.g. "Forchstrasse 6c, 8610 Uster") - if not municipality_name and full_address: - plz_municipality_match = re.search(r"\b(\d{4})\s+([A-ZÄÖÜ][a-zäöüß\s-]+)", full_address) - if plz_municipality_match: - extracted_municipality = plz_municipality_match.group(2).strip() - extracted_municipality = re.sub(r"[,;\.]+$", "", extracted_municipality).strip() - if extracted_municipality: - municipality_name = extracted_municipality - if not plz: - plz = plz_municipality_match.group(1) - logger.debug(f"Extracted municipality from address: {municipality_name}") - - # Try to extract municipality name from BFSNR if not found - bfsnr = attributes.get("bfsnr") - if not municipality_name and bfsnr and canton and context.mandateId: - try: - interface = getRealEstateInterface( - context.user, mandateId=str(context.mandateId), featureInstanceId=None - ) - gemeinden = interface.getGemeinden(recordFilter={"mandateId": str(context.mandateId)}) - for g in gemeinden: - for k in (g.kontextInformationen or []): - try: - data = json.loads(k.inhalt) if isinstance(k.inhalt, str) else k.inhalt - if isinstance(data, dict): - bfs = data.get("bfs_nummer") or data.get("bfsnr") or data.get("municipality_code") - if str(bfs) == str(bfsnr): - municipality_name = g.label - logger.debug(f"Found Gemeinde by BFS {bfsnr} in DB: {municipality_name}") - break - except (json.JSONDecodeError, AttributeError): - continue - if municipality_name: - break - except Exception as e: - logger.debug(f"Error querying Gemeinde by BFS: {e}") - - # Swiss Topo geocoding to get municipality from coordinates - if not municipality_name and centroid and canton: - try: - geocode_url = "https://api3.geo.admin.ch/rest/services/api/MapServer/identify" - params = { - "geometry": f"{centroid['x']},{centroid['y']}", - "geometryType": "esriGeometryPoint", - "layers": "all:ch.swisstopo.swissboundaries3d-gemeinde-flaeche.fill", - "tolerance": "0", - "returnGeometry": "false", - "sr": "2056", - "f": "json", - } - async with aiohttp.ClientSession() as session: - async with session.get(geocode_url, params=params) as resp: - if resp.status == 200: - data = await resp.json() - results = data.get("results", []) - if results: - attrs = results[0].get("attributes", {}) - geo_name = attrs.get("name") or attrs.get("gemeindename") or attrs.get("label") - if geo_name: - municipality_name = connector._clean_municipality_name(str(geo_name)) - logger.debug(f"Found municipality via Swiss Topo geocoding: {municipality_name}") - except Exception as e: - logger.debug(f"Error querying Swiss Topo geocoding: {e}") - - # Expanded common municipalities fallback - if not municipality_name and bfsnr: - common_municipalities = { - 261: "Zürich", 198: "Pfäffikon", 191: "Uster", 3203: "Winterthur", - 351: "Bern", 2701: "Basel", 6621: "Genève", 5586: "Lausanne", - 1061: "Luzern", 230: "St. Gallen", 5192: "Lugano", 1367: "Schwyz", - } - if bfsnr in common_municipalities: - municipality_name = common_municipalities[bfsnr] - logger.debug(f"Looked up municipality from common list: {municipality_name}") - elif canton and bfsnr: - municipality_name = f"{canton}-{bfsnr}" - logger.debug(f"Using fallback municipality: {municipality_name}") - - # Final validation: Don't use EGRID as address - if full_address and full_address.startswith("CH") and len(full_address) == 14 and full_address[2:].isdigit(): - # This is an EGRID, not an address - full_address = None - logger.debug("Removed EGRID from address field") - - # Query Bauzone (wohnzone) from ÖREB WFS when requested - bauzone = None - has_geometry = geometry and (geometry.get("rings") or geometry.get("coordinates")) - if include_bauzone and canton and has_geometry and centroid: - try: - logger.debug(f"Querying zone information for parcel {attributes.get('label')} in canton {canton}") - oereb_connector = OerebWfsConnector() - zone_results = await oereb_connector.query_zone_layer( - egrid=attributes.get("egris_egrid", "") or "", - x=centroid["x"], - y=centroid["y"], - canton=canton, - geometry=geometry, - ) - if zone_results and len(zone_results) > 0: - zone_attrs = zone_results[0].get("attributes", {}) - typ_gde_abkuerzung = zone_attrs.get("typ_gde_abkuerzung") - if typ_gde_abkuerzung: - bauzone = typ_gde_abkuerzung - logger.debug(f"Found bauzone: {bauzone} for parcel {attributes.get('label')}") - except Exception as e: - logger.warning(f"Error querying zone information: {e}", exc_info=True) - - # Build parcel info - parcel_info = { - "id": attributes.get("label") or attributes.get("number"), - "egrid": attributes.get("egris_egrid"), - "number": attributes.get("number"), - "name": attributes.get("name"), - "identnd": attributes.get("identnd"), - "canton": attributes.get("ak"), - "municipality_code": attributes.get("bfsnr"), - "municipality_name": municipality_name, - "address": full_address, - "plz": plz, - "perimeter": extracted_attributes.get("perimeter"), - "area_m2": area_m2, - "centroid": centroid, - "geoportal_url": attributes.get("geoportal_url"), - "realestate_type": attributes.get("realestate_type"), - "bauzone": bauzone, - } - - # Build map view info - bbox = parcel_data.get("bbox", []) - map_view = { - "center": centroid, - "zoom_bounds": { - "min_x": bbox[0] if len(bbox) >= 4 else None, - "min_y": bbox[1] if len(bbox) >= 4 else None, - "max_x": bbox[2] if len(bbox) >= 4 else None, - "max_y": bbox[3] if len(bbox) >= 4 else None - }, - "geometry_geojson": { - "type": "Feature", - "geometry": { - "type": "Polygon", - "coordinates": [ - [[p["x"], p["y"]] for p in extracted_attributes["perimeter"]["punkte"]] - ] if extracted_attributes.get("perimeter") else [] - }, - "properties": { - "id": parcel_info["id"], - "egrid": parcel_info["egrid"], - "number": parcel_info["number"] - } - } - } - - # Build response - response_data = { - "parcel": parcel_info, - "map_view": map_view - } - - # Fetch adjacent parcels if requested - if include_adjacent and parcel_data and parcel_data.get("geometry"): - try: - # Use the connector's method to find neighboring parcels by sampling along the boundary - # This ensures we find all parcels that actually touch the selected parcel - selected_parcel_id = parcel_info["id"] - adjacent_parcels_raw = await connector.find_neighboring_parcels( - parcel_data=parcel_data, - selected_parcel_id=selected_parcel_id, - sample_distance=20.0, # Sample every 20 meters (balanced for coverage and speed) - max_sample_points=30, # Allow up to 30 points to ensure all vertices are covered - max_neighbors=15, # Find up to 15 neighbors - max_concurrent=50 # Process up to 50 queries concurrently (maximum parallelization) - ) - - # Convert adjacent parcels to include GeoJSON geometry (optimized, minimal logging) - def convert_parcel_geometry(adj_parcel: Dict[str, Any]) -> Dict[str, Any]: - """Convert a single adjacent parcel to include GeoJSON geometry.""" - adj_parcel_with_geo = { - "id": adj_parcel["id"], - "egrid": adj_parcel.get("egrid"), - "number": adj_parcel.get("number"), - "perimeter": adj_parcel.get("perimeter") - } - - # Convert geometry to GeoJSON format if available - adj_geometry = adj_parcel.get("geometry") - adj_perimeter = adj_parcel.get("perimeter") - - if adj_geometry: - # Handle ESRI format (rings) - if "rings" in adj_geometry and adj_geometry["rings"]: - ring = adj_geometry["rings"][0] # Outer ring - coordinates = [[[p[0], p[1]] for p in ring]] - adj_parcel_with_geo["geometry_geojson"] = { - "type": "Feature", - "geometry": { - "type": "Polygon", - "coordinates": coordinates - }, - "properties": { - "id": adj_parcel["id"], - "egrid": adj_parcel.get("egrid"), - "number": adj_parcel.get("number") - } - } - # Handle GeoJSON format - elif adj_geometry.get("type") == "Polygon": - adj_parcel_with_geo["geometry_geojson"] = { - "type": "Feature", - "geometry": adj_geometry, - "properties": { - "id": adj_parcel["id"], - "egrid": adj_parcel.get("egrid"), - "number": adj_parcel.get("number") - } - } - - # If no geometry_geojson was created but we have perimeter, create it from perimeter - if "geometry_geojson" not in adj_parcel_with_geo and adj_perimeter and adj_perimeter.get("punkte"): - punkte = adj_perimeter["punkte"] - coordinates = [[[p["x"], p["y"]] for p in punkte]] - adj_parcel_with_geo["geometry_geojson"] = { - "type": "Feature", - "geometry": { - "type": "Polygon", - "coordinates": coordinates - }, - "properties": { - "id": adj_parcel["id"], - "egrid": adj_parcel.get("egrid"), - "number": adj_parcel.get("number") - } - } - - return adj_parcel_with_geo - - # Convert all parcels in parallel (using list comprehension for speed) - adjacent_parcels = [convert_parcel_geometry(adj_parcel) for adj_parcel in adjacent_parcels_raw] - - response_data["adjacent_parcels"] = adjacent_parcels - logger.info(f"Found {len(adjacent_parcels)} neighboring parcels for parcel {selected_parcel_id}") - - except Exception as e: - logger.warning(f"Error fetching adjacent parcels: {e}", exc_info=True) - response_data["adjacent_parcels"] = [] - - return response_data - + mandateId = str(context.mandateId) if context.mandateId else None + return await processParcelSearch(context.user, mandateId, location, include_bauzone, include_adjacent) except HTTPException: raise except Exception as e: @@ -1811,18 +861,6 @@ async def parcel_selection_summary( ) -def _build_geometry_geojson(extracted: Dict[str, Any], parcel_info: Dict[str, Any]) -> Dict[str, Any]: - """Build geometry_geojson from extracted perimeter for add-adjacent response.""" - coords = [] - if extracted.get("perimeter", {}).get("punkte"): - coords = [[[p["x"], p["y"]] for p in extracted["perimeter"]["punkte"]]] - return { - "type": "Feature", - "geometry": {"type": "Polygon", "coordinates": coords}, - "properties": {"id": parcel_info["id"], "egrid": parcel_info["egrid"], "number": parcel_info["number"]}, - } - - @router.post("/parcel/add-adjacent", response_model=Dict[str, Any]) @limiter.limit("60/minute") async def add_adjacent_parcel( @@ -1846,89 +884,7 @@ async def add_adjacent_parcel( selected_parcels = body.get("selected_parcels", []) if not location or "x" not in location or "y" not in location: raise HTTPException(status_code=400, detail=routeApiMsg("location with x,y required")) - loc_str = f"{location['x']},{location['y']}" - connector = SwissTopoMapServerConnector() - parcel_data = await connector.search_parcel(loc_str) - if not parcel_data: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=routeApiMsg("No parcel found at this location") - ) - extracted = connector.extract_parcel_attributes(parcel_data) - attributes = parcel_data.get("attributes", {}) - geometry = parcel_data.get("geometry", {}) - area_m2 = None - centroid = None - if extracted.get("perimeter"): - perimeter = extracted["perimeter"] - points = perimeter.get("punkte", []) - if len(points) >= 3: - area = 0 - for i in range(len(points)): - j = (i + 1) % len(points) - area += points[i]["x"] * points[j]["y"] - area -= points[j]["x"] * points[i]["y"] - area_m2 = abs(area / 2) - sum_x = sum(p["x"] for p in points) - sum_y = sum(p["y"] for p in points) - centroid = {"x": sum_x / len(points), "y": sum_y / len(points)} - parcel_info = { - "id": attributes.get("label") or attributes.get("number"), - "egrid": attributes.get("egris_egrid"), - "number": attributes.get("number"), - "name": attributes.get("name"), - "identnd": attributes.get("identnd"), - "canton": attributes.get("ak"), - "municipality_code": attributes.get("bfsnr"), - "municipality_name": None, - "address": None, - "plz": None, - "perimeter": extracted.get("perimeter"), - "area_m2": area_m2, - "centroid": centroid, - "geoportal_url": attributes.get("geoportal_url"), - "realestate_type": attributes.get("realestate_type"), - "bauzone": None, - } - map_view = { - "center": centroid, - "zoom_bounds": parcel_data.get("bbox", []) and { - "min_x": parcel_data["bbox"][0], - "min_y": parcel_data["bbox"][1], - "max_x": parcel_data["bbox"][2], - "max_y": parcel_data["bbox"][3], - } or None, - "geometry_geojson": _build_geometry_geojson(extracted, parcel_info), - } - new_parcel_response = {"parcel": parcel_info, "map_view": map_view} - if not is_parcel_adjacent_to_selection(new_parcel_response, selected_parcels): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=routeApiMsg("Nur angrenzende Parzellen können hinzugefügt werden") - ) - bbox = parcel_data.get("bbox", []) - map_view["zoom_bounds"] = { - "min_x": bbox[0], "min_y": bbox[1], "max_x": bbox[2], "max_y": bbox[3] - } if len(bbox) >= 4 else None - geocoded_address = parcel_data.get("geocoded_address") - if geocoded_address: - parcel_info["municipality_name"] = geocoded_address.get("municipality") - parcel_info["address"] = geocoded_address.get("full_address") - parcel_info["plz"] = geocoded_address.get("plz") - if centroid and attributes.get("ak"): - try: - oereb = OerebWfsConnector() - zone_results = await oereb.query_zone_layer( - egrid=attributes.get("egris_egrid", "") or "", - x=centroid["x"], y=centroid["y"], - canton=attributes.get("ak"), - geometry=geometry, - ) - if zone_results and len(zone_results) > 0: - parcel_info["bauzone"] = zone_results[0].get("attributes", {}).get("typ_gde_abkuerzung") - except Exception as oe: - logger.debug(f"ÖREB zone query failed: {oe}") - return new_parcel_response + return await processAddAdjacentParcel(location, selected_parcels) except HTTPException: raise except Exception as e: @@ -1949,196 +905,13 @@ async def add_parcel_to_project( ) -> Dict[str, Any]: """ Add a parcel to an existing project. - - This endpoint can either: - 1. Link an existing Parzelle to the Projekt - 2. Create a new Parzelle from location data and link it - - Request Body: - Option 1 - Link existing parcel: - { - "parcelId": "existing-parcel-id" - } - - Option 2 - Create new parcel from location: - { - "location": "Hauptstrasse 42, 8000 Zuerich" - } - - Option 3 - Create new parcel with custom data: - { - "parcelData": { - "label": "Parzelle 123", - "strasseNr": "Hauptstrasse 42", - "plz": "8000", - "bauzone": "W3", - ... - } - } - - Headers: - - X-CSRF-Token: CSRF token (required for security) - - Returns: - { - "projekt": {...}, // Updated Projekt - "parzelle": {...} // Parcel that was added - } + Supports linking existing parcel, creating from location, or creating from custom data. """ try: - # Validate CSRF token - csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token") - if not csrf_token: - logger.warning(f"CSRF token missing for POST /api/realestate/projekt/{projekt_id}/add-parcel from user {context.user.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.") - ) - - # Validate CSRF token format - if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("Invalid CSRF token format") - ) - try: - int(csrf_token, 16) - except ValueError: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("Invalid CSRF token format") - ) - + _validateCsrfToken(request, f"POST /api/realestate/projekt/{projekt_id}/add-parcel", str(context.user.id)) logger.info(f"Adding parcel to project {projekt_id} for user {context.user.id} (mandate: {context.mandateId})") - - # Get interface - realEstateInterface = getRealEstateInterface(context.user, mandateId=str(context.mandateId) if context.mandateId else None) - - # Fetch existing Projekt - use mandateId from context - recordFilter = {"id": projekt_id} - if context.mandateId: - recordFilter["mandateId"] = str(context.mandateId) - projekte = realEstateInterface.getProjekte(recordFilter=recordFilter) - if not projekte: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Projekt {projekt_id} not found" - ) - projekt = projekte[0] - - # Determine which option was used - parcel_id = body.get("parcelId") - location = body.get("location") - parcel_data_dict = body.get("parcelData") - - parzelle = None - - # Option 1: Link existing parcel - if parcel_id: - logger.info(f"Linking existing parcel {parcel_id}") - parcelFilter = {"id": parcel_id} - if context.mandateId: - parcelFilter["mandateId"] = str(context.mandateId) - parcels = realEstateInterface.getParzellen(recordFilter=parcelFilter) - if not parcels: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Parzelle {parcel_id} not found" - ) - parzelle = parcels[0] - - # Option 2: Create from location - elif location: - logger.info(f"Creating parcel from location: {location}") - - # Initialize connector and search for parcel - connector = SwissTopoMapServerConnector() - parcel_data = await connector.search_parcel(location) - - if not parcel_data: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"No parcel found at location: {location}" - ) - - # Extract attributes - extracted_attributes = connector.extract_parcel_attributes(parcel_data) - attributes = parcel_data.get("attributes", {}) - - # Create Parzelle with mandateId from context - parzelle_create_data = { - "mandateId": str(context.mandateId) if context.mandateId else None, - "label": extracted_attributes.get("label") or attributes.get("number") or "Unknown", - "parzellenAliasTags": [attributes.get("egris_egrid")] if attributes.get("egris_egrid") else [], - "eigentuemerschaft": None, - "strasseNr": location if not location.replace(",", "").replace(".", "").replace(" ", "").isdigit() else None, - "plz": None, - "perimeter": extracted_attributes.get("perimeter"), - "baulinie": None, - "kontextGemeinde": None, - "bauzone": None, - "az": None, - "bz": None, - "vollgeschossZahl": None, - "anrechenbarDachgeschoss": None, - "anrechenbarUntergeschoss": None, - "gebaeudehoeheMax": None, - "regelnGrenzabstand": [], - "regelnMehrlaengenzuschlag": [], - "regelnMehrhoehenzuschlag": [], - "parzelleBebaut": None, - "parzelleErschlossen": None, - "parzelleHanglage": None, - "laermschutzzone": None, - "hochwasserschutzzone": None, - "grundwasserschutzzone": None, - "parzellenNachbarschaft": [], - "dokumente": [], - "kontextInformationen": [ - Kontext( - thema="Swiss Topo Data", - inhalt=json.dumps({ - "egrid": attributes.get("egris_egrid"), - "identnd": attributes.get("identnd"), - "canton": attributes.get("ak"), - "municipality_code": attributes.get("bfsnr"), - "geoportal_url": attributes.get("geoportal_url") - }, ensure_ascii=False) - ) - ] - } - - parzelle_instance = Parzelle(**parzelle_create_data) - parzelle = realEstateInterface.createParzelle(parzelle_instance) - - # Option 3: Create from custom data - elif parcel_data_dict: - logger.info(f"Creating parcel from custom data") - parcel_data_dict["mandateId"] = str(context.mandateId) if context.mandateId else None - parzelle_instance = Parzelle(**parcel_data_dict) - parzelle = realEstateInterface.createParzelle(parzelle_instance) - - else: - raise ValueError("One of 'parcelId', 'location', or 'parcelData' is required") - - # Add parcel to project - if parzelle not in projekt.parzellen: - projekt.parzellen.append(parzelle) - - # Update projekt perimeter if needed (use first parcel's perimeter) - if not projekt.perimeter and parzelle.perimeter: - projekt.perimeter = parzelle.perimeter - - # Update Projekt - updated_projekt = realEstateInterface.updateProjekt(projekt) - - logger.info(f"Added Parzelle {parzelle.id} to Projekt {projekt_id}") - - return { - "projekt": updated_projekt.model_dump(), - "parzelle": parzelle.model_dump() - } - + mandateId = str(context.mandateId) if context.mandateId else None + return await processAddParcelToProject(context.user, mandateId, projekt_id, body) except ValueError as e: logger.error(f"Validation error in add_parcel_to_project: {str(e)}", exc_info=True) raise HTTPException( diff --git a/modules/features/realEstate/serviceAiIntent.py b/modules/features/realEstate/serviceAiIntent.py new file mode 100644 index 00000000..62efb1a0 --- /dev/null +++ b/modules/features/realEstate/serviceAiIntent.py @@ -0,0 +1,1087 @@ +""" +Real Estate feature — AI-based intent recognition and CRUD operations. + +Handles natural language processing, intent analysis, direct query execution, +and intent-based CRUD operations for the real estate domain. +""" + +import json +import re +import logging +from typing import Optional, Dict, Any, List + +from fastapi import HTTPException, status + +from modules.datamodels.datamodelUam import User +from .datamodelFeatureRealEstate import ( + Projekt, + Parzelle, + StatusProzess, + GeoPolylinie, + GeoPunkt, + Kontext, + Gemeinde, + Kanton, + Land, +) +from modules.serviceCenter.serviceHub import getInterface as getServices +from .interfaceFeatureRealEstate import getInterface as getRealEstateInterface +from .serviceGeometry import fetch_parcel_polygon_from_swisstopo + +logger = logging.getLogger(__name__) + + +async def executeDirectQuery( + currentUser: User, + mandateId: str, + queryText: str, + parameters: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """ + Execute a database query directly without session management. + + Args: + currentUser: Current authenticated user + mandateId: Mandate context (from RequestContext / X-Mandate-Id header) + queryText: SQL query text + parameters: Optional parameters for parameterized queries + + Returns: + Dictionary containing query result (rows, columns, rowCount) + + Note: + - No session or query history is saved + - Query is executed directly and result is returned + - For production, validate and sanitize queries before execution + """ + try: + logger.info(f"Executing direct query for user {currentUser.id} (mandate: {mandateId})") + logger.debug(f"Query text: {queryText}") + if parameters: + logger.debug(f"Query parameters: {parameters}") + + realEstateInterface = getRealEstateInterface(currentUser, mandateId=mandateId) + result = realEstateInterface.executeQuery(queryText, parameters) + + logger.info( + f"Query executed successfully: {result['rowCount']} rows in {result.get('executionTime', 0):.3f}s" + ) + + return { + "status": "success", + "rows": result["rows"], + "columns": result["columns"], + "rowCount": result["rowCount"], + "executionTime": result.get("executionTime", 0), + } + + except Exception as e: + logger.error(f"Error executing query: {str(e)}", exc_info=True) + raise + + +def _formatEntitySummary(entity_type: str, items: List[Dict[str, Any]], filters: Dict[str, Any]) -> str: + """ + Format a human-readable summary of query results. + + Args: + entity_type: Type of entity (Projekt, Parzelle, etc.) + items: List of entity data dictionaries + filters: Filter parameters used in the query + + Returns: + Human-readable summary string + """ + if not items: + return f"Keine {entity_type} gefunden" + + count = len(items) + filter_desc = "" + if filters: + if "kontextGemeinde" in filters: + filter_desc = f" in {filters['kontextGemeinde']}" + elif "plz" in filters: + filter_desc = f" mit PLZ {filters['plz']}" + elif "location_filter" in filters: + filter_desc = f" in {filters['location_filter']}" + + summary = f"Gefunden: {count} {entity_type}{filter_desc}" + + if entity_type == "Parzelle": + summary += "\n\nDetails:" + for i, item in enumerate(items[:10], 1): + parts = [] + + if item.get("label"): + parts.append(f"Parzelle '{item['label']}'") + elif item.get("id"): + parts.append(f"Parzelle {item['id'][:8]}...") + + if item.get("strasseNr"): + parts.append(item["strasseNr"]) + + location_parts = [] + if item.get("plz"): + location_parts.append(item["plz"]) + if item.get("kontextGemeinde"): + location_parts.append(item["kontextGemeinde"]) + if location_parts: + parts.append(" ".join(location_parts)) + + if item.get("bauzone"): + parts.append(f"Bauzone: {item['bauzone']}") + + summary += f"\n{i}. {', '.join(parts)}" + + if count > 10: + summary += f"\n... und {count - 10} weitere" + + elif entity_type == "Projekt": + summary += "\n\nDetails:" + for i, item in enumerate(items[:10], 1): + parts = [] + + if item.get("label"): + parts.append(f"'{item['label']}'") + + if item.get("statusProzess"): + parts.append(f"Status: {item['statusProzess']}") + + parzellen = item.get("parzellen", []) + if parzellen: + parts.append(f"{len(parzellen)} Parzelle(n)") + + summary += f"\n{i}. {' - '.join(parts)}" + + if count > 10: + summary += f"\n... und {count - 10} weitere" + + elif entity_type == "Gemeinde": + summary += "\n\nDetails:" + for i, item in enumerate(items[:10], 1): + parts = [] + + if item.get("label"): + parts.append(item["label"]) + if item.get("plz"): + parts.append(f"PLZ: {item['plz']}") + if item.get("abk"): + parts.append(f"Abk: {item['abk']}") + + summary += f"\n{i}. {', '.join(parts)}" + + if count > 10: + summary += f"\n... und {count - 10} weitere" + + elif entity_type == "Dokument": + summary += "\n\nDetails:" + for i, item in enumerate(items[:10], 1): + parts = [] + + if item.get("label"): + parts.append(item["label"]) + if item.get("dokumentTyp"): + parts.append(f"Typ: {item['dokumentTyp']}") + if item.get("quelle"): + parts.append(f"Quelle: {item['quelle']}") + + summary += f"\n{i}. {', '.join(parts)}" + + if count > 10: + summary += f"\n... und {count - 10} weitere" + + else: + if count <= 5: + summary += "\n\nDetails:" + for i, item in enumerate(items, 1): + label = item.get("label") or item.get("id", "") + if label: + summary += f"\n{i}. {label}" + + return summary + + +async def processNaturalLanguageCommand( + currentUser: User, + mandateId: str, + userInput: str, +) -> Dict[str, Any]: + """ + Process natural language user input and execute corresponding CRUD operations. + + Uses AI to analyze user intent and extract parameters, then executes the appropriate + CRUD operation through the interface. Works stateless without session management. + + Args: + currentUser: Current authenticated user + mandateId: Mandate context (from RequestContext / X-Mandate-Id header) + userInput: Natural language command from user + + Returns: + Dictionary containing operation result and metadata + + Example user inputs: + - "Erstelle ein neues Projekt namens 'Hauptstrasse 42'" + - "Zeige mir alle Projekte in Zürich" + - "Aktualisiere Projekt XYZ mit Status 'Planung'" + - "Lösche Parzelle ABC" + - "SELECT * FROM Projekt WHERE plz = '8000'" + """ + try: + logger.info(f"Processing natural language command for user {currentUser.id} (mandate: {mandateId})") + logger.debug(f"User input: {userInput}") + + services = getServices(currentUser, workflow=None, mandateId=mandateId) + aiService = services.ai + + intentAnalysis = await analyzeUserIntent(aiService, userInput) + + logger.info(f"Intent analysis result: intent={intentAnalysis.get('intent')}, entity={intentAnalysis.get('entity')}") + + result = await executeIntentBasedOperation( + currentUser=currentUser, + mandateId=mandateId, + intent=intentAnalysis["intent"], + entity=intentAnalysis.get("entity"), + parameters=intentAnalysis.get("parameters", {}), + ) + + response = { + "success": True, + "intent": intentAnalysis["intent"], + "entity": intentAnalysis.get("entity"), + "result": result, + } + + if intentAnalysis["intent"] == "CREATE" and isinstance(result, dict): + operation_result = result.get("result") + if isinstance(operation_result, dict): + entity_name = intentAnalysis.get('entity', 'Eintrag') + label = operation_result.get("label", operation_result.get("id", "")) + + msg_parts = [f"✅ {entity_name} '{label}' erfolgreich erstellt"] + + if entity_name == "Parzelle": + if operation_result.get("plz"): + msg_parts.append(f"PLZ: {operation_result['plz']}") + if operation_result.get("kontextGemeinde"): + msg_parts.append(f"Gemeinde: {operation_result['kontextGemeinde']}") + if operation_result.get("bauzone"): + msg_parts.append(f"Bauzone: {operation_result['bauzone']}") + + kontext_items = operation_result.get("kontextInformationen", []) + if kontext_items: + msg_parts.append(f"\n📋 {len(kontext_items)} Kontextinformationen gespeichert:") + for kontext in kontext_items[:5]: + thema = kontext.get("thema", "") + inhalt = kontext.get("inhalt", "") + if thema and inhalt: + msg_parts.append(f" • {thema}: {inhalt}") + if len(kontext_items) > 5: + msg_parts.append(f" • ... und {len(kontext_items) - 5} weitere") + + elif entity_name == "Projekt": + if operation_result.get("statusProzess"): + msg_parts.append(f"Status: {operation_result['statusProzess']}") + parzellen = operation_result.get("parzellen", []) + if parzellen: + msg_parts.append(f"{len(parzellen)} Parzelle(n)") + + response["message"] = "\n".join(msg_parts) + + elif intentAnalysis["intent"] == "READ" and isinstance(result, dict): + operation_result = result.get("result") + if isinstance(operation_result, list): + response["count"] = len(operation_result) + entity_name = intentAnalysis.get('entity', 'Einträge') + + if len(operation_result) == 0: + filter_info = intentAnalysis.get('parameters', {}) + if filter_info: + filter_desc = ", ".join([f"{k}={v}" for k, v in filter_info.items()]) + response["message"] = f"Keine {entity_name} gefunden mit Filter: {filter_desc}. Möglicherweise sind noch keine Daten vorhanden oder der Filter ist zu spezifisch." + else: + response["message"] = f"Keine {entity_name} vorhanden. Erstellen Sie zuerst neue Einträge." + else: + response["message"] = _formatEntitySummary( + entity_name, + operation_result, + intentAnalysis.get('parameters', {}) + ) + elif isinstance(operation_result, dict): + response["count"] = 1 + entity_name = intentAnalysis.get('entity', 'Eintrag') + response["message"] = _formatEntitySummary(entity_name, [operation_result], {}) + + return response + + except Exception as e: + logger.error(f"Error processing natural language command: {str(e)}", exc_info=True) + raise + + +async def analyzeUserIntent( + aiService, + userInput: str +) -> Dict[str, Any]: + """ + Use AI to analyze user input and extract intent, entity, and parameters. + + Args: + aiService: AI service instance + userInput: Natural language user input + + Returns: + Dictionary with 'intent', 'entity', and 'parameters' + """ + intentPrompt = f""" +Analyze the following user command and extract the intent, entity, and parameters. + +User Command: "{userInput}" + +Available intents: +- CREATE: User wants to create a new entity +- READ: User wants to read/query entities +- UPDATE: User wants to update an existing entity +- DELETE: User wants to delete an entity +- QUERY: User wants to execute a database query (SQL statements) + +Available entities and their fields: + +**Projekt** (Real estate project): +- id: string (primary key) +- mandateId: string (mandate ID) +- label: string (project designation/name) +- statusProzess: string enum (Eingang, Analyse, Studie, Planung, Baurechtsverfahren, Umsetzung, Archiv) +- perimeter: GeoPolylinie (geographic boundary, JSONB) +- baulinie: GeoPolylinie (building line, JSONB) +- parzellen: List[Parzelle] (plots belonging to project, JSONB) +- dokumente: List[Dokument] (documents, JSONB) +- kontextInformationen: List[Kontext] (context info, JSONB) + +**Parzelle** (Plot/parcel): +- id: string (primary key) +- mandateId: string (mandate ID) +- label: string (plot designation) +- strasseNr: string (street and house number) +- plz: string (postal code) +- kontextGemeinde: string (municipality ID, Foreign Key to Gemeinde table) +- bauzone: string (building zone, e.g. W3, WG2) +- az: float (Ausnützungsziffer) +- bz: float (Bebauungsziffer) +- vollgeschossZahl: int (number of allowed full floors) +- gebaeudehoeheMax: float (maximum building height in meters) +- laermschutzzone: string (noise protection zone) +- hochwasserschutzzone: string (flood protection zone) +- grundwasserschutzzone: string (groundwater protection zone) +- parzelleBebaut: JaNein enum (is plot built) +- parzelleErschlossen: JaNein enum (is plot developed) +- parzelleHanglage: JaNein enum (is plot on slope) +- kontextInformationen: List[Kontext] (metadata - each item has 'thema' and 'inhalt' fields only) + +**Kontext** (Context information for metadata): +- thema: string (topic/subject, e.g. "EGRID", "Fläche", "Zentrum") +- inhalt: string (content as text, e.g. "CH887199917793", "6514.99 m²", "X: 123, Y: 456") + +**Important relationships:** +- Projekte contain Parzellen (projects have plots) +- Parzelle links to Gemeinde (via kontextGemeinde) +- Gemeinde links to Kanton (via id_kanton) +- Kanton links to Land (via id_land) +- Location queries (city, postal code) should use Parzelle.kontextGemeinde (municipality name will be resolved to ID) +- Projekt does NOT have location fields directly - location is stored in associated Parzellen + +Return a JSON object with the following structure: +{{ + "intent": "CREATE|READ|UPDATE|DELETE|QUERY", + "entity": "Projekt|Parzelle|Dokument|Kanton|Gemeinde|null", + "parameters": {{ + // Extracted parameters from user input + // For CREATE/UPDATE: include all relevant fields using EXACT field names from above + // For READ: include filter criteria using EXACT field names (id, label, plz, kontextGemeinde, etc.) + // For DELETE: include entity ID if mentioned + // For QUERY: include queryText if SQL is detected + // IMPORTANT: Use only field names that exist in the entity definition above + }}, + "confidence": 0.0-1.0 // Confidence score for the analysis +}} + +Examples: +- Input: "Erstelle ein neues Projekt namens 'Hauptstrasse 42'" + Output: {{"intent": "CREATE", "entity": "Projekt", "parameters": {{"label": "Hauptstrasse 42"}}, "confidence": 0.95}} + +- Input: "Erstelle eine Parzelle mit Label 123, PLZ 8000, Gemeinde Zürich, Bauzone W3" + Output: {{"intent": "CREATE", "entity": "Parzelle", "parameters": {{"label": "123", "plz": "8000", "kontextGemeinde": "Zürich", "bauzone": "W3"}}, "confidence": 0.95}} + +- Input: "Parzellen-Informationen: ID:AA1704, Nummer:AA1704, EGRID:CH887199917793, Kanton:ZH, Gemeinde:Zürich, Gemeinde-Code:261, Fläche:6514.99 m², Zentrum:2682951.44,1247622.91" + Output: {{ + "intent": "CREATE", + "entity": "Parzelle", + "parameters": {{ + "label": "AA1704", + "parzellenAliasTags": ["AA1704"], + "kontextGemeinde": "Zürich", + "kontextInformationen": [ + {{"thema": "EGRID", "inhalt": "CH887199917793"}}, + {{"thema": "Kanton", "inhalt": "ZH"}}, + {{"thema": "BFS-Nummer", "inhalt": "261"}}, + {{"thema": "Fläche", "inhalt": "6514.99 m²"}}, + {{"thema": "Zentrum (LV95)", "inhalt": "X: 2682951.44 m, Y: 1247622.91 m (EPSG:2056)"}} + ] + }}, + "confidence": 0.9 + }} + Note: Extract structured data from detailed input. Use kontextInformationen for metadata. Each item has 'thema' (topic) and 'inhalt' (content as text). + +- Input: "Zeige mir alle Projekte" + Output: {{"intent": "READ", "entity": "Projekt", "parameters": {{}}, "confidence": 0.9}} + +- Input: "Zeige mir Projekte in Zürich" or "Wie viele Projekte in Zürich" + Output: {{"intent": "READ", "entity": "Projekt", "parameters": {{"location_filter": "Zürich"}}, "confidence": 0.9}} + Note: For project location queries, use Projekt entity with location_filter parameter + +- Input: "Zeige mir Parzellen mit PLZ 8000" + Output: {{"intent": "READ", "entity": "Parzelle", "parameters": {{"plz": "8000"}}, "confidence": 0.95}} + +- Input: "Aktualisiere Projekt XYZ mit Status 'Planung'" + Output: {{"intent": "UPDATE", "entity": "Projekt", "parameters": {{"id": "XYZ", "statusProzess": "Planung"}}, "confidence": 0.85}} + +- Input: "SELECT * FROM Projekt WHERE label = 'Test'" + Output: {{"intent": "QUERY", "entity": null, "parameters": {{"queryText": "SELECT * FROM Projekt WHERE label = 'Test'", "queryType": "sql"}}, "confidence": 1.0}} + +- Input: "Lösche Parzelle ABC" + Output: {{"intent": "DELETE", "entity": "Parzelle", "parameters": {{"id": "ABC"}}, "confidence": 0.9}} + +IMPORTANT EXTRACTION RULES: +1. For CREATE operations, extract ALL mentioned data fields from the user input +2. Use kontextInformationen array for metadata that doesn't have dedicated fields (EGRID, BFS numbers, area, coordinates, etc.) +3. Each kontextInformationen item MUST have exactly two fields: 'thema' (topic/subject) and 'inhalt' (content as text string) +4. Format kontextInformationen values as readable text strings, including units (e.g., "6514.99 m²", "X: 123, Y: 456") +5. Match field names EXACTLY to the entity definition above +6. Convert data types correctly (strings for text, numbers for numeric values) +7. Extract coordinates, areas, and other numeric values from text +8. When multiple values are mentioned for the same concept (ID, Nummer, Name), use the most relevant one for 'label' and put alternatives in parzellenAliasTags +""" + + try: + response = await aiService.callAiPlanning( + prompt=intentPrompt, + debugType="intentanalysis" + ) + + jsonStart = response.find('{') + jsonEnd = response.rfind('}') + 1 + + if jsonStart == -1 or jsonEnd == 0: + raise ValueError("No JSON found in AI response") + + jsonStr = response[jsonStart:jsonEnd] + + intentData = json.loads(jsonStr) + + if "intent" not in intentData: + raise ValueError("Invalid intent analysis response: missing 'intent' field") + + if "parameters" not in intentData: + intentData["parameters"] = {} + + logger.debug(f"Parsed intent analysis: {intentData}") + + return intentData + + except json.JSONDecodeError as e: + logger.error(f"Failed to parse AI intent analysis response: {e}") + logger.error(f"Raw response: {response}") + raise ValueError(f"AI returned invalid JSON: {str(e)}") + except Exception as e: + logger.error(f"Error analyzing user intent: {str(e)}", exc_info=True) + raise + + +async def executeIntentBasedOperation( + currentUser: User, + mandateId: str, + intent: str, + entity: Optional[str], + parameters: Dict[str, Any], +) -> Dict[str, Any]: + """ + Execute CRUD operation based on analyzed intent. + + Args: + currentUser: Current authenticated user + mandateId: Mandate context (from RequestContext / X-Mandate-Id header) + intent: Intent from AI analysis (CREATE, READ, UPDATE, DELETE, QUERY) + entity: Entity type from AI analysis + parameters: Extracted parameters from AI analysis + + Returns: + Operation result + + Note: + - Supports CREATE, READ, UPDATE, DELETE, QUERY intents + - Entity types: Projekt, Parzelle, Gemeinde, Kanton, Land, Dokument + """ + try: + logger.info(f"Executing intent-based operation: intent={intent}, entity={entity}") + logger.debug(f"Parameters: {parameters}") + + if intent == "QUERY": + queryText = parameters.get("queryText", "") + + if not queryText: + raise ValueError("QUERY intent requires queryText in parameters") + + result = await executeDirectQuery( + currentUser=currentUser, + mandateId=mandateId, + queryText=queryText, + parameters=parameters.get("queryParameters"), + ) + return result + + elif intent == "CREATE": + realEstateInterface = getRealEstateInterface(currentUser, mandateId=mandateId) + + if entity == "Projekt": + projekt = Projekt( + mandateId=mandateId, + label=parameters.get("label", ""), + statusProzess=StatusProzess(parameters.get("statusProzess", "EINGANG")) if parameters.get("statusProzess") else None, + ) + created = realEstateInterface.createProjekt(projekt) + return { + "operation": "CREATE", + "entity": "Projekt", + "result": created.model_dump() + } + + elif entity == "Parzelle": + from modules.features.realestate.datamodelFeatureRealEstate import Kontext, GeoPolylinie + + parzelle_data = { + "mandateId": mandateId, + "label": parameters.get("label", ""), + } + + optional_fields = [ + "parzellenAliasTags", "eigentuemerschaft", "strasseNr", "plz", + "bauzone", "az", "bz", "vollgeschossZahl", "anrechenbarDachgeschoss", + "anrechenbarUntergeschoss", "gebaeudehoeheMax", "kontextGemeinde", + "regelnGrenzabstand", "regelnMehrlaengenzuschlag", "regelnMehrhoehenzuschlag", + "parzelleBebaut", "parzelleErschlossen", "parzelleHanglage", + "laermschutzzone", "hochwasserschutzzone", "grundwasserschutzzone" + ] + + for field in optional_fields: + if field in parameters and parameters[field] is not None: + parzelle_data[field] = parameters[field] + + if "perimeter" in parameters and parameters["perimeter"]: + parzelle_data["perimeter"] = GeoPolylinie(**parameters["perimeter"]) + elif "kontextGemeinde" in parameters and parameters.get("kontextGemeinde"): + gemeinde = parameters.get("kontextGemeinde") + parzellen_nr = parameters.get("label") or parameters.get("parzellen_nr") or parameters.get("parzellennummer") + + if gemeinde and parzellen_nr: + logger.info(f"Attempting to fetch polygon from Swisstopo for {gemeinde} {parzellen_nr}") + try: + gemeinde_name = gemeinde + if len(gemeinde) == 36: # UUID format + gemeinde_obj = realEstateInterface.getGemeinde(gemeinde) + if gemeinde_obj: + gemeinde_name = gemeinde_obj.label + + polygon_data = await fetch_parcel_polygon_from_swisstopo( + gemeinde=gemeinde_name, + parzellen_nr=str(parzellen_nr), + sr=2056 + ) + + if polygon_data: + parzelle_data["perimeter"] = GeoPolylinie(**polygon_data) + logger.info(f"Successfully fetched and set perimeter from Swisstopo") + else: + logger.warning(f"Could not fetch polygon from Swisstopo for {gemeinde_name} {parzellen_nr}") + except Exception as e: + logger.warning(f"Error fetching polygon from Swisstopo (continuing without): {e}") + + if "baulinie" in parameters and parameters["baulinie"]: + parzelle_data["baulinie"] = GeoPolylinie(**parameters["baulinie"]) + + if "kontextInformationen" in parameters and parameters["kontextInformationen"]: + kontext_list = [] + for kontext_data in parameters["kontextInformationen"]: + if isinstance(kontext_data, dict): + kontext_obj = Kontext( + thema=kontext_data.get("thema", ""), + inhalt=kontext_data.get("inhalt", "") + ) + kontext_list.append(kontext_obj) + else: + kontext_list.append(kontext_data) + parzelle_data["kontextInformationen"] = kontext_list + + parzelle = Parzelle(**parzelle_data) + created = realEstateInterface.createParzelle(parzelle) + + logger.info(f"Created Parzelle '{created.label}' with {len(created.kontextInformationen)} context items") + + return { + "operation": "CREATE", + "entity": "Parzelle", + "result": created.model_dump() + } + elif entity == "Gemeinde": + from modules.features.realestate.datamodelFeatureRealEstate import Gemeinde + gemeinde = Gemeinde( + mandateId=mandateId, + label=parameters.get("label", ""), + id_kanton=parameters.get("id_kanton"), + plz=parameters.get("plz"), + ) + created = realEstateInterface.createGemeinde(gemeinde) + return { + "operation": "CREATE", + "entity": "Gemeinde", + "result": created.model_dump() + } + elif entity == "Kanton": + from modules.features.realestate.datamodelFeatureRealEstate import Kanton + kanton = Kanton( + mandateId=mandateId, + label=parameters.get("label", ""), + id_land=parameters.get("id_land"), + abk=parameters.get("abk"), + ) + created = realEstateInterface.createKanton(kanton) + return { + "operation": "CREATE", + "entity": "Kanton", + "result": created.model_dump() + } + elif entity == "Land": + from modules.features.realestate.datamodelFeatureRealEstate import Land + land = Land( + mandateId=mandateId, + label=parameters.get("label", ""), + abk=parameters.get("abk"), + ) + created = realEstateInterface.createLand(land) + return { + "operation": "CREATE", + "entity": "Land", + "result": created.model_dump() + } + elif entity == "Dokument": + from modules.features.realestate.datamodelFeatureRealEstate import Dokument + dokument = Dokument( + mandateId=mandateId, + label=parameters.get("label", ""), + dokumentReferenz=parameters.get("dokumentReferenz", ""), + versionsbezeichnung=parameters.get("versionsbezeichnung"), + dokumentTyp=parameters.get("dokumentTyp"), + quelle=parameters.get("quelle"), + mimeType=parameters.get("mimeType"), + ) + created = realEstateInterface.createDokument(dokument) + return { + "operation": "CREATE", + "entity": "Dokument", + "result": created.model_dump() + } + else: + raise ValueError(f"CREATE operation not supported for entity: {entity}") + + elif intent == "READ": + realEstateInterface = getRealEstateInterface(currentUser) + + if entity == "Projekt": + projektId = parameters.get("id") + if projektId: + projekt = realEstateInterface.getProjekt(projektId) + if not projekt: + raise ValueError(f"Projekt {projektId} not found") + return { + "operation": "READ", + "entity": "Projekt", + "result": projekt.model_dump() + } + else: + validProjektFields = {"id", "mandateId", "label", "statusProzess"} + recordFilter = { + k: v for k, v in parameters.items() + if k != "id" and k in validProjektFields + } + + location_filter = parameters.get("location_filter") + + projekte = realEstateInterface.getProjekte(recordFilter=recordFilter if recordFilter else None) + + if location_filter: + logger.info(f"Filtering projects by location: {location_filter}") + + location_id = None + try: + uuid_pattern = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.IGNORECASE) + if not uuid_pattern.match(location_filter): + gemeinde_records = realEstateInterface.getGemeinden(recordFilter={"label": location_filter}) + if gemeinde_records: + location_id = gemeinde_records[0].id + logger.debug(f"Resolved location '{location_filter}' to ID '{location_id}'") + except Exception as e: + logger.debug(f"Could not resolve location filter: {e}") + + filtered_projekte = [] + + for projekt in projekte: + for parzelle in projekt.parzellen: + location_lower = location_filter.lower() + matches = False + + if parzelle.kontextGemeinde: + if (parzelle.kontextGemeinde == location_id or + parzelle.kontextGemeinde == location_filter or + location_lower in parzelle.kontextGemeinde.lower()): + matches = True + + if not matches and ( + (parzelle.plz and location_lower in parzelle.plz) or + (parzelle.strasseNr and location_lower in parzelle.strasseNr.lower()) + ): + matches = True + + if matches: + filtered_projekte.append(projekt) + break + + projekte = filtered_projekte + logger.info(f"Found {len(projekte)} projects in location '{location_filter}'") + + return { + "operation": "READ", + "entity": "Projekt", + "result": [p.model_dump() for p in projekte], + "count": len(projekte) + } + elif entity == "Parzelle": + parzelleId = parameters.get("id") + if parzelleId: + parzelle = realEstateInterface.getParzelle(parzelleId) + if not parzelle: + raise ValueError(f"Parzelle {parzelleId} not found") + return { + "operation": "READ", + "entity": "Parzelle", + "result": parzelle.model_dump() + } + else: + validParzelleFields = { + "id", "mandateId", "label", "strasseNr", "plz", + "kontextGemeinde", + "bauzone", "az", "bz", "vollgeschossZahl", "gebaeudehoeheMax", + "laermschutzzone", "hochwasserschutzzone", "grundwasserschutzzone", + "parzelleBebaut", "parzelleErschlossen", "parzelleHanglage" + } + recordFilter = { + k: v for k, v in parameters.items() + if k != "id" and k in validParzelleFields + } + invalidFields = {k: v for k, v in parameters.items() if k not in validParzelleFields and k != "id"} + if invalidFields: + logger.warning(f"Invalid filter fields for Parzelle ignored: {list(invalidFields.keys())}") + + parzellen = realEstateInterface.getParzellen(recordFilter=recordFilter if recordFilter else None) + + if not parzellen and recordFilter: + logger.info(f"No Parzellen found matching filter: {recordFilter}") + all_parzellen = realEstateInterface.getParzellen(recordFilter=None) + logger.info(f"Total Parzellen in database: {len(all_parzellen)}") + if all_parzellen: + sample_gemeinden = set() + for p in all_parzellen[:10]: + if p.kontextGemeinde: + sample_gemeinden.add(p.kontextGemeinde) + logger.info(f"Sample kontextGemeinde values in database: {sample_gemeinden}") + + return { + "operation": "READ", + "entity": "Parzelle", + "result": [p.model_dump() for p in parzellen], + "count": len(parzellen) + } + elif entity == "Gemeinde": + from modules.features.realestate.datamodelFeatureRealEstate import Gemeinde + gemeindeId = parameters.get("id") + if gemeindeId: + gemeinde = realEstateInterface.getGemeinde(gemeindeId) + if not gemeinde: + raise ValueError(f"Gemeinde {gemeindeId} not found") + return { + "operation": "READ", + "entity": "Gemeinde", + "result": gemeinde.model_dump() + } + else: + recordFilter = {k: v for k, v in parameters.items() if k != "id"} + gemeinden = realEstateInterface.getGemeinden(recordFilter=recordFilter if recordFilter else None) + return { + "operation": "READ", + "entity": "Gemeinde", + "result": [g.model_dump() for g in gemeinden], + "count": len(gemeinden) + } + elif entity == "Kanton": + from modules.features.realestate.datamodelFeatureRealEstate import Kanton + kantonId = parameters.get("id") + if kantonId: + kanton = realEstateInterface.getKanton(kantonId) + if not kanton: + raise ValueError(f"Kanton {kantonId} not found") + return { + "operation": "READ", + "entity": "Kanton", + "result": kanton.model_dump() + } + else: + recordFilter = {k: v for k, v in parameters.items() if k != "id"} + kantone = realEstateInterface.getKantone(recordFilter=recordFilter if recordFilter else None) + return { + "operation": "READ", + "entity": "Kanton", + "result": [k.model_dump() for k in kantone], + "count": len(kantone) + } + elif entity == "Land": + from modules.features.realestate.datamodelFeatureRealEstate import Land + landId = parameters.get("id") + if landId: + land = realEstateInterface.getLand(landId) + if not land: + raise ValueError(f"Land {landId} not found") + return { + "operation": "READ", + "entity": "Land", + "result": land.model_dump() + } + else: + recordFilter = {k: v for k, v in parameters.items() if k != "id"} + laender = realEstateInterface.getLaender(recordFilter=recordFilter if recordFilter else None) + return { + "operation": "READ", + "entity": "Land", + "result": [l.model_dump() for l in laender], + "count": len(laender) + } + elif entity == "Dokument": + from modules.features.realestate.datamodelFeatureRealEstate import Dokument + dokumentId = parameters.get("id") + if dokumentId: + dokument = realEstateInterface.getDokument(dokumentId) + if not dokument: + raise ValueError(f"Dokument {dokumentId} not found") + return { + "operation": "READ", + "entity": "Dokument", + "result": dokument.model_dump() + } + else: + recordFilter = {k: v for k, v in parameters.items() if k != "id"} + dokumente = realEstateInterface.getDokumente(recordFilter=recordFilter if recordFilter else None) + return { + "operation": "READ", + "entity": "Dokument", + "result": [d.model_dump() for d in dokumente], + "count": len(dokumente) + } + else: + raise ValueError(f"READ operation not supported for entity: {entity}") + + elif intent == "UPDATE": + realEstateInterface = getRealEstateInterface(currentUser) + + if entity == "Projekt": + projektId = parameters.get("id") + if not projektId: + raise ValueError("UPDATE operation requires entity ID") + + projekt = realEstateInterface.getProjekt(projektId) + if not projekt: + raise ValueError(f"Projekt {projektId} not found") + + updateData = {k: v for k, v in parameters.items() if k != "id"} + updated = realEstateInterface.updateProjekt(projektId, updateData) + return { + "operation": "UPDATE", + "entity": "Projekt", + "result": updated.model_dump() + } + elif entity == "Parzelle": + parzelleId = parameters.get("id") + if not parzelleId: + raise ValueError("UPDATE operation requires entity ID") + + parzelle = realEstateInterface.getParzelle(parzelleId) + if not parzelle: + raise ValueError(f"Parzelle {parzelleId} not found") + + updateData = {k: v for k, v in parameters.items() if k != "id"} + updated = realEstateInterface.updateParzelle(parzelleId, updateData) + return { + "operation": "UPDATE", + "entity": "Parzelle", + "result": updated.model_dump() + } + elif entity == "Gemeinde": + from modules.features.realestate.datamodelFeatureRealEstate import Gemeinde + gemeindeId = parameters.get("id") + if not gemeindeId: + raise ValueError("UPDATE operation requires entity ID") + + gemeinde = realEstateInterface.getGemeinde(gemeindeId) + if not gemeinde: + raise ValueError(f"Gemeinde {gemeindeId} not found") + + updateData = {k: v for k, v in parameters.items() if k != "id"} + updated = realEstateInterface.updateGemeinde(gemeindeId, updateData) + return { + "operation": "UPDATE", + "entity": "Gemeinde", + "result": updated.model_dump() + } + elif entity == "Kanton": + from modules.features.realestate.datamodelFeatureRealEstate import Kanton + kantonId = parameters.get("id") + if not kantonId: + raise ValueError("UPDATE operation requires entity ID") + + kanton = realEstateInterface.getKanton(kantonId) + if not kanton: + raise ValueError(f"Kanton {kantonId} not found") + + updateData = {k: v for k, v in parameters.items() if k != "id"} + updated = realEstateInterface.updateKanton(kantonId, updateData) + return { + "operation": "UPDATE", + "entity": "Kanton", + "result": updated.model_dump() + } + elif entity == "Land": + from modules.features.realestate.datamodelFeatureRealEstate import Land + landId = parameters.get("id") + if not landId: + raise ValueError("UPDATE operation requires entity ID") + + land = realEstateInterface.getLand(landId) + if not land: + raise ValueError(f"Land {landId} not found") + + updateData = {k: v for k, v in parameters.items() if k != "id"} + updated = realEstateInterface.updateLand(landId, updateData) + return { + "operation": "UPDATE", + "entity": "Land", + "result": updated.model_dump() + } + elif entity == "Dokument": + from modules.features.realestate.datamodelFeatureRealEstate import Dokument + dokumentId = parameters.get("id") + if not dokumentId: + raise ValueError("UPDATE operation requires entity ID") + + dokument = realEstateInterface.getDokument(dokumentId) + if not dokument: + raise ValueError(f"Dokument {dokumentId} not found") + + updateData = {k: v for k, v in parameters.items() if k != "id"} + updated = realEstateInterface.updateDokument(dokumentId, updateData) + return { + "operation": "UPDATE", + "entity": "Dokument", + "result": updated.model_dump() + } + else: + raise ValueError(f"UPDATE operation not supported for entity: {entity}") + + elif intent == "DELETE": + realEstateInterface = getRealEstateInterface(currentUser) + + if entity == "Projekt": + projektId = parameters.get("id") + if not projektId: + raise ValueError("DELETE operation requires entity ID") + + success = realEstateInterface.deleteProjekt(projektId) + return { + "operation": "DELETE", + "entity": "Projekt", + "success": success + } + elif entity == "Parzelle": + parzelleId = parameters.get("id") + if not parzelleId: + raise ValueError("DELETE operation requires entity ID") + + success = realEstateInterface.deleteParzelle(parzelleId) + return { + "operation": "DELETE", + "entity": "Parzelle", + "success": success + } + elif entity == "Gemeinde": + from modules.features.realestate.datamodelFeatureRealEstate import Gemeinde + gemeindeId = parameters.get("id") + if not gemeindeId: + raise ValueError("DELETE operation requires entity ID") + + success = realEstateInterface.deleteGemeinde(gemeindeId) + return { + "operation": "DELETE", + "entity": "Gemeinde", + "success": success + } + elif entity == "Kanton": + from modules.features.realestate.datamodelFeatureRealEstate import Kanton + kantonId = parameters.get("id") + if not kantonId: + raise ValueError("DELETE operation requires entity ID") + + success = realEstateInterface.deleteKanton(kantonId) + return { + "operation": "DELETE", + "entity": "Kanton", + "success": success + } + elif entity == "Land": + from modules.features.realestate.datamodelFeatureRealEstate import Land + landId = parameters.get("id") + if not landId: + raise ValueError("DELETE operation requires entity ID") + + success = realEstateInterface.deleteLand(landId) + return { + "operation": "DELETE", + "entity": "Land", + "success": success + } + elif entity == "Dokument": + from modules.features.realestate.datamodelFeatureRealEstate import Dokument + dokumentId = parameters.get("id") + if not dokumentId: + raise ValueError("DELETE operation requires entity ID") + + success = realEstateInterface.deleteDokument(dokumentId) + return { + "operation": "DELETE", + "entity": "Dokument", + "success": success + } + else: + raise ValueError(f"DELETE operation not supported for entity: {entity}") + + else: + raise ValueError(f"Unknown intent: {intent}") + + except Exception as e: + logger.error(f"Error executing intent-based operation: {str(e)}", exc_info=True) + raise diff --git a/modules/features/realEstate/serviceBzo.py b/modules/features/realEstate/serviceBzo.py new file mode 100644 index 00000000..c7510fb3 --- /dev/null +++ b/modules/features/realEstate/serviceBzo.py @@ -0,0 +1,725 @@ +""" +Real Estate feature — BZO (Bau- und Zonenordnung) information extraction. + +Handles extraction of BZO information from PDF documents, filtering rules/zones/articles +by Bauzone, and generating AI summaries for building zone regulations. +""" + +import logging +from typing import Optional, Dict, Any, List + +from fastapi import HTTPException, status + +from modules.datamodels.datamodelUam import User +from .datamodelFeatureRealEstate import DokumentTyp +from modules.serviceCenter.serviceHub import getInterface as getServices +from .interfaceFeatureRealEstate import getInterface as getRealEstateInterface +from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface +from modules.features.realEstate.bzoDocumentRetriever import BZODocumentRetriever +from modules.features.realEstate.bzoExtraction import run_extraction, run_bzo_params_extraction +from modules.features.realEstate.parcelSelectionService import compute_selection_summary +from modules.features.realEstate.realEstateGemeindeService import ( + ensure_single_gemeinde, + fetch_bzo_for_gemeinde, +) + +logger = logging.getLogger(__name__) + + +async def extract_bzo_information( + currentUser: User, + gemeinde: str, + bauzone: str, + mandateId: Optional[str] = None, + featureInstanceId: Optional[str] = None, + total_area_m2: Optional[float] = None, + parcels: Optional[List[Dict[str, Any]]] = None, +) -> Dict[str, Any]: + """ + Extract BZO information from PDF documents for a specific Bauzone in a Gemeinde. + + Retrieves BZO documents for the specified Gemeinde, extracts content using + the BZO extraction pipeline, filters by Bauzone, and uses AI to find relevant information. + When total_area_m2 or parcels are provided, runs Machbarkeitsstudie for structured output. + + Args: + currentUser: Current authenticated user + gemeinde: Gemeinde name (e.g., "Zürich") or ID + bauzone: Bauzone code (e.g., "W3", "W2/30") + mandateId: Optional mandate ID for instance-scoped data (defaults to currentUser.mandateId) + featureInstanceId: Optional feature instance ID for instance-scoped data + total_area_m2: Optional total parcel area (m²) for Machbarkeitsstudie + parcels: Optional list of parcel dicts; total area computed via compute_selection_summary if not total_area_m2 + + Returns: + Dictionary containing: + - bauzone, gemeinde, extracted_content, ai_summary, relevant_rules, documents_processed + - machbarkeitsstudie: Structured Machbarkeitsstudie output when total_area_m2/parcels provided + """ + try: + _mandateId = mandateId or (str(currentUser.mandateId) if currentUser.mandateId else None) + logger.info(f"Extracting BZO information for Gemeinde '{gemeinde}', Bauzone '{bauzone}' (user: {currentUser.id}, mandate: {_mandateId})") + + realEstateInterface = getRealEstateInterface( + currentUser, mandateId=_mandateId, featureInstanceId=featureInstanceId + ) + componentInterface = getComponentInterface( + currentUser, mandateId=_mandateId, featureInstanceId=featureInstanceId + ) + + logger.debug(f"Attempting to retrieve Gemeinde '{gemeinde}' for mandate {_mandateId}") + gemeinde_obj = realEstateInterface.getGemeinde(gemeinde) + + if not gemeinde_obj: + logger.debug(f"Gemeinde not found by ID, trying to search by label: {gemeinde}") + record_filter = {"label": gemeinde} + if _mandateId: + record_filter["mandateId"] = _mandateId + gemeinden_by_label = realEstateInterface.getGemeinden( + recordFilter=record_filter + ) + if gemeinden_by_label and len(gemeinden_by_label) > 0: + gemeinde_obj = gemeinden_by_label[0] + logger.info(f"Found Gemeinde by label '{gemeinde}' with ID: {gemeinde_obj.id}") + + if not gemeinde_obj and _mandateId and featureInstanceId: + logger.info(f"Gemeinde '{gemeinde}' not in DB - fetching from Swiss Topo (this Gemeinde only)") + gemeinde_obj = await ensure_single_gemeinde( + realEstateInterface, _mandateId, featureInstanceId, gemeinde_name=gemeinde + ) + + if not gemeinde_obj: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Gemeinde '{gemeinde}' not found or not accessible" + ) + + gemeinde_id = gemeinde_obj.id + + bzo_documents = [] + if gemeinde_obj.dokumente: + for doc in gemeinde_obj.dokumente: + if isinstance(doc, dict): + doc_id = doc.get("id") + doc_typ = doc.get("dokumentTyp") + else: + doc_id = doc.id if hasattr(doc, "id") else None + doc_typ = doc.dokumentTyp if hasattr(doc, "dokumentTyp") else None + + if doc_typ: + if isinstance(doc_typ, DokumentTyp): + is_bzo = doc_typ in [DokumentTyp.GEMEINDE_BZO_AKTUELL, DokumentTyp.GEMEINDE_BZO_REVISION] + elif isinstance(doc_typ, str): + is_bzo = doc_typ in ["gemeindeBzoAktuell", "gemeindeBzoRevision", "GEMEINDE_BZO_AKTUELL", "GEMEINDE_BZO_REVISION"] + else: + doc_typ_str = str(doc_typ) + is_bzo = doc_typ_str in ["gemeindeBzoAktuell", "gemeindeBzoRevision", "GEMEINDE_BZO_AKTUELL", "GEMEINDE_BZO_REVISION"] + + if is_bzo: + if doc_id: + full_doc = realEstateInterface.getDokument(doc_id) + if full_doc: + bzo_documents.append(full_doc) + else: + logger.warning(f"Document {doc_id} referenced in Gemeinde but not found in database") + + if not bzo_documents and _mandateId and featureInstanceId: + logger.info(f"No BZO documents for Gemeinde '{gemeinde_obj.label}' - fetching from web") + fetched = await fetch_bzo_for_gemeinde( + realEstateInterface, componentInterface, gemeinde_obj, _mandateId, featureInstanceId + ) + if fetched: + gemeinde_obj = realEstateInterface.getGemeinde(gemeinde_obj.id) + bzo_documents = [] + if gemeinde_obj and gemeinde_obj.dokumente: + for doc in gemeinde_obj.dokumente: + if isinstance(doc, dict): + doc_id = doc.get("id") + doc_typ = doc.get("dokumentTyp") + else: + doc_id = doc.id if hasattr(doc, "id") else None + doc_typ = doc.dokumentTyp if hasattr(doc, "dokumentTyp") else None + if doc_typ: + if isinstance(doc_typ, DokumentTyp): + is_bzo = doc_typ in [DokumentTyp.GEMEINDE_BZO_AKTUELL, DokumentTyp.GEMEINDE_BZO_REVISION] + elif isinstance(doc_typ, str): + is_bzo = doc_typ in ["gemeindeBzoAktuell", "gemeindeBzoRevision", "GEMEINDE_BZO_AKTUELL", "GEMEINDE_BZO_REVISION"] + else: + is_bzo = str(doc_typ) in ["gemeindeBzoAktuell", "gemeindeBzoRevision", "GEMEINDE_BZO_AKTUELL", "GEMEINDE_BZO_REVISION"] + if is_bzo and doc_id: + full_doc = realEstateInterface.getDokument(doc_id) + if full_doc: + bzo_documents.append(full_doc) + + if not bzo_documents: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"No BZO documents found for Gemeinde '{gemeinde_obj.label}'" + ) + + logger.info(f"Found {len(bzo_documents)} BZO document(s) for Gemeinde '{gemeinde_obj.label}'") + + document_retriever = BZODocumentRetriever(realEstateInterface, componentInterface) + + all_extracted_content = { + "articles": [], + "zones": [], + "rules": [], + "zone_parameter_tables": [], + "errors": [], + "warnings": [] + } + documents_processed = [] + + for dokument in bzo_documents: + try: + logger.info(f"Processing document {dokument.id}: {dokument.label}") + + pdf_bytes = document_retriever.retrieve_pdf_content(dokument) + if not pdf_bytes: + logger.warning(f"Could not retrieve PDF content for dokument {dokument.id}") + all_extracted_content["warnings"].append( + f"Could not retrieve PDF content for document '{dokument.label}'" + ) + continue + + extraction_result = run_extraction( + pdf_bytes=pdf_bytes, + pdf_id=dokument.dokumentReferenz or f"dok_{dokument.id}", + dokument_id=dokument.id + ) + + all_extracted_content["articles"].extend(extraction_result.get("articles", [])) + all_extracted_content["zones"].extend(extraction_result.get("zones", [])) + all_extracted_content["rules"].extend(extraction_result.get("rules", [])) + all_extracted_content["zone_parameter_tables"].extend(extraction_result.get("zone_parameter_tables", [])) + all_extracted_content["errors"].extend(extraction_result.get("errors", [])) + all_extracted_content["warnings"].extend(extraction_result.get("warnings", [])) + + documents_processed.append({ + "id": dokument.id, + "label": dokument.label, + "dokumentTyp": dokument.dokumentTyp.value if dokument.dokumentTyp else None + }) + + except Exception as e: + logger.error(f"Error processing document {dokument.id}: {str(e)}", exc_info=True) + all_extracted_content["errors"].append( + f"Error processing document '{dokument.label}': {str(e)}" + ) + continue + + relevant_rules = filter_rules_by_bauzone( + all_extracted_content["rules"], + bauzone + ) + logger.info(f"Extracting for Bauzone {bauzone}: {len(relevant_rules)} zone-specific rules, " + f"{len([t for t in all_extracted_content.get('zone_parameter_tables', []) if bauzone.upper() in str(t.get('zones', [])).upper()])} tables with zone data") + + relevant_zones = filter_zones_by_bauzone( + all_extracted_content["zones"], + bauzone + ) + + relevant_articles = filter_articles_by_bauzone( + all_extracted_content.get("articles", []), + bauzone + ) + + _total_area_m2 = total_area_m2 + if _total_area_m2 is None and parcels: + selection_summary = compute_selection_summary(parcels) + _total_area_m2 = selection_summary.get("total_area_m2") or 0.0 + + bzo_params_result = None + try: + services = getServices( + currentUser, workflow=None, mandateId=_mandateId, featureInstanceId=featureInstanceId + ) + ai_service = services.ai + bzo_params_result = await run_bzo_params_extraction( + extracted_content=all_extracted_content, + bauzone=bauzone, + ai_service=ai_service, + gemeinde=gemeinde_obj.label, + relevant_rules=relevant_rules, + relevant_articles=relevant_articles, + total_area_m2=_total_area_m2, + ) + except Exception as me: + logger.warning(f"BZO parameter extraction failed: {me}", exc_info=True) + all_extracted_content["warnings"] = all_extracted_content.get("warnings", []) + [ + f"BZO-Parameter konnten nicht extrahiert werden: {str(me)}" + ] + + ai_summary = await generate_bauzone_ai_summary( + currentUser=currentUser, + bauzone=bauzone, + gemeinde=gemeinde_obj.label, + extracted_content=all_extracted_content, + relevant_rules=relevant_rules, + relevant_zones=relevant_zones, + mandateId=_mandateId, + featureInstanceId=featureInstanceId, + ) + + unified_summary = ai_summary + + summary_lower = unified_summary.lower() + + zones_mentioned = any(zone.get("zone_code", "").upper() in summary_lower for zone in relevant_zones) + if not zones_mentioned and relevant_zones: + unified_summary += "\n\n=== ZONENDEFINITIONEN ===\n" + for zone in relevant_zones: + zone_code = zone.get("zone_code", "") + zone_name = zone.get("zone_name", "") + zone_category = zone.get("zone_category", "") + geschosszahl = zone.get("geschosszahl") + gewerbeerleichterung = zone.get("gewerbeerleichterung", False) + page_num = zone.get("page", 0) + source_article = zone.get("source_article", "") + + zone_info = f"{zone_code}: {zone_name}" + if zone_category: + zone_info += f"\nKategorie: {zone_category}" + if geschosszahl: + zone_info += f"\nGeschosszahl: {geschosszahl}" + if gewerbeerleichterung: + zone_info += "\nGewerbeerleichterung: Ja" + if source_article: + zone_info += f"\nQuelle: {source_article} (Seite {page_num})" + unified_summary += zone_info + "\n\n" + + articles_mentioned = any(article.get("article_label", "") in summary_lower for article in relevant_articles) + if not articles_mentioned and relevant_articles: + unified_summary += "\n\n=== RELEVANTE ARTIKEL ===\n" + for article in relevant_articles: + article_label = article.get("article_label", "") + article_title = article.get("article_title", "") + article_text = article.get("text", "") + page_start = article.get("page_start", 0) + page_end = article.get("page_end", 0) + page_range = f"Seite {page_start}" if page_start == page_end else f"Seiten {page_start}-{page_end}" + + unified_summary += f"{article_label}" + if article_title: + unified_summary += f": {article_title}" + unified_summary += f" ({page_range})\n" + if article_text: + preview = article_text[:500] + "..." if len(article_text) > 500 else article_text + unified_summary += f"{preview}\n\n" + + return { + "bauzone": bauzone, + "gemeinde": { + "id": gemeinde_obj.id, + "label": gemeinde_obj.label, + "plz": gemeinde_obj.plz + }, + "extracted_content": { + "zones": relevant_zones, + "rules": relevant_rules, + "articles": relevant_articles, + "zone_parameter_tables": _filter_tables_by_bauzone( + all_extracted_content.get("zone_parameter_tables", []), + bauzone + ), + "total_zones": len(all_extracted_content.get("zones", [])), + "total_rules": len(all_extracted_content.get("rules", [])), + "total_articles": len(all_extracted_content.get("articles", [])), + "total_tables": len(all_extracted_content.get("zone_parameter_tables", [])) + }, + "ai_summary": unified_summary, + "relevant_rules": relevant_rules, + "documents_processed": documents_processed, + "errors": all_extracted_content.get("errors", []), + "warnings": all_extracted_content.get("warnings", []), + "machbarkeitsstudie": bzo_params_result, + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error extracting BZO information for Gemeinde '{gemeinde}', Bauzone '{bauzone}': {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error extracting BZO information: {str(e)}" + ) + + +def filter_rules_by_bauzone(rules: List[Dict[str, Any]], bauzone: str) -> List[Dict[str, Any]]: + """ + Filter rules by Bauzone code. Only keeps rules from SINGLE-zone articles to avoid + wrong values (e.g. article with W2,W3,W5 has different values per zone - we cannot + associate a rule value with a specific zone from article text alone). + """ + relevant_rules = [] + bauzone_upper = bauzone.upper() + + def _zone_matches(z: str) -> bool: + zu = (z or "").upper().strip() + if not zu: + return False + if bauzone_upper in zu: + return True + if zu in bauzone_upper and len(zu) >= 2: + return True + return False + + for rule in rules: + table_zones = rule.get("table_zones", []) or [] + zone_raw = rule.get("zone_raw") + + has_zone = bool(zone_raw) or bool(table_zones) + if not has_zone: + continue + + if len(table_zones) > 1: + matches_all = all(_zone_matches(str(z)) for z in table_zones) + if not matches_all: + continue + + matches = False + if zone_raw and _zone_matches(zone_raw): + matches = True + if not matches and table_zones: + for tz in table_zones: + if _zone_matches(str(tz)): + matches = True + break + if not matches: + ts = (rule.get("text_snippet") or "").upper() + if bauzone_upper in ts and len(table_zones) <= 1: + matches = True + + if matches: + relevant_rules.append(rule) + + logger.info(f"Filtered {len(relevant_rules)} rules for Bauzone {bauzone} from {len(rules)} total (multi-zone articles excluded)") + return relevant_rules + + +def filter_zones_by_bauzone(zones: List[Dict[str, Any]], bauzone: str) -> List[Dict[str, Any]]: + """ + Filter zones by Bauzone code. + + Args: + zones: List of zone dictionaries from extraction + bauzone: Bauzone code to filter by + + Returns: + Filtered list of zones that match the Bauzone + """ + relevant_zones = [] + bauzone_upper = bauzone.upper() + + for zone in zones: + zone_code = zone.get("zone_code", "") + if bauzone_upper in zone_code.upper(): + relevant_zones.append(zone) + + logger.info(f"Filtered {len(relevant_zones)} zones for Bauzone {bauzone} from {len(zones)} total zones") + return relevant_zones + + +def filter_articles_by_bauzone(articles: List[Dict[str, Any]], bauzone: str) -> List[Dict[str, Any]]: + """ + Filter articles that mention the Bauzone. + + Args: + articles: List of article dictionaries from extraction + bauzone: Bauzone code to filter by + + Returns: + Filtered list of articles that mention the Bauzone + """ + relevant_articles = [] + bauzone_upper = bauzone.upper() + + for article in articles: + text = article.get("text", "") + zone_raw = article.get("zone_raw") + + text_matches = bauzone_upper in text.upper() if text else False + zone_matches = bauzone_upper in zone_raw.upper() if zone_raw else False + + if text_matches or zone_matches: + relevant_articles.append(article) + + logger.info(f"Filtered {len(relevant_articles)} articles for Bauzone {bauzone} from {len(articles)} total articles") + return relevant_articles + + +def _filter_tables_by_bauzone(tables: List[Dict[str, Any]], bauzone: str) -> List[Dict[str, Any]]: + """ + Filter zone-parameter tables to include only those containing the specified Bauzone. + + Args: + tables: List of zone-parameter table dictionaries + bauzone: Bauzone code to filter by + + Returns: + Filtered list of tables containing the Bauzone + """ + relevant_tables = [] + bauzone_upper = bauzone.upper() + + for table in tables: + zones = table.get("zones", []) + matching_zones = [z for z in zones if bauzone_upper in str(z).upper()] + + if matching_zones: + filtered_table = { + "page": table.get("page"), + "zones": matching_zones, + "parameters": [] + } + + for param in table.get("parameters", []): + values_by_zone = param.get("values_by_zone", {}) + filtered_values = { + zone: values_by_zone[zone] + for zone in matching_zones + if zone in values_by_zone + } + + if filtered_values: + filtered_table["parameters"].append({ + "parameter": param.get("parameter"), + "values_by_zone": filtered_values + }) + + if filtered_table["parameters"]: + relevant_tables.append(filtered_table) + + logger.info(f"Filtered {len(relevant_tables)} tables for Bauzone {bauzone} from {len(tables)} total tables") + return relevant_tables + + +async def generate_bauzone_ai_summary( + currentUser: User, + bauzone: str, + gemeinde: str, + extracted_content: Dict[str, Any], + relevant_rules: List[Dict[str, Any]], + relevant_zones: List[Dict[str, Any]], + mandateId: Optional[str] = None, + featureInstanceId: Optional[str] = None, +) -> str: + """ + Use AI to generate a summary of relevant information for a Bauzone. + + Args: + currentUser: Current authenticated user + bauzone: Bauzone code + gemeinde: Gemeinde name + extracted_content: All extracted content from PDFs + relevant_rules: Rules filtered by Bauzone + relevant_zones: Zones filtered by Bauzone + + Returns: + AI-generated summary string + """ + try: + services = getServices( + currentUser, workflow=None, mandateId=mandateId, featureInstanceId=featureInstanceId + ) + aiService = services.ai + + context_parts = [] + + zone_parameter_tables = extracted_content.get("zone_parameter_tables", []) + table_values_for_bauzone = [] + + if zone_parameter_tables: + context_parts.append("=== BUILDING REGULATIONS TABLE VALUES FOR BAUZONE (INCLUDE THESE EXACT VALUES IN YOUR SUMMARY) ===") + for table in zone_parameter_tables: + page_num = table.get("page", 0) + article_ref = table.get("article", "Unknown article") + zones_in_table = table.get("zones", []) + + matching_zones = [z for z in zones_in_table if bauzone.upper() in str(z).upper()] + + if matching_zones: + context_parts.append(f"\nTabelle aus {article_ref} (Seite {page_num}):") + + for param in table.get("parameters", []): + param_name = param.get("parameter", "") + values_by_zone = param.get("values_by_zone", {}) + + for zone, values in values_by_zone.items(): + if bauzone.upper() in zone.upper(): + if isinstance(values, list) and len(values) > 0: + val_entry = values[0] + value = val_entry.get("value", "") + unit = val_entry.get("unit", "") + unit_str = f" {unit}" if unit else "" + + formatted_param = param_name + if "Ausnützungsziffer" in param_name or "ausnützungsziffer" in param_name.lower(): + formatted_param = "Ausnützungsziffer max." + elif "Vollgeschosse" in param_name or "vollgeschosse" in param_name.lower(): + formatted_param = "Vollgeschosse max." + elif "Gebäudelänge" in param_name or "gebäudelänge" in param_name.lower(): + formatted_param = "Gebäudelänge max." + elif ("Grenzabstand" in param_name or "grenzabstand" in param_name.lower()) and ("Grundabstand" in param_name or "grundabstand" in param_name.lower()): + formatted_param = "Grenzabstand - Grundabstand min." + elif ("Grenzabstand" in param_name or "grenzabstand" in param_name.lower()) and ("Mehrlängen" in param_name or "mehrlängen" in param_name.lower()): + formatted_param = "Grenzabstand - Mehrlängen-zuschlag" + elif ("Grenzabstand" in param_name or "grenzabstand" in param_name.lower()) and ("Höchstmass" in param_name or "höchstmass" in param_name.lower() or "Höchstmaß" in param_name): + formatted_param = "Grenzabstand - Höchstmass max." + elif "Fassadenhöhen" in param_name or "fassadenhöhen" in param_name.lower(): + formatted_param = "Fassadenhöhen max." + elif "Dachgeschosse" in param_name or "dachgeschosse" in param_name.lower(): + formatted_param = "anrechenbare Dachgeschosse max." + elif "Attikageschoss" in param_name or "attikageschoss" in param_name.lower(): + formatted_param = "anrechenbares Attikageschoss max." + elif "Untergeschoss" in param_name or "untergeschoss" in param_name.lower(): + formatted_param = "anrechenbares Untergeschoss max." + + table_values_for_bauzone.append({ + "parameter": formatted_param, + "value": value, + "unit": unit_str, + "article": article_ref, + "page": page_num + }) + context_parts.append(f" • {formatted_param}: {value}{unit_str} (Quelle: {article_ref}, Seite {page_num})") + + if len(values) > 1: + for idx, val_entry in enumerate(values[1:], 1): + value_extra = val_entry.get("value", "") + unit_extra = val_entry.get("unit", "") + unit_str_extra = f" {unit_extra}" if unit_extra else "" + context_parts.append(f" (Alternative: {value_extra}{unit_str_extra})") + + if relevant_zones: + context_parts.append("\n=== ZONE DEFINITIONS ===") + for zone in relevant_zones: + zone_code = zone.get("zone_code", "") + zone_name = zone.get("zone_name", "") + zone_category = zone.get("zone_category", "") + geschosszahl = zone.get("geschosszahl") + gewerbeerleichterung = zone.get("gewerbeerleichterung", False) + page_num = zone.get("page", 0) + source_article = zone.get("source_article", "") + + zone_info = f"- {zone_code}: {zone_name}" + if zone_category: + zone_info += f" (Kategorie: {zone_category})" + if geschosszahl: + zone_info += f", Geschosszahl: {geschosszahl}" + if gewerbeerleichterung: + zone_info += ", Gewerbeerleichterung: Ja" + if source_article: + zone_info += f" - Quelle: {source_article} (Seite {page_num})" + context_parts.append(zone_info) + + relevant_articles = filter_articles_by_bauzone(extracted_content.get("articles", []), bauzone) + if relevant_articles: + context_parts.append("\n=== RELEVANT ARTICLES (full content) ===") + for article in relevant_articles: + article_label = article.get("article_label", "") + article_title = article.get("article_title", "") + article_text = article.get("text", "") + page_start = article.get("page_start", 0) + page_end = article.get("page_end", 0) + page_range = f"Seite {page_start}" if page_start == page_end else f"Seiten {page_start}-{page_end}" + + context_parts.append(f"\n{article_label}: {article_title or 'Kein Titel'}") + context_parts.append(f"Lage: {page_range}") + if len(article_text) > 1000: + context_parts.append(f"Inhalt: {article_text[:1000]}...") + else: + context_parts.append(f"Inhalt: {article_text}") + + if relevant_rules: + table_parameter_names = set() + for table in zone_parameter_tables: + for param in table.get("parameters", []): + param_name = param.get("parameter", "").lower() + table_parameter_names.add(param_name) + + unique_rules = [] + for rule in relevant_rules[:15]: + rule_type = rule.get("rule_type", "").lower() + if not any(tp in rule_type for tp in table_parameter_names): + unique_rules.append(rule) + + if unique_rules: + context_parts.append("\n=== ADDITIONAL BUILDING REGULATIONS (from text) ===") + for rule in unique_rules[:8]: + rule_type = rule.get("rule_type", "") + value_numeric = rule.get("value_numeric") + value_text = rule.get("value_text", "") + unit = rule.get("unit", "") + page_num = rule.get("page", 0) + + rule_desc = f"- {rule_type}: " + if value_numeric is not None: + rule_desc += f"{value_numeric}" + if unit: + rule_desc += f" {unit}" + else: + rule_desc += value_text + rule_desc += f" (Seite {page_num})" + + context_parts.append(rule_desc) + + context = "\n".join(context_parts) + + prompt = f""" +Analyze the following building zone (Bauzone) information extracted from BZO (Bau- und Zonenordnung) documents for {gemeinde}, specifically for Bauzone {bauzone}. + +Extracted Content: +{context} + +CRITICAL INSTRUCTIONS: +1. You MUST include ALL actual values from the tables in your summary - do NOT just say "see tables on page X" +2. List ALL parameters with their actual values: Ausnützungsziffer, Vollgeschosse, Gebäudelänge, Grenzabstand (Grundabstand, Mehrlängen-zuschlag, Höchstmass), Fassadenhöhen, etc. +3. Integrate zone definitions and article information INTO the summary text - do NOT create separate sections +4. Always cite WHERE each piece of information was found (article number and page number) +5. Combine everything into ONE unified, flowing summary - no separate sections for zones/articles +6. Be comprehensive - include all relevant details from zones, articles, and tables +7. Format as a single, well-structured German text document + +Please provide a comprehensive, unified summary that includes: + +1. General description of Bauzone {bauzone}: + - Zone category (Wohnzonen, Zentrumszonen, etc.) + - Geschosszahl (number of full storeys) + - Gewerbeerleichterung status (Ja/Nein) + - Where defined (article and page number) + +2. ALL building regulations with ACTUAL VALUES from tables (you MUST include the exact values): + - Ausnützungsziffer max.: [ACTUAL PERCENTAGE VALUE]% (from article, page) + - Vollgeschosse max.: [ACTUAL NUMBER] (from article, page) + - anrechenbare Dachgeschosse max.: [ACTUAL NUMBER] (from article, page) + - anrechenbares Attikageschoss max.: [ACTUAL NUMBER] (from article, page) + - anrechenbares Untergeschoss max.: [ACTUAL NUMBER] (from article, page) + - Gebäudelänge max.: [ACTUAL VALUE] m (from article, page) + - Grenzabstand - Grundabstand min.: [ACTUAL VALUE] m (from article, page) + - Grenzabstand - Mehrlängen-zuschlag: [ACTUAL FRACTION] (from article, page) + - Grenzabstand - Höchstmass max.: [ACTUAL VALUE] m (from article, page) + - Fassadenhöhen max.: [ACTUAL VALUE] m (from article, page, include footnote values if present) + +3. Zone definitions: Integrate information about where this zone is defined (which articles mention it, with page numbers) + +4. Relevant articles: Integrate key content from relevant articles naturally into the summary, citing article numbers and page numbers + +5. Special conditions: Any special requirements or exceptions mentioned in articles + +CRITICAL: You MUST include the actual numeric values from the tables in your summary. Do NOT say "see tables" - list the actual values. Format everything as ONE unified, flowing German text document without separate sections. Integrate zones and articles naturally into the narrative. +""" + + logger.info(f"Generating AI summary for Bauzone {bauzone} in {gemeinde}") + ai_response = await aiService.callAiPlanning( + prompt=prompt, + debugType="bzo_summary" + ) + + return ai_response.strip() + + except Exception as e: + logger.error(f"Error generating AI summary: {str(e)}", exc_info=True) + return f"Summary generation failed: {str(e)}. Found {len(relevant_rules)} relevant rules and {len(relevant_zones)} zones for Bauzone {bauzone}." diff --git a/modules/features/realEstate/serviceGeometry.py b/modules/features/realEstate/serviceGeometry.py new file mode 100644 index 00000000..c8021701 --- /dev/null +++ b/modules/features/realEstate/serviceGeometry.py @@ -0,0 +1,817 @@ +""" +Real Estate feature — Geometry utilities. + +Handles conversion between GeoPolylinie and Shapely polygons, combining +parcel geometries, filtering neighbor parcels, fetching parcel polygons +from Swisstopo, creating projects with parcel data, and GeoJSON conversion. +""" + +import logging +from typing import Optional, Dict, Any, List + +from shapely.geometry import Polygon +from shapely.ops import unary_union + +from .datamodelFeatureRealEstate import ( + Projekt, + Parzelle, + StatusProzess, + GeoPolylinie, + GeoPunkt, + Kontext, + Gemeinde, + Kanton, + Land, +) +from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerConnector +from .interfaceFeatureRealEstate import getInterface as getRealEstateInterface +from modules.datamodels.datamodelUam import User +from fastapi import HTTPException, status + +logger = logging.getLogger(__name__) + + +def geopolylinie_to_shapely_polygon(geopolylinie: GeoPolylinie) -> Polygon: + """ + Convert GeoPolylinie to Shapely Polygon. + + Args: + geopolylinie: GeoPolylinie instance with punkte list + + Returns: + Shapely Polygon object + """ + if not geopolylinie or not geopolylinie.punkte: + raise ValueError("GeoPolylinie must have at least one point") + + coordinates = [] + for punkt in geopolylinie.punkte: + coordinates.append((punkt.x, punkt.y)) + + if len(coordinates) < 3: + raise ValueError("Polygon must have at least 3 points") + + if coordinates[0] != coordinates[-1]: + coordinates.append(coordinates[0]) + + return Polygon(coordinates) + + +def shapely_polygon_to_geopolylinie(polygon: Polygon) -> GeoPolylinie: + """ + Convert Shapely Polygon to GeoPolylinie. + + Args: + polygon: Shapely Polygon object + + Returns: + GeoPolylinie instance with LV95 coordinate system + """ + if not polygon or polygon.is_empty: + raise ValueError("Polygon must not be empty") + + exterior_coords = list(polygon.exterior.coords) + + if len(exterior_coords) > 1 and exterior_coords[0] == exterior_coords[-1]: + exterior_coords = exterior_coords[:-1] + + punkte = [] + for coord in exterior_coords: + punkt = GeoPunkt( + koordinatensystem="LV95", + x=float(coord[0]), + y=float(coord[1]), + z=None + ) + punkte.append(punkt) + + return GeoPolylinie( + closed=True, + punkte=punkte + ) + + +def combine_parcel_geometries(geometries: List[GeoPolylinie]) -> GeoPolylinie: + """ + Combine multiple parcel geometries into a single outer outline. + + Uses Shapely union operation to merge polygons and automatically + removes internal edges. The result is a clean outer boundary. + + Args: + geometries: List of GeoPolylinie instances to combine + + Returns: + Combined GeoPolylinie representing the outer outline + + Raises: + ValueError: If geometries list is empty or invalid + """ + if not geometries or len(geometries) == 0: + raise ValueError("At least one geometry is required") + + if len(geometries) == 1: + return geometries[0] + + shapely_polygons = [] + for geo in geometries: + try: + polygon = geopolylinie_to_shapely_polygon(geo) + if not polygon.is_empty: + shapely_polygons.append(polygon) + except Exception as e: + logger.warning(f"Error converting geometry to Shapely Polygon: {e}") + continue + + if not shapely_polygons: + raise ValueError("No valid geometries to combine") + + if len(shapely_polygons) == 1: + return shapely_polygon_to_geopolylinie(shapely_polygons[0]) + + try: + combined = unary_union(shapely_polygons) + + if hasattr(combined, 'geoms'): + largest = max(combined.geoms, key=lambda p: p.area) + combined = largest + + if combined.is_empty: + raise ValueError("Union resulted in empty geometry") + + result = shapely_polygon_to_geopolylinie(combined) + logger.info(f"Combined {len(geometries)} geometries into single outline with {len(result.punkte)} points") + return result + + except Exception as e: + logger.error(f"Error combining geometries: {e}", exc_info=True) + raise ValueError(f"Failed to combine geometries: {str(e)}") + + +def filter_neighbor_parcels( + neighbors: List[Dict[str, Any]], + selected_geometries: List[GeoPolylinie] +) -> List[Dict[str, Any]]: + """ + Filter neighbor parcels to exclude those that are part of the selected parcels. + + Uses geometric comparison to check if neighbor parcels intersect or touch + any of the selected parcel geometries. + + Args: + neighbors: List of neighbor parcel dictionaries (must have 'perimeter' or 'geometry_geojson') + selected_geometries: List of GeoPolylinie instances representing selected parcels + + Returns: + Filtered list of neighbor parcels (excluding selected ones) + """ + if not neighbors or not selected_geometries: + return neighbors + + selected_polygons = [] + for geo in selected_geometries: + try: + polygon = geopolylinie_to_shapely_polygon(geo) + if not polygon.is_empty: + selected_polygons.append(polygon) + except Exception as e: + logger.warning(f"Error converting selected geometry for filtering: {e}") + continue + + if not selected_polygons: + return neighbors + + filtered_neighbors = [] + for neighbor in neighbors: + try: + neighbor_geometry = None + + if neighbor.get("perimeter"): + perimeter = neighbor["perimeter"] + if isinstance(perimeter, dict) and perimeter.get("punkte"): + punkte = [] + for p in perimeter["punkte"]: + punkt = GeoPunkt( + koordinatensystem=p.get("koordinatensystem", "LV95"), + x=float(p.get("x", 0)), + y=float(p.get("y", 0)), + z=p.get("z") + ) + punkte.append(punkt) + neighbor_geometry = GeoPolylinie(closed=True, punkte=punkte) + + elif neighbor.get("geometry_geojson"): + geo_json = neighbor["geometry_geojson"] + geometry = geo_json.get("geometry") if isinstance(geo_json, dict) else geo_json + + if geometry and geometry.get("type") == "Polygon": + coordinates = geometry.get("coordinates", []) + if coordinates and len(coordinates) > 0: + ring = coordinates[0] + punkte = [] + for coord in ring: + if len(coord) >= 2: + punkt = GeoPunkt( + koordinatensystem="LV95", + x=float(coord[0]), + y=float(coord[1]), + z=float(coord[2]) if len(coord) > 2 else None + ) + punkte.append(punkt) + neighbor_geometry = GeoPolylinie(closed=True, punkte=punkte) + + if not neighbor_geometry: + filtered_neighbors.append(neighbor) + continue + + neighbor_polygon = geopolylinie_to_shapely_polygon(neighbor_geometry) + + is_selected = False + for selected_polygon in selected_polygons: + if neighbor_polygon.intersects(selected_polygon) or neighbor_polygon.touches(selected_polygon): + area_diff = abs(neighbor_polygon.area - selected_polygon.area) + if area_diff < 1.0: + is_selected = True + break + if neighbor_polygon.contains(selected_polygon) or selected_polygon.contains(neighbor_polygon): + is_selected = True + break + + if not is_selected: + filtered_neighbors.append(neighbor) + else: + logger.debug(f"Filtered out neighbor parcel {neighbor.get('id')} - part of selected parcels") + + except Exception as e: + logger.warning(f"Error filtering neighbor parcel {neighbor.get('id')}: {e}") + filtered_neighbors.append(neighbor) + + logger.info(f"Filtered {len(neighbors)} neighbors to {len(filtered_neighbors)} (removed {len(neighbors) - len(filtered_neighbors)} selected parcels)") + return filtered_neighbors + + +async def fetch_parcel_polygon_from_swisstopo( + gemeinde: str, + parzellen_nr: str, + sr: int = 2056 +) -> Optional[Dict[str, Any]]: + """ + Holt die vollständige Polygon-Geometrie einer Parzelle von Swisstopo API. + + Args: + gemeinde: Name der Gemeinde (z.B. "Bern") + parzellen_nr: Parzellennummer (z.B. "1234") + sr: Koordinatensystem (2056=LV95, 4326=WGS84) + + Returns: + Dictionary mit GeoPolylinie-Format für perimeter-Feld, oder None wenn nicht gefunden + Format: {"closed": True, "punkte": [{"koordinatensystem": "LV95", "x": ..., "y": ..., "z": None}, ...]} + """ + try: + connector = SwissTopoMapServerConnector() + + feature = await connector.get_parcel_polygon(gemeinde, parzellen_nr, sr) + + if not feature: + logger.warning(f"Parzelle {gemeinde} {parzellen_nr} nicht gefunden in Swisstopo") + return None + + geometry = feature.get("geometry", {}) + if geometry.get("type") == "Polygon": + coordinates = geometry.get("coordinates", []) + if coordinates and len(coordinates) > 0: + ring = coordinates[0] + + punkte = [] + for coord in ring: + if len(coord) >= 2: + punkt = { + "koordinatensystem": "LV95" if sr == 2056 else "WGS84", + "x": coord[0], + "y": coord[1], + "z": coord[2] if len(coord) > 2 else None + } + punkte.append(punkt) + + logger.info(f"Successfully fetched polygon with {len(punkte)} points for {gemeinde} {parzellen_nr}") + + return { + "closed": True, + "punkte": punkte + } + + logger.warning(f"Unexpected geometry type in Swisstopo response: {geometry.get('type')}") + return None + + except Exception as e: + logger.error(f"Error fetching parcel polygon from Swisstopo: {e}", exc_info=True) + return None + + +def convert_geojson_to_geopolylinie(geometry_data: Dict[str, Any]) -> Optional[GeoPolylinie]: + """Convert GeoJSON geometry to GeoPolylinie format.""" + if not geometry_data: + return None + + if "geometry" in geometry_data: + geometry_data = geometry_data["geometry"] + + geometry_type = geometry_data.get("type") + coordinates = geometry_data.get("coordinates") + + if not coordinates or geometry_type != "Polygon": + return None + + if not coordinates or len(coordinates) == 0: + return None + + ring = coordinates[0] + + punkte = [] + for coord in ring: + if len(coord) >= 2: + punkt = GeoPunkt( + koordinatensystem="LV95", + x=float(coord[0]), + y=float(coord[1]), + z=float(coord[2]) if len(coord) > 2 else None + ) + punkte.append(punkt) + + if not punkte: + return None + + return GeoPolylinie( + closed=True, + punkte=punkte + ) + + +async def create_project_with_parcel_data( + currentUser: User, + mandateId: str, + projekt_label: str, + parzellen_data: List[Dict[str, Any]], + status_prozess: Optional[str] = None, +) -> Dict[str, Any]: + """ + Create a Projekt with one or more Parzellen from provided parcel data. + + Args: + currentUser: Current authenticated user + mandateId: Mandate context (from RequestContext / X-Mandate-Id header) + projekt_label: Label for the Projekt + parzellen_data: List of dictionaries containing parcel information from request + status_prozess: Optional project status (defaults to "Eingang") + + Returns: + Dictionary containing created Projekt and list of Parzellen + + Raises: + HTTPException: If Gemeinde or Kanton not found, or validation fails + """ + try: + logger.info(f"Creating project '{projekt_label}' with {len(parzellen_data)} parcel(s) for user {currentUser.id}") + + realEstateInterface = getRealEstateInterface(currentUser, mandateId=mandateId) + + if not projekt_label: + raise ValueError("Projekt label is required") + + if not parzellen_data or len(parzellen_data) == 0: + raise ValueError("At least one Parzelle data is required") + + for idx, parzelle_data in enumerate(parzellen_data): + if not parzelle_data.get("perimeter"): + raise ValueError(f"Parzelle {idx + 1} perimeter is required") + + # First pass: Collect all parcel geometries for neighbor filtering + all_parcel_geometries = [] + for parzelle_data in parzellen_data: + perimeter = parzelle_data.get("perimeter") + if perimeter: + if isinstance(perimeter, dict): + if "punkte" in perimeter and "closed" in perimeter: + try: + geo_perimeter = GeoPolylinie(**perimeter) + all_parcel_geometries.append(geo_perimeter) + except Exception as e: + logger.warning(f"Error converting perimeter to GeoPolylinie: {e}") + else: + converted = convert_geojson_to_geopolylinie(perimeter) + if converted: + all_parcel_geometries.append(converted) + elif isinstance(perimeter, GeoPolylinie): + all_parcel_geometries.append(perimeter) + + created_parzellen = [] + parcel_perimeters = [] + + for idx, parzelle_data in enumerate(parzellen_data): + logger.info(f"Processing Parzelle {idx + 1}/{len(parzellen_data)}") + + parcel_label = parzelle_data.get("id") or parzelle_data.get("number") or parzelle_data.get("label") or "Unknown" + + existing_parzellen = realEstateInterface.getParzellen( + recordFilter={"label": parcel_label, "mandateId": mandateId} + ) + + if existing_parzellen and len(existing_parzellen) > 0: + existing_parzelle = existing_parzellen[0] + logger.info(f"Parzelle with label '{parcel_label}' already exists (ID: {existing_parzelle.id}), reusing it") + + if existing_parzelle.perimeter: + parcel_perimeters.append(existing_parzelle.perimeter) + + created_parzellen.append(existing_parzelle) + continue + + logger.info(f"Parzelle with label '{parcel_label}' does not exist, creating new one") + + gemeinde_id = None + canton_abk = parzelle_data.get("canton") + municipality_name = parzelle_data.get("municipality_name") + + logger.debug(f"Resolving Gemeinde/Kanton: canton='{canton_abk}', municipality='{municipality_name}'") + + if municipality_name and canton_abk: + canton_names = { + "ZH": "Zürich", "BE": "Bern", "LU": "Luzern", "UR": "Uri", "SZ": "Schwyz", + "OW": "Obwalden", "NW": "Nidwalden", "GL": "Glarus", "ZG": "Zug", "FR": "Freiburg", + "SO": "Solothurn", "BS": "Basel-Stadt", "BL": "Basel-Landschaft", "SH": "Schaffhausen", + "AR": "Appenzell Ausserrhoden", "AI": "Appenzell Innerrhoden", "SG": "St. Gallen", + "GR": "Graubünden", "AG": "Aargau", "TG": "Thurgau", "TI": "Tessin", + "VD": "Waadt", "VS": "Wallis", "NE": "Neuenburg", "GE": "Genf", "JU": "Jura" + } + + logger.debug("Ensuring Land 'Schweiz' exists") + laender = realEstateInterface.getLaender(recordFilter={"label": "Schweiz"}) + if not laender: + logger.info("Creating Land 'Schweiz'") + land = Land( + mandateId=mandateId, + label="Schweiz", + abk="CH" + ) + land = realEstateInterface.createLand(land) + logger.info(f"Created Land 'Schweiz' with ID: {land.id}") + else: + land = laender[0] + logger.debug(f"Found Land 'Schweiz' with ID: {land.id}") + + logger.debug(f"Looking up Kanton with abk='{canton_abk}'") + kantone = realEstateInterface.getKantone(recordFilter={"abk": canton_abk}) + logger.debug(f"Found {len(kantone)} Kanton(e) with abk='{canton_abk}'") + if not kantone: + logger.info(f"Kanton '{canton_abk}' not found, creating it") + kanton_label = canton_names.get(canton_abk, canton_abk) + kanton = Kanton( + mandateId=mandateId, + label=kanton_label, + abk=canton_abk, + id_land=land.id + ) + kanton = realEstateInterface.createKanton(kanton) + logger.info(f"Created Kanton '{kanton_label}' ({canton_abk}) with ID: {kanton.id}") + else: + kanton = kantone[0] + logger.debug(f"Found Kanton: ID={kanton.id}, Label={kanton.label}, abk={kanton.abk}") + + logger.debug(f"Looking up Gemeinde with label='{municipality_name}' and id_kanton='{kanton.id}'") + gemeinden = realEstateInterface.getGemeinden( + recordFilter={"label": municipality_name, "id_kanton": kanton.id} + ) + logger.debug(f"Found {len(gemeinden)} Gemeinde(n) with label='{municipality_name}' and id_kanton='{kanton.id}'") + if not gemeinden: + logger.info(f"Gemeinde '{municipality_name}' in Kanton '{canton_abk}' not found, creating it") + gemeinde = Gemeinde( + mandateId=mandateId, + label=municipality_name, + id_kanton=kanton.id, + plz=parzelle_data.get("plz") + ) + gemeinde = realEstateInterface.createGemeinde(gemeinde) + logger.info(f"Created Gemeinde '{municipality_name}' with ID: {gemeinde.id}") + else: + gemeinde = gemeinden[0] + logger.debug(f"Found Gemeinde: ID={gemeinde.id}, Label={gemeinde.label}") + + gemeinde_id = gemeinde.id + logger.info(f"Resolved Gemeinde '{municipality_name}' to ID '{gemeinde_id}'") + else: + logger.warning(f"Missing Gemeinde/Kanton data: municipality_name={municipality_name}, canton={canton_abk}") + + alias_tags = [] + if parzelle_data.get("egrid"): + alias_tags.append(parzelle_data["egrid"]) + if parzelle_data.get("number") and parzelle_data["number"] != parzelle_data.get("id"): + alias_tags.append(parzelle_data["number"]) + + strasse_nr = None + plz = None + + address = parzelle_data.get("address") + if address: + parts = address.split(",") + if len(parts) >= 1: + strasse_nr = parts[0].strip() + plz = parzelle_data.get("plz") + + logger.debug(f"Parzelle {idx + 1} address data: strasse_nr='{strasse_nr}', plz='{plz}', full_address='{address}'") + + if not strasse_nr and not plz: + logger.warning(f"No address data found for Parzelle {idx + 1} (label: {parcel_label})") + + kontext_items = [] + + if parzelle_data.get("egrid"): + kontext_items.append(Kontext( + thema="EGRID", + inhalt=parzelle_data["egrid"] + )) + + if parzelle_data.get("identnd"): + kontext_items.append(Kontext( + thema="IdentND", + inhalt=parzelle_data["identnd"] + )) + + if parzelle_data.get("area_m2"): + kontext_items.append(Kontext( + thema="Fläche", + inhalt=f"{parzelle_data['area_m2']} m²" + )) + + if parzelle_data.get("centroid"): + centroid = parzelle_data["centroid"] + kontext_items.append(Kontext( + thema="Zentrum (LV95)", + inhalt=f"X: {centroid.get('x')} m, Y: {centroid.get('y')} m (EPSG:2056)" + )) + + if parzelle_data.get("geoportal_url"): + kontext_items.append(Kontext( + thema="Geoportal URL", + inhalt=parzelle_data["geoportal_url"] + )) + + if parzelle_data.get("municipality_code"): + kontext_items.append(Kontext( + thema="BFS-Nummer", + inhalt=str(parzelle_data["municipality_code"]) + )) + + adjacent_parcel_refs = [] + if parzelle_data.get("adjacent_parcels"): + neighbors_to_filter = [] + for adj_parcel in parzelle_data["adjacent_parcels"]: + if isinstance(adj_parcel, dict): + neighbors_to_filter.append(adj_parcel) + elif isinstance(adj_parcel, str): + neighbors_to_filter.append({"id": adj_parcel}) + + if all_parcel_geometries and neighbors_to_filter: + try: + filtered_neighbors = filter_neighbor_parcels( + neighbors_to_filter, + all_parcel_geometries + ) + for filtered_neighbor in filtered_neighbors: + adj_id = filtered_neighbor.get("id") + if adj_id: + adjacent_parcel_refs.append({"id": adj_id}) + except Exception as e: + logger.warning(f"Error filtering neighbor parcels: {e}, including all neighbors") + for adj_parcel in parzelle_data["adjacent_parcels"]: + if isinstance(adj_parcel, dict): + adj_id = adj_parcel.get("id") + if adj_id: + adjacent_parcel_refs.append({"id": adj_id}) + elif isinstance(adj_parcel, str): + adjacent_parcel_refs.append({"id": adj_parcel}) + else: + for adj_parcel in parzelle_data["adjacent_parcels"]: + if isinstance(adj_parcel, dict): + adj_id = adj_parcel.get("id") + if adj_id: + adjacent_parcel_refs.append({"id": adj_id}) + elif isinstance(adj_parcel, str): + adjacent_parcel_refs.append({"id": adj_parcel}) + + perimeter = parzelle_data.get("perimeter") + if isinstance(perimeter, dict): + if "punkte" in perimeter and "closed" in perimeter: + try: + perimeter = GeoPolylinie(**perimeter) + except Exception as e: + raise ValueError(f"Invalid perimeter format: {str(e)}") + else: + converted = convert_geojson_to_geopolylinie(perimeter) + if converted: + perimeter = converted + else: + raise ValueError("Invalid perimeter format: cannot convert to GeoPolylinie") + elif isinstance(perimeter, GeoPolylinie): + pass + else: + raise ValueError("Invalid perimeter type: must be dict or GeoPolylinie") + + baulinie = None + geometry = parzelle_data.get("geometry") + logger.debug(f"Geometry present: {geometry is not None}") + if geometry: + logger.debug(f"Geometry type: {type(geometry)}, keys: {list(geometry.keys()) if isinstance(geometry, dict) else 'not a dict'}") + baulinie = convert_geojson_to_geopolylinie(geometry) + if baulinie: + logger.info(f"Extracted baulinie from geometry with {len(baulinie.punkte)} points") + else: + logger.warning("Failed to extract baulinie from geometry") + else: + logger.warning("No geometry found in parzelle_data") + + parzelle_create_data = { + "mandateId": mandateId, + "label": parcel_label, + "parzellenAliasTags": alias_tags, + "eigentuemerschaft": None, + "strasseNr": strasse_nr, + "plz": plz, + "perimeter": perimeter, + "baulinie": baulinie, + "kontextGemeinde": gemeinde_id, + "bauzone": None, + "az": None, + "bz": None, + "vollgeschossZahl": None, + "anrechenbarDachgeschoss": None, + "anrechenbarUntergeschoss": None, + "gebaeudehoeheMax": None, + "regelnGrenzabstand": [], + "regelnMehrlaengenzuschlag": [], + "regelnMehrhoehenzuschlag": [], + "parzelleBebaut": None, + "parzelleErschlossen": None, + "parzelleHanglage": None, + "laermschutzzone": None, + "hochwasserschutzzone": None, + "grundwasserschutzzone": None, + "parzellenNachbarschaft": adjacent_parcel_refs, + "dokumente": [], + "kontextInformationen": kontext_items, + } + + logger.debug(f"Creating Parzelle with label: {parzelle_create_data.get('label')}") + logger.debug(f"Parzelle mandateId: {parzelle_create_data.get('mandateId')}") + logger.debug(f"Parzelle perimeter present: {parzelle_create_data.get('perimeter') is not None}") + + try: + parzelle_instance = Parzelle(**parzelle_create_data) + logger.debug(f"Parzelle instance created successfully with ID: {parzelle_instance.id}") + except Exception as e: + logger.error(f"Error creating Parzelle instance: {str(e)}", exc_info=True) + raise + + try: + logger.info(f"Calling createParzelle for Parzelle '{parzelle_instance.label}' (ID: {parzelle_instance.id})") + logger.debug(f"Parzelle instance before createParzelle: {parzelle_instance.model_dump(mode='json', exclude={'perimeter', 'baulinie', 'kontextInformationen'})}") + + parzelle_dict = parzelle_instance.model_dump(mode='json') + logger.debug(f"Parzelle dict keys: {list(parzelle_dict.keys())}") + + created_parzelle = realEstateInterface.createParzelle(parzelle_instance) + + logger.info(f"createParzelle returned: ID={created_parzelle.id if created_parzelle else 'None'}, Label={created_parzelle.label if created_parzelle else 'None'}") + + if not created_parzelle: + raise ValueError("Failed to create Parzelle - createParzelle returned None") + + if not created_parzelle.id: + raise ValueError("Failed to create Parzelle - no ID returned") + + logger.info(f"Parzelle created with ID: {created_parzelle.id}") + + logger.debug(f"Verifying Parzelle {created_parzelle.id} exists in database...") + verify_parzelle = realEstateInterface.getParzelle(created_parzelle.id) + if not verify_parzelle: + logger.error(f"Parzelle {created_parzelle.id} was not found in database after creation") + all_parzellen = realEstateInterface.getParzellen(recordFilter=None) + logger.error(f"Total Parzellen in database: {len(all_parzellen)}") + if all_parzellen: + logger.error(f"Sample Parzelle IDs: {[p.id for p in all_parzellen[:5]]}") + raise ValueError(f"Parzelle {created_parzelle.id} was not found in database after creation") + + logger.info(f"Verified Parzelle {created_parzelle.id} exists in database") + created_parzelle = verify_parzelle + + if created_parzelle.perimeter: + parcel_perimeters.append(created_parzelle.perimeter) + + created_parzellen.append(created_parzelle) + + except Exception as e: + logger.error(f"Error creating Parzelle {idx + 1}: {str(e)}", exc_info=True) + raise + + if not created_parzellen: + raise ValueError("No Parzellen were successfully created") + + logger.info(f"Successfully created {len(created_parzellen)} Parzelle(n)") + + project_baulinie = None + if len(parcel_perimeters) > 0: + try: + if len(parcel_perimeters) == 1: + project_baulinie = parcel_perimeters[0] + logger.info("Using single parcel perimeter as baulinie") + else: + logger.info(f"Combining {len(parcel_perimeters)} parcel geometries to create baulinie") + project_baulinie = combine_parcel_geometries(parcel_perimeters) + logger.info(f"Created combined baulinie with {len(project_baulinie.punkte)} points") + except Exception as e: + logger.error(f"Error combining parcel geometries for baulinie: {e}", exc_info=True) + if parcel_perimeters: + project_baulinie = parcel_perimeters[0] + logger.warning("Using first parcel perimeter as fallback baulinie") + + status_prozess_enum = None + if status_prozess: + try: + if isinstance(status_prozess, str): + status_prozess_enum = StatusProzess(status_prozess) + elif isinstance(status_prozess, StatusProzess): + status_prozess_enum = status_prozess + except (ValueError, KeyError): + logger.warning(f"Invalid statusProzess '{status_prozess}', using default 'Eingang'") + status_prozess_enum = StatusProzess.EINGANG + else: + status_prozess_enum = StatusProzess.EINGANG + + logger.debug(f"Preparing Projekt creation with baulinie: {project_baulinie is not None}") + if project_baulinie: + logger.debug(f"Baulinie has {len(project_baulinie.punkte)} points") + + project_perimeter = created_parzellen[0].perimeter if created_parzellen else None + + projekt_create_data = { + "mandateId": mandateId, + "label": projekt_label, + "statusProzess": status_prozess_enum, + "perimeter": project_perimeter, + "baulinie": project_baulinie, + "parzellen": created_parzellen, + "dokumente": [], + "kontextInformationen": [], + } + + logger.debug(f"Projekt data prepared: label={projekt_label}, parzellen_count={len(projekt_create_data['parzellen'])}, baulinie={'present' if project_baulinie else 'None'}") + + try: + projekt_instance = Projekt(**projekt_create_data) + logger.debug(f"Projekt instance created successfully with ID: {projekt_instance.id}") + except Exception as e: + logger.error(f"Error creating Projekt instance: {str(e)}", exc_info=True) + raise + + logger.debug(f"Creating Projekt with {len(projekt_instance.parzellen)} Parzelle(n)") + if projekt_instance.parzellen: + for idx, p in enumerate(projekt_instance.parzellen): + logger.debug(f" Parzelle {idx}: ID={p.id}, Label={p.label}") + + logger.debug(f"Projekt baulinie before save: {projekt_instance.baulinie is not None}") + if projekt_instance.baulinie: + logger.debug(f"Projekt baulinie has {len(projekt_instance.baulinie.punkte)} points") + + try: + created_projekt = realEstateInterface.createProjekt(projekt_instance) + logger.info(f"Created Projekt '{created_projekt.label}' (ID: {created_projekt.id})") + logger.debug(f"Created Projekt baulinie: {created_projekt.baulinie is not None}") + except Exception as e: + logger.error(f"Error calling createProjekt: {str(e)}", exc_info=True) + raise + + if not created_projekt or not created_projekt.id: + raise ValueError("Failed to create Projekt - no ID returned") + + if not created_projekt.parzellen or len(created_projekt.parzellen) == 0: + logger.warning(f"Projekt {created_projekt.id} created but no Parzellen linked") + verify_projekt = realEstateInterface.getProjekt(created_projekt.id) + if verify_projekt and verify_projekt.parzellen: + logger.info(f"Parzellen found when fetching Projekt from database: {len(verify_projekt.parzellen)}") + created_projekt = verify_projekt + else: + raise ValueError(f"Projekt {created_projekt.id} has no Parzellen linked after creation") + else: + logger.info(f"Projekt {created_projekt.id} successfully linked to {len(created_projekt.parzellen)} Parzelle(n)") + for idx, p in enumerate(created_projekt.parzellen): + logger.debug(f" Linked Parzelle {idx}: ID={p.id if hasattr(p, 'id') else 'NO ID'}, Label={p.label if hasattr(p, 'label') else 'NO LABEL'}") + + return { + "projekt": created_projekt.model_dump(), + "parzellen": [p.model_dump() for p in created_parzellen], + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error creating project with parcel data: {str(e)}", exc_info=True) + raise diff --git a/modules/features/teamsbot/service.py b/modules/features/teamsbot/service.py index edf5af94..2bafd0e2 100644 --- a/modules/features/teamsbot/service.py +++ b/modules/features/teamsbot/service.py @@ -44,31 +44,19 @@ from .browserBotConnector import BrowserBotConnector logger = logging.getLogger(__name__) # Agent run limits for director prompts / speech escalation (meeting context). -# Higher than default workspace agent: Teams research + tool chains need depth. TEAMSBOT_AGENT_MAX_ROUNDS = 8 TEAMSBOT_AGENT_MAX_COST_CHF = 0.12 -# How many recent director-prompt briefings (one-shot + persistent) we keep in -# session memory so SPEECH_TEAMS triggers and speech escalation can still see -# the operator's attached files + analysis after the prompt itself was consumed. +# How many recent director-prompt briefings we keep in session memory. _RECENT_DIRECTOR_BRIEFINGS_MAX = 6 -# Quick-ack ("Moment...") UX: fire a SHORT TTS the moment the bot's name is -# detected so the speaker hears within ~1s that the bot reacted, instead of -# waiting for the full debounce + SPEECH_TEAMS + agent pipeline (~5-30s). -# Throttled per session to avoid acking every fragment of a long utterance. +# Quick-ack throttle interval. _QUICK_ACK_MIN_INTERVAL_SEC = 25.0 -# Number of phrase variants we generate per kind (rotated round-robin so back- -# to-back acks/notices don't sound identical). +# Number of phrase variants we generate per kind. _EPHEMERAL_PHRASE_VARIANTS = 4 -# Localisation INTENTS for ephemeral phrases. Each kind describes WHAT the -# phrase should express; the actual wording is produced at runtime by the AI -# in the bot's configured language + persona. The intent text below is the -# instruction passed to the LLM (English, since it's a model directive — the -# OUTPUT will be in the configured spoken language). Add new ephemeral phrase -# kinds here, never inline string literals at the call site. +# Localisation INTENTS for ephemeral phrases. _EPHEMERAL_PHRASE_INTENTS: Dict[str, str] = { "quickAck": ( "Very short verbal acknowledgment (1 to 4 words) the assistant says " @@ -91,6 +79,10 @@ _EPHEMERAL_PHRASE_INTENTS: Dict[str, str] = { } +# ========================================================================= +# Module-level utility functions (used by extracted modules too) +# ========================================================================= + def _voiceLineLooksLikeBillingOrMeta(line: str) -> bool: """Heuristic: trailing lines that are separators or billing/usage footers.""" s = line.strip() @@ -119,45 +111,22 @@ def _voiceLineLooksLikeBillingOrMeta(line: str) -> bool: _EMOJI_PATTERN = re.compile( "[" - "\U0001F300-\U0001FAFF" # symbols & pictographs, emoticons, transport, supplemental - "\U00002600-\U000027BF" # misc symbols + dingbats (incl. ⚙ 🔐 🔌 ✓ ✗) - "\U0001F1E6-\U0001F1FF" # regional indicator (flags) - "\U00002B00-\U00002BFF" # arrows, geometric - "\U0001F900-\U0001F9FF" # supplemental symbols (incl. 🤖 🧠) - "\U0000FE0F" # variation selector-16 (emoji presentation) + "\U0001F300-\U0001FAFF" + "\U00002600-\U000027BF" + "\U0001F1E6-\U0001F1FF" + "\U00002B00-\U00002BFF" + "\U0001F900-\U0001F9FF" + "\U0000FE0F" "]+", flags=re.UNICODE, ) def _voiceFriendlyMeetingText(raw: str) -> str: - """Sanitise a chat/markdown response so it can be SPOKEN naturally. - - Aggressive cleanup — when a TTS engine reads raw markdown out loud the - listener hears "hash hash hash Zusammenfassung pipe pipe pipe", which - is unbearable in a meeting. The chat / DB / UI keep the original text; - only the audio path goes through this sanitiser. - - What we strip: - * Code fences and inline code - * Markdown emphasis (**bold**, *italic*, __bold__, _italic_) - * Markdown links → keep label - * Headings (# .. ######) - * Markdown tables (any line with two or more pipes is dropped wholesale) - * Horizontal rules (---, ***, ___ on their own line) - * Bullet markers (-, *, •, ·) and numbered list markers (1., 2)) at line start - * Emojis (full Unicode pictograph ranges + variation selector) - * Decorative trailing colons on bullet headings - * Stray pipes left over from inline tables - * Trailing billing / "maximum rounds reached" / "budget exceeded" footers - - Whitespace is then collapsed to single spaces. - """ + """Sanitise a chat/markdown response so it can be SPOKEN naturally.""" if not raw: return "" - # Trim trailing operator/billing footers BEFORE any structural rewrite - # so we don't waste effort sanitising a footer that gets dropped. low = raw.lower() if "maximum rounds reached" in low: m = re.search(r"(?is)maximum\s+rounds\s+reached", raw) @@ -179,13 +148,9 @@ def _voiceFriendlyMeetingText(raw: str) -> str: if not t: t = raw.strip() - # 1) Strip code blocks (multi-line first, then inline) t = re.sub(r"```[\s\S]*?```", " ", t) t = re.sub(r"`([^`]+)`", r"\1", t) - # 2) Drop markdown table rows (any line with two or more pipes) and the - # separator lines they come with (|---|---|). A paragraph that just - # happens to contain ONE pipe survives. cleanedLines: List[str] = [] for ln in t.split("\n"): stripped = ln.strip() @@ -196,84 +161,31 @@ def _voiceFriendlyMeetingText(raw: str) -> str: cleanedLines.append(ln) t = "\n".join(cleanedLines) - # 3) Drop horizontal rule lines (---, ***, ___, with optional spaces) t = re.sub(r"(?m)^\s*([-*_])\s*\1\s*\1[\s\1]*$", "", t) - - # 4) Headings: drop the leading hashes t = re.sub(r"(?m)^\s*#{1,6}\s+", "", t) - - # 5) Bullet markers at line start — keep the content, drop the bullet t = re.sub(r"(?m)^\s*[-*•·]\s+", "", t) - # 6) Numbered list markers at line start ("1.", "2)", "3 -") t = re.sub(r"(?m)^\s*\d+[\.\)]\s+", "", t) - - # 7) Emphasis markers (after bullets so a "**Bold:**" heading is handled) t = re.sub(r"\*\*([^*]+)\*\*", r"\1", t) t = re.sub(r"\*([^*\n]+)\*", r"\1", t) t = re.sub(r"__([^_]+)__", r"\1", t) t = re.sub(r"(?` `{` `}` `[` `]` `(` `)` - # `_` `&` `@` `$` `%` `` -- replaced with a space so word - # boundaries are preserved. t = re.sub(r"[*#~^=+|\\<>{}\[\]()_&@$%`]+", " ", t) - - # 10e) Drop ASCII double-quote (single quotes are legitimate apostrophes - # in contractions like "don't" / "geht's", so we keep U+0027). t = t.replace('"', "") - - # 10f) Slash between letters/digits — TTS reads "slash". Replace with - # " or " for readability when it separates words like "und/oder". t = re.sub(r"(?<=\w)\s*/\s*(?=\w)", " oder ", t) - # Any remaining stray slash is just whitespace. t = t.replace("/", " ") - - # 10g) Trim multiple punctuation runs ("...!!!" → "..." / "!" / etc.) t = re.sub(r"([\.,;:!\?])\1{1,}", r"\1", t) - # Remove orphan punctuation directly preceded by whitespace - # (common after symbol stripping: " , ", " . "). t = re.sub(r"\s+([\.,;:!\?])", r"\1", t) - # Collapse trailing colon at end of meaningful phrase to a period for - # nicer cadence ("Was ist PowerOn:" → "Was ist PowerOn."). t = re.sub(r":\s*$", ".", t.rstrip()) - # 10h) Collapse " :" tail of MULTI-LINE blocks the same way. t = re.sub(r"\s+:\s*$", ":", t, flags=re.MULTILINE) - # 11) Collapse whitespace to single spaces; protect sentence breaks by - # turning paragraph blanks into a period if the previous chunk - # didn't already terminate. paragraphs = [p.strip() for p in re.split(r"\n\s*\n", t) if p.strip()] rebuilt: List[str] = [] for p in paragraphs: @@ -285,28 +197,14 @@ def _voiceFriendlyMeetingText(raw: str) -> str: rebuilt.append(p) t = " ".join(rebuilt) t = re.sub(r"\s+", " ", t).strip() - - # If we sanitised away everything (e.g. the input was *only* a markdown - # table or a wall of pictographs) return empty — the caller (TTS / voice - # summary) treats empty as "nothing to say", which is the safe default. - # Falling back to raw markdown here would leak the very symbols we just - # spent ten passes removing. return t -# Google Cloud TTS rejects single sentences that exceed ~5000 bytes. The Chirp3 -# voices are stricter: long, comma-heavy sentences (no terminating punctuation) -# also fail with "Sentence ... is too long". We chunk well below the documented -# limit AND inject sentence terminators so the synthesizer accepts every chunk. _TTS_MAX_CHUNK_CHARS = 800 def _splitTextForTts(text: str, maxChars: int = _TTS_MAX_CHUNK_CHARS) -> List[str]: - """Split a long voice line into TTS-safe chunks at sentence/paragraph boundaries. - - The result preserves order and contains no empty strings. A single - sentence longer than ``maxChars`` is hard-cut at word boundaries. - """ + """Split a long voice line into TTS-safe chunks at sentence/paragraph boundaries.""" cleaned = (text or "").strip() if not cleaned: return [] @@ -369,19 +267,7 @@ async def _speakTextChunked( voiceName: Optional[str], isCancelled: Optional[Callable[[], bool]] = None, ) -> Dict[str, Any]: - """Run TTS in chunks and dispatch each ``playAudio`` over the websocket. - - Returns ``{"success": bool, "chunks": int, "played": int, "error": Optional[str], "cancelled": bool}``. - Failure for one chunk does NOT abort the rest; partial playback still - counts as ``success=True`` so the caller can decide whether to add a chat - fallback for the missing parts. - - ``isCancelled`` is an optional zero-arg predicate the caller passes in to - signal "abort the remaining chunks". It is checked BEFORE each Google - TTS round-trip and again BEFORE each websocket send, so a stop word in - the meeting can interrupt a multi-chunk dispatch within at most one - chunk boundary instead of waiting for the whole answer to finish. - """ + """Run TTS in chunks and dispatch each ``playAudio`` over the websocket.""" chunks = _splitTextForTts(voiceText) result: Dict[str, Any] = {"success": False, "chunks": len(chunks), "played": 0, "error": None, "cancelled": False} if not chunks: @@ -406,7 +292,7 @@ async def _speakTextChunked( languageCode=languageCode, voiceName=voiceName, ) - except Exception as ttsErr: # pragma: no cover - network/runtime errors + except Exception as ttsErr: lastError = f"chunk {idx}/{len(chunks)} raised: {ttsErr}" logger.warning(f"Session {sessionId}: TTS {lastError}") continue @@ -447,7 +333,7 @@ async def _speakTextChunked( }, })) result["played"] += 1 - except Exception as wsErr: # pragma: no cover - websocket failures + except Exception as wsErr: lastError = f"chunk {idx}/{len(chunks)} websocket send failed: {wsErr}" logger.warning(f"Session {sessionId}: TTS {lastError}") break @@ -459,8 +345,7 @@ async def _speakTextChunked( def _coercePersistedDetectedIntent(raw: Optional[str]) -> tuple: - """Map free-form intent labels (e.g. agent:directorPrompt) to TeamsbotDetectedIntent - for DB persistence; return (enum, meta_suffix_or_None for reasoning).""" + """Map free-form intent labels to TeamsbotDetectedIntent for DB persistence.""" if not raw or not str(raw).strip(): return TeamsbotDetectedIntent.NONE, None s = str(raw).strip().lower() @@ -472,11 +357,6 @@ def _coercePersistedDetectedIntent(raw: Optional[str]) -> tuple: return TeamsbotDetectedIntent.NONE, str(raw).strip()[:120] -# Director prompts are PRIVATE operator instructions — they must NOT be echoed -# verbatim into the meeting. The agent is asked to start its FINAL answer with -# either ``MEETING_REPLY:`` (followed by the text actually meant for the meeting) -# or ``SILENT:`` / ``INTERNAL_ONLY:`` (followed by an internal note for the -# operator UI). Anything else → treat as silent (safe default). _DIRECTOR_REPLY_PATTERN = re.compile( r"^\s*(MEETING_REPLY|MEETING|REPLY|SAY|SPEAK)\s*:\s*", re.IGNORECASE, @@ -488,12 +368,7 @@ _DIRECTOR_SILENT_PATTERN = re.compile( def _parseDirectorPromptFinal(finalText: str) -> Dict[str, Any]: - """Parse the agent's final answer for a director prompt. - - Returns ``{"kind": "meeting"|"silent", "meetingText": str, "internalNote": str}``. - - Default is ``silent`` so unmarked replies are NOT broadcast into the meeting. - """ + """Parse the agent's final answer for a director prompt.""" text = (finalText or "").strip() if not text: return {"kind": "silent", "meetingText": "", "internalNote": ""} @@ -508,18 +383,11 @@ def _parseDirectorPromptFinal(finalText: str) -> Dict[str, Any]: body = text[silentMatch.end():].strip() return {"kind": "silent", "meetingText": "", "internalNote": body} - # No marker → safe default: do NOT spam the meeting with the agent's - # internal reasoning. Keep the full text as an internal note for the - # operator UI so nothing is lost. return {"kind": "silent", "meetingText": "", "internalNote": text} # ========================================================================= # Active Service Registry (sessionId -> running TeamsbotService instance) -# -# Required so HTTP endpoints (e.g. director-prompt POST) can reach the -# TeamsbotService instance currently holding the live websocket + voice -# interface for that session, without going through the websocket loop. # ========================================================================= _activeServices: Dict[str, "TeamsbotService"] = {} @@ -530,7 +398,7 @@ def getActiveService(sessionId: str) -> Optional["TeamsbotService"]: # ========================================================================= -# AI Service Factory (for billing-aware AI calls) +# AI Service Factory # ========================================================================= def createAiService(user, mandateId, featureInstanceId=None): @@ -551,19 +419,14 @@ sessionEvents: Dict[str, asyncio.Queue] = {} async def _emitSessionEvent(sessionId: str, eventType: str, data: Any): - """Emit an event to the session's SSE stream. - Creates the queue on-demand so events are never silently dropped.""" + """Emit an event to the session's SSE stream.""" if sessionId not in sessionEvents: sessionEvents[sessionId] = asyncio.Queue() await sessionEvents[sessionId].put({"type": eventType, "data": data, "timestamp": getUtcTimestamp()}) def _normalizeGatewayHostForBotWs(host: str) -> str: - """Use IPv4 loopback for local dev WebSocket URLs passed to the Node browser-bot. - - Node on Windows often resolves ``localhost`` to ``::1`` first; Uvicorn bound to - ``0.0.0.0`` typically accepts IPv4 only, so the bot gets ``ECONNREFUSED ::1``. - """ + """Use IPv4 loopback for local dev WebSocket URLs passed to the Node browser-bot.""" h = host.strip() lower = h.lower() if lower == "localhost": @@ -577,6 +440,10 @@ def _normalizeGatewayHostForBotWs(host: str) -> str: return h +# ========================================================================= +# TeamsbotService Class +# ========================================================================= + class TeamsbotService: """ Pipeline Orchestrator for Teams Bot sessions. @@ -594,8 +461,8 @@ class TeamsbotService: self._lastAiCallTime: float = 0.0 self._aiAnalysisInProgress: bool = False self._contextBuffer: List[Dict[str, Any]] = [] - self._sessionContext: Optional[str] = None # User-provided background context - self._contextSummary: Optional[str] = None # AI-generated summary of long context + self._sessionContext: Optional[str] = None + self._contextSummary: Optional[str] = None # Differential transcript tracking self._lastTranscriptSpeaker: Optional[str] = None @@ -603,8 +470,7 @@ class TeamsbotService: self._lastTranscriptId: Optional[str] = None self._lastSttTime: float = 0.0 - # Audio chunk aggregation: collect chunks and send to STT only - # after a speech pause or when the buffer reaches a target duration. + # Audio chunk aggregation self._audioBuffer: bytes = b"" self._audioBufferStartTime: float = 0.0 self._audioBufferLastChunkTime: float = 0.0 @@ -612,82 +478,37 @@ class TeamsbotService: self._lastBotResponseText: Optional[str] = None self._lastBotResponseTs: float = 0.0 - # Speaker attribution: simple last-caption-speaker model + # Speaker attribution self._lastCaptionSpeaker: Optional[str] = None self._unattributedTranscriptIds: List[str] = [] self._knownSpeakers: set = set() - # Debounced name trigger: wait for speaker to finish before AI analysis + # Debounced name trigger self._pendingNameTrigger: Optional[Dict[str, Any]] = None self._followUpWindowEnd: float = 0.0 - # Quick-ack throttle (timestamp of the last short "Moment..." ack we - # spoke into the meeting). Without this guard a long sentence with - # multiple name mentions would trigger several acks in a row. + # Quick-ack throttle self._lastQuickAckTs: float = 0.0 - # Session-scoped phrase pool for SHORT ephemeral utterances (quick - # acks, "checking..." notices, per-round progress). Lazily populated - # by the AI in the bot's configured language + persona — no hardcoded - # strings or hardcoded language branching anywhere downstream. Keyed - # by the kinds defined in ``_EPHEMERAL_PHRASE_INTENTS``. - # * ``self._phrasePool[kind]`` -> list of variants for that kind - # * ``self._phrasePoolIdx[kind]`` -> round-robin pointer - # Concurrent generation calls for the same kind are serialised by the - # lock so we don't spawn duplicate AI requests on a burst. + # Ephemeral phrase pool self._phrasePool: Dict[str, List[str]] = {} self._phrasePoolIdx: Dict[str, int] = {} self._phrasePoolLock: asyncio.Lock = asyncio.Lock() - # Voice pipeline: a single per-session lock that serialises every TTS - # dispatch into the meeting. Without it three independent code paths - # (SPEECH_TEAMS direct answer, agent escalation final answer, and - # operator-driven director prompt) can all reach - # ``websocket.send_text({"type": "playAudio", ...})`` at the same time - # and the browser bot then plays interleaved chunks — i.e. "two bots - # talking over each other" exactly as the operator suspects. Chat - # (text) sends are NOT locked: they're cheap and can interleave fine. + # Voice pipeline serialisation self._meetingTtsLock: asyncio.Lock = asyncio.Lock() - # Generation counter incremented every time we begin producing a NEW - # meeting answer OR every time the user issues a hard stop. Any TTS - # chunk loop captures the counter value at start; before sending - # each chunk to the bot it re-checks the counter and bails out if - # it has moved on. This is what makes "Stopp" actually feel - # instantaneous: the in-flight TTS dispatch loop drops itself the - # moment the next chunk would have been sent, without waiting for - # any AI round-trip or extra Google TTS call to come back. self._answerGenerationCounter: int = 0 - # Tracking handles for cancellable background tasks. Keeping a - # reference lets ``_cancelInFlightSpeech`` actually call - # ``task.cancel()`` instead of just hoping the task notices the - # generation counter has moved on. Cleared in the task's own - # ``finally`` block. self._currentEscalationTask: Optional[asyncio.Task] = None self._currentQuickAckTask: Optional[asyncio.Task] = None - # Whether an agent escalation task is in flight. Kept separate from - # ``_aiAnalysisInProgress`` (which only covers the SPEECH_TEAMS phase) - # so a new speech trigger that arrives WHILE the agent is still - # researching does not start a parallel SPEECH_TEAMS that would then - # answer at the same time as the agent. self._agentEscalationInFlight: bool = False - # Live transport handles for out-of-band actions (director prompts, agent escalation). - # Set in handleBotWebSocket once the bot connects; cleared on disconnect. + # Live transport handles self._activeSessionId: Optional[str] = None self._websocket: Optional[WebSocket] = None self._voiceInterface = None - # Persistent director prompts kept in memory for context injection across triggers. - # Loaded from DB on (re)connect; mutated by submit/delete director prompt routes. + # Director prompts self._activePersistentPrompts: List[Dict[str, Any]] = [] - - # Recent director-prompt briefings (one-shot AND persistent) — keeps the - # operator's attached files and the agent's internal analysis available - # for later SPEECH_TEAMS triggers, even after a one-shot prompt has been - # consumed. Without this pool, the bot "forgets" attached docs as soon - # as the director prompt finished, and answers later meeting questions - # ("summarize the doc") with general babble instead of the file content. - # Capped by ``_RECENT_DIRECTOR_BRIEFINGS_MAX`` to bound prompt size. self._recentDirectorBriefings: List[Dict[str, Any]] = [] # ========================================================================= @@ -703,36 +524,22 @@ class TeamsbotService: botAccountEmail: Optional[str] = None, botAccountPassword: Optional[str] = None, ): - """Send join command to the Browser Bot service. - - The browser bot will: - 1. Launch browser (headful if credentials provided, headless otherwise) - 2. Navigate to Teams web app - 3. Authenticate if credentials provided, otherwise join as anonymous guest - 4. Enable captions/audio capture and start scraping - 5. Connect back via WebSocket to send transcripts - """ + """Send join command to the Browser Bot service.""" from . import interfaceFeatureTeamsbot as interfaceDb interface = interfaceDb.getInterface(self.currentUser, self.mandateId, self.instanceId) - # Reuse existing SSE event queue if a consumer (SSE generator) already - # holds a reference; replacing it would strand the SSE stream. if sessionId not in sessionEvents: sessionEvents[sessionId] = asyncio.Queue() try: - # Update status to JOINING interface.updateSession(sessionId, {"status": TeamsbotSessionStatus.JOINING.value}) await _emitSessionEvent(sessionId, "statusChange", {"status": "joining"}) - # Send join command to browser bot session = interface.getSession(sessionId) if not session: raise ValueError(f"Session {sessionId} not found") - # Build the full WebSocket URL for the bot to connect back to this gateway instance - # gatewayBaseUrl is passed from the route handler (derived from request.base_url) wsScheme = "wss" if gatewayBaseUrl.startswith("https") else "ws" gatewayHost = gatewayBaseUrl.replace("https://", "").replace("http://", "").rstrip("/") gatewayHost = _normalizeGatewayHostForBotWs(gatewayHost) @@ -764,7 +571,7 @@ class TeamsbotService: if result.get("success"): interface.updateSession(sessionId, { - "status": TeamsbotSessionStatus.JOINING.value, # Will become ACTIVE when bot connects via WS + "status": TeamsbotSessionStatus.JOINING.value, }) logger.info(f"Browser bot deployment started for session {sessionId}") else: @@ -794,7 +601,7 @@ class TeamsbotService: return getattr(self.config, "avatarFileId", None) def _loadAvatarFileData(self, fileId, _teamsbotInterface): - """Load avatar file as base64 data + mime type. Returns (data, mimeType) or (None, None).""" + """Load avatar file as base64 data + mime type.""" from modules.interfaces import interfaceDbManagement try: mgmt = interfaceDbManagement.getInterface(self.currentUser, self.mandateId, featureInstanceId=self.instanceId) @@ -832,7 +639,6 @@ class TeamsbotService: }) await _emitSessionEvent(sessionId, "statusChange", {"status": "ended"}) - # Generate meeting summary in background asyncio.create_task(self._generateMeetingSummary(sessionId)) logger.info(f"Bot left meeting for session {sessionId}") @@ -845,504 +651,95 @@ class TeamsbotService: "endedAt": getUtcTimestamp(), }) - # Cleanup event queue sessionEvents.pop(sessionId, None) # ========================================================================= - # Browser Bot WebSocket Communication + # WebSocket — delegates to serviceWebSocket module # ========================================================================= async def handleBotWebSocket(self, websocket: WebSocket, sessionId: str): - """ - Main WebSocket handler for Browser Bot communication. - - Receives: - - transcript: Caption text scraped from Teams - - status: Bot state changes (joined, in_lobby, left, error) - - Sends: - - playAudio: TTS audio for the bot to play in the meeting - """ - from . import interfaceFeatureTeamsbot as interfaceDb - from modules.interfaces.interfaceVoiceObjects import getVoiceInterface + """Main WebSocket handler — delegates to serviceWebSocket.""" + from .serviceWebSocket import handleBotWebSocket as _handleBotWebSocket + await _handleBotWebSocket(self, websocket, sessionId) - interface = interfaceDb.getInterface(self.currentUser, self.mandateId, self.instanceId) - voiceInterface = getVoiceInterface(self.currentUser, self.mandateId) + # ========================================================================= + # Conversation — delegates to serviceConversation module + # ========================================================================= - # Load session context (user-provided background knowledge) - # If the context is long (>500 chars), summarize it to reduce token usage - session = interface.getSession(sessionId) - if session: - rawContext = session.get("sessionContext") - if rawContext and len(rawContext) > 500: - logger.info(f"Session {sessionId}: Summarizing long session context ({len(rawContext)} chars)...") - self._sessionContext = await self._summarizeSessionContext(sessionId, rawContext) - elif rawContext: - self._sessionContext = rawContext - if self._sessionContext: - logger.info(f"Session {sessionId}: Session context ready ({len(self._sessionContext)} chars)") + async def _processTranscript(self, *args, **kwargs): + from .serviceConversation import _processTranscript + await _processTranscript(self, *args, **kwargs) - # Resolve system bot email for speaker detection (prevents bot from triggering AI on own speech) - try: - systemBot = interface.getActiveSystemBot(self.mandateId) - self._botAccountEmail = systemBot.get("email") if systemBot else None - if self._botAccountEmail: - logger.info(f"Session {sessionId}: Bot account email resolved: {self._botAccountEmail}") - except Exception: - self._botAccountEmail = None + async def _analyzeAndRespond(self, *args, **kwargs): + from .serviceConversation import _analyzeAndRespond + await _analyzeAndRespond(self, *args, **kwargs) - # Register the live service so out-of-band callers (director prompts, - # agent escalation) can deliver text/audio through this same websocket. - self._activeSessionId = sessionId - self._websocket = websocket - self._voiceInterface = voiceInterface - _activeServices[sessionId] = self + async def _summarizeForVoice(self, sessionId: str, rawAnswer: str) -> str: + from .serviceConversation import _summarizeForVoice + return await _summarizeForVoice(self, sessionId, rawAnswer) - # Notify the operator UI that the bot's WebSocket is now live so the - # director-prompt panel can enable its submit button. - try: - await _emitSessionEvent(sessionId, "botConnectionState", { - "connected": True, - "timestamp": getUtcTimestamp(), - }) - except Exception: - pass + async def _pickQuickAckText(self) -> Optional[str]: + from .serviceConversation import _pickQuickAckText + return await _pickQuickAckText(self) - # Restore active persistent director prompts from DB (survives reconnects). - try: - self._activePersistentPrompts = interface.getActivePersistentPrompts(sessionId) or [] - if self._activePersistentPrompts: - logger.info( - f"Session {sessionId}: Loaded {len(self._activePersistentPrompts)} active persistent director prompt(s)" - ) - except Exception as restoreErr: - logger.warning(f"Session {sessionId}: Could not restore persistent director prompts: {restoreErr}") - self._activePersistentPrompts = [] + async def _pickEphemeralPhrase(self, kind: str, substitutions=None) -> Optional[str]: + from .serviceConversation import _pickEphemeralPhrase + return await _pickEphemeralPhrase(self, kind, substitutions) - # Pre-warm the ephemeral phrase pool in the background so the first - # quick-ack ("Moment...") and interim agent notice don't have to wait - # for the AI round-trip. Best-effort: if generation fails, the - # corresponding ephemeral cue is silently skipped at runtime — never - # falls back to hardcoded language strings. - asyncio.create_task(self._warmEphemeralPhrasePool(sessionId)) + async def _getEphemeralPhrases(self, kind: str) -> List[str]: + from .serviceConversation import _getEphemeralPhrases + return await _getEphemeralPhrases(self, kind) - logger.info(f"[WS] Handler started for session {sessionId}") + async def _generateEphemeralPhrases(self, kind: str, count: int) -> List[str]: + from .serviceConversation import _generateEphemeralPhrases + return await _generateEphemeralPhrases(self, kind, count) - try: - msgCount = 0 - while True: - data = await websocket.receive() - msgCount += 1 + async def _runQuickAck(self, sessionId: str) -> None: + from .serviceConversation import _runQuickAck + await _runQuickAck(self, sessionId) - if "text" not in data: - logger.debug(f"[WS] session={sessionId} msg #{msgCount}: non-text data (keys: {list(data.keys())})") - continue + async def _checkPendingNameTrigger(self, delaySec: float = 3.0): + from .serviceConversation import _checkPendingNameTrigger + await _checkPendingNameTrigger(self, delaySec) - message = json.loads(data["text"]) - msgType = message.get("type") - if msgType not in ("audioChunk", "ping"): - logger.info(f"[WS] session={sessionId} msg #{msgCount}: type={msgType}") + async def _warmEphemeralPhrasePool(self, sessionId: str) -> None: + from .serviceConversation import _warmEphemeralPhrasePool + await _warmEphemeralPhrasePool(self, sessionId) - if msgType == "transcript": - transcript = message.get("transcript", {}) - source = transcript.get("source", "caption") - speaker = transcript.get("speaker", "Unknown") - textPreview = (transcript.get("text", "") or "")[:60] - # Caption/speakerHint: name resolution only; transcript comes from STT - logger.info(f"[WS] Transcript (source={source}, speaker={speaker}): {textPreview}...") - await self._processTranscript( - sessionId=sessionId, - speaker=transcript.get("speaker", "Unknown"), - text=transcript.get("text", ""), - isFinal=transcript.get("isFinal", True), - interface=interface, - voiceInterface=voiceInterface, - websocket=websocket, - source=source, - ) + async def _runEscalationAndRelease(self, *args, **kwargs) -> None: + from .serviceConversation import _runEscalationAndRelease + await _runEscalationAndRelease(self, *args, **kwargs) - elif msgType == "chatMessage": - chat = message.get("chat", {}) - isHistory = chat.get("isHistory", False) - source = "chatHistory" if isHistory else "chat" - logger.info( - f"[WS] Chat{'[HISTORY]' if isHistory else ''}: " - f"speaker={chat.get('speaker')}, text={chat.get('text', '')[:60]}..." - ) - await self._processTranscript( - sessionId=sessionId, - speaker=chat.get("speaker", "Unknown"), - text=chat.get("text", ""), - isFinal=True, - interface=interface, - voiceInterface=voiceInterface, - websocket=websocket, - source=source, - ) + # ========================================================================= + # Commands — delegates to serviceCommands module + # ========================================================================= - elif msgType == "status": - status = message.get("status") - errorMessage = message.get("message") - logger.info(f"[WS] Status: status={status}, message={errorMessage}") - await self._handleBotStatus(sessionId, status, errorMessage, interface) + async def _executeCommands(self, sessionId, commands, voiceInterface, websocket): + from .serviceCommands import _executeCommands + await _executeCommands(self, sessionId, commands, voiceInterface, websocket) - elif msgType == "audioChunk": - audioData = message.get("audio", {}) - audioBase64 = audioData.get("data", "") - sampleRate = audioData.get("sampleRate", 16000) - captureDiagnostics = audioData.get("captureDiagnostics") or {} - if audioBase64: - await self._processAudioChunk( - sessionId=sessionId, - audioBase64=audioBase64, - sampleRate=sampleRate, - captureDiagnostics=captureDiagnostics, - interface=interface, - voiceInterface=voiceInterface, - websocket=websocket, - ) + # ========================================================================= + # WebSocket helpers (kept on class — used by serviceWebSocket) + # ========================================================================= - elif msgType == "voiceGreeting": - # Legacy path: older bot images send a pre-built greeting - # text. New bots use ``requestGreeting`` and let the - # Gateway own greeting generation. - greetingText = message.get("text", "") - greetingLang = message.get("language", self.config.language) - logger.info( - f"[WS] Voice greeting (legacy): text={greetingText[:60]}..., language={greetingLang}" - ) - if greetingText and voiceInterface: - await self._dispatchGreetingToMeeting( - sessionId=sessionId, - greetingText=greetingText, - greetingLang=greetingLang, - sendToChat=False, - interface=interface, - voiceInterface=voiceInterface, - websocket=websocket, - ) + async def _handleBotStatus(self, sessionId, status, errorMessage, interface): + from .serviceWebSocket import _handleBotStatus + await _handleBotStatus(self, sessionId, status, errorMessage, interface) - elif msgType == "requestGreeting": - # New path: bot just signals "I have joined" — Gateway - # generates the greeting text via AI in the configured - # language + persona, then dispatches it to BOTH the - # meeting chat (sendChatMessage command) and TTS. No - # hardcoded language strings on the bot side. - requestedLang = ( - message.get("language") or self.config.language or "" - ).strip() or "en-US" - botNameHint = ( - message.get("botName") or self.config.botName or "" - ).strip() or self.config.botName - logger.info( - f"[WS] Greeting request from bot: language={requestedLang}, name={botNameHint}" - ) - if voiceInterface: - try: - greetingText = await self._generateGreetingText( - requestedLang - ) - except Exception as genErr: - logger.warning( - f"Greeting generation failed for session {sessionId}: {genErr}" - ) - greetingText = "" - if greetingText: - await self._dispatchGreetingToMeeting( - sessionId=sessionId, - greetingText=greetingText, - greetingLang=requestedLang, - sendToChat=True, - interface=interface, - voiceInterface=voiceInterface, - websocket=websocket, - ) - else: - logger.warning( - f"Session {sessionId}: Skipping greeting — AI generation produced no text" - ) + async def _processAudioChunk(self, *args, **kwargs): + from .serviceWebSocket import _processAudioChunk + await _processAudioChunk(self, *args, **kwargs) - elif msgType == "ping": - await websocket.send_text(json.dumps({"type": "pong"})) + async def _cancelInFlightSpeech(self, sessionId, websocket, reason): + from .serviceWebSocket import _cancelInFlightSpeech + await _cancelInFlightSpeech(self, sessionId, websocket, reason) - elif msgType == "ttsPlaybackAck": - playback = message.get("playback", {}) or {} - status = playback.get("status", "unknown") - ackMessage = playback.get("message") or "Bot playback status update" - logger.info( - f"[WS] TTS playback ack: status={status}, format={playback.get('format')}, " - f"bytesBase64={playback.get('bytesBase64')}" - ) - await _emitSessionEvent(sessionId, "ttsDeliveryStatus", { - "status": f"playback_{status}", - "hasWebSocket": True, - "message": ackMessage, - "timestamp": playback.get("timestamp") or getUtcTimestamp(), - "format": playback.get("format"), - "bytesBase64": playback.get("bytesBase64"), - }) - - elif msgType == "mfaChallenge": - mfaData = message.get("mfa", {}) - mfaType = mfaData.get("type", "unknown") - displayNumber = mfaData.get("displayNumber") - prompt = mfaData.get("prompt", "") - logger.info(f"[WS] MFA challenge: type={mfaType}, number={displayNumber}, prompt={prompt[:60]}") - - await _emitSessionEvent(sessionId, "mfaChallenge", { - "mfaType": mfaType, - "displayNumber": displayNumber, - "prompt": prompt, - "timestamp": getUtcTimestamp(), - }) - - from .routeFeatureTeamsbot import mfaCodeQueues, mfaWaitTasks - mfaQueue = asyncio.Queue() - mfaCodeQueues[sessionId] = mfaQueue - - async def _waitAndForwardMfa(sid, queue, ws): - try: - mfaResponse = await asyncio.wait_for(queue.get(), timeout=120.0) - logger.info(f"[WS] MFA response received for session {sid}: action={mfaResponse.get('action')}") - await ws.send_text(json.dumps({ - "type": "mfaResponse", - "sessionId": sid, - "mfa": mfaResponse, - })) - except asyncio.TimeoutError: - logger.warning(f"[WS] MFA response timeout for session {sid}") - await ws.send_text(json.dumps({ - "type": "mfaResponse", - "sessionId": sid, - "mfa": {"action": "timeout"}, - })) - await _emitSessionEvent(sid, "mfaChallenge", { - "mfaType": "timeout", - "prompt": "MFA-Zeitlimit ueberschritten. Bitte erneut versuchen.", - }) - except asyncio.CancelledError: - logger.info(f"[WS] MFA wait cancelled for session {sid} (resolved via page)") - finally: - mfaCodeQueues.pop(sid, None) - mfaWaitTasks.pop(sid, None) - - mfaWaitTasks[sessionId] = asyncio.create_task( - _waitAndForwardMfa(sessionId, mfaQueue, websocket) - ) - - elif msgType == "chatSendFailed": - errorData = message.get("error", {}) - reason = errorData.get("reason", "unknown") - failedText = errorData.get("text", "") - logger.warning( - f"[WS] Chat send failed for session {sessionId}: " - f"reason={reason}, text={failedText[:60]}" - ) - await _emitSessionEvent(sessionId, "chatSendFailed", { - "reason": reason, - "message": errorData.get("message", "Chat message could not be sent"), - "text": failedText, - "timestamp": getUtcTimestamp(), - }) - - elif msgType == "mfaResolved": - success = message.get("success", False) - logger.info(f"[WS] MFA resolved: success={success}") - from .routeFeatureTeamsbot import mfaCodeQueues, mfaWaitTasks - task = mfaWaitTasks.pop(sessionId, None) - if task and not task.done(): - task.cancel() - mfaCodeQueues.pop(sessionId, None) - await _emitSessionEvent(sessionId, "mfaResolved", { - "success": success, - "timestamp": getUtcTimestamp(), - }) - - except Exception as e: - if "disconnect" not in str(e).lower(): - logger.error(f"[WS] Error for session {sessionId}: {type(e).__name__}: {e}") - finally: - if _activeServices.get(sessionId) is self: - _activeServices.pop(sessionId, None) - self._websocket = None - self._voiceInterface = None - self._activeSessionId = None - try: - await _emitSessionEvent(sessionId, "botConnectionState", { - "connected": False, - "timestamp": getUtcTimestamp(), - }) - except Exception: - pass - - logger.info(f"[WS] Handler ended for session {sessionId} after {msgCount} messages") - - async def _handleBotStatus( - self, - sessionId: str, - status: str, - errorMessage: Optional[str], - interface, - ): - """Handle status updates from the browser bot.""" - logger.info(f"Bot status update for session {sessionId}: {status}") - - statusMap = { - "connecting": TeamsbotSessionStatus.JOINING.value, - "launching": TeamsbotSessionStatus.JOINING.value, - "navigating": TeamsbotSessionStatus.JOINING.value, - "in_lobby": TeamsbotSessionStatus.JOINING.value, - "joined": TeamsbotSessionStatus.ACTIVE.value, - "in_meeting": TeamsbotSessionStatus.ACTIVE.value, - "left": TeamsbotSessionStatus.ENDED.value, - "error": TeamsbotSessionStatus.ERROR.value, - } - - dbStatus = statusMap.get(status, TeamsbotSessionStatus.ACTIVE.value) - - updates = {"status": dbStatus} - if errorMessage: - updates["errorMessage"] = errorMessage - if dbStatus == TeamsbotSessionStatus.ACTIVE.value: - updates["startedAt"] = getUtcTimestamp() - elif dbStatus in [TeamsbotSessionStatus.ENDED.value, TeamsbotSessionStatus.ERROR.value]: - updates["endedAt"] = getUtcTimestamp() - - interface.updateSession(sessionId, updates) - await _emitSessionEvent(sessionId, "statusChange", {"status": status, "errorMessage": errorMessage}) - - # Flush remaining audio buffer before generating summary - if dbStatus in [TeamsbotSessionStatus.ENDED.value, TeamsbotSessionStatus.ERROR.value]: - if self._audioBuffer: - logger.info(f"[AudioChunk] Flushing remaining buffer on session end ({len(self._audioBuffer)} bytes)") - self._audioBuffer = b"" - self._audioBufferStartTime = 0.0 - self._audioBufferLastChunkTime = 0.0 - - # Generate summary when session ends - if dbStatus == TeamsbotSessionStatus.ENDED.value: - asyncio.create_task(self._generateMeetingSummary(sessionId)) - - async def _processAudioChunk( - self, - sessionId: str, - audioBase64: str, - sampleRate: int, - captureDiagnostics: Optional[Dict[str, Any]], - interface, - voiceInterface, - websocket: WebSocket, - ): - """Process an audio chunk from WebRTC capture. The bot-side VAD - (AudioWorklet / ScriptProcessor) already segments speech into 1-8s - voiced chunks. Here we apply a minimum-duration safety net: very short - chunks (<1s) are buffered until they reach 1s; everything else goes - straight to STT. A wall-clock timeout flushes stale buffers.""" - _MIN_CHUNK_SEC = 1.0 - _STALE_TIMEOUT_SEC = 3.0 - - try: - audioBytes = base64.b64decode(audioBase64) - if len(audioBytes) < 500: - return - - if captureDiagnostics: - trackId = captureDiagnostics.get("trackId") - readyState = captureDiagnostics.get("readyState") - rms = captureDiagnostics.get("rms") - nativeSampleRate = captureDiagnostics.get("nativeSampleRate") - logger.debug( - f"[AudioChunk] diagnostics: track={trackId}, readyState={readyState}, " - f"rms={rms}, nativeRate={nativeSampleRate}, bytes={len(audioBytes)}" - ) - - isSilent = False - if captureDiagnostics and captureDiagnostics.get("rms") is not None: - try: - rmsVal = float(captureDiagnostics.get("rms")) - if rmsVal < 0.0003: - isSilent = True - except Exception: - pass - - if not voiceInterface: - logger.warning(f"[AudioChunk] No voice interface available for session {sessionId}") - return - - now = time.time() - effectiveRate = sampleRate if sampleRate and sampleRate > 0 else 16000 - - if not isSilent: - if not self._audioBuffer: - self._audioBufferStartTime = now - self._audioBuffer += audioBytes - self._audioBufferLastChunkTime = now - self._audioBufferSampleRate = effectiveRate - - bufferDuration = len(self._audioBuffer) / (effectiveRate * 2) if self._audioBuffer else 0.0 - bufferAge = (now - self._audioBufferStartTime) if self._audioBuffer else 0.0 - - shouldFlush = ( - self._audioBuffer - and ( - bufferDuration >= _MIN_CHUNK_SEC - or (bufferAge >= _STALE_TIMEOUT_SEC and bufferDuration > 0.3) - ) - ) - - if not shouldFlush: - return - - flushBytes = self._audioBuffer - flushRate = self._audioBufferSampleRate - self._audioBuffer = b"" - self._audioBufferStartTime = 0.0 - self._audioBufferLastChunkTime = 0.0 - - flushDuration = len(flushBytes) / (flushRate * 2) - logger.info(f"[AudioChunk] Flushing buffer: {len(flushBytes)} bytes, {flushDuration:.1f}s, {flushRate}Hz") - - phraseHints = list(self._knownSpeakers) - if self.config.botName: - phraseHints.append(self.config.botName) - - sttResult = await voiceInterface.speechToText( - audioContent=flushBytes, - language=self.config.language or "de-DE", - sampleRate=flushRate, - channels=1, - skipFallbacks=True, - phraseHints=phraseHints if phraseHints else None, - audioFormat="linear16", - ) - - if sttResult and sttResult.get("success") and sttResult.get("text"): - text = sttResult["text"].strip() - if text: - resolvedSpeaker = self._resolveSpeakerForAudioCapture() - fromCaption = resolvedSpeaker.get("speakerResolvedFromHint", False) - logger.info( - f"[AudioChunk] STT result: speaker={resolvedSpeaker.get('speaker', 'Meeting Audio')} " - f"(fromCaption={fromCaption}), text={text[:80]}..." - ) - await self._processTranscript( - sessionId=sessionId, - speaker=resolvedSpeaker["speaker"], - text=text, - isFinal=True, - interface=interface, - voiceInterface=voiceInterface, - websocket=websocket, - source="audioCapture", - speakerResolvedFromHint=resolvedSpeaker["speakerResolvedFromHint"], - ) - except Exception as e: - logger.error(f"[AudioChunk] STT error for session {sessionId}: {type(e).__name__}: {e}") + # ========================================================================= + # Speaker & Trigger detection (kept on class — small, stateful) + # ========================================================================= def _registerSpeakerHint(self, speaker: str, text: str, sessionId: str = ""): - """Track current speaker from captions for STT attribution. - Retroactively attributes any unattributed STT segments whenever a - new non-bot caption speaker arrives (not just the first time).""" + """Track current speaker from captions for STT attribution.""" if not speaker: return normalizedSpeaker = speaker.strip() @@ -1377,313 +774,42 @@ class TeamsbotService: return {"speaker": self._lastCaptionSpeaker, "speakerResolvedFromHint": True} return {"speaker": "Unknown", "speakerResolvedFromHint": False} - async def _processTranscript( - self, - sessionId: str, - speaker: str, - text: str, - isFinal: bool, - interface, - voiceInterface, - websocket: WebSocket, - source: str = "caption", - speakerResolvedFromHint: Optional[bool] = None, - ): - """Process a transcript segment from captions or chat messages. - - Differential writing: When the same speaker continues (text grows - incrementally as captions stream), we UPDATE the existing DB record - instead of creating a cascade of near-duplicate rows. A new record - is only created when the speaker changes or the text is not a - continuation of the previous segment. - """ - - text = text.strip() - if not text: - return - - # Captions are used ONLY for speaker name resolution (never as transcript). - # Transcript text comes exclusively from audio STT or chat. - # Address detection (bot name in caption) still triggers AI analysis - # using existing audio-based context — but caption text itself is NOT - # added to the context buffer. - if source in ("caption", "speakerHint"): - self._registerSpeakerHint(speaker, text, sessionId) - - if ( - source == "speakerHint" - and isFinal - and not self._isBotSpeaker(speaker) - and self.config.responseMode != TeamsbotResponseMode.TRANSCRIBE_ONLY - and self._detectBotName(text) - ): - triggerTranscript = {"id": None, "speaker": speaker, "text": text, "source": source} - isNew = self._setPendingNameTrigger(sessionId, interface, voiceInterface, websocket, triggerTranscript) - if isNew: - logger.info(f"Session {sessionId}: Bot name in caption, debounce trigger started") - asyncio.create_task(self._checkPendingNameTrigger()) - # Fire a short audible "Moment..." in parallel so the - # speaker hears the bot react immediately, instead of - # waiting for debounce + SPEECH_TEAMS + agent (~5-30s). - self._currentQuickAckTask = asyncio.create_task( - self._runQuickAck(sessionId) - ) - return - - # Chat history: messages sent before the bot joined the meeting. - # Stored in DB for reference but NOT added to the AI context buffer, - # because old messages (e.g. "nyla, summarize the protocol") would - # be treated as current requests when AI analysis is triggered. - if source == "chatHistory": - transcriptData = TeamsbotTranscript( - sessionId=sessionId, - speaker=speaker, - text=text, - timestamp=getUtcTimestamp(), - confidence=1.0, - language=self.config.language, - isFinal=True, - source="chatHistory", - ).model_dump() - createdTranscript = interface.createTranscript(transcriptData) - - await _emitSessionEvent(sessionId, "transcript", { - "id": createdTranscript.get("id"), - "speaker": speaker, - "text": text, - "confidence": 1.0, - "timestamp": getUtcTimestamp(), - "isContinuation": False, - "source": "chatHistory", - "isHistory": True, - }) - logger.debug(f"Session {sessionId}: Chat history stored (no AI trigger): [{speaker}] {text[:60]}") - return - - # Filter out the bot's own speech (caption/audioCapture) — garbled text - # pollutes context. Chat from the bot is clean text and must appear in - # the transcript for all participants. - isBotSpeaker = self._isBotSpeaker(speaker) - if isBotSpeaker and source != "chat": - logger.debug(f"Session {sessionId}: Ignoring own bot caption from: [{speaker}] {text[:80]}...") - return - - # Differential transcript writing: - # audioCapture from same speaker → append text (merge STT chunks into one block) - # Start a new block after a pause (>5s gap between STT results) - sttPauseThreshold = 5.0 - isMerge = ( - source == "audioCapture" - and self._lastTranscriptSpeaker == speaker - and self._lastTranscriptText is not None - and self._lastTranscriptId is not None - and (time.time() - self._lastSttTime) < sttPauseThreshold - ) - - if isMerge: - mergedText = f"{self._lastTranscriptText} {text}" - interface.updateTranscript(self._lastTranscriptId, { - "text": mergedText, - "isFinal": isFinal, - }) - self._lastTranscriptText = mergedText - createdTranscript = {"id": self._lastTranscriptId} - - if self._contextBuffer and self._contextBuffer[-1].get("speaker") == speaker: - self._contextBuffer[-1]["text"] = mergedText - else: - transcriptData = TeamsbotTranscript( - sessionId=sessionId, - speaker=speaker, - text=text, - timestamp=getUtcTimestamp(), - confidence=1.0, - language=self.config.language, - isFinal=isFinal, - source=source, - ).model_dump() - - createdTranscript = interface.createTranscript(transcriptData) - - self._lastTranscriptSpeaker = speaker - self._lastTranscriptText = text - self._lastTranscriptId = createdTranscript.get("id") - - if source == "audioCapture" and speaker == "Unknown": - self._unattributedTranscriptIds.append(createdTranscript.get("id")) - - self._contextBuffer.append({ - "speaker": speaker or "Unknown", - "text": text, - "timestamp": getUtcTimestamp(), - "source": source, - }) - - maxSegments = self.config.contextWindowSegments - if len(self._contextBuffer) > maxSegments: - if not self._contextSummary and len(self._contextBuffer) > maxSegments * 1.5: - asyncio.create_task(self._summarizeContextBuffer(sessionId)) - self._contextBuffer = self._contextBuffer[-maxSegments:] - - session = interface.getSession(sessionId) - if session: - count = session.get("transcriptSegmentCount", 0) + 1 - interface.updateSession(sessionId, {"transcriptSegmentCount": count}) - - if source == "audioCapture": - self._lastSttTime = time.time() - - displayText = self._lastTranscriptText if isMerge else text - await _emitSessionEvent(sessionId, "transcript", { - "id": createdTranscript.get("id"), - "speaker": speaker, - "text": displayText, - "confidence": 1.0, - "timestamp": getUtcTimestamp(), - "isContinuation": isMerge, - "source": source, - "speakerResolvedFromHint": ( - speakerResolvedFromHint - if speakerResolvedFromHint is not None - else False - ), - }) - - if not isFinal: - return - - if self.config.responseMode == TeamsbotResponseMode.TRANSCRIBE_ONLY: - return - - # Bot's own chat: stored for display only, never trigger AI - if source == "chat" and isBotSpeaker: - return - - # Stop phrases: HARD STOP, no AI round-trip. We previously routed - # this through ``_analyzeAndRespond`` which spent 1-2 seconds in - # the speech LLM just to classify the intent, during which the - # current TTS kept playing — and the LLM round-trip would also - # produce yet another response that joined the queue. The new - # path goes straight to the browser bot's audio cancel and - # invalidates everything else in flight. - if self._isStopPhrase(text): - logger.info( - f"Session {sessionId}: Stop phrase detected ('{text.strip()[:60]}'), " - f"hard-cancelling in-flight speech immediately" - ) - await self._cancelInFlightSpeech( - sessionId=sessionId, - websocket=websocket, - reason="userStopPhrase", - ) - return - - # Update activity for any pending debounced trigger - if self._pendingNameTrigger: - self._pendingNameTrigger["lastActivity"] = time.time() - - # Bot name detection → debounced trigger (wait for speaker to finish) - if self._detectBotName(text): - isNew = self._setPendingNameTrigger(sessionId, interface, voiceInterface, websocket, createdTranscript) - if isNew: - asyncio.create_task(self._checkPendingNameTrigger()) - # Audible early-feedback ack ("Moment...") in parallel — runs - # while we still wait the debounce window and SPEECH_TEAMS - # decides what to actually answer. - self._currentQuickAckTask = asyncio.create_task( - self._runQuickAck(sessionId) - ) - return - - # Follow-up window: after a bot response, trigger AI for any human speech - # without requiring the bot name — the AI decides via shouldRespond - if ( - source == "audioCapture" - and not self._isBotSpeaker(speaker) - and time.time() < self._followUpWindowEnd - and not self._pendingNameTrigger - ): - isNew = self._setPendingNameTrigger(sessionId, interface, voiceInterface, websocket, createdTranscript) - if isNew: - logger.info(f"Session {sessionId}: Follow-up window trigger (no name needed)") - asyncio.create_task(self._checkPendingNameTrigger()) - return - - # Periodic trigger (only when no debounce pending) - if not self._pendingNameTrigger: - shouldTrigger = self._shouldTriggerAnalysis(text) - if shouldTrigger: - logger.info(f"Session {sessionId}: Periodic trigger (buffer: {len(self._contextBuffer)} segments)") - await self._analyzeAndRespond(sessionId, interface, voiceInterface, websocket, createdTranscript) - def _isBotSpeaker(self, speaker: str) -> bool: - """Check if a transcript speaker is the bot itself. - - Teams captions show the bot as e.g. "BotName (Unverified)" or - "Nyla Larsson" depending on auth/anonymous join. We match against: - - The configured/derived bot name - - The bot account display name if authenticated - """ + """Check if a transcript speaker is the bot itself.""" if not speaker: return False - speakerLower = speaker.lower().strip() - - # Match against configured bot name botName = self.config.botName.lower().strip() if botName and botName in speakerLower: return True - - # Match against bot account email prefix (e.g. "nyla.larsson" from "nyla.larsson@poweron.swiss") botAccountEmail = getattr(self, '_botAccountEmail', None) or getattr(self.config, 'botAccountEmail', None) if botAccountEmail: emailPrefix = botAccountEmail.split("@")[0].lower().replace(".", " ") if emailPrefix in speakerLower: return True - return False def _shouldTriggerAnalysis(self, transcriptText: str, allowPeriodic: bool = True) -> bool: - """ - Decide whether to trigger AI analysis based on the latest transcript. - Bot name detection is handled separately via debounce. - This method only checks periodic/cooldown triggers. - """ + """Decide whether to trigger AI analysis based on the latest transcript.""" now = time.time() timeSinceLastCall = now - self._lastAiCallTime - if timeSinceLastCall < self.config.triggerCooldownSeconds: return False - if allowPeriodic and timeSinceLastCall >= self.config.triggerIntervalSeconds: logger.info(f"Trigger: Periodic interval ({self.config.triggerIntervalSeconds}s) elapsed ({timeSinceLastCall:.1f}s)") return True - return False def _isStopPhrase(self, text: str) -> bool: - """Check if text is an immediate-cancel command from the meeting. - - Recognised intents (any language we hear in practice): - * Hard stop: stop / stopp / halt / ruhe / stille / arrete / quiet / shut - * Pause / wait: warte / wait / moment / pause / hold (hold on) - * Silence: sei still / be quiet / shut up / aufhoeren / aufhören / silence - Hits trigger the direct stop pipeline in ``_cancelInFlightSpeech``: - kill TTS, invalidate pending generations, clear name-trigger debounce. - Critically: NO new AI call is fired — the user explicitly asked the - bot to be quiet, so the worst thing we could do is generate yet - another response on top of the one we just cancelled. - """ + """Check if text is an immediate-cancel command from the meeting.""" if not text or len(text.strip()) < 2: return False t = text.strip().lower() words = [w.strip(".,!?:;\"'()[]") for w in t.split() if w.strip()] wordSet = set(words) stopWords = { - # Hard-stop verbs "stop", "stopp", "halt", "ruhe", "stille", "schweig", "arrete", "quiet", "shut", "silence", - # Pause / wait verbs (still "be quiet now" semantics) "warte", "wait", "moment", "pause", } if wordSet & stopWords: @@ -1700,98 +826,16 @@ class TeamsbotService: return False def _makeAnswerCancelHook(self) -> Callable[[], bool]: - """Capture the current ``_answerGenerationCounter`` and return a - zero-arg predicate that returns ``True`` once a hard stop (or any - future "supersede this answer" event) has bumped the counter. - - Pass the returned predicate as ``isCancelled`` into - ``_speakTextChunked`` so a multi-chunk dispatch can bail out - between chunks instead of speaking a 30-second answer to the end. - """ + """Capture the current generation counter and return a cancel predicate.""" snapshot = self._answerGenerationCounter return lambda: self._answerGenerationCounter != snapshot - async def _cancelInFlightSpeech( - self, - sessionId: str, - websocket: Optional[WebSocket], - reason: str, - ) -> None: - """Hard stop everything the bot is currently doing in the meeting. - - Pipeline (ALL synchronous from the caller's point of view, no AI - round-trips): - - 1. Bump ``_answerGenerationCounter`` so any in-flight TTS chunk - loop, agent escalation or quick-ack drops its remaining work - the moment it next checks the counter. - 2. Clear ``_pendingNameTrigger`` so a debounced "speaker just said - the bot name" trigger that was queued before the stop word - cannot wake up 3 seconds later and answer anyway. - 3. Cancel tracked background tasks (escalation, quick-ack). The - tasks themselves swallow ``CancelledError`` in their finally - block. - 4. Send ``{"type":"stopAudio"}`` to the browser bot — it stops the - current playback in the AudioContext and clears its play queue - so nothing buffered comes through afterwards. - - Deliberately does NOT generate a new response. The user just told - the bot to be quiet; producing a "Okay, ich bin still" reply on - top would be the exact opposite of what was asked for. - """ - self._answerGenerationCounter += 1 - gen = self._answerGenerationCounter - logger.info( - f"Session {sessionId}: Cancelling in-flight speech " - f"(reason={reason}, gen={gen})" - ) - - if self._pendingNameTrigger: - logger.info( - f"Session {sessionId}: Dropping pending debounced name " - f"trigger (was queued before stop)" - ) - self._pendingNameTrigger = None - - for taskAttr in ("_currentEscalationTask", "_currentQuickAckTask"): - task = getattr(self, taskAttr, None) - if task is not None and not task.done(): - logger.info( - f"Session {sessionId}: Cancelling background task " - f"{taskAttr}" - ) - task.cancel() - - if websocket is not None: - try: - await websocket.send_text(json.dumps({ - "type": "stopAudio", - "sessionId": sessionId, - "reason": reason, - })) - except Exception as stopErr: - logger.warning( - f"Session {sessionId}: Failed to send stopAudio to " - f"browser bot: {stopErr}" - ) - - try: - await _emitSessionEvent(sessionId, "speechCancelled", { - "reason": reason, - "generation": gen, - "timestamp": getUtcTimestamp(), - }) - except Exception: - pass - def _detectBotName(self, text: str) -> bool: """Check if text contains the bot's name (exact or phonetically similar).""" botNameLower = self.config.botName.lower() textLower = text.lower() - if botNameLower in textLower: return True - botFirstName = botNameLower.split()[0] if " " in botNameLower else botNameLower if len(botFirstName) >= 3: for word in textLower.split(): @@ -1823,314 +867,13 @@ class TeamsbotService: } return True - async def _warmEphemeralPhrasePool(self, sessionId: str) -> None: - """Fire-and-forget background task: generate the ephemeral phrase - pool for every kind defined in ``_EPHEMERAL_PHRASE_INTENTS`` so the - first quick-ack / interim notice doesn't pay the AI round-trip - latency at runtime. Failures are logged but never raised — the - runtime selectors handle empty pools by silently skipping the cue.""" - try: - for kind in _EPHEMERAL_PHRASE_INTENTS: - try: - await self._getEphemeralPhrases(kind) - except Exception as innerErr: - logger.warning( - f"Session {sessionId}: Phrase pool warmup failed for " - f"kind={kind}: {innerErr}" - ) - except Exception as warmErr: - logger.warning( - f"Session {sessionId}: Phrase pool warmup task crashed: {warmErr}" - ) - - # ---------------------------------------------------------------- Voice - # When the bot's full answer is a long structured chat post (markdown - # tables, bullet lists, headings, multi-paragraph) we MUST NOT read it - # out verbatim into the meeting — even after sanitisation it sounds - # like a wall of text and easily takes 5+ minutes. The chat keeps the - # full answer; the audio path goes through ``_summarizeForVoice`` which - # asks the AI for a 1-3 sentence spoken paraphrase in the configured - # bot persona / language. - - # Threshold: anything longer than this many characters (after sanitise) - # OR any answer whose source contains markdown structure (tables / - # multiple bullets / multiple headings) gets condensed before TTS. - _VOICE_DIRECT_MAX_CHARS = 600 - _VOICE_SUMMARY_MAX_CHARS = 350 - - @staticmethod - def _looksLikeStructuredText(raw: str) -> bool: - """Heuristic: does the original answer have markdown structure that - would be miserable to listen to verbatim? Used to trigger the - AI summary path even when the sanitised text is short enough.""" - if not raw: - return False - if raw.count("|") >= 4: # at least one markdown table row - return True - if raw.count("\n#") >= 1: # at least one heading after newline - return True - if raw.count("\n- ") + raw.count("\n* ") + raw.count("\n• ") >= 3: - return True # 3+ bullets → list-like - if re.search(r"\n\d+[\.\)]\s", raw): # numbered list - count = len(re.findall(r"(?m)^\s*\d+[\.\)]\s", raw)) - if count >= 3: - return True - return False - - async def _summarizeForVoice( - self, - sessionId: str, - rawAnswer: str, - ) -> str: - """Return a SHORT, naturally-spoken paraphrase of ``rawAnswer`` for - TTS playback. Falls back to the sanitised + truncated original if - the AI call fails — never blocks the response. - - The chat / DB / UI keep the original ``rawAnswer`` untouched. Only - the voice channel goes through this condensation. - """ - if not rawAnswer or not rawAnswer.strip(): - return "" - - sanitised = _voiceFriendlyMeetingText(rawAnswer) - # Short + unstructured → speak as-is, no AI round-trip - if ( - len(sanitised) <= self._VOICE_DIRECT_MAX_CHARS - and not self._looksLikeStructuredText(rawAnswer) - ): - return sanitised - - targetLang = (self.config.language or "de-DE").strip() - botName = (self.config.botName or "").strip() or "the assistant" - persona = (self.config.aiSystemPrompt or "").strip() - personaBlock = ( - f"\n\nBOT PERSONA / TONE:\n{persona}\n" - if persona else "" - ) - - prompt = ( - f"You are condensing a long written answer into a SHORT spoken " - f"paraphrase that the assistant '{botName}' will say out loud " - f"into a Microsoft Teams meeting. The full written answer is " - f"already in the meeting chat — your job is to summarise it for " - f"the EAR, not the eye.\n\n" - f"STRICT REQUIREMENTS:\n" - f"1. Output language: BCP-47 '{targetLang}'. No other language.\n" - f"2. 1 to 3 sentences, max ~{self._VOICE_SUMMARY_MAX_CHARS} characters total.\n" - f"3. Natural spoken style — no headings, no bullet points, no " - f"tables, no markdown, no emojis, no enumerations like 'Erstens... " - f"Zweitens...' unless that genuinely flows in speech.\n" - f"4. Capture the essence and the most important conclusion. Do " - f"NOT try to fit every detail. Listeners can read the chat for " - f"the full version.\n" - f"5. End by gently pointing the audience to the chat for details, " - f"e.g. 'Details stehen im Chat.' (adapted to the target language).\n" - f"6. Output ONLY the spoken text. No JSON, no quotes around it, " - f"no preamble like 'Here is the summary:'.\n" - f"{personaBlock}\n" - f"FULL WRITTEN ANSWER (markdown-formatted, sometimes long):\n" - f"---\n{rawAnswer.strip()[:6000]}\n---\n" - ) - - try: - aiService = createAiService( - self.currentUser, self.mandateId, self.instanceId - ) - await aiService.ensureAiObjectsInitialized() - request = AiCallRequest( - prompt=prompt, - context="", - options=AiCallOptions( - operationType=OperationTypeEnum.DATA_ANALYSE, - priority=PriorityEnum.SPEED, - ), - ) - response = await aiService.callAi(request) - except Exception as aiErr: - logger.warning( - f"Session {sessionId}: Voice summary AI call failed: {aiErr}" - ) - return sanitised[: self._VOICE_DIRECT_MAX_CHARS] - - if not response or response.errorCount != 0 or not response.content: - logger.warning( - f"Session {sessionId}: Voice summary returned empty/error" - ) - return sanitised[: self._VOICE_DIRECT_MAX_CHARS] - - spoken = response.content.strip() - # Defensive sanitiser pass — the model usually obeys the - # "no markdown" instruction but not always. - spoken = _voiceFriendlyMeetingText(spoken) - if not spoken: - return sanitised[: self._VOICE_DIRECT_MAX_CHARS] - - logger.info( - f"Session {sessionId}: Voice summary generated " - f"(orig={len(rawAnswer)} chars, sanitised={len(sanitised)}, " - f"spoken={len(spoken)})" - ) - return spoken - - async def _pickQuickAckText(self) -> Optional[str]: - """Return a short ack text in the bot's configured language. The - actual phrases are AI-generated once per session (cached) and rotated - round-robin so consecutive acks don't sound identical. Returns - ``None`` only if AI generation completely failed and no fallback - variant could be produced — in that case the caller silently skips - the ack.""" - return await self._pickEphemeralPhrase("quickAck") - - async def _pickEphemeralPhrase( - self, - kind: str, - substitutions: Optional[Dict[str, Any]] = None, - ) -> Optional[str]: - """Round-robin selector over the cached phrase pool for ``kind``. - Lazily generates the pool on first use. ``substitutions`` is applied - to the chosen phrase via ``str.format(**substitutions)`` so kinds - like ``agentRound`` can render ``{round}`` / ``{maxRounds}``. - Returns ``None`` if no phrases are available.""" - variants = await self._getEphemeralPhrases(kind) - if not variants: - return None - idx = self._phrasePoolIdx.get(kind, 0) % len(variants) - self._phrasePoolIdx[kind] = (idx + 1) % len(variants) - chosen = variants[idx] - if substitutions: - try: - chosen = chosen.format(**substitutions) - except (KeyError, IndexError, ValueError) as fmtErr: - # The AI didn't include the expected placeholder — return the - # raw phrase rather than crash. The user still hears something - # in the right language; only the numeric hint is missing. - logger.debug( - f"Ephemeral phrase substitution failed for kind={kind}: {fmtErr}" - ) - return chosen - - async def _getEphemeralPhrases(self, kind: str) -> List[str]: - """Return the cached pool of AI-generated variants for ``kind``, - generating it on first request. Subsequent calls hit the in-memory - cache. Concurrent first-time callers are serialised by the pool lock - so only ONE AI request is fired per kind per session.""" - cached = self._phrasePool.get(kind) - if cached: - return cached - async with self._phrasePoolLock: - cached = self._phrasePool.get(kind) - if cached: - return cached - phrases = await self._generateEphemeralPhrases( - kind, _EPHEMERAL_PHRASE_VARIANTS - ) - if phrases: - self._phrasePool[kind] = phrases - return phrases - - async def _generateEphemeralPhrases( - self, kind: str, count: int - ) -> List[str]: - """Ask the AI to produce ``count`` short utterances for ``kind`` in - the bot's configured language and persona. Returns ``[]`` on any - failure — callers must treat empty as 'silently skip this ephemeral - cue', NEVER fall back to a hardcoded localized string.""" - intent = _EPHEMERAL_PHRASE_INTENTS.get(kind) - if not intent: - logger.warning(f"Unknown ephemeral phrase kind requested: {kind}") - return [] - - targetLang = (self.config.language or "").strip() or "en-US" - botName = (self.config.botName or "the assistant").strip() - persona = (self.config.aiSystemPrompt or "").strip() - - # The prompt is in English on purpose — these are instructions to the - # LLM, not user-facing text. The OUTPUT is required to be in - # ``targetLang``. We ask for a strict JSON array so parsing is robust. - prompt = ( - f"You are localizing short SPOKEN-LANGUAGE utterances for a " - f"meeting assistant named '{botName}'.\n\n" - f"Persona / style guide for the assistant:\n" - f"{persona or '(no persona configured — use a neutral, polite, professional tone)'}\n\n" - f"Target spoken language (BCP-47 code): {targetLang}\n\n" - f"Utterance intent:\n{intent}\n\n" - f"Generate {count} DIFFERENT variants matching this intent, in " - f"the target language. Variants should feel natural when spoken " - f"aloud, not robotic. Do NOT include the assistant's name in " - f"the variants.\n\n" - f"Output STRICTLY a JSON array of {count} plain-text strings, " - f"with no markdown fences, no commentary, no surrounding " - f"quotation marks beyond the JSON syntax itself. Example " - f"format: [\"...\", \"...\", \"...\", \"...\"]" - ) - - try: - aiService = createAiService( - self.currentUser, self.mandateId, self.instanceId - ) - await aiService.ensureAiObjectsInitialized() - request = AiCallRequest( - prompt=prompt, - context="", - options=AiCallOptions( - operationType=OperationTypeEnum.DATA_ANALYSE, - priority=PriorityEnum.SPEED, - ), - ) - response = await aiService.callAi(request) - except Exception as aiErr: - logger.warning( - f"Ephemeral phrase generation failed (kind={kind}, lang={targetLang}): {aiErr}" - ) - return [] - - if not response or response.errorCount != 0 or not response.content: - logger.warning( - f"Ephemeral phrase generation returned empty/error " - f"(kind={kind}, lang={targetLang})" - ) - return [] - - raw = response.content.strip() - # Strip optional ```json ... ``` fences before parsing. - raw = re.sub(r"^```(?:json)?\s*", "", raw) - raw = re.sub(r"\s*```\s*$", "", raw) - try: - arr = json.loads(raw) - except json.JSONDecodeError as parseErr: - logger.warning( - f"Ephemeral phrase generation: could not parse JSON " - f"(kind={kind}, lang={targetLang}): {parseErr} " - f"raw={raw[:200]}" - ) - return [] - if not isinstance(arr, list): - return [] - cleaned = [ - str(v).strip() - for v in arr - if isinstance(v, str) and str(v).strip() - ] - cleaned = cleaned[:count] - if cleaned: - logger.info( - f"Ephemeral phrase pool generated (kind={kind}, " - f"lang={targetLang}, count={len(cleaned)})" - ) - return cleaned - def _shouldFireQuickAck(self) -> bool: - """Centralized gate so the call sites stay short and consistent.""" + """Centralized gate for quick-ack firing.""" now = time.time() if (now - self._lastQuickAckTs) < _QUICK_ACK_MIN_INTERVAL_SEC: return False - # If we are already producing a real response, the ack would step on - # the actual answer's TTS — skip it. Same for an in-flight agent - # escalation: the agent will deliver its own answer (and we already - # spoke an interim "moment please" when it started). if self._aiAnalysisInProgress or self._agentEscalationInFlight: return False - # Voice channel must be active. Chat-only mode would just spam "...". channelRaw = self.config.responseChannel channelStr = ( channelRaw.value if hasattr(channelRaw, "value") else str(channelRaw) @@ -2144,852 +887,36 @@ class TeamsbotService: return False return True - async def _runQuickAck(self, sessionId: str) -> None: - """Background task: speak the short ack into the meeting via TTS. - - Designed to be fired as ``asyncio.create_task(self._runQuickAck(...))`` - the moment the bot's name is detected — does not block the regular - debounced analysis pipeline. Persists nothing to the DB and emits no - botResponse event; this is purely an audio cue ("Moment...") so the - speaker hears within ~1s that the bot is reacting. - """ - websocket = self._websocket - voiceInterface = self._voiceInterface - if websocket is None or voiceInterface is None: - return - if not self._shouldFireQuickAck(): - return - ackText = await self._pickQuickAckText() - if not ackText: - return - # Mark the throttle BEFORE TTS so two near-simultaneous detections - # don't both fire (TTS dispatch can take a few hundred ms). - self._lastQuickAckTs = time.time() - try: - await _emitSessionEvent(sessionId, "quickAck", { - "text": ackText, - "timestamp": getUtcTimestamp(), - }) - cancelHook = self._makeAnswerCancelHook() - async with self._meetingTtsLock: - outcome = await _speakTextChunked( - websocket=websocket, - voiceInterface=voiceInterface, - sessionId=sessionId, - voiceText=ackText, - languageCode=self.config.language, - voiceName=self.config.voiceId, - isCancelled=cancelHook, - ) - if not outcome.get("success"): - logger.info( - f"Session {sessionId}: Quick ack TTS failed silently " - f"({outcome.get('error')}) — main response will still go through" - ) - except asyncio.CancelledError: - logger.info(f"Session {sessionId}: Quick ack cancelled by stop signal") - except Exception as ackErr: - logger.warning(f"Session {sessionId}: Quick ack failed: {ackErr}") - finally: - self._currentQuickAckTask = None - - async def _checkPendingNameTrigger(self, delaySec: float = 3.0): - """Async loop: fire the pending name trigger once the speaker is quiet.""" - await asyncio.sleep(delaySec) - if not self._pendingNameTrigger: - return - - now = time.time() - lastActivity = self._pendingNameTrigger.get("lastActivity", 0) - detectedAt = self._pendingNameTrigger.get("detectedAt", 0) - quietSec = now - lastActivity - totalWaitSec = now - detectedAt - - if quietSec >= 3.0 or totalWaitSec >= 15.0: - trigger = self._pendingNameTrigger - self._pendingNameTrigger = None - logger.info( - f"Session {trigger['sessionId']}: Debounced name trigger fires " - f"(quiet={quietSec:.1f}s, totalWait={totalWaitSec:.1f}s)" - ) - await self._analyzeAndRespond( - trigger["sessionId"], - trigger["interface"], - trigger["voiceInterface"], - trigger["websocket"], - trigger["triggerTranscript"], - ) - else: - remaining = max(0.5, 3.0 - quietSec) - asyncio.create_task(self._checkPendingNameTrigger(remaining)) - - async def _analyzeAndRespond( - self, - sessionId: str, - interface, - voiceInterface, - websocket: WebSocket, - triggerTranscript: Dict[str, Any], - ): - """Run SPEECH_TEAMS AI analysis and respond if needed.""" - if self._aiAnalysisInProgress: - logger.info(f"Session {sessionId}: AI analysis already in progress, skipping duplicate trigger") - return - # An agent escalation from a previous trigger may still be researching - # (it lives in its own task, ``_aiAnalysisInProgress`` was already - # released when SPEECH_TEAMS returned). If we let a fresh SPEECH_TEAMS - # run now, both pipelines would race to the meeting voice channel and - # the operator would hear "two bots talking". Skip until the agent - # finishes; the speaker can re-trigger by saying the bot name again - # if they have a new question. - if self._agentEscalationInFlight: - logger.info( - f"Session {sessionId}: Agent escalation still in flight — " - f"skipping new SPEECH_TEAMS trigger to prevent overlapping replies" - ) - return - self._aiAnalysisInProgress = True - self._lastAiCallTime = time.time() - - # Build transcript context from buffer. - # Mark bot's own utterances and chat messages for the AI. - contextLines = [] - for segment in self._contextBuffer: - speaker = segment.get("speaker", "Unknown") - text = segment.get("text", "") - segSource = segment.get("source", "caption") - prefix = "Chat" if segSource == "chat" else "" - if self._isBotSpeaker(speaker): - contextLines.append(f"[YOU ({self.config.botName})]: {text}") - elif prefix: - contextLines.append(f"[{prefix}: {speaker}]: {text}") - else: - contextLines.append(f"[{speaker}]: {text}") - - # Include session context if provided by the user at session start - sessionContextStr = "" - if self._sessionContext: - sessionContextStr = f"\nSESSION_CONTEXT (background knowledge provided by the user):\n{self._sessionContext}\n" - - # Include summary of earlier conversation if available - summaryStr = "" - if self._contextSummary: - summaryStr = f"\nEARLIER_CONVERSATION_SUMMARY:\n{self._contextSummary}\n" - - # Persistent director prompts: private operator instructions that stay - # in effect across triggers (e.g. "respond in English", "always be brief"). - directorStr = self._buildPersistentDirectorContext() - - transcriptContext = f"BOT_NAME:{self.config.botName}{sessionContextStr}{summaryStr}{directorStr}\nRECENT_TRANSCRIPT:\n" + "\n".join(contextLines) - - # Call SPEECH_TEAMS - try: - aiService = createAiService(self.currentUser, self.mandateId, self.instanceId) - await aiService.ensureAiObjectsInitialized() - - request = AiCallRequest( - prompt=self.config.aiSystemPrompt, - context=transcriptContext, - options=AiCallOptions( - operationType=OperationTypeEnum.SPEECH_TEAMS, - priority=PriorityEnum.SPEED, - ) - ) - - response = await aiService.callAi(request) - - # Parse structured response - try: - speechResult = SpeechTeamsResponse.model_validate_json(response.content) - except Exception: - # Try to extract JSON from response content - try: - jsonStr = response.content - if "```json" in jsonStr: - jsonStr = jsonStr.split("```json")[1].split("```")[0] - elif "```" in jsonStr: - jsonStr = jsonStr.split("```")[1].split("```")[0] - speechResult = SpeechTeamsResponse.model_validate_json(jsonStr.strip()) - except Exception as parseErr: - logger.warning(f"Failed to parse SPEECH_TEAMS response: {parseErr}") - speechResult = SpeechTeamsResponse( - shouldRespond=False, - reasoning=f"Parse error: {str(parseErr)[:100]}", - detectedIntent="none" - ) - - logger.info( - f"SPEECH_TEAMS result: shouldRespond={speechResult.shouldRespond}, " - f"intent={speechResult.detectedIntent}, " - f"reasoning={speechResult.reasoning[:80]}..." - ) - - # Emit analysis event (always, for debug/UI) - await _emitSessionEvent(sessionId, "analysis", { - "shouldRespond": speechResult.shouldRespond, - "detectedIntent": speechResult.detectedIntent, - "reasoning": speechResult.reasoning, - "modelName": response.modelName, - "processingTime": response.processingTime, - "priceCHF": response.priceCHF, - "needsAgent": speechResult.needsAgent, - "agentReason": speechResult.agentReason, - }) - - # Hybrid routing: SPEECH_TEAMS detected a complex request that - # requires the full agent (web research, mail, multi-step). Hand - # off to the agent path; do NOT speak the SPEECH_TEAMS placeholder. - if speechResult.needsAgent: - # Director prompts (persistent + recent one-shot) have already - # delivered files to the operator. The escalation agent MUST see - # them — otherwise it answers "summarize the doc" with general - # babble because the SPEECH_TEAMS prompt itself never had file - # access. We also forward the prior agent analysis so the - # escalation can build on, not duplicate, the earlier work. - briefings = self._collectActiveDirectorBriefings() - briefingFileIds = self._collectDirectorFileIds() - briefingBlock = "" - if briefings: - parts = [] - for b in briefings: - seg = f"- ({b.get('mode')}) {b.get('text', '')}".rstrip() - if b.get("fileIds"): - seg += f"\n attachedFileIds: {', '.join(b['fileIds'])}" - if b.get("note"): - note = b["note"] - seg += ( - "\n priorAgentAnalysis: " - + (note if len(note) <= 800 else note[:800] + "...") - ) - parts.append(seg) - briefingBlock = ( - "\n\nACTIVE_OPERATOR_BRIEFINGS (private; you may read the " - "attached files via summarizeContent / readFile / " - "readContentObjects to answer the user precisely; do NOT " - "quote the directive text itself):\n" + "\n".join(parts) - ) - logger.info( - f"Session {sessionId}: SPEECH_TEAMS escalates to agent. " - f"Reason: {speechResult.agentReason or speechResult.reasoning} | " - f"briefings={len(briefings)}, fileIds={len(briefingFileIds)}" - ) - taskBrief = ( - (speechResult.agentReason - or speechResult.responseText - or "Verarbeite die juengste Sprecheranfrage und antworte ins Meeting.") - + briefingBlock - ) - # Mark escalation as in-flight BEFORE we create the task so the - # ``_aiAnalysisInProgress=False`` released in our finally block - # cannot let a competing speech trigger sneak past the gate - # before the agent task has even been scheduled. - self._agentEscalationInFlight = True - self._currentEscalationTask = asyncio.create_task( - self._runEscalationAndRelease( - sessionId=sessionId, - taskBrief=taskBrief, - briefingFileIds=briefingFileIds, - triggerTranscriptId=triggerTranscript.get("id"), - ) - ) - return - - # Step 4a: Handle STOP intent -- stop audio immediately - if speechResult.detectedIntent == "stop": - logger.info(f"Session {sessionId}: AI detected STOP intent: {speechResult.reasoning}") - if websocket: - try: - await websocket.send_text(json.dumps({ - "type": "stopAudio", - "sessionId": sessionId, - })) - except Exception as stopErr: - logger.warning(f"Failed to send stop command: {stopErr}") - return - - # Step 4b: Respond if AI decided to - if speechResult.shouldRespond and speechResult.responseText: - - if self.config.responseMode == TeamsbotResponseMode.MANUAL: - # In manual mode, suggest but don't send - await _emitSessionEvent(sessionId, "suggestedResponse", { - "responseText": speechResult.responseText, - "detectedIntent": speechResult.detectedIntent, - "reasoning": speechResult.reasoning, - }) - return - - # Determine response channel: per-request (AI) overrides config - channels = speechResult.responseChannels - if channels and isinstance(channels, list): - channelStr = ",".join(str(c).lower().strip() for c in channels) - sendVoice = "voice" in channelStr - sendChat = "chat" in channelStr - logger.info(f"Response channel (from AI): voice={sendVoice}, chat={sendChat}") - else: - channelRaw = self.config.responseChannel - channelStr = (channelRaw.value if hasattr(channelRaw, 'value') else str(channelRaw)).lower().strip() - sendVoice = channelStr in ("voice", "both") - sendChat = channelStr in ("chat", "both") - logger.info(f"Response channel (from config): '{channelStr}'") - - if sendVoice and sendChat: - responseType = TeamsbotResponseType.BOTH - elif sendVoice: - responseType = TeamsbotResponseType.AUDIO - else: - responseType = TeamsbotResponseType.CHAT - - # Suppress duplicate responses in short windows ("repeat loop" protection). - canonicalText = ( - speechResult.responseText - or speechResult.responseTextForVoice - or speechResult.responseTextForChat - or "" - ) - normalizedResponse = (canonicalText or "").strip().lower() - nowTs = time.time() - if ( - normalizedResponse - and self._lastBotResponseText == normalizedResponse - and (nowTs - self._lastBotResponseTs) < 90 - ): - logger.info(f"Session {sessionId}: Suppressing duplicate bot response within 90s window") - await _emitSessionEvent(sessionId, "analysis", { - "shouldRespond": False, - "detectedIntent": speechResult.detectedIntent, - "reasoning": "Suppressed duplicate response within 90s", - "modelName": response.modelName, - "processingTime": response.processingTime, - "priceCHF": response.priceCHF, - }) - return - - # Resolve text per channel (AI can send different content to voice vs chat) - textForVoice = speechResult.responseTextForVoice or speechResult.responseText - textForChat = speechResult.responseTextForChat or speechResult.responseText - storedText = textForChat or textForVoice or speechResult.responseText - - # 4a: Voice response (TTS -> Audio to bot, chunked for long replies) - if sendVoice and textForVoice: - await _emitSessionEvent(sessionId, "ttsDeliveryStatus", { - "status": "requested", - "hasWebSocket": websocket is not None, - "message": "TTS generation requested", - "timestamp": getUtcTimestamp(), - }) - logger.info( - f"Session {sessionId}: TTS requested (websocket_available={websocket is not None})" - ) - if not websocket: - logger.warning( - f"Session {sessionId}: TTS skipped (bot websocket unavailable, likely fallback mode)" - ) - await _emitSessionEvent(sessionId, "ttsDeliveryStatus", { - "status": "unavailable", - "hasWebSocket": False, - "message": "TTS skipped — bot websocket unavailable", - "timestamp": getUtcTimestamp(), - }) - if not sendChat: - sendChat = True - else: - # Long / structured answers → AI condenses for ear; chat keeps full text. - spokenText = await self._summarizeForVoice(sessionId, textForVoice) - cancelHook = self._makeAnswerCancelHook() - async with self._meetingTtsLock: - ttsOutcome = await _speakTextChunked( - websocket=websocket, - voiceInterface=voiceInterface, - sessionId=sessionId, - voiceText=spokenText, - languageCode=self.config.language, - voiceName=self.config.voiceId, - isCancelled=cancelHook, - ) - if ttsOutcome.get("success"): - logger.info( - f"Session {sessionId}: TTS audio dispatched to bot " - f"(chunks={ttsOutcome.get('chunks')}, played={ttsOutcome.get('played')})" - ) - await _emitSessionEvent(sessionId, "ttsDeliveryStatus", { - "status": "dispatched", - "hasWebSocket": True, - "chunks": ttsOutcome.get("chunks"), - "played": ttsOutcome.get("played"), - "timestamp": getUtcTimestamp(), - }) - else: - logger.warning( - f"TTS failed for session {sessionId}: {ttsOutcome.get('error')}" - ) - await _emitSessionEvent(sessionId, "ttsDeliveryStatus", { - "status": "failed", - "hasWebSocket": True, - "chunks": ttsOutcome.get("chunks"), - "played": ttsOutcome.get("played"), - "message": ttsOutcome.get("error"), - "timestamp": getUtcTimestamp(), - }) - if not sendChat: - sendChat = True # Fallback to chat if voice-only and TTS failed - - # 4b: Chat response (send text message to meeting chat) - if sendChat and textForChat: - try: - if websocket: - await websocket.send_text(json.dumps({ - "type": "sendChatMessage", - "sessionId": sessionId, - "text": textForChat, - })) - logger.info(f"Chat response sent for session {sessionId}") - except Exception as chatErr: - logger.warning(f"Chat message send failed for session {sessionId}: {chatErr}") - - # 4b: Store bot response - botResponseData = TeamsbotBotResponse( - sessionId=sessionId, - responseText=storedText, - responseType=responseType, - detectedIntent=speechResult.detectedIntent, - reasoning=speechResult.reasoning, - triggeredByTranscriptId=triggerTranscript.get("id"), - modelName=response.modelName, - processingTime=response.processingTime, - priceCHF=response.priceCHF, - timestamp=getUtcTimestamp(), - ).model_dump() - - createdResponse = interface.createBotResponse(botResponseData) - - # 4c: Emit SSE event - await _emitSessionEvent(sessionId, "botResponse", { - "id": createdResponse.get("id"), - "responseText": storedText, - "responseType": responseType.value, - "detectedIntent": speechResult.detectedIntent, - "reasoning": speechResult.reasoning, - "modelName": response.modelName, - "processingTime": response.processingTime, - "priceCHF": response.priceCHF, - "timestamp": botResponseData.get("timestamp"), - }) - - # Update session response count - session = interface.getSession(sessionId) - if session: - count = session.get("botResponseCount", 0) + 1 - interface.updateSession(sessionId, {"botResponseCount": count}) - - self._lastBotResponseText = normalizedResponse - self._lastBotResponseTs = nowTs - - # Record bot response in transcript (exactly once, regardless of channel) - botTranscriptData = TeamsbotTranscript( - sessionId=sessionId, - speaker=self.config.botName, - text=storedText, - timestamp=getUtcTimestamp(), - confidence=1.0, - language=self.config.language, - isFinal=True, - ).model_dump() - botTranscript = interface.createTranscript(botTranscriptData) - - self._contextBuffer.append({ - "speaker": self.config.botName, - "text": storedText, - "timestamp": getUtcTimestamp(), - "source": "botResponse", - }) - - await _emitSessionEvent(sessionId, "transcript", { - "id": botTranscript.get("id"), - "speaker": self.config.botName, - "text": storedText, - "confidence": 1.0, - "timestamp": getUtcTimestamp(), - "isContinuation": False, - "source": "botResponse", - "speakerResolvedFromHint": False, - }) - - # Reset differential writing tracker so next STT creates a new block - self._lastTranscriptSpeaker = self.config.botName - self._lastTranscriptText = storedText - self._lastTranscriptId = botTranscript.get("id") - - self._followUpWindowEnd = time.time() + 15.0 - logger.info(f"Bot responded in session {sessionId}: intent={speechResult.detectedIntent}, follow-up window open for 15s") - - # Step 5: Execute AI-issued commands (if any) - if speechResult.commands: - await self._executeCommands(sessionId, speechResult.commands, voiceInterface, websocket) - - # When AI used only commands (no responseText), emit botResponse SSE - # so the UI shows the response. Extract text from sendChat commands. - if speechResult.shouldRespond and not speechResult.responseText: - cmdTexts = [ - c.params.get("text", "") for c in speechResult.commands - if c.action == "sendChat" and c.params and c.params.get("text") - ] - combinedText = " ".join(cmdTexts) if cmdTexts else None - if combinedText: - botResponseData = TeamsbotBotResponse( - sessionId=sessionId, - responseText=combinedText, - responseType=TeamsbotResponseType.CHAT, - detectedIntent=speechResult.detectedIntent, - reasoning=speechResult.reasoning, - triggeredByTranscriptId=triggerTranscript.get("id"), - modelName=response.modelName, - processingTime=response.processingTime, - priceCHF=response.priceCHF, - timestamp=getUtcTimestamp(), - ).model_dump() - createdResponse = interface.createBotResponse(botResponseData) - await _emitSessionEvent(sessionId, "botResponse", { - "id": createdResponse.get("id"), - "responseText": combinedText, - "responseType": TeamsbotResponseType.CHAT.value, - "detectedIntent": speechResult.detectedIntent, - "reasoning": speechResult.reasoning, - "modelName": response.modelName, - "processingTime": response.processingTime, - "priceCHF": response.priceCHF, - "timestamp": botResponseData.get("timestamp"), - }) - - session = interface.getSession(sessionId) - if session: - count = session.get("botResponseCount", 0) + 1 - interface.updateSession(sessionId, {"botResponseCount": count}) - - self._followUpWindowEnd = time.time() + 15.0 - logger.info( - f"Bot responded via commands in session {sessionId}: " - f"intent={speechResult.detectedIntent}, follow-up window open for 15s" - ) - - except Exception as e: - logger.error(f"SPEECH_TEAMS analysis failed for session {sessionId}: {type(e).__name__}: {e}", exc_info=True) - await _emitSessionEvent(sessionId, "error", {"message": f"AI analysis failed: {type(e).__name__}: {str(e)}"}) - finally: - self._aiAnalysisInProgress = False - - async def _runEscalationAndRelease( - self, - sessionId: str, - taskBrief: str, - briefingFileIds: List[str], - triggerTranscriptId: Optional[str], - ) -> None: - """Background wrapper for ``_runAgentForMeeting`` that holds the - ``_agentEscalationInFlight`` flag for the entire duration of the agent - run — not just for the moment we schedule the task. Without this - wrapper, ``_aiAnalysisInProgress`` would already be ``False`` while - the agent is still researching, and a fresh SPEECH_TEAMS trigger from - a new utterance would race the agent to the voice channel.""" - try: - await self._runAgentForMeeting( - sessionId=sessionId, - taskText=taskBrief, - fileIds=briefingFileIds, - sourceLabel="speechEscalation", - triggerTranscriptId=triggerTranscriptId, - ) - except asyncio.CancelledError: - logger.info( - f"Session {sessionId}: Escalation agent task cancelled by stop signal" - ) - except Exception as escErr: - logger.error( - f"Session {sessionId}: Escalation agent task failed: " - f"{type(escErr).__name__}: {escErr}", - exc_info=True, - ) - finally: - self._agentEscalationInFlight = False - self._currentEscalationTask = None - # ========================================================================= - # AI Command Execution + # Voice helpers (kept on class) # ========================================================================= - async def _executeCommands( - self, - sessionId: str, - commands: List[TeamsbotCommand], - voiceInterface, - websocket: WebSocket, - ): - """Execute structured commands returned by the AI. - Each command is dispatched to a dedicated handler function.""" - for cmd in commands: - action = cmd.action - params = cmd.params or {} - logger.info(f"Session {sessionId}: Executing command '{action}' with params {params}") - try: - if action == "toggleTranscript": - await self._cmdToggleTranscript(sessionId, params, websocket) - elif action == "toggleChat": - await self._cmdToggleChat(sessionId, params, websocket) - elif action == "sendChat": - await self._cmdSendChat(sessionId, params, websocket) - elif action == "readChat": - await self._cmdReadChat(sessionId, params, voiceInterface, websocket) - elif action == "readAloud": - await self._cmdReadAloud(sessionId, params, voiceInterface, websocket) - elif action == "changeLanguage": - await self._cmdChangeLanguage(sessionId, params) - elif action in ("toggleMic", "toggleCamera"): - await self._cmdToggleMicOrCamera(sessionId, action, params, websocket) - elif action == "sendMail": - await self._cmdSendMail(sessionId, params) - elif action == "storeDocument": - await self._cmdStoreDocument(sessionId, params) - else: - logger.warning(f"Session {sessionId}: Unknown command '{action}'") - except Exception as cmdErr: - logger.warning(f"Session {sessionId}: Command '{action}' failed: {cmdErr}") + _VOICE_DIRECT_MAX_CHARS = 600 + _VOICE_SUMMARY_MAX_CHARS = 350 - async def _cmdToggleTranscript(self, sessionId: str, params: dict, websocket: WebSocket): - """Caption on/off - toggle Teams live transcript capture.""" - enable = params.get("enable", True) - if websocket: - await websocket.send_text(json.dumps({ - "type": "botCommand", - "sessionId": sessionId, - "command": "toggleTranscript", - "params": {"enable": enable}, - })) - - async def _cmdToggleChat(self, sessionId: str, params: dict, websocket: WebSocket): - """Chat on/off - enable/disable meeting chat monitoring.""" - enable = params.get("enable", True) - if websocket: - await websocket.send_text(json.dumps({ - "type": "botCommand", - "sessionId": sessionId, - "command": "toggleChat", - "params": {"enable": enable}, - })) - - async def _cmdSendChat(self, sessionId: str, params: dict, websocket: WebSocket): - """Send a message to the meeting chat and record it in transcript/SSE.""" - chatText = params.get("text", "") - if not chatText: - return - if websocket: - await websocket.send_text(json.dumps({ - "type": "sendChatMessage", - "sessionId": sessionId, - "text": chatText, - })) - logger.info(f"Chat command sent for session {sessionId}") - - from . import interfaceFeatureTeamsbot as interfaceDb - interface = interfaceDb.getInterface(self.currentUser, self.mandateId, self.instanceId) - - transcriptData = TeamsbotTranscript( - sessionId=sessionId, - speaker=self.config.botName, - text=chatText, - timestamp=getUtcTimestamp(), - confidence=1.0, - language=self.config.language, - isFinal=True, - source="chat", - ).model_dump() - createdTranscript = interface.createTranscript(transcriptData) - - self._contextBuffer.append({ - "speaker": self.config.botName, - "text": chatText, - "timestamp": getUtcTimestamp(), - "source": "chat", - }) - self._lastTranscriptSpeaker = self.config.botName - self._lastTranscriptText = chatText - self._lastTranscriptId = createdTranscript.get("id") - self._lastBotResponseText = chatText.strip().lower() - self._lastBotResponseTs = time.time() - - await _emitSessionEvent(sessionId, "transcript", { - "id": createdTranscript.get("id"), - "speaker": self.config.botName, - "text": chatText, - "confidence": 1.0, - "timestamp": getUtcTimestamp(), - "isContinuation": False, - "source": "chat", - "speakerResolvedFromHint": False, - }) - - async def _cmdReadChat( - self, - sessionId: str, - params: dict, - voiceInterface, - websocket: WebSocket, - ): - """Read chat messages (from DB) with optional fromdatetime/todatetime, then speak or send to chat.""" - from . import interfaceFeatureTeamsbot as interfaceDb - interface = interfaceDb.getInterface(self.currentUser, self.mandateId, self.instanceId) - transcripts = interface.getTranscripts(sessionId) - fromDtRaw = params.get("fromdatetime") or params.get("fromDateTime") - toDtRaw = params.get("todatetime") or params.get("toDateTime") - fromTs = datetime.fromisoformat(fromDtRaw).replace(tzinfo=timezone.utc).timestamp() if fromDtRaw else None - toTs = datetime.fromisoformat(toDtRaw).replace(tzinfo=timezone.utc).timestamp() if toDtRaw else None - chatOnly = [t for t in transcripts if t.get("source") in ("chat", "chatHistory")] - if fromTs is not None: - chatOnly = [t for t in chatOnly if (t.get("timestamp") or 0) >= fromTs] - if toTs is not None: - chatOnly = [t for t in chatOnly if (t.get("timestamp") or 0) <= toTs] - summary = "\n".join(f"[{t.get('speaker', '?')}]: {t.get('text', '')}" for t in chatOnly[-20:]) - if not summary: - summary = "Keine Chat-Nachrichten im angegebenen Zeitraum." - if voiceInterface and websocket: - spokenSummary = await self._summarizeForVoice(sessionId, summary[:2000]) - cancelHook = self._makeAnswerCancelHook() - async with self._meetingTtsLock: - await _speakTextChunked( - websocket=websocket, - voiceInterface=voiceInterface, - sessionId=sessionId, - voiceText=spokenSummary, - languageCode=self.config.language, - voiceName=self.config.voiceId, - isCancelled=cancelHook, - ) - - async def _cmdReadAloud( - self, - sessionId: str, - params: dict, - voiceInterface, - websocket: WebSocket, - ): - """Read text aloud via TTS and play in meeting.""" - readText = params.get("text", "") - if readText and voiceInterface and websocket: - cancelHook = self._makeAnswerCancelHook() - async with self._meetingTtsLock: - await _speakTextChunked( - websocket=websocket, - voiceInterface=voiceInterface, - sessionId=sessionId, - voiceText=_voiceFriendlyMeetingText(readText), - languageCode=self.config.language, - voiceName=self.config.voiceId, - isCancelled=cancelHook, - ) - - async def _cmdChangeLanguage(self, sessionId: str, params: dict): - """Change bot language.""" - newLang = params.get("language", "") - if newLang: - self.config = self.config.model_copy(update={"language": newLang}) - logger.info(f"Session {sessionId}: Language changed to '{newLang}'") - await _emitSessionEvent(sessionId, "languageChanged", {"language": newLang}) - - async def _cmdToggleMicOrCamera( - self, - sessionId: str, - action: str, - params: dict, - websocket: WebSocket, - ): - """Toggle mic or camera in the meeting.""" - if websocket: - await websocket.send_text(json.dumps({ - "type": "botCommand", - "sessionId": sessionId, - "command": action, - "params": params, - })) - - async def _cmdSendMail(self, sessionId: str, params: dict): - """Send email via Service Center MessagingService.""" - recipient = params.get("recipient") or params.get("to", "") - subject = params.get("subject", "") - message = params.get("message") or params.get("body", "") - if not recipient or not subject: - logger.warning(f"Session {sessionId}: sendMail requires recipient and subject") - return - try: - from modules.serviceCenter import ServiceCenterContext, getService - ctx = ServiceCenterContext( - user=self.currentUser, - mandate_id=self.mandateId, - feature_instance_id=self.instanceId, - ) - messaging = getService("messaging", ctx) - success = messaging.sendEmailDirect( - recipient=recipient, - subject=subject, - message=message, - userId=str(self.currentUser.id) if self.currentUser else None, - ) - if success: - logger.info(f"Session {sessionId}: Email sent to {recipient}") - else: - logger.warning(f"Session {sessionId}: Email send failed for {recipient}") - except Exception as e: - logger.warning(f"Session {sessionId}: sendMail failed: {e}") - - async def _cmdStoreDocument(self, sessionId: str, params: dict): - """Store document via Service Center SharepointService.""" - sitePath = params.get("sitePath") or params.get("site", "") - folderPath = params.get("folderPath") or params.get("folder", "") - fileName = params.get("fileName", "document.txt") - content = params.get("content", "") - if isinstance(content, str): - content = content.encode("utf-8") - if not sitePath or not folderPath: - logger.warning(f"Session {sessionId}: storeDocument requires sitePath and folderPath") - return - try: - from modules.serviceCenter import ServiceCenterContext, getService - ctx = ServiceCenterContext( - user=self.currentUser, - mandate_id=self.mandateId, - feature_instance_id=self.instanceId, - ) - sharepoint = getService("sharepoint", ctx) - if not sharepoint.setAccessTokenFromConnection(self.currentUser): - logger.warning(f"Session {sessionId}: SharePoint connection not configured") - return - site = await sharepoint.getSiteByStandardPath(sitePath) - if not site: - logger.warning(f"Session {sessionId}: SharePoint site not found: {sitePath}") - return - result = await sharepoint.uploadFile( - siteId=site["id"], - folderPath=folderPath, - fileName=fileName, - content=content, - ) - if "error" in result: - logger.warning(f"Session {sessionId}: storeDocument failed: {result['error']}") - else: - logger.info(f"Session {sessionId}: Document stored: {fileName}") - except Exception as e: - logger.warning(f"Session {sessionId}: storeDocument failed: {e}") + @staticmethod + def _looksLikeStructuredText(raw: str) -> bool: + """Heuristic: does the original answer have markdown structure?""" + if not raw: + return False + if raw.count("|") >= 4: + return True + if raw.count("\n#") >= 1: + return True + if raw.count("\n- ") + raw.count("\n* ") + raw.count("\n• ") >= 3: + return True + if re.search(r"\n\d+[\.\)]\s", raw): + count = len(re.findall(r"(?m)^\s*\d+[\.\)]\s", raw)) + if count >= 3: + return True + return False # ========================================================================= # Director Prompts (private operator instructions during a live meeting) # ========================================================================= def _collectActiveDirectorBriefings(self) -> List[Dict[str, Any]]: - """Return the deduplicated list of director-prompt briefings that are - currently relevant for the meeting context: every active persistent - prompt PLUS every recent one-shot prompt that still sits in the - ``_recentDirectorBriefings`` pool. Each entry carries ``text``, - ``fileIds`` (UDB attachments), ``mode``, ``promptId`` and ``note`` - (the agent's internal analysis from the SILENT director run, if any). - """ + """Return the deduplicated list of director-prompt briefings.""" seen: Dict[str, Dict[str, Any]] = {} for p in self._activePersistentPrompts: pid = p.get("id") or "" @@ -3003,8 +930,6 @@ class TeamsbotService: for b in self._recentDirectorBriefings: pid = b.get("promptId") or "" if pid in seen: - # Refresh note with the latest analysis if the persistent run - # produced one after the prompt was first loaded from DB. if b.get("note"): seen[pid]["note"] = b["note"] continue @@ -3018,10 +943,7 @@ class TeamsbotService: return [v for v in seen.values() if v.get("text") or v.get("fileIds")] def _collectDirectorFileIds(self) -> List[str]: - """Flat, deduplicated list of UDB file IDs attached to any currently - relevant director prompt (persistent + recent one-shot). Used when - SPEECH_TEAMS escalates to the agent so the agent can actually READ the - documents the operator already provided.""" + """Flat, deduplicated list of UDB file IDs attached to director prompts.""" out: List[str] = [] seen: set = set() for b in self._collectActiveDirectorBriefings(): @@ -3032,19 +954,7 @@ class TeamsbotService: return out def _buildPersistentDirectorContext(self) -> str: - """Render active director-prompt briefings as private operator guidance - for the SPEECH_TEAMS system prompt context block. - - Surfaces three things SPEECH_TEAMS otherwise misses: - - * the operator's directive text (as before) - * the IDs of any UDB files the operator attached — so SPEECH_TEAMS - knows the documents exist and can decide to escalate to the agent, - which has the tooling to read them. - * the agent's previous internal analysis of the prompt (the SILENT - ``MEETING_REPLY/SILENT`` decision's note), so SPEECH_TEAMS can answer - short questions without re-running the agent. - """ + """Render active director-prompt briefings for SPEECH_TEAMS context.""" briefings = self._collectActiveDirectorBriefings() if not briefings: return "" @@ -3078,12 +988,8 @@ class TeamsbotService: internalNote: str, meetingText: str, ) -> None: - """Append a director-prompt briefing to the session-scoped pool so the - attached files and the agent's analysis stay available for subsequent - SPEECH_TEAMS triggers — even after a one-shot prompt was consumed. - Idempotent per ``promptId`` (latest entry wins).""" + """Append a director-prompt briefing to the session-scoped pool.""" pid = prompt.get("id") or "" - # Drop any older entry for the same prompt so we keep the freshest note. self._recentDirectorBriefings = [ b for b in self._recentDirectorBriefings if b.get("promptId") != pid ] @@ -3108,11 +1014,7 @@ class TeamsbotService: mode: TeamsbotDirectorPromptMode, fileIds: List[str], ) -> Dict[str, Any]: - """Persist a new director prompt and trigger immediate agent processing. - - Returns the created prompt record. Processing happens asynchronously - and emits SSE events ('directorPrompt') for the operator UI. - """ + """Persist a new director prompt and trigger immediate agent processing.""" from . import interfaceFeatureTeamsbot as interfaceDb interface = interfaceDb.getInterface(self.currentUser, self.mandateId, self.instanceId) @@ -3128,9 +1030,6 @@ class TeamsbotService: ).model_dump() created = interface.createDirectorPrompt(promptData) - # Persistent prompts join in-memory directives immediately so they - # also influence subsequent SPEECH_TEAMS triggers, not only the - # one-shot agent run we kick off below. if mode == TeamsbotDirectorPromptMode.PERSISTENT: self._activePersistentPrompts.append(created) @@ -3147,7 +1046,7 @@ class TeamsbotService: return created async def removePersistentPrompt(self, promptId: str) -> bool: - """Remove a persistent director prompt (operator clicked 'remove').""" + """Remove a persistent director prompt.""" from . import interfaceFeatureTeamsbot as interfaceDb interface = interfaceDb.getInterface(self.currentUser, self.mandateId, self.instanceId) @@ -3163,9 +1062,6 @@ class TeamsbotService: self._activePersistentPrompts = [ p for p in self._activePersistentPrompts if p.get("id") != promptId ] - # Also drop the briefing copy so SPEECH_TEAMS forgets the doc reference - # immediately; otherwise the bot would keep "remembering" a doc the - # operator just retired. self._recentDirectorBriefings = [ b for b in self._recentDirectorBriefings if b.get("promptId") != promptId ] @@ -3180,8 +1076,7 @@ class TeamsbotService: return True async def _processDirectorPrompt(self, prompt: Dict[str, Any]) -> None: - """Run the agent for a director prompt and deliver the FINAL text into - the meeting via TTS + chat (using the bot's existing channels).""" + """Run the agent for a director prompt and deliver the result.""" from . import interfaceFeatureTeamsbot as interfaceDb sessionId = prompt.get("sessionId") @@ -3196,18 +1091,12 @@ class TeamsbotService: "status": TeamsbotDirectorPromptStatus.RUNNING.value, }) - # Build a task brief for the agent that surfaces the meeting context. recentTranscript = self._renderRecentTranscriptForAgent(maxLines=20) directorText = (prompt.get("text") or "").strip() attachedFileIds = list(prompt.get("fileIds") or []) promptMode = (prompt.get("mode") or "").lower() isPersistentPrompt = promptMode == TeamsbotDirectorPromptMode.PERSISTENT.value.lower() - # Make file attachment EXPLICIT in the brief. The agent service already - # prepends a "## Attached Files & Folders" header via _enrichPromptWithFiles - # when fileIds are passed, but without an explicit instruction the agent - # sometimes goes straight to a generic answer. We force the workflow: - # studyDocs -> form briefing -> decide MEETING_REPLY vs SILENT. filesBlock = "" if attachedFileIds: filesBlock = ( @@ -3219,10 +1108,6 @@ class TeamsbotService: "Meeting-Reply ein, statt allgemein zu antworten.\n" ) - # Persistent prompts that ship documents are usually a "knowledge briefing" - # the operator wants the bot to STUDY now and USE LATER. The SILENT note - # in that case must be a useful, file-grounded summary that subsequent - # SPEECH_TEAMS triggers can pick up — not "noted". persistentNoteHint = "" if isPersistentPrompt and attachedFileIds: persistentNoteHint = ( @@ -3270,7 +1155,6 @@ class TeamsbotService: directorPromptMode=True, ) - # One-shot: mark consumed; persistent: keep active but record success. isPersistent = prompt.get("mode") == TeamsbotDirectorPromptMode.PERSISTENT.value updates: Dict[str, Any] = { "status": TeamsbotDirectorPromptStatus.SUCCEEDED.value, @@ -3305,8 +1189,7 @@ class TeamsbotService: ] def _renderRecentTranscriptForAgent(self, maxLines: int = 20) -> str: - """Render the most recent context buffer entries for inclusion in the - agent task brief (similar to SPEECH_TEAMS context, but plain text).""" + """Render the most recent context buffer entries for agent task brief.""" if not self._contextBuffer: return "(noch keine Aussagen erfasst)" recent = self._contextBuffer[-maxLines:] @@ -3323,21 +1206,13 @@ class TeamsbotService: return "\n".join(lines) async def _interimAgentBusyMessage(self) -> Optional[str]: - """Short spoken/chat line before a potentially long agent run (web, - tools). Phrasing is AI-localised to ``self.config.language`` and - cached per session — no hardcoded language branching. Returns - ``None`` if generation failed; caller must treat that as - 'silently skip the interim notice'.""" + """Short spoken/chat line before a potentially long agent run.""" return await self._pickEphemeralPhrase("agentBusy") async def _interimAgentRoundMessage( self, lastToolLabel: Optional[str] = None ) -> Optional[str]: - """Per-round progress notice for long agent runs (meeting voice / - chat, ephemeral). Generates a single short phrase in the bot's - configured language that describes the current activity. Unlike - the cached ephemeral phrases, this is a per-call AI generation - to avoid mixing English displayLabels into non-English speech.""" + """Per-round progress notice for long agent runs.""" targetLang = (self.config.language or "").strip() or "en-US" botName = (self.config.botName or "the assistant").strip() activityHint = lastToolLabel or "working on the task" @@ -3379,9 +1254,7 @@ class TeamsbotService: return result async def _notifyMeetingEphemeral(self, sessionId: str, text: str) -> None: - """Deliver a short line to the meeting (TTS + chat per config) without - persisting botResponses/transcripts, so the main agent answer stays the - single recorded follow-up.""" + """Deliver a short line to the meeting without persisting botResponses.""" websocket = self._websocket voiceInterface = self._voiceInterface if not websocket: @@ -3440,18 +1313,7 @@ class TeamsbotService: promptId: Optional[str] = None, directorPromptMode: bool = False, ) -> str: - """Run agentService.runAgent for a meeting context, deliver the FINAL - text via the bot's existing TTS + chat channels, and return that text. - - sourceLabel is used for logging and SSE differentiation - ('directorPrompt' or 'speechEscalation'). - - ``directorPromptMode`` activates the silent-by-default protocol for - operator director prompts: interim notices are suppressed, no per-round - meeting updates, and the FINAL text is parsed for an explicit - ``MEETING_REPLY:`` / ``SILENT:`` marker. Only ``MEETING_REPLY`` content - is dispatched to the meeting; everything else stays internal. - """ + """Run agentService.runAgent for a meeting context.""" from modules.serviceCenter.services.serviceAgent.datamodelAgent import ( AgentConfig, AgentEventTypeEnum ) @@ -3464,7 +1326,6 @@ class TeamsbotService: ) agentService = _getServiceCenterService("agent", ctx) - # Workflow id stable per session so RAG/round-memory accumulate per meeting. workflowId = f"teamsbot:{sessionId}" agentConfig = AgentConfig( @@ -3482,8 +1343,6 @@ class TeamsbotService: "timestamp": getUtcTimestamp(), }) - # Director prompts run silently by default — no spontaneous "moment please" - # in the meeting just because the operator gave an internal directive. if not directorPromptMode: try: interimText = await self._interimAgentBusyMessage() @@ -3589,12 +1448,6 @@ class TeamsbotService: "internalNote": internalNote, }) - # Record this prompt as a session-scoped briefing BEFORE we hand - # delivery off. This is what later SPEECH_TEAMS triggers see, so - # if the user attached a doc with mode=PERSISTENT and the agent - # produced a file-grounded SILENT note, that note (and the - # original fileIds) stays available for "summarize the doc" - # follow-up questions in the meeting. try: promptRecord: Dict[str, Any] = {} if promptId: @@ -3619,11 +1472,6 @@ class TeamsbotService: f"Session {sessionId}: Director briefing pool update failed: {briefErr}" ) - # If this was a persistent prompt, the live in-memory copy in - # ``_activePersistentPrompts`` was loaded BEFORE the agent ran - # — refresh its ``responseText`` so subsequent - # ``_collectActiveDirectorBriefings`` calls show the latest - # analysis without waiting for the next session reload. if promptId: for p in self._activePersistentPrompts: if p.get("id") == promptId: @@ -3639,10 +1487,6 @@ class TeamsbotService: triggerTranscriptId=triggerTranscriptId, ) else: - # Silent: persist as internal-only botResponse so the operator - # UI keeps a record, but DO NOT push into the meeting (no TTS, - # no chat send). The director prompt SSE above already carries - # the note for the operator UI. await self._persistInternalDirectorReply( sessionId=sessionId, internalNote=internalNote or finalText, @@ -3669,13 +1513,7 @@ class TeamsbotService: reasoning: str, triggerTranscriptId: Optional[str] = None, ) -> None: - """Send agent text into the meeting via the same channels SPEECH_TEAMS - uses: TTS + chat per config, plus DB persistence and SSE events. - - Uses the websocket/voiceInterface stored on this instance. If the bot - is not connected anymore, the call still records the response in the DB - and emits SSE so the operator UI shows the agent answer. - """ + """Send agent text into the meeting via TTS + chat per config.""" from . import interfaceFeatureTeamsbot as interfaceDb interface = interfaceDb.getInterface(self.currentUser, self.mandateId, self.instanceId) @@ -3696,12 +1534,10 @@ class TeamsbotService: else: responseType = TeamsbotResponseType.CHAT - # Voice (TTS input is voice-sanitized; chat + DB keep full structured text). - # Long agent answers must be chunked: Google TTS rejects single sentences - # > ~5000 bytes, and the Chirp3 voices fail on long comma-heavy lines too. ttsOutcome: Optional[Dict[str, Any]] = None if sendVoice and voiceInterface and websocket: - spokenText = await self._summarizeForVoice(sessionId, text) + from .serviceConversation import _summarizeForVoice + spokenText = await _summarizeForVoice(self, sessionId, text) cancelHook = self._makeAnswerCancelHook() async with self._meetingTtsLock: ttsOutcome = await _speakTextChunked( @@ -3729,7 +1565,6 @@ class TeamsbotService: if not sendChat: sendChat = True - # Chat if sendChat and websocket: try: await websocket.send_text(json.dumps({ @@ -3741,7 +1576,6 @@ class TeamsbotService: except Exception as chatErr: logger.warning(f"Session {sessionId}: Agent chat delivery failed: {chatErr}") - # Persist as botResponse + transcript so it shows up in history/UI. intentEnum, intentMeta = _coercePersistedDetectedIntent(detectedIntent) reasoningForDb = ( f"{reasoning} [{intentMeta}]" if intentMeta else reasoning @@ -3821,13 +1655,7 @@ class TeamsbotService: promptId: Optional[str], triggerTranscriptId: Optional[str] = None, ) -> None: - """Record a director-prompt agent reply as INTERNAL (operator-UI only). - - Unlike ``_deliverTextToMeeting`` this never dispatches TTS or chat into - the meeting, never appends to the meeting context buffer, and does not - create a meeting transcript line. It only persists a botResponse and - emits an SSE event so the operator UI shows what the agent decided. - """ + """Record a director-prompt agent reply as INTERNAL (operator-UI only).""" from . import interfaceFeatureTeamsbot as interfaceDb note = (internalNote or "").strip() @@ -3876,20 +1704,16 @@ class TeamsbotService: ) # ========================================================================= - # Greeting (AI-localised, no hardcoded language strings) + # Greeting (AI-localised) # ========================================================================= async def _generateGreetingText(self, languageCode: str) -> str: - """Generate the bot's join greeting via AI in ``languageCode`` and the - configured persona. Returns empty string on failure — the caller must - treat that as 'skip the greeting' (NEVER fall back to a hardcoded - localised string).""" + """Generate the bot's join greeting via AI.""" targetLang = (languageCode or self.config.language or "").strip() or "en-US" botName = (self.config.botName or "the assistant").strip() firstName = botName.split(" ")[0] if botName else botName persona = (self.config.aiSystemPrompt or "").strip() - # English instructions to the LLM; the OUTPUT must be in ``targetLang``. prompt = ( f"You are localizing the join greeting for a meeting assistant.\n\n" f"Assistant display name (use exactly this, no translation): {firstName}\n\n" @@ -3935,7 +1759,6 @@ class TeamsbotService: return "" text = response.content.strip() - # Strip any wrapping quotes/code fences the model might have added. text = re.sub(r"^```.*?\n", "", text, flags=re.DOTALL) text = re.sub(r"\n```\s*$", "", text) text = text.strip().strip("\"'`").strip() @@ -3956,15 +1779,7 @@ class TeamsbotService: voiceInterface: Any, websocket: WebSocket, ) -> None: - """Centralised dispatcher for the bot's join greeting: speaks the - text via TTS into the meeting and (optionally) tells the bot to post - it in the meeting chat. Persists the greeting as a bot transcript / - botResponse so it appears in the operator UI history. - - ``sendToChat`` is ``False`` for the legacy ``voiceGreeting`` path - (the bot already chatted itself) and ``True`` for the new - ``requestGreeting`` path where the Gateway owns chat dispatch too. - """ + """Dispatch the bot's join greeting (TTS + optional chat).""" try: await _emitSessionEvent(sessionId, "ttsDeliveryStatus", { "status": "requested", @@ -4065,12 +1880,11 @@ class TeamsbotService: ) # ========================================================================= - # Context Summarization (for long sessions) + # Context Summarization # ========================================================================= async def _summarizeSessionContext(self, sessionId: str, rawContext: str) -> str: - """Summarize a long user-provided session context to its essential points. - This reduces token usage in every subsequent AI call.""" + """Summarize a long user-provided session context.""" try: aiService = createAiService(self.currentUser, self.mandateId, self.instanceId) await aiService.ensureAiObjectsInitialized() @@ -4096,25 +1910,21 @@ class TeamsbotService: return summary except Exception as e: logger.warning(f"Session context summarization failed for {sessionId}: {e}") - - # Fallback: return original (truncated if very long) + return rawContext[:2000] if len(rawContext) > 2000 else rawContext async def _summarizeContextBuffer(self, sessionId: str): - """Summarize the older part of the context buffer to preserve information - without exceeding the context window. This runs in the background.""" + """Summarize the older part of the context buffer.""" try: if self._contextSummary: - return # Already summarized recently + return - # Take the older half of the buffer for summarization halfPoint = len(self._contextBuffer) // 2 oldSegments = self._contextBuffer[:halfPoint] if len(oldSegments) < 10: - return # Not enough to summarize + return - # Build text to summarize lines = [] for seg in oldSegments: speaker = seg.get("speaker", "Unknown") @@ -4155,9 +1965,8 @@ class TeamsbotService: transcripts = interface.getTranscripts(sessionId) if not transcripts or len(transcripts) < 5: - return # Not enough content for a summary + return - # Build full transcript fullTranscript = "\n".join( f"[{t.get('speaker', 'Unknown')}]: {t.get('text', '')}" for t in transcripts diff --git a/modules/features/teamsbot/serviceCommands.py b/modules/features/teamsbot/serviceCommands.py new file mode 100644 index 00000000..55f16bf0 --- /dev/null +++ b/modules/features/teamsbot/serviceCommands.py @@ -0,0 +1,305 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Teamsbot Service — AI command execution logic. + +Extracted from service.py. All functions accept `service` (a TeamsbotService +instance) as the first parameter so the class can delegate to them. +""" + +import logging +import json +import asyncio +from datetime import datetime, timezone +from typing import List + +from fastapi import WebSocket + +from modules.shared.timeUtils import getUtcTimestamp + +from .datamodelTeamsbot import ( + TeamsbotTranscript, + TeamsbotCommand, +) + +logger = logging.getLogger(__name__) + + +async def _executeCommands( + service, + sessionId: str, + commands: List[TeamsbotCommand], + voiceInterface, + websocket: WebSocket, +): + """Execute structured commands returned by the AI.""" + for cmd in commands: + action = cmd.action + params = cmd.params or {} + logger.info(f"Session {sessionId}: Executing command '{action}' with params {params}") + try: + if action == "toggleTranscript": + await _cmdToggleTranscript(service, sessionId, params, websocket) + elif action == "toggleChat": + await _cmdToggleChat(service, sessionId, params, websocket) + elif action == "sendChat": + await _cmdSendChat(service, sessionId, params, websocket) + elif action == "readChat": + await _cmdReadChat(service, sessionId, params, voiceInterface, websocket) + elif action == "readAloud": + await _cmdReadAloud(service, sessionId, params, voiceInterface, websocket) + elif action == "changeLanguage": + await _cmdChangeLanguage(service, sessionId, params) + elif action in ("toggleMic", "toggleCamera"): + await _cmdToggleMicOrCamera(service, sessionId, action, params, websocket) + elif action == "sendMail": + await _cmdSendMail(service, sessionId, params) + elif action == "storeDocument": + await _cmdStoreDocument(service, sessionId, params) + else: + logger.warning(f"Session {sessionId}: Unknown command '{action}'") + except Exception as cmdErr: + logger.warning(f"Session {sessionId}: Command '{action}' failed: {cmdErr}") + + +async def _cmdToggleTranscript(service, sessionId: str, params: dict, websocket: WebSocket): + """Caption on/off - toggle Teams live transcript capture.""" + enable = params.get("enable", True) + if websocket: + await websocket.send_text(json.dumps({ + "type": "botCommand", + "sessionId": sessionId, + "command": "toggleTranscript", + "params": {"enable": enable}, + })) + + +async def _cmdToggleChat(service, sessionId: str, params: dict, websocket: WebSocket): + """Chat on/off - enable/disable meeting chat monitoring.""" + enable = params.get("enable", True) + if websocket: + await websocket.send_text(json.dumps({ + "type": "botCommand", + "sessionId": sessionId, + "command": "toggleChat", + "params": {"enable": enable}, + })) + + +async def _cmdSendChat(service, sessionId: str, params: dict, websocket: WebSocket): + """Send a message to the meeting chat and record it in transcript/SSE.""" + from .service import _emitSessionEvent + + chatText = params.get("text", "") + if not chatText: + return + if websocket: + await websocket.send_text(json.dumps({ + "type": "sendChatMessage", + "sessionId": sessionId, + "text": chatText, + })) + logger.info(f"Chat command sent for session {sessionId}") + + from . import interfaceFeatureTeamsbot as interfaceDb + interface = interfaceDb.getInterface(service.currentUser, service.mandateId, service.instanceId) + + transcriptData = TeamsbotTranscript( + sessionId=sessionId, + speaker=service.config.botName, + text=chatText, + timestamp=getUtcTimestamp(), + confidence=1.0, + language=service.config.language, + isFinal=True, + source="chat", + ).model_dump() + createdTranscript = interface.createTranscript(transcriptData) + + import time + service._contextBuffer.append({ + "speaker": service.config.botName, + "text": chatText, + "timestamp": getUtcTimestamp(), + "source": "chat", + }) + service._lastTranscriptSpeaker = service.config.botName + service._lastTranscriptText = chatText + service._lastTranscriptId = createdTranscript.get("id") + service._lastBotResponseText = chatText.strip().lower() + service._lastBotResponseTs = time.time() + + await _emitSessionEvent(sessionId, "transcript", { + "id": createdTranscript.get("id"), + "speaker": service.config.botName, + "text": chatText, + "confidence": 1.0, + "timestamp": getUtcTimestamp(), + "isContinuation": False, + "source": "chat", + "speakerResolvedFromHint": False, + }) + + +async def _cmdReadChat( + service, + sessionId: str, + params: dict, + voiceInterface, + websocket: WebSocket, +): + """Read chat messages (from DB) with optional fromdatetime/todatetime, then speak or send to chat.""" + from .service import _speakTextChunked + from .serviceConversation import _summarizeForVoice + + from . import interfaceFeatureTeamsbot as interfaceDb + interface = interfaceDb.getInterface(service.currentUser, service.mandateId, service.instanceId) + transcripts = interface.getTranscripts(sessionId) + fromDtRaw = params.get("fromdatetime") or params.get("fromDateTime") + toDtRaw = params.get("todatetime") or params.get("toDateTime") + fromTs = datetime.fromisoformat(fromDtRaw).replace(tzinfo=timezone.utc).timestamp() if fromDtRaw else None + toTs = datetime.fromisoformat(toDtRaw).replace(tzinfo=timezone.utc).timestamp() if toDtRaw else None + chatOnly = [t for t in transcripts if t.get("source") in ("chat", "chatHistory")] + if fromTs is not None: + chatOnly = [t for t in chatOnly if (t.get("timestamp") or 0) >= fromTs] + if toTs is not None: + chatOnly = [t for t in chatOnly if (t.get("timestamp") or 0) <= toTs] + summary = "\n".join(f"[{t.get('speaker', '?')}]: {t.get('text', '')}" for t in chatOnly[-20:]) + if not summary: + summary = "Keine Chat-Nachrichten im angegebenen Zeitraum." + if voiceInterface and websocket: + spokenSummary = await _summarizeForVoice(service, sessionId, summary[:2000]) + cancelHook = service._makeAnswerCancelHook() + async with service._meetingTtsLock: + await _speakTextChunked( + websocket=websocket, + voiceInterface=voiceInterface, + sessionId=sessionId, + voiceText=spokenSummary, + languageCode=service.config.language, + voiceName=service.config.voiceId, + isCancelled=cancelHook, + ) + + +async def _cmdReadAloud( + service, + sessionId: str, + params: dict, + voiceInterface, + websocket: WebSocket, +): + """Read text aloud via TTS and play in meeting.""" + from .service import _speakTextChunked, _voiceFriendlyMeetingText + + readText = params.get("text", "") + if readText and voiceInterface and websocket: + cancelHook = service._makeAnswerCancelHook() + async with service._meetingTtsLock: + await _speakTextChunked( + websocket=websocket, + voiceInterface=voiceInterface, + sessionId=sessionId, + voiceText=_voiceFriendlyMeetingText(readText), + languageCode=service.config.language, + voiceName=service.config.voiceId, + isCancelled=cancelHook, + ) + + +async def _cmdChangeLanguage(service, sessionId: str, params: dict): + """Change bot language.""" + from .service import _emitSessionEvent + + newLang = params.get("language", "") + if newLang: + service.config = service.config.model_copy(update={"language": newLang}) + logger.info(f"Session {sessionId}: Language changed to '{newLang}'") + await _emitSessionEvent(sessionId, "languageChanged", {"language": newLang}) + + +async def _cmdToggleMicOrCamera( + service, + sessionId: str, + action: str, + params: dict, + websocket: WebSocket, +): + """Toggle mic or camera in the meeting.""" + if websocket: + await websocket.send_text(json.dumps({ + "type": "botCommand", + "sessionId": sessionId, + "command": action, + "params": params, + })) + + +async def _cmdSendMail(service, sessionId: str, params: dict): + """Send email via Service Center MessagingService.""" + recipient = params.get("recipient") or params.get("to", "") + subject = params.get("subject", "") + message = params.get("message") or params.get("body", "") + if not recipient or not subject: + logger.warning(f"Session {sessionId}: sendMail requires recipient and subject") + return + try: + from modules.serviceCenter import ServiceCenterContext, getService + ctx = ServiceCenterContext( + user=service.currentUser, + mandate_id=service.mandateId, + feature_instance_id=service.instanceId, + ) + messaging = getService("messaging", ctx) + success = messaging.sendEmailDirect( + recipient=recipient, + subject=subject, + message=message, + userId=str(service.currentUser.id) if service.currentUser else None, + ) + if success: + logger.info(f"Session {sessionId}: Email sent to {recipient}") + else: + logger.warning(f"Session {sessionId}: Email send failed for {recipient}") + except Exception as e: + logger.warning(f"Session {sessionId}: sendMail failed: {e}") + + +async def _cmdStoreDocument(service, sessionId: str, params: dict): + """Store document via Service Center SharepointService.""" + sitePath = params.get("sitePath") or params.get("site", "") + folderPath = params.get("folderPath") or params.get("folder", "") + fileName = params.get("fileName", "document.txt") + content = params.get("content", "") + if isinstance(content, str): + content = content.encode("utf-8") + if not sitePath or not folderPath: + logger.warning(f"Session {sessionId}: storeDocument requires sitePath and folderPath") + return + try: + from modules.serviceCenter import ServiceCenterContext, getService + ctx = ServiceCenterContext( + user=service.currentUser, + mandate_id=service.mandateId, + feature_instance_id=service.instanceId, + ) + sharepoint = getService("sharepoint", ctx) + if not sharepoint.setAccessTokenFromConnection(service.currentUser): + logger.warning(f"Session {sessionId}: SharePoint connection not configured") + return + site = await sharepoint.getSiteByStandardPath(sitePath) + if not site: + logger.warning(f"Session {sessionId}: SharePoint site not found: {sitePath}") + return + result = await sharepoint.uploadFile( + siteId=site["id"], + folderPath=folderPath, + fileName=fileName, + content=content, + ) + if "error" in result: + logger.warning(f"Session {sessionId}: storeDocument failed: {result['error']}") + else: + logger.info(f"Session {sessionId}: Document stored: {fileName}") + except Exception as e: + logger.warning(f"Session {sessionId}: storeDocument failed: {e}") diff --git a/modules/features/teamsbot/serviceConversation.py b/modules/features/teamsbot/serviceConversation.py new file mode 100644 index 00000000..bf844d89 --- /dev/null +++ b/modules/features/teamsbot/serviceConversation.py @@ -0,0 +1,996 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Teamsbot Service — Conversation & AI analysis logic. + +Extracted from service.py. All functions accept `service` (a TeamsbotService +instance) as the first parameter so the class can delegate to them. +""" + +import logging +import json +import re +import asyncio +import time +from typing import Optional, Dict, Any, List + +from fastapi import WebSocket + +from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, PriorityEnum +from modules.shared.timeUtils import getUtcTimestamp + +from .datamodelTeamsbot import ( + TeamsbotTranscript, + TeamsbotBotResponse, + TeamsbotResponseType, + TeamsbotResponseMode, + TeamsbotResponseChannel, + SpeechTeamsResponse, + TeamsbotDirectorPromptMode, + TeamsbotDirectorPromptStatus, +) + +logger = logging.getLogger(__name__) + + +async def _analyzeAndRespond( + service, + sessionId: str, + interface, + voiceInterface, + websocket: WebSocket, + triggerTranscript: Dict[str, Any], +): + """Run SPEECH_TEAMS AI analysis and respond if needed.""" + from .service import ( + _emitSessionEvent, createAiService, _speakTextChunked, + _voiceFriendlyMeetingText, + TEAMSBOT_AGENT_MAX_ROUNDS, TEAMSBOT_AGENT_MAX_COST_CHF, + ) + + if service._aiAnalysisInProgress: + logger.info(f"Session {sessionId}: AI analysis already in progress, skipping duplicate trigger") + return + if service._agentEscalationInFlight: + logger.info( + f"Session {sessionId}: Agent escalation still in flight — " + f"skipping new SPEECH_TEAMS trigger to prevent overlapping replies" + ) + return + service._aiAnalysisInProgress = True + service._lastAiCallTime = time.time() + + contextLines = [] + for segment in service._contextBuffer: + speaker = segment.get("speaker", "Unknown") + text = segment.get("text", "") + segSource = segment.get("source", "caption") + prefix = "Chat" if segSource == "chat" else "" + if service._isBotSpeaker(speaker): + contextLines.append(f"[YOU ({service.config.botName})]: {text}") + elif prefix: + contextLines.append(f"[{prefix}: {speaker}]: {text}") + else: + contextLines.append(f"[{speaker}]: {text}") + + sessionContextStr = "" + if service._sessionContext: + sessionContextStr = f"\nSESSION_CONTEXT (background knowledge provided by the user):\n{service._sessionContext}\n" + + summaryStr = "" + if service._contextSummary: + summaryStr = f"\nEARLIER_CONVERSATION_SUMMARY:\n{service._contextSummary}\n" + + directorStr = service._buildPersistentDirectorContext() + + transcriptContext = f"BOT_NAME:{service.config.botName}{sessionContextStr}{summaryStr}{directorStr}\nRECENT_TRANSCRIPT:\n" + "\n".join(contextLines) + + try: + aiService = createAiService(service.currentUser, service.mandateId, service.instanceId) + await aiService.ensureAiObjectsInitialized() + + request = AiCallRequest( + prompt=service.config.aiSystemPrompt, + context=transcriptContext, + options=AiCallOptions( + operationType=OperationTypeEnum.SPEECH_TEAMS, + priority=PriorityEnum.SPEED, + ) + ) + + response = await aiService.callAi(request) + + try: + speechResult = SpeechTeamsResponse.model_validate_json(response.content) + except Exception: + try: + jsonStr = response.content + if "```json" in jsonStr: + jsonStr = jsonStr.split("```json")[1].split("```")[0] + elif "```" in jsonStr: + jsonStr = jsonStr.split("```")[1].split("```")[0] + speechResult = SpeechTeamsResponse.model_validate_json(jsonStr.strip()) + except Exception as parseErr: + logger.warning(f"Failed to parse SPEECH_TEAMS response: {parseErr}") + speechResult = SpeechTeamsResponse( + shouldRespond=False, + reasoning=f"Parse error: {str(parseErr)[:100]}", + detectedIntent="none" + ) + + logger.info( + f"SPEECH_TEAMS result: shouldRespond={speechResult.shouldRespond}, " + f"intent={speechResult.detectedIntent}, " + f"reasoning={speechResult.reasoning[:80]}..." + ) + + await _emitSessionEvent(sessionId, "analysis", { + "shouldRespond": speechResult.shouldRespond, + "detectedIntent": speechResult.detectedIntent, + "reasoning": speechResult.reasoning, + "modelName": response.modelName, + "processingTime": response.processingTime, + "priceCHF": response.priceCHF, + "needsAgent": speechResult.needsAgent, + "agentReason": speechResult.agentReason, + }) + + if speechResult.needsAgent: + briefings = service._collectActiveDirectorBriefings() + briefingFileIds = service._collectDirectorFileIds() + briefingBlock = "" + if briefings: + parts = [] + for b in briefings: + seg = f"- ({b.get('mode')}) {b.get('text', '')}".rstrip() + if b.get("fileIds"): + seg += f"\n attachedFileIds: {', '.join(b['fileIds'])}" + if b.get("note"): + note = b["note"] + seg += ( + "\n priorAgentAnalysis: " + + (note if len(note) <= 800 else note[:800] + "...") + ) + parts.append(seg) + briefingBlock = ( + "\n\nACTIVE_OPERATOR_BRIEFINGS (private; you may read the " + "attached files via summarizeContent / readFile / " + "readContentObjects to answer the user precisely; do NOT " + "quote the directive text itself):\n" + "\n".join(parts) + ) + logger.info( + f"Session {sessionId}: SPEECH_TEAMS escalates to agent. " + f"Reason: {speechResult.agentReason or speechResult.reasoning} | " + f"briefings={len(briefings)}, fileIds={len(briefingFileIds)}" + ) + taskBrief = ( + (speechResult.agentReason + or speechResult.responseText + or "Verarbeite die juengste Sprecheranfrage und antworte ins Meeting.") + + briefingBlock + ) + service._agentEscalationInFlight = True + service._currentEscalationTask = asyncio.create_task( + _runEscalationAndRelease( + service, + sessionId=sessionId, + taskBrief=taskBrief, + briefingFileIds=briefingFileIds, + triggerTranscriptId=triggerTranscript.get("id"), + ) + ) + return + + if speechResult.detectedIntent == "stop": + logger.info(f"Session {sessionId}: AI detected STOP intent: {speechResult.reasoning}") + if websocket: + try: + await websocket.send_text(json.dumps({ + "type": "stopAudio", + "sessionId": sessionId, + })) + except Exception as stopErr: + logger.warning(f"Failed to send stop command: {stopErr}") + return + + if speechResult.shouldRespond and speechResult.responseText: + + if service.config.responseMode == TeamsbotResponseMode.MANUAL: + await _emitSessionEvent(sessionId, "suggestedResponse", { + "responseText": speechResult.responseText, + "detectedIntent": speechResult.detectedIntent, + "reasoning": speechResult.reasoning, + }) + return + + channels = speechResult.responseChannels + if channels and isinstance(channels, list): + channelStr = ",".join(str(c).lower().strip() for c in channels) + sendVoice = "voice" in channelStr + sendChat = "chat" in channelStr + logger.info(f"Response channel (from AI): voice={sendVoice}, chat={sendChat}") + else: + channelRaw = service.config.responseChannel + channelStr = (channelRaw.value if hasattr(channelRaw, 'value') else str(channelRaw)).lower().strip() + sendVoice = channelStr in ("voice", "both") + sendChat = channelStr in ("chat", "both") + logger.info(f"Response channel (from config): '{channelStr}'") + + if sendVoice and sendChat: + responseType = TeamsbotResponseType.BOTH + elif sendVoice: + responseType = TeamsbotResponseType.AUDIO + else: + responseType = TeamsbotResponseType.CHAT + + canonicalText = ( + speechResult.responseText + or speechResult.responseTextForVoice + or speechResult.responseTextForChat + or "" + ) + normalizedResponse = (canonicalText or "").strip().lower() + nowTs = time.time() + if ( + normalizedResponse + and service._lastBotResponseText == normalizedResponse + and (nowTs - service._lastBotResponseTs) < 90 + ): + logger.info(f"Session {sessionId}: Suppressing duplicate bot response within 90s window") + await _emitSessionEvent(sessionId, "analysis", { + "shouldRespond": False, + "detectedIntent": speechResult.detectedIntent, + "reasoning": "Suppressed duplicate response within 90s", + "modelName": response.modelName, + "processingTime": response.processingTime, + "priceCHF": response.priceCHF, + }) + return + + textForVoice = speechResult.responseTextForVoice or speechResult.responseText + textForChat = speechResult.responseTextForChat or speechResult.responseText + storedText = textForChat or textForVoice or speechResult.responseText + + if sendVoice and textForVoice: + await _emitSessionEvent(sessionId, "ttsDeliveryStatus", { + "status": "requested", + "hasWebSocket": websocket is not None, + "message": "TTS generation requested", + "timestamp": getUtcTimestamp(), + }) + logger.info( + f"Session {sessionId}: TTS requested (websocket_available={websocket is not None})" + ) + if not websocket: + logger.warning( + f"Session {sessionId}: TTS skipped (bot websocket unavailable, likely fallback mode)" + ) + await _emitSessionEvent(sessionId, "ttsDeliveryStatus", { + "status": "unavailable", + "hasWebSocket": False, + "message": "TTS skipped — bot websocket unavailable", + "timestamp": getUtcTimestamp(), + }) + if not sendChat: + sendChat = True + else: + spokenText = await _summarizeForVoice(service, sessionId, textForVoice) + cancelHook = service._makeAnswerCancelHook() + async with service._meetingTtsLock: + ttsOutcome = await _speakTextChunked( + websocket=websocket, + voiceInterface=voiceInterface, + sessionId=sessionId, + voiceText=spokenText, + languageCode=service.config.language, + voiceName=service.config.voiceId, + isCancelled=cancelHook, + ) + if ttsOutcome.get("success"): + logger.info( + f"Session {sessionId}: TTS audio dispatched to bot " + f"(chunks={ttsOutcome.get('chunks')}, played={ttsOutcome.get('played')})" + ) + await _emitSessionEvent(sessionId, "ttsDeliveryStatus", { + "status": "dispatched", + "hasWebSocket": True, + "chunks": ttsOutcome.get("chunks"), + "played": ttsOutcome.get("played"), + "timestamp": getUtcTimestamp(), + }) + else: + logger.warning( + f"TTS failed for session {sessionId}: {ttsOutcome.get('error')}" + ) + await _emitSessionEvent(sessionId, "ttsDeliveryStatus", { + "status": "failed", + "hasWebSocket": True, + "chunks": ttsOutcome.get("chunks"), + "played": ttsOutcome.get("played"), + "message": ttsOutcome.get("error"), + "timestamp": getUtcTimestamp(), + }) + if not sendChat: + sendChat = True + + if sendChat and textForChat: + try: + if websocket: + await websocket.send_text(json.dumps({ + "type": "sendChatMessage", + "sessionId": sessionId, + "text": textForChat, + })) + logger.info(f"Chat response sent for session {sessionId}") + except Exception as chatErr: + logger.warning(f"Chat message send failed for session {sessionId}: {chatErr}") + + botResponseData = TeamsbotBotResponse( + sessionId=sessionId, + responseText=storedText, + responseType=responseType, + detectedIntent=speechResult.detectedIntent, + reasoning=speechResult.reasoning, + triggeredByTranscriptId=triggerTranscript.get("id"), + modelName=response.modelName, + processingTime=response.processingTime, + priceCHF=response.priceCHF, + timestamp=getUtcTimestamp(), + ).model_dump() + + createdResponse = interface.createBotResponse(botResponseData) + + await _emitSessionEvent(sessionId, "botResponse", { + "id": createdResponse.get("id"), + "responseText": storedText, + "responseType": responseType.value, + "detectedIntent": speechResult.detectedIntent, + "reasoning": speechResult.reasoning, + "modelName": response.modelName, + "processingTime": response.processingTime, + "priceCHF": response.priceCHF, + "timestamp": botResponseData.get("timestamp"), + }) + + session = interface.getSession(sessionId) + if session: + count = session.get("botResponseCount", 0) + 1 + interface.updateSession(sessionId, {"botResponseCount": count}) + + service._lastBotResponseText = normalizedResponse + service._lastBotResponseTs = nowTs + + botTranscriptData = TeamsbotTranscript( + sessionId=sessionId, + speaker=service.config.botName, + text=storedText, + timestamp=getUtcTimestamp(), + confidence=1.0, + language=service.config.language, + isFinal=True, + ).model_dump() + botTranscript = interface.createTranscript(botTranscriptData) + + service._contextBuffer.append({ + "speaker": service.config.botName, + "text": storedText, + "timestamp": getUtcTimestamp(), + "source": "botResponse", + }) + + await _emitSessionEvent(sessionId, "transcript", { + "id": botTranscript.get("id"), + "speaker": service.config.botName, + "text": storedText, + "confidence": 1.0, + "timestamp": getUtcTimestamp(), + "isContinuation": False, + "source": "botResponse", + "speakerResolvedFromHint": False, + }) + + service._lastTranscriptSpeaker = service.config.botName + service._lastTranscriptText = storedText + service._lastTranscriptId = botTranscript.get("id") + + service._followUpWindowEnd = time.time() + 15.0 + logger.info(f"Bot responded in session {sessionId}: intent={speechResult.detectedIntent}, follow-up window open for 15s") + + if speechResult.commands: + from .serviceCommands import _executeCommands + await _executeCommands(service, sessionId, speechResult.commands, voiceInterface, websocket) + + if speechResult.shouldRespond and not speechResult.responseText: + cmdTexts = [ + c.params.get("text", "") for c in speechResult.commands + if c.action == "sendChat" and c.params and c.params.get("text") + ] + combinedText = " ".join(cmdTexts) if cmdTexts else None + if combinedText: + botResponseData = TeamsbotBotResponse( + sessionId=sessionId, + responseText=combinedText, + responseType=TeamsbotResponseType.CHAT, + detectedIntent=speechResult.detectedIntent, + reasoning=speechResult.reasoning, + triggeredByTranscriptId=triggerTranscript.get("id"), + modelName=response.modelName, + processingTime=response.processingTime, + priceCHF=response.priceCHF, + timestamp=getUtcTimestamp(), + ).model_dump() + createdResponse = interface.createBotResponse(botResponseData) + await _emitSessionEvent(sessionId, "botResponse", { + "id": createdResponse.get("id"), + "responseText": combinedText, + "responseType": TeamsbotResponseType.CHAT.value, + "detectedIntent": speechResult.detectedIntent, + "reasoning": speechResult.reasoning, + "modelName": response.modelName, + "processingTime": response.processingTime, + "priceCHF": response.priceCHF, + "timestamp": botResponseData.get("timestamp"), + }) + + session = interface.getSession(sessionId) + if session: + count = session.get("botResponseCount", 0) + 1 + interface.updateSession(sessionId, {"botResponseCount": count}) + + service._followUpWindowEnd = time.time() + 15.0 + logger.info( + f"Bot responded via commands in session {sessionId}: " + f"intent={speechResult.detectedIntent}, follow-up window open for 15s" + ) + + except Exception as e: + logger.error(f"SPEECH_TEAMS analysis failed for session {sessionId}: {type(e).__name__}: {e}", exc_info=True) + await _emitSessionEvent(sessionId, "error", {"message": f"AI analysis failed: {type(e).__name__}: {str(e)}"}) + finally: + service._aiAnalysisInProgress = False + + +async def _processTranscript( + service, + sessionId: str, + speaker: str, + text: str, + isFinal: bool, + interface, + voiceInterface, + websocket: WebSocket, + source: str = "caption", + speakerResolvedFromHint: Optional[bool] = None, +): + """Process a transcript segment from captions or chat messages.""" + from .service import _emitSessionEvent + + text = text.strip() + if not text: + return + + if source in ("caption", "speakerHint"): + service._registerSpeakerHint(speaker, text, sessionId) + + if ( + source == "speakerHint" + and isFinal + and not service._isBotSpeaker(speaker) + and service.config.responseMode != TeamsbotResponseMode.TRANSCRIBE_ONLY + and service._detectBotName(text) + ): + triggerTranscript = {"id": None, "speaker": speaker, "text": text, "source": source} + isNew = service._setPendingNameTrigger(sessionId, interface, voiceInterface, websocket, triggerTranscript) + if isNew: + logger.info(f"Session {sessionId}: Bot name in caption, debounce trigger started") + asyncio.create_task(_checkPendingNameTrigger(service)) + service._currentQuickAckTask = asyncio.create_task( + _runQuickAck(service, sessionId) + ) + return + + if source == "chatHistory": + transcriptData = TeamsbotTranscript( + sessionId=sessionId, + speaker=speaker, + text=text, + timestamp=getUtcTimestamp(), + confidence=1.0, + language=service.config.language, + isFinal=True, + source="chatHistory", + ).model_dump() + createdTranscript = interface.createTranscript(transcriptData) + + await _emitSessionEvent(sessionId, "transcript", { + "id": createdTranscript.get("id"), + "speaker": speaker, + "text": text, + "confidence": 1.0, + "timestamp": getUtcTimestamp(), + "isContinuation": False, + "source": "chatHistory", + "isHistory": True, + }) + logger.debug(f"Session {sessionId}: Chat history stored (no AI trigger): [{speaker}] {text[:60]}") + return + + isBotSpeaker = service._isBotSpeaker(speaker) + if isBotSpeaker and source != "chat": + logger.debug(f"Session {sessionId}: Ignoring own bot caption from: [{speaker}] {text[:80]}...") + return + + sttPauseThreshold = 5.0 + isMerge = ( + source == "audioCapture" + and service._lastTranscriptSpeaker == speaker + and service._lastTranscriptText is not None + and service._lastTranscriptId is not None + and (time.time() - service._lastSttTime) < sttPauseThreshold + ) + + if isMerge: + mergedText = f"{service._lastTranscriptText} {text}" + interface.updateTranscript(service._lastTranscriptId, { + "text": mergedText, + "isFinal": isFinal, + }) + service._lastTranscriptText = mergedText + createdTranscript = {"id": service._lastTranscriptId} + + if service._contextBuffer and service._contextBuffer[-1].get("speaker") == speaker: + service._contextBuffer[-1]["text"] = mergedText + else: + transcriptData = TeamsbotTranscript( + sessionId=sessionId, + speaker=speaker, + text=text, + timestamp=getUtcTimestamp(), + confidence=1.0, + language=service.config.language, + isFinal=isFinal, + source=source, + ).model_dump() + + createdTranscript = interface.createTranscript(transcriptData) + + service._lastTranscriptSpeaker = speaker + service._lastTranscriptText = text + service._lastTranscriptId = createdTranscript.get("id") + + if source == "audioCapture" and speaker == "Unknown": + service._unattributedTranscriptIds.append(createdTranscript.get("id")) + + service._contextBuffer.append({ + "speaker": speaker or "Unknown", + "text": text, + "timestamp": getUtcTimestamp(), + "source": source, + }) + + maxSegments = service.config.contextWindowSegments + if len(service._contextBuffer) > maxSegments: + if not service._contextSummary and len(service._contextBuffer) > maxSegments * 1.5: + asyncio.create_task(service._summarizeContextBuffer(sessionId)) + service._contextBuffer = service._contextBuffer[-maxSegments:] + + session = interface.getSession(sessionId) + if session: + count = session.get("transcriptSegmentCount", 0) + 1 + interface.updateSession(sessionId, {"transcriptSegmentCount": count}) + + if source == "audioCapture": + service._lastSttTime = time.time() + + displayText = service._lastTranscriptText if isMerge else text + await _emitSessionEvent(sessionId, "transcript", { + "id": createdTranscript.get("id"), + "speaker": speaker, + "text": displayText, + "confidence": 1.0, + "timestamp": getUtcTimestamp(), + "isContinuation": isMerge, + "source": source, + "speakerResolvedFromHint": ( + speakerResolvedFromHint + if speakerResolvedFromHint is not None + else False + ), + }) + + if not isFinal: + return + + if service.config.responseMode == TeamsbotResponseMode.TRANSCRIBE_ONLY: + return + + if source == "chat" and isBotSpeaker: + return + + if service._isStopPhrase(text): + logger.info( + f"Session {sessionId}: Stop phrase detected ('{text.strip()[:60]}'), " + f"hard-cancelling in-flight speech immediately" + ) + from .serviceWebSocket import _cancelInFlightSpeech + await _cancelInFlightSpeech( + service, + sessionId=sessionId, + websocket=websocket, + reason="userStopPhrase", + ) + return + + if service._pendingNameTrigger: + service._pendingNameTrigger["lastActivity"] = time.time() + + if service._detectBotName(text): + isNew = service._setPendingNameTrigger(sessionId, interface, voiceInterface, websocket, createdTranscript) + if isNew: + asyncio.create_task(_checkPendingNameTrigger(service)) + service._currentQuickAckTask = asyncio.create_task( + _runQuickAck(service, sessionId) + ) + return + + if ( + source == "audioCapture" + and not service._isBotSpeaker(speaker) + and time.time() < service._followUpWindowEnd + and not service._pendingNameTrigger + ): + isNew = service._setPendingNameTrigger(sessionId, interface, voiceInterface, websocket, createdTranscript) + if isNew: + logger.info(f"Session {sessionId}: Follow-up window trigger (no name needed)") + asyncio.create_task(_checkPendingNameTrigger(service)) + return + + if not service._pendingNameTrigger: + shouldTrigger = service._shouldTriggerAnalysis(text) + if shouldTrigger: + logger.info(f"Session {sessionId}: Periodic trigger (buffer: {len(service._contextBuffer)} segments)") + await _analyzeAndRespond(service, sessionId, interface, voiceInterface, websocket, createdTranscript) + + +async def _summarizeForVoice( + service, + sessionId: str, + rawAnswer: str, +) -> str: + """Return a SHORT, naturally-spoken paraphrase of ``rawAnswer`` for TTS.""" + from .service import _voiceFriendlyMeetingText, createAiService + + if not rawAnswer or not rawAnswer.strip(): + return "" + + sanitised = _voiceFriendlyMeetingText(rawAnswer) + if ( + len(sanitised) <= service._VOICE_DIRECT_MAX_CHARS + and not service._looksLikeStructuredText(rawAnswer) + ): + return sanitised + + targetLang = (service.config.language or "de-DE").strip() + botName = (service.config.botName or "").strip() or "the assistant" + persona = (service.config.aiSystemPrompt or "").strip() + personaBlock = ( + f"\n\nBOT PERSONA / TONE:\n{persona}\n" + if persona else "" + ) + + prompt = ( + f"You are condensing a long written answer into a SHORT spoken " + f"paraphrase that the assistant '{botName}' will say out loud " + f"into a Microsoft Teams meeting. The full written answer is " + f"already in the meeting chat — your job is to summarise it for " + f"the EAR, not the eye.\n\n" + f"STRICT REQUIREMENTS:\n" + f"1. Output language: BCP-47 '{targetLang}'. No other language.\n" + f"2. 1 to 3 sentences, max ~{service._VOICE_SUMMARY_MAX_CHARS} characters total.\n" + f"3. Natural spoken style — no headings, no bullet points, no " + f"tables, no markdown, no emojis, no enumerations like 'Erstens... " + f"Zweitens...' unless that genuinely flows in speech.\n" + f"4. Capture the essence and the most important conclusion. Do " + f"NOT try to fit every detail. Listeners can read the chat for " + f"the full version.\n" + f"5. End by gently pointing the audience to the chat for details, " + f"e.g. 'Details stehen im Chat.' (adapted to the target language).\n" + f"6. Output ONLY the spoken text. No JSON, no quotes around it, " + f"no preamble like 'Here is the summary:'.\n" + f"{personaBlock}\n" + f"FULL WRITTEN ANSWER (markdown-formatted, sometimes long):\n" + f"---\n{rawAnswer.strip()[:6000]}\n---\n" + ) + + try: + aiService = createAiService( + service.currentUser, service.mandateId, service.instanceId + ) + await aiService.ensureAiObjectsInitialized() + request = AiCallRequest( + prompt=prompt, + context="", + options=AiCallOptions( + operationType=OperationTypeEnum.DATA_ANALYSE, + priority=PriorityEnum.SPEED, + ), + ) + response = await aiService.callAi(request) + except Exception as aiErr: + logger.warning( + f"Session {sessionId}: Voice summary AI call failed: {aiErr}" + ) + return sanitised[: service._VOICE_DIRECT_MAX_CHARS] + + if not response or response.errorCount != 0 or not response.content: + logger.warning( + f"Session {sessionId}: Voice summary returned empty/error" + ) + return sanitised[: service._VOICE_DIRECT_MAX_CHARS] + + spoken = response.content.strip() + spoken = _voiceFriendlyMeetingText(spoken) + if not spoken: + return sanitised[: service._VOICE_DIRECT_MAX_CHARS] + + logger.info( + f"Session {sessionId}: Voice summary generated " + f"(orig={len(rawAnswer)} chars, sanitised={len(sanitised)}, " + f"spoken={len(spoken)})" + ) + return spoken + + +async def _pickQuickAckText(service) -> Optional[str]: + """Return a short ack text in the bot's configured language.""" + return await _pickEphemeralPhrase(service, "quickAck") + + +async def _pickEphemeralPhrase( + service, + kind: str, + substitutions: Optional[Dict[str, Any]] = None, +) -> Optional[str]: + """Round-robin selector over the cached phrase pool for ``kind``.""" + variants = await _getEphemeralPhrases(service, kind) + if not variants: + return None + idx = service._phrasePoolIdx.get(kind, 0) % len(variants) + service._phrasePoolIdx[kind] = (idx + 1) % len(variants) + chosen = variants[idx] + if substitutions: + try: + chosen = chosen.format(**substitutions) + except (KeyError, IndexError, ValueError) as fmtErr: + logger.debug( + f"Ephemeral phrase substitution failed for kind={kind}: {fmtErr}" + ) + return chosen + + +async def _getEphemeralPhrases(service, kind: str) -> List[str]: + """Return the cached pool of AI-generated variants for ``kind``.""" + cached = service._phrasePool.get(kind) + if cached: + return cached + async with service._phrasePoolLock: + cached = service._phrasePool.get(kind) + if cached: + return cached + phrases = await _generateEphemeralPhrases(service, kind, 4) + if phrases: + service._phrasePool[kind] = phrases + return phrases + + +async def _generateEphemeralPhrases( + service, kind: str, count: int +) -> List[str]: + """Ask the AI to produce ``count`` short utterances for ``kind``.""" + from .service import createAiService, _EPHEMERAL_PHRASE_INTENTS + + intent = _EPHEMERAL_PHRASE_INTENTS.get(kind) + if not intent: + logger.warning(f"Unknown ephemeral phrase kind requested: {kind}") + return [] + + targetLang = (service.config.language or "").strip() or "en-US" + botName = (service.config.botName or "the assistant").strip() + persona = (service.config.aiSystemPrompt or "").strip() + + prompt = ( + f"You are localizing short SPOKEN-LANGUAGE utterances for a " + f"meeting assistant named '{botName}'.\n\n" + f"Persona / style guide for the assistant:\n" + f"{persona or '(no persona configured — use a neutral, polite, professional tone)'}\n\n" + f"Target spoken language (BCP-47 code): {targetLang}\n\n" + f"Utterance intent:\n{intent}\n\n" + f"Generate {count} DIFFERENT variants matching this intent, in " + f"the target language. Variants should feel natural when spoken " + f"aloud, not robotic. Do NOT include the assistant's name in " + f"the variants.\n\n" + f"Output STRICTLY a JSON array of {count} plain-text strings, " + f"with no markdown fences, no commentary, no surrounding " + f"quotation marks beyond the JSON syntax itself. Example " + f"format: [\"...\", \"...\", \"...\", \"...\"]" + ) + + try: + aiService = createAiService( + service.currentUser, service.mandateId, service.instanceId + ) + await aiService.ensureAiObjectsInitialized() + request = AiCallRequest( + prompt=prompt, + context="", + options=AiCallOptions( + operationType=OperationTypeEnum.DATA_ANALYSE, + priority=PriorityEnum.SPEED, + ), + ) + response = await aiService.callAi(request) + except Exception as aiErr: + logger.warning( + f"Ephemeral phrase generation failed (kind={kind}, lang={targetLang}): {aiErr}" + ) + return [] + + if not response or response.errorCount != 0 or not response.content: + logger.warning( + f"Ephemeral phrase generation returned empty/error " + f"(kind={kind}, lang={targetLang})" + ) + return [] + + raw = response.content.strip() + raw = re.sub(r"^```(?:json)?\s*", "", raw) + raw = re.sub(r"\s*```\s*$", "", raw) + try: + arr = json.loads(raw) + except json.JSONDecodeError as parseErr: + logger.warning( + f"Ephemeral phrase generation: could not parse JSON " + f"(kind={kind}, lang={targetLang}): {parseErr} " + f"raw={raw[:200]}" + ) + return [] + if not isinstance(arr, list): + return [] + cleaned = [ + str(v).strip() + for v in arr + if isinstance(v, str) and str(v).strip() + ] + cleaned = cleaned[:count] + if cleaned: + logger.info( + f"Ephemeral phrase pool generated (kind={kind}, " + f"lang={targetLang}, count={len(cleaned)})" + ) + return cleaned + + +async def _runQuickAck(service, sessionId: str) -> None: + """Background task: speak a short ack into the meeting via TTS.""" + from .service import _emitSessionEvent, _speakTextChunked + + websocket = service._websocket + voiceInterface = service._voiceInterface + if websocket is None or voiceInterface is None: + return + if not service._shouldFireQuickAck(): + return + ackText = await _pickQuickAckText(service) + if not ackText: + return + service._lastQuickAckTs = time.time() + try: + await _emitSessionEvent(sessionId, "quickAck", { + "text": ackText, + "timestamp": getUtcTimestamp(), + }) + cancelHook = service._makeAnswerCancelHook() + async with service._meetingTtsLock: + outcome = await _speakTextChunked( + websocket=websocket, + voiceInterface=voiceInterface, + sessionId=sessionId, + voiceText=ackText, + languageCode=service.config.language, + voiceName=service.config.voiceId, + isCancelled=cancelHook, + ) + if not outcome.get("success"): + logger.info( + f"Session {sessionId}: Quick ack TTS failed silently " + f"({outcome.get('error')}) — main response will still go through" + ) + except asyncio.CancelledError: + logger.info(f"Session {sessionId}: Quick ack cancelled by stop signal") + except Exception as ackErr: + logger.warning(f"Session {sessionId}: Quick ack failed: {ackErr}") + finally: + service._currentQuickAckTask = None + + +async def _checkPendingNameTrigger(service, delaySec: float = 3.0): + """Async loop: fire the pending name trigger once the speaker is quiet.""" + await asyncio.sleep(delaySec) + if not service._pendingNameTrigger: + return + + now = time.time() + lastActivity = service._pendingNameTrigger.get("lastActivity", 0) + detectedAt = service._pendingNameTrigger.get("detectedAt", 0) + quietSec = now - lastActivity + totalWaitSec = now - detectedAt + + if quietSec >= 3.0 or totalWaitSec >= 15.0: + trigger = service._pendingNameTrigger + service._pendingNameTrigger = None + logger.info( + f"Session {trigger['sessionId']}: Debounced name trigger fires " + f"(quiet={quietSec:.1f}s, totalWait={totalWaitSec:.1f}s)" + ) + await _analyzeAndRespond( + service, + trigger["sessionId"], + trigger["interface"], + trigger["voiceInterface"], + trigger["websocket"], + trigger["triggerTranscript"], + ) + else: + remaining = max(0.5, 3.0 - quietSec) + asyncio.create_task(_checkPendingNameTrigger(service, remaining)) + + +async def _warmEphemeralPhrasePool(service, sessionId: str) -> None: + """Fire-and-forget: generate ephemeral phrase pool for all kinds.""" + from .service import _EPHEMERAL_PHRASE_INTENTS + + try: + for kind in _EPHEMERAL_PHRASE_INTENTS: + try: + await _getEphemeralPhrases(service, kind) + except Exception as innerErr: + logger.warning( + f"Session {sessionId}: Phrase pool warmup failed for " + f"kind={kind}: {innerErr}" + ) + except Exception as warmErr: + logger.warning( + f"Session {sessionId}: Phrase pool warmup task crashed: {warmErr}" + ) + + +async def _runEscalationAndRelease( + service, + sessionId: str, + taskBrief: str, + briefingFileIds: List[str], + triggerTranscriptId: Optional[str], +) -> None: + """Background wrapper for ``_runAgentForMeeting`` that holds the + ``_agentEscalationInFlight`` flag for the duration of the agent run.""" + try: + await service._runAgentForMeeting( + sessionId=sessionId, + taskText=taskBrief, + fileIds=briefingFileIds, + sourceLabel="speechEscalation", + triggerTranscriptId=triggerTranscriptId, + ) + except asyncio.CancelledError: + logger.info( + f"Session {sessionId}: Escalation agent task cancelled by stop signal" + ) + except Exception as escErr: + logger.error( + f"Session {sessionId}: Escalation agent task failed: " + f"{type(escErr).__name__}: {escErr}", + exc_info=True, + ) + finally: + service._agentEscalationInFlight = False + service._currentEscalationTask = None diff --git a/modules/features/teamsbot/serviceWebSocket.py b/modules/features/teamsbot/serviceWebSocket.py new file mode 100644 index 00000000..2c462624 --- /dev/null +++ b/modules/features/teamsbot/serviceWebSocket.py @@ -0,0 +1,545 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Teamsbot Service — WebSocket handler & audio chunk processing. + +Extracted from service.py. All functions accept `service` (a TeamsbotService +instance) as the first parameter so the class can delegate to them. +""" + +import logging +import json +import asyncio +import time +import base64 +from typing import Optional, Dict, Any + +from fastapi import WebSocket + +from modules.shared.timeUtils import getUtcTimestamp + +logger = logging.getLogger(__name__) + + +async def handleBotWebSocket(service, websocket: WebSocket, sessionId: str): + """Main WebSocket handler for Browser Bot communication.""" + from . import interfaceFeatureTeamsbot as interfaceDb + from modules.interfaces.interfaceVoiceObjects import getVoiceInterface + from .service import _activeServices, _emitSessionEvent, sessionEvents + from .serviceConversation import _processTranscript, _warmEphemeralPhrasePool + + interface = interfaceDb.getInterface(service.currentUser, service.mandateId, service.instanceId) + voiceInterface = getVoiceInterface(service.currentUser, service.mandateId) + + session = interface.getSession(sessionId) + if session: + rawContext = session.get("sessionContext") + if rawContext and len(rawContext) > 500: + logger.info(f"Session {sessionId}: Summarizing long session context ({len(rawContext)} chars)...") + service._sessionContext = await service._summarizeSessionContext(sessionId, rawContext) + elif rawContext: + service._sessionContext = rawContext + if service._sessionContext: + logger.info(f"Session {sessionId}: Session context ready ({len(service._sessionContext)} chars)") + + try: + systemBot = interface.getActiveSystemBot(service.mandateId) + service._botAccountEmail = systemBot.get("email") if systemBot else None + if service._botAccountEmail: + logger.info(f"Session {sessionId}: Bot account email resolved: {service._botAccountEmail}") + except Exception: + service._botAccountEmail = None + + service._activeSessionId = sessionId + service._websocket = websocket + service._voiceInterface = voiceInterface + _activeServices[sessionId] = service + + try: + await _emitSessionEvent(sessionId, "botConnectionState", { + "connected": True, + "timestamp": getUtcTimestamp(), + }) + except Exception: + pass + + try: + service._activePersistentPrompts = interface.getActivePersistentPrompts(sessionId) or [] + if service._activePersistentPrompts: + logger.info( + f"Session {sessionId}: Loaded {len(service._activePersistentPrompts)} active persistent director prompt(s)" + ) + except Exception as restoreErr: + logger.warning(f"Session {sessionId}: Could not restore persistent director prompts: {restoreErr}") + service._activePersistentPrompts = [] + + asyncio.create_task(_warmEphemeralPhrasePool(service, sessionId)) + + logger.info(f"[WS] Handler started for session {sessionId}") + + try: + msgCount = 0 + while True: + data = await websocket.receive() + msgCount += 1 + + if "text" not in data: + logger.debug(f"[WS] session={sessionId} msg #{msgCount}: non-text data (keys: {list(data.keys())})") + continue + + message = json.loads(data["text"]) + msgType = message.get("type") + if msgType not in ("audioChunk", "ping"): + logger.info(f"[WS] session={sessionId} msg #{msgCount}: type={msgType}") + + if msgType == "transcript": + transcript = message.get("transcript", {}) + source = transcript.get("source", "caption") + speaker = transcript.get("speaker", "Unknown") + textPreview = (transcript.get("text", "") or "")[:60] + logger.info(f"[WS] Transcript (source={source}, speaker={speaker}): {textPreview}...") + await _processTranscript( + service, + sessionId=sessionId, + speaker=transcript.get("speaker", "Unknown"), + text=transcript.get("text", ""), + isFinal=transcript.get("isFinal", True), + interface=interface, + voiceInterface=voiceInterface, + websocket=websocket, + source=source, + ) + + elif msgType == "chatMessage": + chat = message.get("chat", {}) + isHistory = chat.get("isHistory", False) + source = "chatHistory" if isHistory else "chat" + logger.info( + f"[WS] Chat{'[HISTORY]' if isHistory else ''}: " + f"speaker={chat.get('speaker')}, text={chat.get('text', '')[:60]}..." + ) + await _processTranscript( + service, + sessionId=sessionId, + speaker=chat.get("speaker", "Unknown"), + text=chat.get("text", ""), + isFinal=True, + interface=interface, + voiceInterface=voiceInterface, + websocket=websocket, + source=source, + ) + + elif msgType == "status": + status = message.get("status") + errorMessage = message.get("message") + logger.info(f"[WS] Status: status={status}, message={errorMessage}") + await _handleBotStatus(service, sessionId, status, errorMessage, interface) + + elif msgType == "audioChunk": + audioData = message.get("audio", {}) + audioBase64 = audioData.get("data", "") + sampleRate = audioData.get("sampleRate", 16000) + captureDiagnostics = audioData.get("captureDiagnostics") or {} + if audioBase64: + await _processAudioChunk( + service, + sessionId=sessionId, + audioBase64=audioBase64, + sampleRate=sampleRate, + captureDiagnostics=captureDiagnostics, + interface=interface, + voiceInterface=voiceInterface, + websocket=websocket, + ) + + elif msgType == "voiceGreeting": + greetingText = message.get("text", "") + greetingLang = message.get("language", service.config.language) + logger.info( + f"[WS] Voice greeting (legacy): text={greetingText[:60]}..., language={greetingLang}" + ) + if greetingText and voiceInterface: + await service._dispatchGreetingToMeeting( + sessionId=sessionId, + greetingText=greetingText, + greetingLang=greetingLang, + sendToChat=False, + interface=interface, + voiceInterface=voiceInterface, + websocket=websocket, + ) + + elif msgType == "requestGreeting": + requestedLang = ( + message.get("language") or service.config.language or "" + ).strip() or "en-US" + botNameHint = ( + message.get("botName") or service.config.botName or "" + ).strip() or service.config.botName + logger.info( + f"[WS] Greeting request from bot: language={requestedLang}, name={botNameHint}" + ) + if voiceInterface: + try: + greetingText = await service._generateGreetingText( + requestedLang + ) + except Exception as genErr: + logger.warning( + f"Greeting generation failed for session {sessionId}: {genErr}" + ) + greetingText = "" + if greetingText: + await service._dispatchGreetingToMeeting( + sessionId=sessionId, + greetingText=greetingText, + greetingLang=requestedLang, + sendToChat=True, + interface=interface, + voiceInterface=voiceInterface, + websocket=websocket, + ) + else: + logger.warning( + f"Session {sessionId}: Skipping greeting — AI generation produced no text" + ) + + elif msgType == "ping": + await websocket.send_text(json.dumps({"type": "pong"})) + + elif msgType == "ttsPlaybackAck": + playback = message.get("playback", {}) or {} + status = playback.get("status", "unknown") + ackMessage = playback.get("message") or "Bot playback status update" + logger.info( + f"[WS] TTS playback ack: status={status}, format={playback.get('format')}, " + f"bytesBase64={playback.get('bytesBase64')}" + ) + await _emitSessionEvent(sessionId, "ttsDeliveryStatus", { + "status": f"playback_{status}", + "hasWebSocket": True, + "message": ackMessage, + "timestamp": playback.get("timestamp") or getUtcTimestamp(), + "format": playback.get("format"), + "bytesBase64": playback.get("bytesBase64"), + }) + + elif msgType == "mfaChallenge": + mfaData = message.get("mfa", {}) + mfaType = mfaData.get("type", "unknown") + displayNumber = mfaData.get("displayNumber") + prompt = mfaData.get("prompt", "") + logger.info(f"[WS] MFA challenge: type={mfaType}, number={displayNumber}, prompt={prompt[:60]}") + + await _emitSessionEvent(sessionId, "mfaChallenge", { + "mfaType": mfaType, + "displayNumber": displayNumber, + "prompt": prompt, + "timestamp": getUtcTimestamp(), + }) + + from .routeFeatureTeamsbot import mfaCodeQueues, mfaWaitTasks + mfaQueue = asyncio.Queue() + mfaCodeQueues[sessionId] = mfaQueue + + mfaWaitTasks[sessionId] = asyncio.create_task( + _waitAndForwardMfa(sessionId, mfaQueue, websocket) + ) + + elif msgType == "chatSendFailed": + errorData = message.get("error", {}) + reason = errorData.get("reason", "unknown") + failedText = errorData.get("text", "") + logger.warning( + f"[WS] Chat send failed for session {sessionId}: " + f"reason={reason}, text={failedText[:60]}" + ) + await _emitSessionEvent(sessionId, "chatSendFailed", { + "reason": reason, + "message": errorData.get("message", "Chat message could not be sent"), + "text": failedText, + "timestamp": getUtcTimestamp(), + }) + + elif msgType == "mfaResolved": + success = message.get("success", False) + logger.info(f"[WS] MFA resolved: success={success}") + from .routeFeatureTeamsbot import mfaCodeQueues, mfaWaitTasks + task = mfaWaitTasks.pop(sessionId, None) + if task and not task.done(): + task.cancel() + mfaCodeQueues.pop(sessionId, None) + await _emitSessionEvent(sessionId, "mfaResolved", { + "success": success, + "timestamp": getUtcTimestamp(), + }) + + except Exception as e: + if "disconnect" not in str(e).lower(): + logger.error(f"[WS] Error for session {sessionId}: {type(e).__name__}: {e}") + finally: + if _activeServices.get(sessionId) is service: + _activeServices.pop(sessionId, None) + service._websocket = None + service._voiceInterface = None + service._activeSessionId = None + try: + await _emitSessionEvent(sessionId, "botConnectionState", { + "connected": False, + "timestamp": getUtcTimestamp(), + }) + except Exception: + pass + + logger.info(f"[WS] Handler ended for session {sessionId} after {msgCount} messages") + + +async def _waitAndForwardMfa(sid: str, queue: asyncio.Queue, ws: WebSocket): + """Wait for an MFA code from the operator and forward it to the bot.""" + from .service import _emitSessionEvent + from .routeFeatureTeamsbot import mfaCodeQueues, mfaWaitTasks + + try: + mfaResponse = await asyncio.wait_for(queue.get(), timeout=120.0) + logger.info(f"[WS] MFA response received for session {sid}: action={mfaResponse.get('action')}") + await ws.send_text(json.dumps({ + "type": "mfaResponse", + "sessionId": sid, + "mfa": mfaResponse, + })) + except asyncio.TimeoutError: + logger.warning(f"[WS] MFA response timeout for session {sid}") + await ws.send_text(json.dumps({ + "type": "mfaResponse", + "sessionId": sid, + "mfa": {"action": "timeout"}, + })) + await _emitSessionEvent(sid, "mfaChallenge", { + "mfaType": "timeout", + "prompt": "MFA-Zeitlimit ueberschritten. Bitte erneut versuchen.", + }) + except asyncio.CancelledError: + logger.info(f"[WS] MFA wait cancelled for session {sid} (resolved via page)") + finally: + mfaCodeQueues.pop(sid, None) + mfaWaitTasks.pop(sid, None) + + +async def _handleBotStatus( + service, + sessionId: str, + status: str, + errorMessage: Optional[str], + interface, +): + """Handle status updates from the browser bot.""" + from .service import _emitSessionEvent + from .datamodelTeamsbot import TeamsbotSessionStatus + + logger.info(f"Bot status update for session {sessionId}: {status}") + + statusMap = { + "connecting": TeamsbotSessionStatus.JOINING.value, + "launching": TeamsbotSessionStatus.JOINING.value, + "navigating": TeamsbotSessionStatus.JOINING.value, + "in_lobby": TeamsbotSessionStatus.JOINING.value, + "joined": TeamsbotSessionStatus.ACTIVE.value, + "in_meeting": TeamsbotSessionStatus.ACTIVE.value, + "left": TeamsbotSessionStatus.ENDED.value, + "error": TeamsbotSessionStatus.ERROR.value, + } + + dbStatus = statusMap.get(status, TeamsbotSessionStatus.ACTIVE.value) + + updates = {"status": dbStatus} + if errorMessage: + updates["errorMessage"] = errorMessage + if dbStatus == TeamsbotSessionStatus.ACTIVE.value: + updates["startedAt"] = getUtcTimestamp() + elif dbStatus in [TeamsbotSessionStatus.ENDED.value, TeamsbotSessionStatus.ERROR.value]: + updates["endedAt"] = getUtcTimestamp() + + interface.updateSession(sessionId, updates) + await _emitSessionEvent(sessionId, "statusChange", {"status": status, "errorMessage": errorMessage}) + + if dbStatus in [TeamsbotSessionStatus.ENDED.value, TeamsbotSessionStatus.ERROR.value]: + if service._audioBuffer: + logger.info(f"[AudioChunk] Flushing remaining buffer on session end ({len(service._audioBuffer)} bytes)") + service._audioBuffer = b"" + service._audioBufferStartTime = 0.0 + service._audioBufferLastChunkTime = 0.0 + + if dbStatus == TeamsbotSessionStatus.ENDED.value: + asyncio.create_task(service._generateMeetingSummary(sessionId)) + + +async def _processAudioChunk( + service, + sessionId: str, + audioBase64: str, + sampleRate: int, + captureDiagnostics: Optional[Dict[str, Any]], + interface, + voiceInterface, + websocket: WebSocket, +): + """Process an audio chunk from WebRTC capture.""" + from .serviceConversation import _processTranscript + + _MIN_CHUNK_SEC = 1.0 + _STALE_TIMEOUT_SEC = 3.0 + + try: + audioBytes = base64.b64decode(audioBase64) + if len(audioBytes) < 500: + return + + if captureDiagnostics: + trackId = captureDiagnostics.get("trackId") + readyState = captureDiagnostics.get("readyState") + rms = captureDiagnostics.get("rms") + nativeSampleRate = captureDiagnostics.get("nativeSampleRate") + logger.debug( + f"[AudioChunk] diagnostics: track={trackId}, readyState={readyState}, " + f"rms={rms}, nativeRate={nativeSampleRate}, bytes={len(audioBytes)}" + ) + + isSilent = False + if captureDiagnostics and captureDiagnostics.get("rms") is not None: + try: + rmsVal = float(captureDiagnostics.get("rms")) + if rmsVal < 0.0003: + isSilent = True + except Exception: + pass + + if not voiceInterface: + logger.warning(f"[AudioChunk] No voice interface available for session {sessionId}") + return + + now = time.time() + effectiveRate = sampleRate if sampleRate and sampleRate > 0 else 16000 + + if not isSilent: + if not service._audioBuffer: + service._audioBufferStartTime = now + service._audioBuffer += audioBytes + service._audioBufferLastChunkTime = now + service._audioBufferSampleRate = effectiveRate + + bufferDuration = len(service._audioBuffer) / (effectiveRate * 2) if service._audioBuffer else 0.0 + bufferAge = (now - service._audioBufferStartTime) if service._audioBuffer else 0.0 + + shouldFlush = ( + service._audioBuffer + and ( + bufferDuration >= _MIN_CHUNK_SEC + or (bufferAge >= _STALE_TIMEOUT_SEC and bufferDuration > 0.3) + ) + ) + + if not shouldFlush: + return + + flushBytes = service._audioBuffer + flushRate = service._audioBufferSampleRate + service._audioBuffer = b"" + service._audioBufferStartTime = 0.0 + service._audioBufferLastChunkTime = 0.0 + + flushDuration = len(flushBytes) / (flushRate * 2) + logger.info(f"[AudioChunk] Flushing buffer: {len(flushBytes)} bytes, {flushDuration:.1f}s, {flushRate}Hz") + + phraseHints = list(service._knownSpeakers) + if service.config.botName: + phraseHints.append(service.config.botName) + + sttResult = await voiceInterface.speechToText( + audioContent=flushBytes, + language=service.config.language or "de-DE", + sampleRate=flushRate, + channels=1, + skipFallbacks=True, + phraseHints=phraseHints if phraseHints else None, + audioFormat="linear16", + ) + + if sttResult and sttResult.get("success") and sttResult.get("text"): + text = sttResult["text"].strip() + if text: + resolvedSpeaker = service._resolveSpeakerForAudioCapture() + fromCaption = resolvedSpeaker.get("speakerResolvedFromHint", False) + logger.info( + f"[AudioChunk] STT result: speaker={resolvedSpeaker.get('speaker', 'Meeting Audio')} " + f"(fromCaption={fromCaption}), text={text[:80]}..." + ) + await _processTranscript( + service, + sessionId=sessionId, + speaker=resolvedSpeaker["speaker"], + text=text, + isFinal=True, + interface=interface, + voiceInterface=voiceInterface, + websocket=websocket, + source="audioCapture", + speakerResolvedFromHint=resolvedSpeaker["speakerResolvedFromHint"], + ) + except Exception as e: + logger.error(f"[AudioChunk] STT error for session {sessionId}: {type(e).__name__}: {e}") + + +async def _cancelInFlightSpeech( + service, + sessionId: str, + websocket: Optional[WebSocket], + reason: str, +) -> None: + """Hard stop everything the bot is currently doing in the meeting.""" + from .service import _emitSessionEvent + + service._answerGenerationCounter += 1 + gen = service._answerGenerationCounter + logger.info( + f"Session {sessionId}: Cancelling in-flight speech " + f"(reason={reason}, gen={gen})" + ) + + if service._pendingNameTrigger: + logger.info( + f"Session {sessionId}: Dropping pending debounced name " + f"trigger (was queued before stop)" + ) + service._pendingNameTrigger = None + + for taskAttr in ("_currentEscalationTask", "_currentQuickAckTask"): + task = getattr(service, taskAttr, None) + if task is not None and not task.done(): + logger.info( + f"Session {sessionId}: Cancelling background task " + f"{taskAttr}" + ) + task.cancel() + + if websocket is not None: + try: + await websocket.send_text(json.dumps({ + "type": "stopAudio", + "sessionId": sessionId, + "reason": reason, + })) + except Exception as stopErr: + logger.warning( + f"Session {sessionId}: Failed to send stopAudio to " + f"browser bot: {stopErr}" + ) + + try: + await _emitSessionEvent(sessionId, "speechCancelled", { + "reason": reason, + "generation": gen, + "timestamp": getUtcTimestamp(), + }) + except Exception: + pass diff --git a/modules/features/trustee/handlerTrusteeAccounting.py b/modules/features/trustee/handlerTrusteeAccounting.py new file mode 100644 index 00000000..212d20e3 --- /dev/null +++ b/modules/features/trustee/handlerTrusteeAccounting.py @@ -0,0 +1,371 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Business logic for Trustee accounting integration endpoints. +Extracted from routeFeatureTrustee.py for maintainability. +""" + +import json +import logging +import time +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + +_CONFIG_PLACEHOLDER = "***" + + +class SaveAccountingConfigBody(BaseModel): + """Request body for saving accounting config.""" + connectorType: str = "" + displayLabel: str = "" + config: Dict[str, Any] = Field(default_factory=dict, description="Connector credentials (e.g. clientName, apiKey)") + + +def getConfigMasked(connectorType: str, plainConfig: Dict[str, Any]) -> Dict[str, str]: + """Build config with secret values replaced by placeholder for GET response.""" + from .accounting.accountingRegistry import getAccountingRegistry + connector = getAccountingRegistry().getConnector(connectorType) + if not connector: + return {k: (v if isinstance(v, str) else str(v)) for k, v in (plainConfig or {}).items()} + secretKeys = {f.key for f in connector.getRequiredConfigFields() if f.secret} + return { + k: _CONFIG_PLACEHOLDER if k in secretKeys else (v if isinstance(v, str) else str(v) if v is not None else "") + for k, v in (plainConfig or {}).items() + } + + +async def refreshChartSilently(interface, instanceId: str) -> None: + """Best-effort chart-of-accounts cache refresh. Logs but does not raise on failure.""" + try: + from .accounting.accountingBridge import AccountingBridge + bridge = AccountingBridge(interface) + charts = await bridge.refreshChartOfAccounts(instanceId) + logger.info(f"Chart cache refreshed: {len(charts)} entries for instance {instanceId}") + except Exception as e: + logger.warning(f"Chart cache refresh failed (non-critical): {e}") + + +def readAccountingConfig(interface, instanceId: str) -> Dict[str, Any]: + """Read and return the masked accounting config for an instance.""" + from .datamodelFeatureTrustee import TrusteeAccountingConfig + from modules.shared.configuration import decryptValue + + records = interface.db.getRecordset( + TrusteeAccountingConfig, recordFilter={"featureInstanceId": instanceId, "isActive": True} + ) + if not records: + return {"configured": False} + + record = {k: v for k, v in records[0].items() if not k.startswith("_")} + encryptedConfig = record.pop("encryptedConfig", None) + record["configured"] = True + if encryptedConfig: + try: + plain = json.loads(decryptValue(encryptedConfig, keyName="accountingConfig")) + record["configMasked"] = getConfigMasked(record.get("connectorType", ""), plain) + except Exception: + record["configMasked"] = {} + else: + record["configMasked"] = {} + return record + + +async def saveAccountingConfig(interface, instanceId: str, mandateId: str, body: "SaveAccountingConfigBody") -> Dict[str, Any]: + """Save or update accounting config with encrypted credentials and config merging.""" + import uuid as _uuid + from .datamodelFeatureTrustee import TrusteeAccountingConfig + from modules.shared.configuration import encryptValue, decryptValue + + plainConfig = body.config if isinstance(body.config, dict) else {} + if not plainConfig and body.connectorType: + logger.warning("Accounting config save: config is empty (credentials will not be stored or updated)") + else: + logger.info( + "Accounting config save: instanceId=%s connectorType=%s configKeys=%s", + instanceId, body.connectorType, list(plainConfig.keys()) + ) + + existing = interface.db.getRecordset(TrusteeAccountingConfig, recordFilter={"featureInstanceId": instanceId}) + if existing: + configId = existing[0].get("id") + updatePayload = { + "connectorType": body.connectorType or "", + "displayLabel": body.displayLabel or "", + "isActive": True, + } + if plainConfig: + existingEnc = existing[0].get("encryptedConfig") or "" + merged = {} + if existingEnc: + try: + merged = json.loads(decryptValue(existingEnc, keyName="accountingConfig")) + except Exception: + pass + for k, v in plainConfig.items(): + if v is not None and str(v).strip() and str(v).strip() != _CONFIG_PLACEHOLDER: + merged[k] = v + updatePayload["encryptedConfig"] = encryptValue(json.dumps(merged), keyName="accountingConfig") + interface.db.recordModify(TrusteeAccountingConfig, configId, updatePayload) + await refreshChartSilently(interface, instanceId) + return {"message": "Accounting config updated", "id": configId} + + if not plainConfig: + return None # Signal to route handler: raise 400 + + encryptedConfig = encryptValue(json.dumps(plainConfig), keyName="accountingConfig") + configRecord = { + "id": str(_uuid.uuid4()), + "featureInstanceId": instanceId, + "connectorType": body.connectorType or "", + "displayLabel": body.displayLabel or "", + "encryptedConfig": encryptedConfig, + "isActive": True, + "mandateId": mandateId, + } + interface.db.recordCreate(TrusteeAccountingConfig, configRecord) + await refreshChartSilently(interface, instanceId) + return {"message": "Accounting config created", "id": configRecord["id"]} + + +def getImportStatus(interface, instanceId: str) -> Dict[str, Any]: + """Get counts of imported TrusteeData* records for this instance.""" + from .datamodelFeatureTrustee import ( + TrusteeDataAccount, TrusteeDataJournalEntry, TrusteeDataJournalLine, + TrusteeDataContact, TrusteeDataAccountBalance, TrusteeAccountingConfig, + ) + filt = {"featureInstanceId": instanceId} + counts = { + "accounts": len(interface.db.getRecordset(TrusteeDataAccount, recordFilter=filt) or []), + "journalEntries": len(interface.db.getRecordset(TrusteeDataJournalEntry, recordFilter=filt) or []), + "journalLines": len(interface.db.getRecordset(TrusteeDataJournalLine, recordFilter=filt) or []), + "contacts": len(interface.db.getRecordset(TrusteeDataContact, recordFilter=filt) or []), + "accountBalances": len(interface.db.getRecordset(TrusteeDataAccountBalance, recordFilter=filt) or []), + } + cfgRecords = interface.db.getRecordset(TrusteeAccountingConfig, recordFilter={"featureInstanceId": instanceId, "isActive": True}) + if cfgRecords: + cfg = cfgRecords[0] + counts["lastSyncAt"] = cfg.get("lastSyncAt") + counts["lastSyncStatus"] = cfg.get("lastSyncStatus") + counts["lastSyncErrorMessage"] = cfg.get("lastSyncErrorMessage") + counts["lastSyncDateFrom"] = cfg.get("lastSyncDateFrom") + counts["lastSyncDateTo"] = cfg.get("lastSyncDateTo") + counts["lastSyncCounts"] = cfg.get("lastSyncCounts") + return counts + + +def wipeImportedData(interface, instanceId: str) -> Dict[str, Any]: + """Delete all TrusteeData* rows imported for this instance and reset sync markers.""" + from .datamodelFeatureTrustee import ( + TrusteeDataAccount, TrusteeDataJournalEntry, TrusteeDataJournalLine, + TrusteeDataContact, TrusteeDataAccountBalance, TrusteeAccountingConfig, + ) + from modules.serviceCenter.services.serviceAgent.coreTools._featureSubAgentTools import clearFeatureQueryCache + + removed: Dict[str, int] = {} + for tableName, model in [ + ("accounts", TrusteeDataAccount), + ("journalEntries", TrusteeDataJournalEntry), + ("journalLines", TrusteeDataJournalLine), + ("contacts", TrusteeDataContact), + ("accountBalances", TrusteeDataAccountBalance), + ]: + try: + removed[tableName] = int(interface.db.recordDeleteWhere(model, {"featureInstanceId": instanceId}) or 0) + except Exception as ex: + logger.warning("wipeImportedData: failed for %s: %s", tableName, ex) + removed[tableName] = 0 + + cfgRecords = interface.db.getRecordset( + TrusteeAccountingConfig, + recordFilter={"featureInstanceId": instanceId, "isActive": True}, + ) + if cfgRecords: + cfgId = cfgRecords[0].get("id") + if cfgId: + try: + interface.db.recordModify(TrusteeAccountingConfig, cfgId, { + "lastSyncAt": None, + "lastSyncStatus": None, + "lastSyncErrorMessage": None, + "lastSyncDateFrom": None, + "lastSyncDateTo": None, + "lastSyncCounts": None, + }) + except Exception as ex: + logger.warning("wipeImportedData: failed to reset lastSync* on cfg %s: %s", cfgId, ex) + + cacheCleared = clearFeatureQueryCache(instanceId) + logger.info("wipeImportedData instance=%s removed=%s cacheCleared=%s", instanceId, removed, cacheCleared) + return { + "removed": removed, + "totalRemoved": sum(removed.values()), + "cacheCleared": cacheCleared, + "featureInstanceId": instanceId, + } + + +def exportAccountingData(interface, instanceId: str, mandateId: str) -> Dict[str, Any]: + """Build the export payload for all TrusteeData* tables for this instance.""" + from .datamodelFeatureTrustee import ( + TrusteeDataAccount, TrusteeDataJournalEntry, TrusteeDataJournalLine, + TrusteeDataContact, TrusteeDataAccountBalance, TrusteeAccountingConfig, + ) + _filter = {"featureInstanceId": instanceId} + + tables: Dict[str, Any] = {} + for tableName, model in [ + ("TrusteeDataAccount", TrusteeDataAccount), + ("TrusteeDataJournalEntry", TrusteeDataJournalEntry), + ("TrusteeDataJournalLine", TrusteeDataJournalLine), + ("TrusteeDataContact", TrusteeDataContact), + ("TrusteeDataAccountBalance", TrusteeDataAccountBalance), + ]: + records = interface.db.getRecordset(model, recordFilter=_filter) or [] + tables[tableName] = records + + cfgRecords = interface.db.getRecordset( + TrusteeAccountingConfig, + recordFilter={"featureInstanceId": instanceId, "isActive": True}, + ) + syncInfo = {} + if cfgRecords: + cfg = cfgRecords[0] + syncInfo = { + "connectorType": cfg.get("connectorType", ""), + "lastSyncAt": cfg.get("lastSyncAt"), + "lastSyncStatus": cfg.get("lastSyncStatus", ""), + } + + return { + "exportedAt": time.time(), + "featureInstanceId": instanceId, + "mandateId": mandateId, + "syncInfo": syncInfo, + "tables": tables, + } + + +# --------------------------------------------------------------------------- +# Background Job Handlers +# --------------------------------------------------------------------------- + +TRUSTEE_ACCOUNTING_PUSH_JOB_TYPE = "trusteeAccountingPush" +TRUSTEE_ACCOUNTING_SYNC_JOB_TYPE = "trusteeAccountingSync" + + +async def accountingPushJobHandler(job: Dict[str, Any], progressCb) -> Dict[str, Any]: + """BackgroundJob handler: pushes a batch of positions to the external accounting system.""" + from modules.security.rootAccess import getRootUser + from .accounting.accountingBridge import AccountingBridge, SyncResult + from .interfaceFeatureTrustee import getInterface + + instanceId = job["featureInstanceId"] + mandateId = job["mandateId"] + payload = job.get("payload") or {} + positionIds: List[str] = list(payload.get("positionIds") or []) + if not positionIds: + return {"total": 0, "success": 0, "skipped": 0, "errors": 0, "results": []} + + rootUser = getRootUser() + interface = getInterface(rootUser, mandateId=mandateId, featureInstanceId=instanceId) + bridge = AccountingBridge(interface) + + results = [] + total = len(positionIds) + progressCb( + 2, + messageKey="Sync wird vorbereitet ({total} Position(en))...", + messageParams={"total": total}, + ) + + try: + connector, plainConfig, configRecord = await bridge._resolveConnectorAndConfig(instanceId) + except Exception as resolveErr: + logger.exception("Accounting push: failed to resolve connector/config") + progressCb(100, messageKey="Verbindungsaufbau fehlgeschlagen.") + raise resolveErr + + if not connector or not plainConfig: + results = [SyncResult(success=False, errorMessage="No active accounting configuration found") for _ in positionIds] + progressCb(100, messageKey="Keine aktive Buchhaltungs-Konfiguration gefunden.") + return { + "total": len(results), + "success": 0, + "skipped": 0, + "errors": len(results), + "results": [r.model_dump() for r in results], + } + + for index, positionId in enumerate(positionIds, start=1): + result = await bridge.pushPositionToAccounting( + instanceId, + positionId, + _resolvedConnector=connector, + _resolvedPlainConfig=plainConfig, + _resolvedConfigRecord=configRecord, + ) + results.append(result) + pct = 5 + int(90 * index / total) + progressCb( + pct, + messageKey="Position {index}/{total} verarbeitet", + messageParams={"index": index, "total": total}, + ) + + skipped = [r for r in results if not r.success and r.errorMessage and "already synced" in r.errorMessage] + failed = [r for r in results if not r.success and r not in skipped] + if skipped: + logger.info("Accounting sync: %s position(s) already synced, skipped", len(skipped)) + if failed: + logger.warning( + "Accounting sync had %s failure(s): %s", + len(failed), + "; ".join(r.errorMessage or "unknown" for r in failed[:3]), + ) + + progressCb(100, messageKey="Sync abgeschlossen.") + return { + "total": len(results), + "success": sum(1 for r in results if r.success), + "skipped": len(skipped), + "errors": len(failed), + "results": [r.model_dump() for r in results], + } + + +async def accountingSyncJobHandler(job: Dict[str, Any], progressCb) -> Dict[str, Any]: + """BackgroundJob handler: imports accounting data from the external system.""" + from modules.security.rootAccess import getRootUser + from .accounting.accountingDataSync import AccountingDataSync + from .interfaceFeatureTrustee import getInterface + + instanceId = job["featureInstanceId"] + mandateId = job["mandateId"] + payload = job.get("payload") or {} + rootUser = getRootUser() + + progressCb(5, messageKey="Initialisiere Import...") + interface = getInterface(rootUser, mandateId=mandateId, featureInstanceId=instanceId) + sync = AccountingDataSync(interface) + progressCb(10, messageKey="Verbinde mit Buchhaltungssystem...") + result = await sync.importData( + featureInstanceId=instanceId, + mandateId=mandateId, + dateFrom=payload.get("dateFrom"), + dateTo=payload.get("dateTo"), + progressCb=progressCb, + ) + progressCb(100, messageKey="Import abgeschlossen.") + return result + + +# Register background job handlers +try: + from modules.serviceCenter.services.serviceBackgroundJobs import registerJobHandler + registerJobHandler(TRUSTEE_ACCOUNTING_PUSH_JOB_TYPE, accountingPushJobHandler) + registerJobHandler(TRUSTEE_ACCOUNTING_SYNC_JOB_TYPE, accountingSyncJobHandler) +except Exception as _regErr: + logger.warning("Failed to register accounting job handlers: %s", _regErr) diff --git a/modules/features/trustee/mainTrustee.py b/modules/features/trustee/mainTrustee.py index 7c686f3e..4bcee319 100644 --- a/modules/features/trustee/mainTrustee.py +++ b/modules/features/trustee/mainTrustee.py @@ -1097,3 +1097,27 @@ def onMandateDelete(mandateId: str, instances: list) -> None: except Exception as e: logger.warning(f"Failed to cascade-delete trustee data for mandate {mandateId}: {e}") + +def onUserDelete(userId: str, currentUser) -> dict: + """Delete/anonymize user data from the trustee database (GDPR).""" + from modules.system.gdprDeletion import deleteUserDataFromDatabase + from modules.connectors.connectorDbPostgre import DatabaseConnector + from modules.shared.configuration import APP_CONFIG + + dbName = "poweron_trustee" + try: + db = DatabaseConnector( + dbHost=APP_CONFIG.get("DB_HOST", "localhost"), + dbDatabase=dbName, + dbUser=APP_CONFIG.get("DB_USER"), + dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), + dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), + userId=None, + ) + stats = deleteUserDataFromDatabase(db, userId, dbName) + db.close() + return stats + except Exception as e: + logger.warning(f"onUserDelete trustee failed: {e}") + return {"database": dbName, "tablesProcessed": 0, "recordsDeleted": 0, "recordsAnonymized": 0, "errors": [str(e)]} + diff --git a/modules/features/trustee/routeFeatureTrustee.py b/modules/features/trustee/routeFeatureTrustee.py index 45a37ca3..8b2452af 100644 --- a/modules/features/trustee/routeFeatureTrustee.py +++ b/modules/features/trustee/routeFeatureTrustee.py @@ -1395,6 +1395,19 @@ def delete_position( # ===== Accounting Integration Endpoints ===== +from .handlerTrusteeAccounting import ( + SaveAccountingConfigBody, + readAccountingConfig, + saveAccountingConfig as _saveAccountingConfig, + refreshChartSilently as _refreshChartSilently, + getImportStatus as _getImportStatus, + wipeImportedData as _wipeImportedData, + exportAccountingData as _exportAccountingData, + TRUSTEE_ACCOUNTING_PUSH_JOB_TYPE, + TRUSTEE_ACCOUNTING_SYNC_JOB_TYPE, +) + + @router.get("/{instanceId}/accounting/connectors") @limiter.limit("30/minute") def get_available_accounting_connectors( @@ -1408,23 +1421,6 @@ def get_available_accounting_connectors( return getAccountingRegistry().getAvailableConnectors() -# Placeholder returned for secret config fields so frontend can prefill form without sending real secrets. -_CONFIG_PLACEHOLDER = "***" - - -def _getConfigMasked(connectorType: str, plainConfig: Dict[str, Any]) -> Dict[str, str]: - """Build config with secret values replaced by placeholder for GET response.""" - from .accounting.accountingRegistry import getAccountingRegistry - connector = getAccountingRegistry().getConnector(connectorType) - if not connector: - return {k: (v if isinstance(v, str) else str(v)) for k, v in (plainConfig or {}).items()} - secretKeys = {f.key for f in connector.getRequiredConfigFields() if f.secret} - return { - k: _CONFIG_PLACEHOLDER if k in secretKeys else (v if isinstance(v, str) else str(v) if v is not None else "") - for k, v in (plainConfig or {}).items() - } - - @router.get("/{instanceId}/accounting/config") @limiter.limit("30/minute") def get_accounting_config( @@ -1432,33 +1428,10 @@ def get_accounting_config( instanceId: str = Path(..., description="Feature Instance ID"), context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: - """Get the active accounting config for this instance. Credentials are masked (secret fields = ***) for form prefill.""" + """Get the active accounting config for this instance. Credentials are masked.""" mandateId = _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) - from .datamodelFeatureTrustee import TrusteeAccountingConfig - from modules.shared.configuration import decryptValue - records = interface.db.getRecordset(TrusteeAccountingConfig, recordFilter={"featureInstanceId": instanceId, "isActive": True}) - if not records: - return {"configured": False} - record = {k: v for k, v in records[0].items() if not k.startswith("_")} - encryptedConfig = record.pop("encryptedConfig", None) - record["configured"] = True - if encryptedConfig: - try: - plain = json.loads(decryptValue(encryptedConfig, keyName="accountingConfig")) - record["configMasked"] = _getConfigMasked(record.get("connectorType", ""), plain) - except Exception: - record["configMasked"] = {} - else: - record["configMasked"] = {} - return record - - -class SaveAccountingConfigBody(BaseModel): - """Request body for saving accounting config. Ensures 'config' is present and used.""" - connectorType: str = "" - displayLabel: str = "" - config: Dict[str, Any] = Field(default_factory=dict, description="Connector credentials (e.g. clientName, apiKey)") + return readAccountingConfig(interface, instanceId) @router.post("/{instanceId}/accounting/config", status_code=201) @@ -1469,73 +1442,16 @@ async def save_accounting_config( body: SaveAccountingConfigBody = Body(...), context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: - """Save or update the accounting config for this instance. - - Body: { connectorType, displayLabel, config: { clientName, apiKey, ... } } - The 'config' object is stored encrypted; without it credentials would be empty in DB. - """ + """Save or update the accounting config for this instance.""" mandateId = _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) - - from .datamodelFeatureTrustee import TrusteeAccountingConfig - from modules.shared.configuration import encryptValue - - plainConfig = body.config if isinstance(body.config, dict) else {} - # When updating, empty config is normal (frontend never receives credentials from GET). - # Do not overwrite encryptedConfig with empty – keep existing credentials. - if not plainConfig and body.connectorType: - logger.warning("Accounting config save: config is empty (credentials will not be stored or updated)") - else: - logger.info( - "Accounting config save: instanceId=%s connectorType=%s configKeys=%s", - instanceId, body.connectorType, list(plainConfig.keys()) - ) - - existing = interface.db.getRecordset(TrusteeAccountingConfig, recordFilter={"featureInstanceId": instanceId}) - if existing: - configId = existing[0].get("id") - updatePayload = { - "connectorType": body.connectorType or "", - "displayLabel": body.displayLabel or "", - "isActive": True, - } - if plainConfig: - # Merge with existing: placeholder or empty = keep existing value (so form prefill does not overwrite secrets). - from modules.shared.configuration import decryptValue - existingEnc = existing[0].get("encryptedConfig") or "" - merged = {} - if existingEnc: - try: - merged = json.loads(decryptValue(existingEnc, keyName="accountingConfig")) - except Exception: - pass - for k, v in plainConfig.items(): - if v is not None and str(v).strip() and str(v).strip() != _CONFIG_PLACEHOLDER: - merged[k] = v - updatePayload["encryptedConfig"] = encryptValue(json.dumps(merged), keyName="accountingConfig") - interface.db.recordModify(TrusteeAccountingConfig, configId, updatePayload) - await _refreshChartSilently(interface, instanceId) - return {"message": "Accounting config updated", "id": configId} - - if not plainConfig: + result = await _saveAccountingConfig(interface, instanceId, mandateId, body) + if result is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=routeApiMsg("config is required for new integration (e.g. clientName, apiKey).") ) - encryptedConfig = encryptValue(json.dumps(plainConfig), keyName="accountingConfig") - - configRecord = { - "id": str(uuid.uuid4()), - "featureInstanceId": instanceId, - "connectorType": body.connectorType or "", - "displayLabel": body.displayLabel or "", - "encryptedConfig": encryptedConfig, - "isActive": True, - "mandateId": mandateId, - } - interface.db.recordCreate(TrusteeAccountingConfig, configRecord) - await _refreshChartSilently(interface, instanceId) - return {"message": "Accounting config created", "id": configRecord["id"]} + return result @router.post("/{instanceId}/accounting/test-connection") @@ -1545,7 +1461,7 @@ async def test_accounting_connection( instanceId: str = Path(..., description="Feature Instance ID"), context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: - """Test the connection to the configured accounting system. On success, refreshes the local chart-of-accounts cache.""" + """Test the connection to the configured accounting system.""" mandateId = _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) from .accounting.accountingBridge import AccountingBridge @@ -1581,7 +1497,7 @@ async def get_chart_of_accounts( accountType: Optional[str] = Query(None, description="Filter by type: expense, asset, liability, revenue"), context: RequestContext = Depends(getRequestContext) ) -> List[Dict[str, Any]]: - """Load the chart of accounts from the connected accounting system. Optional filter by accountType.""" + """Load the chart of accounts from the connected accounting system.""" mandateId = _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) from .accounting.accountingBridge import AccountingBridge @@ -1590,17 +1506,6 @@ async def get_chart_of_accounts( return [c.model_dump() for c in charts] -async def _refreshChartSilently(interface, instanceId: str) -> None: - """Best-effort chart-of-accounts cache refresh. Logs but does not raise on failure.""" - try: - from .accounting.accountingBridge import AccountingBridge - bridge = AccountingBridge(interface) - charts = await bridge.refreshChartOfAccounts(instanceId) - logger.info(f"Chart cache refreshed: {len(charts)} entries for instance {instanceId}") - except Exception as e: - logger.warning(f"Chart cache refresh failed (non-critical): {e}") - - @router.post("/{instanceId}/accounting/refresh-chart") @limiter.limit("5/minute") async def refresh_chart_of_accounts( @@ -1617,108 +1522,6 @@ async def refresh_chart_of_accounts( return {"message": f"Chart of accounts refreshed: {len(charts)} entries", "count": len(charts)} -TRUSTEE_ACCOUNTING_PUSH_JOB_TYPE = "trusteeAccountingPush" - - -async def _trusteeAccountingPushJobHandler(job: Dict[str, Any], progressCb) -> Dict[str, Any]: - """BackgroundJob handler: pushes a batch of positions to the external - accounting system. Runs in the worker without blocking the original HTTP - request, so the user can continue navigating while the sync runs. - - Reads inputs from `job["payload"]` (`positionIds`) and reports incremental - progress via `progressCb(percent, message)`. The job result has the same - shape that the legacy synchronous endpoint used to return. - """ - from modules.security.rootAccess import getRootUser - from .accounting.accountingBridge import AccountingBridge - - instanceId = job["featureInstanceId"] - mandateId = job["mandateId"] - payload = job.get("payload") or {} - positionIds: List[str] = list(payload.get("positionIds") or []) - if not positionIds: - return {"total": 0, "success": 0, "skipped": 0, "errors": 0, "results": []} - - rootUser = getRootUser() - interface = getInterface(rootUser, mandateId=mandateId, featureInstanceId=instanceId) - bridge = AccountingBridge(interface) - - results = [] - total = len(positionIds) - progressCb( - 2, - messageKey="Sync wird vorbereitet ({total} Position(en))...", - messageParams={"total": total}, - ) - - # Resolve connector + plain config once to avoid decryption rate-limits - # (mirrors the optimisation in pushBatchToAccounting). We push positions - # one-by-one inside the job so we can emit incremental progress and so - # one bad row never aborts the rest. - from .accounting.accountingBridge import SyncResult - try: - connector, plainConfig, configRecord = await bridge._resolveConnectorAndConfig(instanceId) - except Exception as resolveErr: - logger.exception("Accounting push: failed to resolve connector/config") - progressCb(100, messageKey="Verbindungsaufbau fehlgeschlagen.") - raise resolveErr - - if not connector or not plainConfig: - results = [SyncResult(success=False, errorMessage="No active accounting configuration found") for _ in positionIds] - progressCb(100, messageKey="Keine aktive Buchhaltungs-Konfiguration gefunden.") - return { - "total": len(results), - "success": 0, - "skipped": 0, - "errors": len(results), - "results": [r.model_dump() for r in results], - } - - for index, positionId in enumerate(positionIds, start=1): - result = await bridge.pushPositionToAccounting( - instanceId, - positionId, - _resolvedConnector=connector, - _resolvedPlainConfig=plainConfig, - _resolvedConfigRecord=configRecord, - ) - results.append(result) - # Reserve 5..95% for the push loop, keep the tail for summary. - pct = 5 + int(90 * index / total) - progressCb( - pct, - messageKey="Position {index}/{total} verarbeitet", - messageParams={"index": index, "total": total}, - ) - - skipped = [r for r in results if not r.success and r.errorMessage and "already synced" in r.errorMessage] - failed = [r for r in results if not r.success and r not in skipped] - if skipped: - logger.info("Accounting sync: %s position(s) already synced, skipped", len(skipped)) - if failed: - logger.warning( - "Accounting sync had %s failure(s): %s", - len(failed), - "; ".join(r.errorMessage or "unknown" for r in failed[:3]), - ) - - progressCb(100, messageKey="Sync abgeschlossen.") - return { - "total": len(results), - "success": sum(1 for r in results if r.success), - "skipped": len(skipped), - "errors": len(failed), - "results": [r.model_dump() for r in results], - } - - -try: - from modules.serviceCenter.services.serviceBackgroundJobs import registerJobHandler as _registerPushJobHandler - _registerPushJobHandler(TRUSTEE_ACCOUNTING_PUSH_JOB_TYPE, _trusteeAccountingPushJobHandler) -except Exception as _pushRegErr: - logger.warning("Failed to register trusteeAccountingPush job handler: %s", _pushRegErr) - - @router.post("/{instanceId}/accounting/sync", status_code=status.HTTP_202_ACCEPTED) @limiter.limit("5/minute") async def sync_positions_to_accounting( @@ -1727,21 +1530,10 @@ async def sync_positions_to_accounting( data: Dict[str, Any] = Body(...), context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: - """Submit a background job that pushes positions to the accounting system. - - Body: ``{ positionIds: [...] }`` - - Returns ``{ jobId, status: "pending" }`` immediately so the user is not - blocked while the (potentially long) external accounting calls run. - Clients poll ``GET /api/jobs/{jobId}`` until status is ``SUCCESS`` / - ``ERROR`` and then read the same ``{ total, success, skipped, errors, - results }`` payload from ``job.result`` that the legacy synchronous - endpoint returned. - """ + """Submit a background job that pushes positions to the accounting system.""" from modules.serviceCenter.services.serviceBackgroundJobs import startJob mandateId = _validateInstanceAccess(instanceId, context) - positionIds = data.get("positionIds", []) if not positionIds: raise HTTPException(status_code=400, detail=routeApiMsg("positionIds required")) @@ -1771,11 +1563,7 @@ async def sync_single_position_to_accounting( bridge = AccountingBridge(interface) result = await bridge.pushPositionToAccounting(instanceId, positionId) if not result.success: - logger.warning( - "Accounting sync failed for positionId=%s: %s", - positionId, - result.errorMessage or "unknown", - ) + logger.warning("Accounting sync failed for positionId=%s: %s", positionId, result.errorMessage or "unknown") return result.model_dump() @@ -1791,8 +1579,7 @@ def get_sync_status( interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) from .datamodelFeatureTrustee import TrusteeAccountingSync records = interface.db.getRecordset(TrusteeAccountingSync, recordFilter={"featureInstanceId": instanceId}) - items = [{k: v for k, v in r.items() if not k.startswith("_")} for r in records] - return {"items": items} + return {"items": [{k: v for k, v in r.items() if not k.startswith("_")} for r in records]} @router.get("/{instanceId}/accounting/sync-status/{positionId}") @@ -1808,52 +1595,11 @@ def get_position_sync_status( interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) from .datamodelFeatureTrustee import TrusteeAccountingSync records = interface.db.getRecordset(TrusteeAccountingSync, recordFilter={"positionId": positionId, "featureInstanceId": instanceId}) - items = [{k: v for k, v in r.items() if not k.startswith("_")} for r in records] - return {"items": items} + return {"items": [{k: v for k, v in r.items() if not k.startswith("_")} for r in records]} # ===== Accounting Data Import ===== -TRUSTEE_ACCOUNTING_SYNC_JOB_TYPE = "trusteeAccountingSync" - - -async def _trusteeAccountingSyncJobHandler(job: Dict[str, Any], progressCb) -> Dict[str, Any]: - """BackgroundJob handler: imports accounting data from the external system. - - Reads inputs from `job["payload"]` (dateFrom, dateTo, userId) and runs - `AccountingDataSync.importData(...)` in the worker's event loop without - blocking the original HTTP request that submitted the job. - """ - from modules.security.rootAccess import getRootUser - from .accounting.accountingDataSync import AccountingDataSync - - instanceId = job["featureInstanceId"] - mandateId = job["mandateId"] - payload = job.get("payload") or {} - rootUser = getRootUser() - - progressCb(5, messageKey="Initialisiere Import...") - interface = getInterface(rootUser, mandateId=mandateId, featureInstanceId=instanceId) - sync = AccountingDataSync(interface) - progressCb(10, messageKey="Verbinde mit Buchhaltungssystem...") - result = await sync.importData( - featureInstanceId=instanceId, - mandateId=mandateId, - dateFrom=payload.get("dateFrom"), - dateTo=payload.get("dateTo"), - progressCb=progressCb, - ) - progressCb(100, messageKey="Import abgeschlossen.") - return result - - -try: - from modules.serviceCenter.services.serviceBackgroundJobs import registerJobHandler - registerJobHandler(TRUSTEE_ACCOUNTING_SYNC_JOB_TYPE, _trusteeAccountingSyncJobHandler) -except Exception as _regErr: - logger.warning("Failed to register trusteeAccountingSync job handler: %s", _regErr) - - @router.post("/{instanceId}/accounting/import-data", status_code=status.HTTP_202_ACCEPTED) @limiter.limit("3/minute") async def import_accounting_data( @@ -1862,18 +1608,11 @@ async def import_accounting_data( data: Dict[str, Any] = Body(default={}), context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: - """Submit a background job to import accounting data. - - Returns immediately with `{ jobId }`; clients poll `GET /api/jobs/{jobId}` - until status is SUCCESS / ERROR. - """ + """Submit a background job to import accounting data.""" from modules.serviceCenter.services.serviceBackgroundJobs import startJob mandateId = _validateInstanceAccess(instanceId, context) - payload = { - "dateFrom": data.get("dateFrom"), - "dateTo": data.get("dateTo"), - } + payload = {"dateFrom": data.get("dateFrom"), "dateTo": data.get("dateTo")} jobId = await startJob( TRUSTEE_ACCOUNTING_SYNC_JOB_TYPE, payload, @@ -1894,28 +1633,7 @@ def get_import_status( """Get counts of imported TrusteeData* records for this instance.""" mandateId = _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) - from .datamodelFeatureTrustee import ( - TrusteeDataAccount, TrusteeDataJournalEntry, TrusteeDataJournalLine, - TrusteeDataContact, TrusteeDataAccountBalance, TrusteeAccountingConfig, - ) - filt = {"featureInstanceId": instanceId} - counts = { - "accounts": len(interface.db.getRecordset(TrusteeDataAccount, recordFilter=filt) or []), - "journalEntries": len(interface.db.getRecordset(TrusteeDataJournalEntry, recordFilter=filt) or []), - "journalLines": len(interface.db.getRecordset(TrusteeDataJournalLine, recordFilter=filt) or []), - "contacts": len(interface.db.getRecordset(TrusteeDataContact, recordFilter=filt) or []), - "accountBalances": len(interface.db.getRecordset(TrusteeDataAccountBalance, recordFilter=filt) or []), - } - cfgRecords = interface.db.getRecordset(TrusteeAccountingConfig, recordFilter={"featureInstanceId": instanceId, "isActive": True}) - if cfgRecords: - cfg = cfgRecords[0] - counts["lastSyncAt"] = cfg.get("lastSyncAt") - counts["lastSyncStatus"] = cfg.get("lastSyncStatus") - counts["lastSyncErrorMessage"] = cfg.get("lastSyncErrorMessage") - counts["lastSyncDateFrom"] = cfg.get("lastSyncDateFrom") - counts["lastSyncDateTo"] = cfg.get("lastSyncDateTo") - counts["lastSyncCounts"] = cfg.get("lastSyncCounts") - return counts + return _getImportStatus(interface, instanceId) # ===== AI Data Cache ===== @@ -1927,12 +1645,7 @@ def clear_ai_data_cache( instanceId: str = Path(..., description="Feature Instance ID"), context: RequestContext = Depends(getRequestContext), ) -> Dict[str, Any]: - """Clear ONLY the AI feature-data query result cache (in-memory, ~5 min TTL). - - Important: this does NOT touch the synchronised ``TrusteeData*`` tables. - The synced rows (chart of accounts, journal entries/lines, contacts, balances) - stay exactly as imported. To wipe those rows, use POST .../wipe-imported-data. - """ + """Clear the AI feature-data query result cache (in-memory).""" _validateInstanceAccess(instanceId, context) from modules.serviceCenter.services.serviceAgent.coreTools._featureSubAgentTools import clearFeatureQueryCache removed = clearFeatureQueryCache(instanceId) @@ -1946,66 +1659,10 @@ def wipe_imported_accounting_data( instanceId: str = Path(..., description="Feature Instance ID"), context: RequestContext = Depends(getRequestContext), ) -> Dict[str, Any]: - """Delete every ``TrusteeData*`` row imported for this feature instance. - - Use when the source system was changed, test data needs to be cleared, or - the user suspects stale rows from earlier connector versions. Also resets - the ``lastSync*`` markers on the active config so the UI no longer reports - a stale "letzter Import" status. The connector configuration / credentials - remain untouched -- only synchronised payload data is removed. - """ + """Delete all TrusteeData* rows imported for this feature instance and reset sync markers.""" mandateId = _validateInstanceAccess(instanceId, context) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) - from .datamodelFeatureTrustee import ( - TrusteeDataAccount, TrusteeDataJournalEntry, TrusteeDataJournalLine, - TrusteeDataContact, TrusteeDataAccountBalance, TrusteeAccountingConfig, - ) - from modules.serviceCenter.services.serviceAgent.coreTools._featureSubAgentTools import clearFeatureQueryCache - - removed: Dict[str, int] = {} - for tableName, model in [ - ("accounts", TrusteeDataAccount), - ("journalEntries", TrusteeDataJournalEntry), - ("journalLines", TrusteeDataJournalLine), - ("contacts", TrusteeDataContact), - ("accountBalances", TrusteeDataAccountBalance), - ]: - try: - removed[tableName] = int(interface.db.recordDeleteWhere(model, {"featureInstanceId": instanceId}) or 0) - except Exception as ex: - logger.warning("wipeImportedData: failed for %s: %s", tableName, ex) - removed[tableName] = 0 - - cfgRecords = interface.db.getRecordset( - TrusteeAccountingConfig, - recordFilter={"featureInstanceId": instanceId, "isActive": True}, - ) - if cfgRecords: - cfgId = cfgRecords[0].get("id") - if cfgId: - try: - interface.db.recordModify(TrusteeAccountingConfig, cfgId, { - "lastSyncAt": None, - "lastSyncStatus": None, - "lastSyncErrorMessage": None, - "lastSyncDateFrom": None, - "lastSyncDateTo": None, - "lastSyncCounts": None, - }) - except Exception as ex: - logger.warning("wipeImportedData: failed to reset lastSync* on cfg %s: %s", cfgId, ex) - - cacheCleared = clearFeatureQueryCache(instanceId) - logger.info( - "wipeImportedData instance=%s removed=%s cacheCleared=%s", - instanceId, removed, cacheCleared, - ) - return { - "removed": removed, - "totalRemoved": sum(removed.values()), - "cacheCleared": cacheCleared, - "featureInstanceId": instanceId, - } + return _wipeImportedData(interface, instanceId) # ===== Data Export ===== @@ -2017,52 +1674,10 @@ def export_accounting_data( instanceId: str = Path(..., description="Feature Instance ID"), context: RequestContext = Depends(getRequestContext), ) -> Response: - """Export all TrusteeData* tables for this instance as a JSON download (admin only).""" + """Export all TrusteeData* tables for this instance as a JSON download.""" mandateId = _validateInstanceAccess(instanceId, context) - - from .datamodelFeatureTrustee import ( - TrusteeDataAccount, - TrusteeDataJournalEntry, - TrusteeDataJournalLine, - TrusteeDataContact, - TrusteeDataAccountBalance, - TrusteeAccountingConfig, - ) interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) - _filter = {"featureInstanceId": instanceId} - - tables: Dict[str, Any] = {} - for tableName, model in [ - ("TrusteeDataAccount", TrusteeDataAccount), - ("TrusteeDataJournalEntry", TrusteeDataJournalEntry), - ("TrusteeDataJournalLine", TrusteeDataJournalLine), - ("TrusteeDataContact", TrusteeDataContact), - ("TrusteeDataAccountBalance", TrusteeDataAccountBalance), - ]: - records = interface.db.getRecordset(model, recordFilter=_filter) or [] - tables[tableName] = records - - cfgRecords = interface.db.getRecordset( - TrusteeAccountingConfig, - recordFilter={"featureInstanceId": instanceId, "isActive": True}, - ) - syncInfo = {} - if cfgRecords: - cfg = cfgRecords[0] - syncInfo = { - "connectorType": cfg.get("connectorType", ""), - "lastSyncAt": cfg.get("lastSyncAt"), - "lastSyncStatus": cfg.get("lastSyncStatus", ""), - } - - payload = { - "exportedAt": time.time(), - "featureInstanceId": instanceId, - "mandateId": mandateId, - "syncInfo": syncInfo, - "tables": tables, - } - + payload = _exportAccountingData(interface, instanceId, mandateId) jsonBytes = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8") return Response( content=jsonBytes, diff --git a/modules/features/trustee/workflows/__init__.py b/modules/features/trustee/workflows/__init__.py new file mode 100644 index 00000000..976edabd --- /dev/null +++ b/modules/features/trustee/workflows/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +"""Trustee feature-owned workflow methods.""" diff --git a/modules/workflows/methods/methodTrustee/__init__.py b/modules/features/trustee/workflows/methodTrustee/__init__.py similarity index 100% rename from modules/workflows/methods/methodTrustee/__init__.py rename to modules/features/trustee/workflows/methodTrustee/__init__.py diff --git a/modules/workflows/methods/methodTrustee/actions/extractFromFiles.py b/modules/features/trustee/workflows/methodTrustee/actions/extractFromFiles.py similarity index 99% rename from modules/workflows/methods/methodTrustee/actions/extractFromFiles.py rename to modules/features/trustee/workflows/methodTrustee/actions/extractFromFiles.py index fa677e2b..d28c8a3c 100644 --- a/modules/workflows/methods/methodTrustee/actions/extractFromFiles.py +++ b/modules/features/trustee/workflows/methodTrustee/actions/extractFromFiles.py @@ -19,7 +19,7 @@ from typing import Dict, Any, List, Optional, Tuple from modules.datamodels.datamodelChat import ActionResult, ActionDocument, ChatDocument, ChatMessage from modules.datamodels.datamodelDocref import DocumentReferenceList, DocumentItemReference from modules.datamodels.datamodelAi import AiCallOptions, AiCallRequest, OperationTypeEnum -from modules.shared.serviceExceptions import SubscriptionInactiveException as _SubscriptionInactiveException, BillingContextError as _BillingContextError +from modules.datamodels.serviceExceptions import SubscriptionInactiveException as _SubscriptionInactiveException, BillingContextError as _BillingContextError logger = logging.getLogger(__name__) diff --git a/modules/workflows/methods/methodTrustee/actions/processDocuments.py b/modules/features/trustee/workflows/methodTrustee/actions/processDocuments.py similarity index 100% rename from modules/workflows/methods/methodTrustee/actions/processDocuments.py rename to modules/features/trustee/workflows/methodTrustee/actions/processDocuments.py diff --git a/modules/workflows/methods/methodTrustee/actions/queryData.py b/modules/features/trustee/workflows/methodTrustee/actions/queryData.py similarity index 100% rename from modules/workflows/methods/methodTrustee/actions/queryData.py rename to modules/features/trustee/workflows/methodTrustee/actions/queryData.py diff --git a/modules/workflows/methods/methodTrustee/actions/refreshAccountingData.py b/modules/features/trustee/workflows/methodTrustee/actions/refreshAccountingData.py similarity index 100% rename from modules/workflows/methods/methodTrustee/actions/refreshAccountingData.py rename to modules/features/trustee/workflows/methodTrustee/actions/refreshAccountingData.py diff --git a/modules/workflows/methods/methodTrustee/actions/syncToAccounting.py b/modules/features/trustee/workflows/methodTrustee/actions/syncToAccounting.py similarity index 100% rename from modules/workflows/methods/methodTrustee/actions/syncToAccounting.py rename to modules/features/trustee/workflows/methodTrustee/actions/syncToAccounting.py diff --git a/modules/workflows/methods/methodTrustee/methodTrustee.py b/modules/features/trustee/workflows/methodTrustee/methodTrustee.py similarity index 100% rename from modules/workflows/methods/methodTrustee/methodTrustee.py rename to modules/features/trustee/workflows/methodTrustee/methodTrustee.py diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py index 287e60df..5ac5d089 100644 --- a/modules/interfaces/interfaceBootstrap.py +++ b/modules/interfaces/interfaceBootstrap.py @@ -111,7 +111,7 @@ def initBootstrap(db: DatabaseConnector) -> None: logger.warning(f"Mandate retention purge failed: {e}") # Let features run their own bootstrap logic via lifecycle hooks - from modules.system.registry import loadFeatureMainModules + from modules.shared.featureDiscovery import loadFeatureMainModules for _fCode, _fMod in loadFeatureMainModules().items(): _bootHook = getattr(_fMod, "onBootstrap", None) if _bootHook: @@ -172,7 +172,7 @@ def initRootMandateFeatures(db: DatabaseConnector, mandateId: str) -> None: """ from modules.datamodels.datamodelFeatures import FeatureInstance from modules.interfaces.interfaceFeatures import getFeatureInterface - from modules.system.registry import loadFeatureMainModules + from modules.shared.featureDiscovery import loadFeatureMainModules logger.info("Initializing root mandate features") @@ -241,7 +241,7 @@ def initRootMandateFeatures(db: DatabaseConnector, mandateId: str) -> None: def _cleanupRemovedFeatureInstances(db: DatabaseConnector) -> None: """Remove feature instances whose featureCode no longer exists in the codebase.""" from modules.datamodels.datamodelFeatures import FeatureInstance - from modules.system.registry import loadFeatureMainModules + from modules.shared.featureDiscovery import loadFeatureMainModules mainModules = loadFeatureMainModules() activeCodes = set() @@ -1144,7 +1144,7 @@ def _createUiContextRules(db: DatabaseConnector) -> None: Args: db: Database connector instance """ - from modules.system.mainSystem import NAVIGATION_SECTIONS + from modules.datamodels.datamodelNavigation import NAVIGATION_SECTIONS uiRules = [] adminId = _getRoleId(db, "admin") @@ -1200,7 +1200,7 @@ def _ensureUiContextRules(db: DatabaseConnector) -> None: Args: db: Database connector instance """ - from modules.system.mainSystem import NAVIGATION_SECTIONS + from modules.datamodels.datamodelNavigation import NAVIGATION_SECTIONS # Template role IDs adminId = _getRoleId(db, "admin") diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py index 60aac0e8..6ebadaaf 100644 --- a/modules/interfaces/interfaceDbApp.py +++ b/modules/interfaces/interfaceDbApp.py @@ -1579,7 +1579,7 @@ class AppObjects: from modules.datamodels.datamodelFeatures import FeatureInstance from modules.interfaces.interfaceBootstrap import copySystemRolesToMandate from modules.interfaces.interfaceFeatures import getFeatureInterface - from modules.system.registry import loadFeatureMainModules + from modules.shared.featureDiscovery import loadFeatureMainModules plan = BUILTIN_PLANS.get(planKey) if not plan: raise ValueError(f"Unknown plan: {planKey}") @@ -1871,7 +1871,7 @@ class AppObjects: instances = self.db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId}) # 0-pre. Let features cascade-delete their own data via lifecycle hooks - from modules.system.registry import loadFeatureMainModules + from modules.shared.featureDiscovery import loadFeatureMainModules for _fCode, _fMod in loadFeatureMainModules().items(): _hook = getattr(_fMod, "onMandateDelete", None) if _hook: diff --git a/modules/interfaces/interfaceDbSubscription.py b/modules/interfaces/interfaceDbSubscription.py index bdcaeb2b..b6cb26ff 100644 --- a/modules/interfaces/interfaceDbSubscription.py +++ b/modules/interfaces/interfaceDbSubscription.py @@ -30,7 +30,7 @@ from modules.datamodels.datamodelSubscription import ( getEffectiveLimits, ) -from modules.shared.serviceExceptions import SubscriptionCapacityException +from modules.datamodels.serviceExceptions import SubscriptionCapacityException logger = logging.getLogger(__name__) diff --git a/modules/interfaces/interfaceFeatures.py b/modules/interfaces/interfaceFeatures.py index 4c1b29b8..5f239c01 100644 --- a/modules/interfaces/interfaceFeatures.py +++ b/modules/interfaces/interfaceFeatures.py @@ -287,7 +287,7 @@ class FeatureInterface: RuntimeError: If templates exist but cannot be copied. Caller decides whether to swallow or re-raise. """ - from modules.system.registry import loadFeatureMainModules + from modules.shared.featureDiscovery import loadFeatureMainModules mainModules = loadFeatureMainModules() featureModule = mainModules.get(featureCode) if not featureModule: diff --git a/modules/routes/billingWebhookHandler.py b/modules/routes/billingWebhookHandler.py new file mode 100644 index 00000000..ecfe37b4 --- /dev/null +++ b/modules/routes/billingWebhookHandler.py @@ -0,0 +1,399 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Stripe webhook and subscription business logic for billing. +Extracted from routeBilling.py for maintainability. +""" + +import logging +from datetime import datetime, timezone +from typing import Any, Dict, Optional + +from fastapi import HTTPException + +from modules.datamodels.datamodelBilling import ( + BillingTransaction, + TransactionTypeEnum, + ReferenceTypeEnum, +) +from modules.shared.i18nRegistry import apiRouteContext + +routeApiMsg = apiRouteContext("routeBilling") +logger = logging.getLogger(__name__) + + +def creditStripeSessionIfNeeded( + billingInterface, + session: Dict[str, Any], + eventId: Optional[str] = None, + CheckoutConfirmResponse=None, +): + """Credit balance from Stripe Checkout session if not already credited. + Uses Checkout session ID for idempotency across webhook + manual confirmation flows. + """ + from modules.serviceCenter.services.serviceBilling.stripeCheckout import ALLOWED_AMOUNTS_CHF + + session_id = session.get("id") + metadata = session.get("metadata") or {} + mandate_id = metadata.get("mandateId") + user_id = metadata.get("userId") or None + amount_chf_str = metadata.get("amountChf", "0") + + if not session_id: + raise HTTPException(status_code=400, detail=routeApiMsg("Stripe session id missing")) + if not mandate_id: + raise HTTPException(status_code=400, detail=routeApiMsg("Invalid session metadata: mandateId missing")) + + existing_payment_tx = billingInterface.getPaymentTransactionByReferenceId(session_id) + if existing_payment_tx: + if eventId and not billingInterface.getStripeWebhookEventByEventId(eventId): + billingInterface.createStripeWebhookEvent(eventId) + return CheckoutConfirmResponse( + credited=False, + alreadyCredited=True, + sessionId=session_id, + mandateId=mandate_id, + amountChf=float(existing_payment_tx.get("amount", 0.0)), + ) + + try: + amount_chf = float(amount_chf_str) + except (TypeError, ValueError): + amount_chf = None + + if amount_chf is None or amount_chf not in ALLOWED_AMOUNTS_CHF: + amount_total = session.get("amount_total") + if amount_total is not None: + amount_chf = amount_total / 100.0 + else: + raise HTTPException(status_code=400, detail=routeApiMsg("Invalid amount in Stripe session")) + + settings = billingInterface.getSettings(mandate_id) + if not settings: + raise HTTPException(status_code=404, detail=routeApiMsg("Billing settings not found")) + + account = billingInterface.getOrCreateMandateAccount(mandate_id, initialBalance=0.0) + + transaction = BillingTransaction( + accountId=account["id"], + transactionType=TransactionTypeEnum.CREDIT, + amount=amount_chf, + description="Stripe-Zahlung", + referenceType=ReferenceTypeEnum.PAYMENT, + referenceId=session_id, + createdByUserId=user_id, + ) + billingInterface.createTransaction(transaction) + + if eventId and not billingInterface.getStripeWebhookEventByEventId(eventId): + billingInterface.createStripeWebhookEvent(eventId) + + logger.info(f"Stripe credit applied: {amount_chf} CHF for session {session_id} on mandate {mandate_id}") + return CheckoutConfirmResponse( + credited=True, + alreadyCredited=False, + sessionId=session_id, + mandateId=mandate_id, + amountChf=amount_chf, + ) + + +def handleSubscriptionCheckoutCompleted(session, eventId: str, getRootInterface) -> None: + """Handle checkout.session.completed for mode=subscription. + Resolves the local PENDING record by ID from webhook metadata and transitions it.""" + from modules.interfaces.interfaceDbSubscription import getRootInterface as getSubRootInterface + from modules.datamodels.datamodelSubscription import SubscriptionStatusEnum, getPlan + from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import ( + getService as getSubscriptionService, + _notifySubscriptionChange, + ) + from modules.security.rootAccess import getRootUser + + if not isinstance(session, dict): + from modules.shared.stripeClient import stripeToDict + session = stripeToDict(session) + + metadata = session.get("metadata") or {} + subscriptionRecordId = metadata.get("subscriptionRecordId") + mandateId = metadata.get("mandateId") + planKey = metadata.get("planKey", "") + platformUrl = metadata.get("platformUrl", "") + + if not subscriptionRecordId: + stripeSub = session.get("subscription") + if stripeSub: + try: + from modules.shared.stripeClient import getStripeClient + stripe = getStripeClient() + from modules.shared.stripeClient import stripeToDict + subObj = stripeToDict(stripe.Subscription.retrieve(stripeSub)) + metadata = subObj.get("metadata") or {} + subscriptionRecordId = metadata.get("subscriptionRecordId") + mandateId = metadata.get("mandateId") + planKey = metadata.get("planKey", "") + platformUrl = platformUrl or metadata.get("platformUrl", "") + except Exception as e: + logger.error( + "Stripe Subscription.retrieve(%s) failed during checkout " + "metadata recovery: %s", stripeSub, e, + ) + raise + + stripeSubId = session.get("subscription") + + if not mandateId or not subscriptionRecordId: + logger.warning("Subscription checkout missing metadata: %s", metadata) + return + + subInterface = getSubRootInterface() + rootUser = getRootUser() + + sub = subInterface.getById(subscriptionRecordId) + if not sub: + logger.error("Subscription record %s not found for checkout webhook", subscriptionRecordId) + return + if sub.get("status") != SubscriptionStatusEnum.PENDING.value: + logger.warning("Subscription %s is %s, expected PENDING — skipping", subscriptionRecordId, sub.get("status")) + return + + stripeData: Dict[str, Any] = {} + if stripeSubId: + stripeData["stripeSubscriptionId"] = stripeSubId + try: + from modules.shared.stripeClient import getStripeClient + stripe = getStripeClient() + from modules.shared.stripeClient import stripeToDict + stripeSub = stripeToDict(stripe.Subscription.retrieve(stripeSubId, expand=["items"])) + + if stripeSub.get("current_period_start"): + stripeData["currentPeriodStart"] = float(stripeSub["current_period_start"]) + if stripeSub.get("current_period_end"): + stripeData["currentPeriodEnd"] = float(stripeSub["current_period_end"]) + + from modules.serviceCenter.services.serviceSubscription.stripeBootstrap import getStripePricesForPlan + priceMapping = getStripePricesForPlan(planKey) + items = stripeSub.get("items") or {} + if not isinstance(items, dict): + items = dict(items) + for item in items.get("data", []): + priceId = (item.get("price") or {}).get("id", "") + if priceMapping and priceId == priceMapping.stripePriceIdUsers: + stripeData["stripeItemIdUsers"] = item["id"] + elif priceMapping and priceId == priceMapping.stripePriceIdInstances: + stripeData["stripeItemIdInstances"] = item["id"] + except Exception as e: + logger.error( + "Error retrieving Stripe subscription %s during checkout " + "completion (will be retried by Stripe): %s", + stripeSubId, e, + ) + raise + + if stripeData: + subInterface.updateFields(subscriptionRecordId, stripeData) + + operative = subInterface.getOperativeForMandate(mandateId) + hasActivePredecessor = operative is not None and operative["id"] != subscriptionRecordId + predecessorIsTrial = ( + hasActivePredecessor + and operative.get("status") == SubscriptionStatusEnum.TRIALING.value + ) + + if hasActivePredecessor and predecessorIsTrial: + try: + subInterface.forceExpire(operative["id"]) + logger.info( + "Trial subscription %s expired immediately for mandate %s due to paid upgrade %s", + operative["id"], mandateId, subscriptionRecordId, + ) + except Exception as e: + logger.error("Failed to expire trial predecessor %s: %s", operative["id"], e) + toStatus = SubscriptionStatusEnum.ACTIVE + elif hasActivePredecessor: + toStatus = SubscriptionStatusEnum.SCHEDULED + if operative.get("recurring", True): + operativeStripeId = operative.get("stripeSubscriptionId") + if operativeStripeId: + try: + from modules.shared.stripeClient import getStripeClient + stripe = getStripeClient() + stripe.Subscription.modify(operativeStripeId, cancel_at_period_end=True) + except Exception as e: + logger.error("Failed to set cancel_at_period_end on predecessor %s: %s", operativeStripeId, e) + subInterface.updateFields(operative["id"], {"recurring": False}) + effectiveFrom = operative.get("currentPeriodEnd") + if effectiveFrom: + subInterface.updateFields(subscriptionRecordId, {"effectiveFrom": effectiveFrom}) + else: + toStatus = SubscriptionStatusEnum.ACTIVE + + try: + subInterface.transitionStatus( + subscriptionRecordId, SubscriptionStatusEnum.PENDING, toStatus, + {"recurring": True}, + ) + except Exception as e: + logger.error("Failed to transition subscription %s: %s", subscriptionRecordId, e) + return + + subService = getSubscriptionService(rootUser, mandateId) + subService.invalidateCache(mandateId) + + if toStatus == SubscriptionStatusEnum.ACTIVE: + plan = getPlan(planKey) + updatedSub = subInterface.getById(subscriptionRecordId) + _notifySubscriptionChange(mandateId, "activated", plan, subscriptionRecord=updatedSub, platformUrl=platformUrl) + + try: + billingIf = getRootInterface() + billingIf.creditSubscriptionBudget(mandateId, planKey, periodLabel="Erstaktivierung") + except Exception as ex: + logger.error("creditSubscriptionBudget on activation failed: %s", ex) + + logger.info( + "Checkout completed: sub=%s -> %s, mandate=%s, plan=%s", + subscriptionRecordId, toStatus.value, mandateId, planKey, + ) + + +def handleSubscriptionWebhook(event, getRootInterface) -> None: + """Process Stripe subscription webhook events. + All record resolution is by stripeSubscriptionId.""" + from modules.interfaces.interfaceDbSubscription import getRootInterface as getSubRootInterface + from modules.datamodels.datamodelSubscription import SubscriptionStatusEnum, getPlan + from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import ( + getService as getSubscriptionService, + _notifySubscriptionChange, + ) + from modules.security.rootAccess import getRootUser + + obj = event.data.object + rawSub = obj.get("id") if event.type.startswith("customer.subscription") else obj.get("subscription") + stripeSubId = rawSub.get("id") if isinstance(rawSub, dict) else rawSub + if not stripeSubId: + logger.warning("Subscription webhook %s has no subscription ID", event.type) + return + + subInterface = getSubRootInterface() + sub = subInterface.getByStripeSubscriptionId(stripeSubId) + if not sub: + logger.warning("No local record for Stripe subscription %s (event: %s)", stripeSubId, event.type) + return + + subId = sub["id"] + mandateId = sub["mandateId"] + currentStatus = SubscriptionStatusEnum(sub["status"]) + rootUser = getRootUser() + subService = getSubscriptionService(rootUser, mandateId) + + subMetadata = obj.get("metadata") or {} + webhookPlatformUrl = subMetadata.get("platformUrl", "") + + if event.type == "customer.subscription.updated": + stripeStatus = obj.get("status", "") + + periodData: Dict[str, Any] = {} + if obj.get("current_period_start"): + periodData["currentPeriodStart"] = float(obj["current_period_start"]) + if obj.get("current_period_end"): + periodData["currentPeriodEnd"] = float(obj["current_period_end"]) + if periodData: + subInterface.updateFields(subId, periodData) + + if stripeStatus == "active" and currentStatus == SubscriptionStatusEnum.SCHEDULED: + subInterface.transitionStatus(subId, SubscriptionStatusEnum.SCHEDULED, SubscriptionStatusEnum.ACTIVE) + subService.invalidateCache(mandateId) + planKey = sub.get("planKey", "") + plan = getPlan(planKey) + refreshedSub = subInterface.getById(subId) + _notifySubscriptionChange(mandateId, "activated", plan, subscriptionRecord=refreshedSub, platformUrl=webhookPlatformUrl) + try: + getRootInterface().creditSubscriptionBudget(mandateId, planKey, periodLabel="Erstaktivierung") + except Exception as ex: + logger.error("creditSubscriptionBudget SCHEDULED->ACTIVE failed: %s", ex) + logger.info("SCHEDULED -> ACTIVE for sub %s (mandate %s)", subId, mandateId) + + elif stripeStatus == "active" and currentStatus == SubscriptionStatusEnum.PAST_DUE: + subInterface.transitionStatus(subId, SubscriptionStatusEnum.PAST_DUE, SubscriptionStatusEnum.ACTIVE) + subService.invalidateCache(mandateId) + logger.info("PAST_DUE -> ACTIVE for sub %s (mandate %s)", subId, mandateId) + + elif stripeStatus == "past_due" and currentStatus == SubscriptionStatusEnum.ACTIVE: + subInterface.transitionStatus(subId, SubscriptionStatusEnum.ACTIVE, SubscriptionStatusEnum.PAST_DUE) + subService.invalidateCache(mandateId) + logger.info("ACTIVE -> PAST_DUE for sub %s (mandate %s)", subId, mandateId) + + elif stripeStatus == "active" and currentStatus == SubscriptionStatusEnum.ACTIVE: + subService.invalidateCache(mandateId) + logger.info("Period renewed for sub %s (mandate %s)", subId, mandateId) + + elif event.type == "customer.subscription.deleted": + if currentStatus not in (SubscriptionStatusEnum.ACTIVE, SubscriptionStatusEnum.PAST_DUE, + SubscriptionStatusEnum.SCHEDULED): + logger.info("Ignoring deletion for sub %s in status %s", subId, currentStatus.value) + return + + subInterface.transitionStatus(subId, currentStatus, SubscriptionStatusEnum.EXPIRED) + subService.invalidateCache(mandateId) + logger.info("Sub %s -> EXPIRED (Stripe deleted, mandate %s)", subId, mandateId) + + scheduled = subInterface.getScheduledForMandate(mandateId) + if scheduled: + try: + subInterface.transitionStatus( + scheduled["id"], SubscriptionStatusEnum.SCHEDULED, SubscriptionStatusEnum.ACTIVE, + ) + subService.invalidateCache(mandateId) + plan = getPlan(scheduled.get("planKey", "")) + refreshedScheduled = subInterface.getById(scheduled["id"]) + _notifySubscriptionChange(mandateId, "activated", plan, subscriptionRecord=refreshedScheduled, platformUrl=webhookPlatformUrl) + logger.info("Promoted SCHEDULED sub %s -> ACTIVE (mandate %s)", scheduled["id"], mandateId) + except Exception as e: + logger.error("Failed to promote SCHEDULED sub %s: %s", scheduled["id"], e) + + elif event.type == "invoice.payment_failed": + if currentStatus == SubscriptionStatusEnum.ACTIVE: + subInterface.transitionStatus(subId, SubscriptionStatusEnum.ACTIVE, SubscriptionStatusEnum.PAST_DUE) + subService.invalidateCache(mandateId) + plan = getPlan(sub.get("planKey", "")) + _notifySubscriptionChange(mandateId, "payment_failed", plan, subscriptionRecord=sub, platformUrl=webhookPlatformUrl) + logger.info("Payment failed for sub %s (mandate %s)", subId, mandateId) + + elif event.type == "customer.subscription.trial_will_end": + logger.info("Trial ending soon for sub %s (mandate %s)", subId, mandateId) + try: + from modules.system.notifyMandateAdmins import notifyMandateAdmins + notifyMandateAdmins( + mandateId, + "[PowerOn] Testphase endet bald", + "Testphase endet bald", + [ + "Die kostenlose Testphase für Ihren Mandanten endet in Kürze.", + "Bitte wählen Sie einen Plan unter Billing-Verwaltung › Abonnement.", + ], + ) + except Exception as e: + logger.error("Failed to notify about trial ending: %s", e) + + elif event.type == "invoice.paid": + period_ts = obj.get("period_start") + periodLabel = "" + if period_ts: + period_start_at = datetime.fromtimestamp(int(period_ts), tz=timezone.utc) + periodLabel = period_start_at.strftime("%Y-%m-%d") + try: + billing_if = getRootInterface() + billing_if.resetStorageBillingPeriod(mandateId, period_start_at) + billing_if.reconcileMandateStorageBilling(mandateId) + except Exception as ex: + logger.error("Storage billing on invoice.paid failed: %s", ex) + + planKey = sub.get("planKey", "") + try: + billing_if = getRootInterface() + billing_if.creditSubscriptionBudget(mandateId, planKey, periodLabel=periodLabel or "Periodenverlängerung") + except Exception as ex: + logger.error("creditSubscriptionBudget on invoice.paid failed: %s", ex) + + logger.info("Invoice paid for sub %s (mandate %s)", subId, mandateId) + return None diff --git a/modules/routes/routeBilling.py b/modules/routes/routeBilling.py index 19341394..058038c0 100644 --- a/modules/routes/routeBilling.py +++ b/modules/routes/routeBilling.py @@ -330,80 +330,10 @@ def _getStripeClient(): return getStripeClient() -def _creditStripeSessionIfNeeded( - billingInterface, - session: Dict[str, Any], - eventId: Optional[str] = None, -) -> CheckoutConfirmResponse: - """ - Credit balance from Stripe Checkout session if not already credited. - Uses Checkout session ID for idempotency across webhook + manual confirmation flows. - """ - from modules.serviceCenter.services.serviceBilling.stripeCheckout import ALLOWED_AMOUNTS_CHF - - session_id = session.get("id") - metadata = session.get("metadata") or {} - mandate_id = metadata.get("mandateId") - user_id = metadata.get("userId") or None - amount_chf_str = metadata.get("amountChf", "0") - - if not session_id: - raise HTTPException(status_code=400, detail=routeApiMsg("Stripe session id missing")) - if not mandate_id: - raise HTTPException(status_code=400, detail=routeApiMsg("Invalid session metadata: mandateId missing")) - - existing_payment_tx = billingInterface.getPaymentTransactionByReferenceId(session_id) - if existing_payment_tx: - if eventId and not billingInterface.getStripeWebhookEventByEventId(eventId): - billingInterface.createStripeWebhookEvent(eventId) - return CheckoutConfirmResponse( - credited=False, - alreadyCredited=True, - sessionId=session_id, - mandateId=mandate_id, - amountChf=float(existing_payment_tx.get("amount", 0.0)), - ) - - try: - amount_chf = float(amount_chf_str) - except (TypeError, ValueError): - amount_chf = None - - if amount_chf is None or amount_chf not in ALLOWED_AMOUNTS_CHF: - amount_total = session.get("amount_total") - if amount_total is not None: - amount_chf = amount_total / 100.0 - else: - raise HTTPException(status_code=400, detail=routeApiMsg("Invalid amount in Stripe session")) - - settings = billingInterface.getSettings(mandate_id) - if not settings: - raise HTTPException(status_code=404, detail=routeApiMsg("Billing settings not found")) - - account = billingInterface.getOrCreateMandateAccount(mandate_id, initialBalance=0.0) - - transaction = BillingTransaction( - accountId=account["id"], - transactionType=TransactionTypeEnum.CREDIT, - amount=amount_chf, - description="Stripe-Zahlung", - referenceType=ReferenceTypeEnum.PAYMENT, - referenceId=session_id, - createdByUserId=user_id, - ) - billingInterface.createTransaction(transaction) - - if eventId and not billingInterface.getStripeWebhookEventByEventId(eventId): - billingInterface.createStripeWebhookEvent(eventId) - - logger.info(f"Stripe credit applied: {amount_chf} CHF for session {session_id} on mandate {mandate_id}") - return CheckoutConfirmResponse( - credited=True, - alreadyCredited=False, - sessionId=session_id, - mandateId=mandate_id, - amountChf=amount_chf, - ) +def _creditStripeSessionIfNeeded(billingInterface, session: Dict[str, Any], eventId: Optional[str] = None) -> CheckoutConfirmResponse: + """Credit balance from Stripe Checkout session if not already credited.""" + from .billingWebhookHandler import creditStripeSessionIfNeeded + return creditStripeSessionIfNeeded(billingInterface, session, eventId, CheckoutConfirmResponse) # ============================================================================= @@ -1148,314 +1078,15 @@ async def stripeWebhook( def handleSubscriptionCheckoutCompleted(session, eventId: str) -> None: - """Handle checkout.session.completed for mode=subscription. - Resolves the local PENDING record by ID from webhook metadata and transitions it.""" - from modules.interfaces.interfaceDbSubscription import getRootInterface as getSubRootInterface - from modules.datamodels.datamodelSubscription import SubscriptionStatusEnum, getPlan - from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import ( - getService as getSubscriptionService, - _notifySubscriptionChange, - ) - from modules.security.rootAccess import getRootUser - - if not isinstance(session, dict): - from modules.shared.stripeClient import stripeToDict - session = stripeToDict(session) - - metadata = session.get("metadata") or {} - subscriptionRecordId = metadata.get("subscriptionRecordId") - mandateId = metadata.get("mandateId") - planKey = metadata.get("planKey", "") - - platformUrl = metadata.get("platformUrl", "") - - if not subscriptionRecordId: - stripeSub = session.get("subscription") - if stripeSub: - try: - from modules.shared.stripeClient import getStripeClient - stripe = getStripeClient() - from modules.shared.stripeClient import stripeToDict - subObj = stripeToDict(stripe.Subscription.retrieve(stripeSub)) - metadata = subObj.get("metadata") or {} - subscriptionRecordId = metadata.get("subscriptionRecordId") - mandateId = metadata.get("mandateId") - planKey = metadata.get("planKey", "") - platformUrl = platformUrl or metadata.get("platformUrl", "") - except Exception as e: - # Stripe lookup is the only way to recover the metadata at this - # point — if it fails we MUST surface it, otherwise the webhook - # later short-circuits with "missing metadata" and the user - # silently gets stuck in PENDING. - logger.error( - "Stripe Subscription.retrieve(%s) failed during checkout " - "metadata recovery: %s", stripeSub, e, - ) - raise - - stripeSubId = session.get("subscription") - - if not mandateId or not subscriptionRecordId: - logger.warning("Subscription checkout missing metadata: %s", metadata) - return - - subInterface = getSubRootInterface() - rootUser = getRootUser() - - sub = subInterface.getById(subscriptionRecordId) - if not sub: - logger.error("Subscription record %s not found for checkout webhook", subscriptionRecordId) - return - if sub.get("status") != SubscriptionStatusEnum.PENDING.value: - logger.warning("Subscription %s is %s, expected PENDING — skipping", subscriptionRecordId, sub.get("status")) - return - - stripeData: Dict[str, Any] = {} - if stripeSubId: - stripeData["stripeSubscriptionId"] = stripeSubId - try: - from modules.shared.stripeClient import getStripeClient - stripe = getStripeClient() - from modules.shared.stripeClient import stripeToDict - stripeSub = stripeToDict(stripe.Subscription.retrieve(stripeSubId, expand=["items"])) - - if stripeSub.get("current_period_start"): - stripeData["currentPeriodStart"] = float(stripeSub["current_period_start"]) - if stripeSub.get("current_period_end"): - stripeData["currentPeriodEnd"] = float(stripeSub["current_period_end"]) - - from modules.serviceCenter.services.serviceSubscription.stripeBootstrap import getStripePricesForPlan - priceMapping = getStripePricesForPlan(planKey) - items = stripeSub.get("items") or {} - if not isinstance(items, dict): - items = dict(items) - for item in items.get("data", []): - priceId = (item.get("price") or {}).get("id", "") - if priceMapping and priceId == priceMapping.stripePriceIdUsers: - stripeData["stripeItemIdUsers"] = item["id"] - elif priceMapping and priceId == priceMapping.stripePriceIdInstances: - stripeData["stripeItemIdInstances"] = item["id"] - except Exception as e: - # Without these enrichment fields the activation completes anyway - # (status flips to ACTIVE/SCHEDULED below), but periods + Stripe - # item-IDs are missing on the local record, which breaks later - # add-on billing and renewal accounting. Re-raise so the webhook - # is retried by Stripe instead of silently shipping a broken row. - logger.error( - "Error retrieving Stripe subscription %s during checkout " - "completion (will be retried by Stripe): %s", - stripeSubId, e, - ) - raise - - if stripeData: - subInterface.updateFields(subscriptionRecordId, stripeData) - - operative = subInterface.getOperativeForMandate(mandateId) - hasActivePredecessor = operative is not None and operative["id"] != subscriptionRecordId - predecessorIsTrial = ( - hasActivePredecessor - and operative.get("status") == SubscriptionStatusEnum.TRIALING.value - ) - - if hasActivePredecessor and predecessorIsTrial: - try: - subInterface.forceExpire(operative["id"]) - logger.info( - "Trial subscription %s expired immediately for mandate %s due to paid upgrade %s", - operative["id"], mandateId, subscriptionRecordId, - ) - except Exception as e: - logger.error("Failed to expire trial predecessor %s: %s", operative["id"], e) - toStatus = SubscriptionStatusEnum.ACTIVE - elif hasActivePredecessor: - toStatus = SubscriptionStatusEnum.SCHEDULED - if operative.get("recurring", True): - operativeStripeId = operative.get("stripeSubscriptionId") - if operativeStripeId: - try: - from modules.shared.stripeClient import getStripeClient - stripe = getStripeClient() - stripe.Subscription.modify(operativeStripeId, cancel_at_period_end=True) - except Exception as e: - logger.error("Failed to set cancel_at_period_end on predecessor %s: %s", operativeStripeId, e) - subInterface.updateFields(operative["id"], {"recurring": False}) - effectiveFrom = operative.get("currentPeriodEnd") - if effectiveFrom: - subInterface.updateFields(subscriptionRecordId, {"effectiveFrom": effectiveFrom}) - else: - toStatus = SubscriptionStatusEnum.ACTIVE - - try: - subInterface.transitionStatus( - subscriptionRecordId, SubscriptionStatusEnum.PENDING, toStatus, - {"recurring": True}, - ) - except Exception as e: - logger.error("Failed to transition subscription %s: %s", subscriptionRecordId, e) - return - - subService = getSubscriptionService(rootUser, mandateId) - subService.invalidateCache(mandateId) - - if toStatus == SubscriptionStatusEnum.ACTIVE: - plan = getPlan(planKey) - updatedSub = subInterface.getById(subscriptionRecordId) - _notifySubscriptionChange(mandateId, "activated", plan, subscriptionRecord=updatedSub, platformUrl=platformUrl) - - try: - billingIf = getRootInterface() - billingIf.creditSubscriptionBudget(mandateId, planKey, periodLabel="Erstaktivierung") - except Exception as ex: - logger.error("creditSubscriptionBudget on activation failed: %s", ex) - - logger.info( - "Checkout completed: sub=%s -> %s, mandate=%s, plan=%s", - subscriptionRecordId, toStatus.value, mandateId, planKey, - ) + """Handle checkout.session.completed for mode=subscription.""" + from .billingWebhookHandler import handleSubscriptionCheckoutCompleted as _handler + _handler(session, eventId, getRootInterface) def _handleSubscriptionWebhook(event) -> None: - """Process Stripe subscription webhook events. - All record resolution is by stripeSubscriptionId — no mandate-based guessing.""" - from modules.interfaces.interfaceDbSubscription import getRootInterface as getSubRootInterface - from modules.datamodels.datamodelSubscription import SubscriptionStatusEnum, getPlan - from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import ( - getService as getSubscriptionService, - _notifySubscriptionChange, - ) - from modules.security.rootAccess import getRootUser - - obj = event.data.object - rawSub = obj.get("id") if event.type.startswith("customer.subscription") else obj.get("subscription") - stripeSubId = rawSub.get("id") if isinstance(rawSub, dict) else rawSub - if not stripeSubId: - logger.warning("Subscription webhook %s has no subscription ID", event.type) - return - - subInterface = getSubRootInterface() - sub = subInterface.getByStripeSubscriptionId(stripeSubId) - if not sub: - logger.warning("No local record for Stripe subscription %s (event: %s)", stripeSubId, event.type) - return - - subId = sub["id"] - mandateId = sub["mandateId"] - currentStatus = SubscriptionStatusEnum(sub["status"]) - rootUser = getRootUser() - subService = getSubscriptionService(rootUser, mandateId) - - subMetadata = obj.get("metadata") or {} - webhookPlatformUrl = subMetadata.get("platformUrl", "") - - if event.type == "customer.subscription.updated": - stripeStatus = obj.get("status", "") - - periodData: Dict[str, Any] = {} - if obj.get("current_period_start"): - periodData["currentPeriodStart"] = float(obj["current_period_start"]) - if obj.get("current_period_end"): - periodData["currentPeriodEnd"] = float(obj["current_period_end"]) - if periodData: - subInterface.updateFields(subId, periodData) - - if stripeStatus == "active" and currentStatus == SubscriptionStatusEnum.SCHEDULED: - subInterface.transitionStatus(subId, SubscriptionStatusEnum.SCHEDULED, SubscriptionStatusEnum.ACTIVE) - subService.invalidateCache(mandateId) - planKey = sub.get("planKey", "") - plan = getPlan(planKey) - refreshedSub = subInterface.getById(subId) - _notifySubscriptionChange(mandateId, "activated", plan, subscriptionRecord=refreshedSub, platformUrl=webhookPlatformUrl) - try: - getRootInterface().creditSubscriptionBudget(mandateId, planKey, periodLabel="Erstaktivierung") - except Exception as ex: - logger.error("creditSubscriptionBudget SCHEDULED->ACTIVE failed: %s", ex) - logger.info("SCHEDULED -> ACTIVE for sub %s (mandate %s)", subId, mandateId) - - elif stripeStatus == "active" and currentStatus == SubscriptionStatusEnum.PAST_DUE: - subInterface.transitionStatus(subId, SubscriptionStatusEnum.PAST_DUE, SubscriptionStatusEnum.ACTIVE) - subService.invalidateCache(mandateId) - logger.info("PAST_DUE -> ACTIVE for sub %s (mandate %s)", subId, mandateId) - - elif stripeStatus == "past_due" and currentStatus == SubscriptionStatusEnum.ACTIVE: - subInterface.transitionStatus(subId, SubscriptionStatusEnum.ACTIVE, SubscriptionStatusEnum.PAST_DUE) - subService.invalidateCache(mandateId) - logger.info("ACTIVE -> PAST_DUE for sub %s (mandate %s)", subId, mandateId) - - elif stripeStatus == "active" and currentStatus == SubscriptionStatusEnum.ACTIVE: - subService.invalidateCache(mandateId) - logger.info("Period renewed for sub %s (mandate %s)", subId, mandateId) - - elif event.type == "customer.subscription.deleted": - if currentStatus not in (SubscriptionStatusEnum.ACTIVE, SubscriptionStatusEnum.PAST_DUE, - SubscriptionStatusEnum.SCHEDULED): - logger.info("Ignoring deletion for sub %s in status %s", subId, currentStatus.value) - return - - subInterface.transitionStatus(subId, currentStatus, SubscriptionStatusEnum.EXPIRED) - subService.invalidateCache(mandateId) - logger.info("Sub %s -> EXPIRED (Stripe deleted, mandate %s)", subId, mandateId) - - scheduled = subInterface.getScheduledForMandate(mandateId) - if scheduled: - try: - subInterface.transitionStatus( - scheduled["id"], SubscriptionStatusEnum.SCHEDULED, SubscriptionStatusEnum.ACTIVE, - ) - subService.invalidateCache(mandateId) - plan = getPlan(scheduled.get("planKey", "")) - refreshedScheduled = subInterface.getById(scheduled["id"]) - _notifySubscriptionChange(mandateId, "activated", plan, subscriptionRecord=refreshedScheduled, platformUrl=webhookPlatformUrl) - logger.info("Promoted SCHEDULED sub %s -> ACTIVE (mandate %s)", scheduled["id"], mandateId) - except Exception as e: - logger.error("Failed to promote SCHEDULED sub %s: %s", scheduled["id"], e) - - elif event.type == "invoice.payment_failed": - if currentStatus == SubscriptionStatusEnum.ACTIVE: - subInterface.transitionStatus(subId, SubscriptionStatusEnum.ACTIVE, SubscriptionStatusEnum.PAST_DUE) - subService.invalidateCache(mandateId) - plan = getPlan(sub.get("planKey", "")) - _notifySubscriptionChange(mandateId, "payment_failed", plan, subscriptionRecord=sub, platformUrl=webhookPlatformUrl) - logger.info("Payment failed for sub %s (mandate %s)", subId, mandateId) - - elif event.type == "customer.subscription.trial_will_end": - logger.info("Trial ending soon for sub %s (mandate %s)", subId, mandateId) - try: - from modules.system.notifyMandateAdmins import notifyMandateAdmins - notifyMandateAdmins( - mandateId, - "[PowerOn] Testphase endet bald", - "Testphase endet bald", - [ - "Die kostenlose Testphase für Ihren Mandanten endet in Kürze.", - "Bitte wählen Sie einen Plan unter Billing-Verwaltung › Abonnement.", - ], - ) - except Exception as e: - logger.error("Failed to notify about trial ending: %s", e) - - elif event.type == "invoice.paid": - period_ts = obj.get("period_start") - periodLabel = "" - if period_ts: - period_start_at = datetime.fromtimestamp(int(period_ts), tz=timezone.utc) - periodLabel = period_start_at.strftime("%Y-%m-%d") - try: - billing_if = getRootInterface() - billing_if.resetStorageBillingPeriod(mandateId, period_start_at) - billing_if.reconcileMandateStorageBilling(mandateId) - except Exception as ex: - logger.error("Storage billing on invoice.paid failed: %s", ex) - - planKey = sub.get("planKey", "") - try: - billing_if = getRootInterface() - billing_if.creditSubscriptionBudget(mandateId, planKey, periodLabel=periodLabel or "Periodenverlängerung") - except Exception as ex: - logger.error("creditSubscriptionBudget on invoice.paid failed: %s", ex) - - logger.info("Invoice paid for sub %s (mandate %s)", subId, mandateId) - return None + """Process Stripe subscription webhook events.""" + from .billingWebhookHandler import handleSubscriptionWebhook as _handler + _handler(event, getRootInterface) @router.get("/admin/accounts/{targetMandateId}", response_model=List[AccountSummary]) diff --git a/modules/routes/routeClickup.py b/modules/routes/routeClickup.py index 7a869a9f..a6c6745d 100644 --- a/modules/routes/routeClickup.py +++ b/modules/routes/routeClickup.py @@ -9,7 +9,7 @@ from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request, sta from modules.auth import getCurrentUser, limiter from modules.datamodels.datamodelUam import AuthAuthority, User, UserConnection from modules.interfaces.interfaceDbApp import getInterface -from modules.serviceHub import getInterface as getServices +from modules.serviceCenter.serviceHub import getInterface as getServices from modules.shared.i18nRegistry import apiRouteContext routeApiMsg = apiRouteContext("routeClickup") diff --git a/modules/routes/routeSharepoint.py b/modules/routes/routeSharepoint.py index 1ee21900..b144328e 100644 --- a/modules/routes/routeSharepoint.py +++ b/modules/routes/routeSharepoint.py @@ -12,7 +12,7 @@ from fastapi import APIRouter, HTTPException, Depends, Path, Query, Request, sta from modules.auth import limiter, getCurrentUser from modules.datamodels.datamodelUam import User, UserConnection from modules.interfaces.interfaceDbApp import getInterface -from modules.serviceHub import getInterface as getServices +from modules.serviceCenter.serviceHub import getInterface as getServices from modules.shared.i18nRegistry import apiRouteContext routeApiMsg = apiRouteContext("routeSharepoint") diff --git a/modules/security/rootAccess.py b/modules/security/rootAccess.py index d87c22e8..1735891d 100644 --- a/modules/security/rootAccess.py +++ b/modules/security/rootAccess.py @@ -4,7 +4,7 @@ Root access management for system-level operations. Provides secure access to root user and DbApp database connector. -Bei leerer Datenbank wird automatisch Bootstrap ausgeführt. +Bootstrap is guaranteed by app.py lifespan before any access. """ import logging @@ -16,7 +16,7 @@ logger = logging.getLogger(__name__) _rootDbAppConnector = None _rootUser = None -_bootstrapExecuted = False + def getRootDbAppConnector() -> DatabaseConnector: """ @@ -39,34 +39,12 @@ def getRootDbAppConnector() -> DatabaseConnector: return _rootDbAppConnector -def _ensureBootstrap(): - """ - Führt Bootstrap aus, falls noch nicht geschehen. - Wird automatisch aufgerufen, wenn getRootUser() keinen User findet. - """ - global _bootstrapExecuted - - if _bootstrapExecuted: - return - - logger.info("Running bootstrap to initialize database") - - # Import here to avoid circular imports - from modules.interfaces.interfaceBootstrap import initBootstrap - - dbApp = getRootDbAppConnector() - initBootstrap(dbApp) - - _bootstrapExecuted = True - logger.info("Bootstrap completed") - - def getRootUser() -> User: """ Returns the root user (initial user from database). Used for system-level operations that require root privileges. - - Falls kein User existiert, wird Bootstrap automatisch ausgeführt. + + Raises RuntimeError if no user exists (bootstrap incomplete). """ global _rootUser @@ -74,19 +52,15 @@ def getRootUser() -> User: dbApp = getRootDbAppConnector() initialUserId = dbApp.getInitialId(UserInDB) - # Wenn kein User existiert, Bootstrap ausführen if not initialUserId: - logger.info("No initial user found, running bootstrap") - _ensureBootstrap() - - # Nochmal versuchen nach Bootstrap - initialUserId = dbApp.getInitialId(UserInDB) - if not initialUserId: - raise ValueError("No initial user ID found in database after bootstrap") + raise RuntimeError( + "No root user found - bootstrap incomplete. " + "Ensure app.py lifespan runs initBootstrap before any service access." + ) users = dbApp.getRecordset(UserInDB, recordFilter={"id": initialUserId}) if not users: - raise ValueError("Initial user not found in database") + raise RuntimeError("Initial user not found in database") user_data = users[0] _rootUser = User(**user_data) diff --git a/modules/serviceCenter/core/serviceStreaming/eventManager.py b/modules/serviceCenter/core/serviceStreaming/eventManager.py index 180430eb..823dbda1 100644 --- a/modules/serviceCenter/core/serviceStreaming/eventManager.py +++ b/modules/serviceCenter/core/serviceStreaming/eventManager.py @@ -1,222 +1,8 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. -""" -Event manager for SSE streaming. -Manages event queues for Server-Sent Events (SSE) streaming across features. -""" +"""Re-export shim — canonical source is modules.shared.eventManager.""" -import logging -import asyncio -from typing import Dict, Optional, Any - -logger = logging.getLogger(__name__) - - -class EventManager: - """ - Manages event queues for SSE streaming. - Each workflow has its own async queue for events. - """ - - def __init__(self): - """Initialize the event manager.""" - self._queues: Dict[str, asyncio.Queue] = {} - self._cleanup_tasks: Dict[str, asyncio.Task] = {} - self._agent_tasks: Dict[str, asyncio.Task] = {} - self._cancelled: Dict[str, bool] = {} - - def create_queue(self, workflow_id: str) -> asyncio.Queue: - """ - Create an event queue for a workflow. - - Args: - workflow_id: Workflow ID - - Returns: - Async queue for events - """ - if workflow_id in self._cleanup_tasks: - self._cleanup_tasks[workflow_id].cancel() - del self._cleanup_tasks[workflow_id] - logger.debug(f"Cancelled pending cleanup for workflow {workflow_id}") - - if workflow_id not in self._queues: - self._queues[workflow_id] = asyncio.Queue() - logger.debug(f"Created event queue for workflow {workflow_id}") - else: - old = self._queues[workflow_id] - while not old.empty(): - try: - old.get_nowait() - except asyncio.QueueEmpty: - break - logger.debug(f"Reusing event queue for workflow {workflow_id} (drained stale events)") - return self._queues[workflow_id] - - def get_queue(self, workflow_id: str) -> Optional[asyncio.Queue]: - """ - Get the event queue for a workflow. - - Args: - workflow_id: Workflow ID - - Returns: - Async queue if exists, None otherwise - """ - return self._queues.get(workflow_id) - - def has_queue(self, workflow_id: str) -> bool: - """ - Check if a queue exists for a workflow. - - Args: - workflow_id: Workflow ID - - Returns: - True if queue exists, False otherwise - """ - return workflow_id in self._queues - - def register_agent_task(self, workflow_id: str, task: asyncio.Task) -> None: - """Register the asyncio Task running the agent for a workflow.""" - self._agent_tasks[workflow_id] = task - self._cancelled.pop(workflow_id, None) - - def is_cancelled(self, workflow_id: str) -> bool: - """Check if a workflow has been cancelled.""" - return self._cancelled.get(workflow_id, False) - - async def cancel_agent(self, workflow_id: str) -> bool: - """Cancel the running agent task for a workflow. Returns True if cancelled.""" - self._cancelled[workflow_id] = True - task = self._agent_tasks.pop(workflow_id, None) - if task and not task.done(): - task.cancel() - logger.info(f"Cancelled agent task for workflow {workflow_id}") - return True - logger.debug(f"No running agent task found for workflow {workflow_id}") - return False - - def _unregister_agent_task(self, workflow_id: str) -> None: - """Remove the agent task reference after completion.""" - self._agent_tasks.pop(workflow_id, None) - self._cancelled.pop(workflow_id, None) - - async def emit_event( - self, - context_id: str, - event_type: str, - data: Dict[str, Any], - event_category: str = "chat", - message: Optional[str] = None, - step: Optional[str] = None - ) -> None: - """ - Emit an event to the queue for a workflow. - - Args: - context_id: Workflow ID (context) - event_type: Type of event (e.g., "chatdata", "complete", "error") - data: Event data dictionary - event_category: Category of event (e.g., "chat", "workflow") - message: Optional message string - step: Optional step identifier - """ - queue = self._queues.get(context_id) - if not queue: - # DEBUG level: This is normal for background workflows without active SSE listener - return - - event = { - "type": event_type, - "data": data, - "category": event_category, - "message": message, - "step": step - } - - try: - await queue.put(event) - if event_type not in ("chunk",): - logger.debug(f"Emitted {event_type} event for workflow {context_id}") - except Exception as e: - logger.error(f"Error emitting event for workflow {context_id}: {e}", exc_info=True) - - async def cleanup(self, workflow_id: str, delay: float = 60.0) -> None: - """ - Schedule cleanup of a queue after a delay. - - Args: - workflow_id: Workflow ID - delay: Delay in seconds before cleanup - """ - # Cancel existing cleanup task if any - if workflow_id in self._cleanup_tasks: - self._cleanup_tasks[workflow_id].cancel() - - async def _cleanup(): - try: - await asyncio.sleep(delay) - if workflow_id in self._queues: - # Drain remaining events - queue = self._queues[workflow_id] - while not queue.empty(): - try: - queue.get_nowait() - except asyncio.QueueEmpty: - break - - # Remove queue - del self._queues[workflow_id] - logger.info(f"Cleaned up event queue for workflow {workflow_id}") - except asyncio.CancelledError: - logger.debug(f"Cleanup cancelled for workflow {workflow_id}") - except Exception as e: - logger.error(f"Error during cleanup for workflow {workflow_id}: {e}", exc_info=True) - finally: - if workflow_id in self._cleanup_tasks: - del self._cleanup_tasks[workflow_id] - - # Schedule cleanup - task = asyncio.create_task(_cleanup()) - self._cleanup_tasks[workflow_id] = task - - def shutdown(self) -> None: - """Cancel all pending cleanup and agent tasks for fast process exit. - - Injects ``None`` sentinels into every live queue so that SSE generators - (which block on ``queue.get()``) break out of their loop immediately - instead of waiting up to the keepalive timeout. - """ - for _wfId, q in list(self._queues.items()): - try: - q.put_nowait(None) - except Exception: - pass - for wfId, task in list(self._cleanup_tasks.items()): - if not task.done(): - task.cancel() - self._cleanup_tasks.clear() - for wfId, task in list(self._agent_tasks.items()): - if not task.done(): - task.cancel() - self._agent_tasks.clear() - self._queues.clear() - logger.info("EventManager shutdown: all tasks cancelled, queues drained") - - -# Global event manager instance -_event_manager: Optional[EventManager] = None - - -def get_event_manager() -> EventManager: - """ - Get the global event manager instance. - - Returns: - EventManager instance - """ - global _event_manager - if _event_manager is None: - _event_manager = EventManager() - return _event_manager +from modules.shared.eventManager import ( # noqa: F401 + EventManager, + get_event_manager, +) diff --git a/modules/serviceCenter/serviceHub.py b/modules/serviceCenter/serviceHub.py new file mode 100644 index 00000000..a42f8d0e --- /dev/null +++ b/modules/serviceCenter/serviceHub.py @@ -0,0 +1,189 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Service Hub. +Consumer-facing aggregation layer for services, DB interfaces, and runtime state. + +Architecture: +- serviceHub delegates service resolution to serviceCenter (DI container) +- serviceHub owns DB interface initialization and runtime state +- serviceCenter knows nothing about serviceHub (one-way dependency) + +Import-Regelwerk: +- Zentrale Module (wie dieses) duerfen KEINE Feature-Container importieren +- Feature-spezifische Services werden dynamisch geladen +- Shared Services werden via serviceCenter resolved +""" + +import os +import importlib +import glob +from typing import Any, Optional, TYPE_CHECKING +import logging + +from modules.datamodels.datamodelUam import User + +if TYPE_CHECKING: + from modules.datamodels.datamodelChat import ChatWorkflow + +logger = logging.getLogger(__name__) + +_FEATURES_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "features") + + +class PublicService: + """Lightweight proxy exposing only public callable attributes of a target.""" + + def __init__(self, target: Any, functionsOnly: bool = True, nameFilter=None): + self._target = target + self._functionsOnly = functionsOnly + self._nameFilter = nameFilter + + def __getattr__(self, name: str): + if name.startswith('_'): + raise AttributeError(f"'{type(self._target).__name__}' attribute '{name}' is private") + if self._nameFilter and not self._nameFilter(name): + raise AttributeError(f"'{name}' not exposed by policy") + attr = getattr(self._target, name) + if self._functionsOnly and not callable(attr): + raise AttributeError(f"'{name}' is not a function") + return attr + + def __dir__(self): + return sorted([ + n for n in dir(self._target) + if not n.startswith('_') + and (not self._functionsOnly or callable(getattr(self._target, n, None))) + and (self._nameFilter(n) if self._nameFilter else True) + ]) + + +class ServiceHub: + """ + Consumer-facing aggregation of services, DB interfaces, and runtime state. + + Services are lazy-resolved via serviceCenter on first access. + DB interfaces and runtime state are initialized eagerly. + Feature services/interfaces are discovered dynamically from features/. + """ + + _SERVICE_CENTER_WRAPPING = { + "ai": {"functionsOnly": False}, + } + + def __init__(self, user: User, workflow: "ChatWorkflow" = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None): + self.user: User = user + self.workflow = workflow + self.mandateId: Optional[str] = mandateId + self.featureInstanceId: Optional[str] = featureInstanceId + self.currentUserPrompt: str = "" + self.rawUserPrompt: str = "" + + from modules.serviceCenter.context import ServiceCenterContext + self._serviceCenterContext = ServiceCenterContext( + user=user, + workflow=workflow, + mandate_id=mandateId, + feature_instance_id=featureInstanceId, + ) + + from modules.interfaces.interfaceDbApp import getInterface as getAppInterface + self.interfaceDbApp = getAppInterface(user, mandateId=mandateId) + + from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface + self.interfaceDbComponent = getComponentInterface(user, mandateId=mandateId) + + self.rbac = self.interfaceDbApp.rbac if self.interfaceDbApp else None + + from modules.interfaces.interfaceDbChat import getInterface as getChatInterface + self.interfaceDbChat = getChatInterface(user, mandateId=mandateId, featureInstanceId=featureInstanceId) + + self._loadFeatureInterfaces() + self._loadFeatureServices() + + def __getattr__(self, name: str): + """Lazy-resolve services via serviceCenter on first access.""" + if name.startswith('_'): + raise AttributeError(name) + try: + from modules.serviceCenter import getService + service = getService(name, self._serviceCenterContext) + wrapping = self._SERVICE_CENTER_WRAPPING.get(name, {}) + functionsOnly = wrapping.get("functionsOnly", True) + wrapped = PublicService(service, functionsOnly=functionsOnly) + setattr(self, name, wrapped) + return wrapped + except KeyError: + raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'") + + def _loadFeatureInterfaces(self): + """Dynamically load interfaces from feature containers by filename pattern.""" + pattern = os.path.join(_FEATURES_DIR, "*", "interfaceFeature*.py") + for filepath in glob.glob(pattern): + try: + featureDir = os.path.basename(os.path.dirname(filepath)) + filename = os.path.basename(filepath)[:-3] + + modulePath = f"modules.features.{featureDir}.{filename}" + module = importlib.import_module(modulePath) + + if hasattr(module, "getInterface"): + interface = module.getInterface(self.user, mandateId=self.mandateId, featureInstanceId=self.featureInstanceId) + attrName = filename.replace("interfaceFeature", "interfaceDb") + setattr(self, attrName, interface) + logger.debug(f"Loaded interface: {attrName} from {modulePath}") + except Exception as e: + logger.debug(f"Could not load interface from {filepath}: {e}") + + def _loadFeatureServices(self): + """Dynamically load services from feature containers by filename pattern.""" + pattern = os.path.join(_FEATURES_DIR, "*", "service*", "mainService*.py") + for filepath in glob.glob(pattern): + try: + serviceDir = os.path.basename(os.path.dirname(filepath)) + featureDir = os.path.basename(os.path.dirname(os.path.dirname(filepath))) + filename = os.path.basename(filepath)[:-3] + + modulePath = f"modules.features.{featureDir}.{serviceDir}.{filename}" + module = importlib.import_module(modulePath) + + serviceClass = None + for attrName in dir(module): + if attrName.endswith("Service") and not attrName.startswith("_"): + cls = getattr(module, attrName) + if isinstance(cls, type): + serviceClass = cls + break + + if serviceClass: + attrName = serviceDir.replace("service", "").lower() + if not attrName: + attrName = serviceDir.lower() + + functionsOnly = attrName != "ai" + + def _makeServiceResolver(hub): + def _resolver(depKey: str): + return getattr(hub, depKey) + return _resolver + + import inspect + sig = inspect.signature(serviceClass.__init__) + paramCount = len([p for p in sig.parameters if p != 'self']) + if paramCount >= 2: + serviceInstance = serviceClass(self, _makeServiceResolver(self)) + else: + serviceInstance = serviceClass(self) + setattr(self, attrName, PublicService(serviceInstance, functionsOnly=functionsOnly)) + logger.debug(f"Loaded service: {attrName} from {modulePath}") + except Exception as e: + logger.debug(f"Could not load service from {filepath}: {e}") + + +# Backward-compatible alias +Services = ServiceHub + + +def getInterface(user: User, workflow: "ChatWorkflow" = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> ServiceHub: + """Get ServiceHub instance for the given user, mandate, and feature instance context.""" + return ServiceHub(user, workflow, mandateId=mandateId, featureInstanceId=featureInstanceId) diff --git a/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py b/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py index dd9fac2c..f2418b4b 100644 --- a/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py +++ b/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py @@ -524,7 +524,7 @@ class ProviderNotAllowedException(Exception): super().__init__(self.message) -from modules.shared.serviceExceptions import BillingContextError +from modules.datamodels.serviceExceptions import BillingContextError # Expose exception classes on BillingService so consumers can use service.InsufficientBalanceException # instead of importing from this module diff --git a/modules/serviceCenter/services/serviceGeneration/subDocumentUtility.py b/modules/serviceCenter/services/serviceGeneration/subDocumentUtility.py index da1194c4..21ba33d1 100644 --- a/modules/serviceCenter/services/serviceGeneration/subDocumentUtility.py +++ b/modules/serviceCenter/services/serviceGeneration/subDocumentUtility.py @@ -76,63 +76,7 @@ def enhancePlainTextWithMarkdownTables(body: str) -> str: return "\n\n".join(out_parts) -def parseInlineRuns(text: str) -> list: - """ - Parse inline markdown formatting into a list of InlineRun dicts. - Handles: images, links, bold, italic, inline code, plain text. - Uses a regex-based tokenizer that processes tokens left-to-right. - """ - if not text: - return [{"type": "text", "value": ""}] - - # Pattern order matters: images before links, bold before italic - _TOKEN_RE = re.compile( - r'!\[(?P[^\]]*)\]\((?P[^)"]+)(?:\s+"(?P\d+)pt")?\)' # image - r'|\[(?P[^\]]+)\]\((?P[^)]+)\)' # link - r'|`(?P[^`]+)`' # inline code - r'|\*\*(?P.+?)\*\*' # bold - r'|(?.+?)\*(?!\w)' # italic *x* - r'|(?.+?)_(?!\w)' # italic _x_ - ) - - runs = [] - lastEnd = 0 - - for m in _TOKEN_RE.finditer(text): - # Plain text before this match - if m.start() > lastEnd: - runs.append({"type": "text", "value": text[lastEnd:m.start()]}) - - if m.group("imgAlt") is not None or m.group("imgSrc") is not None: - alt = (m.group("imgAlt") or "").strip() or "Image" - src = (m.group("imgSrc") or "").strip() - widthStr = m.group("imgWidth") - run = {"type": "image", "value": alt} - if src.startswith("file:"): - run["fileId"] = src[5:] - else: - run["href"] = src - if widthStr: - run["widthPt"] = int(widthStr) - runs.append(run) - elif m.group("linkText") is not None: - runs.append({"type": "link", "value": m.group("linkText"), "href": m.group("linkHref")}) - elif m.group("code") is not None: - runs.append({"type": "code", "value": m.group("code")}) - elif m.group("bold") is not None: - runs.append({"type": "bold", "value": m.group("bold")}) - elif m.group("italic1") is not None: - runs.append({"type": "italic", "value": m.group("italic1")}) - elif m.group("italic2") is not None: - runs.append({"type": "italic", "value": m.group("italic2")}) - - lastEnd = m.end() - - # Trailing plain text - if lastEnd < len(text): - runs.append({"type": "text", "value": text[lastEnd:]}) - - return runs if runs else [{"type": "text", "value": text}] +from modules.shared.documentUtils import parseInlineRuns # noqa: F401 — canonical source in shared/ def _imageRefToDict(token: str) -> Optional[Dict[str, Any]]: diff --git a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py index 1eaebf56..71dc4526 100644 --- a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py +++ b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py @@ -1039,7 +1039,7 @@ def _buildCancelSummaryHtml(subRecord: Dict[str, Any], platformUrl: str = "") -> # Exception Classes (defined in shared, re-exported here for backward compat) # ============================================================================ -from modules.shared.serviceExceptions import ( +from modules.datamodels.serviceExceptions import ( SubscriptionInactiveException, SubscriptionCapacityException, SUBSCRIPTION_USER_ACTION_UPGRADE, diff --git a/modules/serviceHub/__init__.py b/modules/serviceHub/__init__.py index a42f8d0e..14021394 100644 --- a/modules/serviceHub/__init__.py +++ b/modules/serviceHub/__init__.py @@ -1,189 +1,7 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -""" -Service Hub. -Consumer-facing aggregation layer for services, DB interfaces, and runtime state. - -Architecture: -- serviceHub delegates service resolution to serviceCenter (DI container) -- serviceHub owns DB interface initialization and runtime state -- serviceCenter knows nothing about serviceHub (one-way dependency) - -Import-Regelwerk: -- Zentrale Module (wie dieses) duerfen KEINE Feature-Container importieren -- Feature-spezifische Services werden dynamisch geladen -- Shared Services werden via serviceCenter resolved -""" - -import os -import importlib -import glob -from typing import Any, Optional, TYPE_CHECKING -import logging - -from modules.datamodels.datamodelUam import User - -if TYPE_CHECKING: - from modules.datamodels.datamodelChat import ChatWorkflow - -logger = logging.getLogger(__name__) - -_FEATURES_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "features") - - -class PublicService: - """Lightweight proxy exposing only public callable attributes of a target.""" - - def __init__(self, target: Any, functionsOnly: bool = True, nameFilter=None): - self._target = target - self._functionsOnly = functionsOnly - self._nameFilter = nameFilter - - def __getattr__(self, name: str): - if name.startswith('_'): - raise AttributeError(f"'{type(self._target).__name__}' attribute '{name}' is private") - if self._nameFilter and not self._nameFilter(name): - raise AttributeError(f"'{name}' not exposed by policy") - attr = getattr(self._target, name) - if self._functionsOnly and not callable(attr): - raise AttributeError(f"'{name}' is not a function") - return attr - - def __dir__(self): - return sorted([ - n for n in dir(self._target) - if not n.startswith('_') - and (not self._functionsOnly or callable(getattr(self._target, n, None))) - and (self._nameFilter(n) if self._nameFilter else True) - ]) - - -class ServiceHub: - """ - Consumer-facing aggregation of services, DB interfaces, and runtime state. - - Services are lazy-resolved via serviceCenter on first access. - DB interfaces and runtime state are initialized eagerly. - Feature services/interfaces are discovered dynamically from features/. - """ - - _SERVICE_CENTER_WRAPPING = { - "ai": {"functionsOnly": False}, - } - - def __init__(self, user: User, workflow: "ChatWorkflow" = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None): - self.user: User = user - self.workflow = workflow - self.mandateId: Optional[str] = mandateId - self.featureInstanceId: Optional[str] = featureInstanceId - self.currentUserPrompt: str = "" - self.rawUserPrompt: str = "" - - from modules.serviceCenter.context import ServiceCenterContext - self._serviceCenterContext = ServiceCenterContext( - user=user, - workflow=workflow, - mandate_id=mandateId, - feature_instance_id=featureInstanceId, - ) - - from modules.interfaces.interfaceDbApp import getInterface as getAppInterface - self.interfaceDbApp = getAppInterface(user, mandateId=mandateId) - - from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface - self.interfaceDbComponent = getComponentInterface(user, mandateId=mandateId) - - self.rbac = self.interfaceDbApp.rbac if self.interfaceDbApp else None - - from modules.interfaces.interfaceDbChat import getInterface as getChatInterface - self.interfaceDbChat = getChatInterface(user, mandateId=mandateId, featureInstanceId=featureInstanceId) - - self._loadFeatureInterfaces() - self._loadFeatureServices() - - def __getattr__(self, name: str): - """Lazy-resolve services via serviceCenter on first access.""" - if name.startswith('_'): - raise AttributeError(name) - try: - from modules.serviceCenter import getService - service = getService(name, self._serviceCenterContext) - wrapping = self._SERVICE_CENTER_WRAPPING.get(name, {}) - functionsOnly = wrapping.get("functionsOnly", True) - wrapped = PublicService(service, functionsOnly=functionsOnly) - setattr(self, name, wrapped) - return wrapped - except KeyError: - raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'") - - def _loadFeatureInterfaces(self): - """Dynamically load interfaces from feature containers by filename pattern.""" - pattern = os.path.join(_FEATURES_DIR, "*", "interfaceFeature*.py") - for filepath in glob.glob(pattern): - try: - featureDir = os.path.basename(os.path.dirname(filepath)) - filename = os.path.basename(filepath)[:-3] - - modulePath = f"modules.features.{featureDir}.{filename}" - module = importlib.import_module(modulePath) - - if hasattr(module, "getInterface"): - interface = module.getInterface(self.user, mandateId=self.mandateId, featureInstanceId=self.featureInstanceId) - attrName = filename.replace("interfaceFeature", "interfaceDb") - setattr(self, attrName, interface) - logger.debug(f"Loaded interface: {attrName} from {modulePath}") - except Exception as e: - logger.debug(f"Could not load interface from {filepath}: {e}") - - def _loadFeatureServices(self): - """Dynamically load services from feature containers by filename pattern.""" - pattern = os.path.join(_FEATURES_DIR, "*", "service*", "mainService*.py") - for filepath in glob.glob(pattern): - try: - serviceDir = os.path.basename(os.path.dirname(filepath)) - featureDir = os.path.basename(os.path.dirname(os.path.dirname(filepath))) - filename = os.path.basename(filepath)[:-3] - - modulePath = f"modules.features.{featureDir}.{serviceDir}.{filename}" - module = importlib.import_module(modulePath) - - serviceClass = None - for attrName in dir(module): - if attrName.endswith("Service") and not attrName.startswith("_"): - cls = getattr(module, attrName) - if isinstance(cls, type): - serviceClass = cls - break - - if serviceClass: - attrName = serviceDir.replace("service", "").lower() - if not attrName: - attrName = serviceDir.lower() - - functionsOnly = attrName != "ai" - - def _makeServiceResolver(hub): - def _resolver(depKey: str): - return getattr(hub, depKey) - return _resolver - - import inspect - sig = inspect.signature(serviceClass.__init__) - paramCount = len([p for p in sig.parameters if p != 'self']) - if paramCount >= 2: - serviceInstance = serviceClass(self, _makeServiceResolver(self)) - else: - serviceInstance = serviceClass(self) - setattr(self, attrName, PublicService(serviceInstance, functionsOnly=functionsOnly)) - logger.debug(f"Loaded service: {attrName} from {modulePath}") - except Exception as e: - logger.debug(f"Could not load service from {filepath}: {e}") - - -# Backward-compatible alias -Services = ServiceHub - - -def getInterface(user: User, workflow: "ChatWorkflow" = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> ServiceHub: - """Get ServiceHub instance for the given user, mandate, and feature instance context.""" - return ServiceHub(user, workflow, mandateId=mandateId, featureInstanceId=featureInstanceId) +# Re-export shim — canonical source: modules.serviceCenter.serviceHub +from modules.serviceCenter.serviceHub import ( # noqa: F401 + PublicService, + ServiceHub, + Services, + getInterface, +) diff --git a/modules/shared/documentUtils.py b/modules/shared/documentUtils.py new file mode 100644 index 00000000..cc08835c --- /dev/null +++ b/modules/shared/documentUtils.py @@ -0,0 +1,64 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Document utility functions (Layer L0 - shared). +Pure text-processing helpers with zero internal dependencies. +""" + +import re + + +def parseInlineRuns(text: str) -> list: + """ + Parse inline markdown formatting into a list of InlineRun dicts. + Handles: images, links, bold, italic, inline code, plain text. + Uses a regex-based tokenizer that processes tokens left-to-right. + """ + if not text: + return [{"type": "text", "value": ""}] + + _TOKEN_RE = re.compile( + r'!\[(?P[^\]]*)\]\((?P[^)"]+)(?:\s+"(?P\d+)pt")?\)' + r'|\[(?P[^\]]+)\]\((?P[^)]+)\)' + r'|`(?P[^`]+)`' + r'|\*\*(?P.+?)\*\*' + r'|(?.+?)\*(?!\w)' + r'|(?.+?)_(?!\w)' + ) + + runs = [] + lastEnd = 0 + + for m in _TOKEN_RE.finditer(text): + if m.start() > lastEnd: + runs.append({"type": "text", "value": text[lastEnd:m.start()]}) + + if m.group("imgAlt") is not None or m.group("imgSrc") is not None: + alt = (m.group("imgAlt") or "").strip() or "Image" + src = (m.group("imgSrc") or "").strip() + widthStr = m.group("imgWidth") + run = {"type": "image", "value": alt} + if src.startswith("file:"): + run["fileId"] = src[5:] + else: + run["href"] = src + if widthStr: + run["widthPt"] = int(widthStr) + runs.append(run) + elif m.group("linkText") is not None: + runs.append({"type": "link", "value": m.group("linkText"), "href": m.group("linkHref")}) + elif m.group("code") is not None: + runs.append({"type": "code", "value": m.group("code")}) + elif m.group("bold") is not None: + runs.append({"type": "bold", "value": m.group("bold")}) + elif m.group("italic1") is not None: + runs.append({"type": "italic", "value": m.group("italic1")}) + elif m.group("italic2") is not None: + runs.append({"type": "italic", "value": m.group("italic2")}) + + lastEnd = m.end() + + if lastEnd < len(text): + runs.append({"type": "text", "value": text[lastEnd:]}) + + return runs if runs else [{"type": "text", "value": text}] diff --git a/modules/shared/eventManager.py b/modules/shared/eventManager.py new file mode 100644 index 00000000..13b0b322 --- /dev/null +++ b/modules/shared/eventManager.py @@ -0,0 +1,167 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Event manager for SSE streaming (Layer L0 - shared). +Manages event queues for Server-Sent Events (SSE) streaming across features. +Generic pub/sub infrastructure with zero internal dependencies. +""" + +import logging +import asyncio +from typing import Dict, Optional, Any + +logger = logging.getLogger(__name__) + + +class EventManager: + """ + Manages event queues for SSE streaming. + Each workflow has its own async queue for events. + """ + + def __init__(self): + """Initialize the event manager.""" + self._queues: Dict[str, asyncio.Queue] = {} + self._cleanup_tasks: Dict[str, asyncio.Task] = {} + self._agent_tasks: Dict[str, asyncio.Task] = {} + self._cancelled: Dict[str, bool] = {} + + def create_queue(self, workflow_id: str) -> asyncio.Queue: + """Create an event queue for a workflow.""" + if workflow_id in self._cleanup_tasks: + self._cleanup_tasks[workflow_id].cancel() + del self._cleanup_tasks[workflow_id] + logger.debug(f"Cancelled pending cleanup for workflow {workflow_id}") + + if workflow_id not in self._queues: + self._queues[workflow_id] = asyncio.Queue() + logger.debug(f"Created event queue for workflow {workflow_id}") + else: + old = self._queues[workflow_id] + while not old.empty(): + try: + old.get_nowait() + except asyncio.QueueEmpty: + break + logger.debug(f"Reusing event queue for workflow {workflow_id} (drained stale events)") + return self._queues[workflow_id] + + def get_queue(self, workflow_id: str) -> Optional[asyncio.Queue]: + """Get the event queue for a workflow.""" + return self._queues.get(workflow_id) + + def has_queue(self, workflow_id: str) -> bool: + """Check if a queue exists for a workflow.""" + return workflow_id in self._queues + + def register_agent_task(self, workflow_id: str, task: asyncio.Task) -> None: + """Register the asyncio Task running the agent for a workflow.""" + self._agent_tasks[workflow_id] = task + self._cancelled.pop(workflow_id, None) + + def is_cancelled(self, workflow_id: str) -> bool: + """Check if a workflow has been cancelled.""" + return self._cancelled.get(workflow_id, False) + + async def cancel_agent(self, workflow_id: str) -> bool: + """Cancel the running agent task for a workflow. Returns True if cancelled.""" + self._cancelled[workflow_id] = True + task = self._agent_tasks.pop(workflow_id, None) + if task and not task.done(): + task.cancel() + logger.info(f"Cancelled agent task for workflow {workflow_id}") + return True + logger.debug(f"No running agent task found for workflow {workflow_id}") + return False + + def _unregister_agent_task(self, workflow_id: str) -> None: + """Remove the agent task reference after completion.""" + self._agent_tasks.pop(workflow_id, None) + self._cancelled.pop(workflow_id, None) + + async def emit_event( + self, + context_id: str, + event_type: str, + data: Dict[str, Any], + event_category: str = "chat", + message: Optional[str] = None, + step: Optional[str] = None + ) -> None: + """Emit an event to the queue for a workflow.""" + queue = self._queues.get(context_id) + if not queue: + return + + event = { + "type": event_type, + "data": data, + "category": event_category, + "message": message, + "step": step + } + + try: + await queue.put(event) + if event_type not in ("chunk",): + logger.debug(f"Emitted {event_type} event for workflow {context_id}") + except Exception as e: + logger.error(f"Error emitting event for workflow {context_id}: {e}", exc_info=True) + + async def cleanup(self, workflow_id: str, delay: float = 60.0) -> None: + """Schedule cleanup of a queue after a delay.""" + if workflow_id in self._cleanup_tasks: + self._cleanup_tasks[workflow_id].cancel() + + async def _cleanup(): + try: + await asyncio.sleep(delay) + if workflow_id in self._queues: + queue = self._queues[workflow_id] + while not queue.empty(): + try: + queue.get_nowait() + except asyncio.QueueEmpty: + break + del self._queues[workflow_id] + logger.info(f"Cleaned up event queue for workflow {workflow_id}") + except asyncio.CancelledError: + logger.debug(f"Cleanup cancelled for workflow {workflow_id}") + except Exception as e: + logger.error(f"Error during cleanup for workflow {workflow_id}: {e}", exc_info=True) + finally: + if workflow_id in self._cleanup_tasks: + del self._cleanup_tasks[workflow_id] + + task = asyncio.create_task(_cleanup()) + self._cleanup_tasks[workflow_id] = task + + def shutdown(self) -> None: + """Cancel all pending cleanup and agent tasks for fast process exit.""" + for _wfId, q in list(self._queues.items()): + try: + q.put_nowait(None) + except Exception: + pass + for wfId, task in list(self._cleanup_tasks.items()): + if not task.done(): + task.cancel() + self._cleanup_tasks.clear() + for wfId, task in list(self._agent_tasks.items()): + if not task.done(): + task.cancel() + self._agent_tasks.clear() + self._queues.clear() + logger.info("EventManager shutdown: all tasks cancelled, queues drained") + + +# Global event manager instance +_event_manager: Optional[EventManager] = None + + +def get_event_manager() -> EventManager: + """Get the global event manager instance.""" + global _event_manager + if _event_manager is None: + _event_manager = EventManager() + return _event_manager diff --git a/modules/shared/featureDiscovery.py b/modules/shared/featureDiscovery.py new file mode 100644 index 00000000..0332e9c1 --- /dev/null +++ b/modules/shared/featureDiscovery.py @@ -0,0 +1,59 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Feature discovery utility (Layer L0 - shared). +Dynamically discovers and loads feature main modules from the features directory. +Zero internal dependencies — only os, glob, importlib, logging. +""" + +import os +import glob +import importlib +import logging +from typing import Dict, Any + +logger = logging.getLogger(__name__) + +FEATURES_DIR = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "features" +) + +_cachedMainModules = None + + +def loadFeatureMainModules() -> Dict[str, Any]: + """ + Dynamically load main modules from all discovered feature containers. + Results are cached after the first call. + """ + global _cachedMainModules + if _cachedMainModules is not None: + return _cachedMainModules + + mainModules = {} + pattern = os.path.join(FEATURES_DIR, "*", "main*.py") + + for filepath in glob.glob(pattern): + filename = os.path.basename(filepath) + if filename == "__init__.py": + continue + + featureDir = os.path.basename(os.path.dirname(filepath)) + if featureDir.startswith("_"): + continue + + if featureDir in mainModules: + continue + + mainFile = filename[:-3] + + try: + modulePath = f"modules.features.{featureDir}.{mainFile}" + module = importlib.import_module(modulePath) + mainModules[featureDir] = module + logger.debug(f"Loaded main module: {featureDir}") + except Exception as e: + logger.error(f"Failed to load main module from {featureDir}: {e}") + + _cachedMainModules = mainModules + return mainModules diff --git a/modules/system/gdprDeletion.py b/modules/system/gdprDeletion.py index c6f8d5ca..ab3a6e2b 100644 --- a/modules/system/gdprDeletion.py +++ b/modules/system/gdprDeletion.py @@ -561,39 +561,27 @@ def _deleteUserDataFromFeatureDatabases(userId: str, currentUser) -> Dict[str, A logger.info(f"Found {len(featureCodes)} feature types to process: {featureCodes}") - # Process each feature type + # Process each feature type via lifecycle hooks + from modules.shared.featureDiscovery import loadFeatureMainModules + featureModules = loadFeatureMainModules() + for featureCode in featureCodes: try: - dbName = f"poweron_{featureCode}" - - # Try to get feature interface - featureInterface = None - - if featureCode == "trustee": - from modules.features.trustee.interfaceFeatureTrustee import getInterface as getTrusteeInterface - featureInterface = getTrusteeInterface(currentUser) - elif featureCode == "realestate": - from modules.features.realestate.interfaceFeatureRealEstate import getInterface as getRealEstateInterface - featureInterface = getRealEstateInterface(currentUser) - elif featureCode == "neutralization": - from modules.features.neutralization.interfaceFeatureNeutralizer import getInterface as getNeutralizerInterface - featureInterface = getNeutralizerInterface(currentUser) - else: - logger.warning(f"No interface found for feature code: {featureCode}") + featureModule = featureModules.get(featureCode) + hook = getattr(featureModule, "onUserDelete", None) if featureModule else None + + if hook is None: + logger.warning(f"No onUserDelete hook for feature: {featureCode}") continue - - if featureInterface and hasattr(featureInterface, 'db'): - featureStats = deleteUserDataFromDatabase( - featureInterface.db, - userId, - dbName - ) + + featureStats = hook(userId, currentUser) + if featureStats: stats["databases"].append(featureStats) - stats["totalTablesProcessed"] += featureStats["tablesProcessed"] - stats["totalRecordsDeleted"] += featureStats["recordsDeleted"] - stats["totalRecordsAnonymized"] += featureStats["recordsAnonymized"] - stats["errors"].extend(featureStats["errors"]) - + stats["totalTablesProcessed"] += featureStats.get("tablesProcessed", 0) + stats["totalRecordsDeleted"] += featureStats.get("recordsDeleted", 0) + stats["totalRecordsAnonymized"] += featureStats.get("recordsAnonymized", 0) + stats["errors"].extend(featureStats.get("errors", [])) + except Exception as featureErr: errorMsg = f"Error processing feature {featureCode}: {featureErr}" logger.warning(errorMsg) diff --git a/modules/system/i18nBootSync.py b/modules/system/i18nBootSync.py index e60aa4d3..96f1b69d 100644 --- a/modules/system/i18nBootSync.py +++ b/modules/system/i18nBootSync.py @@ -202,19 +202,19 @@ def _registerRbacLabels(): logger.info("i18n rbac labels: %d new keys (rbac.* context)", added) -def _registerServiceCenterLabels(): - """Register service-center category labels and bootstrap role descriptions.""" +def _registerServiceCenterLabels(serviceLabels: list = None): + """Register service-center category labels and bootstrap role descriptions. + + serviceLabels is injected by app.py (Composition Root) to avoid + system(L4) → serviceCenter(L5) upward import. + """ added = 0 - try: - from modules.serviceCenter.registry import IMPORTABLE_SERVICES - for svc in IMPORTABLE_SERVICES.values(): - key = _extractRegistrySourceText(svc.get("label")) - if key and key not in _REGISTRY: - _REGISTRY[key] = _I18nRegistryEntry(context="service", value="") - added += 1 - except ImportError: - pass + for label in (serviceLabels or []): + key = _extractRegistrySourceText(label) + if key and key not in _REGISTRY: + _REGISTRY[key] = _I18nRegistryEntry(context="service", value="") + added += 1 _bootstrapRoleDescriptions = [ "Administrator - Benutzer und Ressourcen im Mandanten verwalten", @@ -288,38 +288,28 @@ def _registerNodeLabels(): logger.info("i18n node labels: %d new keys (node.*/port.* context)", added) -def _registerAccountingConnectorLabels(): - """Register all accounting connector configField labels at boot time.""" +def _registerAccountingConnectorLabels(accountingLabels: list = None): + """Register accounting connector configField labels at boot time. + + Args: + accountingLabels: List of dicts with keys 'label' and 'connectorType', + injected from app.py to avoid features-import. + """ + if not accountingLabels: + return + added = 0 - try: - from modules.features.trustee.accounting.accountingRegistry import getAccountingRegistry - except ImportError: - logger.debug("i18n accounting connectors: registry not importable") - return - - try: - registry = getAccountingRegistry() - except Exception as e: - logger.warning("i18n accounting connectors: registry init failed: %s", e) - return - - for connectorType, connector in (registry._connectors or {}).items(): - try: - for field in connector.getRequiredConfigFields(): - key = getattr(field, "label", "") or "" - if not isinstance(key, str) or not key: - continue - if key not in _REGISTRY: - _REGISTRY[key] = _I18nRegistryEntry( - context=f"connector.accounting.{connectorType}", - value="", - ) - added += 1 - except Exception as e: - logger.warning( - "i18n accounting connector %s: failed to read fields: %s", - connectorType, e, + for entry in accountingLabels: + key = entry.get("label", "") + connectorType = entry.get("connectorType", "unknown") + if not isinstance(key, str) or not key: + continue + if key not in _REGISTRY: + _REGISTRY[key] = _I18nRegistryEntry( + context=f"connector.accounting.{connectorType}", + value="", ) + added += 1 logger.info("i18n accounting connector labels: %d new keys", added) @@ -385,16 +375,21 @@ def _registerDatamodelOptionLabels(): # Public boot API (called by app.py) # --------------------------------------------------------------------------- -async def syncRegistryToDb(): - """Boot hook: discover all i18n keys and write them into UiLanguageSet(xx).""" +async def syncRegistryToDb(serviceLabels: list = None, accountingLabels: list = None): + """Boot hook: discover all i18n keys and write them into UiLanguageSet(xx). + + Args: + serviceLabels: Service label strings injected from app.py (avoids upward import). + accountingLabels: Accounting connector field labels injected from app.py. + """ _scanRouteApiMsgKeys() _registerNavLabels() _registerFeatureUiLabels() _registerRbacLabels() - _registerServiceCenterLabels() + _registerServiceCenterLabels(serviceLabels) _registerNodeLabels() _registerDatamodelOptionLabels() - _registerAccountingConnectorLabels() + _registerAccountingConnectorLabels(accountingLabels) if not _REGISTRY: logger.info("i18n registry: no keys to sync (empty registry)") diff --git a/modules/system/mainSystem.py b/modules/system/mainSystem.py index 21d0cbee..bbdffbbd 100644 --- a/modules/system/mainSystem.py +++ b/modules/system/mainSystem.py @@ -20,351 +20,7 @@ FEATURE_CODE = "system" FEATURE_LABEL = "System" FEATURE_ICON = "mdi-cog" -# ============================================================================= -# Navigation Structure (Single Source of Truth) -# ============================================================================= -# -# Block Order (gemäss Navigation-API-Konzept): -# - System: 10 -# - : 15 (wird in routeSystem.py eingefügt) -# - Basisdaten: 30 -# - Administration: 200 -# -# NOTE: Workflows and Migrate sections removed - now handled as features -# -# Item Order: Default-Abstand 10 pro Item -# uiComponent: Abgeleitet von objectKey (ui.system.home -> page.system.home) -# icon: Wird intern gehalten aber NICHT in der API Response zurückgegeben - -NAVIGATION_SECTIONS = [ - # ─── Meine Sicht (with top-level item + subgroups) ─── - { - "id": "system", - "title": t("Meine Sicht"), - "order": 10, - "items": [ - { - "id": "home", - "objectKey": "ui.system.home", - "label": t("Start"), - "icon": "FaHome", - "path": "/", - "order": 10, - "public": True, - }, - ], - "subgroups": [ - # ── Übersichten ── - { - "id": "system-overviews", - "title": t("Übersichten"), - "order": 15, - "items": [ - { - "id": "integrations", - "objectKey": "ui.system.integrations", - "label": t("Integrationen"), - "icon": "FaProjectDiagram", - "path": "/integrations", - "order": 10, - "public": True, - }, - { - "id": "compliance-audit", - "objectKey": "ui.system.complianceAudit", - "label": t("Compliance & Audit"), - "icon": "FaShieldAlt", - "path": "/compliance-audit", - "order": 20, - }, - ], - }, - # ── Basisdaten ── - { - "id": "system-basedata", - "title": t("Basisdaten"), - "order": 20, - "items": [ - { - "id": "connections", - "objectKey": "ui.system.connections", - "label": t("Verbindungen"), - "icon": "FaLink", - "path": "/basedata/connections", - "order": 10, - "public": True, - }, - { - "id": "files", - "objectKey": "ui.system.files", - "label": t("Dateien"), - "icon": "FaRegFileAlt", - "path": "/basedata/files", - "order": 20, - "public": True, - }, - { - "id": "prompts", - "objectKey": "ui.system.prompts", - "label": t("Prompts"), - "icon": "FaLightbulb", - "path": "/basedata/prompts", - "order": 30, - "public": True, - }, - ], - }, - # ── Nutzung ── - { - "id": "system-usage", - "title": t("Nutzung"), - "order": 30, - "items": [ - { - "id": "billing-admin", - "objectKey": "ui.system.billingAdmin", - "label": t("Abrechnung"), - "icon": "FaMoneyBillAlt", - "path": "/billing/admin", - "order": 10, - }, - { - "id": "statistics", - "objectKey": "ui.system.statistics", - "label": t("Statistiken"), - "icon": "FaChartBar", - "path": "/billing/transactions", - "order": 20, - }, - { - "id": "automations", - "objectKey": "ui.system.automations", - "label": t("Automations"), - "icon": "FaRobot", - "path": "/automations", - "order": 30, - }, - { - "id": "rag-inventory", - "objectKey": "ui.system.ragInventory", - "label": t("RAG-Inventar"), - "icon": "FaDatabase", - "path": "/rag-inventory", - "order": 35, - }, - { - "id": "store", - "objectKey": "ui.system.store", - "label": t("Store"), - "icon": "FaStore", - "path": "/store", - "order": 40, - "public": True, - }, - { - "id": "settings", - "objectKey": "ui.system.settings", - "label": t("Einstellungen"), - "icon": "FaCog", - "path": "/settings", - "order": 50, - "public": True, - }, - ], - }, - ], - }, - # ─── Administration (with subgroups) ─── - { - "id": "admin", - "title": t("Administration"), - "order": 200, - "subgroups": [ - # ── Wizards ── - { - "id": "admin-wizards", - "title": t("Wizards"), - "order": 10, - "items": [ - { - "id": "admin-mandate-wizard", - "objectKey": "ui.admin.mandateWizard", - "label": t("Mandanten-Wizard"), - "icon": "FaMagic", - "path": "/admin/mandate-wizard", - "order": 10, - "adminOnly": True, - }, - { - "id": "admin-invitation-wizard", - "objectKey": "ui.admin.invitationWizard", - "label": t("Einladungs-Wizard"), - "icon": "FaEnvelopeOpenText", - "path": "/admin/invitation-wizard", - "order": 20, - "adminOnly": True, - }, - ], - }, - # ── Users ── - { - "id": "admin-users-group", - "title": t("Benutzer"), - "order": 20, - "items": [ - { - "id": "admin-users", - "objectKey": "ui.admin.users", - "label": t("Benutzer"), - "icon": "FaUsers", - "path": "/admin/users", - "order": 10, - "adminOnly": True, - }, - { - "id": "admin-invitations", - "objectKey": "ui.admin.invitations", - "label": t("Benutzer-Einladungen"), - "icon": "FaEnvelopeOpenText", - "path": "/admin/invitations", - "order": 20, - "adminOnly": True, - }, - { - "id": "admin-user-access-overview", - "objectKey": "ui.admin.userAccessOverview", - "label": t("Benutzer-Zugriffsübersicht"), - "icon": "FaClipboardList", - "path": "/admin/user-access-overview", - "order": 30, - "adminOnly": True, - }, - { - "id": "admin-subscriptions", - "objectKey": "ui.admin.subscriptions", - "label": t("Abonnements"), - "icon": "FaFileContract", - "path": "/admin/subscriptions", - "order": 40, - "adminOnly": True, - }, - ], - }, - # ── System ── - { - "id": "admin-system-group", - "title": t("System"), - "order": 30, - "items": [ - { - "id": "admin-roles", - "objectKey": "ui.admin.roles", - "label": t("Rollen"), - "icon": "FaUserTag", - "path": "/admin/mandate-roles", - "order": 10, - "adminOnly": True, - }, - { - "id": "admin-mandate-role-permissions", - "objectKey": "ui.admin.mandateRolePermissions", - "label": t("Rollen-Berechtigungen"), - "icon": "FaKey", - "path": "/admin/mandate-role-permissions", - "order": 20, - "adminOnly": True, - }, - { - "id": "admin-mandates", - "objectKey": "ui.admin.mandates", - "label": t("Mandanten"), - "icon": "FaBuilding", - "path": "/admin/mandates", - "order": 30, - "adminOnly": True, - }, - { - "id": "admin-user-mandates", - "objectKey": "ui.admin.userMandates", - "label": t("Mandanten-Mitglieder"), - "icon": "FaUserFriends", - "path": "/admin/user-mandates", - "order": 40, - "adminOnly": True, - }, - { - "id": "admin-access", - "objectKey": "ui.admin.access", - "label": t("Zugriffsverwaltung"), - "icon": "FaBuilding", - "path": "/admin/access", - "order": 50, - "adminOnly": True, - }, - { - "id": "admin-feature-instances", - "objectKey": "ui.admin.featureInstances", - "label": t("Feature-Instanzen"), - "icon": "FaCubes", - "path": "/admin/feature-instances", - "order": 60, - "adminOnly": True, - }, - { - "id": "admin-feature-roles", - "objectKey": "ui.admin.featureRoles", - "label": t("Features Rollen-Vorlagen"), - "icon": "FaShieldAlt", - "path": "/admin/feature-roles", - "order": 70, - "adminOnly": True, - "sysAdminOnly": True, - }, - { - "id": "admin-logs", - "objectKey": "ui.admin.logs", - "label": t("Logs"), - "icon": "FaFileAlt", - "path": "/admin/logs", - "order": 90, - "adminOnly": True, - "sysAdminOnly": True, - }, - { - "id": "admin-languages", - "objectKey": "ui.admin.languages", - "label": t("UI-Sprachen"), - "icon": "FaGlobe", - "path": "/admin/languages", - "order": 95, - "adminOnly": True, - "sysAdminOnly": True, - }, - { - "id": "admin-database-health", - "objectKey": "ui.admin.databaseHealth", - "label": t("Datenbank-Gesundheit"), - "icon": "FaDatabase", - "path": "/admin/database-health", - "order": 98, - "adminOnly": True, - "sysAdminOnly": True, - }, - { - "id": "admin-demo-config", - "objectKey": "ui.admin.demoConfig", - "label": t("Demo Config"), - "icon": "FaCubes", - "path": "/admin/demo-config", - "order": 100, - "adminOnly": True, - "sysAdminOnly": True, - }, - ], - }, - ], - }, -] +from modules.datamodels.datamodelNavigation import NAVIGATION_SECTIONS # noqa: F401 — canonical source def objectKeyToUiComponent(objectKey: str) -> str: diff --git a/modules/system/registry.py b/modules/system/registry.py index 67f3d28b..1e2dffb4 100644 --- a/modules/system/registry.py +++ b/modules/system/registry.py @@ -89,45 +89,7 @@ def loadFeatureRouters(app: FastAPI) -> Dict[str, Any]: return results -_cachedMainModules = None - -def loadFeatureMainModules() -> Dict[str, Any]: - """ - Dynamically load main modules from all discovered feature containers. - Results are cached after the first call. - """ - global _cachedMainModules - if _cachedMainModules is not None: - return _cachedMainModules - - mainModules = {} - pattern = os.path.join(FEATURES_DIR, "*", "main*.py") - - for filepath in glob.glob(pattern): - filename = os.path.basename(filepath) - if filename == "__init__.py": - continue - - featureDir = os.path.basename(os.path.dirname(filepath)) - if featureDir.startswith("_"): - continue - - # Skip if this feature already has a main module loaded (avoid duplicates) - if featureDir in mainModules: - continue - - mainFile = filename[:-3] # Remove .py - - try: - modulePath = f"modules.features.{featureDir}.{mainFile}" - module = importlib.import_module(modulePath) - mainModules[featureDir] = module - logger.debug(f"Loaded main module: {featureDir}") - except Exception as e: - logger.error(f"Failed to load main module from {featureDir}: {e}") - - _cachedMainModules = mainModules - return mainModules +from modules.shared.featureDiscovery import loadFeatureMainModules # noqa: F401 — re-export def registerAllFeaturesInCatalog(catalogService) -> Dict[str, bool]: @@ -150,16 +112,8 @@ def registerAllFeaturesInCatalog(catalogService) -> Dict[str, bool]: logger.error(f"Error registering system RBAC objects: {e}") results["system"] = False - # Register service center RBAC objects (service.web, service.extraction, etc.) - try: - from modules.serviceCenter import registerServiceObjects - success = registerServiceObjects(catalogService) - results["servicecenter"] = success - except ImportError as e: - logger.warning(f"Service center not found, skipping service RBAC registration: {e}") - except Exception as e: - logger.error(f"Error registering service RBAC objects: {e}") - results["servicecenter"] = False + # Service center RBAC objects are registered by app.py (Composition Root) + # to avoid system(L4) → serviceCenter(L5) upward import. # Register feature modules mainModules = loadFeatureMainModules() diff --git a/modules/workflows/automation2/executionEngine.py b/modules/workflows/automation2/executionEngine.py index f8d95f1c..d5fdbd0e 100644 --- a/modules/workflows/automation2/executionEngine.py +++ b/modules/workflows/automation2/executionEngine.py @@ -31,7 +31,7 @@ from modules.workflows.automation2.executors import ( ) from modules.features.graphicalEditor.portTypes import normalizeToSchema, wrapTransit, unwrapTransit from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES -from modules.shared.serviceExceptions import SubscriptionInactiveException as _SubscriptionInactiveException, BillingContextError as _BillingContextError +from modules.datamodels.serviceExceptions import SubscriptionInactiveException as _SubscriptionInactiveException, BillingContextError as _BillingContextError from modules.workflows.automation2.graphicalEditorRunFileLogger import ( GraphicalEditorRunFileLogger, graphical_editor_run_file_logging_enabled, @@ -252,7 +252,7 @@ def _merge_node_parameters_into_snap( def _emitStepEvent(runId: str, stepData: Dict[str, Any]) -> None: """Emit a step-log SSE event to any listening client for this run.""" try: - from modules.serviceCenter.core.serviceStreaming.eventManager import get_event_manager + from modules.shared.eventManager import get_event_manager em = get_event_manager() queueId = f"run-trace-{runId}" if not em.has_queue(queueId): diff --git a/modules/workflows/automation2/executors/actionNodeExecutor.py b/modules/workflows/automation2/executors/actionNodeExecutor.py index 20fed58a..9af626d4 100644 --- a/modules/workflows/automation2/executors/actionNodeExecutor.py +++ b/modules/workflows/automation2/executors/actionNodeExecutor.py @@ -19,7 +19,7 @@ from modules.features.graphicalEditor.portTypes import ( _normalizeError, normalizeToSchema, ) -from modules.shared.serviceExceptions import SubscriptionInactiveException as _SubscriptionInactiveException, BillingContextError as _BillingContextError +from modules.datamodels.serviceExceptions import SubscriptionInactiveException as _SubscriptionInactiveException, BillingContextError as _BillingContextError from modules.workflows.automation2.executors.inputExecutor import PauseForHumanTaskError from modules.workflows.methods.methodContext.actions.extractContent import ( PRESENTATION_KIND, diff --git a/modules/workflows/methods/methodAi/actions/consolidate.py b/modules/workflows/methods/methodAi/actions/consolidate.py index 0dced074..70d345cd 100644 --- a/modules/workflows/methods/methodAi/actions/consolidate.py +++ b/modules/workflows/methods/methodAi/actions/consolidate.py @@ -7,7 +7,7 @@ from typing import Any, Dict, List from modules.datamodels.datamodelAi import AiCallOptions, AiCallRequest, OperationTypeEnum from modules.datamodels.datamodelChat import ActionResult -from modules.shared.serviceExceptions import SubscriptionInactiveException, BillingContextError +from modules.datamodels.serviceExceptions import SubscriptionInactiveException, BillingContextError logger = logging.getLogger(__name__) diff --git a/modules/workflows/methods/methodAi/actions/generateCode.py b/modules/workflows/methods/methodAi/actions/generateCode.py index 66a1d0bf..7a13e4a1 100644 --- a/modules/workflows/methods/methodAi/actions/generateCode.py +++ b/modules/workflows/methods/methodAi/actions/generateCode.py @@ -9,7 +9,7 @@ from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelExtraction import ContentPart from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum from modules.datamodels.datamodelWorkflow import AiResponse, DocumentData -from modules.shared.serviceExceptions import SubscriptionInactiveException, BillingContextError +from modules.datamodels.serviceExceptions import SubscriptionInactiveException, BillingContextError logger = logging.getLogger(__name__) diff --git a/modules/workflows/methods/methodAi/actions/generateDocument.py b/modules/workflows/methods/methodAi/actions/generateDocument.py index 2006ba96..20b82042 100644 --- a/modules/workflows/methods/methodAi/actions/generateDocument.py +++ b/modules/workflows/methods/methodAi/actions/generateDocument.py @@ -9,7 +9,7 @@ from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelExtraction import ContentPart from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum from modules.datamodels.datamodelWorkflow import AiResponse, DocumentData -from modules.shared.serviceExceptions import SubscriptionInactiveException, BillingContextError +from modules.datamodels.serviceExceptions import SubscriptionInactiveException, BillingContextError logger = logging.getLogger(__name__) diff --git a/modules/workflows/methods/methodAi/actions/process.py b/modules/workflows/methods/methodAi/actions/process.py index 47774eb1..04046f39 100644 --- a/modules/workflows/methods/methodAi/actions/process.py +++ b/modules/workflows/methods/methodAi/actions/process.py @@ -10,7 +10,7 @@ from typing import Dict, Any, List, Optional from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, ProcessingModeEnum from modules.datamodels.datamodelExtraction import ContentPart -from modules.shared.serviceExceptions import SubscriptionInactiveException, BillingContextError +from modules.datamodels.serviceExceptions import SubscriptionInactiveException, BillingContextError logger = logging.getLogger(__name__) diff --git a/modules/workflows/methods/methodAi/actions/webResearch.py b/modules/workflows/methods/methodAi/actions/webResearch.py index 0dfdeeab..778faf11 100644 --- a/modules/workflows/methods/methodAi/actions/webResearch.py +++ b/modules/workflows/methods/methodAi/actions/webResearch.py @@ -7,8 +7,7 @@ import re import json from typing import Dict, Any from modules.datamodels.datamodelChat import ActionResult, ActionDocument -from modules.serviceCenter import ServiceCenterContext, getService, can_access_service -from modules.shared.serviceExceptions import SubscriptionInactiveException, BillingContextError +from modules.datamodels.serviceExceptions import SubscriptionInactiveException, BillingContextError logger = logging.getLogger(__name__) @@ -45,6 +44,8 @@ def _build_research_prompt(parameters: Dict[str, Any]) -> str: async def webResearch(self, parameters: Dict[str, Any]) -> ActionResult: + from modules.serviceCenter import ServiceCenterContext, getService, can_access_service + operationId = None try: prompt = _build_research_prompt(parameters) diff --git a/modules/workflows/methods/methodContext/actions/extractContent.py b/modules/workflows/methods/methodContext/actions/extractContent.py index 44309888..2c1a2f9c 100644 --- a/modules/workflows/methods/methodContext/actions/extractContent.py +++ b/modules/workflows/methods/methodContext/actions/extractContent.py @@ -1399,7 +1399,7 @@ def _load_image_bytes_by_file_id(services: Any, file_id: str) -> Optional[bytes] def _inline_runs_from_presentation_lines(lines: List[Any]) -> List[Dict[str, Any]]: """Map presentation ``lines`` to inline runs, preserving line order with explicit breaks.""" - from modules.serviceCenter.services.serviceGeneration.subDocumentUtility import parseInlineRuns + from modules.shared.documentUtils import parseInlineRuns runs: List[Dict[str, Any]] = [] first = True @@ -1537,7 +1537,7 @@ def presentation_envelopes_to_document_json( services: Any = None, ) -> Dict[str, Any]: """Map presentation envelope(s) to ``renderReport`` ``extractedContent`` (documents/sections).""" - from modules.serviceCenter.services.serviceGeneration.subDocumentUtility import parseInlineRuns + from modules.shared.documentUtils import parseInlineRuns envelopes = normalize_presentation_envelopes(raw) if not envelopes: diff --git a/modules/workflows/processing/shared/methodDiscovery.py b/modules/workflows/processing/shared/methodDiscovery.py index 213dee83..d778ba39 100644 --- a/modules/workflows/processing/shared/methodDiscovery.py +++ b/modules/workflows/processing/shared/methodDiscovery.py @@ -34,57 +34,78 @@ def _collectActionsUnfiltered(methodInstance) -> Dict[str, Dict[str, Any]]: return result +def _registerMethodClasses(serviceCenter, modulePath: str, uniqueCount: int) -> int: + """Import a method module and register all MethodBase subclasses found in it.""" + try: + module = importlib.import_module(modulePath) + except Exception as e: + logger.error(f"Error importing method module {modulePath}: {e}") + return uniqueCount + + for itemName, item in inspect.getmembers(module): + if (inspect.isclass(item) and + issubclass(item, MethodBase) and + item != MethodBase): + + if itemName in methods: + continue + + shortName = itemName.replace('Method', '').lower() + methodInstance = item(serviceCenter) + actions = _collectActionsUnfiltered(methodInstance) + + methodInfo = { + 'instance': methodInstance, + 'actions': actions, + 'description': item.__doc__ or f"Method {itemName}" + } + + methods[itemName] = methodInfo + methods[shortName] = methodInfo + uniqueCount += 1 + logger.info(f"Discovered method {itemName} (short: {shortName}) with {len(actions)} actions") + + return uniqueCount + + def discoverMethods(serviceCenter): - """Dynamically discover all method classes and their actions in modules methods package. - - Always creates fresh method instances bound to the given serviceCenter, - preventing stale or cross-workflow service references. + """Dynamically discover all method classes and their actions. + + Scans two locations: + 1. modules.workflows.methods (core methods) + 2. modules.features.*/workflows/ (feature-owned methods) """ global methods try: methodsPackage = importlib.import_module('modules.workflows.methods') - - # Clear and rebuild to prevent cross-workflow state contamination + methods.clear() uniqueCount = 0 - + for _, name, isPkg in pkgutil.iter_modules(methodsPackage.__path__): if name.startswith('method'): - try: - module = importlib.import_module(f'modules.workflows.methods.{name}') - - for itemName, item in inspect.getmembers(module): - if (inspect.isclass(item) and - issubclass(item, MethodBase) and - item != MethodBase): - - shortName = itemName.replace('Method', '').lower() - - # Skip if already processed (via another module path) - if itemName in methods: - continue - - methodInstance = item(serviceCenter) - actions = _collectActionsUnfiltered(methodInstance) - - methodInfo = { - 'instance': methodInstance, - 'actions': actions, - 'description': item.__doc__ or f"Method {itemName}" - } - - methods[itemName] = methodInfo - methods[shortName] = methodInfo - uniqueCount += 1 - - logger.info(f"Discovered method {itemName} (short: {shortName}) with {len(actions)} actions") - - except Exception as e: - logger.error(f"Error discovering method {name}: {str(e)}") - continue - + uniqueCount = _registerMethodClasses( + serviceCenter, f'modules.workflows.methods.{name}', uniqueCount + ) + + # Feature-owned methods (e.g. features/trustee/workflows/methodTrustee) + import os + import glob as _glob + featuresDir = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + "features" + ) + for wfInit in _glob.glob(os.path.join(featuresDir, "*", "workflows", "__init__.py")): + wfDir = os.path.dirname(wfInit) + featureName = os.path.basename(os.path.dirname(wfDir)) + for entry in os.listdir(wfDir): + entryPath = os.path.join(wfDir, entry) + if os.path.isdir(entryPath) and entry.startswith("method"): + modulePath = f"modules.features.{featureName}.workflows.{entry}" + uniqueCount = _registerMethodClasses(serviceCenter, modulePath, uniqueCount) + logger.info(f"Discovered {uniqueCount} unique methods ({len(methods)} entries with aliases)") - + except Exception as e: logger.error(f"Error discovering methods: {str(e)}") diff --git a/modules/workflows/scheduler/mainScheduler.py b/modules/workflows/scheduler/mainScheduler.py index ef89f821..9af9889f 100644 --- a/modules/workflows/scheduler/mainScheduler.py +++ b/modules/workflows/scheduler/mainScheduler.py @@ -396,48 +396,29 @@ def _createRunFailedNotification( logger.warning("Failed to create in-app run.failed notification: %s", e) +_onRunFailedCallback = None + + +def setOnRunFailedCallback(callback) -> None: + """Set the callback for run failure notifications (injected by app.py).""" + global _onRunFailedCallback + _onRunFailedCallback = callback + + def _triggerRunFailedSubscription( workflowId: str, runId: str, error: str, mandateId: str = None, workflowLabel: str = None ) -> None: - """Trigger the messaging subscription for run failures (email notifications).""" + """Trigger the messaging subscription for run failures via injected callback.""" + if _onRunFailedCallback is None: + return try: - 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() - if not rootInterface: - return - eventUser = rootInterface.getUserByUsername("event") - if not eventUser: - return - - ctx = ServiceCenterContext( - user=eventUser, - mandate_id=mandateId or "", - feature_instance_id="", - feature_code="graphicalEditor", + _onRunFailedCallback( + workflowId=workflowId, + runId=runId, + error=error, + mandateId=mandateId, + workflowLabel=workflowLabel, ) - messagingService = getService("messaging", ctx) - - subscriptionId = "GraphicalEditorRunFailed" - eventParams = MessagingEventParameters(triggerData={ - "workflowId": workflowId, - "workflowLabel": workflowLabel or workflowId, - "runId": runId, - "error": error, - "mandateId": mandateId or "", - }) - result = messagingService.executeSubscription(subscriptionId, eventParams) - logger.info( - "Triggered run.failed subscription: sent=%d success=%s", - result.messagesSent, result.success, - ) - except FileNotFoundError: - logger.debug("Subscription function GraphicalEditorRunFailed not found (not yet registered)") - except ValueError as e: - logger.debug("Subscription GraphicalEditorRunFailed: %s", e) except Exception as e: logger.warning("Failed to trigger run.failed subscription: %s", e) From 877f859f6b27eb44b077e3a34540f42929817086 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sun, 7 Jun 2026 08:25:43 +0200 Subject: [PATCH 03/16] fix(tests): align test imports with refactored module paths Fix broken test imports after architecture refactoring: - mfaService: _buildTotp -> buildTotp, _decryptSecret -> decryptSecret - _actionSignatureValidator: _validateTypeRef -> validateTypeRef - fkRegistry: modules.shared -> modules.dbHelpers - costEstimate/ragLimits: _costEstimate -> costEstimate, _ragLimits -> ragLimits - udbNodes: _isFeatureAdmin -> isFeatureAdmin - inheritFlags: _normalisePath -> normalisePath - methodTrustee: old workflow path -> features/trustee/workflows - methodDiscovery: fix featuresDir path calculation (4 dirname levels) - mainGraphicalEditor: wrap template labels with t() for i18n Co-authored-by: Cursor --- .../graphicalEditor/mainGraphicalEditor.py | 4 ++-- .../processing/shared/methodDiscovery.py | 2 +- .../trustee/test_spesenbelege_workflow_e2e.py | 2 +- tests/unit/auth/test_mfaService.py | 20 +++++++++---------- .../graphicalEditor/test_adapter_validator.py | 2 +- .../test_action_signature_validator.py | 6 +++--- tests/unit/services/test_costEstimate.py | 2 +- .../services/test_featureDataAgent_schema.py | 2 +- tests/unit/services/test_inheritFlags.py | 10 +++++----- tests/unit/services/test_queryValidator.py | 2 +- tests/unit/services/test_ragLimits.py | 2 +- tests/unit/services/test_trusteeOntology.py | 2 +- tests/unit/services/test_udbNodes.py | 10 +++++----- tests/unit/workflow/test_trusteeQueryData.py | 2 +- 14 files changed, 34 insertions(+), 34 deletions(-) diff --git a/modules/features/graphicalEditor/mainGraphicalEditor.py b/modules/features/graphicalEditor/mainGraphicalEditor.py index 44cb890e..bf50abb2 100644 --- a/modules/features/graphicalEditor/mainGraphicalEditor.py +++ b/modules/features/graphicalEditor/mainGraphicalEditor.py @@ -413,7 +413,7 @@ def _buildSystemTemplates(): """Build the graph definitions for platform system templates.""" return [ { - "label": "Personal Assistant: E-Mail-Antwort-Drafting", + "label": t("Personal Assistant: E-Mail-Antwort-Drafting"), "mandateId": None, "featureInstanceId": None, "isTemplate": True, @@ -442,7 +442,7 @@ def _buildSystemTemplates(): "invocations": [{"type": "schedule", "cronExpression": "0 8 * * 1-5"}], }, { - "label": "Treuhand: PDF-Klassifizierung & Trustee-Import", + "label": t("Treuhand: PDF-Klassifizierung & Trustee-Import"), "mandateId": None, "featureInstanceId": None, "isTemplate": True, diff --git a/modules/workflows/processing/shared/methodDiscovery.py b/modules/workflows/processing/shared/methodDiscovery.py index d778ba39..9271585c 100644 --- a/modules/workflows/processing/shared/methodDiscovery.py +++ b/modules/workflows/processing/shared/methodDiscovery.py @@ -92,7 +92,7 @@ def discoverMethods(serviceCenter): import os import glob as _glob featuresDir = os.path.join( - os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))), "features" ) for wfInit in _glob.glob(os.path.join(featuresDir, "*", "workflows", "__init__.py")): diff --git a/tests/integration/trustee/test_spesenbelege_workflow_e2e.py b/tests/integration/trustee/test_spesenbelege_workflow_e2e.py index 171eff4d..fcda01e4 100644 --- a/tests/integration/trustee/test_spesenbelege_workflow_e2e.py +++ b/tests/integration/trustee/test_spesenbelege_workflow_e2e.py @@ -178,7 +178,7 @@ def resetAccountingBridgeCalls(): def patchTrustee(monkeypatch, trusteeInterface): """Patches ``getInterface`` + ``AccountingBridge`` in both action modules so the real action code runs against the in-memory fakes.""" - from modules.workflows.methods.methodTrustee.actions import ( + from modules.features.trustee.workflows.methodTrustee.actions import ( processDocuments as _procMod, syncToAccounting as _syncMod, ) diff --git a/tests/unit/auth/test_mfaService.py b/tests/unit/auth/test_mfaService.py index b6f3a1e3..5010ceef 100644 --- a/tests/unit/auth/test_mfaService.py +++ b/tests/unit/auth/test_mfaService.py @@ -13,7 +13,7 @@ import pyotp from modules.auth.mfaService import ( _generateSecret, - _buildTotp, + buildTotp, generateSetup, confirmSetup, verifyCode, @@ -28,27 +28,27 @@ class TestTotpBasics: assert isinstance(secret, str) assert len(secret) >= 16 - def test_buildTotp_generates_valid_code(self): + def testbuildTotp_generates_valid_code(self): secret = _generateSecret() - totp = _buildTotp(secret) + totp = buildTotp(secret) code = totp.now() assert len(code) == 6 assert code.isdigit() def test_verifyCode_accepts_current_code(self): secret = _generateSecret() - totp = _buildTotp(secret) + totp = buildTotp(secret) code = totp.now() encrypted = f"FAKE_ENC:{secret}" - with patch("modules.auth.mfaService._decryptSecret", return_value=secret): + with patch("modules.auth.mfaService.decryptSecret", return_value=secret): assert verifyCode(encrypted, code) is True def test_verifyCode_rejects_wrong_code(self): secret = _generateSecret() encrypted = f"FAKE_ENC:{secret}" - with patch("modules.auth.mfaService._decryptSecret", return_value=secret): + with patch("modules.auth.mfaService.decryptSecret", return_value=secret): assert verifyCode(encrypted, "000000") is False @@ -66,19 +66,19 @@ class TestGenerateSetup: class TestConfirmSetup: def test_confirmSetup_with_valid_code(self): secret = _generateSecret() - totp = _buildTotp(secret) + totp = buildTotp(secret) code = totp.now() - with patch("modules.auth.mfaService._decryptSecret", return_value=secret): + with patch("modules.auth.mfaService.decryptSecret", return_value=secret): assert confirmSetup("ENC", code) is True def test_confirmSetup_with_invalid_code(self): secret = _generateSecret() - with patch("modules.auth.mfaService._decryptSecret", return_value=secret): + with patch("modules.auth.mfaService.decryptSecret", return_value=secret): assert confirmSetup("ENC", "999999") is False def test_confirmSetup_handles_decryption_error(self): - with patch("modules.auth.mfaService._decryptSecret", side_effect=Exception("decrypt error")): + with patch("modules.auth.mfaService.decryptSecret", side_effect=Exception("decrypt error")): assert confirmSetup("BAD_ENC", "123456") is False diff --git a/tests/unit/graphicalEditor/test_adapter_validator.py b/tests/unit/graphicalEditor/test_adapter_validator.py index 5f8091fd..5ee5abef 100644 --- a/tests/unit/graphicalEditor/test_adapter_validator.py +++ b/tests/unit/graphicalEditor/test_adapter_validator.py @@ -253,7 +253,7 @@ def _ensureOptionalDeps(): _LIVE_METHODS = [ - ("modules.workflows.methods.methodTrustee.methodTrustee", "MethodTrustee", "trustee"), + ("modules.features.trustee.workflows.methodTrustee.methodTrustee", "MethodTrustee", "trustee"), ("modules.workflows.methods.methodRedmine.methodRedmine", "MethodRedmine", "redmine"), ("modules.workflows.methods.methodSharepoint.methodSharepoint", "MethodSharepoint", "sharepoint"), ("modules.workflows.methods.methodOutlook.methodOutlook", "MethodOutlook", "outlook"), diff --git a/tests/unit/methods/test_action_signature_validator.py b/tests/unit/methods/test_action_signature_validator.py index 5607117a..7afd4597 100644 --- a/tests/unit/methods/test_action_signature_validator.py +++ b/tests/unit/methods/test_action_signature_validator.py @@ -25,7 +25,7 @@ from modules.workflows.methods._actionSignatureValidator import ( _validateActionParameter, _validateActionsDict, _validateMethods, - _validateTypeRef, + validateTypeRef, ) @@ -75,7 +75,7 @@ class TestValidateTypeRef: "List[FeatureInstanceRef]", ]) def test_validTypes(self, t): - assert _validateTypeRef(t) == [] + assert validateTypeRef(t) == [] @pytest.mark.parametrize("t", [ "list", # too generic @@ -86,7 +86,7 @@ class TestValidateTypeRef: "", # empty ]) def test_invalidTypes(self, t): - errors = _validateTypeRef(t) + errors = validateTypeRef(t) assert errors, f"expected validation errors for {t!r}" diff --git a/tests/unit/services/test_costEstimate.py b/tests/unit/services/test_costEstimate.py index 00fbb6b6..a8e25138 100644 --- a/tests/unit/services/test_costEstimate.py +++ b/tests/unit/services/test_costEstimate.py @@ -8,7 +8,7 @@ from __future__ import annotations import unittest -from modules.serviceCenter.services.serviceKnowledge import _costEstimate +from modules.serviceCenter.services.serviceKnowledge import costEstimate as _costEstimate class TestCostEstimate(unittest.TestCase): diff --git a/tests/unit/services/test_featureDataAgent_schema.py b/tests/unit/services/test_featureDataAgent_schema.py index 0e852c70..2b70532d 100644 --- a/tests/unit/services/test_featureDataAgent_schema.py +++ b/tests/unit/services/test_featureDataAgent_schema.py @@ -24,7 +24,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from modules.shared import fkRegistry +from modules.dbHelpers import fkRegistry from modules.serviceCenter.services.serviceAgent.datamodelAgent import ( ToolCallRequest, ToolResult, ) diff --git a/tests/unit/services/test_inheritFlags.py b/tests/unit/services/test_inheritFlags.py index 3b9ce395..a74f1f7f 100644 --- a/tests/unit/services/test_inheritFlags.py +++ b/tests/unit/services/test_inheritFlags.py @@ -626,15 +626,15 @@ class TestCascadeResetFdsRag(unittest.TestCase): class TestPathNormalization(unittest.TestCase): def test_empty_path_normalises_to_root(self): - self.assertEqual(_inheritFlags._normalisePath(""), "/") - self.assertEqual(_inheritFlags._normalisePath(None), "/") + self.assertEqual(_inheritFlags.normalisePath(""), "/") + self.assertEqual(_inheritFlags.normalisePath(None), "/") def test_trailing_slash_stripped(self): - self.assertEqual(_inheritFlags._normalisePath("/foo/"), "/foo") - self.assertEqual(_inheritFlags._normalisePath("/"), "/") + self.assertEqual(_inheritFlags.normalisePath("/foo/"), "/foo") + self.assertEqual(_inheritFlags.normalisePath("/"), "/") def test_leading_slash_added(self): - self.assertEqual(_inheritFlags._normalisePath("foo/bar"), "/foo/bar") + self.assertEqual(_inheritFlags.normalisePath("foo/bar"), "/foo/bar") # =========================================================================== diff --git a/tests/unit/services/test_queryValidator.py b/tests/unit/services/test_queryValidator.py index ed3235f0..0fb0b4a4 100644 --- a/tests/unit/services/test_queryValidator.py +++ b/tests/unit/services/test_queryValidator.py @@ -15,7 +15,7 @@ from __future__ import annotations import pytest -from modules.shared import fkRegistry +from modules.dbHelpers import fkRegistry from modules.serviceCenter.services.serviceAgent.datamodelOntology import ( Constraint, ConstraintRule, diff --git a/tests/unit/services/test_ragLimits.py b/tests/unit/services/test_ragLimits.py index bb336ed3..1ab5c403 100644 --- a/tests/unit/services/test_ragLimits.py +++ b/tests/unit/services/test_ragLimits.py @@ -11,7 +11,7 @@ from __future__ import annotations import unittest -from modules.serviceCenter.services.serviceKnowledge import _ragLimits +from modules.serviceCenter.services.serviceKnowledge import ragLimits as _ragLimits class TestGetDefaults(unittest.TestCase): diff --git a/tests/unit/services/test_trusteeOntology.py b/tests/unit/services/test_trusteeOntology.py index c9945ea4..89d714c6 100644 --- a/tests/unit/services/test_trusteeOntology.py +++ b/tests/unit/services/test_trusteeOntology.py @@ -35,7 +35,7 @@ from modules.serviceCenter.services.serviceAgent.ontologyToPromptCompiler import compileOntologyToPrompt, ) from modules.serviceCenter.services.serviceAgent.queryValidator import QueryValidator -from modules.shared import fkRegistry +from modules.dbHelpers import fkRegistry @pytest.fixture(scope="module", autouse=True) diff --git a/tests/unit/services/test_udbNodes.py b/tests/unit/services/test_udbNodes.py index a7454d85..f9fae171 100644 --- a/tests/unit/services/test_udbNodes.py +++ b/tests/unit/services/test_udbNodes.py @@ -24,7 +24,7 @@ from modules.serviceCenter.services.serviceKnowledge.udbNodes import ( FdsWorkspaceNode, FdsTableNode, FdsFieldNode, - _isFeatureAdmin, + isFeatureAdmin, ) @@ -422,27 +422,27 @@ class TestIsFeatureAdmin(unittest.TestCase): def test_no_access_returns_false(self): rootIf = MagicMock() rootIf.getFeatureAccess.return_value = None - self.assertFalse(_isFeatureAdmin(rootIf, "user-1", "fi1")) + self.assertFalse(isFeatureAdmin(rootIf, "user-1", "fi1")) def test_no_roles_returns_false(self): rootIf = MagicMock() rootIf.getFeatureAccess.return_value = MagicMock(id="acc1", enabled=True) rootIf.getRoleIdsForFeatureAccess.return_value = [] - self.assertFalse(_isFeatureAdmin(rootIf, "user-1", "fi1")) + self.assertFalse(isFeatureAdmin(rootIf, "user-1", "fi1")) def test_non_admin_role_returns_false(self): rootIf = MagicMock() rootIf.getFeatureAccess.return_value = MagicMock(id="acc1", enabled=True) rootIf.getRoleIdsForFeatureAccess.return_value = ["r1"] rootIf.db.getRecord.return_value = {"id": "r1", "roleLabel": "trustee-user"} - self.assertFalse(_isFeatureAdmin(rootIf, "user-1", "fi1")) + self.assertFalse(isFeatureAdmin(rootIf, "user-1", "fi1")) def test_admin_role_returns_true(self): rootIf = MagicMock() rootIf.getFeatureAccess.return_value = MagicMock(id="acc1", enabled=True) rootIf.getRoleIdsForFeatureAccess.return_value = ["r1"] rootIf.db.getRecord.return_value = {"id": "r1", "roleLabel": "workspace-admin"} - self.assertTrue(_isFeatureAdmin(rootIf, "user-1", "fi1")) + self.assertTrue(isFeatureAdmin(rootIf, "user-1", "fi1")) if __name__ == "__main__": diff --git a/tests/unit/workflow/test_trusteeQueryData.py b/tests/unit/workflow/test_trusteeQueryData.py index b0bbae3b..93e0f4c5 100644 --- a/tests/unit/workflow/test_trusteeQueryData.py +++ b/tests/unit/workflow/test_trusteeQueryData.py @@ -4,7 +4,7 @@ import pytest -from modules.workflows.methods.methodTrustee.actions.queryData import ( +from modules.features.trustee.workflows.methodTrustee.actions.queryData import ( _accountMatcher, _normalizeText, _parseFilterJson, From 2b208ee504e786f177716550c625ea3044db4218 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sun, 7 Jun 2026 08:39:19 +0200 Subject: [PATCH 04/16] fix(test): update methodTrustee path in signature validator parametrize Co-authored-by: Cursor --- .../interfaceFeatureGraphicalEditor.py | 58 +++++++++---------- .../test_action_signature_validator.py | 2 +- 2 files changed, 29 insertions(+), 31 deletions(-) diff --git a/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py b/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py index aacc9e45..092389c6 100644 --- a/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py +++ b/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py @@ -46,8 +46,6 @@ from modules.datamodels.datamodelWorkflowAutomation import ( AutoRun, AutoStepLog, AutoTask, - Automation2Workflow, - Automation2WorkflowRun, ) from modules.features.graphicalEditor.entryPoints import invocations_synced_with_graph from modules.connectors.connectorDbPostgre import DatabaseConnector @@ -96,11 +94,11 @@ def getAllWorkflowsForScheduling() -> List[Dict[str, Any]]: dbPort=dbPort, userId=None, ) - if not connector._ensureTableExists(Automation2Workflow): - logger.warning("GraphicalEditor schedule: table Automation2Workflow does not exist yet") + if not connector._ensureTableExists(AutoWorkflow): + logger.warning("GraphicalEditor schedule: table AutoWorkflow does not exist yet") return [] records = connector.getRecordset( - Automation2Workflow, + AutoWorkflow, recordFilter=None, ) raw_count = len(records) if records else 0 @@ -191,7 +189,7 @@ class GraphicalEditorObjects: def getWorkflows(self, active: Optional[bool] = None) -> List[Dict[str, Any]]: """Get all workflows for this mandate (cross-instance).""" - if not self.db._ensureTableExists(Automation2Workflow): + if not self.db._ensureTableExists(AutoWorkflow): return [] rf: Dict[str, Any] = { "mandateId": self.mandateId, @@ -199,7 +197,7 @@ class GraphicalEditorObjects: if active is not None: rf["active"] = active records = self.db.getRecordset( - Automation2Workflow, + AutoWorkflow, recordFilter=rf, ) rows = [dict(r) for r in records] if records else [] @@ -209,10 +207,10 @@ class GraphicalEditorObjects: def getWorkflow(self, workflowId: str) -> Optional[Dict[str, Any]]: """Get a single workflow by ID (mandate-scoped, cross-instance).""" - if not self.db._ensureTableExists(Automation2Workflow): + if not self.db._ensureTableExists(AutoWorkflow): return None records = self.db.getRecordset( - Automation2Workflow, + AutoWorkflow, recordFilter={ "id": workflowId, "mandateId": self.mandateId, @@ -235,7 +233,7 @@ class GraphicalEditorObjects: if "active" not in data or data.get("active") is None: data["active"] = True data["invocations"] = invocations_synced_with_graph(data.get("graph") or {}, data.get("invocations")) - created = self.db.recordCreate(Automation2Workflow, data) + created = self.db.recordCreate(AutoWorkflow, data) out = dict(created) out["invocations"] = invocations_synced_with_graph(out.get("graph") or {}, out.get("invocations")) try: @@ -258,7 +256,7 @@ class GraphicalEditorObjects: g = {} inv = data["invocations"] if "invocations" in data else existing.get("invocations") data["invocations"] = invocations_synced_with_graph(g, inv) - updated = self.db.recordModify(Automation2Workflow, workflowId, data) + updated = self.db.recordModify(AutoWorkflow, workflowId, data) out = dict(updated) out["invocations"] = invocations_synced_with_graph(out.get("graph") or {}, out.get("invocations")) try: @@ -273,7 +271,7 @@ class GraphicalEditorObjects: existing = self.getWorkflow(workflowId) if not existing: return False - self.db.recordDelete(Automation2Workflow, workflowId) + self.db.recordDelete(AutoWorkflow, workflowId) try: from modules.shared.callbackRegistry import callbackRegistry callbackRegistry.trigger(_CALLBACK_WORKFLOW_CHANGED) @@ -305,15 +303,15 @@ class GraphicalEditorObjects: "mandateId": ctx.get("mandateId") or self.mandateId, "ownerId": ctx.get("userId") or (self.currentUser.id if self.currentUser else None), } - created = self.db.recordCreate(Automation2WorkflowRun, data) + created = self.db.recordCreate(AutoRun, data) return dict(created) def getRun(self, runId: str) -> Optional[Dict[str, Any]]: """Get a run by ID.""" - if not self.db._ensureTableExists(Automation2WorkflowRun): + if not self.db._ensureTableExists(AutoRun): return None records = self.db.getRecordset( - Automation2WorkflowRun, + AutoRun, recordFilter={"id": runId}, ) if not records: @@ -345,29 +343,29 @@ class GraphicalEditorObjects: updates["context"] = context if not updates: return run - updated = self.db.recordModify(Automation2WorkflowRun, runId, updates) + updated = self.db.recordModify(AutoRun, runId, updates) return dict(updated) def getRunsByWorkflow(self, workflowId: str) -> List[Dict[str, Any]]: """Get all runs for a workflow.""" - if not self.db._ensureTableExists(Automation2WorkflowRun): + if not self.db._ensureTableExists(AutoRun): return [] records = self.db.getRecordset( - Automation2WorkflowRun, + AutoRun, recordFilter={"workflowId": workflowId}, ) return [dict(r) for r in records] if records else [] def getRecentCompletedRuns(self, limit: int = 20) -> List[Dict[str, Any]]: """Get recent runs (all statuses) for workflows in this instance.""" - if not self.db._ensureTableExists(Automation2WorkflowRun): + if not self.db._ensureTableExists(AutoRun): return [] workflows = self.getWorkflows() wf_ids = [w["id"] for w in workflows if w.get("id")] if not wf_ids: return [] records = self.db.getRecordset( - Automation2WorkflowRun, + AutoRun, recordFilter={}, ) if not records: @@ -385,10 +383,10 @@ class GraphicalEditorObjects: def getRunsWaitingForEmail(self) -> List[Dict[str, Any]]: """Get all paused runs waiting for a new email (for background poller).""" - if not self.db._ensureTableExists(Automation2WorkflowRun): + if not self.db._ensureTableExists(AutoRun): return [] records = self.db.getRecordset( - Automation2WorkflowRun, + AutoRun, recordFilter={"status": "paused"}, ) if not records: @@ -426,15 +424,15 @@ class GraphicalEditorObjects: "status": "pending", "result": None, } - created = self.db.recordCreate(Automation2HumanTask, data) + created = self.db.recordCreate(AutoTask, data) return dict(created) def getTask(self, taskId: str) -> Optional[Dict[str, Any]]: """Get a task by ID.""" - if not self.db._ensureTableExists(Automation2HumanTask): + if not self.db._ensureTableExists(AutoTask): return None records = self.db.getRecordset( - Automation2HumanTask, + AutoTask, recordFilter={"id": taskId}, ) if not records: @@ -453,7 +451,7 @@ class GraphicalEditorObjects: updates["result"] = result if not updates: return task - updated = self.db.recordModify(Automation2HumanTask, taskId, updates) + updated = self.db.recordModify(AutoTask, taskId, updates) return dict(updated) def getTasks( @@ -464,7 +462,7 @@ class GraphicalEditorObjects: assigneeId: str = None, ) -> List[Dict[str, Any]]: """Get tasks with optional filters.""" - if not self.db._ensureTableExists(Automation2HumanTask): + if not self.db._ensureTableExists(AutoTask): return [] base_rf: Dict[str, Any] = {} if workflowId: @@ -476,8 +474,8 @@ class GraphicalEditorObjects: if assigneeId: rf_assigned = {**base_rf, "assigneeId": assigneeId} rf_unassigned = {**base_rf, "assigneeId": None} - records1 = self.db.getRecordset(Automation2HumanTask, recordFilter=rf_assigned) - records2 = self.db.getRecordset(Automation2HumanTask, recordFilter=rf_unassigned) + records1 = self.db.getRecordset(AutoTask, recordFilter=rf_assigned) + records2 = self.db.getRecordset(AutoTask, recordFilter=rf_unassigned) seen = set() items = [] for r in (records1 or []) + (records2 or []): @@ -488,7 +486,7 @@ class GraphicalEditorObjects: items.append(rec) else: records = self.db.getRecordset( - Automation2HumanTask, + AutoTask, recordFilter=base_rf if base_rf else None, ) items = [dict(r) for r in records] if records else [] diff --git a/tests/unit/methods/test_action_signature_validator.py b/tests/unit/methods/test_action_signature_validator.py index 7afd4597..fa4aa71f 100644 --- a/tests/unit/methods/test_action_signature_validator.py +++ b/tests/unit/methods/test_action_signature_validator.py @@ -255,7 +255,7 @@ def _instantiateMethod(methodCls): @pytest.mark.parametrize("modulePath,className", [ - ("modules.workflows.methods.methodTrustee.methodTrustee", "MethodTrustee"), + ("modules.features.trustee.workflows.methodTrustee.methodTrustee", "MethodTrustee"), ("modules.workflows.methods.methodRedmine.methodRedmine", "MethodRedmine"), ("modules.workflows.methods.methodSharepoint.methodSharepoint", "MethodSharepoint"), ("modules.workflows.methods.methodOutlook.methodOutlook", "MethodOutlook"), From 39aba4cca814520a1aaa554e52cd4a5463c3feea Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sun, 7 Jun 2026 22:26:18 +0200 Subject: [PATCH 05/16] before refactory workflowAutomation --- app.py | 25 +- modules/datamodels/datamodelNavigation.py | 48 ++ .../datamodels/datamodelWorkflowAutomation.py | 18 +- modules/datamodels/serviceExceptions.py | 25 + modules/demoConfigs/investorDemo2026.py | 4 +- modules/demoConfigs/pwgDemo2026.py | 2 +- .../graphicalEditor/mainGraphicalEditor.py | 195 -------- .../routeFeatureGraphicalEditor.py | 8 +- modules/interfaces/interfaceBootstrap.py | 2 +- modules/routes/routeAutomationWorkspace.py | 6 +- modules/routes/routeSystem.py | 5 +- modules/routes/routeWorkflowAutomation.py | 453 ++++++++++++++++++ modules/routes/routeWorkflowDashboard.py | 29 +- .../services/serviceAgent/workflowTools.py | 5 + modules/shared/documentUtils.py | 49 ++ modules/system/i18nBootSync.py | 2 + .../workflows/automation2/executionEngine.py | 50 ++ .../executors/actionNodeExecutor.py | 47 +- .../automation2/executors/inputExecutor.py | 22 +- .../methodContext/actions/setContext.py | 2 +- .../methods/methodFile/actions/create.py | 2 +- modules/workflows/scheduler/mainScheduler.py | 4 +- 22 files changed, 713 insertions(+), 290 deletions(-) create mode 100644 modules/routes/routeWorkflowAutomation.py diff --git a/app.py b/app.py index c91212e3..d8104fad 100644 --- a/app.py +++ b/app.py @@ -481,7 +481,15 @@ async def lifespan(app: FastAPI): except RuntimeError: pass eventManager.start() - + + # --- WorkflowAutomation: Scheduler boot (System-Lifespan, not Feature-onStart) --- + try: + from modules.workflows.scheduler.mainScheduler import start as _startWorkflowScheduler + _startWorkflowScheduler(eventUser) + logger.info("WorkflowAutomation scheduler started (system lifespan)") + except Exception as e: + logger.error(f"WorkflowAutomation scheduler failed to start: {e}") + # Register audit log cleanup scheduler from modules.dbHelpers.auditLogger import registerAuditLogCleanupScheduler registerAuditLogCleanupScheduler() @@ -562,6 +570,18 @@ async def lifespan(app: FastAPI): # 3. Stop scheduler (removes all pending cron/interval jobs) eventManager.stop() + # 3.5 Stop WorkflowAutomation scheduler + email poller (System-Lifespan) + try: + from modules.workflows.scheduler.mainScheduler import stop as _stopWorkflowScheduler + _stopWorkflowScheduler() + except Exception as e: + logger.warning(f"WorkflowAutomation scheduler stop failed: {e}") + try: + from modules.features.graphicalEditor.emailPoller import stop as _stopEmailPoller + _stopEmailPoller(eventUser) + except Exception as e: + logger.warning(f"Email poller stop failed: {e}") + # 4. Stop Feature Containers (Plug&Play) try: mainModules = loadFeatureMainModules() @@ -849,6 +869,9 @@ app.include_router(workflowDashboardRouter) from modules.routes.routeAutomationWorkspace import router as automationWorkspaceRouter app.include_router(automationWorkspaceRouter) +from modules.routes.routeWorkflowAutomation import router as workflowAutomationRouter +app.include_router(workflowAutomationRouter) + # ============================================================================ # PLUG&PLAY FEATURE ROUTERS # Dynamically load routers from feature containers in modules/features/ diff --git a/modules/datamodels/datamodelNavigation.py b/modules/datamodels/datamodelNavigation.py index 2fc278ef..eb9d3b69 100644 --- a/modules/datamodels/datamodelNavigation.py +++ b/modules/datamodels/datamodelNavigation.py @@ -158,6 +158,54 @@ NAVIGATION_SECTIONS = [ }, ], }, + # --- Workflow-Automation (System-Komponente, cross-mandate) --- + { + "id": "workflowAutomation", + "title": t("Workflow-Automation"), + "order": 25, + "items": [ + { + "id": "wa-workflows", + "objectKey": "ui.system.workflowAutomation.workflows", + "label": t("Workflows"), + "icon": "FaSitemap", + "path": "/workflow-automation?tab=workflows", + "order": 10, + }, + { + "id": "wa-editor", + "objectKey": "ui.system.workflowAutomation.editor", + "label": t("Editor"), + "icon": "FaProjectDiagram", + "path": "/workflow-automation?tab=editor", + "order": 20, + }, + { + "id": "wa-templates", + "objectKey": "ui.system.workflowAutomation.templates", + "label": t("Vorlagen"), + "icon": "FaCopy", + "path": "/workflow-automation?tab=templates", + "order": 30, + }, + { + "id": "wa-runs", + "objectKey": "ui.system.workflowAutomation.runs", + "label": t("Läufe"), + "icon": "FaPlay", + "path": "/workflow-automation?tab=runs", + "order": 40, + }, + { + "id": "wa-tasks", + "objectKey": "ui.system.workflowAutomation.tasks", + "label": t("Tasks"), + "icon": "FaTasks", + "path": "/workflow-automation?tab=tasks", + "order": 50, + }, + ], + }, # --- Administration (with subgroups) --- { "id": "admin", diff --git a/modules/datamodels/datamodelWorkflowAutomation.py b/modules/datamodels/datamodelWorkflowAutomation.py index 5f9cb7b2..c9957c25 100644 --- a/modules/datamodels/datamodelWorkflowAutomation.py +++ b/modules/datamodels/datamodelWorkflowAutomation.py @@ -77,14 +77,26 @@ class AutoWorkflow(PowerOnModel): "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}, }, ) - featureInstanceId: str = Field( - description="Feature instance ID (GE owner instance / RBAC scope)", + featureInstanceId: Optional[str] = Field( + default=None, + description="Feature instance ID (legacy GE owner — being phased out; NULL for mandate-level workflows)", json_schema_extra={ "frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Feature-Instanz-ID", - "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}, + "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label", "softFk": True}, + }, + ) + runAsPrincipal: Optional[str] = Field( + default=None, + description="Identity (userId or service-account) under which this workflow executes. Governs RBAC for data access at runtime.", + json_schema_extra={ + "frontend_type": "text", + "frontend_readonly": False, + "frontend_required": False, + "label": "Ausführungsidentität", + "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username", "softFk": True}, }, ) targetFeatureInstanceId: Optional[str] = Field( diff --git a/modules/datamodels/serviceExceptions.py b/modules/datamodels/serviceExceptions.py index 2aa94d95..7585c6a9 100644 --- a/modules/datamodels/serviceExceptions.py +++ b/modules/datamodels/serviceExceptions.py @@ -144,3 +144,28 @@ class BillingContextError(Exception): def __init__(self, message: str = None): self.message = message or "Billing context incomplete - AI call blocked" super().__init__(self.message) + + +# ============================================================================ +# Workflow execution pause exceptions +# (Canonical location — formerly in automation2/executors/inputExecutor.py) +# ============================================================================ + +class PauseForHumanTaskError(Exception): + """Raised when execution must pause for a human task. Contains runId, taskId.""" + + def __init__(self, runId: str, taskId: str, nodeId: str): + self.runId = runId + self.taskId = taskId + self.nodeId = nodeId + super().__init__(f"Pause for human task {taskId} (run {runId}, node {nodeId})") + + +class PauseForEmailWaitError(Exception): + """Raised when execution must pause waiting for a new email. Background poller will resume.""" + + def __init__(self, runId: str, nodeId: str, waitConfig: Dict[str, Any]): + self.runId = runId + self.nodeId = nodeId + self.waitConfig = waitConfig + super().__init__(f"Pause for email wait (run {runId}, node {nodeId})") diff --git a/modules/demoConfigs/investorDemo2026.py b/modules/demoConfigs/investorDemo2026.py index 6855a63c..0f7b0863 100644 --- a/modules/demoConfigs/investorDemo2026.py +++ b/modules/demoConfigs/investorDemo2026.py @@ -44,7 +44,7 @@ _USER = { _FEATURES_HAPPYLIFE = [ {"code": "workspace", "label": "Dokumentenablage"}, {"code": "trustee", "label": "Buchhaltung"}, - {"code": "graphicalEditor", "label": "Automationen"}, + {"code": "graphicalEditor", "label": "Automationen"}, # DEPRECATED: migrated to WorkflowAutomation system component {"code": "neutralization", "label": "Datenschutz"}, ] _FEATURES_ALPINA = [ @@ -52,7 +52,7 @@ _FEATURES_ALPINA = [ {"code": "trustee", "label": "BUHA Müller Immobilien GmbH"}, {"code": "trustee", "label": "BUHA Schneider Gastro AG"}, {"code": "trustee", "label": "BUHA Weber Consulting"}, - {"code": "graphicalEditor", "label": "Automationen"}, + {"code": "graphicalEditor", "label": "Automationen"}, # DEPRECATED: migrated to WorkflowAutomation system component {"code": "neutralization", "label": "Datenschutz"}, ] diff --git a/modules/demoConfigs/pwgDemo2026.py b/modules/demoConfigs/pwgDemo2026.py index 968aabf8..6e21e45a 100644 --- a/modules/demoConfigs/pwgDemo2026.py +++ b/modules/demoConfigs/pwgDemo2026.py @@ -49,7 +49,7 @@ _USER = { _FEATURES_PWG = [ {"code": "workspace", "label": "Dokumentenablage PWG"}, {"code": "trustee", "label": "Buchhaltung PWG"}, - {"code": "graphicalEditor", "label": "PWG Automationen"}, + {"code": "graphicalEditor", "label": "PWG Automationen"}, # DEPRECATED: migrated to WorkflowAutomation system component {"code": "neutralization", "label": "Datenschutz"}, ] diff --git a/modules/features/graphicalEditor/mainGraphicalEditor.py b/modules/features/graphicalEditor/mainGraphicalEditor.py index bf50abb2..f88ccfdc 100644 --- a/modules/features/graphicalEditor/mainGraphicalEditor.py +++ b/modules/features/graphicalEditor/mainGraphicalEditor.py @@ -26,25 +26,6 @@ REQUIRED_SERVICES = [ {"serviceKey": "generation", "meta": {"usage": "file.create document rendering"}}, ] FEATURE_LABEL = t("Grafischer Editor", context="UI") -FEATURE_ICON = "mdi-sitemap" - -UI_OBJECTS = [ - { - "objectKey": "ui.feature.graphicalEditor.editor", - "label": t("Editor", context="UI"), - "meta": {"area": "editor"} - }, - { - "objectKey": "ui.feature.graphicalEditor.templates", - "label": t("Vorlagen", context="UI"), - "meta": {"area": "templates"} - }, - { - "objectKey": "ui.feature.graphicalEditor.workflows-tasks", - "label": t("Tasks", context="UI"), - "meta": {"area": "tasks"} - }, -] RESOURCE_OBJECTS = [ { @@ -64,41 +45,6 @@ RESOURCE_OBJECTS = [ }, ] -TEMPLATE_ROLES = [ - { - "roleLabel": "graphicalEditor-viewer", - "description": "Grafischer Editor Betrachter - Workflows ansehen (nur lesen)", - "accessRules": [ - {"context": "UI", "item": "ui.feature.graphicalEditor.workflows", "view": True}, - {"context": "UI", "item": "ui.feature.graphicalEditor.workflows-tasks", "view": True}, - {"context": "UI", "item": "ui.feature.graphicalEditor.templates", "view": True}, - {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, - ], - }, - { - "roleLabel": "graphicalEditor-user", - "description": "Grafischer Editor Benutzer - Flow-Builder nutzen", - "accessRules": [ - {"context": "UI", "item": "ui.feature.graphicalEditor.editor", "view": True}, - {"context": "UI", "item": "ui.feature.graphicalEditor.workflows", "view": True}, - {"context": "UI", "item": "ui.feature.graphicalEditor.workflows-tasks", "view": True}, - {"context": "UI", "item": "ui.feature.graphicalEditor.templates", "view": True}, - {"context": "RESOURCE", "item": "resource.feature.graphicalEditor.dashboard", "view": True}, - {"context": "RESOURCE", "item": "resource.feature.graphicalEditor.node-types", "view": True}, - {"context": "RESOURCE", "item": "resource.feature.graphicalEditor.execute", "view": True}, - {"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"}, - ], - }, - { - "roleLabel": "graphicalEditor-admin", - "description": "Grafischer Editor Admin - Volle UI und API für die Instanz; Daten weiterhin benutzerspezifisch (MY)", - "accessRules": [ - {"context": "UI", "item": None, "view": True}, - {"context": "RESOURCE", "item": None, "view": True}, - {"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"}, - ], - }, -] def getRequiredServiceKeys() -> List[str]: @@ -186,28 +132,6 @@ class _GraphicalEditorServiceHub: generation = None -async def onStart(eventUser) -> None: - """Feature startup: start consolidated scheduler.""" - from modules.workflows.scheduler.mainScheduler import start as startScheduler - startScheduler(eventUser) - - -async def onStop(eventUser) -> None: - """Feature shutdown - stop scheduler and email poller.""" - from modules.workflows.scheduler.mainScheduler import stop as stopScheduler - stopScheduler() - from modules.features.graphicalEditor.emailPoller import stop as stopEmailPoller - stopEmailPoller(eventUser) - - -def getFeatureDefinition() -> Dict[str, Any]: - """Return the feature definition for registration.""" - return { - "code": FEATURE_CODE, - "label": FEATURE_LABEL, - "icon": FEATURE_ICON, - "autoCreateInstance": False, - } # --------------------------------------------------------------------------- @@ -473,125 +397,6 @@ def _buildSystemTemplates(): ] -def getUiObjects() -> List[Dict[str, Any]]: - """Return UI objects for RBAC catalog registration.""" - return UI_OBJECTS - - def getResourceObjects() -> List[Dict[str, Any]]: """Return resource objects for RBAC catalog registration.""" return RESOURCE_OBJECTS - - -def getTemplateRoles() -> List[Dict[str, Any]]: - """Return template roles for this feature.""" - return TEMPLATE_ROLES - - -def registerFeature(catalogService) -> bool: - """Register this feature's RBAC objects in the catalog.""" - try: - for uiObj in UI_OBJECTS: - catalogService.registerUiObject( - featureCode=FEATURE_CODE, - objectKey=uiObj["objectKey"], - label=uiObj["label"], - meta=uiObj.get("meta") - ) - for resObj in RESOURCE_OBJECTS: - catalogService.registerResourceObject( - featureCode=FEATURE_CODE, - objectKey=resObj["objectKey"], - label=resObj["label"], - meta=resObj.get("meta") - ) - _syncTemplateRolesToDb() - logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI objects and {len(RESOURCE_OBJECTS)} resource objects") - return True - except Exception as e: - logger.error(f"Failed to register feature '{FEATURE_CODE}': {e}") - return False - - -def _syncTemplateRolesToDb() -> int: - """Sync template roles and their AccessRules to database. - Also syncs rules to mandate-specific roles (same roleLabel) so new UI objects - become visible after gateway restart without manual role update. - """ - try: - from modules.interfaces.interfaceDbApp import getRootInterface - from modules.datamodels.datamodelRbac import Role - from modules.datamodels.datamodelUtils import coerce_text_multilingual - - rootInterface = getRootInterface() - existingRoles = rootInterface.getRolesByFeatureCode(FEATURE_CODE) - existingLabels = {r.roleLabel: str(r.id) for r in existingRoles if r.mandateId is None} - created = 0 - - for template in TEMPLATE_ROLES: - roleLabel = template["roleLabel"] - if roleLabel in existingLabels: - roleId = existingLabels[roleLabel] - else: - newRole = Role( - roleLabel=roleLabel, - description=coerce_text_multilingual(template.get("description", {})), - featureCode=FEATURE_CODE, - mandateId=None, - featureInstanceId=None, - isSystemRole=False - ) - rec = rootInterface.db.recordCreate(Role, newRole.model_dump()) - roleId = rec.get("id") - created += 1 - logger.info(f"Created template role '{roleLabel}' for {FEATURE_CODE}") - - _ensureAccessRulesForRole(rootInterface, roleId, template.get("accessRules", [])) - - for r in existingRoles: - if r.mandateId and r.roleLabel == roleLabel: - added = _ensureAccessRulesForRole( - rootInterface, str(r.id), template.get("accessRules", []) - ) - if added: - logger.debug(f"Added {added} access rules to mandate role {r.id}") - return created - except Exception as e: - logger.warning(f"Template role sync for {FEATURE_CODE}: {e}") - return 0 - - -def _ensureAccessRulesForRole(rootInterface, roleId: str, ruleTemplates: List[Dict[str, Any]]) -> int: - """Ensure AccessRules exist for a role based on templates.""" - from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext - - existingRules = rootInterface.getAccessRulesByRole(roleId) - existingSignatures = { - (r.context.value if r.context else None, r.item) - for r in existingRules - } - created = 0 - for t in ruleTemplates: - context = t.get("context", "UI") - item = t.get("item") - sig = (context, item) - if sig in existingSignatures: - continue - ctx_enum = ( - AccessRuleContext.UI if context == "UI" else - AccessRuleContext.DATA if context == "DATA" else - AccessRuleContext.RESOURCE if context == "RESOURCE" else context - ) - newRule = AccessRule( - roleId=roleId, - context=ctx_enum, - item=item, - view=t.get("view", False), - read=t.get("read"), - create=t.get("create"), - update=t.get("update"), - delete=t.get("delete"), - ) - rootInterface.db.recordCreate(AccessRule, newRule.model_dump()) - created += 1 - return created diff --git a/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py b/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py index 20a2708b..38d9d769 100644 --- a/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py +++ b/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py @@ -1,7 +1,10 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. """ -GraphicalEditor routes - node-types, execute, workflows, runs, tasks, connections, browse. +DEPRECATED: These per-instance routes are superseded by /api/workflow-automation/ +(routeWorkflowAutomation.py). Kept for backward compatibility during migration. + +Original: GraphicalEditor routes - node-types, execute, workflows, runs, tasks, connections, browse. """ import asyncio @@ -644,7 +647,8 @@ def get_templates( from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow - enrichRowsWithFkLabels(templates, AutoWorkflow, db=iface.db) + from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIface + enrichRowsWithFkLabels(templates, AutoWorkflow, db=_getRootIface().db) if mode == "filterValues": if not column: diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py index 5ac5d089..9a6e2e26 100644 --- a/modules/interfaces/interfaceBootstrap.py +++ b/modules/interfaces/interfaceBootstrap.py @@ -1610,7 +1610,7 @@ def _createStoreResourceRules(db: DatabaseConnector) -> None: "resource.store.workspace", "resource.store.commcoach", "resource.store.trustee", - "resource.store.graphicalEditor", + "resource.store.graphicalEditor", # DEPRECATED: will move with WorkflowAutomation code restructuring ] storeRules = [] diff --git a/modules/routes/routeAutomationWorkspace.py b/modules/routes/routeAutomationWorkspace.py index 09c5238c..a93fff70 100644 --- a/modules/routes/routeAutomationWorkspace.py +++ b/modules/routes/routeAutomationWorkspace.py @@ -21,12 +21,12 @@ from slowapi.util import get_remote_address from modules.auth.authentication import getRequestContext, RequestContext from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.shared.configuration import APP_CONFIG -from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import ( +from modules.datamodels.datamodelWorkflowAutomation import ( AutoRun, AutoStepLog, AutoWorkflow, + GRAPHICAL_EDITOR_DATABASE, ) -from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import graphicalEditorDatabase from modules.workflows.automation2.workflowArtifactVisibility import suppress_workflow_file_in_workspace_ui from modules.shared.i18nRegistry import apiRouteContext @@ -40,7 +40,7 @@ router = APIRouter(prefix="/api/automations/runs", tags=["AutomationWorkspace"]) def _getDb() -> DatabaseConnector: return DatabaseConnector( dbHost=APP_CONFIG.get("DB_HOST", "localhost"), - dbDatabase=graphicalEditorDatabase, + dbDatabase=GRAPHICAL_EDITOR_DATABASE, dbUser=APP_CONFIG.get("DB_USER"), dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), diff --git a/modules/routes/routeSystem.py b/modules/routes/routeSystem.py index 56568cd9..217dfa14 100644 --- a/modules/routes/routeSystem.py +++ b/modules/routes/routeSystem.py @@ -105,9 +105,6 @@ def _getFeatureUiObjects(featureCode: str) -> List[Dict[str, Any]]: elif featureCode == "realestate": from modules.features.realEstate.mainRealEstate import UI_OBJECTS return UI_OBJECTS - elif featureCode == "graphicalEditor": - from modules.features.graphicalEditor.mainGraphicalEditor import UI_OBJECTS - return UI_OBJECTS elif featureCode == "teamsbot": from modules.features.teamsbot.mainTeamsbot import UI_OBJECTS return UI_OBJECTS @@ -841,7 +838,7 @@ def _buildIntegrationsOverviewPayload(userId: str, user=None) -> Dict[str, Any]: from modules.shared.configuration import APP_CONFIG from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.datamodels.datamodelPagination import PaginationParams - from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import ( + from modules.datamodels.datamodelWorkflowAutomation import ( AutoWorkflow, AutoRun, ) diff --git a/modules/routes/routeWorkflowAutomation.py b/modules/routes/routeWorkflowAutomation.py new file mode 100644 index 00000000..6ce6fb21 --- /dev/null +++ b/modules/routes/routeWorkflowAutomation.py @@ -0,0 +1,453 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Mandatsweite WorkflowAutomation API. + +System-level API for workflows, runs, tasks — scoped by mandate membership, +not by graphicalEditor FeatureInstance. Parallel to the legacy per-instance +API in routeFeatureGraphicalEditor.py during the migration period. + +RBAC model: + - Read: mandate membership (user sees workflows in own mandates) + - Write/Execute: mandate admin or isPlatformAdmin + - isPlatformAdmin bypasses all checks +""" + +import json +import logging +import time +from typing import Optional, List, Dict, Any + +from fastapi import APIRouter, Depends, HTTPException, Query +from slowapi import Limiter +from slowapi.util import get_remote_address + +from modules.auth.authentication import getRequestContext, RequestContext +from modules.connectors.connectorDbPostgre import DatabaseConnector +from modules.datamodels.datamodelWorkflowAutomation import ( + AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask, + GRAPHICAL_EDITOR_DATABASE, +) +from modules.datamodels.datamodelPagination import PaginationParams, normalize_pagination_dict +from modules.interfaces.interfaceDbApp import getRootInterface +from modules.shared.configuration import APP_CONFIG +from modules.shared.i18nRegistry import apiRouteContext + +routeApiMsg = apiRouteContext("routeWorkflowAutomation") + +logger = logging.getLogger(__name__) +limiter = Limiter(key_func=get_remote_address) + +router = APIRouter(prefix="/api/workflow-automation", tags=["WorkflowAutomation"]) + + +# --------------------------------------------------------------------------- +# DB + RBAC helpers +# --------------------------------------------------------------------------- + +def _getDb() -> DatabaseConnector: + return DatabaseConnector( + dbHost=APP_CONFIG.get("DB_HOST", "localhost"), + dbDatabase=GRAPHICAL_EDITOR_DATABASE, + dbUser=APP_CONFIG.get("DB_USER"), + dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), + dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), + userId=None, + ) + + +def _getUserMandateIds(userId: str) -> List[str]: + rootIface = getRootInterface() + memberships = rootIface.getUserMandates(userId) + return [um.mandateId for um in memberships if um.mandateId and um.enabled] + + +def _getAdminMandateIds(userId: str, mandateIds: List[str]) -> List[str]: + if not mandateIds: + return [] + rootIface = getRootInterface() + from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole + + memberships = rootIface.db.getRecordset( + UserMandate, + recordFilter={"userId": userId, "mandateId": mandateIds, "enabled": True}, + ) + if not memberships: + return [] + + umIdToMandateId: Dict[str, str] = {} + for m in memberships: + row = m if isinstance(m, dict) else m.__dict__ + um_id = row.get("id") + mid = row.get("mandateId") + if um_id and mid: + umIdToMandateId[str(um_id)] = str(mid) + + userMandateIds = list(umIdToMandateId.keys()) + allRoles = rootIface.db.getRecordset( + UserMandateRole, + recordFilter={"userMandateId": userMandateIds}, + ) + if not allRoles: + return [] + + roleIds: set = set() + roleToMandate: Dict[str, set] = {} + for r in allRoles: + row = r if isinstance(r, dict) else r.__dict__ + rid = row.get("roleId") + um_id = row.get("userMandateId") + mid = umIdToMandateId.get(str(um_id)) if um_id else None + if rid and mid: + roleIds.add(rid) + roleToMandate.setdefault(rid, set()).add(mid) + + if not roleIds: + return [] + + from modules.datamodels.datamodelRbac import Role + roleRecords = rootIface.db.getRecordset(Role, recordFilter={"id": list(roleIds)}) + adminMandates: set = set() + for role in (roleRecords or []): + row = role if isinstance(role, dict) else role.__dict__ + rid = row.get("id") + if not rid or rid not in roleToMandate: + continue + if row.get("roleLabel") == "admin" and not row.get("featureInstanceId"): + adminMandates.update(roleToMandate[rid]) + + return [mid for mid in mandateIds if mid in adminMandates] + + +def _validateWorkflowAccess( + context: RequestContext, + workflow: Optional[Dict[str, Any]], + action: str = "read", +) -> None: + """Validate access to a workflow based on mandate membership + admin status. + + Actions: 'read' (mandate member), 'write'/'execute'/'delete' (mandate admin or platform admin). + Raises HTTPException(403) on denial. + """ + if context.isPlatformAdmin: + return + + userId = str(context.user.id) if context.user else None + if not userId: + raise HTTPException(status_code=403, detail="Authentication required") + + if workflow is None: + raise HTTPException(status_code=404, detail="Workflow not found") + + wfMandateId = workflow.get("mandateId") or "" + if not wfMandateId: + if action == "read": + return + raise HTTPException(status_code=403, detail="Workflow has no mandate — admin only") + + userMandateIds = _getUserMandateIds(userId) + if wfMandateId not in userMandateIds: + raise HTTPException(status_code=403, detail="Not a member of the workflow's mandate") + + if action == "read": + return + + adminMandateIds = _getAdminMandateIds(userId, [wfMandateId]) + if wfMandateId not in adminMandateIds: + raise HTTPException( + status_code=403, + detail=f"Mandate admin required for '{action}' on workflows", + ) + + +def _scopedWorkflowFilter(context: RequestContext) -> Optional[Dict[str, Any]]: + """Build DB filter for listing workflows: mandate-scoped for members, None for sysadmin.""" + if context.isPlatformAdmin: + return None + + userId = str(context.user.id) if context.user else None + if not userId: + return {"mandateId": "__impossible__"} + + mandateIds = _getUserMandateIds(userId) + if mandateIds: + return {"mandateId": mandateIds} + return {"mandateId": "__impossible__"} + + +def _scopedRunFilter(context: RequestContext) -> Optional[Dict[str, Any]]: + """Build DB filter for listing runs: admin sees mandate runs, user sees own.""" + if context.isPlatformAdmin: + return None + + userId = str(context.user.id) if context.user else None + if not userId: + return {"ownerId": "__impossible__"} + + mandateIds = _getUserMandateIds(userId) + adminMandateIds = _getAdminMandateIds(userId, mandateIds) + + if adminMandateIds: + return {"mandateId": adminMandateIds} + return {"ownerId": userId} + + +def _parsePagination(pagination: Optional[str]) -> Optional[PaginationParams]: + if not pagination: + return None + try: + d = json.loads(pagination) + except json.JSONDecodeError: + raise HTTPException(status_code=400, detail="Invalid pagination JSON") + if not d: + return None + return normalize_pagination_dict(d) + + +# --------------------------------------------------------------------------- +# Workflow CRUD +# --------------------------------------------------------------------------- + +@router.get("/workflows") +async def _listWorkflows( + request: RequestContext = Depends(getRequestContext), + pagination: Optional[str] = Query(default=None), + mandateId: Optional[str] = Query(default=None), +): + db = _getDb() + try: + db._ensureTableExists(AutoWorkflow) + scopeFilter = _scopedWorkflowFilter(request) + if mandateId and scopeFilter is not None: + if mandateId not in (scopeFilter.get("mandateId") or []): + return {"items": [], "total": 0} + scopeFilter = {"mandateId": mandateId} + elif mandateId and scopeFilter is None: + scopeFilter = {"mandateId": mandateId} + + params = _parsePagination(pagination) + records = db.getRecordset(AutoWorkflow, recordFilter=scopeFilter, pagination=params) + total = db.getRecordCount(AutoWorkflow, recordFilter=scopeFilter) if params else len(records or []) + return {"items": records or [], "total": total} + finally: + db.close() + + +@router.get("/workflows/{workflowId}") +async def _getWorkflow( + workflowId: str, + request: RequestContext = Depends(getRequestContext), +): + db = _getDb() + try: + db._ensureTableExists(AutoWorkflow) + wf = db.getRecord(AutoWorkflow, workflowId) + if not wf: + raise HTTPException(status_code=404, detail="Workflow not found") + _validateWorkflowAccess(request, wf, "read") + return wf + finally: + db.close() + + +@router.post("/workflows") +async def _createWorkflow( + request: RequestContext = Depends(getRequestContext), + body: Dict[str, Any] = {}, +): + mandateId = body.get("mandateId") + if not mandateId: + raise HTTPException(status_code=400, detail="mandateId required") + + _validateWorkflowAccess(request, {"mandateId": mandateId}, "write") + + db = _getDb() + try: + db._ensureTableExists(AutoWorkflow) + import uuid + data = {**body, "id": str(uuid.uuid4())} + if request.user: + data.setdefault("runAsPrincipal", str(request.user.id)) + rec = db.recordCreate(AutoWorkflow, data) + return rec + finally: + db.close() + + +@router.put("/workflows/{workflowId}") +async def _updateWorkflow( + workflowId: str, + request: RequestContext = Depends(getRequestContext), + body: Dict[str, Any] = {}, +): + db = _getDb() + try: + db._ensureTableExists(AutoWorkflow) + wf = db.getRecord(AutoWorkflow, workflowId) + _validateWorkflowAccess(request, wf, "write") + updated = db.recordModify(AutoWorkflow, workflowId, body) + return updated + finally: + db.close() + + +@router.delete("/workflows/{workflowId}") +async def _deleteWorkflow( + workflowId: str, + request: RequestContext = Depends(getRequestContext), +): + db = _getDb() + try: + db._ensureTableExists(AutoWorkflow) + wf = db.getRecord(AutoWorkflow, workflowId) + _validateWorkflowAccess(request, wf, "delete") + + for v in db.getRecordset(AutoVersion, recordFilter={"workflowId": workflowId}) or []: + db.recordDelete(AutoVersion, v.get("id")) + for run in db.getRecordset(AutoRun, recordFilter={"workflowId": workflowId}) or []: + runId = run.get("id") + for sl in db.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []: + db.recordDelete(AutoStepLog, sl.get("id")) + db.recordDelete(AutoRun, runId) + for task in db.getRecordset(AutoTask, recordFilter={"workflowId": workflowId}) or []: + db.recordDelete(AutoTask, task.get("id")) + db.recordDelete(AutoWorkflow, workflowId) + return {"deleted": True, "workflowId": workflowId} + finally: + db.close() + + +# --------------------------------------------------------------------------- +# Runs +# --------------------------------------------------------------------------- + +@router.get("/runs") +async def _listRuns( + request: RequestContext = Depends(getRequestContext), + pagination: Optional[str] = Query(default=None), + mandateId: Optional[str] = Query(default=None), + workflowId: Optional[str] = Query(default=None), +): + db = _getDb() + try: + db._ensureTableExists(AutoRun) + scopeFilter = _scopedRunFilter(request) + if mandateId: + if scopeFilter is None: + scopeFilter = {"mandateId": mandateId} + elif "mandateId" in scopeFilter: + if mandateId not in scopeFilter["mandateId"]: + return {"items": [], "total": 0} + scopeFilter = {"mandateId": mandateId} + if workflowId: + scopeFilter = {**(scopeFilter or {}), "workflowId": workflowId} + + params = _parsePagination(pagination) + records = db.getRecordset(AutoRun, recordFilter=scopeFilter, pagination=params) + total = db.getRecordCount(AutoRun, recordFilter=scopeFilter) if params else len(records or []) + return {"items": records or [], "total": total} + finally: + db.close() + + +@router.get("/runs/{runId}") +async def _getRun( + runId: str, + request: RequestContext = Depends(getRequestContext), +): + db = _getDb() + try: + db._ensureTableExists(AutoRun) + run = db.getRecord(AutoRun, runId) + if not run: + raise HTTPException(status_code=404, detail="Run not found") + + wfId = run.get("workflowId") + if wfId: + wf = db.getRecord(AutoWorkflow, wfId) + _validateWorkflowAccess(request, wf, "read") + return run + finally: + db.close() + + +# --------------------------------------------------------------------------- +# Tasks +# --------------------------------------------------------------------------- + +@router.get("/tasks") +async def _listTasks( + request: RequestContext = Depends(getRequestContext), + pagination: Optional[str] = Query(default=None), + status: Optional[str] = Query(default=None), +): + db = _getDb() + try: + db._ensureTableExists(AutoTask) + scopeFilter: Optional[Dict[str, Any]] = None + + if not request.isPlatformAdmin: + userId = str(request.user.id) if request.user else None + if not userId: + return {"items": [], "total": 0} + scopeFilter = {"assigneeId": userId} + + if status: + scopeFilter = {**(scopeFilter or {}), "status": status} + + params = _parsePagination(pagination) + records = db.getRecordset(AutoTask, recordFilter=scopeFilter, pagination=params) + total = db.getRecordCount(AutoTask, recordFilter=scopeFilter) if params else len(records or []) + return {"items": records or [], "total": total} + finally: + db.close() + + +# --------------------------------------------------------------------------- +# Versions +# --------------------------------------------------------------------------- + +@router.get("/workflows/{workflowId}/versions") +async def _listVersions( + workflowId: str, + request: RequestContext = Depends(getRequestContext), +): + db = _getDb() + try: + db._ensureTableExists(AutoWorkflow) + wf = db.getRecord(AutoWorkflow, workflowId) + _validateWorkflowAccess(request, wf, "read") + + db._ensureTableExists(AutoVersion) + versions = db.getRecordset(AutoVersion, recordFilter={"workflowId": workflowId}) + return {"items": versions or []} + finally: + db.close() + + +# --------------------------------------------------------------------------- +# Step logs +# --------------------------------------------------------------------------- + +@router.get("/runs/{runId}/steps") +async def _listStepLogs( + runId: str, + request: RequestContext = Depends(getRequestContext), +): + db = _getDb() + try: + db._ensureTableExists(AutoRun) + run = db.getRecord(AutoRun, runId) + if not run: + raise HTTPException(status_code=404, detail="Run not found") + + wfId = run.get("workflowId") + if wfId: + wf = db.getRecord(AutoWorkflow, wfId) + _validateWorkflowAccess(request, wf, "read") + + db._ensureTableExists(AutoStepLog) + steps = db.getRecordset(AutoStepLog, recordFilter={"runId": runId}) + return {"items": steps or []} + finally: + db.close() diff --git a/modules/routes/routeWorkflowDashboard.py b/modules/routes/routeWorkflowDashboard.py index f29fc557..020e5ec7 100644 --- a/modules/routes/routeWorkflowDashboard.py +++ b/modules/routes/routeWorkflowDashboard.py @@ -27,10 +27,10 @@ from modules.interfaces.interfaceDbApp import getRootInterface from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.shared.configuration import APP_CONFIG from modules.datamodels.datamodelPagination import PaginationParams, normalize_pagination_dict -from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import ( +from modules.datamodels.datamodelWorkflowAutomation import ( AutoRun, AutoStepLog, AutoWorkflow, AutoTask, AutoVersion, + GRAPHICAL_EDITOR_DATABASE, ) -from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import graphicalEditorDatabase from modules.shared.i18nRegistry import apiRouteContext routeApiMsg = apiRouteContext("routeWorkflowDashboard") @@ -44,7 +44,7 @@ router = APIRouter(prefix="/api/system/workflow-runs", tags=["WorkflowDashboard" def _getDb() -> DatabaseConnector: return DatabaseConnector( dbHost=APP_CONFIG.get("DB_HOST", "localhost"), - dbDatabase=graphicalEditorDatabase, + dbDatabase=GRAPHICAL_EDITOR_DATABASE, dbUser=APP_CONFIG.get("DB_USER"), dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), @@ -619,7 +619,8 @@ def get_workflow_runs( for wf in (wfs or []): wfMap[wf.get("id")] = wf - from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels, resolveMandateLabels, resolveInstanceLabels + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels, resolveMandateLabels, resolveInstanceLabels, resolveUserLabels + from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIface runs = [] for r in pageRuns: @@ -635,17 +636,20 @@ def get_workflow_runs( row["featureInstanceId"] = fiid runs.append(row) + appDb = _getRootIface().db enrichRowsWithFkLabels( runs, db=db, labelResolvers={ - "mandateId": partial(resolveMandateLabels, db), - "featureInstanceId": partial(resolveInstanceLabels, db), + "mandateId": partial(resolveMandateLabels, appDb), + "featureInstanceId": partial(resolveInstanceLabels, appDb), + "ownerId": partial(resolveUserLabels, appDb), }, ) for row in runs: row["instanceLabel"] = row.pop("featureInstanceIdLabel", None) row["mandateLabel"] = row.pop("mandateIdLabel", None) + row["ownerLabel"] = row.pop("ownerIdLabel", None) return {"runs": runs, "total": total, "limit": limit, "offset": offset} @@ -808,6 +812,9 @@ def get_system_workflows( userMandateIds = _getUserMandateIds(userId) adminMandateIds = _getAdminMandateIds(userId, userMandateIds) + from modules.dbHelpers.fkLabelResolver import resolveUserLabels as _resolveUserLabels + from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIface + fkSortField = _firstFkSortFieldForWorkflows(paginationParams) if fkSortField: from modules.dbHelpers.paginationHelpers import getRecordsetPaginatedWithFkSort, applyFiltersAndSort @@ -869,17 +876,20 @@ def get_system_workflows( row["canExecute"] = False row.pop("graph", None) items.append(row) + _appDb = _getRootIface().db enrichRowsWithFkLabels( items, db=db, labelResolvers={ - "mandateId": partial(resolveMandateLabels, db), + "mandateId": partial(resolveMandateLabels, _appDb), "featureInstanceId": _resolveInstanceLabelsWithFeatureCode, + "ownerId": partial(_resolveUserLabels, _appDb), }, ) for row in items: row["instanceLabel"] = row.pop("featureInstanceIdLabel", None) row["mandateLabel"] = row.pop("mandateIdLabel", None) + row["ownerLabel"] = row.pop("ownerIdLabel", None) row["featureCode"] = featureCodeMap.get(row.get("featureInstanceId")) if hasComputedFilter or hasComputedSort: computedFilters = { @@ -932,17 +942,20 @@ def get_system_workflows( row["canExecute"] = False row.pop("graph", None) items.append(row) + _appDb2 = _getRootIface().db enrichRowsWithFkLabels( items, db=db, labelResolvers={ - "mandateId": partial(resolveMandateLabels, db), + "mandateId": partial(resolveMandateLabels, _appDb2), "featureInstanceId": _resolveInstanceLabelsWithFeatureCode, + "ownerId": partial(_resolveUserLabels, _appDb2), }, ) for row in items: row["instanceLabel"] = row.pop("featureInstanceIdLabel", None) row["mandateLabel"] = row.pop("mandateIdLabel", None) + row["ownerLabel"] = row.pop("ownerIdLabel", None) row["featureCode"] = featureCodeMap.get(row.get("featureInstanceId")) return { diff --git a/modules/serviceCenter/services/serviceAgent/workflowTools.py b/modules/serviceCenter/services/serviceAgent/workflowTools.py index 82eda22d..32defa2b 100644 --- a/modules/serviceCenter/services/serviceAgent/workflowTools.py +++ b/modules/serviceCenter/services/serviceAgent/workflowTools.py @@ -89,6 +89,7 @@ def _resolveMandateId(context: Any) -> str: def _getInterface(context: Any, instanceId: str): + # DEPRECATED: will move with WorkflowAutomation code restructuring from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface return getGraphicalEditorInterface(_resolveUser(context), _resolveMandateId(context), instanceId) @@ -306,6 +307,7 @@ async def _list_upstream_paths(params: Dict[str, Any], context: Any) -> ToolResu return _err(name, f"Workflow {workflow_id} not found") graph = wf.get("graph", {}) or {} + # DEPRECATED: will move with WorkflowAutomation code restructuring from modules.features.graphicalEditor.upstreamPathsService import compute_upstream_paths paths = compute_upstream_paths(graph if isinstance(graph, dict) else {}, str(node_id)) @@ -436,6 +438,7 @@ async def _listAvailableNodeTypes(params: Dict[str, Any], context: Any) -> ToolR """ name = "listAvailableNodeTypes" try: + # DEPRECATED: will move with WorkflowAutomation code restructuring from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES nodeTypes = [] for n in STATIC_NODE_TYPES: @@ -462,6 +465,7 @@ async def _describeNodeType(params: Dict[str, Any], context: Any) -> ToolResult: nodeType = params.get("nodeType") or params.get("id") if not nodeType: return _err(name, "nodeType required") + # DEPRECATED: will move with WorkflowAutomation code restructuring from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES target: Dict[str, Any] = {} for n in STATIC_NODE_TYPES: @@ -875,6 +879,7 @@ async def _exportWorkflowToFile(params: Dict[str, Any], context: Any) -> ToolRes envelope = iface.exportWorkflowToDict(workflowId) if envelope is None: return _err(name, f"Workflow {workflowId} not found") + # DEPRECATED: will move with WorkflowAutomation code restructuring from modules.features.graphicalEditor._workflowFileSchema import buildFileName return _ok(name, { "fileName": buildFileName(envelope.get("label", "workflow")), diff --git a/modules/shared/documentUtils.py b/modules/shared/documentUtils.py index cc08835c..37eec8a5 100644 --- a/modules/shared/documentUtils.py +++ b/modules/shared/documentUtils.py @@ -5,7 +5,10 @@ Document utility functions (Layer L0 - shared). Pure text-processing helpers with zero internal dependencies. """ +import base64 +import binascii import re +from typing import Any, Optional def parseInlineRuns(text: str) -> list: @@ -62,3 +65,49 @@ def parseInlineRuns(text: str) -> list: runs.append({"type": "text", "value": text[lastEnd:]}) return runs if runs else [{"type": "text", "value": text}] + + +def _looksLikeAsciiBase64Payload(s: str) -> bool: + """Heuristic: ActionDocument binary payloads use standard ASCII base64; markdown/text uses other chars.""" + t = "".join(s.split()) + if len(t) < 8: + return False + if not t.isascii(): + return False + return bool(re.fullmatch(r"[A-Za-z0-9+/]+=*", t)) and len(t) % 4 == 0 + + +def coerceDocumentDataToBytes(raw: Any) -> Optional[bytes]: + """Normalize documentData for DB file persistence. + + ActionDocument conventions (see methodFile.create): binary bodies are carried as ASCII + base64 strings; plain markdown/text stays as Unicode. Do not UTF-8-encode a base64 + literal — that persists the ASCII of the encoding (file looks like base64 gibberish). + """ + if raw is None: + return None + if isinstance(raw, bytes): + return raw if len(raw) > 0 else None + if isinstance(raw, bytearray): + b = bytes(raw) + return b if len(b) > 0 else None + if isinstance(raw, memoryview): + b = raw.tobytes() + return b if len(b) > 0 else None + if isinstance(raw, str): + stripped = raw.strip() + if not stripped: + return None + if _looksLikeAsciiBase64Payload(stripped): + try: + decoded = base64.b64decode(stripped, validate=True) + except (TypeError, binascii.Error, ValueError): + try: + decoded = base64.b64decode(stripped) + except (binascii.Error, ValueError): + decoded = b"" + if decoded: + return decoded + b = stripped.encode("utf-8") + return b if len(b) > 0 else None + return None diff --git a/modules/system/i18nBootSync.py b/modules/system/i18nBootSync.py index 96f1b69d..15501a0f 100644 --- a/modules/system/i18nBootSync.py +++ b/modules/system/i18nBootSync.py @@ -242,6 +242,7 @@ def _registerNodeLabels(): added += 1 try: + # DEPRECATED: will move with WorkflowAutomation code restructuring from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES for nd in STATIC_NODE_TYPES: _reg(_extractRegistrySourceText(nd.get("label")), "node.label") @@ -265,6 +266,7 @@ def _registerNodeLabels(): pass try: + # DEPRECATED: will move with WorkflowAutomation code restructuring from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG for schema in PORT_TYPE_CATALOG.values(): for field in getattr(schema, "fields", []) or []: diff --git a/modules/workflows/automation2/executionEngine.py b/modules/workflows/automation2/executionEngine.py index d5fdbd0e..b6313342 100644 --- a/modules/workflows/automation2/executionEngine.py +++ b/modules/workflows/automation2/executionEngine.py @@ -429,6 +429,52 @@ async def _executeWithRetry(executor, node, context, maxRetries: int = 0, retryD raise lastError +def _validateFeatureInstanceMandates(graph: Dict[str, Any], mandateId: str) -> None: + """Verify that all FeatureInstanceRef IDs in the graph belong to the workflow's mandate. + + Logs a warning for each mismatch but does NOT abort execution — the node + executor will fail on its own with a more specific error if the instance is + truly inaccessible. This is a defence-in-depth guard (A0.2). + """ + nodes = graph.get("nodes") if isinstance(graph, dict) else None + if not isinstance(nodes, list): + return + instanceIds: set = set() + for node in nodes: + if not isinstance(node, dict): + continue + params = node.get("parameters") or {} + ref = params.get("featureInstanceId") + if isinstance(ref, dict) and ref.get("$type") == "FeatureInstanceRef": + iid = ref.get("id") + if iid: + instanceIds.add(iid) + elif isinstance(ref, str) and ref.strip(): + instanceIds.add(ref.strip()) + if not instanceIds: + return + try: + from modules.interfaces.interfaceDbApp import getRootInterface + root = getRootInterface() + from modules.datamodels.datamodelFeatures import FeatureInstance + for iid in instanceIds: + fi = root.db.getRecord(FeatureInstance, iid) + if not fi: + logger.warning( + "MandateValidation: FeatureInstance %s referenced in graph not found", iid, + ) + continue + fiMandateId = fi.get("mandateId") if isinstance(fi, dict) else getattr(fi, "mandateId", None) + if fiMandateId and fiMandateId != mandateId: + logger.warning( + "MandateValidation: FeatureInstance %s belongs to mandate %s, " + "but workflow mandate is %s — cross-mandate access", + iid, fiMandateId, mandateId, + ) + except Exception as e: + logger.debug("MandateValidation: could not verify instances: %s", e) + + def _substituteFeatureInstancePlaceholders( graph: Dict[str, Any], targetFeatureInstanceId: str, @@ -675,6 +721,10 @@ async def executeGraph( # Phase-5 Schicht-4: typed-ref envelopes are materialized FIRST so the # subsequent connection-ref pass and validation see the canonical shape. graph = materializeFeatureInstanceRefs(graph) + + if mandateId: + _validateFeatureInstanceMandates(graph, mandateId) + graph = materializeConnectionRefs(graph) graph = materializePrimaryTextHandover(graph) graph = materializeRecommendedDataPickRef(graph) diff --git a/modules/workflows/automation2/executors/actionNodeExecutor.py b/modules/workflows/automation2/executors/actionNodeExecutor.py index 9af626d4..ee1101e5 100644 --- a/modules/workflows/automation2/executors/actionNodeExecutor.py +++ b/modules/workflows/automation2/executors/actionNodeExecutor.py @@ -7,8 +7,6 @@ # ``documentListWire`` is applied at runtime in this executor via graphUtils.extract_wired_document_list. -import base64 -import binascii import json import logging import re @@ -122,50 +120,7 @@ def _log_file_create_context_resolution( ) -def _looks_like_ascii_base64_payload(s: str) -> bool: - """Heuristic: ActionDocument binary payloads use standard ASCII base64; markdown/text uses other chars (#, *, -, …).""" - t = "".join(s.split()) - if len(t) < 8: - return False - if not t.isascii(): - return False - return bool(re.fullmatch(r"[A-Za-z0-9+/]+=*", t)) and len(t) % 4 == 0 - - -def coerceDocumentDataToBytes(raw: Any) -> Optional[bytes]: - """Normalize documentData for DB file persistence. - - ActionDocument conventions (see methodFile.create): binary bodies are carried as ASCII - base64 strings; plain markdown/text stays as Unicode. Do not UTF-8-encode a base64 - literal — that persists the ASCII of the encoding (file looks like base64 gibberish). - """ - if raw is None: - return None - if isinstance(raw, bytes): - return raw if len(raw) > 0 else None - if isinstance(raw, bytearray): - b = bytes(raw) - return b if len(b) > 0 else None - if isinstance(raw, memoryview): - b = raw.tobytes() - return b if len(b) > 0 else None - if isinstance(raw, str): - stripped = raw.strip() - if not stripped: - return None - if _looks_like_ascii_base64_payload(stripped): - try: - decoded = base64.b64decode(stripped, validate=True) - except (TypeError, binascii.Error, ValueError): - try: - decoded = base64.b64decode(stripped) - except (binascii.Error, ValueError): - decoded = b"" - if decoded: - return decoded - b = stripped.encode("utf-8") - return b if len(b) > 0 else None - return None +from modules.shared.documentUtils import coerceDocumentDataToBytes # noqa: F401 — re-export shim def _image_documents_from_docs_list(docs_list: list) -> list: diff --git a/modules/workflows/automation2/executors/inputExecutor.py b/modules/workflows/automation2/executors/inputExecutor.py index 4ccef725..aaf31ff1 100644 --- a/modules/workflows/automation2/executors/inputExecutor.py +++ b/modules/workflows/automation2/executors/inputExecutor.py @@ -4,29 +4,11 @@ import logging from typing import Dict, Any +from modules.datamodels.serviceExceptions import PauseForHumanTaskError, PauseForEmailWaitError # noqa: F401 — re-export shim + logger = logging.getLogger(__name__) -class PauseForHumanTaskError(Exception): - """Raised when execution must pause for a human task. Contains runId, taskId.""" - - def __init__(self, runId: str, taskId: str, nodeId: str): - self.runId = runId - self.taskId = taskId - self.nodeId = nodeId - super().__init__(f"Pause for human task {taskId} (run {runId}, node {nodeId})") - - -class PauseForEmailWaitError(Exception): - """Raised when execution must pause waiting for a new email. Background poller will resume.""" - - def __init__(self, runId: str, nodeId: str, waitConfig: Dict[str, Any]): - self.runId = runId - self.nodeId = nodeId - self.waitConfig = waitConfig - super().__init__(f"Pause for email wait (run {runId}, node {nodeId})") - - class InputExecutor: """ Execute input/human nodes. Creates a HumanTask, pauses the run, and raises diff --git a/modules/workflows/methods/methodContext/actions/setContext.py b/modules/workflows/methods/methodContext/actions/setContext.py index 10f292b7..24e10fc8 100644 --- a/modules/workflows/methods/methodContext/actions/setContext.py +++ b/modules/workflows/methods/methodContext/actions/setContext.py @@ -22,7 +22,7 @@ import logging from typing import Any, Dict, List, Optional, Tuple from modules.datamodels.datamodelChat import ActionResult -from modules.workflows.automation2.executors.inputExecutor import PauseForHumanTaskError +from modules.datamodels.serviceExceptions import PauseForHumanTaskError logger = logging.getLogger(__name__) diff --git a/modules/workflows/methods/methodFile/actions/create.py b/modules/workflows/methods/methodFile/actions/create.py index cc5550ca..bb778c8f 100644 --- a/modules/workflows/methods/methodFile/actions/create.py +++ b/modules/workflows/methods/methodFile/actions/create.py @@ -13,7 +13,7 @@ import re from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.shared.i18nRegistry import normalizePrimaryLanguageTag -from modules.workflows.automation2.executors.actionNodeExecutor import coerceDocumentDataToBytes +from modules.shared.documentUtils import coerceDocumentDataToBytes from modules.workflows.methods.methodAi._common import is_image_action_document_list from modules.workflows.methods.methodContext.actions.extractContent import ( presentation_envelopes_to_document_json, diff --git a/modules/workflows/scheduler/mainScheduler.py b/modules/workflows/scheduler/mainScheduler.py index 9af9889f..11544015 100644 --- a/modules/workflows/scheduler/mainScheduler.py +++ b/modules/workflows/scheduler/mainScheduler.py @@ -93,9 +93,9 @@ class WorkflowScheduler: activeWorkflowIds.add(workflowId) cron = item.get("cron") mandateId = item.get("mandateId") - instanceId = item.get("featureInstanceId") + instanceId = item.get("featureInstanceId") or "" - if not instanceId or not cron: + if not cron: continue jobId = f"{JOB_ID_PREFIX}{workflowId}" From 9be2d8aab59fb850b4488894b401883e538df078 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Mon, 8 Jun 2026 10:31:17 +0200 Subject: [PATCH 06/16] refactory workflowAutomation completed as system component reolacing automation2 and graphEditor --- app.py | 18 +- modules/datamodels/datamodelChat.py | 2 +- modules/datamodels/datamodelNavigation.py | 8 - modules/demoConfigs/investorDemo2026.py | 18 +- modules/demoConfigs/pwgDemo2026.py | 37 +- modules/features/graphicalEditor/__init__.py | 2 - .../datamodelFeatureGraphicalEditor.py | 25 - .../routeFeatureGraphicalEditor.py | 1880 ----------------- modules/interfaces/interfaceBootstrap.py | 9 +- modules/interfaces/interfaceDbApp.py | 7 + modules/interfaces/interfaceDbChat.py | 2 +- modules/interfaces/interfaceDbManagement.py | 2 +- modules/interfaces/interfaceFeatures.py | 10 +- modules/interfaces/interfaceRbac.py | 18 +- .../interfaceWorkflowAutomation.py} | 72 +- modules/routes/routeAdminFeatures.py | 4 +- modules/routes/routeAutomationWorkspace.py | 309 --- modules/routes/routeSystem.py | 2 +- modules/routes/routeWorkflowAutomation.py | 1780 ++++++++++++++-- modules/routes/routeWorkflowDashboard.py | 1293 ------------ .../services/serviceAgent/datamodelAgent.py | 2 +- .../services/serviceAgent/toolboxRegistry.py | 2 +- .../services/serviceAgent/workflowTools.py | 19 +- ...ubscriptionWorkflowAutomationRunFailed.py} | 6 +- modules/shared/workflowAutomationHelpers.py | 624 ++++++ modules/system/i18nBootSync.py | 10 +- modules/system/mainSystem.py | 4 +- modules/workflowAutomation/__init__.py | 8 + modules/workflowAutomation/editor/__init__.py | 5 + .../editor}/_workflowFileSchema.py | 4 +- .../editor}/adapterValidator.py | 2 +- .../editor}/conditionOperators.py | 4 +- .../editor}/entryPoints.py | 2 +- .../editor}/nodeAdapter.py | 0 .../editor}/nodeDefinitions/__init__.py | 0 .../editor}/nodeDefinitions/ai.py | 4 +- .../editor}/nodeDefinitions/clickup.py | 2 +- .../editor}/nodeDefinitions/context.py | 16 +- .../nodeDefinitions/contextPickerHelp.py | 8 +- .../editor}/nodeDefinitions/data.py | 2 +- .../editor}/nodeDefinitions/email.py | 4 +- .../editor}/nodeDefinitions/file.py | 7 +- .../editor}/nodeDefinitions/flow.py | 16 +- .../editor}/nodeDefinitions/input.py | 2 +- .../editor}/nodeDefinitions/redmine.py | 2 +- .../editor}/nodeDefinitions/sharepoint.py | 2 +- .../editor}/nodeDefinitions/triggers.py | 2 +- .../editor}/nodeDefinitions/trustee.py | 8 +- .../editor}/nodeRegistry.py | 14 +- .../editor}/portTypes.py | 2 +- .../editor}/switchOutput.py | 2 +- .../editor}/upstreamPathsService.py | 8 +- modules/workflowAutomation/engine/__init__.py | 2 + .../engine}/clickupTaskUpdateMerge.py | 0 .../engine}/executionEngine.py | 36 +- .../engine/executors/__init__.py | 18 + .../engine}/executors/actionNodeExecutor.py | 22 +- .../engine}/executors/dataExecutor.py | 2 +- .../engine}/executors/flowExecutor.py | 14 +- .../engine}/executors/inputExecutor.py | 2 +- .../engine}/executors/ioExecutor.py | 2 +- .../engine}/executors/triggerExecutor.py | 2 +- .../engine}/featureInstanceRefMigration.py | 0 .../engine}/graphUtils.py | 16 +- .../engine}/pickNotPushMigration.py | 6 +- .../engine}/runEnvelope.py | 0 .../engine/runFileLogger.py} | 8 +- .../engine}/scheduleCron.py | 0 .../engine}/udmUpstreamShapes.py | 0 .../engine}/workflowArtifactVisibility.py | 0 .../mainWorkflowAutomation.py} | 203 +- .../workflowAutomation/scheduler/__init__.py | 11 + .../scheduler}/emailPoller.py | 12 +- .../scheduler/mainScheduler.py | 32 +- modules/workflows/automation2/__init__.py | 13 +- .../automation2/executors/__init__.py | 17 +- .../methods/_actionSignatureValidator.py | 2 +- modules/workflows/methods/methodBase.py | 2 +- .../methodContext/actions/setContext.py | 2 +- .../processing/shared/parameterValidation.py | 2 +- modules/workflows/scheduler/__init__.py | 11 +- tests/demo/test_demo_bootstrap.py | 4 +- tests/demo/test_demo_uc1_trustee.py | 2 +- tests/demo/test_pwg_demo_bootstrap.py | 17 +- .../test_pick_not_push_migration_v2.py | 6 +- .../trustee/test_spesenbelege_workflow_e2e.py | 4 +- ...xecute_graph_loop_aggregate_consolidate.py | 8 +- .../test_action_node_connection_provenance.py | 2 +- .../graphicalEditor/test_adapter_validator.py | 6 +- .../test_condition_operator_catalog.py | 2 +- ...est_featureInstanceRef_node_definitions.py | 4 +- .../unit/graphicalEditor/test_node_adapter.py | 2 +- .../graphicalEditor/test_portTypes_catalog.py | 2 +- .../test_port_schema_recursive.py | 2 +- .../test_resolve_value_kind.py | 2 +- .../test_route_options_feature_instance.py | 66 - .../test_upstream_paths_and_graph_schema.py | 6 +- .../test_trustee_schema_compliance.py | 8 +- .../unit/nodeDefinitions/test_usesai_flag.py | 2 +- .../serviceAgent/test_workflow_tools_crud.py | 2 +- .../workflow/test_extract_content_handover.py | 2 +- .../workflow/test_flow_executor_conditions.py | 2 +- tests/unit/workflow/test_node_combinations.py | 18 +- .../unit/workflow/test_phase3_context_node.py | 10 +- .../workflow/test_phase4_workflow_nodes.py | 22 +- tests/unit/workflow/test_phase5_highvol.py | 10 +- .../workflow/test_switch_filtered_output.py | 10 +- .../unit/workflow/test_workflowFileSchema.py | 2 +- .../workflows/test_automation2_graphUtils.py | 14 +- .../test_featureInstanceRefMigration.py | 4 +- tests/unit/workflows/test_trigger_executor.py | 4 +- 111 files changed, 2726 insertions(+), 4247 deletions(-) delete mode 100644 modules/features/graphicalEditor/__init__.py delete mode 100644 modules/features/graphicalEditor/datamodelFeatureGraphicalEditor.py delete mode 100644 modules/features/graphicalEditor/routeFeatureGraphicalEditor.py rename modules/{features/graphicalEditor/interfaceFeatureGraphicalEditor.py => interfaces/interfaceWorkflowAutomation.py} (91%) delete mode 100644 modules/routes/routeAutomationWorkspace.py delete mode 100644 modules/routes/routeWorkflowDashboard.py rename modules/serviceCenter/services/serviceMessaging/subscriptions/{subSubscriptionGraphicalEditorRunFailed.py => subSubscriptionWorkflowAutomationRunFailed.py} (91%) create mode 100644 modules/shared/workflowAutomationHelpers.py create mode 100644 modules/workflowAutomation/__init__.py create mode 100644 modules/workflowAutomation/editor/__init__.py rename modules/{features/graphicalEditor => workflowAutomation/editor}/_workflowFileSchema.py (98%) rename modules/{features/graphicalEditor => workflowAutomation/editor}/adapterValidator.py (99%) rename modules/{features/graphicalEditor => workflowAutomation/editor}/conditionOperators.py (99%) rename modules/{features/graphicalEditor => workflowAutomation/editor}/entryPoints.py (98%) rename modules/{features/graphicalEditor => workflowAutomation/editor}/nodeAdapter.py (100%) rename modules/{features/graphicalEditor => workflowAutomation/editor}/nodeDefinitions/__init__.py (100%) rename modules/{features/graphicalEditor => workflowAutomation/editor}/nodeDefinitions/ai.py (99%) rename modules/{features/graphicalEditor => workflowAutomation/editor}/nodeDefinitions/clickup.py (99%) rename modules/{features/graphicalEditor => workflowAutomation/editor}/nodeDefinitions/context.py (93%) rename modules/{features/graphicalEditor => workflowAutomation/editor}/nodeDefinitions/contextPickerHelp.py (78%) rename modules/{features/graphicalEditor => workflowAutomation/editor}/nodeDefinitions/data.py (97%) rename modules/{features/graphicalEditor => workflowAutomation/editor}/nodeDefinitions/email.py (96%) rename modules/{features/graphicalEditor => workflowAutomation/editor}/nodeDefinitions/file.py (86%) rename modules/{features/graphicalEditor => workflowAutomation/editor}/nodeDefinitions/flow.py (93%) rename modules/{features/graphicalEditor => workflowAutomation/editor}/nodeDefinitions/input.py (98%) rename modules/{features/graphicalEditor => workflowAutomation/editor}/nodeDefinitions/redmine.py (98%) rename modules/{features/graphicalEditor => workflowAutomation/editor}/nodeDefinitions/sharepoint.py (99%) rename modules/{features/graphicalEditor => workflowAutomation/editor}/nodeDefinitions/triggers.py (96%) rename modules/{features/graphicalEditor => workflowAutomation/editor}/nodeDefinitions/trustee.py (93%) rename modules/{features/graphicalEditor => workflowAutomation/editor}/nodeRegistry.py (92%) rename modules/{features/graphicalEditor => workflowAutomation/editor}/portTypes.py (99%) rename modules/{features/graphicalEditor => workflowAutomation/editor}/switchOutput.py (99%) rename modules/{features/graphicalEditor => workflowAutomation/editor}/upstreamPathsService.py (95%) create mode 100644 modules/workflowAutomation/engine/__init__.py rename modules/{workflows/automation2 => workflowAutomation/engine}/clickupTaskUpdateMerge.py (100%) rename modules/{workflows/automation2 => workflowAutomation/engine}/executionEngine.py (98%) create mode 100644 modules/workflowAutomation/engine/executors/__init__.py rename modules/{workflows/automation2 => workflowAutomation/engine}/executors/actionNodeExecutor.py (97%) rename modules/{workflows/automation2 => workflowAutomation/engine}/executors/dataExecutor.py (99%) rename modules/{workflows/automation2 => workflowAutomation/engine}/executors/flowExecutor.py (96%) rename modules/{workflows/automation2 => workflowAutomation/engine}/executors/inputExecutor.py (95%) rename modules/{workflows/automation2 => workflowAutomation/engine}/executors/ioExecutor.py (95%) rename modules/{workflows/automation2 => workflowAutomation/engine}/executors/triggerExecutor.py (94%) rename modules/{workflows/automation2 => workflowAutomation/engine}/featureInstanceRefMigration.py (100%) rename modules/{workflows/automation2 => workflowAutomation/engine}/graphUtils.py (97%) rename modules/{workflows/automation2 => workflowAutomation/engine}/pickNotPushMigration.py (97%) rename modules/{workflows/automation2 => workflowAutomation/engine}/runEnvelope.py (100%) rename modules/{workflows/automation2/graphicalEditorRunFileLogger.py => workflowAutomation/engine/runFileLogger.py} (97%) rename modules/{workflows/automation2 => workflowAutomation/engine}/scheduleCron.py (100%) rename modules/{workflows/automation2 => workflowAutomation/engine}/udmUpstreamShapes.py (100%) rename modules/{workflows/automation2 => workflowAutomation/engine}/workflowArtifactVisibility.py (100%) rename modules/{features/graphicalEditor/mainGraphicalEditor.py => workflowAutomation/mainWorkflowAutomation.py} (72%) create mode 100644 modules/workflowAutomation/scheduler/__init__.py rename modules/{features/graphicalEditor => workflowAutomation/scheduler}/emailPoller.py (94%) rename modules/{workflows => workflowAutomation}/scheduler/mainScheduler.py (91%) delete mode 100644 tests/unit/graphicalEditor/test_route_options_feature_instance.py diff --git a/app.py b/app.py index d8104fad..2ecf3ad5 100644 --- a/app.py +++ b/app.py @@ -432,7 +432,7 @@ async def lifespan(app: FastAPI): try: main_loop = asyncio.get_running_loop() eventManager.set_event_loop(main_loop) - from modules.workflows.scheduler.mainScheduler import setMainLoop as setSchedulerMainLoop, setOnRunFailedCallback + from modules.workflowAutomation.scheduler.mainScheduler import setMainLoop as setSchedulerMainLoop, setOnRunFailedCallback setSchedulerMainLoop(main_loop) # Inject run-failed notification callback (Composition Root — avoids workflows→serviceCenter import) @@ -452,10 +452,10 @@ async def lifespan(app: FastAPI): user=eventUser, mandate_id=mandateId or "", feature_instance_id="", - feature_code="graphicalEditor", + feature_code="workflowAutomation", ) messagingService = getService("messaging", ctx) - subscriptionId = "GraphicalEditorRunFailed" + subscriptionId = "WorkflowAutomationRunFailed" eventParams = MessagingEventParameters(triggerData={ "workflowId": workflowId, "workflowLabel": workflowLabel or workflowId, @@ -484,7 +484,7 @@ async def lifespan(app: FastAPI): # --- WorkflowAutomation: Scheduler boot (System-Lifespan, not Feature-onStart) --- try: - from modules.workflows.scheduler.mainScheduler import start as _startWorkflowScheduler + from modules.workflowAutomation.scheduler.mainScheduler import start as _startWorkflowScheduler _startWorkflowScheduler(eventUser) logger.info("WorkflowAutomation scheduler started (system lifespan)") except Exception as e: @@ -572,12 +572,12 @@ async def lifespan(app: FastAPI): # 3.5 Stop WorkflowAutomation scheduler + email poller (System-Lifespan) try: - from modules.workflows.scheduler.mainScheduler import stop as _stopWorkflowScheduler + from modules.workflowAutomation.scheduler.mainScheduler import stop as _stopWorkflowScheduler _stopWorkflowScheduler() except Exception as e: logger.warning(f"WorkflowAutomation scheduler stop failed: {e}") try: - from modules.features.graphicalEditor.emailPoller import stop as _stopEmailPoller + from modules.workflowAutomation.scheduler.emailPoller import stop as _stopEmailPoller _stopEmailPoller(eventUser) except Exception as e: logger.warning(f"Email poller stop failed: {e}") @@ -863,12 +863,6 @@ from modules.routes.routeSystem import router as systemRouter, navigationRouter app.include_router(systemRouter) app.include_router(navigationRouter) -from modules.routes.routeWorkflowDashboard import router as workflowDashboardRouter -app.include_router(workflowDashboardRouter) - -from modules.routes.routeAutomationWorkspace import router as automationWorkspaceRouter -app.include_router(automationWorkspaceRouter) - from modules.routes.routeWorkflowAutomation import router as workflowAutomationRouter app.include_router(workflowAutomationRouter) diff --git a/modules/datamodels/datamodelChat.py b/modules/datamodels/datamodelChat.py index 93eae82a..7b4e21eb 100644 --- a/modules/datamodels/datamodelChat.py +++ b/modules/datamodels/datamodelChat.py @@ -131,7 +131,7 @@ class ChatWorkflow(PowerOnModel): None, description=( "Optional foreign key linking this chat to an entity outside the " - "ChatWorkflow table (e.g. an Automation2Workflow in the GraphicalEditor " + "ChatWorkflow table (e.g. an Automation2Workflow in WorkflowAutomation " "AI editor chat). NULL for the default workspace chats. Combined with " "featureInstanceId this gives a 1:1 relation entity ↔ chat per feature." ), diff --git a/modules/datamodels/datamodelNavigation.py b/modules/datamodels/datamodelNavigation.py index eb9d3b69..22f851c8 100644 --- a/modules/datamodels/datamodelNavigation.py +++ b/modules/datamodels/datamodelNavigation.py @@ -120,14 +120,6 @@ NAVIGATION_SECTIONS = [ "path": "/billing/transactions", "order": 20, }, - { - "id": "automations", - "objectKey": "ui.system.automations", - "label": t("Automations"), - "icon": "FaRobot", - "path": "/automations", - "order": 30, - }, { "id": "rag-inventory", "objectKey": "ui.system.ragInventory", diff --git a/modules/demoConfigs/investorDemo2026.py b/modules/demoConfigs/investorDemo2026.py index 0f7b0863..62b523d1 100644 --- a/modules/demoConfigs/investorDemo2026.py +++ b/modules/demoConfigs/investorDemo2026.py @@ -44,7 +44,6 @@ _USER = { _FEATURES_HAPPYLIFE = [ {"code": "workspace", "label": "Dokumentenablage"}, {"code": "trustee", "label": "Buchhaltung"}, - {"code": "graphicalEditor", "label": "Automationen"}, # DEPRECATED: migrated to WorkflowAutomation system component {"code": "neutralization", "label": "Datenschutz"}, ] _FEATURES_ALPINA = [ @@ -52,7 +51,6 @@ _FEATURES_ALPINA = [ {"code": "trustee", "label": "BUHA Müller Immobilien GmbH"}, {"code": "trustee", "label": "BUHA Schneider Gastro AG"}, {"code": "trustee", "label": "BUHA Weber Consulting"}, - {"code": "graphicalEditor", "label": "Automationen"}, # DEPRECATED: migrated to WorkflowAutomation system component {"code": "neutralization", "label": "Datenschutz"}, ] @@ -492,8 +490,8 @@ class InvestorDemo2026(BaseDemoConfig): if not instId: continue - if featureCode == "graphicalEditor": - self._removeGraphicalEditorData(instId, mandateId, mandateLabel, summary) + if featureCode == "workflowAutomation": + self._removeWorkflowAutomationData(instId, mandateId, mandateLabel, summary) if featureCode == "trustee": self._removeTrusteeData(db, instId, mandateLabel, summary) @@ -551,10 +549,10 @@ class InvestorDemo2026(BaseDemoConfig): except Exception as e: summary["errors"].append(f"Billing cleanup for {mandateLabel}: {e}") - def _removeGraphicalEditorData(self, featureInstanceId: str, mandateId: str, mandateLabel: str, summary: Dict): - """Remove all AutoWorkflow data (workflows, runs, versions, logs, tasks) from the Greenfield DB.""" + def _removeWorkflowAutomationData(self, featureInstanceId: str, mandateId: str, mandateLabel: str, summary: Dict): + """Remove all AutoWorkflow data (workflows, runs, versions, logs, tasks) from the WorkflowAutomation DB.""" try: - from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import ( + from modules.datamodels.datamodelWorkflowAutomation import ( AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask, ) from modules.connectors.connectorDbPostgre import DatabaseConnector @@ -596,10 +594,10 @@ class InvestorDemo2026(BaseDemoConfig): if workflows: summary["removed"].append(f"{len(workflows)} AutoWorkflows in {mandateLabel}") - logger.info(f"Removed {len(workflows)} graphical editor workflows for {mandateLabel}") + logger.info(f"Removed {len(workflows)} automation workflows for {mandateLabel}") except Exception as e: - summary["errors"].append(f"GraphicalEditor cleanup for {mandateLabel}: {e}") - logger.error(f"Failed to clean up graphical editor data for {mandateLabel}: {e}") + summary["errors"].append(f"WorkflowAutomation cleanup for {mandateLabel}: {e}") + logger.error(f"Failed to clean up workflow automation data for {mandateLabel}: {e}") def _removeTrusteeData(self, db, featureInstanceId: str, mandateLabel: str, summary: Dict): """Remove TrusteeAccountingConfig for a feature instance.""" diff --git a/modules/demoConfigs/pwgDemo2026.py b/modules/demoConfigs/pwgDemo2026.py index 6e21e45a..c2c196af 100644 --- a/modules/demoConfigs/pwgDemo2026.py +++ b/modules/demoConfigs/pwgDemo2026.py @@ -4,8 +4,7 @@ Bootstraps a complete PWG-Pilot demo environment in an empty dev/demo install: - 1 mandate "Stiftung PWG" - 1 SysAdmin demo user "pwg.demo" - - 4 features: workspace, trustee (BUHA PWG), graphicalEditor (PWG Automationen), - neutralization (Datenschutz) + - 3 features: workspace, trustee (BUHA PWG), neutralization (Datenschutz) - Trustee seed-data (5 fictitious tenants with monthly rent journal lines for the current year, loaded from ``demoData/pwg/_seedTrusteeData.json``) - Pilot workflow imported from @@ -49,7 +48,6 @@ _USER = { _FEATURES_PWG = [ {"code": "workspace", "label": "Dokumentenablage PWG"}, {"code": "trustee", "label": "Buchhaltung PWG"}, - {"code": "graphicalEditor", "label": "PWG Automationen"}, # DEPRECATED: migrated to WorkflowAutomation system component {"code": "neutralization", "label": "Datenschutz"}, ] @@ -98,9 +96,6 @@ class PwgDemo2026(BaseDemoConfig): if trusteeInstanceId: self._ensureTrusteeSeed(mandateId, trusteeInstanceId, summary) - graphInstanceId = self._getFeatureInstanceId(db, mandateId, "graphicalEditor", "PWG Automationen") - if graphInstanceId: - self._ensurePilotWorkflow(mandateId, graphInstanceId, summary) except Exception as e: logger.error(f"PWG demo load failed: {e}", exc_info=True) @@ -542,11 +537,11 @@ class PwgDemo2026(BaseDemoConfig): summary["skipped"].append(f"PWG seed: {skippedTenants} tenants already present") def _ensurePilotWorkflow(self, mandateId: str, featureInstanceId: str, summary: Dict): - """Import the pilot workflow JSON into the graphical-editor DB. + """Import the pilot workflow JSON into the WorkflowAutomation DB. Uses the schema-aware import pipeline introduced in Phase 1 (``_workflowFileSchema.envelopeToWorkflowData`` + - ``GraphicalEditorObjects.importWorkflowFromDict``). The workflow is + ``WorkflowAutomationObjects.importWorkflowFromDict``). The workflow is always created with ``active=False`` so a manual trigger is required — this matches the demo-bootstrap safety default. """ @@ -561,17 +556,17 @@ class PwgDemo2026(BaseDemoConfig): return try: - geDb = _openGraphicalEditorDb() + geDb = _openWorkflowAutomationDb() except Exception as exc: - summary["errors"].append(f"GraphicalEditor DB connection failed: {exc}") + summary["errors"].append(f"WorkflowAutomation DB connection failed: {exc}") return - from modules.features.graphicalEditor._workflowFileSchema import ( + from modules.workflowAutomation.editor._workflowFileSchema import ( envelopeToWorkflowData, validateFileEnvelope, ) - from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow - from modules.features.graphicalEditor.nodeRegistry import STATIC_NODE_TYPES + from modules.datamodels.datamodelWorkflowAutomation import AutoWorkflow + from modules.workflowAutomation.editor.nodeRegistry import STATIC_NODE_TYPES existing = geDb.getRecordset(AutoWorkflow, recordFilter={ "mandateId": mandateId, @@ -625,7 +620,7 @@ class PwgDemo2026(BaseDemoConfig): ) created = geDb.recordCreate(AutoWorkflow, record) summary["created"].append(f"Pilot workflow imported (active=false, id={created.get('id')})") - logger.info(f"Imported pilot workflow into graphicalEditor instance {featureInstanceId}") + logger.info(f"Imported pilot workflow into workflowAutomation instance {featureInstanceId}") def _guessTrusteeInstanceId(self, mandateId: str) -> Optional[str]: """Return the first trustee feature-instance id of the given mandate. @@ -678,8 +673,8 @@ class PwgDemo2026(BaseDemoConfig): if not instId: continue - if featureCode == "graphicalEditor": - self._removeGraphicalEditorData(instId, mandateId, mandateLabel, summary) + if featureCode == "workflowAutomation": + self._removeWorkflowAutomationData(instId, mandateId, mandateLabel, summary) if featureCode == "trustee": self._removeTrusteeSeed(instId, mandateLabel, summary) if featureCode == "neutralization": @@ -724,16 +719,16 @@ class PwgDemo2026(BaseDemoConfig): except Exception as e: summary["errors"].append(f"Billing cleanup for {mandateLabel}: {e}") - def _removeGraphicalEditorData(self, featureInstanceId: str, mandateId: str, mandateLabel: str, summary: Dict): + def _removeWorkflowAutomationData(self, featureInstanceId: str, mandateId: str, mandateLabel: str, summary: Dict): try: - from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import ( + from modules.datamodels.datamodelWorkflowAutomation import ( AutoRun, AutoStepLog, AutoTask, AutoVersion, AutoWorkflow, ) - geDb = _openGraphicalEditorDb() + geDb = _openWorkflowAutomationDb() workflows = geDb.getRecordset(AutoWorkflow, recordFilter={ "mandateId": mandateId, "featureInstanceId": featureInstanceId, @@ -753,7 +748,7 @@ class PwgDemo2026(BaseDemoConfig): if workflows: summary["removed"].append(f"{len(workflows)} AutoWorkflows in {mandateLabel}") except Exception as e: - summary["errors"].append(f"GraphicalEditor cleanup for {mandateLabel}: {e}") + summary["errors"].append(f"WorkflowAutomation cleanup for {mandateLabel}: {e}") def _removeTrusteeSeed(self, featureInstanceId: str, mandateLabel: str, summary: Dict): try: @@ -818,7 +813,7 @@ def _openTrusteeDb(): ) -def _openGraphicalEditorDb(): +def _openWorkflowAutomationDb(): """Open a privileged DB connection to ``poweron_graphicaleditor``.""" from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.shared.configuration import APP_CONFIG diff --git a/modules/features/graphicalEditor/__init__.py b/modules/features/graphicalEditor/__init__.py deleted file mode 100644 index bb8c0a4b..00000000 --- a/modules/features/graphicalEditor/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# GraphicalEditor feature - n8n-style flow automation with visual editor diff --git a/modules/features/graphicalEditor/datamodelFeatureGraphicalEditor.py b/modules/features/graphicalEditor/datamodelFeatureGraphicalEditor.py deleted file mode 100644 index 1e701716..00000000 --- a/modules/features/graphicalEditor/datamodelFeatureGraphicalEditor.py +++ /dev/null @@ -1,25 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -"""GraphicalEditor models — re-exports from canonical datamodels.datamodelWorkflowAutomation.""" - -# All models and enums re-exported for backward compatibility. -# Canonical location: modules.datamodels.datamodelWorkflowAutomation -from modules.datamodels.datamodelWorkflowAutomation import ( # noqa: F401 - AutoWorkflowStatus, - AutoRunStatus, - AutoStepStatus, - AutoTaskStatus, - AutoTemplateScope, - GRAPHICAL_EDITOR_DATABASE, - AutoWorkflow, - AutoVersion, - AutoRun, - AutoStepLog, - AutoTask, - Automation2Workflow, - Automation2WorkflowRun, - Automation2HumanTask, -) - -# Legacy alias -graphicalEditorDatabase = GRAPHICAL_EDITOR_DATABASE diff --git a/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py b/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py deleted file mode 100644 index 38d9d769..00000000 --- a/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py +++ /dev/null @@ -1,1880 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -""" -DEPRECATED: These per-instance routes are superseded by /api/workflow-automation/ -(routeWorkflowAutomation.py). Kept for backward compatibility during migration. - -Original: GraphicalEditor routes - node-types, execute, workflows, runs, tasks, connections, browse. -""" - -import asyncio -import json -import logging -import math -import uuid -from typing import Any, Dict, List, Optional - -from fastapi import APIRouter, Depends, Path, Query, Body, Request, HTTPException -from fastapi.responses import JSONResponse, StreamingResponse, Response -from modules.auth import limiter, getRequestContext, RequestContext -from modules.datamodels.datamodelPagination import PaginationParams, PaginationMetadata, normalize_pagination_dict -from modules.dbHelpers.paginationHelpers import applyFiltersAndSort - -from modules.features.graphicalEditor.mainGraphicalEditor import getGraphicalEditorServices -from modules.features.graphicalEditor.nodeRegistry import getNodeTypesForApi -from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface -from modules.workflows.automation2.executionEngine import executeGraph -from modules.workflows.automation2.runEnvelope import ( - default_run_envelope, - merge_run_envelope, - normalize_run_envelope, -) -from modules.features.graphicalEditor.entryPoints import find_invocation -from modules.features.graphicalEditor.conditionOperators import resolve_condition_meta -from modules.features.graphicalEditor.upstreamPathsService import compute_upstream_paths, compute_graph_data_sources -from modules.shared.i18nRegistry import apiRouteContext, resolveText -routeApiMsg = apiRouteContext("routeFeatureGraphicalEditor") - -logger = logging.getLogger(__name__) - - -def _build_execute_run_envelope( - body: Dict[str, Any], - workflow: Optional[Dict[str, Any]], - user_id: Optional[str], - requestLang: Optional[str] = None, -) -> Dict[str, Any]: - """Build normalized run envelope from POST /execute body.""" - if isinstance(body.get("runEnvelope"), dict): - env = normalize_run_envelope(body["runEnvelope"], user_id=user_id) - pl = body.get("payload") - if isinstance(pl, dict): - env = merge_run_envelope(env, {"payload": pl}) - return env - - entry_point_id = body.get("entryPointId") - if entry_point_id: - if not workflow: - raise HTTPException( - status_code=400, - detail=routeApiMsg("entryPointId requires a saved workflow (workflowId must refer to a stored workflow)"), - ) - inv = find_invocation(workflow, entry_point_id) - if not inv: - raise HTTPException(status_code=400, detail=routeApiMsg("entryPointId not found on workflow")) - if not inv.get("enabled", True): - raise HTTPException(status_code=400, detail=routeApiMsg("entry point is disabled")) - kind = inv.get("kind", "manual") - trig_map = { - "manual": "manual", - "form": "form", - "schedule": "schedule", - "always_on": "event", - "email": "email", - "webhook": "webhook", - "api": "api", - "event": "event", - } - trig = trig_map.get(kind, "manual") - title = inv.get("title") or {} - label = resolveText(title) - base = default_run_envelope( - trig, - entry_point_id=inv.get("id"), - entry_point_label=label or None, - ) - pl = body.get("payload") - if isinstance(pl, dict): - base = merge_run_envelope(base, {"payload": pl}) - return normalize_run_envelope(base, user_id=user_id) - - env = normalize_run_envelope(None, user_id=user_id) - pl = body.get("payload") - if isinstance(pl, dict): - env = merge_run_envelope(env, {"payload": pl}) - return env - -router = APIRouter( - prefix="/api/workflows", - tags=["GraphicalEditor"], - responses={404: {"description": "Not found"}, 403: {"description": "Forbidden"}}, -) - - -def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str: - """Validate user has access to the graphicalEditor feature instance. Returns mandateId.""" - from fastapi import HTTPException - from modules.interfaces.interfaceDbApp import getRootInterface - - rootInterface = getRootInterface() - instance = rootInterface.getFeatureInstance(instanceId) - if not instance: - raise HTTPException(status_code=404, detail=f"Feature instance {instanceId} not found") - featureAccess = rootInterface.getFeatureAccess(str(context.user.id), instanceId) - if not featureAccess or not featureAccess.enabled: - raise HTTPException(status_code=403, detail=routeApiMsg("Access denied to this feature instance")) - return str(instance.mandateId) if instance.mandateId else "" - - -def _validateTargetInstance( - workflowData: Dict[str, Any], - ownerInstanceId: str, - context: RequestContext, -) -> None: - """Enforce targetFeatureInstanceId rules for non-template workflows. - - - Templates (isTemplate=True) may omit targetFeatureInstanceId. - - Non-templates MUST have a non-empty targetFeatureInstanceId. - - If the targetFeatureInstanceId differs from the GE owner instance, - the user must also have FeatureAccess on that target instance. - """ - if workflowData.get("isTemplate"): - return - - targetId = workflowData.get("targetFeatureInstanceId") - if not targetId: - return - - if targetId == ownerInstanceId: - return - - from modules.interfaces.interfaceDbApp import getRootInterface - rootInterface = getRootInterface() - targetInstance = rootInterface.getFeatureInstance(targetId) - if not targetInstance: - raise HTTPException( - status_code=400, - detail=routeApiMsg("targetFeatureInstanceId refers to a non-existent feature instance"), - ) - targetAccess = rootInterface.getFeatureAccess(str(context.user.id), targetId) - if not targetAccess or not targetAccess.enabled: - raise HTTPException( - status_code=403, - detail=routeApiMsg("Access denied to target feature instance"), - ) - - -@router.get("/{instanceId}/node-types") -@limiter.limit("60/minute") -def get_node_types( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - language: str = Query("en", description="Localization (en, de, fr)"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Return node types for the flow builder: static + I/O from methodDiscovery.""" - logger.info("graphicalEditor node-types request: instanceId=%s language=%s", instanceId, language) - mandateId = _validateInstanceAccess(instanceId, context) - services = getGraphicalEditorServices( - context.user, - mandateId=mandateId, - featureInstanceId=instanceId, - ) - result = getNodeTypesForApi(services, language=language) - logger.info( - "graphicalEditor node-types response: %d nodeTypes %d categories", - len(result.get("nodeTypes", [])), - len(result.get("categories", [])), - ) - return result - - -@router.post("/{instanceId}/upstream-paths") -@limiter.limit("60/minute") -def post_upstream_paths( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - body: Dict[str, Any] = Body(...), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Return pickable upstream DataRef paths for a node (draft graph in body).""" - _validateInstanceAccess(instanceId, context) - graph = body.get("graph") - node_id = body.get("nodeId") - if not isinstance(graph, dict) or not node_id: - raise HTTPException(status_code=400, detail=routeApiMsg("graph and nodeId are required")) - paths = compute_upstream_paths(graph, str(node_id)) - return {"paths": paths} - - -@router.post("/{instanceId}/condition-meta") -@limiter.limit("120/minute") -def post_condition_meta( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - body: Dict[str, Any] = Body(...), - language: str = Query("de", description="Localization (en, de, fr)"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Return valueKind and operators for a DataRef (backend-driven If/Else UI).""" - _validateInstanceAccess(instanceId, context) - graph = body.get("graph") - ref = body.get("ref") - node_id = body.get("nodeId") - if not isinstance(graph, dict) or not isinstance(ref, dict): - raise HTTPException(status_code=400, detail=routeApiMsg("graph and ref are required")) - graph_payload = dict(graph) - if node_id: - graph_payload["targetNodeId"] = str(node_id) - return resolve_condition_meta(graph_payload, ref, lang=language) - - -@router.post("/{instanceId}/graph-data-sources") -@limiter.limit("120/minute") -def post_graph_data_sources( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - body: Dict[str, Any] = Body(...), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Scope-aware data sources for the DataPicker. - - Takes ``{ nodeId, graph: { nodes, connections } }`` and returns:: - - { - "availableSourceIds": [...], # ancestors minus loop-body nodes on Done branch - "portIndexOverrides": {nodeId: n}, # use outputPorts[n] instead of 0 - "loopBodyContextIds": [...], # loops whose body the node is in - } - - All loop scope logic lives here so the frontend has zero topology knowledge. - """ - _validateInstanceAccess(instanceId, context) - graph = body.get("graph") - node_id = body.get("nodeId") - if not isinstance(graph, dict) or not node_id: - raise HTTPException(status_code=400, detail=routeApiMsg("graph and nodeId are required")) - return compute_graph_data_sources(graph, str(node_id)) - - -@router.get("/{instanceId}/upstream-paths/{node_id}") -@limiter.limit("60/minute") -def get_upstream_paths_saved( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - node_id: str = Path(..., description="Target node id"), - workflowId: str = Query(..., description="Workflow id whose saved graph is used"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Return upstream paths using the persisted workflow graph (same payload as POST variant).""" - mandate_id = _validateInstanceAccess(instanceId, context) - if not workflowId: - raise HTTPException(status_code=400, detail=routeApiMsg("workflowId is required")) - from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface - - iface = getGraphicalEditorInterface(context.user, mandate_id, featureInstanceId=instanceId) - wf = iface.getWorkflow(workflowId) - if not wf: - raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) - graph = wf.get("graph") or {} - paths = compute_upstream_paths(graph if isinstance(graph, dict) else {}, str(node_id)) - return {"paths": paths} - - -@router.get("/{instanceId}/options/user.connection") -@limiter.limit("60/minute") -def get_user_connection_options( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - authority: Optional[str] = Query(None, description="Optional authority filter (e.g. 'msft', 'google', 'clickup', 'local')"), - activeOnly: bool = Query(True, description="If true (default), only ACTIVE connections are returned"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Return current user's UserConnections as { options: [{ value, label }] }. - - Used by node parameters with frontendType='userConnection'. Optional - `authority` lets a node declare which provider it expects (e.g. SharePoint - nodes pass authority=msft so only Microsoft connections show up). - """ - _validateInstanceAccess(instanceId, context) - if not context.user: - raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) - from modules.interfaces.interfaceDbApp import getRootInterface - rootInterface = getRootInterface() - try: - connections = rootInterface.getUserConnections(str(context.user.id)) or [] - except Exception as e: - logger.error("get_user_connection_options: failed to load connections: %s", e, exc_info=True) - return {"options": []} - wanted = (authority or "").strip().lower() or None - options: List[Dict[str, str]] = [] - for conn in connections: - connStatus = getattr(conn, "status", None) - statusVal = connStatus.value if hasattr(connStatus, "value") else str(connStatus or "") - if activeOnly and statusVal.lower() != "active": - continue - connAuthority = getattr(conn, "authority", None) - authorityVal = (connAuthority.value if hasattr(connAuthority, "value") else str(connAuthority or "")).lower() - if wanted and authorityVal != wanted: - continue - username = getattr(conn, "externalUsername", "") or "" - email = getattr(conn, "externalEmail", "") or "" - connId = str(getattr(conn, "id", "") or "") - labelParts = [p for p in [username, email] if p] - label = " — ".join(labelParts) if labelParts else connId - if authorityVal: - label = f"[{authorityVal}] {label}" - value = f"connection:{authorityVal}:{username}" if authorityVal and username else connId - options.append({"value": value, "label": label}) - logger.info( - "graphicalEditor user.connection options: instanceId=%s authority=%s -> %d options", - instanceId, wanted, len(options), - ) - return {"options": options} - - -@router.get("/{instanceId}/options/feature.instance") -@limiter.limit("60/minute") -def get_feature_instance_options( - request: Request, - instanceId: str = Path(..., description="GraphicalEditor feature instance ID (workflow context)"), - featureCode: str = Query(..., description="Feature code to filter by (e.g. 'trustee', 'redmine', 'clickup')"), - enabledOnly: bool = Query(True, description="If true (default), only enabled feature instances are returned"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Return mandate-scoped FeatureInstances for the given featureCode. - - Used by node parameters with frontendType='featureInstance' (e.g. Trustee - or Redmine nodes that need to bind to a specific tenant FeatureInstance). - Always restricted to the calling user's mandate (derived from the workflow - feature instance) so the picker never leaks foreign-mandate instances. - - Response: { options: [ { value: "", label: " ([code])" } ] } - """ - mandateId = _validateInstanceAccess(instanceId, context) - if not context.user: - raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) - code = (featureCode or "").strip().lower() - if not code: - raise HTTPException(status_code=400, detail=routeApiMsg("featureCode query parameter is required")) - if not mandateId: - return {"options": []} - - from modules.interfaces.interfaceDbApp import getRootInterface - rootInterface = getRootInterface() - try: - instances = rootInterface.getFeatureInstancesByMandate( - mandateId, enabledOnly=bool(enabledOnly) - ) or [] - except Exception as e: - logger.error( - "get_feature_instance_options: failed to load instances mandateId=%s: %s", - mandateId, e, exc_info=True, - ) - return {"options": []} - - options: List[Dict[str, str]] = [] - for fi in instances: - fiCode = (getattr(fi, "featureCode", "") or "").strip().lower() - if fiCode != code: - continue - fiId = str(getattr(fi, "id", "") or "") - if not fiId: - continue - rawLabel = getattr(fi, "label", None) or getattr(fi, "name", None) or fiId - options.append({"value": fiId, "label": f"{rawLabel} ({fiCode})"}) - - logger.info( - "graphicalEditor feature.instance options: instanceId=%s mandateId=%s " - "featureCode=%s enabledOnly=%s -> %d options", - instanceId, mandateId, code, enabledOnly, len(options), - ) - return {"options": options} - - -@router.post("/{instanceId}/execute") -@limiter.limit("30/minute") -async def post_execute( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - body: dict = Body(..., description="{ workflowId?, graph: { nodes, connections } }"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Execute workflow graph. Body: { workflowId?, graph: { nodes, connections } }.""" - userId = str(context.user.id) if context.user else None - logger.info( - "graphicalEditor execute request: instanceId=%s userId=%s body_keys=%s", - instanceId, - userId, - list(body.keys()), - ) - mandateId = _validateInstanceAccess(instanceId, context) - services = getGraphicalEditorServices( - context.user, - mandateId=mandateId, - featureInstanceId=instanceId, - ) - from modules.workflows.processing.shared.methodDiscovery import discoverMethods - discoverMethods(services) - - graph = body.get("graph") or body - workflowId = body.get("workflowId") - req_nodes = graph.get("nodes") or [] - workflow_for_envelope: Optional[Dict[str, Any]] = None - targetFeatureInstanceId: Optional[str] = None - if workflowId and not str(workflowId).startswith("transient-"): - iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) - workflow_for_envelope = iface.getWorkflow(workflowId) - if workflow_for_envelope: - targetFeatureInstanceId = workflow_for_envelope.get("targetFeatureInstanceId") - if workflowId and len(req_nodes) == 0: - iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) - wf = iface.getWorkflow(workflowId) - if wf and wf.get("graph"): - graph = wf["graph"] - logger.info("graphicalEditor execute: loaded graph from workflow %s", workflowId) - workflow_for_envelope = wf - targetFeatureInstanceId = wf.get("targetFeatureInstanceId") - if not workflowId: - workflowId = f"transient-{uuid.uuid4().hex[:12]}" - logger.info("graphicalEditor execute: using transient workflowId=%s", workflowId) - - if targetFeatureInstanceId and targetFeatureInstanceId != instanceId: - _validateTargetInstance( - {"targetFeatureInstanceId": targetFeatureInstanceId}, - instanceId, - context, - ) - nodes_count = len(graph.get("nodes") or []) - connections_count = len(graph.get("connections") or []) - logger.info( - "graphicalEditor execute: graph nodes=%d connections=%d workflowId=%s mandateId=%s", - nodes_count, - connections_count, - workflowId, - mandateId, - ) - run_env = _build_execute_run_envelope( - body, - workflow_for_envelope, - userId, - getattr(context.user, "language", None) if context.user else None, - ) - - _wfLabel = None - if workflow_for_envelope: - _wfLabel = workflow_for_envelope.get("label") if isinstance(workflow_for_envelope, dict) else getattr(workflow_for_envelope, "label", None) - - ge_interface = getGraphicalEditorInterface(context.user, mandateId, instanceId) - result = await executeGraph( - graph=graph, - services=services, - workflowId=workflowId, - instanceId=instanceId, - userId=userId, - mandateId=mandateId, - automation2_interface=ge_interface, - run_envelope=run_env, - label=_wfLabel, - targetFeatureInstanceId=targetFeatureInstanceId, - ) - logger.info( - "graphicalEditor execute result: success=%s error=%s nodeOutputs_keys=%s failedNode=%s paused=%s", - result.get("success"), - result.get("error"), - list(result.get("nodeOutputs", {}).keys()) if result.get("nodeOutputs") else [], - result.get("failedNode"), - result.get("paused"), - ) - return result - - -# ------------------------------------------------------------------------- -# Run Tracing SSE Stream -# ------------------------------------------------------------------------- - - -@router.get("/{instanceId}/runs/{runId}/stream") -async def get_run_stream( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - runId: str = Path(..., description="Run ID"), - context: RequestContext = Depends(getRequestContext), -): - """SSE stream for live step-log updates during a workflow run.""" - _validateInstanceAccess(instanceId, context) - - from modules.serviceCenter.core.serviceStreaming.eventManager import get_event_manager - sseEventManager = get_event_manager() - queueId = f"run-trace-{runId}" - sseEventManager.create_queue(queueId) - - async def _sseGenerator(): - queue = sseEventManager.get_queue(queueId) - if not queue: - return - while True: - try: - event = await asyncio.wait_for(queue.get(), timeout=30) - except asyncio.TimeoutError: - yield "data: {\"type\": \"keepalive\"}\n\n" - continue - if event is None: - break - payload = event.get("data", event) if isinstance(event, dict) else event - yield f"data: {json.dumps(payload, default=str)}\n\n" - eventType = payload.get("type", "") if isinstance(payload, dict) else "" - if eventType in ("run_complete", "run_failed"): - break - await sseEventManager.cleanup(queueId, delay=10) - - return StreamingResponse( - _sseGenerator(), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - }, - ) - - -# ------------------------------------------------------------------------- -# Versions (AutoVersion Lifecycle) -# ------------------------------------------------------------------------- - - -@router.get("/{instanceId}/workflows/{workflowId}/versions") -@limiter.limit("60/minute") -def get_versions( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - workflowId: str = Path(..., description="Workflow ID"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """List all versions for a workflow.""" - mandateId = _validateInstanceAccess(instanceId, context) - iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) - versions = iface.getVersions(workflowId) - return {"versions": versions} - - -@router.post("/{instanceId}/workflows/{workflowId}/versions/draft") -@limiter.limit("30/minute") -def create_draft_version( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - workflowId: str = Path(..., description="Workflow ID"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Create a new draft version from the workflow's current graph.""" - mandateId = _validateInstanceAccess(instanceId, context) - iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) - version = iface.createDraftVersion(workflowId) - if not version: - raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) - return version - - -@router.post("/{instanceId}/versions/{versionId}/publish") -@limiter.limit("30/minute") -def publish_version( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - versionId: str = Path(..., description="Version ID"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Publish a draft version. Archives the previously published version.""" - mandateId = _validateInstanceAccess(instanceId, context) - iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) - userId = str(context.user.id) if context.user else None - version = iface.publishVersion(versionId, userId=userId) - if not version: - raise HTTPException(status_code=400, detail=routeApiMsg("Version not found or not in draft status")) - return version - - -@router.post("/{instanceId}/versions/{versionId}/unpublish") -@limiter.limit("30/minute") -def unpublish_version( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - versionId: str = Path(..., description="Version ID"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Unpublish a version (revert to draft).""" - mandateId = _validateInstanceAccess(instanceId, context) - iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) - version = iface.unpublishVersion(versionId) - if not version: - raise HTTPException(status_code=400, detail=routeApiMsg("Version not found or not published")) - return version - - -@router.post("/{instanceId}/versions/{versionId}/archive") -@limiter.limit("30/minute") -def archive_version( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - versionId: str = Path(..., description="Version ID"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Archive a version.""" - mandateId = _validateInstanceAccess(instanceId, context) - iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) - version = iface.archiveVersion(versionId) - if not version: - raise HTTPException(status_code=404, detail=routeApiMsg("Version not found")) - return version - - -# ------------------------------------------------------------------------- -# Templates -# ------------------------------------------------------------------------- - - -@router.get("/{instanceId}/templates") -@limiter.limit("60/minute") -def get_templates( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - scope: Optional[str] = Query(None, description="Filter by scope: user, instance, mandate, system"), - pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), - mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"), - column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"), - context: RequestContext = Depends(getRequestContext), -): - """List workflow templates with optional pagination. - - Supports the FormGeneratorTable backend pattern: - - default: paginated/filtered/sorted ``{items, pagination}`` response - - ``mode=filterValues&column=X``: distinct values for column X (cross-filtered) - - ``mode=ids``: all IDs matching current filters - """ - mandateId = _validateInstanceAccess(instanceId, context) - iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) - templates = iface.getTemplates(scope=scope) - - from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels - from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow - 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 - if pagination: - try: - paginationDict = json.loads(pagination) - if paginationDict: - paginationDict = normalize_pagination_dict(paginationDict) - paginationParams = PaginationParams(**paginationDict) - except (json.JSONDecodeError, ValueError) as e: - raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}") - - if paginationParams: - filtered = applyFiltersAndSort(templates, paginationParams) - totalItems = len(filtered) - totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0 - startIdx = (paginationParams.page - 1) * paginationParams.pageSize - endIdx = startIdx + paginationParams.pageSize - return { - "items": filtered[startIdx:endIdx], - "pagination": PaginationMetadata( - currentPage=paginationParams.page, pageSize=paginationParams.pageSize, - totalItems=totalItems, totalPages=totalPages, - sort=paginationParams.sort, filters=paginationParams.filters, - ).model_dump(), - } - return {"templates": templates} - - -@router.post("/{instanceId}/templates/from-workflow") -@limiter.limit("30/minute") -def create_template_from_workflow( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - body: dict = Body(..., description="{ workflowId, scope? }"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Create a template from an existing workflow.""" - mandateId = _validateInstanceAccess(instanceId, context) - workflowId = body.get("workflowId") - scope = body.get("scope", "user") - if not workflowId: - raise HTTPException(status_code=400, detail=routeApiMsg("workflowId required")) - iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) - template = iface.createTemplateFromWorkflow(workflowId, scope=scope) - if not template: - raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) - return template - - -@router.post("/{instanceId}/templates/{templateId}/copy") -@limiter.limit("30/minute") -def copy_template( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - templateId: str = Path(..., description="Template ID"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Copy a template to a new user-owned workflow.""" - mandateId = _validateInstanceAccess(instanceId, context) - iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) - workflow = iface.copyTemplateToUser(templateId) - if not workflow: - raise HTTPException(status_code=404, detail=routeApiMsg("Template not found")) - return workflow - - -@router.post("/{instanceId}/templates/{templateId}/share") -@limiter.limit("30/minute") -def share_template( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - templateId: str = Path(..., description="Template ID"), - body: dict = Body(..., description="{ scope }"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Share a template by changing its scope.""" - mandateId = _validateInstanceAccess(instanceId, context) - scope = body.get("scope") - if not scope or scope not in ("user", "instance", "mandate", "system"): - raise HTTPException(status_code=400, detail=routeApiMsg("scope must be user, instance, mandate, or system")) - iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) - template = iface.shareTemplate(templateId, scope=scope) - if not template: - raise HTTPException(status_code=404, detail=routeApiMsg("Template not found")) - return template - - -# ------------------------------------------------------------------------- -# AI Chat for Editor -# ------------------------------------------------------------------------- - - -def _editorChatQueueId(workflowId: str) -> str: - """Deterministic SSE queue id for the editor chat (one active stream per workflow). - - Mirrors the workspace pattern (``workspace-{workflowId}``) so stop/cancel can - target the running task by workflowId without needing per-request handles. - """ - return f"ge-chat-{workflowId}" - - -def _getEditorChatInterface(context: RequestContext, mandateId: str, instanceId: str): - """Build the ChatObjects interface used to persist editor-chat messages.""" - from modules.interfaces import interfaceDbChat - return interfaceDbChat.getInterface( - context.user, - mandateId=mandateId, - featureInstanceId=instanceId, - ) - - -def _editorConversationHistoryFromPersisted(chatInterface, chatWorkflowId: str) -> List[Dict[str, Any]]: - """Load persisted ChatMessages for the editor chat and shape them as the - agent expects (``[{role, message}]``). Skips empty / system messages. - """ - try: - msgs = chatInterface.getMessages(chatWorkflowId) or [] - except Exception as e: - logger.warning("Editor chat: could not load persisted history for %s: %s", chatWorkflowId, e) - return [] - history: List[Dict[str, Any]] = [] - for m in msgs: - role = (getattr(m, "role", None) or (m.get("role") if isinstance(m, dict) else None) or "").strip() - text = (getattr(m, "message", None) or (m.get("message") if isinstance(m, dict) else None) or "").strip() - if not role or not text: - continue - if role not in ("user", "assistant", "system"): - continue - history.append({"role": role, "message": text}) - return history - - -@router.post("/{instanceId}/{workflowId}/chat/stream") -@limiter.limit("30/minute") -async def post_editor_chat( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - workflowId: str = Path(..., description="Workflow ID"), - body: dict = Body(..., description="{ message, userLanguage? }"), - context: RequestContext = Depends(getRequestContext), -): - """AI chat endpoint for the editor with SSE streaming. Uses workflow tools to mutate the graph. - - Persistence: the chat is stored in the standard ``ChatWorkflow`` table linked - to this Automation2Workflow via ``ChatWorkflow.linkedWorkflowId``. The user - message is persisted before the agent starts; the assistant message after. - Conversation history is loaded server-side from this linked ChatWorkflow — - the client does not need to maintain it. - """ - mandateId = _validateInstanceAccess(instanceId, context) - message = body.get("message", "") - if not message: - raise HTTPException(status_code=400, detail=routeApiMsg("message required")) - - iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) - wf = iface.getWorkflow(workflowId) - if not wf: - raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) - - userLanguage = body.get("userLanguage", "de") - fileIds = body.get("fileIds") or [] - dataSourceIds = body.get("dataSourceIds") or [] - featureDataSourceIds = body.get("featureDataSourceIds") or [] - - chatInterface = _getEditorChatInterface(context, mandateId, instanceId) - wfLabel = wf.get("label") if isinstance(wf, dict) else getattr(wf, "label", None) - chatWorkflow = chatInterface.getOrCreateLinkedWorkflow( - featureInstanceId=instanceId, - linkedWorkflowId=workflowId, - name=wfLabel or f"Editor Chat ({workflowId})", - ) - chatWorkflowId = chatWorkflow.id if hasattr(chatWorkflow, "id") else chatWorkflow.get("id") - - conversationHistory = _editorConversationHistoryFromPersisted(chatInterface, chatWorkflowId) - - try: - chatInterface.createMessage({ - "workflowId": chatWorkflowId, - "role": "user", - "message": message, - "status": "first" if not conversationHistory else "step", - }) - except Exception as e: - logger.error("Editor chat: failed to persist user message: %s", e) - - from modules.serviceCenter.core.serviceStreaming import get_event_manager - sseEventManager = get_event_manager() - queueId = _editorChatQueueId(workflowId) - await sseEventManager.cancel_agent(queueId) - sseEventManager.create_queue(queueId) - - agentTask = asyncio.ensure_future( - _runEditorAgent( - workflowId=workflowId, - queueId=queueId, - prompt=message, - instanceId=instanceId, - user=context.user, - mandateId=mandateId, - sseEventManager=sseEventManager, - userLanguage=userLanguage, - conversationHistory=conversationHistory, - fileIds=fileIds, - dataSourceIds=dataSourceIds, - featureDataSourceIds=featureDataSourceIds, - chatInterface=chatInterface, - chatWorkflowId=chatWorkflowId, - ) - ) - sseEventManager.register_agent_task(queueId, agentTask) - - async def _sseGenerator(): - queue = sseEventManager.get_queue(queueId) - if not queue: - return - while True: - try: - event = await asyncio.wait_for(queue.get(), timeout=120) - except asyncio.TimeoutError: - yield "data: {\"type\": \"keepalive\"}\n\n" - continue - if event is None: - break - ssePayload = event.get("data", event) if isinstance(event, dict) else event - yield f"data: {json.dumps(ssePayload, default=str)}\n\n" - eventType = ssePayload.get("type", "") if isinstance(ssePayload, dict) else "" - if eventType in ("complete", "error", "stopped"): - break - await sseEventManager.cleanup(queueId, delay=30) - - return StreamingResponse( - _sseGenerator(), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - }, - ) - - -@router.get("/{instanceId}/{workflowId}/chat/messages") -@limiter.limit("120/minute") -def get_editor_chat_messages( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - workflowId: str = Path(..., description="Workflow ID (Automation2Workflow)"), - context: RequestContext = Depends(getRequestContext), -): - """Return persisted editor-chat messages for an Automation2Workflow. - - The chat is stored in ``ChatWorkflow`` with ``linkedWorkflowId == workflowId``; - if no chat has been started yet for this workflow we return an empty list (we - do NOT eagerly create one — the row is created on the first POST /chat/stream). - """ - mandateId = _validateInstanceAccess(instanceId, context) - chatInterface = _getEditorChatInterface(context, mandateId, instanceId) - chatWorkflow = chatInterface.getWorkflowByLink( - featureInstanceId=instanceId, - linkedWorkflowId=workflowId, - ) - if not chatWorkflow: - return JSONResponse({ - "chatWorkflowId": None, - "messages": [], - }) - - chatWorkflowId = chatWorkflow.id if hasattr(chatWorkflow, "id") else chatWorkflow.get("id") - rawMessages = chatInterface.getMessages(chatWorkflowId) or [] - - items: List[Dict[str, Any]] = [] - for m in rawMessages: - getter = (lambda key, default=None: getattr(m, key, default)) if not isinstance(m, dict) else (lambda key, default=None: m.get(key, default)) - role = (getter("role") or "").strip() - content = (getter("message") or "").strip() - if not role or not content: - continue - items.append({ - "id": getter("id"), - "role": role, - "content": content, - "timestamp": getter("publishedAt") or 0, - "sequenceNr": getter("sequenceNr") or 0, - }) - - items.sort(key=lambda x: (float(x.get("timestamp") or 0), int(x.get("sequenceNr") or 0))) - - return JSONResponse({ - "chatWorkflowId": chatWorkflowId, - "messages": items, - }) - - -@router.post("/{instanceId}/{workflowId}/chat/stop") -@limiter.limit("120/minute") -async def post_editor_chat_stop( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - workflowId: str = Path(..., description="Workflow ID"), - context: RequestContext = Depends(getRequestContext), -): - """Stop a running editor-chat agent for the given workflow.""" - _validateInstanceAccess(instanceId, context) - from modules.serviceCenter.core.serviceStreaming import get_event_manager - sseEventManager = get_event_manager() - queueId = _editorChatQueueId(workflowId) - cancelled = await sseEventManager.cancel_agent(queueId) - await sseEventManager.emit_event(queueId, "stopped", { - "type": "stopped", - "workflowId": workflowId, - }) - logger.info("Editor chat stop requested for workflow %s, cancelled=%s", workflowId, cancelled) - return JSONResponse({"status": "stopped", "workflowId": workflowId, "cancelled": cancelled}) - - -async def _runEditorAgent( - workflowId: str, - queueId: str, - prompt: str, - instanceId: str, - user=None, - mandateId: str = "", - sseEventManager=None, - userLanguage: str = "de", - conversationHistory: List[Dict[str, Any]] = None, - fileIds: List[str] = None, - dataSourceIds: List[str] = None, - featureDataSourceIds: List[str] = None, - chatInterface=None, - chatWorkflowId: Optional[str] = None, -): - """Run the serviceAgent loop with workflow toolbox and forward events to the SSE queue. - - Persists the assistant response to ``ChatMessage`` (linked via ``chatWorkflowId``) - on FINAL/ERROR. On cancellation any partial accumulated text is still saved so - the editor chat history reflects what the user actually saw on screen. - """ - assistantPersisted = False - - def _persistAssistant(text: str) -> None: - nonlocal assistantPersisted - if assistantPersisted or not chatInterface or not chatWorkflowId: - return - cleaned = (text or "").strip() - if not cleaned: - return - try: - chatInterface.createMessage({ - "workflowId": chatWorkflowId, - "role": "assistant", - "message": cleaned, - "status": "last", - }) - assistantPersisted = True - except Exception as msgErr: - logger.error("Editor chat: failed to persist assistant message: %s", msgErr) - - try: - from modules.serviceCenter import getService - from modules.serviceCenter.context import ServiceCenterContext - from modules.serviceCenter.services.serviceAgent.datamodelAgent import ( - AgentEventTypeEnum, AgentConfig, - ) - - ctx = ServiceCenterContext( - user=user, - mandate_id=mandateId, - feature_instance_id=instanceId, - workflow_id=workflowId, - feature_code="graphicalEditor", - ) - agentService = getService("agent", ctx) - - systemPrompt = ( - "You are a workflow EDITOR assistant for the GraphicalEditor. " - "Your job is to MANAGE workflows for the user — create, rename, " - "import/export, edit the graph (nodes + connections) — but you must " - "NEVER execute a workflow or any of its actions. Even when the user " - "says 'create a workflow that sends an email', you build the graph " - "(add an email node, connect it) — you do NOT actually send an email." - "\n\nAvailable tools (all valid — use whichever the user's intent calls for):" - "\n Graph-mutating: readWorkflowGraph, listAvailableNodeTypes, " - "describeNodeType, addNode, removeNode, connectNodes, setNodeParameter, " - "listUpstreamPaths, bindNodeParameter, " - "autoLayoutWorkflow, validateGraph." - "\n Workflow lifecycle: createWorkflow (new empty workflow), " - "updateWorkflowMetadata (rename / change description / tags / activate), " - "createWorkflowFromFile (import .workflow.json from UDB), " - "exportWorkflowToFile (download envelope), deleteWorkflow (destructive — " - "ALWAYS confirm with the user before calling)." - "\n History: listWorkflowHistory, readWorkflowMessages." - "\n Connections (for parameters of frontendType='userConnection'): listConnections." - "\n\nIntent → tool mapping (do NOT improvise destructive paths):" - "\n • 'rename / umbenennen / call it X / nenne … um' → updateWorkflowMetadata({label: \"X\"})." - "\n • 'create empty workflow / new workflow / leeren Workflow' → createWorkflow({label: \"…\"})." - "\n • 'import / load from file' → createWorkflowFromFile({fileId: …})." - "\n • 'export / save to file / download' → exportWorkflowToFile()." - "\n • 'activate / deactivate' → updateWorkflowMetadata({active: true|false})." - "\n NEVER batch-call removeNode to 'rebuild' or 'rename' a workflow — that " - "destroys the user's work. removeNode is for removing ONE specific node the " - "user explicitly asked to delete." - "\n\nMandatory build sequence WHEN editing the graph:" - "\n1. readWorkflowGraph — understand current state." - "\n2. listAvailableNodeTypes — find candidate node ids." - "\n3. For EACH node type you plan to add: call describeNodeType(nodeType=...) " - "to learn its requiredParameters, allowedValues and ports. Never skip this " - "step — guessing parameters leaves the user with empty config cards." - "\n4. If any required parameter has frontendType='userConnection' (e.g. " - "email.checkEmail.connectionReference), call listConnections and pick the " - "connectionId that matches the user's intent (or ask the user if none clearly fits)." - "\n5. addNode with parameters={...} containing AT LEAST every requiredParameter " - "filled with a sensible value (use the user's request, the parameter " - "description, sane defaults, or — for required user-connection fields — " - "an actual connectionId). Do NOT pass position; the layout step handles it." - "\n6. connectNodes — wire the nodes consistent with port schemas from describeNodeType." - "\n6b. When a parameter must take data from an upstream node, call listUpstreamPaths(nodeId=target) " - "then bindNodeParameter(producerNodeId, path, parameterName) — do not rely on implicit wire fill." - "\n7. autoLayoutWorkflow — call exactly once as the LAST graph-mutating step so the " - "canvas shows a readable top-down layout instead of overlapping boxes." - "\n8. validateGraph — sanity check, then answer the user." - "\n\nIf a required parameter cannot be filled from the user's request and has " - "no safe default, ask the user once for that specific value (e.g. recipient " - "address, target language, prompt text) instead of leaving the field blank. " - "Respond concisely in the user's language and list what you changed." - ) - - editorConfig = AgentConfig( - toolSet="core", - excludeActionTools=True, - ) - - enrichedPrompt = prompt - if dataSourceIds: - from modules.features.workspace.routeFeatureWorkspace import buildDataSourceContext - chatSvc = getService("chat", ctx) - dsInfo = buildDataSourceContext(chatSvc, dataSourceIds) - if dsInfo: - enrichedPrompt = f"{prompt}\n\n[Active Data Sources]\n{dsInfo}" - - if featureDataSourceIds: - from modules.features.workspace.routeFeatureWorkspace import buildFeatureDataSourceContext - fdsInfo = buildFeatureDataSourceContext(featureDataSourceIds) - if fdsInfo: - enrichedPrompt = f"{enrichedPrompt}\n\n[Attached Feature Data Sources]\n{fdsInfo}" - - accumulatedText = "" - - async for event in agentService.runAgent( - prompt=enrichedPrompt, - fileIds=fileIds or [], - config=editorConfig, - workflowId=workflowId, - userLanguage=userLanguage, - conversationHistory=conversationHistory or [], - toolSet="core", - additionalTools=None, - systemPromptOverride=systemPrompt, - ): - if sseEventManager.is_cancelled(queueId): - logger.info("Editor chat agent cancelled for workflow %s", workflowId) - break - - if event.type == AgentEventTypeEnum.CHUNK and event.content: - accumulatedText += event.content - - sseEvent = { - "type": event.type.value if hasattr(event.type, "value") else event.type, - "workflowId": workflowId, - } - if event.content: - sseEvent["content"] = event.content - if event.data: - sseEvent["item"] = event.data - - await sseEventManager.emit_event(queueId, sseEvent["type"], sseEvent) - - if event.type in (AgentEventTypeEnum.FINAL, AgentEventTypeEnum.ERROR): - _persistAssistant(event.content or accumulatedText) - break - - # Fallback: any streamed content not yet stored (cancellation path, no FINAL). - if not assistantPersisted and accumulatedText.strip(): - _persistAssistant(accumulatedText) - - await sseEventManager.emit_event(queueId, "complete", { - "type": "complete", - "workflowId": workflowId, - }) - - except asyncio.CancelledError: - logger.info("Editor chat agent task cancelled for workflow %s", workflowId) - # Save whatever the user already saw before cancelling so the next reload - # shows the same partial answer (matches workspace behaviour). - try: - _persistAssistant(accumulatedText if "accumulatedText" in locals() else "") - except Exception: - pass - await sseEventManager.emit_event(queueId, "stopped", { - "type": "stopped", - "workflowId": workflowId, - }) - - except Exception as e: - logger.error("Editor chat agent error: %s", e, exc_info=True) - await sseEventManager.emit_event(queueId, "error", { - "type": "error", - "content": str(e), - "workflowId": workflowId, - }) - finally: - sseEventManager._unregister_agent_task(queueId) - - -# ------------------------------------------------------------------------- -# Connections and Browse (for Email/SharePoint node config) -# ------------------------------------------------------------------------- - - -def _buildResolverDbInterface(chatService): - """Build a DB adapter that ConnectorResolver can use to load UserConnections.""" - class _ResolverDbAdapter: - def __init__(self, appInterface): - self._app = appInterface - - def getUserConnection(self, connectionId: str): - if hasattr(self._app, "getUserConnectionById"): - return self._app.getUserConnectionById(connectionId) - return None - - appIf = getattr(chatService, "interfaceDbApp", None) - if appIf: - return _ResolverDbAdapter(appIf) - return getattr(chatService, "interfaceDbComponent", None) - - -@router.get("/{instanceId}/connections") -@limiter.limit("300/minute") -def list_connections( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Return the user's active connections (UserConnections) for Email/SharePoint node config.""" - mandateId = _validateInstanceAccess(instanceId, context) - from modules.serviceCenter import getService - from modules.serviceCenter.context import ServiceCenterContext - ctx = ServiceCenterContext( - user=context.user, - mandate_id=str(context.mandateId) if context.mandateId else mandateId, - feature_instance_id=instanceId, - ) - chatService = getService("chat", ctx) - connections = chatService.getUserConnections() - items = [] - for c in connections or []: - conn = c if isinstance(c, dict) else (c.model_dump() if hasattr(c, "model_dump") else {}) - authority = conn.get("authority") - if hasattr(authority, "value"): - authority = authority.value - status = conn.get("status") - if hasattr(status, "value"): - status = status.value - items.append({ - "id": conn.get("id"), - "authority": authority, - "externalUsername": conn.get("externalUsername"), - "externalEmail": conn.get("externalEmail"), - "status": status, - }) - return {"connections": items} - - -@router.get("/{instanceId}/connections/{connectionId}/services") -@limiter.limit("120/minute") -async def list_connection_services( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - connectionId: str = Path(..., description="Connection ID"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Return the available services for a specific UserConnection.""" - mandateId = _validateInstanceAccess(instanceId, context) - try: - from modules.connectors.connectorResolver import ConnectorResolver - from modules.serviceCenter import getService as getSvc - from modules.serviceCenter.context import ServiceCenterContext - ctx = ServiceCenterContext( - user=context.user, - mandate_id=str(context.mandateId) if context.mandateId else mandateId, - feature_instance_id=instanceId, - ) - chatService = getSvc("chat", ctx) - securityService = getSvc("security", ctx) - dbInterface = _buildResolverDbInterface(chatService) - resolver = ConnectorResolver(securityService, dbInterface) - provider = await resolver.resolve(connectionId) - services = provider.getAvailableServices() - _serviceLabels = { - "sharepoint": "SharePoint", - "clickup": "ClickUp", - "outlook": "Outlook", - "teams": "Teams", - "onedrive": "OneDrive", - "drive": "Google Drive", - "gmail": "Gmail", - "files": "Files (FTP)", - "kdrive": "kDrive", - "calendar": "Calendar", - "contact": "Contacts", - } - _serviceIcons = { - "sharepoint": "sharepoint", - "clickup": "folder", - "outlook": "mail", - "teams": "chat", - "onedrive": "cloud", - "drive": "cloud", - "gmail": "mail", - "files": "folder", - "kdrive": "cloud", - "calendar": "calendar", - "contact": "contact", - } - items = [ - {"service": s, "label": _serviceLabels.get(s, s), "icon": _serviceIcons.get(s, "folder")} - for s in services - ] - return {"services": items} - except Exception as e: - logger.error(f"Error listing services for connection {connectionId}: {e}") - return JSONResponse({"services": [], "error": str(e)}, status_code=400) - - -@router.get("/{instanceId}/connections/{connectionId}/browse") -@limiter.limit("300/minute") -async def browse_connection_service( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - connectionId: str = Path(..., description="Connection ID"), - service: str = Query(..., description="Service name (e.g. sharepoint, onedrive, outlook)"), - path: str = Query("/", description="Path within the service to browse"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Browse folders/items within a connection's service at a given path.""" - mandateId = _validateInstanceAccess(instanceId, context) - try: - from modules.connectors.connectorResolver import ConnectorResolver - from modules.serviceCenter import getService as getSvc - from modules.serviceCenter.context import ServiceCenterContext - ctx = ServiceCenterContext( - user=context.user, - mandate_id=str(context.mandateId) if context.mandateId else mandateId, - feature_instance_id=instanceId, - ) - chatService = getSvc("chat", ctx) - securityService = getSvc("security", ctx) - dbInterface = _buildResolverDbInterface(chatService) - resolver = ConnectorResolver(securityService, dbInterface) - adapter = await resolver.resolveService(connectionId, service) - entries = await adapter.browse(path, filter=None) - items = [] - for entry in (entries or []): - items.append({ - "name": entry.name, - "path": entry.path, - "isFolder": entry.isFolder, - "size": entry.size, - "mimeType": entry.mimeType, - "metadata": entry.metadata if hasattr(entry, "metadata") else {}, - }) - return {"items": items, "path": path, "service": service} - except Exception as e: - logger.error(f"Error browsing {service} for connection {connectionId} at '{path}': {e}") - return JSONResponse({"items": [], "error": str(e)}, status_code=400) - - -# ------------------------------------------------------------------------- -# Workflow CRUD -# ------------------------------------------------------------------------- - - -def _get_node_label_from_graph(graph: dict, nodeId: str) -> str: - """Extract human-readable label for a node from graph.""" - if not graph or not nodeId: - return nodeId or "" - nodes = graph.get("nodes") or [] - for n in nodes: - if n.get("id") == nodeId: - params = n.get("parameters") or {} - config = params.get("config") or {} - if isinstance(config, dict): - label = config.get("title") or config.get("label") - else: - label = None - return ( - n.get("title") - or label - or params.get("title") - or params.get("label") - or n.get("type", "") - or nodeId - ) - return nodeId or "" - - -@router.get("/{instanceId}/workflows") -@limiter.limit("60/minute") -def get_workflows( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - active: Optional[bool] = Query(None, description="Filter by active: true|false"), - pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), - mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"), - column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"), - context: RequestContext = Depends(getRequestContext), -): - """List all workflows for this feature instance. - - Supports the FormGeneratorTable backend pattern: - - default: paginated/filtered/sorted ``{items, pagination}`` response - - ``mode=filterValues&column=X``: distinct values for column X (cross-filtered) - - ``mode=ids``: all IDs matching current filters (for "select all") - """ - mandateId = _validateInstanceAccess(instanceId, context) - iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) - items = iface.getWorkflows(active=active) - enriched = [] - for wf in items: - wf_id = wf.get("id") - runs = iface.getRunsByWorkflow(wf_id) if wf_id else [] - run_count = len(runs) - active_run = None - last_started_at = None - for r in runs: - ts = r.get("sysCreatedAt") - if ts and (last_started_at is None or ts > last_started_at): - last_started_at = ts - if r.get("status") in ("running", "paused"): - active_run = r - stuck_at_node_id = active_run.get("currentNodeId") if active_run else None - stuck_at_node_label = "" - if stuck_at_node_id and wf.get("graph"): - stuck_at_node_label = _get_node_label_from_graph(wf["graph"], stuck_at_node_id) - enriched.append({ - **wf, - "runCount": run_count, - "isRunning": active_run is not None, - "runStatus": active_run.get("status") if active_run else None, - "stuckAtNodeId": stuck_at_node_id, - "stuckAtNodeLabel": stuck_at_node_label or stuck_at_node_id or "", - "lastStartedAt": last_started_at, - }) - - 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(enriched, column, pagination) - - if mode == "ids": - from modules.dbHelpers.paginationHelpers import handleIdsInMemory - return handleIdsInMemory(enriched, pagination) - - paginationParams = None - if pagination: - try: - paginationDict = json.loads(pagination) - if paginationDict: - paginationDict = normalize_pagination_dict(paginationDict) - paginationParams = PaginationParams(**paginationDict) - except (json.JSONDecodeError, ValueError) as e: - raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}") - - if paginationParams: - filtered = applyFiltersAndSort(enriched, paginationParams) - totalItems = len(filtered) - totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0 - startIdx = (paginationParams.page - 1) * paginationParams.pageSize - endIdx = startIdx + paginationParams.pageSize - return { - "items": filtered[startIdx:endIdx], - "pagination": PaginationMetadata( - currentPage=paginationParams.page, pageSize=paginationParams.pageSize, - totalItems=totalItems, totalPages=totalPages, - sort=paginationParams.sort, filters=paginationParams.filters, - ).model_dump(), - } - return {"workflows": enriched} - - -@router.get("/{instanceId}/workflows/{workflowId}") -@limiter.limit("60/minute") -def get_workflow( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - workflowId: str = Path(..., description="Workflow ID"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Get a single workflow by ID.""" - mandateId = _validateInstanceAccess(instanceId, context) - iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) - wf = iface.getWorkflow(workflowId) - if not wf: - raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) - return wf - - -@router.post("/{instanceId}/workflows") -@limiter.limit("30/minute") -def create_workflow( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - body: dict = Body(..., description="{ label, graph }"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Create a new workflow.""" - mandateId = _validateInstanceAccess(instanceId, context) - _validateTargetInstance(body, instanceId, context) - iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) - created = iface.createWorkflow(body) - return created - - -@router.put("/{instanceId}/workflows/{workflowId}") -@limiter.limit("30/minute") -def update_workflow( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - workflowId: str = Path(..., description="Workflow ID"), - body: dict = Body(..., description="{ label?, graph? }"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Update a workflow.""" - mandateId = _validateInstanceAccess(instanceId, context) - iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) - existing = iface.getWorkflow(workflowId) - if not existing: - raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) - merged = {**existing, **body} - _validateTargetInstance(merged, instanceId, context) - updated = iface.updateWorkflow(workflowId, body) - if not updated: - raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) - return updated - - -@router.delete("/{instanceId}/workflows/{workflowId}") -@limiter.limit("30/minute") -def delete_workflow( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - workflowId: str = Path(..., description="Workflow ID"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Delete a workflow.""" - mandateId = _validateInstanceAccess(instanceId, context) - iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) - if not iface.deleteWorkflow(workflowId): - raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) - return {"success": True} - - -# ------------------------------------------------------------------------- -# Workflow File IO (versioned envelope export/import) -# ------------------------------------------------------------------------- - - -@router.post("/{instanceId}/workflows/import") -@limiter.limit("30/minute") -def import_workflow( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - body: dict = Body( - ..., - description=( - "{ envelope: , existingWorkflowId?: str, " - "fileId?: str } — supply EITHER the envelope inline OR a fileId of " - "a previously uploaded workflow file (.workflow.json)" - ), - ), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Import a workflow from a versioned-envelope file. - - Two input modes: - - ``envelope``: the parsed workflow-file payload (preferred for the agent) - - ``fileId``: the id of a previously uploaded ``.workflow.json`` in - Unified-Data-Bar (preferred for the UI "Import" modal) - - On success returns the created/updated workflow plus any non-fatal - warnings (e.g. dangling connection references). Imports are always - saved with ``active=False``. - """ - from modules.features.graphicalEditor._workflowFileSchema import WorkflowFileSchemaError - - mandateId = _validateInstanceAccess(instanceId, context) - iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) - - envelope = body.get("envelope") if isinstance(body, dict) else None - fileId = body.get("fileId") if isinstance(body, dict) else None - existingWorkflowId = body.get("existingWorkflowId") if isinstance(body, dict) else None - - if not envelope and fileId: - envelope = _loadEnvelopeFromFile(str(fileId), context) - - if not envelope: - raise HTTPException( - status_code=400, - detail=routeApiMsg("Body must contain 'envelope' or 'fileId'"), - ) - - try: - result = iface.importWorkflowFromDict(envelope, existingWorkflowId=existingWorkflowId) - except WorkflowFileSchemaError as exc: - raise HTTPException(status_code=400, detail=str(exc)) - except ValueError as exc: - raise HTTPException(status_code=404, detail=str(exc)) - - return result - - -@router.get("/{instanceId}/workflows/{workflowId}/export") -@limiter.limit("60/minute") -def export_workflow( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - workflowId: str = Path(..., description="Workflow ID"), - download: bool = Query(False, description="If true, return as file download"), - context: RequestContext = Depends(getRequestContext), -): - """Export a workflow as a versioned-envelope JSON file. - - With ``download=true`` returns a streaming response with the canonical - ``.workflow.json`` filename so the browser triggers a save dialog. - Without it returns the envelope inline as JSON (used by the agent and by - the editor's "Save to file" → upload-to-UDB flow). - """ - from modules.features.graphicalEditor._workflowFileSchema import buildFileName - - mandateId = _validateInstanceAccess(instanceId, context) - iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) - envelope = iface.exportWorkflowToDict(workflowId) - if envelope is None: - raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) - - if not download: - return {"envelope": envelope, "fileName": buildFileName(envelope.get("label", "workflow"))} - - fileName = buildFileName(envelope.get("label", "workflow")) - payload = json.dumps(envelope, ensure_ascii=False, indent=2).encode("utf-8") - return Response( - content=payload, - media_type="application/json", - headers={"Content-Disposition": f'attachment; filename="{fileName}"'}, - ) - - -def _loadEnvelopeFromFile(fileId: str, context: RequestContext) -> Optional[Dict[str, Any]]: - """Load and parse a ``.workflow.json`` file from the Unified-Data-Bar - by file id. Returns the parsed envelope dict or raises HTTPException.""" - try: - import modules.interfaces.interfaceDbManagement as interfaceDbManagement - mgmt = interfaceDbManagement.getInterface(context.user) - rawBytes = mgmt.getFileData(fileId) - except Exception as exc: - logger.warning("Failed to load workflow file %s: %s", fileId, exc) - raise HTTPException(status_code=404, detail=routeApiMsg(f"File {fileId} not found")) - - if not rawBytes: - raise HTTPException(status_code=404, detail=routeApiMsg(f"File {fileId} is empty")) - - try: - if isinstance(rawBytes, bytes): - text = rawBytes.decode("utf-8") - else: - text = str(rawBytes) - return json.loads(text) - except Exception as exc: - raise HTTPException( - status_code=400, - detail=routeApiMsg(f"File {fileId} is not valid JSON: {exc}"), - ) - - -# ------------------------------------------------------------------------- -# Runs and Resume -# ------------------------------------------------------------------------- - - -@router.get("/{instanceId}/runs/completed") -@limiter.limit("60/minute") -def get_completed_runs( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - limit: int = Query(20, ge=1, le=50), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Get recently completed runs with output.""" - mandateId = _validateInstanceAccess(instanceId, context) - iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) - runs = iface.getRecentCompletedRuns(limit=limit) - return {"runs": runs} - - -@router.get("/{instanceId}/workflows/{workflowId}/runs") -@limiter.limit("60/minute") -def get_workflow_runs( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - workflowId: str = Path(..., description="Workflow ID"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Get runs for a workflow.""" - mandateId = _validateInstanceAccess(instanceId, context) - iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) - if not iface.getWorkflow(workflowId): - raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) - runs = iface.getRunsByWorkflow(workflowId) - return {"runs": runs} - - -@router.get("/{instanceId}/runs/{runId}/steps") -@limiter.limit("60/minute") -def get_run_steps( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - runId: str = Path(..., description="Run ID"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Get step logs for a run (AutoStepLog entries).""" - mandateId = _validateInstanceAccess(instanceId, context) - iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) - from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoStepLog - if not iface.db._ensureTableExists(AutoStepLog): - return {"steps": []} - records = iface.db.getRecordset(AutoStepLog, recordFilter={"runId": runId}) - steps = [dict(r) for r in records] if records else [] - steps.sort(key=lambda s: s.get("startedAt") or 0) - return {"steps": steps} - - -# ------------------------------------------------------------------------- -# Tasks -# ------------------------------------------------------------------------- - - -@router.get("/{instanceId}/tasks") -@limiter.limit("60/minute") -def get_tasks( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - workflowId: str = Query(None, description="Filter by workflow ID"), - status: str = Query(None, description="Filter: pending, completed, rejected"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Get tasks assigned to current user.""" - mandateId = _validateInstanceAccess(instanceId, context) - iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) - assigneeId = str(context.user.id) if context.user else None - items = iface.getTasks(workflowId=workflowId, status=status, assigneeId=assigneeId) - workflows = {w["id"]: w for w in iface.getWorkflows()} - enriched = [] - for t in items: - wf = workflows.get(t.get("workflowId") or "") - enriched.append({ - **t, - "workflowLabel": wf.get("label", t.get("workflowId", "")) if wf else t.get("workflowId", ""), - "createdAt": t.get("sysCreatedAt"), - }) - return {"tasks": enriched} - - -@router.post("/{instanceId}/tasks/{taskId}/complete") -@limiter.limit("30/minute") -async def complete_task( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - taskId: str = Path(..., description="Task ID"), - body: dict = Body(..., description="{ result }"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Complete a task and resume the run.""" - mandateId = _validateInstanceAccess(instanceId, context) - iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) - task = iface.getTask(taskId) - if not task: - raise HTTPException(status_code=404, detail=routeApiMsg("Task not found")) - runId = task.get("runId") - result = body.get("result") - if result is None: - raise HTTPException(status_code=400, detail=routeApiMsg("result required")) - run = iface.getRun(runId) - if not run: - raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) - if task.get("status") != "pending": - raise HTTPException(status_code=400, detail=routeApiMsg("Task already completed")) - iface.updateTask(taskId, status="completed", result=result) - nodeId = task.get("nodeId") - nodeOutputs = dict(run.get("nodeOutputs") or {}) - nodeOutputs[nodeId] = result - workflowId = run.get("workflowId") - wf = iface.getWorkflow(workflowId) if workflowId else None - if not wf or not wf.get("graph"): - raise HTTPException(status_code=400, detail=routeApiMsg("Workflow graph not found")) - graph = wf["graph"] - services = getGraphicalEditorServices(context.user, mandateId=mandateId, featureInstanceId=instanceId) - return await executeGraph( - graph=graph, - services=services, - workflowId=workflowId, - instanceId=instanceId, - userId=str(context.user.id) if context.user else None, - mandateId=mandateId, - automation2_interface=iface, - initialNodeOutputs=nodeOutputs, - startAfterNodeId=nodeId, - runId=runId, - ) - - -@router.post("/{instanceId}/tasks/{taskId}/cancel") -@limiter.limit("30/minute") -def cancel_pending_task_stop_run( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - taskId: str = Path(..., description="Human task ID"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Cancel a pending human task and stop the workflow run behind it.""" - mandateId = _validateInstanceAccess(instanceId, context) - iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) - task = iface.getTask(taskId) - if not task: - raise HTTPException(status_code=404, detail=routeApiMsg("Task not found")) - - wf_ids = {w.get("id") for w in iface.getWorkflows() if w.get("id")} - if task.get("workflowId") not in wf_ids: - raise HTTPException(status_code=404, detail=routeApiMsg("Task not found")) - - if task.get("status") != "pending": - raise HTTPException(status_code=400, detail=routeApiMsg("Task already completed")) - - run_id = task.get("runId") - - from modules.workflows.automation2.executionEngine import requestRunStop - - if run_id: - requestRunStop(run_id) - db_run = iface.getRun(run_id) - if db_run: - current = db_run.get("status") or "" - if current not in ("completed", "failed", "cancelled"): - iface.updateRun(run_id, status="cancelled") - - pending = iface.getTasks(runId=run_id, status="pending") - for t in pending: - tid = t.get("id") - if tid: - iface.updateTask(tid, status="cancelled") - else: - iface.updateTask(taskId, status="cancelled") - - return {"success": True, "runId": run_id, "taskId": taskId} - - -# ------------------------------------------------------------------------- -# Monitoring / Metrics -# ------------------------------------------------------------------------- - - -@router.get("/{instanceId}/metrics") -@limiter.limit("60/minute") -def get_metrics( - request: Request, - instanceId: str = Path(..., description="Feature instance ID"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Aggregated metrics for the monitoring dashboard.""" - mandateId = _validateInstanceAccess(instanceId, context) - iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) - - from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import ( - AutoWorkflow, AutoRun, AutoStepLog, AutoTask, - ) - - workflows = iface.db.getRecordset(AutoWorkflow, recordFilter={ - "mandateId": mandateId, "featureInstanceId": instanceId, "isTemplate": False, - }) or [] - runs = iface.db.getRecordset(AutoRun, recordFilter={ - "workflowId": {"$in": [w.get("id") for w in workflows]} if workflows else "__none__", - }) or [] - tasks = iface.db.getRecordset(AutoTask, recordFilter={ - "workflowId": {"$in": [w.get("id") for w in workflows]} if workflows else "__none__", - }) or [] - - runsByStatus = {} - totalTokens = 0 - totalCredits = 0.0 - for r in runs: - s = r.get("status", "unknown") - runsByStatus[s] = runsByStatus.get(s, 0) + 1 - totalTokens += r.get("costTokens", 0) or 0 - totalCredits += r.get("costCredits", 0.0) or 0.0 - - tasksByStatus = {} - for t in tasks: - s = t.get("status", "unknown") - tasksByStatus[s] = tasksByStatus.get(s, 0) + 1 - - return { - "workflowCount": len(workflows), - "activeWorkflows": sum(1 for w in workflows if w.get("active")), - "totalRuns": len(runs), - "runsByStatus": runsByStatus, - "totalTasks": len(tasks), - "tasksByStatus": tasksByStatus, - "totalTokens": totalTokens, - "totalCredits": round(totalCredits, 4), - } diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py index 9a6e2e26..002cb02d 100644 --- a/modules/interfaces/interfaceBootstrap.py +++ b/modules/interfaces/interfaceBootstrap.py @@ -110,6 +110,13 @@ def initBootstrap(db: DatabaseConnector) -> None: except Exception as e: logger.warning(f"Mandate retention purge failed: {e}") + # WorkflowAutomation bootstrap (system component, not auto-discovered) + try: + from modules.workflowAutomation.mainWorkflowAutomation import onBootstrap as _waBootstrap + _waBootstrap() + except Exception as _waBootErr: + logger.warning(f"onBootstrap hook for 'workflowAutomation' failed: {_waBootErr}") + # Let features run their own bootstrap logic via lifecycle hooks from modules.shared.featureDiscovery import loadFeatureMainModules for _fCode, _fMod in loadFeatureMainModules().items(): @@ -1610,7 +1617,7 @@ def _createStoreResourceRules(db: DatabaseConnector) -> None: "resource.store.workspace", "resource.store.commcoach", "resource.store.trustee", - "resource.store.graphicalEditor", # DEPRECATED: will move with WorkflowAutomation code restructuring + "resource.store.workflowAutomation", ] storeRules = [] diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py index 6ebadaaf..023e07f3 100644 --- a/modules/interfaces/interfaceDbApp.py +++ b/modules/interfaces/interfaceDbApp.py @@ -1870,6 +1870,13 @@ class AppObjects: instances = self.db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId}) + # 0-pre-wa. WorkflowAutomation cascade-delete (system component, not auto-discovered) + try: + from modules.workflowAutomation.mainWorkflowAutomation import onMandateDelete as _waDeleteHook + _waDeleteHook(mandateId, instances) + except Exception as _waDelErr: + logger.warning(f"onMandateDelete hook for 'workflowAutomation' failed: {_waDelErr}") + # 0-pre. Let features cascade-delete their own data via lifecycle hooks from modules.shared.featureDiscovery import loadFeatureMainModules for _fCode, _fMod in loadFeatureMainModules().items(): diff --git a/modules/interfaces/interfaceDbChat.py b/modules/interfaces/interfaceDbChat.py index 432769bd..39d95440 100644 --- a/modules/interfaces/interfaceDbChat.py +++ b/modules/interfaces/interfaceDbChat.py @@ -765,7 +765,7 @@ class ChatObjects: ) -> Optional[ChatWorkflow]: """Return the ChatWorkflow linked to (featureInstanceId, linkedWorkflowId), if any. - Used by editor-style features (e.g. GraphicalEditor AI editor chat) to + Used by editor-style features (e.g. WorkflowAutomation AI editor chat) to find the persisted chat for a specific external entity (Automation2Workflow). Falls under the same RBAC as ``getWorkflow``. """ diff --git a/modules/interfaces/interfaceDbManagement.py b/modules/interfaces/interfaceDbManagement.py index 46289b7e..35a4008e 100644 --- a/modules/interfaces/interfaceDbManagement.py +++ b/modules/interfaces/interfaceDbManagement.py @@ -933,7 +933,7 @@ class ComponentObjects: If pagination is provided: PaginatedResult with items and metadata """ def _convertFileItems(files): - from modules.workflows.automation2.workflowArtifactVisibility import ( + from modules.workflowAutomation.engine.workflowArtifactVisibility import ( suppress_workflow_file_in_workspace_ui, ) diff --git a/modules/interfaces/interfaceFeatures.py b/modules/interfaces/interfaceFeatures.py index 5f239c01..c947806c 100644 --- a/modules/interfaces/interfaceFeatures.py +++ b/modules/interfaces/interfaceFeatures.py @@ -271,7 +271,7 @@ class FeatureInterface: Copy feature-specific template workflows to a new instance. Loads TEMPLATE_WORKFLOWS from the feature module and creates - AutoWorkflow records in the graphicalEditor DB, scoped to + AutoWorkflow records in the workflowAutomation DB, scoped to (mandateId, instanceId). The placeholder {{featureInstanceId}} in graph parameters is replaced with the actual instanceId. @@ -321,14 +321,10 @@ class FeatureInterface: f"for feature '{featureCode}' to instance {instanceId} (mandate={mandateId})" ) - geMod = mainModules.get("graphicalEditor") - onInstanceCreateHook = getattr(geMod, "onInstanceCreate", None) if geMod else None - if not onInstanceCreateHook: - logger.warning("_copyTemplateWorkflows: graphicalEditor.onInstanceCreate hook not available") - return 0 + from modules.workflowAutomation.mainWorkflowAutomation import onInstanceCreate as _waOnInstanceCreate try: - copied = onInstanceCreateHook(mandateId, instanceId, featureCode, templateWorkflows) + copied = _waOnInstanceCreate(mandateId, instanceId, featureCode, templateWorkflows) except Exception as e: logger.error( f"_copyTemplateWorkflows: onInstanceCreate hook failed for '{featureCode}': {e}", diff --git a/modules/interfaces/interfaceRbac.py b/modules/interfaces/interfaceRbac.py index 8d886cfd..16429acb 100644 --- a/modules/interfaces/interfaceRbac.py +++ b/modules/interfaces/interfaceRbac.py @@ -204,16 +204,16 @@ TABLE_NAMESPACE = { # Automation - benutzer-eigen "AutomationDefinition": "automation", "AutomationTemplate": "automation", - # GraphicalEditor - Greenfield DB poweron_graphicaleditor (Auto-prefix models) - "AutoWorkflow": "feature.graphicalEditor", - "AutoVersion": "feature.graphicalEditor", - "AutoRun": "feature.graphicalEditor", - "AutoStepLog": "feature.graphicalEditor", - "AutoTask": "feature.graphicalEditor", + # WorkflowAutomation - Greenfield DB poweron_graphicaleditor (Auto-prefix models) + "AutoWorkflow": "system.workflowAutomation", + "AutoVersion": "system.workflowAutomation", + "AutoRun": "system.workflowAutomation", + "AutoStepLog": "system.workflowAutomation", + "AutoTask": "system.workflowAutomation", # Legacy aliases (backward compat) - "Automation2Workflow": "feature.graphicalEditor", - "Automation2WorkflowRun": "feature.graphicalEditor", - "Automation2HumanTask": "feature.graphicalEditor", + "Automation2Workflow": "system.workflowAutomation", + "Automation2WorkflowRun": "system.workflowAutomation", + "Automation2HumanTask": "system.workflowAutomation", # Knowledge Store - benutzer-eigen "FileContentIndex": "knowledge", "ContentChunk": "knowledge", diff --git a/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py b/modules/interfaces/interfaceWorkflowAutomation.py similarity index 91% rename from modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py rename to modules/interfaces/interfaceWorkflowAutomation.py index 092389c6..6d192451 100644 --- a/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py +++ b/modules/interfaces/interfaceWorkflowAutomation.py @@ -1,8 +1,14 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. """ -Interface for GraphicalEditor feature - Workflows, Runs, Human Tasks. -Uses PostgreSQL poweron_graphicaleditor database (Greenfield). +Interface for WorkflowAutomation system component - Workflows, Runs, Human Tasks. +Uses PostgreSQL poweron_graphicaleditor database. + +Architecture note: This interface (L4) uses lazy imports from +workflowAutomation.editor (L5) for export/import operations. +This is a documented exception — workflowAutomation is a system component +whose editor module provides pure transformation functions with no +upward dependencies. """ import base64 @@ -47,33 +53,36 @@ from modules.datamodels.datamodelWorkflowAutomation import ( AutoStepLog, AutoTask, ) -from modules.features.graphicalEditor.entryPoints import invocations_synced_with_graph from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.shared.configuration import APP_CONFIG from modules.dbHelpers.dbRegistry import registerDatabase logger = logging.getLogger(__name__) -graphicalEditorDatabase = GRAPHICAL_EDITOR_DATABASE -registerDatabase(graphicalEditorDatabase) -_CALLBACK_WORKFLOW_CHANGED = "graphicalEditor.workflow.changed" +workflowAutomationDatabase = GRAPHICAL_EDITOR_DATABASE +registerDatabase(workflowAutomationDatabase) +_CALLBACK_WORKFLOW_CHANGED = "workflowAutomation.workflow.changed" -def getGraphicalEditorInterface( +def _invocationsSyncedWithGraph(graph, invocations): + """Lazy-load entryPoints to avoid L4->L5 top-level import.""" + from modules.workflowAutomation.editor.entryPoints import invocations_synced_with_graph + return invocations_synced_with_graph(graph, invocations) + + +def _getWorkflowAutomationInterface( currentUser: User, mandateId: str, featureInstanceId: str, -) -> "GraphicalEditorObjects": - """Factory for GraphicalEditor interface with user context.""" - return GraphicalEditorObjects( +) -> "WorkflowAutomationObjects": + """Factory for WorkflowAutomation interface with user context.""" + return WorkflowAutomationObjects( currentUser=currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId, ) -# Backward-compatible alias used by workflows/automation2/ execution engine -getAutomation2Interface = getGraphicalEditorInterface def getAllWorkflowsForScheduling() -> List[Dict[str, Any]]: @@ -82,7 +91,7 @@ def getAllWorkflowsForScheduling() -> List[Dict[str, Any]]: Used by the scheduler to register cron jobs. Does not filter by mandate/instance. """ dbHost = APP_CONFIG.get("DB_HOST", "localhost") - dbDatabase = graphicalEditorDatabase + dbDatabase = workflowAutomationDatabase dbUser = APP_CONFIG.get("DB_USER") dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD") dbPort = int(APP_CONFIG.get("DB_PORT", 5432)) @@ -95,7 +104,7 @@ def getAllWorkflowsForScheduling() -> List[Dict[str, Any]]: userId=None, ) if not connector._ensureTableExists(AutoWorkflow): - logger.warning("GraphicalEditor schedule: table AutoWorkflow does not exist yet") + logger.warning("WorkflowAutomation schedule: table AutoWorkflow does not exist yet") return [] records = connector.getRecordset( AutoWorkflow, @@ -107,7 +116,7 @@ def getAllWorkflowsForScheduling() -> List[Dict[str, Any]]: if r.get("active") is False: continue wf = dict(r) - wf["invocations"] = invocations_synced_with_graph(wf.get("graph") or {}, wf.get("invocations")) + wf["invocations"] = _invocationsSyncedWithGraph(wf.get("graph") or {}, wf.get("invocations")) invocations = wf.get("invocations") or [] primary = invocations[0] if invocations else {} if not isinstance(primary, dict): @@ -142,15 +151,15 @@ def getAllWorkflowsForScheduling() -> List[Dict[str, Any]]: "workflow": wf, }) logger.info( - "GraphicalEditor schedule: DB has %d workflow(s), %d active with trigger.schedule+cron", + "WorkflowAutomation schedule: DB has %d workflow(s), %d active with trigger.schedule+cron", raw_count, len(result), ) return result -class GraphicalEditorObjects: - """Interface for GraphicalEditor database operations (Greenfield DB).""" +class WorkflowAutomationObjects: + """Interface for WorkflowAutomation database operations (poweron_graphicaleditor DB).""" def __init__( self, @@ -167,9 +176,9 @@ class GraphicalEditorObjects: self.db.updateContext(self.userId) def _init_db(self): - """Initialize database connection to poweron_graphicaleditor (Greenfield).""" + """Initialize database connection to poweron_graphicaleditor.""" dbHost = APP_CONFIG.get("DB_HOST", "localhost") - dbDatabase = graphicalEditorDatabase + dbDatabase = workflowAutomationDatabase dbUser = APP_CONFIG.get("DB_USER") dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD") dbPort = int(APP_CONFIG.get("DB_PORT", 5432)) @@ -181,7 +190,7 @@ class GraphicalEditorObjects: dbPort=dbPort, userId=self.userId, ) - logger.debug("GraphicalEditor database initialized for user %s", self.userId) + logger.debug("WorkflowAutomation database initialized for user %s", self.userId) # ------------------------------------------------------------------------- # Workflow CRUD @@ -202,7 +211,7 @@ class GraphicalEditorObjects: ) rows = [dict(r) for r in records] if records else [] for wf in rows: - wf["invocations"] = invocations_synced_with_graph(wf.get("graph") or {}, wf.get("invocations")) + wf["invocations"] = _invocationsSyncedWithGraph(wf.get("graph") or {}, wf.get("invocations")) return rows def getWorkflow(self, workflowId: str) -> Optional[Dict[str, Any]]: @@ -219,7 +228,7 @@ class GraphicalEditorObjects: if not records: return None wf = dict(records[0]) - wf["invocations"] = invocations_synced_with_graph(wf.get("graph") or {}, wf.get("invocations")) + wf["invocations"] = _invocationsSyncedWithGraph(wf.get("graph") or {}, wf.get("invocations")) return wf def createWorkflow(self, data: Dict[str, Any]) -> Dict[str, Any]: @@ -232,10 +241,10 @@ class GraphicalEditorObjects: data["targetFeatureInstanceId"] = self.featureInstanceId if "active" not in data or data.get("active") is None: data["active"] = True - data["invocations"] = invocations_synced_with_graph(data.get("graph") or {}, data.get("invocations")) + data["invocations"] = _invocationsSyncedWithGraph(data.get("graph") or {}, data.get("invocations")) created = self.db.recordCreate(AutoWorkflow, data) out = dict(created) - out["invocations"] = invocations_synced_with_graph(out.get("graph") or {}, out.get("invocations")) + out["invocations"] = _invocationsSyncedWithGraph(out.get("graph") or {}, out.get("invocations")) try: from modules.shared.callbackRegistry import callbackRegistry callbackRegistry.trigger(_CALLBACK_WORKFLOW_CHANGED) @@ -255,10 +264,10 @@ class GraphicalEditorObjects: if not isinstance(g, dict): g = {} inv = data["invocations"] if "invocations" in data else existing.get("invocations") - data["invocations"] = invocations_synced_with_graph(g, inv) + data["invocations"] = _invocationsSyncedWithGraph(g, inv) updated = self.db.recordModify(AutoWorkflow, workflowId, data) out = dict(updated) - out["invocations"] = invocations_synced_with_graph(out.get("graph") or {}, out.get("invocations")) + out["invocations"] = _invocationsSyncedWithGraph(out.get("graph") or {}, out.get("invocations")) try: from modules.shared.callbackRegistry import callbackRegistry callbackRegistry.trigger(_CALLBACK_WORKFLOW_CHANGED) @@ -683,7 +692,7 @@ class GraphicalEditorObjects: envelope) and can be JSON-serialized as-is. Returns ``None`` if the workflow does not exist for this mandate. """ - from modules.features.graphicalEditor._workflowFileSchema import buildFileFromWorkflow + from modules.workflowAutomation.editor._workflowFileSchema import buildFileFromWorkflow wf = self.getWorkflow(workflowId) if not wf: @@ -702,11 +711,11 @@ class GraphicalEditorObjects: ``existingWorkflowId`` is given. Imports are always saved with ``active=False`` so operators can review before scheduling. """ - from modules.features.graphicalEditor._workflowFileSchema import ( + from modules.workflowAutomation.editor._workflowFileSchema import ( envelopeToWorkflowData, validateFileEnvelope, ) - from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES + from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES knownTypes = [n.get("id") for n in STATIC_NODE_TYPES if isinstance(n, dict) and n.get("id")] normalizedEnvelope, warnings = validateFileEnvelope(envelope, knownNodeTypes=knownTypes) @@ -728,6 +737,3 @@ class GraphicalEditorObjects: created = self.createWorkflow(data) return {"workflow": created, "warnings": warnings, "created": True} - -# Backward-compatible alias -Automation2Objects = GraphicalEditorObjects diff --git a/modules/routes/routeAdminFeatures.py b/modules/routes/routeAdminFeatures.py index b3072edc..350d8311 100644 --- a/modules/routes/routeAdminFeatures.py +++ b/modules/routes/routeAdminFeatures.py @@ -913,11 +913,11 @@ def _syncInstanceWorkflows( if not templateWorkflows: return SyncWorkflowsResult(added=0, skipped=0, total=0) - from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface + from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface from modules.security.rootAccess import getRootUser rootUser = getRootUser() - geInterface = getGraphicalEditorInterface(rootUser, mandateId, instanceId) + geInterface = _getWorkflowAutomationInterface(rootUser, mandateId, instanceId) existingWorkflows = geInterface.getWorkflows() or [] existingSourceIds = set() diff --git a/modules/routes/routeAutomationWorkspace.py b/modules/routes/routeAutomationWorkspace.py deleted file mode 100644 index a93fff70..00000000 --- a/modules/routes/routeAutomationWorkspace.py +++ /dev/null @@ -1,309 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -""" -User-facing Automation Workspace API. - -Lists workflow runs the user can access (via FeatureAccess on -targetFeatureInstanceId) and provides detail views with step logs -and linked files. Designed for the "Workspace" tab under -Nutzung > Automation. -""" - -import logging -import math -from functools import partial -from typing import Optional - -from fastapi import APIRouter, Depends, Request, Query, Path, HTTPException -from slowapi import Limiter -from slowapi.util import get_remote_address - -from modules.auth.authentication import getRequestContext, RequestContext -from modules.connectors.connectorDbPostgre import DatabaseConnector -from modules.shared.configuration import APP_CONFIG -from modules.datamodels.datamodelWorkflowAutomation import ( - AutoRun, - AutoStepLog, - AutoWorkflow, - GRAPHICAL_EDITOR_DATABASE, -) -from modules.workflows.automation2.workflowArtifactVisibility import suppress_workflow_file_in_workspace_ui -from modules.shared.i18nRegistry import apiRouteContext - -routeApiMsg = apiRouteContext("routeAutomationWorkspace") -logger = logging.getLogger(__name__) -limiter = Limiter(key_func=get_remote_address) - -router = APIRouter(prefix="/api/automations/runs", tags=["AutomationWorkspace"]) - - -def _getDb() -> DatabaseConnector: - return DatabaseConnector( - dbHost=APP_CONFIG.get("DB_HOST", "localhost"), - dbDatabase=GRAPHICAL_EDITOR_DATABASE, - dbUser=APP_CONFIG.get("DB_USER"), - dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), - dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), - userId=None, - ) - - -def _getUserAccessibleInstanceIds(userId: str) -> list[str]: - """Return all featureInstanceIds the user has enabled FeatureAccess for.""" - from modules.interfaces.interfaceDbApp import getRootInterface - rootIface = getRootInterface() - allAccess = rootIface.getFeatureAccessesForUser(userId) or [] - return [ - a.featureInstanceId - for a in allAccess - if a.featureInstanceId and a.enabled - ] - - -_FILE_REF_KEYS = ("fileId", "documentId", "fileIds", "documents") - - -def _extractFileIdsFromValue(value, accumulator: set[str]) -> None: - """Recursively scan a value (dict/list/str) for file id references.""" - if isinstance(value, dict): - for key, sub in value.items(): - if key in _FILE_REF_KEYS: - _collectFileIdsFromRef(sub, accumulator) - else: - _extractFileIdsFromValue(sub, accumulator) - elif isinstance(value, list): - for item in value: - _extractFileIdsFromValue(item, accumulator) - - -def _collectFileIdsFromRef(val, accumulator: set[str]) -> None: - """Add file ids from a value located under a known file-reference key.""" - if isinstance(val, str) and val: - accumulator.add(val) - elif isinstance(val, list): - for v in val: - if isinstance(v, str) and v: - accumulator.add(v) - elif isinstance(v, dict) and v.get("id"): - accumulator.add(v["id"]) - elif isinstance(val, dict) and val.get("id"): - accumulator.add(val["id"]) - - -@router.get("") -@limiter.limit("60/minute") -def listWorkspaceRuns( - request: Request, - scope: str = Query("mine", description="mine = own runs, mandate = all accessible"), - status: Optional[str] = Query(None, description="Filter by run status"), - targetInstanceId: Optional[str] = Query(None, description="Filter by targetFeatureInstanceId"), - workflowId: Optional[str] = Query(None, description="Filter by workflow"), - limit: int = Query(50, ge=1, le=200), - offset: int = Query(0, ge=0), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """List workflow runs visible to the user. - - scope=mine: only runs owned by the user. - scope=mandate: all runs where the user has FeatureAccess on the - workflow's targetFeatureInstanceId. - """ - db = _getDb() - if not db._ensureTableExists(AutoRun): - return {"runs": [], "total": 0, "limit": limit, "offset": offset} - - userId = str(context.user.id) if context.user else None - if not userId: - raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) - - accessibleInstanceIds = _getUserAccessibleInstanceIds(userId) - if not accessibleInstanceIds: - return {"runs": [], "total": 0, "limit": limit, "offset": offset} - - if not db._ensureTableExists(AutoWorkflow): - return {"runs": [], "total": 0, "limit": limit, "offset": offset} - - wfFilter: dict = {} - if targetInstanceId: - if targetInstanceId not in accessibleInstanceIds: - raise HTTPException(status_code=403, detail=routeApiMsg("Access denied to target instance")) - wfFilter["targetFeatureInstanceId"] = targetInstanceId - workflows = db.getRecordset(AutoWorkflow, recordFilter=wfFilter or None) or [] - - visibleWfIds: set[str] = set() - wfMap: dict = {} - for wf in workflows: - wfDict = dict(wf) - tid = wfDict.get("targetFeatureInstanceId") or wfDict.get("featureInstanceId") - if tid and tid in accessibleInstanceIds: - wfId = wfDict.get("id") - if wfId: - visibleWfIds.add(wfId) - wfMap[wfId] = wfDict - - if workflowId: - if workflowId not in visibleWfIds: - return {"runs": [], "total": 0, "limit": limit, "offset": offset} - visibleWfIds = {workflowId} - - if not visibleWfIds: - return {"runs": [], "total": 0, "limit": limit, "offset": offset} - - allRuns = db.getRecordset(AutoRun, recordFilter={}) or [] - filtered = [] - for r in allRuns: - row = dict(r) - if row.get("workflowId") not in visibleWfIds: - continue - if scope == "mine" and row.get("ownerId") != userId: - continue - if status and row.get("status") != status: - continue - filtered.append(row) - - filtered.sort( - key=lambda x: x.get("startedAt") or x.get("sysCreatedAt") or 0, - reverse=True, - ) - total = len(filtered) - page = filtered[offset: offset + limit] - - from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels, resolveMandateLabels, resolveInstanceLabels - - for row in page: - wf = wfMap.get(row.get("workflowId"), {}) - row["workflowLabel"] = row.get("label") or wf.get("label") or row.get("workflowId", "") - row["targetFeatureInstanceId"] = wf.get("targetFeatureInstanceId") or wf.get("featureInstanceId") - - enrichRowsWithFkLabels( - page, - db=db, - labelResolvers={ - "mandateId": partial(resolveMandateLabels, db), - "targetFeatureInstanceId": partial(resolveInstanceLabels, db), - }, - ) - for row in page: - row["targetInstanceLabel"] = row.pop("targetFeatureInstanceIdLabel", None) - row["mandateLabel"] = row.pop("mandateIdLabel", None) - - return {"runs": page, "total": total, "limit": limit, "offset": offset} - - -@router.get("/{runId}/detail") -@limiter.limit("60/minute") -def getWorkspaceRunDetail( - request: Request, - runId: str = Path(..., description="Run ID"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Get full detail for a single run: metadata, step logs, linked files.""" - db = _getDb() - userId = str(context.user.id) if context.user else None - if not userId: - raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) - - if not db._ensureTableExists(AutoRun): - raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) - - runs = db.getRecordset(AutoRun, recordFilter={"id": runId}) - if not runs: - raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) - run = dict(runs[0]) - - wfId = run.get("workflowId") - workflow: dict = {} - if wfId and db._ensureTableExists(AutoWorkflow): - wfs = db.getRecordset(AutoWorkflow, recordFilter={"id": wfId}) - if wfs: - workflow = dict(wfs[0]) - - tid = workflow.get("targetFeatureInstanceId") or workflow.get("featureInstanceId") - accessibleIds = _getUserAccessibleInstanceIds(userId) - isOwner = run.get("ownerId") == userId - - if not isOwner and (not tid or tid not in accessibleIds) and not context.isPlatformAdmin: - raise HTTPException(status_code=403, detail=routeApiMsg("Access denied")) - - steps: list = [] - if db._ensureTableExists(AutoStepLog): - stepRecords = db.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or [] - steps = [dict(s) for s in stepRecords] - steps.sort(key=lambda s: s.get("startedAt") or 0) - - allFileIds: set[str] = set() - perStepFileIds: list[tuple[set[str], set[str]]] = [] - for step in steps: - inputIds: set[str] = set() - outputIds: set[str] = set() - _extractFileIdsFromValue(step.get("inputSnapshot") or {}, inputIds) - _extractFileIdsFromValue(step.get("output") or {}, outputIds) - perStepFileIds.append((inputIds, outputIds)) - allFileIds.update(inputIds) - allFileIds.update(outputIds) - - nodeOutputs = run.get("nodeOutputs") or {} - runLevelIds: set[str] = set() - _extractFileIdsFromValue(nodeOutputs, runLevelIds) - allFileIds.update(runLevelIds) - - fileMetaById: dict[str, dict] = {} - try: - from modules.datamodels.datamodelFiles import FileItem - from modules.interfaces.interfaceDbManagement import ComponentObjects - mgmtDb = ComponentObjects().db - if mgmtDb._ensureTableExists(FileItem): - for fid in allFileIds: - try: - rec = mgmtDb.getRecord(FileItem, fid) - if rec: - recDict = dict(rec) - fileMetaById[fid] = { - "id": fid, - "fileName": recDict.get("fileName") or recDict.get("name"), - } - except Exception: - pass - except Exception as e: - logger.warning("getWorkspaceRunDetail: file lookup failed: %s", e) - - def _resolveFileList(ids: set[str]) -> list[dict]: - rows = [dict(fileMetaById[fid]) for fid in ids if fid in fileMetaById] - return [m for m in rows if not suppress_workflow_file_in_workspace_ui(m)] - - assignedFileIds: set[str] = set() - for step, (inputIds, outputIds) in zip(steps, perStepFileIds): - step["inputFiles"] = _resolveFileList(inputIds) - step["outputFiles"] = _resolveFileList(outputIds) - assignedFileIds.update(inputIds) - assignedFileIds.update(outputIds) - - unassignedFiles = _resolveFileList(allFileIds - assignedFileIds) - allFiles = _resolveFileList(allFileIds) - - run["workflowLabel"] = run.get("label") or workflow.get("label") or wfId - run["targetFeatureInstanceId"] = tid - - targetInstanceLabel = None - if tid: - try: - from modules.dbHelpers.fkLabelResolver import resolveInstanceLabels - labelMap = resolveInstanceLabels(db, [tid]) - targetInstanceLabel = labelMap.get(tid) - except Exception: - pass - run["targetInstanceLabel"] = targetInstanceLabel - - return { - "run": run, - "workflow": { - "id": workflow.get("id"), - "label": workflow.get("label"), - "targetFeatureInstanceId": tid, - "featureInstanceId": workflow.get("featureInstanceId"), - "tags": workflow.get("tags", []), - } if workflow else None, - "steps": steps, - "files": allFiles, - "unassignedFiles": unassignedFiles, - } diff --git a/modules/routes/routeSystem.py b/modules/routes/routeSystem.py index 217dfa14..8529206b 100644 --- a/modules/routes/routeSystem.py +++ b/modules/routes/routeSystem.py @@ -833,7 +833,7 @@ def _buildIntegrationsOverviewPayload(userId: str, user=None) -> Dict[str, Any]: except Exception as e: logger.debug(f"integrations-overview billing stats: {e}") - # Workflow metrics (same logic as routeWorkflowDashboard.get_workflow_metrics) + # Workflow metrics (same logic as routeWorkflowAutomation.get_workflow_metrics) try: from modules.shared.configuration import APP_CONFIG from modules.connectors.connectorDbPostgre import DatabaseConnector diff --git a/modules/routes/routeWorkflowAutomation.py b/modules/routes/routeWorkflowAutomation.py index 6ce6fb21..ee5d4ac1 100644 --- a/modules/routes/routeWorkflowAutomation.py +++ b/modules/routes/routeWorkflowAutomation.py @@ -4,8 +4,7 @@ Mandatsweite WorkflowAutomation API. System-level API for workflows, runs, tasks — scoped by mandate membership, -not by graphicalEditor FeatureInstance. Parallel to the legacy per-instance -API in routeFeatureGraphicalEditor.py during the migration period. +not by FeatureInstance. Uses mandate-scoped RBAC. RBAC model: - Read: mandate membership (user sees workflows in own mandates) @@ -13,12 +12,11 @@ RBAC model: - isPlatformAdmin bypasses all checks """ -import json import logging -import time +import uuid from typing import Optional, List, Dict, Any -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Request from slowapi import Limiter from slowapi.util import get_remote_address @@ -26,12 +24,16 @@ from modules.auth.authentication import getRequestContext, RequestContext from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.datamodels.datamodelWorkflowAutomation import ( AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask, - GRAPHICAL_EDITOR_DATABASE, ) -from modules.datamodels.datamodelPagination import PaginationParams, normalize_pagination_dict -from modules.interfaces.interfaceDbApp import getRootInterface -from modules.shared.configuration import APP_CONFIG -from modules.shared.i18nRegistry import apiRouteContext +from modules.shared.i18nRegistry import apiRouteContext, resolveText +from modules.shared.workflowAutomationHelpers import ( + _getWorkflowAutomationDb, + _validateWorkflowAccess, + _scopedWorkflowFilter, + _scopedRunFilter, + _parsePaginationOr400, + _cascadeDeleteWorkflow, +) routeApiMsg = apiRouteContext("routeWorkflowAutomation") @@ -41,169 +43,6 @@ limiter = Limiter(key_func=get_remote_address) router = APIRouter(prefix="/api/workflow-automation", tags=["WorkflowAutomation"]) -# --------------------------------------------------------------------------- -# DB + RBAC helpers -# --------------------------------------------------------------------------- - -def _getDb() -> DatabaseConnector: - return DatabaseConnector( - dbHost=APP_CONFIG.get("DB_HOST", "localhost"), - dbDatabase=GRAPHICAL_EDITOR_DATABASE, - dbUser=APP_CONFIG.get("DB_USER"), - dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), - dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), - userId=None, - ) - - -def _getUserMandateIds(userId: str) -> List[str]: - rootIface = getRootInterface() - memberships = rootIface.getUserMandates(userId) - return [um.mandateId for um in memberships if um.mandateId and um.enabled] - - -def _getAdminMandateIds(userId: str, mandateIds: List[str]) -> List[str]: - if not mandateIds: - return [] - rootIface = getRootInterface() - from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole - - memberships = rootIface.db.getRecordset( - UserMandate, - recordFilter={"userId": userId, "mandateId": mandateIds, "enabled": True}, - ) - if not memberships: - return [] - - umIdToMandateId: Dict[str, str] = {} - for m in memberships: - row = m if isinstance(m, dict) else m.__dict__ - um_id = row.get("id") - mid = row.get("mandateId") - if um_id and mid: - umIdToMandateId[str(um_id)] = str(mid) - - userMandateIds = list(umIdToMandateId.keys()) - allRoles = rootIface.db.getRecordset( - UserMandateRole, - recordFilter={"userMandateId": userMandateIds}, - ) - if not allRoles: - return [] - - roleIds: set = set() - roleToMandate: Dict[str, set] = {} - for r in allRoles: - row = r if isinstance(r, dict) else r.__dict__ - rid = row.get("roleId") - um_id = row.get("userMandateId") - mid = umIdToMandateId.get(str(um_id)) if um_id else None - if rid and mid: - roleIds.add(rid) - roleToMandate.setdefault(rid, set()).add(mid) - - if not roleIds: - return [] - - from modules.datamodels.datamodelRbac import Role - roleRecords = rootIface.db.getRecordset(Role, recordFilter={"id": list(roleIds)}) - adminMandates: set = set() - for role in (roleRecords or []): - row = role if isinstance(role, dict) else role.__dict__ - rid = row.get("id") - if not rid or rid not in roleToMandate: - continue - if row.get("roleLabel") == "admin" and not row.get("featureInstanceId"): - adminMandates.update(roleToMandate[rid]) - - return [mid for mid in mandateIds if mid in adminMandates] - - -def _validateWorkflowAccess( - context: RequestContext, - workflow: Optional[Dict[str, Any]], - action: str = "read", -) -> None: - """Validate access to a workflow based on mandate membership + admin status. - - Actions: 'read' (mandate member), 'write'/'execute'/'delete' (mandate admin or platform admin). - Raises HTTPException(403) on denial. - """ - if context.isPlatformAdmin: - return - - userId = str(context.user.id) if context.user else None - if not userId: - raise HTTPException(status_code=403, detail="Authentication required") - - if workflow is None: - raise HTTPException(status_code=404, detail="Workflow not found") - - wfMandateId = workflow.get("mandateId") or "" - if not wfMandateId: - if action == "read": - return - raise HTTPException(status_code=403, detail="Workflow has no mandate — admin only") - - userMandateIds = _getUserMandateIds(userId) - if wfMandateId not in userMandateIds: - raise HTTPException(status_code=403, detail="Not a member of the workflow's mandate") - - if action == "read": - return - - adminMandateIds = _getAdminMandateIds(userId, [wfMandateId]) - if wfMandateId not in adminMandateIds: - raise HTTPException( - status_code=403, - detail=f"Mandate admin required for '{action}' on workflows", - ) - - -def _scopedWorkflowFilter(context: RequestContext) -> Optional[Dict[str, Any]]: - """Build DB filter for listing workflows: mandate-scoped for members, None for sysadmin.""" - if context.isPlatformAdmin: - return None - - userId = str(context.user.id) if context.user else None - if not userId: - return {"mandateId": "__impossible__"} - - mandateIds = _getUserMandateIds(userId) - if mandateIds: - return {"mandateId": mandateIds} - return {"mandateId": "__impossible__"} - - -def _scopedRunFilter(context: RequestContext) -> Optional[Dict[str, Any]]: - """Build DB filter for listing runs: admin sees mandate runs, user sees own.""" - if context.isPlatformAdmin: - return None - - userId = str(context.user.id) if context.user else None - if not userId: - return {"ownerId": "__impossible__"} - - mandateIds = _getUserMandateIds(userId) - adminMandateIds = _getAdminMandateIds(userId, mandateIds) - - if adminMandateIds: - return {"mandateId": adminMandateIds} - return {"ownerId": userId} - - -def _parsePagination(pagination: Optional[str]) -> Optional[PaginationParams]: - if not pagination: - return None - try: - d = json.loads(pagination) - except json.JSONDecodeError: - raise HTTPException(status_code=400, detail="Invalid pagination JSON") - if not d: - return None - return normalize_pagination_dict(d) - - # --------------------------------------------------------------------------- # Workflow CRUD # --------------------------------------------------------------------------- @@ -214,7 +53,7 @@ async def _listWorkflows( pagination: Optional[str] = Query(default=None), mandateId: Optional[str] = Query(default=None), ): - db = _getDb() + db = _getWorkflowAutomationDb() try: db._ensureTableExists(AutoWorkflow) scopeFilter = _scopedWorkflowFilter(request) @@ -225,7 +64,7 @@ async def _listWorkflows( elif mandateId and scopeFilter is None: scopeFilter = {"mandateId": mandateId} - params = _parsePagination(pagination) + params = _parsePaginationOr400(pagination) records = db.getRecordset(AutoWorkflow, recordFilter=scopeFilter, pagination=params) total = db.getRecordCount(AutoWorkflow, recordFilter=scopeFilter) if params else len(records or []) return {"items": records or [], "total": total} @@ -238,7 +77,7 @@ async def _getWorkflow( workflowId: str, request: RequestContext = Depends(getRequestContext), ): - db = _getDb() + db = _getWorkflowAutomationDb() try: db._ensureTableExists(AutoWorkflow) wf = db.getRecord(AutoWorkflow, workflowId) @@ -261,10 +100,9 @@ async def _createWorkflow( _validateWorkflowAccess(request, {"mandateId": mandateId}, "write") - db = _getDb() + db = _getWorkflowAutomationDb() try: db._ensureTableExists(AutoWorkflow) - import uuid data = {**body, "id": str(uuid.uuid4())} if request.user: data.setdefault("runAsPrincipal", str(request.user.id)) @@ -280,7 +118,7 @@ async def _updateWorkflow( request: RequestContext = Depends(getRequestContext), body: Dict[str, Any] = {}, ): - db = _getDb() + db = _getWorkflowAutomationDb() try: db._ensureTableExists(AutoWorkflow) wf = db.getRecord(AutoWorkflow, workflowId) @@ -296,22 +134,12 @@ async def _deleteWorkflow( workflowId: str, request: RequestContext = Depends(getRequestContext), ): - db = _getDb() + db = _getWorkflowAutomationDb() try: db._ensureTableExists(AutoWorkflow) wf = db.getRecord(AutoWorkflow, workflowId) _validateWorkflowAccess(request, wf, "delete") - - for v in db.getRecordset(AutoVersion, recordFilter={"workflowId": workflowId}) or []: - db.recordDelete(AutoVersion, v.get("id")) - for run in db.getRecordset(AutoRun, recordFilter={"workflowId": workflowId}) or []: - runId = run.get("id") - for sl in db.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []: - db.recordDelete(AutoStepLog, sl.get("id")) - db.recordDelete(AutoRun, runId) - for task in db.getRecordset(AutoTask, recordFilter={"workflowId": workflowId}) or []: - db.recordDelete(AutoTask, task.get("id")) - db.recordDelete(AutoWorkflow, workflowId) + _cascadeDeleteWorkflow(db, workflowId) return {"deleted": True, "workflowId": workflowId} finally: db.close() @@ -328,7 +156,7 @@ async def _listRuns( mandateId: Optional[str] = Query(default=None), workflowId: Optional[str] = Query(default=None), ): - db = _getDb() + db = _getWorkflowAutomationDb() try: db._ensureTableExists(AutoRun) scopeFilter = _scopedRunFilter(request) @@ -342,7 +170,7 @@ async def _listRuns( if workflowId: scopeFilter = {**(scopeFilter or {}), "workflowId": workflowId} - params = _parsePagination(pagination) + params = _parsePaginationOr400(pagination) records = db.getRecordset(AutoRun, recordFilter=scopeFilter, pagination=params) total = db.getRecordCount(AutoRun, recordFilter=scopeFilter) if params else len(records or []) return {"items": records or [], "total": total} @@ -355,7 +183,7 @@ async def _getRun( runId: str, request: RequestContext = Depends(getRequestContext), ): - db = _getDb() + db = _getWorkflowAutomationDb() try: db._ensureTableExists(AutoRun) run = db.getRecord(AutoRun, runId) @@ -381,7 +209,7 @@ async def _listTasks( pagination: Optional[str] = Query(default=None), status: Optional[str] = Query(default=None), ): - db = _getDb() + db = _getWorkflowAutomationDb() try: db._ensureTableExists(AutoTask) scopeFilter: Optional[Dict[str, Any]] = None @@ -395,7 +223,7 @@ async def _listTasks( if status: scopeFilter = {**(scopeFilter or {}), "status": status} - params = _parsePagination(pagination) + params = _parsePaginationOr400(pagination) records = db.getRecordset(AutoTask, recordFilter=scopeFilter, pagination=params) total = db.getRecordCount(AutoTask, recordFilter=scopeFilter) if params else len(records or []) return {"items": records or [], "total": total} @@ -412,7 +240,7 @@ async def _listVersions( workflowId: str, request: RequestContext = Depends(getRequestContext), ): - db = _getDb() + db = _getWorkflowAutomationDb() try: db._ensureTableExists(AutoWorkflow) wf = db.getRecord(AutoWorkflow, workflowId) @@ -434,7 +262,7 @@ async def _listStepLogs( runId: str, request: RequestContext = Depends(getRequestContext), ): - db = _getDb() + db = _getWorkflowAutomationDb() try: db._ensureTableExists(AutoRun) run = db.getRecord(AutoRun, runId) @@ -451,3 +279,1559 @@ async def _listStepLogs( return {"items": steps or []} finally: db.close() + + +# --------------------------------------------------------------------------- +# Internal helpers (mandate resolution, connector adapter) +# --------------------------------------------------------------------------- + +def _resolveInstanceIdForWorkflow(db: DatabaseConnector, workflowId: str) -> Optional[str]: + """Look up the featureInstanceId stored on the workflow record.""" + if not workflowId: + return None + wf = db.getRecord(AutoWorkflow, workflowId) if db._ensureTableExists(AutoWorkflow) else None + if not wf: + return None + return wf.get("featureInstanceId") or wf.get("targetFeatureInstanceId") + + +def _resolveMandateIdForWorkflow(db: DatabaseConnector, workflowId: str) -> Optional[str]: + """Look up the mandateId stored on the workflow record.""" + if not workflowId: + return None + wf = db.getRecord(AutoWorkflow, workflowId) if db._ensureTableExists(AutoWorkflow) else None + if not wf: + return None + return wf.get("mandateId") + + +def _buildResolverDbInterface(chatService): + """Build a DB adapter that ConnectorResolver can use to load UserConnections.""" + class _ResolverDbAdapter: + def __init__(self, appInterface): + self._app = appInterface + + def getUserConnection(self, connectionId: str): + if hasattr(self._app, "getUserConnectionById"): + return self._app.getUserConnectionById(connectionId) + return None + + appIf = getattr(chatService, "interfaceDbApp", None) + if appIf: + return _ResolverDbAdapter(appIf) + return getattr(chatService, "interfaceDbComponent", None) + + +def _getWorkflowAutomationInterface(context: RequestContext, mandateId: str, instanceId: str): + """Build the WorkflowAutomation interface for template / import-export operations.""" + from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface as _ifaceFactory + return _ifaceFactory(context.user, mandateId, instanceId) + + +def _loadEnvelopeFromFile(fileId: str, context: RequestContext) -> Optional[Dict[str, Any]]: + """Load and parse a ``.workflow.json`` file from the Unified-Data-Bar by file id.""" + try: + import modules.interfaces.interfaceDbManagement as interfaceDbManagement + mgmt = interfaceDbManagement.getInterface(context.user) + rawBytes = mgmt.getFileData(fileId) + except Exception as exc: + logger.warning("Failed to load workflow file %s: %s", fileId, exc) + raise HTTPException(status_code=404, detail=routeApiMsg(f"File {fileId} not found")) + + if not rawBytes: + raise HTTPException(status_code=404, detail=routeApiMsg(f"File {fileId} is empty")) + + try: + if isinstance(rawBytes, bytes): + text = rawBytes.decode("utf-8") + else: + text = str(rawBytes) + return json.loads(text) + except Exception as exc: + raise HTTPException( + status_code=400, + detail=routeApiMsg(f"File {fileId} is not valid JSON: {exc}"), + ) + + +def _getUserAccessibleInstanceIds(userId: str) -> List[str]: + """Return all featureInstanceIds the user has enabled FeatureAccess for.""" + rootIface = getRootInterface() + allAccess = rootIface.getFeatureAccessesForUser(userId) or [] + return [ + a.featureInstanceId + for a in allAccess + if a.featureInstanceId and a.enabled + ] + + +# --------------------------------------------------------------------------- +# Group 4 — Templates +# --------------------------------------------------------------------------- + +@router.get("/templates") +@limiter.limit("60/minute") +def _listTemplates( + request: Request, + scope: Optional[str] = Query(None, description="Filter by scope: user, instance, mandate, system"), + pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), + mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"), + column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"), + mandateId: Optional[str] = Query(None, description="Mandate ID to scope templates"), + context: RequestContext = Depends(getRequestContext), +): + """List workflow templates with optional pagination. + + Supports the FormGeneratorTable backend pattern: + - default: paginated/filtered/sorted ``{items, pagination}`` response + - ``mode=filterValues&column=X``: distinct values for column X (cross-filtered) + - ``mode=ids``: all IDs matching current filters + """ + if not context.user: + raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) + + userId = str(context.user.id) + userMandateIds = _getUserMandateIds(userId) if not context.isPlatformAdmin else [] + effectiveMandateId = mandateId or (userMandateIds[0] if userMandateIds else None) + if not effectiveMandateId and not context.isPlatformAdmin: + return {"templates": []} + + instanceId = None + if effectiveMandateId: + db = _getWorkflowAutomationDb() + try: + if db._ensureTableExists(AutoWorkflow): + wfs = db.getRecordset(AutoWorkflow, recordFilter={"mandateId": effectiveMandateId}) + for w in (wfs or []): + fid = w.get("featureInstanceId") + if fid: + instanceId = fid + break + finally: + db.close() + + iface = _getWorkflowAutomationInterface(context, effectiveMandateId or "", instanceId or "") + 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 + if pagination: + try: + paginationDict = json.loads(pagination) + if paginationDict: + paginationDict = normalize_pagination_dict(paginationDict) + paginationParams = PaginationParams(**paginationDict) + except (json.JSONDecodeError, ValueError) as e: + raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}") + + if paginationParams: + filtered = applyFiltersAndSort(templates, paginationParams) + totalItems = len(filtered) + totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0 + startIdx = (paginationParams.page - 1) * paginationParams.pageSize + endIdx = startIdx + paginationParams.pageSize + return { + "items": filtered[startIdx:endIdx], + "pagination": PaginationMetadata( + currentPage=paginationParams.page, pageSize=paginationParams.pageSize, + totalItems=totalItems, totalPages=totalPages, + sort=paginationParams.sort, filters=paginationParams.filters, + ).model_dump(), + } + return {"templates": templates} + + +@router.post("/templates/from-workflow") +@limiter.limit("30/minute") +def _createTemplateFromWorkflow( + request: Request, + body: dict = Body(..., description="{ workflowId, scope? }"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Create a template from an existing workflow.""" + if not context.user: + raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) + + workflowId = body.get("workflowId") + scope = body.get("scope", "user") + if not workflowId: + raise HTTPException(status_code=400, detail=routeApiMsg("workflowId required")) + + db = _getWorkflowAutomationDb() + try: + wf = db.getRecord(AutoWorkflow, workflowId) if db._ensureTableExists(AutoWorkflow) else None + if not wf: + raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) + _validateWorkflowAccess(context, wf, "read") + mandateId = wf.get("mandateId", "") + instanceId = wf.get("featureInstanceId", "") + finally: + db.close() + + iface = _getWorkflowAutomationInterface(context, mandateId, instanceId) + template = iface.createTemplateFromWorkflow(workflowId, scope=scope) + if not template: + raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) + return template + + +@router.post("/templates/{templateId}/copy") +@limiter.limit("30/minute") +def _copyTemplate( + request: Request, + templateId: str = Path(..., description="Template ID"), + body: dict = Body(default={}, description="{ mandateId? }"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Copy a template to a new user-owned workflow.""" + if not context.user: + raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) + + mandateId = body.get("mandateId") if isinstance(body, dict) else None + userId = str(context.user.id) + if not mandateId: + userMandateIds = _getUserMandateIds(userId) + mandateId = userMandateIds[0] if userMandateIds else "" + + db = _getWorkflowAutomationDb() + try: + instanceId = None + if db._ensureTableExists(AutoWorkflow) and mandateId: + wfs = db.getRecordset(AutoWorkflow, recordFilter={"mandateId": mandateId}) + for w in (wfs or []): + fid = w.get("featureInstanceId") + if fid: + instanceId = fid + break + finally: + db.close() + + iface = _getWorkflowAutomationInterface(context, mandateId or "", instanceId or "") + workflow = iface.copyTemplateToUser(templateId) + if not workflow: + raise HTTPException(status_code=404, detail=routeApiMsg("Template not found")) + return workflow + + +@router.post("/templates/{templateId}/share") +@limiter.limit("30/minute") +def _shareTemplate( + request: Request, + templateId: str = Path(..., description="Template ID"), + body: dict = Body(..., description="{ scope }"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Share a template by changing its scope.""" + if not context.user: + raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) + + scope = body.get("scope") + if not scope or scope not in ("user", "instance", "mandate", "system"): + raise HTTPException(status_code=400, detail=routeApiMsg("scope must be user, instance, mandate, or system")) + + mandateId = body.get("mandateId", "") + userId = str(context.user.id) + if not mandateId: + userMandateIds = _getUserMandateIds(userId) + mandateId = userMandateIds[0] if userMandateIds else "" + + db = _getWorkflowAutomationDb() + try: + instanceId = None + if db._ensureTableExists(AutoWorkflow) and mandateId: + wfs = db.getRecordset(AutoWorkflow, recordFilter={"mandateId": mandateId}) + for w in (wfs or []): + fid = w.get("featureInstanceId") + if fid: + instanceId = fid + break + finally: + db.close() + + iface = _getWorkflowAutomationInterface(context, mandateId or "", instanceId or "") + template = iface.shareTemplate(templateId, scope=scope) + if not template: + raise HTTPException(status_code=404, detail=routeApiMsg("Template not found")) + return template + + +# --------------------------------------------------------------------------- +# Group 5 — Connections (SharePoint etc.) +# --------------------------------------------------------------------------- + +def _buildServiceCenterContext(context: RequestContext, mandateId: str, instanceId: str = ""): + """Build a ServiceCenterContext for connector/service calls.""" + from modules.serviceCenter.context import ServiceCenterContext + return ServiceCenterContext( + user=context.user, + mandate_id=str(context.mandateId) if context.mandateId else mandateId, + feature_instance_id=instanceId, + ) + + +@router.get("/connections") +@limiter.limit("300/minute") +def _listConnections( + request: Request, + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Return the user's active connections (UserConnections) for Email/SharePoint node config.""" + if not context.user: + raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) + + userId = str(context.user.id) + userMandateIds = _getUserMandateIds(userId) if not context.isPlatformAdmin else [] + mandateId = userMandateIds[0] if userMandateIds else "" + + from modules.serviceCenter import getService + ctx = _buildServiceCenterContext(context, mandateId) + chatService = getService("chat", ctx) + connections = chatService.getUserConnections() + items = [] + for c in connections or []: + conn = c if isinstance(c, dict) else (c.model_dump() if hasattr(c, "model_dump") else {}) + authority = conn.get("authority") + if hasattr(authority, "value"): + authority = authority.value + status = conn.get("status") + if hasattr(status, "value"): + status = status.value + items.append({ + "id": conn.get("id"), + "authority": authority, + "externalUsername": conn.get("externalUsername"), + "externalEmail": conn.get("externalEmail"), + "status": status, + }) + return {"connections": items} + + +@router.get("/connections/{connectionId}/services") +@limiter.limit("120/minute") +async def _listConnectionServices( + request: Request, + connectionId: str = Path(..., description="Connection ID"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Return the available services for a specific UserConnection.""" + if not context.user: + raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) + + userId = str(context.user.id) + userMandateIds = _getUserMandateIds(userId) if not context.isPlatformAdmin else [] + mandateId = userMandateIds[0] if userMandateIds else "" + + try: + from modules.connectors.connectorResolver import ConnectorResolver + from modules.serviceCenter import getService as getSvc + ctx = _buildServiceCenterContext(context, mandateId) + chatService = getSvc("chat", ctx) + securityService = getSvc("security", ctx) + dbInterface = _buildResolverDbInterface(chatService) + resolver = ConnectorResolver(securityService, dbInterface) + provider = await resolver.resolve(connectionId) + services = provider.getAvailableServices() + _serviceLabels = { + "sharepoint": "SharePoint", "clickup": "ClickUp", "outlook": "Outlook", + "teams": "Teams", "onedrive": "OneDrive", "drive": "Google Drive", + "gmail": "Gmail", "files": "Files (FTP)", "kdrive": "kDrive", + "calendar": "Calendar", "contact": "Contacts", + } + _serviceIcons = { + "sharepoint": "sharepoint", "clickup": "folder", "outlook": "mail", + "teams": "chat", "onedrive": "cloud", "drive": "cloud", + "gmail": "mail", "files": "folder", "kdrive": "cloud", + "calendar": "calendar", "contact": "contact", + } + items = [ + {"service": s, "label": _serviceLabels.get(s, s), "icon": _serviceIcons.get(s, "folder")} + for s in services + ] + return {"services": items} + except Exception as e: + logger.error(f"Error listing services for connection {connectionId}: {e}") + return JSONResponse({"services": [], "error": str(e)}, status_code=400) + + +@router.get("/connections/{connectionId}/browse") +@limiter.limit("300/minute") +async def _browseConnectionService( + request: Request, + connectionId: str = Path(..., description="Connection ID"), + service: str = Query(..., description="Service name (e.g. sharepoint, onedrive, outlook)"), + path: str = Query("/", description="Path within the service to browse"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Browse folders/items within a connection's service at a given path.""" + if not context.user: + raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) + + userId = str(context.user.id) + userMandateIds = _getUserMandateIds(userId) if not context.isPlatformAdmin else [] + mandateId = userMandateIds[0] if userMandateIds else "" + + try: + from modules.connectors.connectorResolver import ConnectorResolver + from modules.serviceCenter import getService as getSvc + ctx = _buildServiceCenterContext(context, mandateId) + chatService = getSvc("chat", ctx) + securityService = getSvc("security", ctx) + dbInterface = _buildResolverDbInterface(chatService) + resolver = ConnectorResolver(securityService, dbInterface) + adapter = await resolver.resolveService(connectionId, service) + entries = await adapter.browse(path, filter=None) + items = [] + for entry in (entries or []): + items.append({ + "name": entry.name, + "path": entry.path, + "isFolder": entry.isFolder, + "size": entry.size, + "mimeType": entry.mimeType, + "metadata": entry.metadata if hasattr(entry, "metadata") else {}, + }) + return {"items": items, "path": path, "service": service} + except Exception as e: + logger.error(f"Error browsing {service} for connection {connectionId} at '{path}': {e}") + return JSONResponse({"items": [], "error": str(e)}, status_code=400) + + +# --------------------------------------------------------------------------- +# Group 6 — Import / Export +# --------------------------------------------------------------------------- + +@router.post("/workflows/import") +@limiter.limit("30/minute") +def _importWorkflow( + request: Request, + body: dict = Body( + ..., + description=( + "{ envelope: , existingWorkflowId?: str, " + "fileId?: str, mandateId?: str } — supply EITHER the envelope " + "inline OR a fileId of a previously uploaded workflow file (.workflow.json)" + ), + ), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Import a workflow from a versioned-envelope file. + + Two input modes: + - ``envelope``: the parsed workflow-file payload + - ``fileId``: the id of a previously uploaded ``.workflow.json`` + """ + if not context.user: + raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) + + from modules.workflowAutomation.editor._workflowFileSchema import WorkflowFileSchemaError + + mandateId = body.get("mandateId") if isinstance(body, dict) else None + userId = str(context.user.id) + if not mandateId: + userMandateIds = _getUserMandateIds(userId) + mandateId = userMandateIds[0] if userMandateIds else "" + + if not mandateId and not context.isPlatformAdmin: + raise HTTPException(status_code=400, detail=routeApiMsg("mandateId required")) + + _validateWorkflowAccess(context, {"mandateId": mandateId}, "write") + + db = _getWorkflowAutomationDb() + try: + instanceId = None + if db._ensureTableExists(AutoWorkflow) and mandateId: + wfs = db.getRecordset(AutoWorkflow, recordFilter={"mandateId": mandateId}) + for w in (wfs or []): + fid = w.get("featureInstanceId") + if fid: + instanceId = fid + break + finally: + db.close() + + iface = _getWorkflowAutomationInterface(context, mandateId or "", instanceId or "") + + envelope = body.get("envelope") if isinstance(body, dict) else None + fileId = body.get("fileId") if isinstance(body, dict) else None + existingWorkflowId = body.get("existingWorkflowId") if isinstance(body, dict) else None + + if not envelope and fileId: + envelope = _loadEnvelopeFromFile(str(fileId), context) + + if not envelope: + raise HTTPException( + status_code=400, + detail=routeApiMsg("Body must contain 'envelope' or 'fileId'"), + ) + + try: + result = iface.importWorkflowFromDict(envelope, existingWorkflowId=existingWorkflowId) + except WorkflowFileSchemaError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + + return result + + +@router.get("/workflows/{workflowId}/export") +@limiter.limit("60/minute") +def _exportWorkflow( + request: Request, + workflowId: str = Path(..., description="Workflow ID"), + download: bool = Query(False, description="If true, return as file download"), + context: RequestContext = Depends(getRequestContext), +): + """Export a workflow as a versioned-envelope JSON file.""" + if not context.user: + raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) + + from modules.workflowAutomation.editor._workflowFileSchema import buildFileName + + db = _getWorkflowAutomationDb() + try: + wf = db.getRecord(AutoWorkflow, workflowId) if db._ensureTableExists(AutoWorkflow) else None + if not wf: + raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) + _validateWorkflowAccess(context, wf, "read") + mandateId = wf.get("mandateId", "") + instanceId = wf.get("featureInstanceId", "") + finally: + db.close() + + iface = _getWorkflowAutomationInterface(context, mandateId, instanceId) + envelope = iface.exportWorkflowToDict(workflowId) + if envelope is None: + raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) + + if not download: + return {"envelope": envelope, "fileName": buildFileName(envelope.get("label", "workflow"))} + + fileName = buildFileName(envelope.get("label", "workflow")) + payload = json.dumps(envelope, ensure_ascii=False, indent=2).encode("utf-8") + return Response( + content=payload, + media_type="application/json", + headers={"Content-Disposition": f'attachment; filename="{fileName}"'}, + ) + + +# --------------------------------------------------------------------------- +# Group 7 — Options +# --------------------------------------------------------------------------- + +@router.get("/options/user.connection") +@limiter.limit("60/minute") +def _getUserConnectionOptions( + request: Request, + authority: Optional[str] = Query(None, description="Optional authority filter (e.g. 'msft', 'google', 'clickup', 'local')"), + activeOnly: bool = Query(True, description="If true (default), only ACTIVE connections are returned"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Return current user's UserConnections as { options: [{ value, label }] }. + + Used by node parameters with frontendType='userConnection'. + """ + if not context.user: + raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) + + rootInterface = getRootInterface() + try: + connections = rootInterface.getUserConnections(str(context.user.id)) or [] + except Exception as e: + logger.error("_getUserConnectionOptions: failed to load connections: %s", e, exc_info=True) + return {"options": []} + + wanted = (authority or "").strip().lower() or None + options: List[Dict[str, str]] = [] + for conn in connections: + connStatus = getattr(conn, "status", None) + statusVal = connStatus.value if hasattr(connStatus, "value") else str(connStatus or "") + if activeOnly and statusVal.lower() != "active": + continue + connAuthority = getattr(conn, "authority", None) + authorityVal = (connAuthority.value if hasattr(connAuthority, "value") else str(connAuthority or "")).lower() + if wanted and authorityVal != wanted: + continue + username = getattr(conn, "externalUsername", "") or "" + email = getattr(conn, "externalEmail", "") or "" + connId = str(getattr(conn, "id", "") or "") + labelParts = [p for p in [username, email] if p] + label = " — ".join(labelParts) if labelParts else connId + if authorityVal: + label = f"[{authorityVal}] {label}" + value = f"connection:{authorityVal}:{username}" if authorityVal and username else connId + options.append({"value": value, "label": label}) + + return {"options": options} + + +@router.get("/options/feature.instance") +@limiter.limit("60/minute") +def _getFeatureInstanceOptions( + request: Request, + featureCode: str = Query(..., description="Feature code to filter by (e.g. 'trustee', 'redmine', 'clickup')"), + enabledOnly: bool = Query(True, description="If true (default), only enabled feature instances are returned"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Return mandate-scoped FeatureInstances for the given featureCode. + + Used by node parameters with frontendType='featureInstance'. + """ + if not context.user: + raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) + + code = (featureCode or "").strip().lower() + if not code: + raise HTTPException(status_code=400, detail=routeApiMsg("featureCode query parameter is required")) + + userId = str(context.user.id) + userMandateIds = _getUserMandateIds(userId) if not context.isPlatformAdmin else [] + if not userMandateIds and not context.isPlatformAdmin: + return {"options": []} + + rootInterface = getRootInterface() + allOptions: List[Dict[str, str]] = [] + + targetMandateIds = userMandateIds if not context.isPlatformAdmin else [] + if context.isPlatformAdmin: + try: + from modules.datamodels.datamodelMandate import Mandate + mandates = rootInterface.db.getRecordset(Mandate) or [] + targetMandateIds = [str(m.get("id") if isinstance(m, dict) else getattr(m, "id", "")) for m in mandates] + except Exception: + targetMandateIds = [] + + for mid in targetMandateIds: + try: + instances = rootInterface.getFeatureInstancesByMandate(mid, enabledOnly=bool(enabledOnly)) or [] + except Exception as e: + logger.error("_getFeatureInstanceOptions: failed to load instances mandateId=%s: %s", mid, e, exc_info=True) + continue + + for fi in instances: + fiCode = (getattr(fi, "featureCode", "") or "").strip().lower() + if fiCode != code: + continue + fiId = str(getattr(fi, "id", "") or "") + if not fiId: + continue + rawLabel = getattr(fi, "label", None) or getattr(fi, "name", None) or fiId + allOptions.append({"value": fiId, "label": f"{rawLabel} ({fiCode})"}) + + return {"options": allOptions} + + +# --------------------------------------------------------------------------- +# Group 8 — Metrics +# --------------------------------------------------------------------------- + +@router.get("/metrics") +@limiter.limit("60/minute") +def _getMetrics( + request: Request, + mandateId: Optional[str] = Query(None, description="Filter metrics by mandate"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Aggregated metrics for the monitoring dashboard.""" + if not context.user: + raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) + + userId = str(context.user.id) + userMandateIds = _getUserMandateIds(userId) if not context.isPlatformAdmin else [] + + if mandateId: + if not context.isPlatformAdmin and mandateId not in userMandateIds: + raise HTTPException(status_code=403, detail=routeApiMsg("Access denied")) + scopeFilter: Dict[str, Any] = {"mandateId": mandateId, "isTemplate": False} + elif context.isPlatformAdmin: + scopeFilter = {"isTemplate": False} + elif userMandateIds: + scopeFilter = {"mandateId": userMandateIds, "isTemplate": False} + else: + return { + "workflowCount": 0, "activeWorkflows": 0, "totalRuns": 0, + "runsByStatus": {}, "totalTasks": 0, "tasksByStatus": {}, + "totalTokens": 0, "totalCredits": 0.0, + } + + db = _getWorkflowAutomationDb() + try: + workflows = db.getRecordset(AutoWorkflow, recordFilter=scopeFilter) or [] if db._ensureTableExists(AutoWorkflow) else [] + wfIds = [w.get("id") for w in workflows] + runFilter = {"workflowId": {"$in": wfIds}} if wfIds else {"workflowId": "__none__"} + runs = db.getRecordset(AutoRun, recordFilter=runFilter) or [] if db._ensureTableExists(AutoRun) else [] + tasks = db.getRecordset(AutoTask, recordFilter=runFilter) or [] if db._ensureTableExists(AutoTask) else [] + finally: + db.close() + + runsByStatus: Dict[str, int] = {} + totalTokens = 0 + totalCredits = 0.0 + for r in runs: + s = r.get("status", "unknown") + runsByStatus[s] = runsByStatus.get(s, 0) + 1 + totalTokens += r.get("costTokens", 0) or 0 + totalCredits += r.get("costCredits", 0.0) or 0.0 + + tasksByStatus: Dict[str, int] = {} + for t in tasks: + s = t.get("status", "unknown") + tasksByStatus[s] = tasksByStatus.get(s, 0) + 1 + + return { + "workflowCount": len(workflows), + "activeWorkflows": sum(1 for w in workflows if w.get("active")), + "totalRuns": len(runs), + "runsByStatus": runsByStatus, + "totalTasks": len(tasks), + "tasksByStatus": tasksByStatus, + "totalTokens": totalTokens, + "totalCredits": round(totalCredits, 4), + } + + +# --------------------------------------------------------------------------- +# Group 9 — SSE Stream + Stop + Run Detail +# --------------------------------------------------------------------------- + +@router.get("/runs/{runId}/stream") +async def _getRunStream( + request: Request, + runId: str = Path(..., description="Run ID"), + context: RequestContext = Depends(getRequestContext), +): + """SSE stream for live step-log updates during a workflow run.""" + db = _getWorkflowAutomationDb() + try: + if not db._ensureTableExists(AutoRun): + raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) + + runs = db.getRecordset(AutoRun, recordFilter={"id": runId}) + if not runs: + raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) + run = dict(runs[0]) + finally: + db.close() + + if not context.isPlatformAdmin: + userId = str(context.user.id) if context.user else None + runOwner = run.get("ownerId") + runMandate = run.get("mandateId") + if runOwner == userId: + pass + elif runMandate and userId and _isUserMandateAdmin(userId, runMandate): + pass + else: + raise HTTPException(status_code=403, detail=routeApiMsg("Access denied")) + + from modules.serviceCenter.core.serviceStreaming.eventManager import get_event_manager + sseEventManager = get_event_manager() + queueId = f"run-trace-{runId}" + sseEventManager.create_queue(queueId) + + async def _sseGenerator(): + queue = sseEventManager.get_queue(queueId) + if not queue: + return + while True: + try: + event = await asyncio.wait_for(queue.get(), timeout=30) + except asyncio.TimeoutError: + yield "data: {\"type\": \"keepalive\"}\n\n" + continue + if event is None: + break + payload = event.get("data", event) if isinstance(event, dict) else event + yield f"data: {json.dumps(payload, default=str)}\n\n" + eventType = payload.get("type", "") if isinstance(payload, dict) else "" + if eventType in ("run_complete", "run_failed"): + break + await sseEventManager.cleanup(queueId, delay=10) + + return StreamingResponse( + _sseGenerator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + +@router.post("/runs/{runId}/stop") +@limiter.limit("30/minute") +def _stopWorkflowRun( + request: Request, + runId: str = Path(..., description="Run ID"), + context: RequestContext = Depends(getRequestContext), +): + """Stop a running workflow execution.""" + db = _getWorkflowAutomationDb() + try: + if not db._ensureTableExists(AutoRun): + raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) + + runs = db.getRecordset(AutoRun, recordFilter={"id": runId}) + if not runs: + raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) + run = dict(runs[0]) + + if not context.isPlatformAdmin: + userId = str(context.user.id) if context.user else None + runOwner = run.get("ownerId") + runMandate = run.get("mandateId") + if runOwner == userId: + pass + elif runMandate and userId and _isUserMandateAdmin(userId, runMandate): + pass + else: + raise HTTPException(status_code=403, detail=routeApiMsg("Access denied")) + + from modules.workflowAutomation.engine.executionEngine import requestRunStop + flagged = requestRunStop(runId) + + if not flagged: + currentStatus = run.get("status", "") + if currentStatus in ("completed", "failed", "stopped"): + return {"status": currentStatus, "runId": runId, "message": "Run already finished"} + stopUpdates: Dict[str, Any] = {"status": "stopped"} + if not run.get("completedAt"): + stopUpdates["completedAt"] = time.time() + db.recordModify(AutoRun, runId, stopUpdates) + return {"status": "stopped", "runId": runId, "message": "Run not active in memory, marked as stopped"} + + return {"status": "stopping", "runId": runId, "message": "Stop signal sent"} + finally: + db.close() + + +# --------------------------------------------------------------------------- +# Run Detail (enriched with step logs, workflow info, files) +# --------------------------------------------------------------------------- + +_FILE_REF_KEYS = ("fileId", "documentId", "fileIds", "documents") + + +def _extractFileIdsFromValue(value, accumulator: set) -> None: + """Recursively scan a value (dict/list/str) for file id references.""" + if isinstance(value, dict): + for key, sub in value.items(): + if key in _FILE_REF_KEYS: + _collectFileIdsFromRef(sub, accumulator) + else: + _extractFileIdsFromValue(sub, accumulator) + elif isinstance(value, list): + for item in value: + _extractFileIdsFromValue(item, accumulator) + + +def _collectFileIdsFromRef(val, accumulator: set) -> None: + """Add file ids from a value located under a known file-reference key.""" + if isinstance(val, str) and val: + accumulator.add(val) + elif isinstance(val, list): + for v in val: + if isinstance(v, str) and v: + accumulator.add(v) + elif isinstance(v, dict) and v.get("id"): + accumulator.add(v["id"]) + elif isinstance(val, dict) and val.get("id"): + accumulator.add(val["id"]) + + +@router.get("/runs/{runId}/detail") +@limiter.limit("60/minute") +def _getRunDetail( + request: Request, + runId: str = Path(..., description="Run ID"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Get full detail for a single run: metadata, step logs, linked files.""" + if not context.user: + raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) + + userId = str(context.user.id) + db = _getWorkflowAutomationDb() + + try: + if not db._ensureTableExists(AutoRun): + raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) + + runs = db.getRecordset(AutoRun, recordFilter={"id": runId}) + if not runs: + raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) + run = dict(runs[0]) + + wfId = run.get("workflowId") + workflow: dict = {} + if wfId and db._ensureTableExists(AutoWorkflow): + wfs = db.getRecordset(AutoWorkflow, recordFilter={"id": wfId}) + if wfs: + workflow = dict(wfs[0]) + + tid = workflow.get("targetFeatureInstanceId") or workflow.get("featureInstanceId") + accessibleIds = _getUserAccessibleInstanceIds(userId) + isOwner = run.get("ownerId") == userId + + if not isOwner and (not tid or tid not in accessibleIds) and not context.isPlatformAdmin: + raise HTTPException(status_code=403, detail=routeApiMsg("Access denied")) + + steps: list = [] + if db._ensureTableExists(AutoStepLog): + stepRecords = db.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or [] + steps = [dict(s) for s in stepRecords] + steps.sort(key=lambda s: s.get("startedAt") or 0) + + allFileIds: set = set() + perStepFileIds: list = [] + for step in steps: + inputIds: set = set() + outputIds: set = set() + _extractFileIdsFromValue(step.get("inputSnapshot") or {}, inputIds) + _extractFileIdsFromValue(step.get("output") or {}, outputIds) + perStepFileIds.append((inputIds, outputIds)) + allFileIds.update(inputIds) + allFileIds.update(outputIds) + + nodeOutputs = run.get("nodeOutputs") or {} + runLevelIds: set = set() + _extractFileIdsFromValue(nodeOutputs, runLevelIds) + allFileIds.update(runLevelIds) + + fileMetaById: dict = {} + try: + from modules.datamodels.datamodelFiles import FileItem + from modules.interfaces.interfaceDbManagement import ComponentObjects + mgmtDb = ComponentObjects().db + if mgmtDb._ensureTableExists(FileItem): + for fid in allFileIds: + try: + rec = mgmtDb.getRecord(FileItem, fid) + if rec: + recDict = dict(rec) + fileMetaById[fid] = { + "id": fid, + "fileName": recDict.get("fileName") or recDict.get("name"), + } + except Exception: + pass + except Exception as e: + logger.warning("_getRunDetail: file lookup failed: %s", e) + + from modules.workflowAutomation.engine.workflowArtifactVisibility import suppress_workflow_file_in_workspace_ui + + def _resolveFileList(ids: set) -> list: + rows = [dict(fileMetaById[fid]) for fid in ids if fid in fileMetaById] + return [m for m in rows if not suppress_workflow_file_in_workspace_ui(m)] + + assignedFileIds: set = set() + for step, (inputIds, outputIds) in zip(steps, perStepFileIds): + step["inputFiles"] = _resolveFileList(inputIds) + step["outputFiles"] = _resolveFileList(outputIds) + assignedFileIds.update(inputIds) + assignedFileIds.update(outputIds) + + unassignedFiles = _resolveFileList(allFileIds - assignedFileIds) + allFiles = _resolveFileList(allFileIds) + + run["workflowLabel"] = run.get("label") or workflow.get("label") or wfId + run["targetFeatureInstanceId"] = tid + + targetInstanceLabel = None + if tid: + try: + from modules.dbHelpers.fkLabelResolver import resolveInstanceLabels + labelMap = resolveInstanceLabels(db, [tid]) + targetInstanceLabel = labelMap.get(tid) + except Exception: + pass + run["targetInstanceLabel"] = targetInstanceLabel + + return { + "run": run, + "workflow": { + "id": workflow.get("id"), + "label": workflow.get("label"), + "targetFeatureInstanceId": tid, + "featureInstanceId": workflow.get("featureInstanceId"), + "tags": workflow.get("tags", []), + } if workflow else None, + "steps": steps, + "files": allFiles, + "unassignedFiles": unassignedFiles, + } + finally: + db.close() + + +# --------------------------------------------------------------------------- +# Execute workflow +# --------------------------------------------------------------------------- + +def _buildExecuteRunEnvelope( + body: Dict[str, Any], + workflow: Optional[Dict[str, Any]], + userId: Optional[str], + requestLang: Optional[str] = None, +) -> Dict[str, Any]: + """Build normalized run envelope from POST /execute body.""" + from modules.workflowAutomation.engine.runEnvelope import ( + default_run_envelope, + merge_run_envelope, + normalize_run_envelope, + ) + from modules.workflowAutomation.editor.entryPoints import find_invocation + + if isinstance(body.get("runEnvelope"), dict): + env = normalize_run_envelope(body["runEnvelope"], user_id=userId) + pl = body.get("payload") + if isinstance(pl, dict): + env = merge_run_envelope(env, {"payload": pl}) + return env + + entryPointId = body.get("entryPointId") + if entryPointId: + if not workflow: + raise HTTPException( + status_code=400, + detail=routeApiMsg("entryPointId requires a saved workflow (workflowId must refer to a stored workflow)"), + ) + inv = find_invocation(workflow, entryPointId) + if not inv: + raise HTTPException(status_code=400, detail=routeApiMsg("entryPointId not found on workflow")) + if not inv.get("enabled", True): + raise HTTPException(status_code=400, detail=routeApiMsg("entry point is disabled")) + kind = inv.get("kind", "manual") + trigMap = { + "manual": "manual", + "form": "form", + "schedule": "schedule", + "always_on": "event", + "email": "email", + "webhook": "webhook", + "api": "api", + "event": "event", + } + trig = trigMap.get(kind, "manual") + title = inv.get("title") or {} + label = resolveText(title) + base = default_run_envelope( + trig, + entry_point_id=inv.get("id"), + entry_point_label=label or None, + ) + pl = body.get("payload") + if isinstance(pl, dict): + base = merge_run_envelope(base, {"payload": pl}) + return normalize_run_envelope(base, user_id=userId) + + env = normalize_run_envelope(None, user_id=userId) + pl = body.get("payload") + if isinstance(pl, dict): + env = merge_run_envelope(env, {"payload": pl}) + return env + + +@router.post("/workflows/{workflowId}/execute") +@limiter.limit("30/minute") +async def _executeWorkflow( + request: Request, + workflowId: str = Path(..., description="Workflow ID"), + body: dict = Body(..., description="{ graph?, entryPointId?, payload?, runEnvelope? }"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Execute a workflow graph.""" + from modules.workflowAutomation.mainWorkflowAutomation import _getWorkflowAutomationServices + from modules.workflowAutomation.engine.executionEngine import executeGraph + from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface + from modules.workflows.processing.shared.methodDiscovery import discoverMethods + + userId = str(context.user.id) if context.user else None + logger.info("workflowAutomation execute: workflowId=%s userId=%s", workflowId, userId) + + db = _getWorkflowAutomationDb() + try: + db._ensureTableExists(AutoWorkflow) + wf = db.getRecord(AutoWorkflow, workflowId) + finally: + db.close() + + if not wf: + raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) + _validateWorkflowAccess(context, wf, "execute") + + mandateId = wf.get("mandateId") + instanceId = wf.get("featureInstanceId") or "" + targetFeatureInstanceId = wf.get("targetFeatureInstanceId") + + services = _getWorkflowAutomationServices( + context.user, + mandateId=mandateId, + featureInstanceId=instanceId, + ) + discoverMethods(services) + + graph = body.get("graph") or body + reqNodes = graph.get("nodes") or [] + workflowForEnvelope: Optional[Dict[str, Any]] = wf + + if len(reqNodes) == 0: + graph = wf.get("graph") or {} + logger.info("workflowAutomation execute: loaded graph from workflow %s", workflowId) + + nodesCount = len(graph.get("nodes") or []) + connectionsCount = len(graph.get("connections") or []) + logger.info( + "workflowAutomation execute: graph nodes=%d connections=%d workflowId=%s mandateId=%s", + nodesCount, connectionsCount, workflowId, mandateId, + ) + + runEnv = _buildExecuteRunEnvelope( + body, + workflowForEnvelope, + userId, + getattr(context.user, "language", None) if context.user else None, + ) + + wfLabel = wf.get("label") if isinstance(wf, dict) else getattr(wf, "label", None) + + iface = _getWorkflowAutomationInterface(context.user, mandateId, instanceId) + result = await executeGraph( + graph=graph, + services=services, + workflowId=workflowId, + instanceId=instanceId, + userId=userId, + mandateId=mandateId, + automation2_interface=iface, + run_envelope=runEnv, + label=wfLabel, + targetFeatureInstanceId=targetFeatureInstanceId, + ) + logger.info( + "workflowAutomation execute result: success=%s error=%s paused=%s", + result.get("success"), result.get("error"), result.get("paused"), + ) + return result + + +# --------------------------------------------------------------------------- +# Version management +# --------------------------------------------------------------------------- + +@router.post("/workflows/{workflowId}/versions/draft") +@limiter.limit("30/minute") +async def _createDraftVersion( + request: Request, + workflowId: str = Path(..., description="Workflow ID"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Create a new draft version from the workflow's current graph.""" + from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface + + db = _getWorkflowAutomationDb() + try: + db._ensureTableExists(AutoWorkflow) + wf = db.getRecord(AutoWorkflow, workflowId) + finally: + db.close() + + if not wf: + raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) + _validateWorkflowAccess(context, wf, "write") + + mandateId = wf.get("mandateId") + instanceId = wf.get("featureInstanceId") or "" + iface = _getWorkflowAutomationInterface(context.user, mandateId, instanceId) + version = iface.createDraftVersion(workflowId) + if not version: + raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) + return version + + +@router.post("/versions/{versionId}/publish") +@limiter.limit("30/minute") +async def _publishVersion( + request: Request, + versionId: str = Path(..., description="Version ID"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Publish a draft version. Archives the previously published version.""" + from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface + + db = _getWorkflowAutomationDb() + try: + db._ensureTableExists(AutoVersion) + ver = db.getRecord(AutoVersion, versionId) + if not ver: + raise HTTPException(status_code=404, detail=routeApiMsg("Version not found")) + wfId = ver.get("workflowId") + db._ensureTableExists(AutoWorkflow) + wf = db.getRecord(AutoWorkflow, wfId) if wfId else None + finally: + db.close() + + if not wf: + raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) + _validateWorkflowAccess(context, wf, "write") + + mandateId = wf.get("mandateId") + instanceId = wf.get("featureInstanceId") or "" + userId = str(context.user.id) if context.user else None + iface = _getWorkflowAutomationInterface(context.user, mandateId, instanceId) + version = iface.publishVersion(versionId, userId=userId) + if not version: + raise HTTPException(status_code=400, detail=routeApiMsg("Version not found or not in draft status")) + return version + + +@router.post("/versions/{versionId}/unpublish") +@limiter.limit("30/minute") +async def _unpublishVersion( + request: Request, + versionId: str = Path(..., description="Version ID"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Unpublish a version (revert to draft).""" + from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface + + db = _getWorkflowAutomationDb() + try: + db._ensureTableExists(AutoVersion) + ver = db.getRecord(AutoVersion, versionId) + if not ver: + raise HTTPException(status_code=404, detail=routeApiMsg("Version not found")) + wfId = ver.get("workflowId") + db._ensureTableExists(AutoWorkflow) + wf = db.getRecord(AutoWorkflow, wfId) if wfId else None + finally: + db.close() + + if not wf: + raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) + _validateWorkflowAccess(context, wf, "write") + + mandateId = wf.get("mandateId") + instanceId = wf.get("featureInstanceId") or "" + iface = _getWorkflowAutomationInterface(context.user, mandateId, instanceId) + version = iface.unpublishVersion(versionId) + if not version: + raise HTTPException(status_code=400, detail=routeApiMsg("Version not found or not published")) + return version + + +@router.post("/versions/{versionId}/archive") +@limiter.limit("30/minute") +async def _archiveVersion( + request: Request, + versionId: str = Path(..., description="Version ID"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Archive a version.""" + from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface + + db = _getWorkflowAutomationDb() + try: + db._ensureTableExists(AutoVersion) + ver = db.getRecord(AutoVersion, versionId) + if not ver: + raise HTTPException(status_code=404, detail=routeApiMsg("Version not found")) + wfId = ver.get("workflowId") + db._ensureTableExists(AutoWorkflow) + wf = db.getRecord(AutoWorkflow, wfId) if wfId else None + finally: + db.close() + + if not wf: + raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) + _validateWorkflowAccess(context, wf, "write") + + mandateId = wf.get("mandateId") + instanceId = wf.get("featureInstanceId") or "" + iface = _getWorkflowAutomationInterface(context.user, mandateId, instanceId) + version = iface.archiveVersion(versionId) + if not version: + raise HTTPException(status_code=404, detail=routeApiMsg("Version not found")) + return version + + +# --------------------------------------------------------------------------- +# Node types + Editor metadata +# --------------------------------------------------------------------------- + +@router.get("/node-types") +@limiter.limit("60/minute") +async def _getNodeTypes( + request: Request, + mandateId: str = Query(..., description="Mandate ID for context"), + featureInstanceId: Optional[str] = Query(default=None, description="Feature instance ID"), + language: str = Query("en", description="Localization (en, de, fr)"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Return node types for the flow builder: static + I/O from methodDiscovery.""" + from modules.workflowAutomation.mainWorkflowAutomation import _getWorkflowAutomationServices + from modules.workflowAutomation.editor.nodeRegistry import getNodeTypesForApi + + logger.info("workflowAutomation node-types: mandateId=%s language=%s", mandateId, language) + services = _getWorkflowAutomationServices( + context.user, + mandateId=mandateId, + featureInstanceId=featureInstanceId or "", + ) + result = getNodeTypesForApi(services, language=language) + logger.info( + "workflowAutomation node-types response: %d nodeTypes %d categories", + len(result.get("nodeTypes", [])), + len(result.get("categories", [])), + ) + return result + + +@router.post("/upstream-paths") +@limiter.limit("60/minute") +async def _postUpstreamPaths( + request: Request, + body: Dict[str, Any] = Body(...), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Return pickable upstream DataRef paths for a node (draft graph in body).""" + from modules.workflowAutomation.editor.upstreamPathsService import compute_upstream_paths + + graph = body.get("graph") + nodeId = body.get("nodeId") + if not isinstance(graph, dict) or not nodeId: + raise HTTPException(status_code=400, detail=routeApiMsg("graph and nodeId are required")) + paths = compute_upstream_paths(graph, str(nodeId)) + return {"paths": paths} + + +@router.post("/condition-meta") +@limiter.limit("120/minute") +async def _postConditionMeta( + request: Request, + body: Dict[str, Any] = Body(...), + language: str = Query("de", description="Localization (en, de, fr)"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Return valueKind and operators for a DataRef (backend-driven If/Else UI).""" + from modules.workflowAutomation.editor.conditionOperators import resolve_condition_meta + + graph = body.get("graph") + ref = body.get("ref") + nodeId = body.get("nodeId") + if not isinstance(graph, dict) or not isinstance(ref, dict): + raise HTTPException(status_code=400, detail=routeApiMsg("graph and ref are required")) + graphPayload = dict(graph) + if nodeId: + graphPayload["targetNodeId"] = str(nodeId) + return resolve_condition_meta(graphPayload, ref, lang=language) + + +@router.post("/graph-data-sources") +@limiter.limit("120/minute") +async def _postGraphDataSources( + request: Request, + body: Dict[str, Any] = Body(...), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Scope-aware data sources for the DataPicker.""" + from modules.workflowAutomation.editor.upstreamPathsService import compute_graph_data_sources + + graph = body.get("graph") + nodeId = body.get("nodeId") + if not isinstance(graph, dict) or not nodeId: + raise HTTPException(status_code=400, detail=routeApiMsg("graph and nodeId are required")) + return compute_graph_data_sources(graph, str(nodeId)) + + +@router.get("/upstream-paths/{nodeId}") +@limiter.limit("60/minute") +async def _getUpstreamPathsSaved( + request: Request, + nodeId: str = Path(..., description="Target node id"), + workflowId: str = Query(..., description="Workflow id whose saved graph is used"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Return upstream paths using the persisted workflow graph.""" + from modules.workflowAutomation.editor.upstreamPathsService import compute_upstream_paths + + if not workflowId: + raise HTTPException(status_code=400, detail=routeApiMsg("workflowId is required")) + + db = _getWorkflowAutomationDb() + try: + db._ensureTableExists(AutoWorkflow) + wf = db.getRecord(AutoWorkflow, workflowId) + finally: + db.close() + + if not wf: + raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) + _validateWorkflowAccess(context, wf, "read") + + graph = wf.get("graph") or {} + paths = compute_upstream_paths(graph if isinstance(graph, dict) else {}, str(nodeId)) + return {"paths": paths} + + +# --------------------------------------------------------------------------- +# Tasks complete/cancel +# --------------------------------------------------------------------------- + +@router.post("/tasks/{taskId}/complete") +@limiter.limit("30/minute") +async def _completeTask( + request: Request, + taskId: str = Path(..., description="Task ID"), + body: dict = Body(..., description="{ result }"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Complete a human task and resume the workflow.""" + from modules.workflowAutomation.mainWorkflowAutomation import _getWorkflowAutomationServices + from modules.workflowAutomation.engine.executionEngine import executeGraph + from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface + + db = _getWorkflowAutomationDb() + try: + db._ensureTableExists(AutoTask) + task = db.getRecord(AutoTask, taskId) + finally: + db.close() + + if not task: + raise HTTPException(status_code=404, detail=routeApiMsg("Task not found")) + + wfId = task.get("workflowId") + db2 = _getWorkflowAutomationDb() + try: + db2._ensureTableExists(AutoWorkflow) + wf = db2.getRecord(AutoWorkflow, wfId) if wfId else None + finally: + db2.close() + + if not wf: + raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) + _validateWorkflowAccess(context, wf, "execute") + + mandateId = wf.get("mandateId") + instanceId = wf.get("featureInstanceId") or "" + iface = _getWorkflowAutomationInterface(context.user, mandateId, instanceId) + + taskRecord = iface.getTask(taskId) + if not taskRecord: + raise HTTPException(status_code=404, detail=routeApiMsg("Task not found")) + + runId = taskRecord.get("runId") + result = body.get("result") + if result is None: + raise HTTPException(status_code=400, detail=routeApiMsg("result required")) + + run = iface.getRun(runId) + if not run: + raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) + if taskRecord.get("status") != "pending": + raise HTTPException(status_code=400, detail=routeApiMsg("Task already completed")) + + iface.updateTask(taskId, status="completed", result=result) + taskNodeId = taskRecord.get("nodeId") + nodeOutputs = dict(run.get("nodeOutputs") or {}) + nodeOutputs[taskNodeId] = result + + workflowId = run.get("workflowId") + wfForGraph = iface.getWorkflow(workflowId) if workflowId else None + if not wfForGraph or not wfForGraph.get("graph"): + raise HTTPException(status_code=400, detail=routeApiMsg("Workflow graph not found")) + + graph = wfForGraph["graph"] + services = _getWorkflowAutomationServices(context.user, mandateId=mandateId, featureInstanceId=instanceId) + return await executeGraph( + graph=graph, + services=services, + workflowId=workflowId, + instanceId=instanceId, + userId=str(context.user.id) if context.user else None, + mandateId=mandateId, + automation2_interface=iface, + initialNodeOutputs=nodeOutputs, + startAfterNodeId=taskNodeId, + runId=runId, + ) + + +@router.post("/tasks/{taskId}/cancel") +@limiter.limit("30/minute") +async def _cancelTask( + request: Request, + taskId: str = Path(..., description="Human task ID"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Cancel a pending human task and stop the workflow run behind it.""" + from modules.workflowAutomation.engine.executionEngine import requestRunStop + from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface + + db = _getWorkflowAutomationDb() + try: + db._ensureTableExists(AutoTask) + task = db.getRecord(AutoTask, taskId) + finally: + db.close() + + if not task: + raise HTTPException(status_code=404, detail=routeApiMsg("Task not found")) + + wfId = task.get("workflowId") + db2 = _getWorkflowAutomationDb() + try: + db2._ensureTableExists(AutoWorkflow) + wf = db2.getRecord(AutoWorkflow, wfId) if wfId else None + finally: + db2.close() + + if not wf: + raise HTTPException(status_code=404, detail=routeApiMsg("Task not found")) + _validateWorkflowAccess(context, wf, "execute") + + mandateId = wf.get("mandateId") + instanceId = wf.get("featureInstanceId") or "" + iface = _getWorkflowAutomationInterface(context.user, mandateId, instanceId) + + taskRecord = iface.getTask(taskId) + if not taskRecord: + raise HTTPException(status_code=404, detail=routeApiMsg("Task not found")) + if taskRecord.get("status") != "pending": + raise HTTPException(status_code=400, detail=routeApiMsg("Task already completed")) + + runId = taskRecord.get("runId") + + if runId: + requestRunStop(runId) + dbRun = iface.getRun(runId) + if dbRun: + current = dbRun.get("status") or "" + if current not in ("completed", "failed", "cancelled"): + iface.updateRun(runId, status="cancelled") + + pending = iface.getTasks(runId=runId, status="pending") + for t in pending: + tid = t.get("id") + if tid: + iface.updateTask(tid, status="cancelled") + else: + iface.updateTask(taskId, status="cancelled") + + return {"success": True, "runId": runId, "taskId": taskId} diff --git a/modules/routes/routeWorkflowDashboard.py b/modules/routes/routeWorkflowDashboard.py deleted file mode 100644 index 020e5ec7..00000000 --- a/modules/routes/routeWorkflowDashboard.py +++ /dev/null @@ -1,1293 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -""" -System-level Workflow Dashboard API. - -Provides cross-feature, cross-mandate access to workflow runs AND workflows -with RBAC scoping: user sees own runs/workflows, mandate admin sees mandate -runs/workflows, sysadmin sees all. -""" - -import asyncio -import json -import logging -import math -import re -import time -from datetime import datetime, timezone -from functools import partial -from typing import Optional, List -from fastapi import APIRouter, Depends, Request, Query, Path, HTTPException -from fastapi.responses import StreamingResponse -from slowapi import Limiter -from slowapi.util import get_remote_address - -from modules.auth.authentication import getRequestContext, RequestContext -from modules.interfaces.interfaceDbApp import getRootInterface -from modules.connectors.connectorDbPostgre import DatabaseConnector -from modules.shared.configuration import APP_CONFIG -from modules.datamodels.datamodelPagination import PaginationParams, normalize_pagination_dict -from modules.datamodels.datamodelWorkflowAutomation import ( - AutoRun, AutoStepLog, AutoWorkflow, AutoTask, AutoVersion, - GRAPHICAL_EDITOR_DATABASE, -) -from modules.shared.i18nRegistry import apiRouteContext - -routeApiMsg = apiRouteContext("routeWorkflowDashboard") - -logger = logging.getLogger(__name__) -limiter = Limiter(key_func=get_remote_address) - -router = APIRouter(prefix="/api/system/workflow-runs", tags=["WorkflowDashboard"]) - - -def _getDb() -> DatabaseConnector: - return DatabaseConnector( - dbHost=APP_CONFIG.get("DB_HOST", "localhost"), - dbDatabase=GRAPHICAL_EDITOR_DATABASE, - dbUser=APP_CONFIG.get("DB_USER"), - dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), - dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), - userId=None, - ) - - -def _getUserMandateIds(userId: str) -> list[str]: - """Get mandate IDs the user is a member of.""" - rootIface = getRootInterface() - memberships = rootIface.getUserMandates(userId) - return [um.mandateId for um in memberships if um.mandateId and um.enabled] - - -def _getAdminMandateIds(userId: str, mandateIds: list) -> list: - """Batch-check which mandates the user is admin for (UserMandate → UserMandateRole → Role).""" - if not mandateIds: - return [] - rootIface = getRootInterface() - from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole - - memberships = rootIface.db.getRecordset( - UserMandate, - recordFilter={"userId": userId, "mandateId": mandateIds, "enabled": True}, - ) - if not memberships: - return [] - - umIdToMandateId: dict[str, str] = {} - for m in memberships: - row = m if isinstance(m, dict) else m.__dict__ - um_id = row.get("id") - mid = row.get("mandateId") - if um_id and mid: - umIdToMandateId[str(um_id)] = str(mid) - - userMandateIds = list(umIdToMandateId.keys()) - allRoles = rootIface.db.getRecordset( - UserMandateRole, - recordFilter={"userMandateId": userMandateIds}, - ) - if not allRoles: - return [] - - roleIds = set() - roleToMandate: dict = {} - for r in allRoles: - row = r if isinstance(r, dict) else r.__dict__ - rid = row.get("roleId") - um_id = row.get("userMandateId") - mid = umIdToMandateId.get(str(um_id)) if um_id else None - if rid and mid: - roleIds.add(rid) - roleToMandate.setdefault(rid, set()).add(mid) - - if not roleIds: - return [] - - from modules.datamodels.datamodelRbac import Role - roleRecords = rootIface.db.getRecordset(Role, recordFilter={"id": list(roleIds)}) - adminMandates: set = set() - for role in (roleRecords or []): - row = role if isinstance(role, dict) else role.__dict__ - rid = row.get("id") - if not rid or rid not in roleToMandate: - continue - # Same rule as routeBilling._isAdminOfMandate / notifyMandateAdmins - if row.get("roleLabel") == "admin" and not row.get("featureInstanceId"): - adminMandates.update(roleToMandate[rid]) - - return [mid for mid in mandateIds if mid in adminMandates] - - -def _isUserMandateAdmin(userId: str, mandateId: str) -> bool: - """Check if user is admin for a specific mandate.""" - adminIds = _getAdminMandateIds(userId, [mandateId]) - return mandateId in adminIds - - -def _scopedRunFilter(context: RequestContext) -> Optional[dict]: - """ - Build a DB filter dict based on RBAC: - - sysadmin: None (no filter) - - mandate admin: mandateId IN user's mandates - - normal user: ownerId = userId - """ - if context.isPlatformAdmin: - return None - - userId = str(context.user.id) if context.user else None - if not userId: - return {"ownerId": "__impossible__"} - - mandateIds = _getUserMandateIds(userId) - adminMandateIds = _getAdminMandateIds(userId, mandateIds) - - if adminMandateIds: - return {"mandateId": adminMandateIds} - - return {"ownerId": userId} - - -def _scopedWorkflowFilter(context: RequestContext) -> Optional[dict]: - """ - Build a DB filter for AutoWorkflow based on RBAC: - - sysadmin: None (no filter, sees all) - - normal user: mandateId IN user's mandates - """ - if context.isPlatformAdmin: - return None - - userId = str(context.user.id) if context.user else None - if not userId: - return {"mandateId": "__impossible__"} - - mandateIds = _getUserMandateIds(userId) - if mandateIds: - return {"mandateId": mandateIds} - - return {"mandateId": "__impossible__"} - - -def _userMayDeleteWorkflow(context: RequestContext, wfMandateId: Optional[str]) -> bool: - """Same rules as canDelete on rows in get_system_workflows.""" - if context.isPlatformAdmin: - return True - userId = str(context.user.id) if context.user else None - if not userId or not wfMandateId: - return False - userMandateIds = _getUserMandateIds(userId) - adminMandateIds = _getAdminMandateIds(userId, userMandateIds) - return wfMandateId in adminMandateIds - - -def _parsePaginationOr400(pagination: Optional[str]) -> Optional[PaginationParams]: - """Parse a JSON pagination query string into PaginationParams. - - Returns None when the input is empty/None. Raises HTTPException(400) on any - parse / validation error so the caller can propagate the error to the - client instead of silently falling back to defaults (which used to mask - real frontend bugs). - """ - if not pagination: - return None - try: - paginationDict = json.loads(pagination) - except json.JSONDecodeError as e: - raise HTTPException( - status_code=400, - detail=f"Invalid 'pagination' query: not valid JSON ({e.msg})", - ) - if not paginationDict: - return None - try: - paginationDict = normalize_pagination_dict(paginationDict) - return PaginationParams(**paginationDict) - except Exception as e: - raise HTTPException( - status_code=400, - detail=f"Invalid 'pagination' payload: {e}", - ) - - -_RUN_STATS_SUBQUERY = """ -( - SELECT s."workflowId" AS "workflowId", - MAX(COALESCE(s."startedAt", s."sysCreatedAt")) AS "lastStartedAt", - COUNT(s."id")::bigint AS "runCount", - MAX(CASE WHEN s."status" IN ('running', 'paused') THEN s."id" END) AS "activeRunId" - FROM "AutoRun" s - GROUP BY s."workflowId" -) rs -""" - - -def _firstFkSortFieldForWorkflows(pagination) -> Optional[str]: - """First sort field that requires FK label resolution (cross-DB), or None.""" - from modules.dbHelpers.fkLabelResolver import buildLabelResolversFromModel - if not pagination or not pagination.sort: - return None - resolvers = buildLabelResolversFromModel(AutoWorkflow) - if not resolvers: - return None - for sf in pagination.sort: - sfField = sf.get("field") if isinstance(sf, dict) else getattr(sf, "field", None) - if sfField and sfField in resolvers: - return sfField - return None - - -def _batchRunStatsForWorkflowIds(db: DatabaseConnector, workflowIds: List[str]) -> dict: - """One grouped query: lastStartedAt, runCount, activeRunId per workflow.""" - if not workflowIds or not db._ensureTableExists(AutoRun): - return {} - db._ensure_connection() - sql = """ -SELECT "workflowId", - MAX(COALESCE("startedAt", "sysCreatedAt")) AS "lastStartedAt", - COUNT("id")::bigint AS "runCount", - MAX(CASE WHEN "status" IN ('running', 'paused') THEN "id" END) AS "activeRunId" -FROM "AutoRun" -WHERE "workflowId" = ANY(%s) -GROUP BY "workflowId" -""" - out: dict = {} - with db.borrowCursor() as cursor: - cursor.execute(sql, (workflowIds,)) - for row in cursor.fetchall(): - r = dict(row) - wid = r.get("workflowId") - if wid: - out[str(wid)] = r - return out - - -def _listingColSql(key: str, wfFieldNames: set) -> Optional[str]: - if key == "lastStartedAt": - return 'rs."lastStartedAt"' - if key == "runCount": - return 'COALESCE(rs."runCount", 0::bigint)' - if key == "isRunning": - return '(rs."activeRunId" IS NOT NULL)' - if key in wfFieldNames: - return f'w."{key}"' - return None - - -def _listingOrderExpr(key: str, wfFieldNames: set, wfFields: dict) -> Optional[str]: - if key == "lastStartedAt": - return 'rs."lastStartedAt"' - if key == "runCount": - return 'COALESCE(rs."runCount", 0::bigint)' - if key == "isRunning": - return 'CASE WHEN rs."activeRunId" IS NOT NULL THEN 1 ELSE 0 END' - if key in wfFieldNames: - colType = wfFields.get(key, "TEXT") - if colType == "BOOLEAN": - return f'COALESCE(w."{key}", FALSE)' - return f'w."{key}"' - return None - - -def _appendJoinedListingFilters(whereParts: list, values: list, pagination, wfFields: dict) -> None: - """Append WHERE fragments for joined workflow listing (w + rs).""" - wfFieldNames = set(wfFields.keys()) - validCols = wfFieldNames | {"lastStartedAt", "runCount", "isRunning"} - - if not pagination or not pagination.filters: - return - - for key, val in pagination.filters.items(): - if key == "search" and isinstance(val, str) and val.strip(): - term = f"%{val.strip()}%" - textCols = [c for c, t in wfFields.items() if t == "TEXT"] - if textCols: - orParts = [f'COALESCE(w."{c}"::TEXT, \'\') ILIKE %s' for c in textCols] - whereParts.append(f"({' OR '.join(orParts)})") - values.extend([term] * len(textCols)) - continue - - if key not in validCols: - continue - - if key == "isRunning": - if isinstance(val, dict): - op = val.get("operator", "equals") - v = val.get("value", "") - isTrue = str(v).lower() == "true" - if op in ("equals", "eq"): - whereParts.append('(rs."activeRunId" IS NOT NULL)' if isTrue else '(rs."activeRunId" IS NULL)') - elif val is None: - whereParts.append('(rs."activeRunId" IS NULL)') - else: - whereParts.append( - '(rs."activeRunId" IS NOT NULL)' if str(val).lower() == "true" else '(rs."activeRunId" IS NULL)' - ) - continue - - colRef = _listingColSql(key, wfFieldNames) - if not colRef: - continue - - colType = wfFields.get(key, "TEXT") if key in wfFieldNames else ( - "DOUBLE PRECISION" if key == "lastStartedAt" else "BIGINT" if key == "runCount" else "TEXT" - ) - - if val is None: - if key == "lastStartedAt": - whereParts.append(f'({colRef} IS NULL)') - elif key == "runCount": - whereParts.append(f'({colRef} = 0)') - else: - whereParts.append(f'({colRef} IS NULL OR {colRef}::TEXT = \'\')') - continue - - if not isinstance(val, dict): - if colType == "BOOLEAN" or key == "isRunning": - whereParts.append(f'COALESCE({colRef}, FALSE) = %s') - values.append(str(val).lower() == "true") - else: - whereParts.append(f'{colRef}::TEXT ILIKE %s') - values.append(str(val)) - continue - - op = val.get("operator", "equals") - v = val.get("value", "") - if op in ("equals", "eq"): - if colType == "BOOLEAN": - whereParts.append(f'COALESCE({colRef}, FALSE) = %s') - values.append(str(v).lower() == "true") - else: - whereParts.append(f'{colRef}::TEXT = %s') - values.append(str(v)) - elif op == "contains": - whereParts.append(f'{colRef}::TEXT ILIKE %s') - values.append(f"%{v}%") - elif op == "startsWith": - whereParts.append(f'{colRef}::TEXT ILIKE %s') - values.append(f"{v}%") - elif op == "endsWith": - whereParts.append(f'{colRef}::TEXT ILIKE %s') - values.append(f"%{v}") - elif op in ("gt", "gte", "lt", "lte"): - sqlOp = {"gt": ">", "gte": ">=", "lt": "<", "lte": "<="}[op] - if colType in ("INTEGER", "DOUBLE PRECISION", "BIGINT") or key in ("lastStartedAt", "runCount"): - try: - whereParts.append(f'{colRef}::double precision {sqlOp} %s') - values.append(float(v)) - except (ValueError, TypeError): - continue - else: - whereParts.append(f'{colRef}::TEXT {sqlOp} %s') - values.append(str(v)) - elif op == "between": - fromVal = v.get("from", "") if isinstance(v, dict) else "" - toVal = v.get("to", "") if isinstance(v, dict) else "" - if not fromVal and not toVal: - continue - isNumericCol = colType in ("INTEGER", "DOUBLE PRECISION", "BIGINT") or key in ("lastStartedAt", "runCount") - isDateVal = bool(fromVal and re.match(r"^\d{4}-\d{2}-\d{2}$", str(fromVal))) or bool( - toVal and re.match(r"^\d{4}-\d{2}-\d{2}$", str(toVal)) - ) - if isNumericCol and isDateVal: - if fromVal and toVal: - fromTs = datetime.strptime(str(fromVal), "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp() - toTs = datetime.strptime(str(toVal), "%Y-%m-%d").replace( - hour=23, minute=59, second=59, tzinfo=timezone.utc - ).timestamp() - whereParts.append(f"({colRef} >= %s AND {colRef} <= %s)") - values.extend([fromTs, toTs]) - elif fromVal: - fromTs = datetime.strptime(str(fromVal), "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp() - whereParts.append(f"({colRef} >= %s)") - values.append(fromTs) - else: - toTs = datetime.strptime(str(toVal), "%Y-%m-%d").replace( - hour=23, minute=59, second=59, tzinfo=timezone.utc - ).timestamp() - whereParts.append(f"({colRef} <= %s)") - values.append(toTs) - elif isNumericCol: - try: - if fromVal and toVal: - whereParts.append( - f"({colRef}::double precision >= %s AND {colRef}::double precision <= %s)" - ) - values.extend([float(fromVal), float(toVal)]) - elif fromVal: - whereParts.append(f"{colRef}::double precision >= %s") - values.append(float(fromVal)) - elif toVal: - whereParts.append(f"{colRef}::double precision <= %s") - values.append(float(toVal)) - except (ValueError, TypeError): - continue - else: - if fromVal and toVal: - whereParts.append(f"({colRef}::TEXT >= %s AND {colRef}::TEXT <= %s)") - values.extend([str(fromVal), str(toVal)]) - elif fromVal: - whereParts.append(f"{colRef}::TEXT >= %s") - values.append(str(fromVal)) - elif toVal: - whereParts.append(f"{colRef}::TEXT <= %s") - values.append(str(toVal)) - - -def _buildJoinedWorkflowWhereOrderLimit( - recordFilter: dict, - pagination, - wfFields: dict, -) -> tuple: - """WHERE / ORDER BY / LIMIT for joined AutoWorkflow + run stats listing.""" - wfFieldNames = set(wfFields.keys()) - whereParts: list = [] - values: list = [] - - for field, value in (recordFilter or {}).items(): - if value is None: - whereParts.append(f'w."{field}" IS NULL') - elif isinstance(value, list): - whereParts.append(f'w."{field}" = ANY(%s)') - values.append(value) - else: - whereParts.append(f'w."{field}" = %s') - values.append(value) - - _appendJoinedListingFilters(whereParts, values, pagination, wfFields) - - whereClause = " WHERE " + " AND ".join(whereParts) if whereParts else "" - - orderParts: list = [] - if pagination and pagination.sort: - for sf in pagination.sort: - sfField = sf.get("field") if isinstance(sf, dict) else getattr(sf, "field", None) - sfDir = sf.get("direction", "asc") if isinstance(sf, dict) else getattr(sf, "direction", "asc") - if not sfField: - continue - expr = _listingOrderExpr(sfField, wfFieldNames, wfFields) - if not expr: - continue - direction = "DESC" if str(sfDir).lower() == "desc" else "ASC" - orderParts.append(f"{expr} {direction} NULLS LAST") - if not orderParts: - orderParts.append('w."sysCreatedAt" DESC NULLS LAST') - - orderClause = " ORDER BY " + ", ".join(orderParts) - - limitClause = "" - if pagination: - offset = (pagination.page - 1) * pagination.pageSize - limitClause = f" LIMIT {pagination.pageSize} OFFSET {offset}" - - return whereClause, orderClause, limitClause, values - - -def _getWorkflowsJoinedPaginated( - db: DatabaseConnector, - recordFilter: dict, - 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( - recordFilter, paginationParams, wfFields, - ) - countValues = list(values) - - fromSql = f'"AutoWorkflow" w LEFT JOIN {_RUN_STATS_SUBQUERY.strip()} ON rs."workflowId" = w."id"' - - countSql = f"SELECT COUNT(*) AS cnt FROM {fromSql}{whereClause}" - dataSql = f"SELECT w.*, rs.\"lastStartedAt\", rs.\"runCount\", rs.\"activeRunId\" FROM {fromSql}{whereClause}{orderClause}{limitClause}" - - db._ensure_connection() - with db.borrowCursor() as cursor: - cursor.execute(countSql, countValues) - totalItems = int(cursor.fetchone()["cnt"]) - - cursor.execute(dataSql, values) - rawRows = [dict(row) for row in cursor.fetchall()] - - pageSize = paginationParams.pageSize if paginationParams else max(totalItems, 1) - totalPages = math.ceil(totalItems / pageSize) if totalItems > 0 else 0 - - modelFields = AutoWorkflow.model_fields - for record in rawRows: - parseRecordFields(record, wfFields, "table AutoWorkflow joined listing") - for fieldName, fieldType in wfFields.items(): - if fieldType == "JSONB" and fieldName in record and record[fieldName] is None: - fieldInfo = modelFields.get(fieldName) - if fieldInfo: - fieldAnnotation = fieldInfo.annotation - if fieldAnnotation == list or ( - hasattr(fieldAnnotation, "__origin__") and fieldAnnotation.__origin__ is list - ): - record[fieldName] = [] - elif fieldAnnotation == dict or ( - hasattr(fieldAnnotation, "__origin__") and fieldAnnotation.__origin__ is dict - ): - record[fieldName] = {} - - return {"items": rawRows, "totalItems": totalItems, "totalPages": totalPages} - - -def _cascadeDeleteAutoWorkflow(db: DatabaseConnector, workflowId: str) -> None: - """Delete AutoWorkflow and dependent rows (same order as interfaceDbApp._cascadeDeleteGraphicalEditorData).""" - wf_id = workflowId - for v in db.getRecordset(AutoVersion, recordFilter={"workflowId": wf_id}) or []: - vid = v.get("id") - if vid: - db.recordDelete(AutoVersion, vid) - for run in db.getRecordset(AutoRun, recordFilter={"workflowId": wf_id}) or []: - run_id = run.get("id") - if not run_id: - continue - for sl in db.getRecordset(AutoStepLog, recordFilter={"runId": run_id}) or []: - slid = sl.get("id") - if slid: - db.recordDelete(AutoStepLog, slid) - db.recordDelete(AutoRun, run_id) - for task in db.getRecordset(AutoTask, recordFilter={"workflowId": wf_id}) or []: - tid = task.get("id") - if tid: - db.recordDelete(AutoTask, tid) - db.recordDelete(AutoWorkflow, wf_id) - - -@router.get("") -@limiter.limit("60/minute") -def get_workflow_runs( - request: Request, - limit: int = Query(50, ge=1, le=200), - offset: int = Query(0, ge=0), - status: Optional[str] = Query(None, description="Filter by status"), - mandateId: Optional[str] = Query(None, description="Filter by mandate"), - pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"), - mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"), - column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """List workflow runs with RBAC scoping (SQL-paginated).""" - db = _getDb() - if not db._ensureTableExists(AutoRun): - if mode in ("filterValues", "ids"): - from fastapi.responses import JSONResponse - return JSONResponse(content=[]) - return {"runs": [], "total": 0, "limit": limit, "offset": offset} - - if mode == "filterValues": - if not column: - raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") - return _enrichedFilterValues(db, context, AutoRun, _scopedRunFilter, column) - - if mode == "ids": - from modules.dbHelpers.paginationHelpers import handleIdsMode - baseFilter = _scopedRunFilter(context) - recordFilter = dict(baseFilter) if baseFilter else {} - return handleIdsMode(db, AutoRun, pagination, recordFilter) - - baseFilter = _scopedRunFilter(context) - recordFilter = dict(baseFilter) if baseFilter else {} - - if status: - recordFilter["status"] = status - if mandateId: - recordFilter["mandateId"] = mandateId - - paginationParams = _parsePaginationOr400(pagination) - if not paginationParams: - page = (offset // limit) + 1 if limit > 0 else 1 - paginationParams = PaginationParams( - page=page, - pageSize=limit, - sort=[{"field": "startedAt", "direction": "desc"}], - ) - - from modules.dbHelpers.paginationHelpers import getRecordsetPaginatedWithFkSort - result = getRecordsetPaginatedWithFkSort( - db, AutoRun, - pagination=paginationParams, - recordFilter=recordFilter if recordFilter else None, - ) - pageRuns = result.get("items", []) if isinstance(result, dict) else result.items - total = result.get("totalItems", 0) if isinstance(result, dict) else result.totalItems - - wfIds = list({r.get("workflowId") for r in pageRuns if r.get("workflowId")}) - wfMap: dict = {} - if wfIds and db._ensureTableExists(AutoWorkflow): - wfs = db.getRecordset(AutoWorkflow, recordFilter={"id": wfIds}) - for wf in (wfs or []): - wfMap[wf.get("id")] = wf - - from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels, resolveMandateLabels, resolveInstanceLabels, resolveUserLabels - from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIface - - runs = [] - for r in pageRuns: - row = dict(r) - wfId = row.get("workflowId") - wf = wfMap.get(wfId, {}) - row["workflowLabel"] = ( - row.get("label") - or (wf.get("label") if isinstance(wf, dict) else None) - or wfId - ) - fiid = wf.get("featureInstanceId") if isinstance(wf, dict) else None - row["featureInstanceId"] = fiid - runs.append(row) - - appDb = _getRootIface().db - enrichRowsWithFkLabels( - runs, - db=db, - labelResolvers={ - "mandateId": partial(resolveMandateLabels, appDb), - "featureInstanceId": partial(resolveInstanceLabels, appDb), - "ownerId": partial(resolveUserLabels, appDb), - }, - ) - for row in runs: - row["instanceLabel"] = row.pop("featureInstanceIdLabel", None) - row["mandateLabel"] = row.pop("mandateIdLabel", None) - row["ownerLabel"] = row.pop("ownerIdLabel", None) - - return {"runs": runs, "total": total, "limit": limit, "offset": offset} - - -@router.get("/metrics") -@limiter.limit("60/minute") -def get_workflow_metrics( - request: Request, - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Aggregated metrics across all accessible workflow runs (SQL COUNT). - - Uses the same RBAC scoping as the runs list and workflows list - so that metric cards always match the table data. - """ - db = _getDb() - - # --- Workflow counts (same filter as /workflows endpoint) --- - workflowCount = 0 - activeWorkflows = 0 - if db._ensureTableExists(AutoWorkflow): - wfBaseFilter = _scopedWorkflowFilter(context) - wfFilter = dict(wfBaseFilter) if wfBaseFilter else {} - wfFilter["isTemplate"] = False - - wfCount = db.getRecordsetPaginated( - AutoWorkflow, pagination=PaginationParams(page=1, pageSize=1), - recordFilter=wfFilter if wfFilter else None, - ) - workflowCount = wfCount.get("totalItems", 0) if isinstance(wfCount, dict) else wfCount.totalItems - - activeFilter = dict(wfFilter) - activeFilter["active"] = True - activeCount = db.getRecordsetPaginated( - AutoWorkflow, pagination=PaginationParams(page=1, pageSize=1), - recordFilter=activeFilter, - ) - activeWorkflows = activeCount.get("totalItems", 0) if isinstance(activeCount, dict) else activeCount.totalItems - - # --- Run counts (same filter as /runs endpoint) --- - if not db._ensureTableExists(AutoRun): - return { - "totalRuns": 0, "runsByStatus": {}, "totalTokens": 0, - "totalCredits": 0, "workflowCount": workflowCount, - "activeWorkflows": activeWorkflows, - } - - runBaseFilter = _scopedRunFilter(context) - - countResult = db.getRecordsetPaginated( - AutoRun, pagination=PaginationParams(page=1, pageSize=1), - recordFilter=runBaseFilter, - ) - totalRuns = countResult.get("totalItems", 0) if isinstance(countResult, dict) else countResult.totalItems - - runsByStatus: dict = {} - statusValues = db.getDistinctColumnValues(AutoRun, "status", recordFilter=runBaseFilter) - for sv in (statusValues or []): - statusFilter = dict(runBaseFilter) if runBaseFilter else {} - statusFilter["status"] = sv - sr = db.getRecordsetPaginated( - AutoRun, pagination=PaginationParams(page=1, pageSize=1), - recordFilter=statusFilter, - ) - runsByStatus[sv] = sr.get("totalItems", 0) if isinstance(sr, dict) else sr.totalItems - - totalTokens = 0 - totalCredits = 0.0 - if 0 < totalRuns <= 10000: - allRuns = db.getRecordset(AutoRun, recordFilter=runBaseFilter, fieldFilter=["costTokens", "costCredits"]) or [] - for r in allRuns: - totalTokens += r.get("costTokens", 0) or 0 - totalCredits += r.get("costCredits", 0.0) or 0.0 - - return { - "totalRuns": totalRuns, - "runsByStatus": runsByStatus, - "totalTokens": totalTokens, - "totalCredits": round(totalCredits, 4), - "workflowCount": workflowCount, - "activeWorkflows": activeWorkflows, - } - - -# --------------------------------------------------------------------------- -# System-level Workflow listing (all workflows the user can see via RBAC) -# --------------------------------------------------------------------------- - -@router.get("/workflows") -@limiter.limit("60/minute") -def get_system_workflows( - request: Request, - active: Optional[bool] = Query(None, description="Filter by active status"), - mandateId: Optional[str] = Query(None, description="Filter by mandate"), - pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"), - mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"), - column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """List all workflows the user has access to (RBAC-scoped, cross-instance).""" - db = _getDb() - if not db._ensureTableExists(AutoWorkflow): - if mode in ("filterValues", "ids"): - from fastapi.responses import JSONResponse - return JSONResponse(content=[]) - return {"items": [], "pagination": {"currentPage": 1, "pageSize": 25, "totalItems": 0, "totalPages": 0}} - - if mode == "filterValues": - if not column: - raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") - return _enrichedFilterValues(db, context, AutoWorkflow, _scopedWorkflowFilter, column) - - if mode == "ids": - from modules.dbHelpers.paginationHelpers import handleIdsMode - baseFilter = _scopedWorkflowFilter(context) - recordFilter = dict(baseFilter) if baseFilter else {} - recordFilter["isTemplate"] = False - return handleIdsMode(db, AutoWorkflow, pagination, recordFilter) - - baseFilter = _scopedWorkflowFilter(context) - recordFilter = dict(baseFilter) if baseFilter else {} - recordFilter["isTemplate"] = False - - if active is not None: - recordFilter["active"] = active - if mandateId: - recordFilter["mandateId"] = mandateId - - paginationParams = _parsePaginationOr400(pagination) - if not paginationParams: - paginationParams = PaginationParams( - page=1, - pageSize=25, - sort=[{"field": "sysCreatedAt", "direction": "desc"}], - ) - - from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels, resolveMandateLabels, resolveInstanceLabels - - featureCodeMap: dict = {} - - def _resolveInstanceLabelsWithFeatureCode(ids): - from modules.interfaces.interfaceDbApp import getRootInterface as _getRI - from modules.interfaces.interfaceFeatures import getFeatureInterface - rootIf = _getRI() - featureIf = getFeatureInterface(rootIf.db) - result = {} - for iid in ids: - fi = featureIf.getFeatureInstance(iid) - if fi: - result[iid] = fi.label or None - featureCodeMap[iid] = fi.featureCode - else: - logger.warning("getSystemWorkflows: feature-instance not found for id=%s", iid) - result[iid] = None - return result - - userId = str(context.user.id) if context.user else None - adminMandateIds = [] - if userId and not context.isPlatformAdmin: - userMandateIds = _getUserMandateIds(userId) - adminMandateIds = _getAdminMandateIds(userId, userMandateIds) - - from modules.dbHelpers.fkLabelResolver import resolveUserLabels as _resolveUserLabels - from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIface - - fkSortField = _firstFkSortFieldForWorkflows(paginationParams) - if fkSortField: - from modules.dbHelpers.paginationHelpers import getRecordsetPaginatedWithFkSort, applyFiltersAndSort - _COMPUTED_FIELDS = {"lastStartedAt", "runCount", "isRunning"} - hasComputedFilter = bool( - paginationParams.filters - and any(k in _COMPUTED_FIELDS for k in paginationParams.filters) - ) - hasComputedSort = any( - (s.field if hasattr(s, "field") else s.get("field", "")) in _COMPUTED_FIELDS - for s in (paginationParams.sort or []) - ) - dbPagination = paginationParams - if hasComputedFilter or hasComputedSort: - dbFilters = { - k: v for k, v in (paginationParams.filters or {}).items() - if k not in _COMPUTED_FIELDS - } or None - dbSort = [ - s for s in (paginationParams.sort or []) - if (s.field if hasattr(s, "field") else s.get("field", "")) not in _COMPUTED_FIELDS - ] - dbPagination = PaginationParams.model_construct( - page=1, - pageSize=9999, - sort=dbSort or [{"field": "sysCreatedAt", "direction": "desc"}], - filters=dbFilters, - ) - result = getRecordsetPaginatedWithFkSort( - db, AutoWorkflow, - pagination=dbPagination, - recordFilter=recordFilter if recordFilter else None, - ) - pageItems = result.get("items", []) if isinstance(result, dict) else result.items - workflowIds = [w.get("id") for w in pageItems if w.get("id")] - statsById = _batchRunStatsForWorkflowIds(db, workflowIds) - items = [] - for w in pageItems: - row = dict(w) - wfId = row.get("id") - st = statsById.get(str(wfId)) if wfId else None - activeRunId = st.get("activeRunId") if st else None - row["isRunning"] = bool(activeRunId) - row["activeRunId"] = activeRunId - row["runCount"] = int(st.get("runCount") or 0) if st else 0 - row["lastStartedAt"] = float(st["lastStartedAt"]) if st and st.get("lastStartedAt") is not None else None - wMandateId = row.get("mandateId") - if context.isPlatformAdmin: - row["canEdit"] = True - row["canDelete"] = True - row["canExecute"] = True - elif wMandateId and wMandateId in adminMandateIds: - row["canEdit"] = True - row["canDelete"] = True - row["canExecute"] = True - else: - row["canEdit"] = False - row["canDelete"] = False - row["canExecute"] = False - row.pop("graph", None) - items.append(row) - _appDb = _getRootIface().db - enrichRowsWithFkLabels( - items, - db=db, - labelResolvers={ - "mandateId": partial(resolveMandateLabels, _appDb), - "featureInstanceId": _resolveInstanceLabelsWithFeatureCode, - "ownerId": partial(_resolveUserLabels, _appDb), - }, - ) - for row in items: - row["instanceLabel"] = row.pop("featureInstanceIdLabel", None) - row["mandateLabel"] = row.pop("mandateIdLabel", None) - row["ownerLabel"] = row.pop("ownerIdLabel", None) - row["featureCode"] = featureCodeMap.get(row.get("featureInstanceId")) - if hasComputedFilter or hasComputedSort: - computedFilters = { - k: v for k, v in (paginationParams.filters or {}).items() - if k in _COMPUTED_FIELDS - } - computedSort = [ - s for s in (paginationParams.sort or []) - if (s.field if hasattr(s, "field") else s.get("field", "")) in _COMPUTED_FIELDS - ] - computedPagination = PaginationParams.model_construct( - page=paginationParams.page, - pageSize=paginationParams.pageSize, - sort=computedSort or [], - filters=computedFilters or None, - ) - filtered = applyFiltersAndSort(items, computedPagination) - totalItems = filtered.get("totalItems", len(items)) - totalPages = filtered.get("totalPages", 1) - items = filtered.get("items", items) - else: - totalItems = result.get("totalItems", 0) if isinstance(result, dict) else result.totalItems - totalPages = result.get("totalPages", 0) if isinstance(result, dict) else result.totalPages - else: - result = _getWorkflowsJoinedPaginated( - db, recordFilter if recordFilter else {}, paginationParams, - ) - pageItems = result.get("items", []) - totalItems = result.get("totalItems", 0) - totalPages = result.get("totalPages", 0) - items = [] - for row in pageItems: - wMandateId = row.get("mandateId") - wfId = row.get("id") - activeRunId = row.get("activeRunId") - if row.get("runCount") is not None: - row["runCount"] = int(row["runCount"]) - row["isRunning"] = bool(activeRunId) - if context.isPlatformAdmin: - row["canEdit"] = True - row["canDelete"] = True - row["canExecute"] = True - elif wMandateId and wMandateId in adminMandateIds: - row["canEdit"] = True - row["canDelete"] = True - row["canExecute"] = True - else: - row["canEdit"] = False - row["canDelete"] = False - row["canExecute"] = False - row.pop("graph", None) - items.append(row) - _appDb2 = _getRootIface().db - enrichRowsWithFkLabels( - items, - db=db, - labelResolvers={ - "mandateId": partial(resolveMandateLabels, _appDb2), - "featureInstanceId": _resolveInstanceLabelsWithFeatureCode, - "ownerId": partial(_resolveUserLabels, _appDb2), - }, - ) - for row in items: - row["instanceLabel"] = row.pop("featureInstanceIdLabel", None) - row["mandateLabel"] = row.pop("mandateIdLabel", None) - row["ownerLabel"] = row.pop("ownerIdLabel", None) - row["featureCode"] = featureCodeMap.get(row.get("featureInstanceId")) - - return { - "items": items, - "pagination": { - "currentPage": paginationParams.page, - "pageSize": paginationParams.pageSize, - "totalItems": totalItems, - "totalPages": totalPages, - }, - } - - -@router.delete("/workflows/{workflowId}") -@limiter.limit("30/minute") -def delete_system_workflow( - request: Request, - workflowId: str = Path(..., description="AutoWorkflow ID"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """ - Delete a workflow by ID without requiring featureInstanceId (orphan / broken FK rows). - RBAC matches get_system_workflows: SysAdmin or Mandate-Admin for the workflow's mandate. - Cascades versions, runs, step logs, tasks — same as mandate cascade delete. - """ - db = _getDb() - if not db._ensureTableExists(AutoWorkflow): - raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) - - rows = db.getRecordset(AutoWorkflow, recordFilter={"id": workflowId}) - if not rows: - raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) - - wf = dict(rows[0]) if rows else {} - if wf.get("isTemplate"): - raise HTTPException(status_code=400, detail=routeApiMsg("Cannot delete a template workflow here")) - - wf_mandate_id = wf.get("mandateId") - if not _userMayDeleteWorkflow(context, wf_mandate_id): - raise HTTPException(status_code=403, detail=routeApiMsg("Not allowed to delete this workflow")) - - try: - _cascadeDeleteAutoWorkflow(db, workflowId) - except Exception as e: - logger.error(f"delete_system_workflow cascade failed: {e}") - raise HTTPException(status_code=500, detail=routeApiMsg(str(e))) - - # Callback registry: log + propagate so listener bugs are visible. - # Cascade is already committed at this point — failure here is a side-effect - # bug (stale caches, missed notifications), never a "ignore silently" event. - try: - from modules.shared.callbackRegistry import callbackRegistry - callbackRegistry.trigger("graphicalEditor.workflow.changed") - except Exception as e: - logger.error( - f"delete_system_workflow: callbackRegistry.trigger failed for " - f"workflowId={workflowId}: {e}" - ) - raise HTTPException( - status_code=500, - detail=routeApiMsg(f"Workflow deleted but post-delete callback failed: {e}"), - ) - - return {"success": True, "id": workflowId} - - -# --------------------------------------------------------------------------- -# Filter-values endpoints (for FormGeneratorTable column filters) -# --------------------------------------------------------------------------- - -_SYNTHETIC_TIMESTAMP_FIELDS = {"lastStartedAt"} - - -def _isTimestampColumn(modelClass, column: str) -> bool: - """Check if a column is a timestamp field (PeriodPicker, no discrete values needed).""" - if column in _SYNTHETIC_TIMESTAMP_FIELDS: - return True - fields = getattr(modelClass, "model_fields", {}) - fieldInfo = fields.get(column) - if not fieldInfo: - return False - extra = getattr(fieldInfo, "json_schema_extra", None) - if isinstance(extra, dict): - return extra.get("frontend_type") == "timestamp" - return False - - -def _enrichedFilterValues( - db, context: RequestContext, modelClass, scopeFilter, column: str, -): - """Return distinct filter values for FormGeneratorTable column filters. - - For FK columns (mandateId, featureInstanceId) returns ``{value, label}`` - objects so the frontend can display human-readable labels in the dropdown - without a separate source fk fetch. Non-FK columns return ``string | null``. - - Timestamp columns (sysCreatedAt, lastStartedAt) return an empty list because - the frontend uses a PeriodPicker (range selector) — no discrete values needed. - - ``null`` is included when rows with NULL/empty values exist (enables the - "(Leer)" filter option). - - Returns JSONResponse to bypass FastAPI response_model validation. - """ - from fastapi.responses import JSONResponse - from modules.dbHelpers.fkLabelResolver import resolveMandateLabels, resolveInstanceLabels - - if _isTimestampColumn(modelClass, column): - return JSONResponse(content=[]) - - if column in ("mandateLabel", "mandateId"): - baseFilter = scopeFilter(context) - recordFilter = dict(baseFilter) if baseFilter else {} - if modelClass == AutoWorkflow: - recordFilter["isTemplate"] = False - items = db.getRecordset(modelClass, recordFilter=recordFilter or None, fieldFilter=["mandateId"]) or [] - allVals = {r.get("mandateId") for r in items} - mandateIds = sorted(v for v in allVals if v) - hasEmpty = None in allVals or "" in allVals - labelMap = resolveMandateLabels(db, mandateIds) if mandateIds else {} - result = [{"value": mid, "label": labelMap.get(mid) or f"NA({mid})"} for mid in mandateIds] - if hasEmpty: - result.append(None) - return JSONResponse(content=result) - - if column in ("instanceLabel", "featureInstanceId"): - baseFilter = scopeFilter(context) - recordFilter = dict(baseFilter) if baseFilter else {} - if modelClass == AutoWorkflow: - recordFilter["isTemplate"] = False - items = db.getRecordset(modelClass, recordFilter=recordFilter or None, fieldFilter=["featureInstanceId"]) or [] - allVals = {r.get("featureInstanceId") for r in items} - instanceIds = sorted(v for v in allVals if v) - hasEmpty = None in allVals or "" in allVals - else: - items = db.getRecordset(modelClass, recordFilter=recordFilter or None, fieldFilter=["workflowId"]) or [] - wfIds = list({r.get("workflowId") for r in items if r.get("workflowId")}) - instanceIds = [] - hasEmpty = False - if wfIds and db._ensureTableExists(AutoWorkflow): - wfs = db.getRecordset(AutoWorkflow, recordFilter={"id": wfIds}, fieldFilter=["featureInstanceId"]) or [] - allVals = {w.get("featureInstanceId") for w in wfs} - instanceIds = sorted(v for v in allVals if v) - hasEmpty = None in allVals or "" in allVals - labelMap = resolveInstanceLabels(db, instanceIds) if instanceIds else {} - result = [{"value": iid, "label": labelMap.get(iid) or f"NA({iid})"} for iid in instanceIds] - if hasEmpty: - result.append(None) - return JSONResponse(content=result) - - if column == "workflowLabel": - baseFilter = scopeFilter(context) - recordFilter = dict(baseFilter) if baseFilter else {} - items = db.getRecordset(modelClass, recordFilter=recordFilter or None, fieldFilter=["workflowId", "label"]) or [] - labels = set() - wfIds = set() - hasEmpty = False - for r in items: - if r.get("label"): - labels.add(r["label"]) - elif not r.get("workflowId"): - hasEmpty = True - if r.get("workflowId"): - wfIds.add(r["workflowId"]) - if wfIds and db._ensureTableExists(AutoWorkflow): - wfs = db.getRecordset(AutoWorkflow, recordFilter={"id": list(wfIds)}, fieldFilter=["label"]) or [] - for wf in wfs: - if wf.get("label"): - labels.add(wf["label"]) - result = sorted(labels, key=lambda v: v.lower()) - if hasEmpty: - result.append(None) - return JSONResponse(content=result) - - baseFilter = scopeFilter(context) - recordFilter = dict(baseFilter) if baseFilter else {} - if modelClass == AutoWorkflow: - recordFilter["isTemplate"] = False - return JSONResponse(content=db.getDistinctColumnValues(modelClass, column, recordFilter=recordFilter or None) or []) - - - - - - -# --------------------------------------------------------------------------- -# Run-specific endpoints (path-param routes MUST come after static routes) -# --------------------------------------------------------------------------- - -@router.get("/{runId}/steps") -@limiter.limit("60/minute") -def get_run_steps( - request: Request, - runId: str = Path(..., description="Run ID"), - context: RequestContext = Depends(getRequestContext), -) -> dict: - """Get step logs for a specific run (with access check).""" - db = _getDb() - if not db._ensureTableExists(AutoRun): - raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) - - runs = db.getRecordset(AutoRun, recordFilter={"id": runId}) - if not runs: - raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) - run = dict(runs[0]) - - if not context.isPlatformAdmin: - userId = str(context.user.id) if context.user else None - runOwner = run.get("ownerId") - runMandate = run.get("mandateId") - - if runOwner == userId: - pass - elif runMandate and userId and _isUserMandateAdmin(userId, runMandate): - pass - else: - raise HTTPException(status_code=403, detail=routeApiMsg("Access denied")) - - if not db._ensureTableExists(AutoStepLog): - return {"steps": []} - - records = db.getRecordset(AutoStepLog, recordFilter={"runId": runId}) - steps = [dict(r) for r in records] if records else [] - steps.sort(key=lambda s: s.get("startedAt") or 0) - return {"steps": steps} - - -# --------------------------------------------------------------------------- -# SSE stream for live run tracing (system-level, no instanceId required) -# --------------------------------------------------------------------------- - -@router.get("/{runId}/stream") -async def get_run_stream( - request: Request, - runId: str = Path(..., description="Run ID"), - context: RequestContext = Depends(getRequestContext), -): - """SSE stream for live step-log updates during a workflow run (system-level).""" - db = _getDb() - if not db._ensureTableExists(AutoRun): - raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) - - runs = db.getRecordset(AutoRun, recordFilter={"id": runId}) - if not runs: - raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) - run = dict(runs[0]) - - if not context.isPlatformAdmin: - userId = str(context.user.id) if context.user else None - runOwner = run.get("ownerId") - runMandate = run.get("mandateId") - if runOwner == userId: - pass - elif runMandate and userId and _isUserMandateAdmin(userId, runMandate): - pass - else: - raise HTTPException(status_code=403, detail=routeApiMsg("Access denied")) - - from modules.serviceCenter.core.serviceStreaming.eventManager import get_event_manager - sseEventManager = get_event_manager() - queueId = f"run-trace-{runId}" - sseEventManager.create_queue(queueId) - - async def _sseGenerator(): - queue = sseEventManager.get_queue(queueId) - if not queue: - return - while True: - try: - event = await asyncio.wait_for(queue.get(), timeout=30) - except asyncio.TimeoutError: - yield "data: {\"type\": \"keepalive\"}\n\n" - continue - if event is None: - break - payload = event.get("data", event) if isinstance(event, dict) else event - yield f"data: {json.dumps(payload, default=str)}\n\n" - eventType = payload.get("type", "") if isinstance(payload, dict) else "" - if eventType in ("run_complete", "run_failed"): - break - await sseEventManager.cleanup(queueId, delay=10) - - return StreamingResponse( - _sseGenerator(), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - }, - ) - - -@router.post("/{runId}/stop") -@limiter.limit("30/minute") -def stop_workflow_run( - request: Request, - runId: str = Path(..., description="Run ID"), - context: RequestContext = Depends(getRequestContext), -): - """Stop a running workflow execution (system-level).""" - db = _getDb() - if not db._ensureTableExists(AutoRun): - raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) - - runs = db.getRecordset(AutoRun, recordFilter={"id": runId}) - if not runs: - raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) - run = dict(runs[0]) - - if not context.isPlatformAdmin: - userId = str(context.user.id) if context.user else None - runOwner = run.get("ownerId") - runMandate = run.get("mandateId") - if runOwner == userId: - pass - elif runMandate and userId and _isUserMandateAdmin(userId, runMandate): - pass - else: - raise HTTPException(status_code=403, detail=routeApiMsg("Access denied")) - - from modules.workflows.automation2.executionEngine import requestRunStop - flagged = requestRunStop(runId) - - if not flagged: - currentStatus = run.get("status", "") - if currentStatus in ("completed", "failed", "stopped"): - return {"status": currentStatus, "runId": runId, "message": "Run already finished"} - stopUpdates = {"status": "stopped"} - if not run.get("completedAt"): - stopUpdates["completedAt"] = time.time() - db.recordModify(AutoRun, runId, stopUpdates) - return {"status": "stopped", "runId": runId, "message": "Run not active in memory, marked as stopped"} - - return {"status": "stopping", "runId": runId, "message": "Stop signal sent"} diff --git a/modules/serviceCenter/services/serviceAgent/datamodelAgent.py b/modules/serviceCenter/services/serviceAgent/datamodelAgent.py index 25099bf8..9c94247d 100644 --- a/modules/serviceCenter/services/serviceAgent/datamodelAgent.py +++ b/modules/serviceCenter/services/serviceAgent/datamodelAgent.py @@ -112,7 +112,7 @@ class AgentConfig(BaseModel): default=False, description=( "If True, do NOT register workflow-action methods as agent tools. " - "Used by editor-style agents (e.g. GraphicalEditor) that should only " + "Used by editor-style agents (e.g. WorkflowAutomation) that should only " "manipulate the workflow graph, not execute its actions." ), ) diff --git a/modules/serviceCenter/services/serviceAgent/toolboxRegistry.py b/modules/serviceCenter/services/serviceAgent/toolboxRegistry.py index bcfaff26..a464525a 100644 --- a/modules/serviceCenter/services/serviceAgent/toolboxRegistry.py +++ b/modules/serviceCenter/services/serviceAgent/toolboxRegistry.py @@ -210,7 +210,7 @@ def _registerDefaultToolboxes() -> None: id="workflow", label="Workflow", description="Graph manipulation tools for the visual editor", - featureCode="graphicalEditor", + featureCode="workflowAutomation", isDefault=False, tools=[ "readWorkflowGraph", "addNode", "removeNode", "connectNodes", diff --git a/modules/serviceCenter/services/serviceAgent/workflowTools.py b/modules/serviceCenter/services/serviceAgent/workflowTools.py index 32defa2b..c1d3bf1e 100644 --- a/modules/serviceCenter/services/serviceAgent/workflowTools.py +++ b/modules/serviceCenter/services/serviceAgent/workflowTools.py @@ -1,7 +1,7 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. """ -Workflow Toolbox - AI-assisted graph manipulation tools for the GraphicalEditor. +Workflow Toolbox - AI-assisted graph manipulation tools for WorkflowAutomation. Tools: readWorkflowGraph, addNode, removeNode, connectNodes, setNodeParameter, listAvailableNodeTypes, describeNodeType, autoLayoutWorkflow, validateGraph, listWorkflowHistory, readWorkflowMessages. @@ -89,9 +89,8 @@ def _resolveMandateId(context: Any) -> str: def _getInterface(context: Any, instanceId: str): - # DEPRECATED: will move with WorkflowAutomation code restructuring - from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface - return getGraphicalEditorInterface(_resolveUser(context), _resolveMandateId(context), instanceId) + from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface + return _getWorkflowAutomationInterface(_resolveUser(context), _resolveMandateId(context), instanceId) async def _readWorkflowGraph(params: Dict[str, Any], context: Any) -> ToolResult: @@ -307,8 +306,7 @@ async def _list_upstream_paths(params: Dict[str, Any], context: Any) -> ToolResu return _err(name, f"Workflow {workflow_id} not found") graph = wf.get("graph", {}) or {} - # DEPRECATED: will move with WorkflowAutomation code restructuring - from modules.features.graphicalEditor.upstreamPathsService import compute_upstream_paths + from modules.workflowAutomation.editor.upstreamPathsService import compute_upstream_paths paths = compute_upstream_paths(graph if isinstance(graph, dict) else {}, str(node_id)) return _ok(name, {"paths": paths}) @@ -438,8 +436,7 @@ async def _listAvailableNodeTypes(params: Dict[str, Any], context: Any) -> ToolR """ name = "listAvailableNodeTypes" try: - # DEPRECATED: will move with WorkflowAutomation code restructuring - from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES + from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES nodeTypes = [] for n in STATIC_NODE_TYPES: if not isinstance(n, dict): @@ -465,8 +462,7 @@ async def _describeNodeType(params: Dict[str, Any], context: Any) -> ToolResult: nodeType = params.get("nodeType") or params.get("id") if not nodeType: return _err(name, "nodeType required") - # DEPRECATED: will move with WorkflowAutomation code restructuring - from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES + from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES target: Dict[str, Any] = {} for n in STATIC_NODE_TYPES: if isinstance(n, dict) and n.get("id") == nodeType: @@ -879,8 +875,7 @@ async def _exportWorkflowToFile(params: Dict[str, Any], context: Any) -> ToolRes envelope = iface.exportWorkflowToDict(workflowId) if envelope is None: return _err(name, f"Workflow {workflowId} not found") - # DEPRECATED: will move with WorkflowAutomation code restructuring - from modules.features.graphicalEditor._workflowFileSchema import buildFileName + from modules.workflowAutomation.editor._workflowFileSchema import buildFileName return _ok(name, { "fileName": buildFileName(envelope.get("label", "workflow")), "envelope": envelope, diff --git a/modules/serviceCenter/services/serviceMessaging/subscriptions/subSubscriptionGraphicalEditorRunFailed.py b/modules/serviceCenter/services/serviceMessaging/subscriptions/subSubscriptionWorkflowAutomationRunFailed.py similarity index 91% rename from modules/serviceCenter/services/serviceMessaging/subscriptions/subSubscriptionGraphicalEditorRunFailed.py rename to modules/serviceCenter/services/serviceMessaging/subscriptions/subSubscriptionWorkflowAutomationRunFailed.py index 2d77fd5b..b1cfecd0 100644 --- a/modules/serviceCenter/services/serviceMessaging/subscriptions/subSubscriptionGraphicalEditorRunFailed.py +++ b/modules/serviceCenter/services/serviceMessaging/subscriptions/subSubscriptionWorkflowAutomationRunFailed.py @@ -1,7 +1,7 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. """ -Subscription handler for GraphicalEditor workflow run failures. +Subscription handler for WorkflowAutomation workflow run failures. Sends email notifications to subscribed users when a workflow run fails. """ @@ -20,7 +20,7 @@ def execute( messagingService, ) -> MessagingSubscriptionExecutionResult: """ - Subscription function for GraphicalEditor run failures. + Subscription function for WorkflowAutomation run failures. Sends email/SMS to registered users when a workflow run fails. """ triggerData = eventParameters.triggerData or {} @@ -40,7 +40,7 @@ def execute( f"Workflow-ID: {workflowId}\n" f"Run-ID: {runId}\n" f"Fehler: {error}\n\n" - f"Bitte prüfen Sie den Workflow im Grafischen Editor." + f"Bitte prüfen Sie den Workflow in der Workflow-Automation." ) smsMessage = f"Workflow '{workflowLabel}' fehlgeschlagen: {error[:100]}" diff --git a/modules/shared/workflowAutomationHelpers.py b/modules/shared/workflowAutomationHelpers.py new file mode 100644 index 00000000..4813c087 --- /dev/null +++ b/modules/shared/workflowAutomationHelpers.py @@ -0,0 +1,624 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Shared helpers for WorkflowAutomation route files. + +Extracted from routeWorkflowDashboard.py and routeWorkflowAutomation.py to +avoid code duplication across route files. Contains DB access, RBAC scoping, +pagination helpers, and FK label resolver setup. +""" + +import json +import logging +import math +import re +from datetime import datetime, timezone +from typing import Optional, List, Dict, Any + +from fastapi import HTTPException + +from modules.auth.authentication import RequestContext +from modules.connectors.connectorDbPostgre import DatabaseConnector +from modules.datamodels.datamodelPagination import PaginationParams, normalize_pagination_dict +from modules.datamodels.datamodelWorkflowAutomation import ( + AutoRun, AutoStepLog, AutoWorkflow, AutoTask, AutoVersion, + GRAPHICAL_EDITOR_DATABASE, +) +from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIface +from modules.shared.configuration import APP_CONFIG + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# DB access +# --------------------------------------------------------------------------- + +def _getWorkflowAutomationDb() -> DatabaseConnector: + """Get a DatabaseConnector for the WorkflowAutomation (graphicaleditor) DB.""" + return DatabaseConnector( + dbHost=APP_CONFIG.get("DB_HOST", "localhost"), + dbDatabase=GRAPHICAL_EDITOR_DATABASE, + dbUser=APP_CONFIG.get("DB_USER"), + dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), + dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), + userId=None, + ) + + +def _getAppDb() -> DatabaseConnector: + """Get the root interface DB (poweron_app) for FK label resolution.""" + return _getRootIface().db + + +# --------------------------------------------------------------------------- +# RBAC helpers +# --------------------------------------------------------------------------- + +def _getUserMandateIds(userId: str) -> List[str]: + """Get mandate IDs the user is a member of.""" + rootIface = _getRootIface() + memberships = rootIface.getUserMandates(userId) + return [um.mandateId for um in memberships if um.mandateId and um.enabled] + + +def _getAdminMandateIds(userId: str, mandateIds: List[str]) -> List[str]: + """Batch-check which mandates the user is admin for.""" + if not mandateIds: + return [] + rootIface = _getRootIface() + from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole + + memberships = rootIface.db.getRecordset( + UserMandate, + recordFilter={"userId": userId, "mandateId": mandateIds, "enabled": True}, + ) + if not memberships: + return [] + + umIdToMandateId: Dict[str, str] = {} + for m in memberships: + row = m if isinstance(m, dict) else m.__dict__ + um_id = row.get("id") + mid = row.get("mandateId") + if um_id and mid: + umIdToMandateId[str(um_id)] = str(mid) + + userMandateIds = list(umIdToMandateId.keys()) + allRoles = rootIface.db.getRecordset( + UserMandateRole, + recordFilter={"userMandateId": userMandateIds}, + ) + if not allRoles: + return [] + + roleIds: set = set() + roleToMandate: Dict[str, set] = {} + for r in allRoles: + row = r if isinstance(r, dict) else r.__dict__ + rid = row.get("roleId") + um_id = row.get("userMandateId") + mid = umIdToMandateId.get(str(um_id)) if um_id else None + if rid and mid: + roleIds.add(rid) + roleToMandate.setdefault(rid, set()).add(mid) + + if not roleIds: + return [] + + from modules.datamodels.datamodelRbac import Role + roleRecords = rootIface.db.getRecordset(Role, recordFilter={"id": list(roleIds)}) + adminMandates: set = set() + for role in (roleRecords or []): + row = role if isinstance(role, dict) else role.__dict__ + rid = row.get("id") + if not rid or rid not in roleToMandate: + continue + if row.get("roleLabel") == "admin" and not row.get("featureInstanceId"): + adminMandates.update(roleToMandate[rid]) + + return [mid for mid in mandateIds if mid in adminMandates] + + +def _isUserMandateAdmin(userId: str, mandateId: str) -> bool: + """Check if user is admin for a specific mandate.""" + return mandateId in _getAdminMandateIds(userId, [mandateId]) + + +def _scopedWorkflowFilter(context: RequestContext) -> Optional[Dict[str, Any]]: + """Build DB filter for listing workflows: mandate-scoped for members, None for sysadmin.""" + if context.isPlatformAdmin: + return None + userId = str(context.user.id) if context.user else None + if not userId: + return {"mandateId": "__impossible__"} + mandateIds = _getUserMandateIds(userId) + if mandateIds: + return {"mandateId": mandateIds} + return {"mandateId": "__impossible__"} + + +def _scopedRunFilter(context: RequestContext) -> Optional[Dict[str, Any]]: + """Build DB filter for listing runs: admin sees mandate runs, user sees own.""" + if context.isPlatformAdmin: + return None + userId = str(context.user.id) if context.user else None + if not userId: + return {"ownerId": "__impossible__"} + mandateIds = _getUserMandateIds(userId) + adminMandateIds = _getAdminMandateIds(userId, mandateIds) + if adminMandateIds: + return {"mandateId": adminMandateIds} + return {"ownerId": userId} + + +def _userMayDeleteWorkflow(context: RequestContext, wfMandateId: Optional[str]) -> bool: + """Check if user may delete a workflow in the given mandate.""" + if context.isPlatformAdmin: + return True + userId = str(context.user.id) if context.user else None + if not userId or not wfMandateId: + return False + userMandateIds = _getUserMandateIds(userId) + adminMandateIds = _getAdminMandateIds(userId, userMandateIds) + return wfMandateId in adminMandateIds + + +def _validateWorkflowAccess( + context: RequestContext, + workflow: Optional[Dict[str, Any]], + action: str = "read", +) -> None: + """Validate access to a workflow. Raises HTTPException(403) on denial. + + Actions: + - 'read': mandate membership + - 'write'/'delete': mandate admin + - 'execute': mandate membership + FeatureAccess on targetInstanceId + """ + if context.isPlatformAdmin: + return + + userId = str(context.user.id) if context.user else None + if not userId: + raise HTTPException(status_code=403, detail="Authentication required") + + if workflow is None: + raise HTTPException(status_code=404, detail="Workflow not found") + + wfMandateId = workflow.get("mandateId") or "" + if not wfMandateId: + if action == "read": + return + raise HTTPException(status_code=403, detail="Workflow has no mandate — admin only") + + userMandateIds = _getUserMandateIds(userId) + if wfMandateId not in userMandateIds: + raise HTTPException(status_code=403, detail="Not a member of the workflow's mandate") + + if action == "read": + return + + if action == "execute": + targetInstanceId = workflow.get("targetFeatureInstanceId") + if targetInstanceId: + from modules.interfaces.interfaceFeatureAccess import _hasFeatureAccess + if _hasFeatureAccess(userId, targetInstanceId): + return + + adminMandateIds = _getAdminMandateIds(userId, [wfMandateId]) + if wfMandateId not in adminMandateIds: + raise HTTPException( + status_code=403, + detail=f"Mandate admin required for '{action}' on workflows", + ) + + +# --------------------------------------------------------------------------- +# Pagination +# --------------------------------------------------------------------------- + +def _parsePaginationOr400(pagination: Optional[str]) -> Optional[PaginationParams]: + """Parse a JSON pagination query string. Raises 400 on parse errors.""" + if not pagination: + return None + try: + paginationDict = json.loads(pagination) + except json.JSONDecodeError as e: + raise HTTPException( + status_code=400, + detail=f"Invalid 'pagination' query: not valid JSON ({e.msg})", + ) + if not paginationDict: + return None + try: + paginationDict = normalize_pagination_dict(paginationDict) + return PaginationParams(**paginationDict) + except Exception as e: + raise HTTPException( + status_code=400, + detail=f"Invalid 'pagination' payload: {e}", + ) + + +# --------------------------------------------------------------------------- +# FK label resolver setup (cross-DB: poweron_app vs poweron_graphicaleditor) +# --------------------------------------------------------------------------- + +def _resolveFkLabels(rows: list, model, labelResolvers: Optional[dict] = None) -> list: + """Resolve FK labels for a list of rows using the app DB for user/mandate/instance lookups.""" + if not rows: + return rows + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels + appDb = _getAppDb() + enrichRowsWithFkLabels(rows, model, db=appDb, labelResolvers=labelResolvers) + return rows + + +def _buildStandardLabelResolvers() -> dict: + """Standard FK label resolvers for mandateId, featureInstanceId, ownerId.""" + from modules.dbHelpers.fkLabelResolver import ( + resolveMandateLabels, + resolveInstanceLabels, + resolveUserLabels, + ) + appDb = _getAppDb() + return { + "mandateId": lambda ids: resolveMandateLabels(ids, db=appDb), + "featureInstanceId": lambda ids: resolveInstanceLabels(ids, db=appDb), + "ownerId": lambda ids: resolveUserLabels(ids, db=appDb), + "sysCreatedBy": lambda ids: resolveUserLabels(ids, db=appDb), + } + + +# --------------------------------------------------------------------------- +# Cascade delete +# --------------------------------------------------------------------------- + +def _cascadeDeleteWorkflow(db: DatabaseConnector, workflowId: str) -> None: + """Delete AutoWorkflow and all dependent rows (versions, runs, step logs, tasks).""" + for v in db.getRecordset(AutoVersion, recordFilter={"workflowId": workflowId}) or []: + vid = v.get("id") + if vid: + db.recordDelete(AutoVersion, vid) + for run in db.getRecordset(AutoRun, recordFilter={"workflowId": workflowId}) or []: + runId = run.get("id") + if not runId: + continue + for sl in db.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []: + slid = sl.get("id") + if slid: + db.recordDelete(AutoStepLog, slid) + db.recordDelete(AutoRun, runId) + for task in db.getRecordset(AutoTask, recordFilter={"workflowId": workflowId}) or []: + tid = task.get("id") + if tid: + db.recordDelete(AutoTask, tid) + db.recordDelete(AutoWorkflow, workflowId) + + +# --------------------------------------------------------------------------- +# SQL join helpers for workflow listing with run stats +# --------------------------------------------------------------------------- + +_RUN_STATS_SUBQUERY = """ +( + SELECT s."workflowId" AS "workflowId", + MAX(COALESCE(s."startedAt", s."sysCreatedAt")) AS "lastStartedAt", + COUNT(s."id")::bigint AS "runCount", + MAX(CASE WHEN s."status" IN ('running', 'paused') THEN s."id" END) AS "activeRunId" + FROM "AutoRun" s + GROUP BY s."workflowId" +) rs +""" + + +def _firstFkSortFieldForWorkflows(pagination) -> Optional[str]: + """First sort field that requires FK label resolution (cross-DB), or None.""" + from modules.dbHelpers.fkLabelResolver import buildLabelResolversFromModel + if not pagination or not pagination.sort: + return None + resolvers = buildLabelResolversFromModel(AutoWorkflow) + if not resolvers: + return None + for sf in pagination.sort: + sfField = sf.get("field") if isinstance(sf, dict) else getattr(sf, "field", None) + if sfField and sfField in resolvers: + return sfField + return None + + +def _batchRunStatsForWorkflowIds(db: DatabaseConnector, workflowIds: List[str]) -> dict: + """One grouped query: lastStartedAt, runCount, activeRunId per workflow.""" + if not workflowIds or not db._ensureTableExists(AutoRun): + return {} + db._ensure_connection() + sql = """ +SELECT "workflowId", + MAX(COALESCE("startedAt", "sysCreatedAt")) AS "lastStartedAt", + COUNT("id")::bigint AS "runCount", + MAX(CASE WHEN "status" IN ('running', 'paused') THEN "id" END) AS "activeRunId" +FROM "AutoRun" +WHERE "workflowId" = ANY(%s) +GROUP BY "workflowId" +""" + out: dict = {} + with db.borrowCursor() as cursor: + cursor.execute(sql, (workflowIds,)) + for row in cursor.fetchall(): + r = dict(row) + wid = r.get("workflowId") + if wid: + out[str(wid)] = r + return out + + +def _listingColSql(key: str, wfFieldNames: set) -> Optional[str]: + if key == "lastStartedAt": + return 'rs."lastStartedAt"' + if key == "runCount": + return 'COALESCE(rs."runCount", 0::bigint)' + if key == "isRunning": + return '(rs."activeRunId" IS NOT NULL)' + if key in wfFieldNames: + return f'w."{key}"' + return None + + +def _listingOrderExpr(key: str, wfFieldNames: set, wfFields: dict) -> Optional[str]: + if key == "lastStartedAt": + return 'rs."lastStartedAt"' + if key == "runCount": + return 'COALESCE(rs."runCount", 0::bigint)' + if key == "isRunning": + return 'CASE WHEN rs."activeRunId" IS NOT NULL THEN 1 ELSE 0 END' + if key in wfFieldNames: + colType = wfFields.get(key, "TEXT") + if colType == "BOOLEAN": + return f'COALESCE(w."{key}", FALSE)' + return f'w."{key}"' + return None + + +def _appendJoinedListingFilters(whereParts: list, values: list, pagination, wfFields: dict) -> None: + """Append WHERE fragments for joined workflow listing (w + rs).""" + wfFieldNames = set(wfFields.keys()) + validCols = wfFieldNames | {"lastStartedAt", "runCount", "isRunning"} + + if not pagination or not pagination.filters: + return + + for key, val in pagination.filters.items(): + if key == "search" and isinstance(val, str) and val.strip(): + term = f"%{val.strip()}%" + textCols = [c for c, t in wfFields.items() if t == "TEXT"] + if textCols: + orParts = [f'COALESCE(w."{c}"::TEXT, \'\') ILIKE %s' for c in textCols] + whereParts.append(f"({' OR '.join(orParts)})") + values.extend([term] * len(textCols)) + continue + + if key not in validCols: + continue + + if key == "isRunning": + if isinstance(val, dict): + op = val.get("operator", "equals") + v = val.get("value", "") + isTrue = str(v).lower() == "true" + if op in ("equals", "eq"): + whereParts.append('(rs."activeRunId" IS NOT NULL)' if isTrue else '(rs."activeRunId" IS NULL)') + elif val is None: + whereParts.append('(rs."activeRunId" IS NULL)') + else: + whereParts.append( + '(rs."activeRunId" IS NOT NULL)' if str(val).lower() == "true" else '(rs."activeRunId" IS NULL)' + ) + continue + + colRef = _listingColSql(key, wfFieldNames) + if not colRef: + continue + + colType = wfFields.get(key, "TEXT") if key in wfFieldNames else ( + "DOUBLE PRECISION" if key == "lastStartedAt" else "BIGINT" if key == "runCount" else "TEXT" + ) + + if val is None: + if key == "lastStartedAt": + whereParts.append(f'({colRef} IS NULL)') + elif key == "runCount": + whereParts.append(f'({colRef} = 0)') + else: + whereParts.append(f'({colRef} IS NULL OR {colRef}::TEXT = \'\')') + continue + + if not isinstance(val, dict): + if colType == "BOOLEAN" or key == "isRunning": + whereParts.append(f'COALESCE({colRef}, FALSE) = %s') + values.append(str(val).lower() == "true") + else: + whereParts.append(f'{colRef}::TEXT ILIKE %s') + values.append(str(val)) + continue + + op = val.get("operator", "equals") + v = val.get("value", "") + if op in ("equals", "eq"): + if colType == "BOOLEAN": + whereParts.append(f'COALESCE({colRef}, FALSE) = %s') + values.append(str(v).lower() == "true") + else: + whereParts.append(f'{colRef}::TEXT = %s') + values.append(str(v)) + elif op == "contains": + whereParts.append(f'{colRef}::TEXT ILIKE %s') + values.append(f"%{v}%") + elif op == "startsWith": + whereParts.append(f'{colRef}::TEXT ILIKE %s') + values.append(f"{v}%") + elif op == "endsWith": + whereParts.append(f'{colRef}::TEXT ILIKE %s') + values.append(f"%{v}") + elif op in ("gt", "gte", "lt", "lte"): + sqlOp = {"gt": ">", "gte": ">=", "lt": "<", "lte": "<="}[op] + if colType in ("INTEGER", "DOUBLE PRECISION", "BIGINT") or key in ("lastStartedAt", "runCount"): + try: + whereParts.append(f'{colRef}::double precision {sqlOp} %s') + values.append(float(v)) + except (ValueError, TypeError): + continue + else: + whereParts.append(f'{colRef}::TEXT {sqlOp} %s') + values.append(str(v)) + elif op == "between": + fromVal = v.get("from", "") if isinstance(v, dict) else "" + toVal = v.get("to", "") if isinstance(v, dict) else "" + if not fromVal and not toVal: + continue + isNumericCol = colType in ("INTEGER", "DOUBLE PRECISION", "BIGINT") or key in ("lastStartedAt", "runCount") + isDateVal = bool(fromVal and re.match(r"^\d{4}-\d{2}-\d{2}$", str(fromVal))) or bool( + toVal and re.match(r"^\d{4}-\d{2}-\d{2}$", str(toVal)) + ) + if isNumericCol and isDateVal: + if fromVal and toVal: + fromTs = datetime.strptime(str(fromVal), "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp() + toTs = datetime.strptime(str(toVal), "%Y-%m-%d").replace( + hour=23, minute=59, second=59, tzinfo=timezone.utc + ).timestamp() + whereParts.append(f"({colRef} >= %s AND {colRef} <= %s)") + values.extend([fromTs, toTs]) + elif fromVal: + fromTs = datetime.strptime(str(fromVal), "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp() + whereParts.append(f"({colRef} >= %s)") + values.append(fromTs) + else: + toTs = datetime.strptime(str(toVal), "%Y-%m-%d").replace( + hour=23, minute=59, second=59, tzinfo=timezone.utc + ).timestamp() + whereParts.append(f"({colRef} <= %s)") + values.append(toTs) + elif isNumericCol: + try: + if fromVal and toVal: + whereParts.append( + f"({colRef}::double precision >= %s AND {colRef}::double precision <= %s)" + ) + values.extend([float(fromVal), float(toVal)]) + elif fromVal: + whereParts.append(f"{colRef}::double precision >= %s") + values.append(float(fromVal)) + elif toVal: + whereParts.append(f"{colRef}::double precision <= %s") + values.append(float(toVal)) + except (ValueError, TypeError): + continue + else: + if fromVal and toVal: + whereParts.append(f"({colRef}::TEXT >= %s AND {colRef}::TEXT <= %s)") + values.extend([str(fromVal), str(toVal)]) + elif fromVal: + whereParts.append(f"{colRef}::TEXT >= %s") + values.append(str(fromVal)) + elif toVal: + whereParts.append(f"{colRef}::TEXT <= %s") + values.append(str(toVal)) + + +def _buildJoinedWorkflowWhereOrderLimit( + recordFilter: dict, + pagination, + wfFields: dict, +) -> tuple: + """WHERE / ORDER BY / LIMIT for joined AutoWorkflow + run stats listing.""" + wfFieldNames = set(wfFields.keys()) + whereParts: list = [] + values: list = [] + + for field, value in (recordFilter or {}).items(): + if value is None: + whereParts.append(f'w."{field}" IS NULL') + elif isinstance(value, list): + whereParts.append(f'w."{field}" = ANY(%s)') + values.append(value) + else: + whereParts.append(f'w."{field}" = %s') + values.append(value) + + _appendJoinedListingFilters(whereParts, values, pagination, wfFields) + + whereClause = " WHERE " + " AND ".join(whereParts) if whereParts else "" + + orderParts: list = [] + if pagination and pagination.sort: + for sf in pagination.sort: + sfField = sf.get("field") if isinstance(sf, dict) else getattr(sf, "field", None) + sfDir = sf.get("direction", "asc") if isinstance(sf, dict) else getattr(sf, "direction", "asc") + if not sfField: + continue + expr = _listingOrderExpr(sfField, wfFieldNames, wfFields) + if not expr: + continue + direction = "DESC" if str(sfDir).lower() == "desc" else "ASC" + orderParts.append(f"{expr} {direction} NULLS LAST") + if not orderParts: + orderParts.append('w."sysCreatedAt" DESC NULLS LAST') + + orderClause = " ORDER BY " + ", ".join(orderParts) + + limitClause = "" + if pagination: + offset = (pagination.page - 1) * pagination.pageSize + limitClause = f" LIMIT {pagination.pageSize} OFFSET {offset}" + + return whereClause, orderClause, limitClause, values + + +def _getWorkflowsJoinedPaginated( + db: DatabaseConnector, + recordFilter: dict, + 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( + recordFilter, paginationParams, wfFields, + ) + countValues = list(values) + + fromSql = f'"AutoWorkflow" w LEFT JOIN {_RUN_STATS_SUBQUERY.strip()} ON rs."workflowId" = w."id"' + + countSql = f"SELECT COUNT(*) AS cnt FROM {fromSql}{whereClause}" + dataSql = f"SELECT w.*, rs.\"lastStartedAt\", rs.\"runCount\", rs.\"activeRunId\" FROM {fromSql}{whereClause}{orderClause}{limitClause}" + + db._ensure_connection() + with db.borrowCursor() as cursor: + cursor.execute(countSql, countValues) + totalItems = int(cursor.fetchone()["cnt"]) + + cursor.execute(dataSql, values) + rawRows = [dict(row) for row in cursor.fetchall()] + + pageSize = paginationParams.pageSize if paginationParams else max(totalItems, 1) + totalPages = math.ceil(totalItems / pageSize) if totalItems > 0 else 0 + + modelFields = AutoWorkflow.model_fields + for record in rawRows: + parseRecordFields(record, wfFields, "table AutoWorkflow joined listing") + for fieldName, fieldType in wfFields.items(): + if fieldType == "JSONB" and fieldName in record and record[fieldName] is None: + fieldInfo = modelFields.get(fieldName) + if fieldInfo: + fieldAnnotation = fieldInfo.annotation + if fieldAnnotation == list or ( + hasattr(fieldAnnotation, "__origin__") and fieldAnnotation.__origin__ is list + ): + record[fieldName] = [] + elif fieldAnnotation == dict or ( + hasattr(fieldAnnotation, "__origin__") and fieldAnnotation.__origin__ is dict + ): + record[fieldName] = {} + + return {"items": rawRows, "totalItems": totalItems, "totalPages": totalPages} diff --git a/modules/system/i18nBootSync.py b/modules/system/i18nBootSync.py index 15501a0f..3a32bee2 100644 --- a/modules/system/i18nBootSync.py +++ b/modules/system/i18nBootSync.py @@ -120,7 +120,7 @@ def _registerFeatureUiLabels(): _featureModulePaths = ( "modules.features.trustee.mainTrustee", - "modules.features.graphicalEditor.mainGraphicalEditor", + "modules.workflowAutomation.mainWorkflowAutomation", "modules.features.commcoach.mainCommcoach", "modules.features.teamsbot.mainTeamsbot", "modules.features.workspace.mainWorkspace", @@ -150,7 +150,7 @@ def _registerRbacLabels(): _featureModulePaths = ( "modules.system.mainSystem", "modules.features.trustee.mainTrustee", - "modules.features.graphicalEditor.mainGraphicalEditor", + "modules.workflowAutomation.mainWorkflowAutomation", "modules.features.commcoach.mainCommcoach", "modules.features.teamsbot.mainTeamsbot", "modules.features.workspace.mainWorkspace", @@ -242,8 +242,7 @@ def _registerNodeLabels(): added += 1 try: - # DEPRECATED: will move with WorkflowAutomation code restructuring - from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES + from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES for nd in STATIC_NODE_TYPES: _reg(_extractRegistrySourceText(nd.get("label")), "node.label") _reg(_extractRegistrySourceText(nd.get("description")), "node.desc") @@ -266,8 +265,7 @@ def _registerNodeLabels(): pass try: - # DEPRECATED: will move with WorkflowAutomation code restructuring - from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG + from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG for schema in PORT_TYPE_CATALOG.values(): for field in getattr(schema, "fields", []) or []: desc = getattr(field, "description", None) diff --git a/modules/system/mainSystem.py b/modules/system/mainSystem.py index bbdffbbd..b85ccf0b 100644 --- a/modules/system/mainSystem.py +++ b/modules/system/mainSystem.py @@ -178,9 +178,9 @@ RESOURCE_OBJECTS = [ "meta": {"category": "store", "featureCode": "trustee"} }, { - "objectKey": "resource.store.graphicalEditor", + "objectKey": "resource.store.workflowAutomation", "label": t("Store: Workflow-Automation", context="UI"), - "meta": {"category": "store", "featureCode": "graphicalEditor"} + "meta": {"category": "store", "featureCode": "workflowAutomation"} }, { "objectKey": "resource.system.api.auth", diff --git a/modules/workflowAutomation/__init__.py b/modules/workflowAutomation/__init__.py new file mode 100644 index 00000000..e6472791 --- /dev/null +++ b/modules/workflowAutomation/__init__.py @@ -0,0 +1,8 @@ +""" +workflowAutomation — System component for workflow orchestration. + +Contains: +- editor/ : Graph/Flow authoring (node registry, adapters, port types) +- engine/ : Graph execution runtime (ex workflows/automation2) +- scheduler/ : Workflow scheduler + email poller +""" diff --git a/modules/workflowAutomation/editor/__init__.py b/modules/workflowAutomation/editor/__init__.py new file mode 100644 index 00000000..471ba8a5 --- /dev/null +++ b/modules/workflowAutomation/editor/__init__.py @@ -0,0 +1,5 @@ +""" +workflowAutomation.editor — Graph/Flow authoring backend. + +Node registry, port types, adapters, condition operators, entry points. +""" diff --git a/modules/features/graphicalEditor/_workflowFileSchema.py b/modules/workflowAutomation/editor/_workflowFileSchema.py similarity index 98% rename from modules/features/graphicalEditor/_workflowFileSchema.py rename to modules/workflowAutomation/editor/_workflowFileSchema.py index 2ab5dfc9..efb06aea 100644 --- a/modules/features/graphicalEditor/_workflowFileSchema.py +++ b/modules/workflowAutomation/editor/_workflowFileSchema.py @@ -1,7 +1,7 @@ # Copyright (c) 2026 Patrick Motsch # All rights reserved. """ -Workflow File Schema (Versioned Envelope) for the GraphicalEditor. +Workflow File Schema (Versioned Envelope) for WorkflowAutomation. A *workflow file* is a portable JSON representation of an ``AutoWorkflow`` that can be exchanged between mandates / instances / installations. It contains the @@ -244,7 +244,7 @@ def envelopeToWorkflowData( featureInstanceId: str, ) -> Dict[str, Any]: """Convert a validated workflow-file envelope into a dict suitable for - ``GraphicalEditorObjects.createWorkflow`` / ``updateWorkflow``. + ``WorkflowAutomationObjects.createWorkflow`` / ``updateWorkflow``. Imports are always inactive — operators must explicitly activate them. Persistence-bound fields are NEVER copied from the envelope. diff --git a/modules/features/graphicalEditor/adapterValidator.py b/modules/workflowAutomation/editor/adapterValidator.py similarity index 99% rename from modules/features/graphicalEditor/adapterValidator.py rename to modules/workflowAutomation/editor/adapterValidator.py index 08e25232..77d16a91 100644 --- a/modules/features/graphicalEditor/adapterValidator.py +++ b/modules/workflowAutomation/editor/adapterValidator.py @@ -26,7 +26,7 @@ from __future__ import annotations from dataclasses import dataclass, field from typing import Any, Dict, List, Mapping -from modules.features.graphicalEditor.nodeAdapter import ( +from modules.workflowAutomation.editor.nodeAdapter import ( NodeAdapter, _adapterFromLegacyNode, _isMethodBoundNode, diff --git a/modules/features/graphicalEditor/conditionOperators.py b/modules/workflowAutomation/editor/conditionOperators.py similarity index 99% rename from modules/features/graphicalEditor/conditionOperators.py rename to modules/workflowAutomation/editor/conditionOperators.py index b375e407..3f67440f 100644 --- a/modules/features/graphicalEditor/conditionOperators.py +++ b/modules/workflowAutomation/editor/conditionOperators.py @@ -8,7 +8,7 @@ import re from datetime import datetime from typing import Any, Dict, List, Optional, Tuple -from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES +from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES from modules.shared.i18nRegistry import resolveText, t logger = logging.getLogger(__name__) @@ -282,7 +282,7 @@ def resolve_value_kind(graph: Dict[str, Any], ref: Dict[str, Any], *, _skip_upst return "file" if not _skip_upstream: - from modules.features.graphicalEditor.upstreamPathsService import compute_upstream_paths + from modules.workflowAutomation.editor.upstreamPathsService import compute_upstream_paths target_id = graph.get("targetNodeId") or producer_id matched_type: Optional[str] = None diff --git a/modules/features/graphicalEditor/entryPoints.py b/modules/workflowAutomation/editor/entryPoints.py similarity index 98% rename from modules/features/graphicalEditor/entryPoints.py rename to modules/workflowAutomation/editor/entryPoints.py index e70cfebb..3b4763f7 100644 --- a/modules/features/graphicalEditor/entryPoints.py +++ b/modules/workflowAutomation/editor/entryPoints.py @@ -99,7 +99,7 @@ def invocations_synced_with_graph( If the graph has no start node, only non-primary stored invocations are kept (no injected default). Document order in ``nodes`` defines which start wins. """ - from modules.workflows.automation2.graphUtils import getTriggerNodes + from modules.workflowAutomation.engine.graphUtils import getTriggerNodes g = graph if isinstance(graph, dict) else {} nodes = g.get("nodes") or [] diff --git a/modules/features/graphicalEditor/nodeAdapter.py b/modules/workflowAutomation/editor/nodeAdapter.py similarity index 100% rename from modules/features/graphicalEditor/nodeAdapter.py rename to modules/workflowAutomation/editor/nodeAdapter.py diff --git a/modules/features/graphicalEditor/nodeDefinitions/__init__.py b/modules/workflowAutomation/editor/nodeDefinitions/__init__.py similarity index 100% rename from modules/features/graphicalEditor/nodeDefinitions/__init__.py rename to modules/workflowAutomation/editor/nodeDefinitions/__init__.py diff --git a/modules/features/graphicalEditor/nodeDefinitions/ai.py b/modules/workflowAutomation/editor/nodeDefinitions/ai.py similarity index 99% rename from modules/features/graphicalEditor/nodeDefinitions/ai.py rename to modules/workflowAutomation/editor/nodeDefinitions/ai.py index a709f0be..37cf691f 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/ai.py +++ b/modules/workflowAutomation/editor/nodeDefinitions/ai.py @@ -3,10 +3,10 @@ from modules.shared.i18nRegistry import t -from modules.features.graphicalEditor.nodeDefinitions.contextPickerHelp import ( +from modules.workflowAutomation.editor.nodeDefinitions.contextPickerHelp import ( CONTEXT_BUILDER_PARAM_DESCRIPTION, ) -from modules.features.graphicalEditor.nodeDefinitions.flow import ( +from modules.workflowAutomation.editor.nodeDefinitions.flow import ( CONTEXT_ENVELOPE_DATA_PICK_OPTIONS, ) diff --git a/modules/features/graphicalEditor/nodeDefinitions/clickup.py b/modules/workflowAutomation/editor/nodeDefinitions/clickup.py similarity index 99% rename from modules/features/graphicalEditor/nodeDefinitions/clickup.py rename to modules/workflowAutomation/editor/nodeDefinitions/clickup.py index 77710a64..60c60bd5 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/clickup.py +++ b/modules/workflowAutomation/editor/nodeDefinitions/clickup.py @@ -4,7 +4,7 @@ from modules.shared.i18nRegistry import t -from modules.features.graphicalEditor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS +from modules.workflowAutomation.editor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS TASK_LIST_DATA_PICK_OPTIONS = [ { diff --git a/modules/features/graphicalEditor/nodeDefinitions/context.py b/modules/workflowAutomation/editor/nodeDefinitions/context.py similarity index 93% rename from modules/features/graphicalEditor/nodeDefinitions/context.py rename to modules/workflowAutomation/editor/nodeDefinitions/context.py index 743d92e8..839417e9 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/context.py +++ b/modules/workflowAutomation/editor/nodeDefinitions/context.py @@ -4,7 +4,7 @@ from modules.shared.i18nRegistry import t -from modules.features.graphicalEditor.nodeDefinitions.flow import ( +from modules.workflowAutomation.editor.nodeDefinitions.flow import ( CONTEXT_ENVELOPE_DATA_PICK_OPTIONS, CONTEXT_MERGE_ACTION_RESULT_DATA_PICK_OPTIONS, ) @@ -245,7 +245,7 @@ CONTEXT_NODES = [ "description": t( "Filtert fuer die Presentation-Schicht nach typeGroup/MIME " "(gilt fuer alle Dokumenttypen analog, nicht nur PDF). " - "Passt zum Inhaltsfilter „Alles“; „Text & Tabellen“ blendet Bild-Parts in der Presentation aus." + "Passt zum Inhaltsfilter „Alles"; „Text & Tabellen" blendet Bild-Parts in der Presentation aus." ), }, { @@ -271,12 +271,7 @@ CONTEXT_NODES = [ "outputPorts": { 0: { "schema": "ActionResult", - # Override the schema-level primaryTextRef path: ``response`` is intentionally - # empty for this node; downstream nodes with ``primaryTextRef`` should resolve to - # the full presentation object under ``data``. "primaryTextRefPath": ["data"], - # Authoritative DataPicker paths (same idea as ``parameters`` for configuration). - # Frontend uses only this list — no schema expansion merge for this port. "dataPickOptions": [ { "path": ["data"], @@ -320,7 +315,6 @@ CONTEXT_NODES = [ "meta": {"icon": "mdi-file-tree-outline", "color": "#00897B", "usesAi": False}, "_method": "context", "_action": "extractContent", - # Executor behaviour flags — drives actionNodeExecutor without hardcoded type checks. "skipUnifiedPresentation": True, "clearResponse": True, "imageDocumentsFromExtractData": True, @@ -356,14 +350,10 @@ CONTEXT_NODES = [ 0: {"schema": "ActionResult", "dataPickOptions": CONTEXT_MERGE_ACTION_RESULT_DATA_PICK_OPTIONS} }, "injectUpstreamPayload": True, - # Same contract as transformContext: picker paths like ``merged`` / ``first`` must match - # ``nodeOutputs`` (see actionNodeExecutor ``surfaceDataAsTopLevel``); merge payloads live in ``data``. "surfaceDataAsTopLevel": True, "meta": {"icon": "mdi-call-merge", "color": "#7B1FA2", "usesAi": False}, "_method": "context", "_action": "mergeContext", - # Image documents live on ``data.merged.imageDocumentsOnly`` (accumulated across - # iterations) rather than the top-level ``documents`` list which is always empty. "imageDocumentsFromMerged": True, }, { @@ -433,8 +423,6 @@ CONTEXT_NODES = [ "deriveFrom": "mappings", "deriveNameField": "outputField", "dataPickOptions": CONTEXT_ENVELOPE_DATA_PICK_OPTIONS, - # ActionResult is the correct normalization schema — NOT FormPayload. - # The output is a versionned ActionResult envelope built by contextEnvelope. "fromGraphResultSchema": "ActionResult", } }, diff --git a/modules/features/graphicalEditor/nodeDefinitions/contextPickerHelp.py b/modules/workflowAutomation/editor/nodeDefinitions/contextPickerHelp.py similarity index 78% rename from modules/features/graphicalEditor/nodeDefinitions/contextPickerHelp.py rename to modules/workflowAutomation/editor/nodeDefinitions/contextPickerHelp.py index 116164c1..55529951 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/contextPickerHelp.py +++ b/modules/workflowAutomation/editor/nodeDefinitions/contextPickerHelp.py @@ -4,14 +4,14 @@ from modules.shared.i18nRegistry import t CONTEXT_BUILDER_PARAM_DESCRIPTION = t( - "Inhalt aus vorherigen Schritten wählen (DataRef / Daten-Picker): z. B. „response“ für Klartext, " + "Inhalt aus vorherigen Schritten wählen (DataRef / Daten-Picker): z. B. „response" für Klartext, " "Handover-Pfade für strukturiertes JSON oder Medienlisten. " "Die Auflösung erfolgt vollständig serverseitig (`resolveParameterReferences`). " - "Formular-Schritte speichern Antworten unter „payload“ — fehlt ein gewählter Pfad am Root, " - "wird derselbe Pfad automatisch unter „payload“ nachgeschlagen (Kompatibilität mit älteren " + "Formular-Schritte speichern Antworten unter „payload" — fehlt ein gewählter Pfad am Root, " + "wird derselbe Pfad automatisch unter „payload" nachgeschlagen (Kompatibilität mit älteren " "und neuen Picker-Pfaden). " "In Freitext-/Template-Feldern werden weiterhin Platzhalter `{{KnotenId.feld.b.z.}}` ersetzt " - "(gleiche Semantik inkl. optionalem Nachschlagen unter „payload“)." + "(gleiche Semantik inkl. optionalem Nachschlagen unter „payload")." ) # Kurzreferenz für Node-Beschreibungen (optional einbinden): dieselbe Auflösungslogik diff --git a/modules/features/graphicalEditor/nodeDefinitions/data.py b/modules/workflowAutomation/editor/nodeDefinitions/data.py similarity index 97% rename from modules/features/graphicalEditor/nodeDefinitions/data.py rename to modules/workflowAutomation/editor/nodeDefinitions/data.py index 118de127..c8a4a3e5 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/data.py +++ b/modules/workflowAutomation/editor/nodeDefinitions/data.py @@ -3,7 +3,7 @@ from modules.shared.i18nRegistry import t -from modules.features.graphicalEditor.nodeDefinitions.ai import CONSOLIDATE_RESULT_DATA_PICK_OPTIONS +from modules.workflowAutomation.editor.nodeDefinitions.ai import CONSOLIDATE_RESULT_DATA_PICK_OPTIONS AGGREGATE_RESULT_DATA_PICK_OPTIONS = [ { diff --git a/modules/features/graphicalEditor/nodeDefinitions/email.py b/modules/workflowAutomation/editor/nodeDefinitions/email.py similarity index 96% rename from modules/features/graphicalEditor/nodeDefinitions/email.py rename to modules/workflowAutomation/editor/nodeDefinitions/email.py index cc4f1474..d5c7fe8c 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/email.py +++ b/modules/workflowAutomation/editor/nodeDefinitions/email.py @@ -3,10 +3,10 @@ from modules.shared.i18nRegistry import t -from modules.features.graphicalEditor.nodeDefinitions.contextPickerHelp import ( +from modules.workflowAutomation.editor.nodeDefinitions.contextPickerHelp import ( CONTEXT_BUILDER_PARAM_DESCRIPTION, ) -from modules.features.graphicalEditor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS +from modules.workflowAutomation.editor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS EMAIL_LIST_DATA_PICK_OPTIONS = [ { diff --git a/modules/features/graphicalEditor/nodeDefinitions/file.py b/modules/workflowAutomation/editor/nodeDefinitions/file.py similarity index 86% rename from modules/features/graphicalEditor/nodeDefinitions/file.py rename to modules/workflowAutomation/editor/nodeDefinitions/file.py index a10999a2..88deb5ec 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/file.py +++ b/modules/workflowAutomation/editor/nodeDefinitions/file.py @@ -3,10 +3,10 @@ from modules.shared.i18nRegistry import t -from modules.features.graphicalEditor.nodeDefinitions.contextPickerHelp import ( +from modules.workflowAutomation.editor.nodeDefinitions.contextPickerHelp import ( CONTEXT_BUILDER_PARAM_DESCRIPTION, ) -from modules.features.graphicalEditor.nodeDefinitions.ai import DOCUMENT_LIST_DATA_PICK_OPTIONS +from modules.workflowAutomation.editor.nodeDefinitions.ai import DOCUMENT_LIST_DATA_PICK_OPTIONS FILE_NODES = [ { @@ -14,7 +14,7 @@ FILE_NODES = [ "category": "file", "label": t("Datei erstellen"), "description": t( - "Erstellt eine Datei aus der Presentation von „Inhalt extrahieren“ " + "Erstellt eine Datei aus der Presentation von „Inhalt extrahieren" " "(``data`` oder Schleifen-``bodyResults``). Ausgabe über den Generation-Service." ), "parameters": [ @@ -37,7 +37,6 @@ FILE_NODES = [ "meta": {"icon": "mdi-file-plus-outline", "color": "#2196F3", "usesAi": False}, "_method": "file", "_action": "create", - # Emit a debug log tracing how the ``context`` parameter was resolved. "logContextResolution": True, }, ] diff --git a/modules/features/graphicalEditor/nodeDefinitions/flow.py b/modules/workflowAutomation/editor/nodeDefinitions/flow.py similarity index 93% rename from modules/features/graphicalEditor/nodeDefinitions/flow.py rename to modules/workflowAutomation/editor/nodeDefinitions/flow.py index f1efa0ec..fe1b1f30 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/flow.py +++ b/modules/workflowAutomation/editor/nodeDefinitions/flow.py @@ -96,7 +96,7 @@ MERGE_RESULT_DATA_PICK_OPTIONS = [ { "path": ["first"], "pickerLabel": t("Erster Zweig"), - "detail": t("Daten vom ersten verbundenen Eingang (Modus „first“)."), + "detail": t("Daten vom ersten verbundenen Eingang (Modus „first")."), "recommended": False, "type": "Any", }, @@ -243,9 +243,9 @@ FLOW_NODES = [ "category": "flow", "label": t("Schleife / Für jedes"), "description": t( - "Zwei Ausgänge: „Schleife“ verbindet den Rumpf (pro Element); optional führt der Rumpf " + "Zwei Ausgänge: „Schleife" verbindet den Rumpf (pro Element); optional führt der Rumpf " "mit einem Rücklauf-Pfeil wieder zum **gleichen Eingang** wie der vorherige Schritt (wie in n8n). " - "„Fertig“ führt genau einmal fort, wenn alle Iterationen beendet sind. " + "„Fertig" führt genau einmal fort, wenn alle Iterationen beendet sind. " "Die zu durchlaufende Liste wählen Sie wie bisher; UDM-/Strukturdaten werden automatisch sinnvoll in Elemente aufgelöst." ), "parameters": [ @@ -266,7 +266,7 @@ FLOW_NODES = [ }, "description": t( "Welche Elemente die Schleife besucht: alle, nur das erste/letzte, jedes zweite/dritte " - "oder jedes n-te (Schritt dann unter „Schrittweite“)." + "oder jedes n-te (Schritt dann unter „Schrittweite")." ), "default": "all", }, @@ -276,7 +276,7 @@ FLOW_NODES = [ "required": False, "frontendType": "number", "frontendOptions": {"min": 2, "max": 100}, - "description": t("Nur bei „jedes n-te“: Schrittweite (z. B. 5 = jedes 5. Element ab Index 0)."), + "description": t("Nur bei „jedes n-te": Schrittweite (z. B. 5 = jedes 5. Element ab Index 0)."), "default": 2, }, { @@ -333,12 +333,6 @@ FLOW_NODES = [ "default": 2, }, ], - # ``inputs: 2`` is the static minimum / default topology. ``inputCount`` is a - # frontend hint: the editor adds/removes input ports dynamically when the user - # changes the value. ``FlowExecutor._merge`` collects whatever ports exist in - # ``inputSources`` at runtime, so extra ports (3–5) work without further changes - # to this definition. ``inputPorts`` below only type-declares the two minimum - # ports; additional ports inherit the same ``_FLOW_INPUT_SCHEMAS`` accepts list. "inputs": 2, "outputs": 1, "inputPorts": { diff --git a/modules/features/graphicalEditor/nodeDefinitions/input.py b/modules/workflowAutomation/editor/nodeDefinitions/input.py similarity index 98% rename from modules/features/graphicalEditor/nodeDefinitions/input.py rename to modules/workflowAutomation/editor/nodeDefinitions/input.py index 5bf84e74..5c152fdb 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/input.py +++ b/modules/workflowAutomation/editor/nodeDefinitions/input.py @@ -3,7 +3,7 @@ from modules.shared.i18nRegistry import t -from modules.features.graphicalEditor.nodeDefinitions.ai import DOCUMENT_LIST_DATA_PICK_OPTIONS +from modules.workflowAutomation.editor.nodeDefinitions.ai import DOCUMENT_LIST_DATA_PICK_OPTIONS BOOL_RESULT_DATA_PICK_OPTIONS = [ { diff --git a/modules/features/graphicalEditor/nodeDefinitions/redmine.py b/modules/workflowAutomation/editor/nodeDefinitions/redmine.py similarity index 98% rename from modules/features/graphicalEditor/nodeDefinitions/redmine.py rename to modules/workflowAutomation/editor/nodeDefinitions/redmine.py index 675fe957..f20f2901 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/redmine.py +++ b/modules/workflowAutomation/editor/nodeDefinitions/redmine.py @@ -4,7 +4,7 @@ from modules.shared.i18nRegistry import t -from modules.features.graphicalEditor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS +from modules.workflowAutomation.editor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS # Typed FeatureInstance binding (replaces legacy `string, hidden`). # - type FeatureInstanceRef[redmine] is filtered by the DataPicker. diff --git a/modules/features/graphicalEditor/nodeDefinitions/sharepoint.py b/modules/workflowAutomation/editor/nodeDefinitions/sharepoint.py similarity index 99% rename from modules/features/graphicalEditor/nodeDefinitions/sharepoint.py rename to modules/workflowAutomation/editor/nodeDefinitions/sharepoint.py index 2a1a1a32..db48d8db 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/sharepoint.py +++ b/modules/workflowAutomation/editor/nodeDefinitions/sharepoint.py @@ -3,7 +3,7 @@ from modules.shared.i18nRegistry import t -from modules.features.graphicalEditor.nodeDefinitions.ai import ( +from modules.workflowAutomation.editor.nodeDefinitions.ai import ( ACTION_RESULT_DATA_PICK_OPTIONS, DOCUMENT_LIST_DATA_PICK_OPTIONS, ) diff --git a/modules/features/graphicalEditor/nodeDefinitions/triggers.py b/modules/workflowAutomation/editor/nodeDefinitions/triggers.py similarity index 96% rename from modules/features/graphicalEditor/nodeDefinitions/triggers.py rename to modules/workflowAutomation/editor/nodeDefinitions/triggers.py index 074125e2..0ae34ff2 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/triggers.py +++ b/modules/workflowAutomation/editor/nodeDefinitions/triggers.py @@ -3,7 +3,7 @@ from modules.shared.i18nRegistry import t -from modules.features.graphicalEditor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS +from modules.workflowAutomation.editor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS TRIGGER_NODES = [ { diff --git a/modules/features/graphicalEditor/nodeDefinitions/trustee.py b/modules/workflowAutomation/editor/nodeDefinitions/trustee.py similarity index 93% rename from modules/features/graphicalEditor/nodeDefinitions/trustee.py rename to modules/workflowAutomation/editor/nodeDefinitions/trustee.py index d6a82e4b..a8c390a8 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/trustee.py +++ b/modules/workflowAutomation/editor/nodeDefinitions/trustee.py @@ -3,7 +3,7 @@ from modules.shared.i18nRegistry import t -from modules.features.graphicalEditor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS +from modules.workflowAutomation.editor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS # Typed FeatureInstance binding (replaces legacy `string, hidden`). # - type uses the discriminator notation `FeatureInstanceRef[]` so the @@ -61,9 +61,6 @@ TRUSTEE_NODES = [ "inputs": 1, "outputs": 1, "inputPorts": {0: {"accepts": ["DocumentList", "Transit", "AiResult", "LoopItem", "ActionResult"]}}, - # Runtime returns ActionResult.isSuccess(documents=[...]) — see - # actions/extractFromFiles.py. Declaring DocumentList here was adapter - # drift and broke the DataPicker for downstream nodes. "outputPorts": {0: {"schema": "ActionResult", "dataPickOptions": ACTION_RESULT_DATA_PICK_OPTIONS}}, "meta": {"icon": "mdi-file-document-scan", "color": "#4CAF50", "usesAi": True}, "_method": "trustee", @@ -75,9 +72,6 @@ TRUSTEE_NODES = [ "label": t("Dokumente verarbeiten"), "description": t("TrusteeDocument + TrusteePosition aus Extraktionsergebnis erstellen."), "parameters": [ - # Type matches what producers actually emit: ActionResult.documents - # is List[ActionDocument] (see datamodelChat.ActionResult). The - # DataPicker uses this string to filter compatible upstream paths. {"name": "documentList", "type": "List[ActionDocument]", "required": True, "frontendType": "dataRef", "description": t("Dokumente aus vorherigen Schritten"), "graphInherit": {"port": 0, "kind": "documentListWire"}}, diff --git a/modules/features/graphicalEditor/nodeRegistry.py b/modules/workflowAutomation/editor/nodeRegistry.py similarity index 92% rename from modules/features/graphicalEditor/nodeRegistry.py rename to modules/workflowAutomation/editor/nodeRegistry.py index 0b0c09fd..bbddd9f0 100644 --- a/modules/features/graphicalEditor/nodeRegistry.py +++ b/modules/workflowAutomation/editor/nodeRegistry.py @@ -1,18 +1,18 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. """ -Node Type Registry for graphicalEditor - static node definitions (start, input, flow, data, ai, email, …). +Node Type Registry for WorkflowAutomation editor - static node definitions (start, input, flow, data, ai, email, …). Nodes are defined first; IO/method actions are used at execution time. """ import logging from typing import Dict, List, Any, Optional -from modules.features.graphicalEditor.conditionOperators import localize_operator_catalog -from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES -from modules.features.graphicalEditor.nodeDefinitions.input import FORM_FIELD_TYPES -from modules.features.graphicalEditor.nodeAdapter import bindsActionFromLegacy -from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG, SYSTEM_VARIABLES +from modules.workflowAutomation.editor.conditionOperators import localize_operator_catalog +from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES +from modules.workflowAutomation.editor.nodeDefinitions.input import FORM_FIELD_TYPES +from modules.workflowAutomation.editor.nodeAdapter import bindsActionFromLegacy +from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG, SYSTEM_VARIABLES from modules.shared.i18nRegistry import normalizePrimaryLanguageTag, resolveText logger = logging.getLogger(__name__) @@ -178,7 +178,7 @@ def validateAdaptersAgainstMethods(methodInstances: Optional[Dict[str, Any]] = N Pass `methodInstances` directly for testability; defaults to importing the live registry from `methodDiscovery.methods`. """ - from modules.features.graphicalEditor.adapterValidator import ( + from modules.workflowAutomation.editor.adapterValidator import ( _buildActionsRegistryFromMethods, _formatAdapterReport, _validateAllAdapters, diff --git a/modules/features/graphicalEditor/portTypes.py b/modules/workflowAutomation/editor/portTypes.py similarity index 99% rename from modules/features/graphicalEditor/portTypes.py rename to modules/workflowAutomation/editor/portTypes.py index a7eb0f3f..6246896e 100644 --- a/modules/features/graphicalEditor/portTypes.py +++ b/modules/workflowAutomation/editor/portTypes.py @@ -418,7 +418,7 @@ def deriveFormPayloadSchemaFromParam( - Group-fields: ``type == "group"`` recursed via ``fields``. - List[str]: each string is taken as a leaf path key (used for ``filterContext.keys``). """ - from modules.features.graphicalEditor.nodeDefinitions.input import FORM_FIELD_TYPES + from modules.workflowAutomation.editor.nodeDefinitions.input import FORM_FIELD_TYPES _FORM_TYPE_TO_PORT: Dict[str, str] = {f["id"]: f["portType"] for f in FORM_FIELD_TYPES} fields_param = (node.get("parameters") or {}).get(param_key) diff --git a/modules/features/graphicalEditor/switchOutput.py b/modules/workflowAutomation/editor/switchOutput.py similarity index 99% rename from modules/features/graphicalEditor/switchOutput.py rename to modules/workflowAutomation/editor/switchOutput.py index be469ead..e7cc830b 100644 --- a/modules/features/graphicalEditor/switchOutput.py +++ b/modules/workflowAutomation/editor/switchOutput.py @@ -7,7 +7,7 @@ import copy import re from typing import Any, Dict, List, Optional -from modules.features.graphicalEditor.portTypes import unwrapTransit +from modules.workflowAutomation.editor.portTypes import unwrapTransit _CONTEXT_FILTER_OPERATORS = frozenset({"contains_content"}) _BLOB_IMAGE_CHUNK_RE = re.compile(r"^\[image(?:\:([^\]]+))?\]$") diff --git a/modules/features/graphicalEditor/upstreamPathsService.py b/modules/workflowAutomation/editor/upstreamPathsService.py similarity index 95% rename from modules/features/graphicalEditor/upstreamPathsService.py rename to modules/workflowAutomation/editor/upstreamPathsService.py index ade9524a..f3d2a6ab 100644 --- a/modules/features/graphicalEditor/upstreamPathsService.py +++ b/modules/workflowAutomation/editor/upstreamPathsService.py @@ -4,10 +4,10 @@ from __future__ import annotations from typing import Any, Dict, List, Set -from modules.features.graphicalEditor.conditionOperators import catalog_type_to_value_kind, resolve_value_kind -from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES -from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG, PortSchema, parse_graph_defined_output_schema -from modules.workflows.automation2.graphUtils import buildConnectionMap, getLoopBodyNodeIds, getLoopDoneNodeIds +from modules.workflowAutomation.editor.conditionOperators import catalog_type_to_value_kind, resolve_value_kind +from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES +from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG, PortSchema, parse_graph_defined_output_schema +from modules.workflowAutomation.engine.graphUtils import buildConnectionMap, getLoopBodyNodeIds, getLoopDoneNodeIds _NODE_BY_TYPE = {n["id"]: n for n in STATIC_NODE_TYPES} diff --git a/modules/workflowAutomation/engine/__init__.py b/modules/workflowAutomation/engine/__init__.py new file mode 100644 index 00000000..0656ab39 --- /dev/null +++ b/modules/workflowAutomation/engine/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2025 Patrick Motsch +# automation2 - n8n-style graph execution engine. diff --git a/modules/workflows/automation2/clickupTaskUpdateMerge.py b/modules/workflowAutomation/engine/clickupTaskUpdateMerge.py similarity index 100% rename from modules/workflows/automation2/clickupTaskUpdateMerge.py rename to modules/workflowAutomation/engine/clickupTaskUpdateMerge.py diff --git a/modules/workflows/automation2/executionEngine.py b/modules/workflowAutomation/engine/executionEngine.py similarity index 98% rename from modules/workflows/automation2/executionEngine.py rename to modules/workflowAutomation/engine/executionEngine.py index b6313342..cbe572da 100644 --- a/modules/workflows/automation2/executionEngine.py +++ b/modules/workflowAutomation/engine/executionEngine.py @@ -9,7 +9,7 @@ import uuid from datetime import datetime, timezone from typing import Dict, Any, List, Set, Optional -from modules.workflows.automation2.graphUtils import ( +from modules.workflowAutomation.engine.graphUtils import ( parseGraph, buildConnectionMap, validateGraph, @@ -20,7 +20,7 @@ from modules.workflows.automation2.graphUtils import ( getLoopPrimaryInputSource, ) -from modules.workflows.automation2.executors import ( +from modules.workflowAutomation.engine.executors import ( TriggerExecutor, FlowExecutor, ActionNodeExecutor, @@ -29,15 +29,15 @@ from modules.workflows.automation2.executors import ( PauseForHumanTaskError, PauseForEmailWaitError, ) -from modules.features.graphicalEditor.portTypes import normalizeToSchema, wrapTransit, unwrapTransit -from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES +from modules.workflowAutomation.editor.portTypes import normalizeToSchema, wrapTransit, unwrapTransit +from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES from modules.datamodels.serviceExceptions import SubscriptionInactiveException as _SubscriptionInactiveException, BillingContextError as _BillingContextError -from modules.workflows.automation2.graphicalEditorRunFileLogger import ( - GraphicalEditorRunFileLogger, +from modules.workflowAutomation.engine.runFileLogger import ( + RunFileLogger, graphical_editor_run_file_logging_enabled, merge_run_context_with_ge_log_prefix, ) -from modules.workflows.automation2.runEnvelope import normalize_run_envelope +from modules.workflowAutomation.engine.runEnvelope import normalize_run_envelope logger = logging.getLogger(__name__) @@ -269,7 +269,7 @@ def _createStepLog(iface, runId: str, nodeId: str, nodeType: str, status: str = if not iface or not runId: return None try: - from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoStepLog + from modules.datamodels.datamodelWorkflowAutomation import AutoStepLog stepId = str(uuid.uuid4()) startedAt = time.time() iface.db.recordCreate(AutoStepLog, { @@ -298,7 +298,7 @@ def _updateStepLog(iface, stepId: str, status: str, output: Dict = None, error: if not iface or not stepId: return try: - from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoStepLog + from modules.datamodels.datamodelWorkflowAutomation import AutoStepLog completedAt = time.time() updates: Dict[str, Any] = { "status": status, @@ -333,7 +333,7 @@ def _ge_iso_timestamp() -> str: async def _ge_log_node_finished( - file_logger: Optional[GraphicalEditorRunFileLogger], + file_logger: Optional[RunFileLogger], *, run_id: Optional[str], node_outputs: Dict[str, Any], @@ -511,7 +511,7 @@ async def _run_post_loop_done_nodes( automation2_interface: Optional[Any], runId: Optional[str], processed_in_loop: Set[str], - ge_file_logger: Optional[GraphicalEditorRunFileLogger] = None, + ge_file_logger: Optional[RunFileLogger] = None, ) -> Optional[Dict[str, Any]]: """After all loop iterations: merge upstream into loop output and run the Done (output 1) branch once.""" _prim_in = getLoopPrimaryInputSource(loop_node_id, connectionMap, body_ids) @@ -705,13 +705,13 @@ async def executeGraph( ) from modules.workflows.processing.shared.methodDiscovery import discoverMethods discoverMethods(services) - from modules.workflows.automation2.pickNotPushMigration import ( + from modules.workflowAutomation.engine.pickNotPushMigration import ( materializeConnectionRefs, materializePrimaryTextHandover, materializeRecommendedDataPickRef, normalizeFileCreatePresentationRefs, ) - from modules.workflows.automation2.featureInstanceRefMigration import ( + from modules.workflowAutomation.engine.featureInstanceRefMigration import ( materializeFeatureInstanceRefs, ) @@ -767,7 +767,7 @@ async def executeGraph( except Exception as valErr: logger.warning("executeGraph resume: schema validation failed for %s: %s", startAfterNodeId, valErr) - ge_file_logger: Optional[GraphicalEditorRunFileLogger] = None + ge_file_logger: Optional[RunFileLogger] = None nodeOutputs: Dict[str, Any] = dict(initialNodeOutputs or {}) if not runId and automation2_interface and workflowId and not is_resume: run_context = { @@ -806,7 +806,7 @@ async def executeGraph( runId = run.get("id") if run else None logger.info("executeGraph created run %s label=%s", runId, run_label) if runId and graphical_editor_run_file_logging_enabled(): - ge_file_logger = GraphicalEditorRunFileLogger.bootstrap_new_run( + ge_file_logger = RunFileLogger.bootstrap_new_run( automation2_interface, runId, run_context, @@ -847,7 +847,7 @@ async def executeGraph( and runId and ge_file_logger is None ): - ge_file_logger = GraphicalEditorRunFileLogger.ensure_attached( + ge_file_logger = RunFileLogger.ensure_attached( automation2_interface, runId, ) @@ -1542,7 +1542,7 @@ async def executeGraph( logger.info("executeGraph paused for email wait (run %s, node %s)", e.runId, e.nodeId) try: from modules.interfaces.interfaceDbApp import getRootInterface - from modules.features.graphicalEditor.emailPoller import ensureRunning + from modules.workflowAutomation.scheduler.emailPoller import ensureRunning root = getRootInterface() event_user = root.getUserByUsername("event") if root else None if event_user: @@ -1612,7 +1612,7 @@ async def executeGraph( ) if _wfObj else {} _shouldNotify = _wfDict.get("notifyOnFailure", True) if _wfDict else True if _shouldNotify: - from modules.workflows.scheduler.mainScheduler import notifyRunFailed + from modules.workflowAutomation.scheduler.mainScheduler import notifyRunFailed notifyRunFailed( workflowId or "", runId or "", str(e), mandateId=mandateId, diff --git a/modules/workflowAutomation/engine/executors/__init__.py b/modules/workflowAutomation/engine/executors/__init__.py new file mode 100644 index 00000000..4d2180c3 --- /dev/null +++ b/modules/workflowAutomation/engine/executors/__init__.py @@ -0,0 +1,18 @@ +# Copyright (c) 2025 Patrick Motsch +# Executors for automation2 node types. + +from .triggerExecutor import TriggerExecutor +from .flowExecutor import FlowExecutor +from .actionNodeExecutor import ActionNodeExecutor +from .inputExecutor import InputExecutor, PauseForHumanTaskError, PauseForEmailWaitError +from .dataExecutor import DataExecutor + +__all__ = [ + "TriggerExecutor", + "FlowExecutor", + "ActionNodeExecutor", + "InputExecutor", + "DataExecutor", + "PauseForHumanTaskError", + "PauseForEmailWaitError", +] diff --git a/modules/workflows/automation2/executors/actionNodeExecutor.py b/modules/workflowAutomation/engine/executors/actionNodeExecutor.py similarity index 97% rename from modules/workflows/automation2/executors/actionNodeExecutor.py rename to modules/workflowAutomation/engine/executors/actionNodeExecutor.py index ee1101e5..41c88a5d 100644 --- a/modules/workflows/automation2/executors/actionNodeExecutor.py +++ b/modules/workflowAutomation/engine/executors/actionNodeExecutor.py @@ -13,12 +13,12 @@ import re import time from typing import Any, Dict, Optional -from modules.features.graphicalEditor.portTypes import ( +from modules.workflowAutomation.editor.portTypes import ( _normalizeError, normalizeToSchema, ) from modules.datamodels.serviceExceptions import SubscriptionInactiveException as _SubscriptionInactiveException, BillingContextError as _BillingContextError -from modules.workflows.automation2.executors.inputExecutor import PauseForHumanTaskError +from modules.workflowAutomation.engine.executors.inputExecutor import PauseForHumanTaskError from modules.workflows.methods.methodContext.actions.extractContent import ( PRESENTATION_KIND, build_presentation_envelope_from_plain_text, @@ -181,7 +181,7 @@ def _isUserConnectionId(val: Any) -> bool: def _getNodeDefinition(nodeType: str) -> Optional[Dict[str, Any]]: """Get node definition by type id.""" - from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES + from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES for node in STATIC_NODE_TYPES: if node.get("id") == nodeType: return node @@ -304,7 +304,7 @@ 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.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG + from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG schema = PORT_TYPE_CATALOG.get(outputSchema) return bool(getattr(schema, "carriesConnectionProvenance", False)) @@ -388,7 +388,7 @@ def _mapper_emailDraftContextFromSubjectBody(params: Dict, **_) -> None: def _mapper_clickupTaskUpdateMerge(params: Dict, **_) -> None: - from modules.workflows.automation2.clickupTaskUpdateMerge import merge_clickup_task_update_entries + from modules.workflowAutomation.engine.clickupTaskUpdateMerge import merge_clickup_task_update_entries merge_clickup_task_update_entries(params) @@ -430,7 +430,7 @@ def _resolveUpstreamPayload(nodeId: str, context: Dict[str, Any]) -> Any: the first ``connectionMap`` entry so ``injectUpstreamPayload`` (e.g. ``context.mergeContext`` after ``flow.loop``) still receives data. """ - from modules.features.graphicalEditor.switchOutput import unwrap_transit_for_port + from modules.workflowAutomation.editor.switchOutput import unwrap_transit_for_port nodeOutputs = context.get("nodeOutputs") or {} connectionMap = context.get("connectionMap") or {} @@ -456,9 +456,9 @@ def _resolveUpstreamPayload(nodeId: str, context: Dict[str, Any]) -> Any: return unwrap_transit_for_port(upstream, src_out) -def _resolveBranchInputs(nodeId: str, context: Dict[str, Any]) -> Dict[int, Any]: + def _resolveBranchInputs(nodeId: str, context: Dict[str, Any]) -> Dict[int, Any]: """Return ``Dict[port_index → unwrapped upstream output]`` for every wired input port.""" - from modules.features.graphicalEditor.switchOutput import unwrap_transit_for_port + from modules.workflowAutomation.editor.switchOutput import unwrap_transit_for_port src_map = (context.get("inputSources") or {}).get(nodeId) or {} nodeOutputs = context.get("nodeOutputs") or {} out: Dict[int, Any] = {} @@ -484,8 +484,8 @@ class ActionNodeExecutor: node: Dict[str, Any], context: Dict[str, Any], ) -> Any: - from modules.features.graphicalEditor.nodeRegistry import getNodeTypeToMethodAction - from modules.workflows.automation2.graphUtils import ( + from modules.workflowAutomation.editor.nodeRegistry import getNodeTypeToMethodAction + from modules.workflowAutomation.engine.graphUtils import ( document_list_param_is_empty, extract_wired_document_list, resolveParameterReferences, @@ -569,7 +569,7 @@ class ActionNodeExecutor: workflowId = context.get("workflowId") connRef = resolvedParams.get("connectionReference") if runId and workflowId and connRef: - from modules.workflows.automation2.executors import PauseForEmailWaitError + from modules.workflowAutomation.engine.executors import PauseForEmailWaitError waitConfig = { "connectionReference": connRef, "folder": resolvedParams.get("folder", "Inbox"), diff --git a/modules/workflows/automation2/executors/dataExecutor.py b/modules/workflowAutomation/engine/executors/dataExecutor.py similarity index 99% rename from modules/workflows/automation2/executors/dataExecutor.py rename to modules/workflowAutomation/engine/executors/dataExecutor.py index ef205590..3429e650 100644 --- a/modules/workflows/automation2/executors/dataExecutor.py +++ b/modules/workflowAutomation/engine/executors/dataExecutor.py @@ -4,7 +4,7 @@ import logging from typing import Any, Dict -from modules.features.graphicalEditor.portTypes import unwrapTransit, wrapTransit +from modules.workflowAutomation.editor.portTypes import unwrapTransit, wrapTransit logger = logging.getLogger(__name__) diff --git a/modules/workflows/automation2/executors/flowExecutor.py b/modules/workflowAutomation/engine/executors/flowExecutor.py similarity index 96% rename from modules/workflows/automation2/executors/flowExecutor.py rename to modules/workflowAutomation/engine/executors/flowExecutor.py index 3da89a87..f107a580 100644 --- a/modules/workflows/automation2/executors/flowExecutor.py +++ b/modules/workflowAutomation/engine/executors/flowExecutor.py @@ -5,8 +5,8 @@ import logging from datetime import datetime from typing import Any, Dict, List, Optional -from modules.features.graphicalEditor.conditionOperators import apply_condition_operator, resolve_value_kind -from modules.features.graphicalEditor.portTypes import wrapTransit, unwrapTransit +from modules.workflowAutomation.editor.conditionOperators import apply_condition_operator, resolve_value_kind +from modules.workflowAutomation.editor.portTypes import wrapTransit, unwrapTransit logger = logging.getLogger(__name__) @@ -90,7 +90,7 @@ class FlowExecutor: return False if isinstance(condParam, dict) and condParam.get("type") == "condition": return self._evalStructuredCondition(condParam, nodeOutputs, item_param=item_param, node=node) - from modules.workflows.automation2.graphUtils import resolveParameterReferences + from modules.workflowAutomation.engine.graphUtils import resolveParameterReferences resolved = resolveParameterReferences(condParam, nodeOutputs) return self._evalCondition(resolved) @@ -121,7 +121,7 @@ class FlowExecutor: node: Optional[Dict] = None, ) -> bool: """Evaluate structured {operator, value} with Item dataRef (legacy: condition.ref).""" - from modules.workflows.automation2.graphUtils import resolveParameterReferences + from modules.workflowAutomation.engine.graphUtils import resolveParameterReferences left_ref = item_param if left_ref is None or (isinstance(left_ref, dict) and not left_ref): @@ -208,8 +208,8 @@ class FlowExecutor: async def _switch(self, node: Dict, nodeOutputs: Dict, nodeId: str, inputSources: Dict) -> Any: params = node.get("parameters") or {} valueExpr = params.get("value", "") - from modules.workflows.automation2.graphUtils import resolveParameterReferences - from modules.features.graphicalEditor.switchOutput import ( + from modules.workflowAutomation.engine.graphUtils import resolveParameterReferences + from modules.workflowAutomation.editor.switchOutput import ( build_switch_combined_output, build_switch_default_payload, ) @@ -258,7 +258,7 @@ class FlowExecutor: async def _loop(self, node: Dict, nodeOutputs: Dict, nodeId: str, inputSources: Dict) -> Any: params = node.get("parameters") or {} itemsPath = params.get("items", "[]") - from modules.workflows.automation2.graphUtils import resolveParameterReferences + from modules.workflowAutomation.engine.graphUtils import resolveParameterReferences raw = resolveParameterReferences( itemsPath, diff --git a/modules/workflows/automation2/executors/inputExecutor.py b/modules/workflowAutomation/engine/executors/inputExecutor.py similarity index 95% rename from modules/workflows/automation2/executors/inputExecutor.py rename to modules/workflowAutomation/engine/executors/inputExecutor.py index aaf31ff1..39efcfe6 100644 --- a/modules/workflows/automation2/executors/inputExecutor.py +++ b/modules/workflowAutomation/engine/executors/inputExecutor.py @@ -47,7 +47,7 @@ class InputExecutor: ) taskId = task.get("id") - from modules.workflows.automation2.graphicalEditorRunFileLogger import merge_persisted_run_context + from modules.workflowAutomation.engine.runFileLogger import merge_persisted_run_context _pause_ctx = merge_persisted_run_context( self.automation2, diff --git a/modules/workflows/automation2/executors/ioExecutor.py b/modules/workflowAutomation/engine/executors/ioExecutor.py similarity index 95% rename from modules/workflows/automation2/executors/ioExecutor.py rename to modules/workflowAutomation/engine/executors/ioExecutor.py index 14bc8f91..ae527adf 100644 --- a/modules/workflows/automation2/executors/ioExecutor.py +++ b/modules/workflowAutomation/engine/executors/ioExecutor.py @@ -37,7 +37,7 @@ class IOExecutor: nodeOutputs = context.get("nodeOutputs", {}) params = dict(node.get("parameters") or {}) - from modules.workflows.automation2.graphUtils import extract_wired_document_list, resolveParameterReferences + from modules.workflowAutomation.engine.graphUtils import extract_wired_document_list, resolveParameterReferences resolvedParams = resolveParameterReferences(params, nodeOutputs) logger.info("IOExecutor node %s resolvedParams keys=%s", nodeId, list(resolvedParams.keys())) diff --git a/modules/workflows/automation2/executors/triggerExecutor.py b/modules/workflowAutomation/engine/executors/triggerExecutor.py similarity index 94% rename from modules/workflows/automation2/executors/triggerExecutor.py rename to modules/workflowAutomation/engine/executors/triggerExecutor.py index cd2d118e..35b46237 100644 --- a/modules/workflows/automation2/executors/triggerExecutor.py +++ b/modules/workflowAutomation/engine/executors/triggerExecutor.py @@ -4,7 +4,7 @@ import logging from typing import Any, Dict -from modules.workflows.automation2.runEnvelope import normalize_run_envelope +from modules.workflowAutomation.engine.runEnvelope import normalize_run_envelope logger = logging.getLogger(__name__) diff --git a/modules/workflows/automation2/featureInstanceRefMigration.py b/modules/workflowAutomation/engine/featureInstanceRefMigration.py similarity index 100% rename from modules/workflows/automation2/featureInstanceRefMigration.py rename to modules/workflowAutomation/engine/featureInstanceRefMigration.py diff --git a/modules/workflows/automation2/graphUtils.py b/modules/workflowAutomation/engine/graphUtils.py similarity index 97% rename from modules/workflows/automation2/graphUtils.py rename to modules/workflowAutomation/engine/graphUtils.py index 9130f023..946faafa 100644 --- a/modules/workflows/automation2/graphUtils.py +++ b/modules/workflowAutomation/engine/graphUtils.py @@ -91,7 +91,7 @@ def getLoopPrimaryInputSource( ) -> Optional[Tuple[str, int]]: """Pick the inbound edge for ``flow.loop`` when several wires hit the same input (0). - The Schleifen-Rücklauf vom Rumpf und der „normale“ Vorgänger enden auf demselben Port; + The Schleifen-Rücklauf vom Rumpf und der „normale" Vorgänger enden auf demselben Port; für die Datenzusammenführung (Fertig-Ausgang, Logs) zählt der Vorgänger **außerhalb** des Rumpfes. """ incoming = connectionMap.get(loop_node_id, []) @@ -209,7 +209,7 @@ def parse_graph_defined_schema(node: Dict[str, Any], parameter_key: str) -> Opti Build a JSON-serializable port schema dict from graph parameters (e.g. form ``fields``). Used by tooling and future API surfaces; mirrors ``parse_graph_defined_output_schema`` logic. """ - from modules.features.graphicalEditor.portTypes import deriveFormPayloadSchemaFromParam + from modules.workflowAutomation.editor.portTypes import deriveFormPayloadSchemaFromParam sch = deriveFormPayloadSchemaFromParam(node, parameter_key) if sch is None: @@ -227,8 +227,8 @@ def _checkPortCompatibility( """ Hard typed-port check: incompatible connections become validation errors. """ - from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES - from modules.features.graphicalEditor.portTypes import resolve_output_schema_name + from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES + from modules.workflowAutomation.editor.portTypes import resolve_output_schema_name nodeDefMap = {n["id"]: n for n in STATIC_NODE_TYPES} nodeById = {n["id"]: n for n in nodes if n.get("id")} @@ -443,14 +443,14 @@ def resolveParameterReferences( if consumer_node_id and input_sources: wired = (input_sources.get(consumer_node_id) or {}).get(0) if wired and wired[0] == node_id: - from modules.features.graphicalEditor.switchOutput import unwrap_transit_for_port + from modules.workflowAutomation.editor.switchOutput import unwrap_transit_for_port data = unwrap_transit_for_port(data, wired[1]) elif isinstance(data, dict) and data.get("_transit"): data = data.get("data", data) plist = list(path) resolved = _get_by_path(data, plist) if resolved is None: - from modules.workflows.automation2.pickNotPushMigration import ( + from modules.workflowAutomation.engine.pickNotPushMigration import ( remap_stale_presentation_ref_path, ) alt_path = remap_stale_presentation_ref_path(plist) @@ -481,7 +481,7 @@ def resolveParameterReferences( ) if value.get("type") == "system": variable = value.get("variable", "") - from modules.features.graphicalEditor.portTypes import resolveSystemVariable + from modules.workflowAutomation.editor.portTypes import resolveSystemVariable return resolveSystemVariable(variable, nodeOutputs.get("_context", {})) return { k: resolveParameterReferences( @@ -576,7 +576,7 @@ def extract_wired_document_list(inp: Any) -> Optional[Dict[str, Any]]: """ if inp is None: return None - from modules.features.graphicalEditor.portTypes import ( + from modules.workflowAutomation.editor.portTypes import ( unwrapTransit, _coerce_document_list_upload_fields, _file_record_to_document, diff --git a/modules/workflows/automation2/pickNotPushMigration.py b/modules/workflowAutomation/engine/pickNotPushMigration.py similarity index 97% rename from modules/workflows/automation2/pickNotPushMigration.py rename to modules/workflowAutomation/engine/pickNotPushMigration.py index a40e6c33..1b3d9249 100644 --- a/modules/workflows/automation2/pickNotPushMigration.py +++ b/modules/workflowAutomation/engine/pickNotPushMigration.py @@ -16,12 +16,12 @@ import copy import logging from typing import Any, Dict, List, Optional -from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES -from modules.features.graphicalEditor.portTypes import ( +from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES +from modules.workflowAutomation.editor.portTypes import ( PRIMARY_TEXT_HANDOVER_REF_PATH, resolve_output_schema_name, ) -from modules.workflows.automation2.graphUtils import buildConnectionMap, getInputSources +from modules.workflowAutomation.engine.graphUtils import buildConnectionMap, getInputSources logger = logging.getLogger(__name__) diff --git a/modules/workflows/automation2/runEnvelope.py b/modules/workflowAutomation/engine/runEnvelope.py similarity index 100% rename from modules/workflows/automation2/runEnvelope.py rename to modules/workflowAutomation/engine/runEnvelope.py diff --git a/modules/workflows/automation2/graphicalEditorRunFileLogger.py b/modules/workflowAutomation/engine/runFileLogger.py similarity index 97% rename from modules/workflows/automation2/graphicalEditorRunFileLogger.py rename to modules/workflowAutomation/engine/runFileLogger.py index ac28ddb1..07600317 100644 --- a/modules/workflows/automation2/graphicalEditorRunFileLogger.py +++ b/modules/workflowAutomation/engine/runFileLogger.py @@ -53,7 +53,7 @@ def merge_persisted_run_context( return {**prev, **(replacement or {})} -class GraphicalEditorRunFileLogger: +class RunFileLogger: """Append-only NDJSON log for one run folder under ``resolve_app_log_dir()``.""" __slots__ = ("_exec_path", "_ctx_path", "_lock", "_run_id") @@ -80,7 +80,7 @@ class GraphicalEditorRunFileLogger: return "/".join((RUN_FILE_LOG_RELATIVE_ROOT, subdir_name)) @classmethod - def bootstrap_new_run(cls, automation2_interface: Any, run_id: str, run_context: Dict[str, Any]) -> GraphicalEditorRunFileLogger | None: + def bootstrap_new_run(cls, automation2_interface: Any, run_id: str, run_context: Dict[str, Any]) -> RunFileLogger | None: """Create filesystem folder + persist CONTEXT_KEY via ``updateRun``.""" if not graphical_editor_run_file_logging_enabled(): return None @@ -107,7 +107,7 @@ class GraphicalEditorRunFileLogger: return cls(run_id, absolute) @classmethod - def open_from_run_record(cls, automation2_interface: Any, run_id: str) -> GraphicalEditorRunFileLogger | None: + def open_from_run_record(cls, automation2_interface: Any, run_id: str) -> RunFileLogger | None: """Open logger for an existing run using CONTEXT_KEY from DB.""" if not graphical_editor_run_file_logging_enabled(): return None @@ -154,7 +154,7 @@ class GraphicalEditorRunFileLogger: return cand if os.path.isdir(cand) else None @classmethod - def ensure_attached(cls, automation2_interface: Any, run_id: str) -> GraphicalEditorRunFileLogger | None: + def ensure_attached(cls, automation2_interface: Any, run_id: str) -> RunFileLogger | None: """Open logger from DB, or reattach an on-disk folder for *run_id*, or create a new one.""" opened = cls.open_from_run_record(automation2_interface, run_id) if opened is not None: diff --git a/modules/workflows/automation2/scheduleCron.py b/modules/workflowAutomation/engine/scheduleCron.py similarity index 100% rename from modules/workflows/automation2/scheduleCron.py rename to modules/workflowAutomation/engine/scheduleCron.py diff --git a/modules/workflows/automation2/udmUpstreamShapes.py b/modules/workflowAutomation/engine/udmUpstreamShapes.py similarity index 100% rename from modules/workflows/automation2/udmUpstreamShapes.py rename to modules/workflowAutomation/engine/udmUpstreamShapes.py diff --git a/modules/workflows/automation2/workflowArtifactVisibility.py b/modules/workflowAutomation/engine/workflowArtifactVisibility.py similarity index 100% rename from modules/workflows/automation2/workflowArtifactVisibility.py rename to modules/workflowAutomation/engine/workflowArtifactVisibility.py diff --git a/modules/features/graphicalEditor/mainGraphicalEditor.py b/modules/workflowAutomation/mainWorkflowAutomation.py similarity index 72% rename from modules/features/graphicalEditor/mainGraphicalEditor.py rename to modules/workflowAutomation/mainWorkflowAutomation.py index f88ccfdc..754d77b5 100644 --- a/modules/features/graphicalEditor/mainGraphicalEditor.py +++ b/modules/workflowAutomation/mainWorkflowAutomation.py @@ -1,8 +1,10 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. """ -GraphicalEditor Feature - n8n-style flow automation. -Minimal bootstrap for feature instance creation. Build from here. +WorkflowAutomation System Component — n8n-style flow automation. + +System-level orchestration infrastructure (not a feature). +Provides lifecycle hooks, service hub, and system templates. """ import json @@ -14,7 +16,7 @@ from modules.shared.i18nRegistry import t logger = logging.getLogger(__name__) -FEATURE_CODE = "graphicalEditor" +COMPONENT_CODE = "workflowAutomation" REQUIRED_SERVICES = [ {"serviceKey": "chat", "meta": {"usage": "Interfaces, RBAC"}}, @@ -25,41 +27,21 @@ REQUIRED_SERVICES = [ {"serviceKey": "clickup", "meta": {"usage": "ClickUp actions"}}, {"serviceKey": "generation", "meta": {"usage": "file.create document rendering"}}, ] -FEATURE_LABEL = t("Grafischer Editor", context="UI") - -RESOURCE_OBJECTS = [ - { - "objectKey": "resource.feature.graphicalEditor.dashboard", - "label": t("Dashboard aufrufen", context="UI"), - "meta": {"endpoint": "/api/workflows/{instanceId}/info", "method": "GET"} - }, - { - "objectKey": "resource.feature.graphicalEditor.node-types", - "label": t("Node-Typen abrufen", context="UI"), - "meta": {"endpoint": "/api/workflows/{instanceId}/node-types", "method": "GET"} - }, - { - "objectKey": "resource.feature.graphicalEditor.execute", - "label": t("Workflow ausführen", context="UI"), - "meta": {"endpoint": "/api/workflows/{instanceId}/execute", "method": "POST"} - }, -] - -def getRequiredServiceKeys() -> List[str]: - """Return list of service keys this feature requires.""" +def _getRequiredServiceKeys() -> List[str]: + """Return list of service keys this component requires.""" return [s["serviceKey"] for s in REQUIRED_SERVICES] -def getGraphicalEditorServices( +def _getWorkflowAutomationServices( user, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None, workflow=None, -) -> "_GraphicalEditorServiceHub": +) -> "_WorkflowAutomationServiceHub": """ - Get a service hub for graphicalEditor using the service center. + Get a service hub for WorkflowAutomation using the service center. Used for methodDiscovery (I/O nodes) and execution (ActionExecutor). """ from modules.serviceCenter import getService @@ -70,7 +52,7 @@ def getGraphicalEditorServices( _workflow = type( "_Placeholder", (), - {"featureCode": FEATURE_CODE, "id": f"transient-{uuid.uuid4().hex[:12]}", "workflowMode": None, "messages": []}, + {"featureCode": COMPONENT_CODE, "id": f"transient-{uuid.uuid4().hex[:12]}", "workflowMode": None, "messages": []}, )() ctx = ServiceCenterContext( @@ -80,13 +62,13 @@ def getGraphicalEditorServices( workflow=_workflow, ) - hub = _GraphicalEditorServiceHub() + hub = _WorkflowAutomationServiceHub() hub.user = user hub.mandateId = mandateId hub.featureInstanceId = featureInstanceId hub._service_context = ctx hub.workflow = _workflow - hub.featureCode = FEATURE_CODE + hub.featureCode = COMPONENT_CODE for spec in REQUIRED_SERVICES: key = spec["serviceKey"] @@ -94,7 +76,7 @@ def getGraphicalEditorServices( svc = getService(key, ctx) setattr(hub, key, svc) except Exception as e: - logger.warning(f"Could not resolve service '{key}' for graphicalEditor: {e}") + logger.warning(f"Could not resolve service '{key}' for workflowAutomation: {e}") setattr(hub, key, None) if hub.chat: @@ -106,19 +88,17 @@ def getGraphicalEditorServices( return hub -# Backward-compatible alias used by workflows/automation2/ execution engine -getAutomation2Services = getGraphicalEditorServices -class _GraphicalEditorServiceHub: - """Lightweight hub for graphicalEditor (methodDiscovery, execution).""" +class _WorkflowAutomationServiceHub: + """Lightweight hub for WorkflowAutomation (methodDiscovery, execution).""" user = None mandateId = None featureInstanceId = None _service_context = None workflow = None - featureCode = FEATURE_CODE + featureCode = COMPONENT_CODE interfaceDbApp = None interfaceDbComponent = None interfaceDbChat = None @@ -132,14 +112,12 @@ class _GraphicalEditorServiceHub: generation = None - - # --------------------------------------------------------------------------- -# Feature Lifecycle Hooks (called dynamically by core via loadFeatureMainModules) +# Lifecycle Hooks # --------------------------------------------------------------------------- def onMandateDelete(mandateId: str, instances: list) -> None: - """Cascade-delete all AutoWorkflow data in the Greenfield DB for this mandate.""" + """Cascade-delete all AutoWorkflow data for this mandate.""" from modules.datamodels.datamodelWorkflowAutomation import ( GRAPHICAL_EDITOR_DATABASE, AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask, ) @@ -147,7 +125,7 @@ def onMandateDelete(mandateId: str, instances: list) -> None: from modules.shared.configuration import APP_CONFIG try: - geDb = DatabaseConnector( + waDb = DatabaseConnector( dbHost=APP_CONFIG.get("DB_HOST", "localhost"), dbDatabase=GRAPHICAL_EDITOR_DATABASE, dbUser=APP_CONFIG.get("DB_USER"), @@ -156,69 +134,116 @@ def onMandateDelete(mandateId: str, instances: list) -> None: userId=None, ) - if not geDb._ensureTableExists(AutoWorkflow): + if not waDb._ensureTableExists(AutoWorkflow): return - geInstances = [ - inst for inst in instances - if (inst.get("featureCode") if isinstance(inst, dict) else getattr(inst, "featureCode", "")) == "graphicalEditor" - ] + workflows = waDb.getRecordset(AutoWorkflow, recordFilter={ + "mandateId": mandateId, + }) or [] totalDeleted = 0 - for inst in geInstances: - instId = inst.get("id") if isinstance(inst, dict) else getattr(inst, "id", None) - if not instId: + for wf in workflows: + wfId = wf.get("id") + if not wfId: continue - workflows = geDb.getRecordset(AutoWorkflow, recordFilter={ - "mandateId": mandateId, - "featureInstanceId": instId, - }) or [] + for v in waDb.getRecordset(AutoVersion, recordFilter={"workflowId": wfId}) or []: + waDb.recordDelete(AutoVersion, v.get("id")) - for wf in workflows: - wfId = wf.get("id") - if not wfId: - continue + for run in waDb.getRecordset(AutoRun, recordFilter={"workflowId": wfId}) or []: + runId = run.get("id") + for sl in waDb.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []: + waDb.recordDelete(AutoStepLog, sl.get("id")) + waDb.recordDelete(AutoRun, runId) - for v in geDb.getRecordset(AutoVersion, recordFilter={"workflowId": wfId}) or []: - geDb.recordDelete(AutoVersion, v.get("id")) + for task in waDb.getRecordset(AutoTask, recordFilter={"workflowId": wfId}) or []: + waDb.recordDelete(AutoTask, task.get("id")) - for run in geDb.getRecordset(AutoRun, recordFilter={"workflowId": wfId}) or []: - runId = run.get("id") - for sl in geDb.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []: - geDb.recordDelete(AutoStepLog, sl.get("id")) - geDb.recordDelete(AutoRun, runId) - - for task in geDb.getRecordset(AutoTask, recordFilter={"workflowId": wfId}) or []: - geDb.recordDelete(AutoTask, task.get("id")) - - geDb.recordDelete(AutoWorkflow, wfId) - totalDeleted += 1 + waDb.recordDelete(AutoWorkflow, wfId) + totalDeleted += 1 if totalDeleted: - logger.info(f"Cascade: deleted {totalDeleted} AutoWorkflow(s) in Greenfield DB for mandate {mandateId}") - geDb.close() + logger.info(f"Cascade: deleted {totalDeleted} AutoWorkflow(s) for mandate {mandateId}") + waDb.close() except Exception as e: - logger.warning(f"Failed to cascade-delete graphical editor data for mandate {mandateId}: {e}") + logger.warning(f"Failed to cascade-delete workflow automation data for mandate {mandateId}: {e}") + + +def _migrateRbacNamespace() -> None: + """Migrate legacy AccessRule objectKeys to the canonical workflowAutomation namespace. + + Idempotent: silently returns when no old-prefix records remain. + Must NOT crash the boot process — all exceptions are caught and logged. + """ + import psycopg2 + from modules.shared.configuration import APP_CONFIG + + _REPLACEMENTS = [ + ("resource.feature.graphicalEditor.", "resource.system.workflowAutomation."), + ("ui.feature.graphicalEditor.", "ui.system.workflowAutomation."), + ("resource.store.graphicalEditor", "resource.store.workflowAutomation"), + ] + + try: + conn = psycopg2.connect( + host=APP_CONFIG.get("DB_HOST", "localhost"), + port=int(APP_CONFIG.get("DB_PORT", "5432")), + user=APP_CONFIG.get("DB_USER"), + password=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), + dbname="poweron_app", + ) + conn.autocommit = False + cur = conn.cursor() + + totalUpdated = 0 + for oldPrefix, newPrefix in _REPLACEMENTS: + cur.execute( + 'SELECT id, "objectKey" FROM "AccessRule" WHERE "objectKey" LIKE %s', + (f"{oldPrefix}%",), + ) + rows = cur.fetchall() + if not rows: + continue + + for rowId, objectKey in rows: + newKey = objectKey.replace(oldPrefix, newPrefix, 1) + cur.execute( + 'UPDATE "AccessRule" SET "objectKey" = %s WHERE id = %s', + (newKey, rowId), + ) + totalUpdated += 1 + + conn.commit() + cur.close() + conn.close() + + if totalUpdated: + logger.info( + f"RBAC namespace migration: updated {totalUpdated} AccessRule record(s) " + f"from legacy → workflowAutomation" + ) + except Exception as e: + logger.warning(f"RBAC namespace migration failed (non-critical): {e}") def onBootstrap() -> None: """Seed system workflow templates and sync feature template workflows on boot.""" + _migrateRbacNamespace() + from modules.datamodels.datamodelWorkflowAutomation import GRAPHICAL_EDITOR_DATABASE, AutoWorkflow from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.shared.configuration import APP_CONFIG try: - greenfieldDb = DatabaseConnector( + waDb = DatabaseConnector( dbHost=APP_CONFIG.get("DB_HOST", "localhost"), dbDatabase=GRAPHICAL_EDITOR_DATABASE, dbUser=APP_CONFIG.get("DB_USER"), dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), ) - greenfieldDb._ensureTableExists(AutoWorkflow) + waDb._ensureTableExists(AutoWorkflow) - # --- Seed system templates --- - existing = greenfieldDb.getRecordset(AutoWorkflow, recordFilter={ + existing = waDb.getRecordset(AutoWorkflow, recordFilter={ "isTemplate": True, "templateScope": "system", }) @@ -230,13 +255,12 @@ def onBootstrap() -> None: if tpl["label"] in existingLabels: continue tpl["id"] = str(uuid.uuid4()) - greenfieldDb.recordCreate(AutoWorkflow, tpl) + waDb.recordCreate(AutoWorkflow, tpl) created += 1 if created: logger.info(f"Bootstrapped {created} system workflow template(s)") - # --- Sync feature template workflows --- from modules.system.registry import loadFeatureMainModules mainModules = loadFeatureMainModules() @@ -257,7 +281,7 @@ def onBootstrap() -> None: if templatesBySourceId: updated = 0 for sourceId, tpl in templatesBySourceId.items(): - instances = greenfieldDb.getRecordset(AutoWorkflow, recordFilter={ + instances = waDb.getRecordset(AutoWorkflow, recordFilter={ "templateSourceId": sourceId, "isTemplate": False, }) @@ -285,25 +309,25 @@ def onBootstrap() -> None: if existingGraph == newGraph: continue - greenfieldDb.recordModify(AutoWorkflow, instId, {"graph": newGraph}) + waDb.recordModify(AutoWorkflow, instId, {"graph": newGraph}) updated += 1 if updated: logger.info(f"Synced {updated} workflow(s) with current feature templates") - greenfieldDb.close() + waDb.close() except Exception as e: - logger.warning(f"GraphicalEditor bootstrap failed: {e}") + logger.warning(f"WorkflowAutomation bootstrap failed: {e}") def onInstanceCreate(mandateId: str, instanceId: str, featureCode: str, templateWorkflows: list) -> int: """Create workflow instances from template definitions when a feature instance is created.""" - from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface + from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface from modules.security.rootAccess import getRootUser from modules.shared.i18nRegistry import resolveText rootUser = getRootUser() - geInterface = getGraphicalEditorInterface(rootUser, mandateId, instanceId) + waInterface = _getWorkflowAutomationInterface(rootUser, mandateId, instanceId) copied = 0 for template in templateWorkflows: @@ -315,7 +339,7 @@ def onInstanceCreate(mandateId: str, instanceId: str, featureCode: str, template label = resolveText(template.get("label")) - geInterface.createWorkflow({ + waInterface.createWorkflow({ "label": label, "graph": graph, "tags": template.get("tags", [f"feature:{featureCode}"]), @@ -395,8 +419,3 @@ def _buildSystemTemplates(): "invocations": [{"type": "schedule", "cronExpression": "0 7 * * 1-5"}], }, ] - - -def getResourceObjects() -> List[Dict[str, Any]]: - """Return resource objects for RBAC catalog registration.""" - return RESOURCE_OBJECTS diff --git a/modules/workflowAutomation/scheduler/__init__.py b/modules/workflowAutomation/scheduler/__init__.py new file mode 100644 index 00000000..d5178091 --- /dev/null +++ b/modules/workflowAutomation/scheduler/__init__.py @@ -0,0 +1,11 @@ +# Copyright (c) 2025 Patrick Motsch +# Workflow Scheduler — consolidated scheduler with v1 incremental sync patterns. +from modules.workflowAutomation.scheduler.mainScheduler import ( + WorkflowScheduler, + start, + stop, + syncNow, + setMainLoop, + notifyRunFailed, + setOnRunFailedCallback, +) diff --git a/modules/features/graphicalEditor/emailPoller.py b/modules/workflowAutomation/scheduler/emailPoller.py similarity index 94% rename from modules/features/graphicalEditor/emailPoller.py rename to modules/workflowAutomation/scheduler/emailPoller.py index 7c769463..944135bc 100644 --- a/modules/features/graphicalEditor/emailPoller.py +++ b/modules/workflowAutomation/scheduler/emailPoller.py @@ -25,9 +25,9 @@ async def _pollEmailWaits(eventUser) -> None: Stops the poller when no runs are waiting. """ try: - from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface as getAutomation2Interface - from modules.features.graphicalEditor.mainGraphicalEditor import getGraphicalEditorServices as getAutomation2Services - from modules.workflows.automation2.executionEngine import executeGraph + from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface + from modules.workflowAutomation.mainWorkflowAutomation import _getWorkflowAutomationServices + from modules.workflowAutomation.engine.executionEngine import executeGraph from modules.workflows.processing.shared.methodDiscovery import discoverMethods from modules.interfaces.interfaceDbApp import getRootInterface @@ -36,7 +36,7 @@ async def _pollEmailWaits(eventUser) -> None: logger.warning("Email poller: root interface not available") return # Use eventUser - getRunsWaitingForEmail queries by status only - a2 = getAutomation2Interface(eventUser, mandateId="", featureInstanceId="") + a2 = _getWorkflowAutomationInterface(eventUser, mandateId="", featureInstanceId="") runs = a2.getRunsWaitingForEmail() if not runs: # No workflows waiting for email - stop the poller @@ -77,7 +77,7 @@ async def _pollEmailWaits(eventUser) -> None: continue # Get workflow (need scoped interface for mandate/instance) - a2_scoped = getAutomation2Interface(eventUser, mandateId=mandate_id, featureInstanceId=instance_id) + a2_scoped = _getWorkflowAutomationInterface(eventUser, mandateId=mandate_id, featureInstanceId=instance_id) wf = a2_scoped.getWorkflow(workflow_id) if not wf or not wf.get("graph"): logger.warning("Email wait run %s: workflow %s not found or has no graph", run_id, workflow_id) @@ -90,7 +90,7 @@ async def _pollEmailWaits(eventUser) -> None: logger.warning("Email wait run %s: paused at email.searchEmail (should not wait) – skipping", run_id) continue - services = getAutomation2Services(owner, mandateId=mandate_id, featureInstanceId=instance_id) + services = _getWorkflowAutomationServices(owner, mandateId=mandate_id, featureInstanceId=instance_id) discoverMethods(services) # Build filter with receivedDateTime – only emails received at or after baseline (new emails) diff --git a/modules/workflows/scheduler/mainScheduler.py b/modules/workflowAutomation/scheduler/mainScheduler.py similarity index 91% rename from modules/workflows/scheduler/mainScheduler.py rename to modules/workflowAutomation/scheduler/mainScheduler.py index 11544015..2f45932e 100644 --- a/modules/workflows/scheduler/mainScheduler.py +++ b/modules/workflowAutomation/scheduler/mainScheduler.py @@ -22,8 +22,8 @@ logger = logging.getLogger(__name__) _main_loop = None -JOB_ID_PREFIX = "graphicalEditor." -_CALLBACK_NAME = "graphicalEditor.workflow.changed" +JOB_ID_PREFIX = "workflowAutomation." +_CALLBACK_NAME = "workflowAutomation.workflow.changed" def _setMainLoop(loop) -> None: @@ -76,8 +76,8 @@ class WorkflowScheduler: Incremental sync: only re-register jobs whose eventId has changed. Uses AutoWorkflow.eventId for change detection (v1 pattern). """ - from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getAllWorkflowsForScheduling - from modules.workflows.automation2.scheduleCron import parse_cron_to_kwargs + from modules.interfaces.interfaceWorkflowAutomation import getAllWorkflowsForScheduling + from modules.workflowAutomation.engine.scheduleCron import parse_cron_to_kwargs items = getAllWorkflowsForScheduling() logger.info("WorkflowScheduler: found %d workflow(s) with trigger.schedule+cron", len(items)) @@ -174,7 +174,7 @@ class WorkflowScheduler: currentEventId = workflow.get("eventId") if currentEventId != jobId: try: - from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface + from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface from modules.interfaces.interfaceDbApp import getRootInterface root = getRootInterface() eventUser = root.getUserByUsername("event") if root else self._eventUser @@ -182,7 +182,7 @@ class WorkflowScheduler: return mandateId = workflow.get("mandateId", "") instanceId = workflow.get("featureInstanceId", "") - iface = getGraphicalEditorInterface(eventUser, mandateId, instanceId) + iface = _getWorkflowAutomationInterface(eventUser, mandateId, instanceId) iface.updateWorkflow(workflowId, {"eventId": jobId}) except Exception as e: logger.debug("WorkflowScheduler: could not update eventId for %s: %s", workflowId, e) @@ -205,14 +205,14 @@ class WorkflowScheduler: logger.error("WorkflowScheduler: event user not available") return - from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface - from modules.features.graphicalEditor.mainGraphicalEditor import getGraphicalEditorServices - from modules.workflows.automation2.executionEngine import executeGraph + from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface + from modules.workflowAutomation.mainWorkflowAutomation import _getWorkflowAutomationServices + from modules.workflowAutomation.engine.executionEngine import executeGraph from modules.workflows.processing.shared.methodDiscovery import discoverMethods - from modules.features.graphicalEditor.entryPoints import find_invocation - from modules.workflows.automation2.runEnvelope import default_run_envelope, normalize_run_envelope + from modules.workflowAutomation.editor.entryPoints import find_invocation + from modules.workflowAutomation.engine.runEnvelope import default_run_envelope, normalize_run_envelope - iface = getGraphicalEditorInterface(eventUser, mandateId, instanceId) + iface = _getWorkflowAutomationInterface(eventUser, mandateId, instanceId) wf = iface.getWorkflow(workflowId) if not wf or not wf.get("graph"): logger.warning("WorkflowScheduler: workflow %s not found or no graph", workflowId) @@ -226,7 +226,7 @@ class WorkflowScheduler: logger.info("WorkflowScheduler: entry point %s disabled for workflow %s", entryPointId, workflowId) return - services = getGraphicalEditorServices( + services = _getWorkflowAutomationServices( eventUser, mandateId=mandateId, featureInstanceId=instanceId, @@ -336,7 +336,7 @@ def _cronToIntervalSeconds(cron: str): def notifyRunFailed(workflowId: str, runId: str, error: str, mandateId: str = None, workflowLabel: str = None) -> None: """Notify on workflow run failure: emit event, create in-app notification, trigger email subscription.""" try: - eventManager.emit("graphicalEditor.run.failed", { + eventManager.emit("workflowAutomation.run.failed", { "workflowId": workflowId, "runId": runId, "error": error, @@ -362,12 +362,12 @@ def _createRunFailedNotification( if not rootInterface: return - from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface + from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface eventUser = rootInterface.getUserByUsername("event") if not eventUser: return - iface = getGraphicalEditorInterface(eventUser, mandateId or "", "") + iface = _getWorkflowAutomationInterface(eventUser, mandateId or "", "") wf = iface.getWorkflow(workflowId) if not wf: return diff --git a/modules/workflows/automation2/__init__.py b/modules/workflows/automation2/__init__.py index 0656ab39..28ce2eea 100644 --- a/modules/workflows/automation2/__init__.py +++ b/modules/workflows/automation2/__init__.py @@ -1,2 +1,13 @@ # Copyright (c) 2025 Patrick Motsch -# automation2 - n8n-style graph execution engine. +# Re-export shim: modules moved to modules.workflowAutomation.engine +# This file preserves backwards compatibility for existing imports. + +from modules.workflowAutomation.engine.executionEngine import * # noqa: F401,F403 +from modules.workflowAutomation.engine.graphUtils import * # noqa: F401,F403 +from modules.workflowAutomation.engine.runEnvelope import * # noqa: F401,F403 +from modules.workflowAutomation.engine.scheduleCron import * # noqa: F401,F403 +from modules.workflowAutomation.engine.runFileLogger import * # noqa: F401,F403 +from modules.workflowAutomation.engine.pickNotPushMigration import * # noqa: F401,F403 +from modules.workflowAutomation.engine.featureInstanceRefMigration import * # noqa: F401,F403 +from modules.workflowAutomation.engine.workflowArtifactVisibility import * # noqa: F401,F403 +from modules.workflowAutomation.engine.clickupTaskUpdateMerge import * # noqa: F401,F403 diff --git a/modules/workflows/automation2/executors/__init__.py b/modules/workflows/automation2/executors/__init__.py index 4d2180c3..1c2b18d4 100644 --- a/modules/workflows/automation2/executors/__init__.py +++ b/modules/workflows/automation2/executors/__init__.py @@ -1,11 +1,16 @@ # Copyright (c) 2025 Patrick Motsch -# Executors for automation2 node types. +# Re-export shim: executors moved to modules.workflowAutomation.engine.executors +# This file preserves backwards compatibility for existing imports. -from .triggerExecutor import TriggerExecutor -from .flowExecutor import FlowExecutor -from .actionNodeExecutor import ActionNodeExecutor -from .inputExecutor import InputExecutor, PauseForHumanTaskError, PauseForEmailWaitError -from .dataExecutor import DataExecutor +from modules.workflowAutomation.engine.executors import ( # noqa: F401 + TriggerExecutor, + FlowExecutor, + ActionNodeExecutor, + InputExecutor, + DataExecutor, + PauseForHumanTaskError, + PauseForEmailWaitError, +) __all__ = [ "TriggerExecutor", diff --git a/modules/workflows/methods/_actionSignatureValidator.py b/modules/workflows/methods/_actionSignatureValidator.py index 25be8175..aeeb49c1 100644 --- a/modules/workflows/methods/_actionSignatureValidator.py +++ b/modules/workflows/methods/_actionSignatureValidator.py @@ -25,7 +25,7 @@ from modules.datamodels.datamodelWorkflowActions import ( WorkflowActionDefinition, WorkflowActionParameter, ) -from modules.features.graphicalEditor.portTypes import ( +from modules.workflowAutomation.editor.portTypes import ( PORT_TYPE_CATALOG, PRIMITIVE_TYPES, _stripContainer, diff --git a/modules/workflows/methods/methodBase.py b/modules/workflows/methods/methodBase.py index 57670f61..5ab10077 100644 --- a/modules/workflows/methods/methodBase.py +++ b/modules/workflows/methods/methodBase.py @@ -240,7 +240,7 @@ class MethodBase: runtime structural validation is handled by the workflow engine / port-schema layer, not at the action-call boundary. """ - from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG + from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG if expectedType in PORT_TYPE_CATALOG: return value diff --git a/modules/workflows/methods/methodContext/actions/setContext.py b/modules/workflows/methods/methodContext/actions/setContext.py index 24e10fc8..62435e38 100644 --- a/modules/workflows/methods/methodContext/actions/setContext.py +++ b/modules/workflows/methods/methodContext/actions/setContext.py @@ -320,7 +320,7 @@ def _pause_for_human_tasks( ) task_id = str((task or {}).get("id") or "") ordered_ids = [n.get("id") for n in (run_context.get("_orderedNodes") or []) if n.get("id")] - from modules.workflows.automation2.graphicalEditorRunFileLogger import merge_persisted_run_context + from modules.workflowAutomation.engine.runFileLogger import merge_persisted_run_context _pause_ctx = merge_persisted_run_context( iface, diff --git a/modules/workflows/processing/shared/parameterValidation.py b/modules/workflows/processing/shared/parameterValidation.py index f86b605f..ea182212 100644 --- a/modules/workflows/processing/shared/parameterValidation.py +++ b/modules/workflows/processing/shared/parameterValidation.py @@ -64,7 +64,7 @@ def _isRefSchema(typeStr: str) -> bool: """ if not typeStr or not typeStr.endswith("Ref"): return False - from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG + from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG schema = PORT_TYPE_CATALOG.get(typeStr) if schema is None: return False diff --git a/modules/workflows/scheduler/__init__.py b/modules/workflows/scheduler/__init__.py index e2b0f5de..4e814ab5 100644 --- a/modules/workflows/scheduler/__init__.py +++ b/modules/workflows/scheduler/__init__.py @@ -1,2 +1,11 @@ # Copyright (c) 2025 Patrick Motsch -# Workflow Scheduler - consolidated scheduler with v1 incremental sync patterns +# Re-export shim — real implementation moved to modules.workflowAutomation.scheduler +from modules.workflowAutomation.scheduler.mainScheduler import ( + WorkflowScheduler, + start, + stop, + syncNow, + setMainLoop, + notifyRunFailed, + setOnRunFailedCallback, +) diff --git a/tests/demo/test_demo_bootstrap.py b/tests/demo/test_demo_bootstrap.py index 3ac6073e..45db18c7 100644 --- a/tests/demo/test_demo_bootstrap.py +++ b/tests/demo/test_demo_bootstrap.py @@ -48,13 +48,13 @@ class TestDemoBootstrap: memberships = db.getRecordset(UserMandate, recordFilter={"userId": userId, "mandateId": mid}) assert len(memberships) >= 1, f"User not member of mandate {mandate.get('label')}" - @pytest.mark.parametrize("featureCode", ["workspace", "trustee", "graphicalEditor", "neutralization"]) + @pytest.mark.parametrize("featureCode", ["workspace", "trustee", "neutralization"]) def test_happylifeFeaturesExist(self, db, mandateHappylife, featureCode): mid = mandateHappylife.get("id") instances = _getFeatureInstances(db, mid, featureCode) assert len(instances) >= 1, f"Feature '{featureCode}' missing in HappyLife AG" - @pytest.mark.parametrize("featureCode", ["workspace", "trustee", "graphicalEditor", "neutralization"]) + @pytest.mark.parametrize("featureCode", ["workspace", "trustee", "neutralization"]) def test_alpinaFeaturesExist(self, db, mandateAlpina, featureCode): mid = mandateAlpina.get("id") instances = _getFeatureInstances(db, mid, featureCode) diff --git a/tests/demo/test_demo_uc1_trustee.py b/tests/demo/test_demo_uc1_trustee.py index 54d2ac70..f7fd2ce0 100644 --- a/tests/demo/test_demo_uc1_trustee.py +++ b/tests/demo/test_demo_uc1_trustee.py @@ -50,7 +50,7 @@ class TestSystemWorkflowTemplates: def test_systemTemplatesExist(self, db): """System workflow templates should exist (created by system bootstrap, not demo config).""" - from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow + from modules.datamodels.datamodelWorkflowAutomation import AutoWorkflow try: templates = db.getRecordset(AutoWorkflow, recordFilter={"isTemplate": True, "templateScope": "system"}) except Exception: diff --git a/tests/demo/test_pwg_demo_bootstrap.py b/tests/demo/test_pwg_demo_bootstrap.py index 94c890e4..7bc38345 100644 --- a/tests/demo/test_pwg_demo_bootstrap.py +++ b/tests/demo/test_pwg_demo_bootstrap.py @@ -105,7 +105,7 @@ class TestPwgDemoBootstrap: @pytest.mark.parametrize( "featureCode", - ["workspace", "trustee", "graphicalEditor", "neutralization"], + ["workspace", "trustee", "neutralization"], ) def test_pwgFeaturesExist(self, db, mandatePwg, featureCode): instances = _getFeatureInstances(db, mandatePwg.get("id"), featureCode) @@ -116,8 +116,8 @@ class TestPwgDemoBootstrap: "mandateId": mandatePwg.get("id"), }) or [] codes = sorted({i.get("featureCode") for i in instances}) - assert codes == ["graphicalEditor", "neutralization", "trustee", "workspace"], ( - f"Expected exactly 4 feature instances, got {codes}" + assert codes == ["neutralization", "trustee", "workspace"], ( + f"Expected exactly 3 feature instances, got {codes}" ) @@ -183,20 +183,15 @@ class TestPwgTrusteeSeed: class TestPwgPilotWorkflow: def test_pilotWorkflowImported(self, db, mandatePwg): - from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow - from modules.demoConfigs.pwgDemo2026 import _openGraphicalEditorDb - instances = _getFeatureInstances(db, mandatePwg.get("id"), "graphicalEditor") - assert instances, "No graphicalEditor instance for PWG" - instId = instances[0].get("id") - geDb = _openGraphicalEditorDb() + from modules.datamodels.datamodelWorkflowAutomation import AutoWorkflow + from modules.demoConfigs.pwgDemo2026 import _openWorkflowAutomationDb + geDb = _openWorkflowAutomationDb() wfs = geDb.getRecordset(AutoWorkflow, recordFilter={ "mandateId": mandatePwg.get("id"), - "featureInstanceId": instId, "label": "PWG Pilot: Jahresmietzinsbestätigung", }) or [] assert len(wfs) == 1, f"Expected exactly 1 PWG pilot workflow, got {len(wfs)}" wf = wfs[0] - # AC 10: imports must be inactive by default assert wf.get("active") is False, "PWG pilot workflow must be imported with active=false" graph = wf.get("graph") or {} assert (graph.get("nodes") or []), "PWG pilot workflow has no nodes" diff --git a/tests/integration/automation2/test_pick_not_push_migration_v2.py b/tests/integration/automation2/test_pick_not_push_migration_v2.py index 9b98e0ec..fb109337 100644 --- a/tests/integration/automation2/test_pick_not_push_migration_v2.py +++ b/tests/integration/automation2/test_pick_not_push_migration_v2.py @@ -25,11 +25,11 @@ from typing import Any, Dict import pytest -from modules.workflows.automation2.featureInstanceRefMigration import ( +from modules.workflowAutomation.engine.featureInstanceRefMigration import ( materializeFeatureInstanceRefs, ) -from modules.workflows.automation2.graphUtils import resolveParameterReferences -from modules.workflows.automation2.pickNotPushMigration import materializeConnectionRefs +from modules.workflowAutomation.engine.graphUtils import resolveParameterReferences +from modules.workflowAutomation.engine.pickNotPushMigration import materializeConnectionRefs _TRUSTEE_INSTANCE_UUID = "f1e2d3c4-b5a6-7890-1234-567890abcdef" diff --git a/tests/integration/trustee/test_spesenbelege_workflow_e2e.py b/tests/integration/trustee/test_spesenbelege_workflow_e2e.py index fcda01e4..b7b952b8 100644 --- a/tests/integration/trustee/test_spesenbelege_workflow_e2e.py +++ b/tests/integration/trustee/test_spesenbelege_workflow_e2e.py @@ -43,8 +43,8 @@ from typing import Any, Dict, List, Optional import pytest -from modules.workflows.automation2.executionEngine import executeGraph -from modules.workflows.automation2.runEnvelope import default_run_envelope +from modules.workflowAutomation.engine.executionEngine import executeGraph +from modules.workflowAutomation.engine.runEnvelope import default_run_envelope _TRUSTEE_INSTANCE_UUID = "11111111-2222-3333-4444-555555555555" diff --git a/tests/integration/workflows/test_execute_graph_loop_aggregate_consolidate.py b/tests/integration/workflows/test_execute_graph_loop_aggregate_consolidate.py index 751de6d4..3fc75f54 100644 --- a/tests/integration/workflows/test_execute_graph_loop_aggregate_consolidate.py +++ b/tests/integration/workflows/test_execute_graph_loop_aggregate_consolidate.py @@ -4,10 +4,10 @@ import pytest from unittest.mock import MagicMock -from modules.workflows.automation2.executionEngine import executeGraph -from modules.workflows.automation2.graphUtils import buildConnectionMap, getInputSources -from modules.workflows.automation2.executors.dataExecutor import DataExecutor -from modules.workflows.automation2.runEnvelope import default_run_envelope +from modules.workflowAutomation.engine.executionEngine import executeGraph +from modules.workflowAutomation.engine.graphUtils import buildConnectionMap, getInputSources +from modules.workflowAutomation.engine.executors.dataExecutor import DataExecutor +from modules.workflowAutomation.engine.runEnvelope import default_run_envelope def _minimal_services(): diff --git a/tests/unit/graphicalEditor/test_action_node_connection_provenance.py b/tests/unit/graphicalEditor/test_action_node_connection_provenance.py index b04dd594..610d35c9 100644 --- a/tests/unit/graphicalEditor/test_action_node_connection_provenance.py +++ b/tests/unit/graphicalEditor/test_action_node_connection_provenance.py @@ -1,5 +1,5 @@ # Copyright (c) 2025 Patrick Motsch -from modules.workflows.automation2.executors.actionNodeExecutor import _buildConnectionRefDict +from modules.workflowAutomation.engine.executors.actionNodeExecutor import _buildConnectionRefDict def test_build_connection_ref_dict_from_logical_string(): diff --git a/tests/unit/graphicalEditor/test_adapter_validator.py b/tests/unit/graphicalEditor/test_adapter_validator.py index 5ee5abef..605251c6 100644 --- a/tests/unit/graphicalEditor/test_adapter_validator.py +++ b/tests/unit/graphicalEditor/test_adapter_validator.py @@ -27,14 +27,14 @@ from modules.datamodels.datamodelWorkflowActions import ( WorkflowActionDefinition, WorkflowActionParameter, ) -from modules.features.graphicalEditor.adapterValidator import ( +from modules.workflowAutomation.editor.adapterValidator import ( AdapterValidationReport, _buildActionsRegistryFromMethods, _formatAdapterReport, _validateAdapterAgainstAction, _validateAllAdapters, ) -from modules.features.graphicalEditor.nodeAdapter import ( +from modules.workflowAutomation.editor.nodeAdapter import ( NodeAdapter, UserParamMapping, ) @@ -334,7 +334,7 @@ def test_staticNodesHaveNoDriftAgainstLiveMethods(): History: wiki/c-work/4-done/2026-04-adapter-drift-cleanup.md """ - from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES + from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES instances = _instantiateLiveMethods() if not instances: diff --git a/tests/unit/graphicalEditor/test_condition_operator_catalog.py b/tests/unit/graphicalEditor/test_condition_operator_catalog.py index a1954448..ce02c083 100644 --- a/tests/unit/graphicalEditor/test_condition_operator_catalog.py +++ b/tests/unit/graphicalEditor/test_condition_operator_catalog.py @@ -1,7 +1,7 @@ # Copyright (c) 2025 Patrick Motsch """Tests for backend-driven condition operator catalog.""" -from modules.features.graphicalEditor.conditionOperators import ( +from modules.workflowAutomation.editor.conditionOperators import ( CONDITION_OPERATOR_CATALOG, VALUE_KINDS, apply_condition_operator, diff --git a/tests/unit/graphicalEditor/test_featureInstanceRef_node_definitions.py b/tests/unit/graphicalEditor/test_featureInstanceRef_node_definitions.py index 279c6da4..525faa4a 100644 --- a/tests/unit/graphicalEditor/test_featureInstanceRef_node_definitions.py +++ b/tests/unit/graphicalEditor/test_featureInstanceRef_node_definitions.py @@ -24,8 +24,8 @@ from __future__ import annotations import pytest -from modules.features.graphicalEditor.nodeDefinitions.redmine import REDMINE_NODES -from modules.features.graphicalEditor.nodeDefinitions.trustee import TRUSTEE_NODES +from modules.workflowAutomation.editor.nodeDefinitions.redmine import REDMINE_NODES +from modules.workflowAutomation.editor.nodeDefinitions.trustee import TRUSTEE_NODES def _featureInstanceParam(node: dict) -> dict | None: diff --git a/tests/unit/graphicalEditor/test_node_adapter.py b/tests/unit/graphicalEditor/test_node_adapter.py index 64915a17..3c18f438 100644 --- a/tests/unit/graphicalEditor/test_node_adapter.py +++ b/tests/unit/graphicalEditor/test_node_adapter.py @@ -17,7 +17,7 @@ from __future__ import annotations import pytest -from modules.features.graphicalEditor.nodeAdapter import ( +from modules.workflowAutomation.editor.nodeAdapter import ( NodeAdapter, UserParamMapping, _adapterFromLegacyNode, diff --git a/tests/unit/graphicalEditor/test_portTypes_catalog.py b/tests/unit/graphicalEditor/test_portTypes_catalog.py index 11967376..0506be27 100644 --- a/tests/unit/graphicalEditor/test_portTypes_catalog.py +++ b/tests/unit/graphicalEditor/test_portTypes_catalog.py @@ -6,7 +6,7 @@ Catalog integrity + new Phase-1 schemas import pytest -from modules.features.graphicalEditor.portTypes import ( +from modules.workflowAutomation.editor.portTypes import ( PORT_TYPE_CATALOG, PRIMITIVE_TYPES, PortField, diff --git a/tests/unit/graphicalEditor/test_port_schema_recursive.py b/tests/unit/graphicalEditor/test_port_schema_recursive.py index b3ae22c6..7884109e 100644 --- a/tests/unit/graphicalEditor/test_port_schema_recursive.py +++ b/tests/unit/graphicalEditor/test_port_schema_recursive.py @@ -1,7 +1,7 @@ # Copyright (c) 2025 Patrick Motsch """Port type catalog: nested provenance schemas (Typed Generic Handover).""" -from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG, _defaultForType +from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG, _defaultForType def test_connection_ref_in_catalog(): diff --git a/tests/unit/graphicalEditor/test_resolve_value_kind.py b/tests/unit/graphicalEditor/test_resolve_value_kind.py index 35b53e07..497619e2 100644 --- a/tests/unit/graphicalEditor/test_resolve_value_kind.py +++ b/tests/unit/graphicalEditor/test_resolve_value_kind.py @@ -1,7 +1,7 @@ # Copyright (c) 2025 Patrick Motsch """Tests for condition valueKind resolution.""" -from modules.features.graphicalEditor.conditionOperators import resolve_value_kind +from modules.workflowAutomation.editor.conditionOperators import resolve_value_kind def _graph(nodes, connections=None, target=None): diff --git a/tests/unit/graphicalEditor/test_route_options_feature_instance.py b/tests/unit/graphicalEditor/test_route_options_feature_instance.py deleted file mode 100644 index d626c135..00000000 --- a/tests/unit/graphicalEditor/test_route_options_feature_instance.py +++ /dev/null @@ -1,66 +0,0 @@ -# Copyright (c) 2026 Patrick Motsch -# All rights reserved. -""" -Smoke test for the new ``GET /options/feature.instance`` endpoint that backs -the frontend ``FeatureInstancePicker`` (Schicht-4 / Phase-5 follow-up). - -A heavyweight HTTP integration test would need the full FastAPI client + -DB fixtures; this lightweight test asserts at the router level that the -endpoint exists with the expected method, path, and required query -parameter, so a refactor that drops or renames it fails loudly. - -Track-doc: ``wiki/c-work/2-build/2026-04-feature-instance-ref-adapter-migration.md``. -""" -from __future__ import annotations - -import pytest - -from modules.features.graphicalEditor.routeFeatureGraphicalEditor import router - - -def _findRoute(path: str, method: str = "GET"): - for route in router.routes: - # FastAPI routes expose `path` and `methods` attributes. - if getattr(route, "path", None) == path and method in ( - getattr(route, "methods", set()) or set() - ): - return route - return None - - -_ROUTE_PATH = "/api/workflows/{instanceId}/options/feature.instance" - - -def test_optionsFeatureInstanceRouteIsRegistered() -> None: - """The picker endpoint must be available at the documented path.""" - route = _findRoute(_ROUTE_PATH, "GET") - assert route is not None, ( - f"GET {_ROUTE_PATH} is not registered on graphicalEditor router. " - "The FeatureInstancePicker will fail to load mandate-scoped instances." - ) - - -def test_optionsFeatureInstanceRouteRequiresFeatureCode() -> None: - """``featureCode`` must be a required query parameter (no default).""" - route = _findRoute(_ROUTE_PATH, "GET") - assert route is not None - endpoint = route.endpoint - sig = __import__("inspect").signature(endpoint) - featureCode = sig.parameters.get("featureCode") - assert featureCode is not None, "featureCode parameter missing" - # FastAPI's Query(...) sentinel produces a FieldInfo whose `is_required()` - # returns True; older variants encoded the same intent via - # `default is Ellipsis` or `default.default is Ellipsis`. Accept any of - # those so the test stays robust across FastAPI/Pydantic versions. - default = featureCode.default - isRequiredFn = getattr(default, "is_required", None) - isRequired = ( - (callable(isRequiredFn) and isRequiredFn()) - or default is ... - or getattr(default, "default", None) is ... - ) - assert isRequired, ( - "featureCode must be a required Query parameter; otherwise the picker " - "could ask for ALL feature instances of the mandate, which is not the " - "intent of /options/feature.instance." - ) diff --git a/tests/unit/graphicalEditor/test_upstream_paths_and_graph_schema.py b/tests/unit/graphicalEditor/test_upstream_paths_and_graph_schema.py index 13072b3f..8e64367e 100644 --- a/tests/unit/graphicalEditor/test_upstream_paths_and_graph_schema.py +++ b/tests/unit/graphicalEditor/test_upstream_paths_and_graph_schema.py @@ -1,7 +1,7 @@ # Copyright (c) 2025 Patrick Motsch -from modules.features.graphicalEditor.upstreamPathsService import compute_upstream_paths -from modules.workflows.automation2.graphUtils import parse_graph_defined_schema, validateGraph -from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES +from modules.workflowAutomation.editor.upstreamPathsService import compute_upstream_paths +from modules.workflowAutomation.engine.graphUtils import parse_graph_defined_schema, validateGraph +from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES def test_compute_upstream_paths_includes_form_dynamic_fields(): diff --git a/tests/unit/nodeDefinitions/test_trustee_schema_compliance.py b/tests/unit/nodeDefinitions/test_trustee_schema_compliance.py index d1b6397c..36038ee1 100644 --- a/tests/unit/nodeDefinitions/test_trustee_schema_compliance.py +++ b/tests/unit/nodeDefinitions/test_trustee_schema_compliance.py @@ -20,10 +20,10 @@ Verifies that: import inspect -from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES -from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG -from modules.workflows.automation2.executors import actionNodeExecutor as _actionExec -from modules.workflows.automation2.graphUtils import validateGraph +from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES +from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG +from modules.workflowAutomation.engine.executors import actionNodeExecutor as _actionExec +from modules.workflowAutomation.engine.graphUtils import validateGraph def _node(nodeId: str) -> dict: diff --git a/tests/unit/nodeDefinitions/test_usesai_flag.py b/tests/unit/nodeDefinitions/test_usesai_flag.py index 1c7bbf99..bf578fd0 100644 --- a/tests/unit/nodeDefinitions/test_usesai_flag.py +++ b/tests/unit/nodeDefinitions/test_usesai_flag.py @@ -2,7 +2,7 @@ import pytest -from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES +from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES def test_all_nodes_have_usesAi(): diff --git a/tests/unit/serviceAgent/test_workflow_tools_crud.py b/tests/unit/serviceAgent/test_workflow_tools_crud.py index 9ebe1df6..b578b1de 100644 --- a/tests/unit/serviceAgent/test_workflow_tools_crud.py +++ b/tests/unit/serviceAgent/test_workflow_tools_crud.py @@ -31,7 +31,7 @@ from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResul # --------------------------------------------------------------------------- class _FakeInterface: - """In-memory stand-in for ``GraphicalEditorObjects``. + """In-memory stand-in for ``WorkflowAutomationObjects``. Stores workflows by id and records every method call in ``self.calls`` so tests can assert on the parameters the tool layer forwarded. diff --git a/tests/unit/workflow/test_extract_content_handover.py b/tests/unit/workflow/test_extract_content_handover.py index c0009251..9153f350 100644 --- a/tests/unit/workflow/test_extract_content_handover.py +++ b/tests/unit/workflow/test_extract_content_handover.py @@ -395,7 +395,7 @@ def test_action_result_contract_new_extract_payload_keys(): def test_automation_workspace_suppresses_extract_artifacts(): - from modules.workflows.automation2.workflowArtifactVisibility import suppress_workflow_file_in_workspace_ui + from modules.workflowAutomation.engine.workflowArtifactVisibility import suppress_workflow_file_in_workspace_ui assert suppress_workflow_file_in_workspace_ui({"fileName": "extracted_content_transient-abc_99.json"}) assert suppress_workflow_file_in_workspace_ui({"fileName": "extract_media_stem_uuid.png"}) diff --git a/tests/unit/workflow/test_flow_executor_conditions.py b/tests/unit/workflow/test_flow_executor_conditions.py index 70cc84f4..b16e8e5c 100644 --- a/tests/unit/workflow/test_flow_executor_conditions.py +++ b/tests/unit/workflow/test_flow_executor_conditions.py @@ -3,7 +3,7 @@ import pytest -from modules.workflows.automation2.executors.flowExecutor import FlowExecutor +from modules.workflowAutomation.engine.executors.flowExecutor import FlowExecutor from modules.workflows.methods.methodContext.actions.extractContent import PRESENTATION_KIND diff --git a/tests/unit/workflow/test_node_combinations.py b/tests/unit/workflow/test_node_combinations.py index 2fd5dd00..15159048 100644 --- a/tests/unit/workflow/test_node_combinations.py +++ b/tests/unit/workflow/test_node_combinations.py @@ -14,8 +14,8 @@ import json import pytest -from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES -from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG +from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES +from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG from modules.workflows.methods.methodContext.actions.extractContent import ( PRESENTATION_KIND, build_presentation_envelope_from_plain_text, @@ -47,7 +47,7 @@ def _ai_output(response: str) -> dict: def test_extract_to_file_create_recommended_ref_is_data(): """materializeRecommendedDataPickRef must resolve extractContent port 0 to path ['data'].""" - from modules.workflows.automation2.pickNotPushMigration import materializeRecommendedDataPickRef + from modules.workflowAutomation.engine.pickNotPushMigration import materializeRecommendedDataPickRef graph = { "nodes": [ @@ -90,7 +90,7 @@ def test_extract_output_response_is_empty(): def test_extract_primary_text_ref_override_materializes_to_data(): """When ai.prompt connects to extractContent, primaryTextRef must resolve to ['data'].""" - from modules.workflows.automation2.pickNotPushMigration import materializePrimaryTextHandover + from modules.workflowAutomation.engine.pickNotPushMigration import materializePrimaryTextHandover graph = { "nodes": [ @@ -183,7 +183,7 @@ async def test_merge_context_items_without_success_key_are_included(): def test_ai_prompt_primary_text_ref_materializes_to_response(): """primaryTextRef from ai.prompt output must resolve to ['response'].""" - from modules.workflows.automation2.pickNotPushMigration import materializePrimaryTextHandover + from modules.workflowAutomation.engine.pickNotPushMigration import materializePrimaryTextHandover graph = { "nodes": [ @@ -345,7 +345,7 @@ def test_ai_result_catalog_has_data_field(): def test_output_schema_for_transform_context_is_action_result(): """_outputSchemaForNode must return ActionResult for context.transformContext.""" - from modules.workflows.automation2.executionEngine import _outputSchemaForNode + from modules.workflowAutomation.engine.executionEngine import _outputSchemaForNode schema = _outputSchemaForNode("context.transformContext") assert schema == "ActionResult", ( f"Expected ActionResult, got {schema!r}. fromGraph port must use fromGraphResultSchema." @@ -357,19 +357,19 @@ def test_output_schema_for_transform_context_is_action_result(): # --------------------------------------------------------------------------- def test_flow_merge_is_barrier(): - from modules.workflows.automation2.executionEngine import _isBarrierNode + from modules.workflowAutomation.engine.executionEngine import _isBarrierNode assert _isBarrierNode("flow.merge") is True def test_context_merge_context_is_not_barrier(): """context.mergeContext is not a barrier — it receives data via dataSource DataRef.""" - from modules.workflows.automation2.executionEngine import _isBarrierNode + from modules.workflowAutomation.engine.executionEngine import _isBarrierNode assert _isBarrierNode("context.mergeContext") is False def test_no_node_named_is_merge_node_in_engine(): """Legacy _isMergeNode alias must be removed from executionEngine.""" - import modules.workflows.automation2.executionEngine as eng + import modules.workflowAutomation.engine.executionEngine as eng assert not hasattr(eng, "_isMergeNode"), "_isMergeNode legacy alias must be deleted" diff --git a/tests/unit/workflow/test_phase3_context_node.py b/tests/unit/workflow/test_phase3_context_node.py index 76fbc972..49500bc2 100644 --- a/tests/unit/workflow/test_phase3_context_node.py +++ b/tests/unit/workflow/test_phase3_context_node.py @@ -1,9 +1,9 @@ # Tests for Phase 3: context.extractContent node, port types, executor dispatch. import pytest -from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES -from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG -from modules.workflows.automation2.udmUpstreamShapes import ( +from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES +from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG +from modules.workflowAutomation.engine.udmUpstreamShapes import ( _coerceConsolidateResultInput, _coerceUdmDocumentInput, _coerceUdmNodeListInput, @@ -89,8 +89,8 @@ def test_coerceConsolidateResult(): def test_getExecutor_dispatches_context(): - from modules.workflows.automation2.executionEngine import _getExecutor - from modules.workflows.automation2.executors import ActionNodeExecutor + from modules.workflowAutomation.engine.executionEngine import _getExecutor + from modules.workflowAutomation.engine.executors import ActionNodeExecutor executor = _getExecutor("context.extractContent", None) assert isinstance(executor, ActionNodeExecutor) diff --git a/tests/unit/workflow/test_phase4_workflow_nodes.py b/tests/unit/workflow/test_phase4_workflow_nodes.py index eb478bda..24a29d1f 100644 --- a/tests/unit/workflow/test_phase4_workflow_nodes.py +++ b/tests/unit/workflow/test_phase4_workflow_nodes.py @@ -1,7 +1,7 @@ # Tests for Phase 4: data.consolidate, ai.consolidate, flow.loop level/concurrency, flow.merge dynamic. import pytest -from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES +from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES class TestNodeDefinitions: @@ -63,7 +63,7 @@ class TestNodeDefinitions: class TestDataConsolidateExecutor: async def test_consolidate_table_mode(self): - from modules.workflows.automation2.executors.dataExecutor import DataExecutor + from modules.workflowAutomation.engine.executors.dataExecutor import DataExecutor ex = DataExecutor() node = {"type": "data.consolidate", "id": "dc1", "parameters": {"mode": "table"}} ctx = {"nodeOutputs": {"src": {"items": [{"a": 1, "b": 2}, {"a": 3, "b": 4}], "count": 2}}, "inputSources": {"dc1": {0: ("src", 0)}}} @@ -75,7 +75,7 @@ class TestDataConsolidateExecutor: assert len(result["result"]["rows"]) == 2 async def test_consolidate_concat_mode(self): - from modules.workflows.automation2.executors.dataExecutor import DataExecutor + from modules.workflowAutomation.engine.executors.dataExecutor import DataExecutor ex = DataExecutor() node = {"type": "data.consolidate", "id": "dc1", "parameters": {"mode": "concat", "separator": "; "}} ctx = {"nodeOutputs": {"src": {"items": ["hello", "world"], "count": 2}}, "inputSources": {"dc1": {0: ("src", 0)}}} @@ -84,7 +84,7 @@ class TestDataConsolidateExecutor: assert result["result"] == "hello; world" async def test_consolidate_merge_mode(self): - from modules.workflows.automation2.executors.dataExecutor import DataExecutor + from modules.workflowAutomation.engine.executors.dataExecutor import DataExecutor ex = DataExecutor() node = {"type": "data.consolidate", "id": "dc1", "parameters": {"mode": "merge"}} ctx = {"nodeOutputs": {"src": {"items": [{"a": 1}, {"b": 2}, {"a": 99}], "count": 3}}, "inputSources": {"dc1": {0: ("src", 0)}}} @@ -98,7 +98,7 @@ class TestFlowLoopUdmLevel: """Unit tests for FlowExecutor._resolveUdmLevel (bypass resolveParameterReferences).""" def test_resolveUdmLevel_structural_nodes(self): - from modules.workflows.automation2.executors.flowExecutor import FlowExecutor + from modules.workflowAutomation.engine.executors.flowExecutor import FlowExecutor ex = FlowExecutor() udm = { "id": "d1", "role": "document", @@ -112,7 +112,7 @@ class TestFlowLoopUdmLevel: assert result[0]["id"] == "p1" def test_resolveUdmLevel_content_blocks(self): - from modules.workflows.automation2.executors.flowExecutor import FlowExecutor + from modules.workflowAutomation.engine.executors.flowExecutor import FlowExecutor ex = FlowExecutor() udm = { "id": "d1", "role": "document", @@ -130,7 +130,7 @@ class TestFlowLoopUdmLevel: assert len(result) == 3 def test_resolveUdmLevel_documents(self): - from modules.workflows.automation2.executors.flowExecutor import FlowExecutor + from modules.workflowAutomation.engine.executors.flowExecutor import FlowExecutor ex = FlowExecutor() archive = { "id": "a1", "role": "archive", @@ -145,20 +145,20 @@ class TestFlowLoopUdmLevel: @pytest.mark.asyncio async def test_loop_auto_dict_with_children(self): - from modules.workflows.automation2.executors.flowExecutor import FlowExecutor + from modules.workflowAutomation.engine.executors.flowExecutor import FlowExecutor ex = FlowExecutor() udm = {"id": "d1", "role": "document", "children": [{"id": "p1"}, {"id": "p2"}]} node = {"type": "flow.loop", "id": "loop1", "parameters": {"items": "direct"}} ctx = {"nodeOutputs": {"loop1": udm, "direct": udm}, "connectionMap": {}, "inputSources": {"loop1": {0: ("direct", 0)}}} from unittest.mock import patch - with patch("modules.workflows.automation2.graphUtils.resolveParameterReferences", return_value=udm): + with patch("modules.workflowAutomation.engine.graphUtils.resolveParameterReferences", return_value=udm): result = await ex.execute(node, ctx) assert result["count"] == 2 @pytest.mark.asyncio async def test_loop_every_nth_stride(self): - from modules.workflows.automation2.executors.flowExecutor import FlowExecutor + from modules.workflowAutomation.engine.executors.flowExecutor import FlowExecutor ex = FlowExecutor() node = {"type": "flow.loop", "id": "loop1", "parameters": { "items": {"type": "value", "value": [10, 20, 30, 40, 50]}, @@ -175,7 +175,7 @@ class TestFlowLoopUdmLevel: class TestDataFilterUdm: async def test_filter_by_udm_content_type(self): - from modules.workflows.automation2.executors.dataExecutor import DataExecutor + from modules.workflowAutomation.engine.executors.dataExecutor import DataExecutor ex = DataExecutor() udmData = { "id": "d1", "role": "document", diff --git a/tests/unit/workflow/test_phase5_highvol.py b/tests/unit/workflow/test_phase5_highvol.py index 382c273b..45079fb4 100644 --- a/tests/unit/workflow/test_phase5_highvol.py +++ b/tests/unit/workflow/test_phase5_highvol.py @@ -1,7 +1,7 @@ # Tests for Phase 5: Loop concurrency, StepLog batching, streaming aggregate. import pytest -from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES +from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES def test_loop_concurrency_param_default_1(): @@ -15,7 +15,7 @@ def test_loop_concurrency_param_default_1(): def test_executionEngine_has_batch_threshold(): """Verify STEPLOG_BATCH_THRESHOLD and AGGREGATE_FLUSH_THRESHOLD are defined in the loop block.""" import inspect - from modules.workflows.automation2.executionEngine import executeGraph + from modules.workflowAutomation.engine.executionEngine import executeGraph source = inspect.getsource(executeGraph) assert "STEPLOG_BATCH_THRESHOLD" in source assert "AGGREGATE_FLUSH_THRESHOLD" in source @@ -24,7 +24,7 @@ def test_executionEngine_has_batch_threshold(): def test_executionEngine_has_loop_progress_event(): """Verify loop_progress SSE event is emitted for batch-mode loops.""" import inspect - from modules.workflows.automation2.executionEngine import executeGraph + from modules.workflowAutomation.engine.executionEngine import executeGraph source = inspect.getsource(executeGraph) assert "loop_progress" in source @@ -32,7 +32,7 @@ def test_executionEngine_has_loop_progress_event(): def test_executionEngine_has_concurrency_semaphore(): """Verify asyncio.Semaphore is used for concurrent loop execution.""" import inspect - from modules.workflows.automation2.executionEngine import executeGraph + from modules.workflowAutomation.engine.executionEngine import executeGraph source = inspect.getsource(executeGraph) assert "Semaphore" in source @@ -40,6 +40,6 @@ def test_executionEngine_has_concurrency_semaphore(): def test_executionEngine_aggregate_temp_chunks(): """Verify streaming aggregate flush uses _aggregateTempChunks.""" import inspect - from modules.workflows.automation2.executionEngine import executeGraph + from modules.workflowAutomation.engine.executionEngine import executeGraph source = inspect.getsource(executeGraph) assert "_aggregateTempChunks" in source diff --git a/tests/unit/workflow/test_switch_filtered_output.py b/tests/unit/workflow/test_switch_filtered_output.py index 1cfac160..334a8e81 100644 --- a/tests/unit/workflow/test_switch_filtered_output.py +++ b/tests/unit/workflow/test_switch_filtered_output.py @@ -3,16 +3,16 @@ import pytest -from modules.features.graphicalEditor.portTypes import unwrapTransit, wrapTransit -from modules.features.graphicalEditor.switchOutput import ( +from modules.workflowAutomation.editor.portTypes import unwrapTransit, wrapTransit +from modules.workflowAutomation.editor.switchOutput import ( build_switch_branch_payload, build_switch_combined_output, build_switch_default_payload, unwrap_transit_for_port, ) -from modules.workflows.automation2.executionEngine import _is_node_on_active_path -from modules.workflows.automation2.executors.flowExecutor import FlowExecutor -from modules.workflows.automation2.graphUtils import resolveParameterReferences +from modules.workflowAutomation.engine.executionEngine import _is_node_on_active_path +from modules.workflowAutomation.engine.executors.flowExecutor import FlowExecutor +from modules.workflowAutomation.engine.graphUtils import resolveParameterReferences from modules.workflows.methods.methodContext.actions.extractContent import PRESENTATION_KIND diff --git a/tests/unit/workflow/test_workflowFileSchema.py b/tests/unit/workflow/test_workflowFileSchema.py index 81849d06..e7109cbc 100644 --- a/tests/unit/workflow/test_workflowFileSchema.py +++ b/tests/unit/workflow/test_workflowFileSchema.py @@ -4,7 +4,7 @@ import pytest -from modules.features.graphicalEditor._workflowFileSchema import ( +from modules.workflowAutomation.editor._workflowFileSchema import ( WORKFLOW_FILE_KIND, WORKFLOW_FILE_SCHEMA_VERSION, WorkflowFileSchemaError, diff --git a/tests/unit/workflows/test_automation2_graphUtils.py b/tests/unit/workflows/test_automation2_graphUtils.py index f76b9545..0ee29412 100644 --- a/tests/unit/workflows/test_automation2_graphUtils.py +++ b/tests/unit/workflows/test_automation2_graphUtils.py @@ -5,7 +5,7 @@ Unit tests for automation2 graphUtils - resolveParameterReferences (ref/value fo import pytest -from modules.workflows.automation2.graphUtils import resolveParameterReferences, validateGraph +from modules.workflowAutomation.engine.graphUtils import resolveParameterReferences, validateGraph _KNOWN_TYPES = frozenset({"trigger.manual", "trigger.form", "ai.prompt", "flow.pass"}) @@ -38,7 +38,7 @@ class TestValidateGraphStartNode: def test_switch_second_output_to_ai_prompt_ok(self): - from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES + from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES node_type_ids = {n["id"] for n in STATIC_NODE_TYPES} graph = { @@ -220,17 +220,17 @@ class TestPathContainsWildcard: """ def test_detects_wildcard(self): - from modules.workflows.automation2.graphUtils import _pathContainsWildcard + from modules.workflowAutomation.engine.graphUtils import _pathContainsWildcard assert _pathContainsWildcard(["docs", "*", "name"]) is True assert _pathContainsWildcard(["*"]) is True def test_no_wildcard(self): - from modules.workflows.automation2.graphUtils import _pathContainsWildcard + from modules.workflowAutomation.engine.graphUtils import _pathContainsWildcard assert _pathContainsWildcard(["docs", 0, "name"]) is False assert _pathContainsWildcard([]) is False def test_literal_star_in_int_segment_does_not_match(self): - from modules.workflows.automation2.graphUtils import _pathContainsWildcard + from modules.workflowAutomation.engine.graphUtils import _pathContainsWildcard assert _pathContainsWildcard([1, 2, 3]) is False @@ -238,7 +238,7 @@ class TestLoopBodyAndDoneReachability: """flow.loop: body only from output 0; done branch from output 1 (engine helpers).""" def test_body_only_output_0_not_done_chain(self): - from modules.workflows.automation2.graphUtils import buildConnectionMap, getLoopBodyNodeIds, getLoopDoneNodeIds + from modules.workflowAutomation.engine.graphUtils import buildConnectionMap, getLoopBodyNodeIds, getLoopDoneNodeIds conns = [ {"source": "tr", "target": "loop", "targetInput": 0}, @@ -251,7 +251,7 @@ class TestLoopBodyAndDoneReachability: assert getLoopDoneNodeIds("loop", cm) == {"d"} def test_primary_input_prefers_outside_body(self): - from modules.workflows.automation2.graphUtils import ( + from modules.workflowAutomation.engine.graphUtils import ( buildConnectionMap, getLoopBodyNodeIds, getLoopPrimaryInputSource, diff --git a/tests/unit/workflows/test_featureInstanceRefMigration.py b/tests/unit/workflows/test_featureInstanceRefMigration.py index 573f7b66..2ffb6682 100644 --- a/tests/unit/workflows/test_featureInstanceRefMigration.py +++ b/tests/unit/workflows/test_featureInstanceRefMigration.py @@ -11,10 +11,10 @@ import copy import pytest -from modules.workflows.automation2.featureInstanceRefMigration import ( +from modules.workflowAutomation.engine.featureInstanceRefMigration import ( materializeFeatureInstanceRefs, ) -from modules.workflows.automation2.graphUtils import ( +from modules.workflowAutomation.engine.graphUtils import ( _isTypedRefEnvelope, _unwrapTypedRef, resolveParameterReferences, diff --git a/tests/unit/workflows/test_trigger_executor.py b/tests/unit/workflows/test_trigger_executor.py index 446d92da..96a0bf68 100644 --- a/tests/unit/workflows/test_trigger_executor.py +++ b/tests/unit/workflows/test_trigger_executor.py @@ -3,8 +3,8 @@ import pytest -from modules.workflows.automation2.executors.triggerExecutor import TriggerExecutor -from modules.workflows.automation2.runEnvelope import default_run_envelope +from modules.workflowAutomation.engine.executors.triggerExecutor import TriggerExecutor +from modules.workflowAutomation.engine.runEnvelope import default_run_envelope @pytest.mark.asyncio From ce612ffcfcb8cee9e8d2e84d1acb751583a583f6 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Mon, 8 Jun 2026 14:46:52 +0200 Subject: [PATCH 07/16] import referencing fixes --- app.py | 2 +- modules/aicore/aicoreModelRegistry.py | 25 ++++++++---- modules/datamodels/datamodelPortTypes.py | 21 ++++++++++ modules/demoConfigs/pwgDemo2026.py | 2 +- .../features/redmine/workflows/__init__.py | 3 ++ .../workflows}/methodRedmine/__init__.py | 0 .../methodRedmine/actions/__init__.py | 0 .../methodRedmine/actions/_shared.py | 0 .../methodRedmine/actions/createTicket.py | 0 .../methodRedmine/actions/getStats.py | 0 .../methodRedmine/actions/listRelations.py | 0 .../methodRedmine/actions/listTickets.py | 0 .../methodRedmine/actions/readTicket.py | 0 .../methodRedmine/actions/runSync.py | 0 .../methodRedmine/actions/updateTicket.py | 0 .../workflows}/methodRedmine/methodRedmine.py | 0 .../workspace/routeFeatureWorkspace.py | 2 +- .../interfaces/interfaceWorkflowAutomation.py | 6 +-- modules/nodeCatalog/__init__.py | 8 ++++ .../_workflowFileSchema.py | 0 .../editor => nodeCatalog}/nodeAdapter.py | 0 .../nodeDefinitions/__init__.py | 0 .../nodeDefinitions/ai.py | 4 +- .../nodeDefinitions/clickup.py | 2 +- .../nodeDefinitions/context.py | 4 +- .../nodeDefinitions/contextPickerHelp.py | 8 ++-- .../nodeDefinitions/data.py | 2 +- .../nodeDefinitions/email.py | 4 +- .../nodeDefinitions/file.py | 6 +-- .../nodeDefinitions/flow.py | 10 ++--- .../nodeDefinitions/input.py | 2 +- .../nodeDefinitions/redmine.py | 2 +- .../nodeDefinitions/sharepoint.py | 2 +- .../nodeDefinitions/triggers.py | 2 +- .../nodeDefinitions/trustee.py | 2 +- .../editor => nodeCatalog}/portTypes.py | 2 +- modules/routes/routeWorkflowAutomation.py | 20 +++++++--- .../core/serviceStreaming/__init__.py | 3 +- .../core/serviceStreaming/eventManager.py | 8 ---- .../serviceStreaming/mainServiceStreaming.py | 2 +- .../serviceAgent/externalToolRegistry.py | 39 +++++++++++++++++++ .../services/serviceAgent/mainServiceAgent.py | 27 ++++++------- modules/serviceHub/__init__.py | 7 ---- modules/system/i18nBootSync.py | 4 +- .../agentTools.py} | 6 +-- .../editor/adapterValidator.py | 2 +- .../editor/conditionOperators.py | 2 +- .../workflowAutomation/editor/nodeRegistry.py | 8 ++-- .../workflowAutomation/editor/switchOutput.py | 2 +- .../editor/upstreamPathsService.py | 4 +- .../engine/executionEngine.py | 4 +- .../engine/executors/actionNodeExecutor.py | 11 +++--- .../engine/executors/dataExecutor.py | 2 +- .../engine/executors/flowExecutor.py | 2 +- .../workflowAutomation/engine/graphUtils.py | 12 +++--- .../engine/pickNotPushMigration.py | 4 +- .../helpers.py} | 5 ++- .../mainWorkflowAutomation.py | 15 +++++++ modules/workflows/automation2/__init__.py | 13 ------- .../automation2/executors/__init__.py | 23 ----------- .../methods/_actionSignatureValidator.py | 4 +- modules/workflows/methods/methodBase.py | 2 +- .../methodContext/actions/setContext.py | 19 ++++----- .../processing/core/actionExecutor.py | 2 +- .../processing/core/messageCreator.py | 2 +- .../workflows/processing/core/taskPlanner.py | 2 +- .../processing/modes/modeAutomation.py | 2 +- .../workflows/processing/modes/modeDynamic.py | 2 +- .../processing/shared/parameterValidation.py | 2 +- .../workflows/processing/shared/stateTools.py | 10 ----- .../workflows/processing/workflowProcessor.py | 2 +- modules/workflows/scheduler/__init__.py | 11 ------ modules/workflows/workflowManager.py | 2 +- .../script_migrate_feature_instance_refs.py | 2 +- tests/eval/runTrusteeBenchmark.py | 2 +- tests/functional/test01_ai_model_selection.py | 2 +- tests/functional/test02_ai_models.py | 2 +- tests/functional/test03_ai_operations.py | 2 +- tests/functional/test04_ai_behavior.py | 2 +- .../graphicalEditor/test_adapter_validator.py | 6 +-- ...est_featureInstanceRef_node_definitions.py | 4 +- .../unit/graphicalEditor/test_node_adapter.py | 2 +- .../graphicalEditor/test_portTypes_catalog.py | 2 +- .../test_port_schema_recursive.py | 2 +- .../test_upstream_paths_and_graph_schema.py | 2 +- .../test_action_signature_validator.py | 2 +- .../test_trustee_schema_compliance.py | 4 +- .../unit/nodeDefinitions/test_usesai_flag.py | 2 +- .../serviceAgent/test_workflow_tools_crud.py | 2 +- tests/unit/workflow/test_node_combinations.py | 4 +- .../unit/workflow/test_phase3_context_node.py | 4 +- .../workflow/test_phase4_workflow_nodes.py | 2 +- tests/unit/workflow/test_phase5_highvol.py | 2 +- .../workflow/test_switch_filtered_output.py | 2 +- .../unit/workflow/test_workflowFileSchema.py | 2 +- .../workflows/test_automation2_graphUtils.py | 2 +- 96 files changed, 249 insertions(+), 217 deletions(-) create mode 100644 modules/features/redmine/workflows/__init__.py rename modules/{workflows/methods => features/redmine/workflows}/methodRedmine/__init__.py (100%) rename modules/{workflows/methods => features/redmine/workflows}/methodRedmine/actions/__init__.py (100%) rename modules/{workflows/methods => features/redmine/workflows}/methodRedmine/actions/_shared.py (100%) rename modules/{workflows/methods => features/redmine/workflows}/methodRedmine/actions/createTicket.py (100%) rename modules/{workflows/methods => features/redmine/workflows}/methodRedmine/actions/getStats.py (100%) rename modules/{workflows/methods => features/redmine/workflows}/methodRedmine/actions/listRelations.py (100%) rename modules/{workflows/methods => features/redmine/workflows}/methodRedmine/actions/listTickets.py (100%) rename modules/{workflows/methods => features/redmine/workflows}/methodRedmine/actions/readTicket.py (100%) rename modules/{workflows/methods => features/redmine/workflows}/methodRedmine/actions/runSync.py (100%) rename modules/{workflows/methods => features/redmine/workflows}/methodRedmine/actions/updateTicket.py (100%) rename modules/{workflows/methods => features/redmine/workflows}/methodRedmine/methodRedmine.py (100%) create mode 100644 modules/nodeCatalog/__init__.py rename modules/{workflowAutomation/editor => nodeCatalog}/_workflowFileSchema.py (100%) rename modules/{workflowAutomation/editor => nodeCatalog}/nodeAdapter.py (100%) rename modules/{workflowAutomation/editor => nodeCatalog}/nodeDefinitions/__init__.py (100%) rename modules/{workflowAutomation/editor => nodeCatalog}/nodeDefinitions/ai.py (99%) rename modules/{workflowAutomation/editor => nodeCatalog}/nodeDefinitions/clickup.py (99%) rename modules/{workflowAutomation/editor => nodeCatalog}/nodeDefinitions/context.py (98%) rename modules/{workflowAutomation/editor => nodeCatalog}/nodeDefinitions/contextPickerHelp.py (78%) rename modules/{workflowAutomation/editor => nodeCatalog}/nodeDefinitions/data.py (97%) rename modules/{workflowAutomation/editor => nodeCatalog}/nodeDefinitions/email.py (96%) rename modules/{workflowAutomation/editor => nodeCatalog}/nodeDefinitions/file.py (90%) rename modules/{workflowAutomation/editor => nodeCatalog}/nodeDefinitions/flow.py (97%) rename modules/{workflowAutomation/editor => nodeCatalog}/nodeDefinitions/input.py (98%) rename modules/{workflowAutomation/editor => nodeCatalog}/nodeDefinitions/redmine.py (98%) rename modules/{workflowAutomation/editor => nodeCatalog}/nodeDefinitions/sharepoint.py (99%) rename modules/{workflowAutomation/editor => nodeCatalog}/nodeDefinitions/triggers.py (96%) rename modules/{workflowAutomation/editor => nodeCatalog}/nodeDefinitions/trustee.py (98%) rename modules/{workflowAutomation/editor => nodeCatalog}/portTypes.py (99%) delete mode 100644 modules/serviceCenter/core/serviceStreaming/eventManager.py create mode 100644 modules/serviceCenter/services/serviceAgent/externalToolRegistry.py delete mode 100644 modules/serviceHub/__init__.py rename modules/{serviceCenter/services/serviceAgent/workflowTools.py => workflowAutomation/agentTools.py} (99%) rename modules/{shared/workflowAutomationHelpers.py => workflowAutomation/helpers.py} (99%) delete mode 100644 modules/workflows/automation2/__init__.py delete mode 100644 modules/workflows/automation2/executors/__init__.py delete mode 100644 modules/workflows/processing/shared/stateTools.py delete mode 100644 modules/workflows/scheduler/__init__.py diff --git a/app.py b/app.py index 2ecf3ad5..185ea95d 100644 --- a/app.py +++ b/app.py @@ -552,7 +552,7 @@ async def lifespan(app: FastAPI): # to finish (up to 120 s keepalive timeout) before the rest of # the shutdown can proceed. try: - from modules.serviceCenter.core.serviceStreaming.eventManager import get_event_manager as _getStreamingEM + from modules.shared.eventManager import get_event_manager as _getStreamingEM _getStreamingEM().shutdown() except Exception as e: logger.warning(f"Streaming EventManager shutdown failed: {e}") diff --git a/modules/aicore/aicoreModelRegistry.py b/modules/aicore/aicoreModelRegistry.py index 813b3ac4..164f71f9 100644 --- a/modules/aicore/aicoreModelRegistry.py +++ b/modules/aicore/aicoreModelRegistry.py @@ -10,14 +10,16 @@ import importlib import os import time import threading -from typing import Dict, List, Optional, Any, Tuple +from typing import Dict, List, Optional, Any, Tuple, TYPE_CHECKING from modules.datamodels.datamodelAi import AiModel +from modules.datamodels.datamodelRbac import AccessRuleContext from .aicoreBase import BaseConnectorAi from modules.datamodels.datamodelUam import User -from modules.security.rbacHelpers import checkResourceAccess -from modules.security.rbac import RbacClass from modules.connectors.connectorDbPostgre import DatabaseConnector +if TYPE_CHECKING: + from modules.security.rbac import RbacClass + logger = logging.getLogger(__name__) # TODO TESTING: Override maxTokens for all models during testing @@ -186,7 +188,7 @@ class ModelRegistry: def getAvailableModels( self, currentUser: Optional[User] = None, - rbacInstance: Optional[RbacClass] = None, + rbacInstance: Optional["RbacClass"] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None ) -> List[AiModel]: @@ -237,7 +239,7 @@ class ModelRegistry: self, models: List[AiModel], currentUser: User, - rbacInstance: RbacClass, + rbacInstance: "RbacClass", mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None ) -> List[AiModel]: @@ -262,7 +264,7 @@ class ModelRegistry: logger.debug(f"User {currentUser.username} does not have access to model {model.displayName} (connector: {model.connectorType})") return filteredModels - def getModel(self, displayName: str, currentUser: Optional[User] = None, rbacInstance: Optional[RbacClass] = None) -> Optional[AiModel]: + def getModel(self, displayName: str, currentUser: Optional[User] = None, rbacInstance: Optional["RbacClass"] = None) -> Optional[AiModel]: """Get a specific model by displayName, optionally checking RBAC permissions. Args: @@ -284,8 +286,15 @@ class ModelRegistry: connectorResourcePath = f"ai.model.{model.connectorType}" modelResourcePath = f"ai.model.{model.connectorType}.{model.displayName}" - hasConnectorAccess = checkResourceAccess(rbacInstance, currentUser, connectorResourcePath) - hasModelAccess = checkResourceAccess(rbacInstance, currentUser, modelResourcePath) + try: + connPerms = rbacInstance.getUserPermissions(currentUser, AccessRuleContext.RESOURCE, connectorResourcePath) + modelPerms = rbacInstance.getUserPermissions(currentUser, AccessRuleContext.RESOURCE, modelResourcePath) + hasConnectorAccess = connPerms.view if connPerms else False + hasModelAccess = modelPerms.view if modelPerms else False + except Exception as e: + logger.error(f"Error checking resource access for {modelResourcePath}: {e}") + hasConnectorAccess = False + hasModelAccess = False if not (hasConnectorAccess or hasModelAccess): logger.warning(f"User {currentUser.username} does not have access to model {displayName}") diff --git a/modules/datamodels/datamodelPortTypes.py b/modules/datamodels/datamodelPortTypes.py index 6d87c25e..1357af4f 100644 --- a/modules/datamodels/datamodelPortTypes.py +++ b/modules/datamodels/datamodelPortTypes.py @@ -549,3 +549,24 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = { PRIMITIVE_TYPES: frozenset = frozenset({ "str", "int", "bool", "float", "Any", "Dict", "List", }) + + +def stripContainer(typeStr: str) -> List[str]: + """ + Extract referenced type names from a PortField.type string. + + Examples: + "str" -> ["str"] + "List[Document]" -> ["Document"] + "Dict[str,Any]" -> ["str", "Any"] + "ConnectionRef" -> ["ConnectionRef"] + "List[ProcessError]" -> ["ProcessError"] + """ + s = (typeStr or "").strip() + if not s: + return [] + if "[" in s and s.endswith("]"): + inner = s[s.index("[") + 1 : -1] + parts = [p.strip() for p in inner.split(",") if p.strip()] + return parts or [s] + return [s] diff --git a/modules/demoConfigs/pwgDemo2026.py b/modules/demoConfigs/pwgDemo2026.py index c2c196af..efcd8c8a 100644 --- a/modules/demoConfigs/pwgDemo2026.py +++ b/modules/demoConfigs/pwgDemo2026.py @@ -561,7 +561,7 @@ class PwgDemo2026(BaseDemoConfig): summary["errors"].append(f"WorkflowAutomation DB connection failed: {exc}") return - from modules.workflowAutomation.editor._workflowFileSchema import ( + from modules.nodeCatalog._workflowFileSchema import ( envelopeToWorkflowData, validateFileEnvelope, ) diff --git a/modules/features/redmine/workflows/__init__.py b/modules/features/redmine/workflows/__init__.py new file mode 100644 index 00000000..8c4ceb1a --- /dev/null +++ b/modules/features/redmine/workflows/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +"""Feature-owned workflow methods for Redmine.""" diff --git a/modules/workflows/methods/methodRedmine/__init__.py b/modules/features/redmine/workflows/methodRedmine/__init__.py similarity index 100% rename from modules/workflows/methods/methodRedmine/__init__.py rename to modules/features/redmine/workflows/methodRedmine/__init__.py diff --git a/modules/workflows/methods/methodRedmine/actions/__init__.py b/modules/features/redmine/workflows/methodRedmine/actions/__init__.py similarity index 100% rename from modules/workflows/methods/methodRedmine/actions/__init__.py rename to modules/features/redmine/workflows/methodRedmine/actions/__init__.py diff --git a/modules/workflows/methods/methodRedmine/actions/_shared.py b/modules/features/redmine/workflows/methodRedmine/actions/_shared.py similarity index 100% rename from modules/workflows/methods/methodRedmine/actions/_shared.py rename to modules/features/redmine/workflows/methodRedmine/actions/_shared.py diff --git a/modules/workflows/methods/methodRedmine/actions/createTicket.py b/modules/features/redmine/workflows/methodRedmine/actions/createTicket.py similarity index 100% rename from modules/workflows/methods/methodRedmine/actions/createTicket.py rename to modules/features/redmine/workflows/methodRedmine/actions/createTicket.py diff --git a/modules/workflows/methods/methodRedmine/actions/getStats.py b/modules/features/redmine/workflows/methodRedmine/actions/getStats.py similarity index 100% rename from modules/workflows/methods/methodRedmine/actions/getStats.py rename to modules/features/redmine/workflows/methodRedmine/actions/getStats.py diff --git a/modules/workflows/methods/methodRedmine/actions/listRelations.py b/modules/features/redmine/workflows/methodRedmine/actions/listRelations.py similarity index 100% rename from modules/workflows/methods/methodRedmine/actions/listRelations.py rename to modules/features/redmine/workflows/methodRedmine/actions/listRelations.py diff --git a/modules/workflows/methods/methodRedmine/actions/listTickets.py b/modules/features/redmine/workflows/methodRedmine/actions/listTickets.py similarity index 100% rename from modules/workflows/methods/methodRedmine/actions/listTickets.py rename to modules/features/redmine/workflows/methodRedmine/actions/listTickets.py diff --git a/modules/workflows/methods/methodRedmine/actions/readTicket.py b/modules/features/redmine/workflows/methodRedmine/actions/readTicket.py similarity index 100% rename from modules/workflows/methods/methodRedmine/actions/readTicket.py rename to modules/features/redmine/workflows/methodRedmine/actions/readTicket.py diff --git a/modules/workflows/methods/methodRedmine/actions/runSync.py b/modules/features/redmine/workflows/methodRedmine/actions/runSync.py similarity index 100% rename from modules/workflows/methods/methodRedmine/actions/runSync.py rename to modules/features/redmine/workflows/methodRedmine/actions/runSync.py diff --git a/modules/workflows/methods/methodRedmine/actions/updateTicket.py b/modules/features/redmine/workflows/methodRedmine/actions/updateTicket.py similarity index 100% rename from modules/workflows/methods/methodRedmine/actions/updateTicket.py rename to modules/features/redmine/workflows/methodRedmine/actions/updateTicket.py diff --git a/modules/workflows/methods/methodRedmine/methodRedmine.py b/modules/features/redmine/workflows/methodRedmine/methodRedmine.py similarity index 100% rename from modules/workflows/methods/methodRedmine/methodRedmine.py rename to modules/features/redmine/workflows/methodRedmine/methodRedmine.py diff --git a/modules/features/workspace/routeFeatureWorkspace.py b/modules/features/workspace/routeFeatureWorkspace.py index d9fc3f4d..6d83e234 100644 --- a/modules/features/workspace/routeFeatureWorkspace.py +++ b/modules/features/workspace/routeFeatureWorkspace.py @@ -27,7 +27,7 @@ from modules.interfaces import interfaceDbChat, interfaceDbManagement from modules.features.workspace import interfaceFeatureWorkspace from modules.interfaces.interfaceDbKnowledge import getInterface as getKnowledgeInterface from modules.interfaces.interfaceAiObjects import AiObjects -from modules.serviceCenter.core.serviceStreaming import get_event_manager +from modules.shared.eventManager import get_event_manager from modules.serviceCenter.services.serviceAgent.datamodelAgent import AgentEventTypeEnum, PendingFileEdit from modules.shared.timeUtils import parseTimestamp from modules.shared.i18nRegistry import apiRouteContext, resolveText diff --git a/modules/interfaces/interfaceWorkflowAutomation.py b/modules/interfaces/interfaceWorkflowAutomation.py index 6d192451..ba8fe6e7 100644 --- a/modules/interfaces/interfaceWorkflowAutomation.py +++ b/modules/interfaces/interfaceWorkflowAutomation.py @@ -692,7 +692,7 @@ class WorkflowAutomationObjects: envelope) and can be JSON-serialized as-is. Returns ``None`` if the workflow does not exist for this mandate. """ - from modules.workflowAutomation.editor._workflowFileSchema import buildFileFromWorkflow + from modules.nodeCatalog._workflowFileSchema import buildFileFromWorkflow wf = self.getWorkflow(workflowId) if not wf: @@ -711,11 +711,11 @@ class WorkflowAutomationObjects: ``existingWorkflowId`` is given. Imports are always saved with ``active=False`` so operators can review before scheduling. """ - from modules.workflowAutomation.editor._workflowFileSchema import ( + from modules.nodeCatalog._workflowFileSchema import ( envelopeToWorkflowData, validateFileEnvelope, ) - from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES + from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES knownTypes = [n.get("id") for n in STATIC_NODE_TYPES if isinstance(n, dict) and n.get("id")] normalizedEnvelope, warnings = validateFileEnvelope(envelope, knownNodeTypes=knownTypes) diff --git a/modules/nodeCatalog/__init__.py b/modules/nodeCatalog/__init__.py new file mode 100644 index 00000000..cbe6a49e --- /dev/null +++ b/modules/nodeCatalog/__init__.py @@ -0,0 +1,8 @@ +# Copyright (c) 2025 Patrick Motsch +""" +nodeCatalog (L2) — neutraler Node-Definitions-Container. + +Statische Node-Schemas, Port-Typen, Node-Adapter und das Workflow-File-Schema. +Haengt NUR von shared (L0) + datamodels (L1) ab. Wird von workflowAutomation, +serviceCenter, interfaces, system, routes und demoConfigs importiert. +""" diff --git a/modules/workflowAutomation/editor/_workflowFileSchema.py b/modules/nodeCatalog/_workflowFileSchema.py similarity index 100% rename from modules/workflowAutomation/editor/_workflowFileSchema.py rename to modules/nodeCatalog/_workflowFileSchema.py diff --git a/modules/workflowAutomation/editor/nodeAdapter.py b/modules/nodeCatalog/nodeAdapter.py similarity index 100% rename from modules/workflowAutomation/editor/nodeAdapter.py rename to modules/nodeCatalog/nodeAdapter.py diff --git a/modules/workflowAutomation/editor/nodeDefinitions/__init__.py b/modules/nodeCatalog/nodeDefinitions/__init__.py similarity index 100% rename from modules/workflowAutomation/editor/nodeDefinitions/__init__.py rename to modules/nodeCatalog/nodeDefinitions/__init__.py diff --git a/modules/workflowAutomation/editor/nodeDefinitions/ai.py b/modules/nodeCatalog/nodeDefinitions/ai.py similarity index 99% rename from modules/workflowAutomation/editor/nodeDefinitions/ai.py rename to modules/nodeCatalog/nodeDefinitions/ai.py index 37cf691f..8e0f081e 100644 --- a/modules/workflowAutomation/editor/nodeDefinitions/ai.py +++ b/modules/nodeCatalog/nodeDefinitions/ai.py @@ -3,10 +3,10 @@ from modules.shared.i18nRegistry import t -from modules.workflowAutomation.editor.nodeDefinitions.contextPickerHelp import ( +from modules.nodeCatalog.nodeDefinitions.contextPickerHelp import ( CONTEXT_BUILDER_PARAM_DESCRIPTION, ) -from modules.workflowAutomation.editor.nodeDefinitions.flow import ( +from modules.nodeCatalog.nodeDefinitions.flow import ( CONTEXT_ENVELOPE_DATA_PICK_OPTIONS, ) diff --git a/modules/workflowAutomation/editor/nodeDefinitions/clickup.py b/modules/nodeCatalog/nodeDefinitions/clickup.py similarity index 99% rename from modules/workflowAutomation/editor/nodeDefinitions/clickup.py rename to modules/nodeCatalog/nodeDefinitions/clickup.py index 60c60bd5..1e330d29 100644 --- a/modules/workflowAutomation/editor/nodeDefinitions/clickup.py +++ b/modules/nodeCatalog/nodeDefinitions/clickup.py @@ -4,7 +4,7 @@ from modules.shared.i18nRegistry import t -from modules.workflowAutomation.editor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS +from modules.nodeCatalog.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS TASK_LIST_DATA_PICK_OPTIONS = [ { diff --git a/modules/workflowAutomation/editor/nodeDefinitions/context.py b/modules/nodeCatalog/nodeDefinitions/context.py similarity index 98% rename from modules/workflowAutomation/editor/nodeDefinitions/context.py rename to modules/nodeCatalog/nodeDefinitions/context.py index 839417e9..dc05fa40 100644 --- a/modules/workflowAutomation/editor/nodeDefinitions/context.py +++ b/modules/nodeCatalog/nodeDefinitions/context.py @@ -4,7 +4,7 @@ from modules.shared.i18nRegistry import t -from modules.workflowAutomation.editor.nodeDefinitions.flow import ( +from modules.nodeCatalog.nodeDefinitions.flow import ( CONTEXT_ENVELOPE_DATA_PICK_OPTIONS, CONTEXT_MERGE_ACTION_RESULT_DATA_PICK_OPTIONS, ) @@ -245,7 +245,7 @@ CONTEXT_NODES = [ "description": t( "Filtert fuer die Presentation-Schicht nach typeGroup/MIME " "(gilt fuer alle Dokumenttypen analog, nicht nur PDF). " - "Passt zum Inhaltsfilter „Alles"; „Text & Tabellen" blendet Bild-Parts in der Presentation aus." + "Passt zum Inhaltsfilter „Alles“; „Text & Tabellen“ blendet Bild-Parts in der Presentation aus." ), }, { diff --git a/modules/workflowAutomation/editor/nodeDefinitions/contextPickerHelp.py b/modules/nodeCatalog/nodeDefinitions/contextPickerHelp.py similarity index 78% rename from modules/workflowAutomation/editor/nodeDefinitions/contextPickerHelp.py rename to modules/nodeCatalog/nodeDefinitions/contextPickerHelp.py index 55529951..116164c1 100644 --- a/modules/workflowAutomation/editor/nodeDefinitions/contextPickerHelp.py +++ b/modules/nodeCatalog/nodeDefinitions/contextPickerHelp.py @@ -4,14 +4,14 @@ from modules.shared.i18nRegistry import t CONTEXT_BUILDER_PARAM_DESCRIPTION = t( - "Inhalt aus vorherigen Schritten wählen (DataRef / Daten-Picker): z. B. „response" für Klartext, " + "Inhalt aus vorherigen Schritten wählen (DataRef / Daten-Picker): z. B. „response“ für Klartext, " "Handover-Pfade für strukturiertes JSON oder Medienlisten. " "Die Auflösung erfolgt vollständig serverseitig (`resolveParameterReferences`). " - "Formular-Schritte speichern Antworten unter „payload" — fehlt ein gewählter Pfad am Root, " - "wird derselbe Pfad automatisch unter „payload" nachgeschlagen (Kompatibilität mit älteren " + "Formular-Schritte speichern Antworten unter „payload“ — fehlt ein gewählter Pfad am Root, " + "wird derselbe Pfad automatisch unter „payload“ nachgeschlagen (Kompatibilität mit älteren " "und neuen Picker-Pfaden). " "In Freitext-/Template-Feldern werden weiterhin Platzhalter `{{KnotenId.feld.b.z.}}` ersetzt " - "(gleiche Semantik inkl. optionalem Nachschlagen unter „payload")." + "(gleiche Semantik inkl. optionalem Nachschlagen unter „payload“)." ) # Kurzreferenz für Node-Beschreibungen (optional einbinden): dieselbe Auflösungslogik diff --git a/modules/workflowAutomation/editor/nodeDefinitions/data.py b/modules/nodeCatalog/nodeDefinitions/data.py similarity index 97% rename from modules/workflowAutomation/editor/nodeDefinitions/data.py rename to modules/nodeCatalog/nodeDefinitions/data.py index c8a4a3e5..a12ddeb6 100644 --- a/modules/workflowAutomation/editor/nodeDefinitions/data.py +++ b/modules/nodeCatalog/nodeDefinitions/data.py @@ -3,7 +3,7 @@ from modules.shared.i18nRegistry import t -from modules.workflowAutomation.editor.nodeDefinitions.ai import CONSOLIDATE_RESULT_DATA_PICK_OPTIONS +from modules.nodeCatalog.nodeDefinitions.ai import CONSOLIDATE_RESULT_DATA_PICK_OPTIONS AGGREGATE_RESULT_DATA_PICK_OPTIONS = [ { diff --git a/modules/workflowAutomation/editor/nodeDefinitions/email.py b/modules/nodeCatalog/nodeDefinitions/email.py similarity index 96% rename from modules/workflowAutomation/editor/nodeDefinitions/email.py rename to modules/nodeCatalog/nodeDefinitions/email.py index d5c7fe8c..a0503452 100644 --- a/modules/workflowAutomation/editor/nodeDefinitions/email.py +++ b/modules/nodeCatalog/nodeDefinitions/email.py @@ -3,10 +3,10 @@ from modules.shared.i18nRegistry import t -from modules.workflowAutomation.editor.nodeDefinitions.contextPickerHelp import ( +from modules.nodeCatalog.nodeDefinitions.contextPickerHelp import ( CONTEXT_BUILDER_PARAM_DESCRIPTION, ) -from modules.workflowAutomation.editor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS +from modules.nodeCatalog.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS EMAIL_LIST_DATA_PICK_OPTIONS = [ { diff --git a/modules/workflowAutomation/editor/nodeDefinitions/file.py b/modules/nodeCatalog/nodeDefinitions/file.py similarity index 90% rename from modules/workflowAutomation/editor/nodeDefinitions/file.py rename to modules/nodeCatalog/nodeDefinitions/file.py index 88deb5ec..70c13a07 100644 --- a/modules/workflowAutomation/editor/nodeDefinitions/file.py +++ b/modules/nodeCatalog/nodeDefinitions/file.py @@ -3,10 +3,10 @@ from modules.shared.i18nRegistry import t -from modules.workflowAutomation.editor.nodeDefinitions.contextPickerHelp import ( +from modules.nodeCatalog.nodeDefinitions.contextPickerHelp import ( CONTEXT_BUILDER_PARAM_DESCRIPTION, ) -from modules.workflowAutomation.editor.nodeDefinitions.ai import DOCUMENT_LIST_DATA_PICK_OPTIONS +from modules.nodeCatalog.nodeDefinitions.ai import DOCUMENT_LIST_DATA_PICK_OPTIONS FILE_NODES = [ { @@ -14,7 +14,7 @@ FILE_NODES = [ "category": "file", "label": t("Datei erstellen"), "description": t( - "Erstellt eine Datei aus der Presentation von „Inhalt extrahieren" " + "Erstellt eine Datei aus der Presentation von „Inhalt extrahieren“ " "(``data`` oder Schleifen-``bodyResults``). Ausgabe über den Generation-Service." ), "parameters": [ diff --git a/modules/workflowAutomation/editor/nodeDefinitions/flow.py b/modules/nodeCatalog/nodeDefinitions/flow.py similarity index 97% rename from modules/workflowAutomation/editor/nodeDefinitions/flow.py rename to modules/nodeCatalog/nodeDefinitions/flow.py index fe1b1f30..94f517d9 100644 --- a/modules/workflowAutomation/editor/nodeDefinitions/flow.py +++ b/modules/nodeCatalog/nodeDefinitions/flow.py @@ -96,7 +96,7 @@ MERGE_RESULT_DATA_PICK_OPTIONS = [ { "path": ["first"], "pickerLabel": t("Erster Zweig"), - "detail": t("Daten vom ersten verbundenen Eingang (Modus „first")."), + "detail": t("Daten vom ersten verbundenen Eingang (Modus „first“)."), "recommended": False, "type": "Any", }, @@ -243,9 +243,9 @@ FLOW_NODES = [ "category": "flow", "label": t("Schleife / Für jedes"), "description": t( - "Zwei Ausgänge: „Schleife" verbindet den Rumpf (pro Element); optional führt der Rumpf " + "Zwei Ausgänge: „Schleife“ verbindet den Rumpf (pro Element); optional führt der Rumpf " "mit einem Rücklauf-Pfeil wieder zum **gleichen Eingang** wie der vorherige Schritt (wie in n8n). " - "„Fertig" führt genau einmal fort, wenn alle Iterationen beendet sind. " + "„Fertig“ führt genau einmal fort, wenn alle Iterationen beendet sind. " "Die zu durchlaufende Liste wählen Sie wie bisher; UDM-/Strukturdaten werden automatisch sinnvoll in Elemente aufgelöst." ), "parameters": [ @@ -266,7 +266,7 @@ FLOW_NODES = [ }, "description": t( "Welche Elemente die Schleife besucht: alle, nur das erste/letzte, jedes zweite/dritte " - "oder jedes n-te (Schritt dann unter „Schrittweite")." + "oder jedes n-te (Schritt dann unter „Schrittweite“)." ), "default": "all", }, @@ -276,7 +276,7 @@ FLOW_NODES = [ "required": False, "frontendType": "number", "frontendOptions": {"min": 2, "max": 100}, - "description": t("Nur bei „jedes n-te": Schrittweite (z. B. 5 = jedes 5. Element ab Index 0)."), + "description": t("Nur bei „jedes n-te“: Schrittweite (z. B. 5 = jedes 5. Element ab Index 0)."), "default": 2, }, { diff --git a/modules/workflowAutomation/editor/nodeDefinitions/input.py b/modules/nodeCatalog/nodeDefinitions/input.py similarity index 98% rename from modules/workflowAutomation/editor/nodeDefinitions/input.py rename to modules/nodeCatalog/nodeDefinitions/input.py index 5c152fdb..0f469880 100644 --- a/modules/workflowAutomation/editor/nodeDefinitions/input.py +++ b/modules/nodeCatalog/nodeDefinitions/input.py @@ -3,7 +3,7 @@ from modules.shared.i18nRegistry import t -from modules.workflowAutomation.editor.nodeDefinitions.ai import DOCUMENT_LIST_DATA_PICK_OPTIONS +from modules.nodeCatalog.nodeDefinitions.ai import DOCUMENT_LIST_DATA_PICK_OPTIONS BOOL_RESULT_DATA_PICK_OPTIONS = [ { diff --git a/modules/workflowAutomation/editor/nodeDefinitions/redmine.py b/modules/nodeCatalog/nodeDefinitions/redmine.py similarity index 98% rename from modules/workflowAutomation/editor/nodeDefinitions/redmine.py rename to modules/nodeCatalog/nodeDefinitions/redmine.py index f20f2901..bf61cd26 100644 --- a/modules/workflowAutomation/editor/nodeDefinitions/redmine.py +++ b/modules/nodeCatalog/nodeDefinitions/redmine.py @@ -4,7 +4,7 @@ from modules.shared.i18nRegistry import t -from modules.workflowAutomation.editor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS +from modules.nodeCatalog.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS # Typed FeatureInstance binding (replaces legacy `string, hidden`). # - type FeatureInstanceRef[redmine] is filtered by the DataPicker. diff --git a/modules/workflowAutomation/editor/nodeDefinitions/sharepoint.py b/modules/nodeCatalog/nodeDefinitions/sharepoint.py similarity index 99% rename from modules/workflowAutomation/editor/nodeDefinitions/sharepoint.py rename to modules/nodeCatalog/nodeDefinitions/sharepoint.py index db48d8db..ae56f9a6 100644 --- a/modules/workflowAutomation/editor/nodeDefinitions/sharepoint.py +++ b/modules/nodeCatalog/nodeDefinitions/sharepoint.py @@ -3,7 +3,7 @@ from modules.shared.i18nRegistry import t -from modules.workflowAutomation.editor.nodeDefinitions.ai import ( +from modules.nodeCatalog.nodeDefinitions.ai import ( ACTION_RESULT_DATA_PICK_OPTIONS, DOCUMENT_LIST_DATA_PICK_OPTIONS, ) diff --git a/modules/workflowAutomation/editor/nodeDefinitions/triggers.py b/modules/nodeCatalog/nodeDefinitions/triggers.py similarity index 96% rename from modules/workflowAutomation/editor/nodeDefinitions/triggers.py rename to modules/nodeCatalog/nodeDefinitions/triggers.py index 0ae34ff2..deeae7a0 100644 --- a/modules/workflowAutomation/editor/nodeDefinitions/triggers.py +++ b/modules/nodeCatalog/nodeDefinitions/triggers.py @@ -3,7 +3,7 @@ from modules.shared.i18nRegistry import t -from modules.workflowAutomation.editor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS +from modules.nodeCatalog.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS TRIGGER_NODES = [ { diff --git a/modules/workflowAutomation/editor/nodeDefinitions/trustee.py b/modules/nodeCatalog/nodeDefinitions/trustee.py similarity index 98% rename from modules/workflowAutomation/editor/nodeDefinitions/trustee.py rename to modules/nodeCatalog/nodeDefinitions/trustee.py index a8c390a8..b0521696 100644 --- a/modules/workflowAutomation/editor/nodeDefinitions/trustee.py +++ b/modules/nodeCatalog/nodeDefinitions/trustee.py @@ -3,7 +3,7 @@ from modules.shared.i18nRegistry import t -from modules.workflowAutomation.editor.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS +from modules.nodeCatalog.nodeDefinitions.ai import ACTION_RESULT_DATA_PICK_OPTIONS # Typed FeatureInstance binding (replaces legacy `string, hidden`). # - type uses the discriminator notation `FeatureInstanceRef[]` so the diff --git a/modules/workflowAutomation/editor/portTypes.py b/modules/nodeCatalog/portTypes.py similarity index 99% rename from modules/workflowAutomation/editor/portTypes.py rename to modules/nodeCatalog/portTypes.py index 6246896e..aa8f4385 100644 --- a/modules/workflowAutomation/editor/portTypes.py +++ b/modules/nodeCatalog/portTypes.py @@ -418,7 +418,7 @@ def deriveFormPayloadSchemaFromParam( - Group-fields: ``type == "group"`` recursed via ``fields``. - List[str]: each string is taken as a leaf path key (used for ``filterContext.keys``). """ - from modules.workflowAutomation.editor.nodeDefinitions.input import FORM_FIELD_TYPES + from modules.nodeCatalog.nodeDefinitions.input import FORM_FIELD_TYPES _FORM_TYPE_TO_PORT: Dict[str, str] = {f["id"]: f["portType"] for f in FORM_FIELD_TYPES} fields_param = (node.get("parameters") or {}).get(param_key) diff --git a/modules/routes/routeWorkflowAutomation.py b/modules/routes/routeWorkflowAutomation.py index ee5d4ac1..81c009fb 100644 --- a/modules/routes/routeWorkflowAutomation.py +++ b/modules/routes/routeWorkflowAutomation.py @@ -12,22 +12,32 @@ RBAC model: - isPlatformAdmin bypasses all checks """ +import asyncio +import json import logging +import math +import time import uuid from typing import Optional, List, Dict, Any from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Request +from fastapi.responses import JSONResponse, Response, StreamingResponse from slowapi import Limiter from slowapi.util import get_remote_address from modules.auth.authentication import getRequestContext, RequestContext from modules.connectors.connectorDbPostgre import DatabaseConnector +from modules.datamodels.datamodelPagination import PaginationParams, PaginationMetadata, normalize_pagination_dict from modules.datamodels.datamodelWorkflowAutomation import ( AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask, ) +from modules.dbHelpers.paginationHelpers import applyFiltersAndSort +from modules.interfaces.interfaceDbApp import getRootInterface from modules.shared.i18nRegistry import apiRouteContext, resolveText -from modules.shared.workflowAutomationHelpers import ( +from modules.workflowAutomation.helpers import ( _getWorkflowAutomationDb, + _getUserMandateIds, + _isUserMandateAdmin, _validateWorkflowAccess, _scopedWorkflowFilter, _scopedRunFilter, @@ -736,7 +746,7 @@ def _importWorkflow( if not context.user: raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) - from modules.workflowAutomation.editor._workflowFileSchema import WorkflowFileSchemaError + from modules.nodeCatalog._workflowFileSchema import WorkflowFileSchemaError mandateId = body.get("mandateId") if isinstance(body, dict) else None userId = str(context.user.id) @@ -799,7 +809,7 @@ def _exportWorkflow( if not context.user: raise HTTPException(status_code=401, detail=routeApiMsg("Authentication required")) - from modules.workflowAutomation.editor._workflowFileSchema import buildFileName + from modules.nodeCatalog._workflowFileSchema import buildFileName db = _getWorkflowAutomationDb() try: @@ -909,7 +919,7 @@ def _getFeatureInstanceOptions( targetMandateIds = userMandateIds if not context.isPlatformAdmin else [] if context.isPlatformAdmin: try: - from modules.datamodels.datamodelMandate import Mandate + from modules.datamodels.datamodelUam import Mandate mandates = rootInterface.db.getRecordset(Mandate) or [] targetMandateIds = [str(m.get("id") if isinstance(m, dict) else getattr(m, "id", "")) for m in mandates] except Exception: @@ -1038,7 +1048,7 @@ async def _getRunStream( else: raise HTTPException(status_code=403, detail=routeApiMsg("Access denied")) - from modules.serviceCenter.core.serviceStreaming.eventManager import get_event_manager + from modules.shared.eventManager import get_event_manager sseEventManager = get_event_manager() queueId = f"run-trace-{runId}" sseEventManager.create_queue(queueId) diff --git a/modules/serviceCenter/core/serviceStreaming/__init__.py b/modules/serviceCenter/core/serviceStreaming/__init__.py index ae7f582b..18a34f4e 100644 --- a/modules/serviceCenter/core/serviceStreaming/__init__.py +++ b/modules/serviceCenter/core/serviceStreaming/__init__.py @@ -2,7 +2,6 @@ # All rights reserved. """Streaming core service for SSE event management.""" -from .eventManager import EventManager, get_event_manager from .mainServiceStreaming import StreamingService -__all__ = ["EventManager", "get_event_manager", "StreamingService"] +__all__ = ["StreamingService"] diff --git a/modules/serviceCenter/core/serviceStreaming/eventManager.py b/modules/serviceCenter/core/serviceStreaming/eventManager.py deleted file mode 100644 index 823dbda1..00000000 --- a/modules/serviceCenter/core/serviceStreaming/eventManager.py +++ /dev/null @@ -1,8 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -"""Re-export shim — canonical source is modules.shared.eventManager.""" - -from modules.shared.eventManager import ( # noqa: F401 - EventManager, - get_event_manager, -) diff --git a/modules/serviceCenter/core/serviceStreaming/mainServiceStreaming.py b/modules/serviceCenter/core/serviceStreaming/mainServiceStreaming.py index ee534706..c6c7ddf7 100644 --- a/modules/serviceCenter/core/serviceStreaming/mainServiceStreaming.py +++ b/modules/serviceCenter/core/serviceStreaming/mainServiceStreaming.py @@ -8,7 +8,7 @@ Core service - not requested by features directly. import logging from typing import Any, Callable -from modules.serviceCenter.core.serviceStreaming.eventManager import EventManager, get_event_manager +from modules.shared.eventManager import EventManager, get_event_manager logger = logging.getLogger(__name__) diff --git a/modules/serviceCenter/services/serviceAgent/externalToolRegistry.py b/modules/serviceCenter/services/serviceAgent/externalToolRegistry.py new file mode 100644 index 00000000..cfa24a2b --- /dev/null +++ b/modules/serviceCenter/services/serviceAgent/externalToolRegistry.py @@ -0,0 +1,39 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +External agent-tool provider registry. + +Allows higher-layer components (e.g. ``workflowAutomation``) to register their +agent tool definitions WITHOUT the agent service importing them. The agent +reads registered definitions by toolbox id during toolbox activation. + +This inverts the dependency: ``workflowAutomation -> serviceCenter`` (push at +boot), instead of ``serviceCenter -> workflowAutomation`` (pull). The agent +never imports workflowAutomation. + +Tool definition dicts are the same shape consumed by +``ToolRegistry.registerFromDefinition`` (``name``, ``description``, +``parameters``, optional ``handler`` / ``readOnly`` / ``toolSet`` ...). +""" + +import logging +from typing import Dict, List, Any + +logger = logging.getLogger(__name__) + +_externalTools: Dict[str, List[Dict[str, Any]]] = {} + + +def registerExternalTools(toolboxId: str, toolDefinitions: List[Dict[str, Any]]) -> None: + """Register agent tool definitions (with handlers) for a toolbox id.""" + _externalTools[toolboxId] = list(toolDefinitions or []) + logger.info( + "Registered %d external agent tool(s) for toolbox '%s'", + len(_externalTools[toolboxId]), + toolboxId, + ) + + +def getExternalTools(toolboxId: str) -> List[Dict[str, Any]]: + """Return registered tool definitions for a toolbox id (empty if none).""" + return _externalTools.get(toolboxId, []) diff --git a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py index 6620f219..e0c57496 100644 --- a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py +++ b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py @@ -420,20 +420,21 @@ class AgentService: for tb in activeToolboxes: activeToolNames.update(tb.tools) + from modules.serviceCenter.services.serviceAgent.externalToolRegistry import getExternalTools + from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolDefinition for tb in activeToolboxes: - if tb.id == "workflow": - try: - from modules.serviceCenter.services.serviceAgent.workflowTools import getWorkflowToolDefinitions - from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolDefinition - wfDefs = getWorkflowToolDefinitions() - for rawDef in wfDefs: - handler = rawDef.get("handler") - defFields = {k: v for k, v in rawDef.items() if k != "handler"} - toolDef = ToolDefinition(**defFields) - registry.registerFromDefinition(toolDef, handler) - logger.info("Registered %d workflow tools from toolbox", len(wfDefs)) - except Exception as e: - logger.warning("Could not register workflow tools: %s", e) + extDefs = getExternalTools(tb.id) + if not extDefs: + continue + try: + for rawDef in extDefs: + handler = rawDef.get("handler") + defFields = {k: v for k, v in rawDef.items() if k != "handler"} + toolDef = ToolDefinition(**defFields) + registry.registerFromDefinition(toolDef, handler) + logger.info("Registered %d external tool(s) for toolbox '%s'", len(extDefs), tb.id) + except Exception as e: + logger.warning("Could not register external tools for toolbox '%s': %s", tb.id, e) inactiveToolNames = set() for tb in tbRegistry.getAllToolboxes(): diff --git a/modules/serviceHub/__init__.py b/modules/serviceHub/__init__.py deleted file mode 100644 index 14021394..00000000 --- a/modules/serviceHub/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# Re-export shim — canonical source: modules.serviceCenter.serviceHub -from modules.serviceCenter.serviceHub import ( # noqa: F401 - PublicService, - ServiceHub, - Services, - getInterface, -) diff --git a/modules/system/i18nBootSync.py b/modules/system/i18nBootSync.py index 3a32bee2..820376b1 100644 --- a/modules/system/i18nBootSync.py +++ b/modules/system/i18nBootSync.py @@ -242,7 +242,7 @@ def _registerNodeLabels(): added += 1 try: - from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES + from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES for nd in STATIC_NODE_TYPES: _reg(_extractRegistrySourceText(nd.get("label")), "node.label") _reg(_extractRegistrySourceText(nd.get("description")), "node.desc") @@ -265,7 +265,7 @@ def _registerNodeLabels(): pass try: - from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG + from modules.nodeCatalog.portTypes import PORT_TYPE_CATALOG for schema in PORT_TYPE_CATALOG.values(): for field in getattr(schema, "fields", []) or []: desc = getattr(field, "description", None) diff --git a/modules/serviceCenter/services/serviceAgent/workflowTools.py b/modules/workflowAutomation/agentTools.py similarity index 99% rename from modules/serviceCenter/services/serviceAgent/workflowTools.py rename to modules/workflowAutomation/agentTools.py index c1d3bf1e..88ac3a05 100644 --- a/modules/serviceCenter/services/serviceAgent/workflowTools.py +++ b/modules/workflowAutomation/agentTools.py @@ -436,7 +436,7 @@ async def _listAvailableNodeTypes(params: Dict[str, Any], context: Any) -> ToolR """ name = "listAvailableNodeTypes" try: - from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES + from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES nodeTypes = [] for n in STATIC_NODE_TYPES: if not isinstance(n, dict): @@ -462,7 +462,7 @@ async def _describeNodeType(params: Dict[str, Any], context: Any) -> ToolResult: nodeType = params.get("nodeType") or params.get("id") if not nodeType: return _err(name, "nodeType required") - from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES + from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES target: Dict[str, Any] = {} for n in STATIC_NODE_TYPES: if isinstance(n, dict) and n.get("id") == nodeType: @@ -875,7 +875,7 @@ async def _exportWorkflowToFile(params: Dict[str, Any], context: Any) -> ToolRes envelope = iface.exportWorkflowToDict(workflowId) if envelope is None: return _err(name, f"Workflow {workflowId} not found") - from modules.workflowAutomation.editor._workflowFileSchema import buildFileName + from modules.nodeCatalog._workflowFileSchema import buildFileName return _ok(name, { "fileName": buildFileName(envelope.get("label", "workflow")), "envelope": envelope, diff --git a/modules/workflowAutomation/editor/adapterValidator.py b/modules/workflowAutomation/editor/adapterValidator.py index 77d16a91..6e430878 100644 --- a/modules/workflowAutomation/editor/adapterValidator.py +++ b/modules/workflowAutomation/editor/adapterValidator.py @@ -26,7 +26,7 @@ from __future__ import annotations from dataclasses import dataclass, field from typing import Any, Dict, List, Mapping -from modules.workflowAutomation.editor.nodeAdapter import ( +from modules.nodeCatalog.nodeAdapter import ( NodeAdapter, _adapterFromLegacyNode, _isMethodBoundNode, diff --git a/modules/workflowAutomation/editor/conditionOperators.py b/modules/workflowAutomation/editor/conditionOperators.py index 3f67440f..e99defc1 100644 --- a/modules/workflowAutomation/editor/conditionOperators.py +++ b/modules/workflowAutomation/editor/conditionOperators.py @@ -8,7 +8,7 @@ import re from datetime import datetime from typing import Any, Dict, List, Optional, Tuple -from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES +from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES from modules.shared.i18nRegistry import resolveText, t logger = logging.getLogger(__name__) diff --git a/modules/workflowAutomation/editor/nodeRegistry.py b/modules/workflowAutomation/editor/nodeRegistry.py index bbddd9f0..7a7ca1a9 100644 --- a/modules/workflowAutomation/editor/nodeRegistry.py +++ b/modules/workflowAutomation/editor/nodeRegistry.py @@ -9,10 +9,10 @@ import logging from typing import Dict, List, Any, Optional from modules.workflowAutomation.editor.conditionOperators import localize_operator_catalog -from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES -from modules.workflowAutomation.editor.nodeDefinitions.input import FORM_FIELD_TYPES -from modules.workflowAutomation.editor.nodeAdapter import bindsActionFromLegacy -from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG, SYSTEM_VARIABLES +from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES +from modules.nodeCatalog.nodeDefinitions.input import FORM_FIELD_TYPES +from modules.nodeCatalog.nodeAdapter import bindsActionFromLegacy +from modules.nodeCatalog.portTypes import PORT_TYPE_CATALOG, SYSTEM_VARIABLES from modules.shared.i18nRegistry import normalizePrimaryLanguageTag, resolveText logger = logging.getLogger(__name__) diff --git a/modules/workflowAutomation/editor/switchOutput.py b/modules/workflowAutomation/editor/switchOutput.py index e7cc830b..b70c5eb1 100644 --- a/modules/workflowAutomation/editor/switchOutput.py +++ b/modules/workflowAutomation/editor/switchOutput.py @@ -7,7 +7,7 @@ import copy import re from typing import Any, Dict, List, Optional -from modules.workflowAutomation.editor.portTypes import unwrapTransit +from modules.nodeCatalog.portTypes import unwrapTransit _CONTEXT_FILTER_OPERATORS = frozenset({"contains_content"}) _BLOB_IMAGE_CHUNK_RE = re.compile(r"^\[image(?:\:([^\]]+))?\]$") diff --git a/modules/workflowAutomation/editor/upstreamPathsService.py b/modules/workflowAutomation/editor/upstreamPathsService.py index f3d2a6ab..3639b7b7 100644 --- a/modules/workflowAutomation/editor/upstreamPathsService.py +++ b/modules/workflowAutomation/editor/upstreamPathsService.py @@ -5,8 +5,8 @@ from __future__ import annotations from typing import Any, Dict, List, Set from modules.workflowAutomation.editor.conditionOperators import catalog_type_to_value_kind, resolve_value_kind -from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES -from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG, PortSchema, parse_graph_defined_output_schema +from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES +from modules.nodeCatalog.portTypes import PORT_TYPE_CATALOG, PortSchema, parse_graph_defined_output_schema from modules.workflowAutomation.engine.graphUtils import buildConnectionMap, getLoopBodyNodeIds, getLoopDoneNodeIds _NODE_BY_TYPE = {n["id"]: n for n in STATIC_NODE_TYPES} diff --git a/modules/workflowAutomation/engine/executionEngine.py b/modules/workflowAutomation/engine/executionEngine.py index cbe572da..443de25d 100644 --- a/modules/workflowAutomation/engine/executionEngine.py +++ b/modules/workflowAutomation/engine/executionEngine.py @@ -29,8 +29,8 @@ from modules.workflowAutomation.engine.executors import ( PauseForHumanTaskError, PauseForEmailWaitError, ) -from modules.workflowAutomation.editor.portTypes import normalizeToSchema, wrapTransit, unwrapTransit -from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES +from modules.nodeCatalog.portTypes import normalizeToSchema, wrapTransit, unwrapTransit +from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES from modules.datamodels.serviceExceptions import SubscriptionInactiveException as _SubscriptionInactiveException, BillingContextError as _BillingContextError from modules.workflowAutomation.engine.runFileLogger import ( RunFileLogger, diff --git a/modules/workflowAutomation/engine/executors/actionNodeExecutor.py b/modules/workflowAutomation/engine/executors/actionNodeExecutor.py index 41c88a5d..dc88c7ab 100644 --- a/modules/workflowAutomation/engine/executors/actionNodeExecutor.py +++ b/modules/workflowAutomation/engine/executors/actionNodeExecutor.py @@ -13,7 +13,7 @@ import re import time from typing import Any, Dict, Optional -from modules.workflowAutomation.editor.portTypes import ( +from modules.nodeCatalog.portTypes import ( _normalizeError, normalizeToSchema, ) @@ -35,6 +35,7 @@ def _attach_unified_presentation_data(out: Dict[str, Any], *, node_def: Dict[str """Ensure ``out[\"data\"]`` carries ``context.extractContent.presentation.v1`` for ``file.create``.""" if node_def.get("skipUnifiedPresentation"): return + node_type = node_def.get("type") or node_def.get("nodeType") data = out.get("data") if isinstance(data, dict) and data.get("kind") == PRESENTATION_KIND: return @@ -181,7 +182,7 @@ def _isUserConnectionId(val: Any) -> bool: def _getNodeDefinition(nodeType: str) -> Optional[Dict[str, Any]]: """Get node definition by type id.""" - from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES + from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES for node in STATIC_NODE_TYPES: if node.get("id") == nodeType: return node @@ -304,7 +305,7 @@ 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.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG + from modules.nodeCatalog.portTypes import PORT_TYPE_CATALOG schema = PORT_TYPE_CATALOG.get(outputSchema) return bool(getattr(schema, "carriesConnectionProvenance", False)) @@ -430,7 +431,7 @@ def _resolveUpstreamPayload(nodeId: str, context: Dict[str, Any]) -> Any: the first ``connectionMap`` entry so ``injectUpstreamPayload`` (e.g. ``context.mergeContext`` after ``flow.loop``) still receives data. """ - from modules.workflowAutomation.editor.switchOutput import unwrap_transit_for_port + from modules.workflowAutomation.editor.switchOutput import unwrap_transit_for_port nodeOutputs = context.get("nodeOutputs") or {} connectionMap = context.get("connectionMap") or {} @@ -456,7 +457,7 @@ def _resolveUpstreamPayload(nodeId: str, context: Dict[str, Any]) -> Any: return unwrap_transit_for_port(upstream, src_out) - def _resolveBranchInputs(nodeId: str, context: Dict[str, Any]) -> Dict[int, Any]: +def _resolveBranchInputs(nodeId: str, context: Dict[str, Any]) -> Dict[int, Any]: """Return ``Dict[port_index → unwrapped upstream output]`` for every wired input port.""" from modules.workflowAutomation.editor.switchOutput import unwrap_transit_for_port src_map = (context.get("inputSources") or {}).get(nodeId) or {} diff --git a/modules/workflowAutomation/engine/executors/dataExecutor.py b/modules/workflowAutomation/engine/executors/dataExecutor.py index 3429e650..e22eda6f 100644 --- a/modules/workflowAutomation/engine/executors/dataExecutor.py +++ b/modules/workflowAutomation/engine/executors/dataExecutor.py @@ -4,7 +4,7 @@ import logging from typing import Any, Dict -from modules.workflowAutomation.editor.portTypes import unwrapTransit, wrapTransit +from modules.nodeCatalog.portTypes import unwrapTransit, wrapTransit logger = logging.getLogger(__name__) diff --git a/modules/workflowAutomation/engine/executors/flowExecutor.py b/modules/workflowAutomation/engine/executors/flowExecutor.py index f107a580..7a296204 100644 --- a/modules/workflowAutomation/engine/executors/flowExecutor.py +++ b/modules/workflowAutomation/engine/executors/flowExecutor.py @@ -6,7 +6,7 @@ from datetime import datetime from typing import Any, Dict, List, Optional from modules.workflowAutomation.editor.conditionOperators import apply_condition_operator, resolve_value_kind -from modules.workflowAutomation.editor.portTypes import wrapTransit, unwrapTransit +from modules.nodeCatalog.portTypes import wrapTransit, unwrapTransit logger = logging.getLogger(__name__) diff --git a/modules/workflowAutomation/engine/graphUtils.py b/modules/workflowAutomation/engine/graphUtils.py index 946faafa..c6a4b5cd 100644 --- a/modules/workflowAutomation/engine/graphUtils.py +++ b/modules/workflowAutomation/engine/graphUtils.py @@ -91,7 +91,7 @@ def getLoopPrimaryInputSource( ) -> Optional[Tuple[str, int]]: """Pick the inbound edge for ``flow.loop`` when several wires hit the same input (0). - The Schleifen-Rücklauf vom Rumpf und der „normale" Vorgänger enden auf demselben Port; + The Schleifen-Rücklauf vom Rumpf und der „normale“ Vorgänger enden auf demselben Port; für die Datenzusammenführung (Fertig-Ausgang, Logs) zählt der Vorgänger **außerhalb** des Rumpfes. """ incoming = connectionMap.get(loop_node_id, []) @@ -209,7 +209,7 @@ def parse_graph_defined_schema(node: Dict[str, Any], parameter_key: str) -> Opti Build a JSON-serializable port schema dict from graph parameters (e.g. form ``fields``). Used by tooling and future API surfaces; mirrors ``parse_graph_defined_output_schema`` logic. """ - from modules.workflowAutomation.editor.portTypes import deriveFormPayloadSchemaFromParam + from modules.nodeCatalog.portTypes import deriveFormPayloadSchemaFromParam sch = deriveFormPayloadSchemaFromParam(node, parameter_key) if sch is None: @@ -227,8 +227,8 @@ def _checkPortCompatibility( """ Hard typed-port check: incompatible connections become validation errors. """ - from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES - from modules.workflowAutomation.editor.portTypes import resolve_output_schema_name + from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES + from modules.nodeCatalog.portTypes import resolve_output_schema_name nodeDefMap = {n["id"]: n for n in STATIC_NODE_TYPES} nodeById = {n["id"]: n for n in nodes if n.get("id")} @@ -481,7 +481,7 @@ def resolveParameterReferences( ) if value.get("type") == "system": variable = value.get("variable", "") - from modules.workflowAutomation.editor.portTypes import resolveSystemVariable + from modules.nodeCatalog.portTypes import resolveSystemVariable return resolveSystemVariable(variable, nodeOutputs.get("_context", {})) return { k: resolveParameterReferences( @@ -576,7 +576,7 @@ def extract_wired_document_list(inp: Any) -> Optional[Dict[str, Any]]: """ if inp is None: return None - from modules.workflowAutomation.editor.portTypes import ( + from modules.nodeCatalog.portTypes import ( unwrapTransit, _coerce_document_list_upload_fields, _file_record_to_document, diff --git a/modules/workflowAutomation/engine/pickNotPushMigration.py b/modules/workflowAutomation/engine/pickNotPushMigration.py index 1b3d9249..78bd63c4 100644 --- a/modules/workflowAutomation/engine/pickNotPushMigration.py +++ b/modules/workflowAutomation/engine/pickNotPushMigration.py @@ -16,8 +16,8 @@ import copy import logging from typing import Any, Dict, List, Optional -from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES -from modules.workflowAutomation.editor.portTypes import ( +from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES +from modules.nodeCatalog.portTypes import ( PRIMARY_TEXT_HANDOVER_REF_PATH, resolve_output_schema_name, ) diff --git a/modules/shared/workflowAutomationHelpers.py b/modules/workflowAutomation/helpers.py similarity index 99% rename from modules/shared/workflowAutomationHelpers.py rename to modules/workflowAutomation/helpers.py index 4813c087..21471e6e 100644 --- a/modules/shared/workflowAutomationHelpers.py +++ b/modules/workflowAutomation/helpers.py @@ -202,8 +202,9 @@ def _validateWorkflowAccess( if action == "execute": targetInstanceId = workflow.get("targetFeatureInstanceId") if targetInstanceId: - from modules.interfaces.interfaceFeatureAccess import _hasFeatureAccess - if _hasFeatureAccess(userId, targetInstanceId): + from modules.interfaces.interfaceDbApp import getRootInterface + access = getRootInterface().getFeatureAccess(userId, targetInstanceId) + if access and access.get("enabled"): return adminMandateIds = _getAdminMandateIds(userId, [wfMandateId]) diff --git a/modules/workflowAutomation/mainWorkflowAutomation.py b/modules/workflowAutomation/mainWorkflowAutomation.py index 754d77b5..20c1d4fb 100644 --- a/modules/workflowAutomation/mainWorkflowAutomation.py +++ b/modules/workflowAutomation/mainWorkflowAutomation.py @@ -226,9 +226,24 @@ def _migrateRbacNamespace() -> None: logger.warning(f"RBAC namespace migration failed (non-critical): {e}") +def _registerAgentTools() -> None: + """Push workflow agent tools into the agent's external tool registry. + + Inverts the dependency: workflowAutomation -> serviceCenter (push at boot), + so the agent service never imports workflowAutomation to obtain its tools. + """ + try: + from modules.serviceCenter.services.serviceAgent.externalToolRegistry import registerExternalTools + from modules.workflowAutomation.agentTools import getWorkflowToolDefinitions, TOOLBOX_ID + registerExternalTools(TOOLBOX_ID, getWorkflowToolDefinitions()) + except Exception as e: + logger.warning(f"Could not register workflow agent tools (non-critical): {e}") + + def onBootstrap() -> None: """Seed system workflow templates and sync feature template workflows on boot.""" _migrateRbacNamespace() + _registerAgentTools() from modules.datamodels.datamodelWorkflowAutomation import GRAPHICAL_EDITOR_DATABASE, AutoWorkflow from modules.connectors.connectorDbPostgre import DatabaseConnector diff --git a/modules/workflows/automation2/__init__.py b/modules/workflows/automation2/__init__.py deleted file mode 100644 index 28ce2eea..00000000 --- a/modules/workflows/automation2/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# Re-export shim: modules moved to modules.workflowAutomation.engine -# This file preserves backwards compatibility for existing imports. - -from modules.workflowAutomation.engine.executionEngine import * # noqa: F401,F403 -from modules.workflowAutomation.engine.graphUtils import * # noqa: F401,F403 -from modules.workflowAutomation.engine.runEnvelope import * # noqa: F401,F403 -from modules.workflowAutomation.engine.scheduleCron import * # noqa: F401,F403 -from modules.workflowAutomation.engine.runFileLogger import * # noqa: F401,F403 -from modules.workflowAutomation.engine.pickNotPushMigration import * # noqa: F401,F403 -from modules.workflowAutomation.engine.featureInstanceRefMigration import * # noqa: F401,F403 -from modules.workflowAutomation.engine.workflowArtifactVisibility import * # noqa: F401,F403 -from modules.workflowAutomation.engine.clickupTaskUpdateMerge import * # noqa: F401,F403 diff --git a/modules/workflows/automation2/executors/__init__.py b/modules/workflows/automation2/executors/__init__.py deleted file mode 100644 index 1c2b18d4..00000000 --- a/modules/workflows/automation2/executors/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# Re-export shim: executors moved to modules.workflowAutomation.engine.executors -# This file preserves backwards compatibility for existing imports. - -from modules.workflowAutomation.engine.executors import ( # noqa: F401 - TriggerExecutor, - FlowExecutor, - ActionNodeExecutor, - InputExecutor, - DataExecutor, - PauseForHumanTaskError, - PauseForEmailWaitError, -) - -__all__ = [ - "TriggerExecutor", - "FlowExecutor", - "ActionNodeExecutor", - "InputExecutor", - "DataExecutor", - "PauseForHumanTaskError", - "PauseForEmailWaitError", -] diff --git a/modules/workflows/methods/_actionSignatureValidator.py b/modules/workflows/methods/_actionSignatureValidator.py index aeeb49c1..ce43ee7b 100644 --- a/modules/workflows/methods/_actionSignatureValidator.py +++ b/modules/workflows/methods/_actionSignatureValidator.py @@ -25,10 +25,10 @@ from modules.datamodels.datamodelWorkflowActions import ( WorkflowActionDefinition, WorkflowActionParameter, ) -from modules.workflowAutomation.editor.portTypes import ( +from modules.datamodels.datamodelPortTypes import ( PORT_TYPE_CATALOG, PRIMITIVE_TYPES, - _stripContainer, + stripContainer as _stripContainer, ) logger = logging.getLogger(__name__) diff --git a/modules/workflows/methods/methodBase.py b/modules/workflows/methods/methodBase.py index 5ab10077..abc7b9c0 100644 --- a/modules/workflows/methods/methodBase.py +++ b/modules/workflows/methods/methodBase.py @@ -240,7 +240,7 @@ class MethodBase: runtime structural validation is handled by the workflow engine / port-schema layer, not at the action-call boundary. """ - from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG + from modules.datamodels.datamodelPortTypes import PORT_TYPE_CATALOG if expectedType in PORT_TYPE_CATALOG: return value diff --git a/modules/workflows/methods/methodContext/actions/setContext.py b/modules/workflows/methods/methodContext/actions/setContext.py index 62435e38..58925f9e 100644 --- a/modules/workflows/methods/methodContext/actions/setContext.py +++ b/modules/workflows/methods/methodContext/actions/setContext.py @@ -320,18 +320,15 @@ def _pause_for_human_tasks( ) task_id = str((task or {}).get("id") or "") ordered_ids = [n.get("id") for n in (run_context.get("_orderedNodes") or []) if n.get("id")] - from modules.workflowAutomation.engine.runFileLogger import merge_persisted_run_context - _pause_ctx = merge_persisted_run_context( - iface, - run_id, - { - "connectionMap": run_context.get("connectionMap"), - "inputSources": run_context.get("inputSources"), - "orderedNodeIds": ordered_ids, - "pauseReason": "contextAssignment", - }, - ) + prev_ctx = dict((iface.getRun(run_id) or {}).get("context") or {}) + _pause_ctx = { + **prev_ctx, + "connectionMap": run_context.get("connectionMap"), + "inputSources": run_context.get("inputSources"), + "orderedNodeIds": ordered_ids, + "pauseReason": "contextAssignment", + } iface.updateRun( run_id, status="paused", diff --git a/modules/workflows/processing/core/actionExecutor.py b/modules/workflows/processing/core/actionExecutor.py index 1a162922..3156aa4b 100644 --- a/modules/workflows/processing/core/actionExecutor.py +++ b/modules/workflows/processing/core/actionExecutor.py @@ -9,7 +9,7 @@ from typing import Dict, Any, List from modules.datamodels.datamodelChat import ActionResult, ActionItem, TaskStep from modules.datamodels.datamodelChat import ChatWorkflow from modules.workflows.processing.shared.methodDiscovery import methods -from modules.workflows.processing.shared.stateTools import checkWorkflowStopped +from modules.shared.workflowState import checkWorkflowStopped from modules.workflows.processing.shared.parameterValidation import ( InvalidActionParameterError, validateAndCoerceParameters, ) diff --git a/modules/workflows/processing/core/messageCreator.py b/modules/workflows/processing/core/messageCreator.py index cb8e344f..00aebc20 100644 --- a/modules/workflows/processing/core/messageCreator.py +++ b/modules/workflows/processing/core/messageCreator.py @@ -8,7 +8,7 @@ import re from typing import Dict, Any, Optional, List from modules.datamodels.datamodelChat import TaskPlan, TaskStep, ActionResult, ReviewResult from modules.datamodels.datamodelChat import ChatWorkflow -from modules.workflows.processing.shared.stateTools import checkWorkflowStopped +from modules.shared.workflowState import checkWorkflowStopped logger = logging.getLogger(__name__) diff --git a/modules/workflows/processing/core/taskPlanner.py b/modules/workflows/processing/core/taskPlanner.py index 233488fe..8401c2a3 100644 --- a/modules/workflows/processing/core/taskPlanner.py +++ b/modules/workflows/processing/core/taskPlanner.py @@ -10,7 +10,7 @@ from modules.datamodels.datamodelChat import TaskStep, TaskContext, TaskPlan, Wo from modules.workflows.processing.shared.promptGenerationTaskplan import ( generateTaskPlanningPrompt ) -from modules.workflows.processing.shared.stateTools import checkWorkflowStopped +from modules.shared.workflowState import checkWorkflowStopped logger = logging.getLogger(__name__) diff --git a/modules/workflows/processing/modes/modeAutomation.py b/modules/workflows/processing/modes/modeAutomation.py index f48d509e..229bed5b 100644 --- a/modules/workflows/processing/modes/modeAutomation.py +++ b/modules/workflows/processing/modes/modeAutomation.py @@ -13,7 +13,7 @@ from modules.datamodels.datamodelChat import ( ) from modules.datamodels.datamodelChat import ChatWorkflow from modules.workflows.processing.modes.modeBase import BaseMode -from modules.workflows.processing.shared.stateTools import checkWorkflowStopped +from modules.shared.workflowState import checkWorkflowStopped from modules.shared.timeUtils import parseTimestamp logger = logging.getLogger(__name__) diff --git a/modules/workflows/processing/modes/modeDynamic.py b/modules/workflows/processing/modes/modeDynamic.py index b31568a2..045835fa 100644 --- a/modules/workflows/processing/modes/modeDynamic.py +++ b/modules/workflows/processing/modes/modeDynamic.py @@ -15,7 +15,7 @@ from modules.datamodels.datamodelChat import ( ) from modules.datamodels.datamodelChat import ChatWorkflow from modules.workflows.processing.modes.modeBase import BaseMode -from modules.workflows.processing.shared.stateTools import checkWorkflowStopped +from modules.shared.workflowState import checkWorkflowStopped from modules.shared.timeUtils import parseTimestamp from modules.workflows.processing.shared.executionState import TaskExecutionState, shouldContinue from modules.workflows.processing.shared.promptGenerationActionsDynamic import ( diff --git a/modules/workflows/processing/shared/parameterValidation.py b/modules/workflows/processing/shared/parameterValidation.py index ea182212..f8045b28 100644 --- a/modules/workflows/processing/shared/parameterValidation.py +++ b/modules/workflows/processing/shared/parameterValidation.py @@ -64,7 +64,7 @@ def _isRefSchema(typeStr: str) -> bool: """ if not typeStr or not typeStr.endswith("Ref"): return False - from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG + from modules.datamodels.datamodelPortTypes import PORT_TYPE_CATALOG schema = PORT_TYPE_CATALOG.get(typeStr) if schema is None: return False diff --git a/modules/workflows/processing/shared/stateTools.py b/modules/workflows/processing/shared/stateTools.py deleted file mode 100644 index c1614b69..00000000 --- a/modules/workflows/processing/shared/stateTools.py +++ /dev/null @@ -1,10 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -""" -State Tools -Re-exports from modules.shared.workflowState for backward compatibility. -""" - -from modules.shared.workflowState import checkWorkflowStopped, WorkflowStoppedException - -__all__ = ["checkWorkflowStopped", "WorkflowStoppedException"] diff --git a/modules/workflows/processing/workflowProcessor.py b/modules/workflows/processing/workflowProcessor.py index 7f63fc62..d6fa00f0 100644 --- a/modules/workflows/processing/workflowProcessor.py +++ b/modules/workflows/processing/workflowProcessor.py @@ -14,7 +14,7 @@ from modules.datamodels.datamodelChat import ChatWorkflow, WorkflowModeEnum from modules.workflows.processing.modes.modeBase import BaseMode from modules.workflows.processing.modes.modeDynamic import DynamicMode from modules.workflows.processing.modes.modeAutomation import AutomationMode -from modules.workflows.processing.shared.stateTools import checkWorkflowStopped +from modules.shared.workflowState import checkWorkflowStopped from modules.datamodels.datamodelAi import OperationTypeEnum, PriorityEnum, ProcessingModeEnum, AiCallOptions, AiCallRequest from modules.shared.jsonUtils import extractJsonString, repairBrokenJson, parseJsonWithModel from modules.datamodels.datamodelWorkflow import UnderstandingResult diff --git a/modules/workflows/scheduler/__init__.py b/modules/workflows/scheduler/__init__.py deleted file mode 100644 index 4e814ab5..00000000 --- a/modules/workflows/scheduler/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# Re-export shim — real implementation moved to modules.workflowAutomation.scheduler -from modules.workflowAutomation.scheduler.mainScheduler import ( - WorkflowScheduler, - start, - stop, - syncNow, - setMainLoop, - notifyRunFailed, - setOnRunFailedCallback, -) diff --git a/modules/workflows/workflowManager.py b/modules/workflows/workflowManager.py index 379283b8..e983a139 100644 --- a/modules/workflows/workflowManager.py +++ b/modules/workflows/workflowManager.py @@ -14,7 +14,7 @@ from modules.datamodels.datamodelChat import ( ) from modules.datamodels.datamodelChat import TaskContext from modules.workflows.processing.workflowProcessor import WorkflowProcessor -from modules.workflows.processing.shared.stateTools import WorkflowStoppedException, checkWorkflowStopped +from modules.shared.workflowState import WorkflowStoppedException, checkWorkflowStopped logger = logging.getLogger(__name__) diff --git a/scripts/script_migrate_feature_instance_refs.py b/scripts/script_migrate_feature_instance_refs.py index 40f723c1..8af55a6c 100644 --- a/scripts/script_migrate_feature_instance_refs.py +++ b/scripts/script_migrate_feature_instance_refs.py @@ -56,7 +56,7 @@ import psycopg2 # noqa: E402 from psycopg2.extras import Json, RealDictCursor # noqa: E402 from modules.shared.configuration import APP_CONFIG # noqa: E402 -from modules.workflows.automation2.featureInstanceRefMigration import ( # noqa: E402 +from modules.workflowAutomation.engine.featureInstanceRefMigration import ( # noqa: E402 materializeFeatureInstanceRefs, ) diff --git a/tests/eval/runTrusteeBenchmark.py b/tests/eval/runTrusteeBenchmark.py index 3f298173..749bf996 100644 --- a/tests/eval/runTrusteeBenchmark.py +++ b/tests/eval/runTrusteeBenchmark.py @@ -415,7 +415,7 @@ def _bootstrapServices() -> Tuple[Any, str, str]: """ from modules.interfaces.interfaceDbApp import getRootInterface from modules.datamodels.datamodelUam import Mandate - from modules.serviceHub import getInterface as getServices + from modules.serviceCenter.serviceHub import getInterface as getServices rootInterface = getRootInterface() user = rootInterface.currentUser diff --git a/tests/functional/test01_ai_model_selection.py b/tests/functional/test01_ai_model_selection.py index c6a588c9..7c69b927 100644 --- a/tests/functional/test01_ai_model_selection.py +++ b/tests/functional/test01_ai_model_selection.py @@ -19,7 +19,7 @@ _gateway_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ". if _gateway_path not in sys.path: sys.path.insert(0, _gateway_path) -from modules.serviceHub import getInterface as getServices +from modules.serviceCenter.serviceHub import getInterface as getServices from modules.datamodels.datamodelAi import ( AiCallOptions, AiCallRequest, diff --git a/tests/functional/test02_ai_models.py b/tests/functional/test02_ai_models.py index 0a0948cf..32aeed80 100644 --- a/tests/functional/test02_ai_models.py +++ b/tests/functional/test02_ai_models.py @@ -32,7 +32,7 @@ if _gateway_path not in sys.path: sys.path.insert(0, _gateway_path) # Import the service initialization -from modules.serviceHub import getInterface as getServices +from modules.serviceCenter.serviceHub import getInterface as getServices from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum from modules.datamodels.datamodelUam import User diff --git a/tests/functional/test03_ai_operations.py b/tests/functional/test03_ai_operations.py index 4df53c5d..835078f0 100644 --- a/tests/functional/test03_ai_operations.py +++ b/tests/functional/test03_ai_operations.py @@ -101,7 +101,7 @@ class MethodAiOperationsTester: interfaceDbChat = interfaceDbChat.getInterface(self.testUser) # Import and initialize services - from modules.serviceHub import getInterface as getServices + from modules.serviceCenter.serviceHub import getInterface as getServices # Get services first self.services = getServices(self.testUser, None) diff --git a/tests/functional/test04_ai_behavior.py b/tests/functional/test04_ai_behavior.py index 953b52a3..7845733a 100644 --- a/tests/functional/test04_ai_behavior.py +++ b/tests/functional/test04_ai_behavior.py @@ -17,7 +17,7 @@ if _gateway_path not in sys.path: sys.path.insert(0, _gateway_path) # Import the service initialization -from modules.serviceHub import getInterface as getServices +from modules.serviceCenter.serviceHub import getInterface as getServices from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelWorkflow import AiResponse diff --git a/tests/unit/graphicalEditor/test_adapter_validator.py b/tests/unit/graphicalEditor/test_adapter_validator.py index 605251c6..ad507daa 100644 --- a/tests/unit/graphicalEditor/test_adapter_validator.py +++ b/tests/unit/graphicalEditor/test_adapter_validator.py @@ -34,7 +34,7 @@ from modules.workflowAutomation.editor.adapterValidator import ( _validateAdapterAgainstAction, _validateAllAdapters, ) -from modules.workflowAutomation.editor.nodeAdapter import ( +from modules.nodeCatalog.nodeAdapter import ( NodeAdapter, UserParamMapping, ) @@ -254,7 +254,7 @@ def _ensureOptionalDeps(): _LIVE_METHODS = [ ("modules.features.trustee.workflows.methodTrustee.methodTrustee", "MethodTrustee", "trustee"), - ("modules.workflows.methods.methodRedmine.methodRedmine", "MethodRedmine", "redmine"), + ("modules.features.redmine.workflows.methodRedmine.methodRedmine", "MethodRedmine", "redmine"), ("modules.workflows.methods.methodSharepoint.methodSharepoint", "MethodSharepoint", "sharepoint"), ("modules.workflows.methods.methodOutlook.methodOutlook", "MethodOutlook", "outlook"), ("modules.workflows.methods.methodAi.methodAi", "MethodAi", "ai"), @@ -334,7 +334,7 @@ def test_staticNodesHaveNoDriftAgainstLiveMethods(): History: wiki/c-work/4-done/2026-04-adapter-drift-cleanup.md """ - from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES + from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES instances = _instantiateLiveMethods() if not instances: diff --git a/tests/unit/graphicalEditor/test_featureInstanceRef_node_definitions.py b/tests/unit/graphicalEditor/test_featureInstanceRef_node_definitions.py index 525faa4a..e81a0a4f 100644 --- a/tests/unit/graphicalEditor/test_featureInstanceRef_node_definitions.py +++ b/tests/unit/graphicalEditor/test_featureInstanceRef_node_definitions.py @@ -24,8 +24,8 @@ from __future__ import annotations import pytest -from modules.workflowAutomation.editor.nodeDefinitions.redmine import REDMINE_NODES -from modules.workflowAutomation.editor.nodeDefinitions.trustee import TRUSTEE_NODES +from modules.nodeCatalog.nodeDefinitions.redmine import REDMINE_NODES +from modules.nodeCatalog.nodeDefinitions.trustee import TRUSTEE_NODES def _featureInstanceParam(node: dict) -> dict | None: diff --git a/tests/unit/graphicalEditor/test_node_adapter.py b/tests/unit/graphicalEditor/test_node_adapter.py index 3c18f438..634f76d2 100644 --- a/tests/unit/graphicalEditor/test_node_adapter.py +++ b/tests/unit/graphicalEditor/test_node_adapter.py @@ -17,7 +17,7 @@ from __future__ import annotations import pytest -from modules.workflowAutomation.editor.nodeAdapter import ( +from modules.nodeCatalog.nodeAdapter import ( NodeAdapter, UserParamMapping, _adapterFromLegacyNode, diff --git a/tests/unit/graphicalEditor/test_portTypes_catalog.py b/tests/unit/graphicalEditor/test_portTypes_catalog.py index 0506be27..9e97d475 100644 --- a/tests/unit/graphicalEditor/test_portTypes_catalog.py +++ b/tests/unit/graphicalEditor/test_portTypes_catalog.py @@ -6,7 +6,7 @@ Catalog integrity + new Phase-1 schemas import pytest -from modules.workflowAutomation.editor.portTypes import ( +from modules.nodeCatalog.portTypes import ( PORT_TYPE_CATALOG, PRIMITIVE_TYPES, PortField, diff --git a/tests/unit/graphicalEditor/test_port_schema_recursive.py b/tests/unit/graphicalEditor/test_port_schema_recursive.py index 7884109e..cd32e461 100644 --- a/tests/unit/graphicalEditor/test_port_schema_recursive.py +++ b/tests/unit/graphicalEditor/test_port_schema_recursive.py @@ -1,7 +1,7 @@ # Copyright (c) 2025 Patrick Motsch """Port type catalog: nested provenance schemas (Typed Generic Handover).""" -from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG, _defaultForType +from modules.nodeCatalog.portTypes import PORT_TYPE_CATALOG, _defaultForType def test_connection_ref_in_catalog(): diff --git a/tests/unit/graphicalEditor/test_upstream_paths_and_graph_schema.py b/tests/unit/graphicalEditor/test_upstream_paths_and_graph_schema.py index 8e64367e..6c6ff2cc 100644 --- a/tests/unit/graphicalEditor/test_upstream_paths_and_graph_schema.py +++ b/tests/unit/graphicalEditor/test_upstream_paths_and_graph_schema.py @@ -1,7 +1,7 @@ # Copyright (c) 2025 Patrick Motsch from modules.workflowAutomation.editor.upstreamPathsService import compute_upstream_paths from modules.workflowAutomation.engine.graphUtils import parse_graph_defined_schema, validateGraph -from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES +from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES def test_compute_upstream_paths_includes_form_dynamic_fields(): diff --git a/tests/unit/methods/test_action_signature_validator.py b/tests/unit/methods/test_action_signature_validator.py index fa4aa71f..a959989e 100644 --- a/tests/unit/methods/test_action_signature_validator.py +++ b/tests/unit/methods/test_action_signature_validator.py @@ -256,7 +256,7 @@ def _instantiateMethod(methodCls): @pytest.mark.parametrize("modulePath,className", [ ("modules.features.trustee.workflows.methodTrustee.methodTrustee", "MethodTrustee"), - ("modules.workflows.methods.methodRedmine.methodRedmine", "MethodRedmine"), + ("modules.features.redmine.workflows.methodRedmine.methodRedmine", "MethodRedmine"), ("modules.workflows.methods.methodSharepoint.methodSharepoint", "MethodSharepoint"), ("modules.workflows.methods.methodOutlook.methodOutlook", "MethodOutlook"), ("modules.workflows.methods.methodAi.methodAi", "MethodAi"), diff --git a/tests/unit/nodeDefinitions/test_trustee_schema_compliance.py b/tests/unit/nodeDefinitions/test_trustee_schema_compliance.py index 36038ee1..060d04a6 100644 --- a/tests/unit/nodeDefinitions/test_trustee_schema_compliance.py +++ b/tests/unit/nodeDefinitions/test_trustee_schema_compliance.py @@ -20,8 +20,8 @@ Verifies that: import inspect -from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES -from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG +from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES +from modules.nodeCatalog.portTypes import PORT_TYPE_CATALOG from modules.workflowAutomation.engine.executors import actionNodeExecutor as _actionExec from modules.workflowAutomation.engine.graphUtils import validateGraph diff --git a/tests/unit/nodeDefinitions/test_usesai_flag.py b/tests/unit/nodeDefinitions/test_usesai_flag.py index bf578fd0..caf07960 100644 --- a/tests/unit/nodeDefinitions/test_usesai_flag.py +++ b/tests/unit/nodeDefinitions/test_usesai_flag.py @@ -2,7 +2,7 @@ import pytest -from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES +from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES def test_all_nodes_have_usesAi(): diff --git a/tests/unit/serviceAgent/test_workflow_tools_crud.py b/tests/unit/serviceAgent/test_workflow_tools_crud.py index b578b1de..41e56ab6 100644 --- a/tests/unit/serviceAgent/test_workflow_tools_crud.py +++ b/tests/unit/serviceAgent/test_workflow_tools_crud.py @@ -22,7 +22,7 @@ from typing import Any, Dict, Optional import pytest -from modules.serviceCenter.services.serviceAgent import workflowTools +from modules.workflowAutomation import agentTools as workflowTools from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResult diff --git a/tests/unit/workflow/test_node_combinations.py b/tests/unit/workflow/test_node_combinations.py index 15159048..b4857a14 100644 --- a/tests/unit/workflow/test_node_combinations.py +++ b/tests/unit/workflow/test_node_combinations.py @@ -14,8 +14,8 @@ import json import pytest -from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES -from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG +from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES +from modules.nodeCatalog.portTypes import PORT_TYPE_CATALOG from modules.workflows.methods.methodContext.actions.extractContent import ( PRESENTATION_KIND, build_presentation_envelope_from_plain_text, diff --git a/tests/unit/workflow/test_phase3_context_node.py b/tests/unit/workflow/test_phase3_context_node.py index 49500bc2..5f113d5e 100644 --- a/tests/unit/workflow/test_phase3_context_node.py +++ b/tests/unit/workflow/test_phase3_context_node.py @@ -1,8 +1,8 @@ # Tests for Phase 3: context.extractContent node, port types, executor dispatch. import pytest -from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES -from modules.workflowAutomation.editor.portTypes import PORT_TYPE_CATALOG +from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES +from modules.nodeCatalog.portTypes import PORT_TYPE_CATALOG from modules.workflowAutomation.engine.udmUpstreamShapes import ( _coerceConsolidateResultInput, _coerceUdmDocumentInput, diff --git a/tests/unit/workflow/test_phase4_workflow_nodes.py b/tests/unit/workflow/test_phase4_workflow_nodes.py index 24a29d1f..3ca0792d 100644 --- a/tests/unit/workflow/test_phase4_workflow_nodes.py +++ b/tests/unit/workflow/test_phase4_workflow_nodes.py @@ -1,7 +1,7 @@ # Tests for Phase 4: data.consolidate, ai.consolidate, flow.loop level/concurrency, flow.merge dynamic. import pytest -from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES +from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES class TestNodeDefinitions: diff --git a/tests/unit/workflow/test_phase5_highvol.py b/tests/unit/workflow/test_phase5_highvol.py index 45079fb4..44c51d76 100644 --- a/tests/unit/workflow/test_phase5_highvol.py +++ b/tests/unit/workflow/test_phase5_highvol.py @@ -1,7 +1,7 @@ # Tests for Phase 5: Loop concurrency, StepLog batching, streaming aggregate. import pytest -from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES +from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES def test_loop_concurrency_param_default_1(): diff --git a/tests/unit/workflow/test_switch_filtered_output.py b/tests/unit/workflow/test_switch_filtered_output.py index 334a8e81..ee9271d9 100644 --- a/tests/unit/workflow/test_switch_filtered_output.py +++ b/tests/unit/workflow/test_switch_filtered_output.py @@ -3,7 +3,7 @@ import pytest -from modules.workflowAutomation.editor.portTypes import unwrapTransit, wrapTransit +from modules.nodeCatalog.portTypes import unwrapTransit, wrapTransit from modules.workflowAutomation.editor.switchOutput import ( build_switch_branch_payload, build_switch_combined_output, diff --git a/tests/unit/workflow/test_workflowFileSchema.py b/tests/unit/workflow/test_workflowFileSchema.py index e7109cbc..3eb0fb2c 100644 --- a/tests/unit/workflow/test_workflowFileSchema.py +++ b/tests/unit/workflow/test_workflowFileSchema.py @@ -4,7 +4,7 @@ import pytest -from modules.workflowAutomation.editor._workflowFileSchema import ( +from modules.nodeCatalog._workflowFileSchema import ( WORKFLOW_FILE_KIND, WORKFLOW_FILE_SCHEMA_VERSION, WorkflowFileSchemaError, diff --git a/tests/unit/workflows/test_automation2_graphUtils.py b/tests/unit/workflows/test_automation2_graphUtils.py index 0ee29412..179857c1 100644 --- a/tests/unit/workflows/test_automation2_graphUtils.py +++ b/tests/unit/workflows/test_automation2_graphUtils.py @@ -38,7 +38,7 @@ class TestValidateGraphStartNode: def test_switch_second_output_to_ai_prompt_ok(self): - from modules.workflowAutomation.editor.nodeDefinitions import STATIC_NODE_TYPES + from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES node_type_ids = {n["id"] for n in STATIC_NODE_TYPES} graph = { From e0caad0a75a6ace5e6dfe01ef8549f9f3db918c1 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Mon, 8 Jun 2026 20:45:51 +0200 Subject: [PATCH 08/16] import fixes --- modules/auth/jwtService.py | 2 +- .../commcoach/routeFeatureCommcoach.py | 12 +- modules/routes/routeRealEstate.py | 2339 ----------------- modules/routes/routeRealEstateScraping.py | 881 ------- .../services/serviceAi/subDocumentIntents.py | 2 +- .../serviceAi/subJsonResponseHandling.py | 12 +- .../renderers/rendererPptx.py | 5 +- .../engine/executors/actionNodeExecutor.py | 1 - .../engine/executors/flowExecutor.py | 4 +- 9 files changed, 21 insertions(+), 3237 deletions(-) delete mode 100644 modules/routes/routeRealEstate.py delete mode 100644 modules/routes/routeRealEstateScraping.py diff --git a/modules/auth/jwtService.py b/modules/auth/jwtService.py index 6ea4535d..04071053 100644 --- a/modules/auth/jwtService.py +++ b/modules/auth/jwtService.py @@ -5,7 +5,7 @@ JWT Service Centralizes local JWT creation and cookie helpers. """ -from datetime import timedelta +from datetime import datetime, timedelta from typing import Optional, Tuple from fastapi import Response from jose import jwt diff --git a/modules/features/commcoach/routeFeatureCommcoach.py b/modules/features/commcoach/routeFeatureCommcoach.py index a60db504..c7759900 100644 --- a/modules/features/commcoach/routeFeatureCommcoach.py +++ b/modules/features/commcoach/routeFeatureCommcoach.py @@ -14,7 +14,7 @@ import uuid from typing import Optional -from fastapi import APIRouter, HTTPException, Depends, Request, WebSocket, WebSocketDisconnect, Query +from fastapi import APIRouter, HTTPException, Depends, Request, Query from fastapi.responses import StreamingResponse, Response from modules.auth import limiter, getRequestContext, RequestContext @@ -27,13 +27,13 @@ from .datamodelCommcoach import ( TrainingModule, TrainingModuleStatus, CoachingSession, CoachingSessionStatus, CoachingMessage, CoachingMessageRole, CoachingMessageContentType, CoachingTask, CoachingTaskStatus, - CoachingPersona, CoachingBadge, ModulePersonaMapping, + CoachingPersona, CreateModuleRequest, UpdateModuleRequest, SendMessageRequest, CreateTaskRequest, UpdateTaskRequest, UpdateTaskStatusRequest, UpdateProfileRequest, - StartSessionRequest, CreatePersonaRequest, UpdatePersonaRequest, SetModulePersonasRequest, + CreatePersonaRequest, UpdatePersonaRequest, SetModulePersonasRequest, ) -from .serviceCommcoach import CommcoachService, emitSessionEvent, getSessionEventQueue, cleanupSessionEvents +from .serviceCommcoach import CommcoachService, emitSessionEvent, getSessionEventQueue from modules.shared.i18nRegistry import apiRouteContext routeApiMsg = apiRouteContext("routeFeatureCommcoach") logger = logging.getLogger(__name__) @@ -104,7 +104,7 @@ async def listModules( context: RequestContext = Depends(getRequestContext), ): """List all training modules for the current user.""" - mandateId = _validateInstanceAccess(instanceId, context) + _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) userId = str(context.user.id) modules = interface.getModules(instanceId, userId, includeArchived=includeArchived) @@ -349,7 +349,7 @@ async def startSession( yield f"data: {json.dumps({'type': 'ttsAudio', 'data': {'audio': audioB64, 'format': 'mp3'}})}\n\n" else: errorDetail = ttsResult.get("error", "Text-to-Speech failed") - yield f"data: {json.dumps({'type': 'error', 'data': {'message': _buildTtsConfigErrorMessage(language, voiceName, errorDetail), 'detail': errorDetail, 'ttsLanguage': language, 'ttsVoice': voiceName}})}\n\n" + yield f"data: {json.dumps({'type': 'error', 'data': {'message': buildTtsConfigErrorMessage(language, voiceName, errorDetail), 'detail': errorDetail, 'ttsLanguage': language, 'ttsVoice': voiceName}})}\n\n" except Exception as e: logger.warning(f"TTS failed for resumed session: {e}") yield f"data: {json.dumps({'type': 'error', 'data': {'message': 'Die konfigurierte Stimme für diese Sprache ist ungültig oder nicht verfügbar. Bitte passe sie unter Einstellungen > Stimme & Sprache an.', 'detail': str(e)}})}\n\n" diff --git a/modules/routes/routeRealEstate.py b/modules/routes/routeRealEstate.py deleted file mode 100644 index 81550de2..00000000 --- a/modules/routes/routeRealEstate.py +++ /dev/null @@ -1,2339 +0,0 @@ -""" -Real Estate routes for the backend API. -Implements stateless endpoints for real estate database operations with AI-powered natural language processing. -""" - -import logging -import json -import re -import requests -import aiohttp -import asyncio -import ssl -from urllib.parse import urljoin, urlparse -from typing import Optional, Dict, Any, List, Union -from fastapi import APIRouter, HTTPException, Depends, Body, Request, Query, Path, status - -# Import auth modules -from modules.auth import limiter, getCurrentUser - -# Import models -from modules.datamodels.datamodelPagination import ( - PaginationParams, - PaginatedResponse, - PaginationMetadata, - normalize_pagination_dict, -) -from modules.interfaces.interfaceDbApp import getRootInterface -from modules.interfaces.interfaceFeatures import getFeatureInterface -from modules.features.realEstate.datamodelFeatureRealEstate import ( - Projekt, - Parzelle, - Dokument, - Gemeinde, - Kanton, - Land, - Kontext, - StatusProzess, - DokumentTyp, -) - -# Import interfaces -from modules.interfaces.interfaceDbRealEstateObjects import getInterface as getRealEstateInterface -from modules.interfaces.interfaceDbComponentObjects import getInterface as getComponentInterface - -# Import feature logic for AI-powered commands -from modules.features.realEstate.mainRealEstate import ( - processNaturalLanguageCommand, - create_project_with_parcel_data, - extract_bzo_information, -) - -# Import Swiss Topo MapServer connector for testing -from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerConnector -from modules.connectors.connectorOerebWfs import OerebWfsConnector - -# Import Tavily connector for BZO document search -from modules.aicore.aicorePluginTavily import AiTavily - -# Import helper functions from scraping route -from modules.routes.routeRealEstateScraping import ( - _get_language_from_kanton, - _get_bzo_search_query, -) - -# Import attribute utilities for model schema -from modules.shared.attributeUtils import getModelAttributeDefinitions -from modules.shared.i18nRegistry import apiRouteContext -routeApiMsg = apiRouteContext("routeRealEstate") - -# Configure logger -logger = logging.getLogger(__name__) - -# Create router for real estate endpoints -router = APIRouter( - prefix="/api/realestate", - tags=["Real Estate"], - responses={ - 404: {"description": "Not found"}, - 400: {"description": "Bad request"}, - 401: {"description": "Unauthorized"}, - 403: {"description": "Forbidden"}, - 500: {"description": "Internal server error"} - } -) - - -# ===== Helper Functions (instanceId-based routes, backend-driven like Trustee) ===== - -def _parsePagination(pagination: Optional[str]) -> Optional[PaginationParams]: - """Parse pagination parameter from JSON string.""" - if not pagination: - return None - try: - paginationDict = json.loads(pagination) - if paginationDict: - paginationDict = normalize_pagination_dict(paginationDict) - return PaginationParams(**paginationDict) - except (json.JSONDecodeError, ValueError) as e: - raise HTTPException( - status_code=400, - detail=f"Invalid pagination parameter: {str(e)}" - ) - return None - - -async def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str: - """ - Validate that the user has access to the feature instance. - Returns the mandateId for the instance. - """ - rootInterface = getRootInterface() - featureInterface = getFeatureInterface(rootInterface.db) - instance = featureInterface.getFeatureInstance(instanceId) - if not instance: - raise HTTPException( - status_code=404, - detail=f"Feature instance '{instanceId}' not found" - ) - if instance.featureCode != "realestate": - raise HTTPException( - status_code=400, - detail=f"Instance '{instanceId}' is not a realestate instance" - ) - if not context.isPlatformAdmin: - featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id)) - hasAccess = any( - str(fa.featureInstanceId) == instanceId and fa.enabled - for fa in featureAccesses - ) - if not hasAccess: - raise HTTPException( - status_code=403, - detail=f"Access denied to feature instance '{instanceId}'" - ) - return str(instance.mandateId) - - -# Mapping of entity names to Pydantic model classes (for attributes endpoint) -_REALESTATE_ENTITY_MODELS = { - "Projekt": Projekt, - "Parzelle": Parzelle, - "Dokument": Dokument, - "Gemeinde": Gemeinde, - "Kanton": Kanton, - "Land": Land, -} - - -# ============================================================================ -# INSTANCE-ID ROUTES (backend-driven, analog to Trustee) -# ============================================================================ - -@router.get("/{instanceId}/attributes/{entityType}", response_model=Dict[str, Any]) -@limiter.limit("30/minute") -async def get_entity_attributes( - request: Request, - instanceId: str = Path(..., description="Feature Instance ID"), - entityType: str = Path(..., description="Entity type (e.g., Projekt, Parzelle)"), - context: RequestContext = Depends(getRequestContext) -) -> Dict[str, Any]: - """Get attribute definitions for a Real Estate entity. Used by FormGeneratorTable.""" - await _validateInstanceAccess(instanceId, context) - if entityType not in _REALESTATE_ENTITY_MODELS: - raise HTTPException( - status_code=404, - detail=f"Unknown entity type: {entityType}. Valid types: {list(_REALESTATE_ENTITY_MODELS.keys())}" - ) - modelClass = _REALESTATE_ENTITY_MODELS[entityType] - try: - attrDefs = getModelAttributeDefinitions(modelClass) - visibleAttrs = [ - attr for attr in attrDefs.get("attributes", []) - if isinstance(attr, dict) and attr.get("visible", True) - ] - return {"attributes": visibleAttrs} - except Exception as e: - logger.error(f"Error getting attributes for {entityType}: {e}") - raise HTTPException( - status_code=500, - detail=f"Error getting attributes for {entityType}: {str(e)}" - ) - - -@router.get("/{instanceId}/projects/options", response_model=List[Dict[str, Any]]) -@limiter.limit("60/minute") -async def get_project_options( - request: Request, - instanceId: str = Path(..., description="Feature Instance ID"), - context: RequestContext = Depends(getRequestContext) -) -> List[Dict[str, Any]]: - """Get project options for select dropdowns. Returns: [{ value, label }]""" - mandateId = await _validateInstanceAccess(instanceId, context) - interface = getRealEstateInterface( - context.user, mandateId=mandateId, featureInstanceId=instanceId - ) - items = interface.getProjekte(recordFilter={"featureInstanceId": instanceId}) - return [{"value": p.id, "label": getattr(p, "label", None) or p.id} for p in items] - - -@router.get("/{instanceId}/parcels/options", response_model=List[Dict[str, Any]]) -@limiter.limit("60/minute") -async def get_parcel_options( - request: Request, - instanceId: str = Path(..., description="Feature Instance ID"), - context: RequestContext = Depends(getRequestContext) -) -> List[Dict[str, Any]]: - """Get parcel options for select dropdowns. Returns: [{ value, label }]""" - mandateId = await _validateInstanceAccess(instanceId, context) - interface = getRealEstateInterface( - context.user, mandateId=mandateId, featureInstanceId=instanceId - ) - items = interface.getParzellen(recordFilter={"featureInstanceId": instanceId}) - return [{"value": p.id, "label": getattr(p, "label", None) or p.id} for p in items] - - -# ----- Projects CRUD ----- - -@router.get("/{instanceId}/projects", response_model=PaginatedResponse[Projekt]) -@limiter.limit("30/minute") -async def get_projects( - request: Request, - instanceId: str = Path(..., description="Feature Instance ID"), - pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"), - context: RequestContext = Depends(getRequestContext) -) -> PaginatedResponse[Projekt]: - """Get all projects for a feature instance with optional pagination.""" - mandateId = await _validateInstanceAccess(instanceId, context) - interface = getRealEstateInterface( - context.user, mandateId=mandateId, featureInstanceId=instanceId - ) - recordFilter = {"featureInstanceId": instanceId} - paginationParams = _parsePagination(pagination) - if paginationParams: - result = interface.getProjekte(pagination=paginationParams, recordFilter=recordFilter) - if hasattr(result, 'items'): - return PaginatedResponse( - items=result.items, - pagination=PaginationMetadata( - currentPage=paginationParams.page, - pageSize=paginationParams.pageSize, - totalItems=result.totalItems, - totalPages=result.totalPages, - sort=paginationParams.sort or [], - filters=paginationParams.filters - ) - ) - items = interface.getProjekte(recordFilter=recordFilter) - return PaginatedResponse(items=items, pagination=None) - - -@router.get("/{instanceId}/projects/{projectId}", response_model=Projekt) -@limiter.limit("30/minute") -async def get_project_by_id( - request: Request, - instanceId: str = Path(..., description="Feature Instance ID"), - projectId: str = Path(..., description="Project ID"), - context: RequestContext = Depends(getRequestContext) -) -> Projekt: - """Get a single project by ID.""" - mandateId = await _validateInstanceAccess(instanceId, context) - interface = getRealEstateInterface( - context.user, mandateId=mandateId, featureInstanceId=instanceId - ) - projekt = interface.getProjekt(projectId) - if not projekt or str(getattr(projekt, "featureInstanceId", None)) != instanceId: - raise HTTPException(status_code=404, detail=f"Project '{projectId}' not found") - return projekt - - -@router.post("/{instanceId}/projects", response_model=Projekt) -@limiter.limit("30/minute") -async def create_project( - request: Request, - instanceId: str = Path(..., description="Feature Instance ID"), - data: Dict[str, Any] = Body(...), - context: RequestContext = Depends(getRequestContext) -) -> Projekt: - """Create a new project.""" - mandateId = await _validateInstanceAccess(instanceId, context) - interface = getRealEstateInterface( - context.user, mandateId=mandateId, featureInstanceId=instanceId - ) - if "mandateId" not in data: - data["mandateId"] = mandateId - if "featureInstanceId" not in data: - data["featureInstanceId"] = instanceId - try: - projekt = Projekt(**data) - except Exception as e: - raise HTTPException(status_code=400, detail=f"Invalid data: {str(e)}") - return interface.createProjekt(projekt) - - -@router.put("/{instanceId}/projects/{projectId}", response_model=Projekt) -@limiter.limit("30/minute") -async def update_project( - request: Request, - instanceId: str = Path(..., description="Feature Instance ID"), - projectId: str = Path(..., description="Project ID"), - data: Dict[str, Any] = Body(...), - context: RequestContext = Depends(getRequestContext) -) -> Projekt: - """Update a project.""" - mandateId = await _validateInstanceAccess(instanceId, context) - interface = getRealEstateInterface( - context.user, mandateId=mandateId, featureInstanceId=instanceId - ) - projekt = interface.getProjekt(projectId) - if not projekt or str(getattr(projekt, "featureInstanceId", None)) != instanceId: - raise HTTPException(status_code=404, detail=f"Project '{projectId}' not found") - updated = interface.updateProjekt(projectId, data) - if not updated: - raise HTTPException(status_code=500, detail=routeApiMsg("Update failed")) - return updated - - -@router.delete("/{instanceId}/projects/{projectId}", status_code=status.HTTP_204_NO_CONTENT) -@limiter.limit("30/minute") -async def delete_project( - request: Request, - instanceId: str = Path(..., description="Feature Instance ID"), - projectId: str = Path(..., description="Project ID"), - context: RequestContext = Depends(getRequestContext) -) -> None: - """Delete a project.""" - mandateId = await _validateInstanceAccess(instanceId, context) - interface = getRealEstateInterface( - context.user, mandateId=mandateId, featureInstanceId=instanceId - ) - projekt = interface.getProjekt(projectId) - if not projekt or str(getattr(projekt, "featureInstanceId", None)) != instanceId: - raise HTTPException(status_code=404, detail=f"Project '{projectId}' not found") - if not interface.deleteProjekt(projectId): - raise HTTPException(status_code=500, detail=routeApiMsg("Delete failed")) - - -# ----- Parcels CRUD ----- - -@router.get("/{instanceId}/parcels", response_model=PaginatedResponse[Parzelle]) -@limiter.limit("30/minute") -async def get_parcels( - request: Request, - instanceId: str = Path(..., description="Feature Instance ID"), - pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"), - context: RequestContext = Depends(getRequestContext) -) -> PaginatedResponse[Parzelle]: - """Get all parcels for a feature instance with optional pagination.""" - mandateId = await _validateInstanceAccess(instanceId, context) - interface = getRealEstateInterface( - context.user, mandateId=mandateId, featureInstanceId=instanceId - ) - recordFilter = {"featureInstanceId": instanceId} - paginationParams = _parsePagination(pagination) - if paginationParams: - result = interface.getParzellen(pagination=paginationParams, recordFilter=recordFilter) - if hasattr(result, 'items'): - return PaginatedResponse( - items=result.items, - pagination=PaginationMetadata( - currentPage=paginationParams.page, - pageSize=paginationParams.pageSize, - totalItems=result.totalItems, - totalPages=result.totalPages, - sort=paginationParams.sort or [], - filters=paginationParams.filters - ) - ) - items = interface.getParzellen(recordFilter=recordFilter) - return PaginatedResponse(items=items, pagination=None) - - -@router.get("/{instanceId}/parcels/{parcelId}", response_model=Parzelle) -@limiter.limit("30/minute") -async def get_parcel_by_id( - request: Request, - instanceId: str = Path(..., description="Feature Instance ID"), - parcelId: str = Path(..., description="Parcel ID"), - context: RequestContext = Depends(getRequestContext) -) -> Parzelle: - """Get a single parcel by ID.""" - mandateId = await _validateInstanceAccess(instanceId, context) - interface = getRealEstateInterface( - context.user, mandateId=mandateId, featureInstanceId=instanceId - ) - parzelle = interface.getParzelle(parcelId) - if not parzelle or str(getattr(parzelle, "featureInstanceId", None)) != instanceId: - raise HTTPException(status_code=404, detail=f"Parcel '{parcelId}' not found") - return parzelle - - -@router.post("/{instanceId}/parcels", response_model=Parzelle) -@limiter.limit("30/minute") -async def create_parcel( - request: Request, - instanceId: str = Path(..., description="Feature Instance ID"), - data: Dict[str, Any] = Body(...), - context: RequestContext = Depends(getRequestContext) -) -> Parzelle: - """Create a new parcel.""" - mandateId = await _validateInstanceAccess(instanceId, context) - interface = getRealEstateInterface( - context.user, mandateId=mandateId, featureInstanceId=instanceId - ) - if "mandateId" not in data: - data["mandateId"] = mandateId - if "featureInstanceId" not in data: - data["featureInstanceId"] = instanceId - try: - parzelle = Parzelle(**data) - except Exception as e: - raise HTTPException(status_code=400, detail=f"Invalid data: {str(e)}") - return interface.createParzelle(parzelle) - - -@router.put("/{instanceId}/parcels/{parcelId}", response_model=Parzelle) -@limiter.limit("30/minute") -async def update_parcel( - request: Request, - instanceId: str = Path(..., description="Feature Instance ID"), - parcelId: str = Path(..., description="Parcel ID"), - data: Dict[str, Any] = Body(...), - context: RequestContext = Depends(getRequestContext) -) -> Parzelle: - """Update a parcel.""" - mandateId = await _validateInstanceAccess(instanceId, context) - interface = getRealEstateInterface( - context.user, mandateId=mandateId, featureInstanceId=instanceId - ) - parzelle = interface.getParzelle(parcelId) - if not parzelle or str(getattr(parzelle, "featureInstanceId", None)) != instanceId: - raise HTTPException(status_code=404, detail=f"Parcel '{parcelId}' not found") - updated = interface.updateParzelle(parcelId, data) - if not updated: - raise HTTPException(status_code=500, detail=routeApiMsg("Update failed")) - return updated - - -@router.delete("/{instanceId}/parcels/{parcelId}", status_code=status.HTTP_204_NO_CONTENT) -@limiter.limit("30/minute") -async def delete_parcel( - request: Request, - instanceId: str = Path(..., description="Feature Instance ID"), - parcelId: str = Path(..., description="Parcel ID"), - context: RequestContext = Depends(getRequestContext) -) -> None: - """Delete a parcel.""" - mandateId = await _validateInstanceAccess(instanceId, context) - interface = getRealEstateInterface( - context.user, mandateId=mandateId, featureInstanceId=instanceId - ) - parzelle = interface.getParzelle(parcelId) - if not parzelle or str(getattr(parzelle, "featureInstanceId", None)) != instanceId: - raise HTTPException(status_code=404, detail=f"Parcel '{parcelId}' not found") - if not interface.deleteParzelle(parcelId): - raise HTTPException(status_code=500, detail=routeApiMsg("Delete failed")) - - -# ============================================================================ -# LEGACY / STATELESS ROUTES (unchanged) -# ============================================================================ - -@router.post("/command", response_model=Dict[str, Any]) -@limiter.limit("120/minute") -async def process_command( - request: Request, - userInput: str = Body(..., embed=True, description="Natural language command"), - currentUser: User = Depends(getCurrentUser) -) -> Dict[str, Any]: - """ - Process natural language command and execute corresponding CRUD operation. - - Uses AI to analyze user intent and extract parameters, then executes the appropriate - CRUD operation. Works stateless without session management. - - Example user inputs: - - "Erstelle ein neues Projekt namens 'Hauptstrasse 42'" - - "Zeige mir alle Projekte in Zürich" - - "Aktualisiere Projekt XYZ mit Status 'Planung'" - - "Lösche Parzelle ABC" - - "SELECT * FROM Projekt WHERE plz = '8000'" - - Headers: - - X-CSRF-Token: CSRF token (required for security) - - Returns: - { - "success": true, - "intent": "CREATE|READ|UPDATE|DELETE|QUERY", - "entity": "Projekt|Parzelle|...|null", - "result": {...} - } - """ - try: - # Validate CSRF token (middleware also checks, but explicit validation for better error messages) - csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token") - if not csrf_token: - logger.warning(f"CSRF token missing for POST /api/realestate/command from user {currentUser.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.") - ) - - # Basic CSRF token format validation - if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: - logger.warning(f"Invalid CSRF token format for POST /api/realestate/command from user {currentUser.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("Invalid CSRF token format") - ) - - # Validate token is hex string - try: - int(csrf_token, 16) - except ValueError: - logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/command from user {currentUser.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("Invalid CSRF token format") - ) - - logger.info(f"Processing command request from user {currentUser.id} (mandate: {currentUser.mandateId})") - logger.debug(f"User input: {userInput}") - - # Process natural language command with AI - result = await processNaturalLanguageCommand( - currentUser=currentUser, - userInput=userInput - ) - - return result - - except ValueError as e: - logger.error(f"Validation error in process_command: {str(e)}", exc_info=True) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Validation error: {str(e)}" - ) - except HTTPException: - raise - except Exception as e: - logger.error(f"Error processing command: {str(e)}", exc_info=True) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error processing command: {str(e)}" - ) - -@router.get("/tables", response_model=Dict[str, Any]) -@limiter.limit("120/minute") -async def get_available_tables( - request: Request, - currentUser: User = Depends(getCurrentUser) -) -> Dict[str, Any]: - """ - Get all available real estate tables. - - Returns a list of available table names with their descriptions. - - Headers: - - X-CSRF-Token: CSRF token (required for security) - - Example: - - GET /api/realestate/tables - """ - try: - # Validate CSRF token if provided - csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token") - if not csrf_token: - logger.warning(f"CSRF token missing for GET /api/realestate/tables from user {currentUser.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.") - ) - - # Basic CSRF token format validation - if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: - logger.warning(f"Invalid CSRF token format for GET /api/realestate/tables from user {currentUser.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("Invalid CSRF token format") - ) - - # Validate token is hex string - try: - int(csrf_token, 16) - except ValueError: - logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/tables from user {currentUser.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("Invalid CSRF token format") - ) - - logger.info(f"Getting available tables for user {currentUser.id} (mandate: {currentUser.mandateId})") - - # Define available tables with descriptions - tables = [ - { - "name": "Projekt", - "description": "Real estate projects", - "model": "Projekt" - }, - { - "name": "Parzelle", - "description": "Plots/parcels", - "model": "Parzelle" - }, - { - "name": "Dokument", - "description": "Documents", - "model": "Dokument" - }, - { - "name": "Gemeinde", - "description": "Municipalities", - "model": "Gemeinde" - }, - { - "name": "Kanton", - "description": "Cantons", - "model": "Kanton" - }, - { - "name": "Land", - "description": "Countries", - "model": "Land" - }, - ] - - return { - "tables": tables, - "count": len(tables) - } - - except HTTPException: - raise - except Exception as e: - logger.error(f"Error getting available tables: {str(e)}", exc_info=True) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error getting available tables: {str(e)}" - ) - - -@router.get("/table/{table}", response_model=PaginatedResponse[Any]) -@limiter.limit("120/minute") -async def get_table_data( - request: Request, - table: str = Path(..., description="Table name (Projekt, Parzelle, Dokument, Gemeinde, Kanton, Land)"), - pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), - currentUser: User = Depends(getCurrentUser) -) -> PaginatedResponse[Dict[str, Any]]: - """ - Get all data from a specific real estate table with optional pagination. - - Available tables: - - Projekt: Real estate projects - - Parzelle: Plots/parcels - - Dokument: Documents - - Gemeinde: Municipalities - - Kanton: Cantons - - Land: Countries - - Query Parameters: - - pagination: JSON-encoded PaginationParams object, or None for no pagination - - Headers: - - X-CSRF-Token: CSRF token (required for security) - - Examples: - - GET /api/realestate/table/Projekt (no pagination - returns all items) - - GET /api/realestate/table/Parzelle?pagination={"page":1,"pageSize":10,"sort":[]} - - GET /api/realestate/table/Gemeinde?pagination={"page":2,"pageSize":20,"sort":[{"field":"label","direction":"asc"}]} - """ - try: - # Validate CSRF token if provided - csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token") - if not csrf_token: - logger.warning(f"CSRF token missing for GET /api/realestate/table/{table} from user {currentUser.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.") - ) - - # Basic CSRF token format validation - if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: - logger.warning(f"Invalid CSRF token format for GET /api/realestate/table/{table} from user {currentUser.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("Invalid CSRF token format") - ) - - # Validate token is hex string - try: - int(csrf_token, 16) - except ValueError: - logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/table/{table} from user {currentUser.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("Invalid CSRF token format") - ) - - logger.info(f"Getting table data for '{table}' from user {currentUser.id} (mandate: {currentUser.mandateId})") - - # Map table names to model classes and getter methods - table_mapping = { - "Projekt": (Projekt, "getProjekte"), - "Parzelle": (Parzelle, "getParzellen"), - "Dokument": (Dokument, "getDokumente"), - "Gemeinde": (Gemeinde, "getGemeinden"), - "Kanton": (Kanton, "getKantone"), - "Land": (Land, "getLaender"), - } - - # Validate table name - if table not in table_mapping: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid table name '{table}'. Available tables: {', '.join(table_mapping.keys())}" - ) - - # Get interface and fetch data - realEstateInterface = getRealEstateInterface(currentUser) - model_class, method_name = table_mapping[table] - getter_method = getattr(realEstateInterface, method_name) - - # Fetch all records (no filter for now) - records = getter_method(recordFilter=None) - - # Keep records as model instances (like routeDataFiles does with FileItem) - # FastAPI will automatically serialize Pydantic models to JSON - items = records - - # Parse pagination parameter - paginationParams = None - if pagination: - try: - paginationDict = json.loads(pagination) - paginationParams = PaginationParams(**paginationDict) if paginationDict else None - except (json.JSONDecodeError, ValueError) as e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid pagination parameter: {str(e)}" - ) - - # Apply pagination if requested - if paginationParams: - # Apply sorting if specified - if paginationParams.sort: - for sort_field in reversed(paginationParams.sort): # Reverse to apply in priority order - field_name = sort_field.field - direction = sort_field.direction.lower() - - def sort_key(item): - # Access attribute from model instance - value = getattr(item, field_name, None) - # Handle None values - put them at the end for asc, at the start for desc - if value is None: - return (1, None) # Use tuple to ensure None values sort consistently - return (0, value) - - items.sort(key=sort_key, reverse=(direction == "desc")) - - # Apply pagination - total_items = len(items) - total_pages = (total_items + paginationParams.pageSize - 1) // paginationParams.pageSize # Ceiling division - start_idx = (paginationParams.page - 1) * paginationParams.pageSize - end_idx = start_idx + paginationParams.pageSize - paginated_items = items[start_idx:end_idx] - - return PaginatedResponse( - items=paginated_items, - pagination=PaginationMetadata( - currentPage=paginationParams.page, - pageSize=paginationParams.pageSize, - totalItems=total_items, - totalPages=total_pages, - sort=paginationParams.sort, - filters=paginationParams.filters - ) - ) - else: - # No pagination - return all items (as model instances, like routeDataFiles) - return PaginatedResponse( - items=items, - pagination=None - ) - - except HTTPException: - raise - except Exception as e: - logger.error(f"Error getting table data for '{table}': {str(e)}", exc_info=True) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error getting table data: {str(e)}" - ) - - -@router.post("/table/{table}", response_model=Dict[str, Any]) -@limiter.limit("120/minute") -async def create_table_record( - request: Request, - table: str = Path(..., description="Table name (Projekt, Parzelle, Dokument, Gemeinde, Kanton, Land)"), - data: Dict[str, Any] = Body(..., description="Record data to create"), - currentUser: User = Depends(getCurrentUser) -) -> Dict[str, Any]: - """ - Create a new record in a specific real estate table. - - Available tables: - - Projekt: Real estate projects (with parcel data support) - - Parzelle: Plots/parcels - - Dokument: Documents - - Gemeinde: Municipalities - - Kanton: Cantons - - Land: Countries - - Request Body: - For Projekt: - { - "label": "Projekt Bezeichnung", - "statusProzess": "Eingang", // Optional - "parzelle": { - "id": "OE5913", - "egrid": "CH252699779137", - "perimeter": {...}, - "geometry": {...}, // Used for baulinie - ... - } - } - - For other tables: - - JSON object with fields matching the table's data model - - Headers: - - X-CSRF-Token: CSRF token (required for security) - - Examples: - - POST /api/realestate/table/Projekt - Body: {"label": "Hauptstrasse 42", "parzelle": {...}} - - POST /api/realestate/table/Parzelle - Body: {"label": "Parzelle 1", "strasseNr": "Hauptstrasse 42", "plz": "8000", "bauzone": "W3"} - """ - try: - # Validate CSRF token - csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token") - if not csrf_token: - logger.warning(f"CSRF token missing for POST /api/realestate/table/{table} from user {currentUser.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.") - ) - - # Basic CSRF token format validation - if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: - logger.warning(f"Invalid CSRF token format for POST /api/realestate/table/{table} from user {currentUser.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("Invalid CSRF token format") - ) - - # Validate token is hex string - try: - int(csrf_token, 16) - except ValueError: - logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/table/{table} from user {currentUser.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("Invalid CSRF token format") - ) - - # Special handling for Projekt with parcel data - if table == "Projekt" and ("parzelle" in data or "parzellen" in data): - logger.info(f"Creating Projekt with parcel data for user {currentUser.id} (mandate: {currentUser.mandateId})") - - # Extract fields - label = data.get("label") - if not label: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=routeApiMsg("label is required") - ) - - status_prozess = data.get("statusProzess", "Eingang") - - # Support both single parzelle and multiple parzellen - parzellen_data = [] - if "parzellen" in data: - # Multiple parcels - parzellen_data = data.get("parzellen", []) - if not isinstance(parzellen_data, list): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=routeApiMsg("parzellen must be an array") - ) - elif "parzelle" in data: - # Single parcel (backward compatibility) - parzelle_data = data.get("parzelle") - if parzelle_data: - parzellen_data = [parzelle_data] - - if not parzellen_data: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=routeApiMsg("parzelle or parzellen data is required") - ) - - # Use helper function to create project with parcel data - try: - result = await create_project_with_parcel_data( - currentUser=currentUser, - projekt_label=label, - parzellen_data=parzellen_data, - status_prozess=status_prozess, - ) - - # Return in format expected by frontend (single record, not nested) - return result.get("projekt", {}) - except HTTPException: - # Re-raise HTTPExceptions directly - raise - except Exception as e: - logger.error(f"Error creating Projekt with parcel data: {str(e)}", exc_info=True) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error creating Projekt: {str(e)}" - ) - - # Standard handling for other tables or Projekt without parcel data - logger.info(f"Creating record in table '{table}' for user {currentUser.id} (mandate: {currentUser.mandateId})") - logger.debug(f"Record data: {data}") - - # Map table names to model classes and create methods - table_mapping = { - "Projekt": (Projekt, "createProjekt"), - "Parzelle": (Parzelle, "createParzelle"), - "Dokument": (Dokument, "createDokument"), - "Gemeinde": (Gemeinde, "createGemeinde"), - "Kanton": (Kanton, "createKanton"), - "Land": (Land, "createLand"), - } - - # Validate table name - if table not in table_mapping: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid table name '{table}'. Available tables: {', '.join(table_mapping.keys())}" - ) - - # Get interface - realEstateInterface = getRealEstateInterface(currentUser) - model_class, method_name = table_mapping[table] - create_method = getattr(realEstateInterface, method_name) - - # Ensure mandateId is set (will be set by interface if missing) - if "mandateId" not in data: - data["mandateId"] = currentUser.mandateId - - # Create model instance from data - try: - model_instance = model_class(**data) - except Exception as e: - logger.error(f"Error creating {table} model instance: {str(e)}", exc_info=True) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid data for {table}: {str(e)}" - ) - - # Create record - try: - created_record = create_method(model_instance) - - # Convert to dictionary for response - if hasattr(created_record, 'model_dump'): - return created_record.model_dump() - else: - return created_record - - except Exception as e: - logger.error(f"Error creating {table} record: {str(e)}", exc_info=True) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error creating {table} record: {str(e)}" - ) - - except HTTPException: - raise - except Exception as e: - logger.error(f"Error creating record in table '{table}': {str(e)}", exc_info=True) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error creating record: {str(e)}" - ) - - -@router.get("/parcel/search", response_model=Dict[str, Any]) -@limiter.limit("60/minute") -async def search_parcel( - request: Request, - location: str = Query(..., description="Either coordinates as 'x,y' (LV95) or address string"), - include_adjacent: bool = Query(False, description="Include adjacent parcels information"), - fetch_documents: bool = Query(True, description="If true, fetch BZO documents for the Gemeinde (default: true)"), - currentUser: User = Depends(getCurrentUser) -) -> Dict[str, Any]: - """ - Search for parcel information by address or coordinates. - - Returns comprehensive parcel information including: - - Parcel identification (number, EGRID, etc.) - - Precise boundary geometry for map display - - Administrative context (canton, municipality) - - Link to official cadastral map - - Optional: Adjacent parcels - - Optional: Gemeinde information and BZO documents (if fetch_documents=true) - - Query Parameters: - - location: Either coordinates as "x,y" (LV95/EPSG:2056) or address string - - include_adjacent: If true, fetches information about adjacent parcels (slower) - - fetch_documents: If true, checks for and fetches Bauzonenverordnung (BZO) documents for the Gemeinde (default: true, slower) - - Headers: - - X-CSRF-Token: CSRF token (required for security) - - Examples: - - GET /api/realestate/parcel/search?location=2600000,1200000 - - GET /api/realestate/parcel/search?location=Bundesplatz 3, 3003 Bern - - GET /api/realestate/parcel/search?location=Bundesplatz 3, 3003 Bern&include_adjacent=true - - GET /api/realestate/parcel/search?location=Bundesplatz 3, 3003 Bern&fetch_documents=true - - Returns: - { - "parcel": { - "id": "823", - "egrid": "CH294676423526", - "number": "823", - "name": "823", - "identnd": "BE0200000042", - "canton": "BE", - "municipality_code": 351, - "municipality_name": "Bern", - "address": "Bundesplatz 3 3011 Bern", - "plz": "3011", - "perimeter": {...}, - "area_m2": 1234.56, - "centroid": {"x": 2600000, "y": 1200000}, - "geoportal_url": "https://...", - "realestate_type": null, - "bauzone": "W3" - }, - "map_view": { - "center": {"x": 2600000, "y": 1200000}, - "zoom_bounds": {"min_x": ..., "max_x": ..., "min_y": ..., "max_y": ...}, - "geometry_geojson": {...} - }, - "adjacent_parcels": [...], // Optional (only if include_adjacent=true) - "gemeinde": { // Optional (only if fetch_documents=true) - "id": "...", - "label": "Bern", - "plz": "3011" - }, - "documents": [ // Optional (only if fetch_documents=true and documents found/created) - { - "id": "...", - "label": "BZO Bern", - "dokumentTyp": "gemeindeBzoAktuell", - "dokumentReferenz": "...", - "quelle": "https://...", - "mimeType": "application/pdf" - } - ] - } - """ - try: - # Validate CSRF token - csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token") - if not csrf_token: - logger.warning(f"CSRF token missing for GET /api/realestate/parcel/search from user {currentUser.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.") - ) - - logger.info(f"Searching parcel for user {currentUser.id} (mandate: {currentUser.mandateId}) with location: {location}") - - # Initialize connector - connector = SwissTopoMapServerConnector() - - # Search for parcel - parcel_data = await connector.search_parcel(location) - - if not parcel_data: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"No parcel found for location: {location}" - ) - - # Extract and normalize attributes - extracted_attributes = connector.extract_parcel_attributes(parcel_data) - attributes = parcel_data.get("attributes", {}) - geometry = parcel_data.get("geometry", {}) - - # Calculate parcel area from perimeter - area_m2 = None - centroid = None - if extracted_attributes.get("perimeter"): - perimeter = extracted_attributes["perimeter"] - points = perimeter.get("punkte", []) - - # Calculate area using shoelace formula - if len(points) >= 3: - area = 0 - for i in range(len(points)): - j = (i + 1) % len(points) - area += points[i]["x"] * points[j]["y"] - area -= points[j]["x"] * points[i]["y"] - area_m2 = abs(area / 2) - - # Calculate centroid - sum_x = sum(p["x"] for p in points) - sum_y = sum(p["y"] for p in points) - centroid = { - "x": sum_x / len(points), - "y": sum_y / len(points) - } - - # Extract municipality name and address from Swiss Topo data - municipality_name = None - full_address = None - plz = None - canton = attributes.get("ak") # Extract canton early so it's always available - - # Debug: Log all available attributes to understand what we have - logger.debug(f"Parcel attributes keys: {list(attributes.keys())}") - logger.debug(f"Sample parcel attributes: {dict(list(attributes.items())[:10])}") # First 10 items - - # First, check if municipality is directly in parcel attributes (ggdename or dplzname) - # These fields are often present in the parcel data itself from Swiss Topo - municipality_from_attrs = attributes.get("ggdename") or attributes.get("dplzname") or attributes.get("gemeinde") or attributes.get("gemeindename") - if municipality_from_attrs: - # Use connector's cleaning method to remove canton suffix - municipality_name = connector._clean_municipality_name(str(municipality_from_attrs)) - logger.info(f"Found municipality '{municipality_name}' in parcel attributes (from {municipality_from_attrs})") - - # Also check extracted_attributes for municipality - if not municipality_name: - municipality_from_extracted = extracted_attributes.get("kontextGemeinde") - if municipality_from_extracted: - municipality_name = str(municipality_from_extracted) - logger.info(f"Found municipality '{municipality_name}' in extracted attributes") - - # Also check for PLZ in parcel attributes - if not plz: - plz_from_attrs = attributes.get("dplz4") or attributes.get("plz") - if plz_from_attrs: - plz = str(plz_from_attrs).strip() - logger.debug(f"Found PLZ '{plz}' in parcel attributes") - - # Try to use geocoded address info if available (more accurate than centroid query) - geocoded_address = parcel_data.get('geocoded_address') - if geocoded_address: - if not full_address: - full_address = geocoded_address.get('full_address') - if not plz: - plz = geocoded_address.get('plz') - if not municipality_name: - geocoded_municipality = geocoded_address.get('municipality') - if geocoded_municipality: - municipality_name = connector._clean_municipality_name(geocoded_municipality) - logger.debug(f"Found municipality '{municipality_name}' from geocoded address") - if full_address: - logger.debug(f"Using geocoded address: {full_address}") - - # If geocoded address not available, try to get address by querying the address layer - # Use query coordinates (where user clicked/geocoded) instead of parcel centroid - # This ensures we get the address at the exact location, not at the parcel center - query_coords = parcel_data.get('query_coordinates') - address_query_coords = query_coords if query_coords else centroid - - if not full_address and address_query_coords: - query_x = address_query_coords['x'] - query_y = address_query_coords['y'] - logger.debug(f"Querying address layer at query coordinates: ({query_x}, {query_y})") - - # Check if this was a coordinate search (not geocoded address) - is_coordinate_search = ',' in location and not any(c.isalpha() for c in location.split(',')[0]) - - # Use connector's helper method to query building layer - # Use tolerance=1 (minimum) for coordinate searches to get exact building - building_tolerance = 1 if is_coordinate_search else 10 - building_result = await connector._query_building_layer(query_x, query_y, tolerance=building_tolerance, buffer=25) - - if building_result: - addr_attrs = building_result.get("attributes", {}) - logger.debug(f"Address layer attributes: {addr_attrs}") - - # Extract address using connector's helper method - address_info = connector._extract_address_from_building_attrs(addr_attrs) - if not full_address: - full_address = address_info.get('full_address') - if not plz: - plz = address_info.get('plz') - if not municipality_name: - municipality_name = address_info.get('municipality') - if municipality_name: - logger.debug(f"Found municipality '{municipality_name}' from building layer") - - if full_address: - logger.debug(f"Constructed address: {full_address}") - - # If address not found via building layer, try to construct from available data - if not full_address: - # Check if location was provided as an address string - if location and any(c.isalpha() for c in location) and "CH" not in location: - # Location looks like an address (not an EGRID) - full_address = location - logger.debug(f"Using location as address: {full_address}") - - # Try to extract municipality name from address string if not found yet - if not municipality_name and full_address: - # Parse address string to extract municipality name - # Format is usually: "Street Number, PLZ Municipality" or "Street Number PLZ Municipality" - # Examples: "Forchstrasse 6c, 8610 Uster" or "Bundesplatz 3 3011 Bern" - # Try to match PLZ followed by municipality name - # PLZ is typically 4 digits, municipality name follows - plz_municipality_match = re.search(r'\b(\d{4})\s+([A-ZÄÖÜ][a-zäöüß\s-]+)', full_address) - if plz_municipality_match: - extracted_plz = plz_municipality_match.group(1) - extracted_municipality = plz_municipality_match.group(2).strip() - # Remove trailing commas or other punctuation - extracted_municipality = re.sub(r'[,;\.]+$', '', extracted_municipality).strip() - if extracted_municipality: - municipality_name = extracted_municipality - if not plz: - plz = extracted_plz - logger.debug(f"Extracted municipality '{municipality_name}' and PLZ '{plz}' from address string") - - # Try to extract municipality name from BFSNR if still not found - if not municipality_name: - bfsnr = attributes.get("bfsnr") - - logger.info(f"Attempting to resolve municipality name for BFS number {bfsnr} in canton {canton}") - - # Try to query database for Gemeinde by BFS number - if bfsnr and canton: - try: - realEstateInterface = getRealEstateInterface(currentUser) - # Query Gemeinde by BFS number (stored in kontextInformationen) - gemeinden = realEstateInterface.getGemeinden( - recordFilter={"mandateId": currentUser.mandateId} - ) - logger.debug(f"Found {len(gemeinden)} Gemeinden in database, searching for BFS {bfsnr}") - for gemeinde in gemeinden: - # Check kontextInformationen for BFS number - for kontext in gemeinde.kontextInformationen: - try: - kontext_data = json.loads(kontext.inhalt) if isinstance(kontext.inhalt, str) else kontext.inhalt - if isinstance(kontext_data, dict): - kontext_bfsnr = kontext_data.get("bfs_nummer") or kontext_data.get("bfsnr") or kontext_data.get("municipality_code") - if str(kontext_bfsnr) == str(bfsnr): - municipality_name = gemeinde.label - logger.info(f"Found Gemeinde '{municipality_name}' by BFS number {bfsnr} in database") - break - except (json.JSONDecodeError, AttributeError) as e: - logger.debug(f"Error parsing kontext: {e}") - continue - if municipality_name: - break - except Exception as e: - logger.warning(f"Error querying Gemeinde by BFS number: {e}", exc_info=True) - - # If still not found, try to use Swiss Topo geocoding API to get municipality name from coordinates - # This is more reliable than BFS number lookup since coordinates are exact - if not municipality_name and centroid: - try: - # Use Swiss Topo geocoding to get municipality name from coordinates - geocode_url = "https://api3.geo.admin.ch/rest/services/api/MapServer/identify" - params = { - "geometry": f"{centroid['x']},{centroid['y']}", - "geometryType": "esriGeometryPoint", - "layers": "all:ch.swisstopo.swissboundaries3d-gemeinde-flaeche.fill", - "tolerance": "0", - "returnGeometry": "false", - "sr": "2056" - } - import aiohttp - ssl_context = ssl.create_default_context() - ssl_context.check_hostname = False - ssl_context.verify_mode = ssl.CERT_NONE - connector_aiohttp = aiohttp.TCPConnector(ssl=ssl_context) - async with aiohttp.ClientSession(connector=connector_aiohttp) as session: - async with session.get(geocode_url, params=params) as resp: - if resp.status == 200: - data = await resp.json() - results = data.get("results", []) - if results: - result_attrs = results[0].get("attributes", {}) - geocoded_municipality = result_attrs.get("name") or result_attrs.get("gemeindename") or result_attrs.get("label") - if geocoded_municipality: - municipality_name = connector._clean_municipality_name(str(geocoded_municipality)) - logger.info(f"Found municipality '{municipality_name}' via Swiss Topo geocoding API (from {geocoded_municipality})") - except Exception as e: - logger.debug(f"Error querying Swiss Topo geocoding API: {e}", exc_info=True) - - # If still not found, try expanded Swiss municipalities lookup - if not municipality_name and bfsnr: - # Expanded Swiss municipalities lookup by BFS number - # Source: https://www.bfs.admin.ch/bfs/de/home/grundlagen/agvch.html - common_municipalities = { - # Zürich (ZH) - 261: "Zürich", - 198: "Pfäffikon", # ZH-198 is Pfäffikon - 191: "Uster", # Uster is ZH-191 - 3203: "Winterthur", - # Bern (BE) - 351: "Bern", - # Basel (BS) - 2701: "Basel", - # Genève (GE) - 6621: "Genève", - # Vaud (VD) - 5586: "Lausanne", - # Luzern (LU) - 1061: "Luzern", - # St. Gallen (SG) - 230: "St. Gallen", - # Ticino (TI) - 5192: "Lugano", - # Schwyz (SZ) - 1367: "Schwyz", - } - - if bfsnr in common_municipalities: - municipality_name = common_municipalities[bfsnr] - logger.info(f"Looked up municipality '{municipality_name}' from common list for BFS {bfsnr}") - - # If still not found, log warning - if not municipality_name: - logger.warning(f"Could not determine municipality name for BFS number {bfsnr} in canton {canton}. Municipality name will be None.") - - # Final validation: Don't use EGRID as address - if full_address and full_address.startswith("CH") and len(full_address) == 14 and full_address[2:].isdigit(): - # This is an EGRID, not an address - full_address = None - logger.debug("Removed EGRID from address field") - - # Query zone information (wohnzone/bauzone) from ÖREB WFS - bauzone = None - # Check if geometry has actual data (either rings or coordinates) - has_geometry = geometry and (geometry.get("rings") or geometry.get("coordinates")) - if canton and has_geometry: - try: - logger.debug(f"Querying zone information for parcel {attributes.get('label')} in canton {canton}") - oereb_connector = OerebWfsConnector() - egrid = attributes.get("egris_egrid", "") - x = centroid["x"] if centroid else None - y = centroid["y"] if centroid else None - - # Query zone layer using parcel geometry - zone_results = await oereb_connector.query_zone_layer( - egrid=egrid, - x=x or 0.0, - y=y or 0.0, - canton=canton, - geometry=geometry - ) - - if zone_results and len(zone_results) > 0: - zone_attrs = zone_results[0].get("attributes", {}) - typ_gde_abkuerzung = zone_attrs.get("typ_gde_abkuerzung") - if typ_gde_abkuerzung: - bauzone = typ_gde_abkuerzung - logger.info(f"Found bauzone/wohnzone: {bauzone} for parcel {attributes.get('label')}") - else: - logger.debug(f"No typ_gde_abkuerzung found in zone results for parcel {attributes.get('label')}") - else: - logger.debug(f"No zone results found for parcel {attributes.get('label')}") - except Exception as e: - logger.warning(f"Error querying zone information: {e}", exc_info=True) - # Continue without zone information if query fails - - # Build parcel info - parcel_info = { - "id": attributes.get("label") or attributes.get("number"), - "egrid": attributes.get("egris_egrid"), - "number": attributes.get("number"), - "name": attributes.get("name"), - "identnd": attributes.get("identnd"), - "canton": attributes.get("ak"), - "municipality_code": attributes.get("bfsnr"), - "municipality_name": municipality_name, - "address": full_address, - "plz": plz, - "perimeter": extracted_attributes.get("perimeter"), - "area_m2": area_m2, - "centroid": centroid, - "geoportal_url": attributes.get("geoportal_url"), - "realestate_type": attributes.get("realestate_type"), - "bauzone": bauzone - } - - # Build map view info - bbox = parcel_data.get("bbox", []) - map_view = { - "center": centroid, - "zoom_bounds": { - "min_x": bbox[0] if len(bbox) >= 4 else None, - "min_y": bbox[1] if len(bbox) >= 4 else None, - "max_x": bbox[2] if len(bbox) >= 4 else None, - "max_y": bbox[3] if len(bbox) >= 4 else None - }, - "geometry_geojson": { - "type": "Feature", - "geometry": { - "type": "Polygon", - "coordinates": [ - [[p["x"], p["y"]] for p in extracted_attributes["perimeter"]["punkte"]] - ] if extracted_attributes.get("perimeter") else [] - }, - "properties": { - "id": parcel_info["id"], - "egrid": parcel_info["egrid"], - "number": parcel_info["number"] - } - } - } - - # Build response - response_data = { - "parcel": parcel_info, - "map_view": map_view - } - - # Fetch adjacent parcels if requested - if include_adjacent and parcel_data and parcel_data.get("geometry"): - try: - # Use the connector's method to find neighboring parcels by sampling along the boundary - # This ensures we find all parcels that actually touch the selected parcel - selected_parcel_id = parcel_info["id"] - adjacent_parcels_raw = await connector.find_neighboring_parcels( - parcel_data=parcel_data, - selected_parcel_id=selected_parcel_id, - sample_distance=20.0, # Sample every 20 meters (balanced for coverage and speed) - max_sample_points=30, # Allow up to 30 points to ensure all vertices are covered - max_neighbors=15, # Find up to 15 neighbors - max_concurrent=50 # Process up to 50 queries concurrently (maximum parallelization) - ) - - # Convert adjacent parcels to include GeoJSON geometry (optimized, minimal logging) - def convert_parcel_geometry(adj_parcel: Dict[str, Any]) -> Dict[str, Any]: - """Convert a single adjacent parcel to include GeoJSON geometry.""" - adj_parcel_with_geo = { - "id": adj_parcel["id"], - "egrid": adj_parcel.get("egrid"), - "number": adj_parcel.get("number"), - "perimeter": adj_parcel.get("perimeter") - } - - # Convert geometry to GeoJSON format if available - adj_geometry = adj_parcel.get("geometry") - adj_perimeter = adj_parcel.get("perimeter") - - if adj_geometry: - # Handle ESRI format (rings) - if "rings" in adj_geometry and adj_geometry["rings"]: - ring = adj_geometry["rings"][0] # Outer ring - coordinates = [[[p[0], p[1]] for p in ring]] - adj_parcel_with_geo["geometry_geojson"] = { - "type": "Feature", - "geometry": { - "type": "Polygon", - "coordinates": coordinates - }, - "properties": { - "id": adj_parcel["id"], - "egrid": adj_parcel.get("egrid"), - "number": adj_parcel.get("number") - } - } - # Handle GeoJSON format - elif adj_geometry.get("type") == "Polygon": - adj_parcel_with_geo["geometry_geojson"] = { - "type": "Feature", - "geometry": adj_geometry, - "properties": { - "id": adj_parcel["id"], - "egrid": adj_parcel.get("egrid"), - "number": adj_parcel.get("number") - } - } - - # If no geometry_geojson was created but we have perimeter, create it from perimeter - if "geometry_geojson" not in adj_parcel_with_geo and adj_perimeter and adj_perimeter.get("punkte"): - punkte = adj_perimeter["punkte"] - coordinates = [[[p["x"], p["y"]] for p in punkte]] - adj_parcel_with_geo["geometry_geojson"] = { - "type": "Feature", - "geometry": { - "type": "Polygon", - "coordinates": coordinates - }, - "properties": { - "id": adj_parcel["id"], - "egrid": adj_parcel.get("egrid"), - "number": adj_parcel.get("number") - } - } - - return adj_parcel_with_geo - - # Convert all parcels in parallel (using list comprehension for speed) - adjacent_parcels = [convert_parcel_geometry(adj_parcel) for adj_parcel in adjacent_parcels_raw] - - response_data["adjacent_parcels"] = adjacent_parcels - logger.info(f"Found {len(adjacent_parcels)} neighboring parcels for parcel {selected_parcel_id}") - - except Exception as e: - logger.warning(f"Error fetching adjacent parcels: {e}", exc_info=True) - response_data["adjacent_parcels"] = [] - - # Fetch BZO documents if requested - gemeinde_info = None - bzo_documents = [] - - logger.debug(f"Document fetch check: fetch_documents={fetch_documents}, municipality_name={municipality_name}, canton={canton}") - - if fetch_documents and municipality_name and canton: - logger.info(f"Fetching BZO documents for Gemeinde '{municipality_name}' in canton '{canton}'") - try: - # Get interfaces - realEstateInterface = getRealEstateInterface(currentUser) - componentInterface = getComponentInterface(currentUser) - logger.debug(f"Interfaces initialized for document fetching") - - # Resolve or create Gemeinde - gemeinde = None - # First, ensure Land "Schweiz" exists - laender = realEstateInterface.getLaender(recordFilter={"label": "Schweiz"}) - if not laender: - land = Land( - mandateId=currentUser.mandateId, - label="Schweiz", - abk="CH" - ) - land = realEstateInterface.createLand(land) - logger.debug(f"Created Land 'Schweiz' with ID: {land.id}") - else: - land = laender[0] - - # Map canton abbreviations to full names - canton_names = { - "ZH": "Zürich", "BE": "Bern", "LU": "Luzern", "UR": "Uri", "SZ": "Schwyz", - "OW": "Obwalden", "NW": "Nidwalden", "GL": "Glarus", "ZG": "Zug", "FR": "Freiburg", - "SO": "Solothurn", "BS": "Basel-Stadt", "BL": "Basel-Landschaft", "SH": "Schaffhausen", - "AR": "Appenzell Ausserrhoden", "AI": "Appenzell Innerrhoden", "SG": "St. Gallen", - "GR": "Graubünden", "AG": "Aargau", "TG": "Thurgau", "TI": "Tessin", - "VD": "Waadt", "VS": "Wallis", "NE": "Neuenburg", "GE": "Genf", "JU": "Jura" - } - - # Get or create Kanton - kantone = realEstateInterface.getKantone(recordFilter={"abk": canton}) - if not kantone: - kanton_label = canton_names.get(canton, canton) - kanton_obj = Kanton( - mandateId=currentUser.mandateId, - label=kanton_label, - abk=canton, - id_land=land.id - ) - kanton_obj = realEstateInterface.createKanton(kanton_obj) - logger.debug(f"Created Kanton '{kanton_label}' ({canton})") - else: - kanton_obj = kantone[0] - - # Get or create Gemeinde - gemeinden = realEstateInterface.getGemeinden( - recordFilter={"label": municipality_name, "id_kanton": kanton_obj.id} - ) - if not gemeinden: - gemeinde = Gemeinde( - mandateId=currentUser.mandateId, - label=municipality_name, - id_kanton=kanton_obj.id, - plz=plz - ) - gemeinde = realEstateInterface.createGemeinde(gemeinde) - logger.info(f"Created Gemeinde '{municipality_name}'") - else: - gemeinde = gemeinden[0] - logger.debug(f"Found existing Gemeinde '{municipality_name}'") - - gemeinde_info = { - "id": gemeinde.id, - "label": gemeinde.label, - "plz": gemeinde.plz - } - - # Check if Gemeinde already has BZO documents - existing_bzo = False - logger.debug(f"Checking for existing BZO documents in Gemeinde '{gemeinde.label}' (has {len(gemeinde.dokumente) if gemeinde.dokumente else 0} documents)") - if gemeinde.dokumente: - for doc in gemeinde.dokumente: - if (doc.label and ("BZO" in doc.label.upper() or "BAU UND ZONENORDNUNG" in doc.label.upper() or - "PLAN D'AMÉNAGEMENT" in doc.label.upper() or "RÈGLEMENT DE CONSTRUCTION" in doc.label.upper() or - "PIANO DI UTILIZZAZIONE" in doc.label.upper() or "REGOLAMENTO EDILIZIO" in doc.label.upper())) or \ - (doc.dokumentTyp and doc.dokumentTyp in [DokumentTyp.GEMEINDE_BZO_AKTUELL, DokumentTyp.GEMEINDE_BZO_REVISION]): - existing_bzo = True - logger.info(f"Found existing BZO document: {doc.label} (ID: {doc.id})") - bzo_documents.append({ - "id": doc.id, - "label": doc.label, - "dokumentTyp": doc.dokumentTyp.value if doc.dokumentTyp else None, - "dokumentReferenz": doc.dokumentReferenz, - "quelle": doc.quelle, - "mimeType": doc.mimeType - }) - - if existing_bzo: - logger.info(f"Gemeinde '{municipality_name}' already has {len(bzo_documents)} BZO document(s), skipping search") - - # If no BZO documents found, search and download - if not existing_bzo: - logger.info(f"No BZO documents found for {municipality_name}, searching with Tavily...") - - # Determine language - language = _get_language_from_kanton(canton) - - # Generate search query - search_query = _get_bzo_search_query(municipality_name, language) - logger.debug(f"Tavily search query: {search_query}") - - # Initialize Tavily connector - tavily = AiTavily() - - # Search with Tavily - search_results = await tavily._search( - query=search_query, - maxResults=5, - country="switzerland" - ) - - if search_results: - # First, check for direct PDF URLs in search results - pdf_urls = [] - html_urls = [] - - for result in search_results: - url = result.url.lower() - # Check if it's a direct PDF link - if url.endswith('.pdf') or '/pdf/' in url or url.endswith('/pdf'): - if not any(skip in url for skip in ['.html', '.htm', '/page/', '/article/', '/news/']): - pdf_urls.append(result.url) - else: - # It's an HTML page - we'll crawl it to find PDF links - html_urls.append(result.url) - - # If no direct PDFs found, scrape HTML pages directly to find PDF links - if not pdf_urls and html_urls: - logger.info(f"No direct PDF links found, scraping {len(html_urls)} HTML pages to find PDF documents...") - - # Helper function to scrape HTML and find PDF links - async def scrape_html_for_pdfs(url: str) -> List[str]: - """Scrape an HTML page to find PDF links.""" - found_pdfs = [] - try: - ssl_context = ssl.create_default_context() - ssl_context.check_hostname = False - ssl_context.verify_mode = ssl.CERT_NONE - connector_aiohttp = aiohttp.TCPConnector(ssl=ssl_context) - - timeout = aiohttp.ClientTimeout(total=15, connect=5) - headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', - 'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8' - } - - async with aiohttp.ClientSession(timeout=timeout, headers=headers, connector=connector_aiohttp) as session: - async with session.get(url, allow_redirects=True) as response: - if response.status == 200: - # Check Content-Type header first - content_type = response.headers.get('Content-Type', '').lower() - - # Read first few bytes to check if it's a PDF - raw_bytes = await response.read() - - # Check if it's actually a PDF by magic bytes - if raw_bytes.startswith(b'%PDF'): - found_pdfs.append(url) - logger.info(f"Found direct PDF link (detected by magic bytes): {url}") - return found_pdfs - - # If Content-Type says it's a PDF, add it - if 'application/pdf' in content_type: - found_pdfs.append(url) - logger.info(f"Found direct PDF link (Content-Type): {url}") - return found_pdfs - - # If URL ends with .pdf, it's likely a PDF - if url.lower().endswith('.pdf'): - found_pdfs.append(url) - logger.info(f"Found direct PDF link (URL extension): {url}") - return found_pdfs - - # Try to decode as text for HTML parsing - try: - # Try UTF-8 first - html_content = raw_bytes.decode('utf-8') - except UnicodeDecodeError: - try: - # Try ISO-8859-1 (common for German sites) - html_content = raw_bytes.decode('iso-8859-1') - except UnicodeDecodeError: - try: - # Try Windows-1252 - html_content = raw_bytes.decode('windows-1252') - except UnicodeDecodeError: - # If all else fails, skip this URL - logger.warning(f"Could not decode content from {url} (not UTF-8, ISO-8859-1, or Windows-1252), skipping HTML parsing") - return found_pdfs - - # Look for PDF links in various formats - # Pattern 1: Direct PDF URLs - pdf_pattern = r'https?://[^\s<>"\'\)]+\.pdf(?:\?[^\s<>"\'\)]*)?' - found = re.findall(pdf_pattern, html_content, re.IGNORECASE) - - # Pattern 2: Relative PDF links (convert to absolute) - relative_pattern = r'href=["\']([^"\']+\.pdf[^"\']*)["\']' - relative_found = re.findall(relative_pattern, html_content, re.IGNORECASE) - - # Convert relative URLs to absolute - base_url = f"{urlparse(url).scheme}://{urlparse(url).netloc}" - - for rel_url in relative_found: - # Remove query params and fragments for cleaner URLs - clean_url = rel_url.split('?')[0].split('#')[0] - if clean_url.endswith('.pdf'): - abs_url = urljoin(base_url, clean_url) - if abs_url not in found: - found.append(abs_url) - - # Pattern 3: Look in data attributes and other places - data_pattern = r'data-[^=]*=["\']([^"\']+\.pdf[^"\']*)["\']' - data_found = re.findall(data_pattern, html_content, re.IGNORECASE) - for data_url in data_found: - clean_url = data_url.split('?')[0].split('#')[0] - if clean_url.endswith('.pdf'): - abs_url = urljoin(base_url, clean_url) if not clean_url.startswith('http') else clean_url - if abs_url not in found: - found.append(abs_url) - - # Clean and deduplicate URLs - for pdf_link in found: - pdf_link = pdf_link.rstrip('.,;:!?)').strip() - # Remove common tracking parameters - if '?' in pdf_link: - base, params = pdf_link.split('?', 1) - # Keep only important params, remove tracking - important_params = [] - for param in params.split('&'): - if param.split('=')[0].lower() not in ['utm_source', 'utm_medium', 'utm_campaign', 'ref', 'fbclid', 'gclid']: - important_params.append(param) - if important_params: - pdf_link = f"{base}?{'&'.join(important_params)}" - else: - pdf_link = base - - if pdf_link not in found_pdfs and pdf_link.startswith('http'): - found_pdfs.append(pdf_link) - logger.debug(f"Found PDF link on {url}: {pdf_link}") - - logger.info(f"Found {len(found_pdfs)} PDF links on {url}") - - except Exception as e: - logger.debug(f"Error scraping {url} for PDFs: {e}", exc_info=True) - - return found_pdfs - - # Scrape HTML pages to find PDF links - for html_url in html_urls[:5]: # Limit to first 5 URLs - try: - logger.debug(f"Scraping {html_url} to find PDF links...") - found_pdfs = await scrape_html_for_pdfs(html_url) - pdf_urls.extend(found_pdfs) - except Exception as e: - logger.warning(f"Error scraping {html_url} to find PDFs: {e}", exc_info=True) - continue - - # Also check rawContent from search results for PDF links - for result in search_results: - if result.rawContent: - pdf_pattern = r'https?://[^\s<>"\'\)]+\.pdf(?:\?[^\s<>"\'\)]*)?' - found_pdfs = re.findall(pdf_pattern, result.rawContent, re.IGNORECASE) - for pdf_link in found_pdfs: - pdf_link = pdf_link.rstrip('.,;:!?)').strip() - if pdf_link not in pdf_urls and pdf_link.startswith('http'): - pdf_urls.append(pdf_link) - logger.debug(f"Found PDF link in rawContent: {pdf_link}") - - if not pdf_urls: - logger.warning(f"No PDF URLs found in Tavily results for {municipality_name}. Results were HTML pages, not direct PDF links.") - logger.debug(f"Tavily returned URLs: {[r.url for r in search_results]}") - - logger.info(f"Found {len(pdf_urls)} potential PDF documents for {municipality_name}") - - # Helper function to download a single PDF - async def download_pdf(pdf_url: str) -> Optional[bytes]: - """Download a PDF from a URL with retry logic.""" - max_retries = 3 - retry_delay = 2 - - for attempt in range(max_retries): - try: - if attempt > 0: - headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - 'Accept': '*/*' - } - else: - headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', - 'Accept': 'application/pdf,application/octet-stream,*/*', - 'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8', - 'Accept-Encoding': 'gzip, deflate, br', - 'Connection': 'keep-alive', - 'Upgrade-Insecure-Requests': '1' - } - - # Create SSL context that doesn't verify certificates (for development) - ssl_context = ssl.create_default_context() - ssl_context.check_hostname = False - ssl_context.verify_mode = ssl.CERT_NONE - - # Create connector with SSL context - connector = aiohttp.TCPConnector(ssl=ssl_context) - - timeout = aiohttp.ClientTimeout(total=30, connect=10) - async with aiohttp.ClientSession(timeout=timeout, headers=headers, connector=connector) as session: - async with session.get(pdf_url, allow_redirects=True) as response: - if response.status == 200: - # Check content-type header first - content_type = response.headers.get('Content-Type', '').lower() - if 'text/html' in content_type or 'text/xml' in content_type: - logger.warning(f"URL {pdf_url} returned HTML content (Content-Type: {content_type}), skipping") - raise Exception("Server returned HTML content instead of PDF") - - pdf_content = await response.read() - - if not pdf_content or len(pdf_content) < 100: - raise Exception("Downloaded file is too small or empty") - - # Verify it's actually a PDF - if not pdf_content.startswith(b'%PDF'): - if pdf_content.startswith(b'<') or pdf_content.startswith(b' 1: - file_name = f"BZO_{safe_name}_{idx + 1}.pdf" - doc_label = f"{base_doc_label} ({idx + 1})" - else: - file_name = f"BZO_{safe_name}.pdf" - doc_label = base_doc_label - - # Store file using ComponentObjects - try: - file_item = componentInterface.createFile( - name=file_name, - mimeType="application/pdf", - content=pdf_content - ) - - componentInterface.createFileData(file_item.id, pdf_content) - logger.info(f"Stored file {file_name} with ID {file_item.id}") - except Exception as e: - logger.error(f"Error storing file {file_name}: {str(e)}", exc_info=True) - continue - - # Create Dokument record - dokument = Dokument( - mandateId=currentUser.mandateId, - label=doc_label, - versionsbezeichnung="Aktuell", - dokumentTyp=DokumentTyp.GEMEINDE_BZO_AKTUELL, - dokumentReferenz=file_item.id, - quelle=pdf_url, - mimeType="application/pdf", - kategorienTags=["BZO", "Bauordnung", municipality_name] - ) - - # Create Dokument record - created_dokument = realEstateInterface.createDokument(dokument) - logger.info(f"Created Dokument record with ID {created_dokument.id}") - - current_dokumente.append(created_dokument) - - # Add to response - bzo_documents.append({ - "id": created_dokument.id, - "label": created_dokument.label, - "dokumentTyp": created_dokument.dokumentTyp.value if created_dokument.dokumentTyp else None, - "dokumentReferenz": created_dokument.dokumentReferenz, - "quelle": created_dokument.quelle, - "mimeType": created_dokument.mimeType - }) - - except Exception as e: - logger.error(f"Error processing PDF {pdf_url}: {str(e)}", exc_info=True) - continue - - # Update Gemeinde with new dokumente - if bzo_documents: - updated_gemeinde = realEstateInterface.updateGemeinde( - gemeinde.id, - {"dokumente": current_dokumente} - ) - if updated_gemeinde: - logger.info(f"Successfully created {len(bzo_documents)} BZO document(s) for {municipality_name}") - else: - logger.warning(f"No search results found for {municipality_name}") - - except Exception as e: - logger.error(f"Error fetching BZO documents for {municipality_name}: {e}", exc_info=True) - # Continue without documents - don't fail the request - elif fetch_documents: - if not municipality_name: - logger.warning("fetch_documents=true but municipality_name is not available, skipping document fetch") - elif not canton: - logger.warning("fetch_documents=true but canton is not available, skipping document fetch") - - # Add Gemeinde and documents to response if available - logger.debug(f"Adding to response: gemeinde_info={gemeinde_info is not None}, bzo_documents count={len(bzo_documents)}") - if gemeinde_info: - response_data["gemeinde"] = gemeinde_info - logger.debug(f"Added gemeinde_info to response: {gemeinde_info}") - if bzo_documents: - response_data["documents"] = bzo_documents - logger.info(f"Added {len(bzo_documents)} BZO documents to response") - else: - logger.debug("No BZO documents to add to response") - - return response_data - - except HTTPException: - raise - except Exception as e: - logger.error(f"Error searching parcel: {str(e)}", exc_info=True) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error searching parcel: {str(e)}" - ) - - -@router.post("/projekt/{projekt_id}/add-parcel", response_model=Dict[str, Any]) -@limiter.limit("60/minute") -async def add_parcel_to_project( - request: Request, - projekt_id: str = Path(..., description="Projekt ID"), - body: Dict[str, Any] = Body(...), - currentUser: User = Depends(getCurrentUser) -) -> Dict[str, Any]: - """ - Add a parcel to an existing project. - - This endpoint can either: - 1. Link an existing Parzelle to the Projekt - 2. Create a new Parzelle from location data and link it - - Request Body: - Option 1 - Link existing parcel: - { - "parcelId": "existing-parcel-id" - } - - Option 2 - Create new parcel from location: - { - "location": "Hauptstrasse 42, 8000 Zürich" - } - - Option 3 - Create new parcel with custom data: - { - "parcelData": { - "label": "Parzelle 123", - "strasseNr": "Hauptstrasse 42", - "plz": "8000", - "bauzone": "W3", - ... - } - } - - Headers: - - X-CSRF-Token: CSRF token (required for security) - - Returns: - { - "projekt": {...}, // Updated Projekt - "parzelle": {...} // Parcel that was added - } - """ - try: - # Validate CSRF token - csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token") - if not csrf_token: - logger.warning(f"CSRF token missing for POST /api/realestate/projekt/{projekt_id}/add-parcel from user {currentUser.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.") - ) - - # Validate CSRF token format - if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("Invalid CSRF token format") - ) - try: - int(csrf_token, 16) - except ValueError: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("Invalid CSRF token format") - ) - - logger.info(f"Adding parcel to project {projekt_id} for user {currentUser.id} (mandate: {currentUser.mandateId})") - - # Get interface - realEstateInterface = getRealEstateInterface(currentUser) - - # Fetch existing Projekt - projekte = realEstateInterface.getProjekte( - recordFilter={"id": projekt_id, "mandateId": currentUser.mandateId} - ) - if not projekte: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Projekt {projekt_id} not found" - ) - projekt = projekte[0] - - # Determine which option was used - parcel_id = body.get("parcelId") - location = body.get("location") - parcel_data_dict = body.get("parcelData") - - parzelle = None - - # Option 1: Link existing parcel - if parcel_id: - logger.info(f"Linking existing parcel {parcel_id}") - parcels = realEstateInterface.getParzellen( - recordFilter={"id": parcel_id, "mandateId": currentUser.mandateId} - ) - if not parcels: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Parzelle {parcel_id} not found" - ) - parzelle = parcels[0] - - # Option 2: Create from location - elif location: - logger.info(f"Creating parcel from location: {location}") - - # Initialize connector and search for parcel - connector = SwissTopoMapServerConnector() - parcel_data = await connector.search_parcel(location) - - if not parcel_data: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"No parcel found at location: {location}" - ) - - # Extract attributes - extracted_attributes = connector.extract_parcel_attributes(parcel_data) - attributes = parcel_data.get("attributes", {}) - - # Create Parzelle - parzelle_create_data = { - "mandateId": currentUser.mandateId, - "label": extracted_attributes.get("label") or attributes.get("number") or "Unknown", - "parzellenAliasTags": [attributes.get("egris_egrid")] if attributes.get("egris_egrid") else [], - "eigentuemerschaft": None, - "strasseNr": location if not location.replace(",", "").replace(".", "").replace(" ", "").isdigit() else None, - "plz": None, - "perimeter": extracted_attributes.get("perimeter"), - "baulinie": None, - "kontextGemeinde": None, - "bauzone": None, - "az": None, - "bz": None, - "vollgeschossZahl": None, - "anrechenbarDachgeschoss": None, - "anrechenbarUntergeschoss": None, - "gebaeudehoeheMax": None, - "regelnGrenzabstand": [], - "regelnMehrlaengenzuschlag": [], - "regelnMehrhoehenzuschlag": [], - "parzelleBebaut": None, - "parzelleErschlossen": None, - "parzelleHanglage": None, - "laermschutzzone": None, - "hochwasserschutzzone": None, - "grundwasserschutzzone": None, - "parzellenNachbarschaft": [], - "dokumente": [], - "kontextInformationen": [ - Kontext( - thema="Swiss Topo Data", - inhalt=json.dumps({ - "egrid": attributes.get("egris_egrid"), - "identnd": attributes.get("identnd"), - "canton": attributes.get("ak"), - "municipality_code": attributes.get("bfsnr"), - "geoportal_url": attributes.get("geoportal_url") - }, ensure_ascii=False) - ) - ] - } - - parzelle_instance = Parzelle(**parzelle_create_data) - parzelle = realEstateInterface.createParzelle(parzelle_instance) - - # Option 3: Create from custom data - elif parcel_data_dict: - logger.info(f"Creating parcel from custom data") - parcel_data_dict["mandateId"] = currentUser.mandateId - parzelle_instance = Parzelle(**parcel_data_dict) - parzelle = realEstateInterface.createParzelle(parzelle_instance) - - else: - raise ValueError("One of 'parcelId', 'location', or 'parcelData' is required") - - # Add parcel to project - if parzelle not in projekt.parzellen: - projekt.parzellen.append(parzelle) - - # Update projekt perimeter if needed (use first parcel's perimeter) - if not projekt.perimeter and parzelle.perimeter: - projekt.perimeter = parzelle.perimeter - - # Update Projekt - updated_projekt = realEstateInterface.updateProjekt(projekt) - - logger.info(f"Added Parzelle {parzelle.id} to Projekt {projekt_id}") - - return { - "projekt": updated_projekt.model_dump(), - "parzelle": parzelle.model_dump() - } - - except ValueError as e: - logger.error(f"Validation error in add_parcel_to_project: {str(e)}", exc_info=True) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Validation error: {str(e)}" - ) - except HTTPException: - raise - except Exception as e: - logger.error(f"Error adding parcel to project: {str(e)}", exc_info=True) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error adding parcel to project: {str(e)}" - ) - - -@router.get("/bzo-information", response_model=Dict[str, Any]) -@limiter.limit("30/minute") -async def get_bzo_information( - request: Request, - gemeinde: str = Query(..., description="Gemeinde name or ID"), - bauzone: str = Query(..., description="Bauzone code (e.g., W3, W2/30)"), - total_area_m2: Optional[float] = Query(None, description="Total parcel area (m²) for Machbarkeitsstudie"), - currentUser: User = Depends(getCurrentUser) -) -> Dict[str, Any]: - """ - Extract BZO information from PDF documents for a specific Bauzone in a Gemeinde. - - Uses the BZO extraction pipeline to extract content from BZO PDF documents for the - specified Gemeinde, then uses AI to search for relevant information specific - to the specified Bauzone. - - The workflow: - 1. Finds BZO documents for the Gemeinde (by name or ID) - 2. Extracts content from PDFs using the BZO extraction pipeline - 3. Filters rules, zones, and articles by Bauzone - 4. Uses AI to generate a summary and find relevant information - - Query Parameters: - - gemeinde: Gemeinde name (e.g., "Zürich") or ID - - bauzone: Bauzone code (e.g., "W3", "W2/30", "Z3") - - Headers: - - X-CSRF-Token: CSRF token (required for security) - - Returns: - { - "bauzone": "W3", - "gemeinde": { - "id": "...", - "label": "...", - "plz": "..." - }, - "extracted_content": { - "zones": [...], // Zone information filtered by Bauzone - "rules": [...], // Rules filtered by Bauzone - "articles": [...], // Articles filtered by Bauzone - "total_zones": N, - "total_rules": N, - "total_articles": N - }, - "ai_summary": "...", // AI-generated summary - "relevant_rules": [...], // Rules specifically for this Bauzone - "documents_processed": [ // List of document IDs processed - { - "id": "...", - "label": "...", - "dokumentTyp": "..." - } - ], - "errors": [...], - "warnings": [...] - } - - Examples: - - GET /api/realestate/bzo-information?gemeinde=Zürich&bauzone=W3 - - GET /api/realestate/bzo-information?gemeinde=Uster&bauzone=W2/30 - - Raises: - - 404: Gemeinde not found - - 404: No BZO documents found for Gemeinde - - 500: Error during extraction or processing - """ - try: - # Validate CSRF token - csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token") - if not csrf_token: - logger.warning(f"CSRF token missing for GET /api/realestate/bzo-information from user {currentUser.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.") - ) - - # Basic CSRF token format validation - if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: - logger.warning(f"Invalid CSRF token format for GET /api/realestate/bzo-information from user {currentUser.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("Invalid CSRF token format") - ) - - # Validate token is hex string - try: - int(csrf_token, 16) - except ValueError: - logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/bzo-information from user {currentUser.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("Invalid CSRF token format") - ) - - logger.info(f"Extracting BZO information for Gemeinde '{gemeinde}', Bauzone '{bauzone}' (user: {currentUser.id}, mandate: {currentUser.mandateId})") - - # Call the feature function - result = await extract_bzo_information( - currentUser=currentUser, - gemeinde=gemeinde, - bauzone=bauzone, - total_area_m2=total_area_m2, - ) - - return result - - except HTTPException: - raise - except Exception as e: - logger.error(f"Error extracting BZO information for Gemeinde '{gemeinde}', Bauzone '{bauzone}': {str(e)}", exc_info=True) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error extracting BZO information: {str(e)}" - ) - diff --git a/modules/routes/routeRealEstateScraping.py b/modules/routes/routeRealEstateScraping.py deleted file mode 100644 index abb54299..00000000 --- a/modules/routes/routeRealEstateScraping.py +++ /dev/null @@ -1,881 +0,0 @@ -""" -Real Estate scraping routes for the backend API. -Implements endpoints for scraping real estate data from external sources. -""" - -import logging -import json -import aiohttp -import asyncio -from typing import Optional, Dict, Any -from fastapi import APIRouter, HTTPException, Depends, Body, Request, Query, status - -# Import auth modules -from modules.auth import limiter, getCurrentUser - -# Import models -from modules.datamodels.datamodelUam import User -from modules.datamodels.datamodelRealEstate import ( - Gemeinde, - Kanton, - Dokument, - Kontext, - DokumentTyp, -) - -# Import interfaces -from modules.interfaces.interfaceDbRealEstateObjects import getInterface as getRealEstateInterface -from modules.interfaces.interfaceDbComponentObjects import getInterface as getComponentInterface - -# Import scraping script -from modules.features.realEstate.scrapeSwissTopo import scrape_switzerland - -# Import Swiss Topo MapServer connector -from modules.connectors.connectorSwissTopoMapServer import SwissTopoMapServerConnector -from modules.connectors.connectorOerebWfs import OerebWfsConnector - -# Import Tavily connector for BZO document search -from modules.aicore.aicorePluginTavily import AiTavily -from modules.shared.i18nRegistry import apiRouteContext -routeApiMsg = apiRouteContext("routeRealEstateScraping") - -# Configure logger -logger = logging.getLogger(__name__) - -# Create router for real estate scraping endpoints -router = APIRouter( - prefix="/api/realestate", - tags=["Real Estate Scraping"], - responses={ - 404: {"description": "Not found"}, - 400: {"description": "Bad request"}, - 401: {"description": "Unauthorized"}, - 403: {"description": "Forbidden"}, - 500: {"description": "Internal server error"} - } -) - - -@router.post("/scrape-switzerland", response_model=Dict[str, Any]) -@limiter.limit("5/hour") # Limit to 5 requests per hour (scraping is resource-intensive) -async def scrape_switzerland_route( - request: Request, - body: Dict[str, Any] = Body(..., description="Scraping parameters"), - currentUser: User = Depends(getCurrentUser) -) -> Dict[str, Any]: - """ - Scrape Kanton Zürich systematically using Swiss Topo connector and save parcel data to database. - - This endpoint divides Kanton Zürich into a grid and queries parcels at each grid point, - then deduplicates and saves unique parcels to the database. For each parcel, it also - queries the ÖREB WFS service to retrieve bauzone information. - - **WARNING**: This is a resource-intensive operation that may take a long time - and make many API requests. Use with caution. - - Request Body: - { - "grid_size": 500.0, // Grid cell size in meters (default: 500m) - "max_concurrent": 50, // Maximum concurrent API requests (default: 50) - "batch_size": 100 // Number of parcels to process before saving (default: 100) - } - - Headers: - - X-CSRF-Token: CSRF token (required for security) - - Returns: - { - "success": true, - "stats": { - "total_queries": 1234, - "successful_queries": 1200, - "failed_queries": 34, - "unique_parcels_found": 500, - "parcels_saved": 450, - "parcels_skipped": 50, - "error_count": 5, - "errors": [...] - } - } - - Example: - - POST /api/realestate/scrape-switzerland - Body: {"grid_size": 1000.0, "max_concurrent": 5, "batch_size": 50} - """ - try: - # Validate CSRF token - csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token") - if not csrf_token: - logger.warning(f"CSRF token missing for POST /api/realestate/scrape-switzerland from user {currentUser.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.") - ) - - # Basic CSRF token format validation - if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: - logger.warning(f"Invalid CSRF token format for POST /api/realestate/scrape-switzerland from user {currentUser.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("Invalid CSRF token format") - ) - - # Validate token is hex string - try: - int(csrf_token, 16) - except ValueError: - logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/scrape-switzerland from user {currentUser.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("Invalid CSRF token format") - ) - - # Extract parameters from body with defaults - grid_size = body.get("grid_size", 500.0) - max_concurrent = body.get("max_concurrent", 50) - batch_size = body.get("batch_size", 100) - - # Validate parameters - if grid_size <= 0 or grid_size > 10000: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=routeApiMsg("grid_size must be between 0 and 10000 meters") - ) - - if max_concurrent <= 0 or max_concurrent > 200: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=routeApiMsg("max_concurrent must be between 1 and 200") - ) - - if batch_size <= 0 or batch_size > 1000: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=routeApiMsg("batch_size must be between 1 and 1000") - ) - - logger.info( - f"Starting Switzerland scraping for user {currentUser.id} (mandate: {currentUser.mandateId}) " - f"with grid_size={grid_size}, max_concurrent={max_concurrent}, batch_size={batch_size}" - ) - - # Run scraping operation - result = await scrape_switzerland( - current_user=currentUser, - grid_size=grid_size, - max_concurrent=max_concurrent, - batch_size=batch_size - ) - - logger.info( - f"Scraping completed for user {currentUser.id}: " - f"{result['stats']['parcels_saved']} parcels saved" - ) - - return result - - except HTTPException: - raise - except ValueError as e: - logger.error(f"Validation error in scrape_switzerland_route: {str(e)}", exc_info=True) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Validation error: {str(e)}" - ) - except Exception as e: - logger.error(f"Error scraping Switzerland: {str(e)}", exc_info=True) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error scraping Switzerland: {str(e)}" - ) - - -@router.get("/gemeinden", response_model=Dict[str, Any]) -@limiter.limit("60/minute") -async def get_all_gemeinden( - request: Request, - only_current: bool = Query(True, description="Only return current municipalities (exclude historical)"), - currentUser: User = Depends(getCurrentUser) -) -> Dict[str, Any]: - """ - Fetch all Gemeinden (municipalities) from the Swiss Topo MapServer connector - and save them to the database. - - This endpoint: - 1. Fetches all Swiss municipalities from the Swiss Federal Office of Topography - 2. Saves them to the database (skipping duplicates based on BFS number) - 3. Creates Kantone (cantons) as needed - 4. Returns statistics about the import operation - - Query Parameters: - - only_current: If True, only return current municipalities (default: True). - If False, return all municipalities including historical ones. - - Headers: - - X-CSRF-Token: CSRF token (required for security) - - Returns: - { - "gemeinden": [ - { - "id": "uuid", - "mandateId": "uuid", - "label": "Bern", - "id_kanton": "uuid", - "kontextInformationen": [...], - ... - }, - ... - ], - "count": 2162, - "stats": { - "gemeinden_created": 2100, - "gemeinden_skipped": 62, - "kantone_created": 26, - "error_count": 0, - "errors": [] - } - } - - Example: - - GET /api/realestate/gemeinden - - GET /api/realestate/gemeinden?only_current=false - """ - try: - # Validate CSRF token - csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token") - if not csrf_token: - logger.warning(f"CSRF token missing for GET /api/realestate/gemeinden from user {currentUser.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.") - ) - - # Basic CSRF token format validation - if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: - logger.warning(f"Invalid CSRF token format for GET /api/realestate/gemeinden from user {currentUser.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("Invalid CSRF token format") - ) - - # Validate token is hex string - try: - int(csrf_token, 16) - except ValueError: - logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/gemeinden from user {currentUser.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("Invalid CSRF token format") - ) - - logger.info(f"Fetching all Gemeinden for user {currentUser.id} (mandate: {currentUser.mandateId}), only_current={only_current}") - - # Initialize connectors and fetch all gemeinden - oereb_connector = OerebWfsConnector() - connector = SwissTopoMapServerConnector(oereb_connector=oereb_connector) - gemeinden_data = await connector.get_all_gemeinden(only_current=only_current) - - # Get interface for database operations - realEstateInterface = getRealEstateInterface(currentUser) - - # Statistics - gemeinden_created = 0 - gemeinden_skipped = 0 - kantone_created = 0 - errors = [] - - # Cache for Kanton UUIDs - kanton_cache: Dict[str, str] = {} - - # Helper function to find Gemeinde by BFS number - def find_gemeinde_by_bfs_nummer(bfs_nummer: str) -> Optional[Gemeinde]: - """Find existing Gemeinde by BFS number (stored in kontextInformationen).""" - try: - gemeinden = realEstateInterface.getGemeinden( - recordFilter={"mandateId": currentUser.mandateId} - ) - - for gemeinde in gemeinden: - # Check kontextInformationen for bfs_nummer - for kontext in gemeinde.kontextInformationen: - try: - kontext_data = json.loads(kontext.inhalt) if isinstance(kontext.inhalt, str) else kontext.inhalt - if isinstance(kontext_data, dict): - if str(kontext_data.get("bfs_nummer")) == str(bfs_nummer): - return gemeinde - except (json.JSONDecodeError, AttributeError): - continue - - return None - except Exception as e: - logger.error(f"Error finding Gemeinde by BFS number {bfs_nummer}: {e}", exc_info=True) - return None - - # Helper function to get or create Kanton - def get_or_create_kanton(kanton_abk: str) -> Optional[str]: - """Get or create a Kanton by abbreviation.""" - nonlocal kantone_created, errors - - if not kanton_abk: - return None - - # Check cache first - if kanton_abk in kanton_cache: - return kanton_cache[kanton_abk] - - # Check if exists - kantone = realEstateInterface.getKantone( - recordFilter={ - "mandateId": currentUser.mandateId, - "abk": kanton_abk - } - ) - - if kantone: - kanton_cache[kanton_abk] = kantone[0].id - return kantone[0].id - - # Create new Kanton - try: - # Map common abbreviations to full names - kanton_names = { - "AG": "Aargau", "AI": "Appenzell Innerrhoden", "AR": "Appenzell Ausserrhoden", - "BE": "Bern", "BL": "Basel-Landschaft", "BS": "Basel-Stadt", - "FR": "Freiburg", "GE": "Genf", "GL": "Glarus", "GR": "Graubünden", - "JU": "Jura", "LU": "Luzern", "NE": "Neuenburg", "NW": "Nidwalden", - "OW": "Obwalden", "SG": "St. Gallen", "SH": "Schaffhausen", "SO": "Solothurn", - "SZ": "Schwyz", "TG": "Thurgau", "TI": "Tessin", "UR": "Uri", - "VD": "Waadt", "VS": "Wallis", "ZG": "Zug", "ZH": "Zürich" - } - - kanton_label = kanton_names.get(kanton_abk, kanton_abk) - - kanton = Kanton( - mandateId=currentUser.mandateId, - label=kanton_label, - abk=kanton_abk - ) - - created_kanton = realEstateInterface.createKanton(kanton) - if created_kanton and created_kanton.id: - kanton_cache[kanton_abk] = created_kanton.id - kantone_created += 1 - logger.info(f"Created new Kanton: {kanton_label} ({kanton_abk})") - return created_kanton.id - except Exception as e: - error_msg = f"Error creating Kanton {kanton_abk}: {e}" - logger.error(error_msg, exc_info=True) - errors.append(error_msg) - - return None - - # Process each gemeinde and save to database - saved_gemeinden = [] - for gemeinde_data in gemeinden_data: - try: - gemeinde_name = gemeinde_data.get("name") - bfs_nummer = gemeinde_data.get("bfs_nummer") - kanton_abk = gemeinde_data.get("kanton") - - if not gemeinde_name or not bfs_nummer: - logger.warning(f"Skipping Gemeinde with missing data: {gemeinde_data}") - gemeinden_skipped += 1 - continue - - # Check if Gemeinde already exists - existing_gemeinde = find_gemeinde_by_bfs_nummer(str(bfs_nummer)) - if existing_gemeinde: - logger.debug(f"Gemeinde {gemeinde_name} (BFS: {bfs_nummer}) already exists, skipping") - gemeinden_skipped += 1 - saved_gemeinden.append(existing_gemeinde.model_dump() if hasattr(existing_gemeinde, 'model_dump') else existing_gemeinde) - continue - - # Get or create Kanton - kanton_id = get_or_create_kanton(kanton_abk) if kanton_abk else None - - # Create new Gemeinde - gemeinde = Gemeinde( - mandateId=currentUser.mandateId, - label=gemeinde_name, - id_kanton=kanton_id, - kontextInformationen=[ - Kontext( - thema="BFS Nummer", - inhalt=json.dumps({"bfs_nummer": bfs_nummer}, ensure_ascii=False) - ) - ] - ) - - created_gemeinde = realEstateInterface.createGemeinde(gemeinde) - if created_gemeinde and created_gemeinde.id: - gemeinden_created += 1 - logger.info(f"Created new Gemeinde: {gemeinde_name} (BFS: {bfs_nummer})") - saved_gemeinden.append(created_gemeinde.model_dump() if hasattr(created_gemeinde, 'model_dump') else created_gemeinde) - else: - error_msg = f"Failed to create Gemeinde {gemeinde_name} (BFS: {bfs_nummer})" - logger.error(error_msg) - errors.append(error_msg) - gemeinden_skipped += 1 - - except Exception as e: - error_msg = f"Error processing Gemeinde {gemeinde_data.get('name', 'Unknown')}: {str(e)}" - logger.error(error_msg, exc_info=True) - errors.append(error_msg) - gemeinden_skipped += 1 - - logger.info( - f"Gemeinden import completed: {gemeinden_created} created, " - f"{gemeinden_skipped} skipped, {kantone_created} Kantone created" - ) - - return { - "gemeinden": saved_gemeinden, - "count": len(saved_gemeinden), - "stats": { - "gemeinden_created": gemeinden_created, - "gemeinden_skipped": gemeinden_skipped, - "kantone_created": kantone_created, - "error_count": len(errors), - "errors": errors[:10] # Return first 10 errors - } - } - - except HTTPException: - raise - except Exception as e: - logger.error(f"Error fetching all Gemeinden: {str(e)}", exc_info=True) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error fetching Gemeinden: {str(e)}" - ) - - -def _get_language_from_kanton(kanton_abk: Optional[str]) -> str: - """ - Determine language (German/French/Italian) based on Kanton abbreviation. - - Args: - kanton_abk: Kanton abbreviation (e.g., 'ZH', 'VD', 'TI') - - Returns: - Language code: 'de' (German), 'fr' (French), or 'it' (Italian) - """ - if not kanton_abk: - return 'de' # Default to German - - # French-speaking cantons - french_cantons = {'VD', 'GE', 'NE', 'JU'} - # Italian-speaking canton - italian_cantons = {'TI'} - - kanton_upper = kanton_abk.upper() - if kanton_upper in french_cantons: - return 'fr' - elif kanton_upper in italian_cantons: - return 'it' - else: - return 'de' # Default to German - - -def _get_bzo_search_query(gemeinde_label: str, language: str) -> str: - """ - Generate language-specific BZO search query for a Gemeinde. - - Args: - gemeinde_label: Name of the Gemeinde - language: Language code ('de', 'fr', 'it') - - Returns: - Search query string - """ - if language == 'fr': - # French: Plan d'aménagement local or Règlement de construction - return f"Plan d'aménagement local {gemeinde_label} OR Règlement de construction {gemeinde_label}" - elif language == 'it': - # Italian: Piano di utilizzazione or Regolamento edilizio - return f"Piano di utilizzazione {gemeinde_label} OR Regolamento edilizio {gemeinde_label}" - else: - # German: Bau und Zonenordnung - return f"Bau und Zonenordnung {gemeinde_label}" - - -@router.post("/gemeinden/fetch-bzo-documents", response_model=Dict[str, Any]) -@limiter.limit("10/hour") # Resource-intensive operation -async def fetch_bzo_documents( - request: Request, - currentUser: User = Depends(getCurrentUser) -) -> Dict[str, Any]: - """ - Search for and download Bau und Zonenordnung (BZO) documents for all Gemeinden. - - This endpoint: - 1. Fetches all Gemeinden from the database - 2. For each Gemeinde, determines language based on Kanton - 3. Uses Tavily search to find BZO documents (up to 5 results) - 4. Downloads all PDF files found and stores them with content - 5. Creates Dokument records for each PDF and links them to Gemeinde's dokumente field - 6. Skips Gemeinden that already have BZO documents - - Note: If Tavily returns multiple PDF results, all of them will be downloaded - and saved as separate Dokument records. - - Headers: - - X-CSRF-Token: CSRF token (required for security) - - Returns: - { - "success": true, - "stats": { - "gemeinden_processed": 100, - "documents_created": 85, - "documents_skipped": 15, - "errors": [] - }, - "results": [ - { - "gemeinde_id": "...", - "gemeinde_label": "Zürich", - "status": "created|skipped|error", - "dokument_ids": ["...", "..."], // List of created document IDs (can be multiple) - "error": null - } - ] - } - """ - try: - # Validate CSRF token - csrf_token = request.headers.get("X-CSRF-Token") or request.headers.get("x-csrf-token") - if not csrf_token: - logger.warning(f"CSRF token missing for POST /api/realestate/gemeinden/fetch-bzo-documents from user {currentUser.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.") - ) - - # Basic CSRF token format validation - if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: - logger.warning(f"Invalid CSRF token format for POST /api/realestate/gemeinden/fetch-bzo-documents from user {currentUser.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("Invalid CSRF token format") - ) - - # Validate token is hex string - try: - int(csrf_token, 16) - except ValueError: - logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/gemeinden/fetch-bzo-documents from user {currentUser.id}") - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=routeApiMsg("Invalid CSRF token format") - ) - - logger.info(f"Starting BZO document fetch for user {currentUser.id} (mandate: {currentUser.mandateId})") - - # Get interfaces - realEstateInterface = getRealEstateInterface(currentUser) - componentInterface = getComponentInterface(currentUser) - - # Initialize Tavily connector - tavily = AiTavily() - - # Get all Gemeinden - gemeinden = realEstateInterface.getGemeinden( - recordFilter={"mandateId": currentUser.mandateId} - ) - - logger.info(f"Found {len(gemeinden)} Gemeinden to process") - - # Statistics - stats = { - "gemeinden_processed": 0, - "documents_created": 0, - "documents_skipped": 0, - "errors": [] - } - results = [] - - # Process each Gemeinde - for gemeinde in gemeinden: - gemeinde_result = { - "gemeinde_id": gemeinde.id, - "gemeinde_label": gemeinde.label, - "status": None, - "dokument_ids": [], # Changed to list to support multiple documents - "error": None - } - - try: - stats["gemeinden_processed"] += 1 - - # Check if Gemeinde already has a BZO document - existing_bzo = False - if gemeinde.dokumente: - for doc in gemeinde.dokumente: - # Check if it's a BZO document by label or dokumentTyp - if (doc.label and ("BZO" in doc.label.upper() or "BAU UND ZONENORDNUNG" in doc.label.upper() or - "PLAN D'AMÉNAGEMENT" in doc.label.upper() or "RÈGLEMENT DE CONSTRUCTION" in doc.label.upper() or - "PIANO DI UTILIZZAZIONE" in doc.label.upper() or "REGOLAMENTO EDILIZIO" in doc.label.upper())) or \ - (doc.dokumentTyp and doc.dokumentTyp in [DokumentTyp.GEMEINDE_BZO_AKTUELL, DokumentTyp.GEMEINDE_BZO_REVISION]): - existing_bzo = True - break - - if existing_bzo: - logger.debug(f"Gemeinde {gemeinde.label} already has BZO document, skipping") - gemeinde_result["status"] = "skipped" - stats["documents_skipped"] += 1 - results.append(gemeinde_result) - continue - - # Get Kanton to determine language - kanton_abk = None - if gemeinde.id_kanton: - kanton = realEstateInterface.getKanton(gemeinde.id_kanton) - if kanton: - kanton_abk = kanton.abk - - # Determine language - language = _get_language_from_kanton(kanton_abk) - - # Generate search query - search_query = _get_bzo_search_query(gemeinde.label, language) - logger.info(f"Searching for BZO document for {gemeinde.label} (language: {language}) with query: {search_query}") - - # Search with Tavily using the private _search method - search_results = await tavily._search( - query=search_query, - maxResults=5, - country="switzerland" - ) - - if not search_results: - logger.warning(f"No search results found for {gemeinde.label}") - gemeinde_result["status"] = "error" - gemeinde_result["error"] = "No search results found" - stats["errors"].append(f"{gemeinde.label}: No search results found") - results.append(gemeinde_result) - continue - - # Find all PDF URLs from search results - pdf_urls = [] - for result in search_results: - url = result.url.lower() - if url.endswith('.pdf') or 'pdf' in url: - pdf_urls.append(result.url) - - # If no PDF URLs found, try to use all results (they might be PDFs even without .pdf extension) - if not pdf_urls: - pdf_urls = [result.url for result in search_results] - logger.info(f"No explicit PDF URLs found for {gemeinde.label}, trying all {len(pdf_urls)} results") - - logger.info(f"Found {len(pdf_urls)} potential PDF documents for {gemeinde.label}") - - # Helper function to download a single PDF - async def download_pdf(pdf_url: str) -> Optional[bytes]: - """Download a PDF from a URL with retry logic.""" - max_retries = 3 - retry_delay = 2 - - for attempt in range(max_retries): - try: - # Create headers - use minimal headers on retry after 406 error - if attempt > 0: - # Minimal headers for retry - headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - 'Accept': '*/*' - } - else: - # Full headers for first attempt - headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', - 'Accept': 'application/pdf,application/octet-stream,*/*', - 'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8', - 'Accept-Encoding': 'gzip, deflate, br', - 'Connection': 'keep-alive', - 'Upgrade-Insecure-Requests': '1' - } - - timeout = aiohttp.ClientTimeout(total=30, connect=10) - async with aiohttp.ClientSession(timeout=timeout, headers=headers) as session: - async with session.get(pdf_url, allow_redirects=True) as response: - if response.status == 200: - pdf_content = await response.read() - - if not pdf_content or len(pdf_content) < 100: # Minimum size check - raise Exception("Downloaded file is too small or empty") - - # Verify it's actually a PDF (check PDF magic bytes) - if not pdf_content.startswith(b'%PDF'): - # Check if it's HTML (common error page) - if pdf_content.startswith(b'<') or pdf_content.startswith(b' 1: - file_name = f"BZO_{safe_name}_{idx + 1}.pdf" - doc_label = f"{base_doc_label} ({idx + 1})" - else: - file_name = f"BZO_{safe_name}.pdf" - doc_label = base_doc_label - - # Store file using ComponentObjects - try: - file_item = componentInterface.createFile( - name=file_name, - mimeType="application/pdf", - content=pdf_content - ) - - # Store file data - componentInterface.createFileData(file_item.id, pdf_content) - - logger.info(f"Stored file {file_name} with ID {file_item.id} for {gemeinde.label}") - except Exception as e: - logger.error(f"Error storing file {file_name} for {gemeinde.label}: {str(e)}", exc_info=True) - stats["errors"].append(f"{gemeinde.label}: File storage failed for {pdf_url} - {str(e)}") - continue - - # Create Dokument record - dokument = Dokument( - mandateId=currentUser.mandateId, - label=doc_label, - versionsbezeichnung="Aktuell", - dokumentTyp=DokumentTyp.GEMEINDE_BZO_AKTUELL, - dokumentReferenz=file_item.id, # FileId from ComponentObjects - quelle=pdf_url, # Original URL - mimeType="application/pdf", - kategorienTags=["BZO", "Bauordnung", gemeinde.label] - ) - - # Create Dokument record in the Dokument table - created_dokument = realEstateInterface.createDokument(dokument) - logger.info(f"Created Dokument record with ID {created_dokument.id} for {gemeinde.label} (from {pdf_url})") - - created_dokumente.append(created_dokument) - current_dokumente.append(created_dokument) - gemeinde_result["dokument_ids"].append(created_dokument.id) - - except Exception as e: - logger.error(f"Error processing PDF {pdf_url} for {gemeinde.label}: {str(e)}", exc_info=True) - stats["errors"].append(f"{gemeinde.label}: Error processing PDF {pdf_url} - {str(e)}") - continue - - # Update Gemeinde with all new dokumente - if created_dokumente: - updated_gemeinde = realEstateInterface.updateGemeinde( - gemeinde.id, - {"dokumente": current_dokumente} - ) - - if updated_gemeinde: - logger.info(f"Successfully created {len(created_dokumente)} BZO document(s) for {gemeinde.label}") - gemeinde_result["status"] = "created" - stats["documents_created"] += len(created_dokumente) - else: - raise Exception("Failed to update Gemeinde") - else: - # No documents were successfully created - gemeinde_result["status"] = "error" - gemeinde_result["error"] = "No PDFs could be downloaded or processed" - stats["errors"].append(f"{gemeinde.label}: No PDFs could be downloaded or processed") - - except Exception as e: - logger.error(f"Error processing Gemeinde {gemeinde.label}: {str(e)}", exc_info=True) - gemeinde_result["status"] = "error" - gemeinde_result["error"] = str(e) - stats["errors"].append(f"{gemeinde.label}: {str(e)}") - - results.append(gemeinde_result) - - logger.info( - f"BZO document fetch completed: {stats['documents_created']} created, " - f"{stats['documents_skipped']} skipped, {len(stats['errors'])} errors" - ) - - return { - "success": True, - "stats": stats, - "results": results - } - - except HTTPException: - raise - except Exception as e: - logger.error(f"Error fetching BZO documents: {str(e)}", exc_info=True) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error fetching BZO documents: {str(e)}" - ) diff --git a/modules/serviceCenter/services/serviceAi/subDocumentIntents.py b/modules/serviceCenter/services/serviceAi/subDocumentIntents.py index d4d7fae7..7d47c18f 100644 --- a/modules/serviceCenter/services/serviceAi/subDocumentIntents.py +++ b/modules/serviceCenter/services/serviceAi/subDocumentIntents.py @@ -13,7 +13,7 @@ import logging from typing import Dict, Any, List, Optional from modules.datamodels.datamodelChat import ChatDocument -from modules.datamodels.datamodelExtraction import DocumentIntent +from modules.datamodels.datamodelExtraction import DocumentIntent, ContentExtracted from modules.shared.workflowState import checkWorkflowStopped logger = logging.getLogger(__name__) diff --git a/modules/serviceCenter/services/serviceAi/subJsonResponseHandling.py b/modules/serviceCenter/services/serviceAi/subJsonResponseHandling.py index 3adb613c..1945c550 100644 --- a/modules/serviceCenter/services/serviceAi/subJsonResponseHandling.py +++ b/modules/serviceCenter/services/serviceAi/subJsonResponseHandling.py @@ -12,10 +12,18 @@ Handles merging of JSON responses from multiple AI iterations, including: """ import json import logging -import re from typing import Dict, Any, List, Optional, Tuple -from modules.shared.jsonUtils import extractJsonString, repairBrokenJson, extractSectionsFromDocument +from modules.shared.jsonUtils import ( + extractJsonString, + repairBrokenJson, + extractSectionsFromDocument, + stripCodeFences, + normalizeJsonText, + closeJsonStructures, + tryParseJson, + extractFirstBalancedJson, +) from modules.datamodels.datamodelAi import JsonAccumulationState logger = logging.getLogger(__name__) diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPptx.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPptx.py index 0b502e79..36d399d8 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPptx.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPptx.py @@ -3,7 +3,6 @@ import logging import base64 import io -import json import re from datetime import datetime, UTC from typing import Dict, Any, Optional, List @@ -200,7 +199,6 @@ class RendererPptx(BaseRenderer): logger.warning(f"Could not clear placeholders: {str(placeholder_error)}") # Add title as textbox - from pptx.util import Inches titleBox = slide.shapes.add_textbox(Inches(0.5), Inches(0.2), prs.slide_width - Inches(1), Inches(0.6)) titleFrame = titleBox.text_frame titleFrame.text = slide_data.get("title", "Slide") @@ -299,8 +297,6 @@ class RendererPptx(BaseRenderer): # Convert to base64 pptx_bytes = buffer.getvalue() - pptx_base64 = base64.b64encode(pptx_bytes).decode('utf-8') - logger.info(f"Successfully rendered PowerPoint presentation: {len(pptx_bytes)} bytes") # Determine filename from document or title @@ -1247,6 +1243,7 @@ class RendererPptx(BaseRenderer): try: from pptx.util import Inches, Pt from pptx.enum.text import PP_ALIGN + from pptx.dml.color import RGBColor if not images: logger.debug("No images to render in frame") diff --git a/modules/workflowAutomation/engine/executors/actionNodeExecutor.py b/modules/workflowAutomation/engine/executors/actionNodeExecutor.py index dc88c7ab..799d1606 100644 --- a/modules/workflowAutomation/engine/executors/actionNodeExecutor.py +++ b/modules/workflowAutomation/engine/executors/actionNodeExecutor.py @@ -18,7 +18,6 @@ from modules.nodeCatalog.portTypes import ( normalizeToSchema, ) from modules.datamodels.serviceExceptions import SubscriptionInactiveException as _SubscriptionInactiveException, BillingContextError as _BillingContextError -from modules.workflowAutomation.engine.executors.inputExecutor import PauseForHumanTaskError from modules.workflows.methods.methodContext.actions.extractContent import ( PRESENTATION_KIND, build_presentation_envelope_from_plain_text, diff --git a/modules/workflowAutomation/engine/executors/flowExecutor.py b/modules/workflowAutomation/engine/executors/flowExecutor.py index 7a296204..0f5f85d1 100644 --- a/modules/workflowAutomation/engine/executors/flowExecutor.py +++ b/modules/workflowAutomation/engine/executors/flowExecutor.py @@ -21,7 +21,6 @@ class FlowExecutor: ) -> Any: nodeType = node.get("type", "") nodeOutputs = context.get("nodeOutputs", {}) - connectionMap = context.get("connectionMap", {}) nodeId = node.get("id", "") inputSources = context.get("inputSources", {}).get(nodeId, {}) logger.info( @@ -151,6 +150,8 @@ class FlowExecutor: else: nodes.append({"id": producer_id, "type": ""}) return {"nodes": nodes, "targetNodeId": node.get("id")} + + def _compare_dates(self, left: Any, right: Any, op) -> bool: """Compare left/right as dates; op(a,b) is the comparison.""" def parse(v): @@ -211,7 +212,6 @@ class FlowExecutor: from modules.workflowAutomation.engine.graphUtils import resolveParameterReferences from modules.workflowAutomation.editor.switchOutput import ( build_switch_combined_output, - build_switch_default_payload, ) value = resolveParameterReferences(valueExpr, nodeOutputs) From c1655bdd0aec88cbad7e64d2ea31320dd020a79d Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Mon, 8 Jun 2026 21:01:32 +0200 Subject: [PATCH 09/16] refactor: move billingWebhookHandler to serviceBilling layer Business logic for Stripe webhooks belongs in serviceCenter/services/serviceBilling/, not in routes/. Updates 3 lazy imports in routeBilling.py accordingly. Co-authored-by: Cursor --- modules/routes/routeBilling.py | 6 +++--- .../services/serviceBilling}/billingWebhookHandler.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) rename modules/{routes => serviceCenter/services/serviceBilling}/billingWebhookHandler.py (99%) diff --git a/modules/routes/routeBilling.py b/modules/routes/routeBilling.py index 058038c0..d193f9bb 100644 --- a/modules/routes/routeBilling.py +++ b/modules/routes/routeBilling.py @@ -332,7 +332,7 @@ def _getStripeClient(): def _creditStripeSessionIfNeeded(billingInterface, session: Dict[str, Any], eventId: Optional[str] = None) -> CheckoutConfirmResponse: """Credit balance from Stripe Checkout session if not already credited.""" - from .billingWebhookHandler import creditStripeSessionIfNeeded + from modules.serviceCenter.services.serviceBilling.billingWebhookHandler import creditStripeSessionIfNeeded return creditStripeSessionIfNeeded(billingInterface, session, eventId, CheckoutConfirmResponse) @@ -1079,13 +1079,13 @@ async def stripeWebhook( def handleSubscriptionCheckoutCompleted(session, eventId: str) -> None: """Handle checkout.session.completed for mode=subscription.""" - from .billingWebhookHandler import handleSubscriptionCheckoutCompleted as _handler + from modules.serviceCenter.services.serviceBilling.billingWebhookHandler import handleSubscriptionCheckoutCompleted as _handler _handler(session, eventId, getRootInterface) def _handleSubscriptionWebhook(event) -> None: """Process Stripe subscription webhook events.""" - from .billingWebhookHandler import handleSubscriptionWebhook as _handler + from modules.serviceCenter.services.serviceBilling.billingWebhookHandler import handleSubscriptionWebhook as _handler _handler(event, getRootInterface) diff --git a/modules/routes/billingWebhookHandler.py b/modules/serviceCenter/services/serviceBilling/billingWebhookHandler.py similarity index 99% rename from modules/routes/billingWebhookHandler.py rename to modules/serviceCenter/services/serviceBilling/billingWebhookHandler.py index ecfe37b4..8e765cc7 100644 --- a/modules/routes/billingWebhookHandler.py +++ b/modules/serviceCenter/services/serviceBilling/billingWebhookHandler.py @@ -2,7 +2,7 @@ # All rights reserved. """ Stripe webhook and subscription business logic for billing. -Extracted from routeBilling.py for maintainability. +Handles checkout credit, subscription lifecycle transitions, and invoice events. """ import logging From 4f8473bd701ed35bc2d4e1fafad0958c502ebb15 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Mon, 8 Jun 2026 23:35:31 +0200 Subject: [PATCH 10/16] cleaned servicebag and removed servicehub --- app.py | 11 + modules/datamodels/datamodelViews.py | 2 +- .../datamodels/datamodelWorkflowAutomation.py | 2 +- modules/demoConfigs/investorDemo2026.py | 27 +-- modules/demoConfigs/pwgDemo2026.py | 119 ++--------- .../neutralization/neutralizePlayground.py | 92 +++++---- .../mainServiceNeutralization.py | 12 +- .../features/realEstate/serviceAiIntent.py | 7 +- modules/features/realEstate/serviceBzo.py | 15 +- .../interfaces/_legacyMigrationTelemetry.py | 4 +- modules/interfaces/interfaceBootstrap.py | 13 +- modules/interfaces/interfaceDbApp.py | 13 +- modules/interfaces/interfaceDbManagement.py | 8 +- modules/interfaces/interfaceFeatures.py | 7 +- .../interfaces/interfaceWorkflowAutomation.py | 10 +- .../editor => nodeCatalog}/entryPoints.py | 70 ++++--- modules/routes/routeBilling.py | 4 +- modules/routes/routeClickup.py | 10 +- modules/routes/routeSharepoint.py | 16 +- modules/routes/routeSystem.py | 4 +- modules/routes/routeWorkflowAutomation.py | 37 ++-- modules/serviceCenter/__init__.py | 47 +++-- modules/serviceCenter/resolver.py | 7 +- modules/serviceCenter/serviceHub.py | 189 ------------------ .../serviceAgent/actionToolAdapter.py | 4 +- .../coreTools/_connectionTools.py | 5 +- .../coreTools/_crossWorkflowTools.py | 8 +- .../coreTools/_dataSourceTools.py | 4 +- .../coreTools/_featureSubAgentTools.py | 5 - .../serviceAgent/coreTools/_helpers.py | 13 +- .../serviceAgent/coreTools/_mediaTools.py | 42 ++-- .../serviceAgent/coreTools/_workspaceTools.py | 50 ++--- .../services/serviceAgent/mainServiceAgent.py | 29 +-- .../serviceAi/subContentExtraction.py | 4 +- .../services/serviceAi/subDocumentIntents.py | 2 +- .../services/serviceBilling/stripeCheckout.py | 2 +- .../services/serviceChat/mainServiceChat.py | 145 ++++++++++++++ .../services/serviceClickup/__init__.py | 4 +- .../serviceClickup/mainServiceClickup.py | 2 +- .../mainServiceGeneration.py | 24 +-- modules/shared/systemComponentRegistry.py | 32 +++ .../workflowArtifactVisibility.py | 4 +- modules/shared/workflowState.py | 2 +- .../engine/executionEngine.py | 74 +++---- .../engine/executors/actionNodeExecutor.py | 40 +--- .../engine/executors/inputExecutor.py | 4 +- .../engine/runFileLogger.py | 134 ++++++------- modules/workflowAutomation/helpers.py | 4 +- .../mainWorkflowAutomation.py | 64 +----- .../scheduler/mainScheduler.py | 4 +- .../methods/methodAi/actions/process.py | 21 +- .../methods/methodAi/actions/webResearch.py | 21 +- modules/workflows/methods/methodBase.py | 6 +- .../methodContext/actions/extractContent.py | 28 +-- .../methods/methodFile/actions/create.py | 45 +---- .../actions/downloadFileByPath.py | 18 +- .../processing/modes/modeAutomation.py | 4 +- .../workflows/processing/modes/modeBase.py | 7 +- .../workflows/processing/workflowProcessor.py | 16 +- modules/workflows/workflowManager.py | 18 +- tests/eval/runTrusteeBenchmark.py | 27 ++- tests/functional/test01_ai_model_selection.py | 23 ++- tests/functional/test02_ai_models.py | 27 ++- tests/functional/test03_ai_operations.py | 28 ++- tests/functional/test04_ai_behavior.py | 28 ++- .../workflow/test_extract_content_handover.py | 14 +- 66 files changed, 814 insertions(+), 948 deletions(-) rename modules/{workflowAutomation/editor => nodeCatalog}/entryPoints.py (63%) delete mode 100644 modules/serviceCenter/serviceHub.py create mode 100644 modules/shared/systemComponentRegistry.py rename modules/{workflowAutomation/engine => shared}/workflowArtifactVisibility.py (90%) diff --git a/app.py b/app.py index 185ea95d..f76f7083 100644 --- a/app.py +++ b/app.py @@ -311,6 +311,17 @@ async def lifespan(app: FastAPI): # AI connectors already pre-warmed at module-load via _eager_prewarm() in aicoreModelRegistry. + # Register system-component lifecycle hooks (Composition Root — inverts L4->L5b dependency) + from modules.shared.systemComponentRegistry import registerLifecycleHook + from modules.workflowAutomation.mainWorkflowAutomation import ( + onBootstrap as _waOnBootstrap, + onMandateDelete as _waOnMandateDelete, + onInstanceCreate as _waOnInstanceCreate, + ) + registerLifecycleHook("onBootstrap", _waOnBootstrap) + registerLifecycleHook("onMandateDelete", _waOnMandateDelete) + registerLifecycleHook("onInstanceCreate", _waOnInstanceCreate) + # Bootstrap database if needed (creates initial users, mandates, roles, etc.) # This must happen before getting root interface from modules.security.rootAccess import getRootDbAppConnector diff --git a/modules/datamodels/datamodelViews.py b/modules/datamodels/datamodelViews.py index 03a5a27f..28625d16 100644 --- a/modules/datamodels/datamodelViews.py +++ b/modules/datamodels/datamodelViews.py @@ -247,7 +247,7 @@ from modules.datamodels.datamodelFeatures import AutoWorkflow @i18nModel("Workflow (Ansicht)") -class Automation2WorkflowView(AutoWorkflow): +class AutoWorkflowView(AutoWorkflow): """AutoWorkflow extended with computed dashboard fields. Used exclusively for /api/attributes/ so the frontend can resolve column diff --git a/modules/datamodels/datamodelWorkflowAutomation.py b/modules/datamodels/datamodelWorkflowAutomation.py index c9957c25..51d84814 100644 --- a/modules/datamodels/datamodelWorkflowAutomation.py +++ b/modules/datamodels/datamodelWorkflowAutomation.py @@ -53,7 +53,7 @@ class AutoTemplateScope(str, Enum): SYSTEM = "system" -GRAPHICAL_EDITOR_DATABASE = "poweron_graphicaleditor" +WORKFLOW_AUTOMATION_DATABASE = "poweron_graphicaleditor" # --------------------------------------------------------------------------- diff --git a/modules/demoConfigs/investorDemo2026.py b/modules/demoConfigs/investorDemo2026.py index 62b523d1..84fc5e01 100644 --- a/modules/demoConfigs/investorDemo2026.py +++ b/modules/demoConfigs/investorDemo2026.py @@ -60,7 +60,7 @@ class InvestorDemo2026(BaseDemoConfig): label = "Investor Demo April 2026" description = ( "Two mandates (HappyLife AG + Alpina Treuhand AG), one SysAdmin user, " - "trustee with RMA, workspace, graph editor, and neutralization." + "trustee with RMA, workspace, workflow automation, and neutralization." ) credentials = [ { @@ -554,20 +554,21 @@ class InvestorDemo2026(BaseDemoConfig): try: from modules.datamodels.datamodelWorkflowAutomation import ( AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask, + WORKFLOW_AUTOMATION_DATABASE, ) from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.shared.configuration import APP_CONFIG - geDb = DatabaseConnector( + waDb = DatabaseConnector( dbHost=APP_CONFIG.get("DB_HOST", "localhost"), - dbDatabase="poweron_graphicaleditor", + dbDatabase=WORKFLOW_AUTOMATION_DATABASE, dbUser=APP_CONFIG.get("DB_USER"), dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), userId=None, ) - workflows = geDb.getRecordset(AutoWorkflow, recordFilter={ + workflows = waDb.getRecordset(AutoWorkflow, recordFilter={ "mandateId": mandateId, "featureInstanceId": featureInstanceId, }) or [] @@ -577,20 +578,20 @@ class InvestorDemo2026(BaseDemoConfig): if not wfId: continue - for version in geDb.getRecordset(AutoVersion, recordFilter={"workflowId": wfId}) or []: - geDb.recordDelete(AutoVersion, version.get("id")) + for version in waDb.getRecordset(AutoVersion, recordFilter={"workflowId": wfId}) or []: + waDb.recordDelete(AutoVersion, version.get("id")) - runs = geDb.getRecordset(AutoRun, recordFilter={"workflowId": wfId}) or [] + runs = waDb.getRecordset(AutoRun, recordFilter={"workflowId": wfId}) or [] for run in runs: runId = run.get("id") - for stepLog in geDb.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []: - geDb.recordDelete(AutoStepLog, stepLog.get("id")) - geDb.recordDelete(AutoRun, runId) + for stepLog in waDb.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []: + waDb.recordDelete(AutoStepLog, stepLog.get("id")) + waDb.recordDelete(AutoRun, runId) - for task in geDb.getRecordset(AutoTask, recordFilter={"workflowId": wfId}) or []: - geDb.recordDelete(AutoTask, task.get("id")) + for task in waDb.getRecordset(AutoTask, recordFilter={"workflowId": wfId}) or []: + waDb.recordDelete(AutoTask, task.get("id")) - geDb.recordDelete(AutoWorkflow, wfId) + waDb.recordDelete(AutoWorkflow, wfId) if workflows: summary["removed"].append(f"{len(workflows)} AutoWorkflows in {mandateLabel}") diff --git a/modules/demoConfigs/pwgDemo2026.py b/modules/demoConfigs/pwgDemo2026.py index efcd8c8a..2d301f7a 100644 --- a/modules/demoConfigs/pwgDemo2026.py +++ b/modules/demoConfigs/pwgDemo2026.py @@ -51,9 +51,6 @@ _FEATURES_PWG = [ {"code": "neutralization", "label": "Datenschutz"}, ] -# Filename markers used to identify the imported pilot workflow on remove(). -_PILOT_WORKFLOW_LABEL = "PWG Pilot: Jahresmietzinsbestätigung" -_PILOT_WORKFLOW_FILE = "pwg-mietzinsbestaetigung-pilot.workflow.json" _SEED_TRUSTEE_FILE = "_seedTrusteeData.json" @@ -62,8 +59,7 @@ class PwgDemo2026(BaseDemoConfig): label = "PWG Pilot Demo (Mietzinsbestätigungen)" description = ( "Stiftung PWG, ein Demo-Sachbearbeiter, Trustee mit fiktiven Mietern, " - "Graph-Editor mit dem Pilot-Workflow für Jahresmietzinsbestätigungen " - "(als File importiert, active=false). Idempotent." + "Workflow-Automation (als File importiert, active=false). Idempotent." ) credentials = [ { @@ -536,92 +532,6 @@ class PwgDemo2026(BaseDemoConfig): if skippedTenants: summary["skipped"].append(f"PWG seed: {skippedTenants} tenants already present") - def _ensurePilotWorkflow(self, mandateId: str, featureInstanceId: str, summary: Dict): - """Import the pilot workflow JSON into the WorkflowAutomation DB. - - Uses the schema-aware import pipeline introduced in Phase 1 - (``_workflowFileSchema.envelopeToWorkflowData`` + - ``WorkflowAutomationObjects.importWorkflowFromDict``). The workflow is - always created with ``active=False`` so a manual trigger is required - — this matches the demo-bootstrap safety default. - """ - envelopePath = _demoDataDir() / "workflows" / _PILOT_WORKFLOW_FILE - if not envelopePath.is_file(): - summary["errors"].append(f"Pilot workflow file missing: {envelopePath}") - return - try: - envelope = json.loads(envelopePath.read_text(encoding="utf-8")) - except Exception as exc: - summary["errors"].append(f"Pilot workflow file unreadable: {exc}") - return - - try: - geDb = _openWorkflowAutomationDb() - except Exception as exc: - summary["errors"].append(f"WorkflowAutomation DB connection failed: {exc}") - return - - from modules.nodeCatalog._workflowFileSchema import ( - envelopeToWorkflowData, - validateFileEnvelope, - ) - from modules.datamodels.datamodelWorkflowAutomation import AutoWorkflow - from modules.workflowAutomation.editor.nodeRegistry import STATIC_NODE_TYPES - - existing = geDb.getRecordset(AutoWorkflow, recordFilter={ - "mandateId": mandateId, - "featureInstanceId": featureInstanceId, - "label": _PILOT_WORKFLOW_LABEL, - }) or [] - if existing: - summary["skipped"].append(f"Pilot workflow already imported ({existing[0].get('id')})") - return - - knownTypes = [n.get("id") for n in STATIC_NODE_TYPES if isinstance(n, dict) and n.get("id")] - try: - normalized, warnings = validateFileEnvelope(envelope, knownNodeTypes=knownTypes) - except Exception as exc: - summary["errors"].append(f"Pilot workflow envelope invalid: {exc}") - return - if warnings: - summary["created"].append(f"Pilot workflow warnings: {warnings}") - - data = envelopeToWorkflowData( - normalized, - mandateId=mandateId, - featureInstanceId=featureInstanceId, - ) - # Inject the trustee feature-instance id into the parameters so the - # node runtime resolves it without manual editor cleanup. - trusteeInstanceId = self._guessTrusteeInstanceId(mandateId) - if trusteeInstanceId: - for node in data.get("graph", {}).get("nodes", []) or []: - params = node.get("parameters") or {} - if "featureInstanceId" in params and not params["featureInstanceId"]: - params["featureInstanceId"] = trusteeInstanceId - node["parameters"] = params - - # Force-import: AutoWorkflow.create accepts our envelope-derived data - # (graph, label, invocations, …) verbatim; we add ids/timestamps that - # AutoWorkflow expects. - record = AutoWorkflow( - id=str(uuid.uuid4()), - mandateId=mandateId, - featureInstanceId=featureInstanceId, - label=data.get("label") or _PILOT_WORKFLOW_LABEL, - description=data.get("description") or "", - tags=data.get("tags") or [], - graph=data.get("graph") or {"nodes": [], "connections": []}, - invocations=data.get("invocations") or [], - templateScope=data.get("templateScope") or "instance", - sharedReadOnly=bool(data.get("sharedReadOnly")), - notifyOnFailure=bool(data.get("notifyOnFailure", True)), - active=False, - ) - created = geDb.recordCreate(AutoWorkflow, record) - summary["created"].append(f"Pilot workflow imported (active=false, id={created.get('id')})") - logger.info(f"Imported pilot workflow into workflowAutomation instance {featureInstanceId}") - def _guessTrusteeInstanceId(self, mandateId: str) -> Optional[str]: """Return the first trustee feature-instance id of the given mandate. @@ -728,23 +638,23 @@ class PwgDemo2026(BaseDemoConfig): AutoVersion, AutoWorkflow, ) - geDb = _openWorkflowAutomationDb() - workflows = geDb.getRecordset(AutoWorkflow, recordFilter={ + waDb = _openWorkflowAutomationDb() + workflows = waDb.getRecordset(AutoWorkflow, recordFilter={ "mandateId": mandateId, "featureInstanceId": featureInstanceId, }) or [] for wf in workflows: wfId = wf.get("id") - for version in geDb.getRecordset(AutoVersion, recordFilter={"workflowId": wfId}) or []: - geDb.recordDelete(AutoVersion, version.get("id")) - for run in geDb.getRecordset(AutoRun, recordFilter={"workflowId": wfId}) or []: + for version in waDb.getRecordset(AutoVersion, recordFilter={"workflowId": wfId}) or []: + waDb.recordDelete(AutoVersion, version.get("id")) + for run in waDb.getRecordset(AutoRun, recordFilter={"workflowId": wfId}) or []: runId = run.get("id") - for step in geDb.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []: - geDb.recordDelete(AutoStepLog, step.get("id")) - geDb.recordDelete(AutoRun, runId) - for task in geDb.getRecordset(AutoTask, recordFilter={"workflowId": wfId}) or []: - geDb.recordDelete(AutoTask, task.get("id")) - geDb.recordDelete(AutoWorkflow, wfId) + for step in waDb.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []: + waDb.recordDelete(AutoStepLog, step.get("id")) + waDb.recordDelete(AutoRun, runId) + for task in waDb.getRecordset(AutoTask, recordFilter={"workflowId": wfId}) or []: + waDb.recordDelete(AutoTask, task.get("id")) + waDb.recordDelete(AutoWorkflow, wfId) if workflows: summary["removed"].append(f"{len(workflows)} AutoWorkflows in {mandateLabel}") except Exception as e: @@ -814,12 +724,13 @@ def _openTrusteeDb(): def _openWorkflowAutomationDb(): - """Open a privileged DB connection to ``poweron_graphicaleditor``.""" + """Open a privileged DB connection to the workflow-automation database.""" from modules.connectors.connectorDbPostgre import DatabaseConnector + from modules.datamodels.datamodelWorkflowAutomation import WORKFLOW_AUTOMATION_DATABASE from modules.shared.configuration import APP_CONFIG return DatabaseConnector( dbHost=APP_CONFIG.get("DB_HOST", "localhost"), - dbDatabase="poweron_graphicaleditor", + dbDatabase=WORKFLOW_AUTOMATION_DATABASE, dbUser=APP_CONFIG.get("DB_USER"), dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), diff --git a/modules/features/neutralization/neutralizePlayground.py b/modules/features/neutralization/neutralizePlayground.py index 0bd50b49..e855ad22 100644 --- a/modules/features/neutralization/neutralizePlayground.py +++ b/modules/features/neutralization/neutralizePlayground.py @@ -9,7 +9,8 @@ from urllib.parse import urlparse, unquote from modules.datamodels.datamodelUam import User from .datamodelFeatureNeutralizer import DataNeutralizerAttributes, DataNeutraliserConfig, DataNeutralizationSnapshot from .interfaceFeatureNeutralizer import getInterface as _getNeutralizerInterface -from modules.serviceCenter.serviceHub import getInterface as getServices +from modules.serviceCenter import getService +from modules.serviceCenter.context import ServiceCenterContext logger = logging.getLogger(__name__) @@ -21,10 +22,13 @@ class NeutralizationPlayground: self.currentUser = currentUser self.mandateId = mandateId self.featureInstanceId = featureInstanceId - self.services = getServices(currentUser, None, mandateId=mandateId, featureInstanceId=featureInstanceId) + self._ctx = ServiceCenterContext(user=currentUser, mandate_id=mandateId, feature_instance_id=featureInstanceId) + + def _getService(self, name: str): + return getService(name, self._ctx) def processText(self, text: str) -> Dict[str, Any]: - return self.services.neutralization.processText(text) + return self._getService("neutralization").processText(text) async def processUploadedFileAsync(self, file_bytes: bytes, filename: str) -> Dict[str, Any]: """Process an uploaded file (bytes + filename). Returns neutralized result for text or binary. @@ -43,32 +47,35 @@ class NeutralizationPlayground: original_file_id = None neutralized_file_id = None + neutralizationService = self._getService("neutralization") - # Save original file to user files - if self.services.interfaceDbComponent: + try: + chatService = self._getService("chat") + except Exception: + chatService = None + + if chatService: try: - file_item, _ = self.services.interfaceDbComponent.saveUploadedFile(file_bytes, filename) + file_item, _ = chatService.saveUploadedFile(file_bytes, filename) original_file_id = str(file_item.id) except Exception as e: logger.warning(f"Could not save original file to user files: {e}") if is_binary: - result = await self.services.neutralization.processBinaryBytesAsync(file_bytes, filename, mime) + result = await neutralizationService.processBinaryBytesAsync(file_bytes, filename, mime) neu_bytes = result.get('neutralized_bytes') logger.debug(f"Binary result: neu_bytes type={type(neu_bytes).__name__}, len={len(neu_bytes) if neu_bytes is not None else 0}") if neu_bytes is not None and len(neu_bytes) > 0: result['neutralized_file_base64'] = base64.b64encode(neu_bytes).decode('ascii') result['neutralized_file_name'] = result.get('neutralized_file_name', f'neutralized_{filename}') result['mime_type'] = result.get('mime_type', mime) - # Save neutralized binary to user files - if self.services.interfaceDbComponent: + if chatService: try: neu_name = result['neutralized_file_name'] - file_item, _ = self.services.interfaceDbComponent.saveUploadedFile(neu_bytes, neu_name) + file_item, _ = chatService.saveUploadedFile(neu_bytes, neu_name) neutralized_file_id = str(file_item.id) except Exception as e: logger.warning(f"Could not save neutralized file to user files: {e}") - # Remove raw bytes before JSON response (avoid serialization issues; use base64 only) result.pop('neutralized_bytes', None) result['original_file_id'] = original_file_id result['neutralized_file_id'] = neutralized_file_id @@ -86,15 +93,14 @@ class NeutralizationPlayground: 'neutralized_file_id': None, 'processed_info': {'type': 'error', 'error': 'File could not be decoded as text. Supported: UTF-8, Latin-1. For PDF/Word/Excel, use supported binary formats.'} } - result = await self.services.neutralization.processTextAsync(text_content) + result = await neutralizationService.processTextAsync(text_content) result['neutralized_file_name'] = f'neutralized_{filename}' - # Save neutralized text as file to user files - if self.services.interfaceDbComponent and result.get('neutralized_text') is not None: + if chatService and result.get('neutralized_text') is not None: try: neu_text = result['neutralized_text'] neu_bytes = neu_text.encode('utf-8') neu_name = result['neutralized_file_name'] - file_item, _ = self.services.interfaceDbComponent.saveUploadedFile(neu_bytes, neu_name) + file_item, _ = chatService.saveUploadedFile(neu_bytes, neu_name) neutralized_file_id = str(file_item.id) except Exception as e: logger.warning(f"Could not save neutralized text file to user files: {e}") @@ -111,7 +117,7 @@ class NeutralizationPlayground: errors: List[str] = [] for fileId in fileIds: try: - res = self.services.neutralization.processFile(fileId) + res = self._getService("neutralization").processFile(fileId) results.append({ 'file_id': fileId, 'neutralized_file_name': res.get('neutralized_file_name'), @@ -137,12 +143,12 @@ class NeutralizationPlayground: # Cleanup attributes def cleanAttributes(self, fileId: str) -> bool: - return self.services.neutralization.deleteNeutralizationAttributes(fileId) + return self._getService("neutralization").deleteNeutralizationAttributes(fileId) # Stats def getStats(self) -> Dict[str, Any]: try: - allAttributes = self.services.neutralization.getAttributes() + allAttributes = self._getService("neutralization").getAttributes() patternCounts: Dict[str, int] = {} for attr in allAttributes: # Handle both dict and object access patterns @@ -184,24 +190,24 @@ class NeutralizationPlayground: # Additional methods needed by the route def getConfig(self) -> Optional[DataNeutraliserConfig]: """Get neutralization configuration""" - return self.services.neutralization.getConfig() + return self._getService("neutralization").getConfig() def saveConfig(self, configData: Dict[str, Any]) -> DataNeutraliserConfig: """Save neutralization configuration""" - return self.services.neutralization.saveConfig(configData) + return self._getService("neutralization").saveConfig(configData) def neutralizeText(self, text: str, fileId: str = None) -> Dict[str, Any]: """Neutralize text content""" - return self.services.neutralization.processText(text) + return self._getService("neutralization").processText(text) def resolveText(self, text: str) -> str: """Resolve UIDs in neutralized text back to original text""" - return self.services.neutralization.resolveText(text) + return self._getService("neutralization").resolveText(text) def getSnapshots(self) -> List[DataNeutralizationSnapshot]: """Return stored neutralization text snapshots.""" try: - return self.services.neutralization.getSnapshots() + return self._getService("neutralization").getSnapshots() except Exception as e: logger.error(f"Error getting snapshots: {e}") return [] @@ -209,7 +215,7 @@ class NeutralizationPlayground: def getAttributes(self, fileId: str = None) -> List[DataNeutralizerAttributes]: """Get neutralization attributes, optionally filtered by file ID""" try: - allAttributes = self.services.neutralization.getAttributes() + allAttributes = self._getService("neutralization").getAttributes() if fileId: want = str(fileId).strip() @@ -227,8 +233,7 @@ class NeutralizationPlayground: async def processSharepointFiles(self, sourcePath: str, targetPath: str) -> Dict[str, Any]: """Process files from SharePoint source path and store neutralized files in target path""" - from modules.serviceCenter.services.serviceSharepoint.mainServiceSharepoint import SharepointService - processor = SharepointProcessor(self.currentUser, self.services) + processor = SharepointProcessor(self.currentUser, self._ctx) return await processor.processSharepointFiles(sourcePath, targetPath) def batchNeutralizeFiles(self, filesData: List[Dict[str, Any]]) -> Dict[str, Any]: @@ -247,15 +252,18 @@ class NeutralizationPlayground: # Internal SharePoint helper module separated to keep feature logic tidy class SharepointProcessor: - def __init__(self, currentUser: User, services): + def __init__(self, currentUser: User, ctx: ServiceCenterContext): self.currentUser = currentUser - self.services = services + self._ctx = ctx + self._sharepoint = getService("sharepoint", ctx) + self._neutralization = getService("neutralization", ctx) + from modules.interfaces.interfaceDbApp import getInterface as _getAppInterface + self._interfaceDbApp = _getAppInterface(currentUser, mandateId=ctx.mandate_id) async def processSharepointFiles(self, sourcePath: str, targetPath: str) -> Dict[str, Any]: try: logger.info(f"Processing SharePoint files from {sourcePath} to {targetPath}") - # Get SharePoint connection connection = await self._getSharepointConnection(sourcePath) if not connection: return { @@ -265,8 +273,7 @@ class SharepointProcessor: 'errors': ['No SharePoint connection found'], } - # Set access token for SharePoint service - if not self.services.sharepoint.setAccessTokenFromConnection(connection): + if not self._sharepoint.setAccessTokenFromConnection(connection): return { 'success': False, 'message': 'Failed to set SharePoint access token', @@ -286,8 +293,7 @@ class SharepointProcessor: async def _getSharepointConnection(self, sharepointPath: str = None): try: - # Use interface method to get user connections - connections = self.services.interfaceDbApp.getUserConnections(self.services.interfaceDbApp.userId) + connections = self._interfaceDbApp.getUserConnections(self._interfaceDbApp.userId) def _is_msft_connection(c): av = c.authority.value if hasattr(c.authority, 'value') else str(getattr(c, 'authority', '')) return av and str(av).lower() == 'msft' @@ -322,7 +328,7 @@ class SharepointProcessor: for connection in connections: try: - if not self.services.sharepoint.setAccessTokenFromConnection(connection): + if not self._sharepoint.setAccessTokenFromConnection(connection): continue if await self._testSharepointAccess(sharepointPath): logger.info(f"Found matching connection for domain {targetDomain}: {connection.get('id')}") @@ -340,7 +346,7 @@ class SharepointProcessor: siteUrl, _ = self._parseSharepointPath(sharepointPath) if not siteUrl: return False - siteInfo = await self.services.sharepoint.findSiteByWebUrl(siteUrl) + siteInfo = await self._sharepoint.findSiteByWebUrl(siteUrl) return siteInfo is not None except Exception: return False @@ -351,17 +357,17 @@ class SharepointProcessor: targetSite, targetFolder = self._parseSharepointPath(targetPath) if not sourceSite or not targetSite: return {'success': False, 'message': 'Invalid SharePoint path format', 'processed_files': 0, 'errors': ['Invalid SharePoint path format']} - sourceSiteInfo = await self.services.sharepoint.findSiteByWebUrl(sourceSite) + sourceSiteInfo = await self._sharepoint.findSiteByWebUrl(sourceSite) if not sourceSiteInfo: return {'success': False, 'message': f'Source site not found: {sourceSite}', 'processed_files': 0, 'errors': [f'Source site not found: {sourceSite}']} - targetSiteInfo = await self.services.sharepoint.findSiteByWebUrl(targetSite) + targetSiteInfo = await self._sharepoint.findSiteByWebUrl(targetSite) if not targetSiteInfo: return {'success': False, 'message': f'Target site not found: {targetSite}', 'processed_files': 0, 'errors': [f'Target site not found: {targetSite}']} logger.info(f"Listing files in folder: {sourceFolder} for site: {sourceSiteInfo['id']}") - files = await self.services.sharepoint.listFolderContents(sourceSiteInfo['id'], sourceFolder) + files = await self._sharepoint.listFolderContents(sourceSiteInfo['id'], sourceFolder) if not files: logger.warning(f"No files found in folder '{sourceFolder}', trying root folder") - files = await self.services.sharepoint.listFolderContents(sourceSiteInfo['id'], '') + files = await self._sharepoint.listFolderContents(sourceSiteInfo['id'], '') if files: folders = [f for f in files if f.get('type') == 'folder'] folderNames = [f.get('name') for f in folders] @@ -385,7 +391,7 @@ class SharepointProcessor: async def _processSingle(fileInfo: Dict[str, Any]): try: - fileContent = await self.services.sharepoint.downloadFile(sourceSiteInfo['id'], fileInfo['id']) + fileContent = await self._sharepoint.downloadFile(sourceSiteInfo['id'], fileInfo['id']) if not fileContent: return {'error': f"Failed to download file: {fileInfo['name']}"} name_lower = (fileInfo.get('name') or '').lower() @@ -402,7 +408,7 @@ class SharepointProcessor: mime = next((mime_map[ext] for ext in BINARY_EXTS if name_lower.endswith(ext)), 'text/plain') if is_binary: - result = self.services.neutralization.processBinaryBytes(fileContent, fileInfo['name'], mime) + result = self._neutralization.processBinaryBytes(fileContent, fileInfo['name'], mime) if result.get('neutralized_bytes'): content_to_upload = result['neutralized_bytes'] else: @@ -412,11 +418,11 @@ class SharepointProcessor: textContent = fileContent.decode('utf-8') except UnicodeDecodeError: textContent = fileContent.decode('latin-1') - result = await self.services.neutralization.processTextAsync(textContent) + result = await self._neutralization.processTextAsync(textContent) content_to_upload = (result.get('neutralized_text') or '').encode('utf-8') neutralizedFilename = f"neutralized_{fileInfo['name']}" - uploadResult = await self.services.sharepoint.uploadFile(targetSiteInfo['id'], targetFolder, neutralizedFilename, content_to_upload) + uploadResult = await self._sharepoint.uploadFile(targetSiteInfo['id'], targetFolder, neutralizedFilename, content_to_upload) if 'error' in uploadResult: return {'error': f"Failed to upload neutralized file: {neutralizedFilename} - {uploadResult['error']}"} return { diff --git a/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py b/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py index 809d6be5..4cfec864 100644 --- a/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py +++ b/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py @@ -51,7 +51,6 @@ class NeutralizationService: """ self.services = serviceCenter self._getService = getServiceFn - self.interfaceDbComponent = getattr(serviceCenter, "interfaceDbComponent", None) # Create feature-specific interface for neutralizer DB operations self.interfaceNeutralizer: InterfaceFeatureNeutralizer = None @@ -305,19 +304,20 @@ class NeutralizationService: raise def processFile(self, fileId: str) -> Dict[str, Any]: - """Neutralize a file referenced by its fileId using component interface. + """Neutralize a file referenced by its fileId using ChatService. Supports text files directly; PDF/DOCX/XLSX/PPTX via extract -> neutralize -> generate.""" - if not self.interfaceDbComponent: - raise ValueError("Component interface is required to process a file by fileId") + chatService = self._getService("chat") if self._getService else None + if not chatService: + raise ValueError("Chat service is required to process a file by fileId") fileInfo = None try: - fileInfo = self.interfaceDbComponent.getFile(fileId) + fileInfo = chatService.getFile(fileId) except Exception: fileInfo = None fileName = getattr(fileInfo, 'fileName', None) if fileInfo else None mimeType = getattr(fileInfo, 'mimeType', None) if fileInfo else None - fileData = self.interfaceDbComponent.getFileData(fileId) + fileData = chatService.getFileData(fileId) if not fileData: raise ValueError(f"No file data found for fileId: {fileId}") diff --git a/modules/features/realEstate/serviceAiIntent.py b/modules/features/realEstate/serviceAiIntent.py index 62efb1a0..ca53c98e 100644 --- a/modules/features/realEstate/serviceAiIntent.py +++ b/modules/features/realEstate/serviceAiIntent.py @@ -24,7 +24,8 @@ from .datamodelFeatureRealEstate import ( Kanton, Land, ) -from modules.serviceCenter.serviceHub import getInterface as getServices +from modules.serviceCenter import getService +from modules.serviceCenter.context import ServiceCenterContext from .interfaceFeatureRealEstate import getInterface as getRealEstateInterface from .serviceGeometry import fetch_parcel_polygon_from_swisstopo @@ -231,8 +232,8 @@ async def processNaturalLanguageCommand( logger.info(f"Processing natural language command for user {currentUser.id} (mandate: {mandateId})") logger.debug(f"User input: {userInput}") - services = getServices(currentUser, workflow=None, mandateId=mandateId) - aiService = services.ai + ctx = ServiceCenterContext(user=currentUser, mandate_id=mandateId) + aiService = getService("ai", ctx) intentAnalysis = await analyzeUserIntent(aiService, userInput) diff --git a/modules/features/realEstate/serviceBzo.py b/modules/features/realEstate/serviceBzo.py index c7510fb3..178c8021 100644 --- a/modules/features/realEstate/serviceBzo.py +++ b/modules/features/realEstate/serviceBzo.py @@ -12,7 +12,8 @@ from fastapi import HTTPException, status from modules.datamodels.datamodelUam import User from .datamodelFeatureRealEstate import DokumentTyp -from modules.serviceCenter.serviceHub import getInterface as getServices +from modules.serviceCenter import getService +from modules.serviceCenter.context import ServiceCenterContext from .interfaceFeatureRealEstate import getInterface as getRealEstateInterface from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface from modules.features.realEstate.bzoDocumentRetriever import BZODocumentRetriever @@ -233,10 +234,8 @@ async def extract_bzo_information( bzo_params_result = None try: - services = getServices( - currentUser, workflow=None, mandateId=_mandateId, featureInstanceId=featureInstanceId - ) - ai_service = services.ai + ctx = ServiceCenterContext(user=currentUser, mandate_id=_mandateId, feature_instance_id=featureInstanceId) + ai_service = getService("ai", ctx) bzo_params_result = await run_bzo_params_extraction( extracted_content=all_extracted_content, bauzone=bauzone, @@ -521,10 +520,8 @@ async def generate_bauzone_ai_summary( AI-generated summary string """ try: - services = getServices( - currentUser, workflow=None, mandateId=mandateId, featureInstanceId=featureInstanceId - ) - aiService = services.ai + ctx = ServiceCenterContext(user=currentUser, mandate_id=mandateId, feature_instance_id=featureInstanceId) + aiService = getService("ai", ctx) context_parts = [] diff --git a/modules/interfaces/_legacyMigrationTelemetry.py b/modules/interfaces/_legacyMigrationTelemetry.py index 02c2c184..d80905b1 100644 --- a/modules/interfaces/_legacyMigrationTelemetry.py +++ b/modules/interfaces/_legacyMigrationTelemetry.py @@ -158,7 +158,7 @@ def _backfillTargetFeatureInstanceId() -> None: """ def _do() -> None: from modules.shared.configuration import APP_CONFIG - from modules.datamodels.datamodelWorkflowAutomation import AutoWorkflow + from modules.datamodels.datamodelWorkflowAutomation import AutoWorkflow, WORKFLOW_AUTOMATION_DATABASE dbHost = APP_CONFIG.get("DB_HOST", "localhost") dbUser = APP_CONFIG.get("DB_USER") @@ -166,7 +166,7 @@ def _backfillTargetFeatureInstanceId() -> None: dbPort = int(APP_CONFIG.get("DB_PORT", 5432)) geDb = DatabaseConnector( dbHost=dbHost, - dbDatabase="poweron_graphicaleditor", + dbDatabase=WORKFLOW_AUTOMATION_DATABASE, dbUser=dbUser, dbPassword=dbPassword, dbPort=dbPort, diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py index 002cb02d..4764cd4a 100644 --- a/modules/interfaces/interfaceBootstrap.py +++ b/modules/interfaces/interfaceBootstrap.py @@ -110,12 +110,13 @@ def initBootstrap(db: DatabaseConnector) -> None: except Exception as e: logger.warning(f"Mandate retention purge failed: {e}") - # WorkflowAutomation bootstrap (system component, not auto-discovered) - try: - from modules.workflowAutomation.mainWorkflowAutomation import onBootstrap as _waBootstrap - _waBootstrap() - except Exception as _waBootErr: - logger.warning(f"onBootstrap hook for 'workflowAutomation' failed: {_waBootErr}") + # System-component lifecycle hooks (registered via app.py Composition Root) + from modules.shared.systemComponentRegistry import getLifecycleHooks + for _scHook in getLifecycleHooks("onBootstrap"): + try: + _scHook() + except Exception as _scErr: + logger.warning(f"onBootstrap hook for system component failed: {_scErr}") # Let features run their own bootstrap logic via lifecycle hooks from modules.shared.featureDiscovery import loadFeatureMainModules diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py index 023e07f3..d5fb2e49 100644 --- a/modules/interfaces/interfaceDbApp.py +++ b/modules/interfaces/interfaceDbApp.py @@ -1870,12 +1870,13 @@ class AppObjects: instances = self.db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId}) - # 0-pre-wa. WorkflowAutomation cascade-delete (system component, not auto-discovered) - try: - from modules.workflowAutomation.mainWorkflowAutomation import onMandateDelete as _waDeleteHook - _waDeleteHook(mandateId, instances) - except Exception as _waDelErr: - logger.warning(f"onMandateDelete hook for 'workflowAutomation' failed: {_waDelErr}") + # 0-pre-sc. System-component cascade-delete (registered via app.py Composition Root) + from modules.shared.systemComponentRegistry import getLifecycleHooks + for _scHook in getLifecycleHooks("onMandateDelete"): + try: + _scHook(mandateId, instances) + except Exception as _scErr: + logger.warning(f"onMandateDelete hook for system component failed: {_scErr}") # 0-pre. Let features cascade-delete their own data via lifecycle hooks from modules.shared.featureDiscovery import loadFeatureMainModules diff --git a/modules/interfaces/interfaceDbManagement.py b/modules/interfaces/interfaceDbManagement.py index 35a4008e..b3acfcfc 100644 --- a/modules/interfaces/interfaceDbManagement.py +++ b/modules/interfaces/interfaceDbManagement.py @@ -807,7 +807,7 @@ class ComponentObjects: next ``updateFile`` / ``getFile`` then rejects with ``File with ID ... not found`` -- the well-known "ghost duplicate" symptom seen when ``interfaceDbComponent`` is initialised without an - ``featureInstanceId`` (e.g. via ``serviceHub``) but a same-hash+name + ``featureInstanceId`` (e.g. via ``serviceCenter``) but a same-hash+name file exists in another featureInstance under the same mandate. We therefore cross-check the candidate through the RBAC-aware ``getFile`` before returning it; if RBAC blocks it, we treat it as "no duplicate @@ -933,9 +933,7 @@ class ComponentObjects: If pagination is provided: PaginatedResult with items and metadata """ def _convertFileItems(files): - from modules.workflowAutomation.engine.workflowArtifactVisibility import ( - suppress_workflow_file_in_workspace_ui, - ) + from modules.shared.workflowArtifactVisibility import suppressWorkflowFileInWorkspaceUi fileItems = [] for file in files: @@ -949,7 +947,7 @@ class ComponentObjects: fileName = file.get("fileName") if not fileName or fileName == "None": continue - if suppress_workflow_file_in_workspace_ui(file): + if suppressWorkflowFileInWorkspaceUi(file): continue if file.get("scope") is None: diff --git a/modules/interfaces/interfaceFeatures.py b/modules/interfaces/interfaceFeatures.py index c947806c..c391deaa 100644 --- a/modules/interfaces/interfaceFeatures.py +++ b/modules/interfaces/interfaceFeatures.py @@ -321,7 +321,12 @@ class FeatureInterface: f"for feature '{featureCode}' to instance {instanceId} (mandate={mandateId})" ) - from modules.workflowAutomation.mainWorkflowAutomation import onInstanceCreate as _waOnInstanceCreate + from modules.shared.systemComponentRegistry import getLifecycleHooks + _onInstanceCreateHooks = getLifecycleHooks("onInstanceCreate") + if not _onInstanceCreateHooks: + logger.warning("_copyTemplateWorkflows: no onInstanceCreate hooks registered") + return 0 + _waOnInstanceCreate = _onInstanceCreateHooks[0] try: copied = _waOnInstanceCreate(mandateId, instanceId, featureCode, templateWorkflows) diff --git a/modules/interfaces/interfaceWorkflowAutomation.py b/modules/interfaces/interfaceWorkflowAutomation.py index ba8fe6e7..9859ff2d 100644 --- a/modules/interfaces/interfaceWorkflowAutomation.py +++ b/modules/interfaces/interfaceWorkflowAutomation.py @@ -46,7 +46,7 @@ def _make_json_serializable(obj: Any, _depth: int = 0) -> Any: from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelWorkflowAutomation import ( - GRAPHICAL_EDITOR_DATABASE, + WORKFLOW_AUTOMATION_DATABASE, AutoWorkflow, AutoVersion, AutoRun, @@ -59,15 +59,15 @@ from modules.dbHelpers.dbRegistry import registerDatabase logger = logging.getLogger(__name__) -workflowAutomationDatabase = GRAPHICAL_EDITOR_DATABASE +workflowAutomationDatabase = WORKFLOW_AUTOMATION_DATABASE registerDatabase(workflowAutomationDatabase) _CALLBACK_WORKFLOW_CHANGED = "workflowAutomation.workflow.changed" def _invocationsSyncedWithGraph(graph, invocations): - """Lazy-load entryPoints to avoid L4->L5 top-level import.""" - from modules.workflowAutomation.editor.entryPoints import invocations_synced_with_graph - return invocations_synced_with_graph(graph, invocations) + """Sync invocations with graph trigger nodes (via nodeCatalog, L2).""" + from modules.nodeCatalog.entryPoints import invocationsSyncedWithGraph + return invocationsSyncedWithGraph(graph, invocations) def _getWorkflowAutomationInterface( diff --git a/modules/workflowAutomation/editor/entryPoints.py b/modules/nodeCatalog/entryPoints.py similarity index 63% rename from modules/workflowAutomation/editor/entryPoints.py rename to modules/nodeCatalog/entryPoints.py index 3b4763f7..b1a8ae03 100644 --- a/modules/workflowAutomation/editor/entryPoints.py +++ b/modules/nodeCatalog/entryPoints.py @@ -3,27 +3,28 @@ Workflow entry points (Starts) — configuration outside the flow editor. Kinds align with run envelope trigger.type where applicable. + +Canonical location: modules.nodeCatalog.entryPoints (L2). +Depends only on stdlib — no cross-module imports. """ import uuid from typing import Any, Dict, List, Optional -# On-demand (gear: Manueller Trigger, Formular) KINDS_ON_DEMAND = frozenset({"manual", "form", "api"}) -# Always-on (gear: Zeitplan, Immer aktiv, plus legacy listener kinds) KINDS_ALWAYS_ON = frozenset({"schedule", "always_on", "email", "webhook", "event"}) ALL_KINDS = KINDS_ON_DEMAND | KINDS_ALWAYS_ON -def category_for_kind(kind: str) -> str: +def categoryForKind(kind: str) -> str: if kind in KINDS_ALWAYS_ON: return "always_on" return "on_demand" -def default_manual_entry_point() -> Dict[str, Any]: +def defaultManualEntryPoint() -> Dict[str, Any]: """Single default manual start when a workflow has no invocations yet.""" return { "id": str(uuid.uuid4()), @@ -36,7 +37,7 @@ def default_manual_entry_point() -> Dict[str, Any]: } -def _normalize_title(title: Any) -> str: +def _normalizeTitle(title: Any) -> str: """Extract a plain string from a title value for storage (not display).""" if isinstance(title, dict): picked = title.get("xx") or next((v for v in title.values() if v), None) @@ -46,14 +47,14 @@ def _normalize_title(title: Any) -> str: return "Start" -def normalize_invocation_entry(raw: Dict[str, Any]) -> Dict[str, Any]: +def normalizeInvocationEntry(raw: Dict[str, Any]) -> Dict[str, Any]: """Validate and normalize a single entry point dict.""" kind = (raw.get("kind") or "manual").strip() if kind not in ALL_KINDS: kind = "manual" cat = raw.get("category") if cat not in ("on_demand", "always_on"): - cat = category_for_kind(kind) + cat = categoryForKind(kind) eid = raw.get("id") or str(uuid.uuid4()) enabled = raw.get("enabled", True) if not isinstance(enabled, bool): @@ -65,21 +66,21 @@ def normalize_invocation_entry(raw: Dict[str, Any]) -> Dict[str, Any]: "kind": kind, "category": cat, "enabled": enabled, - "title": _normalize_title(raw.get("title")), + "title": _normalizeTitle(raw.get("title")), "description": desc, "config": config, } -def normalize_invocations_list(items: Optional[List[Any]]) -> List[Dict[str, Any]]: +def normalizeInvocationsList(items: Optional[List[Any]]) -> List[Dict[str, Any]]: if not items: - return [default_manual_entry_point()] + return [defaultManualEntryPoint()] out: List[Dict[str, Any]] = [] for raw in items: if isinstance(raw, dict): - out.append(normalize_invocation_entry(raw)) + out.append(normalizeInvocationEntry(raw)) if not out: - return [default_manual_entry_point()] + return [defaultManualEntryPoint()] return out @@ -90,26 +91,36 @@ _NODE_TYPE_TO_KIND = { } -def invocations_synced_with_graph( +def _getTriggerNodes(nodes: List[Dict]) -> List[Dict]: + """Return start/trigger nodes: type ``trigger.*``, or category ``trigger`` / ``start``.""" + return [ + n + for n in nodes + if ( + str(n.get("type", "")).startswith("trigger.") + or n.get("category") in ("trigger", "start") + ) + ] + + +def invocationsSyncedWithGraph( graph: Optional[Dict[str, Any]], - stored_invocations: Optional[List[Any]], + storedInvocations: Optional[List[Any]], ) -> List[Dict[str, Any]]: """Derive primary invocation (index 0) from the first start node in ``graph``. If the graph has no start node, only non-primary stored invocations are kept (no injected default). Document order in ``nodes`` defines which start wins. """ - from modules.workflowAutomation.engine.graphUtils import getTriggerNodes - g = graph if isinstance(graph, dict) else {} nodes = g.get("nodes") or [] - stored = list(stored_invocations or []) + stored = list(storedInvocations or []) rest: List[Dict[str, Any]] = [] for raw in stored[1:]: if isinstance(raw, dict): - rest.append(normalize_invocation_entry(raw)) + rest.append(normalizeInvocationEntry(raw)) - triggers = getTriggerNodes(nodes) + triggers = _getTriggerNodes(nodes) if not triggers: return rest @@ -119,29 +130,28 @@ def invocations_synced_with_graph( nid = node.get("id") if not nid: nid = str(uuid.uuid4()) - raw_title = node.get("title") or node.get("label") or "Start" + rawTitle = node.get("title") or node.get("label") or "Start" - old_primary = stored[0] if stored and isinstance(stored[0], dict) else {} + oldPrimary = stored[0] if stored and isinstance(stored[0], dict) else {} config: Dict[str, Any] = {} - if isinstance(old_primary.get("config"), dict) and old_primary.get("kind") == kind: - config = dict(old_primary["config"]) - desc = old_primary.get("description") if isinstance(old_primary.get("description"), dict) else {} + if isinstance(oldPrimary.get("config"), dict) and oldPrimary.get("kind") == kind: + config = dict(oldPrimary["config"]) + desc = oldPrimary.get("description") if isinstance(oldPrimary.get("description"), dict) else {} - primary_raw: Dict[str, Any] = { + primaryRaw: Dict[str, Any] = { "id": str(nid), "kind": kind, "enabled": True, - "title": raw_title, + "title": rawTitle, "description": desc, "config": config, } - primary = normalize_invocation_entry(primary_raw) + primary = normalizeInvocationEntry(primaryRaw) return [primary] + rest -# POST .../execute with entryPointId set to a schedule entry — no separate in-process scheduler here yet. -def find_invocation(workflow: Dict[str, Any], entry_point_id: str) -> Optional[Dict[str, Any]]: +def findInvocation(workflow: Dict[str, Any], entryPointId: str) -> Optional[Dict[str, Any]]: for inv in workflow.get("invocations") or []: - if isinstance(inv, dict) and inv.get("id") == entry_point_id: + if isinstance(inv, dict) and inv.get("id") == entryPointId: return inv return None diff --git a/modules/routes/routeBilling.py b/modules/routes/routeBilling.py index d193f9bb..143af2e2 100644 --- a/modules/routes/routeBilling.py +++ b/modules/routes/routeBilling.py @@ -907,8 +907,8 @@ def createCheckoutSession( mandateLabel = targetMandateId invoiceAddress = None - from modules.serviceCenter.services.serviceBilling.stripeCheckout import create_checkout_session - redirect_url = create_checkout_session( + from modules.serviceCenter.services.serviceBilling.stripeCheckout import createCheckoutSession + redirect_url = createCheckoutSession( mandate_id=targetMandateId, user_id=checkoutRequest.userId, amount_chf=checkoutRequest.amount, diff --git a/modules/routes/routeClickup.py b/modules/routes/routeClickup.py index a6c6745d..41797d77 100644 --- a/modules/routes/routeClickup.py +++ b/modules/routes/routeClickup.py @@ -9,7 +9,8 @@ from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request, sta from modules.auth import getCurrentUser, limiter from modules.datamodels.datamodelUam import AuthAuthority, User, UserConnection from modules.interfaces.interfaceDbApp import getInterface -from modules.serviceCenter.serviceHub import getInterface as getServices +from modules.serviceCenter import getService +from modules.serviceCenter.context import ServiceCenterContext from modules.shared.i18nRegistry import apiRouteContext routeApiMsg = apiRouteContext("routeClickup") @@ -59,13 +60,14 @@ def _clickup_connection_or_404(interface, connection_id: str, user_id: str) -> U def _svc_for_connection(current_user: User, connection: UserConnection): - services = getServices(current_user, None) - if not services.clickup.setAccessTokenFromConnection(connection): + ctx = ServiceCenterContext(user=current_user) + clickupService = getService("clickup", ctx) + if not clickupService.setAccessTokenFromConnection(connection): raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=routeApiMsg("Failed to set ClickUp access token. Connection may be expired or invalid."), ) - return services.clickup + return clickupService @router.get("/{connectionId}/teams/{teamId}", response_model=Dict[str, Any]) diff --git a/modules/routes/routeSharepoint.py b/modules/routes/routeSharepoint.py index b144328e..328a1bba 100644 --- a/modules/routes/routeSharepoint.py +++ b/modules/routes/routeSharepoint.py @@ -12,7 +12,8 @@ from fastapi import APIRouter, HTTPException, Depends, Path, Query, Request, sta from modules.auth import limiter, getCurrentUser from modules.datamodels.datamodelUam import User, UserConnection from modules.interfaces.interfaceDbApp import getInterface -from modules.serviceCenter.serviceHub import getInterface as getServices +from modules.serviceCenter import getService +from modules.serviceCenter.context import ServiceCenterContext from modules.shared.i18nRegistry import apiRouteContext routeApiMsg = apiRouteContext("routeSharepoint") @@ -122,19 +123,17 @@ async def getSharepointFolderOptionsByReference( detail=f"Connection is not a Microsoft connection (authority: {authority})" ) - # Initialize services - services = getServices(currentUser, None) + ctx = ServiceCenterContext(user=currentUser) + sharepointService = getService("sharepoint", ctx) - # Set access token on SharePoint service - if not services.sharepoint.setAccessTokenFromConnection(connection): + if not sharepointService.setAccessTokenFromConnection(connection): raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=routeApiMsg("Failed to set SharePoint access token. Connection may be expired or invalid.") ) - # Mode 1: Return sites list if no siteId specified if not siteId: - sites = await services.sharepoint.discoverSites() + sites = await sharepointService.discoverSites() return [ { "type": "site", @@ -148,9 +147,8 @@ async def getSharepointFolderOptionsByReference( for site in sites ] - # Mode 2: Return folders within specific site folderPath = path or "" - items = await services.sharepoint.listFolderContents(siteId, folderPath) + items = await sharepointService.listFolderContents(siteId, folderPath) if not items: return [] diff --git a/modules/routes/routeSystem.py b/modules/routes/routeSystem.py index 8529206b..853c4b32 100644 --- a/modules/routes/routeSystem.py +++ b/modules/routes/routeSystem.py @@ -839,12 +839,12 @@ def _buildIntegrationsOverviewPayload(userId: str, user=None) -> Dict[str, Any]: from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.datamodels.datamodelPagination import PaginationParams from modules.datamodels.datamodelWorkflowAutomation import ( - AutoWorkflow, AutoRun, + AutoWorkflow, AutoRun, WORKFLOW_AUTOMATION_DATABASE, ) wfDb = DatabaseConnector( dbHost=APP_CONFIG.get("DB_HOST", "localhost"), - dbDatabase="poweron_graphicaleditor", + dbDatabase=WORKFLOW_AUTOMATION_DATABASE, dbUser=APP_CONFIG.get("DB_USER"), dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), diff --git a/modules/routes/routeWorkflowAutomation.py b/modules/routes/routeWorkflowAutomation.py index 81c009fb..8a9dd587 100644 --- a/modules/routes/routeWorkflowAutomation.py +++ b/modules/routes/routeWorkflowAutomation.py @@ -31,7 +31,7 @@ from modules.datamodels.datamodelPagination import PaginationParams, PaginationM from modules.datamodels.datamodelWorkflowAutomation import ( AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask, ) -from modules.dbHelpers.paginationHelpers import applyFiltersAndSort +from modules.dbHelpers.paginationHelpers import applyFiltersAndSort, paginateInMemory from modules.interfaces.interfaceDbApp import getRootInterface from modules.shared.i18nRegistry import apiRouteContext, resolveText from modules.workflowAutomation.helpers import ( @@ -75,9 +75,12 @@ async def _listWorkflows( scopeFilter = {"mandateId": mandateId} params = _parsePaginationOr400(pagination) - records = db.getRecordset(AutoWorkflow, recordFilter=scopeFilter, pagination=params) - total = db.getRecordCount(AutoWorkflow, recordFilter=scopeFilter) if params else len(records or []) - return {"items": records or [], "total": total} + records = db.getRecordset(AutoWorkflow, recordFilter=scopeFilter) + if params: + filtered = applyFiltersAndSort(records or [], params) + pageItems, totalItems = paginateInMemory(filtered, params) + return {"items": pageItems, "total": totalItems} + return {"items": records or [], "total": len(records or [])} finally: db.close() @@ -181,9 +184,12 @@ async def _listRuns( scopeFilter = {**(scopeFilter or {}), "workflowId": workflowId} params = _parsePaginationOr400(pagination) - records = db.getRecordset(AutoRun, recordFilter=scopeFilter, pagination=params) - total = db.getRecordCount(AutoRun, recordFilter=scopeFilter) if params else len(records or []) - return {"items": records or [], "total": total} + records = db.getRecordset(AutoRun, recordFilter=scopeFilter) + if params: + filtered = applyFiltersAndSort(records or [], params) + pageItems, totalItems = paginateInMemory(filtered, params) + return {"items": pageItems, "total": totalItems} + return {"items": records or [], "total": len(records or [])} finally: db.close() @@ -234,9 +240,12 @@ async def _listTasks( scopeFilter = {**(scopeFilter or {}), "status": status} params = _parsePaginationOr400(pagination) - records = db.getRecordset(AutoTask, recordFilter=scopeFilter, pagination=params) - total = db.getRecordCount(AutoTask, recordFilter=scopeFilter) if params else len(records or []) - return {"items": records or [], "total": total} + records = db.getRecordset(AutoTask, recordFilter=scopeFilter) + if params: + filtered = applyFiltersAndSort(records or [], params) + pageItems, totalItems = paginateInMemory(filtered, params) + return {"items": pageItems, "total": totalItems} + return {"items": records or [], "total": len(records or [])} finally: db.close() @@ -1243,11 +1252,11 @@ def _getRunDetail( except Exception as e: logger.warning("_getRunDetail: file lookup failed: %s", e) - from modules.workflowAutomation.engine.workflowArtifactVisibility import suppress_workflow_file_in_workspace_ui + from modules.shared.workflowArtifactVisibility import suppressWorkflowFileInWorkspaceUi def _resolveFileList(ids: set) -> list: rows = [dict(fileMetaById[fid]) for fid in ids if fid in fileMetaById] - return [m for m in rows if not suppress_workflow_file_in_workspace_ui(m)] + return [m for m in rows if not suppressWorkflowFileInWorkspaceUi(m)] assignedFileIds: set = set() for step, (inputIds, outputIds) in zip(steps, perStepFileIds): @@ -1305,7 +1314,7 @@ def _buildExecuteRunEnvelope( merge_run_envelope, normalize_run_envelope, ) - from modules.workflowAutomation.editor.entryPoints import find_invocation + from modules.nodeCatalog.entryPoints import findInvocation if isinstance(body.get("runEnvelope"), dict): env = normalize_run_envelope(body["runEnvelope"], user_id=userId) @@ -1321,7 +1330,7 @@ def _buildExecuteRunEnvelope( status_code=400, detail=routeApiMsg("entryPointId requires a saved workflow (workflowId must refer to a stored workflow)"), ) - inv = find_invocation(workflow, entryPointId) + inv = findInvocation(workflow, entryPointId) if not inv: raise HTTPException(status_code=400, detail=routeApiMsg("entryPointId not found on workflow")) if not inv.get("enabled", True): diff --git a/modules/serviceCenter/__init__.py b/modules/serviceCenter/__init__.py index fb40df65..a2590fc6 100644 --- a/modules/serviceCenter/__init__.py +++ b/modules/serviceCenter/__init__.py @@ -16,9 +16,10 @@ from modules.serviceCenter.registry import ( ) from modules.serviceCenter.resolver import ( resolve, - get_resolution_cache, - clear_cache, + getResolutionCache, + clearCache, ) +from modules.serviceCenter.services.serviceAgent.mainServiceAgent import ServicesBag logger = logging.getLogger(__name__) @@ -37,7 +38,7 @@ def getService( Returns: Service instance """ - cache = get_resolution_cache() + cache = getResolutionCache() resolving = set() return resolve(key, context, cache, resolving) @@ -80,13 +81,13 @@ def registerServiceObjects(catalogService) -> bool: return False -def can_access_service( +def canAccessService( user, rbac, - service_key: str, - mandate_id: Optional[str] = None, - feature_instance_id: Optional[str] = None, - allow_when_no_rbac: bool = True, + serviceKey: str, + mandateId: Optional[str] = None, + featureInstanceId: Optional[str] = None, + allowWhenNoRbac: bool = True, ) -> bool: """ Check if user has permission to access the given service. @@ -94,40 +95,42 @@ def can_access_service( Args: user: User object rbac: RbacClass instance (e.g. from interfaceDbApp.rbac) - service_key: Service key (e.g., "web", "extraction") - mandate_id: Optional mandate context - feature_instance_id: Optional feature instance context - allow_when_no_rbac: If True, allow when rbac is None (migration/default) + serviceKey: Service key (e.g., "web", "extraction") + mandateId: Optional mandate context + featureInstanceId: Optional feature instance context + allowWhenNoRbac: If True, allow when rbac is None (migration/default) Returns: True if user has view permission on the service """ if not rbac: - return allow_when_no_rbac - if service_key not in IMPORTABLE_SERVICES: + return allowWhenNoRbac + if serviceKey not in IMPORTABLE_SERVICES: return False - obj = IMPORTABLE_SERVICES[service_key] - object_key = obj.get("objectKey") - if not object_key: + obj = IMPORTABLE_SERVICES[serviceKey] + objectKey = obj.get("objectKey") + if not objectKey: return False from modules.datamodels.datamodelRbac import AccessRuleContext permissions = rbac.getUserPermissions( user, AccessRuleContext.RESOURCE, - object_key, - mandateId=mandate_id, - featureInstanceId=feature_instance_id, + objectKey, + mandateId=mandateId, + featureInstanceId=featureInstanceId, ) return permissions.view if permissions else False + __all__ = [ "ServiceCenterContext", + "ServicesBag", "getService", "preWarm", - "clear_cache", + "clearCache", "registerServiceObjects", - "can_access_service", + "canAccessService", "SERVICE_RBAC_OBJECTS", "CORE_SERVICES", "IMPORTABLE_SERVICES", diff --git a/modules/serviceCenter/resolver.py b/modules/serviceCenter/resolver.py index 5d400760..316ce052 100644 --- a/modules/serviceCenter/resolver.py +++ b/modules/serviceCenter/resolver.py @@ -75,18 +75,21 @@ except ImportError: pass -def get_resolution_cache() -> Dict[str, Any]: +def getResolutionCache() -> Dict[str, Any]: """Get the module-level resolution cache (for preWarm/clear).""" return _resolution_cache -def clear_cache() -> None: + +def clearCache() -> None: """Clear the resolution cache.""" lock = _cache_lock if _cache_lock is not None else _DummyLock() with lock: _resolution_cache.clear() + + class _DummyLock: def __enter__(self): return self diff --git a/modules/serviceCenter/serviceHub.py b/modules/serviceCenter/serviceHub.py deleted file mode 100644 index a42f8d0e..00000000 --- a/modules/serviceCenter/serviceHub.py +++ /dev/null @@ -1,189 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -""" -Service Hub. -Consumer-facing aggregation layer for services, DB interfaces, and runtime state. - -Architecture: -- serviceHub delegates service resolution to serviceCenter (DI container) -- serviceHub owns DB interface initialization and runtime state -- serviceCenter knows nothing about serviceHub (one-way dependency) - -Import-Regelwerk: -- Zentrale Module (wie dieses) duerfen KEINE Feature-Container importieren -- Feature-spezifische Services werden dynamisch geladen -- Shared Services werden via serviceCenter resolved -""" - -import os -import importlib -import glob -from typing import Any, Optional, TYPE_CHECKING -import logging - -from modules.datamodels.datamodelUam import User - -if TYPE_CHECKING: - from modules.datamodels.datamodelChat import ChatWorkflow - -logger = logging.getLogger(__name__) - -_FEATURES_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "features") - - -class PublicService: - """Lightweight proxy exposing only public callable attributes of a target.""" - - def __init__(self, target: Any, functionsOnly: bool = True, nameFilter=None): - self._target = target - self._functionsOnly = functionsOnly - self._nameFilter = nameFilter - - def __getattr__(self, name: str): - if name.startswith('_'): - raise AttributeError(f"'{type(self._target).__name__}' attribute '{name}' is private") - if self._nameFilter and not self._nameFilter(name): - raise AttributeError(f"'{name}' not exposed by policy") - attr = getattr(self._target, name) - if self._functionsOnly and not callable(attr): - raise AttributeError(f"'{name}' is not a function") - return attr - - def __dir__(self): - return sorted([ - n for n in dir(self._target) - if not n.startswith('_') - and (not self._functionsOnly or callable(getattr(self._target, n, None))) - and (self._nameFilter(n) if self._nameFilter else True) - ]) - - -class ServiceHub: - """ - Consumer-facing aggregation of services, DB interfaces, and runtime state. - - Services are lazy-resolved via serviceCenter on first access. - DB interfaces and runtime state are initialized eagerly. - Feature services/interfaces are discovered dynamically from features/. - """ - - _SERVICE_CENTER_WRAPPING = { - "ai": {"functionsOnly": False}, - } - - def __init__(self, user: User, workflow: "ChatWorkflow" = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None): - self.user: User = user - self.workflow = workflow - self.mandateId: Optional[str] = mandateId - self.featureInstanceId: Optional[str] = featureInstanceId - self.currentUserPrompt: str = "" - self.rawUserPrompt: str = "" - - from modules.serviceCenter.context import ServiceCenterContext - self._serviceCenterContext = ServiceCenterContext( - user=user, - workflow=workflow, - mandate_id=mandateId, - feature_instance_id=featureInstanceId, - ) - - from modules.interfaces.interfaceDbApp import getInterface as getAppInterface - self.interfaceDbApp = getAppInterface(user, mandateId=mandateId) - - from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface - self.interfaceDbComponent = getComponentInterface(user, mandateId=mandateId) - - self.rbac = self.interfaceDbApp.rbac if self.interfaceDbApp else None - - from modules.interfaces.interfaceDbChat import getInterface as getChatInterface - self.interfaceDbChat = getChatInterface(user, mandateId=mandateId, featureInstanceId=featureInstanceId) - - self._loadFeatureInterfaces() - self._loadFeatureServices() - - def __getattr__(self, name: str): - """Lazy-resolve services via serviceCenter on first access.""" - if name.startswith('_'): - raise AttributeError(name) - try: - from modules.serviceCenter import getService - service = getService(name, self._serviceCenterContext) - wrapping = self._SERVICE_CENTER_WRAPPING.get(name, {}) - functionsOnly = wrapping.get("functionsOnly", True) - wrapped = PublicService(service, functionsOnly=functionsOnly) - setattr(self, name, wrapped) - return wrapped - except KeyError: - raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'") - - def _loadFeatureInterfaces(self): - """Dynamically load interfaces from feature containers by filename pattern.""" - pattern = os.path.join(_FEATURES_DIR, "*", "interfaceFeature*.py") - for filepath in glob.glob(pattern): - try: - featureDir = os.path.basename(os.path.dirname(filepath)) - filename = os.path.basename(filepath)[:-3] - - modulePath = f"modules.features.{featureDir}.{filename}" - module = importlib.import_module(modulePath) - - if hasattr(module, "getInterface"): - interface = module.getInterface(self.user, mandateId=self.mandateId, featureInstanceId=self.featureInstanceId) - attrName = filename.replace("interfaceFeature", "interfaceDb") - setattr(self, attrName, interface) - logger.debug(f"Loaded interface: {attrName} from {modulePath}") - except Exception as e: - logger.debug(f"Could not load interface from {filepath}: {e}") - - def _loadFeatureServices(self): - """Dynamically load services from feature containers by filename pattern.""" - pattern = os.path.join(_FEATURES_DIR, "*", "service*", "mainService*.py") - for filepath in glob.glob(pattern): - try: - serviceDir = os.path.basename(os.path.dirname(filepath)) - featureDir = os.path.basename(os.path.dirname(os.path.dirname(filepath))) - filename = os.path.basename(filepath)[:-3] - - modulePath = f"modules.features.{featureDir}.{serviceDir}.{filename}" - module = importlib.import_module(modulePath) - - serviceClass = None - for attrName in dir(module): - if attrName.endswith("Service") and not attrName.startswith("_"): - cls = getattr(module, attrName) - if isinstance(cls, type): - serviceClass = cls - break - - if serviceClass: - attrName = serviceDir.replace("service", "").lower() - if not attrName: - attrName = serviceDir.lower() - - functionsOnly = attrName != "ai" - - def _makeServiceResolver(hub): - def _resolver(depKey: str): - return getattr(hub, depKey) - return _resolver - - import inspect - sig = inspect.signature(serviceClass.__init__) - paramCount = len([p for p in sig.parameters if p != 'self']) - if paramCount >= 2: - serviceInstance = serviceClass(self, _makeServiceResolver(self)) - else: - serviceInstance = serviceClass(self) - setattr(self, attrName, PublicService(serviceInstance, functionsOnly=functionsOnly)) - logger.debug(f"Loaded service: {attrName} from {modulePath}") - except Exception as e: - logger.debug(f"Could not load service from {filepath}: {e}") - - -# Backward-compatible alias -Services = ServiceHub - - -def getInterface(user: User, workflow: "ChatWorkflow" = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> ServiceHub: - """Get ServiceHub instance for the given user, mandate, and feature instance context.""" - return ServiceHub(user, workflow, mandateId=mandateId, featureInstanceId=featureInstanceId) diff --git a/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py b/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py index 9389ee85..4cfbb8c4 100644 --- a/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py +++ b/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py @@ -274,7 +274,7 @@ def _persistLargeDocument(doc, services, context: Dict[str, Any]) -> Optional[Di docName = getattr(doc, "documentName", "unnamed") docMime = getattr(doc, "mimeType", "application/octet-stream") try: - fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(docBytes, docName) + fileItem, _ = chatService.saveUploadedFile(docBytes, docName) from modules.serviceCenter.services.serviceAgent.coreTools._helpers import ( _attachFileAsChatDocument, @@ -295,7 +295,7 @@ def _persistLargeDocument(doc, services, context: Dict[str, Any]) -> Optional[Di updateFields["mandateId"] = mandateId if updateFields: logger.debug("_persistLargeDocument: updating file %s with %s", fileItem.id, updateFields) - chatService.interfaceDbComponent.updateFile(fileItem.id, updateFields) + chatService.updateFile(fileItem.id, updateFields) else: logger.warning("_persistLargeDocument: no updateFields for file %s (tempFolderId=%s, fiId=%s)", fileItem.id, tempFolderId, fiId) diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_connectionTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_connectionTools.py index 4bb97de9..0a9e678b 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_connectionTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_connectionTools.py @@ -88,12 +88,11 @@ def registerConnectionTools(registry: ToolRegistry, services): graphAttachments: List[Dict[str, Any]] = [] if attachmentFileIds: chatService = services.chat - dbMgmt = chatService.interfaceDbComponent for fid in attachmentFileIds: - fileRow = dbMgmt.getFile(fid) + fileRow = chatService.getFile(fid) if not fileRow: return ToolResult(toolCallId="", toolName="sendMail", success=False, error=f"Attachment file not found: {fid}") - rawBytes = dbMgmt.getFileData(fid) + rawBytes = chatService.getFileData(fid) if not rawBytes: return ToolResult(toolCallId="", toolName="sendMail", success=False, error=f"Attachment file has no data: {fid}") graphAttachments.append({ diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_crossWorkflowTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_crossWorkflowTools.py index 055a4055..2675257c 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_crossWorkflowTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_crossWorkflowTools.py @@ -27,8 +27,7 @@ def registerCrossWorkflowTools(registry: ToolRegistry, services): """List all chat workflows in this workspace with metadata.""" try: chatService = services.chat - chatInterface = chatService.interfaceDbChat - allWorkflows = chatInterface.getWorkflows() or [] + allWorkflows = chatService.getWorkflows() or [] allWorkflows.sort( key=lambda w: w.get("sysCreatedAt") or w.get("startedAt") or 0, @@ -43,7 +42,7 @@ def registerCrossWorkflowTools(registry: ToolRegistry, services): createdAt = wf.get("sysCreatedAt") or wf.get("startedAt") or 0 lastActivity = wf.get("lastActivity") or createdAt - msgs = chatInterface.getMessages(wfId) or [] + msgs = chatService.getMessages(wfId) or [] messageCount = len(msgs) lastPreview = "" if msgs: @@ -102,8 +101,7 @@ def registerCrossWorkflowTools(registry: ToolRegistry, services): try: chatService = services.chat - chatInterface = chatService.interfaceDbChat - allMsgs = chatInterface.getMessages(targetWorkflowId) or [] + allMsgs = chatService.getMessages(targetWorkflowId) or [] sliced = allMsgs[offset:offset + limit] items = [] diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py index 291f33dc..76fd0bae 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py @@ -359,7 +359,7 @@ def registerDataSourceTools(registry: ToolRegistry, services): elif fileBytes[:2] == b"PK": fileName = f"{fileName}.zip" chatService = services.chat - fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(fileBytes, fileName) + fileItem, _ = chatService.saveUploadedFile(fileBytes, fileName) updateFields = {} tempFolderId = _getOrCreateTempFolder(chatService) if tempFolderId: @@ -370,7 +370,7 @@ def registerDataSourceTools(registry: ToolRegistry, services): if _sourceNeutralize: updateFields["neutralize"] = True if updateFields: - chatService.interfaceDbComponent.updateFile(fileItem.id, updateFields) + chatService.updateFile(fileItem.id, updateFields) chatDocId = _attachFileAsChatDocument( services, fileItem, diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py index e6efad99..2dfc4686 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py @@ -173,11 +173,6 @@ def registerFeatureSubAgentTools(registry: ToolRegistry, services): neutralizePolicy[tn] = {"tableActive": tableActive, "explicitFields": explicitFields} neutralizationService = services.getService("neutralization") if hasattr(services, "getService") else None - if neutralizationService is not None and not getattr(neutralizationService, "interfaceDbComponent", None): - try: - neutralizationService.interfaceDbComponent = services.chat.interfaceDbComponent - except Exception: - pass cacheKey = f"{featureInstanceId}:{hashlib.md5(question.encode()).hexdigest()}" if cacheKey in _featureQueryCache: diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py b/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py index ea80fdc7..4e69d849 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py @@ -48,23 +48,16 @@ def _looksLikeBinary(data: bytes, sampleSize: int = 1024) -> bool: def _getOrCreateTempFolder(chatService) -> Optional[str]: """Return the ID of the user's 'Temp' folder, creating it if it doesn't exist.""" - ifc = getattr(chatService, "interfaceDbComponent", None) - if not ifc: - logger.warning("_getOrCreateTempFolder: no interfaceDbComponent on chatService") - return None - userId = getattr(ifc, "userId", None) - if not userId: - logger.warning("_getOrCreateTempFolder: userId is None on interfaceDbComponent") - return None try: - ownFolders = ifc.getOwnFolderTree() + ownFolders = chatService.getOwnFolderTree() for f in ownFolders: if f.get("name") == "Temp": folderId = f.get("id") logger.debug("_getOrCreateTempFolder: found existing Temp folder %s", folderId) return str(folderId) if folderId else None - newFolder = ifc.createFolder("Temp") + newFolder = chatService.createFolder("Temp") folderId = newFolder.get("id") if isinstance(newFolder, dict) else getattr(newFolder, "id", None) + userId = getattr(getattr(chatService, "interfaceDbComponent", None), "userId", None) logger.info("_getOrCreateTempFolder: created Temp folder %s for user %s", folderId, userId) return str(folderId) if folderId else None except Exception as e: diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_mediaTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_mediaTools.py index 380c9950..e3978b72 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_mediaTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_mediaTools.py @@ -46,8 +46,8 @@ def registerMediaTools(registry: ToolRegistry, services): if sourceFileId: try: - dbMgmt = services.chat.interfaceDbComponent - fileRow = dbMgmt.getFile(sourceFileId) + chatService = services.chat + fileRow = chatService.getFile(sourceFileId) if not fileRow: return ToolResult( toolCallId="", @@ -55,7 +55,7 @@ def registerMediaTools(registry: ToolRegistry, services): success=False, error=f"sourceFileId not found: {sourceFileId}", ) - rawBytes = dbMgmt.getFileData(sourceFileId) + rawBytes = chatService.getFileData(sourceFileId) if not rawBytes: return ToolResult( toolCallId="", @@ -244,11 +244,7 @@ def registerMediaTools(registry: ToolRegistry, services): if not docName.lower().endswith(f".{outputFormat}"): docName = f"{sanitizedTitle}.{outputFormat}" - fileItem = None - if hasattr(chatService.interfaceDbComponent, "saveGeneratedFile"): - fileItem = chatService.interfaceDbComponent.saveGeneratedFile(docData, docName, docMime) - else: - fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(docData, docName) + fileItem, _ = chatService.saveUploadedFile(docData, docName) if fileItem: fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?") @@ -260,7 +256,7 @@ def registerMediaTools(registry: ToolRegistry, services): if fiId: updateFields["featureInstanceId"] = fiId if updateFields: - chatService.interfaceDbComponent.updateFile(fid, updateFields) + chatService.updateFile(fid, updateFields) chatDocId = _attachFileAsChatDocument( services, fileItem, label=f"renderDocument:{docName}", @@ -544,11 +540,7 @@ def registerMediaTools(registry: ToolRegistry, services): if not docName.lower().endswith(".png"): docName = f"{sanitizedTitle}.png" - fileItem = None - if hasattr(chatService.interfaceDbComponent, "saveGeneratedFile"): - fileItem = chatService.interfaceDbComponent.saveGeneratedFile(docData, docName, docMime) - else: - fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(docData, docName) + fileItem, _ = chatService.saveUploadedFile(docData, docName) if fileItem: fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?") @@ -560,7 +552,7 @@ def registerMediaTools(registry: ToolRegistry, services): if fiId: updateFields["featureInstanceId"] = fiId if updateFields: - chatService.interfaceDbComponent.updateFile(fid, updateFields) + chatService.updateFile(fid, updateFields) chatDocId = _attachFileAsChatDocument( services, fileItem, label=f"generateImage:{docName}", @@ -709,10 +701,7 @@ def registerMediaTools(registry: ToolRegistry, services): sanitizedTitle = re.sub(r'[^\w._-]', '_', title, flags=re.UNICODE).strip('_') or "chart" fileName = f"{sanitizedTitle}.png" - if hasattr(chatService.interfaceDbComponent, "saveGeneratedFile"): - fileItem = chatService.interfaceDbComponent.saveGeneratedFile(pngData, fileName, "image/png") - else: - fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(pngData, fileName) + fileItem, _ = chatService.saveUploadedFile(pngData, fileName) fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?") if isinstance(fileItem, dict) else "?" if fid != "?": @@ -724,7 +713,7 @@ def registerMediaTools(registry: ToolRegistry, services): if fiId: updateFields["featureInstanceId"] = fiId if updateFields: - chatService.interfaceDbComponent.updateFile(fid, updateFields) + chatService.updateFile(fid, updateFields) chatDocId = _attachFileAsChatDocument( services, fileItem, @@ -811,7 +800,7 @@ def registerMediaTools(registry: ToolRegistry, services): return ToolResult(toolCallId="", toolName="speechToText", success=False, error="fileId is required") try: chatService = services.chat - audioData = chatService.interfaceDbComponent.getFileData(fileId) + audioData = chatService.getFileData(fileId) if not audioData: return ToolResult(toolCallId="", toolName="speechToText", success=False, error=f"No data found for file {fileId}") from modules.interfaces.interfaceVoiceObjects import getVoiceInterface @@ -855,8 +844,6 @@ def registerMediaTools(registry: ToolRegistry, services): neutralizationService = services.getService("neutralization") if not neutralizationService: return ToolResult(toolCallId="", toolName="neutralizeData", success=False, error="Neutralization service not available") - if not neutralizationService.interfaceDbComponent: - neutralizationService.interfaceDbComponent = services.chat.interfaceDbComponent if text: result = await neutralizationService.processTextAsync(text, fileId or None) else: @@ -890,16 +877,13 @@ def registerMediaTools(registry: ToolRegistry, services): if not neutralizationService or not hasattr(neutralizationService, "resolveText"): return ToolResult(toolCallId="", toolName="revealDocument", success=False, error="Neutralization service not available") - if not getattr(neutralizationService, "interfaceDbComponent", None): - neutralizationService.interfaceDbComponent = services.chat.interfaceDbComponent - if fileId and not text: - dbMgmt = services.chat.interfaceDbComponent - fileRow = dbMgmt.getFile(fileId) + chatService = services.chat + fileRow = chatService.getFile(fileId) if not fileRow: return ToolResult(toolCallId="", toolName="revealDocument", success=False, error=f"fileId not found: {fileId}") - rawBytes = dbMgmt.getFileData(fileId) + rawBytes = chatService.getFileData(fileId) if not rawBytes: return ToolResult(toolCallId="", toolName="revealDocument", success=False, error="File data not accessible") diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_workspaceTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_workspaceTools.py index 9b4d2818..a1d56e24 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_workspaceTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_workspaceTools.py @@ -283,7 +283,7 @@ def registerWorkspaceTools(registry: ToolRegistry, services): return ToolResult(toolCallId="", toolName="tagFile", success=False, error="fileId is required") try: chatService = services.chat - chatService.interfaceDbComponent.updateFile(fileId, {"tags": tags}) + chatService.updateFile(fileId, {"tags": tags}) return ToolResult( toolCallId="", toolName="tagFile", success=True, data=f"Tags updated to {tags} for file {fileId}" @@ -302,22 +302,21 @@ def registerWorkspaceTools(registry: ToolRegistry, services): try: chatService = services.chat - dbMgmt = chatService.interfaceDbComponent if mode == "append": if not fileId: return ToolResult(toolCallId="", toolName="writeFile", success=False, error="fileId is required for mode=append") - file = dbMgmt.getFile(fileId) + file = chatService.getFile(fileId) if not file: return ToolResult(toolCallId="", toolName="writeFile", success=False, error=f"File {fileId} not found") - existingData = dbMgmt.getFileData(fileId) or b"" + existingData = chatService.getFileData(fileId) or b"" try: existingText = existingData.decode("utf-8") except UnicodeDecodeError: existingText = existingData.decode("latin-1", errors="replace") newContent = existingText + content - dbMgmt.updateFileData(fileId, newContent.encode("utf-8")) - dbMgmt.updateFile(fileId, {"fileSize": len(newContent.encode("utf-8"))}) + chatService.updateFileData(fileId, newContent.encode("utf-8")) + chatService.updateFile(fileId, {"fileSize": len(newContent.encode("utf-8"))}) return ToolResult( toolCallId="", toolName="writeFile", success=True, data=f"Appended {len(content)} chars to '{file.fileName}' (id: {fileId}, total: {len(newContent)} chars)", @@ -327,11 +326,11 @@ def registerWorkspaceTools(registry: ToolRegistry, services): if mode == "overwrite": if not fileId: return ToolResult(toolCallId="", toolName="writeFile", success=False, error="fileId is required for mode=overwrite") - file = dbMgmt.getFile(fileId) + file = chatService.getFile(fileId) if not file: return ToolResult(toolCallId="", toolName="writeFile", success=False, error=f"File {fileId} not found") - dbMgmt.updateFileData(fileId, content.encode("utf-8")) - dbMgmt.updateFile(fileId, {"fileSize": len(content.encode("utf-8"))}) + chatService.updateFileData(fileId, content.encode("utf-8")) + chatService.updateFile(fileId, {"fileSize": len(content.encode("utf-8"))}) return ToolResult( toolCallId="", toolName="writeFile", success=True, data=f"Overwritten '{file.fileName}' (id: {fileId}, {len(content)} chars)", @@ -341,7 +340,7 @@ def registerWorkspaceTools(registry: ToolRegistry, services): # mode == "create" (default) if not name: return ToolResult(toolCallId="", toolName="writeFile", success=False, error="name is required for mode=create") - fileItem, _ = dbMgmt.saveUploadedFile(content.encode("utf-8"), name) + fileItem, _ = chatService.saveUploadedFile(content.encode("utf-8"), name) fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "") updateFields: Dict[str, Any] = {} if fiId: @@ -351,7 +350,7 @@ def registerWorkspaceTools(registry: ToolRegistry, services): if args.get("tags"): updateFields["tags"] = args["tags"] if updateFields: - dbMgmt.updateFile(fileItem.id, updateFields) + chatService.updateFile(fileItem.id, updateFields) chatDocId = _attachFileAsChatDocument( services, fileItem, @@ -498,7 +497,7 @@ def registerWorkspaceTools(registry: ToolRegistry, services): return ToolResult(toolCallId="", toolName="deleteFile", success=False, error="fileId is required") try: chatService = services.chat - file = chatService.interfaceDbComponent.getFile(fileId) + file = chatService.getFile(fileId) if not file: return ToolResult(toolCallId="", toolName="deleteFile", success=False, error=f"File {fileId} not found") fileName = file.fileName @@ -508,7 +507,7 @@ def registerWorkspaceTools(registry: ToolRegistry, services): knowledgeService.removeFile(fileId) except Exception as e: logger.warning(f"deleteFile: knowledge store cleanup failed for {fileId}: {e}") - chatService.interfaceDbComponent.deleteFile(fileId) + chatService.deleteFile(fileId) return ToolResult( toolCallId="", toolName="deleteFile", success=True, data=f"File '{fileName}' (id: {fileId}) deleted", @@ -524,7 +523,7 @@ def registerWorkspaceTools(registry: ToolRegistry, services): return ToolResult(toolCallId="", toolName="renameFile", success=False, error="fileId and newName are required") try: chatService = services.chat - chatService.interfaceDbComponent.updateFile(fileId, {"fileName": newName}) + chatService.updateFile(fileId, {"fileName": newName}) return ToolResult( toolCallId="", toolName="renameFile", success=True, data=f"File {fileId} renamed to '{newName}'", @@ -651,7 +650,7 @@ def registerWorkspaceTools(registry: ToolRegistry, services): return ToolResult(toolCallId="", toolName="copyFile", success=False, error="fileId is required") try: chatService = services.chat - copiedFile = chatService.interfaceDbComponent.copyFile( + copiedFile = chatService.copyFile( fileId, newFileName=args.get("newFileName"), ) @@ -676,16 +675,15 @@ def registerWorkspaceTools(registry: ToolRegistry, services): return ToolResult(toolCallId="", toolName="replaceInFile", success=False, error="fileId and oldText are required") try: chatService = services.chat - dbMgmt = chatService.interfaceDbComponent - file = dbMgmt.getFile(fileId) + file = chatService.getFile(fileId) if not file: return ToolResult(toolCallId="", toolName="replaceInFile", success=False, error=f"File {fileId} not found") - if not dbMgmt.isTextMimeType(file.mimeType): + if not chatService.isTextMimeType(file.mimeType): return ToolResult( toolCallId="", toolName="replaceInFile", success=False, error=f"Cannot edit binary file ({file.mimeType}). Only text-based files are supported." ) - rawData = dbMgmt.getFileData(fileId) + rawData = chatService.getFileData(fileId) if not rawData: return ToolResult(toolCallId="", toolName="replaceInFile", success=False, error="File has no content") try: @@ -750,8 +748,7 @@ def registerWorkspaceTools(registry: ToolRegistry, services): return ToolResult(toolCallId="", toolName="createFolder", success=False, error="name is required") try: chatService = services.chat - dbMgmt = chatService.interfaceDbComponent - folder = dbMgmt.createFolder(name, parentId=parentId) + folder = chatService.createFolder(name, parentId=parentId) folderId = folder.get("id") if isinstance(folder, dict) else getattr(folder, "id", None) folderName = folder.get("name") if isinstance(folder, dict) else getattr(folder, "name", name) return ToolResult( @@ -765,8 +762,7 @@ def registerWorkspaceTools(registry: ToolRegistry, services): async def _listFolders(args: Dict[str, Any], context: Dict[str, Any]): try: chatService = services.chat - dbMgmt = chatService.interfaceDbComponent - folders = dbMgmt.getOwnFolderTree() + folders = chatService.getOwnFolderTree() if not folders: return ToolResult(toolCallId="", toolName="listFolders", success=True, data="No folders found.") lines = [] @@ -795,11 +791,10 @@ def registerWorkspaceTools(registry: ToolRegistry, services): return ToolResult(toolCallId="", toolName="moveFile", success=False, error="fileId is required") try: chatService = services.chat - dbMgmt = chatService.interfaceDbComponent - file = dbMgmt.getFile(fileId) + file = chatService.getFile(fileId) if not file: return ToolResult(toolCallId="", toolName="moveFile", success=False, error=f"File {fileId} not found") - dbMgmt.updateFile(fileId, {"folderId": folderId or None}) + chatService.updateFile(fileId, {"folderId": folderId or None}) targetLabel = f"folder {folderId}" if folderId else "root" return ToolResult( toolCallId="", toolName="moveFile", success=True, @@ -843,8 +838,7 @@ def registerWorkspaceTools(registry: ToolRegistry, services): return ToolResult(toolCallId="", toolName="renameFolder", success=False, error="folderId and newName are required") try: chatService = services.chat - dbMgmt = chatService.interfaceDbComponent - folder = dbMgmt.renameFolder(folderId, newName) + folder = chatService.renameFolder(folderId, newName) return ToolResult( toolCallId="", toolName="renameFolder", success=True, data=f"Folder {folderId} renamed to '{newName}'", diff --git a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py index e0c57496..e977f596 100644 --- a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py +++ b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py @@ -26,7 +26,7 @@ from modules.serviceCenter.services.serviceBilling.mainServiceBilling import ( logger = logging.getLogger(__name__) -def _toolbox_connection_authorities(services: "_ServicesAdapter") -> List[str]: +def _toolbox_connection_authorities(services: "ServicesBag") -> List[str]: """Collect connection authority strings for toolbox gating (requiresConnection). The optional ``connection`` service is not always registered; fall back to @@ -59,8 +59,10 @@ def _toolbox_connection_authorities(services: "_ServicesAdapter") -> List[str]: return list(seen) -class _ServicesAdapter: - """Adapter providing service access from (context, get_service).""" +class ServicesBag: + """Canonical services bag providing service access from (context, get_service). + Used by AgentService and WorkflowAutomation as the single source of truth + for service resolution, RBAC checks, and context-scoped properties.""" def __init__(self, context, getService: Callable[[str], Any]): self._context = context @@ -105,13 +107,6 @@ class _ServicesAdapter: def extraction(self): return self._getService("extraction") - @property - def interfaceDbComponent(self): - try: - return self.chat.interfaceDbComponent - except Exception: - return None - @property def rbac(self): """Same RbacClass as workflow hub (MethodBase permission checks during discoverMethods).""" @@ -128,6 +123,15 @@ class _ServicesAdapter: """Access any service by name.""" return self._getService(name) + def canAccessService(self, serviceKey: str) -> bool: + """Check if current user has RBAC permission for a service.""" + from modules.serviceCenter import canAccessService + return canAccessService( + self.user, self.rbac, serviceKey, + mandateId=self.mandateId, + featureInstanceId=self.featureInstanceId, + ) + def __getattr__(self, name: str): """Resolve e.g. services.clickup for MethodClickup / ActionExecutor (discoverMethods).""" if name.startswith("_"): @@ -157,7 +161,7 @@ class AgentService: def __init__(self, context, get_service: Callable[[str], Any]): self._context = context self._getService = get_service - self.services = _ServicesAdapter(context, get_service) + self.services = ServicesBag(context, get_service) async def runAgent( self, @@ -676,8 +680,7 @@ def _buildWorkflowHintItems( Limited to 10 most recent other workflows to keep the hint small. """ try: - chatInterface = services.chat.interfaceDbChat - allWorkflows = chatInterface.getWorkflows() or [] + allWorkflows = services.chat.getWorkflows() or [] except Exception: return [] diff --git a/modules/serviceCenter/services/serviceAi/subContentExtraction.py b/modules/serviceCenter/services/serviceAi/subContentExtraction.py index 6e5ddd42..d66db1cc 100644 --- a/modules/serviceCenter/services/serviceAi/subContentExtraction.py +++ b/modules/serviceCenter/services/serviceAi/subContentExtraction.py @@ -261,7 +261,7 @@ class ContentExtractor: # Check if it's standardized JSON format (has "documents" or "sections") if document.mimeType == "application/json": - docBytes = self.services.interfaceDbComponent.getFileData(document.fileId) + docBytes = self.services.chat.getFileData(document.fileId) if docBytes: try: docData = docBytes.decode('utf-8') @@ -349,7 +349,7 @@ class ContentExtractor: if document.mimeType.startswith("image/") or self._isBinary(document.mimeType): try: # Lade Binary-Daten (getFileData ist nicht async - keine await nötig) - binaryData = self.services.interfaceDbComponent.getFileData(document.fileId) + binaryData = self.services.chat.getFileData(document.fileId) if not binaryData: logger.warning(f"No binary data found for document {document.id}") continue diff --git a/modules/serviceCenter/services/serviceAi/subDocumentIntents.py b/modules/serviceCenter/services/serviceAi/subDocumentIntents.py index 7d47c18f..aae86fc2 100644 --- a/modules/serviceCenter/services/serviceAi/subDocumentIntents.py +++ b/modules/serviceCenter/services/serviceAi/subDocumentIntents.py @@ -155,7 +155,7 @@ class DocumentIntentAnalyzer: return None try: - docBytes = self.services.interfaceDbComponent.getFileData(document.fileId) + docBytes = self.services.chat.getFileData(document.fileId) if not docBytes: return None diff --git a/modules/serviceCenter/services/serviceBilling/stripeCheckout.py b/modules/serviceCenter/services/serviceBilling/stripeCheckout.py index bb9feea7..010c4e4b 100644 --- a/modules/serviceCenter/services/serviceBilling/stripeCheckout.py +++ b/modules/serviceCenter/services/serviceBilling/stripeCheckout.py @@ -212,7 +212,7 @@ def _normalizeReturnUrl(returnUrl: str) -> str: return urlunsplit((parsed.scheme, parsed.netloc, normalized_path, normalized_query, "")) -def create_checkout_session( +def createCheckoutSession( mandate_id: str, user_id: Optional[str], amount_chf: float, diff --git a/modules/serviceCenter/services/serviceChat/mainServiceChat.py b/modules/serviceCenter/services/serviceChat/mainServiceChat.py index 3e3d9f15..77856a7d 100644 --- a/modules/serviceCenter/services/serviceChat/mainServiceChat.py +++ b/modules/serviceCenter/services/serviceChat/mainServiceChat.py @@ -788,6 +788,151 @@ class ChatService: 'workflowId': 'unknown' } + def createActionItem(self, actionData: Dict[str, Any]): + """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) + + def getUserConnectionById(self, connectionId: str): + """Get a single UserConnection by ID, delegating to interfaceDbApp.""" + try: + if self.interfaceDbApp and hasattr(self.interfaceDbApp, "getUserConnectionById"): + return self.interfaceDbApp.getUserConnectionById(str(connectionId)) + except Exception as e: + logger.error(f"Error getting user connection by ID {connectionId}: {e}") + return None + + # ---- File-Write operations (delegate to interfaceDbComponent) ---- + + def saveUploadedFile(self, fileContent: bytes, fileName: str): + """Save uploaded file bytes. Returns (fileItem, duplicateStatus).""" + try: + return self.interfaceDbComponent.saveUploadedFile(fileContent, fileName) + except Exception as e: + logger.error(f"Error saving uploaded file '{fileName}': {e}") + raise + + def createFile(self, name: str, mimeType: str, content: bytes, folderId=None): + """Create a new file record with content.""" + try: + return self.interfaceDbComponent.createFile(name, mimeType, content, folderId=folderId) + except Exception as e: + logger.error(f"Error creating file '{name}': {e}") + raise + + def createFileData(self, fileId: str, data: bytes): + """Write binary data for an existing file record.""" + try: + return self.interfaceDbComponent.createFileData(fileId, data) + except Exception as e: + logger.error(f"Error creating file data for fileId '{fileId}': {e}") + raise + + def updateFile(self, fileId: str, updateData: dict): + """Update file metadata (tags, fileName, fileSize, folderId, etc.).""" + try: + return self.interfaceDbComponent.updateFile(fileId, updateData) + except Exception as e: + logger.error(f"Error updating file '{fileId}': {e}") + raise + + def updateFileData(self, fileId: str, data: bytes): + """Replace file binary content.""" + try: + return self.interfaceDbComponent.updateFileData(fileId, data) + except Exception as e: + logger.error(f"Error updating file data for fileId '{fileId}': {e}") + raise + + # ---- File-Manage operations (delegate to interfaceDbComponent) ---- + + def getFile(self, fileId: str): + """Get file metadata object by ID.""" + try: + return self.interfaceDbComponent.getFile(fileId) + except Exception as e: + logger.error(f"Error getting file '{fileId}': {e}") + return None + + def deleteFile(self, fileId: str): + """Delete a file by ID.""" + try: + return self.interfaceDbComponent.deleteFile(fileId) + except Exception as e: + logger.error(f"Error deleting file '{fileId}': {e}") + raise + + def copyFile(self, sourceFileId: str, newFileName=None): + """Copy a file, optionally with a new name.""" + try: + return self.interfaceDbComponent.copyFile(sourceFileId, newFileName=newFileName) + except Exception as e: + logger.error(f"Error copying file '{sourceFileId}': {e}") + raise + + def isTextMimeType(self, mimeType: str) -> bool: + """Check if a MIME type represents text content.""" + try: + return self.interfaceDbComponent.isTextMimeType(mimeType) + except Exception as e: + logger.error(f"Error checking MIME type '{mimeType}': {e}") + return False + + def getMimeType(self, fileName: str) -> str: + """Determine MIME type from file name.""" + try: + return self.interfaceDbComponent.getMimeType(fileName) + except Exception as e: + logger.error(f"Error getting MIME type for '{fileName}': {e}") + return "application/octet-stream" + + # ---- Folder operations (delegate to interfaceDbComponent) ---- + + def createFolder(self, name: str, parentId=None): + """Create a folder, optionally under a parent.""" + try: + return self.interfaceDbComponent.createFolder(name, parentId=parentId) + except Exception as e: + logger.error(f"Error creating folder '{name}': {e}") + raise + + def getOwnFolderTree(self): + """Get the user's folder tree.""" + try: + return self.interfaceDbComponent.getOwnFolderTree() + except Exception as e: + logger.error(f"Error getting folder tree: {e}") + return None + + def renameFolder(self, folderId: str, newName: str): + """Rename a folder.""" + try: + return self.interfaceDbComponent.renameFolder(folderId, newName) + except Exception as e: + logger.error(f"Error renaming folder '{folderId}': {e}") + raise + + # ---- Workflow-Listing operations (delegate to interfaceDbChat) ---- + + def getWorkflows(self, pagination=None): + """Get all workflows for the current context.""" + try: + return self.interfaceDbChat.getWorkflows(pagination) + except Exception as e: + logger.error(f"Error getting workflows: {e}") + return [] + + def getMessages(self, workflowId: str, pagination=None): + """Get messages for a specific workflow.""" + try: + return self.interfaceDbChat.getMessages(workflowId, pagination) + except Exception as e: + logger.error(f"Error getting messages for workflow '{workflowId}': {e}") + return [] + def createWorkflow(self, workflowData: Dict[str, Any]): """Create a new workflow by delegating to the chat interface""" try: diff --git a/modules/serviceCenter/services/serviceClickup/__init__.py b/modules/serviceCenter/services/serviceClickup/__init__.py index 6b3bb1f3..49f56ec0 100644 --- a/modules/serviceCenter/services/serviceClickup/__init__.py +++ b/modules/serviceCenter/services/serviceClickup/__init__.py @@ -2,6 +2,6 @@ # All rights reserved. """ClickUp service.""" -from .mainServiceClickup import ClickupService, clickup_authorization_header +from .mainServiceClickup import ClickupService -__all__ = ["ClickupService", "clickup_authorization_header"] +__all__ = ["ClickupService"] diff --git a/modules/serviceCenter/services/serviceClickup/mainServiceClickup.py b/modules/serviceCenter/services/serviceClickup/mainServiceClickup.py index df216810..d1ef51b3 100644 --- a/modules/serviceCenter/services/serviceClickup/mainServiceClickup.py +++ b/modules/serviceCenter/services/serviceClickup/mainServiceClickup.py @@ -19,7 +19,7 @@ logger = logging.getLogger(__name__) _CLICKUP_API_BASE = "https://api.clickup.com/api/v2" -def clickup_authorization_header(token: str) -> str: +def _clickupAuthorizationHeader(token: str) -> str: """ClickUp: personal tokens are `pk_...` without Bearer; OAuth uses Bearer.""" return clickupAuthorizationHeader(token) diff --git a/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py b/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py index a7e9a36a..5dbf16de 100644 --- a/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py +++ b/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py @@ -31,7 +31,6 @@ class _ServicesAdapter: self.mandateId = context.mandate_id self.featureInstanceId = context.feature_instance_id chat = get_service("chat") - self.interfaceDbComponent = chat.interfaceDbComponent self.interfaceDbChat = chat.interfaceDbChat @property @@ -56,7 +55,6 @@ class GenerationService: """Initialize with ServiceCenterContext and service resolver.""" self.services = _ServicesAdapter(context, get_service) self._get_service = get_service - self.interfaceDbComponent = self.services.interfaceDbComponent self.interfaceDbChat = self.services.interfaceDbChat def processActionResultDocuments(self, actionResult, action) -> List[Dict[str, Any]]: @@ -289,10 +287,11 @@ class GenerationService: logger.warning(f"Could not set workflow context on document: {str(e)}") def _createDocument(self, fileName: str, mimeType: str, content: str, base64encoded: bool = True, messageId: str = None) -> Optional[ChatDocument]: - """Create file and ChatDocument using interfaces without service indirection.""" + """Create file and ChatDocument using chat service.""" try: - if not self.interfaceDbComponent: - logger.error("Component interface not available for document creation") + chat = self.services.chat + if not chat: + logger.error("Chat service not available for document creation") return None # Convert content to bytes if base64encoded: @@ -301,12 +300,12 @@ class GenerationService: else: content_bytes = content.encode('utf-8') # Create file and store data - file_item = self.interfaceDbComponent.createFile( + file_item = chat.createFile( name=fileName, mimeType=mimeType, content=content_bytes ) - self.interfaceDbComponent.createFileData(file_item.id, content_bytes) + chat.createFileData(file_item.id, content_bytes) # Collect file info file_info = self._getFileInfo(file_item.id) if not file_info: @@ -321,12 +320,6 @@ class GenerationService: fileSize=file_info.get("size", 0), mimeType=file_info.get("mimeType", mimeType) ) - # Ensure document can access component interface later - if hasattr(document, 'setComponentInterface') and self.interfaceDbComponent: - try: - document.setComponentInterface(self.interfaceDbComponent) - except Exception: - pass return document except Exception as e: logger.error(f"Error creating document: {str(e)}") @@ -334,9 +327,10 @@ class GenerationService: def _getFileInfo(self, fileId: str) -> Optional[Dict[str, Any]]: try: - if not self.interfaceDbComponent: + chat = self.services.chat + if not chat: return None - file_item = self.interfaceDbComponent.getFile(fileId) + file_item = chat.getFile(fileId) if file_item: return { "id": file_item.id, diff --git a/modules/shared/systemComponentRegistry.py b/modules/shared/systemComponentRegistry.py new file mode 100644 index 00000000..e4733a68 --- /dev/null +++ b/modules/shared/systemComponentRegistry.py @@ -0,0 +1,32 @@ +# Copyright (c) 2025 Patrick Motsch +""" +System-component lifecycle-hook registry (Layer L0 — shared). + +Higher-layer system components (e.g. workflowAutomation) register their +lifecycle hooks here at boot time via ``app.py`` (Composition Root, L7). +Interface modules read the registry generically — no upward imports needed. + +Supported events: ``onBootstrap``, ``onMandateDelete``, ``onInstanceCreate``. + +This is the same inversion pattern used by +``serviceAgent/externalToolRegistry.py`` for agent tools. +""" + +import logging +from typing import Any, Callable, Dict, List + +logger = logging.getLogger(__name__) + +_hooks: Dict[str, List[Callable[..., Any]]] = {} + + +def registerLifecycleHook(eventName: str, handler: Callable[..., Any]) -> None: + """Register a lifecycle handler for *eventName*.""" + _hooks.setdefault(eventName, []).append(handler) + logger.info("Registered system-component lifecycle hook: %s -> %s", + eventName, getattr(handler, "__qualname__", repr(handler))) + + +def getLifecycleHooks(eventName: str) -> List[Callable[..., Any]]: + """Return all registered handlers for *eventName* (may be empty).""" + return list(_hooks.get(eventName, [])) diff --git a/modules/workflowAutomation/engine/workflowArtifactVisibility.py b/modules/shared/workflowArtifactVisibility.py similarity index 90% rename from modules/workflowAutomation/engine/workflowArtifactVisibility.py rename to modules/shared/workflowArtifactVisibility.py index 0eb8d4bd..3431bee2 100644 --- a/modules/workflowAutomation/engine/workflowArtifactVisibility.py +++ b/modules/shared/workflowArtifactVisibility.py @@ -9,13 +9,13 @@ from typing import Any, Mapping, Optional _WORKFLOW_INTERNAL_FILE_TAG = "_workflowInternal" -def suppress_workflow_file_in_workspace_ui(meta: Optional[Mapping[str, Any]]) -> bool: +def suppressWorkflowFileInWorkspaceUi(meta: Optional[Mapping[str, Any]]) -> bool: """True when a file row should not appear in user-facing file lists. Used by Automation Workspace **and** ``/api/files/list`` (Meine Dateien). Matches persisted JSON handovers from transient runs (``extracted_content_transient*``), internal extract image files (``extract_media_*``), the ``_workflowInternal`` tag, and - optional explicit flags. + optional explicit flags. """ if not isinstance(meta, Mapping): return False diff --git a/modules/shared/workflowState.py b/modules/shared/workflowState.py index 069645b9..6a8680a3 100644 --- a/modules/shared/workflowState.py +++ b/modules/shared/workflowState.py @@ -32,7 +32,7 @@ def checkWorkflowStopped(services: Any) -> None: try: # Get the current workflow status from the database to avoid stale data - currentWorkflow = services.interfaceDbChat.getWorkflow(workflow.id) + currentWorkflow = services.chat.getWorkflow(workflow.id) if currentWorkflow and currentWorkflow.status == "stopped": logger.info("Workflow stopped by user, aborting operation") raise WorkflowStoppedException("Workflow was stopped by user") diff --git a/modules/workflowAutomation/engine/executionEngine.py b/modules/workflowAutomation/engine/executionEngine.py index 443de25d..99f7c2ed 100644 --- a/modules/workflowAutomation/engine/executionEngine.py +++ b/modules/workflowAutomation/engine/executionEngine.py @@ -34,8 +34,8 @@ from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES from modules.datamodels.serviceExceptions import SubscriptionInactiveException as _SubscriptionInactiveException, BillingContextError as _BillingContextError from modules.workflowAutomation.engine.runFileLogger import ( RunFileLogger, - graphical_editor_run_file_logging_enabled, - merge_run_context_with_ge_log_prefix, + workflowAutomationRunFileLoggingEnabled, + mergeRunContextWithWaLogPrefix, ) from modules.workflowAutomation.engine.runEnvelope import normalize_run_envelope @@ -383,7 +383,7 @@ async def _ge_log_node_finished( exec_rec["output"] = ( _stripBinaryValues(output) if isinstance(output, dict) else {"value": _stripBinaryValues(output)} ) - await file_logger.append_node_execution_line(exec_rec) + await file_logger.appendNodeExecutionLine(exec_rec) ctx_rec: Dict[str, Any] = { "timestamp": ts, @@ -398,7 +398,7 @@ async def _ge_log_node_finished( ctx_rec["loopIndex"] = loop_index if loop_node_id is not None: ctx_rec["loopNodeId"] = loop_node_id - await file_logger.append_context_snapshot_line(ctx_rec) + await file_logger.appendContextSnapshotLine(ctx_rec) async def _executeWithRetry(executor, node, context, maxRetries: int = 0, retryDelaySeconds: float = 1.0): @@ -511,7 +511,7 @@ async def _run_post_loop_done_nodes( automation2_interface: Optional[Any], runId: Optional[str], processed_in_loop: Set[str], - ge_file_logger: Optional[RunFileLogger] = None, + waFileLogger: Optional[RunFileLogger] = None, ) -> Optional[Dict[str, Any]]: """After all loop iterations: merge upstream into loop output and run the Done (output 1) branch once.""" _prim_in = getLoopPrimaryInputSource(loop_node_id, connectionMap, body_ids) @@ -553,7 +553,7 @@ async def _run_post_loop_done_nodes( if _skId: _updateStepLog(automation2_interface, _skId, "skipped") await _ge_log_node_finished( - ge_file_logger, + waFileLogger, run_id=runId, node_outputs=nodeOutputs, run_envelope=context.get("runEnvelope"), @@ -586,7 +586,7 @@ async def _run_post_loop_done_nodes( output=_dres if isinstance(_dres, dict) else {"value": _dres}, durationMs=_dDur, tokensUsed=_dTok, retryCount=_dRetry) await _ge_log_node_finished( - ge_file_logger, + waFileLogger, run_id=runId, node_outputs=nodeOutputs, run_envelope=context.get("runEnvelope"), @@ -603,7 +603,7 @@ async def _run_post_loop_done_nodes( _updateStepLog(automation2_interface, _dStepId, "completed", durationMs=int((time.time() - _dStart) * 1000)) await _ge_log_node_finished( - ge_file_logger, + waFileLogger, run_id=runId, node_outputs=nodeOutputs, run_envelope=context.get("runEnvelope"), @@ -619,7 +619,7 @@ async def _run_post_loop_done_nodes( _updateStepLog(automation2_interface, _dStepId, "completed", durationMs=int((time.time() - _dStart) * 1000)) await _ge_log_node_finished( - ge_file_logger, + waFileLogger, run_id=runId, node_outputs=nodeOutputs, run_envelope=context.get("runEnvelope"), @@ -636,7 +636,7 @@ async def _run_post_loop_done_nodes( _updateStepLog(automation2_interface, _dStepId, "failed", error="Subscription/Billing error", durationMs=_dFailDur) await _ge_log_node_finished( - ge_file_logger, + waFileLogger, run_id=runId, node_outputs=nodeOutputs, run_envelope=context.get("runEnvelope"), @@ -654,7 +654,7 @@ async def _run_post_loop_done_nodes( _updateStepLog(automation2_interface, _dStepId, "failed", error=str(_dex), durationMs=_dFailDur2) await _ge_log_node_finished( - ge_file_logger, + waFileLogger, run_id=runId, node_outputs=nodeOutputs, run_envelope=context.get("runEnvelope"), @@ -767,7 +767,7 @@ async def executeGraph( except Exception as valErr: logger.warning("executeGraph resume: schema validation failed for %s: %s", startAfterNodeId, valErr) - ge_file_logger: Optional[RunFileLogger] = None + waFileLogger: Optional[RunFileLogger] = None nodeOutputs: Dict[str, Any] = dict(initialNodeOutputs or {}) if not runId and automation2_interface and workflowId and not is_resume: run_context = { @@ -805,8 +805,8 @@ async def executeGraph( ) runId = run.get("id") if run else None logger.info("executeGraph created run %s label=%s", runId, run_label) - if runId and graphical_editor_run_file_logging_enabled(): - ge_file_logger = RunFileLogger.bootstrap_new_run( + if runId and workflowAutomationRunFileLoggingEnabled(): + waFileLogger = RunFileLogger.bootstrapNewRun( automation2_interface, runId, run_context, @@ -842,12 +842,12 @@ async def executeGraph( _activeRunContexts[runId] = context if ( - graphical_editor_run_file_logging_enabled() + workflowAutomationRunFileLoggingEnabled() and automation2_interface and runId - and ge_file_logger is None + and waFileLogger is None ): - ge_file_logger = RunFileLogger.ensure_attached( + waFileLogger = RunFileLogger.ensureAttached( automation2_interface, runId, ) @@ -916,7 +916,7 @@ async def executeGraph( output=result if isinstance(result, dict) else {"value": result}, durationMs=_rDur, retryCount=_rRetry) await _ge_log_node_finished( - ge_file_logger, + waFileLogger, run_id=runId, node_outputs=nodeOutputs, run_envelope=context.get("runEnvelope"), @@ -940,7 +940,7 @@ async def executeGraph( _updateStepLog(automation2_interface, _rStepId, "completed", durationMs=_rPauseDur) await _ge_log_node_finished( - ge_file_logger, + waFileLogger, run_id=runId, node_outputs=nodeOutputs, run_envelope=context.get("runEnvelope"), @@ -964,7 +964,7 @@ async def executeGraph( _updateStepLog(automation2_interface, _rStepId, "completed", durationMs=_rEmailDur) await _ge_log_node_finished( - ge_file_logger, + waFileLogger, run_id=runId, node_outputs=nodeOutputs, run_envelope=context.get("runEnvelope"), @@ -984,7 +984,7 @@ async def executeGraph( _updateStepLog(automation2_interface, _rStepId, "failed", error="Subscription/Billing error", durationMs=_rFailDurSb) await _ge_log_node_finished( - ge_file_logger, + waFileLogger, run_id=runId, node_outputs=nodeOutputs, run_envelope=context.get("runEnvelope"), @@ -1005,7 +1005,7 @@ async def executeGraph( _updateStepLog(automation2_interface, _rStepId, "failed", error=str(ex), durationMs=_rFailDurEx) await _ge_log_node_finished( - ge_file_logger, + waFileLogger, run_id=runId, node_outputs=nodeOutputs, run_envelope=context.get("runEnvelope"), @@ -1049,7 +1049,7 @@ async def executeGraph( automation2_interface=automation2_interface, runId=runId, processed_in_loop=processed_in_loop, - ge_file_logger=ge_file_logger, + waFileLogger=waFileLogger, ) for i, node in enumerate(ordered): @@ -1088,7 +1088,7 @@ async def executeGraph( if _skipStepId: _updateStepLog(automation2_interface, _skipStepId, "skipped") await _ge_log_node_finished( - ge_file_logger, + waFileLogger, run_id=runId, node_outputs=nodeOutputs, run_envelope=context.get("runEnvelope"), @@ -1206,7 +1206,7 @@ async def executeGraph( output=bres if isinstance(bres, dict) else {"value": bres}, durationMs=_bDur, retryCount=_bRetry) await _ge_log_node_finished( - ge_file_logger, + waFileLogger, run_id=runId, node_outputs=_activeOutputs, run_envelope=context.get("runEnvelope"), @@ -1230,7 +1230,7 @@ async def executeGraph( _updateStepLog(automation2_interface, _bStepId, "completed", durationMs=_bHd) await _ge_log_node_finished( - ge_file_logger, + waFileLogger, run_id=runId, node_outputs=_activeOutputs, run_envelope=context.get("runEnvelope"), @@ -1256,7 +1256,7 @@ async def executeGraph( _updateStepLog(automation2_interface, _bStepId, "completed", durationMs=_bEd) await _ge_log_node_finished( - ge_file_logger, + waFileLogger, run_id=runId, node_outputs=_activeOutputs, run_envelope=context.get("runEnvelope"), @@ -1277,7 +1277,7 @@ async def executeGraph( _updateStepLog(automation2_interface, _bStepId, "failed", error="Subscription/Billing error", durationMs=_bSb) await _ge_log_node_finished( - ge_file_logger, + waFileLogger, run_id=runId, node_outputs=_activeOutputs, run_envelope=context.get("runEnvelope"), @@ -1299,7 +1299,7 @@ async def executeGraph( _updateStepLog(automation2_interface, _bStepId, "failed", error=str(ex), durationMs=_bFail) await _ge_log_node_finished( - ge_file_logger, + waFileLogger, run_id=runId, node_outputs=_activeOutputs, run_envelope=context.get("runEnvelope"), @@ -1393,7 +1393,7 @@ async def executeGraph( automation2_interface=automation2_interface, runId=runId, processed_in_loop=processed_in_loop, - ge_file_logger=ge_file_logger, + waFileLogger=waFileLogger, ) _loopDurMs = int((time.time() - _stepStartMs) * 1000) @@ -1407,7 +1407,7 @@ async def executeGraph( output=_loopStepOut, durationMs=_loopDurMs) await _ge_log_node_finished( - ge_file_logger, + waFileLogger, run_id=runId, node_outputs=nodeOutputs, run_envelope=context.get("runEnvelope"), @@ -1441,7 +1441,7 @@ async def executeGraph( output=result if isinstance(result, dict) else {"value": result}, durationMs=_mergeDurMs, tokensUsed=_mergeTok, retryCount=retryCount) await _ge_log_node_finished( - ge_file_logger, + waFileLogger, run_id=runId, node_outputs=nodeOutputs, run_envelope=context.get("runEnvelope"), @@ -1471,7 +1471,7 @@ async def executeGraph( output=result if isinstance(result, dict) else {"value": result}, durationMs=_durMs, tokensUsed=_tokens, retryCount=retryCount) await _ge_log_node_finished( - ge_file_logger, + waFileLogger, run_id=runId, node_outputs=nodeOutputs, run_envelope=context.get("runEnvelope"), @@ -1500,7 +1500,7 @@ async def executeGraph( if _ge_in is None: _ge_in = locals().get("_loopInputSnap") or {} await _ge_log_node_finished( - ge_file_logger, + waFileLogger, run_id=runId, node_outputs=nodeOutputs, run_envelope=context.get("runEnvelope"), @@ -1528,7 +1528,7 @@ async def executeGraph( if _ge_email_in is None: _ge_email_in = locals().get("_loopInputSnap") or {} await _ge_log_node_finished( - ge_file_logger, + waFileLogger, run_id=runId, node_outputs=nodeOutputs, run_envelope=context.get("runEnvelope"), @@ -1564,7 +1564,7 @@ async def executeGraph( } if automation2_interface and e.runId: prev_ctx = dict((automation2_interface.getRun(e.runId) or {}).get("context") or {}) - run_ctx = merge_run_context_with_ge_log_prefix(prev_ctx, run_ctx) + run_ctx = mergeRunContextWithWaLogPrefix(prev_ctx, run_ctx) automation2_interface.updateRun( e.runId, status="paused", @@ -1589,7 +1589,7 @@ async def executeGraph( if _ge_fail_in is None: _ge_fail_in = locals().get("_loopInputSnap") or {} await _ge_log_node_finished( - ge_file_logger, + waFileLogger, run_id=runId, node_outputs=nodeOutputs, run_envelope=context.get("runEnvelope"), diff --git a/modules/workflowAutomation/engine/executors/actionNodeExecutor.py b/modules/workflowAutomation/engine/executors/actionNodeExecutor.py index 799d1606..82c0cbe1 100644 --- a/modules/workflowAutomation/engine/executors/actionNodeExecutor.py +++ b/modules/workflowAutomation/engine/executors/actionNodeExecutor.py @@ -210,10 +210,10 @@ def _resolveConnectionIdToReference(chatService, connectionId: str, services=Non return f"connection:{authority}:{username}" except Exception as e: logger.debug("_resolveConnectionIdToReference chatService: %s", e) - app = getattr(services, "interfaceDbApp", None) if services else None - if app and hasattr(app, "getUserConnectionById"): + chatSvc = getattr(services, "chat", None) if services else None + if chatSvc and hasattr(chatSvc, "getUserConnectionById"): try: - conn = app.getUserConnectionById(str(connectionId)) + conn = chatSvc.getUserConnectionById(str(connectionId)) if conn: authority = getattr(conn, "authority", None) if hasattr(authority, "value"): @@ -542,8 +542,7 @@ class ActionNodeExecutor: resolvedParams[pname] = _wired # 3. Resolve connectionReference - chatService = getattr(self.services, "chat", None) - _resolveConnectionParam(resolvedParams, chatService, self.services) + _resolveConnectionParam(resolvedParams, self.services.chat, self.services) # 3b. Optional graph-level injections declared on the node definition. # - injectUpstreamPayload: True → ``_upstreamPayload`` (port 0 source output, transit-unwrapped) @@ -580,12 +579,10 @@ class ActionNodeExecutor: # 6. Create progress parent so nested actions have a hierarchy nodeOperationId = f"node_{nodeId}_{context.get('_runId', 'x')}_{int(time.time())}" - chatService = getattr(self.services, "chat", None) - if chatService: - try: - chatService.progressLogStart(nodeOperationId, methodName.capitalize(), actionName, f"Node {nodeId}") - except Exception: - pass + try: + self.services.chat.progressLogStart(nodeOperationId, methodName.capitalize(), actionName, f"Node {nodeId}") + except Exception: + pass resolvedParams["parentOperationId"] = nodeOperationId # 9. Execute action @@ -632,26 +629,7 @@ class ActionNodeExecutor: rawBytes = coerceDocumentDataToBytes(rawData) if isinstance(dumped, dict) and rawBytes: try: - from modules.interfaces.interfaceDbManagement import getInterface as _getMgmtInterface - from modules.interfaces.interfaceDbApp import getInterface as _getAppInterface - from modules.security.rootAccess import getRootUser - _userId = context.get("userId") - _mandateId = context.get("mandateId") - _instanceId = context.get("instanceId") - _owner = None - if _userId: - try: - _umap = _getAppInterface(getRootUser()).getUsersByIds([str(_userId)]) - _owner = _umap.get(str(_userId)) - except Exception as _ue: - logger.warning("Could not resolve workflow user for file persistence: %s", _ue) - if _owner is None: - _owner = getRootUser() - logger.debug( - "Persisting workflow document as root user (no resolved owner userId=%r)", - _userId, - ) - _mgmt = _getMgmtInterface(_owner, mandateId=_mandateId, featureInstanceId=_instanceId) + _mgmt = self.services.interfaceDbComponent _docName = dumped.get("documentName") or f"workflow-result-{nodeId}.bin" _mimeType = dumped.get("mimeType") or "application/octet-stream" _fileItem = _mgmt.createFile(_docName, _mimeType, rawBytes, folderId=persist_folder_id) diff --git a/modules/workflowAutomation/engine/executors/inputExecutor.py b/modules/workflowAutomation/engine/executors/inputExecutor.py index 39efcfe6..926dd3a8 100644 --- a/modules/workflowAutomation/engine/executors/inputExecutor.py +++ b/modules/workflowAutomation/engine/executors/inputExecutor.py @@ -47,9 +47,9 @@ class InputExecutor: ) taskId = task.get("id") - from modules.workflowAutomation.engine.runFileLogger import merge_persisted_run_context + from modules.workflowAutomation.engine.runFileLogger import mergePersistedRunContext - _pause_ctx = merge_persisted_run_context( + _pause_ctx = mergePersistedRunContext( self.automation2, runId, { diff --git a/modules/workflowAutomation/engine/runFileLogger.py b/modules/workflowAutomation/engine/runFileLogger.py index 07600317..af57c275 100644 --- a/modules/workflowAutomation/engine/runFileLogger.py +++ b/modules/workflowAutomation/engine/runFileLogger.py @@ -1,5 +1,5 @@ # Copyright (c) 2025 Patrick Motsch -"""Per-run NDJSON logs for persisted Automation2 / graphical-editor runs.""" +"""Per-run NDJSON logs for persisted workflow-automation runs.""" from __future__ import annotations @@ -16,40 +16,40 @@ from modules.shared.debugLogger import ensureDir, resolve_app_log_dir logger = logging.getLogger(__name__) -RUN_FILE_LOG_RELATIVE_ROOT = "graphical_editor_runs" -CONTEXT_KEY = "_geRunFileLogRelativeDir" +RUN_FILE_LOG_RELATIVE_ROOT = "workflow_automation_runs" +CONTEXT_KEY = "_waRunFileLogRelativeDir" EXECUTION_FILENAME = "node_execution.ndjson" CONTEXT_SNAPSHOT_FILENAME = "workflow_context.ndjson" -def graphical_editor_run_file_logging_enabled() -> bool: +def workflowAutomationRunFileLoggingEnabled() -> bool: """True when NDJSON files should be written for each persisted run.""" - raw = APP_CONFIG.get("APP_GRAPHICAL_EDITOR_RUN_FILE_LOGGING", False) + raw = APP_CONFIG.get("APP_WORKFLOW_AUTOMATION_RUN_FILE_LOGGING") or APP_CONFIG.get("APP_GRAPHICAL_EDITOR_RUN_FILE_LOGGING", False) if isinstance(raw, bool): return raw s = str(raw).strip().lower() return s in ("1", "true", "yes", "on") -def merge_run_context_with_ge_log_prefix( - base_context: Optional[Dict[str, Any]], +def mergeRunContextWithWaLogPrefix( + baseContext: Optional[Dict[str, Any]], incoming: Dict[str, Any], ) -> Dict[str, Any]: - """Copy ``CONTEXT_KEY`` from *base_context* onto *incoming* if present (pause paths).""" + """Copy ``CONTEXT_KEY`` from *baseContext* onto *incoming* if present (pause paths).""" out = dict(incoming or {}) - prev = (base_context or {}).get(CONTEXT_KEY) + prev = (baseContext or {}).get(CONTEXT_KEY) if prev is not None: out[CONTEXT_KEY] = prev return out -def merge_persisted_run_context( - automation2_interface: Any, - run_id: str, +def mergePersistedRunContext( + workflowAutomationInterface: Any, + runId: str, replacement: Dict[str, Any], ) -> Dict[str, Any]: - """``{**db_context, **replacement}`` so *_geRunFileLogRelativeDir* and other keys survive pause updates.""" - prev = dict((automation2_interface.getRun(run_id) or {}).get("context") or {}) + """``{**db_context, **replacement}`` so *_waRunFileLogRelativeDir* and other keys survive pause updates.""" + prev = dict((workflowAutomationInterface.getRun(runId) or {}).get("context") or {}) return {**prev, **(replacement or {})} @@ -58,65 +58,65 @@ class RunFileLogger: __slots__ = ("_exec_path", "_ctx_path", "_lock", "_run_id") - def __init__(self, run_id: str, absolute_run_dir: str) -> None: - self._run_id = run_id - ensureDir(absolute_run_dir) - self._exec_path = os.path.join(absolute_run_dir, EXECUTION_FILENAME) - self._ctx_path = os.path.join(absolute_run_dir, CONTEXT_SNAPSHOT_FILENAME) + def __init__(self, runId: str, absoluteRunDir: str) -> None: + self._run_id = runId + ensureDir(absoluteRunDir) + self._exec_path = os.path.join(absoluteRunDir, EXECUTION_FILENAME) + self._ctx_path = os.path.join(absoluteRunDir, CONTEXT_SNAPSHOT_FILENAME) self._lock = asyncio.Lock() @property - def run_id(self) -> str: + def runId(self) -> str: return self._run_id @staticmethod - def fresh_run_subdirectory_name(run_id: str) -> str: + def freshRunSubdirectoryName(runId: str) -> str: ts = datetime.now(timezone.utc).strftime("%Y_%m_%d_%H_%M_%S") - return f"{ts}__{run_id}" + return f"{ts}__{runId}" @staticmethod - def relative_run_path(subdir_name: str) -> str: + def relativeRunPath(subdirName: str) -> str: """Path relative to ``APP_LOGGING_LOG_DIR`` (POSIX-style segments).""" - return "/".join((RUN_FILE_LOG_RELATIVE_ROOT, subdir_name)) + return "/".join((RUN_FILE_LOG_RELATIVE_ROOT, subdirName)) @classmethod - def bootstrap_new_run(cls, automation2_interface: Any, run_id: str, run_context: Dict[str, Any]) -> RunFileLogger | None: + def bootstrapNewRun(cls, workflowAutomationInterface: Any, runId: str, runContext: Dict[str, Any]) -> RunFileLogger | None: """Create filesystem folder + persist CONTEXT_KEY via ``updateRun``.""" - if not graphical_editor_run_file_logging_enabled(): + if not workflowAutomationRunFileLoggingEnabled(): return None - if not automation2_interface or not run_id: + if not workflowAutomationInterface or not runId: return None - subdir = cls.fresh_run_subdirectory_name(run_id) - rel = cls.relative_run_path(subdir) + subdir = cls.freshRunSubdirectoryName(runId) + rel = cls.relativeRunPath(subdir) base = resolve_app_log_dir() absolute = os.path.join(base, RUN_FILE_LOG_RELATIVE_ROOT, subdir) - merged = dict(run_context or {}) + merged = dict(runContext or {}) merged[CONTEXT_KEY] = rel try: - automation2_interface.updateRun(run_id, context=merged) + workflowAutomationInterface.updateRun(runId, context=merged) except Exception as ex: - logger.warning("GeRunFileLog: could not persist log dir on run=%s: %s", run_id, ex) + logger.warning("WaRunFileLog: could not persist log dir on run=%s: %s", runId, ex) return None logger.info( - "GeRunFileLog: created run folder %s (run=%s)", + "WaRunFileLog: created run folder %s (run=%s)", absolute, - run_id, + runId, ) - return cls(run_id, absolute) + return cls(runId, absolute) @classmethod - def open_from_run_record(cls, automation2_interface: Any, run_id: str) -> RunFileLogger | None: + def openFromRunRecord(cls, workflowAutomationInterface: Any, runId: str) -> RunFileLogger | None: """Open logger for an existing run using CONTEXT_KEY from DB.""" - if not graphical_editor_run_file_logging_enabled(): + if not workflowAutomationRunFileLoggingEnabled(): return None - if not automation2_interface or not run_id: + if not workflowAutomationInterface or not runId: return None try: - run = automation2_interface.getRun(run_id) or {} + run = workflowAutomationInterface.getRun(runId) or {} except Exception as ex: - logger.debug("GeRunFileLog: getRun failed run=%s: %s", run_id, ex) + logger.debug("WaRunFileLog: getRun failed run=%s: %s", runId, ex) return None rel = (run.get("context") or {}).get(CONTEXT_KEY) if not rel or not isinstance(rel, str): @@ -126,21 +126,21 @@ class RunFileLogger: cand = os.path.realpath(os.path.join(base_norm, *rel.replace("\\", "/").split("/"))) if cand != allowed_root and not cand.startswith(allowed_root + os.sep): logger.warning( - "GeRunFileLog: path outside log root denied for run=%s rel=%s", - run_id, + "WaRunFileLog: path outside log root denied for run=%s rel=%s", + runId, rel, ) return None absolute = cand - return cls(run_id, absolute) + return cls(runId, absolute) @classmethod - def find_existing_absolute_dir(cls, run_id: str) -> Optional[str]: + def findExistingAbsoluteDir(cls, runId: str) -> Optional[str]: """If a folder named ``*{timestamp}__{run_id}`` exists under the log root, return its absolute path.""" root = os.path.realpath(os.path.join(resolve_app_log_dir(), RUN_FILE_LOG_RELATIVE_ROOT)) if not os.path.isdir(root): return None - suffix = f"__{run_id}" + suffix = f"__{runId}" try: names = sorted((n for n in os.listdir(root) if n.endswith(suffix)), reverse=True) except OSError: @@ -154,62 +154,62 @@ class RunFileLogger: return cand if os.path.isdir(cand) else None @classmethod - def ensure_attached(cls, automation2_interface: Any, run_id: str) -> RunFileLogger | None: - """Open logger from DB, or reattach an on-disk folder for *run_id*, or create a new one.""" - opened = cls.open_from_run_record(automation2_interface, run_id) + def ensureAttached(cls, workflowAutomationInterface: Any, runId: str) -> RunFileLogger | None: + """Open logger from DB, or reattach an on-disk folder for *runId*, or create a new one.""" + opened = cls.openFromRunRecord(workflowAutomationInterface, runId) if opened is not None: return opened - if not graphical_editor_run_file_logging_enabled(): + if not workflowAutomationRunFileLoggingEnabled(): return None - if not automation2_interface or not run_id: + if not workflowAutomationInterface or not runId: return None try: - run = automation2_interface.getRun(run_id) or {} + run = workflowAutomationInterface.getRun(runId) or {} except Exception as ex: - logger.debug("GeRunFileLog: ensure getRun failed run=%s: %s", run_id, ex) + logger.debug("WaRunFileLog: ensure getRun failed run=%s: %s", runId, ex) return None prev_ctx = dict(run.get("context") or {}) - existing_abs = cls.find_existing_absolute_dir(run_id) + existing_abs = cls.findExistingAbsoluteDir(runId) if existing_abs: base_norm = os.path.realpath(resolve_app_log_dir()) rel = os.path.relpath(existing_abs, base_norm).replace(os.sep, "/") merged = {**prev_ctx, CONTEXT_KEY: rel} try: - automation2_interface.updateRun(run_id, context=merged) + workflowAutomationInterface.updateRun(runId, context=merged) except Exception as ex: - logger.warning("GeRunFileLog: reattach persist failed run=%s: %s", run_id, ex) + logger.warning("WaRunFileLog: reattach persist failed run=%s: %s", runId, ex) return None - logger.info("GeRunFileLog: reattached existing folder for run=%s -> %s", run_id, existing_abs) - return cls(run_id, existing_abs) + logger.info("WaRunFileLog: reattached existing folder for run=%s -> %s", runId, existing_abs) + return cls(runId, existing_abs) - subdir = cls.fresh_run_subdirectory_name(run_id) - rel = cls.relative_run_path(subdir) + subdir = cls.freshRunSubdirectoryName(runId) + rel = cls.relativeRunPath(subdir) base = resolve_app_log_dir() absolute = os.path.join(base, RUN_FILE_LOG_RELATIVE_ROOT, subdir) merged = {**prev_ctx, CONTEXT_KEY: rel} try: - automation2_interface.updateRun(run_id, context=merged) + workflowAutomationInterface.updateRun(runId, context=merged) except Exception as ex: - logger.warning("GeRunFileLog: ensure new folder persist failed run=%s: %s", run_id, ex) + logger.warning("WaRunFileLog: ensure new folder persist failed run=%s: %s", runId, ex) return None - logger.info("GeRunFileLog: created late attach folder %s (run=%s)", absolute, run_id) - return cls(run_id, absolute) + logger.info("WaRunFileLog: created late attach folder %s (run=%s)", absolute, runId) + return cls(runId, absolute) - async def append_node_execution_line(self, record: Dict[str, Any]) -> None: + async def appendNodeExecutionLine(self, record: Dict[str, Any]) -> None: line = json.dumps(record, ensure_ascii=False, default=str) async with self._lock: try: with open(self._exec_path, "a", encoding="utf-8") as f: f.write(line + "\n") except Exception as ex: - logger.warning("GeRunFileLog: append execution failed run=%s: %s", self._run_id, ex) + logger.warning("WaRunFileLog: append execution failed run=%s: %s", self._run_id, ex) - async def append_context_snapshot_line(self, record: Dict[str, Any]) -> None: + async def appendContextSnapshotLine(self, record: Dict[str, Any]) -> None: line = json.dumps(record, ensure_ascii=False, default=str) async with self._lock: try: with open(self._ctx_path, "a", encoding="utf-8") as f: f.write(line + "\n") except Exception as ex: - logger.warning("GeRunFileLog: append context snapshot failed run=%s: %s", self._run_id, ex) + logger.warning("WaRunFileLog: append context snapshot failed run=%s: %s", self._run_id, ex) diff --git a/modules/workflowAutomation/helpers.py b/modules/workflowAutomation/helpers.py index 21471e6e..ddbde49e 100644 --- a/modules/workflowAutomation/helpers.py +++ b/modules/workflowAutomation/helpers.py @@ -22,7 +22,7 @@ from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.datamodels.datamodelPagination import PaginationParams, normalize_pagination_dict from modules.datamodels.datamodelWorkflowAutomation import ( AutoRun, AutoStepLog, AutoWorkflow, AutoTask, AutoVersion, - GRAPHICAL_EDITOR_DATABASE, + WORKFLOW_AUTOMATION_DATABASE, ) from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIface from modules.shared.configuration import APP_CONFIG @@ -38,7 +38,7 @@ def _getWorkflowAutomationDb() -> DatabaseConnector: """Get a DatabaseConnector for the WorkflowAutomation (graphicaleditor) DB.""" return DatabaseConnector( dbHost=APP_CONFIG.get("DB_HOST", "localhost"), - dbDatabase=GRAPHICAL_EDITOR_DATABASE, + dbDatabase=WORKFLOW_AUTOMATION_DATABASE, dbUser=APP_CONFIG.get("DB_USER"), dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), diff --git a/modules/workflowAutomation/mainWorkflowAutomation.py b/modules/workflowAutomation/mainWorkflowAutomation.py index 20c1d4fb..e3a38d84 100644 --- a/modules/workflowAutomation/mainWorkflowAutomation.py +++ b/modules/workflowAutomation/mainWorkflowAutomation.py @@ -39,12 +39,12 @@ def _getWorkflowAutomationServices( mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None, workflow=None, -) -> "_WorkflowAutomationServiceHub": +): """ - Get a service hub for WorkflowAutomation using the service center. + Get a ServicesBag for WorkflowAutomation using the service center. Used for methodDiscovery (I/O nodes) and execution (ActionExecutor). """ - from modules.serviceCenter import getService + from modules.serviceCenter import getService, ServicesBag from modules.serviceCenter.context import ServiceCenterContext _workflow = workflow @@ -61,55 +61,7 @@ def _getWorkflowAutomationServices( feature_instance_id=featureInstanceId, workflow=_workflow, ) - - hub = _WorkflowAutomationServiceHub() - hub.user = user - hub.mandateId = mandateId - hub.featureInstanceId = featureInstanceId - hub._service_context = ctx - hub.workflow = _workflow - hub.featureCode = COMPONENT_CODE - - for spec in REQUIRED_SERVICES: - key = spec["serviceKey"] - try: - svc = getService(key, ctx) - setattr(hub, key, svc) - except Exception as e: - logger.warning(f"Could not resolve service '{key}' for workflowAutomation: {e}") - setattr(hub, key, None) - - if hub.chat: - hub.interfaceDbApp = getattr(hub.chat, "interfaceDbApp", None) - hub.interfaceDbComponent = getattr(hub.chat, "interfaceDbComponent", None) - hub.interfaceDbChat = getattr(hub.chat, "interfaceDbChat", None) - hub.rbac = getattr(hub.interfaceDbApp, "rbac", None) if getattr(hub, "interfaceDbApp", None) else None - - return hub - - - - -class _WorkflowAutomationServiceHub: - """Lightweight hub for WorkflowAutomation (methodDiscovery, execution).""" - - user = None - mandateId = None - featureInstanceId = None - _service_context = None - workflow = None - featureCode = COMPONENT_CODE - interfaceDbApp = None - interfaceDbComponent = None - interfaceDbChat = None - rbac = None - chat = None - ai = None - utils = None - extraction = None - sharepoint = None - clickup = None - generation = None + return ServicesBag(ctx, lambda key: getService(key, ctx)) # --------------------------------------------------------------------------- @@ -119,7 +71,7 @@ class _WorkflowAutomationServiceHub: def onMandateDelete(mandateId: str, instances: list) -> None: """Cascade-delete all AutoWorkflow data for this mandate.""" from modules.datamodels.datamodelWorkflowAutomation import ( - GRAPHICAL_EDITOR_DATABASE, AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask, + WORKFLOW_AUTOMATION_DATABASE, AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask, ) from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.shared.configuration import APP_CONFIG @@ -127,7 +79,7 @@ def onMandateDelete(mandateId: str, instances: list) -> None: try: waDb = DatabaseConnector( dbHost=APP_CONFIG.get("DB_HOST", "localhost"), - dbDatabase=GRAPHICAL_EDITOR_DATABASE, + dbDatabase=WORKFLOW_AUTOMATION_DATABASE, dbUser=APP_CONFIG.get("DB_USER"), dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), @@ -245,14 +197,14 @@ def onBootstrap() -> None: _migrateRbacNamespace() _registerAgentTools() - from modules.datamodels.datamodelWorkflowAutomation import GRAPHICAL_EDITOR_DATABASE, AutoWorkflow + from modules.datamodels.datamodelWorkflowAutomation import WORKFLOW_AUTOMATION_DATABASE, AutoWorkflow from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.shared.configuration import APP_CONFIG try: waDb = DatabaseConnector( dbHost=APP_CONFIG.get("DB_HOST", "localhost"), - dbDatabase=GRAPHICAL_EDITOR_DATABASE, + dbDatabase=WORKFLOW_AUTOMATION_DATABASE, dbUser=APP_CONFIG.get("DB_USER"), dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), ) diff --git a/modules/workflowAutomation/scheduler/mainScheduler.py b/modules/workflowAutomation/scheduler/mainScheduler.py index 2f45932e..ec368480 100644 --- a/modules/workflowAutomation/scheduler/mainScheduler.py +++ b/modules/workflowAutomation/scheduler/mainScheduler.py @@ -209,7 +209,7 @@ class WorkflowScheduler: from modules.workflowAutomation.mainWorkflowAutomation import _getWorkflowAutomationServices from modules.workflowAutomation.engine.executionEngine import executeGraph from modules.workflows.processing.shared.methodDiscovery import discoverMethods - from modules.workflowAutomation.editor.entryPoints import find_invocation + from modules.nodeCatalog.entryPoints import findInvocation from modules.workflowAutomation.engine.runEnvelope import default_run_envelope, normalize_run_envelope iface = _getWorkflowAutomationInterface(eventUser, mandateId, instanceId) @@ -221,7 +221,7 @@ class WorkflowScheduler: logger.info("WorkflowScheduler: workflow %s inactive, skipping", workflowId) return - inv = find_invocation(wf, entryPointId) + inv = findInvocation(wf, entryPointId) if inv and (inv.get("kind") != "schedule" or not inv.get("enabled", True)): logger.info("WorkflowScheduler: entry point %s disabled for workflow %s", entryPointId, workflowId) return diff --git a/modules/workflows/methods/methodAi/actions/process.py b/modules/workflows/methods/methodAi/actions/process.py index 04046f39..e3cc10f0 100644 --- a/modules/workflows/methods/methodAi/actions/process.py +++ b/modules/workflows/methods/methodAi/actions/process.py @@ -40,7 +40,7 @@ def _action_docs_to_content_parts(services, docs: List[Any]) -> List[ContentPart from modules.datamodels.datamodelExtraction import ExtractionOptions, MergeStrategy all_parts = [] - extraction = getattr(services, "extraction", None) + extraction = services.extraction if not extraction: logger.warning("ai.process: No extraction service - cannot extract from inline documents") return [] @@ -80,25 +80,24 @@ def _resolve_file_refs_to_content_parts(services, fileIdRefs) -> List[ContentPar via ``getChatDocumentsFromDocumentList`` instead.""" from modules.datamodels.datamodelExtraction import ExtractionOptions, MergeStrategy - mgmt = getattr(services, 'interfaceDbComponent', None) - extraction = getattr(services, 'extraction', None) - if not mgmt or not extraction: - logger.warning("_resolve_file_refs_to_content_parts: missing interfaceDbComponent or extraction service") + extraction = services.extraction + if not extraction: + logger.warning("_resolve_file_refs_to_content_parts: missing extraction service") return [] allParts: List[ContentPart] = [] opts = ExtractionOptions(prompt="", mergeStrategy=MergeStrategy()) for ref in fileIdRefs: fileId = ref.documentId - fileMeta = mgmt.getFile(fileId) + fileMeta = services.chat.getFile(fileId) if not fileMeta: logger.warning("_resolve_file_refs_to_content_parts: file %s not found " "(lookup scope: mandate=%s, featureInstanceId=%s, userId=%s)", - fileId, getattr(mgmt, "mandateId", "?"), - getattr(mgmt, "featureInstanceId", "?"), - getattr(mgmt, "userId", "?")) + fileId, getattr(services, "mandateId", "?"), + getattr(services, "featureInstanceId", "?"), + getattr(services, "userId", "?")) continue - fileData = mgmt.getFileData(fileId) + fileData = services.chat.getFileData(fileId) if not fileData: logger.warning(f"_resolve_file_refs_to_content_parts: no data for file {fileId}") continue @@ -265,7 +264,7 @@ async def process(self, parameters: Dict[str, Any]) -> ActionResult: try: documents = self.services.chat.getChatDocumentsFromDocumentList(documentList) simpleParts = _action_docs_to_content_parts(self.services, [ - {"documentData": self.services.interfaceDbComponent.getFileData(doc.fileId), + {"documentData": self.services.chat.getFileData(doc.fileId), "documentName": getattr(doc, 'fileName', ''), "mimeType": getattr(doc, 'mimeType', 'application/octet-stream')} for doc in documents if hasattr(doc, 'fileId') and doc.fileId diff --git a/modules/workflows/methods/methodAi/actions/webResearch.py b/modules/workflows/methods/methodAi/actions/webResearch.py index 778faf11..b020cff4 100644 --- a/modules/workflows/methods/methodAi/actions/webResearch.py +++ b/modules/workflows/methods/methodAi/actions/webResearch.py @@ -44,8 +44,6 @@ def _build_research_prompt(parameters: Dict[str, Any]) -> str: async def webResearch(self, parameters: Dict[str, Any]) -> ActionResult: - from modules.serviceCenter import ServiceCenterContext, getService, can_access_service - operationId = None try: prompt = _build_research_prompt(parameters) @@ -53,25 +51,10 @@ async def webResearch(self, parameters: Dict[str, Any]) -> ActionResult: return ActionResult.isFailure(error="Research prompt is required") # RBAC: Check service-level permission - rbac = getattr(self.services, "rbac", None) - if rbac and not can_access_service( - self.services.user, - rbac, - "web", - mandate_id=getattr(self.services, "mandateId", None), - feature_instance_id=getattr(self.services, "featureInstanceId", None), - ): + if hasattr(self.services, "canAccessService") and not self.services.canAccessService("web"): return ActionResult.isFailure(error="Permission denied: Web research service") - # Build context for service center - context = ServiceCenterContext( - user=self.services.user, - mandate_id=getattr(self.services, "mandateId", None), - feature_instance_id=getattr(self.services, "featureInstanceId", None), - workflow_id=self.services.workflow.id if self.services.workflow else None, - workflow=self.services.workflow, - ) - web_service = getService("web", context) + web_service = self.services.getService("web") # Init progress logger workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}" diff --git a/modules/workflows/methods/methodBase.py b/modules/workflows/methods/methodBase.py index abc7b9c0..d9a941c5 100644 --- a/modules/workflows/methods/methodBase.py +++ b/modules/workflows/methods/methodBase.py @@ -133,7 +133,7 @@ class MethodBase: return False # Get current user from services.user (not from chat service) - currentUser = getattr(self.services, 'user', None) + currentUser = self.services.user if not currentUser: self.logger.warning(f"No current user found (services.user is None). Action {actionId} will be denied.") return False @@ -141,8 +141,8 @@ class MethodBase: # RBAC-Check: RESOURCE context, item = actionId # mandateId/featureInstanceId from services context needed to resolve user roles try: - mandateId = getattr(self.services, 'mandateId', None) - featureInstanceId = getattr(self.services, 'featureInstanceId', None) + mandateId = self.services.mandateId + featureInstanceId = self.services.featureInstanceId permissions = self.services.rbac.getUserPermissions( user=currentUser, context=AccessRuleContext.RESOURCE, diff --git a/modules/workflows/methods/methodContext/actions/extractContent.py b/modules/workflows/methods/methodContext/actions/extractContent.py index 2c1a2f9c..e1869be3 100644 --- a/modules/workflows/methods/methodContext/actions/extractContent.py +++ b/modules/workflows/methods/methodContext/actions/extractContent.py @@ -1177,6 +1177,7 @@ def _persist_extracted_image_parts( *, name_stem: str, run_context: Optional[Dict[str, Any]], + services=None, ) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: """Decode base64 image parts, persist bytes, replace with ``embeddedImageFileId``; return artifacts meta.""" artifacts: List[Dict[str, Any]] = [] @@ -1193,27 +1194,19 @@ def _persist_extracted_image_parts( ) return content_extracted_serial, artifacts - try: + if services and hasattr(services, "interfaceDbComponent"): + mgmt = services.interfaceDbComponent + else: from modules.interfaces.interfaceDbManagement import getInterface as _get_mgmt - from modules.interfaces.interfaceDbApp import getInterface as _get_app from modules.security.rootAccess import getRootUser - except Exception as exc: - logger.warning("extractContent image persist: import failed: %s", exc) - return content_extracted_serial, artifacts - - owner = getRootUser() - uid = run_context.get("userId") - if uid: try: - umap = _get_app(getRootUser()).getUsersByIds([str(uid)]) - owner = umap.get(str(uid)) or owner - except Exception: - pass + mgmt = _get_mgmt(getRootUser(), mandateId=str(mandate_id), featureInstanceId=str(instance_id)) + except Exception as exc: + logger.warning("extractContent image persist: mgmt interface failed: %s", exc) + return content_extracted_serial, artifacts - try: - mgmt = _get_mgmt(owner, mandateId=str(mandate_id), featureInstanceId=str(instance_id)) - except Exception as exc: - logger.warning("extractContent image persist: mgmt interface failed: %s", exc) + if not mgmt: + logger.warning("extractContent image persist: no interfaceDbComponent available") return content_extracted_serial, artifacts stem = re.sub(r"[^\w\-]+", "_", name_stem).strip("_") or "extract" @@ -1826,6 +1819,7 @@ async def extractContent(self, parameters: Dict[str, Any]) -> ActionResult: content_extracted_serial, name_stem=stem, run_context=run_ctx if isinstance(run_ctx, dict) else None, + services=self.services, ) presentation = build_presentation_for_serial_extractions(content_extracted_serial, file_names, pres_cfg) diff --git a/modules/workflows/methods/methodFile/actions/create.py b/modules/workflows/methods/methodFile/actions/create.py index bb778c8f..973f62d0 100644 --- a/modules/workflows/methods/methodFile/actions/create.py +++ b/modules/workflows/methods/methodFile/actions/create.py @@ -58,22 +58,9 @@ def _persistDocumentsToUserFiles( ) -> None: """Persist file.create output documents to user's file storage (like upload). Adds fileId to each document's validationMetadata for download links in UI.""" - mgmt = getattr(services, "interfaceDbComponent", None) - if not mgmt: - try: - import modules.interfaces.interfaceDbManagement as iface - user = getattr(services, "user", None) - if not user: - return - mgmt = iface.getInterface( - user, - mandateId=getattr(services, "mandateId", None) or "", - featureInstanceId=getattr(services, "featureInstanceId", None) or "", - ) - except Exception as e: - logger.warning("file.create: could not get management interface for persistence: %s", e) - return - if not mgmt: + chat = getattr(services, "chat", None) + if not chat: + logger.warning("file.create: chat service not available for persistence") return for doc in action_documents: try: @@ -97,8 +84,8 @@ def _persistDocumentsToUserFiles( or doc.get("mimeType") or "application/octet-stream" ) - file_item = mgmt.createFile(doc_name, mime, content, folderId=folder_id) - mgmt.createFileData(file_item.id, content) + file_item = chat.createFile(doc_name, mime, content, folderId=folder_id) + chat.createFileData(file_item.id, content) meta = getattr(doc, "validationMetadata", None) or doc.get("validationMetadata") or {} if isinstance(meta, dict): meta["fileId"] = file_item.id @@ -118,23 +105,11 @@ def _sanitize_output_stem(title: str) -> str: def _get_management_interface(services) -> Optional[Any]: - mgmt = getattr(services, "interfaceDbComponent", None) - if mgmt: - return mgmt - try: - import modules.interfaces.interfaceDbManagement as iface - - user = getattr(services, "user", None) - if not user: - return None - return iface.getInterface( - user, - mandateId=getattr(services, "mandateId", None) or "", - featureInstanceId=getattr(services, "featureInstanceId", None) or "", - ) - except Exception as e: - logger.warning("file.create: could not get management interface: %s", e) - return None + """Get chat service for file operations.""" + chat = getattr(services, "chat", None) + if chat: + return chat + return None def _load_image_bytes_from_action_doc(doc: dict, services) -> Optional[bytes]: diff --git a/modules/workflows/methods/methodSharepoint/actions/downloadFileByPath.py b/modules/workflows/methods/methodSharepoint/actions/downloadFileByPath.py index 447d8c08..793e07c9 100644 --- a/modules/workflows/methods/methodSharepoint/actions/downloadFileByPath.py +++ b/modules/workflows/methods/methodSharepoint/actions/downloadFileByPath.py @@ -89,17 +89,15 @@ async def downloadFileByPath(self, parameters: Dict[str, Any]) -> ActionResult: "downloadFileByPath" ) - # Save to user's Files (FileItem + FileData) via interfaceDbComponent – appears in Files UI + # Save to user's Files (FileItem + FileData) via chat service – appears in Files UI fileItem = None - db = getattr(self.services, "interfaceDbComponent", None) - if db: - try: - mimeType = db.getMimeType(filename) if hasattr(db, "getMimeType") else "application/octet-stream" - fileItem = db.createFile(name=filename, mimeType=mimeType, content=fileContent) - db.createFileData(fileItem.id, fileContent) - logger.info(f"Saved SharePoint file to user Files: {filename} (id={fileItem.id})") - except Exception as e: - logger.warning(f"Could not save to user Files: {e}") + try: + mimeType = self.services.chat.getMimeType(filename) + fileItem = self.services.chat.createFile(name=filename, mimeType=mimeType, content=fileContent) + self.services.chat.createFileData(fileItem.id, fileContent) + logger.info(f"Saved SharePoint file to user Files: {filename} (id={fileItem.id})") + except Exception as e: + logger.warning(f"Could not save to user Files: {e}") # Encode as base64 for workflow context (AI, data nodes) fileBase64 = base64.b64encode(fileContent).decode('utf-8') diff --git a/modules/workflows/processing/modes/modeAutomation.py b/modules/workflows/processing/modes/modeAutomation.py index 229bed5b..b6beabd3 100644 --- a/modules/workflows/processing/modes/modeAutomation.py +++ b/modules/workflows/processing/modes/modeAutomation.py @@ -349,7 +349,7 @@ class AutomationMode(BaseMode): workflow = self.services.workflow updateData = {"totalActions": totalActions} workflow.totalActions = totalActions - self.services.interfaceDbChat.updateWorkflow(workflow.id, updateData) + self.services.chat.updateWorkflow(workflow.id, updateData) logger.info(f"Updated workflow {workflow.id} after action planning: {updateData}") except Exception as e: logger.error(f"Error updating workflow after action planning: {str(e)}") @@ -369,7 +369,7 @@ class AutomationMode(BaseMode): updateData["totalActions"] = totalActions if updateData: - self.services.interfaceDbChat.updateWorkflow(workflow.id, updateData) + self.services.chat.updateWorkflow(workflow.id, updateData) logger.info(f"Updated workflow {workflow.id} totals: {updateData}") except Exception as e: logger.error(f"Error setting workflow totals: {str(e)}") diff --git a/modules/workflows/processing/modes/modeBase.py b/modules/workflows/processing/modes/modeBase.py index a8a3e048..684f5d52 100644 --- a/modules/workflows/processing/modes/modeBase.py +++ b/modules/workflows/processing/modes/modeBase.py @@ -67,8 +67,7 @@ class BaseMode(ABC): if "execParameters" not in actionData: actionData["execParameters"] = {} - simpleFields, objectFields = self.services.interfaceDbChat._separateObjectFields(ActionItem, actionData) - createdAction = self.services.interfaceDbChat.db.recordCreate(ActionItem, simpleFields) + createdAction = self.services.chat.createActionItem(actionData) return ActionItem( id=createdAction["id"], @@ -103,7 +102,7 @@ class BaseMode(ABC): workflow.currentTask = taskNumber workflow.currentAction = 0 workflow.totalActions = 0 - self.services.interfaceDbChat.updateWorkflow(workflow.id, updateData) + self.services.chat.updateWorkflow(workflow.id, updateData) logger.info(f"Updated workflow {workflow.id} before executing task {taskNumber}") except Exception as e: logger.error(f"Error updating workflow before executing task: {str(e)}") @@ -114,7 +113,7 @@ class BaseMode(ABC): workflow = self.services.workflow updateData = {"currentAction": actionNumber} workflow.currentAction = actionNumber - self.services.interfaceDbChat.updateWorkflow(workflow.id, updateData) + self.services.chat.updateWorkflow(workflow.id, updateData) logger.info(f"Updated workflow {workflow.id} before executing action {actionNumber}") except Exception as e: logger.error(f"Error updating workflow before executing action: {str(e)}") diff --git a/modules/workflows/processing/workflowProcessor.py b/modules/workflows/processing/workflowProcessor.py index d6fa00f0..5123f934 100644 --- a/modules/workflows/processing/workflowProcessor.py +++ b/modules/workflows/processing/workflowProcessor.py @@ -190,7 +190,7 @@ class WorkflowProcessor: self.workflow.totalActions = 0 # Update in database - self.services.interfaceDbChat.updateWorkflow(self.workflow.id, updateData) + self.services.chat.updateWorkflow(self.workflow.id, updateData) logger.info(f"Updated workflow {self.workflow.id} after task plan creation: {updateData}") except Exception as e: @@ -211,7 +211,7 @@ class WorkflowProcessor: self.workflow.totalActions = 0 # Update in database - self.services.interfaceDbChat.updateWorkflow(self.workflow.id, updateData) + self.services.chat.updateWorkflow(self.workflow.id, updateData) logger.info(f"Updated workflow {self.workflow.id} before executing task {taskNumber}: {updateData}") except Exception as e: @@ -228,7 +228,7 @@ class WorkflowProcessor: self.workflow.totalActions = totalActions # Update in database - self.services.interfaceDbChat.updateWorkflow(self.workflow.id, updateData) + self.services.chat.updateWorkflow(self.workflow.id, updateData) logger.info(f"Updated workflow {self.workflow.id} after action planning: {updateData}") except Exception as e: @@ -245,7 +245,7 @@ class WorkflowProcessor: self.workflow.currentAction = actionNumber # Update in database - self.services.interfaceDbChat.updateWorkflow(self.workflow.id, updateData) + self.services.chat.updateWorkflow(self.workflow.id, updateData) logger.info(f"Updated workflow {self.workflow.id} before executing action {actionNumber}: {updateData}") except Exception as e: @@ -266,7 +266,7 @@ class WorkflowProcessor: # Update workflow object in database if we have changes if updateData: - self.services.interfaceDbChat.updateWorkflow(self.workflow.id, updateData) + self.services.chat.updateWorkflow(self.workflow.id, updateData) logger.info(f"Updated workflow {self.workflow.id} totals in database: {updateData}") logger.debug(f"Updated workflow totals: Tasks {self.workflow.totalTasks if hasattr(self.workflow, 'totalTasks') else 'N/A'}, Actions {self.workflow.totalActions if hasattr(self.workflow, 'totalActions') else 'N/A'}") @@ -290,7 +290,7 @@ class WorkflowProcessor: self.workflow.totalActions = 0 # Update in database - self.services.interfaceDbChat.updateWorkflow(self.workflow.id, updateData) + self.services.chat.updateWorkflow(self.workflow.id, updateData) logger.info(f"Reset workflow {self.workflow.id} for new session: {updateData}") except Exception as e: @@ -636,12 +636,12 @@ class WorkflowProcessor: else: contentBytes = json.dumps(rawData, ensure_ascii=False).encode('utf-8') - fileItem = self.services.interfaceDbComponent.createFile( + fileItem = self.services.chat.createFile( name=actionDoc.documentName if hasattr(actionDoc, 'documentName') else f"task_{taskResult.taskId}_result.txt", mimeType=actionDoc.mimeType if hasattr(actionDoc, 'mimeType') else "text/plain", content=contentBytes ) - self.services.interfaceDbComponent.createFileData( + self.services.chat.createFileData( fileItem.id, contentBytes ) diff --git a/modules/workflows/workflowManager.py b/modules/workflows/workflowManager.py index e983a139..7f06b325 100644 --- a/modules/workflows/workflowManager.py +++ b/modules/workflows/workflowManager.py @@ -118,7 +118,7 @@ class WorkflowManager: "totalTasks": 0, "totalActions": 0, "mandateId": self.services.mandateId, - "featureInstanceId": getattr(self.services, 'featureInstanceId', None), # Feature instance ID for isolation + "featureInstanceId": self.services.featureInstanceId, "messageIds": [], "workflowMode": workflowMode, "maxSteps": 10 , # Set maxSteps @@ -478,12 +478,12 @@ The following is the user's original input message. Analyze intent, normalize th if userInput.prompt: try: originalPromptBytes = userInput.prompt.encode('utf-8') - fileItem = self.services.interfaceDbComponent.createFile( + fileItem = self.services.chat.createFile( name="user_prompt_original.md", mimeType="text/markdown", content=originalPromptBytes ) - self.services.interfaceDbComponent.createFileData(fileItem.id, originalPromptBytes) + self.services.chat.createFileData(fileItem.id, originalPromptBytes) fileInfo = self.services.chat.getFileInfo(fileItem.id) doc = { "fileId": fileItem.id, @@ -544,13 +544,13 @@ The following is the user's original input message. Analyze intent, normalize th for actionDoc in result.documents: if hasattr(actionDoc, 'documentData') and actionDoc.documentData: # Create file in component storage - fileItem = self.services.interfaceDbComponent.createFile( + fileItem = self.services.chat.createFile( name=actionDoc.documentName if hasattr(actionDoc, 'documentName') else "fast_path_response.txt", mimeType=actionDoc.mimeType if hasattr(actionDoc, 'mimeType') else "text/plain", content=actionDoc.documentData if isinstance(actionDoc.documentData, bytes) else actionDoc.documentData.encode('utf-8') ) # Persist file data - self.services.interfaceDbComponent.createFileData(fileItem.id, actionDoc.documentData if isinstance(actionDoc.documentData, bytes) else actionDoc.documentData.encode('utf-8')) + self.services.chat.createFileData(fileItem.id, actionDoc.documentData if isinstance(actionDoc.documentData, bytes) else actionDoc.documentData.encode('utf-8')) # Get file info fileInfo = self.services.chat.getFileInfo(fileItem.id) @@ -667,12 +667,12 @@ The following is the user's original input message. Analyze intent, normalize th if userInput.prompt: try: originalPromptBytes = userInput.prompt.encode('utf-8') - fileItem = self.services.interfaceDbComponent.createFile( + fileItem = self.services.chat.createFile( name="user_prompt_original.md", mimeType="text/markdown", content=originalPromptBytes ) - self.services.interfaceDbComponent.createFileData(fileItem.id, originalPromptBytes) + self.services.chat.createFileData(fileItem.id, originalPromptBytes) fileInfo = self.services.chat.getFileInfo(fileItem.id) doc = { "fileId": fileItem.id, @@ -807,12 +807,12 @@ The following is the user's original input message. Analyze intent, normalize th if userInput.prompt: try: originalPromptBytes = userInput.prompt.encode('utf-8') - fileItem = self.services.interfaceDbComponent.createFile( + fileItem = self.services.chat.createFile( name="user_prompt_original.md", mimeType="text/markdown", content=originalPromptBytes ) - self.services.interfaceDbComponent.createFileData(fileItem.id, originalPromptBytes) + self.services.chat.createFileData(fileItem.id, originalPromptBytes) fileInfo = self.services.chat.getFileInfo(fileItem.id) doc = { "fileId": fileItem.id, diff --git a/tests/eval/runTrusteeBenchmark.py b/tests/eval/runTrusteeBenchmark.py index 749bf996..7622b3d0 100644 --- a/tests/eval/runTrusteeBenchmark.py +++ b/tests/eval/runTrusteeBenchmark.py @@ -409,20 +409,39 @@ def _extractNumbers(text: str) -> List[float]: def _bootstrapServices() -> Tuple[Any, str, str]: - """Spin up a minimal service hub bound to the root user + initial mandate. + """Spin up a minimal services bag bound to the root user + initial mandate. - Returns the ServiceHub, the user id, and the mandate id used for billing. + Returns a services bag, the user id, and the mandate id used for billing. """ from modules.interfaces.interfaceDbApp import getRootInterface from modules.datamodels.datamodelUam import Mandate - from modules.serviceCenter.serviceHub import getInterface as getServices + from modules.serviceCenter import getService + from modules.serviceCenter.context import ServiceCenterContext rootInterface = getRootInterface() user = rootInterface.currentUser mandateId = rootInterface.getInitialId(Mandate) if not mandateId: raise RuntimeError("No initial mandate available -- run bootstrap loader first.") - services = getServices(user, workflow=None, mandateId=mandateId, featureInstanceId=None) + + ctx = ServiceCenterContext(user=user, mandate_id=mandateId) + + class _BenchmarkServicesBag: + def __init__(self, ctx): + self._ctx = ctx + self.user = ctx.user + self.mandateId = ctx.mandate_id + self.featureInstanceId = ctx.feature_instance_id + self.workflow = ctx.workflow + + def __getattr__(self, name): + if name.startswith("_"): + raise AttributeError(name) + svc = getService(name, self._ctx) + setattr(self, name, svc) + return svc + + services = _BenchmarkServicesBag(ctx) return services, user.id, mandateId diff --git a/tests/functional/test01_ai_model_selection.py b/tests/functional/test01_ai_model_selection.py index 7c69b927..4c299a26 100644 --- a/tests/functional/test01_ai_model_selection.py +++ b/tests/functional/test01_ai_model_selection.py @@ -19,7 +19,8 @@ _gateway_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ". if _gateway_path not in sys.path: sys.path.insert(0, _gateway_path) -from modules.serviceCenter.serviceHub import getInterface as getServices +from modules.serviceCenter import getService +from modules.serviceCenter.context import ServiceCenterContext from modules.datamodels.datamodelAi import ( AiCallOptions, AiCallRequest, @@ -33,6 +34,23 @@ from modules.aicore.aicoreModelRegistry import modelRegistry from modules.aicore.aicoreModelSelector import modelSelector +class _TestServicesBag: + """Mutable services bag for tests — lazy-resolves via getService, allows attribute overrides.""" + def __init__(self, ctx): + self._ctx = ctx + self.user = ctx.user + self.mandateId = ctx.mandate_id + self.featureInstanceId = ctx.feature_instance_id + self.workflow = ctx.workflow + + def __getattr__(self, name): + if name.startswith("_"): + raise AttributeError(name) + svc = getService(name, self._ctx) + setattr(self, name, svc) + return svc + + class ModelSelectionTester: def __init__(self) -> None: testUser = User( @@ -43,7 +61,8 @@ class ModelSelectionTester: language="en", mandateId="test_mandate", ) - self.services = getServices(testUser, None) + ctx = ServiceCenterContext(user=testUser) + self.services = _TestServicesBag(ctx) async def initialize(self) -> None: from modules.serviceCenter.services.serviceAi.mainServiceAi import AiService diff --git a/tests/functional/test02_ai_models.py b/tests/functional/test02_ai_models.py index 32aeed80..4569455e 100644 --- a/tests/functional/test02_ai_models.py +++ b/tests/functional/test02_ai_models.py @@ -31,14 +31,31 @@ _gateway_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ". if _gateway_path not in sys.path: sys.path.insert(0, _gateway_path) -# Import the service initialization -from modules.serviceCenter.serviceHub import getInterface as getServices +from modules.serviceCenter import getService +from modules.serviceCenter.context import ServiceCenterContext from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum from modules.datamodels.datamodelUam import User + +class _TestServicesBag: + """Mutable services bag for tests — lazy-resolves via getService, allows attribute overrides.""" + def __init__(self, ctx): + self._ctx = ctx + self.user = ctx.user + self.mandateId = ctx.mandate_id + self.featureInstanceId = ctx.feature_instance_id + self.workflow = ctx.workflow + + def __getattr__(self, name): + if name.startswith("_"): + raise AttributeError(name) + svc = getService(name, self._ctx) + setattr(self, name, svc) + return svc + + class AIModelsTester: def __init__(self): - # Create a minimal user context for testing testUser = User( id="test_user", username="test_user", @@ -48,8 +65,8 @@ class AIModelsTester: mandateId="test_mandate" ) - # Initialize services using the existing system - self.services = getServices(testUser, None) # Test user, no workflow + ctx = ServiceCenterContext(user=testUser) + self.services = _TestServicesBag(ctx) self.testResults = [] # Create logs directory if it doesn't exist (go up 2 levels from tests/unit/services/) diff --git a/tests/functional/test03_ai_operations.py b/tests/functional/test03_ai_operations.py index 835078f0..ee38af8b 100644 --- a/tests/functional/test03_ai_operations.py +++ b/tests/functional/test03_ai_operations.py @@ -20,6 +20,8 @@ if _gateway_path not in sys.path: from modules.datamodels.datamodelAi import OperationTypeEnum from modules.datamodels.datamodelChat import ChatWorkflow, ChatDocument, WorkflowModeEnum from modules.datamodels.datamodelUam import User +from modules.serviceCenter import getService +from modules.serviceCenter.context import ServiceCenterContext class MethodAiOperationsTester: @@ -96,15 +98,27 @@ class MethodAiOperationsTester: import logging logging.getLogger().setLevel(logging.DEBUG) - # Import and initialize services import modules.interfaces.interfaceDbChat as interfaceFeatureAiChat - interfaceDbChat = interfaceDbChat.getInterface(self.testUser) + interfaceDbChat = interfaceFeatureAiChat.getInterface(self.testUser) - # Import and initialize services - from modules.serviceCenter.serviceHub import getInterface as getServices - - # Get services first - self.services = getServices(self.testUser, None) + ctx = ServiceCenterContext(user=self.testUser, mandate_id=self.testMandateId) + + class _TestServicesBag: + def __init__(self, ctx): + self._ctx = ctx + self.user = ctx.user + self.mandateId = ctx.mandate_id + self.featureInstanceId = ctx.feature_instance_id + self.workflow = ctx.workflow + + def __getattr__(self, name): + if name.startswith("_"): + raise AttributeError(name) + svc = getService(name, self._ctx) + setattr(self, name, svc) + return svc + + self.services = _TestServicesBag(ctx) # Now create AND SAVE workflow in database using the interface import uuid diff --git a/tests/functional/test04_ai_behavior.py b/tests/functional/test04_ai_behavior.py index 7845733a..276b9283 100644 --- a/tests/functional/test04_ai_behavior.py +++ b/tests/functional/test04_ai_behavior.py @@ -16,26 +16,40 @@ _gateway_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ". if _gateway_path not in sys.path: sys.path.insert(0, _gateway_path) -# Import the service initialization -from modules.serviceCenter.serviceHub import getInterface as getServices +from modules.serviceCenter import getService +from modules.serviceCenter.context import ServiceCenterContext from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelWorkflow import AiResponse -# The test uses the AI service which handles JSON template internally + +class _TestServicesBag: + """Mutable services bag for tests — lazy-resolves via getService, allows attribute overrides.""" + def __init__(self, ctx): + self._ctx = ctx + self.user = ctx.user + self.mandateId = ctx.mandate_id + self.featureInstanceId = ctx.feature_instance_id + self.workflow = ctx.workflow + + def __getattr__(self, name): + if name.startswith("_"): + raise AttributeError(name) + svc = getService(name, self._ctx) + setattr(self, name, svc) + return svc + class AIBehaviorTester: def __init__(self): - # Use root user for testing (has full access to everything) from modules.interfaces.interfaceDbApp import getRootInterface from modules.datamodels.datamodelUam import Mandate rootInterface = getRootInterface() self.testUser = rootInterface.currentUser - # Get initial mandate ID for testing (User has no mandateId - use initial mandate) self.testMandateId = rootInterface.getInitialId(Mandate) - # Initialize services using the existing system - self.services = getServices(self.testUser, None) # Test user, no workflow + ctx = ServiceCenterContext(user=self.testUser) + self.services = _TestServicesBag(ctx) self.testResults = [] async def initialize(self): diff --git a/tests/unit/workflow/test_extract_content_handover.py b/tests/unit/workflow/test_extract_content_handover.py index 9153f350..8e8f409c 100644 --- a/tests/unit/workflow/test_extract_content_handover.py +++ b/tests/unit/workflow/test_extract_content_handover.py @@ -395,14 +395,14 @@ def test_action_result_contract_new_extract_payload_keys(): def test_automation_workspace_suppresses_extract_artifacts(): - from modules.workflowAutomation.engine.workflowArtifactVisibility import suppress_workflow_file_in_workspace_ui + from modules.shared.workflowArtifactVisibility import suppressWorkflowFileInWorkspaceUi - assert suppress_workflow_file_in_workspace_ui({"fileName": "extracted_content_transient-abc_99.json"}) - assert suppress_workflow_file_in_workspace_ui({"fileName": "extract_media_stem_uuid.png"}) - assert not suppress_workflow_file_in_workspace_ui({"fileName": "export_2026.csv"}) - assert suppress_workflow_file_in_workspace_ui({"fileName": "", "suppressInWorkflowFileLists": True}) - assert suppress_workflow_file_in_workspace_ui({"fileName": "report.pdf", "tags": ["_workflowInternal"]}) - assert not suppress_workflow_file_in_workspace_ui({"fileName": "report.pdf", "tags": ["invoice"]}) + assert suppressWorkflowFileInWorkspaceUi({"fileName": "extracted_content_transient-abc_99.json"}) + assert suppressWorkflowFileInWorkspaceUi({"fileName": "extract_media_stem_uuid.png"}) + assert not suppressWorkflowFileInWorkspaceUi({"fileName": "export_2026.csv"}) + assert suppressWorkflowFileInWorkspaceUi({"fileName": "", "suppressInWorkflowFileLists": True}) + assert suppressWorkflowFileInWorkspaceUi({"fileName": "report.pdf", "tags": ["_workflowInternal"]}) + assert not suppressWorkflowFileInWorkspaceUi({"fileName": "report.pdf", "tags": ["invoice"]}) def test_normalize_presentation_envelopes_action_result_and_list(): From 26dd8f6f3f1fa097c64881d8ee5e77a5b43658ca Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Tue, 9 Jun 2026 07:05:06 +0200 Subject: [PATCH 11/16] cleanup intra referencings in codebase --- app.py | 14 ++ modules/aicore/aicoreModelRegistry.py | 13 +- modules/datamodels/datamodelRbac.py | 16 +- modules/demoConfigs/investorDemo2026.py | 2 +- modules/demoConfigs/pwgDemo2026.py | 2 +- .../features/commcoach/serviceCommcoach.py | 20 +-- .../interfaceFeatureNeutralizer.py | 4 +- .../neutralization/neutralizePlayground.py | 4 +- .../neutralization/routeFeatureNeutralizer.py | 11 +- .../mainServiceNeutralization.py | 4 +- .../features/realEstate/serviceAiIntent.py | 2 +- modules/features/realEstate/serviceBzo.py | 4 +- .../redmine/interfaceFeatureRedmine.py | 137 +++++++++++++++++- .../features/redmine/routeFeatureRedmine.py | 2 +- modules/features/redmine/serviceRedmine.py | 129 +---------------- .../features/redmine/serviceRedmineStats.py | 6 +- .../features/redmine/serviceRedmineSync.py | 4 +- modules/features/teamsbot/service.py | 12 +- modules/features/teamsbot/serviceCommands.py | 8 +- .../workspace/routeFeatureWorkspace.py | 28 ++-- modules/interfaces/interfaceBootstrap.py | 91 +----------- modules/interfaces/interfaceDbApp.py | 98 ++++--------- modules/interfaces/interfaceDbBilling.py | 80 ++++++++++ modules/interfaces/interfaceDbKnowledge.py | 35 ++--- modules/interfaces/interfaceRbac.py | 98 ++++++++++++- modules/routes/routeDataConnections.py | 2 +- modules/routes/routeDataFiles.py | 24 +-- modules/routes/routeRagInventory.py | 6 +- modules/routes/routeVoiceUser.py | 2 +- modules/routes/routeWorkflowAutomation.py | 24 ++- modules/serviceCenter/__init__.py | 2 +- modules/serviceCenter/context.py | 18 +-- .../flagResolution.py} | 0 .../serviceSecurity/mainServiceSecurity.py | 4 +- .../serviceStreaming/mainServiceStreaming.py | 2 +- .../core/serviceUtils/mainServiceUtils.py | 2 +- modules/serviceCenter/core/types.py | 90 ++++++++++++ modules/serviceCenter/resolver.py | 2 +- .../services/serviceAgent/__init__.py | 5 + .../coreTools/_dataSourceTools.py | 2 +- .../coreTools/_featureSubAgentTools.py | 4 +- .../services/serviceAgent/mainServiceAgent.py | 4 +- .../services/serviceAi/mainServiceAi.py | 38 ++--- .../serviceBilling/mainServiceBilling.py | 16 +- .../services/serviceChat/mainServiceChat.py | 20 +-- .../serviceClickup/mainServiceClickup.py | 4 +- .../mainServiceExtraction.py | 58 ++++---- .../subPromptBuilderExtraction.py | 10 +- .../mainServiceGeneration.py | 14 +- .../services/serviceKnowledge/_buildTree.py | 18 +-- .../serviceKnowledge/mainServiceKnowledge.py | 2 +- .../subConnectorIngestConsumer.py | 2 +- .../subConnectorSyncClickup.py | 2 +- .../subConnectorSyncGdrive.py | 2 +- .../serviceKnowledge/subConnectorSyncGmail.py | 2 +- .../subConnectorSyncKdrive.py | 2 +- .../subConnectorSyncOutlook.py | 2 +- .../subConnectorSyncSharepoint.py | 2 +- .../serviceKnowledge/subFeatureBootstrap.py | 10 +- .../services/serviceKnowledge/udbNodes.py | 14 +- .../serviceMessaging/mainServiceMessaging.py | 2 +- .../mainServiceSharepoint.py | 6 +- .../mainServiceSubscription.py | 4 +- .../serviceTicket/mainServiceTicket.py | 2 +- .../services/serviceWeb/mainServiceWeb.py | 86 +++++------ modules/shared/systemComponentRegistry.py | 4 +- .../editor/_valueKindResolver.py | 102 +++++++++++++ .../editor/conditionOperators.py | 102 ++++--------- .../editor/upstreamPathsService.py | 9 +- .../engine/_runNotifications.py | 118 +++++++++++++++ .../engine/executionEngine.py | 11 +- .../workflowAutomation/engine/graphUtils.py | 18 ++- .../engine/pickNotPushMigration.py | 20 +-- .../mainWorkflowAutomation.py | 4 +- .../workflowAutomation/scheduler/__init__.py | 2 + .../scheduler/mainScheduler.py | 98 ++----------- .../shared/promptGenerationActionsDynamic.py | 4 +- .../mandates/test_createMandate.py | 2 +- 78 files changed, 1048 insertions(+), 781 deletions(-) rename modules/serviceCenter/{services/serviceKnowledge/_inheritFlags.py => core/flagResolution.py} (100%) create mode 100644 modules/serviceCenter/core/types.py create mode 100644 modules/workflowAutomation/editor/_valueKindResolver.py create mode 100644 modules/workflowAutomation/engine/_runNotifications.py diff --git a/app.py b/app.py index f76f7083..31085e6b 100644 --- a/app.py +++ b/app.py @@ -318,9 +318,23 @@ async def lifespan(app: FastAPI): onMandateDelete as _waOnMandateDelete, onInstanceCreate as _waOnInstanceCreate, ) + from modules.interfaces.interfaceDbBilling import ( + onMandateDelete as _billingOnMandateDelete, + onMandateProvision as _billingOnMandateProvision, + onStorageChanged as _billingOnStorageChanged, + onUserMandateCreate as _billingOnUserMandateCreate, + onUserMandateDelete as _billingOnUserMandateDelete, + onUserBudgetAdjust as _billingOnUserBudgetAdjust, + ) registerLifecycleHook("onBootstrap", _waOnBootstrap) registerLifecycleHook("onMandateDelete", _waOnMandateDelete) + registerLifecycleHook("onMandateDelete", _billingOnMandateDelete) + registerLifecycleHook("onMandateProvision", _billingOnMandateProvision) + registerLifecycleHook("onStorageChanged", _billingOnStorageChanged) registerLifecycleHook("onInstanceCreate", _waOnInstanceCreate) + registerLifecycleHook("onUserMandateCreate", _billingOnUserMandateCreate) + registerLifecycleHook("onUserMandateDelete", _billingOnUserMandateDelete) + registerLifecycleHook("onUserBudgetAdjust", _billingOnUserBudgetAdjust) # Bootstrap database if needed (creates initial users, mandates, roles, etc.) # This must happen before getting root interface diff --git a/modules/aicore/aicoreModelRegistry.py b/modules/aicore/aicoreModelRegistry.py index 164f71f9..8c57e0c4 100644 --- a/modules/aicore/aicoreModelRegistry.py +++ b/modules/aicore/aicoreModelRegistry.py @@ -10,16 +10,13 @@ import importlib import os import time import threading -from typing import Dict, List, Optional, Any, Tuple, TYPE_CHECKING +from typing import Dict, List, Optional, Any, Tuple from modules.datamodels.datamodelAi import AiModel -from modules.datamodels.datamodelRbac import AccessRuleContext +from modules.datamodels.datamodelRbac import AccessRuleContext, RbacProtocol from .aicoreBase import BaseConnectorAi from modules.datamodels.datamodelUam import User from modules.connectors.connectorDbPostgre import DatabaseConnector -if TYPE_CHECKING: - from modules.security.rbac import RbacClass - logger = logging.getLogger(__name__) # TODO TESTING: Override maxTokens for all models during testing @@ -188,7 +185,7 @@ class ModelRegistry: def getAvailableModels( self, currentUser: Optional[User] = None, - rbacInstance: Optional["RbacClass"] = None, + rbacInstance: Optional[RbacProtocol] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None ) -> List[AiModel]: @@ -239,7 +236,7 @@ class ModelRegistry: self, models: List[AiModel], currentUser: User, - rbacInstance: "RbacClass", + rbacInstance: RbacProtocol, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None ) -> List[AiModel]: @@ -264,7 +261,7 @@ class ModelRegistry: logger.debug(f"User {currentUser.username} does not have access to model {model.displayName} (connector: {model.connectorType})") return filteredModels - def getModel(self, displayName: str, currentUser: Optional[User] = None, rbacInstance: Optional["RbacClass"] = None) -> Optional[AiModel]: + def getModel(self, displayName: str, currentUser: Optional[User] = None, rbacInstance: Optional[RbacProtocol] = None) -> Optional[AiModel]: """Get a specific model by displayName, optionally checking RBAC permissions. Args: diff --git a/modules/datamodels/datamodelRbac.py b/modules/datamodels/datamodelRbac.py index 83a4525d..7ea9d710 100644 --- a/modules/datamodels/datamodelRbac.py +++ b/modules/datamodels/datamodelRbac.py @@ -10,7 +10,7 @@ Multi-Tenant Design: """ import uuid -from typing import Optional +from typing import Optional, Dict, List, Protocol, runtime_checkable from enum import Enum from pydantic import BaseModel, Field from modules.datamodels.datamodelBase import PowerOnModel @@ -174,6 +174,20 @@ class AccessRule(PowerOnModel): ) +@runtime_checkable +class RbacProtocol(Protocol): + """Structural type for RBAC checkers — allows aicore (L3) to reference + the RBAC contract without importing from security (L4).""" + + def checkResourceAccessBulk( + self, + user: "User", + resourcePaths: List[str], + mandateId: Optional[str] = None, + featureInstanceId: Optional[str] = None, + ) -> Dict[str, bool]: ... + + # IMMUTABLE Fields Definition - für Enforcement auf Application-Level IMMUTABLE_FIELDS = { "Role": ["mandateId", "featureInstanceId", "featureCode"], diff --git a/modules/demoConfigs/investorDemo2026.py b/modules/demoConfigs/investorDemo2026.py index 84fc5e01..e88ce6c7 100644 --- a/modules/demoConfigs/investorDemo2026.py +++ b/modules/demoConfigs/investorDemo2026.py @@ -169,7 +169,7 @@ class InvestorDemo2026(BaseDemoConfig): def _ensureMandate(self, db, mandateDef: Dict, summary: Dict) -> Optional[str]: from modules.datamodels.datamodelUam import Mandate - from modules.interfaces.interfaceBootstrap import copySystemRolesToMandate + from modules.interfaces.interfaceRbac import copySystemRolesToMandate existing = db.getRecordset(Mandate, recordFilter={"name": mandateDef["name"]}) if existing: diff --git a/modules/demoConfigs/pwgDemo2026.py b/modules/demoConfigs/pwgDemo2026.py index 2d301f7a..90e3c3e4 100644 --- a/modules/demoConfigs/pwgDemo2026.py +++ b/modules/demoConfigs/pwgDemo2026.py @@ -154,7 +154,7 @@ class PwgDemo2026(BaseDemoConfig): def _ensureMandate(self, db, mandateDef: Dict, summary: Dict) -> Optional[str]: from modules.datamodels.datamodelUam import Mandate - from modules.interfaces.interfaceBootstrap import copySystemRolesToMandate + from modules.interfaces.interfaceRbac import copySystemRolesToMandate existing = db.getRecordset(Mandate, recordFilter={"name": mandateDef["name"]}) if existing: diff --git a/modules/features/commcoach/serviceCommcoach.py b/modules/features/commcoach/serviceCommcoach.py index d7a79d1f..3aa0c1a0 100644 --- a/modules/features/commcoach/serviceCommcoach.py +++ b/modules/features/commcoach/serviceCommcoach.py @@ -597,8 +597,8 @@ def _createCommcoachRagFn( from modules.serviceCenter.context import ServiceCenterContext serviceContext = ServiceCenterContext( user=currentUser, - mandate_id=mandateId, - feature_instance_id=featureInstanceId, + mandateId=mandateId, + featureInstanceId=featureInstanceId, ) knowledgeService = getService("knowledge", serviceContext) ragContext = await knowledgeService.buildAgentContext( @@ -902,8 +902,8 @@ class CommcoachService: serviceContext = ServiceCenterContext( user=self.currentUser, - mandate_id=self.mandateId, - feature_instance_id=self.instanceId, + mandateId=self.mandateId, + featureInstanceId=self.instanceId, ) agentService = getService("agent", serviceContext) @@ -1240,8 +1240,8 @@ class CommcoachService: serviceContext = ServiceCenterContext( user=self.currentUser, - mandate_id=self.mandateId, - feature_instance_id=self.instanceId, + mandateId=self.mandateId, + featureInstanceId=self.instanceId, ) knowledgeService = getService("knowledge", serviceContext) parsedGoals = aiPrompts._parseJsonField(context.get("goals") if context else None, []) @@ -1535,8 +1535,8 @@ class CommcoachService: serviceContext = ServiceCenterContext( user=self.currentUser, - mandate_id=self.mandateId, - feature_instance_id=self.instanceId, + mandateId=self.mandateId, + featureInstanceId=self.instanceId, ) aiService = getService("ai", serviceContext) await aiService.ensureAiObjectsInitialized() @@ -1561,8 +1561,8 @@ class CommcoachService: serviceContext = ServiceCenterContext( user=self.currentUser, - mandate_id=self.mandateId, - feature_instance_id=self.instanceId, + mandateId=self.mandateId, + featureInstanceId=self.instanceId, ) aiService = getService("ai", serviceContext) await aiService.ensureAiObjectsInitialized() diff --git a/modules/features/neutralization/interfaceFeatureNeutralizer.py b/modules/features/neutralization/interfaceFeatureNeutralizer.py index 3d5c9129..97a466ff 100644 --- a/modules/features/neutralization/interfaceFeatureNeutralizer.py +++ b/modules/features/neutralization/interfaceFeatureNeutralizer.py @@ -309,8 +309,8 @@ class InterfaceFeatureNeutralizer: ) -> Optional[DataNeutralizerAttributes]: """Create a neutralization attribute for placeholder resolution.""" try: - mandate_id = self.mandateId or "" - feature_instance_id = self.featureInstanceId or "" + mandateId = self.mandateId or "" + featureInstanceId = self.featureInstanceId or "" if not self.userId: logger.warning("Cannot create attribute: missing userId") return None diff --git a/modules/features/neutralization/neutralizePlayground.py b/modules/features/neutralization/neutralizePlayground.py index e855ad22..1a46cd25 100644 --- a/modules/features/neutralization/neutralizePlayground.py +++ b/modules/features/neutralization/neutralizePlayground.py @@ -22,7 +22,7 @@ class NeutralizationPlayground: self.currentUser = currentUser self.mandateId = mandateId self.featureInstanceId = featureInstanceId - self._ctx = ServiceCenterContext(user=currentUser, mandate_id=mandateId, feature_instance_id=featureInstanceId) + self._ctx = ServiceCenterContext(user=currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId) def _getService(self, name: str): return getService(name, self._ctx) @@ -258,7 +258,7 @@ class SharepointProcessor: self._sharepoint = getService("sharepoint", ctx) self._neutralization = getService("neutralization", ctx) from modules.interfaces.interfaceDbApp import getInterface as _getAppInterface - self._interfaceDbApp = _getAppInterface(currentUser, mandateId=ctx.mandate_id) + self._interfaceDbApp = _getAppInterface(currentUser, mandateId=ctx.mandateId) async def processSharepointFiles(self, sourcePath: str, targetPath: str) -> Dict[str, Any]: try: diff --git a/modules/features/neutralization/routeFeatureNeutralizer.py b/modules/features/neutralization/routeFeatureNeutralizer.py index bf396e3b..488ef352 100644 --- a/modules/features/neutralization/routeFeatureNeutralizer.py +++ b/modules/features/neutralization/routeFeatureNeutralizer.py @@ -58,18 +58,17 @@ def get_neutralization_config( ) -> DataNeutraliserConfig: """Get data neutralization configuration""" try: - mandate_id = str(context.mandateId) if context.mandateId else "" - feature_instance_id = str(context.featureInstanceId) if context.featureInstanceId else "" + mandateId = str(context.mandateId) if context.mandateId else "" + featureInstanceId = str(context.featureInstanceId) if context.featureInstanceId else "" service = NeutralizationPlayground( - context.user, mandate_id, featureInstanceId=feature_instance_id or None + context.user, mandateId, featureInstanceId=featureInstanceId or None ) config = service.getConfig() if not config: - # Return default config instead of 404 (requires mandateId and featureInstanceId for instance-scoped config) return DataNeutraliserConfig( - mandateId=mandate_id, - featureInstanceId=feature_instance_id, + mandateId=mandateId, + featureInstanceId=featureInstanceId, userId=context.user.id, enabled=True, namesToParse="", diff --git a/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py b/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py index 4cfec864..0388dbba 100644 --- a/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py +++ b/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py @@ -64,8 +64,8 @@ class NeutralizationService: elif serviceCenter and getattr(serviceCenter, "user", None): self.interfaceNeutralizer = getNeutralizerInterface( currentUser=serviceCenter.user, - mandateId=getattr(serviceCenter, 'mandateId', None) or getattr(serviceCenter, 'mandate_id', None), - featureInstanceId=getattr(serviceCenter, 'featureInstanceId', None) or getattr(serviceCenter, 'feature_instance_id', None), + mandateId=getattr(serviceCenter, 'mandateId', None), + featureInstanceId=getattr(serviceCenter, 'featureInstanceId', None), ) namesList = NamesToParse if isinstance(NamesToParse, list) else [] diff --git a/modules/features/realEstate/serviceAiIntent.py b/modules/features/realEstate/serviceAiIntent.py index ca53c98e..d790d7c8 100644 --- a/modules/features/realEstate/serviceAiIntent.py +++ b/modules/features/realEstate/serviceAiIntent.py @@ -232,7 +232,7 @@ async def processNaturalLanguageCommand( logger.info(f"Processing natural language command for user {currentUser.id} (mandate: {mandateId})") logger.debug(f"User input: {userInput}") - ctx = ServiceCenterContext(user=currentUser, mandate_id=mandateId) + ctx = ServiceCenterContext(user=currentUser, mandateId=mandateId) aiService = getService("ai", ctx) intentAnalysis = await analyzeUserIntent(aiService, userInput) diff --git a/modules/features/realEstate/serviceBzo.py b/modules/features/realEstate/serviceBzo.py index 178c8021..f4ec90bd 100644 --- a/modules/features/realEstate/serviceBzo.py +++ b/modules/features/realEstate/serviceBzo.py @@ -234,7 +234,7 @@ async def extract_bzo_information( bzo_params_result = None try: - ctx = ServiceCenterContext(user=currentUser, mandate_id=_mandateId, feature_instance_id=featureInstanceId) + ctx = ServiceCenterContext(user=currentUser, mandateId=_mandateId, featureInstanceId=featureInstanceId) ai_service = getService("ai", ctx) bzo_params_result = await run_bzo_params_extraction( extracted_content=all_extracted_content, @@ -520,7 +520,7 @@ async def generate_bauzone_ai_summary( AI-generated summary string """ try: - ctx = ServiceCenterContext(user=currentUser, mandate_id=mandateId, feature_instance_id=featureInstanceId) + ctx = ServiceCenterContext(user=currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId) aiService = getService("ai", ctx) context_parts = [] diff --git a/modules/features/redmine/interfaceFeatureRedmine.py b/modules/features/redmine/interfaceFeatureRedmine.py index 88855501..225b5a31 100644 --- a/modules/features/redmine/interfaceFeatureRedmine.py +++ b/modules/features/redmine/interfaceFeatureRedmine.py @@ -13,7 +13,7 @@ from __future__ import annotations import logging import time -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional, Tuple from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.connectors.connectorTicketsRedmine import ConnectorTicketsRedmine @@ -21,6 +21,9 @@ from modules.datamodels.datamodelUam import User from modules.features.redmine.datamodelRedmine import ( RedmineConfigDto, RedmineConfigUpdateRequest, + RedmineCustomFieldSchemaDto, + RedmineFieldChoiceDto, + RedmineFieldSchemaDto, RedmineInstanceConfig, RedmineRelationMirror, RedmineTicketMirror, @@ -447,3 +450,135 @@ def getInterface( featureInstanceId=effectiveFeatureInstanceId, ) return _redmineInterfaces[contextKey] + + +# --------------------------------------------------------------------------- +# Project meta -- with TTL cache stored on the config record +# --------------------------------------------------------------------------- + +class RedmineNotConfiguredError(RuntimeError): + """The given feature instance has no usable Redmine config.""" + + +def _resolveRootTrackerId( + rootTrackerName: str, trackers: List[Dict[str, Any]] +) -> Optional[int]: + """Resolve the configured root tracker name to a tracker id. + + Strict: case-insensitive exact match. Returns ``None`` if not found + (the UI must surface this as a config error). + """ + target = (rootTrackerName or "").strip().lower() + if not target: + return None + for t in trackers: + if str(t.get("name") or "").strip().lower() == target: + tid = t.get("id") + return int(tid) if tid is not None else None + return None + + +def _schemaFromCache( + projectId: str, cache: Optional[Dict[str, Any]], rootTrackerName: str +) -> Optional[RedmineFieldSchemaDto]: + if not cache: + return None + trackers = cache.get("trackers") or [] + return RedmineFieldSchemaDto( + projectId=projectId, + projectName=str(cache.get("projectName") or ""), + trackers=[RedmineFieldChoiceDto(**t) for t in trackers], + statuses=[RedmineFieldChoiceDto(**s) for s in cache.get("statuses") or []], + priorities=[RedmineFieldChoiceDto(**p) for p in cache.get("priorities") or []], + users=[RedmineFieldChoiceDto(**u) for u in cache.get("users") or []], + categories=[RedmineFieldChoiceDto(**c) for c in cache.get("categories") or []], + customFields=[ + RedmineCustomFieldSchemaDto( + id=cf.get("id"), + name=cf.get("name", ""), + fieldFormat=cf.get("fieldFormat", "string"), + isRequired=bool(cf.get("isRequired")), + possibleValues=list(cf.get("possibleValues") or []), + multiple=bool(cf.get("multiple")), + defaultValue=cf.get("defaultValue"), + ) + for cf in cache.get("customFields") or [] + if cf.get("id") is not None + ], + rootTrackerName=rootTrackerName, + rootTrackerId=_resolveRootTrackerId(rootTrackerName, trackers), + ) + + +async def getProjectMeta( + currentUser: User, + mandateId: Optional[str], + featureInstanceId: str, + *, + forceRefresh: bool = False, +) -> RedmineFieldSchemaDto: + """Fetch (or return cached) project metadata: trackers, statuses, priorities, etc.""" + iface = getInterface(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId) + connector = iface.resolveConnector(featureInstanceId) + if not connector: + raise RedmineNotConfiguredError( + f"Redmine instance {featureInstanceId} is not configured or inactive" + ) + cfg = iface.getConfig(featureInstanceId) + if cfg is None: + raise RedmineNotConfiguredError("Config row vanished after connector resolve") + + ttl = cfg.schemaCacheTtlSeconds if cfg.schemaCacheTtlSeconds is not None else 24 * 60 * 60 + fresh_enough = ( + cfg.schemaCache + and cfg.schemaCachedAt + and (time.time() - cfg.schemaCachedAt) < ttl + ) + if fresh_enough and not forceRefresh: + schema = _schemaFromCache(cfg.projectId, cfg.schemaCache, cfg.rootTrackerName) + if schema is not None: + return schema + + project_info = await connector.getProjectInfo() + trackers_raw = await connector.getTrackers() + statuses_raw = await connector.getStatuses() + priorities_raw = await connector.getPriorities() + custom_fields_raw = await connector.getCustomFields() + users_raw = await connector.getProjectUsers() + categories_raw = await connector.getIssueCategories() + + schema_cache: Dict[str, Any] = { + "projectName": project_info.get("name", ""), + "trackers": [{"id": t.get("id"), "name": t.get("name")} for t in trackers_raw], + "statuses": [ + { + "id": s.get("id"), + "name": s.get("name"), + "isClosed": bool(s.get("is_closed")), + } + for s in statuses_raw + ], + "priorities": [{"id": p.get("id"), "name": p.get("name")} for p in priorities_raw], + "users": [{"id": u.get("id"), "name": u.get("name")} for u in users_raw], + "categories": [{"id": c.get("id"), "name": c.get("name")} for c in categories_raw if c.get("id") is not None], + "customFields": [ + { + "id": cf.get("id"), + "name": cf.get("name"), + "fieldFormat": cf.get("field_format", "string"), + "isRequired": bool(cf.get("is_required")), + "possibleValues": [pv.get("value") for pv in (cf.get("possible_values") or []) if pv.get("value") is not None], + "multiple": bool(cf.get("multiple")), + "defaultValue": cf.get("default_value"), + } + for cf in custom_fields_raw + ], + } + iface.updateSchemaCache(featureInstanceId, schema_cache) + iface.markConfigConnected(featureInstanceId) + + return _schemaFromCache(cfg.projectId, schema_cache, cfg.rootTrackerName) or RedmineFieldSchemaDto( + projectId=cfg.projectId, + projectName=schema_cache["projectName"], + rootTrackerName=cfg.rootTrackerName, + ) diff --git a/modules/features/redmine/routeFeatureRedmine.py b/modules/features/redmine/routeFeatureRedmine.py index d973e690..bdc8797b 100644 --- a/modules/features/redmine/routeFeatureRedmine.py +++ b/modules/features/redmine/routeFeatureRedmine.py @@ -32,7 +32,7 @@ from modules.features.redmine.datamodelRedmine import ( RedmineTicketDto, RedmineTicketUpdateRequest, ) -from modules.features.redmine.serviceRedmine import RedmineNotConfiguredError +from modules.features.redmine.interfaceFeatureRedmine import RedmineNotConfiguredError from modules.connectors.connectorTicketsRedmine import RedmineApiError from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceFeatures import getFeatureInterface diff --git a/modules/features/redmine/serviceRedmine.py b/modules/features/redmine/serviceRedmine.py index b4a3d137..d772478b 100644 --- a/modules/features/redmine/serviceRedmine.py +++ b/modules/features/redmine/serviceRedmine.py @@ -25,7 +25,6 @@ workflow engine without context-magic. from __future__ import annotations import logging -import time from datetime import datetime from typing import Any, Dict, List, Optional, Tuple @@ -35,9 +34,7 @@ from modules.connectors.connectorTicketsRedmine import ( ) from modules.datamodels.datamodelUam import User from modules.features.redmine.datamodelRedmine import ( - RedmineCustomFieldSchemaDto, RedmineCustomFieldValueDto, - RedmineFieldChoiceDto, RedmineFieldSchemaDto, RedmineRelationCreateRequest, RedmineRelationDto, @@ -46,8 +43,10 @@ from modules.features.redmine.datamodelRedmine import ( RedmineTicketUpdateRequest, ) from modules.features.redmine.interfaceFeatureRedmine import ( + RedmineNotConfiguredError, RedmineObjects, getInterface, + getProjectMeta, ) from modules.features.redmine.serviceRedmineStatsCache import getStatsCache @@ -58,9 +57,6 @@ logger = logging.getLogger(__name__) # Resolution helpers # --------------------------------------------------------------------------- -class RedmineNotConfiguredError(RuntimeError): - """The given feature instance has no usable Redmine config.""" - def _resolveContext( currentUser: User, mandateId: Optional[str], featureInstanceId: str @@ -74,127 +70,6 @@ def _resolveContext( return iface, connector -# --------------------------------------------------------------------------- -# Project meta -- with TTL cache stored on the config record -# --------------------------------------------------------------------------- - -async def getProjectMeta( - currentUser: User, - mandateId: Optional[str], - featureInstanceId: str, - *, - forceRefresh: bool = False, -) -> RedmineFieldSchemaDto: - iface, connector = _resolveContext(currentUser, mandateId, featureInstanceId) - cfg = iface.getConfig(featureInstanceId) - if cfg is None: - raise RedmineNotConfiguredError("Config row vanished after connector resolve") - - ttl = cfg.schemaCacheTtlSeconds if cfg.schemaCacheTtlSeconds is not None else 24 * 60 * 60 - fresh_enough = ( - cfg.schemaCache - and cfg.schemaCachedAt - and (time.time() - cfg.schemaCachedAt) < ttl - ) - if fresh_enough and not forceRefresh: - schema = _schemaFromCache(cfg.projectId, cfg.schemaCache, cfg.rootTrackerName) - if schema is not None: - return schema - - project_info = await connector.getProjectInfo() - trackers_raw = await connector.getTrackers() - statuses_raw = await connector.getStatuses() - priorities_raw = await connector.getPriorities() - custom_fields_raw = await connector.getCustomFields() - users_raw = await connector.getProjectUsers() - categories_raw = await connector.getIssueCategories() - - schema_cache: Dict[str, Any] = { - "projectName": project_info.get("name", ""), - "trackers": [{"id": t.get("id"), "name": t.get("name")} for t in trackers_raw], - "statuses": [ - { - "id": s.get("id"), - "name": s.get("name"), - "isClosed": bool(s.get("is_closed")), - } - for s in statuses_raw - ], - "priorities": [{"id": p.get("id"), "name": p.get("name")} for p in priorities_raw], - "users": [{"id": u.get("id"), "name": u.get("name")} for u in users_raw], - "categories": [{"id": c.get("id"), "name": c.get("name")} for c in categories_raw if c.get("id") is not None], - "customFields": [ - { - "id": cf.get("id"), - "name": cf.get("name"), - "fieldFormat": cf.get("field_format", "string"), - "isRequired": bool(cf.get("is_required")), - "possibleValues": [pv.get("value") for pv in (cf.get("possible_values") or []) if pv.get("value") is not None], - "multiple": bool(cf.get("multiple")), - "defaultValue": cf.get("default_value"), - } - for cf in custom_fields_raw - ], - } - iface.updateSchemaCache(featureInstanceId, schema_cache) - iface.markConfigConnected(featureInstanceId) - - return _schemaFromCache(cfg.projectId, schema_cache, cfg.rootTrackerName) or RedmineFieldSchemaDto( - projectId=cfg.projectId, - projectName=schema_cache["projectName"], - rootTrackerName=cfg.rootTrackerName, - ) - - -def _resolveRootTrackerId( - rootTrackerName: str, trackers: List[Dict[str, Any]] -) -> Optional[int]: - """Resolve the configured root tracker name to a tracker id. - - Strict: case-insensitive exact match. Returns ``None`` if not found - (the UI must surface this as a config error). - """ - target = (rootTrackerName or "").strip().lower() - if not target: - return None - for t in trackers: - if str(t.get("name") or "").strip().lower() == target: - tid = t.get("id") - return int(tid) if tid is not None else None - return None - - -def _schemaFromCache( - projectId: str, cache: Optional[Dict[str, Any]], rootTrackerName: str -) -> Optional[RedmineFieldSchemaDto]: - if not cache: - return None - trackers = cache.get("trackers") or [] - return RedmineFieldSchemaDto( - projectId=projectId, - projectName=str(cache.get("projectName") or ""), - trackers=[RedmineFieldChoiceDto(**t) for t in trackers], - statuses=[RedmineFieldChoiceDto(**s) for s in cache.get("statuses") or []], - priorities=[RedmineFieldChoiceDto(**p) for p in cache.get("priorities") or []], - users=[RedmineFieldChoiceDto(**u) for u in cache.get("users") or []], - categories=[RedmineFieldChoiceDto(**c) for c in cache.get("categories") or []], - customFields=[ - RedmineCustomFieldSchemaDto( - id=cf.get("id"), - name=cf.get("name", ""), - fieldFormat=cf.get("fieldFormat", "string"), - isRequired=bool(cf.get("isRequired")), - possibleValues=list(cf.get("possibleValues") or []), - multiple=bool(cf.get("multiple")), - defaultValue=cf.get("defaultValue"), - ) - for cf in cache.get("customFields") or [] - if cf.get("id") is not None - ], - rootTrackerName=rootTrackerName, - rootTrackerId=_resolveRootTrackerId(rootTrackerName, trackers), - ) - # --------------------------------------------------------------------------- # Mirror -> RedmineTicketDto diff --git a/modules/features/redmine/serviceRedmineStats.py b/modules/features/redmine/serviceRedmineStats.py index 1c289181..8566db16 100644 --- a/modules/features/redmine/serviceRedmineStats.py +++ b/modules/features/redmine/serviceRedmineStats.py @@ -83,10 +83,8 @@ async def getStats( # Lazy import: keeps the pure aggregation helpers below importable # without dragging in aiohttp / DB connector at module load. - from modules.features.redmine.serviceRedmine import ( - getProjectMeta, - listTickets, - ) + from modules.features.redmine.interfaceFeatureRedmine import getProjectMeta + from modules.features.redmine.serviceRedmine import listTickets schema = await getProjectMeta(currentUser, mandateId, featureInstanceId) root_tracker_id = schema.rootTrackerId diff --git a/modules/features/redmine/serviceRedmineSync.py b/modules/features/redmine/serviceRedmineSync.py index 37507973..a56198f1 100644 --- a/modules/features/redmine/serviceRedmineSync.py +++ b/modules/features/redmine/serviceRedmineSync.py @@ -38,7 +38,7 @@ from modules.features.redmine.datamodelRedmine import ( RedmineSyncStatusDto, RedmineTicketMirror, ) -from modules.features.redmine.interfaceFeatureRedmine import getInterface +from modules.features.redmine.interfaceFeatureRedmine import getInterface, getProjectMeta from modules.features.redmine.serviceRedmineStatsCache import getStatsCache logger = logging.getLogger(__name__) @@ -281,8 +281,6 @@ async def _ensureSchemaWarm( statuses = (cfg.schemaCache or {}).get("statuses") or [] if statuses: return - # Lazy import to avoid a circular dependency at module load. - from modules.features.redmine.serviceRedmine import getProjectMeta try: await getProjectMeta(currentUser, mandateId, featureInstanceId, forceRefresh=True) except Exception as e: diff --git a/modules/features/teamsbot/service.py b/modules/features/teamsbot/service.py index 2bafd0e2..2487ad81 100644 --- a/modules/features/teamsbot/service.py +++ b/modules/features/teamsbot/service.py @@ -405,9 +405,9 @@ def createAiService(user, mandateId, featureInstanceId=None): """Create a properly wired AiService via the service center.""" ctx = ServiceCenterContext( user=user, - mandate_id=mandateId, - feature_instance_id=featureInstanceId, - feature_code="teamsbot", + mandateId=mandateId, + featureInstanceId=featureInstanceId, + featureCode="teamsbot", ) return _getServiceCenterService("ai", ctx) @@ -1320,9 +1320,9 @@ class TeamsbotService: ctx = ServiceCenterContext( user=self.currentUser, - mandate_id=self.mandateId, - feature_instance_id=self.instanceId, - feature_code="teamsbot", + mandateId=self.mandateId, + featureInstanceId=self.instanceId, + featureCode="teamsbot", ) agentService = _getServiceCenterService("agent", ctx) diff --git a/modules/features/teamsbot/serviceCommands.py b/modules/features/teamsbot/serviceCommands.py index 55f16bf0..a8ce763f 100644 --- a/modules/features/teamsbot/serviceCommands.py +++ b/modules/features/teamsbot/serviceCommands.py @@ -247,8 +247,8 @@ async def _cmdSendMail(service, sessionId: str, params: dict): from modules.serviceCenter import ServiceCenterContext, getService ctx = ServiceCenterContext( user=service.currentUser, - mandate_id=service.mandateId, - feature_instance_id=service.instanceId, + mandateId=service.mandateId, + featureInstanceId=service.instanceId, ) messaging = getService("messaging", ctx) success = messaging.sendEmailDirect( @@ -280,8 +280,8 @@ async def _cmdStoreDocument(service, sessionId: str, params: dict): from modules.serviceCenter import ServiceCenterContext, getService ctx = ServiceCenterContext( user=service.currentUser, - mandate_id=service.mandateId, - feature_instance_id=service.instanceId, + mandateId=service.mandateId, + featureInstanceId=service.instanceId, ) sharepoint = getService("sharepoint", ctx) if not sharepoint.setAccessTokenFromConnection(service.currentUser): diff --git a/modules/features/workspace/routeFeatureWorkspace.py b/modules/features/workspace/routeFeatureWorkspace.py index 6d83e234..fedda841 100644 --- a/modules/features/workspace/routeFeatureWorkspace.py +++ b/modules/features/workspace/routeFeatureWorkspace.py @@ -566,10 +566,10 @@ async def streamWorkspaceStart( wsBillingFeatureCode = _workspaceBillingFeatureCode(context.user, mandateId or "", instanceId) svcCtx = ServiceCenterContext( user=context.user, - mandate_id=mandateId or "", - feature_instance_id=instanceId, - workflow_id=workflowId, - feature_code=wsBillingFeatureCode, + mandateId=mandateId or "", + featureInstanceId=instanceId, + workflowId=workflowId, + featureCode=wsBillingFeatureCode, ) chatSvc = getService("chat", svcCtx) attachmentLabel = _buildWorkspaceAttachmentLabel( @@ -687,10 +687,10 @@ async def _runWorkspaceAgent( from modules.serviceCenter.context import ServiceCenterContext ctx = ServiceCenterContext( user=user, - mandate_id=mandateId, - feature_instance_id=instanceId, - workflow_id=workflowId, - feature_code=billingFeatureCode, + mandateId=mandateId, + featureInstanceId=instanceId, + workflowId=workflowId, + featureCode=billingFeatureCode, ) agentService = getService("agent", ctx) chatService = getService("chat", ctx) @@ -1299,7 +1299,7 @@ async def listWorkspaceDataSources( try: from modules.datamodels.datamodelDataSource import DataSource from modules.interfaces.interfaceDbApp import getRootInterface - from modules.serviceCenter.services.serviceKnowledge._inheritFlags import buildEffectiveByConnection + from modules.serviceCenter.core.flagResolution import buildEffectiveByConnection rootIf = getRootInterface() recordFilter: dict = {"featureInstanceId": instanceId} if wsMandateId: @@ -1352,8 +1352,8 @@ async def createWorkspaceDataSource( from modules.serviceCenter.context import ServiceCenterContext ctx = ServiceCenterContext( user=context.user, - mandate_id=_mandateId or "", - feature_instance_id=instanceId, + mandateId=_mandateId or "", + featureInstanceId=instanceId, ) chatService = getService("chat", ctx) dataSource = chatService.createDataSource( @@ -1381,8 +1381,8 @@ async def deleteWorkspaceDataSource( from modules.serviceCenter.context import ServiceCenterContext ctx = ServiceCenterContext( user=context.user, - mandate_id=_mandateId or "", - feature_instance_id=instanceId, + mandateId=_mandateId or "", + featureInstanceId=instanceId, ) chatService = getService("chat", ctx) chatService.deleteDataSource(dataSourceId) @@ -1464,7 +1464,7 @@ async def listFeatureDataSources( wsMandateId, _ = _validateInstanceAccess(instanceId, context) from modules.interfaces.interfaceDbApp import getRootInterface from modules.datamodels.datamodelFeatures import FeatureDataSource - from modules.serviceCenter.services.serviceKnowledge._inheritFlags import buildEffectiveByWorkspaceFds + from modules.serviceCenter.core.flagResolution import buildEffectiveByWorkspaceFds rootIf = getRootInterface() recordFilter: dict = {} diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py index 4764cd4a..19ff4e26 100644 --- a/modules/interfaces/interfaceBootstrap.py +++ b/modules/interfaces/interfaceBootstrap.py @@ -11,7 +11,6 @@ Multi-Tenant Design: """ import logging -import uuid from typing import Optional, Dict from passlib.context import CryptContext from modules.connectors.connectorDbPostgre import DatabaseConnector @@ -521,6 +520,8 @@ def _ensureAllMandatesHaveSystemRoles(db: DatabaseConnector) -> None: Ensure all existing mandates have system-instance roles. Serves as both initial setup and migration for existing mandates. """ + from modules.interfaces.interfaceRbac import copySystemRolesToMandate + allMandates = db.getRecordset(Mandate) if not allMandates: logger.info("No mandates found, skipping system role copy") @@ -534,94 +535,6 @@ def _ensureAllMandatesHaveSystemRoles(db: DatabaseConnector) -> None: logger.info(f"Copied {copiedCount} system roles to mandate {mandateId}") -def copySystemRolesToMandate(db: DatabaseConnector, mandateId: str) -> int: - """ - Copy system template roles (mandateId=None, isSystemRole=True) to a mandate - as mandate-instance roles. Also copies all AccessRules for each role. - - This is analogous to how feature template roles are copied to feature instances. - Each mandate gets its own instances of admin/user/viewer with their AccessRules. - - Args: - db: Database connector instance - mandateId: Target mandate ID - - Returns: - Number of roles copied - """ - # Find system template roles (global: mandateId=NULL, isSystemRole=True) - templateRoles = db.getRecordset( - Role, - recordFilter={"isSystemRole": True, "mandateId": None} - ) - - if not templateRoles: - logger.warning(f"No system template roles found (mandateId IS NULL, isSystemRole=True)") - return 0 - - # Check which mandate-level roles already exist for this mandate - existingMandateRoles = db.getRecordset( - Role, - recordFilter={"mandateId": mandateId, "featureInstanceId": None} - ) - existingLabels = {r.get("roleLabel") for r in existingMandateRoles} - logger.info(f"copySystemRolesToMandate: mandate={mandateId}, templates={len(templateRoles)}, existing={len(existingMandateRoles)}, labels={existingLabels}") - - # Load all AccessRules for template roles - templateRoleIds = [r.get("id") for r in templateRoles] - rulesByRoleId = {} - for roleId in templateRoleIds: - rules = db.getRecordset(AccessRule, recordFilter={"roleId": roleId}) - rulesByRoleId[roleId] = rules - - copiedCount = 0 - for templateRole in templateRoles: - roleLabel = templateRole.get("roleLabel") - - # Skip if mandate already has this role - if roleLabel in existingLabels: - logger.debug(f"Mandate {mandateId} already has role '{roleLabel}', skipping") - continue - - newRoleId = str(uuid.uuid4()) - - # Create mandate-instance role - newRole = Role( - id=newRoleId, - roleLabel=roleLabel, - description=coerce_text_multilingual(templateRole.get("description", {})), - mandateId=mandateId, - featureInstanceId=None, - featureCode=None, - isSystemRole=False # Mandate-level role, not a system template - ) - db.recordCreate(Role, newRole.model_dump()) - - # Copy AccessRules - templateRules = rulesByRoleId.get(templateRole.get("id"), []) - for rule in templateRules: - newRule = AccessRule( - id=str(uuid.uuid4()), - roleId=newRoleId, - context=rule.get("context"), - item=rule.get("item"), - view=rule.get("view", False), - read=rule.get("read"), - create=rule.get("create"), - update=rule.get("update"), - delete=rule.get("delete") - ) - db.recordCreate(AccessRule, newRule.model_dump()) - - copiedCount += 1 - logger.info(f"Copied system role '{roleLabel}' to mandate {mandateId} with {len(templateRules)} AccessRules") - - if copiedCount > 0: - logger.info(f"Copied {copiedCount} system roles to mandate {mandateId}") - - return copiedCount - - def _getRoleId(db: DatabaseConnector, roleLabel: str) -> Optional[str]: """ Get role ID by label, using cache or database lookup. diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py index d5fb2e49..13c7ead6 100644 --- a/modules/interfaces/interfaceDbApp.py +++ b/modules/interfaces/interfaceDbApp.py @@ -1560,7 +1560,7 @@ class AppObjects: # Copy system template roles to new mandate (admin, user, viewer + AccessRules) try: - from modules.interfaces.interfaceBootstrap import copySystemRolesToMandate + 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: @@ -1577,7 +1577,7 @@ class AppObjects: """ from modules.datamodels.datamodelSubscription import MandateSubscription, SubscriptionStatusEnum, BUILTIN_PLANS from modules.datamodels.datamodelFeatures import FeatureInstance - from modules.interfaces.interfaceBootstrap import copySystemRolesToMandate + from modules.interfaces.interfaceRbac import copySystemRolesToMandate from modules.interfaces.interfaceFeatures import getFeatureInterface from modules.shared.featureDiscovery import loadFeatureMainModules plan = BUILTIN_PLANS.get(planKey) @@ -1615,7 +1615,7 @@ class AppObjects: raise ValueError(f"No admin role found for mandate {mandateId} — cannot assign user without role") from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot - from modules.interfaces.interfaceDbBilling import getRootInterface as _getBillingRoot + from modules.shared.systemComponentRegistry import getLifecycleHooks as _getHooks now = datetime.now(timezone.utc) nowTs = now.timestamp() @@ -1635,17 +1635,11 @@ class AppObjects: subInterface = _getSubRoot() subInterface.createSubscription(subscription) - try: - billingRoot = _getBillingRoot() - billingRoot.getOrCreateSettings(mandateId) - billingRoot.ensureActivationBudget(mandateId, planKey) - except Exception as billingEx: - logger.error( - "Initial billing setup failed for mandate %s (plan=%s): %s", - mandateId, - planKey, - billingEx, - ) + for _hook in _getHooks("onMandateProvision"): + try: + _hook(mandateId, planKey) + except Exception as _hookErr: + logger.error("onMandateProvision hook failed: %s", _hookErr) self.createUserMandate(userId, mandateId, roleIds=[adminRoleId], skipCapacityCheck=True) @@ -1865,7 +1859,6 @@ class AppObjects: from modules.datamodels.datamodelDataSource import DataSource from modules.datamodels.datamodelKnowledge import FileContentIndex, ContentChunk from modules.datamodels.datamodelFeatures import FeatureDataSource - from modules.datamodels.datamodelBilling import BillingSettings, BillingAccount, BillingTransaction from modules.datamodels.datamodelFeatures import DataNeutralizerAttributes instances = self.db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId}) @@ -1987,20 +1980,7 @@ class AppObjects: subInterface.db.recordDelete(MandateSubscription, subId) logger.info(f"Cascade: deleted {len(subs)} subscriptions for mandate {mandateId}") - # 3b. Delete Billing data (poweron_billing) - from modules.interfaces.interfaceDbBilling import getRootInterface as _getBillingRoot - billingDb = _getBillingRoot().db - billingAccounts = billingDb.getRecordset(BillingAccount, recordFilter={"mandateId": mandateId}) - for acc in billingAccounts: - accTxs = billingDb.getRecordset(BillingTransaction, recordFilter={"accountId": acc.get("id")}) - for tx in accTxs: - billingDb.recordDelete(BillingTransaction, tx.get("id")) - billingDb.recordDelete(BillingAccount, acc.get("id")) - billingSettings = billingDb.getRecordset(BillingSettings, recordFilter={"mandateId": mandateId}) - for bs in billingSettings: - billingDb.recordDelete(BillingSettings, bs.get("id")) - if billingAccounts or billingSettings: - logger.info(f"Cascade: deleted billing data for mandate {mandateId}") + # 3b. Billing data cascade handled by onMandateDelete lifecycle hook (interfaceDbBilling) # 3c. Delete Invitations for this mandate from modules.datamodels.datamodelInvitation import Invitation @@ -2155,10 +2135,20 @@ class AppObjects: ) self.db.recordCreate(UserMandateRole, userMandateRole.model_dump()) - self._ensureUserBillingAccount(userId, mandateId) + from modules.shared.systemComponentRegistry import getLifecycleHooks + for _hook in getLifecycleHooks("onUserMandateCreate"): + try: + _hook(userId, mandateId) + except Exception as _hookErr: + logger.warning("onUserMandateCreate hook failed: %s", _hookErr) + self._syncSubscriptionQuantity(mandateId) if not skipCapacityCheck: - self._adjustAiBudgetForUserChange(mandateId, delta=+1) + for _hook in getLifecycleHooks("onUserBudgetAdjust"): + try: + _hook(mandateId, +1) + except Exception as _hookErr: + logger.warning("onUserBudgetAdjust hook failed: %s", _hookErr) cleanedRecord = dict(createdRecord) return UserMandate(**cleanedRecord) @@ -2167,26 +2157,6 @@ class AppObjects: raise logger.error(f"Error creating UserMandate: {e}") raise ValueError(f"Failed to create UserMandate: {e}") from e - - def _ensureUserBillingAccount(self, userId: str, mandateId: str) -> None: - """ - Ensure a user has a billing audit account for the mandate. - Balance is always on the mandate pool (PREPAY_MANDATE). User accounts are for audit trail only. - """ - try: - from modules.interfaces.interfaceDbBilling import getRootInterface as getBillingRootInterface - - billingInterface = getBillingRootInterface() - settings = billingInterface.getSettings(mandateId) - - if not settings: - return - - billingInterface.getOrCreateUserAccount(mandateId, userId, initialBalance=0.0) - logger.info(f"Ensured billing audit account for user {userId} in mandate {mandateId}") - - except Exception as e: - logger.warning(f"Failed to create billing account for user {userId} (non-critical): {e}") def _checkSubscriptionCapacity(self, mandateId: str, resourceType: str, delta: int = 1) -> None: """Check subscription capacity before creating a resource. Raises on cap violation.""" @@ -2222,23 +2192,6 @@ class AppObjects: raise logger.debug(f"Subscription quantity sync skipped: {e}") - def _adjustAiBudgetForUserChange(self, mandateId: str, delta: int) -> None: - """Pro-rata AI budget credit/debit when a user is added or removed mid-cycle.""" - try: - from modules.interfaces.interfaceDbSubscription import getInterface as getSubInterface - from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface - from modules.security.rootAccess import getRootUser - rootUser = getRootUser() - subIf = getSubInterface(rootUser, mandateId) - operative = subIf.getOperativeForMandate(mandateId) - if not operative: - return - planKey = operative.get("planKey", "") - billingIf = getBillingInterface(rootUser) - billingIf.adjustAiBudgetForUserChange(mandateId, planKey, delta) - except Exception as e: - logger.debug(f"AI budget adjustment skipped: {e}") - def deleteUserMandate(self, userId: str, mandateId: str) -> bool: """ Delete a UserMandate record (remove user from mandate). @@ -2278,7 +2231,14 @@ class AppObjects: result = self.db.recordDelete(UserMandate, existing.id) self._syncSubscriptionQuantity(mandateId) - self._adjustAiBudgetForUserChange(mandateId, delta=-1) + + from modules.shared.systemComponentRegistry import getLifecycleHooks + for _hook in getLifecycleHooks("onUserMandateDelete"): + try: + _hook(userId, mandateId) + except Exception as _hookErr: + logger.warning("onUserMandateDelete hook failed: %s", _hookErr) + return result except Exception as e: logger.error(f"Error deleting UserMandate: {e}") diff --git a/modules/interfaces/interfaceDbBilling.py b/modules/interfaces/interfaceDbBilling.py index d51813d8..84cc748e 100644 --- a/modules/interfaces/interfaceDbBilling.py +++ b/modules/interfaces/interfaceDbBilling.py @@ -2144,3 +2144,83 @@ class BillingObjects: # Sort by creation date descending and limit _sortBillingTransactionsBySysCreatedAtDesc(allTransactions, "getUserTransactionsForMandates") return allTransactions[:limit] + + def deleteMandateData(self, mandateId: str) -> None: + """Delete all billing data for a mandate (accounts, transactions, settings). + + Used as cascade during mandate hard-delete via the onMandateDelete lifecycle hook. + """ + billingAccounts = self.db.getRecordset(BillingAccount, recordFilter={"mandateId": mandateId}) + for acc in billingAccounts: + accTxs = self.db.getRecordset(BillingTransaction, recordFilter={"accountId": acc.get("id")}) + for tx in accTxs: + self.db.recordDelete(BillingTransaction, tx.get("id")) + self.db.recordDelete(BillingAccount, acc.get("id")) + billingSettings = self.db.getRecordset(BillingSettings, recordFilter={"mandateId": mandateId}) + for bs in billingSettings: + self.db.recordDelete(BillingSettings, bs.get("id")) + if billingAccounts or billingSettings: + logger.info("deleteMandateData: deleted billing data for mandate %s", mandateId) + + +def onMandateDelete(mandateId: str, instances: list) -> None: + """Lifecycle hook: cascade-delete billing data when a mandate is hard-deleted.""" + getRootInterface().deleteMandateData(mandateId) + + +def onUserMandateCreate(userId: str, mandateId: str) -> None: + """Lifecycle hook: ensure user has a billing audit account when added to a mandate.""" + try: + billingInterface = getRootInterface() + settings = billingInterface.getSettings(mandateId) + if not settings: + return + billingInterface.getOrCreateUserAccount(mandateId, userId, initialBalance=0.0) + logger.info("Ensured billing audit account for user %s in mandate %s", userId, mandateId) + except Exception as e: + logger.warning("Failed to create billing account for user %s (non-critical): %s", userId, e) + + +def onUserMandateDelete(userId: str, mandateId: str) -> None: + """Lifecycle hook: pro-rata AI budget debit when user is removed from a mandate.""" + _adjustAiBudgetForUserChange(mandateId, delta=-1) + + +def onUserBudgetAdjust(mandateId: str, delta: int) -> None: + """Lifecycle hook: pro-rata AI budget credit/debit for user membership changes.""" + _adjustAiBudgetForUserChange(mandateId, delta) + + +def onMandateProvision(mandateId: str, planKey: str) -> None: + """Lifecycle hook: create billing settings and activation budget for a new mandate.""" + try: + billingRoot = getRootInterface() + billingRoot.getOrCreateSettings(mandateId) + billingRoot.ensureActivationBudget(mandateId, planKey) + except Exception as e: + logger.error("Initial billing setup failed for mandate %s (plan=%s): %s", mandateId, planKey, e) + + +def onStorageChanged(mandateId: str) -> None: + """Lifecycle hook: reconcile storage billing after knowledge content changes.""" + try: + getRootInterface().reconcileMandateStorageBilling(mandateId) + except Exception as e: + logger.warning("reconcileMandateStorageBilling failed for mandate %s: %s", mandateId, e) + + +def _adjustAiBudgetForUserChange(mandateId: str, delta: int) -> None: + """Pro-rata AI budget credit/debit when a user is added or removed mid-cycle.""" + try: + from modules.interfaces.interfaceDbSubscription import getInterface as getSubInterface + from modules.security.rootAccess import getRootUser + rootUser = getRootUser() + subIf = getSubInterface(rootUser, mandateId) + operative = subIf.getOperativeForMandate(mandateId) + if not operative: + return + planKey = operative.get("planKey", "") + billingIf = getInterface(rootUser) + billingIf.adjustAiBudgetForUserChange(mandateId, planKey, delta) + except Exception as e: + logger.debug("AI budget adjustment skipped: %s", e) diff --git a/modules/interfaces/interfaceDbKnowledge.py b/modules/interfaces/interfaceDbKnowledge.py index e979bbd3..20d66dc2 100644 --- a/modules/interfaces/interfaceDbKnowledge.py +++ b/modules/interfaces/interfaceDbKnowledge.py @@ -123,13 +123,13 @@ class KnowledgeObjects: if mid: mandateIds.add(str(mid)) + from modules.shared.systemComponentRegistry import getLifecycleHooks for mid in mandateIds: - try: - from modules.interfaces.interfaceDbBilling import getRootInterface as getBillingRoot - - getBillingRoot().reconcileMandateStorageBilling(mid) - except Exception as ex: - logger.warning("reconcileMandateStorageBilling after connection purge failed: %s", ex) + for _hook in getLifecycleHooks("onStorageChanged"): + try: + _hook(mid) + except Exception as ex: + logger.warning("onStorageChanged hook after connection purge failed: %s", ex) return {"indexRows": indexCount, "chunks": chunkCount} @@ -166,12 +166,13 @@ class KnowledgeObjects: if mid: mandateIds.add(str(mid)) + from modules.shared.systemComponentRegistry import getLifecycleHooks for mid in mandateIds: - try: - from modules.interfaces.interfaceDbBilling import getRootInterface as getBillingRoot - getBillingRoot().reconcileMandateStorageBilling(mid) - except Exception as ex: - logger.warning("reconcileMandateStorageBilling after datasource purge failed: %s", ex) + for _hook in getLifecycleHooks("onStorageChanged"): + try: + _hook(mid) + except Exception as ex: + logger.warning("onStorageChanged hook after datasource purge failed: %s", ex) return {"indexRows": indexCount, "chunks": chunkCount} @@ -196,12 +197,12 @@ class KnowledgeObjects: self.db.recordDelete(ContentChunk, chunk["id"]) ok = self.db.recordDelete(FileContentIndex, fileId) if ok and mandateId: - try: - from modules.interfaces.interfaceDbBilling import getRootInterface - - getRootInterface().reconcileMandateStorageBilling(str(mandateId)) - except Exception as ex: - logger.warning("reconcileMandateStorageBilling after delete failed: %s", ex) + from modules.shared.systemComponentRegistry import getLifecycleHooks + for _hook in getLifecycleHooks("onStorageChanged"): + try: + _hook(str(mandateId)) + except Exception as ex: + logger.warning("onStorageChanged hook after delete failed: %s", ex) return ok # ========================================================================= diff --git a/modules/interfaces/interfaceRbac.py b/modules/interfaces/interfaceRbac.py index 16429acb..5c09942a 100644 --- a/modules/interfaces/interfaceRbac.py +++ b/modules/interfaces/interfaceRbac.py @@ -27,12 +27,15 @@ import json import math import re import copy +import uuid from datetime import datetime, timezone from typing import List, Dict, Any, Optional, Type, Union from pydantic import BaseModel -from modules.datamodels.datamodelRbac import AccessRuleContext +from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext, Role from modules.datamodels.datamodelUam import User, UserPermissions, AccessLevel from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResult +from modules.datamodels.datamodelUtils import coerce_text_multilingual +from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.security.rbac import RbacClass from modules.security.rootAccess import getRootDbAppConnector @@ -1123,3 +1126,96 @@ def _checkRowPermission( # Unknown level - deny by default return False + + +# ============================================================================= +# System Role Provisioning +# ============================================================================= + + +def copySystemRolesToMandate(db: DatabaseConnector, mandateId: str) -> int: + """ + Copy system template roles (mandateId=None, isSystemRole=True) to a mandate + as mandate-instance roles. Also copies all AccessRules for each role. + + This is analogous to how feature template roles are copied to feature instances. + Each mandate gets its own instances of admin/user/viewer with their AccessRules. + + Args: + db: Database connector instance + mandateId: Target mandate ID + + Returns: + Number of roles copied + """ + templateRoles = db.getRecordset( + Role, + recordFilter={"isSystemRole": True, "mandateId": None} + ) + + if not templateRoles: + logger.warning("No system template roles found (mandateId IS NULL, isSystemRole=True)") + return 0 + + existingMandateRoles = db.getRecordset( + Role, + recordFilter={"mandateId": mandateId, "featureInstanceId": None} + ) + existingLabels = {r.get("roleLabel") for r in existingMandateRoles} + logger.info( + "copySystemRolesToMandate: mandate=%s, templates=%s, existing=%s, labels=%s", + mandateId, len(templateRoles), len(existingMandateRoles), existingLabels, + ) + + templateRoleIds = [r.get("id") for r in templateRoles] + rulesByRoleId = {} + for roleId in templateRoleIds: + rules = db.getRecordset(AccessRule, recordFilter={"roleId": roleId}) + rulesByRoleId[roleId] = rules + + copiedCount = 0 + for templateRole in templateRoles: + roleLabel = templateRole.get("roleLabel") + + if roleLabel in existingLabels: + logger.debug("Mandate %s already has role '%s', skipping", mandateId, roleLabel) + continue + + newRoleId = str(uuid.uuid4()) + + newRole = Role( + id=newRoleId, + roleLabel=roleLabel, + description=coerce_text_multilingual(templateRole.get("description", {})), + mandateId=mandateId, + featureInstanceId=None, + featureCode=None, + isSystemRole=False, + ) + db.recordCreate(Role, newRole.model_dump()) + + templateRules = rulesByRoleId.get(templateRole.get("id"), []) + for rule in templateRules: + newRule = AccessRule( + id=str(uuid.uuid4()), + roleId=newRoleId, + context=rule.get("context"), + item=rule.get("item"), + view=rule.get("view", False), + read=rule.get("read"), + create=rule.get("create"), + update=rule.get("update"), + delete=rule.get("delete"), + ) + db.recordCreate(AccessRule, newRule.model_dump()) + + copiedCount += 1 + logger.info( + "Copied system role '%s' to mandate %s with %s AccessRules", + roleLabel, mandateId, len(templateRules), + ) + + if copiedCount > 0: + logger.info("Copied %s system roles to mandate %s", copiedCount, mandateId) + + return copiedCount diff --git a/modules/routes/routeDataConnections.py b/modules/routes/routeDataConnections.py index 5de77a9b..7a327d16 100644 --- a/modules/routes/routeDataConnections.py +++ b/modules/routes/routeDataConnections.py @@ -798,7 +798,7 @@ async def _updateKnowledgeConsent( cancelled = cancelJobsByConnection(connectionId) else: from modules.datamodels.datamodelDataSource import DataSource - from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlag + from modules.serviceCenter.core.flagResolution import getEffectiveFlag allConnDs = rootIf.db.getRecordset(DataSource, recordFilter={"connectionId": connectionId}) dataSources = [ ds for ds in (allConnDs or []) diff --git a/modules/routes/routeDataFiles.py b/modules/routes/routeDataFiles.py index 52a4b98a..a7a4e34b 100644 --- a/modules/routes/routeDataFiles.py +++ b/modules/routes/routeDataFiles.py @@ -98,17 +98,17 @@ async def _autoIndexFile(fileId: str, fileName: str, mimeType: str, user, *, man return file_meta = mgmtInterface.getFile(fileId) - feature_instance_id = "" - mandate_id = "" + featureInstanceId = "" + mandateId = "" file_scope = "personal" if file_meta: if isinstance(file_meta, dict): - feature_instance_id = file_meta.get("featureInstanceId") or "" - mandate_id = file_meta.get("mandateId") or "" + featureInstanceId = file_meta.get("featureInstanceId") or "" + mandateId = file_meta.get("mandateId") or "" file_scope = file_meta.get("scope") or "personal" else: - feature_instance_id = getattr(file_meta, "featureInstanceId", None) or "" - mandate_id = getattr(file_meta, "mandateId", None) or "" + featureInstanceId = getattr(file_meta, "featureInstanceId", None) or "" + mandateId = getattr(file_meta, "mandateId", None) or "" file_scope = getattr(file_meta, "scope", None) or "personal" logger.info(f"Auto-index starting for {fileName} ({len(rawBytes)} bytes, {mimeType})") @@ -121,8 +121,8 @@ async def _autoIndexFile(fileId: str, fileName: str, mimeType: str, user, *, man fileId=fileId, fileName=fileName, userId=userId, - featureInstanceId=str(feature_instance_id) if feature_instance_id else "", - mandateId=str(mandate_id) if mandate_id else "", + featureInstanceId=str(featureInstanceId) if featureInstanceId else "", + mandateId=str(mandateId) if mandateId else "", scope=file_scope, ) logger.info( @@ -208,8 +208,8 @@ async def _autoIndexFile(fileId: str, fileName: str, mimeType: str, user, *, man ctx = ServiceCenterContext( user=user, - mandate_id=str(mandate_id) if mandate_id else "", - feature_instance_id=str(feature_instance_id) if feature_instance_id else "", + mandateId=str(mandateId) if mandateId else "", + featureInstanceId=str(featureInstanceId) if featureInstanceId else "", ) knowledgeService = getService("knowledge", ctx) @@ -222,8 +222,8 @@ async def _autoIndexFile(fileId: str, fileName: str, mimeType: str, user, *, man fileName=fileName, mimeType=mimeType, userId=userId, - featureInstanceId=str(feature_instance_id) if feature_instance_id else "", - mandateId=str(mandate_id) if mandate_id else "", + featureInstanceId=str(featureInstanceId) if featureInstanceId else "", + mandateId=str(mandateId) if mandateId else "", contentObjects=contentObjects, structure=contentIndex.structure, provenance={"lane": "upload", "route": "routeDataFiles._autoIndexFile"}, diff --git a/modules/routes/routeRagInventory.py b/modules/routes/routeRagInventory.py index f7219c60..419ddec1 100644 --- a/modules/routes/routeRagInventory.py +++ b/modules/routes/routeRagInventory.py @@ -86,7 +86,7 @@ def _buildConnectionInventory(connections, rootIf, knowledgeIf, jobService) -> L """ from modules.datamodels.datamodelDataSource import DataSource from modules.datamodels.datamodelKnowledge import FileContentIndex - from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlag + from modules.serviceCenter.core.flagResolution import getEffectiveFlag out = [] for conn in connections: @@ -236,7 +236,7 @@ def _buildFeatureInstanceInventory(featureInstanceIds, rootIf, knowledgeIf) -> L from modules.datamodels.datamodelKnowledge import FileContentIndex from modules.datamodels.datamodelFeatures import FeatureDataSource from modules.interfaces.interfaceFeatures import getFeatureInterface - from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlagFds + from modules.serviceCenter.core.flagResolution import getEffectiveFlagFds from modules.serviceCenter.services.serviceBackgroundJobs import mainBackgroundJobService as jobService from modules.serviceCenter.services.serviceKnowledge.subFeatureBootstrap import FEATURE_BOOTSTRAP_JOB_TYPE @@ -548,7 +548,7 @@ async def _reindexConnection( if str(conn.userId) != str(currentUser.id): raise HTTPException(status_code=403, detail="Not your connection") - from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlag + from modules.serviceCenter.core.flagResolution import getEffectiveFlag dataSources = rootIf.db.getRecordset(DataSource, recordFilter={"connectionId": connectionId}) ragDs = [ds for ds in dataSources if getEffectiveFlag(ds, "ragIndexEnabled", dataSources, mode="walk") is True] if not ragDs: diff --git a/modules/routes/routeVoiceUser.py b/modules/routes/routeVoiceUser.py index 7ddfbed4..ce14afe0 100644 --- a/modules/routes/routeVoiceUser.py +++ b/modules/routes/routeVoiceUser.py @@ -251,7 +251,7 @@ async def _generateTtsSampleTextForLocale( from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionInactiveException mandateId = _resolveMandateIdForVoiceTestAi(request, currentUser) - ctx = ServiceCenterContext(user=currentUser, mandate_id=mandateId, feature_instance_id=None) + ctx = ServiceCenterContext(user=currentUser, mandateId=mandateId, featureInstanceId=None) aiService = getService("ai", ctx) systemPrompt = ( diff --git a/modules/routes/routeWorkflowAutomation.py b/modules/routes/routeWorkflowAutomation.py index 8a9dd587..afd4aaa0 100644 --- a/modules/routes/routeWorkflowAutomation.py +++ b/modules/routes/routeWorkflowAutomation.py @@ -596,8 +596,8 @@ def _buildServiceCenterContext(context: RequestContext, mandateId: str, instance from modules.serviceCenter.context import ServiceCenterContext return ServiceCenterContext( user=context.user, - mandate_id=str(context.mandateId) if context.mandateId else mandateId, - feature_instance_id=instanceId, + mandateId=str(context.mandateId) if context.mandateId else mandateId, + featureInstanceId=instanceId, ) @@ -1366,6 +1366,21 @@ def _buildExecuteRunEnvelope( return env +def _startEmailPollerIfNeeded(result: dict) -> None: + """Start the background email poller when a run pauses for email wait.""" + 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 + if eventUser: + ensureRunning(eventUser) + except Exception as pollErr: + logger.warning("Could not start email poller: %s", pollErr) + + @router.post("/workflows/{workflowId}/execute") @limiter.limit("30/minute") async def _executeWorkflow( @@ -1446,6 +1461,7 @@ async def _executeWorkflow( "workflowAutomation execute result: success=%s error=%s paused=%s", result.get("success"), result.get("error"), result.get("paused"), ) + _startEmailPollerIfNeeded(result) return result @@ -1778,7 +1794,7 @@ async def _completeTask( graph = wfForGraph["graph"] services = _getWorkflowAutomationServices(context.user, mandateId=mandateId, featureInstanceId=instanceId) - return await executeGraph( + result = await executeGraph( graph=graph, services=services, workflowId=workflowId, @@ -1790,6 +1806,8 @@ async def _completeTask( startAfterNodeId=taskNodeId, runId=runId, ) + _startEmailPollerIfNeeded(result) + return result @router.post("/tasks/{taskId}/cancel") diff --git a/modules/serviceCenter/__init__.py b/modules/serviceCenter/__init__.py index a2590fc6..968b8acf 100644 --- a/modules/serviceCenter/__init__.py +++ b/modules/serviceCenter/__init__.py @@ -33,7 +33,7 @@ def getService( Args: key: Service key (e.g., "web", "extraction", "utils") - context: ServiceCenterContext with user, mandate_id, feature_instance_id, workflow + context: ServiceCenterContext with user, mandateId, featureInstanceId, workflow Returns: Service instance diff --git a/modules/serviceCenter/context.py b/modules/serviceCenter/context.py index 24868fca..2738f8b3 100644 --- a/modules/serviceCenter/context.py +++ b/modules/serviceCenter/context.py @@ -16,20 +16,10 @@ class ServiceCenterContext: """Context for service resolution: user, mandate, feature instance, optional workflow.""" user: User - mandate_id: Optional[str] = None - feature_instance_id: Optional[str] = None - workflow_id: Optional[str] = None + mandateId: Optional[str] = None + featureInstanceId: Optional[str] = None + workflowId: Optional[str] = None workflow: Any = None requireNeutralization: Optional[bool] = None # When workflow is absent (e.g. workspace agent), billing/UI still need feature code for transactions. - feature_code: Optional[str] = None - - @property - def mandateId(self) -> Optional[str]: - """Alias for mandate_id (backward compatibility).""" - return self.mandate_id - - @property - def featureInstanceId(self) -> Optional[str]: - """Alias for feature_instance_id (backward compatibility).""" - return self.feature_instance_id + featureCode: Optional[str] = None diff --git a/modules/serviceCenter/services/serviceKnowledge/_inheritFlags.py b/modules/serviceCenter/core/flagResolution.py similarity index 100% rename from modules/serviceCenter/services/serviceKnowledge/_inheritFlags.py rename to modules/serviceCenter/core/flagResolution.py diff --git a/modules/serviceCenter/core/serviceSecurity/mainServiceSecurity.py b/modules/serviceCenter/core/serviceSecurity/mainServiceSecurity.py index 4591c36e..b5a9a84b 100644 --- a/modules/serviceCenter/core/serviceSecurity/mainServiceSecurity.py +++ b/modules/serviceCenter/core/serviceSecurity/mainServiceSecurity.py @@ -20,12 +20,12 @@ class SecurityService: def __init__(self, context: Any, get_service: Callable[[str], Any]): """Initialize with service center context and resolver.""" self._context = context - self._get_service = get_service + self._getService = get_service self._tokenManager = TokenManager() from modules.interfaces.interfaceDbApp import getInterface as getAppInterface self._interfaceDbApp = getAppInterface( context.user, - mandateId=context.mandate_id, + mandateId=context.mandateId, ) def getFreshToken(self, connectionId: str, secondsBeforeExpiry: int = 30 * 60) -> Optional[Token]: diff --git a/modules/serviceCenter/core/serviceStreaming/mainServiceStreaming.py b/modules/serviceCenter/core/serviceStreaming/mainServiceStreaming.py index c6c7ddf7..76369553 100644 --- a/modules/serviceCenter/core/serviceStreaming/mainServiceStreaming.py +++ b/modules/serviceCenter/core/serviceStreaming/mainServiceStreaming.py @@ -19,7 +19,7 @@ class StreamingService: def __init__(self, context: Any, get_service: Callable[[str], Any]): """Initialize with service center context and resolver.""" self._context = context - self._get_service = get_service + self._getService = get_service def getEventManager(self) -> EventManager: """Get the global event manager instance for SSE streaming.""" diff --git a/modules/serviceCenter/core/serviceUtils/mainServiceUtils.py b/modules/serviceCenter/core/serviceUtils/mainServiceUtils.py index d22eab1b..856514bf 100644 --- a/modules/serviceCenter/core/serviceUtils/mainServiceUtils.py +++ b/modules/serviceCenter/core/serviceUtils/mainServiceUtils.py @@ -22,7 +22,7 @@ class UtilsService: def __init__(self, context, get_service: Callable[[str], Any]): """Initialize with service center context and resolver.""" self._context = context - self._get_service = get_service + self._getService = get_service # ===== Event handling ===== diff --git a/modules/serviceCenter/core/types.py b/modules/serviceCenter/core/types.py new file mode 100644 index 00000000..19c15081 --- /dev/null +++ b/modules/serviceCenter/core/types.py @@ -0,0 +1,90 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +"""Neutral protocol types used across serviceCenter services. + +Protocols defined here break import cycles by providing structural typing +contracts that services can depend on without importing concrete classes +from sibling services. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Protocol, runtime_checkable + + +# --------------------------------------------------------------------------- +# FeatureDataProviderProtocol (used by serviceKnowledge, implemented in serviceAgent) +# --------------------------------------------------------------------------- + +@runtime_checkable +class FeatureDataProviderProtocol(Protocol): + """Structural contract for the RBAC-scoped feature-data read layer. + + serviceKnowledge depends on this Protocol for RAG indexing; + serviceAgent supplies the concrete FeatureDataProvider implementation. + """ + + def browseTable( + self, + tableName: str, + featureInstanceId: str, + mandateId: str, + fields: Optional[List[str]] = None, + limit: int = 50, + offset: int = 0, + extraFilters: Optional[List[Dict[str, Any]]] = None, + ) -> Dict[str, Any]: ... + + async def finalizeRowsAsync( + self, + tableName: str, + rows: List[Dict[str, Any]], + ) -> List[Dict[str, Any]]: ... + + +# --------------------------------------------------------------------------- +# FeatureDataProvider factory registry +# --------------------------------------------------------------------------- + +_featureDataProviderFactory = None + + +def registerFeatureDataProviderFactory(factory) -> None: + """Register the concrete FeatureDataProvider class (called at composition time).""" + global _featureDataProviderFactory + _featureDataProviderFactory = factory + + +def createFeatureDataProvider( + dbConnector, + neutralizeFields: Optional[Dict[str, List[str]]] = None, + neutralizePolicy: Optional[Dict[str, Dict[str, Any]]] = None, + neutralizationService: Optional[Any] = None, +) -> FeatureDataProviderProtocol: + """Instantiate a FeatureDataProvider without importing serviceAgent.""" + if _featureDataProviderFactory is None: + raise RuntimeError( + "FeatureDataProvider factory not registered. " + "Ensure serviceAgent is initialized before serviceKnowledge bootstrap runs." + ) + return _featureDataProviderFactory( + dbConnector, + neutralizeFields=neutralizeFields, + neutralizePolicy=neutralizePolicy, + neutralizationService=neutralizationService, + ) + + +# --------------------------------------------------------------------------- +# RendererProtocol (used by serviceExtraction, implemented in serviceGeneration) +# --------------------------------------------------------------------------- + +@runtime_checkable +class RendererProtocol(Protocol): + """Structural contract for document renderers. + + serviceExtraction depends on this Protocol for type hints; + serviceGeneration supplies BaseRenderer and its subclasses. + """ + + def getExtractionGuidelines(self) -> str: ... diff --git a/modules/serviceCenter/resolver.py b/modules/serviceCenter/resolver.py index 316ce052..729adb69 100644 --- a/modules/serviceCenter/resolver.py +++ b/modules/serviceCenter/resolver.py @@ -19,7 +19,7 @@ GetServiceFunc = Callable[[str], Any] def _make_context_id(ctx: ServiceCenterContext) -> str: """Create a stable cache key from context.""" - return f"{id(ctx.user)}_{ctx.mandate_id or ''}_{ctx.feature_instance_id or ''}" + return f"{id(ctx.user)}_{ctx.mandateId or ''}_{ctx.featureInstanceId or ''}" def _load_service_class(module_path: str, class_name: str): diff --git a/modules/serviceCenter/services/serviceAgent/__init__.py b/modules/serviceCenter/services/serviceAgent/__init__.py index 05d5452b..8878ece1 100644 --- a/modules/serviceCenter/services/serviceAgent/__init__.py +++ b/modules/serviceCenter/services/serviceAgent/__init__.py @@ -1,3 +1,8 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. """serviceAgent: AI Agent with ReAct loop and native function calling.""" + +from modules.serviceCenter.core.types import registerFeatureDataProviderFactory +from modules.serviceCenter.services.serviceAgent.featureDataProvider import FeatureDataProvider + +registerFeatureDataProviderFactory(FeatureDataProvider) diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py index 76fd0bae..f1e49368 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py @@ -151,7 +151,7 @@ def registerDataSourceTools(registry: ToolRegistry, services): sourceType = ds.get("sourceType", "") path = ds.get("path", "/") label = ds.get("label", "") - from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlag + from modules.serviceCenter.core.flagResolution import getEffectiveFlag from modules.datamodels.datamodelDataSource import DataSource from modules.interfaces.interfaceDbApp import getRootInterface rootIf = getRootInterface() diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py index 2dfc4686..f522a62a 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py @@ -109,7 +109,7 @@ def registerFeatureSubAgentTools(registry: ToolRegistry, services): recordFilter={"featureInstanceId": featureInstanceId}, ) - from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlagFds + from modules.serviceCenter.core.flagResolution import getEffectiveFlagFds _fdsAll = featureDataSources or [] _anySourceNeutralize = any( getEffectiveFlagFds(ds, "neutralize", _fdsAll, mode="walk") is True @@ -160,7 +160,7 @@ def registerFeatureSubAgentTools(registry: ToolRegistry, services): # A2: build the per-table type/inheritance-aware neutralization policy. # tableActive = effective (own or inherited) table-level neutralize flag; # explicitFields = fields whose neutralize flag is set explicitly. - from modules.serviceCenter.services.serviceKnowledge._inheritFlags import resolveEffectiveForFds + from modules.serviceCenter.core.flagResolution import resolveEffectiveForFds neutralizePolicy: Dict[str, Dict[str, Any]] = {} for tblObj in selectedTables: tn = tblObj.get("meta", {}).get("table", "") if isinstance(tblObj, dict) else "" diff --git a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py index e977f596..390d062c 100644 --- a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py +++ b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py @@ -68,8 +68,8 @@ class ServicesBag: self._context = context self._getService = getService self.user = context.user - self.mandateId = context.mandate_id - self.featureInstanceId = context.feature_instance_id + self.mandateId = context.mandateId + self.featureInstanceId = context.featureInstanceId @property def workflow(self): diff --git a/modules/serviceCenter/services/serviceAi/mainServiceAi.py b/modules/serviceCenter/services/serviceAi/mainServiceAi.py index 0fd10678..98489dc8 100644 --- a/modules/serviceCenter/services/serviceAi/mainServiceAi.py +++ b/modules/serviceCenter/services/serviceAi/mainServiceAi.py @@ -42,10 +42,10 @@ class _ServicesAdapter: Workflow is read from context dynamically so propagation updates are visible.""" def __init__(self, context, get_service: Callable[[str], Any]): self._context = context - self._get_service = get_service + self._getService = get_service self.user = context.user - self.mandateId = context.mandate_id - self.featureInstanceId = context.feature_instance_id + self.mandateId = context.mandateId + self.featureInstanceId = context.featureInstanceId @property def workflow(self): @@ -57,31 +57,31 @@ class _ServicesAdapter: @property def chat(self): - return self._get_service("chat") + return self._getService("chat") @property def extraction(self): - return self._get_service("extraction") + return self._getService("extraction") @property def utils(self): - return self._get_service("utils") + return self._getService("utils") @property def ai(self): - return self._get_service("ai") + return self._getService("ai") @property def interfaceDbChat(self): - return self._get_service("chat").interfaceDbChat + return self._getService("chat").interfaceDbChat @property def interfaceDbComponent(self): - return self._get_service("chat").interfaceDbComponent + return self._getService("chat").interfaceDbComponent @property def featureCode(self) -> Optional[str]: - fc = getattr(self._context, "feature_code", None) + fc = getattr(self._context, "featureCode", None) if fc and str(fc).strip(): return str(fc).strip() w = self.workflow @@ -102,11 +102,11 @@ class AiService: """Initialize with ServiceCenterContext and service resolver. Args: - context: ServiceCenterContext with user, mandate_id, feature_instance_id, workflow + context: ServiceCenterContext with user, mandateId, featureInstanceId, workflow get_service: Callable to resolve dependency services by key """ self.services = _ServicesAdapter(context, get_service) - self._get_service = get_service + self._getService = get_service self.aiObjects = None self.extractionService = None @@ -117,7 +117,7 @@ class AiService: if self.extractionService is None: logger.info("Initializing ExtractionService via service center...") - self.extractionService = self._get_service("extraction") + self.extractionService = self._getService("extraction") # Initialize new submodules from .subResponseParsing import ResponseParser @@ -673,7 +673,7 @@ detectedIntent-Werte: _sources = [] # Source 1: Feature-Instance config - _neutralSvc = self._get_service("neutralization") + _neutralSvc = self._getService("neutralization") if _neutralSvc and hasattr(_neutralSvc, 'getConfig'): _config = _neutralSvc.getConfig() if _config and getattr(_config, 'enabled', False): @@ -721,7 +721,7 @@ detectedIntent-Werte: _hardMode = request.requireNeutralization is True excludedDocs: List[str] = [] - neutralSvc = self._get_service("neutralization") + neutralSvc = self._getService("neutralization") if not neutralSvc or not hasattr(neutralSvc, 'processTextAsync'): if _hardMode: raise RuntimeError("Neutralization explicitly required but service unavailable — AI call BLOCKED") @@ -1193,7 +1193,7 @@ detectedIntent-Werte: contentOut = getattr(response, 'content', None) contentOutput = str(contentOut) if contentOut else None - neutralSvc = self._get_service("neutralization") if wasNeutralized else None + neutralSvc = self._getService("neutralization") if wasNeutralized else None mappingsCount = None if neutralSvc and hasattr(neutralSvc, 'getActiveMappingsCount'): try: @@ -1324,8 +1324,8 @@ detectedIntent-Werte: from modules.serviceCenter.context import ServiceCenterContext ctx = ServiceCenterContext( user=servicesHub.user, - mandate_id=servicesHub.mandateId, - feature_instance_id=servicesHub.featureInstanceId, + mandateId=servicesHub.mandateId, + featureInstanceId=servicesHub.featureInstanceId, workflow=getattr(servicesHub, "workflow", None), ) return getService("ai", ctx) @@ -1721,7 +1721,7 @@ Respond with ONLY a JSON object in this exact format: ) try: - generationService = self._get_service("generation") + generationService = self._getService("generation") # renderReport verarbeitet jetzt jedes Dokument einzeln # und gibt Liste von (documentData, mimeType, filename) zurück diff --git a/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py b/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py index f2418b4b..2158506f 100644 --- a/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py +++ b/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py @@ -56,9 +56,9 @@ def getService(currentUser: User, mandateId: str, featureInstanceId: str = None, return _billingServices[cacheKey] -def _get_feature_code_from_context(context) -> Optional[str]: +def _getFeatureCodeFromContext(context) -> Optional[str]: """Extract featureCode from ServiceCenterContext.""" - explicit = getattr(context, "feature_code", None) + explicit = getattr(context, "featureCode", None) if explicit and str(explicit).strip(): return str(explicit).strip() if context.workflow and hasattr(context.workflow, "feature") and context.workflow.feature: @@ -91,15 +91,15 @@ class BillingService: ctx = context_or_user get_service = mandateId self.currentUser = ctx.user - self.mandateId = ctx.mandate_id or "" - self.featureInstanceId = ctx.feature_instance_id - self.featureCode = _get_feature_code_from_context(ctx) + self.mandateId = ctx.mandateId or "" + self.featureInstanceId = ctx.featureInstanceId + self.featureCode = _getFeatureCodeFromContext(ctx) elif get_service is not None and hasattr(context_or_user, "user"): ctx = context_or_user self.currentUser = ctx.user - self.mandateId = ctx.mandate_id or "" - self.featureInstanceId = ctx.feature_instance_id - self.featureCode = _get_feature_code_from_context(ctx) + self.mandateId = ctx.mandateId or "" + self.featureInstanceId = ctx.featureInstanceId + self.featureCode = _getFeatureCodeFromContext(ctx) else: self.currentUser = context_or_user self.mandateId = mandateId or "" diff --git a/modules/serviceCenter/services/serviceChat/mainServiceChat.py b/modules/serviceCenter/services/serviceChat/mainServiceChat.py index 77856a7d..3382f75e 100644 --- a/modules/serviceCenter/services/serviceChat/mainServiceChat.py +++ b/modules/serviceCenter/services/serviceChat/mainServiceChat.py @@ -18,17 +18,17 @@ class ChatService: def __init__(self, context, get_service: Callable[[str], Any]): """Initialize with ServiceCenterContext and service resolver.""" self._context = context - self._get_service = get_service + self._getService = get_service self.user = context.user from modules.interfaces.interfaceDbApp import getInterface as getAppInterface from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface from modules.interfaces.interfaceDbChat import getInterface as getChatInterface - self.interfaceDbApp = getAppInterface(context.user, mandateId=context.mandate_id) - self.interfaceDbComponent = getComponentInterface(context.user, mandateId=context.mandate_id, featureInstanceId=context.feature_instance_id) + self.interfaceDbApp = getAppInterface(context.user, mandateId=context.mandateId) + self.interfaceDbComponent = getComponentInterface(context.user, mandateId=context.mandateId, featureInstanceId=context.featureInstanceId) self.interfaceDbChat = getChatInterface( context.user, - mandateId=context.mandate_id, - featureInstanceId=context.feature_instance_id, + mandateId=context.mandateId, + featureInstanceId=context.featureInstanceId, ) self._progressLogger = None @@ -374,10 +374,10 @@ class ChatService: try: # Get a fresh token via security service logger.debug(f"Getting fresh token for connection {connection.id}") - token = self._get_service("security").getFreshToken(connection.id) + token = self._getService("security").getFreshToken(connection.id) if token: if hasattr(token, 'expiresAt') and token.expiresAt: - current_time = self._get_service("utils").timestampGetUtc() + current_time = self._getService("utils").timestampGetUtc() if current_time > token.expiresAt: token_status = "expired" else: @@ -462,7 +462,7 @@ class ChatService: Token object or None if not found/expired """ try: - return self._get_service("security").getFreshToken(connectionId) + return self._getService("security").getFreshToken(connectionId) except Exception as e: logger.error(f"Error getting fresh token for connection {connectionId}: {str(e)}") return None @@ -575,8 +575,8 @@ class ChatService: path=path, label=label, displayPath=displayPath, - featureInstanceId=featureInstanceId or self._context.feature_instance_id or "", - mandateId=self._context.mandate_id or "", + featureInstanceId=featureInstanceId or self._context.featureInstanceId or "", + mandateId=self._context.mandateId or "", userId=self.user.id if self.user else "", ) return self.interfaceDbApp.db.recordCreate(DataSource, ds) diff --git a/modules/serviceCenter/services/serviceClickup/mainServiceClickup.py b/modules/serviceCenter/services/serviceClickup/mainServiceClickup.py index d1ef51b3..74a7e809 100644 --- a/modules/serviceCenter/services/serviceClickup/mainServiceClickup.py +++ b/modules/serviceCenter/services/serviceClickup/mainServiceClickup.py @@ -30,7 +30,7 @@ class ClickupService(ClickupApiClient): def __init__(self, context, get_service: Callable[[str], Any]): super().__init__(accessToken="") self._context = context - self._get_service = get_service + self._getService = get_service def setAccessTokenFromConnection(self, userConnection) -> bool: """Load OAuth/personal token from SecurityService for this UserConnection.""" @@ -45,7 +45,7 @@ class ClickupService(ClickupApiClient): if not connection_id: logger.error("UserConnection must have an 'id' field") return False - security = self._get_service("security") + security = self._getService("security") if not security: logger.error("Security service not available for token access") return False diff --git a/modules/serviceCenter/services/serviceExtraction/mainServiceExtraction.py b/modules/serviceCenter/services/serviceExtraction/mainServiceExtraction.py index a3fb0baf..125281e7 100644 --- a/modules/serviceCenter/services/serviceExtraction/mainServiceExtraction.py +++ b/modules/serviceCenter/services/serviceExtraction/mainServiceExtraction.py @@ -28,12 +28,12 @@ class ExtractionService: def __init__(self, context, get_service: Callable[[str], Any]): """Initialize with ServiceCenterContext and service resolver.""" self._context = context - self._get_service = get_service + self._getService = get_service from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface self._interfaceDbComponent = getComponentInterface( context.user, - mandateId=context.mandate_id, - featureInstanceId=context.feature_instance_id, + mandateId=context.mandateId, + featureInstanceId=context.featureInstanceId, ) self._extractorRegistry = getExtractorRegistry() if ExtractionService._sharedChunkerRegistry is None: @@ -117,7 +117,7 @@ class ExtractionService: docOperationId = f"{operationId}_doc_{i}" # Use parentOperationId if provided, otherwise use operationId as parent parentId = parentOperationId if parentOperationId else operationId - self._get_service("chat").progressLogStart( + self._getService("chat").progressLogStart( docOperationId, "Extracting Document", f"Document {i + 1}/{totalDocs}", @@ -130,17 +130,17 @@ class ExtractionService: try: if docOperationId: - self._get_service("chat").progressLogUpdate(docOperationId, 0.1, "Loading document data") + self._getService("chat").progressLogUpdate(docOperationId, 0.1, "Loading document data") # Resolve raw bytes for this document using interface documentBytes = dbInterface.getFileData(doc.fileId) if not documentBytes: if docOperationId: - self._get_service("chat").progressLogFinish(docOperationId, False) + self._getService("chat").progressLogFinish(docOperationId, False) raise ValueError(f"No file data found for fileId={doc.fileId}") if docOperationId: - self._get_service("chat").progressLogUpdate(docOperationId, 0.2, "Running extraction pipeline") + self._getService("chat").progressLogUpdate(docOperationId, 0.2, "Running extraction pipeline") # Convert ChatDocument to the format expected by runExtraction documentData = { @@ -160,7 +160,7 @@ class ExtractionService: ) if docOperationId: - self._get_service("chat").progressLogUpdate(docOperationId, 0.7, f"Extracted {len(ec.parts)} parts") + self._getService("chat").progressLogUpdate(docOperationId, 0.7, f"Extracted {len(ec.parts)} parts") # Log content parts metadata logger.debug(f"Content parts: {len(ec.parts)}") @@ -223,7 +223,7 @@ class ExtractionService: # Use document name and part index for filename doc_name_safe = documentData["fileName"].replace(" ", "_").replace("/", "_").replace("\\", "_")[:50] debug_filename = f"extraction_text_part_{j+1}_{doc_name_safe}.txt" - self._get_service("utils").writeDebugFile(debug_json, debug_filename) + self._getService("utils").writeDebugFile(debug_json, debug_filename) logger.info(f"Wrote debug file for extracted text part {j+1}/{len(ec.parts)}: {debug_filename}") except Exception as e: logger.warning(f"Failed to write debug file for text part {j+1}: {str(e)}") @@ -240,7 +240,7 @@ class ExtractionService: logger.debug(f"No chunking needed - {len(ec.parts)} parts fit within size limits") if docOperationId: - self._get_service("chat").progressLogUpdate(docOperationId, 0.9, f"Processing complete: {len(ec.parts)} parts extracted") + self._getService("chat").progressLogUpdate(docOperationId, 0.9, f"Processing complete: {len(ec.parts)} parts extracted") # Calculate timing and emit stats endTime = time.time() @@ -256,7 +256,7 @@ class ExtractionService: # Hard fail if model is missing; caller must ensure connectors are registered if model is None or model.calculatepriceCHF is None: if docOperationId: - self._get_service("chat").progressLogFinish(docOperationId, False) + self._getService("chat").progressLogFinish(docOperationId, False) raise RuntimeError(f"Pricing model not available: {modelDisplayName}") priceCHF = model.calculatepriceCHF(processingTime, bytesSent, bytesReceived) @@ -309,13 +309,13 @@ class ExtractionService: # Finish document operation successfully if docOperationId: - self._get_service("chat").progressLogFinish(docOperationId, True) + self._getService("chat").progressLogFinish(docOperationId, True) except Exception as e: logger.error(f"Error extracting content from document {i + 1}/{totalDocs} ({doc.fileName}): {str(e)}") if docOperationId: try: - self._get_service("chat").progressLogFinish(docOperationId, False) + self._getService("chat").progressLogFinish(docOperationId, False) except: pass # Don't fail on progress logging errors # Continue with next document instead of failing completely @@ -355,7 +355,7 @@ class ExtractionService: if not operationId: workflowId = self._context.workflow.id if self._context.workflow else f"no-workflow-{int(time.time())}" operationId = f"ai_text_extract_{workflowId}_{int(time.time())}" - self._get_service("chat").progressLogStart( + self._getService("chat").progressLogStart( operationId, "AI Text Extract", "Document Processing", @@ -383,19 +383,19 @@ class ExtractionService: # Extract content WITHOUT chunking if operationId: - self._get_service("chat").progressLogUpdate(operationId, 0.1, f"Extracting content from {len(documents)} documents") + self._getService("chat").progressLogUpdate(operationId, 0.1, f"Extracting content from {len(documents)} documents") # Pass operationId as parentOperationId for hierarchical logging # Correct hierarchy: parentOperationId -> operationId -> docOperationId extractionResult = self.extractContent(documents, extractionOptions, operationId=operationId, parentOperationId=operationId) if not isinstance(extractionResult, list): if operationId: - self._get_service("chat").progressLogFinish(operationId, False) + self._getService("chat").progressLogFinish(operationId, False) return "[Error: No extraction results]" # Process parts (not chunks) with model-aware AI calls if operationId: - self._get_service("chat").progressLogUpdate(operationId, 0.3, f"Processing {len(extractionResult)} extracted content parts") + self._getService("chat").progressLogUpdate(operationId, 0.3, f"Processing {len(extractionResult)} extracted content parts") # Use operationId as parentOperationId for child operations # Correct hierarchy: parentOperationId -> operationId -> partOperationId processParentOperationId = operationId @@ -403,20 +403,20 @@ class ExtractionService: # Merge results using existing merging system if operationId: - self._get_service("chat").progressLogUpdate(operationId, 0.9, f"Merging {len(partResults)} part results") + self._getService("chat").progressLogUpdate(operationId, 0.9, f"Merging {len(partResults)} part results") mergedContent = self.mergePartResults(partResults, options) # Save merged extraction content to debug - self._get_service("utils").writeDebugFile(mergedContent or '', "extraction_merged_text") + self._getService("utils").writeDebugFile(mergedContent or '', "extraction_merged_text") if operationId: - self._get_service("chat").progressLogFinish(operationId, True) + self._getService("chat").progressLogFinish(operationId, True) return mergedContent except Exception as e: logger.error(f"Error in processDocumentsPerChunk: {str(e)}") if operationId: - self._get_service("chat").progressLogFinish(operationId, False) + self._getService("chat").progressLogFinish(operationId, False) raise async def _processPartsWithMapping( @@ -468,7 +468,7 @@ class ExtractionService: if operationId: workflowId = self._context.workflow.id if self._context.workflow else f"no-workflow-{int(time.time())}" partOperationId = f"{operationId}_part_{part_index}" - self._get_service("chat").progressLogStart( + self._getService("chat").progressLogStart( partOperationId, "Content Processing", f"Part {part_index + 1}", @@ -487,15 +487,15 @@ class ExtractionService: # Update progress - initiating if partOperationId: - self._get_service("chat").progressLogUpdate(partOperationId, 0.3, "Initiating") + self._getService("chat").progressLogUpdate(partOperationId, 0.3, "Initiating") # Call AI with model-aware chunking (no progress callback - handled by parent operation) response = await aiObjects.call(request) # Update progress - completed if partOperationId: - self._get_service("chat").progressLogUpdate(partOperationId, 0.9, "Completed") - self._get_service("chat").progressLogFinish(partOperationId, True) + self._getService("chat").progressLogUpdate(partOperationId, 0.9, "Completed") + self._getService("chat").progressLogFinish(partOperationId, True) processing_time = time.time() - start_time @@ -1133,7 +1133,7 @@ class ExtractionService: "perPartExtractedData": per_part_extracted_data } debug_json = json.dumps(debug_content, indent=2, ensure_ascii=False) - self._get_service("utils").writeDebugFile(debug_json, "content_extraction_per_part") + self._getService("utils").writeDebugFile(debug_json, "content_extraction_per_part") logger.info(f"Wrote per-part extracted data to debug file: {len(per_part_extracted_data)} blocks from {len(content_parts)} content parts") except Exception as e: logger.warning(f"Failed to write per-part extracted data to debug file: {str(e)}") @@ -1172,7 +1172,7 @@ class ExtractionService: extraction_result_format["parts"].append(formatted_part) result_json = json.dumps(extraction_result_format, indent=2, ensure_ascii=False) - self._get_service("utils").writeDebugFile(result_json, "content_extraction_original_parts") + self._getService("utils").writeDebugFile(result_json, "content_extraction_original_parts") logger.info(f"Wrote original parts extracted data to debug file: {len(original_parts_extracted_data)} original parts") except Exception as e: logger.warning(f"Failed to write original parts extracted data to debug file: {str(e)}") @@ -1764,11 +1764,11 @@ class ExtractionService: debugPrefix = f"generation_contentPart_{partId}_{partLabelSafe}" # Write prompt - self._get_service("utils").writeDebugFile(prompt, f"{debugPrefix}_prompt") + self._getService("utils").writeDebugFile(prompt, f"{debugPrefix}_prompt") # Write response responseContent = partResult.content if partResult.content else "" - self._get_service("utils").writeDebugFile(responseContent, f"{debugPrefix}_response") + self._getService("utils").writeDebugFile(responseContent, f"{debugPrefix}_response") logger.debug(f"Wrote debug files for contentPart {partId} (generation): {debugPrefix}_prompt, {debugPrefix}_response") except Exception as debugError: diff --git a/modules/serviceCenter/services/serviceExtraction/subPromptBuilderExtraction.py b/modules/serviceCenter/services/serviceExtraction/subPromptBuilderExtraction.py index fe342002..0f9cbf45 100644 --- a/modules/serviceCenter/services/serviceExtraction/subPromptBuilderExtraction.py +++ b/modules/serviceCenter/services/serviceExtraction/subPromptBuilderExtraction.py @@ -10,13 +10,7 @@ import logging from typing import Dict, Any, Optional from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum -# Type hint for renderer parameter -from typing import TYPE_CHECKING -if TYPE_CHECKING: - from modules.serviceCenter.services.serviceGeneration.renderers.documentRendererBaseTemplate import BaseRenderer - _RendererLike = BaseRenderer -else: - _RendererLike = Any +from modules.serviceCenter.core.types import RendererProtocol logger = logging.getLogger(__name__) @@ -27,7 +21,7 @@ async def buildExtractionPrompt( title: str, aiService=None, services=None, - renderer: _RendererLike = None + renderer: Optional[RendererProtocol] = None ) -> str: """ Build unified extraction prompt for extracting content from documents. diff --git a/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py b/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py index 5dbf16de..1137b7d6 100644 --- a/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py +++ b/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py @@ -26,10 +26,10 @@ class _ServicesAdapter: Workflow is read from context dynamically so propagation updates are visible.""" def __init__(self, context, get_service: Callable[[str], Any]): self._context = context - self._get_service = get_service + self._getService = get_service self.user = context.user - self.mandateId = context.mandate_id - self.featureInstanceId = context.feature_instance_id + self.mandateId = context.mandateId + self.featureInstanceId = context.featureInstanceId chat = get_service("chat") self.interfaceDbChat = chat.interfaceDbChat @@ -39,22 +39,22 @@ class _ServicesAdapter: @property def chat(self): - return self._get_service("chat") + return self._getService("chat") @property def utils(self): - return self._get_service("utils") + return self._getService("utils") @property def ai(self): - return self._get_service("ai") + return self._getService("ai") class GenerationService: def __init__(self, context, get_service: Callable[[str], Any]): """Initialize with ServiceCenterContext and service resolver.""" self.services = _ServicesAdapter(context, get_service) - self._get_service = get_service + self._getService = get_service self.interfaceDbChat = self.services.interfaceDbChat def processActionResultDocuments(self, actionResult, action) -> List[Dict[str, Any]]: diff --git a/modules/serviceCenter/services/serviceKnowledge/_buildTree.py b/modules/serviceCenter/services/serviceKnowledge/_buildTree.py index 87021f9d..cf32a925 100644 --- a/modules/serviceCenter/services/serviceKnowledge/_buildTree.py +++ b/modules/serviceCenter/services/serviceKnowledge/_buildTree.py @@ -112,7 +112,7 @@ def _findDsRecord( sourceType: str, path: str, ) -> Optional[Dict[str, Any]]: - from modules.serviceCenter.services.serviceKnowledge._inheritFlags import normalisePath + from modules.serviceCenter.core.flagResolution import normalisePath norm = normalisePath(path) for ds in allDs: if ( @@ -191,8 +191,8 @@ def _personalRootChildrenNodes( mandateId = getattr(context, "mandateId", "") or "" ctx = ServiceCenterContext( user=context.user, - mandate_id=mandateId, - feature_instance_id="", + mandateId=mandateId, + featureInstanceId="", ) chatService = getService("chat", ctx) connections = chatService.getUserConnections() or [] @@ -295,8 +295,8 @@ async def _connectionServiceNodes( mandateId = getattr(context, "mandateId", "") or "" ctx = ServiceCenterContext( user=context.user, - mandate_id=mandateId, - feature_instance_id=instanceId, + mandateId=mandateId, + featureInstanceId=instanceId, ) chatService = getService("chat", ctx) securityService = getService("security", ctx) @@ -347,8 +347,8 @@ async def _browseChildNodes( mandateId = getattr(context, "mandateId", "") or "" ctx = ServiceCenterContext( user=context.user, - mandate_id=mandateId, - feature_instance_id=instanceId, + mandateId=mandateId, + featureInstanceId=instanceId, ) chatService = getService("chat", ctx) securityService = getService("security", ctx) @@ -683,9 +683,9 @@ def _callerInstanceId(context: Any) -> str: """The UDB is feature-agnostic, but `_browseChildNodes` and `_connectionServiceNodes` need a feature instance id for the ServiceCenterContext (the underlying connector resolver wants one). - Use the caller's current feature_instance_id (workspace) when + Use the caller's current featureInstanceId (workspace) when available, else an empty string. The id is NOT used for FDS scoping.""" - fid = getattr(context, "feature_instance_id", None) or getattr(context, "featureInstanceId", None) + fid = getattr(context, "featureInstanceId", None) return str(fid) if fid else "" diff --git a/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py b/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py index 291dd9a6..095e97cc 100644 --- a/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py +++ b/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py @@ -926,7 +926,7 @@ class KnowledgeService: contentObjectId=f"page-{pageIdx}", fileId=fileId, userId=self._context.user.id if self._context.user else "", - featureInstanceId=self._context.feature_instance_id or "", + featureInstanceId=self._context.featureInstanceId or "", contentType="text", data=text, contextRef={ diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorIngestConsumer.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorIngestConsumer.py index df80898b..5fec915e 100644 --- a/modules/serviceCenter/services/serviceKnowledge/subConnectorIngestConsumer.py +++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorIngestConsumer.py @@ -172,7 +172,7 @@ def _loadRagEnabledDataSources(connectionId: str, dataSourceIds: Optional[list] """ from modules.interfaces.interfaceDbApp import getRootInterface from modules.datamodels.datamodelDataSource import DataSource - from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlag + from modules.serviceCenter.core.flagResolution import getEffectiveFlag rootIf = getRootInterface() allDs = rootIf.db.getRecordset(DataSource, recordFilter={"connectionId": connectionId}) diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncClickup.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncClickup.py index ac886099..edddb2c1 100644 --- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncClickup.py +++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncClickup.py @@ -314,7 +314,7 @@ async def _resolveDependencies(connectionId: str): rootUser = getRootUser() ctx = ServiceCenterContext( user=rootUser, - mandate_id=str(getattr(connection, "mandateId", "") or ""), + mandateId=str(getattr(connection, "mandateId", "") or ""), ) knowledgeService = getService("knowledge", ctx) return adapter, connection, knowledgeService diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGdrive.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGdrive.py index 9857bfb7..7c485a82 100644 --- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGdrive.py +++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGdrive.py @@ -244,7 +244,7 @@ async def _resolveDependencies(connectionId: str): rootUser = getRootUser() ctx = ServiceCenterContext( user=rootUser, - mandate_id=str(getattr(connection, "mandateId", "") or ""), + mandateId=str(getattr(connection, "mandateId", "") or ""), ) knowledgeService = getService("knowledge", ctx) return adapter, connection, knowledgeService diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGmail.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGmail.py index 150fe839..b07f83c3 100644 --- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGmail.py +++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGmail.py @@ -297,7 +297,7 @@ async def _resolveDependencies(connectionId: str): rootUser = getRootUser() ctx = ServiceCenterContext( user=rootUser, - mandate_id=str(getattr(connection, "mandateId", "") or ""), + mandateId=str(getattr(connection, "mandateId", "") or ""), ) knowledgeService = getService("knowledge", ctx) return adapter, connection, knowledgeService diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncKdrive.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncKdrive.py index 1c50070e..5dd3174c 100644 --- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncKdrive.py +++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncKdrive.py @@ -211,7 +211,7 @@ async def _resolveDependencies(connectionId: str): rootUser = getRootUser() ctx = ServiceCenterContext( user=rootUser, - mandate_id=str(getattr(connection, "mandateId", "") or ""), + mandateId=str(getattr(connection, "mandateId", "") or ""), ) knowledgeService = getService("knowledge", ctx) return adapter, connection, knowledgeService diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncOutlook.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncOutlook.py index c27a5039..eb131350 100644 --- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncOutlook.py +++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncOutlook.py @@ -256,7 +256,7 @@ async def _resolveDependencies(connectionId: str): rootUser = getRootUser() ctx = ServiceCenterContext( user=rootUser, - mandate_id=str(getattr(connection, "mandateId", "") or ""), + mandateId=str(getattr(connection, "mandateId", "") or ""), ) knowledgeService = getService("knowledge", ctx) return adapter, connection, knowledgeService diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncSharepoint.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncSharepoint.py index 86d61f60..adb4b841 100644 --- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncSharepoint.py +++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncSharepoint.py @@ -245,7 +245,7 @@ async def _resolveDependencies(connectionId: str): rootUser = getRootUser() ctx = ServiceCenterContext( user=rootUser, - mandate_id=str(getattr(connection, "mandateId", "") or ""), + mandateId=str(getattr(connection, "mandateId", "") or ""), ) knowledgeService = getService("knowledge", ctx) return adapter, connection, knowledgeService diff --git a/modules/serviceCenter/services/serviceKnowledge/subFeatureBootstrap.py b/modules/serviceCenter/services/serviceKnowledge/subFeatureBootstrap.py index 4d58933c..f1cd3887 100644 --- a/modules/serviceCenter/services/serviceKnowledge/subFeatureBootstrap.py +++ b/modules/serviceCenter/services/serviceKnowledge/subFeatureBootstrap.py @@ -30,7 +30,7 @@ def _loadRagEnabledFds(featureInstanceId: str, featureDataSourceIds: Optional[Li """ from modules.interfaces.interfaceDbApp import getRootInterface from modules.datamodels.datamodelFeatures import FeatureDataSource - from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlagFds + from modules.serviceCenter.core.flagResolution import getEffectiveFlagFds rootIf = getRootInterface() allFds = rootIf.db.getRecordset( @@ -118,7 +118,7 @@ async def _featureBootstrapHandler( ) return {"featureInstanceId": featureInstanceId, "skipped": True, "reason": "no_rag_enabled_fds"} - from modules.serviceCenter.services.serviceAgent.featureDataProvider import FeatureDataProvider + from modules.serviceCenter.core.types import createFeatureDataProvider from modules.serviceCenter.services.serviceKnowledge.mainServiceKnowledge import IngestionJob from modules.serviceCenter.context import ServiceCenterContext from modules.serviceCenter import getService @@ -156,8 +156,8 @@ async def _featureBootstrapHandler( rootUser = getRootUser() ctx = ServiceCenterContext( user=rootUser, - mandate_id=mandateId, - feature_instance_id=fdsFeatureInstanceId, + mandateId=mandateId, + featureInstanceId=fdsFeatureInstanceId, ) knowledgeService = getService("knowledge", ctx) @@ -171,7 +171,7 @@ async def _featureBootstrapHandler( "explicitFields": set(neutralizeFields), } } - provider = FeatureDataProvider( + provider = createFeatureDataProvider( dbConnector, neutralizePolicy=neutralizePolicy, neutralizationService=neutralizationService, diff --git a/modules/serviceCenter/services/serviceKnowledge/udbNodes.py b/modules/serviceCenter/services/serviceKnowledge/udbNodes.py index d46292ce..879983dd 100644 --- a/modules/serviceCenter/services/serviceKnowledge/udbNodes.py +++ b/modules/serviceCenter/services/serviceKnowledge/udbNodes.py @@ -251,7 +251,7 @@ class _DataSourceFamilyNode(UdbNode): def getEffectiveFlag(self, flag, allDs, allFds, mode="aggregate") -> Any: if not self.supportsFlag(flag): return False - from modules.serviceCenter.services.serviceKnowledge._inheritFlags import ( + from modules.serviceCenter.core.flagResolution import ( resolveEffectiveForPath, ) out = resolveEffectiveForPath(self.connectionId, self.sourceType, self.path, allDs, mode=mode) @@ -260,7 +260,7 @@ class _DataSourceFamilyNode(UdbNode): def setFlag(self, flag, value, rootIf) -> List[str]: from modules.datamodels.datamodelDataSource import DataSource - from modules.serviceCenter.services.serviceKnowledge._inheritFlags import ( + from modules.serviceCenter.core.flagResolution import ( cascadeResetDescendants, ) if not self.rec: @@ -416,7 +416,7 @@ class _FdsFamilyNode(UdbNode): def getEffectiveFlag(self, flag, allDs, allFds, mode="aggregate") -> Any: if not self.supportsFlag(flag): return None - from modules.serviceCenter.services.serviceKnowledge._inheritFlags import ( + from modules.serviceCenter.core.flagResolution import ( resolveEffectiveForFds, ) out = resolveEffectiveForFds(self.featureInstanceId, self.tableName, @@ -428,7 +428,7 @@ class _FdsFamilyNode(UdbNode): if not self.supportsFlag(flag): raise ValueError(f"FDS does not support flag {flag!r}") from modules.datamodels.datamodelFeatures import FeatureDataSource - from modules.serviceCenter.services.serviceKnowledge._inheritFlags import ( + from modules.serviceCenter.core.flagResolution import ( cascadeResetDescendantsFds, ) if not self.rec: @@ -669,7 +669,7 @@ class FdsFieldNode(UdbNode): # Not explicitly overridden -> inherit from the table's effective # neutralize. Use walk mode so the inherited value is concrete # (never 'mixed'); a single field cannot itself be ambiguous. - from modules.serviceCenter.services.serviceKnowledge._inheritFlags import ( + from modules.serviceCenter.core.flagResolution import ( resolveEffectiveForFds, ) out = resolveEffectiveForFds( @@ -753,7 +753,7 @@ def _findOrCreateDs(rootIf: Any, connectionId: str, sourceType: str, """ from modules.datamodels.datamodelDataSource import DataSource from modules.datamodels.datamodelUam import UserConnection - from modules.serviceCenter.services.serviceKnowledge._inheritFlags import normalisePath + from modules.serviceCenter.core.flagResolution import normalisePath normPath = normalisePath(path) @@ -1007,7 +1007,7 @@ def buildNodeForKey(key: str, context: Any, rootIf: Any) -> Optional[UdbNode]: def _findDsByCoord(rootIf: Any, connectionId: str, sourceType: Optional[str], path: str) -> Optional[Dict[str, Any]]: from modules.datamodels.datamodelDataSource import DataSource - from modules.serviceCenter.services.serviceKnowledge._inheritFlags import normalisePath + from modules.serviceCenter.core.flagResolution import normalisePath rf = {"connectionId": connectionId} if sourceType is not None: rf["sourceType"] = sourceType diff --git a/modules/serviceCenter/services/serviceMessaging/mainServiceMessaging.py b/modules/serviceCenter/services/serviceMessaging/mainServiceMessaging.py index 77e77695..cc43ca0c 100644 --- a/modules/serviceCenter/services/serviceMessaging/mainServiceMessaging.py +++ b/modules/serviceCenter/services/serviceMessaging/mainServiceMessaging.py @@ -33,7 +33,7 @@ class _ServicesAdapter: from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface self.interfaceDbComponent = getComponentInterface( context.user, - mandateId=context.mandate_id + mandateId=context.mandateId ) diff --git a/modules/serviceCenter/services/serviceSharepoint/mainServiceSharepoint.py b/modules/serviceCenter/services/serviceSharepoint/mainServiceSharepoint.py index 8456dc52..e6cbc8e4 100644 --- a/modules/serviceCenter/services/serviceSharepoint/mainServiceSharepoint.py +++ b/modules/serviceCenter/services/serviceSharepoint/mainServiceSharepoint.py @@ -24,13 +24,13 @@ class SharepointService: """Initialize SharePoint service without access token. Args: - context: ServiceCenterContext with user, mandate_id, etc. + context: ServiceCenterContext with user, mandateId, etc. get_service: Service resolver for dependency injection (e.g. security) Use setAccessTokenFromConnection() method to configure the access token before making API calls. """ self._context = context - self._get_service = get_service + self._getService = get_service self.accessToken = None self.baseUrl = "https://graph.microsoft.com/v1.0" @@ -59,7 +59,7 @@ class SharepointService: return False # Get a fresh token for this specific connection via security service - security = self._get_service("security") + security = self._getService("security") if not security: logger.error("Security service not available for token access") return False diff --git a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py index 71dc4526..e5924aaf 100644 --- a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py +++ b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py @@ -55,11 +55,11 @@ class SubscriptionService: if mandateId is not None and callable(mandateId): ctx = contextOrUser self.currentUser = ctx.user - self.mandateId = ctx.mandate_id or "" + self.mandateId = ctx.mandateId or "" elif get_service is not None and hasattr(contextOrUser, "user"): ctx = contextOrUser self.currentUser = ctx.user - self.mandateId = ctx.mandate_id or "" + self.mandateId = ctx.mandateId or "" else: self.currentUser = contextOrUser self.mandateId = mandateId or "" diff --git a/modules/serviceCenter/services/serviceTicket/mainServiceTicket.py b/modules/serviceCenter/services/serviceTicket/mainServiceTicket.py index 10ad1ba6..ea229940 100644 --- a/modules/serviceCenter/services/serviceTicket/mainServiceTicket.py +++ b/modules/serviceCenter/services/serviceTicket/mainServiceTicket.py @@ -15,7 +15,7 @@ class TicketService: def __init__(self, context, get_service: Callable[[str], Any]): """Initialize with context and service resolver.""" self._context = context - self._get_service = get_service + self._getService = get_service async def connectTicket( self, diff --git a/modules/serviceCenter/services/serviceWeb/mainServiceWeb.py b/modules/serviceCenter/services/serviceWeb/mainServiceWeb.py index 3445839e..c6403c8d 100644 --- a/modules/serviceCenter/services/serviceWeb/mainServiceWeb.py +++ b/modules/serviceCenter/services/serviceWeb/mainServiceWeb.py @@ -22,14 +22,14 @@ class WebService: def __init__(self, context, get_service): """Initialize webcrawl service with context and service resolver.""" self._context = context - self._get_service = get_service + self._getService = get_service def _workflow_id(self): """Get workflow ID for operation IDs.""" if self._context.workflow: return self._context.workflow.id - if self._context.workflow_id: - return self._context.workflow_id + if self._context.workflowId: + return self._context.workflowId return f"no-workflow-{int(time.time())}" async def performWebResearch( @@ -61,7 +61,7 @@ class WebService: """ # Start progress tracking if operationId provided if operationId: - self._get_service("chat").progressLogStart( + self._getService("chat").progressLogStart( operationId, "Web Research", "Research", @@ -71,7 +71,7 @@ class WebService: try: # Step 1: AI intention analysis - extract URLs and parameters from prompt if operationId: - self._get_service("chat").progressLogUpdate(operationId, 0.1, "Analyzing research intent") + self._getService("chat").progressLogUpdate(operationId, 0.1, "Analyzing research intent") analysisResult = await self._analyzeResearchIntent(prompt, urls, country, language, researchDepth) @@ -99,7 +99,7 @@ class WebService: searchResultsWithContent = [] if needsSearch and (not allUrls or len(allUrls) < maxNumberPages): if operationId: - self._get_service("chat").progressLogUpdate(operationId, 0.3, "Searching for URLs and content") + self._getService("chat").progressLogUpdate(operationId, 0.3, "Searching for URLs and content") try: searchUrls, searchResultsWithContent = await self._performWebSearch( @@ -121,7 +121,7 @@ class WebService: logger.warning("Tavily search returned no URLs, using AI-extracted URLs only") if operationId: - self._get_service("chat").progressLogUpdate(operationId, 0.5, f"Found {len(allUrls)} total URLs") + self._getService("chat").progressLogUpdate(operationId, 0.5, f"Found {len(allUrls)} total URLs") # If we have search results (even without content), use them directly instead of crawling # Tavily search results are more relevant than generic AI-extracted URLs @@ -179,7 +179,7 @@ class WebService: "total_urls": len(searchUrls), "urls_with_content": urlsWithContent, "total_content_length": totalContentLength, - "search_date": self._get_service("utils").timestampGetUtc() + "search_date": self._getService("utils").timestampGetUtc() }, "sections": sections, "statistics": { @@ -201,8 +201,8 @@ class WebService: result["metadata"]["suggested_filename"] = suggestedFilename if operationId: - self._get_service("chat").progressLogUpdate(operationId, 0.9, "Completed") - self._get_service("chat").progressLogFinish(operationId, True) + self._getService("chat").progressLogUpdate(operationId, 0.9, "Completed") + self._getService("chat").progressLogFinish(operationId, True) return result @@ -231,8 +231,8 @@ class WebService: # Step 5: Crawl all URLs with hierarchical logging if operationId: - self._get_service("chat").progressLogUpdate(operationId, 0.4, "Initiating") - self._get_service("chat").progressLogUpdate(operationId, 0.6, f"Crawling {len(validatedUrls)} URLs") + self._getService("chat").progressLogUpdate(operationId, 0.4, "Initiating") + self._getService("chat").progressLogUpdate(operationId, 0.6, f"Crawling {len(validatedUrls)} URLs") # Use parent operation ID directly (parentId should be operationId, not log entry ID) parentOperationId = operationId # Use the parent's operationId directly @@ -246,9 +246,9 @@ class WebService: ) if operationId: - self._get_service("chat").progressLogUpdate(operationId, 0.9, "Consolidating results") - self._get_service("chat").progressLogUpdate(operationId, 0.95, "Completed") - self._get_service("chat").progressLogFinish(operationId, True) + self._getService("chat").progressLogUpdate(operationId, 0.9, "Consolidating results") + self._getService("chat").progressLogUpdate(operationId, 0.95, "Completed") + self._getService("chat").progressLogFinish(operationId, True) # Calculate statistics about crawl results totalResults = len(crawlResult) if isinstance(crawlResult, list) else 1 @@ -317,7 +317,7 @@ class WebService: "total_urls": len(validatedUrls), "urls_with_content": urlsWithContent, "total_content_length": totalContentLength, - "crawl_date": self._get_service("utils").timestampGetUtc() + "crawl_date": self._getService("utils").timestampGetUtc() }, "sections": sections, "statistics": { @@ -345,7 +345,7 @@ class WebService: except Exception as e: logger.error(f"Error in web research: {str(e)}") if operationId: - self._get_service("chat").progressLogFinish(operationId, False) + self._getService("chat").progressLogFinish(operationId, False) raise async def _analyzeResearchIntent( @@ -397,13 +397,13 @@ Return ONLY valid JSON, no additional text: try: # Call AI planning to analyze intent - analysisJson = await self._get_service("ai").callAiPlanning( + analysisJson = await self._getService("ai").callAiPlanning( analysisPrompt, debugType="webresearchintent" ) # Extract JSON from response (handles markdown code blocks) - extractedJson = self._get_service("utils").jsonExtractString(analysisJson) + extractedJson = self._getService("utils").jsonExtractString(analysisJson) if not extractedJson: raise ValueError("No JSON found in AI response") @@ -454,7 +454,7 @@ Return ONLY valid JSON, no additional text: searchPrompt = searchPromptModel.model_dump_json(exclude_none=True, indent=2) # Debug: persist search prompt - self._get_service("utils").writeDebugFile(searchPrompt, "websearch_prompt") + self._getService("utils").writeDebugFile(searchPrompt, "websearch_prompt") # Call AI with WEB_SEARCH_DATA operation searchOptions = AiCallOptions( @@ -463,7 +463,7 @@ Return ONLY valid JSON, no additional text: ) # Use unified callAiContent method - searchResponse = await self._get_service("ai").callAiContent( + searchResponse = await self._getService("ai").callAiContent( prompt=searchPrompt, options=searchOptions, outputFormat="json" @@ -518,16 +518,16 @@ Return ONLY valid JSON, no additional text: # Debug: persist search response if isinstance(searchResult, str): - self._get_service("utils").writeDebugFile(searchResult, "websearch_response") + self._getService("utils").writeDebugFile(searchResult, "websearch_response") logger.debug(f"Search response (first 500 chars): {searchResult[:500]}") else: - self._get_service("utils").writeDebugFile(json.dumps(searchResult, indent=2), "websearch_response") + self._getService("utils").writeDebugFile(json.dumps(searchResult, indent=2), "websearch_response") logger.debug(f"Search response type: {type(searchResult)}, keys: {list(searchResult.keys()) if isinstance(searchResult, dict) else 'N/A'}") # Parse and extract URLs and content if isinstance(searchResult, str): # Extract JSON from response (handles markdown code blocks) - extractedJson = self._get_service("utils").jsonExtractString(searchResult) + extractedJson = self._getService("utils").jsonExtractString(searchResult) if extractedJson: try: searchData = json.loads(extractedJson) @@ -800,7 +800,7 @@ Return ONLY valid JSON, no additional text: if parentOperationId: workflowId = self._workflow_id() urlOperationId = f"web_crawl_url_{workflowId}_{urlIndex}_{int(time.time())}" - self._get_service("chat").progressLogStart( + self._getService("chat").progressLogStart( urlOperationId, "Web Crawl", f"URL {urlIndex + 1}/{totalUrls}", @@ -813,8 +813,8 @@ Return ONLY valid JSON, no additional text: if urlOperationId: displayUrl = url[:50] + "..." if len(url) > 50 else url - self._get_service("chat").progressLogUpdate(urlOperationId, 0.2, f"Crawling: {displayUrl}") - self._get_service("chat").progressLogUpdate(urlOperationId, 0.3, "Initiating crawl") + self._getService("chat").progressLogUpdate(urlOperationId, 0.2, f"Crawling: {displayUrl}") + self._getService("chat").progressLogUpdate(urlOperationId, 0.3, "Initiating crawl") # Build crawl prompt model for single URL # maxWidth is passed from performWebResearch based on researchDepth @@ -829,7 +829,7 @@ Return ONLY valid JSON, no additional text: # Debug: persist crawl prompt (with URL identifier in content for clarity) debugPrompt = f"URL: {url}\n\n{crawlPrompt}" - self._get_service("utils").writeDebugFile(debugPrompt, "webcrawl_prompt") + self._getService("utils").writeDebugFile(debugPrompt, "webcrawl_prompt") # Call AI with WEB_CRAWL operation crawlOptions = AiCallOptions( @@ -838,10 +838,10 @@ Return ONLY valid JSON, no additional text: ) if urlOperationId: - self._get_service("chat").progressLogUpdate(urlOperationId, 0.4, "Calling crawl connector") + self._getService("chat").progressLogUpdate(urlOperationId, 0.4, "Calling crawl connector") # Use unified callAiContent method with parentOperationId for hierarchical logging - crawlResponse = await self._get_service("ai").callAiContent( + crawlResponse = await self._getService("ai").callAiContent( prompt=crawlPrompt, options=crawlOptions, outputFormat="json", @@ -849,22 +849,22 @@ Return ONLY valid JSON, no additional text: ) if urlOperationId: - self._get_service("chat").progressLogUpdate(urlOperationId, 0.7, "Processing crawl results") + self._getService("chat").progressLogUpdate(urlOperationId, 0.7, "Processing crawl results") # Extract content from AiResponse crawlResult = crawlResponse.content # Debug: persist crawl response if isinstance(crawlResult, str): - self._get_service("utils").writeDebugFile(crawlResult, "webcrawl_response") + self._getService("utils").writeDebugFile(crawlResult, "webcrawl_response") else: - self._get_service("utils").writeDebugFile(json.dumps(crawlResult, indent=2), "webcrawl_response") + self._getService("utils").writeDebugFile(json.dumps(crawlResult, indent=2), "webcrawl_response") # Parse crawl result if isinstance(crawlResult, str): try: # Extract JSON from response (handles markdown code blocks) - extractedJson = self._get_service("utils").jsonExtractString(crawlResult) + extractedJson = self._getService("utils").jsonExtractString(crawlResult) crawlData = json.loads(extractedJson) if extractedJson else json.loads(crawlResult) except: crawlData = {"url": url, "content": crawlResult} @@ -873,7 +873,7 @@ Return ONLY valid JSON, no additional text: # Process crawl results and create hierarchical progress logging for sub-URLs if urlOperationId: - self._get_service("chat").progressLogUpdate(urlOperationId, 0.8, "Processing crawl results") + self._getService("chat").progressLogUpdate(urlOperationId, 0.8, "Processing crawl results") # Recursively process crawl results to find nested URLs and create child operations processedResults = self._processCrawlResultsWithHierarchy(crawlData, url, urlOperationId, maxDepth, 0) @@ -891,17 +891,17 @@ Return ONLY valid JSON, no additional text: if urlOperationId: if totalUrlsCrawled > 1: - self._get_service("chat").progressLogUpdate(urlOperationId, 0.9, f"Crawled {totalUrlsCrawled} URLs (including sub-URLs)") + self._getService("chat").progressLogUpdate(urlOperationId, 0.9, f"Crawled {totalUrlsCrawled} URLs (including sub-URLs)") else: - self._get_service("chat").progressLogUpdate(urlOperationId, 0.9, "Crawl completed") - self._get_service("chat").progressLogFinish(urlOperationId, True) + self._getService("chat").progressLogUpdate(urlOperationId, 0.9, "Crawl completed") + self._getService("chat").progressLogFinish(urlOperationId, True) return results except Exception as e: logger.error(f"Error crawling URL {url}: {str(e)}") if urlOperationId: - self._get_service("chat").progressLogFinish(urlOperationId, False) + self._getService("chat").progressLogFinish(urlOperationId, False) return [{"url": url, "error": str(e)}] def _processCrawlResultsWithHierarchy( @@ -943,7 +943,7 @@ Return ONLY valid JSON, no additional text: # This is a sub-URL - create child operation workflowId = self._workflow_id() subUrlOperationId = f"{parentOperationId}_sub_{idx}_{int(time.time())}" - self._get_service("chat").progressLogStart( + self._getService("chat").progressLogStart( subUrlOperationId, "Crawling Sub-URL", f"Depth {currentDepth + 1}", @@ -969,12 +969,12 @@ Return ONLY valid JSON, no additional text: ) item["subUrls"] = nestedResults - self._get_service("chat").progressLogUpdate(subUrlOperationId, 0.9, "Completed") - self._get_service("chat").progressLogFinish(subUrlOperationId, True) + self._getService("chat").progressLogUpdate(subUrlOperationId, 0.9, "Completed") + self._getService("chat").progressLogFinish(subUrlOperationId, True) except Exception as e: logger.error(f"Error processing sub-URL {itemUrl}: {str(e)}") if subUrlOperationId: - self._get_service("chat").progressLogFinish(subUrlOperationId, False) + self._getService("chat").progressLogFinish(subUrlOperationId, False) results.append(item) else: diff --git a/modules/shared/systemComponentRegistry.py b/modules/shared/systemComponentRegistry.py index e4733a68..70cd485d 100644 --- a/modules/shared/systemComponentRegistry.py +++ b/modules/shared/systemComponentRegistry.py @@ -6,7 +6,9 @@ Higher-layer system components (e.g. workflowAutomation) register their lifecycle hooks here at boot time via ``app.py`` (Composition Root, L7). Interface modules read the registry generically — no upward imports needed. -Supported events: ``onBootstrap``, ``onMandateDelete``, ``onInstanceCreate``. +Supported events: ``onBootstrap``, ``onMandateDelete``, ``onMandateProvision``, +``onInstanceCreate``, ``onUserMandateCreate``, ``onUserMandateDelete``, +``onUserBudgetAdjust``, ``onStorageChanged``. This is the same inversion pattern used by ``serviceAgent/externalToolRegistry.py`` for agent tools. diff --git a/modules/workflowAutomation/editor/_valueKindResolver.py b/modules/workflowAutomation/editor/_valueKindResolver.py new file mode 100644 index 00000000..63dd849d --- /dev/null +++ b/modules/workflowAutomation/editor/_valueKindResolver.py @@ -0,0 +1,102 @@ +# Copyright (c) 2025 Patrick Motsch +"""Shared value-kind resolution helpers. + +Extracted from conditionOperators so that upstreamPathsService can resolve +value kinds without importing conditionOperators (breaking the bidirectional +import cycle). +""" +from __future__ import annotations + +from typing import Any, Dict, List + + +def catalogTypeToValueKind(catalogType: str) -> str: + """Map port-catalog / dataPickOptions type strings to condition valueKind.""" + ct = (catalogType or "").strip() + if not ct or ct == "Any": + return "unknown" + low = ct.lower() + if low in ("str", "string", "email", "url"): + return "string" + if low in ("int", "float", "number"): + return "number" + if low == "bool": + return "boolean" + if low in ("date", "datetime", "timestamp"): + return "datetime" + if low.startswith("list[") or low == "list": + return "array" + if low.startswith("dict") or low == "dict": + return "object" + if low in ("file", "actiondocument", "fileref"): + return "file" + return "unknown" + + +def _isContextProducer(nodeType: str) -> bool: + return nodeType in ("context.extractContent", "context.mergeContext", "context.setContext") + + +def _pathSuggestsContext(path: List[Any], producerType: str) -> bool: + if not path: + return _isContextProducer(producerType) + last = str(path[-1]) + if last in ("data", "files", "merged", "presentation"): + return True + if "files" in [str(p) for p in path]: + return True + if _isContextProducer(producerType) and path[0] in ("data", "response", "merged"): + return True + return False + + +def _pathSuggestsFile(path: List[Any], producerType: str) -> bool: + pathStr = [str(p) for p in path] + if producerType == "input.upload": + return True + if "file" in pathStr or "documents" in pathStr or "mimeType" in pathStr or "fileName" in pathStr: + return True + if producerType.startswith("sharepoint.") and "file" in pathStr: + return True + return False + + +def _pathsEqual(a: List[Any], b: List[Any]) -> bool: + if len(a) != len(b): + return False + return all(str(x) == str(y) for x, y in zip(a, b)) + + +def resolveValueKindFromRef(graph: Dict[str, Any], ref: Dict[str, Any]) -> str: + """Resolve condition valueKind using graph-local heuristics only. + + Unlike ``conditionOperators.resolve_value_kind`` this does NOT call + ``compute_upstream_paths``, so it is safe to import from + upstreamPathsService without creating a cycle. + """ + if not isinstance(ref, dict): + return "unknown" + producerId = ref.get("nodeId") + path = ref.get("path") or [] + if not isinstance(path, list): + path = [] + if not producerId: + return "unknown" + + nodes = graph.get("nodes") or [] + nodeById = {n.get("id"): n for n in nodes if n.get("id")} + producer = nodeById.get(producerId) or {} + producerType = str(producer.get("type") or "") + + if _pathSuggestsContext(path, producerType): + return "context" + if _pathSuggestsFile(path, producerType): + tail = str(path[-1]) if path else "" + if tail in ("mimeType", "fileName"): + return "string" + return "file" + + if producerType in ("trigger.form", "input.form") and path and str(path[0]) == "payload": + return "string" + + return "unknown" diff --git a/modules/workflowAutomation/editor/conditionOperators.py b/modules/workflowAutomation/editor/conditionOperators.py index e99defc1..5b5d611a 100644 --- a/modules/workflowAutomation/editor/conditionOperators.py +++ b/modules/workflowAutomation/editor/conditionOperators.py @@ -10,6 +10,12 @@ from typing import Any, Dict, List, Optional, Tuple from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES from modules.shared.i18nRegistry import resolveText, t +from modules.workflowAutomation.editor._valueKindResolver import ( + catalogTypeToValueKind as catalog_type_to_value_kind, + _pathSuggestsContext as _path_suggests_context, + _pathSuggestsFile as _path_suggests_file, + _pathsEqual as _paths_equal, +) logger = logging.getLogger(__name__) @@ -200,64 +206,7 @@ def localize_operator_catalog(lang: str = "de") -> Dict[str, List[Dict[str, Any] return out -def catalog_type_to_value_kind(catalog_type: str) -> str: - """Map port-catalog / dataPickOptions type strings to condition valueKind.""" - ct = (catalog_type or "").strip() - if not ct or ct == "Any": - return "unknown" - low = ct.lower() - if low in ("str", "string", "email", "url"): - return "string" - if low in ("int", "float", "number"): - return "number" - if low == "bool": - return "boolean" - if low in ("date", "datetime", "timestamp"): - return "datetime" - if low.startswith("list[") or low == "list": - return "array" - if low.startswith("dict") or low == "dict": - return "object" - if low in ("file", "actiondocument", "fileref"): - return "file" - return "unknown" - - -def _paths_equal(a: List[Any], b: List[Any]) -> bool: - if len(a) != len(b): - return False - return all(str(x) == str(y) for x, y in zip(a, b)) - - -def _is_context_producer(node_type: str) -> bool: - return node_type in ("context.extractContent", "context.mergeContext", "context.setContext") - - -def _path_suggests_context(path: List[Any], producer_type: str) -> bool: - if not path: - return _is_context_producer(producer_type) - last = str(path[-1]) - if last in ("data", "files", "merged", "presentation"): - return True - if "files" in [str(p) for p in path]: - return True - if _is_context_producer(producer_type) and path[0] in ("data", "response", "merged"): - return True - return False - - -def _path_suggests_file(path: List[Any], producer_type: str) -> bool: - path_str = [str(p) for p in path] - if producer_type == "input.upload": - return True - if "file" in path_str or "documents" in path_str or "mimeType" in path_str or "fileName" in path_str: - return True - if producer_type.startswith("sharepoint.") and "file" in path_str: - return True - return False - - -def resolve_value_kind(graph: Dict[str, Any], ref: Dict[str, Any], *, _skip_upstream: bool = False) -> str: +def resolve_value_kind(graph: Dict[str, Any], ref: Dict[str, Any]) -> str: """Resolve condition valueKind for a DataRef against the workflow graph.""" if not isinstance(ref, dict): return "unknown" @@ -281,32 +230,31 @@ def resolve_value_kind(graph: Dict[str, Any], ref: Dict[str, Any], *, _skip_upst return "string" return "file" - if not _skip_upstream: - from modules.workflowAutomation.editor.upstreamPathsService import compute_upstream_paths + from modules.workflowAutomation.editor.upstreamPathsService import compute_upstream_paths - target_id = graph.get("targetNodeId") or producer_id - matched_type: Optional[str] = None + target_id = graph.get("targetNodeId") or producer_id + matched_type: Optional[str] = None + for entry in compute_upstream_paths(graph, target_id): + if entry.get("producerNodeId") != producer_id: + continue + entry_path = entry.get("path") or [] + if _paths_equal(list(entry_path), list(path)): + matched_type = str(entry.get("type") or "Any") + break + + if matched_type is None and path: + parent_path = list(path[:-1]) for entry in compute_upstream_paths(graph, target_id): if entry.get("producerNodeId") != producer_id: continue - entry_path = entry.get("path") or [] - if _paths_equal(list(entry_path), list(path)): + if _paths_equal(list(entry.get("path") or []), parent_path): matched_type = str(entry.get("type") or "Any") break - if matched_type is None and path: - parent_path = list(path[:-1]) - for entry in compute_upstream_paths(graph, target_id): - if entry.get("producerNodeId") != producer_id: - continue - if _paths_equal(list(entry.get("path") or []), parent_path): - matched_type = str(entry.get("type") or "Any") - break - - if matched_type: - vk = catalog_type_to_value_kind(matched_type) - if vk != "unknown": - return vk + if matched_type: + vk = catalog_type_to_value_kind(matched_type) + if vk != "unknown": + return vk if producer_type in ("trigger.form", "input.form") and path and str(path[0]) == "payload": return "string" diff --git a/modules/workflowAutomation/editor/upstreamPathsService.py b/modules/workflowAutomation/editor/upstreamPathsService.py index 3639b7b7..a98be149 100644 --- a/modules/workflowAutomation/editor/upstreamPathsService.py +++ b/modules/workflowAutomation/editor/upstreamPathsService.py @@ -4,7 +4,10 @@ from __future__ import annotations from typing import Any, Dict, List, Set -from modules.workflowAutomation.editor.conditionOperators import catalog_type_to_value_kind, resolve_value_kind +from modules.workflowAutomation.editor._valueKindResolver import ( + catalogTypeToValueKind, + resolveValueKindFromRef, +) from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES from modules.nodeCatalog.portTypes import PORT_TYPE_CATALOG, PortSchema, parse_graph_defined_output_schema from modules.workflowAutomation.engine.graphUtils import buildConnectionMap, getLoopBodyNodeIds, getLoopDoneNodeIds @@ -170,14 +173,14 @@ def compute_upstream_paths(graph: Dict[str, Any], target_node_id: str) -> List[D for entry in paths: ct = str(entry.get("type") or "Any") - vk = catalog_type_to_value_kind(ct) + vk = catalogTypeToValueKind(ct) if vk == "unknown": ref = { "nodeId": entry.get("producerNodeId"), "path": entry.get("path") or [], } graph_with_target = {**graph, "targetNodeId": target_node_id} - vk = resolve_value_kind(graph_with_target, ref, _skip_upstream=True) + vk = resolveValueKindFromRef(graph_with_target, ref) entry["valueKind"] = vk return paths diff --git a/modules/workflowAutomation/engine/_runNotifications.py b/modules/workflowAutomation/engine/_runNotifications.py new file mode 100644 index 00000000..c8d7786d --- /dev/null +++ b/modules/workflowAutomation/engine/_runNotifications.py @@ -0,0 +1,118 @@ +# Copyright (c) 2025 Patrick Motsch +"""Run failure notification helpers. + +Extracted from scheduler/mainScheduler to break the bidirectional import +cycle between executionEngine and mainScheduler. The engine calls +``notifyRunFailed`` directly (same subfolder, no cycle). +""" + +import logging +from typing import Optional + +from modules.shared.eventManagement import eventManager + +logger = logging.getLogger(__name__) + + +def notifyRunFailed( + workflowId: str, + runId: str, + error: str, + mandateId: str = None, + workflowLabel: str = None, +) -> None: + """Notify on workflow run failure: emit event, create in-app notification, trigger email subscription.""" + try: + eventManager.emit("workflowAutomation.run.failed", { + "workflowId": workflowId, + "runId": runId, + "error": error, + "mandateId": mandateId, + }) + logger.info("Emitted run.failed event for run %s (workflow %s)", runId, workflowId) + except Exception as e: + logger.warning("Failed to emit run.failed event: %s", e) + + _createRunFailedNotification(workflowId, runId, error, mandateId, workflowLabel) + _triggerRunFailedSubscription(workflowId, runId, error, mandateId, workflowLabel) + + +def _createRunFailedNotification( + workflowId: str, + runId: str, + error: str, + mandateId: str = None, + workflowLabel: str = None, +) -> None: + """Create in-app notification for the workflow creator.""" + try: + from modules.interfaces.interfaceDbApp import getRootInterface + from modules.datamodels.datamodelNotification import UserNotification, NotificationType, NotificationStatus + + rootInterface = getRootInterface() + if not rootInterface: + return + + from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface + eventUser = rootInterface.getUserByUsername("event") + if not eventUser: + return + + iface = _getWorkflowAutomationInterface(eventUser, mandateId or "", "") + wf = iface.getWorkflow(workflowId) + if not wf: + return + + creatorId = wf.get("sysCreatedBy") if isinstance(wf, dict) else getattr(wf, "sysCreatedBy", None) + if not creatorId: + return + + label = workflowLabel or (wf.get("label") if isinstance(wf, dict) else getattr(wf, "label", "")) + notification = UserNotification( + userId=creatorId, + type=NotificationType.SYSTEM, + status=NotificationStatus.UNREAD, + title="Workflow fehlgeschlagen", + message=f"Workflow '{label or workflowId}' ist fehlgeschlagen: {error[:200]}", + referenceType="AutoRun", + referenceId=runId, + icon="alert-triangle", + ) + rootInterface.db.recordCreate( + model_class=UserNotification, + record=notification.model_dump(), + ) + logger.info("Created in-app notification for user %s (run %s)", creatorId, runId) + except Exception as e: + logger.warning("Failed to create in-app run.failed notification: %s", e) + + +_onRunFailedCallback = None + + +def setOnRunFailedCallback(callback) -> None: + """Set the callback for run failure notifications (injected by app.py).""" + global _onRunFailedCallback + _onRunFailedCallback = callback + + +def _triggerRunFailedSubscription( + workflowId: str, + runId: str, + error: str, + mandateId: str = None, + workflowLabel: str = None, +) -> None: + """Trigger the messaging subscription for run failures via injected callback.""" + if _onRunFailedCallback is None: + return + try: + _onRunFailedCallback( + workflowId=workflowId, + runId=runId, + error=error, + mandateId=mandateId, + workflowLabel=workflowLabel, + ) + except Exception as e: + logger.warning("Failed to trigger run.failed subscription: %s", e) diff --git a/modules/workflowAutomation/engine/executionEngine.py b/modules/workflowAutomation/engine/executionEngine.py index 99f7c2ed..b1c877c2 100644 --- a/modules/workflowAutomation/engine/executionEngine.py +++ b/modules/workflowAutomation/engine/executionEngine.py @@ -1540,15 +1540,6 @@ async def executeGraph( duration_ms=_emailPauseMs, ) logger.info("executeGraph paused for email wait (run %s, node %s)", e.runId, e.nodeId) - try: - from modules.interfaces.interfaceDbApp import getRootInterface - from modules.workflowAutomation.scheduler.emailPoller import ensureRunning - root = getRootInterface() - event_user = root.getUserByUsername("event") if root else None - if event_user: - ensureRunning(event_user) - except Exception as poll_err: - logger.warning("Could not start email poller: %s", poll_err) paused_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") run_ctx = { "connectionMap": context.get("connectionMap"), @@ -1612,7 +1603,7 @@ async def executeGraph( ) if _wfObj else {} _shouldNotify = _wfDict.get("notifyOnFailure", True) if _wfDict else True if _shouldNotify: - from modules.workflowAutomation.scheduler.mainScheduler import notifyRunFailed + from modules.workflowAutomation.engine._runNotifications import notifyRunFailed notifyRunFailed( workflowId or "", runId or "", str(e), mandateId=mandateId, diff --git a/modules/workflowAutomation/engine/graphUtils.py b/modules/workflowAutomation/engine/graphUtils.py index c6a4b5cd..68368b48 100644 --- a/modules/workflowAutomation/engine/graphUtils.py +++ b/modules/workflowAutomation/engine/graphUtils.py @@ -383,6 +383,21 @@ def _pathContainsWildcard(path: List[Any]) -> bool: # (``featureInstanceRefMigration.materializeFeatureInstanceRefs``) writes the # envelope, the resolver unwraps it on its way to the action. +_STALE_FILE_CREATE_CONTEXT_PATHS = frozenset({ + ("responseData",), + ("response",), + ("merged",), + ("documents", 0, "documentData"), +}) + + +def remap_stale_presentation_ref_path(path: List[Any]) -> List[Any]: + """Map legacy text-handover paths to unified presentation ``data``.""" + if tuple(path) in _STALE_FILE_CREATE_CONTEXT_PATHS: + return ["data"] + return list(path) + + _TYPED_REF_PRIMARY_FIELD = { "FeatureInstanceRef": "id", "ConnectionRef": "id", @@ -450,9 +465,6 @@ def resolveParameterReferences( plist = list(path) resolved = _get_by_path(data, plist) if resolved is None: - from modules.workflowAutomation.engine.pickNotPushMigration import ( - remap_stale_presentation_ref_path, - ) alt_path = remap_stale_presentation_ref_path(plist) if alt_path != plist: resolved = _get_by_path(data, alt_path) diff --git a/modules/workflowAutomation/engine/pickNotPushMigration.py b/modules/workflowAutomation/engine/pickNotPushMigration.py index 78bd63c4..14b91eae 100644 --- a/modules/workflowAutomation/engine/pickNotPushMigration.py +++ b/modules/workflowAutomation/engine/pickNotPushMigration.py @@ -21,7 +21,11 @@ from modules.nodeCatalog.portTypes import ( PRIMARY_TEXT_HANDOVER_REF_PATH, resolve_output_schema_name, ) -from modules.workflowAutomation.engine.graphUtils import buildConnectionMap, getInputSources +from modules.workflowAutomation.engine.graphUtils import ( + buildConnectionMap, + getInputSources, + remap_stale_presentation_ref_path, +) logger = logging.getLogger(__name__) @@ -243,20 +247,6 @@ def materializeRecommendedDataPickRef(graph: Dict[str, Any]) -> Dict[str, Any]: return g -_STALE_FILE_CREATE_CONTEXT_PATHS = frozenset({ - ("responseData",), - ("response",), - ("merged",), - ("documents", 0, "documentData"), -}) - - -def remap_stale_presentation_ref_path(path: List[Any]) -> List[Any]: - """Map legacy text-handover paths to unified presentation ``data``.""" - if tuple(path) in _STALE_FILE_CREATE_CONTEXT_PATHS: - return ["data"] - return list(path) - def _normalize_presentation_refs_in_value(val: Any) -> Any: """Rewrite stale ref paths inside ``contextBuilder`` lists or bare refs.""" diff --git a/modules/workflowAutomation/mainWorkflowAutomation.py b/modules/workflowAutomation/mainWorkflowAutomation.py index e3a38d84..a05064c9 100644 --- a/modules/workflowAutomation/mainWorkflowAutomation.py +++ b/modules/workflowAutomation/mainWorkflowAutomation.py @@ -57,8 +57,8 @@ def _getWorkflowAutomationServices( ctx = ServiceCenterContext( user=user, - mandate_id=mandateId, - feature_instance_id=featureInstanceId, + mandateId=mandateId, + featureInstanceId=featureInstanceId, workflow=_workflow, ) return ServicesBag(ctx, lambda key: getService(key, ctx)) diff --git a/modules/workflowAutomation/scheduler/__init__.py b/modules/workflowAutomation/scheduler/__init__.py index d5178091..ab966ca5 100644 --- a/modules/workflowAutomation/scheduler/__init__.py +++ b/modules/workflowAutomation/scheduler/__init__.py @@ -6,6 +6,8 @@ from modules.workflowAutomation.scheduler.mainScheduler import ( stop, syncNow, setMainLoop, +) +from modules.workflowAutomation.engine._runNotifications import ( notifyRunFailed, setOnRunFailedCallback, ) diff --git a/modules/workflowAutomation/scheduler/mainScheduler.py b/modules/workflowAutomation/scheduler/mainScheduler.py index ec368480..a0ced9cc 100644 --- a/modules/workflowAutomation/scheduler/mainScheduler.py +++ b/modules/workflowAutomation/scheduler/mainScheduler.py @@ -263,6 +263,12 @@ class WorkflowScheduler: "WorkflowScheduler: executed workflow %s success=%s paused=%s", workflowId, result.get("success"), result.get("paused"), ) + if result.get("waitReason") == "email": + try: + from modules.workflowAutomation.scheduler.emailPoller import ensureRunning + ensureRunning(eventUser) + except Exception as pollErr: + logger.warning("WorkflowScheduler: could not start email poller: %s", pollErr) except Exception as e: logger.exception("WorkflowScheduler: failed to execute workflow %s: %s", workflowId, e) @@ -333,94 +339,10 @@ def _cronToIntervalSeconds(cron: str): return None -def notifyRunFailed(workflowId: str, runId: str, error: str, mandateId: str = None, workflowLabel: str = None) -> None: - """Notify on workflow run failure: emit event, create in-app notification, trigger email subscription.""" - try: - eventManager.emit("workflowAutomation.run.failed", { - "workflowId": workflowId, - "runId": runId, - "error": error, - "mandateId": mandateId, - }) - logger.info("Emitted run.failed event for run %s (workflow %s)", runId, workflowId) - except Exception as e: - logger.warning("Failed to emit run.failed event: %s", e) - - _createRunFailedNotification(workflowId, runId, error, mandateId, workflowLabel) - _triggerRunFailedSubscription(workflowId, runId, error, mandateId, workflowLabel) - - -def _createRunFailedNotification( - workflowId: str, runId: str, error: str, mandateId: str = None, workflowLabel: str = None -) -> None: - """Create in-app notification for the workflow creator.""" - try: - from modules.interfaces.interfaceDbApp import getRootInterface - from modules.datamodels.datamodelNotification import UserNotification, NotificationType, NotificationStatus - - rootInterface = getRootInterface() - if not rootInterface: - return - - from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface - eventUser = rootInterface.getUserByUsername("event") - if not eventUser: - return - - iface = _getWorkflowAutomationInterface(eventUser, mandateId or "", "") - wf = iface.getWorkflow(workflowId) - if not wf: - return - - creatorId = wf.get("sysCreatedBy") if isinstance(wf, dict) else getattr(wf, "sysCreatedBy", None) - if not creatorId: - return - - label = workflowLabel or (wf.get("label") if isinstance(wf, dict) else getattr(wf, "label", "")) - notification = UserNotification( - userId=creatorId, - type=NotificationType.SYSTEM, - status=NotificationStatus.UNREAD, - title="Workflow fehlgeschlagen", - message=f"Workflow '{label or workflowId}' ist fehlgeschlagen: {error[:200]}", - referenceType="AutoRun", - referenceId=runId, - icon="alert-triangle", - ) - rootInterface.db.recordCreate( - model_class=UserNotification, - record=notification.model_dump(), - ) - logger.info("Created in-app notification for user %s (run %s)", creatorId, runId) - except Exception as e: - logger.warning("Failed to create in-app run.failed notification: %s", e) - - -_onRunFailedCallback = None - - -def setOnRunFailedCallback(callback) -> None: - """Set the callback for run failure notifications (injected by app.py).""" - global _onRunFailedCallback - _onRunFailedCallback = callback - - -def _triggerRunFailedSubscription( - workflowId: str, runId: str, error: str, mandateId: str = None, workflowLabel: str = None -) -> None: - """Trigger the messaging subscription for run failures via injected callback.""" - if _onRunFailedCallback is None: - return - try: - _onRunFailedCallback( - workflowId=workflowId, - runId=runId, - error=error, - mandateId=mandateId, - workflowLabel=workflowLabel, - ) - except Exception as e: - logger.warning("Failed to trigger run.failed subscription: %s", e) +from modules.workflowAutomation.engine._runNotifications import ( # noqa: E402 — re-export + notifyRunFailed, + setOnRunFailedCallback, +) # Module-level singleton diff --git a/modules/workflows/processing/shared/promptGenerationActionsDynamic.py b/modules/workflows/processing/shared/promptGenerationActionsDynamic.py index dee1cc1f..7415df93 100644 --- a/modules/workflows/processing/shared/promptGenerationActionsDynamic.py +++ b/modules/workflows/processing/shared/promptGenerationActionsDynamic.py @@ -41,7 +41,7 @@ def generateDynamicPlanSelectionPrompt(services, context: Any, learningEngine=No # Add adaptive learning context if available adaptiveContext = {} if learningEngine: - workflowId = getattr(context, 'workflow_id', 'unknown') + workflowId = getattr(context, 'workflowId', 'unknown') userPrompt = extractUserPrompt(context) adaptiveContext = learningEngine.getAdaptiveContextForActionSelection(workflowId, userPrompt) @@ -226,7 +226,7 @@ Excludes documents/connections/history entirely. # Add adaptive learning context if available adaptiveContext = {} if learningEngine: - workflowId = getattr(context, 'workflow_id', 'unknown') + workflowId = getattr(context, 'workflowId', 'unknown') adaptiveContext = learningEngine.getAdaptiveContextForParameters(workflowId, compoundActionName, parametersContext or "") if adaptiveContext: diff --git a/tests/integration/mandates/test_createMandate.py b/tests/integration/mandates/test_createMandate.py index f58f9021..1ad24b75 100644 --- a/tests/integration/mandates/test_createMandate.py +++ b/tests/integration/mandates/test_createMandate.py @@ -78,7 +78,7 @@ def _buildInterface(db: _FakeDb) -> AppObjects: def _stubCopySystemRoles(): """Avoid touching the bootstrap module (which would need a real DB).""" with patch( - "modules.interfaces.interfaceBootstrap.copySystemRolesToMandate", + "modules.interfaces.interfaceRbac.copySystemRolesToMandate", return_value=0, ): yield From 4a60086c8002d3768898ddeaf22b35b11c615678 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Tue, 9 Jun 2026 09:53:31 +0200 Subject: [PATCH 12/16] cp adapted to 2026 poweron --- app.py | 4 +- modules/aicore/aicoreBase.py | 2 +- modules/aicore/aicoreModelRegistry.py | 2 +- modules/aicore/aicoreModelSelector.py | 4 +- modules/aicore/aicorePluginAnthropic.py | 4 +- modules/aicore/aicorePluginInternal.py | 2 +- modules/aicore/aicorePluginMistral.py | 2 +- modules/aicore/aicorePluginOpenai.py | 4 +- modules/aicore/aicorePluginPerplexity.py | 2 +- modules/aicore/aicorePluginPrivateLlm.py | 2 +- modules/aicore/aicorePluginTavily.py | 2 +- modules/auth/__init__.py | 2 +- modules/auth/authentication.py | 2 +- modules/auth/csrf.py | 2 +- modules/auth/jwtService.py | 2 +- modules/auth/mfaService.py | 2 +- modules/auth/oauthConnectTicket.py | 2 +- modules/auth/oauthProviderConfig.py | 2 +- modules/auth/tokenManager.py | 2 +- modules/auth/tokenRefreshMiddleware.py | 2 +- modules/auth/tokenRefreshService.py | 2 +- modules/connectors/connectorDbPostgre.py | 2 +- modules/connectors/connectorMessagingEmail.py | 2 +- modules/connectors/connectorMessagingSms.py | 2 +- modules/connectors/connectorPreprocessor.py | 2 +- modules/connectors/connectorProviderBase.py | 2 +- .../connectors/connectorProviderClickup.py | 2 +- modules/connectors/connectorProviderFtp.py | 2 +- modules/connectors/connectorProviderGoogle.py | 2 +- .../connectors/connectorProviderInfomaniak.py | 2 +- modules/connectors/connectorProviderMsft.py | 2 +- modules/connectors/connectorResolver.py | 2 +- modules/connectors/connectorTicketsClickup.py | 2 +- modules/connectors/connectorTicketsJira.py | 2 +- modules/connectors/connectorTicketsRedmine.py | 2 +- modules/connectors/connectorVoiceGoogle.py | 2 +- modules/datamodels/__init__.py | 4 +- modules/datamodels/datamodelAi.py | 4 +- modules/datamodels/datamodelAiAudit.py | 2 +- modules/datamodels/datamodelAudit.py | 2 +- modules/datamodels/datamodelBackgroundJob.py | 2 +- modules/datamodels/datamodelBase.py | 2 +- modules/datamodels/datamodelBilling.py | 2 +- modules/datamodels/datamodelChat.py | 2 +- modules/datamodels/datamodelContent.py | 2 +- modules/datamodels/datamodelDataSource.py | 2 +- modules/datamodels/datamodelDocref.py | 2 +- modules/datamodels/datamodelDocument.py | 2 +- modules/datamodels/datamodelExtraction.py | 4 +- modules/datamodels/datamodelFeatures.py | 2 +- modules/datamodels/datamodelFiles.py | 2 +- modules/datamodels/datamodelInvitation.py | 2 +- modules/datamodels/datamodelJson.py | 2 +- modules/datamodels/datamodelKnowledge.py | 2 +- modules/datamodels/datamodelMembership.py | 2 +- modules/datamodels/datamodelMessaging.py | 2 +- modules/datamodels/datamodelNavigation.py | 2 +- modules/datamodels/datamodelNotification.py | 2 +- modules/datamodels/datamodelPagination.py | 2 +- modules/datamodels/datamodelPortTypes.py | 2 +- modules/datamodels/datamodelRbac.py | 2 +- modules/datamodels/datamodelSecurity.py | 2 +- modules/datamodels/datamodelSubscription.py | 2 +- modules/datamodels/datamodelTickets.py | 2 +- modules/datamodels/datamodelTools.py | 2 +- modules/datamodels/datamodelUam.py | 2 +- modules/datamodels/datamodelUdm.py | 2 +- modules/datamodels/datamodelUiLanguage.py | 2 +- modules/datamodels/datamodelUtils.py | 2 +- modules/datamodels/datamodelViews.py | 2 +- modules/datamodels/datamodelVoice.py | 2 +- modules/datamodels/datamodelWorkflow.py | 2 +- .../datamodels/datamodelWorkflowActions.py | 2 +- .../datamodels/datamodelWorkflowAutomation.py | 2 +- modules/datamodels/serviceExceptions.py | 2 +- modules/dbHelpers/aiAuditLogger.py | 2 +- modules/dbHelpers/auditLogger.py | 2 +- .../dbHelpers/dbMultiTenantOptimizations.py | 2 +- modules/dbHelpers/dbRegistry.py | 2 +- modules/dbHelpers/fkLabelResolver.py | 2 +- modules/dbHelpers/fkRegistry.py | 2 +- modules/dbHelpers/paginationHelpers.py | 2 +- .../features/commcoach/datamodelCommcoach.py | 2 +- .../commcoach/interfaceFeatureCommcoach.py | 2 +- modules/features/commcoach/mainCommcoach.py | 2 +- .../commcoach/routeFeatureCommcoach.py | 2 +- .../features/commcoach/serviceCommcoach.py | 2 +- .../features/commcoach/serviceCommcoachAi.py | 2 +- .../serviceCommcoachContextRetrieval.py | 2 +- .../commcoach/serviceCommcoachExport.py | 2 +- .../commcoach/serviceCommcoachGamification.py | 2 +- .../commcoach/serviceCommcoachIndexer.py | 2 +- .../commcoach/serviceCommcoachPersonas.py | 2 +- .../commcoach/serviceCommcoachScheduler.py | 2 +- .../commcoach/tests/test_contextRetrieval.py | 2 +- .../commcoach/tests/test_datamodel.py | 2 +- .../commcoach/tests/test_mainCommcoach.py | 2 +- .../commcoach/tests/test_serviceAi.py | 2 +- .../datamodelFeatureNeutralizer.py | 2 +- .../interfaceFeatureNeutralizer.py | 2 +- .../neutralization/mainNeutralization.py | 2 +- .../neutralization/neutralizePlayground.py | 2 +- .../neutralization/routeFeatureNeutralizer.py | 2 +- .../mainServiceNeutralization.py | 2 +- .../subContentPartAdapter.py | 2 +- .../serviceNeutralization/subParseString.py | 2 +- .../serviceNeutralization/subPatterns.py | 4 +- .../serviceNeutralization/subProcessBinary.py | 2 +- .../serviceNeutralization/subProcessCommon.py | 2 +- .../serviceNeutralization/subProcessList.py | 2 +- .../subProcessPdfInPlace.py | 2 +- .../serviceNeutralization/subProcessText.py | 2 +- modules/features/redmine/__init__.py | 2 +- modules/features/redmine/datamodelRedmine.py | 2 +- .../redmine/interfaceFeatureRedmine.py | 2 +- modules/features/redmine/mainRedmine.py | 2 +- .../features/redmine/routeFeatureRedmine.py | 2 +- modules/features/redmine/serviceRedmine.py | 2 +- .../features/redmine/serviceRedmineStats.py | 2 +- .../redmine/serviceRedmineStatsCache.py | 2 +- .../features/redmine/serviceRedmineSync.py | 2 +- .../features/redmine/workflows/__init__.py | 2 +- .../workflows/methodRedmine/__init__.py | 2 +- .../methodRedmine/actions/__init__.py | 2 +- .../methodRedmine/actions/_shared.py | 2 +- .../methodRedmine/actions/createTicket.py | 2 +- .../methodRedmine/actions/getStats.py | 2 +- .../methodRedmine/actions/listRelations.py | 2 +- .../methodRedmine/actions/listTickets.py | 2 +- .../methodRedmine/actions/readTicket.py | 2 +- .../methodRedmine/actions/runSync.py | 2 +- .../methodRedmine/actions/updateTicket.py | 2 +- .../workflows/methodRedmine/methodRedmine.py | 2 +- modules/features/teamsbot/__init__.py | 2 +- modules/features/teamsbot/bridgeConnector.py | 2 +- .../features/teamsbot/browserBotConnector.py | 2 +- modules/features/teamsbot/config.py | 2 +- .../features/teamsbot/datamodelTeamsbot.py | 2 +- .../teamsbot/interfaceFeatureTeamsbot.py | 2 +- modules/features/teamsbot/mainTeamsbot.py | 2 +- .../features/teamsbot/routeFeatureTeamsbot.py | 2 +- modules/features/teamsbot/service.py | 2 +- modules/features/teamsbot/serviceCommands.py | 2 +- .../features/teamsbot/serviceConversation.py | 2 +- modules/features/teamsbot/serviceWebSocket.py | 2 +- .../features/trustee/accounting/__init__.py | 2 +- .../trustee/accounting/accountingBridge.py | 2 +- .../accounting/accountingConnectorBase.py | 2 +- .../trustee/accounting/accountingDataSync.py | 2 +- .../trustee/accounting/accountingRegistry.py | 2 +- .../trustee/accounting/connectors/__init__.py | 2 +- .../connectors/accountingConnectorAbacus.py | 2 +- .../connectors/accountingConnectorBexio.py | 2 +- .../connectors/accountingConnectorRma.py | 2 +- .../trustee/datamodelFeatureTrustee.py | 2 +- .../trustee/handlerTrusteeAccounting.py | 2 +- .../trustee/interfaceFeatureTrustee.py | 2 +- modules/features/trustee/mainTrustee.py | 2 +- .../features/trustee/routeFeatureTrustee.py | 2 +- modules/features/trustee/trusteeOntology.py | 2 +- .../features/trustee/workflows/__init__.py | 2 +- .../workflows/methodTrustee/__init__.py | 2 +- .../methodTrustee/actions/extractFromFiles.py | 2 +- .../methodTrustee/actions/processDocuments.py | 2 +- .../methodTrustee/actions/queryData.py | 2 +- .../actions/refreshAccountingData.py | 2 +- .../methodTrustee/actions/syncToAccounting.py | 2 +- .../workflows/methodTrustee/methodTrustee.py | 2 +- modules/features/workspace/__init__.py | 2 +- .../workspace/datamodelFeatureWorkspace.py | 2 +- .../workspace/interfaceFeatureWorkspace.py | 2 +- modules/features/workspace/mainWorkspace.py | 2 +- .../workspace/routeFeatureWorkspace.py | 2 +- .../interfaces/_legacyMigrationTelemetry.py | 2 +- modules/interfaces/interfaceAiObjects.py | 2 +- modules/interfaces/interfaceBootstrap.py | 2 +- modules/interfaces/interfaceDbApp.py | 2 +- modules/interfaces/interfaceDbBilling.py | 2 +- modules/interfaces/interfaceDbChat.py | 2 +- modules/interfaces/interfaceDbKnowledge.py | 2 +- modules/interfaces/interfaceDbManagement.py | 4 +- modules/interfaces/interfaceDbSubscription.py | 2 +- modules/interfaces/interfaceFeatures.py | 2 +- modules/interfaces/interfaceMessaging.py | 2 +- modules/interfaces/interfaceRbac.py | 2 +- modules/interfaces/interfaceTableHelpers.py | 2 +- modules/interfaces/interfaceTicketObjects.py | 2 +- modules/interfaces/interfaceVoiceObjects.py | 2 +- .../interfaces/interfaceWorkflowAutomation.py | 2 +- modules/nodeCatalog/__init__.py | 3 +- modules/nodeCatalog/_workflowFileSchema.py | 2 +- modules/nodeCatalog/entryPoints.py | 3 +- modules/nodeCatalog/nodeAdapter.py | 2 +- .../nodeCatalog/nodeDefinitions/__init__.py | 3 +- modules/nodeCatalog/nodeDefinitions/ai.py | 3 +- .../nodeCatalog/nodeDefinitions/clickup.py | 2 +- .../nodeCatalog/nodeDefinitions/context.py | 3 +- .../nodeDefinitions/contextPickerHelp.py | 3 +- modules/nodeCatalog/nodeDefinitions/data.py | 3 +- modules/nodeCatalog/nodeDefinitions/email.py | 3 +- modules/nodeCatalog/nodeDefinitions/file.py | 3 +- modules/nodeCatalog/nodeDefinitions/flow.py | 3 +- modules/nodeCatalog/nodeDefinitions/input.py | 3 +- .../nodeCatalog/nodeDefinitions/redmine.py | 2 +- .../nodeCatalog/nodeDefinitions/sharepoint.py | 3 +- .../nodeCatalog/nodeDefinitions/triggers.py | 3 +- .../nodeCatalog/nodeDefinitions/trustee.py | 3 +- modules/nodeCatalog/portTypes.py | 2 +- modules/routes/routeAdmin.py | 2 +- modules/routes/routeAdminDatabaseHealth.py | 2 +- modules/routes/routeAdminFeatures.py | 2 +- modules/routes/routeAdminLogs.py | 2 +- modules/routes/routeAdminRbacRules.py | 2 +- .../routes/routeAdminUserAccessOverview.py | 2 +- modules/routes/routeAttributes.py | 4 +- modules/routes/routeAudit.py | 2 +- modules/routes/routeBilling.py | 2 +- modules/routes/routeClickup.py | 2 +- modules/routes/routeDataConnections.py | 4 +- modules/routes/routeDataFiles.py | 15 +++--- modules/routes/routeDataMandates.py | 2 +- modules/routes/routeDataPrompts.py | 4 +- modules/routes/routeDataSources.py | 2 +- modules/routes/routeDataUsers.py | 2 +- modules/routes/routeGdpr.py | 2 +- modules/routes/routeI18n.py | 2 +- modules/routes/routeInvitations.py | 2 +- modules/routes/routeJobs.py | 2 +- modules/routes/routeMfa.py | 2 +- modules/routes/routeNotifications.py | 2 +- modules/routes/routeRagInventory.py | 2 +- modules/routes/routeSecurityClickup.py | 2 +- modules/routes/routeSecurityGoogle.py | 2 +- modules/routes/routeSecurityInfomaniak.py | 2 +- modules/routes/routeSecurityLocal.py | 2 +- modules/routes/routeSecurityMsft.py | 2 +- modules/routes/routeSharepoint.py | 2 +- modules/routes/routeStore.py | 2 +- modules/routes/routeSubscription.py | 2 +- modules/routes/routeSystem.py | 2 +- modules/routes/routeTableViews.py | 2 +- modules/routes/routeUdb.py | 2 +- modules/routes/routeVoiceGoogle.py | 2 +- modules/routes/routeVoiceUser.py | 2 +- modules/routes/routeWorkflowAutomation.py | 2 +- modules/security/__init__.py | 2 +- modules/security/passwordUtils.py | 2 +- modules/security/rbac.py | 2 +- modules/security/rbacCatalog.py | 2 +- modules/security/rbacHelpers.py | 2 +- modules/security/rootAccess.py | 2 +- modules/serviceCenter/__init__.py | 2 +- modules/serviceCenter/context.py | 2 +- modules/serviceCenter/core/__init__.py | 2 +- modules/serviceCenter/core/flagResolution.py | 2 +- .../core/serviceSecurity/__init__.py | 2 +- .../serviceSecurity/mainServiceSecurity.py | 2 +- .../core/serviceStreaming/__init__.py | 2 +- .../serviceStreaming/mainServiceStreaming.py | 2 +- .../core/serviceUtils/__init__.py | 2 +- .../core/serviceUtils/mainServiceUtils.py | 2 +- modules/serviceCenter/core/types.py | 2 +- modules/serviceCenter/registry.py | 2 +- modules/serviceCenter/resolver.py | 2 +- modules/serviceCenter/services/__init__.py | 2 +- .../services/serviceAgent/__init__.py | 2 +- .../serviceAgent/actionToolAdapter.py | 2 +- .../services/serviceAgent/agentLoop.py | 2 +- .../serviceAgent/conversationManager.py | 2 +- .../serviceAgent/coreTools/__init__.py | 2 +- .../coreTools/_connectionTools.py | 2 +- .../coreTools/_crossWorkflowTools.py | 2 +- .../coreTools/_dataSourceTools.py | 26 ++++----- .../serviceAgent/coreTools/_documentTools.py | 15 +++--- .../serviceAgent/coreTools/_emailTools.py | 2 +- .../coreTools/_featureSubAgentTools.py | 28 +++------- .../serviceAgent/coreTools/_helpers.py | 37 ++++++------- .../serviceAgent/coreTools/_mediaTools.py | 38 ++++--------- .../serviceAgent/coreTools/_workspaceTools.py | 13 +---- .../serviceAgent/coreTools/registerCore.py | 2 +- .../services/serviceAgent/datamodelAgent.py | 2 +- .../serviceAgent/datamodelOntology.py | 2 +- .../serviceAgent/externalToolRegistry.py | 2 +- .../services/serviceAgent/featureDataAgent.py | 2 +- .../serviceAgent/featureDataProvider.py | 2 +- .../services/serviceAgent/mainServiceAgent.py | 2 +- .../serviceAgent/ontologyToPromptCompiler.py | 2 +- .../services/serviceAgent/queryValidator.py | 2 +- .../services/serviceAgent/sandboxExecutor.py | 14 ++--- .../services/serviceAgent/toolRegistry.py | 2 +- .../services/serviceAgent/toolboxRegistry.py | 2 +- .../services/serviceAi/__init__.py | 2 +- .../services/serviceAi/mainServiceAi.py | 2 +- .../services/serviceAi/subAiCallLooping.py | 2 +- .../serviceAi/subContentExtraction.py | 2 +- .../services/serviceAi/subDocumentIntents.py | 2 +- .../services/serviceAi/subJsonMerger.py | 2 +- .../serviceAi/subJsonResponseHandling.py | 2 +- .../services/serviceAi/subLoopingUseCases.py | 2 +- .../services/serviceAi/subResponseParsing.py | 2 +- .../services/serviceAi/subStructureFilling.py | 2 +- .../serviceAi/subStructureGeneration.py | 2 +- .../serviceBackgroundJobs/__init__.py | 2 +- .../mainBackgroundJobService.py | 2 +- .../services/serviceBilling/__init__.py | 2 +- .../serviceBilling/billingExhaustedNotify.py | 2 +- .../serviceBilling/billingWebhookHandler.py | 2 +- .../serviceBilling/mainServiceBilling.py | 2 +- .../services/serviceBilling/stripeCheckout.py | 2 +- .../services/serviceChat/__init__.py | 2 +- .../services/serviceChat/mainServiceChat.py | 52 +++++++++++++++++- .../services/serviceClickup/__init__.py | 2 +- .../serviceClickup/mainServiceClickup.py | 2 +- .../services/serviceExtraction/__init__.py | 2 +- .../serviceExtraction/chunking/__init__.py | 2 +- .../chunking/chunkerImage.py | 2 +- .../chunking/chunkerStructure.py | 2 +- .../chunking/chunkerTable.py | 2 +- .../serviceExtraction/chunking/chunkerText.py | 2 +- .../serviceExtraction/extractors/__init__.py | 2 +- .../extractors/extractorAudio.py | 2 +- .../extractors/extractorBinary.py | 2 +- .../extractors/extractorContainer.py | 2 +- .../extractors/extractorCsv.py | 2 +- .../extractors/extractorDocx.py | 2 +- .../extractors/extractorEmail.py | 53 +++++++++++++++---- .../extractors/extractorFolder.py | 2 +- .../extractors/extractorHtml.py | 2 +- .../extractors/extractorImage.py | 2 +- .../extractors/extractorJson.py | 2 +- .../extractors/extractorPdf.py | 2 +- .../extractors/extractorPptx.py | 2 +- .../extractors/extractorSql.py | 2 +- .../extractors/extractorText.py | 2 +- .../extractors/extractorVideo.py | 2 +- .../extractors/extractorXlsx.py | 2 +- .../extractors/extractorXml.py | 2 +- .../mainServiceExtraction.py | 2 +- .../serviceExtraction/merging/__init__.py | 2 +- .../merging/mergerDefault.py | 2 +- .../serviceExtraction/merging/mergerTable.py | 2 +- .../serviceExtraction/merging/mergerText.py | 2 +- .../services/serviceExtraction/subMerger.py | 2 +- .../services/serviceExtraction/subPipeline.py | 2 +- .../subPromptBuilderExtraction.py | 2 +- .../services/serviceExtraction/subRegistry.py | 2 +- .../services/serviceExtraction/subUtils.py | 2 +- .../services/serviceGeneration/__init__.py | 2 +- .../mainServiceGeneration.py | 4 +- .../serviceGeneration/paths/codePath.py | 2 +- .../serviceGeneration/paths/documentPath.py | 2 +- .../serviceGeneration/paths/imagePath.py | 2 +- .../renderers/_pdfFontFallback.py | 2 +- .../renderers/codeRendererBaseTemplate.py | 2 +- .../renderers/documentRendererBaseTemplate.py | 4 +- .../serviceGeneration/renderers/registry.py | 2 +- .../renderers/rendererCodeCsv.py | 2 +- .../renderers/rendererCodeJson.py | 2 +- .../renderers/rendererCodeXml.py | 2 +- .../renderers/rendererCsv.py | 2 +- .../renderers/rendererDocx.py | 2 +- .../renderers/rendererHtml.py | 2 +- .../renderers/rendererImage.py | 2 +- .../renderers/rendererJson.py | 2 +- .../renderers/rendererMarkdown.py | 2 +- .../renderers/rendererPdf.py | 4 +- .../renderers/rendererPptx.py | 2 +- .../renderers/rendererText.py | 2 +- .../renderers/rendererXlsx.py | 2 +- .../serviceGeneration/styleDefaults.py | 2 +- .../serviceGeneration/subContentGenerator.py | 2 +- .../serviceGeneration/subContentIntegrator.py | 2 +- .../serviceGeneration/subDocumentUtility.py | 4 +- .../serviceGeneration/subJsonSchema.py | 2 +- .../subPromptBuilderGeneration.py | 2 +- .../subStructureGenerator.py | 2 +- .../services/serviceKnowledge/__init__.py | 2 +- .../services/serviceKnowledge/_buildTree.py | 2 +- .../services/serviceKnowledge/costEstimate.py | 2 +- .../serviceKnowledge/mainServiceKnowledge.py | 2 +- .../services/serviceKnowledge/ragLimits.py | 2 +- .../subConnectorIngestConsumer.py | 2 +- .../subConnectorSyncClickup.py | 2 +- .../subConnectorSyncGdrive.py | 2 +- .../serviceKnowledge/subConnectorSyncGmail.py | 2 +- .../subConnectorSyncKdrive.py | 2 +- .../subConnectorSyncOutlook.py | 2 +- .../subConnectorSyncSharepoint.py | 2 +- .../serviceKnowledge/subFeatureBootstrap.py | 2 +- .../services/serviceKnowledge/subPreScan.py | 2 +- .../services/serviceKnowledge/subTextClean.py | 2 +- .../serviceKnowledge/subWalkerHelpers.py | 2 +- .../services/serviceKnowledge/udbNodes.py | 2 +- .../services/serviceMessaging/__init__.py | 2 +- .../serviceMessaging/mainServiceMessaging.py | 2 +- .../subscriptions/__init__.py | 2 +- .../subSubscriptionSystemErrors.py | 2 +- ...SubscriptionWorkflowAutomationRunFailed.py | 2 +- .../services/serviceSharepoint/__init__.py | 2 +- .../mainServiceSharepoint.py | 2 +- .../enterpriseRenewalScheduler.py | 2 +- .../mainServiceSubscription.py | 2 +- .../serviceSubscription/stripeBootstrap.py | 2 +- .../services/serviceTicket/__init__.py | 2 +- .../serviceTicket/mainServiceTicket.py | 2 +- .../services/serviceWeb/__init__.py | 2 +- .../services/serviceWeb/mainServiceWeb.py | 2 +- modules/shared/__init__.py | 2 +- modules/shared/attributeUtils.py | 2 +- modules/shared/callbackRegistry.py | 2 +- modules/shared/configuration.py | 4 +- modules/shared/dateRange.py | 2 +- modules/shared/debugLogger.py | 2 +- modules/shared/documentUtils.py | 2 +- modules/shared/eventManagement.py | 2 +- modules/shared/eventManager.py | 2 +- modules/shared/featureDiscovery.py | 2 +- modules/shared/frontendTypes.py | 2 +- modules/shared/httpResilience.py | 2 +- modules/shared/i18nRegistry.py | 2 +- modules/shared/jsonUtils.py | 2 +- modules/shared/mandateNameUtils.py | 2 +- modules/shared/progressLogger.py | 2 +- modules/shared/stripeClient.py | 2 +- modules/shared/systemComponentRegistry.py | 3 +- modules/shared/timeUtils.py | 4 +- modules/shared/voiceCatalog.py | 2 +- modules/shared/workflowArtifactVisibility.py | 3 +- modules/shared/workflowState.py | 2 +- modules/system/__init__.py | 2 +- modules/system/databaseHealth.py | 2 +- modules/system/databaseMigration.py | 2 +- modules/system/gdprDeletion.py | 2 +- modules/system/i18nBootSync.py | 2 +- modules/system/mainSystem.py | 2 +- modules/system/notifyMandateAdmins.py | 2 +- modules/system/registry.py | 2 +- modules/workflowAutomation/agentTools.py | 2 +- .../editor/_valueKindResolver.py | 3 +- .../editor/adapterValidator.py | 2 +- .../editor/conditionOperators.py | 3 +- .../workflowAutomation/editor/nodeRegistry.py | 2 +- .../workflowAutomation/editor/switchOutput.py | 3 +- .../editor/upstreamPathsService.py | 3 +- modules/workflowAutomation/engine/__init__.py | 3 +- .../engine/_runNotifications.py | 3 +- .../engine/clickupTaskUpdateMerge.py | 3 +- .../engine/executionEngine.py | 3 +- .../engine/executors/__init__.py | 3 +- .../engine/executors/actionNodeExecutor.py | 3 +- .../engine/executors/dataExecutor.py | 3 +- .../engine/executors/flowExecutor.py | 3 +- .../engine/executors/inputExecutor.py | 3 +- .../engine/executors/ioExecutor.py | 3 +- .../engine/executors/triggerExecutor.py | 3 +- .../engine/featureInstanceRefMigration.py | 3 +- .../workflowAutomation/engine/graphUtils.py | 3 +- .../engine/pickNotPushMigration.py | 3 +- .../workflowAutomation/engine/runEnvelope.py | 3 +- .../engine/runFileLogger.py | 3 +- .../workflowAutomation/engine/scheduleCron.py | 3 +- .../engine/udmUpstreamShapes.py | 3 +- modules/workflowAutomation/helpers.py | 2 +- .../mainWorkflowAutomation.py | 10 ++-- .../workflowAutomation/scheduler/__init__.py | 3 +- .../scheduler/emailPoller.py | 2 +- .../scheduler/mainScheduler.py | 2 +- .../methods/_actionSignatureValidator.py | 2 +- .../workflows/methods/methodAi/__init__.py | 2 +- modules/workflows/methods/methodAi/_common.py | 2 +- .../methods/methodAi/actions/__init__.py | 2 +- .../methods/methodAi/actions/consolidate.py | 2 +- .../methodAi/actions/convertDocument.py | 2 +- .../methods/methodAi/actions/generateCode.py | 2 +- .../methodAi/actions/generateDocument.py | 2 +- .../methods/methodAi/actions/process.py | 2 +- .../methodAi/actions/summarizeDocument.py | 2 +- .../methodAi/actions/translateDocument.py | 2 +- .../methods/methodAi/actions/webResearch.py | 2 +- .../methods/methodAi/helpers/__init__.py | 2 +- .../methods/methodAi/helpers/csvProcessing.py | 2 +- .../workflows/methods/methodAi/methodAi.py | 2 +- modules/workflows/methods/methodBase.py | 4 +- .../methods/methodClickup/__init__.py | 2 +- .../methods/methodClickup/actions/__init__.py | 2 +- .../methodClickup/actions/create_task.py | 2 +- .../methods/methodClickup/actions/get_task.py | 2 +- .../methodClickup/actions/list_fields.py | 2 +- .../methodClickup/actions/list_tasks.py | 2 +- .../methodClickup/actions/search_tasks.py | 2 +- .../methodClickup/actions/update_task.py | 2 +- .../actions/upload_attachment.py | 2 +- .../methods/methodClickup/helpers/__init__.py | 2 +- .../methodClickup/helpers/connection.py | 2 +- .../methodClickup/helpers/pathparse.py | 2 +- .../methods/methodClickup/methodClickup.py | 2 +- .../methods/methodContext/__init__.py | 2 +- .../methods/methodContext/actions/__init__.py | 2 +- .../methodContext/actions/extractContent.py | 2 +- .../methodContext/actions/filterContext.py | 2 +- .../methodContext/actions/getDocumentIndex.py | 2 +- .../methodContext/actions/mergeContext.py | 2 +- .../methodContext/actions/neutralizeData.py | 2 +- .../methodContext/actions/setContext.py | 2 +- .../methodContext/actions/transformContext.py | 2 +- .../actions/triggerPreprocessingServer.py | 2 +- .../methods/methodContext/contextEnvelope.py | 3 +- .../methods/methodContext/helpers/__init__.py | 2 +- .../methodContext/helpers/documentIndex.py | 2 +- .../methodContext/helpers/formatting.py | 2 +- .../methods/methodContext/methodContext.py | 2 +- .../workflows/methods/methodFile/__init__.py | 2 +- .../methods/methodFile/actions/__init__.py | 2 +- .../methods/methodFile/actions/create.py | 2 +- .../methods/methodFile/methodFile.py | 2 +- .../workflows/methods/methodJira/__init__.py | 2 +- .../methods/methodJira/actions/__init__.py | 2 +- .../methods/methodJira/actions/connectJira.py | 2 +- .../methodJira/actions/createCsvContent.py | 2 +- .../methodJira/actions/createExcelContent.py | 2 +- .../methodJira/actions/exportTicketsAsJson.py | 2 +- .../actions/importTicketsFromJson.py | 2 +- .../methodJira/actions/mergeTicketData.py | 2 +- .../methodJira/actions/parseCsvContent.py | 2 +- .../methodJira/actions/parseExcelContent.py | 2 +- .../methods/methodJira/helpers/__init__.py | 2 +- .../methodJira/helpers/adfConverter.py | 2 +- .../methodJira/helpers/documentParsing.py | 2 +- .../methods/methodJira/methodJira.py | 2 +- .../methods/methodOutlook/__init__.py | 2 +- .../methods/methodOutlook/actions/__init__.py | 2 +- .../composeAndDraftEmailWithContext.py | 2 +- .../methodOutlook/actions/readEmails.py | 2 +- .../methodOutlook/actions/searchEmails.py | 2 +- .../methodOutlook/actions/sendDraftEmail.py | 2 +- .../methods/methodOutlook/helpers/__init__.py | 2 +- .../methodOutlook/helpers/connection.py | 2 +- .../methodOutlook/helpers/emailProcessing.py | 2 +- .../methodOutlook/helpers/folderManagement.py | 2 +- .../methods/methodOutlook/methodOutlook.py | 2 +- .../methods/methodSharepoint/__init__.py | 2 +- .../methodSharepoint/actions/__init__.py | 2 +- .../actions/analyzeFolderUsage.py | 2 +- .../methodSharepoint/actions/copyFile.py | 2 +- .../actions/downloadFileByPath.py | 2 +- .../actions/findDocumentPath.py | 2 +- .../methodSharepoint/actions/findSiteByUrl.py | 2 +- .../methodSharepoint/actions/listDocuments.py | 2 +- .../methodSharepoint/actions/readDocuments.py | 2 +- .../actions/uploadDocument.py | 2 +- .../methodSharepoint/actions/uploadFile.py | 2 +- .../methodSharepoint/helpers/__init__.py | 2 +- .../methodSharepoint/helpers/apiClient.py | 2 +- .../methodSharepoint/helpers/connection.py | 2 +- .../helpers/documentParsing.py | 2 +- .../helpers/pathProcessing.py | 2 +- .../methodSharepoint/helpers/siteDiscovery.py | 2 +- .../methodSharepoint/methodSharepoint.py | 2 +- .../workflows/processing/adaptive/__init__.py | 2 +- .../adaptive/adaptiveLearningEngine.py | 2 +- .../processing/adaptive/contentValidator.py | 2 +- .../processing/adaptive/learningEngine.py | 2 +- .../processing/adaptive/progressTracker.py | 2 +- modules/workflows/processing/core/__init__.py | 2 +- .../processing/core/actionExecutor.py | 2 +- .../processing/core/messageCreator.py | 2 +- .../workflows/processing/core/taskPlanner.py | 4 +- .../workflows/processing/core/validator.py | 2 +- .../workflows/processing/modes/__init__.py | 2 +- .../processing/modes/modeAutomation.py | 2 +- .../workflows/processing/modes/modeBase.py | 2 +- .../workflows/processing/modes/modeDynamic.py | 2 +- .../workflows/processing/shared/__init__.py | 2 +- .../processing/shared/executionState.py | 4 +- .../processing/shared/methodDiscovery.py | 2 +- .../processing/shared/parameterValidation.py | 2 +- .../processing/shared/placeholderFactory.py | 2 +- .../shared/promptGenerationActionsDynamic.py | 2 +- .../shared/promptGenerationTaskplan.py | 2 +- .../workflows/processing/workflowProcessor.py | 2 +- modules/workflows/workflowManager.py | 2 +- scripts/script_analyze_function_imports.py | 2 +- scripts/script_analyze_imports.py | 2 +- scripts/script_db_export_migration.py | 2 +- scripts/script_generate_container_diagram.py | 2 +- scripts/script_generate_import_diagram.py | 2 +- .../script_migrate_feature_instance_refs.py | 2 +- scripts/script_remove_redundant_imports.py | 2 +- .../script_security_encrypt_all_env_files.py | 2 +- .../script_security_encrypt_config_value.py | 2 +- .../script_security_generate_master_keys.py | 2 +- scripts/script_stats_durations_from_log.py | 2 +- scripts/script_stats_get_codelines.py | 4 +- scripts/script_stats_showUnusedFunctions.py | 2 +- tests/__init__.py | 2 +- tests/conftest.py | 2 +- tests/demo/test_pwg_demo_bootstrap.py | 2 +- tests/eval/__init__.py | 2 +- tests/eval/fakeFeatureDataProvider.py | 2 +- tests/eval/runTrusteeBenchmark.py | 2 +- tests/fixtures/loadRedmineSnapshot.py | 2 +- tests/fixtures/trusteeBenchmark/__init__.py | 2 +- .../loadTrusteeBenchmarkFixture.py | 2 +- tests/functional/__init__.py | 2 +- tests/functional/test01_ai_model_selection.py | 2 +- tests/functional/test02_ai_models.py | 2 +- tests/functional/test03_ai_operations.py | 2 +- tests/functional/test04_ai_behavior.py | 2 +- tests/functional/test07_json_merge.py | 2 +- tests/functional/test08_json_finalization.py | 2 +- tests/functional/test12_json_split_merge.py | 2 +- .../functional/test13_json_completion_cuts.py | 2 +- .../test14_json_continuation_context.py | 2 +- tests/functional/test_kpi_full.py | 2 +- tests/functional/test_kpi_incomplete.py | 2 +- tests/functional/test_kpi_path.py | 2 +- tests/integration/__init__.py | 2 +- tests/integration/automation2/__init__.py | 3 +- .../test_pick_not_push_migration_v2.py | 3 +- .../extraction/test_extract_udm_pipeline.py | 2 +- .../mandates/test_createMandate.py | 2 +- .../mandates/test_provisionMandate.py | 2 +- .../mandates/test_updateMandate.py | 2 +- tests/integration/rbac/__init__.py | 2 +- .../rbac/test_platform_admin_flag.py | 2 +- tests/integration/rbac/test_rbac_database.py | 2 +- tests/integration/trustee/__init__.py | 2 +- .../trustee/test_spesenbelege_workflow_e2e.py | 2 +- tests/integration/users/test_updateUser.py | 2 +- ...xecute_graph_loop_aggregate_consolidate.py | 3 +- .../workflows/test_workflow_execution.py | 2 +- .../test_allowed_models_whitelist.py | 2 +- .../test_inline_image_paragraph.py | 2 +- .../test_large_document_render.py | 2 +- .../test_layout_primitives.py | 2 +- .../test_md_to_json_consolidation.py | 2 +- .../serviceGeneration/test_style_resolver.py | 2 +- tests/test_dateRange.py | 2 +- tests/test_service_redmine_orphans.py | 2 +- tests/test_service_redmine_stats.py | 2 +- tests/test_service_redmine_stats_cache.py | 2 +- tests/unit/__init__.py | 2 +- .../test_aicorePluginOpenai_temperature.py | 2 +- tests/unit/auth/test_mfaService.py | 2 +- .../test_connectorDbPostgre_failLoud.py | 2 +- .../test_connectorDbPostgre_pool.py | 2 +- .../unit/connectors/test_connectorResolver.py | 3 +- .../test_connectorVoiceGoogle_sttHelpers.py | 3 +- tests/unit/datamodels/test_docref.py | 2 +- tests/unit/datamodels/test_udm_bridge.py | 2 +- tests/unit/datamodels/test_udm_models.py | 2 +- tests/unit/datamodels/test_workflow_models.py | 2 +- .../test_trustee_template_workflows.py | 3 +- ...test_accountingConnectorAbacus_balances.py | 2 +- .../test_accountingConnectorBexio_balances.py | 2 +- .../test_accountingConnectorRma_balances.py | 2 +- .../test_accountingDataSync_balances.py | 2 +- .../test_action_node_connection_provenance.py | 3 +- .../graphicalEditor/test_adapter_validator.py | 2 +- .../test_condition_operator_catalog.py | 3 +- ...est_featureInstanceRef_node_definitions.py | 2 +- .../unit/graphicalEditor/test_node_adapter.py | 2 +- .../graphicalEditor/test_portTypes_catalog.py | 3 +- .../test_port_schema_recursive.py | 3 +- .../test_resolve_value_kind.py | 3 +- .../test_upstream_paths_and_graph_schema.py | 3 +- tests/unit/interfaces/test_folderRbac.py | 2 +- .../test_action_signature_validator.py | 2 +- .../test_trustee_schema_compliance.py | 3 +- tests/unit/rbac/__init__.py | 2 +- tests/unit/rbac/test_rbac_bootstrap.py | 2 +- tests/unit/rbac/test_rbac_permissions.py | 2 +- tests/unit/routes/test_folder_crud.py | 2 +- tests/unit/scripts/__init__.py | 2 +- .../test_migrate_feature_instance_refs.py | 2 +- .../test_action_tool_adapter_typed.py | 2 +- .../test_agentTrace_repairCounters.py | 2 +- .../serviceAgent/test_field_neutralization.py | 2 +- .../serviceAgent/test_workflow_tools_crud.py | 2 +- tests/unit/services/test_bootstrap_clickup.py | 2 +- tests/unit/services/test_bootstrap_gdrive.py | 2 +- tests/unit/services/test_bootstrap_gmail.py | 2 +- tests/unit/services/test_bootstrap_outlook.py | 2 +- .../services/test_bootstrap_sharepoint.py | 2 +- tests/unit/services/test_clean_email_body.py | 2 +- tests/unit/services/test_connection_purge.py | 2 +- .../test_extraction_merge_strategy.py | 2 +- .../services/test_featureDataAgent_schema.py | 2 +- .../services/test_ingestion_hash_stability.py | 2 +- .../services/test_json_extraction_merging.py | 2 +- .../test_knowledge_ingest_consumer.py | 2 +- tests/unit/services/test_queryValidator.py | 2 +- .../unit/services/test_renderer_pdf_smoke.py | 2 +- tests/unit/services/test_trusteeOntology.py | 2 +- tests/unit/shared/test_mandateNameUtils.py | 2 +- tests/unit/teamsbot/test_directorPrompts.py | 2 +- tests/unit/utils/test_json_utils.py | 2 +- .../workflow/test_flow_executor_conditions.py | 3 +- .../workflow/test_switch_filtered_output.py | 3 +- tests/unit/workflow/test_trusteeQueryData.py | 2 +- .../unit/workflow/test_workflowFileSchema.py | 2 +- .../test_featureInstanceRefMigration.py | 3 +- .../workflows/test_parameterValidation.py | 2 +- tests/unit/workflows/test_state_management.py | 2 +- tests/unit/workflows/test_trigger_executor.py | 3 +- .../test_architecture_validation.py | 2 +- .../test_featureCatalogLabels_i18n.py | 2 +- 707 files changed, 940 insertions(+), 854 deletions(-) diff --git a/app.py b/app.py index 31085e6b..68341361 100644 --- a/app.py +++ b/app.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import os import sys @@ -917,4 +917,4 @@ if __name__ == "__main__": ], check=True) except ImportError: import uvicorn - uvicorn.run("app:app", host="0.0.0.0", port=port, workers=1, timeout_graceful_shutdown=2) \ No newline at end of file + uvicorn.run("app:app", host="0.0.0.0", port=port, workers=1, timeout_graceful_shutdown=2) diff --git a/modules/aicore/aicoreBase.py b/modules/aicore/aicoreBase.py index 0908c40d..a91cef63 100644 --- a/modules/aicore/aicoreBase.py +++ b/modules/aicore/aicoreBase.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Base connector interface for AI connectors. diff --git a/modules/aicore/aicoreModelRegistry.py b/modules/aicore/aicoreModelRegistry.py index 8c57e0c4..a78229bc 100644 --- a/modules/aicore/aicoreModelRegistry.py +++ b/modules/aicore/aicoreModelRegistry.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Dynamic model registry that collects models from all AI connectors. diff --git a/modules/aicore/aicoreModelSelector.py b/modules/aicore/aicoreModelSelector.py index f51d6cec..f87b8d4c 100644 --- a/modules/aicore/aicoreModelSelector.py +++ b/modules/aicore/aicoreModelSelector.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Simplified model selection based on model properties and priority-based sorting. @@ -323,4 +323,4 @@ class ModelSelector: # Global model selector instance -modelSelector = ModelSelector() \ No newline at end of file +modelSelector = ModelSelector() diff --git a/modules/aicore/aicorePluginAnthropic.py b/modules/aicore/aicorePluginAnthropic.py index 4e873511..00fdd694 100644 --- a/modules/aicore/aicorePluginAnthropic.py +++ b/modules/aicore/aicorePluginAnthropic.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import base64 import json @@ -862,4 +862,4 @@ def _convertToolsToAnthropicFormat(openaiTools: List[Dict[str, Any]]) -> List[Di "description": fn.get("description", ""), "input_schema": fn.get("parameters", {"type": "object", "properties": {}}) }) - return anthropicTools \ No newline at end of file + return anthropicTools diff --git a/modules/aicore/aicorePluginInternal.py b/modules/aicore/aicorePluginInternal.py index 59854629..1e39cb6c 100644 --- a/modules/aicore/aicorePluginInternal.py +++ b/modules/aicore/aicorePluginInternal.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging from typing import List diff --git a/modules/aicore/aicorePluginMistral.py b/modules/aicore/aicorePluginMistral.py index a9805195..8e32c67b 100644 --- a/modules/aicore/aicorePluginMistral.py +++ b/modules/aicore/aicorePluginMistral.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging import json diff --git a/modules/aicore/aicorePluginOpenai.py b/modules/aicore/aicorePluginOpenai.py index 78f8ba26..3667d742 100644 --- a/modules/aicore/aicorePluginOpenai.py +++ b/modules/aicore/aicorePluginOpenai.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging import json @@ -730,4 +730,4 @@ class AiOpenai(BaseConnectorAi): content="", success=False, error=f"Error during image generation: {str(e)}", - ) \ No newline at end of file + ) diff --git a/modules/aicore/aicorePluginPerplexity.py b/modules/aicore/aicorePluginPerplexity.py index dd13deb1..9af3511c 100644 --- a/modules/aicore/aicorePluginPerplexity.py +++ b/modules/aicore/aicorePluginPerplexity.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging import httpx diff --git a/modules/aicore/aicorePluginPrivateLlm.py b/modules/aicore/aicorePluginPrivateLlm.py index 988ae9e4..b96a1c4a 100644 --- a/modules/aicore/aicorePluginPrivateLlm.py +++ b/modules/aicore/aicorePluginPrivateLlm.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ AI Connector for PowerOn Private-LLM Service. diff --git a/modules/aicore/aicorePluginTavily.py b/modules/aicore/aicorePluginTavily.py index 80f9d5b4..09cac0d0 100644 --- a/modules/aicore/aicorePluginTavily.py +++ b/modules/aicore/aicorePluginTavily.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Tavily web search class. """ diff --git a/modules/auth/__init__.py b/modules/auth/__init__.py index 0a485767..2ed3fa13 100644 --- a/modules/auth/__init__.py +++ b/modules/auth/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Authentication and authorization modules for routes and services. diff --git a/modules/auth/authentication.py b/modules/auth/authentication.py index d641d659..eb582fac 100644 --- a/modules/auth/authentication.py +++ b/modules/auth/authentication.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Authentication module for backend API. diff --git a/modules/auth/csrf.py b/modules/auth/csrf.py index bac4b0c3..c9193429 100644 --- a/modules/auth/csrf.py +++ b/modules/auth/csrf.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ CSRF Protection Middleware for PowerOn Gateway diff --git a/modules/auth/jwtService.py b/modules/auth/jwtService.py index 04071053..342c2d0b 100644 --- a/modules/auth/jwtService.py +++ b/modules/auth/jwtService.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ JWT Service diff --git a/modules/auth/mfaService.py b/modules/auth/mfaService.py index 3987eab9..e4841082 100644 --- a/modules/auth/mfaService.py +++ b/modules/auth/mfaService.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ MFA (Multi-Factor Authentication) Service. diff --git a/modules/auth/oauthConnectTicket.py b/modules/auth/oauthConnectTicket.py index f54187cb..af3908f7 100644 --- a/modules/auth/oauthConnectTicket.py +++ b/modules/auth/oauthConnectTicket.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Short-lived signed tickets for OAuth data-connection popups. diff --git a/modules/auth/oauthProviderConfig.py b/modules/auth/oauthProviderConfig.py index b6c482e7..713e356e 100644 --- a/modules/auth/oauthProviderConfig.py +++ b/modules/auth/oauthProviderConfig.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """OAuth scope sets for split Auth- vs Data-apps (Google / Microsoft).""" diff --git a/modules/auth/tokenManager.py b/modules/auth/tokenManager.py index e854f563..3d235da4 100644 --- a/modules/auth/tokenManager.py +++ b/modules/auth/tokenManager.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Token Manager Service diff --git a/modules/auth/tokenRefreshMiddleware.py b/modules/auth/tokenRefreshMiddleware.py index 84d2feae..a712fe6c 100644 --- a/modules/auth/tokenRefreshMiddleware.py +++ b/modules/auth/tokenRefreshMiddleware.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Token Refresh Middleware for PowerOn Gateway diff --git a/modules/auth/tokenRefreshService.py b/modules/auth/tokenRefreshService.py index bc471e6f..7cb3ddeb 100644 --- a/modules/auth/tokenRefreshService.py +++ b/modules/auth/tokenRefreshService.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Token Refresh Service for PowerOn Gateway diff --git a/modules/connectors/connectorDbPostgre.py b/modules/connectors/connectorDbPostgre.py index 493a3862..11b406ad 100644 --- a/modules/connectors/connectorDbPostgre.py +++ b/modules/connectors/connectorDbPostgre.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import contextvars import copy diff --git a/modules/connectors/connectorMessagingEmail.py b/modules/connectors/connectorMessagingEmail.py index 57dd22e7..31a55e2b 100644 --- a/modules/connectors/connectorMessagingEmail.py +++ b/modules/connectors/connectorMessagingEmail.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Azure Communication Services Email Connector diff --git a/modules/connectors/connectorMessagingSms.py b/modules/connectors/connectorMessagingSms.py index 36491b55..41ace8f2 100644 --- a/modules/connectors/connectorMessagingSms.py +++ b/modules/connectors/connectorMessagingSms.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Twilio SMS Connector diff --git a/modules/connectors/connectorPreprocessor.py b/modules/connectors/connectorPreprocessor.py index 189b6e2b..88bf56af 100644 --- a/modules/connectors/connectorPreprocessor.py +++ b/modules/connectors/connectorPreprocessor.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Preprocessor connector for executing SQL queries via HTTP API. diff --git a/modules/connectors/connectorProviderBase.py b/modules/connectors/connectorProviderBase.py index 29062386..5fd65d0e 100644 --- a/modules/connectors/connectorProviderBase.py +++ b/modules/connectors/connectorProviderBase.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Abstract base classes for the Provider-Connector architecture (1:n). diff --git a/modules/connectors/connectorProviderClickup.py b/modules/connectors/connectorProviderClickup.py index 2a2f2ba1..60e69c2a 100644 --- a/modules/connectors/connectorProviderClickup.py +++ b/modules/connectors/connectorProviderClickup.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ClickUp ProviderConnector — virtual paths for teams → lists → tasks (table rows). diff --git a/modules/connectors/connectorProviderFtp.py b/modules/connectors/connectorProviderFtp.py index b6477f82..c59915c5 100644 --- a/modules/connectors/connectorProviderFtp.py +++ b/modules/connectors/connectorProviderFtp.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """FTP/SFTP ProviderConnector stub. diff --git a/modules/connectors/connectorProviderGoogle.py b/modules/connectors/connectorProviderGoogle.py index acce4935..f9c4099f 100644 --- a/modules/connectors/connectorProviderGoogle.py +++ b/modules/connectors/connectorProviderGoogle.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Google ProviderConnector -- Drive and Gmail via Google OAuth.""" diff --git a/modules/connectors/connectorProviderInfomaniak.py b/modules/connectors/connectorProviderInfomaniak.py index 661fdb64..33727e76 100644 --- a/modules/connectors/connectorProviderInfomaniak.py +++ b/modules/connectors/connectorProviderInfomaniak.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Infomaniak ProviderConnector -- kDrive + Calendar + Contacts via PAT. diff --git a/modules/connectors/connectorProviderMsft.py b/modules/connectors/connectorProviderMsft.py index 266f9deb..4d0cfb43 100644 --- a/modules/connectors/connectorProviderMsft.py +++ b/modules/connectors/connectorProviderMsft.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Microsoft ProviderConnector -- one MSFT connection serves SharePoint, Outlook, Teams, OneDrive. diff --git a/modules/connectors/connectorResolver.py b/modules/connectors/connectorResolver.py index a6b559a0..9fbf13ca 100644 --- a/modules/connectors/connectorResolver.py +++ b/modules/connectors/connectorResolver.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ConnectorResolver -- resolves a connectionId to the correct ProviderConnector and ServiceAdapter. diff --git a/modules/connectors/connectorTicketsClickup.py b/modules/connectors/connectorTicketsClickup.py index bb43ceac..4f6f3ec6 100644 --- a/modules/connectors/connectorTicketsClickup.py +++ b/modules/connectors/connectorTicketsClickup.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ClickUp connector for CRUD operations (compatible with TicketInterface). diff --git a/modules/connectors/connectorTicketsJira.py b/modules/connectors/connectorTicketsJira.py index bfc9a370..40b7a630 100644 --- a/modules/connectors/connectorTicketsJira.py +++ b/modules/connectors/connectorTicketsJira.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Jira connector for CRUD operations (neutralized to generic ticket interface). diff --git a/modules/connectors/connectorTicketsRedmine.py b/modules/connectors/connectorTicketsRedmine.py index 9caff47d..391d86c0 100644 --- a/modules/connectors/connectorTicketsRedmine.py +++ b/modules/connectors/connectorTicketsRedmine.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Redmine REST connector. diff --git a/modules/connectors/connectorVoiceGoogle.py b/modules/connectors/connectorVoiceGoogle.py index 7ae8e54b..590fd26b 100644 --- a/modules/connectors/connectorVoiceGoogle.py +++ b/modules/connectors/connectorVoiceGoogle.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Google Cloud Speech-to-Text and Translation Connector diff --git a/modules/datamodels/__init__.py b/modules/datamodels/__init__.py index 40adbebb..ad412b8e 100644 --- a/modules/datamodels/__init__.py +++ b/modules/datamodels/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Unified modules.datamodels package. @@ -14,4 +14,4 @@ from . import datamodelChat as chat from . import datamodelFiles as files from . import datamodelVoice as voice from . import datamodelUtils as utils -from . import jsonContinuation \ No newline at end of file +from . import jsonContinuation diff --git a/modules/datamodels/datamodelAi.py b/modules/datamodels/datamodelAi.py index cd481c9a..a1d6994c 100644 --- a/modules/datamodels/datamodelAi.py +++ b/modules/datamodels/datamodelAi.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from typing import Optional, List, Dict, Any, Callable, TYPE_CHECKING, Tuple from pydantic import BaseModel, Field, ConfigDict @@ -351,4 +351,4 @@ class CodeContentPromptArgs(BaseModel): class CodeStructurePromptArgs(BaseModel): """Type-safe arguments for code structure prompt builder.""" userPrompt: str - contentParts: List[ContentPart] = Field(default_factory=list) \ No newline at end of file + contentParts: List[ContentPart] = Field(default_factory=list) diff --git a/modules/datamodels/datamodelAiAudit.py b/modules/datamodels/datamodelAiAudit.py index f78ecd23..de9942ae 100644 --- a/modules/datamodels/datamodelAiAudit.py +++ b/modules/datamodels/datamodelAiAudit.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """AI Audit Log data model for Compliance & AI-Datenfluss tracking. diff --git a/modules/datamodels/datamodelAudit.py b/modules/datamodels/datamodelAudit.py index 85cdfbf2..577380aa 100644 --- a/modules/datamodels/datamodelAudit.py +++ b/modules/datamodels/datamodelAudit.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Audit Log Data Model for database-based audit logging. diff --git a/modules/datamodels/datamodelBackgroundJob.py b/modules/datamodels/datamodelBackgroundJob.py index 809fb994..4f769186 100644 --- a/modules/datamodels/datamodelBackgroundJob.py +++ b/modules/datamodels/datamodelBackgroundJob.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Background job models: generic, reusable infrastructure for long-running tasks. diff --git a/modules/datamodels/datamodelBase.py b/modules/datamodels/datamodelBase.py index 8fc4fa44..7e5c29f1 100644 --- a/modules/datamodels/datamodelBase.py +++ b/modules/datamodels/datamodelBase.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Base Pydantic model with system-managed fields (DB + API + UI metadata).""" diff --git a/modules/datamodels/datamodelBilling.py b/modules/datamodels/datamodelBilling.py index 78024ce1..04fc5578 100644 --- a/modules/datamodels/datamodelBilling.py +++ b/modules/datamodels/datamodelBilling.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Billing models: BillingAccount, BillingTransaction, BillingSettings, UsageStatistics.""" diff --git a/modules/datamodels/datamodelChat.py b/modules/datamodels/datamodelChat.py index 7b4e21eb..32cfc2f1 100644 --- a/modules/datamodels/datamodelChat.py +++ b/modules/datamodels/datamodelChat.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Chat models: ChatWorkflow, ChatMessage, ChatLog, ChatDocument.""" diff --git a/modules/datamodels/datamodelContent.py b/modules/datamodels/datamodelContent.py index c28036cf..d3685460 100644 --- a/modules/datamodels/datamodelContent.py +++ b/modules/datamodels/datamodelContent.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Content Object data models for the container and content extraction pipeline. diff --git a/modules/datamodels/datamodelDataSource.py b/modules/datamodels/datamodelDataSource.py index 1cfaa933..85fcda23 100644 --- a/modules/datamodels/datamodelDataSource.py +++ b/modules/datamodels/datamodelDataSource.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """DataSource and ExternalEntry models for external data integration. diff --git a/modules/datamodels/datamodelDocref.py b/modules/datamodels/datamodelDocref.py index f4ce09aa..3808f900 100644 --- a/modules/datamodels/datamodelDocref.py +++ b/modules/datamodels/datamodelDocref.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Document reference models for typed document references in workflows. diff --git a/modules/datamodels/datamodelDocument.py b/modules/datamodels/datamodelDocument.py index e34c82ff..66014233 100644 --- a/modules/datamodels/datamodelDocument.py +++ b/modules/datamodels/datamodelDocument.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from typing import Any, Dict, List, Optional, Literal, Union from pydantic import BaseModel, Field, field_serializer diff --git a/modules/datamodels/datamodelExtraction.py b/modules/datamodels/datamodelExtraction.py index 38fd1d27..a9559de0 100644 --- a/modules/datamodels/datamodelExtraction.py +++ b/modules/datamodels/datamodelExtraction.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from typing import Any, Dict, List, Optional, Literal from pydantic import BaseModel, Field @@ -112,4 +112,4 @@ class ExtractionOptions(BaseModel): # Additional processing options enableParallelProcessing: bool = Field(default=True, description="Enable parallel processing of chunks") - maxConcurrentChunks: int = Field(default=5, ge=1, le=20, description="Maximum number of chunks to process concurrently") \ No newline at end of file + maxConcurrentChunks: int = Field(default=5, ge=1, le=20, description="Maximum number of chunks to process concurrently") diff --git a/modules/datamodels/datamodelFeatures.py b/modules/datamodels/datamodelFeatures.py index e43569b1..9a51f38b 100644 --- a/modules/datamodels/datamodelFeatures.py +++ b/modules/datamodels/datamodelFeatures.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Feature models: Feature definitions, instances, data sources, and shared feature types.""" diff --git a/modules/datamodels/datamodelFiles.py b/modules/datamodels/datamodelFiles.py index 6adf6642..a78148f7 100644 --- a/modules/datamodels/datamodelFiles.py +++ b/modules/datamodels/datamodelFiles.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """File-related datamodels: FileItem, FilePreview, FileData.""" diff --git a/modules/datamodels/datamodelInvitation.py b/modules/datamodels/datamodelInvitation.py index 1543b42f..01e270a4 100644 --- a/modules/datamodels/datamodelInvitation.py +++ b/modules/datamodels/datamodelInvitation.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Invitation model for self-service onboarding. diff --git a/modules/datamodels/datamodelJson.py b/modules/datamodels/datamodelJson.py index 6c7793c4..5a975f73 100644 --- a/modules/datamodels/datamodelJson.py +++ b/modules/datamodels/datamodelJson.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Unified JSON document schema and helpers used by both generation prompts and renderers. diff --git a/modules/datamodels/datamodelKnowledge.py b/modules/datamodels/datamodelKnowledge.py index 725c0158..ad837ab1 100644 --- a/modules/datamodels/datamodelKnowledge.py +++ b/modules/datamodels/datamodelKnowledge.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Knowledge Store data models: FileContentIndex, ContentChunk, WorkflowMemory. diff --git a/modules/datamodels/datamodelMembership.py b/modules/datamodels/datamodelMembership.py index 97f865d6..b59651a3 100644 --- a/modules/datamodels/datamodelMembership.py +++ b/modules/datamodels/datamodelMembership.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Membership models: UserMandate, FeatureAccess, and Junction Tables. diff --git a/modules/datamodels/datamodelMessaging.py b/modules/datamodels/datamodelMessaging.py index 904ee526..5395ad9d 100644 --- a/modules/datamodels/datamodelMessaging.py +++ b/modules/datamodels/datamodelMessaging.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Messaging models: MessagingSubscription, MessagingSubscriptionRegistration, MessagingDelivery.""" diff --git a/modules/datamodels/datamodelNavigation.py b/modules/datamodels/datamodelNavigation.py index 22f851c8..5c40a165 100644 --- a/modules/datamodels/datamodelNavigation.py +++ b/modules/datamodels/datamodelNavigation.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Navigation structure data (Layer L1 - datamodels). diff --git a/modules/datamodels/datamodelNotification.py b/modules/datamodels/datamodelNotification.py index 535e6a65..2c8e8ede 100644 --- a/modules/datamodels/datamodelNotification.py +++ b/modules/datamodels/datamodelNotification.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Notification model for in-app notifications. diff --git a/modules/datamodels/datamodelPagination.py b/modules/datamodels/datamodelPagination.py index 259f3880..2dba138d 100644 --- a/modules/datamodels/datamodelPagination.py +++ b/modules/datamodels/datamodelPagination.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Pagination models for server-side pagination, sorting, and filtering. diff --git a/modules/datamodels/datamodelPortTypes.py b/modules/datamodels/datamodelPortTypes.py index 1357af4f..b118f0e6 100644 --- a/modules/datamodels/datamodelPortTypes.py +++ b/modules/datamodels/datamodelPortTypes.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Port type catalog and primitive types for the Graphical Editor workflow system.""" diff --git a/modules/datamodels/datamodelRbac.py b/modules/datamodels/datamodelRbac.py index 7ea9d710..56196d84 100644 --- a/modules/datamodels/datamodelRbac.py +++ b/modules/datamodels/datamodelRbac.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ RBAC models: AccessRule, AccessRuleContext, Role. diff --git a/modules/datamodels/datamodelSecurity.py b/modules/datamodels/datamodelSecurity.py index 1240f088..280fdc9e 100644 --- a/modules/datamodels/datamodelSecurity.py +++ b/modules/datamodels/datamodelSecurity.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Security models: Token and AuthEvent. diff --git a/modules/datamodels/datamodelSubscription.py b/modules/datamodels/datamodelSubscription.py index c8263e37..944f7606 100644 --- a/modules/datamodels/datamodelSubscription.py +++ b/modules/datamodels/datamodelSubscription.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Subscription models: SubscriptionPlan (catalog), MandateSubscription (instance per mandate), StripePlanPrice (persisted Stripe IDs per plan). diff --git a/modules/datamodels/datamodelTickets.py b/modules/datamodels/datamodelTickets.py index 149a7458..a7aaf8be 100644 --- a/modules/datamodels/datamodelTickets.py +++ b/modules/datamodels/datamodelTickets.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Ticket datamodels used across Jira/ClickUp connectors.""" diff --git a/modules/datamodels/datamodelTools.py b/modules/datamodels/datamodelTools.py index ed369748..3f67f136 100644 --- a/modules/datamodels/datamodelTools.py +++ b/modules/datamodels/datamodelTools.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Utility data models and classes for common tools and mappings. diff --git a/modules/datamodels/datamodelUam.py b/modules/datamodels/datamodelUam.py index 1d95598c..18d1e9a5 100644 --- a/modules/datamodels/datamodelUam.py +++ b/modules/datamodels/datamodelUam.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ UAM models: User, Mandate, UserConnection. diff --git a/modules/datamodels/datamodelUdm.py b/modules/datamodels/datamodelUdm.py index c91baa90..49bad763 100644 --- a/modules/datamodels/datamodelUdm.py +++ b/modules/datamodels/datamodelUdm.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Unified Document Model (UDM) — hierarchical document tree and ContentPart bridge.""" from __future__ import annotations diff --git a/modules/datamodels/datamodelUiLanguage.py b/modules/datamodels/datamodelUiLanguage.py index 4c589bb3..b6b04cd6 100644 --- a/modules/datamodels/datamodelUiLanguage.py +++ b/modules/datamodels/datamodelUiLanguage.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """UI language sets: structured i18n entries (context, key, value).""" diff --git a/modules/datamodels/datamodelUtils.py b/modules/datamodels/datamodelUtils.py index 0bd0ed71..4713f691 100644 --- a/modules/datamodels/datamodelUtils.py +++ b/modules/datamodels/datamodelUtils.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Utility datamodels: Prompt, TextMultilingual.""" diff --git a/modules/datamodels/datamodelViews.py b/modules/datamodels/datamodelViews.py index 28625d16..7e98406f 100644 --- a/modules/datamodels/datamodelViews.py +++ b/modules/datamodels/datamodelViews.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ View models for the /api/attributes/ endpoint. diff --git a/modules/datamodels/datamodelVoice.py b/modules/datamodels/datamodelVoice.py index c3a622ac..76d9b7ae 100644 --- a/modules/datamodels/datamodelVoice.py +++ b/modules/datamodels/datamodelVoice.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Voice settings datamodel — re-exported from UAM for central voice preferences.""" diff --git a/modules/datamodels/datamodelWorkflow.py b/modules/datamodels/datamodelWorkflow.py index 490d9fb0..289f16c8 100644 --- a/modules/datamodels/datamodelWorkflow.py +++ b/modules/datamodels/datamodelWorkflow.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Workflow execution models for action definitions, AI responses, and workflow-level structures. diff --git a/modules/datamodels/datamodelWorkflowActions.py b/modules/datamodels/datamodelWorkflowActions.py index e82941f6..ff044b82 100644 --- a/modules/datamodels/datamodelWorkflowActions.py +++ b/modules/datamodels/datamodelWorkflowActions.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Workflow Action models: WorkflowActionParameter, WorkflowActionDefinition.""" diff --git a/modules/datamodels/datamodelWorkflowAutomation.py b/modules/datamodels/datamodelWorkflowAutomation.py index 51d84814..afaacd43 100644 --- a/modules/datamodels/datamodelWorkflowAutomation.py +++ b/modules/datamodels/datamodelWorkflowAutomation.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Workflow Automation models: AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask. diff --git a/modules/datamodels/serviceExceptions.py b/modules/datamodels/serviceExceptions.py index 7585c6a9..587d1c72 100644 --- a/modules/datamodels/serviceExceptions.py +++ b/modules/datamodels/serviceExceptions.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Shared service exception classes. diff --git a/modules/dbHelpers/aiAuditLogger.py b/modules/dbHelpers/aiAuditLogger.py index 060ace33..f351a733 100644 --- a/modules/dbHelpers/aiAuditLogger.py +++ b/modules/dbHelpers/aiAuditLogger.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """AI Audit Logger — records every AI provider call for compliance reporting. diff --git a/modules/dbHelpers/auditLogger.py b/modules/dbHelpers/auditLogger.py index a5b0ec9e..3c799412 100644 --- a/modules/dbHelpers/auditLogger.py +++ b/modules/dbHelpers/auditLogger.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Audit Logging System for PowerOn Gateway diff --git a/modules/dbHelpers/dbMultiTenantOptimizations.py b/modules/dbHelpers/dbMultiTenantOptimizations.py index 4b8a5e78..106b5c15 100644 --- a/modules/dbHelpers/dbMultiTenantOptimizations.py +++ b/modules/dbHelpers/dbMultiTenantOptimizations.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Database optimizations for Multi-Tenant model. diff --git a/modules/dbHelpers/dbRegistry.py b/modules/dbHelpers/dbRegistry.py index 8c24d664..73384914 100644 --- a/modules/dbHelpers/dbRegistry.py +++ b/modules/dbHelpers/dbRegistry.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Dynamic database registry — each interface self-registers its DB on import. diff --git a/modules/dbHelpers/fkLabelResolver.py b/modules/dbHelpers/fkLabelResolver.py index 940866d5..35a673af 100644 --- a/modules/dbHelpers/fkLabelResolver.py +++ b/modules/dbHelpers/fkLabelResolver.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ FK label resolution: resolve foreign-key IDs to human-readable labels. diff --git a/modules/dbHelpers/fkRegistry.py b/modules/dbHelpers/fkRegistry.py index 9ca5b1ec..2e457594 100644 --- a/modules/dbHelpers/fkRegistry.py +++ b/modules/dbHelpers/fkRegistry.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ FK-Discovery — scans the Model-Registry for `fk_target` annotations and diff --git a/modules/dbHelpers/paginationHelpers.py b/modules/dbHelpers/paginationHelpers.py index 981cd411..52235e76 100644 --- a/modules/dbHelpers/paginationHelpers.py +++ b/modules/dbHelpers/paginationHelpers.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Pagination, filtering and sorting helpers for paginated record sets. diff --git a/modules/features/commcoach/datamodelCommcoach.py b/modules/features/commcoach/datamodelCommcoach.py index 06928998..2728a1de 100644 --- a/modules/features/commcoach/datamodelCommcoach.py +++ b/modules/features/commcoach/datamodelCommcoach.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ CommCoach Feature - Data Models. diff --git a/modules/features/commcoach/interfaceFeatureCommcoach.py b/modules/features/commcoach/interfaceFeatureCommcoach.py index 8341ec1b..d4c51a27 100644 --- a/modules/features/commcoach/interfaceFeatureCommcoach.py +++ b/modules/features/commcoach/interfaceFeatureCommcoach.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Interface to CommCoach database. diff --git a/modules/features/commcoach/mainCommcoach.py b/modules/features/commcoach/mainCommcoach.py index 7050a078..ca07a9d0 100644 --- a/modules/features/commcoach/mainCommcoach.py +++ b/modules/features/commcoach/mainCommcoach.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ CommCoach Feature Container - Main Module. diff --git a/modules/features/commcoach/routeFeatureCommcoach.py b/modules/features/commcoach/routeFeatureCommcoach.py index c7759900..81e1254d 100644 --- a/modules/features/commcoach/routeFeatureCommcoach.py +++ b/modules/features/commcoach/routeFeatureCommcoach.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ CommCoach routes for the backend API. diff --git a/modules/features/commcoach/serviceCommcoach.py b/modules/features/commcoach/serviceCommcoach.py index 3aa0c1a0..b3b5ef2a 100644 --- a/modules/features/commcoach/serviceCommcoach.py +++ b/modules/features/commcoach/serviceCommcoach.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ CommCoach Service - Coaching Orchestration. diff --git a/modules/features/commcoach/serviceCommcoachAi.py b/modules/features/commcoach/serviceCommcoachAi.py index 1b9baca8..a924ec9e 100644 --- a/modules/features/commcoach/serviceCommcoachAi.py +++ b/modules/features/commcoach/serviceCommcoachAi.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ CommCoach AI Service. diff --git a/modules/features/commcoach/serviceCommcoachContextRetrieval.py b/modules/features/commcoach/serviceCommcoachContextRetrieval.py index 98673cc6..2f26cac7 100644 --- a/modules/features/commcoach/serviceCommcoachContextRetrieval.py +++ b/modules/features/commcoach/serviceCommcoachContextRetrieval.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ CommCoach Context Retrieval. diff --git a/modules/features/commcoach/serviceCommcoachExport.py b/modules/features/commcoach/serviceCommcoachExport.py index 5f8e9356..d3235e5e 100644 --- a/modules/features/commcoach/serviceCommcoachExport.py +++ b/modules/features/commcoach/serviceCommcoachExport.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ CommCoach Export Service. diff --git a/modules/features/commcoach/serviceCommcoachGamification.py b/modules/features/commcoach/serviceCommcoachGamification.py index 331dd9b1..3848c526 100644 --- a/modules/features/commcoach/serviceCommcoachGamification.py +++ b/modules/features/commcoach/serviceCommcoachGamification.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ CommCoach Gamification - Badge definitions and award logic. diff --git a/modules/features/commcoach/serviceCommcoachIndexer.py b/modules/features/commcoach/serviceCommcoachIndexer.py index 2f042795..8731d6a5 100644 --- a/modules/features/commcoach/serviceCommcoachIndexer.py +++ b/modules/features/commcoach/serviceCommcoachIndexer.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ CommCoach Session Indexer. diff --git a/modules/features/commcoach/serviceCommcoachPersonas.py b/modules/features/commcoach/serviceCommcoachPersonas.py index 867b51a0..fa99e6a2 100644 --- a/modules/features/commcoach/serviceCommcoachPersonas.py +++ b/modules/features/commcoach/serviceCommcoachPersonas.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ CommCoach Personas - Built-in roleplay persona definitions. diff --git a/modules/features/commcoach/serviceCommcoachScheduler.py b/modules/features/commcoach/serviceCommcoachScheduler.py index 51a3491d..916f481f 100644 --- a/modules/features/commcoach/serviceCommcoachScheduler.py +++ b/modules/features/commcoach/serviceCommcoachScheduler.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ CommCoach Scheduler Service. diff --git a/modules/features/commcoach/tests/test_contextRetrieval.py b/modules/features/commcoach/tests/test_contextRetrieval.py index a0dcf226..c646ca19 100644 --- a/modules/features/commcoach/tests/test_contextRetrieval.py +++ b/modules/features/commcoach/tests/test_contextRetrieval.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Tests for CommCoach context retrieval (intent detection, session lookup).""" diff --git a/modules/features/commcoach/tests/test_datamodel.py b/modules/features/commcoach/tests/test_datamodel.py index 05d174c5..1191be21 100644 --- a/modules/features/commcoach/tests/test_datamodel.py +++ b/modules/features/commcoach/tests/test_datamodel.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Tests for CommCoach data models. diff --git a/modules/features/commcoach/tests/test_mainCommcoach.py b/modules/features/commcoach/tests/test_mainCommcoach.py index bed151c8..bad60c5e 100644 --- a/modules/features/commcoach/tests/test_mainCommcoach.py +++ b/modules/features/commcoach/tests/test_mainCommcoach.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Tests for CommCoach feature registration module. diff --git a/modules/features/commcoach/tests/test_serviceAi.py b/modules/features/commcoach/tests/test_serviceAi.py index bc8647b9..8fab02ad 100644 --- a/modules/features/commcoach/tests/test_serviceAi.py +++ b/modules/features/commcoach/tests/test_serviceAi.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Tests for CommCoach AI service (prompt building and response parsing). diff --git a/modules/features/neutralization/datamodelFeatureNeutralizer.py b/modules/features/neutralization/datamodelFeatureNeutralizer.py index a308faa3..95b30a32 100644 --- a/modules/features/neutralization/datamodelFeatureNeutralizer.py +++ b/modules/features/neutralization/datamodelFeatureNeutralizer.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Neutralizer models: DataNeutraliserConfig and DataNeutralizerAttributes.""" diff --git a/modules/features/neutralization/interfaceFeatureNeutralizer.py b/modules/features/neutralization/interfaceFeatureNeutralizer.py index 97a466ff..217b4029 100644 --- a/modules/features/neutralization/interfaceFeatureNeutralizer.py +++ b/modules/features/neutralization/interfaceFeatureNeutralizer.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Database interface for the Neutralizer feature. diff --git a/modules/features/neutralization/mainNeutralization.py b/modules/features/neutralization/mainNeutralization.py index 2a8a6e19..1991b09a 100644 --- a/modules/features/neutralization/mainNeutralization.py +++ b/modules/features/neutralization/mainNeutralization.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Neutralizer Feature Container - Main Module. diff --git a/modules/features/neutralization/neutralizePlayground.py b/modules/features/neutralization/neutralizePlayground.py index 1a46cd25..407f5985 100644 --- a/modules/features/neutralization/neutralizePlayground.py +++ b/modules/features/neutralization/neutralizePlayground.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import base64 import logging diff --git a/modules/features/neutralization/routeFeatureNeutralizer.py b/modules/features/neutralization/routeFeatureNeutralizer.py index 488ef352..f66372e9 100644 --- a/modules/features/neutralization/routeFeatureNeutralizer.py +++ b/modules/features/neutralization/routeFeatureNeutralizer.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from fastapi import APIRouter, HTTPException, Depends, Path, Request, status, Query, Body, File, UploadFile from typing import List, Dict, Any, Optional diff --git a/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py b/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py index 0388dbba..86d1965b 100644 --- a/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py +++ b/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Data Neutralization Service diff --git a/modules/features/neutralization/serviceNeutralization/subContentPartAdapter.py b/modules/features/neutralization/serviceNeutralization/subContentPartAdapter.py index b7de66ca..09a7feb4 100644 --- a/modules/features/neutralization/serviceNeutralization/subContentPartAdapter.py +++ b/modules/features/neutralization/serviceNeutralization/subContentPartAdapter.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Adapter to convert ContentPart list (from extraction) to renderer JSON schema. diff --git a/modules/features/neutralization/serviceNeutralization/subParseString.py b/modules/features/neutralization/serviceNeutralization/subParseString.py index 86ef2f16..54e52da3 100644 --- a/modules/features/neutralization/serviceNeutralization/subParseString.py +++ b/modules/features/neutralization/serviceNeutralization/subParseString.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ String parsing and replacement utilities for data anonymization diff --git a/modules/features/neutralization/serviceNeutralization/subPatterns.py b/modules/features/neutralization/serviceNeutralization/subPatterns.py index f83c817e..642629c6 100644 --- a/modules/features/neutralization/serviceNeutralization/subPatterns.py +++ b/modules/features/neutralization/serviceNeutralization/subPatterns.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Pattern definitions for data anonymization @@ -470,4 +470,4 @@ def findPatternsInText(text: str, patterns: List[Pattern]) -> List[tuple]: for p in pattern.patterns: for match in re.finditer(p, text, re.IGNORECASE): matches.append((pattern.name, match.group(0), match.start(), match.end())) - return sorted(matches, key=lambda x: x[2]) # Sort by start position \ No newline at end of file + return sorted(matches, key=lambda x: x[2]) # Sort by start position diff --git a/modules/features/neutralization/serviceNeutralization/subProcessBinary.py b/modules/features/neutralization/serviceNeutralization/subProcessBinary.py index 28bbf3ee..d1df1ed2 100644 --- a/modules/features/neutralization/serviceNeutralization/subProcessBinary.py +++ b/modules/features/neutralization/serviceNeutralization/subProcessBinary.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Binary data processing module for data anonymization diff --git a/modules/features/neutralization/serviceNeutralization/subProcessCommon.py b/modules/features/neutralization/serviceNeutralization/subProcessCommon.py index dd49ae75..6c097e45 100644 --- a/modules/features/neutralization/serviceNeutralization/subProcessCommon.py +++ b/modules/features/neutralization/serviceNeutralization/subProcessCommon.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Common processing utilities for data anonymization diff --git a/modules/features/neutralization/serviceNeutralization/subProcessList.py b/modules/features/neutralization/serviceNeutralization/subProcessList.py index 021cec2b..a42904ff 100644 --- a/modules/features/neutralization/serviceNeutralization/subProcessList.py +++ b/modules/features/neutralization/serviceNeutralization/subProcessList.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ List processing module for data anonymization diff --git a/modules/features/neutralization/serviceNeutralization/subProcessPdfInPlace.py b/modules/features/neutralization/serviceNeutralization/subProcessPdfInPlace.py index b0c84327..695c8584 100644 --- a/modules/features/neutralization/serviceNeutralization/subProcessPdfInPlace.py +++ b/modules/features/neutralization/serviceNeutralization/subProcessPdfInPlace.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ PDF in-place neutralization using PyMuPDF. diff --git a/modules/features/neutralization/serviceNeutralization/subProcessText.py b/modules/features/neutralization/serviceNeutralization/subProcessText.py index eea270b9..bdc4c995 100644 --- a/modules/features/neutralization/serviceNeutralization/subProcessText.py +++ b/modules/features/neutralization/serviceNeutralization/subProcessText.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Text processing module for data anonymization diff --git a/modules/features/redmine/__init__.py b/modules/features/redmine/__init__.py index 964637d5..ba98788e 100644 --- a/modules/features/redmine/__init__.py +++ b/modules/features/redmine/__init__.py @@ -1,3 +1,3 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Redmine feature container -- ticket browser, statistics, AI tools.""" diff --git a/modules/features/redmine/datamodelRedmine.py b/modules/features/redmine/datamodelRedmine.py index e33ee407..eb5c7f27 100644 --- a/modules/features/redmine/datamodelRedmine.py +++ b/modules/features/redmine/datamodelRedmine.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Redmine feature data models. diff --git a/modules/features/redmine/interfaceFeatureRedmine.py b/modules/features/redmine/interfaceFeatureRedmine.py index 225b5a31..fa885151 100644 --- a/modules/features/redmine/interfaceFeatureRedmine.py +++ b/modules/features/redmine/interfaceFeatureRedmine.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Interface for the Redmine feature. diff --git a/modules/features/redmine/mainRedmine.py b/modules/features/redmine/mainRedmine.py index fe893cef..2d3d8d5f 100644 --- a/modules/features/redmine/mainRedmine.py +++ b/modules/features/redmine/mainRedmine.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Redmine Feature Container -- Main Module. diff --git a/modules/features/redmine/routeFeatureRedmine.py b/modules/features/redmine/routeFeatureRedmine.py index bdc8797b..86ac8d30 100644 --- a/modules/features/redmine/routeFeatureRedmine.py +++ b/modules/features/redmine/routeFeatureRedmine.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """FastAPI routes for the Redmine feature. diff --git a/modules/features/redmine/serviceRedmine.py b/modules/features/redmine/serviceRedmine.py index d772478b..0fe2f2b3 100644 --- a/modules/features/redmine/serviceRedmine.py +++ b/modules/features/redmine/serviceRedmine.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Redmine service layer. diff --git a/modules/features/redmine/serviceRedmineStats.py b/modules/features/redmine/serviceRedmineStats.py index 8566db16..dc187d85 100644 --- a/modules/features/redmine/serviceRedmineStats.py +++ b/modules/features/redmine/serviceRedmineStats.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Redmine statistics aggregator. diff --git a/modules/features/redmine/serviceRedmineStatsCache.py b/modules/features/redmine/serviceRedmineStatsCache.py index 12176178..ae9f9718 100644 --- a/modules/features/redmine/serviceRedmineStatsCache.py +++ b/modules/features/redmine/serviceRedmineStatsCache.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """TTL-based in-memory cache for ``serviceRedmineStats`` results. diff --git a/modules/features/redmine/serviceRedmineSync.py b/modules/features/redmine/serviceRedmineSync.py index a56198f1..38cdd4f9 100644 --- a/modules/features/redmine/serviceRedmineSync.py +++ b/modules/features/redmine/serviceRedmineSync.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Incremental Redmine -> ``poweron_redmine`` mirror sync. diff --git a/modules/features/redmine/workflows/__init__.py b/modules/features/redmine/workflows/__init__.py index 8c4ceb1a..0ec13b90 100644 --- a/modules/features/redmine/workflows/__init__.py +++ b/modules/features/redmine/workflows/__init__.py @@ -1,3 +1,3 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Feature-owned workflow methods for Redmine.""" diff --git a/modules/features/redmine/workflows/methodRedmine/__init__.py b/modules/features/redmine/workflows/methodRedmine/__init__.py index d141dd48..2c8e1590 100644 --- a/modules/features/redmine/workflows/methodRedmine/__init__.py +++ b/modules/features/redmine/workflows/methodRedmine/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Redmine workflow method: read / list / create / update / stats / sync.""" diff --git a/modules/features/redmine/workflows/methodRedmine/actions/__init__.py b/modules/features/redmine/workflows/methodRedmine/actions/__init__.py index 746291ab..06003961 100644 --- a/modules/features/redmine/workflows/methodRedmine/actions/__init__.py +++ b/modules/features/redmine/workflows/methodRedmine/actions/__init__.py @@ -1,2 +1,2 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. diff --git a/modules/features/redmine/workflows/methodRedmine/actions/_shared.py b/modules/features/redmine/workflows/methodRedmine/actions/_shared.py index b7c585d3..0b7709dc 100644 --- a/modules/features/redmine/workflows/methodRedmine/actions/_shared.py +++ b/modules/features/redmine/workflows/methodRedmine/actions/_shared.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Shared helpers for Redmine workflow actions. diff --git a/modules/features/redmine/workflows/methodRedmine/actions/createTicket.py b/modules/features/redmine/workflows/methodRedmine/actions/createTicket.py index 499d21fb..d7924f2f 100644 --- a/modules/features/redmine/workflows/methodRedmine/actions/createTicket.py +++ b/modules/features/redmine/workflows/methodRedmine/actions/createTicket.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Workflow action: create a new Redmine ticket.""" diff --git a/modules/features/redmine/workflows/methodRedmine/actions/getStats.py b/modules/features/redmine/workflows/methodRedmine/actions/getStats.py index e939bf53..3da3fdd9 100644 --- a/modules/features/redmine/workflows/methodRedmine/actions/getStats.py +++ b/modules/features/redmine/workflows/methodRedmine/actions/getStats.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Workflow action: fetch aggregated Redmine statistics from the mirror.""" diff --git a/modules/features/redmine/workflows/methodRedmine/actions/listRelations.py b/modules/features/redmine/workflows/methodRedmine/actions/listRelations.py index 90f44594..b8d45cfa 100644 --- a/modules/features/redmine/workflows/methodRedmine/actions/listRelations.py +++ b/modules/features/redmine/workflows/methodRedmine/actions/listRelations.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Workflow action: list Redmine relations from the mirror.""" diff --git a/modules/features/redmine/workflows/methodRedmine/actions/listTickets.py b/modules/features/redmine/workflows/methodRedmine/actions/listTickets.py index 8573237a..17c6094c 100644 --- a/modules/features/redmine/workflows/methodRedmine/actions/listTickets.py +++ b/modules/features/redmine/workflows/methodRedmine/actions/listTickets.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Workflow action: list Redmine tickets from the mirror with filters.""" diff --git a/modules/features/redmine/workflows/methodRedmine/actions/readTicket.py b/modules/features/redmine/workflows/methodRedmine/actions/readTicket.py index 69ea4459..afd3f5be 100644 --- a/modules/features/redmine/workflows/methodRedmine/actions/readTicket.py +++ b/modules/features/redmine/workflows/methodRedmine/actions/readTicket.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Workflow action: read a single Redmine ticket from the mirror. diff --git a/modules/features/redmine/workflows/methodRedmine/actions/runSync.py b/modules/features/redmine/workflows/methodRedmine/actions/runSync.py index 64a9bff9..f4a65d53 100644 --- a/modules/features/redmine/workflows/methodRedmine/actions/runSync.py +++ b/modules/features/redmine/workflows/methodRedmine/actions/runSync.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Workflow action: trigger an incremental (or full) Redmine mirror sync.""" diff --git a/modules/features/redmine/workflows/methodRedmine/actions/updateTicket.py b/modules/features/redmine/workflows/methodRedmine/actions/updateTicket.py index 4e396093..eacb5a38 100644 --- a/modules/features/redmine/workflows/methodRedmine/actions/updateTicket.py +++ b/modules/features/redmine/workflows/methodRedmine/actions/updateTicket.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Workflow action: update a single Redmine ticket and refresh the mirror.""" diff --git a/modules/features/redmine/workflows/methodRedmine/methodRedmine.py b/modules/features/redmine/workflows/methodRedmine/methodRedmine.py index 700375cd..1b26308e 100644 --- a/modules/features/redmine/workflows/methodRedmine/methodRedmine.py +++ b/modules/features/redmine/workflows/methodRedmine/methodRedmine.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Redmine workflow method. diff --git a/modules/features/teamsbot/__init__.py b/modules/features/teamsbot/__init__.py index fdcc4f0e..06003961 100644 --- a/modules/features/teamsbot/__init__.py +++ b/modules/features/teamsbot/__init__.py @@ -1,2 +1,2 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. diff --git a/modules/features/teamsbot/bridgeConnector.py b/modules/features/teamsbot/bridgeConnector.py index f97f4103..35cd4401 100644 --- a/modules/features/teamsbot/bridgeConnector.py +++ b/modules/features/teamsbot/bridgeConnector.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Bridge Connector - Communication with the .NET Media Bridge. diff --git a/modules/features/teamsbot/browserBotConnector.py b/modules/features/teamsbot/browserBotConnector.py index d99fe829..77df0300 100644 --- a/modules/features/teamsbot/browserBotConnector.py +++ b/modules/features/teamsbot/browserBotConnector.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Browser Bot Connector - Communication with the Node.js Browser Bot Service. diff --git a/modules/features/teamsbot/config.py b/modules/features/teamsbot/config.py index 11a9e3ff..e8a7adc2 100644 --- a/modules/features/teamsbot/config.py +++ b/modules/features/teamsbot/config.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Teamsbot Feature - Configuration utilities. diff --git a/modules/features/teamsbot/datamodelTeamsbot.py b/modules/features/teamsbot/datamodelTeamsbot.py index 70ba5fd5..4bb41ccf 100644 --- a/modules/features/teamsbot/datamodelTeamsbot.py +++ b/modules/features/teamsbot/datamodelTeamsbot.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Teamsbot Feature - Data Models. diff --git a/modules/features/teamsbot/interfaceFeatureTeamsbot.py b/modules/features/teamsbot/interfaceFeatureTeamsbot.py index 5afeea69..9eca9492 100644 --- a/modules/features/teamsbot/interfaceFeatureTeamsbot.py +++ b/modules/features/teamsbot/interfaceFeatureTeamsbot.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Interface to Teamsbot database. diff --git a/modules/features/teamsbot/mainTeamsbot.py b/modules/features/teamsbot/mainTeamsbot.py index 5a003182..afb04004 100644 --- a/modules/features/teamsbot/mainTeamsbot.py +++ b/modules/features/teamsbot/mainTeamsbot.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Teamsbot Feature Container - Main Module. diff --git a/modules/features/teamsbot/routeFeatureTeamsbot.py b/modules/features/teamsbot/routeFeatureTeamsbot.py index c0862ba1..b2ac2980 100644 --- a/modules/features/teamsbot/routeFeatureTeamsbot.py +++ b/modules/features/teamsbot/routeFeatureTeamsbot.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Teamsbot routes for the backend API. diff --git a/modules/features/teamsbot/service.py b/modules/features/teamsbot/service.py index 2487ad81..942096a1 100644 --- a/modules/features/teamsbot/service.py +++ b/modules/features/teamsbot/service.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Teamsbot Service - Pipeline Orchestrator. diff --git a/modules/features/teamsbot/serviceCommands.py b/modules/features/teamsbot/serviceCommands.py index a8ce763f..5e08f879 100644 --- a/modules/features/teamsbot/serviceCommands.py +++ b/modules/features/teamsbot/serviceCommands.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Teamsbot Service — AI command execution logic. diff --git a/modules/features/teamsbot/serviceConversation.py b/modules/features/teamsbot/serviceConversation.py index bf844d89..43835aa6 100644 --- a/modules/features/teamsbot/serviceConversation.py +++ b/modules/features/teamsbot/serviceConversation.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Teamsbot Service — Conversation & AI analysis logic. diff --git a/modules/features/teamsbot/serviceWebSocket.py b/modules/features/teamsbot/serviceWebSocket.py index 2c462624..bea744dc 100644 --- a/modules/features/teamsbot/serviceWebSocket.py +++ b/modules/features/teamsbot/serviceWebSocket.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Teamsbot Service — WebSocket handler & audio chunk processing. diff --git a/modules/features/trustee/accounting/__init__.py b/modules/features/trustee/accounting/__init__.py index fdcc4f0e..06003961 100644 --- a/modules/features/trustee/accounting/__init__.py +++ b/modules/features/trustee/accounting/__init__.py @@ -1,2 +1,2 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. diff --git a/modules/features/trustee/accounting/accountingBridge.py b/modules/features/trustee/accounting/accountingBridge.py index 7fb26b3a..51433bbf 100644 --- a/modules/features/trustee/accounting/accountingBridge.py +++ b/modules/features/trustee/accounting/accountingBridge.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Accounting bridge: standardised interface between Trustee and external accounting systems. diff --git a/modules/features/trustee/accounting/accountingConnectorBase.py b/modules/features/trustee/accounting/accountingConnectorBase.py index 6a59509f..5a569b75 100644 --- a/modules/features/trustee/accounting/accountingConnectorBase.py +++ b/modules/features/trustee/accounting/accountingConnectorBase.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Abstract base class and standard data models for accounting system connectors.""" diff --git a/modules/features/trustee/accounting/accountingDataSync.py b/modules/features/trustee/accounting/accountingDataSync.py index 8ee3b431..82996492 100644 --- a/modules/features/trustee/accounting/accountingDataSync.py +++ b/modules/features/trustee/accounting/accountingDataSync.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Orchestrates importing accounting data from external systems into TrusteeData* tables. diff --git a/modules/features/trustee/accounting/accountingRegistry.py b/modules/features/trustee/accounting/accountingRegistry.py index fe1b20d5..c17933da 100644 --- a/modules/features/trustee/accounting/accountingRegistry.py +++ b/modules/features/trustee/accounting/accountingRegistry.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Plugin-discovery registry for accounting connectors (analogous to aicoreModelRegistry).""" diff --git a/modules/features/trustee/accounting/connectors/__init__.py b/modules/features/trustee/accounting/connectors/__init__.py index fdcc4f0e..06003961 100644 --- a/modules/features/trustee/accounting/connectors/__init__.py +++ b/modules/features/trustee/accounting/connectors/__init__.py @@ -1,2 +1,2 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. diff --git a/modules/features/trustee/accounting/connectors/accountingConnectorAbacus.py b/modules/features/trustee/accounting/connectors/accountingConnectorAbacus.py index a1947b27..04427234 100644 --- a/modules/features/trustee/accounting/connectors/accountingConnectorAbacus.py +++ b/modules/features/trustee/accounting/connectors/accountingConnectorAbacus.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Abacus ERP accounting connector. diff --git a/modules/features/trustee/accounting/connectors/accountingConnectorBexio.py b/modules/features/trustee/accounting/connectors/accountingConnectorBexio.py index 28c2a334..4021d23e 100644 --- a/modules/features/trustee/accounting/connectors/accountingConnectorBexio.py +++ b/modules/features/trustee/accounting/connectors/accountingConnectorBexio.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Bexio accounting connector. diff --git a/modules/features/trustee/accounting/connectors/accountingConnectorRma.py b/modules/features/trustee/accounting/connectors/accountingConnectorRma.py index 98634127..034fb2d3 100644 --- a/modules/features/trustee/accounting/connectors/accountingConnectorRma.py +++ b/modules/features/trustee/accounting/connectors/accountingConnectorRma.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Run My Accounts (Infoniqa) accounting connector. diff --git a/modules/features/trustee/datamodelFeatureTrustee.py b/modules/features/trustee/datamodelFeatureTrustee.py index ad85105e..65d7a1b5 100644 --- a/modules/features/trustee/datamodelFeatureTrustee.py +++ b/modules/features/trustee/datamodelFeatureTrustee.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Trustee models: TrusteeOrganisation, TrusteeRole, TrusteeAccess, TrusteeContract, TrusteeDocument, TrusteePosition.""" diff --git a/modules/features/trustee/handlerTrusteeAccounting.py b/modules/features/trustee/handlerTrusteeAccounting.py index 212d20e3..0e0103fa 100644 --- a/modules/features/trustee/handlerTrusteeAccounting.py +++ b/modules/features/trustee/handlerTrusteeAccounting.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Business logic for Trustee accounting integration endpoints. diff --git a/modules/features/trustee/interfaceFeatureTrustee.py b/modules/features/trustee/interfaceFeatureTrustee.py index 4efaaaef..1e13f185 100644 --- a/modules/features/trustee/interfaceFeatureTrustee.py +++ b/modules/features/trustee/interfaceFeatureTrustee.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Interface to Trustee database. diff --git a/modules/features/trustee/mainTrustee.py b/modules/features/trustee/mainTrustee.py index 4bcee319..33112a12 100644 --- a/modules/features/trustee/mainTrustee.py +++ b/modules/features/trustee/mainTrustee.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Trustee Feature Container - Main Module. diff --git a/modules/features/trustee/routeFeatureTrustee.py b/modules/features/trustee/routeFeatureTrustee.py index 8b2452af..c06f8604 100644 --- a/modules/features/trustee/routeFeatureTrustee.py +++ b/modules/features/trustee/routeFeatureTrustee.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Routes for Trustee feature data management. diff --git a/modules/features/trustee/trusteeOntology.py b/modules/features/trustee/trusteeOntology.py index c5b117d7..62260439 100644 --- a/modules/features/trustee/trusteeOntology.py +++ b/modules/features/trustee/trusteeOntology.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Trustee feature ontology (Phase 2 pilot). diff --git a/modules/features/trustee/workflows/__init__.py b/modules/features/trustee/workflows/__init__.py index 976edabd..7c0edb08 100644 --- a/modules/features/trustee/workflows/__init__.py +++ b/modules/features/trustee/workflows/__init__.py @@ -1,3 +1,3 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Trustee feature-owned workflow methods.""" diff --git a/modules/features/trustee/workflows/methodTrustee/__init__.py b/modules/features/trustee/workflows/methodTrustee/__init__.py index fa7acc95..e3590d46 100644 --- a/modules/features/trustee/workflows/methodTrustee/__init__.py +++ b/modules/features/trustee/workflows/methodTrustee/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Trustee document and expense workflow method (extract, process, sync to accounting).""" diff --git a/modules/features/trustee/workflows/methodTrustee/actions/extractFromFiles.py b/modules/features/trustee/workflows/methodTrustee/actions/extractFromFiles.py index d28c8a3c..240809c1 100644 --- a/modules/features/trustee/workflows/methodTrustee/actions/extractFromFiles.py +++ b/modules/features/trustee/workflows/methodTrustee/actions/extractFromFiles.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Extract document type and structured data from files (PDF, JPG). diff --git a/modules/features/trustee/workflows/methodTrustee/actions/processDocuments.py b/modules/features/trustee/workflows/methodTrustee/actions/processDocuments.py index ab738a14..2b07ad86 100644 --- a/modules/features/trustee/workflows/methodTrustee/actions/processDocuments.py +++ b/modules/features/trustee/workflows/methodTrustee/actions/processDocuments.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Process extracted documents: create TrusteeDocument + TrusteePosition from extraction JSON. diff --git a/modules/features/trustee/workflows/methodTrustee/actions/queryData.py b/modules/features/trustee/workflows/methodTrustee/actions/queryData.py index b30c9390..82f77a0e 100644 --- a/modules/features/trustee/workflows/methodTrustee/actions/queryData.py +++ b/modules/features/trustee/workflows/methodTrustee/actions/queryData.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Query data from the Trustee feature DB. diff --git a/modules/features/trustee/workflows/methodTrustee/actions/refreshAccountingData.py b/modules/features/trustee/workflows/methodTrustee/actions/refreshAccountingData.py index 817d229a..a3f8eed3 100644 --- a/modules/features/trustee/workflows/methodTrustee/actions/refreshAccountingData.py +++ b/modules/features/trustee/workflows/methodTrustee/actions/refreshAccountingData.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Refresh accounting data from external system (e.g. Abacus) into local TrusteeData* tables. diff --git a/modules/features/trustee/workflows/methodTrustee/actions/syncToAccounting.py b/modules/features/trustee/workflows/methodTrustee/actions/syncToAccounting.py index 9529e699..1cd4c588 100644 --- a/modules/features/trustee/workflows/methodTrustee/actions/syncToAccounting.py +++ b/modules/features/trustee/workflows/methodTrustee/actions/syncToAccounting.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Sync trustee positions to accounting (Buha). diff --git a/modules/features/trustee/workflows/methodTrustee/methodTrustee.py b/modules/features/trustee/workflows/methodTrustee/methodTrustee.py index 73e7d573..65df051a 100644 --- a/modules/features/trustee/workflows/methodTrustee/methodTrustee.py +++ b/modules/features/trustee/workflows/methodTrustee/methodTrustee.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Trustee document workflow method: extract from files, process to positions, sync to accounting. diff --git a/modules/features/workspace/__init__.py b/modules/features/workspace/__init__.py index 2e48ea1c..bb9a0a2d 100644 --- a/modules/features/workspace/__init__.py +++ b/modules/features/workspace/__init__.py @@ -1,3 +1,3 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Unified AI Workspace feature.""" diff --git a/modules/features/workspace/datamodelFeatureWorkspace.py b/modules/features/workspace/datamodelFeatureWorkspace.py index d0ba8815..1e467849 100644 --- a/modules/features/workspace/datamodelFeatureWorkspace.py +++ b/modules/features/workspace/datamodelFeatureWorkspace.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Workspace feature data models — WorkspaceUserSettings.""" diff --git a/modules/features/workspace/interfaceFeatureWorkspace.py b/modules/features/workspace/interfaceFeatureWorkspace.py index e2d16521..09fca043 100644 --- a/modules/features/workspace/interfaceFeatureWorkspace.py +++ b/modules/features/workspace/interfaceFeatureWorkspace.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Interface for Workspace feature — manages WorkspaceUserSettings. diff --git a/modules/features/workspace/mainWorkspace.py b/modules/features/workspace/mainWorkspace.py index 1a96a852..605ab9c3 100644 --- a/modules/features/workspace/mainWorkspace.py +++ b/modules/features/workspace/mainWorkspace.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Workspace Feature Container - Main Module. diff --git a/modules/features/workspace/routeFeatureWorkspace.py b/modules/features/workspace/routeFeatureWorkspace.py index fedda841..66ed4966 100644 --- a/modules/features/workspace/routeFeatureWorkspace.py +++ b/modules/features/workspace/routeFeatureWorkspace.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Unified AI Workspace routes. diff --git a/modules/interfaces/_legacyMigrationTelemetry.py b/modules/interfaces/_legacyMigrationTelemetry.py index d80905b1..12fb1ae8 100644 --- a/modules/interfaces/_legacyMigrationTelemetry.py +++ b/modules/interfaces/_legacyMigrationTelemetry.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Lightweight Bootstrap-Telemetrie fuer entfernte Migrationsroutinen. diff --git a/modules/interfaces/interfaceAiObjects.py b/modules/interfaces/interfaceAiObjects.py index 13f5d8a7..c36e10b6 100644 --- a/modules/interfaces/interfaceAiObjects.py +++ b/modules/interfaces/interfaceAiObjects.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging import asyncio diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py index 19ff4e26..6a8522d7 100644 --- a/modules/interfaces/interfaceBootstrap.py +++ b/modules/interfaces/interfaceBootstrap.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Centralized bootstrap interface for system initialization. diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py index 13c7ead6..52cd5a59 100644 --- a/modules/interfaces/interfaceDbApp.py +++ b/modules/interfaces/interfaceDbApp.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Interface to the Gateway system. diff --git a/modules/interfaces/interfaceDbBilling.py b/modules/interfaces/interfaceDbBilling.py index 84cc748e..94600a0c 100644 --- a/modules/interfaces/interfaceDbBilling.py +++ b/modules/interfaces/interfaceDbBilling.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Interface for Billing operations. diff --git a/modules/interfaces/interfaceDbChat.py b/modules/interfaces/interfaceDbChat.py index 39d95440..71ccb774 100644 --- a/modules/interfaces/interfaceDbChat.py +++ b/modules/interfaces/interfaceDbChat.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Interface to LucyDOM database and AI Connectors. diff --git a/modules/interfaces/interfaceDbKnowledge.py b/modules/interfaces/interfaceDbKnowledge.py index 20d66dc2..c52d999e 100644 --- a/modules/interfaces/interfaceDbKnowledge.py +++ b/modules/interfaces/interfaceDbKnowledge.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Interface to the Knowledge Store database (poweron_knowledge). diff --git a/modules/interfaces/interfaceDbManagement.py b/modules/interfaces/interfaceDbManagement.py index b3acfcfc..93e2d1c3 100644 --- a/modules/interfaces/interfaceDbManagement.py +++ b/modules/interfaces/interfaceDbManagement.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Interface to Management database and AI Connectors. @@ -2275,4 +2275,4 @@ def buildResolverDbInterface(chatService): appIf = getattr(chatService, "interfaceDbApp", None) if appIf: return _ResolverDbAdapter(appIf) - return getattr(chatService, "interfaceDbComponent", None) \ No newline at end of file + return getattr(chatService, "interfaceDbComponent", None) diff --git a/modules/interfaces/interfaceDbSubscription.py b/modules/interfaces/interfaceDbSubscription.py index b6cb26ff..04288d23 100644 --- a/modules/interfaces/interfaceDbSubscription.py +++ b/modules/interfaces/interfaceDbSubscription.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Interface for Subscription operations — ID-based, deterministic. diff --git a/modules/interfaces/interfaceFeatures.py b/modules/interfaces/interfaceFeatures.py index c391deaa..885d5bcb 100644 --- a/modules/interfaces/interfaceFeatures.py +++ b/modules/interfaces/interfaceFeatures.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Feature Instance Management Interface. diff --git a/modules/interfaces/interfaceMessaging.py b/modules/interfaces/interfaceMessaging.py index 6a0eb54c..298edf6c 100644 --- a/modules/interfaces/interfaceMessaging.py +++ b/modules/interfaces/interfaceMessaging.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Interface for Messaging Services diff --git a/modules/interfaces/interfaceRbac.py b/modules/interfaces/interfaceRbac.py index 5c09942a..ebcf8c56 100644 --- a/modules/interfaces/interfaceRbac.py +++ b/modules/interfaces/interfaceRbac.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ RBAC helper functions for interfaces. diff --git a/modules/interfaces/interfaceTableHelpers.py b/modules/interfaces/interfaceTableHelpers.py index 81336fed..e7c188c5 100644 --- a/modules/interfaces/interfaceTableHelpers.py +++ b/modules/interfaces/interfaceTableHelpers.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Table/list presentation helpers: view resolution, grouping, Strategy B. diff --git a/modules/interfaces/interfaceTicketObjects.py b/modules/interfaces/interfaceTicketObjects.py index 6525eae5..b2ef7bf9 100644 --- a/modules/interfaces/interfaceTicketObjects.py +++ b/modules/interfaces/interfaceTicketObjects.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from typing import Any, Optional from datetime import datetime, timezone diff --git a/modules/interfaces/interfaceVoiceObjects.py b/modules/interfaces/interfaceVoiceObjects.py index 03729f86..636dbbf6 100644 --- a/modules/interfaces/interfaceVoiceObjects.py +++ b/modules/interfaces/interfaceVoiceObjects.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Interface for Voice Services diff --git a/modules/interfaces/interfaceWorkflowAutomation.py b/modules/interfaces/interfaceWorkflowAutomation.py index 9859ff2d..84efe40a 100644 --- a/modules/interfaces/interfaceWorkflowAutomation.py +++ b/modules/interfaces/interfaceWorkflowAutomation.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Interface for WorkflowAutomation system component - Workflows, Runs, Human Tasks. diff --git a/modules/nodeCatalog/__init__.py b/modules/nodeCatalog/__init__.py index cbe6a49e..6c09ea27 100644 --- a/modules/nodeCatalog/__init__.py +++ b/modules/nodeCatalog/__init__.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ nodeCatalog (L2) — neutraler Node-Definitions-Container. diff --git a/modules/nodeCatalog/_workflowFileSchema.py b/modules/nodeCatalog/_workflowFileSchema.py index efb06aea..3d04318d 100644 --- a/modules/nodeCatalog/_workflowFileSchema.py +++ b/modules/nodeCatalog/_workflowFileSchema.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Workflow File Schema (Versioned Envelope) for WorkflowAutomation. diff --git a/modules/nodeCatalog/entryPoints.py b/modules/nodeCatalog/entryPoints.py index b1a8ae03..3f11f184 100644 --- a/modules/nodeCatalog/entryPoints.py +++ b/modules/nodeCatalog/entryPoints.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Workflow entry points (Starts) — configuration outside the flow editor. diff --git a/modules/nodeCatalog/nodeAdapter.py b/modules/nodeCatalog/nodeAdapter.py index f0cd1469..ff974073 100644 --- a/modules/nodeCatalog/nodeAdapter.py +++ b/modules/nodeCatalog/nodeAdapter.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Schicht-3 Adapter Layer — projects Schicht-2 Actions into Editor-Node form. diff --git a/modules/nodeCatalog/nodeDefinitions/__init__.py b/modules/nodeCatalog/nodeDefinitions/__init__.py index 31895a44..17fdd424 100644 --- a/modules/nodeCatalog/nodeDefinitions/__init__.py +++ b/modules/nodeCatalog/nodeDefinitions/__init__.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. # Node type definitions for automation2 flow builder. from .triggers import TRIGGER_NODES diff --git a/modules/nodeCatalog/nodeDefinitions/ai.py b/modules/nodeCatalog/nodeDefinitions/ai.py index 8e0f081e..ab2f9893 100644 --- a/modules/nodeCatalog/nodeDefinitions/ai.py +++ b/modules/nodeCatalog/nodeDefinitions/ai.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. # AI node definitions - map to methodAi actions. from modules.shared.i18nRegistry import t diff --git a/modules/nodeCatalog/nodeDefinitions/clickup.py b/modules/nodeCatalog/nodeDefinitions/clickup.py index 1e330d29..fde1cec1 100644 --- a/modules/nodeCatalog/nodeDefinitions/clickup.py +++ b/modules/nodeCatalog/nodeDefinitions/clickup.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ClickUp nodes — map to MethodClickup actions.""" diff --git a/modules/nodeCatalog/nodeDefinitions/context.py b/modules/nodeCatalog/nodeDefinitions/context.py index dc05fa40..139fed83 100644 --- a/modules/nodeCatalog/nodeDefinitions/context.py +++ b/modules/nodeCatalog/nodeDefinitions/context.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. # Context node definitions — structural extraction without AI plus # generic key/value, merge, filter and transform helpers. diff --git a/modules/nodeCatalog/nodeDefinitions/contextPickerHelp.py b/modules/nodeCatalog/nodeDefinitions/contextPickerHelp.py index 116164c1..740ecb35 100644 --- a/modules/nodeCatalog/nodeDefinitions/contextPickerHelp.py +++ b/modules/nodeCatalog/nodeDefinitions/contextPickerHelp.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. # Shared parameter copy for ``contextBuilder`` fields (upstream data pick). from modules.shared.i18nRegistry import t diff --git a/modules/nodeCatalog/nodeDefinitions/data.py b/modules/nodeCatalog/nodeDefinitions/data.py index a12ddeb6..c6b5cdab 100644 --- a/modules/nodeCatalog/nodeDefinitions/data.py +++ b/modules/nodeCatalog/nodeDefinitions/data.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. # Data manipulation node definitions: aggregate, transform, filter. from modules.shared.i18nRegistry import t diff --git a/modules/nodeCatalog/nodeDefinitions/email.py b/modules/nodeCatalog/nodeDefinitions/email.py index a0503452..c2dfc0e5 100644 --- a/modules/nodeCatalog/nodeDefinitions/email.py +++ b/modules/nodeCatalog/nodeDefinitions/email.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. # Email node definitions - map to methodOutlook actions. from modules.shared.i18nRegistry import t diff --git a/modules/nodeCatalog/nodeDefinitions/file.py b/modules/nodeCatalog/nodeDefinitions/file.py index 70c13a07..8030d8cb 100644 --- a/modules/nodeCatalog/nodeDefinitions/file.py +++ b/modules/nodeCatalog/nodeDefinitions/file.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. # File node definitions - create files from context (e.g. from AI nodes). from modules.shared.i18nRegistry import t diff --git a/modules/nodeCatalog/nodeDefinitions/flow.py b/modules/nodeCatalog/nodeDefinitions/flow.py index 94f517d9..b9451819 100644 --- a/modules/nodeCatalog/nodeDefinitions/flow.py +++ b/modules/nodeCatalog/nodeDefinitions/flow.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. # Flow control node definitions. from modules.shared.i18nRegistry import t diff --git a/modules/nodeCatalog/nodeDefinitions/input.py b/modules/nodeCatalog/nodeDefinitions/input.py index 0f469880..b68fa74d 100644 --- a/modules/nodeCatalog/nodeDefinitions/input.py +++ b/modules/nodeCatalog/nodeDefinitions/input.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. # Input/Human node definitions - nodes that require user action. from modules.shared.i18nRegistry import t diff --git a/modules/nodeCatalog/nodeDefinitions/redmine.py b/modules/nodeCatalog/nodeDefinitions/redmine.py index bf61cd26..730bcffa 100644 --- a/modules/nodeCatalog/nodeDefinitions/redmine.py +++ b/modules/nodeCatalog/nodeDefinitions/redmine.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Redmine node definitions - map to MethodRedmine actions.""" diff --git a/modules/nodeCatalog/nodeDefinitions/sharepoint.py b/modules/nodeCatalog/nodeDefinitions/sharepoint.py index ae56f9a6..6e011e93 100644 --- a/modules/nodeCatalog/nodeDefinitions/sharepoint.py +++ b/modules/nodeCatalog/nodeDefinitions/sharepoint.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. # SharePoint node definitions - map to methodSharepoint actions. from modules.shared.i18nRegistry import t diff --git a/modules/nodeCatalog/nodeDefinitions/triggers.py b/modules/nodeCatalog/nodeDefinitions/triggers.py index deeae7a0..3343b284 100644 --- a/modules/nodeCatalog/nodeDefinitions/triggers.py +++ b/modules/nodeCatalog/nodeDefinitions/triggers.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. # Start nodes (palette category ``start``); kinds align with workflow entry points / run envelope. from modules.shared.i18nRegistry import t diff --git a/modules/nodeCatalog/nodeDefinitions/trustee.py b/modules/nodeCatalog/nodeDefinitions/trustee.py index b0521696..840b6ff5 100644 --- a/modules/nodeCatalog/nodeDefinitions/trustee.py +++ b/modules/nodeCatalog/nodeDefinitions/trustee.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. # Trustee node definitions - map to methodTrustee actions. from modules.shared.i18nRegistry import t diff --git a/modules/nodeCatalog/portTypes.py b/modules/nodeCatalog/portTypes.py index aa8f4385..f3995099 100644 --- a/modules/nodeCatalog/portTypes.py +++ b/modules/nodeCatalog/portTypes.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Typed Port System for the Graphical Editor. diff --git a/modules/routes/routeAdmin.py b/modules/routes/routeAdmin.py index 0f671f0a..5f9c2b02 100644 --- a/modules/routes/routeAdmin.py +++ b/modules/routes/routeAdmin.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from fastapi import APIRouter, Response, Depends, Request, Body from fastapi.responses import FileResponse diff --git a/modules/routes/routeAdminDatabaseHealth.py b/modules/routes/routeAdminDatabaseHealth.py index f2f866d9..545c5b1b 100644 --- a/modules/routes/routeAdminDatabaseHealth.py +++ b/modules/routes/routeAdminDatabaseHealth.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ SysAdmin API for database table statistics, FK orphan detection/cleanup, diff --git a/modules/routes/routeAdminFeatures.py b/modules/routes/routeAdminFeatures.py index 350d8311..0a9626dc 100644 --- a/modules/routes/routeAdminFeatures.py +++ b/modules/routes/routeAdminFeatures.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Feature management routes for the backend API. diff --git a/modules/routes/routeAdminLogs.py b/modules/routes/routeAdminLogs.py index 926c7370..e5a1357c 100644 --- a/modules/routes/routeAdminLogs.py +++ b/modules/routes/routeAdminLogs.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Admin log viewer routes for the backend API. diff --git a/modules/routes/routeAdminRbacRules.py b/modules/routes/routeAdminRbacRules.py index 3b09d7eb..36577de7 100644 --- a/modules/routes/routeAdminRbacRules.py +++ b/modules/routes/routeAdminRbacRules.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ RBAC routes for the backend API. diff --git a/modules/routes/routeAdminUserAccessOverview.py b/modules/routes/routeAdminUserAccessOverview.py index 4906c093..ed66cb41 100644 --- a/modules/routes/routeAdminUserAccessOverview.py +++ b/modules/routes/routeAdminUserAccessOverview.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Admin User Access Overview routes. diff --git a/modules/routes/routeAttributes.py b/modules/routes/routeAttributes.py index ae0cd6f4..eeec151e 100644 --- a/modules/routes/routeAttributes.py +++ b/modules/routes/routeAttributes.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from fastapi import APIRouter, HTTPException, Path, Response, Request from fastapi import status @@ -85,4 +85,4 @@ def options_entity_attributes( entityType: str = Path(..., description="Type of entity (e.g. prompt)") ) -> Response: """Handle OPTIONS request for CORS preflight""" - return Response(status_code=200) \ No newline at end of file + return Response(status_code=200) diff --git a/modules/routes/routeAudit.py b/modules/routes/routeAudit.py index c9888339..9dfd074d 100644 --- a/modules/routes/routeAudit.py +++ b/modules/routes/routeAudit.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Compliance & Audit API endpoints. diff --git a/modules/routes/routeBilling.py b/modules/routes/routeBilling.py index 143af2e2..ca95f7da 100644 --- a/modules/routes/routeBilling.py +++ b/modules/routes/routeBilling.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Billing routes for the backend API. diff --git a/modules/routes/routeClickup.py b/modules/routes/routeClickup.py index 41797d77..635f1352 100644 --- a/modules/routes/routeClickup.py +++ b/modules/routes/routeClickup.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ClickUp API routes — lists and tasks (connection-scoped). OAuth lives under /api/clickup/auth/* in routeSecurityClickup.""" diff --git a/modules/routes/routeDataConnections.py b/modules/routes/routeDataConnections.py index 7a327d16..2d425d25 100644 --- a/modules/routes/routeDataConnections.py +++ b/modules/routes/routeDataConnections.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Connection routes for the backend API. @@ -906,4 +906,4 @@ def _stopKnowledgeJobs( raise except Exception as e: logger.error("Error stopping knowledge jobs: %s", e, exc_info=True) - raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file + raise HTTPException(status_code=500, detail=str(e)) diff --git a/modules/routes/routeDataFiles.py b/modules/routes/routeDataFiles.py index a7a4e34b..63189e53 100644 --- a/modules/routes/routeDataFiles.py +++ b/modules/routes/routeDataFiles.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from fastapi import APIRouter, HTTPException, Depends, File, UploadFile, Form, Path, Request, status, Query, Response, Body, BackgroundTasks from fastapi.responses import JSONResponse @@ -774,7 +774,7 @@ def get_files( allItems = enrichRowsWithFkLabels( _filesToDicts(allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else [])), FileItem, - db=managementInterface.db, + db=appInterface.db, ) filtered = apply_strategy_b_filters_and_sort(allItems, paginationParams, currentUser) groups_out = build_group_summary_groups(filtered, field, null_label, groupByLevels=groupByLevels) @@ -786,7 +786,7 @@ def get_files( allFiles = managementInterface.getAllFiles() items = allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else []) itemDicts = _filesToDicts(items) - enrichRowsWithFkLabels(itemDicts, FileItem) + enrichRowsWithFkLabels(itemDicts, FileItem, db=appInterface.db) return handleFilterValuesInMemory(itemDicts, column, pagination) if mode == "ids": @@ -797,7 +797,7 @@ def get_files( # No grouping: let DB handle pagination directly (fastest path) result = managementInterface.getAllFiles(pagination=paginationParams) if paginationParams and hasattr(result, 'items'): - enriched = enrichRowsWithFkLabels(_filesToDicts(result.items), FileItem) + enriched = enrichRowsWithFkLabels(_filesToDicts(result.items), FileItem, db=appInterface.db) resp: dict = { "items": enriched, "pagination": PaginationMetadata( @@ -811,7 +811,7 @@ def get_files( } else: items = result if isinstance(result, list) else (result.items if hasattr(result, "items") else [result]) - resp = {"items": enrichRowsWithFkLabels(_filesToDicts(items), FileItem), "pagination": None} + resp = {"items": enrichRowsWithFkLabels(_filesToDicts(items), FileItem, db=appInterface.db), "pagination": None} if viewMeta: resp["appliedView"] = viewMeta.model_dump() return resp @@ -821,7 +821,7 @@ def get_files( allItems = enrichRowsWithFkLabels( _filesToDicts(allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else [])), FileItem, - db=managementInterface.db, + db=appInterface.db, ) from modules.interfaces.interfaceTableHelpers import apply_strategy_b_filters_and_sort @@ -1401,7 +1401,8 @@ def get_file( ) fileDict = fileData.model_dump() if hasattr(fileData, "model_dump") else dict(fileData) - enriched = enrichRowsWithFkLabels([fileDict], FileItem) + import modules.interfaces.interfaceDbApp as _appIface + enriched = enrichRowsWithFkLabels([fileDict], FileItem, db=_appIface.getInterface(currentUser).db) return enriched[0] except interfaceDbManagement.FileNotFoundError as e: diff --git a/modules/routes/routeDataMandates.py b/modules/routes/routeDataMandates.py index 668c16ed..1abf1dff 100644 --- a/modules/routes/routeDataMandates.py +++ b/modules/routes/routeDataMandates.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Mandate routes for the backend API. diff --git a/modules/routes/routeDataPrompts.py b/modules/routes/routeDataPrompts.py index 4d46630c..bbb566e7 100644 --- a/modules/routes/routeDataPrompts.py +++ b/modules/routes/routeDataPrompts.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Query from typing import List, Dict, Any, Optional, Tuple @@ -367,4 +367,4 @@ def delete_prompt( detail=routeApiMsg("Error deleting the prompt") ) - return {"message": f"Prompt with ID {promptId} successfully deleted"} \ No newline at end of file + return {"message": f"Prompt with ID {promptId} successfully deleted"} diff --git a/modules/routes/routeDataSources.py b/modules/routes/routeDataSources.py index e1a6ee39..7dc3dcba 100644 --- a/modules/routes/routeDataSources.py +++ b/modules/routes/routeDataSources.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """DataSource auxiliary endpoints: settings (ragLimits) and cost estimate. diff --git a/modules/routes/routeDataUsers.py b/modules/routes/routeDataUsers.py index e371b547..d0f805bd 100644 --- a/modules/routes/routeDataUsers.py +++ b/modules/routes/routeDataUsers.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ User routes for the backend API. diff --git a/modules/routes/routeGdpr.py b/modules/routes/routeGdpr.py index d07df8a8..7c697c38 100644 --- a/modules/routes/routeGdpr.py +++ b/modules/routes/routeGdpr.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ GDPR compliance routes for the backend API. diff --git a/modules/routes/routeI18n.py b/modules/routes/routeI18n.py index 8b1b46d5..fd42fe8d 100644 --- a/modules/routes/routeI18n.py +++ b/modules/routes/routeI18n.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Public and authenticated routes for UI language sets (DB-backed i18n). diff --git a/modules/routes/routeInvitations.py b/modules/routes/routeInvitations.py index 25049227..e6325d2c 100644 --- a/modules/routes/routeInvitations.py +++ b/modules/routes/routeInvitations.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Invitation routes for the backend API. diff --git a/modules/routes/routeJobs.py b/modules/routes/routeJobs.py index 9cd89d46..c98c7bd0 100644 --- a/modules/routes/routeJobs.py +++ b/modules/routes/routeJobs.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """HTTP API for the generic background job service. diff --git a/modules/routes/routeMfa.py b/modules/routes/routeMfa.py index cb681fe0..0d3e4d59 100644 --- a/modules/routes/routeMfa.py +++ b/modules/routes/routeMfa.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Routes for TOTP-based Multi-Factor Authentication. diff --git a/modules/routes/routeNotifications.py b/modules/routes/routeNotifications.py index c1cacb17..ef63fc1a 100644 --- a/modules/routes/routeNotifications.py +++ b/modules/routes/routeNotifications.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Notification routes for in-app notifications. diff --git a/modules/routes/routeRagInventory.py b/modules/routes/routeRagInventory.py index 419ddec1..82348d9a 100644 --- a/modules/routes/routeRagInventory.py +++ b/modules/routes/routeRagInventory.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """RAG Inventory API — global knowledge-store visibility for users, admins, platform.""" diff --git a/modules/routes/routeSecurityClickup.py b/modules/routes/routeSecurityClickup.py index 935509bc..a61ed9d2 100644 --- a/modules/routes/routeSecurityClickup.py +++ b/modules/routes/routeSecurityClickup.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ClickUp OAuth for data connections (UserConnection + Token).""" diff --git a/modules/routes/routeSecurityGoogle.py b/modules/routes/routeSecurityGoogle.py index 2f1eabd2..a363f7bf 100644 --- a/modules/routes/routeSecurityGoogle.py +++ b/modules/routes/routeSecurityGoogle.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Routes for Google authentication — split Auth app vs Data app. diff --git a/modules/routes/routeSecurityInfomaniak.py b/modules/routes/routeSecurityInfomaniak.py index 4026f4e9..14f2181a 100644 --- a/modules/routes/routeSecurityInfomaniak.py +++ b/modules/routes/routeSecurityInfomaniak.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Infomaniak Personal-Access-Token onboarding for data connections. diff --git a/modules/routes/routeSecurityLocal.py b/modules/routes/routeSecurityLocal.py index 6a25ce04..ee2f6390 100644 --- a/modules/routes/routeSecurityLocal.py +++ b/modules/routes/routeSecurityLocal.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Routes for local security and authentication. diff --git a/modules/routes/routeSecurityMsft.py b/modules/routes/routeSecurityMsft.py index c26503ef..45d3deda 100644 --- a/modules/routes/routeSecurityMsft.py +++ b/modules/routes/routeSecurityMsft.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Routes for Microsoft authentication — split Auth app vs Data app. diff --git a/modules/routes/routeSharepoint.py b/modules/routes/routeSharepoint.py index 328a1bba..5c8c91bc 100644 --- a/modules/routes/routeSharepoint.py +++ b/modules/routes/routeSharepoint.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ SharePoint routes for folder browsing diff --git a/modules/routes/routeStore.py b/modules/routes/routeStore.py index 7356f1aa..8ecb2013 100644 --- a/modules/routes/routeStore.py +++ b/modules/routes/routeStore.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Feature Store routes. diff --git a/modules/routes/routeSubscription.py b/modules/routes/routeSubscription.py index 87d34836..709d70e5 100644 --- a/modules/routes/routeSubscription.py +++ b/modules/routes/routeSubscription.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Subscription routes — ID-based, state-machine-driven. diff --git a/modules/routes/routeSystem.py b/modules/routes/routeSystem.py index 853c4b32..b47a3602 100644 --- a/modules/routes/routeSystem.py +++ b/modules/routes/routeSystem.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ System Routes - Navigation and system-level API endpoints. diff --git a/modules/routes/routeTableViews.py b/modules/routes/routeTableViews.py index 32a4cf7d..80b27fa5 100644 --- a/modules/routes/routeTableViews.py +++ b/modules/routes/routeTableViews.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ CRUD endpoints for saved table views (TableListView). diff --git a/modules/routes/routeUdb.py b/modules/routes/routeUdb.py index 73c7b55c..9a2a09d8 100644 --- a/modules/routes/routeUdb.py +++ b/modules/routes/routeUdb.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Generic UDB (Unified Data Bar) router. diff --git a/modules/routes/routeVoiceGoogle.py b/modules/routes/routeVoiceGoogle.py index 10185cc2..ada38504 100644 --- a/modules/routes/routeVoiceGoogle.py +++ b/modules/routes/routeVoiceGoogle.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Google Cloud Voice Services Routes diff --git a/modules/routes/routeVoiceUser.py b/modules/routes/routeVoiceUser.py index ce14afe0..651a3baa 100644 --- a/modules/routes/routeVoiceUser.py +++ b/modules/routes/routeVoiceUser.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ User-scoped voice settings and TTS/STT catalog endpoints. diff --git a/modules/routes/routeWorkflowAutomation.py b/modules/routes/routeWorkflowAutomation.py index afd4aaa0..4fb7cca9 100644 --- a/modules/routes/routeWorkflowAutomation.py +++ b/modules/routes/routeWorkflowAutomation.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Mandatsweite WorkflowAutomation API. diff --git a/modules/security/__init__.py b/modules/security/__init__.py index bdf934d9..481be3b2 100644 --- a/modules/security/__init__.py +++ b/modules/security/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Security core modules for low-level security operations. diff --git a/modules/security/passwordUtils.py b/modules/security/passwordUtils.py index 6d6ce235..8b20f399 100644 --- a/modules/security/passwordUtils.py +++ b/modules/security/passwordUtils.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Password utility functions for secure password handling. diff --git a/modules/security/rbac.py b/modules/security/rbac.py index 59f8f55f..f043febb 100644 --- a/modules/security/rbac.py +++ b/modules/security/rbac.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ RBAC interface: Core RBAC logic and permission resolution. diff --git a/modules/security/rbacCatalog.py b/modules/security/rbacCatalog.py index 9b1ca22f..a6ee5b33 100644 --- a/modules/security/rbacCatalog.py +++ b/modules/security/rbacCatalog.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ RBAC Catalog Service. diff --git a/modules/security/rbacHelpers.py b/modules/security/rbacHelpers.py index a191e5d8..7dfb30c9 100644 --- a/modules/security/rbacHelpers.py +++ b/modules/security/rbacHelpers.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ RBAC helper functions for resource access control. diff --git a/modules/security/rootAccess.py b/modules/security/rootAccess.py index 1735891d..d2ed8ae2 100644 --- a/modules/security/rootAccess.py +++ b/modules/security/rootAccess.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Root access management for system-level operations. diff --git a/modules/serviceCenter/__init__.py b/modules/serviceCenter/__init__.py index 968b8acf..38ab047e 100644 --- a/modules/serviceCenter/__init__.py +++ b/modules/serviceCenter/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Service Center. diff --git a/modules/serviceCenter/context.py b/modules/serviceCenter/context.py index 2738f8b3..5b58d810 100644 --- a/modules/serviceCenter/context.py +++ b/modules/serviceCenter/context.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Service Center Context. diff --git a/modules/serviceCenter/core/__init__.py b/modules/serviceCenter/core/__init__.py index 752c63b8..226125ab 100644 --- a/modules/serviceCenter/core/__init__.py +++ b/modules/serviceCenter/core/__init__.py @@ -1,3 +1,3 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Core services - internal building blocks, not requested by features.""" diff --git a/modules/serviceCenter/core/flagResolution.py b/modules/serviceCenter/core/flagResolution.py index 69edc3c2..b8ec3b43 100644 --- a/modules/serviceCenter/core/flagResolution.py +++ b/modules/serviceCenter/core/flagResolution.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Cascade-inherit semantics for DataSource flags (neutralize, ragIndexEnabled). diff --git a/modules/serviceCenter/core/serviceSecurity/__init__.py b/modules/serviceCenter/core/serviceSecurity/__init__.py index 78f84b42..55039e2e 100644 --- a/modules/serviceCenter/core/serviceSecurity/__init__.py +++ b/modules/serviceCenter/core/serviceSecurity/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Security core service.""" diff --git a/modules/serviceCenter/core/serviceSecurity/mainServiceSecurity.py b/modules/serviceCenter/core/serviceSecurity/mainServiceSecurity.py index b5a9a84b..0904121d 100644 --- a/modules/serviceCenter/core/serviceSecurity/mainServiceSecurity.py +++ b/modules/serviceCenter/core/serviceSecurity/mainServiceSecurity.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Security service for token management operations. diff --git a/modules/serviceCenter/core/serviceStreaming/__init__.py b/modules/serviceCenter/core/serviceStreaming/__init__.py index 18a34f4e..ee1aa88c 100644 --- a/modules/serviceCenter/core/serviceStreaming/__init__.py +++ b/modules/serviceCenter/core/serviceStreaming/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Streaming core service for SSE event management.""" diff --git a/modules/serviceCenter/core/serviceStreaming/mainServiceStreaming.py b/modules/serviceCenter/core/serviceStreaming/mainServiceStreaming.py index 76369553..98939b0f 100644 --- a/modules/serviceCenter/core/serviceStreaming/mainServiceStreaming.py +++ b/modules/serviceCenter/core/serviceStreaming/mainServiceStreaming.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Streaming service for SSE event management. diff --git a/modules/serviceCenter/core/serviceUtils/__init__.py b/modules/serviceCenter/core/serviceUtils/__init__.py index b3661f8d..f796aa66 100644 --- a/modules/serviceCenter/core/serviceUtils/__init__.py +++ b/modules/serviceCenter/core/serviceUtils/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Utils core service.""" diff --git a/modules/serviceCenter/core/serviceUtils/mainServiceUtils.py b/modules/serviceCenter/core/serviceUtils/mainServiceUtils.py index 856514bf..f6dbcbba 100644 --- a/modules/serviceCenter/core/serviceUtils/mainServiceUtils.py +++ b/modules/serviceCenter/core/serviceUtils/mainServiceUtils.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Utility service for common operations across the gateway. diff --git a/modules/serviceCenter/core/types.py b/modules/serviceCenter/core/types.py index 19c15081..f5b5d090 100644 --- a/modules/serviceCenter/core/types.py +++ b/modules/serviceCenter/core/types.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Neutral protocol types used across serviceCenter services. diff --git a/modules/serviceCenter/registry.py b/modules/serviceCenter/registry.py index 64003d29..0bdd2205 100644 --- a/modules/serviceCenter/registry.py +++ b/modules/serviceCenter/registry.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Service Center Registry. diff --git a/modules/serviceCenter/resolver.py b/modules/serviceCenter/resolver.py index 729adb69..e92359b4 100644 --- a/modules/serviceCenter/resolver.py +++ b/modules/serviceCenter/resolver.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Service Center Resolver. diff --git a/modules/serviceCenter/services/__init__.py b/modules/serviceCenter/services/__init__.py index 3f161a0f..2b05ef33 100644 --- a/modules/serviceCenter/services/__init__.py +++ b/modules/serviceCenter/services/__init__.py @@ -1,3 +1,3 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Importable services - feature-facing, RBAC-protected.""" diff --git a/modules/serviceCenter/services/serviceAgent/__init__.py b/modules/serviceCenter/services/serviceAgent/__init__.py index 8878ece1..eaf9ab4a 100644 --- a/modules/serviceCenter/services/serviceAgent/__init__.py +++ b/modules/serviceCenter/services/serviceAgent/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """serviceAgent: AI Agent with ReAct loop and native function calling.""" diff --git a/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py b/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py index 4cfbb8c4..39e10d88 100644 --- a/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py +++ b/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ActionToolAdapter: wraps existing workflow actions (dynamicMode=True) as agent tools.""" diff --git a/modules/serviceCenter/services/serviceAgent/agentLoop.py b/modules/serviceCenter/services/serviceAgent/agentLoop.py index 99f4dbd7..f2ca07ed 100644 --- a/modules/serviceCenter/services/serviceAgent/agentLoop.py +++ b/modules/serviceCenter/services/serviceAgent/agentLoop.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Agent loop: ReAct pattern with native function calling, budget control, and error handling.""" diff --git a/modules/serviceCenter/services/serviceAgent/conversationManager.py b/modules/serviceCenter/services/serviceAgent/conversationManager.py index 055eae25..c8dfcc36 100644 --- a/modules/serviceCenter/services/serviceAgent/conversationManager.py +++ b/modules/serviceCenter/services/serviceAgent/conversationManager.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Conversation manager for the Agent service. Handles message history, context window management, and progressive summarization.""" diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/__init__.py b/modules/serviceCenter/services/serviceAgent/coreTools/__init__.py index e476ac39..c984f59a 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/__init__.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Core agent tools: registration of built-in ToolRegistry handlers.""" diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_connectionTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_connectionTools.py index 0a9e678b..194783ca 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_connectionTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_connectionTools.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """External connection tools (list connections, upload, send mail).""" diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_crossWorkflowTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_crossWorkflowTools.py index 2675257c..eea4d084 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_crossWorkflowTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_crossWorkflowTools.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Cross-workflow tools and core-only tool-set tagging.""" diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py index f1e49368..59dfa1d9 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """DataSource convenience tools (browse, search, download from external sources).""" @@ -109,18 +109,15 @@ def registerDataSourceTools(registry: ToolRegistry, services): """Auto-extracted from registerCoreTools.""" def _buildResolverDb(): - """Build a DB adapter that ConnectorResolver can use to load UserConnections. - interfaceDbApp has getUserConnectionById; ConnectorResolver expects getUserConnection.""" + """Build a DB adapter that ConnectorResolver can use to load UserConnections.""" chatService = services.chat - appIf = getattr(chatService, "interfaceDbApp", None) - if appIf and hasattr(appIf, "getUserConnectionById"): - class _Adapter: - def __init__(self, app): - self._app = app - def getUserConnection(self, connectionId: str): - return self._app.getUserConnectionById(connectionId) - return _Adapter(appIf) - return getattr(chatService, "interfaceDbComponent", None) + + class _Adapter: + def __init__(self, svc): + self._svc = svc + def getUserConnection(self, connectionId: str): + return self._svc.getUserConnectionById(connectionId) + return _Adapter(chatService) # ---- DataSource convenience tools ---- # Maps the FE-side `sourceType` literal (see SourcesTab.tsx @@ -152,10 +149,7 @@ def registerDataSourceTools(registry: ToolRegistry, services): path = ds.get("path", "/") label = ds.get("label", "") from modules.serviceCenter.core.flagResolution import getEffectiveFlag - from modules.datamodels.datamodelDataSource import DataSource - from modules.interfaces.interfaceDbApp import getRootInterface - rootIf = getRootInterface() - allConnDs = rootIf.db.getRecordset(DataSource, recordFilter={"connectionId": connectionId}) + allConnDs = chatService.getDataSourcesByConnection(connectionId) if connectionId else [ds] neutralize = bool(getEffectiveFlag(ds, "neutralize", allConnDs or [ds], mode="walk")) service = _SOURCE_TYPE_TO_SERVICE.get(sourceType, sourceType) if not connectionId: diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_documentTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_documentTools.py index a79f5995..baaab91c 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_documentTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_documentTools.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Document and vision tools (containers, content objects, image description).""" @@ -455,14 +455,11 @@ def registerDocumentTools(registry: ToolRegistry, services): _opType = OTE.IMAGE_ANALYSE try: - from modules.datamodels.datamodelFiles import FileItem - from modules.interfaces.interfaceDbManagement import ComponentObjects - _fRow = ComponentObjects().db._loadRecord(FileItem, fileId) - if _fRow: - _fGet = (lambda k, d=None: _fRow.get(k, d)) if isinstance(_fRow, dict) else (lambda k, d=None: getattr(_fRow, k, d)) - if bool(_fGet("neutralize", False)): - _opType = OTE.NEUTRALIZATION_IMAGE - logger.info(f"describeImage: file {fileId} has neutralize=True, using NEUTRALIZATION_IMAGE (internal models only)") + _chatSvc = services.chat + _fInfo = _chatSvc.getFileInfo(fileId) if hasattr(_chatSvc, "getFileInfo") else None + if _fInfo and _fInfo.get("neutralize", False): + _opType = OTE.NEUTRALIZATION_IMAGE + logger.info(f"describeImage: file {fileId} has neutralize=True, using NEUTRALIZATION_IMAGE (internal models only)") except Exception as e: logger.warning(f"describeImage: neutralize flag check failed for {fileId}: {e}") diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_emailTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_emailTools.py index d36a2727..a896c56c 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_emailTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_emailTools.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Email management agent tools (reply, forward, move, delete, flag, folder ops). diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py index f522a62a..b3d42944 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Feature Data Sub-Agent tool (queryFeatureInstance).""" @@ -76,11 +76,10 @@ def registerFeatureSubAgentTools(registry: ToolRegistry, services): ) try: from modules.serviceCenter.services.serviceAgent.featureDataAgent import runFeatureDataAgent - from modules.datamodels.datamodelFeatures import FeatureDataSource - from modules.interfaces.interfaceDbApp import getRootInterface + from modules.serviceCenter.core.flagResolution import getEffectiveFlagFds - rootIf = getRootInterface() - instance = rootIf.getFeatureInstance(featureInstanceId) + chatService = services.chat + instance = chatService.getFeatureInstance(featureInstanceId) if not instance: return ToolResult( toolCallId="", toolName="queryFeatureInstance", @@ -92,24 +91,11 @@ def registerFeatureSubAgentTools(registry: ToolRegistry, services): instanceLabel = instance.label or "" userId = context.get("userId", "") requestLang = None - if userId: - langUser = rootIf.getUser(userId) - if langUser: - requestLang = getattr(langUser, "language", None) + if userId and hasattr(chatService, "user") and chatService.user: + requestLang = getattr(chatService.user, "language", None) - rootDbConn = rootIf.db if hasattr(rootIf, "db") else None - if rootDbConn is None: - return ToolResult( - toolCallId="", toolName="queryFeatureInstance", - success=False, error="No database connector available", - ) + featureDataSources = chatService.getFeatureDataSources(featureInstanceId) - featureDataSources = rootDbConn.getRecordset( - FeatureDataSource, - recordFilter={"featureInstanceId": featureInstanceId}, - ) - - from modules.serviceCenter.core.flagResolution import getEffectiveFlagFds _fdsAll = featureDataSources or [] _anySourceNeutralize = any( getEffectiveFlagFds(ds, "neutralize", _fdsAll, mode="walk") is True diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py b/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py index 4e69d849..4546445d 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_helpers.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Shared helpers for core agent tools (file scope, binary detection, group helpers).""" @@ -13,8 +13,8 @@ _MAX_TOOL_RESULT_CHARS = 50_000 _BINARY_SIGNATURES = (b"%PDF", b"\x89PNG", b"\xff\xd8\xff", b"GIF8", b"PK\x03\x04", b"Rar!", b"\x1f\x8b") -def _resolveFileScope(fileId: str, context: dict) -> tuple: - """Resolve featureInstanceId and mandateId for a file from context or management DB. +def _resolveFileScope(fileId: str, context: dict, chatService=None) -> tuple: + """Resolve featureInstanceId and mandateId for a file from context or chat service. Returns (featureInstanceId, mandateId) — never None, always strings. """ @@ -23,13 +23,11 @@ def _resolveFileScope(fileId: str, context: dict) -> tuple: if fiId and mId: return fiId, mId try: - from modules.datamodels.datamodelFiles import FileItem - from modules.interfaces.interfaceDbManagement import ComponentObjects - fm = ComponentObjects().db._loadRecord(FileItem, fileId) - if fm: - _get = (lambda k: fm.get(k, "")) if isinstance(fm, dict) else (lambda k: getattr(fm, k, "")) - fiId = fiId or str(_get("featureInstanceId") or "") - mId = mId or str(_get("mandateId") or "") + if chatService: + fileInfo = chatService.getFileInfo(fileId) + if fileInfo: + fiId = fiId or str(fileInfo.get("featureInstanceId") or "") + mId = mId or str(fileInfo.get("mandateId") or "") except Exception as e: logger.warning(f"_resolveFileScope failed for fileId={fileId}: {e}") return fiId, mId @@ -57,7 +55,7 @@ def _getOrCreateTempFolder(chatService) -> Optional[str]: return str(folderId) if folderId else None newFolder = chatService.createFolder("Temp") folderId = newFolder.get("id") if isinstance(newFolder, dict) else getattr(newFolder, "id", None) - userId = getattr(getattr(chatService, "interfaceDbComponent", None), "userId", None) + userId = getattr(chatService.user, "id", None) if hasattr(chatService, "user") else None logger.info("_getOrCreateTempFolder: created Temp folder %s for user %s", folderId, userId) return str(folderId) if folderId else None except Exception as e: @@ -218,19 +216,16 @@ def _formatToolFileResult( def _buildResolverDbFromServices(services: Any): """DB adapter for ConnectorResolver: load UserConnections by id. - interfaceDbApp exposes getUserConnectionById; ConnectorResolver expects getUserConnection. + Wraps chatService.getUserConnectionById into the interface ConnectorResolver expects. """ chatService = services.chat - appIf = getattr(chatService, "interfaceDbApp", None) - if appIf and hasattr(appIf, "getUserConnectionById"): - class _Adapter: - def __init__(self, app): - self._app = app + class _Adapter: + def __init__(self, svc): + self._svc = svc - def getUserConnection(self, connectionId: str): - return self._app.getUserConnectionById(connectionId) + def getUserConnection(self, connectionId: str): + return self._svc.getUserConnectionById(connectionId) - return _Adapter(appIf) - return getattr(chatService, "interfaceDbComponent", None) + return _Adapter(chatService) diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_mediaTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_mediaTools.py index e3978b72..57f74513 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_mediaTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_mediaTools.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Media and utility tools (render, TTS, STT, image gen, charts, neutralize, code exec).""" @@ -382,26 +382,14 @@ def registerMediaTools(registry: ToolRegistry, services): if not voiceName: try: - from modules.datamodels.datamodelUam import UserVoicePreferences - from modules.interfaces.interfaceDbApp import getRootInterface userId = context.get("userId", "") if userId: - rootIf = getRootInterface() - prefRecords = rootIf.db.getRecordset( - UserVoicePreferences, - recordFilter={"userId": userId} - ) - if prefRecords: - allPrefs = [ - r if isinstance(r, dict) else r.model_dump() if hasattr(r, "model_dump") else r - for r in prefRecords - ] - _mid = str(mandateId or "").strip() - scopedPref = next((p for p in allPrefs if str(p.get("mandateId") or "").strip() == _mid), None) - globalPref = next((p for p in allPrefs if not str(p.get("mandateId") or "").strip()), None) - - def _resolveVoiceFromMap(prefDict, lang): - vm = (prefDict or {}).get("ttsVoiceMap", {}) or {} + chatService = services.chat + mandateIdVal = str(mandateId or "").strip() + prefDict = chatService.getUserVoicePreferences(userId, mandateIdVal) if hasattr(chatService, "getUserVoicePreferences") else None + if prefDict: + def _resolveVoiceFromMap(prefRec, lang): + vm = (prefRec or {}).get("ttsVoiceMap", {}) or {} if not isinstance(vm, dict) or not vm: return None baseLang = lang.split("-")[0].lower() if isinstance(lang, str) and lang else "" @@ -419,16 +407,10 @@ def registerMediaTools(registry: ToolRegistry, services): return mv.get("voiceName") if isinstance(mv, dict) else mv return None - voiceName = ( - _resolveVoiceFromMap(scopedPref, language) - or _resolveVoiceFromMap(globalPref, language) - or _resolveVoiceFromMap(allPrefs[0], language) - ) + voiceName = _resolveVoiceFromMap(prefDict, language) if not voiceName: - for candidate in [globalPref, scopedPref, allPrefs[0]]: - if candidate and candidate.get("ttsVoice") and candidate.get("ttsLanguage") == language: - voiceName = candidate["ttsVoice"] - break + if prefDict.get("ttsVoice") and prefDict.get("ttsLanguage") == language: + voiceName = prefDict["ttsVoice"] if voiceName: logger.info(f"textToSpeech: using configured voice '{voiceName}' for language '{language}'") except Exception as prefErr: diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_workspaceTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_workspaceTools.py index a1d56e24..4fc47e60 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_workspaceTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_workspaceTools.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Workspace and file management tools (read, write, search, folders, web, translate).""" @@ -150,16 +150,7 @@ def registerWorkspaceTools(registry: ToolRegistry, services): try: text = rawBytes.decode(encoding) if text.strip(): - _fileNeedNeutralize = False - try: - from modules.datamodels.datamodelFiles import FileItem - from modules.interfaces.interfaceDbManagement import ComponentObjects - _fRec = ComponentObjects().db._loadRecord(FileItem, fileId) - if _fRec: - _fG = (lambda k, d=None: _fRec.get(k, d)) if isinstance(_fRec, dict) else (lambda k, d=None: getattr(_fRec, k, d)) - _fileNeedNeutralize = bool(_fG("neutralize", False)) - except Exception as e: - logger.warning(f"readFile: neutralize flag check failed for {fileId}: {e}") + _fileNeedNeutralize = fileInfo.get("neutralize", False) if fileInfo else False if _fileNeedNeutralize: try: _nSvc = services.getService("neutralization") if hasattr(services, "getService") else None diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/registerCore.py b/modules/serviceCenter/services/serviceAgent/coreTools/registerCore.py index d2a76c9f..f8d8c76b 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/registerCore.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/registerCore.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Orchestrator: registers all core agent tools by delegating to domain modules.""" diff --git a/modules/serviceCenter/services/serviceAgent/datamodelAgent.py b/modules/serviceCenter/services/serviceAgent/datamodelAgent.py index 9c94247d..e2c76eab 100644 --- a/modules/serviceCenter/services/serviceAgent/datamodelAgent.py +++ b/modules/serviceCenter/services/serviceAgent/datamodelAgent.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Data models for the Agent service.""" diff --git a/modules/serviceCenter/services/serviceAgent/datamodelOntology.py b/modules/serviceCenter/services/serviceAgent/datamodelOntology.py index 30e5b023..2dc3ef6c 100644 --- a/modules/serviceCenter/services/serviceAgent/datamodelOntology.py +++ b/modules/serviceCenter/services/serviceAgent/datamodelOntology.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Ontology data model for feature data sub-agents. diff --git a/modules/serviceCenter/services/serviceAgent/externalToolRegistry.py b/modules/serviceCenter/services/serviceAgent/externalToolRegistry.py index cfa24a2b..5ef41e74 100644 --- a/modules/serviceCenter/services/serviceAgent/externalToolRegistry.py +++ b/modules/serviceCenter/services/serviceAgent/externalToolRegistry.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ External agent-tool provider registry. diff --git a/modules/serviceCenter/services/serviceAgent/featureDataAgent.py b/modules/serviceCenter/services/serviceAgent/featureDataAgent.py index 117645dc..e786030a 100644 --- a/modules/serviceCenter/services/serviceAgent/featureDataAgent.py +++ b/modules/serviceCenter/services/serviceAgent/featureDataAgent.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Feature Data Sub-Agent. diff --git a/modules/serviceCenter/services/serviceAgent/featureDataProvider.py b/modules/serviceCenter/services/serviceAgent/featureDataProvider.py index eec9fcca..c129a267 100644 --- a/modules/serviceCenter/services/serviceAgent/featureDataProvider.py +++ b/modules/serviceCenter/services/serviceAgent/featureDataProvider.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Generic data provider for querying feature-instance tables. diff --git a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py index 390d062c..81fc7f29 100644 --- a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py +++ b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Agent service: entry point for running AI agents with tool use.""" diff --git a/modules/serviceCenter/services/serviceAgent/ontologyToPromptCompiler.py b/modules/serviceCenter/services/serviceAgent/ontologyToPromptCompiler.py index 5b162ed3..d056b0f2 100644 --- a/modules/serviceCenter/services/serviceAgent/ontologyToPromptCompiler.py +++ b/modules/serviceCenter/services/serviceAgent/ontologyToPromptCompiler.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Deterministic compiler: OntologyDescriptor -> sub-agent prompt block. diff --git a/modules/serviceCenter/services/serviceAgent/queryValidator.py b/modules/serviceCenter/services/serviceAgent/queryValidator.py index 2dbbd57e..3f3e8c3d 100644 --- a/modules/serviceCenter/services/serviceAgent/queryValidator.py +++ b/modules/serviceCenter/services/serviceAgent/queryValidator.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Pre-execute query validator for the Feature Data Sub-Agent. diff --git a/modules/serviceCenter/services/serviceAgent/sandboxExecutor.py b/modules/serviceCenter/services/serviceAgent/sandboxExecutor.py index 395c674e..512a9322 100644 --- a/modules/serviceCenter/services/serviceAgent/sandboxExecutor.py +++ b/modules/serviceCenter/services/serviceAgent/sandboxExecutor.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Sandboxed code execution for the AI agent executeCode tool.""" @@ -101,10 +101,10 @@ class _VirtualFS: def _makeReadFile(services): """Create a readFile(fileId) closure bound to the current services context.""" def readFile(fileId: str, encoding: str = "utf-8") -> str: - mgmt = getattr(services, 'interfaceDbComponent', None) if services else None - if not mgmt: + chatService = getattr(services, 'chat', None) if services else None + if not chatService or not hasattr(chatService, 'getFileData'): raise RuntimeError("readFile: no file store available in this session") - data = mgmt.getFileData(str(fileId)) + data = chatService.getFileData(str(fileId)) if data is None: raise FileNotFoundError(f"File '{fileId}' not found in workspace") try: @@ -120,10 +120,10 @@ _MAX_FILE_BYTES = 50_000_000 # 50 MB safety limit def _makeReadFileBytes(services): """Create a readFileBytes(fileId) closure for binary file access in the sandbox.""" def readFileBytes(fileId: str) -> bytes: - mgmt = getattr(services, 'interfaceDbComponent', None) if services else None - if not mgmt: + chatService = getattr(services, 'chat', None) if services else None + if not chatService or not hasattr(chatService, 'getFileData'): raise RuntimeError("readFileBytes: no file store available in this session") - data = mgmt.getFileData(str(fileId)) + data = chatService.getFileData(str(fileId)) if data is None: raise FileNotFoundError(f"File '{fileId}' not found in workspace") if len(data) > _MAX_FILE_BYTES: diff --git a/modules/serviceCenter/services/serviceAgent/toolRegistry.py b/modules/serviceCenter/services/serviceAgent/toolRegistry.py index b2ba67a0..68dd621c 100644 --- a/modules/serviceCenter/services/serviceAgent/toolRegistry.py +++ b/modules/serviceCenter/services/serviceAgent/toolRegistry.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Tool registry for the Agent service. Manages tool definitions and dispatch.""" diff --git a/modules/serviceCenter/services/serviceAgent/toolboxRegistry.py b/modules/serviceCenter/services/serviceAgent/toolboxRegistry.py index a464525a..36c44910 100644 --- a/modules/serviceCenter/services/serviceAgent/toolboxRegistry.py +++ b/modules/serviceCenter/services/serviceAgent/toolboxRegistry.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Toolbox Registry for the Agent service. diff --git a/modules/serviceCenter/services/serviceAi/__init__.py b/modules/serviceCenter/services/serviceAi/__init__.py index c7f7d39c..5e64fe2a 100644 --- a/modules/serviceCenter/services/serviceAi/__init__.py +++ b/modules/serviceCenter/services/serviceAi/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """AI service.""" diff --git a/modules/serviceCenter/services/serviceAi/mainServiceAi.py b/modules/serviceCenter/services/serviceAi/mainServiceAi.py index 98489dc8..79389b21 100644 --- a/modules/serviceCenter/services/serviceAi/mainServiceAi.py +++ b/modules/serviceCenter/services/serviceAi/mainServiceAi.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import json import logging diff --git a/modules/serviceCenter/services/serviceAi/subAiCallLooping.py b/modules/serviceCenter/services/serviceAi/subAiCallLooping.py index ea218e11..1044d1db 100644 --- a/modules/serviceCenter/services/serviceAi/subAiCallLooping.py +++ b/modules/serviceCenter/services/serviceAi/subAiCallLooping.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ AI Call Looping Module diff --git a/modules/serviceCenter/services/serviceAi/subContentExtraction.py b/modules/serviceCenter/services/serviceAi/subContentExtraction.py index d66db1cc..59c90e21 100644 --- a/modules/serviceCenter/services/serviceAi/subContentExtraction.py +++ b/modules/serviceCenter/services/serviceAi/subContentExtraction.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Content Extraction Module diff --git a/modules/serviceCenter/services/serviceAi/subDocumentIntents.py b/modules/serviceCenter/services/serviceAi/subDocumentIntents.py index aae86fc2..ed01ef4c 100644 --- a/modules/serviceCenter/services/serviceAi/subDocumentIntents.py +++ b/modules/serviceCenter/services/serviceAi/subDocumentIntents.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Document Intent Analysis Module diff --git a/modules/serviceCenter/services/serviceAi/subJsonMerger.py b/modules/serviceCenter/services/serviceAi/subJsonMerger.py index 6b4e6c5e..33ff72d6 100644 --- a/modules/serviceCenter/services/serviceAi/subJsonMerger.py +++ b/modules/serviceCenter/services/serviceAi/subJsonMerger.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Modular JSON Merger - Intelligent JSON Fragment Merging diff --git a/modules/serviceCenter/services/serviceAi/subJsonResponseHandling.py b/modules/serviceCenter/services/serviceAi/subJsonResponseHandling.py index 1945c550..92007825 100644 --- a/modules/serviceCenter/services/serviceAi/subJsonResponseHandling.py +++ b/modules/serviceCenter/services/serviceAi/subJsonResponseHandling.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ JSON Response Handling Module diff --git a/modules/serviceCenter/services/serviceAi/subLoopingUseCases.py b/modules/serviceCenter/services/serviceAi/subLoopingUseCases.py index fa52fdac..5b67c822 100644 --- a/modules/serviceCenter/services/serviceAi/subLoopingUseCases.py +++ b/modules/serviceCenter/services/serviceAi/subLoopingUseCases.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Generic Looping Use Case System diff --git a/modules/serviceCenter/services/serviceAi/subResponseParsing.py b/modules/serviceCenter/services/serviceAi/subResponseParsing.py index 68c123ac..fe8b09e6 100644 --- a/modules/serviceCenter/services/serviceAi/subResponseParsing.py +++ b/modules/serviceCenter/services/serviceAi/subResponseParsing.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Response Parsing Module diff --git a/modules/serviceCenter/services/serviceAi/subStructureFilling.py b/modules/serviceCenter/services/serviceAi/subStructureFilling.py index c2e580a4..eb1e1d7d 100644 --- a/modules/serviceCenter/services/serviceAi/subStructureFilling.py +++ b/modules/serviceCenter/services/serviceAi/subStructureFilling.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Structure Filling Module diff --git a/modules/serviceCenter/services/serviceAi/subStructureGeneration.py b/modules/serviceCenter/services/serviceAi/subStructureGeneration.py index cee66f60..72c7cb50 100644 --- a/modules/serviceCenter/services/serviceAi/subStructureGeneration.py +++ b/modules/serviceCenter/services/serviceAi/subStructureGeneration.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Structure Generation Module diff --git a/modules/serviceCenter/services/serviceBackgroundJobs/__init__.py b/modules/serviceCenter/services/serviceBackgroundJobs/__init__.py index ce67dc4a..a5b197a5 100644 --- a/modules/serviceCenter/services/serviceBackgroundJobs/__init__.py +++ b/modules/serviceCenter/services/serviceBackgroundJobs/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Background job service: generic, reusable infrastructure for long-running tasks.""" diff --git a/modules/serviceCenter/services/serviceBackgroundJobs/mainBackgroundJobService.py b/modules/serviceCenter/services/serviceBackgroundJobs/mainBackgroundJobService.py index 6ac1cbee..7beb987b 100644 --- a/modules/serviceCenter/services/serviceBackgroundJobs/mainBackgroundJobService.py +++ b/modules/serviceCenter/services/serviceBackgroundJobs/mainBackgroundJobService.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Background job service. diff --git a/modules/serviceCenter/services/serviceBilling/__init__.py b/modules/serviceCenter/services/serviceBilling/__init__.py index 55d95d1a..3e74a0f2 100644 --- a/modules/serviceCenter/services/serviceBilling/__init__.py +++ b/modules/serviceCenter/services/serviceBilling/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Billing service.""" diff --git a/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py b/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py index 9076f9e0..6985aff9 100644 --- a/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py +++ b/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ When the shared mandate pool (PREPAY_MANDATE) is exhausted, notify mandate admins. diff --git a/modules/serviceCenter/services/serviceBilling/billingWebhookHandler.py b/modules/serviceCenter/services/serviceBilling/billingWebhookHandler.py index 8e765cc7..5aba9e94 100644 --- a/modules/serviceCenter/services/serviceBilling/billingWebhookHandler.py +++ b/modules/serviceCenter/services/serviceBilling/billingWebhookHandler.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Stripe webhook and subscription business logic for billing. diff --git a/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py b/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py index 2158506f..4dcf725c 100644 --- a/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py +++ b/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Billing Service - Central service for billing operations. diff --git a/modules/serviceCenter/services/serviceBilling/stripeCheckout.py b/modules/serviceCenter/services/serviceBilling/stripeCheckout.py index 010c4e4b..a25c4717 100644 --- a/modules/serviceCenter/services/serviceBilling/stripeCheckout.py +++ b/modules/serviceCenter/services/serviceBilling/stripeCheckout.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Stripe Checkout service for billing credit top-ups. diff --git a/modules/serviceCenter/services/serviceChat/__init__.py b/modules/serviceCenter/services/serviceChat/__init__.py index a776b886..6a977f18 100644 --- a/modules/serviceCenter/services/serviceChat/__init__.py +++ b/modules/serviceCenter/services/serviceChat/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Chat service.""" diff --git a/modules/serviceCenter/services/serviceChat/mainServiceChat.py b/modules/serviceCenter/services/serviceChat/mainServiceChat.py index 3382f75e..18ab2a68 100644 --- a/modules/serviceCenter/services/serviceChat/mainServiceChat.py +++ b/modules/serviceCenter/services/serviceChat/mainServiceChat.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Chat service for document processing, chat operations, and workflow management.""" import logging @@ -481,6 +481,9 @@ class ChatService: "tags": getattr(fileItem, "tags", None), "description": getattr(fileItem, "description", None), "status": getattr(fileItem, "status", None), + "neutralize": bool(getattr(fileItem, "neutralize", False)), + "featureInstanceId": getattr(fileItem, "featureInstanceId", None) or "", + "mandateId": getattr(fileItem, "mandateId", None) or "", } return None @@ -595,6 +598,53 @@ class ChatService: results = self.interfaceDbApp.db.getRecordset(DataSource, recordFilter={"id": dataSourceId}) return results[0] if results else None + def getDataSourcesByConnection(self, connectionId: str) -> List[Dict[str, Any]]: + """Get all DataSource records linked to a specific connectionId.""" + from modules.datamodels.datamodelDataSource import DataSource + return self.interfaceDbApp.db.getRecordset(DataSource, recordFilter={"connectionId": connectionId}) or [] + + def getFeatureInstance(self, featureInstanceId: str): + """Get a FeatureInstance record by ID.""" + if not featureInstanceId or not self.interfaceDbApp: + return None + try: + return self.interfaceDbApp.getFeatureInstance(featureInstanceId) + except Exception as e: + logger.warning(f"getFeatureInstance({featureInstanceId}) failed: {e}") + return None + + 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} + ) + if not prefRecords: + return None + allPrefs = [ + r if isinstance(r, dict) else r.model_dump() if hasattr(r, "model_dump") else r + for r in prefRecords + ] + _mid = str(mandateId or "").strip() + scopedPref = next((p for p in allPrefs if str(p.get("mandateId") or "").strip() == _mid), None) if _mid else None + globalPref = next((p for p in allPrefs if not str(p.get("mandateId") or "").strip()), None) + return scopedPref or globalPref + except Exception as e: + logger.warning(f"getUserVoicePreferences({userId}) failed: {e}") + return None + + def getFeatureDataSources(self, featureInstanceId: str) -> List[Dict[str, Any]]: + """Get all FeatureDataSource records for a given featureInstanceId.""" + from modules.datamodels.datamodelFeatures import FeatureDataSource + try: + return self.interfaceDbApp.db.getRecordset( + FeatureDataSource, recordFilter={"featureInstanceId": featureInstanceId} + ) or [] + except Exception as e: + logger.warning(f"getFeatureDataSources({featureInstanceId}) failed: {e}") + return [] + def deleteDataSource(self, dataSourceId: str) -> bool: """Delete a data source.""" from modules.datamodels.datamodelDataSource import DataSource diff --git a/modules/serviceCenter/services/serviceClickup/__init__.py b/modules/serviceCenter/services/serviceClickup/__init__.py index 49f56ec0..31f7b664 100644 --- a/modules/serviceCenter/services/serviceClickup/__init__.py +++ b/modules/serviceCenter/services/serviceClickup/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ClickUp service.""" diff --git a/modules/serviceCenter/services/serviceClickup/mainServiceClickup.py b/modules/serviceCenter/services/serviceClickup/mainServiceClickup.py index 74a7e809..9570c184 100644 --- a/modules/serviceCenter/services/serviceClickup/mainServiceClickup.py +++ b/modules/serviceCenter/services/serviceClickup/mainServiceClickup.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ClickUp API service (OAuth or personal token via UserConnection). diff --git a/modules/serviceCenter/services/serviceExtraction/__init__.py b/modules/serviceCenter/services/serviceExtraction/__init__.py index 737a1900..dbbfca46 100644 --- a/modules/serviceCenter/services/serviceExtraction/__init__.py +++ b/modules/serviceCenter/services/serviceExtraction/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from .mainServiceExtraction import ExtractionService diff --git a/modules/serviceCenter/services/serviceExtraction/chunking/__init__.py b/modules/serviceCenter/services/serviceExtraction/chunking/__init__.py index 085d67cf..e06ded01 100644 --- a/modules/serviceCenter/services/serviceExtraction/chunking/__init__.py +++ b/modules/serviceCenter/services/serviceExtraction/chunking/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. diff --git a/modules/serviceCenter/services/serviceExtraction/chunking/chunkerImage.py b/modules/serviceCenter/services/serviceExtraction/chunking/chunkerImage.py index d58d2139..340a18d0 100644 --- a/modules/serviceCenter/services/serviceExtraction/chunking/chunkerImage.py +++ b/modules/serviceCenter/services/serviceExtraction/chunking/chunkerImage.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from typing import Any, Dict, List import base64 diff --git a/modules/serviceCenter/services/serviceExtraction/chunking/chunkerStructure.py b/modules/serviceCenter/services/serviceExtraction/chunking/chunkerStructure.py index fa65e19c..91f127ed 100644 --- a/modules/serviceCenter/services/serviceExtraction/chunking/chunkerStructure.py +++ b/modules/serviceCenter/services/serviceExtraction/chunking/chunkerStructure.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from typing import Any, Dict, List import json diff --git a/modules/serviceCenter/services/serviceExtraction/chunking/chunkerTable.py b/modules/serviceCenter/services/serviceExtraction/chunking/chunkerTable.py index e137711d..67a48d42 100644 --- a/modules/serviceCenter/services/serviceExtraction/chunking/chunkerTable.py +++ b/modules/serviceCenter/services/serviceExtraction/chunking/chunkerTable.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from typing import Any, Dict, List diff --git a/modules/serviceCenter/services/serviceExtraction/chunking/chunkerText.py b/modules/serviceCenter/services/serviceExtraction/chunking/chunkerText.py index 330f9ea9..bea6e81e 100644 --- a/modules/serviceCenter/services/serviceExtraction/chunking/chunkerText.py +++ b/modules/serviceCenter/services/serviceExtraction/chunking/chunkerText.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from typing import Any, Dict, List import logging diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/__init__.py b/modules/serviceCenter/services/serviceExtraction/extractors/__init__.py index 085d67cf..e06ded01 100644 --- a/modules/serviceCenter/services/serviceExtraction/extractors/__init__.py +++ b/modules/serviceCenter/services/serviceExtraction/extractors/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorAudio.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorAudio.py index a1f06f99..599b0285 100644 --- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorAudio.py +++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorAudio.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Audio extractor for common audio formats. diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorBinary.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorBinary.py index f0048c7a..3f1ea95b 100644 --- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorBinary.py +++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorBinary.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from typing import Any, Dict, List import base64 diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorContainer.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorContainer.py index a69b2e35..3e33db52 100644 --- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorContainer.py +++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorContainer.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Container extractor for ZIP, TAR, GZ, and 7Z archives. diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorCsv.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorCsv.py index 1f6bbaf6..ccf87d55 100644 --- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorCsv.py +++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorCsv.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from typing import Any, Dict, List diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorDocx.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorDocx.py index c8e7c289..5e5ef8fa 100644 --- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorDocx.py +++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorDocx.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from typing import Any, Dict, List import io diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorEmail.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorEmail.py index b557172f..6180f5d1 100644 --- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorEmail.py +++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorEmail.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Email extractor for EML and MSG files. @@ -82,6 +82,9 @@ class EmailExtractor(Extractor): data=headerText, metadata={"emailPart": "headers"}, )) + hasPlainBody = False + htmlBodies: List[str] = [] + for part in msg.walk(): contentType = part.get_content_type() disposition = str(part.get("Content-Disposition", "")) @@ -98,7 +101,8 @@ class EmailExtractor(Extractor): if contentType == "text/plain": body = part.get_content() - if body: + if body and str(body).strip(): + hasPlainBody = True parts.append(ContentPart( id=makeId(), parentId=rootId, label="body_text", typeGroup="text", mimeType="text/plain", @@ -107,10 +111,18 @@ class EmailExtractor(Extractor): elif contentType == "text/html": body = part.get_content() if body: + htmlBodies.append(str(body)) + + if htmlBodies: + if hasPlainBody: + pass + else: + plainText = _htmlToPlainText(htmlBodies[0]) + if plainText.strip(): parts.append(ContentPart( - id=makeId(), parentId=rootId, label="body_html", - typeGroup="text", mimeType="text/html", - data=str(body), metadata={"emailPart": "body_html"}, + id=makeId(), parentId=rootId, label="body_text", + typeGroup="text", mimeType="text/plain", + data=plainText, metadata={"emailPart": "body", "convertedFromHtml": True}, )) return parts @@ -171,11 +183,14 @@ class EmailExtractor(Extractor): if htmlBody: if isinstance(htmlBody, bytes): htmlBody = htmlBody.decode("utf-8", errors="replace") - parts.append(ContentPart( - id=makeId(), parentId=rootId, label="body_html", - typeGroup="text", mimeType="text/html", - data=htmlBody, metadata={"emailPart": "body_html"}, - )) + if not body or not body.strip(): + plainText = _htmlToPlainText(htmlBody) + if plainText.strip(): + parts.append(ContentPart( + id=makeId(), parentId=rootId, label="body_text", + typeGroup="text", mimeType="text/plain", + data=plainText, metadata={"emailPart": "body", "convertedFromHtml": True}, + )) for attachment in (msgFile.attachments or []): attachName = getattr(attachment, "longFilename", None) or getattr(attachment, "shortFilename", None) or "attachment" @@ -201,6 +216,24 @@ def _buildHeaderText(msg) -> str: return "\n".join(lines) +def _htmlToPlainText(html: str) -> str: + """Convert HTML email body to readable plain text, stripping all tags and noise.""" + import re + try: + from bs4 import BeautifulSoup + soup = BeautifulSoup(html, "html.parser") + for tag in soup(["script", "style", "head", "meta", "link"]): + tag.decompose() + text = soup.get_text(separator="\n") + except Exception: + text = re.sub(r"<[^>]+>", " ", html) + lines = [line.strip() for line in text.splitlines()] + lines = [line for line in lines if line] + text = "\n".join(lines) + text = re.sub(r"\n{3,}", "\n\n", text) + return text.strip() + + _MAX_CASCADE_DEPTH = 10 def _delegateAttachment(attachData: bytes, attachName: str, parentId: str, depth: int = 0) -> List[ContentPart]: diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorFolder.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorFolder.py index 51c8d9f5..0f81fce0 100644 --- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorFolder.py +++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorFolder.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Folder extractor -- treats a local folder reference as a container. diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorHtml.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorHtml.py index c7e549bb..b840bdbc 100644 --- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorHtml.py +++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorHtml.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from typing import Any, Dict, List from bs4 import BeautifulSoup diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorImage.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorImage.py index 7f081176..8f51e70b 100644 --- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorImage.py +++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorImage.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from typing import Any, Dict, List import base64 diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorJson.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorJson.py index a4ef705d..39f7b377 100644 --- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorJson.py +++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorJson.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from typing import Any, Dict, List import json diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorPdf.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorPdf.py index 657e3fc6..d87f3c55 100644 --- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorPdf.py +++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorPdf.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from typing import Any, Dict, List import base64 diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorPptx.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorPptx.py index 0c811d20..a278a823 100644 --- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorPptx.py +++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorPptx.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging import base64 diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorSql.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorSql.py index 01b1ba07..7b7e497e 100644 --- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorSql.py +++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorSql.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from typing import Any, Dict, List diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorText.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorText.py index 92b1fc4a..764d6b4d 100644 --- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorText.py +++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorText.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from typing import Any, Dict, List diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorVideo.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorVideo.py index 1b0513ce..517c5c53 100644 --- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorVideo.py +++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorVideo.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Video extractor for common video formats. diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorXlsx.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorXlsx.py index a85902f3..fa45db0b 100644 --- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorXlsx.py +++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorXlsx.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from typing import Any, Dict, List import io diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorXml.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorXml.py index e264e774..900cd1f2 100644 --- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorXml.py +++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorXml.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from typing import Any, Dict, List import xml.etree.ElementTree as ET diff --git a/modules/serviceCenter/services/serviceExtraction/mainServiceExtraction.py b/modules/serviceCenter/services/serviceExtraction/mainServiceExtraction.py index 125281e7..ceed2162 100644 --- a/modules/serviceCenter/services/serviceExtraction/mainServiceExtraction.py +++ b/modules/serviceCenter/services/serviceExtraction/mainServiceExtraction.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Extraction service for document content extraction and processing.""" from typing import Any, Dict, List, Optional, Union, Callable diff --git a/modules/serviceCenter/services/serviceExtraction/merging/__init__.py b/modules/serviceCenter/services/serviceExtraction/merging/__init__.py index fdcc4f0e..06003961 100644 --- a/modules/serviceCenter/services/serviceExtraction/merging/__init__.py +++ b/modules/serviceCenter/services/serviceExtraction/merging/__init__.py @@ -1,2 +1,2 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. diff --git a/modules/serviceCenter/services/serviceExtraction/merging/mergerDefault.py b/modules/serviceCenter/services/serviceExtraction/merging/mergerDefault.py index 3ea0fa82..573a9c6e 100644 --- a/modules/serviceCenter/services/serviceExtraction/merging/mergerDefault.py +++ b/modules/serviceCenter/services/serviceExtraction/merging/mergerDefault.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from typing import Any, Dict, List from modules.datamodels.datamodelExtraction import ContentPart, MergeStrategy diff --git a/modules/serviceCenter/services/serviceExtraction/merging/mergerTable.py b/modules/serviceCenter/services/serviceExtraction/merging/mergerTable.py index 6bdcfc11..c1090cf1 100644 --- a/modules/serviceCenter/services/serviceExtraction/merging/mergerTable.py +++ b/modules/serviceCenter/services/serviceExtraction/merging/mergerTable.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from typing import Any, Dict, List from modules.datamodels.datamodelExtraction import ContentPart, MergeStrategy diff --git a/modules/serviceCenter/services/serviceExtraction/merging/mergerText.py b/modules/serviceCenter/services/serviceExtraction/merging/mergerText.py index 591c4462..15c58070 100644 --- a/modules/serviceCenter/services/serviceExtraction/merging/mergerText.py +++ b/modules/serviceCenter/services/serviceExtraction/merging/mergerText.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from typing import Any, Dict, List from modules.datamodels.datamodelExtraction import ContentPart, MergeStrategy diff --git a/modules/serviceCenter/services/serviceExtraction/subMerger.py b/modules/serviceCenter/services/serviceExtraction/subMerger.py index 003cbce2..147dad38 100644 --- a/modules/serviceCenter/services/serviceExtraction/subMerger.py +++ b/modules/serviceCenter/services/serviceExtraction/subMerger.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Intelligent Token-Aware Merger for optimizing AI calls based on LLM token limits. diff --git a/modules/serviceCenter/services/serviceExtraction/subPipeline.py b/modules/serviceCenter/services/serviceExtraction/subPipeline.py index b76578ed..4717510d 100644 --- a/modules/serviceCenter/services/serviceExtraction/subPipeline.py +++ b/modules/serviceCenter/services/serviceExtraction/subPipeline.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from typing import List import logging diff --git a/modules/serviceCenter/services/serviceExtraction/subPromptBuilderExtraction.py b/modules/serviceCenter/services/serviceExtraction/subPromptBuilderExtraction.py index 0f9cbf45..55fbe405 100644 --- a/modules/serviceCenter/services/serviceExtraction/subPromptBuilderExtraction.py +++ b/modules/serviceCenter/services/serviceExtraction/subPromptBuilderExtraction.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Prompt builder for document extraction. diff --git a/modules/serviceCenter/services/serviceExtraction/subRegistry.py b/modules/serviceCenter/services/serviceExtraction/subRegistry.py index 864afe65..7072ecbb 100644 --- a/modules/serviceCenter/services/serviceExtraction/subRegistry.py +++ b/modules/serviceCenter/services/serviceExtraction/subRegistry.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from typing import Any, Dict, List, Optional, TYPE_CHECKING import logging diff --git a/modules/serviceCenter/services/serviceExtraction/subUtils.py b/modules/serviceCenter/services/serviceExtraction/subUtils.py index 2e3c3384..13e268cb 100644 --- a/modules/serviceCenter/services/serviceExtraction/subUtils.py +++ b/modules/serviceCenter/services/serviceExtraction/subUtils.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import uuid diff --git a/modules/serviceCenter/services/serviceGeneration/__init__.py b/modules/serviceCenter/services/serviceGeneration/__init__.py index 49e4ab4b..38191d73 100644 --- a/modules/serviceCenter/services/serviceGeneration/__init__.py +++ b/modules/serviceCenter/services/serviceGeneration/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Generation service.""" diff --git a/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py b/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py index 1137b7d6..08ab5127 100644 --- a/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py +++ b/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging import uuid @@ -693,4 +693,4 @@ class GenerationService: logger.error(f"Error getting renderer for {output_format}: {str(e)}") # traceback is already imported at module level logger.debug(traceback.format_exc()) - return None \ No newline at end of file + return None diff --git a/modules/serviceCenter/services/serviceGeneration/paths/codePath.py b/modules/serviceCenter/services/serviceGeneration/paths/codePath.py index c7f76689..ac4f85bd 100644 --- a/modules/serviceCenter/services/serviceGeneration/paths/codePath.py +++ b/modules/serviceCenter/services/serviceGeneration/paths/codePath.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Code Generation Path diff --git a/modules/serviceCenter/services/serviceGeneration/paths/documentPath.py b/modules/serviceCenter/services/serviceGeneration/paths/documentPath.py index b74286eb..e680b658 100644 --- a/modules/serviceCenter/services/serviceGeneration/paths/documentPath.py +++ b/modules/serviceCenter/services/serviceGeneration/paths/documentPath.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Document Generation Path diff --git a/modules/serviceCenter/services/serviceGeneration/paths/imagePath.py b/modules/serviceCenter/services/serviceGeneration/paths/imagePath.py index c61bc997..89d99941 100644 --- a/modules/serviceCenter/services/serviceGeneration/paths/imagePath.py +++ b/modules/serviceCenter/services/serviceGeneration/paths/imagePath.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Image Generation Path diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/_pdfFontFallback.py b/modules/serviceCenter/services/serviceGeneration/renderers/_pdfFontFallback.py index 8603c78f..2dad2b3d 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/_pdfFontFallback.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/_pdfFontFallback.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Inline emoji-font fallback for the ReportLab-based PDF renderer. diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/codeRendererBaseTemplate.py b/modules/serviceCenter/services/serviceGeneration/renderers/codeRendererBaseTemplate.py index d3586b8e..ed47240e 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/codeRendererBaseTemplate.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/codeRendererBaseTemplate.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Base renderer class for code format renderers. diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/documentRendererBaseTemplate.py b/modules/serviceCenter/services/serviceGeneration/renderers/documentRendererBaseTemplate.py index 9fc4d94b..cb44dbc0 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/documentRendererBaseTemplate.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/documentRendererBaseTemplate.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Base renderer class for all format renderers. @@ -783,4 +783,4 @@ Requirements: - Ensure all objects are properly closed with closing braces - Only modify styles if style instructions are present in the user request -Return the complete JSON:""" \ No newline at end of file +Return the complete JSON:""" diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/registry.py b/modules/serviceCenter/services/serviceGeneration/renderers/registry.py index f0cea780..568c63c2 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/registry.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/registry.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Renderer registry for automatic discovery and registration of renderers. diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererCodeCsv.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererCodeCsv.py index e430c302..90aa95b8 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererCodeCsv.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererCodeCsv.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ CSV code renderer for code generation. diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererCodeJson.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererCodeJson.py index 143be000..ebf5175a 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererCodeJson.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererCodeJson.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ JSON code renderer for code generation. diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererCodeXml.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererCodeXml.py index f4952679..7fd36e13 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererCodeXml.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererCodeXml.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ XML code renderer for code generation. diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererCsv.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererCsv.py index d08fc1fe..4a0333bc 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererCsv.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererCsv.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ CSV renderer for report generation. diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererDocx.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererDocx.py index 7e427dd4..fdc2f3c4 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererDocx.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererDocx.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ DOCX renderer for report generation using python-docx. diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererHtml.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererHtml.py index fe624723..fee4b2df 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererHtml.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererHtml.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ HTML renderer for report generation. diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererImage.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererImage.py index 2c8524e3..2624c64f 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererImage.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererImage.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Image renderer for report generation using AI image generation. diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererJson.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererJson.py index bc6b6a85..1de10105 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererJson.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererJson.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ JSON renderer for report generation. diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererMarkdown.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererMarkdown.py index b2458f19..f0454690 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererMarkdown.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererMarkdown.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Markdown renderer for report generation. diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py index a7df6875..0543a7f3 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ PDF renderer for report generation using reportlab. @@ -1290,4 +1290,4 @@ class RendererPdf(BaseRenderer): errorStyle = self._createNormalStyle(styles) errorStyle.textColor = self._hexToColor("#FF0000") # Red color for error errorMsg = f"[Error: Could not render image '{image_data.get('altText', 'Image')}'. {str(e)}]" - return [Paragraph(errorMsg, errorStyle)] \ No newline at end of file + return [Paragraph(errorMsg, errorStyle)] diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPptx.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPptx.py index 36d399d8..1547086f 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPptx.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPptx.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging import base64 diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererText.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererText.py index 1af2aec5..366139e8 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererText.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererText.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Text renderer for report generation. diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererXlsx.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererXlsx.py index d82e4a55..aaa5d022 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererXlsx.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererXlsx.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Excel renderer for report generation using openpyxl. diff --git a/modules/serviceCenter/services/serviceGeneration/styleDefaults.py b/modules/serviceCenter/services/serviceGeneration/styleDefaults.py index 8d60c282..abd21feb 100644 --- a/modules/serviceCenter/services/serviceGeneration/styleDefaults.py +++ b/modules/serviceCenter/services/serviceGeneration/styleDefaults.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Default style definitions and style resolution for document rendering.""" diff --git a/modules/serviceCenter/services/serviceGeneration/subContentGenerator.py b/modules/serviceCenter/services/serviceGeneration/subContentGenerator.py index c8713fee..4a6c325a 100644 --- a/modules/serviceCenter/services/serviceGeneration/subContentGenerator.py +++ b/modules/serviceCenter/services/serviceGeneration/subContentGenerator.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Content Generator for hierarchical document generation. diff --git a/modules/serviceCenter/services/serviceGeneration/subContentIntegrator.py b/modules/serviceCenter/services/serviceGeneration/subContentIntegrator.py index 1a83eb6e..3dcc057e 100644 --- a/modules/serviceCenter/services/serviceGeneration/subContentIntegrator.py +++ b/modules/serviceCenter/services/serviceGeneration/subContentIntegrator.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Content Integrator for hierarchical document generation. diff --git a/modules/serviceCenter/services/serviceGeneration/subDocumentUtility.py b/modules/serviceCenter/services/serviceGeneration/subDocumentUtility.py index 21ba33d1..e70920a8 100644 --- a/modules/serviceCenter/services/serviceGeneration/subDocumentUtility.py +++ b/modules/serviceCenter/services/serviceGeneration/subDocumentUtility.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import json import logging @@ -542,4 +542,4 @@ def convertDocumentDataToString(document_data: Any, file_extension: str) -> str: return str(document_data) except Exception as e: logger.error(f"Error converting document data to string: {str(e)}") - return str(document_data) \ No newline at end of file + return str(document_data) diff --git a/modules/serviceCenter/services/serviceGeneration/subJsonSchema.py b/modules/serviceCenter/services/serviceGeneration/subJsonSchema.py index 22359499..67c7db5c 100644 --- a/modules/serviceCenter/services/serviceGeneration/subJsonSchema.py +++ b/modules/serviceCenter/services/serviceGeneration/subJsonSchema.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ JSON Schema definitions for AI-generated document structures (unified). diff --git a/modules/serviceCenter/services/serviceGeneration/subPromptBuilderGeneration.py b/modules/serviceCenter/services/serviceGeneration/subPromptBuilderGeneration.py index f0222dce..cd4462f2 100644 --- a/modules/serviceCenter/services/serviceGeneration/subPromptBuilderGeneration.py +++ b/modules/serviceCenter/services/serviceGeneration/subPromptBuilderGeneration.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Prompt builder for document generation. diff --git a/modules/serviceCenter/services/serviceGeneration/subStructureGenerator.py b/modules/serviceCenter/services/serviceGeneration/subStructureGenerator.py index c2438fc0..cd9c4d11 100644 --- a/modules/serviceCenter/services/serviceGeneration/subStructureGenerator.py +++ b/modules/serviceCenter/services/serviceGeneration/subStructureGenerator.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Structure Generator for hierarchical document generation. diff --git a/modules/serviceCenter/services/serviceKnowledge/__init__.py b/modules/serviceCenter/services/serviceKnowledge/__init__.py index a5d1fc04..6dde9734 100644 --- a/modules/serviceCenter/services/serviceKnowledge/__init__.py +++ b/modules/serviceCenter/services/serviceKnowledge/__init__.py @@ -1,3 +1,3 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """serviceKnowledge: 3-tier RAG Knowledge Store with semantic search.""" diff --git a/modules/serviceCenter/services/serviceKnowledge/_buildTree.py b/modules/serviceCenter/services/serviceKnowledge/_buildTree.py index cf32a925..e750e5a1 100644 --- a/modules/serviceCenter/services/serviceKnowledge/_buildTree.py +++ b/modules/serviceCenter/services/serviceKnowledge/_buildTree.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Generic UDB Tree builder. diff --git a/modules/serviceCenter/services/serviceKnowledge/costEstimate.py b/modules/serviceCenter/services/serviceKnowledge/costEstimate.py index c50da1fa..31b0035a 100644 --- a/modules/serviceCenter/services/serviceKnowledge/costEstimate.py +++ b/modules/serviceCenter/services/serviceKnowledge/costEstimate.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Indicative cost estimation for a RAG bootstrap run. diff --git a/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py b/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py index 095e97cc..d2c0830b 100644 --- a/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py +++ b/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Knowledge service: 3-tier RAG with indexing, semantic search, and context building.""" diff --git a/modules/serviceCenter/services/serviceKnowledge/ragLimits.py b/modules/serviceCenter/services/serviceKnowledge/ragLimits.py index de0a4886..c6c6b54a 100644 --- a/modules/serviceCenter/services/serviceKnowledge/ragLimits.py +++ b/modules/serviceCenter/services/serviceKnowledge/ragLimits.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Centralized RAG bootstrap limits + DataSource-scoped resolution. diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorIngestConsumer.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorIngestConsumer.py index 5fec915e..dca5b01a 100644 --- a/modules/serviceCenter/services/serviceKnowledge/subConnectorIngestConsumer.py +++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorIngestConsumer.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Connection-lifecycle consumer bridging OAuth events to ingestion jobs. diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncClickup.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncClickup.py index edddb2c1..8cb3d74a 100644 --- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncClickup.py +++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncClickup.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ClickUp bootstrap for the unified knowledge ingestion lane. diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGdrive.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGdrive.py index 7c485a82..ae5edb8f 100644 --- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGdrive.py +++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGdrive.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Google Drive bootstrap for the unified knowledge ingestion lane. diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGmail.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGmail.py index b07f83c3..8e2f5935 100644 --- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGmail.py +++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGmail.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Gmail bootstrap for the unified knowledge ingestion lane. diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncKdrive.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncKdrive.py index 5dd3174c..f264ea2b 100644 --- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncKdrive.py +++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncKdrive.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """kDrive bootstrap for the unified knowledge ingestion lane. diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncOutlook.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncOutlook.py index eb131350..53b688ee 100644 --- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncOutlook.py +++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncOutlook.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Outlook bootstrap for the unified knowledge ingestion lane. diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncSharepoint.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncSharepoint.py index adb4b841..6eda9d20 100644 --- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncSharepoint.py +++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncSharepoint.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """SharePoint bootstrap for the unified knowledge ingestion lane. diff --git a/modules/serviceCenter/services/serviceKnowledge/subFeatureBootstrap.py b/modules/serviceCenter/services/serviceKnowledge/subFeatureBootstrap.py index f1cd3887..1c4b838e 100644 --- a/modules/serviceCenter/services/serviceKnowledge/subFeatureBootstrap.py +++ b/modules/serviceCenter/services/serviceKnowledge/subFeatureBootstrap.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Feature-data RAG bootstrap: indexes FeatureDataSource rows into the knowledge store. diff --git a/modules/serviceCenter/services/serviceKnowledge/subPreScan.py b/modules/serviceCenter/services/serviceKnowledge/subPreScan.py index 0688deb2..d798b78d 100644 --- a/modules/serviceCenter/services/serviceKnowledge/subPreScan.py +++ b/modules/serviceCenter/services/serviceKnowledge/subPreScan.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Structure Pre-Scan: fast, AI-free document analysis. diff --git a/modules/serviceCenter/services/serviceKnowledge/subTextClean.py b/modules/serviceCenter/services/serviceKnowledge/subTextClean.py index 2d352cfa..cea45082 100644 --- a/modules/serviceCenter/services/serviceKnowledge/subTextClean.py +++ b/modules/serviceCenter/services/serviceKnowledge/subTextClean.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Text normalisation utilities used by knowledge ingestion. diff --git a/modules/serviceCenter/services/serviceKnowledge/subWalkerHelpers.py b/modules/serviceCenter/services/serviceKnowledge/subWalkerHelpers.py index 41d9d458..51ef0161 100644 --- a/modules/serviceCenter/services/serviceKnowledge/subWalkerHelpers.py +++ b/modules/serviceCenter/services/serviceKnowledge/subWalkerHelpers.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Shared helpers for ingestion walkers (timeouts, per-item logging). diff --git a/modules/serviceCenter/services/serviceKnowledge/udbNodes.py b/modules/serviceCenter/services/serviceKnowledge/udbNodes.py index 879983dd..00f07bfb 100644 --- a/modules/serviceCenter/services/serviceKnowledge/udbNodes.py +++ b/modules/serviceCenter/services/serviceKnowledge/udbNodes.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Polymorphic UdbNode hierarchy for the Unified Data Bar. diff --git a/modules/serviceCenter/services/serviceMessaging/__init__.py b/modules/serviceCenter/services/serviceMessaging/__init__.py index 83b4dfa8..700bf73a 100644 --- a/modules/serviceCenter/services/serviceMessaging/__init__.py +++ b/modules/serviceCenter/services/serviceMessaging/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Messaging service for the service center.""" diff --git a/modules/serviceCenter/services/serviceMessaging/mainServiceMessaging.py b/modules/serviceCenter/services/serviceMessaging/mainServiceMessaging.py index cc43ca0c..c6ca8a0a 100644 --- a/modules/serviceCenter/services/serviceMessaging/mainServiceMessaging.py +++ b/modules/serviceCenter/services/serviceMessaging/mainServiceMessaging.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Messaging service for sending messages across different channels. diff --git a/modules/serviceCenter/services/serviceMessaging/subscriptions/__init__.py b/modules/serviceCenter/services/serviceMessaging/subscriptions/__init__.py index 1a631412..aada1c7c 100644 --- a/modules/serviceCenter/services/serviceMessaging/subscriptions/__init__.py +++ b/modules/serviceCenter/services/serviceMessaging/subscriptions/__init__.py @@ -1,3 +1,3 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Subscription functions for the messaging service.""" diff --git a/modules/serviceCenter/services/serviceMessaging/subscriptions/subSubscriptionSystemErrors.py b/modules/serviceCenter/services/serviceMessaging/subscriptions/subSubscriptionSystemErrors.py index 447bf076..4a5d8366 100644 --- a/modules/serviceCenter/services/serviceMessaging/subscriptions/subSubscriptionSystemErrors.py +++ b/modules/serviceCenter/services/serviceMessaging/subscriptions/subSubscriptionSystemErrors.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Example subscription function for System Errors. diff --git a/modules/serviceCenter/services/serviceMessaging/subscriptions/subSubscriptionWorkflowAutomationRunFailed.py b/modules/serviceCenter/services/serviceMessaging/subscriptions/subSubscriptionWorkflowAutomationRunFailed.py index b1cfecd0..729fe483 100644 --- a/modules/serviceCenter/services/serviceMessaging/subscriptions/subSubscriptionWorkflowAutomationRunFailed.py +++ b/modules/serviceCenter/services/serviceMessaging/subscriptions/subSubscriptionWorkflowAutomationRunFailed.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Subscription handler for WorkflowAutomation workflow run failures. diff --git a/modules/serviceCenter/services/serviceSharepoint/__init__.py b/modules/serviceCenter/services/serviceSharepoint/__init__.py index d1ce925c..befa27e5 100644 --- a/modules/serviceCenter/services/serviceSharepoint/__init__.py +++ b/modules/serviceCenter/services/serviceSharepoint/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """SharePoint service.""" diff --git a/modules/serviceCenter/services/serviceSharepoint/mainServiceSharepoint.py b/modules/serviceCenter/services/serviceSharepoint/mainServiceSharepoint.py index e6cbc8e4..d31cfdda 100644 --- a/modules/serviceCenter/services/serviceSharepoint/mainServiceSharepoint.py +++ b/modules/serviceCenter/services/serviceSharepoint/mainServiceSharepoint.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Connector for SharePoint operations using Microsoft Graph API.""" diff --git a/modules/serviceCenter/services/serviceSubscription/enterpriseRenewalScheduler.py b/modules/serviceCenter/services/serviceSubscription/enterpriseRenewalScheduler.py index 9db20b0f..4cf85b66 100644 --- a/modules/serviceCenter/services/serviceSubscription/enterpriseRenewalScheduler.py +++ b/modules/serviceCenter/services/serviceSubscription/enterpriseRenewalScheduler.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Enterprise subscription auto-renewal scheduler. diff --git a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py index e5924aaf..439d9a5b 100644 --- a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py +++ b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Subscription Service — state-machine-based lifecycle management. diff --git a/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py b/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py index 73fbfb02..37d6a4df 100644 --- a/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py +++ b/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Auto-provision Stripe Products and Prices from the built-in plan catalog. diff --git a/modules/serviceCenter/services/serviceTicket/__init__.py b/modules/serviceCenter/services/serviceTicket/__init__.py index b2403cc9..83bab1db 100644 --- a/modules/serviceCenter/services/serviceTicket/__init__.py +++ b/modules/serviceCenter/services/serviceTicket/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Ticket service.""" diff --git a/modules/serviceCenter/services/serviceTicket/mainServiceTicket.py b/modules/serviceCenter/services/serviceTicket/mainServiceTicket.py index ea229940..af797cb7 100644 --- a/modules/serviceCenter/services/serviceTicket/mainServiceTicket.py +++ b/modules/serviceCenter/services/serviceTicket/mainServiceTicket.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Ticket service for creating ticket interfaces.""" diff --git a/modules/serviceCenter/services/serviceWeb/__init__.py b/modules/serviceCenter/services/serviceWeb/__init__.py index a4085312..38929add 100644 --- a/modules/serviceCenter/services/serviceWeb/__init__.py +++ b/modules/serviceCenter/services/serviceWeb/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Web research service.""" diff --git a/modules/serviceCenter/services/serviceWeb/mainServiceWeb.py b/modules/serviceCenter/services/serviceWeb/mainServiceWeb.py index c6403c8d..a54b50b8 100644 --- a/modules/serviceCenter/services/serviceWeb/mainServiceWeb.py +++ b/modules/serviceCenter/services/serviceWeb/mainServiceWeb.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Web crawl service for handling web research operations. diff --git a/modules/shared/__init__.py b/modules/shared/__init__.py index 6d67ce5c..f520f36c 100644 --- a/modules/shared/__init__.py +++ b/modules/shared/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Shared utilities module. diff --git a/modules/shared/attributeUtils.py b/modules/shared/attributeUtils.py index 35d94d2e..7c17ab1c 100644 --- a/modules/shared/attributeUtils.py +++ b/modules/shared/attributeUtils.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Shared utilities for model attributes and labels. diff --git a/modules/shared/callbackRegistry.py b/modules/shared/callbackRegistry.py index 361f4e1d..4e972fa3 100644 --- a/modules/shared/callbackRegistry.py +++ b/modules/shared/callbackRegistry.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Callback registry for decoupled event notifications. diff --git a/modules/shared/configuration.py b/modules/shared/configuration.py index fc7578f2..f7909533 100644 --- a/modules/shared/configuration.py +++ b/modules/shared/configuration.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Utility module for configuration management. @@ -586,4 +586,4 @@ def clearDecryptionCache() -> None: _decryption_cache.clear() # Create the global APP_CONFIG instance -APP_CONFIG = Configuration() \ No newline at end of file +APP_CONFIG = Configuration() diff --git a/modules/shared/dateRange.py b/modules/shared/dateRange.py index 54a7c594..4a0ea3fa 100644 --- a/modules/shared/dateRange.py +++ b/modules/shared/dateRange.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Date-range parsing for API endpoints that accept user-provided diff --git a/modules/shared/debugLogger.py b/modules/shared/debugLogger.py index 9062ed53..210a9251 100644 --- a/modules/shared/debugLogger.py +++ b/modules/shared/debugLogger.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Simple debug logger for AI prompts and responses. diff --git a/modules/shared/documentUtils.py b/modules/shared/documentUtils.py index 37eec8a5..c9e5c53b 100644 --- a/modules/shared/documentUtils.py +++ b/modules/shared/documentUtils.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Document utility functions (Layer L0 - shared). diff --git a/modules/shared/eventManagement.py b/modules/shared/eventManagement.py index 1edd53be..728b7a38 100644 --- a/modules/shared/eventManagement.py +++ b/modules/shared/eventManagement.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import asyncio import logging diff --git a/modules/shared/eventManager.py b/modules/shared/eventManager.py index 13b0b322..9b49edba 100644 --- a/modules/shared/eventManager.py +++ b/modules/shared/eventManager.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Event manager for SSE streaming (Layer L0 - shared). diff --git a/modules/shared/featureDiscovery.py b/modules/shared/featureDiscovery.py index 0332e9c1..a5eabcb8 100644 --- a/modules/shared/featureDiscovery.py +++ b/modules/shared/featureDiscovery.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Feature discovery utility (Layer L0 - shared). diff --git a/modules/shared/frontendTypes.py b/modules/shared/frontendTypes.py index 46b142a1..5740ca19 100644 --- a/modules/shared/frontendTypes.py +++ b/modules/shared/frontendTypes.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Global frontend type definitions for UI rendering. diff --git a/modules/shared/httpResilience.py b/modules/shared/httpResilience.py index 504686c8..f7226b7d 100644 --- a/modules/shared/httpResilience.py +++ b/modules/shared/httpResilience.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Shared HTTP resilience helpers for provider connectors. diff --git a/modules/shared/i18nRegistry.py b/modules/shared/i18nRegistry.py index a72dcd9c..af44e2a9 100644 --- a/modules/shared/i18nRegistry.py +++ b/modules/shared/i18nRegistry.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Gateway i18n registry: t(), @i18nModel, runtime translation cache. diff --git a/modules/shared/jsonUtils.py b/modules/shared/jsonUtils.py index ea3c0200..3770cf09 100644 --- a/modules/shared/jsonUtils.py +++ b/modules/shared/jsonUtils.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import json import logging diff --git a/modules/shared/mandateNameUtils.py b/modules/shared/mandateNameUtils.py index 661aaeee..c1f9795f 100644 --- a/modules/shared/mandateNameUtils.py +++ b/modules/shared/mandateNameUtils.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Slug and validation helpers for Mandate.name (Kurzzeichen). diff --git a/modules/shared/progressLogger.py b/modules/shared/progressLogger.py index dbcb569a..1dfd7b22 100644 --- a/modules/shared/progressLogger.py +++ b/modules/shared/progressLogger.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Progress Logger utility for standardized progress reporting in workflows. diff --git a/modules/shared/stripeClient.py b/modules/shared/stripeClient.py index 3f7dd3a7..68c9549f 100644 --- a/modules/shared/stripeClient.py +++ b/modules/shared/stripeClient.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Central Stripe SDK initialization. diff --git a/modules/shared/systemComponentRegistry.py b/modules/shared/systemComponentRegistry.py index 70cd485d..9594280d 100644 --- a/modules/shared/systemComponentRegistry.py +++ b/modules/shared/systemComponentRegistry.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ System-component lifecycle-hook registry (Layer L0 — shared). diff --git a/modules/shared/timeUtils.py b/modules/shared/timeUtils.py index 0c7b04f1..32a574f5 100644 --- a/modules/shared/timeUtils.py +++ b/modules/shared/timeUtils.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Timezone utilities for consistent timestamp handling across the gateway. @@ -187,4 +187,4 @@ def parseTimestamp(value: Any, default: Optional[float] = None) -> Optional[floa return float(value) except (ValueError, TypeError) as e: logger.warning(f"parseTimestamp: Failed to convert value of type {type(value).__name__} '{value}' to float: {type(e).__name__}: {str(e)}, returning default={default}") - return default \ No newline at end of file + return default diff --git a/modules/shared/voiceCatalog.py b/modules/shared/voiceCatalog.py index 2e98902e..8a371a9e 100644 --- a/modules/shared/voiceCatalog.py +++ b/modules/shared/voiceCatalog.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Voice / Language Catalog — Single Source of Truth. diff --git a/modules/shared/workflowArtifactVisibility.py b/modules/shared/workflowArtifactVisibility.py index 3431bee2..d2041aaf 100644 --- a/modules/shared/workflowArtifactVisibility.py +++ b/modules/shared/workflowArtifactVisibility.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """Heuristics for hiding internal workflow artefacts from user-facing file lists.""" from __future__ import annotations diff --git a/modules/shared/workflowState.py b/modules/shared/workflowState.py index 6a8680a3..ed32b6ab 100644 --- a/modules/shared/workflowState.py +++ b/modules/shared/workflowState.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Workflow State diff --git a/modules/system/__init__.py b/modules/system/__init__.py index 7c14ddfa..e1b5c340 100644 --- a/modules/system/__init__.py +++ b/modules/system/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ System Module - Contains system-level infrastructure: diff --git a/modules/system/databaseHealth.py b/modules/system/databaseHealth.py index 111cc592..5a9ec8fd 100644 --- a/modules/system/databaseHealth.py +++ b/modules/system/databaseHealth.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Database health utilities — table statistics and orphan detection/cleanup. diff --git a/modules/system/databaseMigration.py b/modules/system/databaseMigration.py index 4227529e..a05f8f94 100644 --- a/modules/system/databaseMigration.py +++ b/modules/system/databaseMigration.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Database migration utilities — backup (export) and restore (import) for all diff --git a/modules/system/gdprDeletion.py b/modules/system/gdprDeletion.py index ab3a6e2b..8b85a196 100644 --- a/modules/system/gdprDeletion.py +++ b/modules/system/gdprDeletion.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Generic GDPR data deletion engine. diff --git a/modules/system/i18nBootSync.py b/modules/system/i18nBootSync.py index 820376b1..3a96c0b1 100644 --- a/modules/system/i18nBootSync.py +++ b/modules/system/i18nBootSync.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ i18n boot-time logic: label discovery, DB sync, and cache loading. diff --git a/modules/system/mainSystem.py b/modules/system/mainSystem.py index b85ccf0b..0d8d01d9 100644 --- a/modules/system/mainSystem.py +++ b/modules/system/mainSystem.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ System Module - Main Module. diff --git a/modules/system/notifyMandateAdmins.py b/modules/system/notifyMandateAdmins.py index 25acd9a7..90ce32bf 100644 --- a/modules/system/notifyMandateAdmins.py +++ b/modules/system/notifyMandateAdmins.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Central notification utility for mandate administrators. diff --git a/modules/system/registry.py b/modules/system/registry.py index 1e2dffb4..61856356 100644 --- a/modules/system/registry.py +++ b/modules/system/registry.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Feature Registry for Plug&Play Feature Container Loading. diff --git a/modules/workflowAutomation/agentTools.py b/modules/workflowAutomation/agentTools.py index 88ac3a05..b8b16a29 100644 --- a/modules/workflowAutomation/agentTools.py +++ b/modules/workflowAutomation/agentTools.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Workflow Toolbox - AI-assisted graph manipulation tools for WorkflowAutomation. diff --git a/modules/workflowAutomation/editor/_valueKindResolver.py b/modules/workflowAutomation/editor/_valueKindResolver.py index 63dd849d..e182b58a 100644 --- a/modules/workflowAutomation/editor/_valueKindResolver.py +++ b/modules/workflowAutomation/editor/_valueKindResolver.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """Shared value-kind resolution helpers. Extracted from conditionOperators so that upstreamPathsService can resolve diff --git a/modules/workflowAutomation/editor/adapterValidator.py b/modules/workflowAutomation/editor/adapterValidator.py index 6e430878..4ed5b1c8 100644 --- a/modules/workflowAutomation/editor/adapterValidator.py +++ b/modules/workflowAutomation/editor/adapterValidator.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Adapter Validator — enforces 5 drift rules between Schicht-3 NodeAdapters diff --git a/modules/workflowAutomation/editor/conditionOperators.py b/modules/workflowAutomation/editor/conditionOperators.py index 5b5d611a..b442ed0d 100644 --- a/modules/workflowAutomation/editor/conditionOperators.py +++ b/modules/workflowAutomation/editor/conditionOperators.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """Backend-driven condition operator catalog and value-kind resolution for flow.ifElse.""" from __future__ import annotations diff --git a/modules/workflowAutomation/editor/nodeRegistry.py b/modules/workflowAutomation/editor/nodeRegistry.py index 7a7ca1a9..33d4ccaa 100644 --- a/modules/workflowAutomation/editor/nodeRegistry.py +++ b/modules/workflowAutomation/editor/nodeRegistry.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Node Type Registry for WorkflowAutomation editor - static node definitions (start, input, flow, data, ai, email, …). diff --git a/modules/workflowAutomation/editor/switchOutput.py b/modules/workflowAutomation/editor/switchOutput.py index b70c5eb1..5e189e79 100644 --- a/modules/workflowAutomation/editor/switchOutput.py +++ b/modules/workflowAutomation/editor/switchOutput.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """Build flow.switch branch payloads: filtered context + loop-ready items.""" from __future__ import annotations diff --git a/modules/workflowAutomation/editor/upstreamPathsService.py b/modules/workflowAutomation/editor/upstreamPathsService.py index a98be149..1f270a96 100644 --- a/modules/workflowAutomation/editor/upstreamPathsService.py +++ b/modules/workflowAutomation/editor/upstreamPathsService.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """Compute pickable upstream paths for DataPicker / AI workflow tools.""" from __future__ import annotations diff --git a/modules/workflowAutomation/engine/__init__.py b/modules/workflowAutomation/engine/__init__.py index 0656ab39..0ea8cbbe 100644 --- a/modules/workflowAutomation/engine/__init__.py +++ b/modules/workflowAutomation/engine/__init__.py @@ -1,2 +1,3 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. # automation2 - n8n-style graph execution engine. diff --git a/modules/workflowAutomation/engine/_runNotifications.py b/modules/workflowAutomation/engine/_runNotifications.py index c8d7786d..6b09ea6c 100644 --- a/modules/workflowAutomation/engine/_runNotifications.py +++ b/modules/workflowAutomation/engine/_runNotifications.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """Run failure notification helpers. Extracted from scheduler/mainScheduler to break the bidirectional import diff --git a/modules/workflowAutomation/engine/clickupTaskUpdateMerge.py b/modules/workflowAutomation/engine/clickupTaskUpdateMerge.py index a74cdaef..5ea56199 100644 --- a/modules/workflowAutomation/engine/clickupTaskUpdateMerge.py +++ b/modules/workflowAutomation/engine/clickupTaskUpdateMerge.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. # Merge clickup.updateTask node parameter taskUpdateEntries into taskUpdate JSON. import json diff --git a/modules/workflowAutomation/engine/executionEngine.py b/modules/workflowAutomation/engine/executionEngine.py index b1c877c2..e188adab 100644 --- a/modules/workflowAutomation/engine/executionEngine.py +++ b/modules/workflowAutomation/engine/executionEngine.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. # Main execution engine for automation2 graphs. import asyncio diff --git a/modules/workflowAutomation/engine/executors/__init__.py b/modules/workflowAutomation/engine/executors/__init__.py index 4d2180c3..d6a444df 100644 --- a/modules/workflowAutomation/engine/executors/__init__.py +++ b/modules/workflowAutomation/engine/executors/__init__.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. # Executors for automation2 node types. from .triggerExecutor import TriggerExecutor diff --git a/modules/workflowAutomation/engine/executors/actionNodeExecutor.py b/modules/workflowAutomation/engine/executors/actionNodeExecutor.py index 82c0cbe1..12dffc31 100644 --- a/modules/workflowAutomation/engine/executors/actionNodeExecutor.py +++ b/modules/workflowAutomation/engine/executors/actionNodeExecutor.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. # Action node executor — maps ai.*, email.*, sharepoint.*, clickup.*, file.*, trustee.* to method actions. # # Typed port system: parameters resolve via DataRefs / static values. Declarative port inheritance diff --git a/modules/workflowAutomation/engine/executors/dataExecutor.py b/modules/workflowAutomation/engine/executors/dataExecutor.py index e22eda6f..b2a53966 100644 --- a/modules/workflowAutomation/engine/executors/dataExecutor.py +++ b/modules/workflowAutomation/engine/executors/dataExecutor.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. # Data manipulation node executor: data.aggregate, data.filter, data.consolidate. import logging diff --git a/modules/workflowAutomation/engine/executors/flowExecutor.py b/modules/workflowAutomation/engine/executors/flowExecutor.py index 0f5f85d1..8029b4e3 100644 --- a/modules/workflowAutomation/engine/executors/flowExecutor.py +++ b/modules/workflowAutomation/engine/executors/flowExecutor.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. # Flow control node executor (ifElse, switch, loop, merge). import logging diff --git a/modules/workflowAutomation/engine/executors/inputExecutor.py b/modules/workflowAutomation/engine/executors/inputExecutor.py index 926dd3a8..364aca69 100644 --- a/modules/workflowAutomation/engine/executors/inputExecutor.py +++ b/modules/workflowAutomation/engine/executors/inputExecutor.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. # Input/Human node executor - creates tasks and pauses execution. import logging diff --git a/modules/workflowAutomation/engine/executors/ioExecutor.py b/modules/workflowAutomation/engine/executors/ioExecutor.py index ae527adf..805a0338 100644 --- a/modules/workflowAutomation/engine/executors/ioExecutor.py +++ b/modules/workflowAutomation/engine/executors/ioExecutor.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. # I/O node executor - delegates to ActionExecutor. import logging diff --git a/modules/workflowAutomation/engine/executors/triggerExecutor.py b/modules/workflowAutomation/engine/executors/triggerExecutor.py index 35b46237..1ab8c6b2 100644 --- a/modules/workflowAutomation/engine/executors/triggerExecutor.py +++ b/modules/workflowAutomation/engine/executors/triggerExecutor.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. # Start node executor (node type trigger.manual) — outputs the unified run envelope from context. import logging diff --git a/modules/workflowAutomation/engine/featureInstanceRefMigration.py b/modules/workflowAutomation/engine/featureInstanceRefMigration.py index b4fba529..7edd84f9 100644 --- a/modules/workflowAutomation/engine/featureInstanceRefMigration.py +++ b/modules/workflowAutomation/engine/featureInstanceRefMigration.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Phase-5 Schicht-4 migration: convert raw ``featureInstanceId: ""`` workflow parameters into typed ``FeatureInstanceRef`` envelopes on disk. diff --git a/modules/workflowAutomation/engine/graphUtils.py b/modules/workflowAutomation/engine/graphUtils.py index 68368b48..cb67076c 100644 --- a/modules/workflowAutomation/engine/graphUtils.py +++ b/modules/workflowAutomation/engine/graphUtils.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. # Graph parsing, validation, and topological sort for automation2. import json diff --git a/modules/workflowAutomation/engine/pickNotPushMigration.py b/modules/workflowAutomation/engine/pickNotPushMigration.py index 14b91eae..6a841ec9 100644 --- a/modules/workflowAutomation/engine/pickNotPushMigration.py +++ b/modules/workflowAutomation/engine/pickNotPushMigration.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Graph helpers for Pick-not-Push: materialize typed DataRefs before executeGraph runs. diff --git a/modules/workflowAutomation/engine/runEnvelope.py b/modules/workflowAutomation/engine/runEnvelope.py index 44da2fb5..cd13d46d 100644 --- a/modules/workflowAutomation/engine/runEnvelope.py +++ b/modules/workflowAutomation/engine/runEnvelope.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Unified run envelope for Automation2 start/trigger nodes. diff --git a/modules/workflowAutomation/engine/runFileLogger.py b/modules/workflowAutomation/engine/runFileLogger.py index af57c275..5efdec98 100644 --- a/modules/workflowAutomation/engine/runFileLogger.py +++ b/modules/workflowAutomation/engine/runFileLogger.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """Per-run NDJSON logs for persisted workflow-automation runs.""" from __future__ import annotations diff --git a/modules/workflowAutomation/engine/scheduleCron.py b/modules/workflowAutomation/engine/scheduleCron.py index 4a0cfa43..f7f35c38 100644 --- a/modules/workflowAutomation/engine/scheduleCron.py +++ b/modules/workflowAutomation/engine/scheduleCron.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Parse cron strings (5-field or 6-field) to APScheduler CronTrigger kwargs. Frontend produces: "minute hour day month dow" (5-field) or "sec min hour day month dow" (6-field). diff --git a/modules/workflowAutomation/engine/udmUpstreamShapes.py b/modules/workflowAutomation/engine/udmUpstreamShapes.py index 33dea176..91c6bdd6 100644 --- a/modules/workflowAutomation/engine/udmUpstreamShapes.py +++ b/modules/workflowAutomation/engine/udmUpstreamShapes.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Pure shape coercion for UDM-related upstream payloads (tests + optional tooling). diff --git a/modules/workflowAutomation/helpers.py b/modules/workflowAutomation/helpers.py index ddbde49e..b2ba3f9f 100644 --- a/modules/workflowAutomation/helpers.py +++ b/modules/workflowAutomation/helpers.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Shared helpers for WorkflowAutomation route files. diff --git a/modules/workflowAutomation/mainWorkflowAutomation.py b/modules/workflowAutomation/mainWorkflowAutomation.py index a05064c9..086530c7 100644 --- a/modules/workflowAutomation/mainWorkflowAutomation.py +++ b/modules/workflowAutomation/mainWorkflowAutomation.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ WorkflowAutomation System Component — n8n-style flow automation. @@ -150,17 +150,17 @@ def _migrateRbacNamespace() -> None: totalUpdated = 0 for oldPrefix, newPrefix in _REPLACEMENTS: cur.execute( - 'SELECT id, "objectKey" FROM "AccessRule" WHERE "objectKey" LIKE %s', + 'SELECT id, "item" FROM "AccessRule" WHERE "item" LIKE %s', (f"{oldPrefix}%",), ) rows = cur.fetchall() if not rows: continue - for rowId, objectKey in rows: - newKey = objectKey.replace(oldPrefix, newPrefix, 1) + for rowId, item in rows: + newKey = item.replace(oldPrefix, newPrefix, 1) cur.execute( - 'UPDATE "AccessRule" SET "objectKey" = %s WHERE id = %s', + 'UPDATE "AccessRule" SET "item" = %s WHERE id = %s', (newKey, rowId), ) totalUpdated += 1 diff --git a/modules/workflowAutomation/scheduler/__init__.py b/modules/workflowAutomation/scheduler/__init__.py index ab966ca5..0a344346 100644 --- a/modules/workflowAutomation/scheduler/__init__.py +++ b/modules/workflowAutomation/scheduler/__init__.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. # Workflow Scheduler — consolidated scheduler with v1 incremental sync patterns. from modules.workflowAutomation.scheduler.mainScheduler import ( WorkflowScheduler, diff --git a/modules/workflowAutomation/scheduler/emailPoller.py b/modules/workflowAutomation/scheduler/emailPoller.py index 944135bc..ae5df741 100644 --- a/modules/workflowAutomation/scheduler/emailPoller.py +++ b/modules/workflowAutomation/scheduler/emailPoller.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Background email poller for automation2. diff --git a/modules/workflowAutomation/scheduler/mainScheduler.py b/modules/workflowAutomation/scheduler/mainScheduler.py index a0ced9cc..d264d25d 100644 --- a/modules/workflowAutomation/scheduler/mainScheduler.py +++ b/modules/workflowAutomation/scheduler/mainScheduler.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Consolidated Workflow Scheduler. diff --git a/modules/workflows/methods/_actionSignatureValidator.py b/modules/workflows/methods/_actionSignatureValidator.py index ce43ee7b..b3d830a5 100644 --- a/modules/workflows/methods/_actionSignatureValidator.py +++ b/modules/workflows/methods/_actionSignatureValidator.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Action signature validator for the Typed Action Architecture (Phase 2). diff --git a/modules/workflows/methods/methodAi/__init__.py b/modules/workflows/methods/methodAi/__init__.py index 7ce40281..e86dc94f 100644 --- a/modules/workflows/methods/methodAi/__init__.py +++ b/modules/workflows/methods/methodAi/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from .methodAi import MethodAi diff --git a/modules/workflows/methods/methodAi/_common.py b/modules/workflows/methods/methodAi/_common.py index 27b36663..2ebc8f18 100644 --- a/modules/workflows/methods/methodAi/_common.py +++ b/modules/workflows/methods/methodAi/_common.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Shared helpers for AI workflow actions.""" diff --git a/modules/workflows/methods/methodAi/actions/__init__.py b/modules/workflows/methods/methodAi/actions/__init__.py index 641b4eaf..f64007f4 100644 --- a/modules/workflows/methods/methodAi/actions/__init__.py +++ b/modules/workflows/methods/methodAi/actions/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Action modules for AI operations.""" diff --git a/modules/workflows/methods/methodAi/actions/consolidate.py b/modules/workflows/methods/methodAi/actions/consolidate.py index 70d345cd..03854c38 100644 --- a/modules/workflows/methods/methodAi/actions/consolidate.py +++ b/modules/workflows/methods/methodAi/actions/consolidate.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import json diff --git a/modules/workflows/methods/methodAi/actions/convertDocument.py b/modules/workflows/methods/methodAi/actions/convertDocument.py index b2ed908b..e318e83d 100644 --- a/modules/workflows/methods/methodAi/actions/convertDocument.py +++ b/modules/workflows/methods/methodAi/actions/convertDocument.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging diff --git a/modules/workflows/methods/methodAi/actions/generateCode.py b/modules/workflows/methods/methodAi/actions/generateCode.py index 7a13e4a1..cf0d816e 100644 --- a/modules/workflows/methods/methodAi/actions/generateCode.py +++ b/modules/workflows/methods/methodAi/actions/generateCode.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging diff --git a/modules/workflows/methods/methodAi/actions/generateDocument.py b/modules/workflows/methods/methodAi/actions/generateDocument.py index 20b82042..a4f37edc 100644 --- a/modules/workflows/methods/methodAi/actions/generateDocument.py +++ b/modules/workflows/methods/methodAi/actions/generateDocument.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging diff --git a/modules/workflows/methods/methodAi/actions/process.py b/modules/workflows/methods/methodAi/actions/process.py index e3cc10f0..77adc40f 100644 --- a/modules/workflows/methods/methodAi/actions/process.py +++ b/modules/workflows/methods/methodAi/actions/process.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import base64 diff --git a/modules/workflows/methods/methodAi/actions/summarizeDocument.py b/modules/workflows/methods/methodAi/actions/summarizeDocument.py index 4c2bb2bc..c7950e2a 100644 --- a/modules/workflows/methods/methodAi/actions/summarizeDocument.py +++ b/modules/workflows/methods/methodAi/actions/summarizeDocument.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging diff --git a/modules/workflows/methods/methodAi/actions/translateDocument.py b/modules/workflows/methods/methodAi/actions/translateDocument.py index dc0533a9..28b4074f 100644 --- a/modules/workflows/methods/methodAi/actions/translateDocument.py +++ b/modules/workflows/methods/methodAi/actions/translateDocument.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging diff --git a/modules/workflows/methods/methodAi/actions/webResearch.py b/modules/workflows/methods/methodAi/actions/webResearch.py index b020cff4..6e82a92f 100644 --- a/modules/workflows/methods/methodAi/actions/webResearch.py +++ b/modules/workflows/methods/methodAi/actions/webResearch.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging diff --git a/modules/workflows/methods/methodAi/helpers/__init__.py b/modules/workflows/methods/methodAi/helpers/__init__.py index 4833e0e7..445ed4b1 100644 --- a/modules/workflows/methods/methodAi/helpers/__init__.py +++ b/modules/workflows/methods/methodAi/helpers/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Helper modules for AI method operations.""" diff --git a/modules/workflows/methods/methodAi/helpers/csvProcessing.py b/modules/workflows/methods/methodAi/helpers/csvProcessing.py index 9121f43c..58d2a6f1 100644 --- a/modules/workflows/methods/methodAi/helpers/csvProcessing.py +++ b/modules/workflows/methods/methodAi/helpers/csvProcessing.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ diff --git a/modules/workflows/methods/methodAi/methodAi.py b/modules/workflows/methods/methodAi/methodAi.py index 55c9a40a..4dc021ed 100644 --- a/modules/workflows/methods/methodAi/methodAi.py +++ b/modules/workflows/methods/methodAi/methodAi.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging diff --git a/modules/workflows/methods/methodBase.py b/modules/workflows/methods/methodBase.py index d9a941c5..55f15c36 100644 --- a/modules/workflows/methods/methodBase.py +++ b/modules/workflows/methods/methodBase.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging import re @@ -496,4 +496,4 @@ class MethodBase: self.logger.warning(f"Error generating meaningful file name, using fallback: {str(e)}") # Fallback to timestamp-based naming timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") - return f"{base_name}_{timestamp}.{extension}" \ No newline at end of file + return f"{base_name}_{timestamp}.{extension}" diff --git a/modules/workflows/methods/methodClickup/__init__.py b/modules/workflows/methods/methodClickup/__init__.py index 9e0362c4..84961b1c 100644 --- a/modules/workflows/methods/methodClickup/__init__.py +++ b/modules/workflows/methods/methodClickup/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from .methodClickup import MethodClickup diff --git a/modules/workflows/methods/methodClickup/actions/__init__.py b/modules/workflows/methods/methodClickup/actions/__init__.py index 5c54c5df..67956702 100644 --- a/modules/workflows/methods/methodClickup/actions/__init__.py +++ b/modules/workflows/methods/methodClickup/actions/__init__.py @@ -1,3 +1,3 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ClickUp workflow actions.""" diff --git a/modules/workflows/methods/methodClickup/actions/create_task.py b/modules/workflows/methods/methodClickup/actions/create_task.py index d010c234..1665aabe 100644 --- a/modules/workflows/methods/methodClickup/actions/create_task.py +++ b/modules/workflows/methods/methodClickup/actions/create_task.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import json diff --git a/modules/workflows/methods/methodClickup/actions/get_task.py b/modules/workflows/methods/methodClickup/actions/get_task.py index 1e3eecad..9eae5569 100644 --- a/modules/workflows/methods/methodClickup/actions/get_task.py +++ b/modules/workflows/methods/methodClickup/actions/get_task.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import json diff --git a/modules/workflows/methods/methodClickup/actions/list_fields.py b/modules/workflows/methods/methodClickup/actions/list_fields.py index 851437d7..a33868db 100644 --- a/modules/workflows/methods/methodClickup/actions/list_fields.py +++ b/modules/workflows/methods/methodClickup/actions/list_fields.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import json diff --git a/modules/workflows/methods/methodClickup/actions/list_tasks.py b/modules/workflows/methods/methodClickup/actions/list_tasks.py index 9ae57f94..90c5694f 100644 --- a/modules/workflows/methods/methodClickup/actions/list_tasks.py +++ b/modules/workflows/methods/methodClickup/actions/list_tasks.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import json diff --git a/modules/workflows/methods/methodClickup/actions/search_tasks.py b/modules/workflows/methods/methodClickup/actions/search_tasks.py index b173020c..259b0a7a 100644 --- a/modules/workflows/methods/methodClickup/actions/search_tasks.py +++ b/modules/workflows/methods/methodClickup/actions/search_tasks.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import json diff --git a/modules/workflows/methods/methodClickup/actions/update_task.py b/modules/workflows/methods/methodClickup/actions/update_task.py index 6282ec78..16b2173c 100644 --- a/modules/workflows/methods/methodClickup/actions/update_task.py +++ b/modules/workflows/methods/methodClickup/actions/update_task.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import json diff --git a/modules/workflows/methods/methodClickup/actions/upload_attachment.py b/modules/workflows/methods/methodClickup/actions/upload_attachment.py index 8cd1de4d..8691ab48 100644 --- a/modules/workflows/methods/methodClickup/actions/upload_attachment.py +++ b/modules/workflows/methods/methodClickup/actions/upload_attachment.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import base64 diff --git a/modules/workflows/methods/methodClickup/helpers/__init__.py b/modules/workflows/methods/methodClickup/helpers/__init__.py index fdcc4f0e..06003961 100644 --- a/modules/workflows/methods/methodClickup/helpers/__init__.py +++ b/modules/workflows/methods/methodClickup/helpers/__init__.py @@ -1,2 +1,2 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. diff --git a/modules/workflows/methods/methodClickup/helpers/connection.py b/modules/workflows/methods/methodClickup/helpers/connection.py index cdcd3601..4e072ea5 100644 --- a/modules/workflows/methods/methodClickup/helpers/connection.py +++ b/modules/workflows/methods/methodClickup/helpers/connection.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Resolve ClickUp UserConnection and configure ClickupService.""" diff --git a/modules/workflows/methods/methodClickup/helpers/pathparse.py b/modules/workflows/methods/methodClickup/helpers/pathparse.py index c97b69b2..e5485920 100644 --- a/modules/workflows/methods/methodClickup/helpers/pathparse.py +++ b/modules/workflows/methods/methodClickup/helpers/pathparse.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Parse virtual ClickUp paths used by the connector.""" diff --git a/modules/workflows/methods/methodClickup/methodClickup.py b/modules/workflows/methods/methodClickup/methodClickup.py index 725929dd..ed03899c 100644 --- a/modules/workflows/methods/methodClickup/methodClickup.py +++ b/modules/workflows/methods/methodClickup/methodClickup.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ClickUp workflow method — list/search/get/create/update tasks and upload attachments.""" diff --git a/modules/workflows/methods/methodContext/__init__.py b/modules/workflows/methods/methodContext/__init__.py index 8d6c7823..6359aebd 100644 --- a/modules/workflows/methods/methodContext/__init__.py +++ b/modules/workflows/methods/methodContext/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from .methodContext import MethodContext diff --git a/modules/workflows/methods/methodContext/actions/__init__.py b/modules/workflows/methods/methodContext/actions/__init__.py index 1750882e..4a18fd19 100644 --- a/modules/workflows/methods/methodContext/actions/__init__.py +++ b/modules/workflows/methods/methodContext/actions/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Action modules for Context operations.""" diff --git a/modules/workflows/methods/methodContext/actions/extractContent.py b/modules/workflows/methods/methodContext/actions/extractContent.py index e1869be3..5172ced2 100644 --- a/modules/workflows/methods/methodContext/actions/extractContent.py +++ b/modules/workflows/methods/methodContext/actions/extractContent.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """context.extractContent — extracts content without AI. diff --git a/modules/workflows/methods/methodContext/actions/filterContext.py b/modules/workflows/methods/methodContext/actions/filterContext.py index 6087b380..0ee02da4 100644 --- a/modules/workflows/methods/methodContext/actions/filterContext.py +++ b/modules/workflows/methods/methodContext/actions/filterContext.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Action ``context.filterContext``. diff --git a/modules/workflows/methods/methodContext/actions/getDocumentIndex.py b/modules/workflows/methods/methodContext/actions/getDocumentIndex.py index b2822e0d..27029fed 100644 --- a/modules/workflows/methods/methodContext/actions/getDocumentIndex.py +++ b/modules/workflows/methods/methodContext/actions/getDocumentIndex.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging diff --git a/modules/workflows/methods/methodContext/actions/mergeContext.py b/modules/workflows/methods/methodContext/actions/mergeContext.py index 79582cf2..0cd3485c 100644 --- a/modules/workflows/methods/methodContext/actions/mergeContext.py +++ b/modules/workflows/methods/methodContext/actions/mergeContext.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Action ``context.mergeContext``. diff --git a/modules/workflows/methods/methodContext/actions/neutralizeData.py b/modules/workflows/methods/methodContext/actions/neutralizeData.py index 5bd1eb34..89298f6a 100644 --- a/modules/workflows/methods/methodContext/actions/neutralizeData.py +++ b/modules/workflows/methods/methodContext/actions/neutralizeData.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import base64 diff --git a/modules/workflows/methods/methodContext/actions/setContext.py b/modules/workflows/methods/methodContext/actions/setContext.py index 58925f9e..861df789 100644 --- a/modules/workflows/methods/methodContext/actions/setContext.py +++ b/modules/workflows/methods/methodContext/actions/setContext.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Action ``context.setContext``. diff --git a/modules/workflows/methods/methodContext/actions/transformContext.py b/modules/workflows/methods/methodContext/actions/transformContext.py index ffff183d..5288dbae 100644 --- a/modules/workflows/methods/methodContext/actions/transformContext.py +++ b/modules/workflows/methods/methodContext/actions/transformContext.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Action ``context.transformContext``. diff --git a/modules/workflows/methods/methodContext/actions/triggerPreprocessingServer.py b/modules/workflows/methods/methodContext/actions/triggerPreprocessingServer.py index 2f011a25..e17df19b 100644 --- a/modules/workflows/methods/methodContext/actions/triggerPreprocessingServer.py +++ b/modules/workflows/methods/methodContext/actions/triggerPreprocessingServer.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging diff --git a/modules/workflows/methods/methodContext/contextEnvelope.py b/modules/workflows/methods/methodContext/contextEnvelope.py index c35836cf..fad01ec0 100644 --- a/modules/workflows/methods/methodContext/contextEnvelope.py +++ b/modules/workflows/methods/methodContext/contextEnvelope.py @@ -1,4 +1,5 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """Versioned ``ActionResult.data`` envelope for context.* actions (merge, transform).""" from __future__ import annotations diff --git a/modules/workflows/methods/methodContext/helpers/__init__.py b/modules/workflows/methods/methodContext/helpers/__init__.py index e1e2ab56..27e16884 100644 --- a/modules/workflows/methods/methodContext/helpers/__init__.py +++ b/modules/workflows/methods/methodContext/helpers/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Helper modules for Context method operations.""" diff --git a/modules/workflows/methods/methodContext/helpers/documentIndex.py b/modules/workflows/methods/methodContext/helpers/documentIndex.py index bba349cf..73bd65a9 100644 --- a/modules/workflows/methods/methodContext/helpers/documentIndex.py +++ b/modules/workflows/methods/methodContext/helpers/documentIndex.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ diff --git a/modules/workflows/methods/methodContext/helpers/formatting.py b/modules/workflows/methods/methodContext/helpers/formatting.py index ac38fb86..79ecace2 100644 --- a/modules/workflows/methods/methodContext/helpers/formatting.py +++ b/modules/workflows/methods/methodContext/helpers/formatting.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ diff --git a/modules/workflows/methods/methodContext/methodContext.py b/modules/workflows/methods/methodContext/methodContext.py index 80e0c089..7d9bf215 100644 --- a/modules/workflows/methods/methodContext/methodContext.py +++ b/modules/workflows/methods/methodContext/methodContext.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging diff --git a/modules/workflows/methods/methodFile/__init__.py b/modules/workflows/methods/methodFile/__init__.py index b8c41e0f..45b4a956 100644 --- a/modules/workflows/methods/methodFile/__init__.py +++ b/modules/workflows/methods/methodFile/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from .methodFile import MethodFile diff --git a/modules/workflows/methods/methodFile/actions/__init__.py b/modules/workflows/methods/methodFile/actions/__init__.py index 9aef4028..5ffbb1f7 100644 --- a/modules/workflows/methods/methodFile/actions/__init__.py +++ b/modules/workflows/methods/methodFile/actions/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from .create import create diff --git a/modules/workflows/methods/methodFile/actions/create.py b/modules/workflows/methods/methodFile/actions/create.py index 973f62d0..e7acd3bd 100644 --- a/modules/workflows/methods/methodFile/actions/create.py +++ b/modules/workflows/methods/methodFile/actions/create.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from typing import Any, Dict, List, Optional diff --git a/modules/workflows/methods/methodFile/methodFile.py b/modules/workflows/methods/methodFile/methodFile.py index c30f86a4..a1d48f87 100644 --- a/modules/workflows/methods/methodFile/methodFile.py +++ b/modules/workflows/methods/methodFile/methodFile.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging diff --git a/modules/workflows/methods/methodJira/__init__.py b/modules/workflows/methods/methodJira/__init__.py index e8b3822d..8793570d 100644 --- a/modules/workflows/methods/methodJira/__init__.py +++ b/modules/workflows/methods/methodJira/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from .methodJira import MethodJira diff --git a/modules/workflows/methods/methodJira/actions/__init__.py b/modules/workflows/methods/methodJira/actions/__init__.py index 67b0d38d..8b2cb166 100644 --- a/modules/workflows/methods/methodJira/actions/__init__.py +++ b/modules/workflows/methods/methodJira/actions/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Action modules for JIRA operations.""" diff --git a/modules/workflows/methods/methodJira/actions/connectJira.py b/modules/workflows/methods/methodJira/actions/connectJira.py index 45b60cad..737ccd88 100644 --- a/modules/workflows/methods/methodJira/actions/connectJira.py +++ b/modules/workflows/methods/methodJira/actions/connectJira.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging diff --git a/modules/workflows/methods/methodJira/actions/createCsvContent.py b/modules/workflows/methods/methodJira/actions/createCsvContent.py index cbec7960..46ec9965 100644 --- a/modules/workflows/methods/methodJira/actions/createCsvContent.py +++ b/modules/workflows/methods/methodJira/actions/createCsvContent.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging diff --git a/modules/workflows/methods/methodJira/actions/createExcelContent.py b/modules/workflows/methods/methodJira/actions/createExcelContent.py index 631795b3..7a71a95d 100644 --- a/modules/workflows/methods/methodJira/actions/createExcelContent.py +++ b/modules/workflows/methods/methodJira/actions/createExcelContent.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging diff --git a/modules/workflows/methods/methodJira/actions/exportTicketsAsJson.py b/modules/workflows/methods/methodJira/actions/exportTicketsAsJson.py index 55d99654..432457cd 100644 --- a/modules/workflows/methods/methodJira/actions/exportTicketsAsJson.py +++ b/modules/workflows/methods/methodJira/actions/exportTicketsAsJson.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging diff --git a/modules/workflows/methods/methodJira/actions/importTicketsFromJson.py b/modules/workflows/methods/methodJira/actions/importTicketsFromJson.py index b997889e..a6b06652 100644 --- a/modules/workflows/methods/methodJira/actions/importTicketsFromJson.py +++ b/modules/workflows/methods/methodJira/actions/importTicketsFromJson.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging diff --git a/modules/workflows/methods/methodJira/actions/mergeTicketData.py b/modules/workflows/methods/methodJira/actions/mergeTicketData.py index 2bd7ab74..c447ff6a 100644 --- a/modules/workflows/methods/methodJira/actions/mergeTicketData.py +++ b/modules/workflows/methods/methodJira/actions/mergeTicketData.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging diff --git a/modules/workflows/methods/methodJira/actions/parseCsvContent.py b/modules/workflows/methods/methodJira/actions/parseCsvContent.py index bbdc2cc7..91d7b25e 100644 --- a/modules/workflows/methods/methodJira/actions/parseCsvContent.py +++ b/modules/workflows/methods/methodJira/actions/parseCsvContent.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging diff --git a/modules/workflows/methods/methodJira/actions/parseExcelContent.py b/modules/workflows/methods/methodJira/actions/parseExcelContent.py index 5ac4e548..4a2ec1b7 100644 --- a/modules/workflows/methods/methodJira/actions/parseExcelContent.py +++ b/modules/workflows/methods/methodJira/actions/parseExcelContent.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging diff --git a/modules/workflows/methods/methodJira/helpers/__init__.py b/modules/workflows/methods/methodJira/helpers/__init__.py index cf2fc4c7..27c3e974 100644 --- a/modules/workflows/methods/methodJira/helpers/__init__.py +++ b/modules/workflows/methods/methodJira/helpers/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Helper modules for JIRA method operations.""" diff --git a/modules/workflows/methods/methodJira/helpers/adfConverter.py b/modules/workflows/methods/methodJira/helpers/adfConverter.py index d8619989..506f3d1c 100644 --- a/modules/workflows/methods/methodJira/helpers/adfConverter.py +++ b/modules/workflows/methods/methodJira/helpers/adfConverter.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ diff --git a/modules/workflows/methods/methodJira/helpers/documentParsing.py b/modules/workflows/methods/methodJira/helpers/documentParsing.py index b0608524..1bb795ab 100644 --- a/modules/workflows/methods/methodJira/helpers/documentParsing.py +++ b/modules/workflows/methods/methodJira/helpers/documentParsing.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ diff --git a/modules/workflows/methods/methodJira/methodJira.py b/modules/workflows/methods/methodJira/methodJira.py index 0268d020..0fa4ddfd 100644 --- a/modules/workflows/methods/methodJira/methodJira.py +++ b/modules/workflows/methods/methodJira/methodJira.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging diff --git a/modules/workflows/methods/methodOutlook/__init__.py b/modules/workflows/methods/methodOutlook/__init__.py index c7653010..02187a5d 100644 --- a/modules/workflows/methods/methodOutlook/__init__.py +++ b/modules/workflows/methods/methodOutlook/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from .methodOutlook import MethodOutlook diff --git a/modules/workflows/methods/methodOutlook/actions/__init__.py b/modules/workflows/methods/methodOutlook/actions/__init__.py index f62e3e0a..c6976650 100644 --- a/modules/workflows/methods/methodOutlook/actions/__init__.py +++ b/modules/workflows/methods/methodOutlook/actions/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Action modules for Outlook operations.""" diff --git a/modules/workflows/methods/methodOutlook/actions/composeAndDraftEmailWithContext.py b/modules/workflows/methods/methodOutlook/actions/composeAndDraftEmailWithContext.py index 22c5ff62..6b482d42 100644 --- a/modules/workflows/methods/methodOutlook/actions/composeAndDraftEmailWithContext.py +++ b/modules/workflows/methods/methodOutlook/actions/composeAndDraftEmailWithContext.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging diff --git a/modules/workflows/methods/methodOutlook/actions/readEmails.py b/modules/workflows/methods/methodOutlook/actions/readEmails.py index 5620a62d..c6051a4b 100644 --- a/modules/workflows/methods/methodOutlook/actions/readEmails.py +++ b/modules/workflows/methods/methodOutlook/actions/readEmails.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging diff --git a/modules/workflows/methods/methodOutlook/actions/searchEmails.py b/modules/workflows/methods/methodOutlook/actions/searchEmails.py index f12c6d71..5f4c8c85 100644 --- a/modules/workflows/methods/methodOutlook/actions/searchEmails.py +++ b/modules/workflows/methods/methodOutlook/actions/searchEmails.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging diff --git a/modules/workflows/methods/methodOutlook/actions/sendDraftEmail.py b/modules/workflows/methods/methodOutlook/actions/sendDraftEmail.py index 1c0c80d4..86749339 100644 --- a/modules/workflows/methods/methodOutlook/actions/sendDraftEmail.py +++ b/modules/workflows/methods/methodOutlook/actions/sendDraftEmail.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging diff --git a/modules/workflows/methods/methodOutlook/helpers/__init__.py b/modules/workflows/methods/methodOutlook/helpers/__init__.py index 45028b5a..42e65b10 100644 --- a/modules/workflows/methods/methodOutlook/helpers/__init__.py +++ b/modules/workflows/methods/methodOutlook/helpers/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Helper modules for Outlook method operations.""" diff --git a/modules/workflows/methods/methodOutlook/helpers/connection.py b/modules/workflows/methods/methodOutlook/helpers/connection.py index cd42b7f5..361ad4d9 100644 --- a/modules/workflows/methods/methodOutlook/helpers/connection.py +++ b/modules/workflows/methods/methodOutlook/helpers/connection.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ diff --git a/modules/workflows/methods/methodOutlook/helpers/emailProcessing.py b/modules/workflows/methods/methodOutlook/helpers/emailProcessing.py index d34bb778..90249ffe 100644 --- a/modules/workflows/methods/methodOutlook/helpers/emailProcessing.py +++ b/modules/workflows/methods/methodOutlook/helpers/emailProcessing.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ diff --git a/modules/workflows/methods/methodOutlook/helpers/folderManagement.py b/modules/workflows/methods/methodOutlook/helpers/folderManagement.py index 2bbb8195..3cc66161 100644 --- a/modules/workflows/methods/methodOutlook/helpers/folderManagement.py +++ b/modules/workflows/methods/methodOutlook/helpers/folderManagement.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ diff --git a/modules/workflows/methods/methodOutlook/methodOutlook.py b/modules/workflows/methods/methodOutlook/methodOutlook.py index 4370b237..a1cd2600 100644 --- a/modules/workflows/methods/methodOutlook/methodOutlook.py +++ b/modules/workflows/methods/methodOutlook/methodOutlook.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging diff --git a/modules/workflows/methods/methodSharepoint/__init__.py b/modules/workflows/methods/methodSharepoint/__init__.py index 40c14cf3..7ff61497 100644 --- a/modules/workflows/methods/methodSharepoint/__init__.py +++ b/modules/workflows/methods/methodSharepoint/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from .methodSharepoint import MethodSharepoint diff --git a/modules/workflows/methods/methodSharepoint/actions/__init__.py b/modules/workflows/methods/methodSharepoint/actions/__init__.py index 6975f8af..85de2950 100644 --- a/modules/workflows/methods/methodSharepoint/actions/__init__.py +++ b/modules/workflows/methods/methodSharepoint/actions/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Action modules for SharePoint operations.""" diff --git a/modules/workflows/methods/methodSharepoint/actions/analyzeFolderUsage.py b/modules/workflows/methods/methodSharepoint/actions/analyzeFolderUsage.py index a4bf18b6..2768ce75 100644 --- a/modules/workflows/methods/methodSharepoint/actions/analyzeFolderUsage.py +++ b/modules/workflows/methods/methodSharepoint/actions/analyzeFolderUsage.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging diff --git a/modules/workflows/methods/methodSharepoint/actions/copyFile.py b/modules/workflows/methods/methodSharepoint/actions/copyFile.py index 92ce88a2..3883a681 100644 --- a/modules/workflows/methods/methodSharepoint/actions/copyFile.py +++ b/modules/workflows/methods/methodSharepoint/actions/copyFile.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging diff --git a/modules/workflows/methods/methodSharepoint/actions/downloadFileByPath.py b/modules/workflows/methods/methodSharepoint/actions/downloadFileByPath.py index 793e07c9..787c55fd 100644 --- a/modules/workflows/methods/methodSharepoint/actions/downloadFileByPath.py +++ b/modules/workflows/methods/methodSharepoint/actions/downloadFileByPath.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging diff --git a/modules/workflows/methods/methodSharepoint/actions/findDocumentPath.py b/modules/workflows/methods/methodSharepoint/actions/findDocumentPath.py index 722dbc99..f04c703b 100644 --- a/modules/workflows/methods/methodSharepoint/actions/findDocumentPath.py +++ b/modules/workflows/methods/methodSharepoint/actions/findDocumentPath.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging diff --git a/modules/workflows/methods/methodSharepoint/actions/findSiteByUrl.py b/modules/workflows/methods/methodSharepoint/actions/findSiteByUrl.py index 62b6dd94..99a9a7d4 100644 --- a/modules/workflows/methods/methodSharepoint/actions/findSiteByUrl.py +++ b/modules/workflows/methods/methodSharepoint/actions/findSiteByUrl.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging diff --git a/modules/workflows/methods/methodSharepoint/actions/listDocuments.py b/modules/workflows/methods/methodSharepoint/actions/listDocuments.py index 318271c3..23313b47 100644 --- a/modules/workflows/methods/methodSharepoint/actions/listDocuments.py +++ b/modules/workflows/methods/methodSharepoint/actions/listDocuments.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging diff --git a/modules/workflows/methods/methodSharepoint/actions/readDocuments.py b/modules/workflows/methods/methodSharepoint/actions/readDocuments.py index 542ab2e8..f483bc3c 100644 --- a/modules/workflows/methods/methodSharepoint/actions/readDocuments.py +++ b/modules/workflows/methods/methodSharepoint/actions/readDocuments.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging diff --git a/modules/workflows/methods/methodSharepoint/actions/uploadDocument.py b/modules/workflows/methods/methodSharepoint/actions/uploadDocument.py index c68133d5..0f56b72f 100644 --- a/modules/workflows/methods/methodSharepoint/actions/uploadDocument.py +++ b/modules/workflows/methods/methodSharepoint/actions/uploadDocument.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging diff --git a/modules/workflows/methods/methodSharepoint/actions/uploadFile.py b/modules/workflows/methods/methodSharepoint/actions/uploadFile.py index 56e9f0b2..b263a34d 100644 --- a/modules/workflows/methods/methodSharepoint/actions/uploadFile.py +++ b/modules/workflows/methods/methodSharepoint/actions/uploadFile.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import logging diff --git a/modules/workflows/methods/methodSharepoint/helpers/__init__.py b/modules/workflows/methods/methodSharepoint/helpers/__init__.py index cc1293b3..111dcd82 100644 --- a/modules/workflows/methods/methodSharepoint/helpers/__init__.py +++ b/modules/workflows/methods/methodSharepoint/helpers/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Helper modules for SharePoint method operations.""" diff --git a/modules/workflows/methods/methodSharepoint/helpers/apiClient.py b/modules/workflows/methods/methodSharepoint/helpers/apiClient.py index 309497a4..5aee3a11 100644 --- a/modules/workflows/methods/methodSharepoint/helpers/apiClient.py +++ b/modules/workflows/methods/methodSharepoint/helpers/apiClient.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ diff --git a/modules/workflows/methods/methodSharepoint/helpers/connection.py b/modules/workflows/methods/methodSharepoint/helpers/connection.py index 3c2ce16d..2694182c 100644 --- a/modules/workflows/methods/methodSharepoint/helpers/connection.py +++ b/modules/workflows/methods/methodSharepoint/helpers/connection.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ diff --git a/modules/workflows/methods/methodSharepoint/helpers/documentParsing.py b/modules/workflows/methods/methodSharepoint/helpers/documentParsing.py index 9903568d..cf1b0755 100644 --- a/modules/workflows/methods/methodSharepoint/helpers/documentParsing.py +++ b/modules/workflows/methods/methodSharepoint/helpers/documentParsing.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ diff --git a/modules/workflows/methods/methodSharepoint/helpers/pathProcessing.py b/modules/workflows/methods/methodSharepoint/helpers/pathProcessing.py index 3e1a94f1..cfa2c073 100644 --- a/modules/workflows/methods/methodSharepoint/helpers/pathProcessing.py +++ b/modules/workflows/methods/methodSharepoint/helpers/pathProcessing.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ diff --git a/modules/workflows/methods/methodSharepoint/helpers/siteDiscovery.py b/modules/workflows/methods/methodSharepoint/helpers/siteDiscovery.py index f59de8f7..495063e7 100644 --- a/modules/workflows/methods/methodSharepoint/helpers/siteDiscovery.py +++ b/modules/workflows/methods/methodSharepoint/helpers/siteDiscovery.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ diff --git a/modules/workflows/methods/methodSharepoint/methodSharepoint.py b/modules/workflows/methods/methodSharepoint/methodSharepoint.py index 78e462d7..dfc45274 100644 --- a/modules/workflows/methods/methodSharepoint/methodSharepoint.py +++ b/modules/workflows/methods/methodSharepoint/methodSharepoint.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ diff --git a/modules/workflows/processing/adaptive/__init__.py b/modules/workflows/processing/adaptive/__init__.py index c397aeb6..39f75a08 100644 --- a/modules/workflows/processing/adaptive/__init__.py +++ b/modules/workflows/processing/adaptive/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. # adaptive module for Dynamic mode # Provides adaptive learning capabilities diff --git a/modules/workflows/processing/adaptive/adaptiveLearningEngine.py b/modules/workflows/processing/adaptive/adaptiveLearningEngine.py index 18588cf2..945acb60 100644 --- a/modules/workflows/processing/adaptive/adaptiveLearningEngine.py +++ b/modules/workflows/processing/adaptive/adaptiveLearningEngine.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. # adaptiveLearningEngine.py # Enhanced learning engine that tracks validation patterns and adapts prompts diff --git a/modules/workflows/processing/adaptive/contentValidator.py b/modules/workflows/processing/adaptive/contentValidator.py index 15e1dc65..d3ae0d7d 100644 --- a/modules/workflows/processing/adaptive/contentValidator.py +++ b/modules/workflows/processing/adaptive/contentValidator.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. # contentValidator.py # Content validation for adaptive Dynamic mode diff --git a/modules/workflows/processing/adaptive/learningEngine.py b/modules/workflows/processing/adaptive/learningEngine.py index 8fb2f958..006c63ad 100644 --- a/modules/workflows/processing/adaptive/learningEngine.py +++ b/modules/workflows/processing/adaptive/learningEngine.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. # learningEngine.py # Learning engine for adaptive Dynamic mode diff --git a/modules/workflows/processing/adaptive/progressTracker.py b/modules/workflows/processing/adaptive/progressTracker.py index 80c570ed..986edaf1 100644 --- a/modules/workflows/processing/adaptive/progressTracker.py +++ b/modules/workflows/processing/adaptive/progressTracker.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. # progressTracker.py # Progress tracking for adaptive Dynamic mode diff --git a/modules/workflows/processing/core/__init__.py b/modules/workflows/processing/core/__init__.py index 784fe27d..fa549c25 100644 --- a/modules/workflows/processing/core/__init__.py +++ b/modules/workflows/processing/core/__init__.py @@ -1,3 +1,3 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. # Core workflow processing modules diff --git a/modules/workflows/processing/core/actionExecutor.py b/modules/workflows/processing/core/actionExecutor.py index 3156aa4b..f0f0f20d 100644 --- a/modules/workflows/processing/core/actionExecutor.py +++ b/modules/workflows/processing/core/actionExecutor.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. # actionExecutor.py # Action execution functionality for workflows diff --git a/modules/workflows/processing/core/messageCreator.py b/modules/workflows/processing/core/messageCreator.py index 00aebc20..ed870784 100644 --- a/modules/workflows/processing/core/messageCreator.py +++ b/modules/workflows/processing/core/messageCreator.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. # messageCreator.py # Generic message creation for all workflow phases diff --git a/modules/workflows/processing/core/taskPlanner.py b/modules/workflows/processing/core/taskPlanner.py index 8401c2a3..ee248cbf 100644 --- a/modules/workflows/processing/core/taskPlanner.py +++ b/modules/workflows/processing/core/taskPlanner.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. # taskPlanner.py # Task planning functionality for workflows @@ -197,4 +197,4 @@ class TaskPlanner: logger.error(f"Error in generateTaskPlan: {str(e)}") raise - \ No newline at end of file + diff --git a/modules/workflows/processing/core/validator.py b/modules/workflows/processing/core/validator.py index 67c685e8..fce1a808 100644 --- a/modules/workflows/processing/core/validator.py +++ b/modules/workflows/processing/core/validator.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. # validator.py # Validation logic for workflows diff --git a/modules/workflows/processing/modes/__init__.py b/modules/workflows/processing/modes/__init__.py index 36d96e63..81bdbca9 100644 --- a/modules/workflows/processing/modes/__init__.py +++ b/modules/workflows/processing/modes/__init__.py @@ -1,3 +1,3 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. # Workflow mode implementations diff --git a/modules/workflows/processing/modes/modeAutomation.py b/modules/workflows/processing/modes/modeAutomation.py index b6beabd3..46f00a79 100644 --- a/modules/workflows/processing/modes/modeAutomation.py +++ b/modules/workflows/processing/modes/modeAutomation.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. # modeAutomation.py # Automation mode implementation for workflows with predefined action plans diff --git a/modules/workflows/processing/modes/modeBase.py b/modules/workflows/processing/modes/modeBase.py index 684f5d52..89e07ecf 100644 --- a/modules/workflows/processing/modes/modeBase.py +++ b/modules/workflows/processing/modes/modeBase.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. # modeBase.py # Abstract base class for workflow modes diff --git a/modules/workflows/processing/modes/modeDynamic.py b/modules/workflows/processing/modes/modeDynamic.py index 045835fa..2d7dd8ac 100644 --- a/modules/workflows/processing/modes/modeDynamic.py +++ b/modules/workflows/processing/modes/modeDynamic.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. # modeDynamic.py # Dynamic mode implementation for workflows diff --git a/modules/workflows/processing/shared/__init__.py b/modules/workflows/processing/shared/__init__.py index bc0c6178..e28f3b80 100644 --- a/modules/workflows/processing/shared/__init__.py +++ b/modules/workflows/processing/shared/__init__.py @@ -1,3 +1,3 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. # Shared workflow utilities diff --git a/modules/workflows/processing/shared/executionState.py b/modules/workflows/processing/shared/executionState.py index e5e48a01..e772a9ae 100644 --- a/modules/workflows/processing/shared/executionState.py +++ b/modules/workflows/processing/shared/executionState.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. # executionState.py # Contains all execution state management logic @@ -79,4 +79,4 @@ def shouldContinue(observation=None, review=None, current_step: int = 0, max_ste return True except Exception as e: logger.warning(f"Error in shouldContinue: {e}") - return False \ No newline at end of file + return False diff --git a/modules/workflows/processing/shared/methodDiscovery.py b/modules/workflows/processing/shared/methodDiscovery.py index 9271585c..2fdc0b85 100644 --- a/modules/workflows/processing/shared/methodDiscovery.py +++ b/modules/workflows/processing/shared/methodDiscovery.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. # methodDiscovery.py # Method discovery and management for workflow execution diff --git a/modules/workflows/processing/shared/parameterValidation.py b/modules/workflows/processing/shared/parameterValidation.py index f8045b28..8e12ca7c 100644 --- a/modules/workflows/processing/shared/parameterValidation.py +++ b/modules/workflows/processing/shared/parameterValidation.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Universal parameter validation + coercion for workflow actions. diff --git a/modules/workflows/processing/shared/placeholderFactory.py b/modules/workflows/processing/shared/placeholderFactory.py index 430204bd..db9e71d9 100644 --- a/modules/workflows/processing/shared/placeholderFactory.py +++ b/modules/workflows/processing/shared/placeholderFactory.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Placeholder Factory diff --git a/modules/workflows/processing/shared/promptGenerationActionsDynamic.py b/modules/workflows/processing/shared/promptGenerationActionsDynamic.py index 7415df93..a902f88f 100644 --- a/modules/workflows/processing/shared/promptGenerationActionsDynamic.py +++ b/modules/workflows/processing/shared/promptGenerationActionsDynamic.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Dynamic Mode Prompt Generation diff --git a/modules/workflows/processing/shared/promptGenerationTaskplan.py b/modules/workflows/processing/shared/promptGenerationTaskplan.py index 11a54ca1..a29fee2b 100644 --- a/modules/workflows/processing/shared/promptGenerationTaskplan.py +++ b/modules/workflows/processing/shared/promptGenerationTaskplan.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Task Planning Prompt Generation diff --git a/modules/workflows/processing/workflowProcessor.py b/modules/workflows/processing/workflowProcessor.py index 5123f934..8a41795c 100644 --- a/modules/workflows/processing/workflowProcessor.py +++ b/modules/workflows/processing/workflowProcessor.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. # workflowProcessor.py # Main workflow processor with delegation pattern diff --git a/modules/workflows/workflowManager.py b/modules/workflows/workflowManager.py index 7f06b325..cb23399b 100644 --- a/modules/workflows/workflowManager.py +++ b/modules/workflows/workflowManager.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from typing import Dict, Any, List, Optional import logging diff --git a/scripts/script_analyze_function_imports.py b/scripts/script_analyze_function_imports.py index 6a3118d2..8c007227 100644 --- a/scripts/script_analyze_function_imports.py +++ b/scripts/script_analyze_function_imports.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Analyze function-level imports to determine which could be moved to header. diff --git a/scripts/script_analyze_imports.py b/scripts/script_analyze_imports.py index c63a556e..7d9a6bb8 100644 --- a/scripts/script_analyze_imports.py +++ b/scripts/script_analyze_imports.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Analyze all imports in the gateway codebase and generate a CSV report. diff --git a/scripts/script_db_export_migration.py b/scripts/script_db_export_migration.py index 5a2f9214..0f72726a 100644 --- a/scripts/script_db_export_migration.py +++ b/scripts/script_db_export_migration.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Datenbank Export-Tool für Migration. diff --git a/scripts/script_generate_container_diagram.py b/scripts/script_generate_container_diagram.py index 7f6243c9..44cfc89a 100644 --- a/scripts/script_generate_container_diagram.py +++ b/scripts/script_generate_container_diagram.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Generate a simplified draw.io diagram showing container-to-container imports. diff --git a/scripts/script_generate_import_diagram.py b/scripts/script_generate_import_diagram.py index 0a7dcdd9..40d5996c 100644 --- a/scripts/script_generate_import_diagram.py +++ b/scripts/script_generate_import_diagram.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Generate a draw.io diagram from import_analysis.csv diff --git a/scripts/script_migrate_feature_instance_refs.py b/scripts/script_migrate_feature_instance_refs.py index 8af55a6c..5a34e12a 100644 --- a/scripts/script_migrate_feature_instance_refs.py +++ b/scripts/script_migrate_feature_instance_refs.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Persistent DB migration: rewrite raw ``featureInstanceId`` UUIDs in stored diff --git a/scripts/script_remove_redundant_imports.py b/scripts/script_remove_redundant_imports.py index 1c83eb9e..b138b4cb 100644 --- a/scripts/script_remove_redundant_imports.py +++ b/scripts/script_remove_redundant_imports.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Remove redundant function-level imports that already exist in the header. diff --git a/scripts/script_security_encrypt_all_env_files.py b/scripts/script_security_encrypt_all_env_files.py index cceae83d..cbee9116 100644 --- a/scripts/script_security_encrypt_all_env_files.py +++ b/scripts/script_security_encrypt_all_env_files.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Tool for encrypting all *_SECRET variables in all environment files. diff --git a/scripts/script_security_encrypt_config_value.py b/scripts/script_security_encrypt_config_value.py index 512d9958..da84cc14 100644 --- a/scripts/script_security_encrypt_config_value.py +++ b/scripts/script_security_encrypt_config_value.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Tool for encrypting configuration values. diff --git a/scripts/script_security_generate_master_keys.py b/scripts/script_security_generate_master_keys.py index 6da55d26..d55285cd 100644 --- a/scripts/script_security_generate_master_keys.py +++ b/scripts/script_security_generate_master_keys.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Generate secure master keys for all environments. diff --git a/scripts/script_stats_durations_from_log.py b/scripts/script_stats_durations_from_log.py index 24176f2c..ef9c3dc6 100644 --- a/scripts/script_stats_durations_from_log.py +++ b/scripts/script_stats_durations_from_log.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import argparse import csv diff --git a/scripts/script_stats_get_codelines.py b/scripts/script_stats_get_codelines.py index 1b4fd61e..37270fad 100644 --- a/scripts/script_stats_get_codelines.py +++ b/scripts/script_stats_get_codelines.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Script to count code lines in a folder and its subfolders. @@ -139,4 +139,4 @@ def main(): print(f"TOTAL LINES OF CODE: {total_lines}") if __name__ == '__main__': - main() \ No newline at end of file + main() diff --git a/scripts/script_stats_showUnusedFunctions.py b/scripts/script_stats_showUnusedFunctions.py index f7f6b2c3..4dc8745b 100644 --- a/scripts/script_stats_showUnusedFunctions.py +++ b/scripts/script_stats_showUnusedFunctions.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Script to find unused functions in Python files. diff --git a/tests/__init__.py b/tests/__init__.py index 77f3aaa9..1240b75c 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Test suite for PowerOn gateway modules diff --git a/tests/conftest.py b/tests/conftest.py index 9a70b5e0..2ac200ce 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Pytest configuration file for test suite. diff --git a/tests/demo/test_pwg_demo_bootstrap.py b/tests/demo/test_pwg_demo_bootstrap.py index 7bc38345..cd256540 100644 --- a/tests/demo/test_pwg_demo_bootstrap.py +++ b/tests/demo/test_pwg_demo_bootstrap.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """T6 — PWG-Pilot demo bootstrap & idempotency tests. diff --git a/tests/eval/__init__.py b/tests/eval/__init__.py index fde23b13..e2dea687 100644 --- a/tests/eval/__init__.py +++ b/tests/eval/__init__.py @@ -1,3 +1,3 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Eval harness for the Feature Data Sub-Agent (Phase 1.5).""" diff --git a/tests/eval/fakeFeatureDataProvider.py b/tests/eval/fakeFeatureDataProvider.py index 55557e7d..7081dfd4 100644 --- a/tests/eval/fakeFeatureDataProvider.py +++ b/tests/eval/fakeFeatureDataProvider.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """In-memory drop-in for FeatureDataProvider used by the eval harness. diff --git a/tests/eval/runTrusteeBenchmark.py b/tests/eval/runTrusteeBenchmark.py index 7622b3d0..69ae3499 100644 --- a/tests/eval/runTrusteeBenchmark.py +++ b/tests/eval/runTrusteeBenchmark.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Trustee Sub-Agent Eval Harness (Phase 1.5). diff --git a/tests/fixtures/loadRedmineSnapshot.py b/tests/fixtures/loadRedmineSnapshot.py index e0a501d7..6a24d35a 100644 --- a/tests/fixtures/loadRedmineSnapshot.py +++ b/tests/fixtures/loadRedmineSnapshot.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Load ``redmineSnapshot.json`` into in-memory ``RedmineTicketDto`` objects. diff --git a/tests/fixtures/trusteeBenchmark/__init__.py b/tests/fixtures/trusteeBenchmark/__init__.py index 52f83ff7..ee9e9c1c 100644 --- a/tests/fixtures/trusteeBenchmark/__init__.py +++ b/tests/fixtures/trusteeBenchmark/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Trustee benchmark fixture: synthetic but realistic Swiss KMU accounting data. diff --git a/tests/fixtures/trusteeBenchmark/loadTrusteeBenchmarkFixture.py b/tests/fixtures/trusteeBenchmark/loadTrusteeBenchmarkFixture.py index 5eb77867..3b6f02ca 100644 --- a/tests/fixtures/trusteeBenchmark/loadTrusteeBenchmarkFixture.py +++ b/tests/fixtures/trusteeBenchmark/loadTrusteeBenchmarkFixture.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Synthetic Trustee benchmark fixture for the Feature Data Sub-Agent eval. diff --git a/tests/functional/__init__.py b/tests/functional/__init__.py index 81e51c0f..6707ed42 100644 --- a/tests/functional/__init__.py +++ b/tests/functional/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Functional tests directory. diff --git a/tests/functional/test01_ai_model_selection.py b/tests/functional/test01_ai_model_selection.py index 4c299a26..370dc471 100644 --- a/tests/functional/test01_ai_model_selection.py +++ b/tests/functional/test01_ai_model_selection.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ AI Model Selection Test - Prints prioritized fallback model lists for all interface calls diff --git a/tests/functional/test02_ai_models.py b/tests/functional/test02_ai_models.py index 4569455e..73fb37df 100644 --- a/tests/functional/test02_ai_models.py +++ b/tests/functional/test02_ai_models.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ AI Models Test - Tests ALL operation types on ALL models that support them diff --git a/tests/functional/test03_ai_operations.py b/tests/functional/test03_ai_operations.py index ee38af8b..65ed2ddc 100644 --- a/tests/functional/test03_ai_operations.py +++ b/tests/functional/test03_ai_operations.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Test script for methodAi operations. diff --git a/tests/functional/test04_ai_behavior.py b/tests/functional/test04_ai_behavior.py index 276b9283..809e345a 100644 --- a/tests/functional/test04_ai_behavior.py +++ b/tests/functional/test04_ai_behavior.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ AI Behavior Test - Tests actual AI responses with different prompt structures diff --git a/tests/functional/test07_json_merge.py b/tests/functional/test07_json_merge.py index 504858f0..c0597e32 100644 --- a/tests/functional/test07_json_merge.py +++ b/tests/functional/test07_json_merge.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Test JSON string accumulation for broken JSON iterations - String accumulation approach""" import json diff --git a/tests/functional/test08_json_finalization.py b/tests/functional/test08_json_finalization.py index 04de9271..5d9e86c0 100644 --- a/tests/functional/test08_json_finalization.py +++ b/tests/functional/test08_json_finalization.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Test JSON finalization process after accumulation is complete. diff --git a/tests/functional/test12_json_split_merge.py b/tests/functional/test12_json_split_merge.py index 6e10c58c..b63b2616 100644 --- a/tests/functional/test12_json_split_merge.py +++ b/tests/functional/test12_json_split_merge.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ JSON Split and Merge Test 12 - Tests JSON splitting and merging using workflow tools diff --git a/tests/functional/test13_json_completion_cuts.py b/tests/functional/test13_json_completion_cuts.py index 494678fc..75c34527 100644 --- a/tests/functional/test13_json_completion_cuts.py +++ b/tests/functional/test13_json_completion_cuts.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ JSON Completion Test 13 - Tests JSON completion at various cut positions diff --git a/tests/functional/test14_json_continuation_context.py b/tests/functional/test14_json_continuation_context.py index ae7ea00e..204a4f49 100644 --- a/tests/functional/test14_json_continuation_context.py +++ b/tests/functional/test14_json_continuation_context.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ JSON Continuation Context Test 14 - Tests getContexts() with a specific cut JSON from debug prompts. diff --git a/tests/functional/test_kpi_full.py b/tests/functional/test_kpi_full.py index b15e0d7e..aa8d8540 100644 --- a/tests/functional/test_kpi_full.py +++ b/tests/functional/test_kpi_full.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Test full KPI extraction and validation flow""" import json diff --git a/tests/functional/test_kpi_incomplete.py b/tests/functional/test_kpi_incomplete.py index e7c728e8..b9125d9f 100644 --- a/tests/functional/test_kpi_incomplete.py +++ b/tests/functional/test_kpi_incomplete.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Test KPI extraction with incomplete JSON""" import json diff --git a/tests/functional/test_kpi_path.py b/tests/functional/test_kpi_path.py index 7814f991..c0a32862 100644 --- a/tests/functional/test_kpi_path.py +++ b/tests/functional/test_kpi_path.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Test KPI path extraction""" import json diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py index 21e803ee..bd6475cb 100644 --- a/tests/integration/__init__.py +++ b/tests/integration/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Integration tests diff --git a/tests/integration/automation2/__init__.py b/tests/integration/automation2/__init__.py index d30846a4..8b7785dc 100644 --- a/tests/integration/automation2/__init__.py +++ b/tests/integration/automation2/__init__.py @@ -1,2 +1,3 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """Integration tests for automation2 typed bindings (Phase-5 Schicht-4).""" diff --git a/tests/integration/automation2/test_pick_not_push_migration_v2.py b/tests/integration/automation2/test_pick_not_push_migration_v2.py index fb109337..34a8b505 100644 --- a/tests/integration/automation2/test_pick_not_push_migration_v2.py +++ b/tests/integration/automation2/test_pick_not_push_migration_v2.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Phase-5 Schicht-4 integration test (T11): the typed-bindings pipeline must produce identical action-call parameters whether a workflow stores diff --git a/tests/integration/extraction/test_extract_udm_pipeline.py b/tests/integration/extraction/test_extract_udm_pipeline.py index 7c9b2bf8..c356b0ee 100644 --- a/tests/integration/extraction/test_extract_udm_pipeline.py +++ b/tests/integration/extraction/test_extract_udm_pipeline.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from modules.datamodels.datamodelExtraction import ExtractionOptions, MergeStrategy from modules.serviceCenter.services.serviceExtraction.subPipeline import runExtraction diff --git a/tests/integration/mandates/test_createMandate.py b/tests/integration/mandates/test_createMandate.py index 1ad24b75..1c66e75a 100644 --- a/tests/integration/mandates/test_createMandate.py +++ b/tests/integration/mandates/test_createMandate.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Integration tests for ``AppObjects.createMandate``. diff --git a/tests/integration/mandates/test_provisionMandate.py b/tests/integration/mandates/test_provisionMandate.py index b88da4ee..d8506cb4 100644 --- a/tests/integration/mandates/test_provisionMandate.py +++ b/tests/integration/mandates/test_provisionMandate.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Integration tests for the slug-derivation contract that diff --git a/tests/integration/mandates/test_updateMandate.py b/tests/integration/mandates/test_updateMandate.py index 385f7fa9..c38875ba 100644 --- a/tests/integration/mandates/test_updateMandate.py +++ b/tests/integration/mandates/test_updateMandate.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Integration tests for ``AppObjects.updateMandate``. diff --git a/tests/integration/rbac/__init__.py b/tests/integration/rbac/__init__.py index 25122eed..86a9d18e 100644 --- a/tests/integration/rbac/__init__.py +++ b/tests/integration/rbac/__init__.py @@ -1,3 +1,3 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Integration tests for RBAC system.""" diff --git a/tests/integration/rbac/test_platform_admin_flag.py b/tests/integration/rbac/test_platform_admin_flag.py index d4cdbe9b..11ad98f8 100644 --- a/tests/integration/rbac/test_platform_admin_flag.py +++ b/tests/integration/rbac/test_platform_admin_flag.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Integration tests for the SysAdmin / PlatformAdmin authority split. diff --git a/tests/integration/rbac/test_rbac_database.py b/tests/integration/rbac/test_rbac_database.py index dbf56dd3..33673cc2 100644 --- a/tests/integration/rbac/test_rbac_database.py +++ b/tests/integration/rbac/test_rbac_database.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Integration tests for RBAC database filtering. diff --git a/tests/integration/trustee/__init__.py b/tests/integration/trustee/__init__.py index d02d6efc..d7c29a91 100644 --- a/tests/integration/trustee/__init__.py +++ b/tests/integration/trustee/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. # # Trustee feature integration tests. diff --git a/tests/integration/trustee/test_spesenbelege_workflow_e2e.py b/tests/integration/trustee/test_spesenbelege_workflow_e2e.py index b7b952b8..606411e9 100644 --- a/tests/integration/trustee/test_spesenbelege_workflow_e2e.py +++ b/tests/integration/trustee/test_spesenbelege_workflow_e2e.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Plan #2 Track A2 (T4): Trustee Spesenbelege Live-E2E Integration-Test. diff --git a/tests/integration/users/test_updateUser.py b/tests/integration/users/test_updateUser.py index 1c7afa29..a557bb3c 100644 --- a/tests/integration/users/test_updateUser.py +++ b/tests/integration/users/test_updateUser.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Integration tests for ``AppObjects.updateUser`` partial-update semantics. diff --git a/tests/integration/workflows/test_execute_graph_loop_aggregate_consolidate.py b/tests/integration/workflows/test_execute_graph_loop_aggregate_consolidate.py index 3fc75f54..f59292b4 100644 --- a/tests/integration/workflows/test_execute_graph_loop_aggregate_consolidate.py +++ b/tests/integration/workflows/test_execute_graph_loop_aggregate_consolidate.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. # Integration: executeGraph with flow.loop + data.aggregate (no AI), then data.consolidate on same outputs. import pytest diff --git a/tests/integration/workflows/test_workflow_execution.py b/tests/integration/workflows/test_workflow_execution.py index a2b69576..ea799406 100644 --- a/tests/integration/workflows/test_workflow_execution.py +++ b/tests/integration/workflows/test_workflow_execution.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Integration tests for workflow execution diff --git a/tests/serviceAi/test_allowed_models_whitelist.py b/tests/serviceAi/test_allowed_models_whitelist.py index 4593afd9..52a1abf7 100644 --- a/tests/serviceAi/test_allowed_models_whitelist.py +++ b/tests/serviceAi/test_allowed_models_whitelist.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import pytest from modules.datamodels.datamodelAi import AiCallOptions diff --git a/tests/serviceGeneration/test_inline_image_paragraph.py b/tests/serviceGeneration/test_inline_image_paragraph.py index be0c5d19..65673138 100644 --- a/tests/serviceGeneration/test_inline_image_paragraph.py +++ b/tests/serviceGeneration/test_inline_image_paragraph.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import pytest from modules.serviceCenter.services.serviceGeneration.subDocumentUtility import markdownToDocumentJson diff --git a/tests/serviceGeneration/test_large_document_render.py b/tests/serviceGeneration/test_large_document_render.py index 8b757e64..245e644c 100644 --- a/tests/serviceGeneration/test_large_document_render.py +++ b/tests/serviceGeneration/test_large_document_render.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """A3 / AC15: lazy file-reference image resolution for large documents. diff --git a/tests/serviceGeneration/test_layout_primitives.py b/tests/serviceGeneration/test_layout_primitives.py index 1c9e6c5e..7fd4fa88 100644 --- a/tests/serviceGeneration/test_layout_primitives.py +++ b/tests/serviceGeneration/test_layout_primitives.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """A3: layout primitives (cover_page, image_grid). diff --git a/tests/serviceGeneration/test_md_to_json_consolidation.py b/tests/serviceGeneration/test_md_to_json_consolidation.py index 83118374..17184f51 100644 --- a/tests/serviceGeneration/test_md_to_json_consolidation.py +++ b/tests/serviceGeneration/test_md_to_json_consolidation.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import pytest from modules.serviceCenter.services.serviceGeneration.subDocumentUtility import markdownToDocumentJson diff --git a/tests/serviceGeneration/test_style_resolver.py b/tests/serviceGeneration/test_style_resolver.py index e7d629cd..a08314b8 100644 --- a/tests/serviceGeneration/test_style_resolver.py +++ b/tests/serviceGeneration/test_style_resolver.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. import pytest from modules.serviceCenter.services.serviceGeneration.styleDefaults import ( diff --git a/tests/test_dateRange.py b/tests/test_dateRange.py index dc8c2619..e68f4a08 100644 --- a/tests/test_dateRange.py +++ b/tests/test_dateRange.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Unit tests for `modules.shared.dateRange`. diff --git a/tests/test_service_redmine_orphans.py b/tests/test_service_redmine_orphans.py index f5a22c7a..829bbd18 100644 --- a/tests/test_service_redmine_orphans.py +++ b/tests/test_service_redmine_orphans.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Pure-Python unit tests for the orphan detection in ``serviceRedmineStats._countOrphans``. diff --git a/tests/test_service_redmine_stats.py b/tests/test_service_redmine_stats.py index aecd2caf..9e9d86ab 100644 --- a/tests/test_service_redmine_stats.py +++ b/tests/test_service_redmine_stats.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Unit tests for the pure aggregation in ``serviceRedmineStats._aggregate``. diff --git a/tests/test_service_redmine_stats_cache.py b/tests/test_service_redmine_stats_cache.py index 47d98a9d..11dd823b 100644 --- a/tests/test_service_redmine_stats_cache.py +++ b/tests/test_service_redmine_stats_cache.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Unit tests for ``RedmineStatsCache``. diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py index e2e77ecd..2ce5982f 100644 --- a/tests/unit/__init__.py +++ b/tests/unit/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Unit tests diff --git a/tests/unit/aicore/test_aicorePluginOpenai_temperature.py b/tests/unit/aicore/test_aicorePluginOpenai_temperature.py index eb2d7cec..1cccc206 100644 --- a/tests/unit/aicore/test_aicorePluginOpenai_temperature.py +++ b/tests/unit/aicore/test_aicorePluginOpenai_temperature.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Unit tests: temperature handling for OpenAI chat-completions models. diff --git a/tests/unit/auth/test_mfaService.py b/tests/unit/auth/test_mfaService.py index 5010ceef..ed42f34d 100644 --- a/tests/unit/auth/test_mfaService.py +++ b/tests/unit/auth/test_mfaService.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Unit tests for modules.auth.mfaService. diff --git a/tests/unit/connectors/test_connectorDbPostgre_failLoud.py b/tests/unit/connectors/test_connectorDbPostgre_failLoud.py index 5fb505d7..df35a902 100644 --- a/tests/unit/connectors/test_connectorDbPostgre_failLoud.py +++ b/tests/unit/connectors/test_connectorDbPostgre_failLoud.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Unit tests: PostgreSQL connector raises DatabaseQueryError on real failures. diff --git a/tests/unit/connectors/test_connectorDbPostgre_pool.py b/tests/unit/connectors/test_connectorDbPostgre_pool.py index 99dbab43..89e8b6ac 100644 --- a/tests/unit/connectors/test_connectorDbPostgre_pool.py +++ b/tests/unit/connectors/test_connectorDbPostgre_pool.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Concurrency tests for the PostgreSQL connection pool. diff --git a/tests/unit/connectors/test_connectorResolver.py b/tests/unit/connectors/test_connectorResolver.py index 0ef82e81..2ae8566f 100644 --- a/tests/unit/connectors/test_connectorResolver.py +++ b/tests/unit/connectors/test_connectorResolver.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. from types import SimpleNamespace from modules.connectors.connectorResolver import _connection_uuid diff --git a/tests/unit/connectors/test_connectorVoiceGoogle_sttHelpers.py b/tests/unit/connectors/test_connectorVoiceGoogle_sttHelpers.py index 258dc0db..22871203 100644 --- a/tests/unit/connectors/test_connectorVoiceGoogle_sttHelpers.py +++ b/tests/unit/connectors/test_connectorVoiceGoogle_sttHelpers.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """Unit tests for Google STT helper config (no API calls).""" from modules.connectors.connectorVoiceGoogle import _buildPrimarySttRecognitionFields diff --git a/tests/unit/datamodels/test_docref.py b/tests/unit/datamodels/test_docref.py index 2f2ee03d..9f66d121 100644 --- a/tests/unit/datamodels/test_docref.py +++ b/tests/unit/datamodels/test_docref.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Unit tests for document reference models in datamodelDocref.py diff --git a/tests/unit/datamodels/test_udm_bridge.py b/tests/unit/datamodels/test_udm_bridge.py index db52ffe6..d189b887 100644 --- a/tests/unit/datamodels/test_udm_bridge.py +++ b/tests/unit/datamodels/test_udm_bridge.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from modules.datamodels.datamodelExtraction import ContentExtracted, ContentPart from modules.datamodels.datamodelUdm import contentPartsToUdm, _udmToContentParts diff --git a/tests/unit/datamodels/test_udm_models.py b/tests/unit/datamodels/test_udm_models.py index 92d86a85..dbda8c92 100644 --- a/tests/unit/datamodels/test_udm_models.py +++ b/tests/unit/datamodels/test_udm_models.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. from modules.datamodels.datamodelUdm import UdmContentBlock, UdmDocument, UdmStructuralNode diff --git a/tests/unit/datamodels/test_workflow_models.py b/tests/unit/datamodels/test_workflow_models.py index 59e3736d..1563e524 100644 --- a/tests/unit/datamodels/test_workflow_models.py +++ b/tests/unit/datamodels/test_workflow_models.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Unit tests for workflow models in datamodelWorkflow.py diff --git a/tests/unit/features/test_trustee_template_workflows.py b/tests/unit/features/test_trustee_template_workflows.py index 388f2d29..2e6023dc 100644 --- a/tests/unit/features/test_trustee_template_workflows.py +++ b/tests/unit/features/test_trustee_template_workflows.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """Guardrails for Trustee ``getTemplateWorkflows`` graphs (new instance bootstrap).""" from __future__ import annotations diff --git a/tests/unit/features/trustee/test_accountingConnectorAbacus_balances.py b/tests/unit/features/trustee/test_accountingConnectorAbacus_balances.py index ae1a39ad..7d4f3884 100644 --- a/tests/unit/features/trustee/test_accountingConnectorAbacus_balances.py +++ b/tests/unit/features/trustee/test_accountingConnectorAbacus_balances.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Unit tests for the Abacus connector's getAccountBalances aggregation logic.""" diff --git a/tests/unit/features/trustee/test_accountingConnectorBexio_balances.py b/tests/unit/features/trustee/test_accountingConnectorBexio_balances.py index 945c7c95..ca9ae84b 100644 --- a/tests/unit/features/trustee/test_accountingConnectorBexio_balances.py +++ b/tests/unit/features/trustee/test_accountingConnectorBexio_balances.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Unit tests for the Bexio connector's getAccountBalances aggregation logic.""" diff --git a/tests/unit/features/trustee/test_accountingConnectorRma_balances.py b/tests/unit/features/trustee/test_accountingConnectorRma_balances.py index b6e43717..6ea3f9d8 100644 --- a/tests/unit/features/trustee/test_accountingConnectorRma_balances.py +++ b/tests/unit/features/trustee/test_accountingConnectorRma_balances.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Unit tests for the RMA connector's getAccountBalances implementation. diff --git a/tests/unit/features/trustee/test_accountingDataSync_balances.py b/tests/unit/features/trustee/test_accountingDataSync_balances.py index 711c9808..d9791c78 100644 --- a/tests/unit/features/trustee/test_accountingDataSync_balances.py +++ b/tests/unit/features/trustee/test_accountingDataSync_balances.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Unit tests for the local-fallback cumulative balance computation in ``AccountingDataSync._buildLocalBalanceFallback`` and the connector handoff diff --git a/tests/unit/graphicalEditor/test_action_node_connection_provenance.py b/tests/unit/graphicalEditor/test_action_node_connection_provenance.py index 610d35c9..8e6d1159 100644 --- a/tests/unit/graphicalEditor/test_action_node_connection_provenance.py +++ b/tests/unit/graphicalEditor/test_action_node_connection_provenance.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. from modules.workflowAutomation.engine.executors.actionNodeExecutor import _buildConnectionRefDict diff --git a/tests/unit/graphicalEditor/test_adapter_validator.py b/tests/unit/graphicalEditor/test_adapter_validator.py index ad507daa..5ac3eb8f 100644 --- a/tests/unit/graphicalEditor/test_adapter_validator.py +++ b/tests/unit/graphicalEditor/test_adapter_validator.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Tests for the Schicht-3 Adapter Validator (Phase 3). diff --git a/tests/unit/graphicalEditor/test_condition_operator_catalog.py b/tests/unit/graphicalEditor/test_condition_operator_catalog.py index ce02c083..05f6cb7b 100644 --- a/tests/unit/graphicalEditor/test_condition_operator_catalog.py +++ b/tests/unit/graphicalEditor/test_condition_operator_catalog.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """Tests for backend-driven condition operator catalog.""" from modules.workflowAutomation.editor.conditionOperators import ( diff --git a/tests/unit/graphicalEditor/test_featureInstanceRef_node_definitions.py b/tests/unit/graphicalEditor/test_featureInstanceRef_node_definitions.py index e81a0a4f..671a6e62 100644 --- a/tests/unit/graphicalEditor/test_featureInstanceRef_node_definitions.py +++ b/tests/unit/graphicalEditor/test_featureInstanceRef_node_definitions.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Schicht-4 / Phase-5 follow-up: assert that all Trustee + Redmine node diff --git a/tests/unit/graphicalEditor/test_node_adapter.py b/tests/unit/graphicalEditor/test_node_adapter.py index 634f76d2..2fc39dde 100644 --- a/tests/unit/graphicalEditor/test_node_adapter.py +++ b/tests/unit/graphicalEditor/test_node_adapter.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Tests for the Schicht-3 NodeAdapter projection (Phase 3). diff --git a/tests/unit/graphicalEditor/test_portTypes_catalog.py b/tests/unit/graphicalEditor/test_portTypes_catalog.py index 9e97d475..4d2a0a38 100644 --- a/tests/unit/graphicalEditor/test_portTypes_catalog.py +++ b/tests/unit/graphicalEditor/test_portTypes_catalog.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Catalog integrity + new Phase-1 schemas (see wiki/c-work/1-plan/2026-04-typed-action-architecture.md). diff --git a/tests/unit/graphicalEditor/test_port_schema_recursive.py b/tests/unit/graphicalEditor/test_port_schema_recursive.py index cd32e461..00f3899e 100644 --- a/tests/unit/graphicalEditor/test_port_schema_recursive.py +++ b/tests/unit/graphicalEditor/test_port_schema_recursive.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """Port type catalog: nested provenance schemas (Typed Generic Handover).""" from modules.nodeCatalog.portTypes import PORT_TYPE_CATALOG, _defaultForType diff --git a/tests/unit/graphicalEditor/test_resolve_value_kind.py b/tests/unit/graphicalEditor/test_resolve_value_kind.py index 497619e2..0e37ff92 100644 --- a/tests/unit/graphicalEditor/test_resolve_value_kind.py +++ b/tests/unit/graphicalEditor/test_resolve_value_kind.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """Tests for condition valueKind resolution.""" from modules.workflowAutomation.editor.conditionOperators import resolve_value_kind diff --git a/tests/unit/graphicalEditor/test_upstream_paths_and_graph_schema.py b/tests/unit/graphicalEditor/test_upstream_paths_and_graph_schema.py index 6c6ff2cc..7663364d 100644 --- a/tests/unit/graphicalEditor/test_upstream_paths_and_graph_schema.py +++ b/tests/unit/graphicalEditor/test_upstream_paths_and_graph_schema.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. from modules.workflowAutomation.editor.upstreamPathsService import compute_upstream_paths from modules.workflowAutomation.engine.graphUtils import parse_graph_defined_schema, validateGraph from modules.nodeCatalog.nodeDefinitions import STATIC_NODE_TYPES diff --git a/tests/unit/interfaces/test_folderRbac.py b/tests/unit/interfaces/test_folderRbac.py index f4b984aa..ea7ab1fb 100644 --- a/tests/unit/interfaces/test_folderRbac.py +++ b/tests/unit/interfaces/test_folderRbac.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Unit tests for folder RBAC two-user matrix (ownership & scope visibility).""" diff --git a/tests/unit/methods/test_action_signature_validator.py b/tests/unit/methods/test_action_signature_validator.py index a959989e..62883b44 100644 --- a/tests/unit/methods/test_action_signature_validator.py +++ b/tests/unit/methods/test_action_signature_validator.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Tests for the action-signature validator (Phase 2 of the Typed Action diff --git a/tests/unit/nodeDefinitions/test_trustee_schema_compliance.py b/tests/unit/nodeDefinitions/test_trustee_schema_compliance.py index 060d04a6..fd75170a 100644 --- a/tests/unit/nodeDefinitions/test_trustee_schema_compliance.py +++ b/tests/unit/nodeDefinitions/test_trustee_schema_compliance.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """Trustee node schema-compliance under the Pick-not-Push typed port system. Verifies that: diff --git a/tests/unit/rbac/__init__.py b/tests/unit/rbac/__init__.py index 76c6c7d0..0afbbeb1 100644 --- a/tests/unit/rbac/__init__.py +++ b/tests/unit/rbac/__init__.py @@ -1,3 +1,3 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Unit tests for RBAC system.""" diff --git a/tests/unit/rbac/test_rbac_bootstrap.py b/tests/unit/rbac/test_rbac_bootstrap.py index 0e69b802..d444a543 100644 --- a/tests/unit/rbac/test_rbac_bootstrap.py +++ b/tests/unit/rbac/test_rbac_bootstrap.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Unit tests for RBAC bootstrap initialization. diff --git a/tests/unit/rbac/test_rbac_permissions.py b/tests/unit/rbac/test_rbac_permissions.py index 49458367..d82af432 100644 --- a/tests/unit/rbac/test_rbac_permissions.py +++ b/tests/unit/rbac/test_rbac_permissions.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Unit tests for RBAC permission resolution. diff --git a/tests/unit/routes/test_folder_crud.py b/tests/unit/routes/test_folder_crud.py index 66bad903..1af53e4d 100644 --- a/tests/unit/routes/test_folder_crud.py +++ b/tests/unit/routes/test_folder_crud.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Unit tests for folder CRUD operations in ComponentObjects.""" diff --git a/tests/unit/scripts/__init__.py b/tests/unit/scripts/__init__.py index fdcc4f0e..06003961 100644 --- a/tests/unit/scripts/__init__.py +++ b/tests/unit/scripts/__init__.py @@ -1,2 +1,2 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. diff --git a/tests/unit/scripts/test_migrate_feature_instance_refs.py b/tests/unit/scripts/test_migrate_feature_instance_refs.py index 80367b4e..c0ff9499 100644 --- a/tests/unit/scripts/test_migrate_feature_instance_refs.py +++ b/tests/unit/scripts/test_migrate_feature_instance_refs.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Tests for ``scripts/script_migrate_feature_instance_refs.py``. diff --git a/tests/unit/serviceAgent/test_action_tool_adapter_typed.py b/tests/unit/serviceAgent/test_action_tool_adapter_typed.py index 44439957..2934eeb3 100644 --- a/tests/unit/serviceAgent/test_action_tool_adapter_typed.py +++ b/tests/unit/serviceAgent/test_action_tool_adapter_typed.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Tests for the catalog-driven JSON-Schema generator in actionToolAdapter diff --git a/tests/unit/serviceAgent/test_agentTrace_repairCounters.py b/tests/unit/serviceAgent/test_agentTrace_repairCounters.py index 4a0909d1..befecea3 100644 --- a/tests/unit/serviceAgent/test_agentTrace_repairCounters.py +++ b/tests/unit/serviceAgent/test_agentTrace_repairCounters.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Unit tests for the repair-loop telemetry aggregation in agentLoop. diff --git a/tests/unit/serviceAgent/test_field_neutralization.py b/tests/unit/serviceAgent/test_field_neutralization.py index 6cd52974..005f34f0 100644 --- a/tests/unit/serviceAgent/test_field_neutralization.py +++ b/tests/unit/serviceAgent/test_field_neutralization.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """A2: type/inheritance-aware field neutralization for feature source data. diff --git a/tests/unit/serviceAgent/test_workflow_tools_crud.py b/tests/unit/serviceAgent/test_workflow_tools_crud.py index 41e56ab6..7e818b31 100644 --- a/tests/unit/serviceAgent/test_workflow_tools_crud.py +++ b/tests/unit/serviceAgent/test_workflow_tools_crud.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """T3 — Unit tests for the workflow-CRUD agent tools. diff --git a/tests/unit/services/test_bootstrap_clickup.py b/tests/unit/services/test_bootstrap_clickup.py index 4ed0c4f1..f8205297 100644 --- a/tests/unit/services/test_bootstrap_clickup.py +++ b/tests/unit/services/test_bootstrap_clickup.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Bootstrap ClickUp tests with a fake service + knowledge service. diff --git a/tests/unit/services/test_bootstrap_gdrive.py b/tests/unit/services/test_bootstrap_gdrive.py index 2741332f..58970df9 100644 --- a/tests/unit/services/test_bootstrap_gdrive.py +++ b/tests/unit/services/test_bootstrap_gdrive.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Bootstrap Google Drive tests with a fake adapter + knowledge service. diff --git a/tests/unit/services/test_bootstrap_gmail.py b/tests/unit/services/test_bootstrap_gmail.py index 86508adb..d2cf2734 100644 --- a/tests/unit/services/test_bootstrap_gmail.py +++ b/tests/unit/services/test_bootstrap_gmail.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Bootstrap Gmail tests with a fake googleGet + knowledge service. diff --git a/tests/unit/services/test_bootstrap_outlook.py b/tests/unit/services/test_bootstrap_outlook.py index c5fea524..5dabc4e7 100644 --- a/tests/unit/services/test_bootstrap_outlook.py +++ b/tests/unit/services/test_bootstrap_outlook.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Bootstrap Outlook tests with a fake adapter + knowledge service. diff --git a/tests/unit/services/test_bootstrap_sharepoint.py b/tests/unit/services/test_bootstrap_sharepoint.py index 91020765..cee22bc6 100644 --- a/tests/unit/services/test_bootstrap_sharepoint.py +++ b/tests/unit/services/test_bootstrap_sharepoint.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Bootstrap SharePoint tests with a fake adapter + knowledge service. diff --git a/tests/unit/services/test_clean_email_body.py b/tests/unit/services/test_clean_email_body.py index a3ee01df..6827f2de 100644 --- a/tests/unit/services/test_clean_email_body.py +++ b/tests/unit/services/test_clean_email_body.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Unit tests for cleanEmailBody. diff --git a/tests/unit/services/test_connection_purge.py b/tests/unit/services/test_connection_purge.py index c32cb5b3..5acbcce2 100644 --- a/tests/unit/services/test_connection_purge.py +++ b/tests/unit/services/test_connection_purge.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Purge tests for KnowledgeObjects.deleteFileContentIndexByConnectionId. diff --git a/tests/unit/services/test_extraction_merge_strategy.py b/tests/unit/services/test_extraction_merge_strategy.py index 784bb783..28a41629 100644 --- a/tests/unit/services/test_extraction_merge_strategy.py +++ b/tests/unit/services/test_extraction_merge_strategy.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Test that runExtraction preserves per-part granularity when mergeStrategy=None. diff --git a/tests/unit/services/test_featureDataAgent_schema.py b/tests/unit/services/test_featureDataAgent_schema.py index 2b70532d..15b51b1c 100644 --- a/tests/unit/services/test_featureDataAgent_schema.py +++ b/tests/unit/services/test_featureDataAgent_schema.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Unit test: feature data sub-agent schema context is rich enough. diff --git a/tests/unit/services/test_ingestion_hash_stability.py b/tests/unit/services/test_ingestion_hash_stability.py index df25a4f0..eb19736a 100644 --- a/tests/unit/services/test_ingestion_hash_stability.py +++ b/tests/unit/services/test_ingestion_hash_stability.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Test that _computeIngestionHash is stable across re-extractions of the same source. diff --git a/tests/unit/services/test_json_extraction_merging.py b/tests/unit/services/test_json_extraction_merging.py index 49f430a8..138e5b62 100644 --- a/tests/unit/services/test_json_extraction_merging.py +++ b/tests/unit/services/test_json_extraction_merging.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Test script for JSON extraction response detection and merging. diff --git a/tests/unit/services/test_knowledge_ingest_consumer.py b/tests/unit/services/test_knowledge_ingest_consumer.py index 9884079e..b523e918 100644 --- a/tests/unit/services/test_knowledge_ingest_consumer.py +++ b/tests/unit/services/test_knowledge_ingest_consumer.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Unit tests for KnowledgeIngestionConsumer event dispatch. diff --git a/tests/unit/services/test_queryValidator.py b/tests/unit/services/test_queryValidator.py index 0fb0b4a4..7aa6c01e 100644 --- a/tests/unit/services/test_queryValidator.py +++ b/tests/unit/services/test_queryValidator.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Unit tests for the Feature Data Sub-Agent QueryValidator. diff --git a/tests/unit/services/test_renderer_pdf_smoke.py b/tests/unit/services/test_renderer_pdf_smoke.py index 60c1a2ef..9e984003 100644 --- a/tests/unit/services/test_renderer_pdf_smoke.py +++ b/tests/unit/services/test_renderer_pdf_smoke.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Smoke test: RendererPdf with every JSON section/element shape the pipeline supports. diff --git a/tests/unit/services/test_trusteeOntology.py b/tests/unit/services/test_trusteeOntology.py index 89d714c6..0699910a 100644 --- a/tests/unit/services/test_trusteeOntology.py +++ b/tests/unit/services/test_trusteeOntology.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Unit tests for the trustee ontology and the ontology-to-prompt compiler. diff --git a/tests/unit/shared/test_mandateNameUtils.py b/tests/unit/shared/test_mandateNameUtils.py index 6ef4bec1..11f7912d 100644 --- a/tests/unit/shared/test_mandateNameUtils.py +++ b/tests/unit/shared/test_mandateNameUtils.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Unit tests for mandateNameUtils (slug, validation, unique allocation).""" diff --git a/tests/unit/teamsbot/test_directorPrompts.py b/tests/unit/teamsbot/test_directorPrompts.py index b8bdaafc..9b23fe17 100644 --- a/tests/unit/teamsbot/test_directorPrompts.py +++ b/tests/unit/teamsbot/test_directorPrompts.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Unit tests for Teamsbot Director Prompts (Plan #5). diff --git a/tests/unit/utils/test_json_utils.py b/tests/unit/utils/test_json_utils.py index 6c0e4357..3ae21ad4 100644 --- a/tests/unit/utils/test_json_utils.py +++ b/tests/unit/utils/test_json_utils.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Unit tests for JSON utilities in jsonUtils.py diff --git a/tests/unit/workflow/test_flow_executor_conditions.py b/tests/unit/workflow/test_flow_executor_conditions.py index b16e8e5c..49af1a7a 100644 --- a/tests/unit/workflow/test_flow_executor_conditions.py +++ b/tests/unit/workflow/test_flow_executor_conditions.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """FlowExecutor structured condition evaluation with Item dataRef.""" import pytest diff --git a/tests/unit/workflow/test_switch_filtered_output.py b/tests/unit/workflow/test_switch_filtered_output.py index ee9271d9..5346a3d6 100644 --- a/tests/unit/workflow/test_switch_filtered_output.py +++ b/tests/unit/workflow/test_switch_filtered_output.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """flow.switch ContextBranch: filtered presentation + loop-ready items.""" import pytest diff --git a/tests/unit/workflow/test_trusteeQueryData.py b/tests/unit/workflow/test_trusteeQueryData.py index 93e0f4c5..8111e9c2 100644 --- a/tests/unit/workflow/test_trusteeQueryData.py +++ b/tests/unit/workflow/test_trusteeQueryData.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Unit tests for trustee.queryData helpers (pure-logic, no DB required).""" diff --git a/tests/unit/workflow/test_workflowFileSchema.py b/tests/unit/workflow/test_workflowFileSchema.py index 3eb0fb2c..3cea8989 100644 --- a/tests/unit/workflow/test_workflowFileSchema.py +++ b/tests/unit/workflow/test_workflowFileSchema.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Unit tests for the workflow-file (versioned envelope) schema.""" diff --git a/tests/unit/workflows/test_featureInstanceRefMigration.py b/tests/unit/workflows/test_featureInstanceRefMigration.py index 2ffb6682..dd363c5c 100644 --- a/tests/unit/workflows/test_featureInstanceRefMigration.py +++ b/tests/unit/workflows/test_featureInstanceRefMigration.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Phase-5 Schicht-4 — unit tests for ``materializeFeatureInstanceRefs`` and the runtime envelope unwrap in ``graphUtils.resolveParameterReferences``. diff --git a/tests/unit/workflows/test_parameterValidation.py b/tests/unit/workflows/test_parameterValidation.py index 62799cd1..149e5c72 100644 --- a/tests/unit/workflows/test_parameterValidation.py +++ b/tests/unit/workflows/test_parameterValidation.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Unit tests: universal action parameter validation + coercion. diff --git a/tests/unit/workflows/test_state_management.py b/tests/unit/workflows/test_state_management.py index ae502397..2162e297 100644 --- a/tests/unit/workflows/test_state_management.py +++ b/tests/unit/workflows/test_state_management.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ Unit tests for workflow state management in ChatWorkflow and TaskContext diff --git a/tests/unit/workflows/test_trigger_executor.py b/tests/unit/workflows/test_trigger_executor.py index 96a0bf68..9d1ec1e0 100644 --- a/tests/unit/workflows/test_trigger_executor.py +++ b/tests/unit/workflows/test_trigger_executor.py @@ -1,4 +1,5 @@ -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """TriggerExecutor: form start output must match FormPayload (payload.* refs).""" import pytest diff --git a/tests/validation/test_architecture_validation.py b/tests/validation/test_architecture_validation.py index 09f6e92c..89f2855e 100644 --- a/tests/validation/test_architecture_validation.py +++ b/tests/validation/test_architecture_validation.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Copyright (c) 2025 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """ End-to-End Validation Tests for New Architecture diff --git a/tests/validation/test_featureCatalogLabels_i18n.py b/tests/validation/test_featureCatalogLabels_i18n.py index d6787c43..ffdf1c2b 100644 --- a/tests/validation/test_featureCatalogLabels_i18n.py +++ b/tests/validation/test_featureCatalogLabels_i18n.py @@ -1,4 +1,4 @@ -# Copyright (c) 2026 Patrick Motsch +# Copyright (c) 2026 PowerOn AG # All rights reserved. """Validation: every label in feature ``main*.py`` catalog lists must be wrapped in ``t(...)``. From ebc4b2a08093eefc043baa8adcb7f621f67ac59b Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Tue, 9 Jun 2026 09:58:05 +0200 Subject: [PATCH 13/16] cp adapted to 2026 poweron 2 --- demoData/neutralizer/_generateTenantDossierPdf.py | 2 ++ demoData/pwg/_generateScans.py | 2 ++ modules/connectors/connectorOerebWfs.py | 2 ++ modules/connectors/connectorSwissTopoMapServer.py | 2 ++ modules/connectors/connectorZhWfsParcels.py | 2 ++ modules/datamodels/jsonContinuation.py | 2 ++ modules/dbHelpers/__init__.py | 2 ++ modules/demoConfigs/__init__.py | 2 ++ modules/demoConfigs/baseDemoConfig.py | 2 ++ modules/demoConfigs/investorDemo2026.py | 2 ++ modules/demoConfigs/pwgDemo2026.py | 2 ++ modules/features/commcoach/__init__.py | 2 ++ modules/features/commcoach/tests/__init__.py | 2 ++ modules/features/realEstate/__init__.py | 2 ++ modules/features/realEstate/bzoDocumentRetriever.py | 2 ++ modules/features/realEstate/bzoExtraction.py | 2 ++ modules/features/realEstate/bzoPdfExtractor.py | 2 ++ modules/features/realEstate/bzoRuleTaxonomy.py | 2 ++ modules/features/realEstate/datamodelFeatureRealEstate.py | 2 ++ modules/features/realEstate/handlerRealEstate.py | 2 ++ modules/features/realEstate/interfaceFeatureRealEstate.py | 2 ++ modules/features/realEstate/mainRealEstate.py | 2 ++ modules/features/realEstate/parcelSelectionService.py | 2 ++ modules/features/realEstate/realEstateGemeindeService.py | 2 ++ modules/features/realEstate/routeFeatureRealEstate.py | 2 ++ modules/features/realEstate/scrapeSwissTopo.py | 2 ++ modules/features/realEstate/serviceAiIntent.py | 2 ++ modules/features/realEstate/serviceBzo.py | 2 ++ modules/features/realEstate/serviceGeometry.py | 2 ++ modules/routes/routeAdminDemoConfig.py | 2 ++ .../services/serviceKnowledge/_progressMessages.py | 2 ++ .../services/serviceKnowledge/subConnectorPrefs.py | 2 ++ modules/serviceCenter/services/serviceSubscription/__init__.py | 2 ++ modules/workflowAutomation/__init__.py | 2 ++ modules/workflowAutomation/editor/__init__.py | 2 ++ scripts/_archive/check_orphan_featureinstance.py | 2 ++ scripts/_archive/i18n_rekey_plaintext_keys.py | 2 ++ scripts/_archive/migrate_async_to_sync.py | 2 ++ scripts/_archive/script_db_cleanup_duplicate_roles.py | 2 ++ scripts/_archive/script_db_migrate_accessrules_objectkeys.py | 2 ++ scripts/check_db_no_sysadmin_role.py | 2 ++ scripts/check_no_sysadmin_role.py | 2 ++ scripts/debug_rag_job_result.py | 2 ++ scripts/exportDbSchemaFromModels.py | 2 ++ scripts/script_db_adapt_to_models.py | 2 ++ scripts/script_db_audit_legacy_state.py | 2 ++ scripts/script_db_init_automation2.py | 2 ++ scripts/script_db_migrate_backgroundjob_progress_data.py | 2 ++ scripts/script_db_migrate_datasource_inherit.py | 2 ++ scripts/script_db_migrate_datasource_rag.py | 2 ++ scripts/script_db_migrate_datasource_settings.py | 2 ++ scripts/script_export_accessrules.py | 2 ++ scripts/script_migrate_user_uid.py | 2 ++ scripts/stage0_filefolder_schema_check.py | 2 ++ tests/demo/__init__.py | 2 ++ tests/demo/conftest.py | 2 ++ tests/demo/test_demo_api.py | 2 ++ tests/demo/test_demo_bootstrap.py | 2 ++ tests/demo/test_demo_data_files.py | 2 ++ tests/demo/test_demo_neutralization.py | 2 ++ tests/demo/test_demo_uc1_trustee.py | 2 ++ tests/demo/test_demo_uc2_realestate.py | 2 ++ tests/demo/test_demo_uc4_i18n.py | 2 ++ tests/fixtures/__init__.py | 2 ++ tests/integration/mandates/__init__.py | 2 ++ tests/integration/users/__init__.py | 2 ++ tests/serviceAi/__init__.py | 2 ++ tests/serviceGeneration/__init__.py | 2 ++ tests/unit/aicore/__init__.py | 2 ++ tests/unit/bootstrap/__init__.py | 2 ++ tests/unit/connectors/__init__.py | 2 ++ tests/unit/methods/__init__.py | 2 ++ tests/unit/nodeDefinitions/test_usesai_flag.py | 2 ++ tests/unit/serviceAgent/test_udm_agent_tools.py | 2 ++ tests/unit/services/test_buildTree.py | 2 ++ tests/unit/services/test_costEstimate.py | 2 ++ tests/unit/services/test_inheritFlags.py | 2 ++ tests/unit/services/test_p1d_consent_prefs.py | 2 ++ tests/unit/services/test_ragLimits.py | 2 ++ tests/unit/services/test_udbNodes.py | 2 ++ tests/unit/teamsbot/__init__.py | 2 ++ tests/unit/workflow/test_extract_content_handover.py | 2 ++ tests/unit/workflow/test_merge_context_handover.py | 2 ++ tests/unit/workflow/test_node_combinations.py | 2 ++ tests/unit/workflow/test_phase3_context_node.py | 2 ++ tests/unit/workflow/test_phase4_workflow_nodes.py | 2 ++ tests/unit/workflow/test_phase5_highvol.py | 2 ++ tests/unit/workflows/test_automation2_graphUtils.py | 2 ++ 88 files changed, 176 insertions(+) diff --git a/demoData/neutralizer/_generateTenantDossierPdf.py b/demoData/neutralizer/_generateTenantDossierPdf.py index 2d4f5a02..f559451b 100644 --- a/demoData/neutralizer/_generateTenantDossierPdf.py +++ b/demoData/neutralizer/_generateTenantDossierPdf.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """Generate tenant-dossier.pdf for neutralization demo. Run: python _generateTenantDossierPdf.py Uses ReportLab so the PDF opens reliably in all viewers (stdlib-only PDFs are fragile). diff --git a/demoData/pwg/_generateScans.py b/demoData/pwg/_generateScans.py index c93eda55..64125068 100644 --- a/demoData/pwg/_generateScans.py +++ b/demoData/pwg/_generateScans.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """Generate the 3 fictitious PWG scan PDFs used by the pilot demo. Run: python _generateScans.py diff --git a/modules/connectors/connectorOerebWfs.py b/modules/connectors/connectorOerebWfs.py index 62b0ee18..c3cc6de5 100644 --- a/modules/connectors/connectorOerebWfs.py +++ b/modules/connectors/connectorOerebWfs.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ ÖREB WFS Connector diff --git a/modules/connectors/connectorSwissTopoMapServer.py b/modules/connectors/connectorSwissTopoMapServer.py index d7b7a91e..a2e5db04 100644 --- a/modules/connectors/connectorSwissTopoMapServer.py +++ b/modules/connectors/connectorSwissTopoMapServer.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Swiss Topo MapServer Connector (Simplified) diff --git a/modules/connectors/connectorZhWfsParcels.py b/modules/connectors/connectorZhWfsParcels.py index 066c1727..1d85c9a8 100644 --- a/modules/connectors/connectorZhWfsParcels.py +++ b/modules/connectors/connectorZhWfsParcels.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Swiss Parcel (Liegenschaften) Connector diff --git a/modules/datamodels/jsonContinuation.py b/modules/datamodels/jsonContinuation.py index d4ee81f9..ed73ceea 100644 --- a/modules/datamodels/jsonContinuation.py +++ b/modules/datamodels/jsonContinuation.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ JSON Continuation Context Module diff --git a/modules/dbHelpers/__init__.py b/modules/dbHelpers/__init__.py index e69de29b..06003961 100644 --- a/modules/dbHelpers/__init__.py +++ b/modules/dbHelpers/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. diff --git a/modules/demoConfigs/__init__.py b/modules/demoConfigs/__init__.py index 6ac5054f..aacdc133 100644 --- a/modules/demoConfigs/__init__.py +++ b/modules/demoConfigs/__init__.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Demo Configs — Auto-Discovery Module diff --git a/modules/demoConfigs/baseDemoConfig.py b/modules/demoConfigs/baseDemoConfig.py index 604c7a78..22a43dc6 100644 --- a/modules/demoConfigs/baseDemoConfig.py +++ b/modules/demoConfigs/baseDemoConfig.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Base class for demo configurations. diff --git a/modules/demoConfigs/investorDemo2026.py b/modules/demoConfigs/investorDemo2026.py index e88ce6c7..7efcb3e4 100644 --- a/modules/demoConfigs/investorDemo2026.py +++ b/modules/demoConfigs/investorDemo2026.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Investor Demo April 2026 diff --git a/modules/demoConfigs/pwgDemo2026.py b/modules/demoConfigs/pwgDemo2026.py index 90e3c3e4..c796d302 100644 --- a/modules/demoConfigs/pwgDemo2026.py +++ b/modules/demoConfigs/pwgDemo2026.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """PWG Pilot Demo (April 2026) Bootstraps a complete PWG-Pilot demo environment in an empty dev/demo install: diff --git a/modules/features/commcoach/__init__.py b/modules/features/commcoach/__init__.py index ea99083a..1130b94a 100644 --- a/modules/features/commcoach/__init__.py +++ b/modules/features/commcoach/__init__.py @@ -1 +1,3 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. # CommCoach Feature Container diff --git a/modules/features/commcoach/tests/__init__.py b/modules/features/commcoach/tests/__init__.py index e69de29b..06003961 100644 --- a/modules/features/commcoach/tests/__init__.py +++ b/modules/features/commcoach/tests/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. diff --git a/modules/features/realEstate/__init__.py b/modules/features/realEstate/__init__.py index 48368b52..9da5534e 100644 --- a/modules/features/realEstate/__init__.py +++ b/modules/features/realEstate/__init__.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Real Estate feature module. """ diff --git a/modules/features/realEstate/bzoDocumentRetriever.py b/modules/features/realEstate/bzoDocumentRetriever.py index 9b271cda..cc5659d8 100644 --- a/modules/features/realEstate/bzoDocumentRetriever.py +++ b/modules/features/realEstate/bzoDocumentRetriever.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Document retriever for BZO extraction pipeline. Queries Dokument table and retrieves PDF content from ComponentObjects. diff --git a/modules/features/realEstate/bzoExtraction.py b/modules/features/realEstate/bzoExtraction.py index 3eace0f2..27ab6966 100644 --- a/modules/features/realEstate/bzoExtraction.py +++ b/modules/features/realEstate/bzoExtraction.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Pipeline for extracting structured content from BZO PDFs. diff --git a/modules/features/realEstate/bzoPdfExtractor.py b/modules/features/realEstate/bzoPdfExtractor.py index 155f5406..374f6d5c 100644 --- a/modules/features/realEstate/bzoPdfExtractor.py +++ b/modules/features/realEstate/bzoPdfExtractor.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ PDF extraction module for BZO documents. Extracts page-aware text blocks from PDF files. diff --git a/modules/features/realEstate/bzoRuleTaxonomy.py b/modules/features/realEstate/bzoRuleTaxonomy.py index dffd824d..583cbf32 100644 --- a/modules/features/realEstate/bzoRuleTaxonomy.py +++ b/modules/features/realEstate/bzoRuleTaxonomy.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Rule taxonomy for BZO extraction. Defines fixed rule types and their patterns for deterministic rule detection. diff --git a/modules/features/realEstate/datamodelFeatureRealEstate.py b/modules/features/realEstate/datamodelFeatureRealEstate.py index 8de665de..346e998b 100644 --- a/modules/features/realEstate/datamodelFeatureRealEstate.py +++ b/modules/features/realEstate/datamodelFeatureRealEstate.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Real Estate data models for Architektur-Planungs-App. Implements a general Swiss architecture planning data model. diff --git a/modules/features/realEstate/handlerRealEstate.py b/modules/features/realEstate/handlerRealEstate.py index e08ff6aa..86b765cd 100644 --- a/modules/features/realEstate/handlerRealEstate.py +++ b/modules/features/realEstate/handlerRealEstate.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Handler functions for Real Estate feature routes. Contains extracted business logic from route handlers. diff --git a/modules/features/realEstate/interfaceFeatureRealEstate.py b/modules/features/realEstate/interfaceFeatureRealEstate.py index 9219a842..24fe4955 100644 --- a/modules/features/realEstate/interfaceFeatureRealEstate.py +++ b/modules/features/realEstate/interfaceFeatureRealEstate.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Interface to Real Estate database objects. Uses PostgreSQL connector for data access with user/mandate filtering. diff --git a/modules/features/realEstate/mainRealEstate.py b/modules/features/realEstate/mainRealEstate.py index b9af4827..0fbbe363 100644 --- a/modules/features/realEstate/mainRealEstate.py +++ b/modules/features/realEstate/mainRealEstate.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Real Estate feature main entry point. Handles feature definition, RBAC registration, and lifecycle hooks. diff --git a/modules/features/realEstate/parcelSelectionService.py b/modules/features/realEstate/parcelSelectionService.py index c83efbe3..fde19efe 100644 --- a/modules/features/realEstate/parcelSelectionService.py +++ b/modules/features/realEstate/parcelSelectionService.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Parcel selection service: compute combined outline, total area, and Bauzone grouping. Used for multi-parcel selection in PEK map view. diff --git a/modules/features/realEstate/realEstateGemeindeService.py b/modules/features/realEstate/realEstateGemeindeService.py index bd1b0f9f..53eddb2c 100644 --- a/modules/features/realEstate/realEstateGemeindeService.py +++ b/modules/features/realEstate/realEstateGemeindeService.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Gemeinde and BZO document services for Real Estate feature. Provides ensure/import logic used by both routes and extract_bzo_information. diff --git a/modules/features/realEstate/routeFeatureRealEstate.py b/modules/features/realEstate/routeFeatureRealEstate.py index b78ee2ee..5723ab39 100644 --- a/modules/features/realEstate/routeFeatureRealEstate.py +++ b/modules/features/realEstate/routeFeatureRealEstate.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Real Estate routes for the backend API. Implements stateless endpoints for real estate database operations with AI-powered natural language processing. diff --git a/modules/features/realEstate/scrapeSwissTopo.py b/modules/features/realEstate/scrapeSwissTopo.py index 7f7d54e7..ff9b1c3c 100644 --- a/modules/features/realEstate/scrapeSwissTopo.py +++ b/modules/features/realEstate/scrapeSwissTopo.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Swiss Topo Scraping Script diff --git a/modules/features/realEstate/serviceAiIntent.py b/modules/features/realEstate/serviceAiIntent.py index d790d7c8..98745fc1 100644 --- a/modules/features/realEstate/serviceAiIntent.py +++ b/modules/features/realEstate/serviceAiIntent.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Real Estate feature — AI-based intent recognition and CRUD operations. diff --git a/modules/features/realEstate/serviceBzo.py b/modules/features/realEstate/serviceBzo.py index f4ec90bd..f70a799b 100644 --- a/modules/features/realEstate/serviceBzo.py +++ b/modules/features/realEstate/serviceBzo.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Real Estate feature — BZO (Bau- und Zonenordnung) information extraction. diff --git a/modules/features/realEstate/serviceGeometry.py b/modules/features/realEstate/serviceGeometry.py index c8021701..0d29e99f 100644 --- a/modules/features/realEstate/serviceGeometry.py +++ b/modules/features/realEstate/serviceGeometry.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Real Estate feature — Geometry utilities. diff --git a/modules/routes/routeAdminDemoConfig.py b/modules/routes/routeAdminDemoConfig.py index 0673c299..7c750977 100644 --- a/modules/routes/routeAdminDemoConfig.py +++ b/modules/routes/routeAdminDemoConfig.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Admin Demo Config API diff --git a/modules/serviceCenter/services/serviceKnowledge/_progressMessages.py b/modules/serviceCenter/services/serviceKnowledge/_progressMessages.py index 99d91d6b..75f6413e 100644 --- a/modules/serviceCenter/services/serviceKnowledge/_progressMessages.py +++ b/modules/serviceCenter/services/serviceKnowledge/_progressMessages.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """Central i18n registration for BackgroundJob progress messages. Walkers and consumers report progress via ``progressCb(..., messageKey="…", diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorPrefs.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorPrefs.py index 4aaaa9bf..29daff58 100644 --- a/modules/serviceCenter/services/serviceKnowledge/subConnectorPrefs.py +++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorPrefs.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """Per-connection knowledge ingestion preference helpers. Walkers call `loadConnectionPrefs(connectionId)` once at bootstrap start and diff --git a/modules/serviceCenter/services/serviceSubscription/__init__.py b/modules/serviceCenter/services/serviceSubscription/__init__.py index e69de29b..06003961 100644 --- a/modules/serviceCenter/services/serviceSubscription/__init__.py +++ b/modules/serviceCenter/services/serviceSubscription/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. diff --git a/modules/workflowAutomation/__init__.py b/modules/workflowAutomation/__init__.py index e6472791..20498d36 100644 --- a/modules/workflowAutomation/__init__.py +++ b/modules/workflowAutomation/__init__.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ workflowAutomation — System component for workflow orchestration. diff --git a/modules/workflowAutomation/editor/__init__.py b/modules/workflowAutomation/editor/__init__.py index 471ba8a5..43e07c7b 100644 --- a/modules/workflowAutomation/editor/__init__.py +++ b/modules/workflowAutomation/editor/__init__.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ workflowAutomation.editor — Graph/Flow authoring backend. diff --git a/scripts/_archive/check_orphan_featureinstance.py b/scripts/_archive/check_orphan_featureinstance.py index c09de61b..13757445 100644 --- a/scripts/_archive/check_orphan_featureinstance.py +++ b/scripts/_archive/check_orphan_featureinstance.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """Quick-Check: existiert FeatureInstance-Row 6019e7d0-b23d-41ec-b9f7-3dd1293078f2 in poweron_app, und welche Mandate/Instances stehen mit dem RedmineTicketMirror in Verbindung? diff --git a/scripts/_archive/i18n_rekey_plaintext_keys.py b/scripts/_archive/i18n_rekey_plaintext_keys.py index cf0e7362..5a1de579 100644 --- a/scripts/_archive/i18n_rekey_plaintext_keys.py +++ b/scripts/_archive/i18n_rekey_plaintext_keys.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Rekey frontend t('dot.notation') -> t('Deutscher Klartext') using locales/de.ts mapping. diff --git a/scripts/_archive/migrate_async_to_sync.py b/scripts/_archive/migrate_async_to_sync.py index 8b5626df..2a6239cc 100644 --- a/scripts/_archive/migrate_async_to_sync.py +++ b/scripts/_archive/migrate_async_to_sync.py @@ -1,4 +1,6 @@ #!/usr/bin/env python3 +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Migration Script: Convert async def → def for route handlers that don't need async. diff --git a/scripts/_archive/script_db_cleanup_duplicate_roles.py b/scripts/_archive/script_db_cleanup_duplicate_roles.py index 1ded5a51..9a0abdf5 100644 --- a/scripts/_archive/script_db_cleanup_duplicate_roles.py +++ b/scripts/_archive/script_db_cleanup_duplicate_roles.py @@ -1,4 +1,6 @@ #!/usr/bin/env python3 +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Cleanup script for duplicate roles in the database. diff --git a/scripts/_archive/script_db_migrate_accessrules_objectkeys.py b/scripts/_archive/script_db_migrate_accessrules_objectkeys.py index b0b5ce4a..156d6398 100644 --- a/scripts/_archive/script_db_migrate_accessrules_objectkeys.py +++ b/scripts/_archive/script_db_migrate_accessrules_objectkeys.py @@ -1,4 +1,6 @@ #!/usr/bin/env python +# Copyright (c) 2026 PowerOn AG +# All rights reserved. # -*- coding: utf-8 -*- """ Migration Script: Migrate AccessRules to Vollqualifizierte ObjectKeys diff --git a/scripts/check_db_no_sysadmin_role.py b/scripts/check_db_no_sysadmin_role.py index e0d02d7c..b15c6ab9 100644 --- a/scripts/check_db_no_sysadmin_role.py +++ b/scripts/check_db_no_sysadmin_role.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """Runtime-Check (A5): bestaetigt, dass die ``sysadmin``-Rolle aus der Datenbank entfernt wurde und liefert eine kurze Inventur fuer die isPlatformAdmin / isSysAdmin Flags. diff --git a/scripts/check_no_sysadmin_role.py b/scripts/check_no_sysadmin_role.py index 23fa8f2e..7af66465 100644 --- a/scripts/check_no_sysadmin_role.py +++ b/scripts/check_no_sysadmin_role.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """CI-Gate: Stelle sicher, dass keine Verweise auf die abgeschaffte ``sysadmin``-Rolle bzw. die alten Helper im Codebase mehr existieren. diff --git a/scripts/debug_rag_job_result.py b/scripts/debug_rag_job_result.py index c107f21e..5742f0b0 100644 --- a/scripts/debug_rag_job_result.py +++ b/scripts/debug_rag_job_result.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """Diagnose: read a connection.bootstrap job result and print its keys. Usage (from repo root): diff --git a/scripts/exportDbSchemaFromModels.py b/scripts/exportDbSchemaFromModels.py index dc3e4ab8..abd23762 100644 --- a/scripts/exportDbSchemaFromModels.py +++ b/scripts/exportDbSchemaFromModels.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """Export the database schema from Pydantic MODEL_REGISTRY + fk_target metadata. Usage (run from gateway/): diff --git a/scripts/script_db_adapt_to_models.py b/scripts/script_db_adapt_to_models.py index 6e5ca7a3..3811b029 100644 --- a/scripts/script_db_adapt_to_models.py +++ b/scripts/script_db_adapt_to_models.py @@ -1,4 +1,6 @@ #!/usr/bin/env python3 +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Datenbank-Anpassung an Pydantic-Modelle. diff --git a/scripts/script_db_audit_legacy_state.py b/scripts/script_db_audit_legacy_state.py index 54ee6474..c6a59fc1 100644 --- a/scripts/script_db_audit_legacy_state.py +++ b/scripts/script_db_audit_legacy_state.py @@ -1,4 +1,6 @@ #!/usr/bin/env python3 +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """Audit-Skript fuer Legacy-Bestaende vor Bootstrap-Cleanup (Plan C). Prueft fuer jede der 5 Bootstrap-Migrationsroutinen, ob noch Restbestand diff --git a/scripts/script_db_init_automation2.py b/scripts/script_db_init_automation2.py index 56d0daaf..7e9681f6 100644 --- a/scripts/script_db_init_automation2.py +++ b/scripts/script_db_init_automation2.py @@ -1,4 +1,6 @@ #!/usr/bin/env python3 +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Initialize poweron_automation2 database for the Automation2 feature. diff --git a/scripts/script_db_migrate_backgroundjob_progress_data.py b/scripts/script_db_migrate_backgroundjob_progress_data.py index bc5fc348..0e7b4cc8 100644 --- a/scripts/script_db_migrate_backgroundjob_progress_data.py +++ b/scripts/script_db_migrate_backgroundjob_progress_data.py @@ -1,4 +1,6 @@ #!/usr/bin/env python3 +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """Migration: Add `progressMessageData` JSONB column to BackgroundJob. Carries the structured i18n payload that lets the frontend translate diff --git a/scripts/script_db_migrate_datasource_inherit.py b/scripts/script_db_migrate_datasource_inherit.py index 3444cbee..014ef01d 100644 --- a/scripts/script_db_migrate_datasource_inherit.py +++ b/scripts/script_db_migrate_datasource_inherit.py @@ -1,4 +1,6 @@ #!/usr/bin/env python3 +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """Migration: Drop NOT NULL on DataSource/FeatureDataSource cascade-inherit flags. Switches three-valued semantics (NULL = inherit, True/False = explicit) for: diff --git a/scripts/script_db_migrate_datasource_rag.py b/scripts/script_db_migrate_datasource_rag.py index 95c2ae35..9771900f 100644 --- a/scripts/script_db_migrate_datasource_rag.py +++ b/scripts/script_db_migrate_datasource_rag.py @@ -1,4 +1,6 @@ #!/usr/bin/env python3 +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """Migration: Rename DataSource.autoSync -> ragIndexEnabled, lastSynced -> lastIndexed. This is a one-off migration for the RAG consent & control unification. diff --git a/scripts/script_db_migrate_datasource_settings.py b/scripts/script_db_migrate_datasource_settings.py index 9e821221..10a6238f 100644 --- a/scripts/script_db_migrate_datasource_settings.py +++ b/scripts/script_db_migrate_datasource_settings.py @@ -1,4 +1,6 @@ #!/usr/bin/env python3 +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """Migration: Add `settings` JSONB column to DataSource and FeatureDataSource. This is a one-off migration for the UDB DataSource Settings (Settings-Icon) diff --git a/scripts/script_export_accessrules.py b/scripts/script_export_accessrules.py index 6d5aeec7..2ba9bf64 100644 --- a/scripts/script_export_accessrules.py +++ b/scripts/script_export_accessrules.py @@ -1,4 +1,6 @@ #!/usr/bin/env python +# Copyright (c) 2026 PowerOn AG +# All rights reserved. # -*- coding: utf-8 -*- """ Export Script: Generate Access Rules per Role Report diff --git a/scripts/script_migrate_user_uid.py b/scripts/script_migrate_user_uid.py index e36f483d..eba0a4a5 100644 --- a/scripts/script_migrate_user_uid.py +++ b/scripts/script_migrate_user_uid.py @@ -1,4 +1,6 @@ #!/usr/bin/env python3 +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """One-time migration: Reassign all DB references from an old user UID to a new UID. When a user is re-created in PORTA (same username, new UUID), all existing records diff --git a/scripts/stage0_filefolder_schema_check.py b/scripts/stage0_filefolder_schema_check.py index d172e19c..33f89c7e 100644 --- a/scripts/stage0_filefolder_schema_check.py +++ b/scripts/stage0_filefolder_schema_check.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """Stage 0: verify FileFolder table + FileItem.folderId column in management DB. Run from the gateway directory (same as uvicorn): diff --git a/tests/demo/__init__.py b/tests/demo/__init__.py index e69de29b..06003961 100644 --- a/tests/demo/__init__.py +++ b/tests/demo/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. diff --git a/tests/demo/conftest.py b/tests/demo/conftest.py index 43d8363e..b01680f2 100644 --- a/tests/demo/conftest.py +++ b/tests/demo/conftest.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Demo test fixtures. diff --git a/tests/demo/test_demo_api.py b/tests/demo/test_demo_api.py index 1973d110..4d5bc7c0 100644 --- a/tests/demo/test_demo_api.py +++ b/tests/demo/test_demo_api.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ T-API: Demo Config API endpoint verification. diff --git a/tests/demo/test_demo_bootstrap.py b/tests/demo/test_demo_bootstrap.py index 45db18c7..a3f4f5d3 100644 --- a/tests/demo/test_demo_bootstrap.py +++ b/tests/demo/test_demo_bootstrap.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ T-BOOT: Bootstrap idempotency and demo state verification. diff --git a/tests/demo/test_demo_data_files.py b/tests/demo/test_demo_data_files.py index 4e7a3d40..40681cbe 100644 --- a/tests/demo/test_demo_data_files.py +++ b/tests/demo/test_demo_data_files.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ T-DATA: Demo data files verification. diff --git a/tests/demo/test_demo_neutralization.py b/tests/demo/test_demo_neutralization.py index aca54491..ff302a52 100644 --- a/tests/demo/test_demo_neutralization.py +++ b/tests/demo/test_demo_neutralization.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ T-NEU: Neutralization config verification. diff --git a/tests/demo/test_demo_uc1_trustee.py b/tests/demo/test_demo_uc1_trustee.py index f7fd2ce0..920ecfb7 100644 --- a/tests/demo/test_demo_uc1_trustee.py +++ b/tests/demo/test_demo_uc1_trustee.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ T-UC1: Trustee — Spesenverarbeitung. diff --git a/tests/demo/test_demo_uc2_realestate.py b/tests/demo/test_demo_uc2_realestate.py index 0d91122e..5205234d 100644 --- a/tests/demo/test_demo_uc2_realestate.py +++ b/tests/demo/test_demo_uc2_realestate.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ T-UC2: Immobilien — Machbarkeitsstudie. diff --git a/tests/demo/test_demo_uc4_i18n.py b/tests/demo/test_demo_uc4_i18n.py index 04eba4b9..a9ea2cf2 100644 --- a/tests/demo/test_demo_uc4_i18n.py +++ b/tests/demo/test_demo_uc4_i18n.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ T-UC4: Sprach-Deployment — Spanish (es). diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py index e69de29b..06003961 100644 --- a/tests/fixtures/__init__.py +++ b/tests/fixtures/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. diff --git a/tests/integration/mandates/__init__.py b/tests/integration/mandates/__init__.py index e69de29b..06003961 100644 --- a/tests/integration/mandates/__init__.py +++ b/tests/integration/mandates/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. diff --git a/tests/integration/users/__init__.py b/tests/integration/users/__init__.py index e69de29b..06003961 100644 --- a/tests/integration/users/__init__.py +++ b/tests/integration/users/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. diff --git a/tests/serviceAi/__init__.py b/tests/serviceAi/__init__.py index e69de29b..06003961 100644 --- a/tests/serviceAi/__init__.py +++ b/tests/serviceAi/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. diff --git a/tests/serviceGeneration/__init__.py b/tests/serviceGeneration/__init__.py index e69de29b..06003961 100644 --- a/tests/serviceGeneration/__init__.py +++ b/tests/serviceGeneration/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. diff --git a/tests/unit/aicore/__init__.py b/tests/unit/aicore/__init__.py index e69de29b..06003961 100644 --- a/tests/unit/aicore/__init__.py +++ b/tests/unit/aicore/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. diff --git a/tests/unit/bootstrap/__init__.py b/tests/unit/bootstrap/__init__.py index e69de29b..06003961 100644 --- a/tests/unit/bootstrap/__init__.py +++ b/tests/unit/bootstrap/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. diff --git a/tests/unit/connectors/__init__.py b/tests/unit/connectors/__init__.py index e69de29b..06003961 100644 --- a/tests/unit/connectors/__init__.py +++ b/tests/unit/connectors/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. diff --git a/tests/unit/methods/__init__.py b/tests/unit/methods/__init__.py index e69de29b..06003961 100644 --- a/tests/unit/methods/__init__.py +++ b/tests/unit/methods/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. diff --git a/tests/unit/nodeDefinitions/test_usesai_flag.py b/tests/unit/nodeDefinitions/test_usesai_flag.py index caf07960..ebbd5444 100644 --- a/tests/unit/nodeDefinitions/test_usesai_flag.py +++ b/tests/unit/nodeDefinitions/test_usesai_flag.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. # T18 — AC #16/#17: meta.usesAi on every node type; AI vs non-AI distinction. import pytest diff --git a/tests/unit/serviceAgent/test_udm_agent_tools.py b/tests/unit/serviceAgent/test_udm_agent_tools.py index 3449dd81..f8e09251 100644 --- a/tests/unit/serviceAgent/test_udm_agent_tools.py +++ b/tests/unit/serviceAgent/test_udm_agent_tools.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. # Phase 7: UDM tools (getUdmStructure, walkUdmBlocks, filterUdmByType). from modules.serviceCenter.services.serviceAgent.coreTools._documentTools import ( diff --git a/tests/unit/services/test_buildTree.py b/tests/unit/services/test_buildTree.py index 1f8c8da0..d99a99c8 100644 --- a/tests/unit/services/test_buildTree.py +++ b/tests/unit/services/test_buildTree.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """Unit tests for the generic UDB tree builder (`_buildTree.py`). Most node-level behavior moved into the polymorphic class hierarchy diff --git a/tests/unit/services/test_costEstimate.py b/tests/unit/services/test_costEstimate.py index a8e25138..8b913bea 100644 --- a/tests/unit/services/test_costEstimate.py +++ b/tests/unit/services/test_costEstimate.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """Unit tests for `_costEstimate` heuristic. Validates the output shape, basic formulas, and that 'basis' annotations diff --git a/tests/unit/services/test_inheritFlags.py b/tests/unit/services/test_inheritFlags.py index a74f1f7f..be3b41cf 100644 --- a/tests/unit/services/test_inheritFlags.py +++ b/tests/unit/services/test_inheritFlags.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """Unit tests for `_inheritFlags` cascade-inherit helpers. Verifies: diff --git a/tests/unit/services/test_p1d_consent_prefs.py b/tests/unit/services/test_p1d_consent_prefs.py index 0d15f546..9300b164 100644 --- a/tests/unit/services/test_p1d_consent_prefs.py +++ b/tests/unit/services/test_p1d_consent_prefs.py @@ -1,4 +1,6 @@ #!/usr/bin/env python3 +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """Unit tests for P1d: consent gating, preference parsing, and walker behaviour. Tests diff --git a/tests/unit/services/test_ragLimits.py b/tests/unit/services/test_ragLimits.py index 1ab5c403..ce2a862d 100644 --- a/tests/unit/services/test_ragLimits.py +++ b/tests/unit/services/test_ragLimits.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """Unit tests for `_ragLimits` central helpers. Verifies: diff --git a/tests/unit/services/test_udbNodes.py b/tests/unit/services/test_udbNodes.py index f9fae171..102bba27 100644 --- a/tests/unit/services/test_udbNodes.py +++ b/tests/unit/services/test_udbNodes.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """Unit tests for the polymorphic UDB node hierarchy (udbNodes.py). Each concrete node class is exercised for: diff --git a/tests/unit/teamsbot/__init__.py b/tests/unit/teamsbot/__init__.py index e69de29b..06003961 100644 --- a/tests/unit/teamsbot/__init__.py +++ b/tests/unit/teamsbot/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. diff --git a/tests/unit/workflow/test_extract_content_handover.py b/tests/unit/workflow/test_extract_content_handover.py index 8e8f409c..401b7a16 100644 --- a/tests/unit/workflow/test_extract_content_handover.py +++ b/tests/unit/workflow/test_extract_content_handover.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. # Unit tests: context.extractContent serialize + presentation helpers (legacy handover dicts vs new paths). import base64 diff --git a/tests/unit/workflow/test_merge_context_handover.py b/tests/unit/workflow/test_merge_context_handover.py index 30a60b8f..d62fcc12 100644 --- a/tests/unit/workflow/test_merge_context_handover.py +++ b/tests/unit/workflow/test_merge_context_handover.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. # Unit tests: context.mergeContext primary text from extract handover (documents[0]). import json diff --git a/tests/unit/workflow/test_node_combinations.py b/tests/unit/workflow/test_node_combinations.py index b4857a14..3c807f19 100644 --- a/tests/unit/workflow/test_node_combinations.py +++ b/tests/unit/workflow/test_node_combinations.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. # Tests: node handover compatibility across all major node combinations. # # Covers: diff --git a/tests/unit/workflow/test_phase3_context_node.py b/tests/unit/workflow/test_phase3_context_node.py index 5f113d5e..56ed0df3 100644 --- a/tests/unit/workflow/test_phase3_context_node.py +++ b/tests/unit/workflow/test_phase3_context_node.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. # Tests for Phase 3: context.extractContent node, port types, executor dispatch. import pytest diff --git a/tests/unit/workflow/test_phase4_workflow_nodes.py b/tests/unit/workflow/test_phase4_workflow_nodes.py index 3ca0792d..e042d85d 100644 --- a/tests/unit/workflow/test_phase4_workflow_nodes.py +++ b/tests/unit/workflow/test_phase4_workflow_nodes.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. # Tests for Phase 4: data.consolidate, ai.consolidate, flow.loop level/concurrency, flow.merge dynamic. import pytest diff --git a/tests/unit/workflow/test_phase5_highvol.py b/tests/unit/workflow/test_phase5_highvol.py index 44c51d76..287c96e5 100644 --- a/tests/unit/workflow/test_phase5_highvol.py +++ b/tests/unit/workflow/test_phase5_highvol.py @@ -1,3 +1,5 @@ +# Copyright (c) 2026 PowerOn AG +# All rights reserved. # Tests for Phase 5: Loop concurrency, StepLog batching, streaming aggregate. import pytest diff --git a/tests/unit/workflows/test_automation2_graphUtils.py b/tests/unit/workflows/test_automation2_graphUtils.py index 179857c1..941880d7 100644 --- a/tests/unit/workflows/test_automation2_graphUtils.py +++ b/tests/unit/workflows/test_automation2_graphUtils.py @@ -1,4 +1,6 @@ #!/usr/bin/env python3 +# Copyright (c) 2026 PowerOn AG +# All rights reserved. """ Unit tests for automation2 graphUtils - resolveParameterReferences (ref/value format). """ From 06e68c343b01b69c9fe0434b6f26e1b2636311ed Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Tue, 9 Jun 2026 22:59:26 +0200 Subject: [PATCH 14/16] automation fixes --- app.py | 2 + modules/datamodels/datamodelNavigation.py | 51 ++-------- modules/dbHelpers/fkLabelResolver.py | 18 ++++ .../realEstate/routeFeatureRealEstate.py | 18 +++- .../features/trustee/routeFeatureTrustee.py | 38 ++++--- modules/routes/routeAdminFeatures.py | 7 +- modules/routes/routeAdminRbacRules.py | 5 +- modules/routes/routeAudit.py | 10 ++ modules/routes/routeDataPrompts.py | 2 +- modules/routes/routeSubscription.py | 4 + modules/routes/routeWorkflowAutomation.py | 98 ++++++++++++++++++- .../services/serviceChat/mainServiceChat.py | 3 + .../services/serviceKnowledge/udbNodes.py | 2 +- .../engine/executionEngine.py | 2 +- .../engine/executors/actionNodeExecutor.py | 16 +-- .../methodContext/actions/extractContent.py | 14 +-- tests/unit/services/test_inheritFlags.py | 2 +- 17 files changed, 207 insertions(+), 85 deletions(-) diff --git a/app.py b/app.py index 68341361..55cc7fc0 100644 --- a/app.py +++ b/app.py @@ -501,6 +501,8 @@ async def lifespan(app: FastAPI): return if isinstance(exc, ConnectionAbortedError): return + if exc and "LocalProtocolError" in type(exc).__name__: + return loop.default_exception_handler(ctx) main_loop.set_exception_handler(_suppressClientDisconnect) except RuntimeError: diff --git a/modules/datamodels/datamodelNavigation.py b/modules/datamodels/datamodelNavigation.py index 5c40a165..101cef99 100644 --- a/modules/datamodels/datamodelNavigation.py +++ b/modules/datamodels/datamodelNavigation.py @@ -150,52 +150,21 @@ NAVIGATION_SECTIONS = [ }, ], }, - # --- Workflow-Automation (System-Komponente, cross-mandate) --- + # --- Solution Design (System-Komponente, cross-mandate) --- + # Single nav entry; tabs are managed internally by WorkflowAutomationHubPage. { "id": "workflowAutomation", - "title": t("Workflow-Automation"), + "title": t("Lösungsdesign"), "order": 25, "items": [ { - "id": "wa-workflows", - "objectKey": "ui.system.workflowAutomation.workflows", - "label": t("Workflows"), + "id": "wa-hub", + "objectKey": "ui.system.workflowAutomation", + "label": t("Workflow-Automation"), "icon": "FaSitemap", - "path": "/workflow-automation?tab=workflows", + "path": "/workflow-automation", "order": 10, }, - { - "id": "wa-editor", - "objectKey": "ui.system.workflowAutomation.editor", - "label": t("Editor"), - "icon": "FaProjectDiagram", - "path": "/workflow-automation?tab=editor", - "order": 20, - }, - { - "id": "wa-templates", - "objectKey": "ui.system.workflowAutomation.templates", - "label": t("Vorlagen"), - "icon": "FaCopy", - "path": "/workflow-automation?tab=templates", - "order": 30, - }, - { - "id": "wa-runs", - "objectKey": "ui.system.workflowAutomation.runs", - "label": t("Läufe"), - "icon": "FaPlay", - "path": "/workflow-automation?tab=runs", - "order": 40, - }, - { - "id": "wa-tasks", - "objectKey": "ui.system.workflowAutomation.tasks", - "label": t("Tasks"), - "icon": "FaTasks", - "path": "/workflow-automation?tab=tasks", - "order": 50, - }, ], }, # --- Administration (with subgroups) --- @@ -237,7 +206,7 @@ NAVIGATION_SECTIONS = [ { "id": "admin-users", "objectKey": "ui.admin.users", - "label": t("Benutzer"), + "label": t("Übersicht"), "icon": "FaUsers", "path": "/admin/users", "order": 10, @@ -246,7 +215,7 @@ NAVIGATION_SECTIONS = [ { "id": "admin-invitations", "objectKey": "ui.admin.invitations", - "label": t("Benutzer-Einladungen"), + "label": t("Einladungen"), "icon": "FaEnvelopeOpenText", "path": "/admin/invitations", "order": 20, @@ -255,7 +224,7 @@ NAVIGATION_SECTIONS = [ { "id": "admin-user-access-overview", "objectKey": "ui.admin.userAccessOverview", - "label": t("Benutzer-Zugriffsübersicht"), + "label": t("Zugriffe"), "icon": "FaClipboardList", "path": "/admin/user-access-overview", "order": 30, diff --git a/modules/dbHelpers/fkLabelResolver.py b/modules/dbHelpers/fkLabelResolver.py index 35a673af..e9829001 100644 --- a/modules/dbHelpers/fkLabelResolver.py +++ b/modules/dbHelpers/fkLabelResolver.py @@ -96,6 +96,23 @@ def resolveRoleLabels(db, ids: List[str]) -> Dict[str, Optional[str]]: return out +def resolveFileLabels(db, ids: List[str]) -> Dict[str, Optional[str]]: + """Resolve FileItem IDs to fileName. Returns None for unresolvable.""" + if not ids: + return {} + from modules.datamodels.datamodelFiles import FileItem as _FileItem + recs = db.getRecordset( + _FileItem, + recordFilter={"id": list(set(ids))}, + ) or [] + out: Dict[str, Optional[str]] = {i: None for i in ids} + for r in recs: + fid = r.get("id") + if fid: + out[fid] = r.get("fileName") or None + return out + + # --------------------------------------------------------------------------- # Resolver registry # --------------------------------------------------------------------------- @@ -105,6 +122,7 @@ _BUILTIN_FK_RESOLVERS: Dict[str, Callable] = { "FeatureInstance": resolveInstanceLabels, "UserInDB": resolveUserLabels, "Role": resolveRoleLabels, + "FileItem": resolveFileLabels, } diff --git a/modules/features/realEstate/routeFeatureRealEstate.py b/modules/features/realEstate/routeFeatureRealEstate.py index 5723ab39..d32c48cd 100644 --- a/modules/features/realEstate/routeFeatureRealEstate.py +++ b/modules/features/realEstate/routeFeatureRealEstate.py @@ -263,7 +263,7 @@ def get_projects( if mode == "filterValues": if not column: raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") - enrichRowsWithFkLabels(itemDicts, Projekt, db=interface.db) + enrichRowsWithFkLabels(itemDicts, Projekt, db=getRootInterface().db) return handleFilterValuesInMemory(itemDicts, column, pagination) return handleIdsInMemory(itemDicts, pagination) @@ -271,7 +271,9 @@ def get_projects( paginationParams = _parsePagination(pagination) if paginationParams: from modules.dbHelpers.paginationHelpers import applyFiltersAndSort + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels itemDicts = [i.model_dump() if hasattr(i, 'model_dump') else i for i in items] + enrichRowsWithFkLabels(itemDicts, Projekt, db=getRootInterface().db) filtered = applyFiltersAndSort(itemDicts, paginationParams) total_items = len(filtered) total_pages = (total_items + paginationParams.pageSize - 1) // paginationParams.pageSize @@ -289,7 +291,10 @@ def get_projects( filters=paginationParams.filters ) ) - return PaginatedResponse(items=items, pagination=None) + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels + itemDicts = [i.model_dump() if hasattr(i, 'model_dump') else i for i in items] + enrichRowsWithFkLabels(itemDicts, Projekt, db=getRootInterface().db) + return PaginatedResponse(items=itemDicts, pagination=None) @router.get("/{instanceId}/projects/{projectId}", response_model=Projekt) @@ -405,7 +410,7 @@ def get_parcels( if mode == "filterValues": if not column: raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") - enrichRowsWithFkLabels(itemDicts, Parzelle, db=interface.db) + enrichRowsWithFkLabels(itemDicts, Parzelle, db=getRootInterface().db) return handleFilterValuesInMemory(itemDicts, column, pagination) return handleIdsInMemory(itemDicts, pagination) @@ -413,7 +418,9 @@ def get_parcels( paginationParams = _parsePagination(pagination) if paginationParams: from modules.dbHelpers.paginationHelpers import applyFiltersAndSort + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels itemDicts = [i.model_dump() if hasattr(i, 'model_dump') else i for i in items] + enrichRowsWithFkLabels(itemDicts, Parzelle, db=getRootInterface().db) filtered = applyFiltersAndSort(itemDicts, paginationParams) total_items = len(filtered) total_pages = (total_items + paginationParams.pageSize - 1) // paginationParams.pageSize @@ -431,7 +438,10 @@ def get_parcels( filters=paginationParams.filters ) ) - return PaginatedResponse(items=items, pagination=None) + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels + itemDicts = [i.model_dump() if hasattr(i, 'model_dump') else i for i in items] + enrichRowsWithFkLabels(itemDicts, Parzelle, db=getRootInterface().db) + return PaginatedResponse(items=itemDicts, pagination=None) @router.get("/{instanceId}/parcels/{parcelId}", response_model=Parzelle) diff --git a/modules/features/trustee/routeFeatureTrustee.py b/modules/features/trustee/routeFeatureTrustee.py index c06f8604..8b5ba94a 100644 --- a/modules/features/trustee/routeFeatureTrustee.py +++ b/modules/features/trustee/routeFeatureTrustee.py @@ -437,7 +437,7 @@ def get_organisations( return [r.model_dump() if hasattr(r, "model_dump") else r for r in items] if paginationParams and hasattr(result, 'items'): - enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeOrganisation, db=interface.db) + enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeOrganisation, db=getRootInterface().db) return { "items": enriched, "pagination": PaginationMetadata( @@ -450,7 +450,7 @@ def get_organisations( ).model_dump(), } items = result if isinstance(result, list) else result.items - enriched = enrichRowsWithFkLabels(_toDicts(items), TrusteeOrganisation, db=interface.db) + enriched = enrichRowsWithFkLabels(_toDicts(items), TrusteeOrganisation, db=getRootInterface().db) return {"items": enriched, "pagination": None} @@ -557,7 +557,7 @@ def get_roles( return [r.model_dump() if hasattr(r, "model_dump") else r for r in items] if paginationParams and hasattr(result, 'items'): - enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeRole, db=interface.db) + enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeRole, db=getRootInterface().db) return { "items": enriched, "pagination": PaginationMetadata( @@ -570,7 +570,7 @@ def get_roles( ).model_dump(), } items = result if isinstance(result, list) else result.items - enriched = enrichRowsWithFkLabels(_toDicts(items), TrusteeRole, db=interface.db) + enriched = enrichRowsWithFkLabels(_toDicts(items), TrusteeRole, db=getRootInterface().db) return {"items": enriched, "pagination": None} @@ -677,7 +677,7 @@ def get_all_access( return [r.model_dump() if hasattr(r, "model_dump") else r for r in items] if paginationParams and hasattr(result, 'items'): - enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeAccess, db=interface.db) + enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeAccess, db=getRootInterface().db) return { "items": enriched, "pagination": PaginationMetadata( @@ -690,7 +690,7 @@ def get_all_access( ).model_dump(), } items = result if isinstance(result, list) else result.items - enriched = enrichRowsWithFkLabels(_toDicts(items), TrusteeAccess, db=interface.db) + enriched = enrichRowsWithFkLabels(_toDicts(items), TrusteeAccess, db=getRootInterface().db) return {"items": enriched, "pagination": None} @@ -827,7 +827,7 @@ def get_contracts( return [r.model_dump() if hasattr(r, "model_dump") else r for r in items] if paginationParams and hasattr(result, 'items'): - enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeContract, db=interface.db) + enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeContract, db=getRootInterface().db) return { "items": enriched, "pagination": PaginationMetadata( @@ -840,7 +840,7 @@ def get_contracts( ).model_dump(), } items = result if isinstance(result, list) else result.items - enriched = enrichRowsWithFkLabels(_toDicts(items), TrusteeContract, db=interface.db) + enriched = enrichRowsWithFkLabels(_toDicts(items), TrusteeContract, db=getRootInterface().db) return {"items": enriched, "pagination": None} @@ -953,6 +953,7 @@ def get_documents( context: RequestContext = Depends(getRequestContext) ): """Get all documents (metadata only) with optional pagination.""" + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels mandateId = _validateInstanceAccess(instanceId, context) if mode in ("filterValues", "ids"): @@ -966,8 +967,9 @@ def get_documents( return [r.model_dump() if hasattr(r, 'model_dump') else r for r in items] if paginationParams and hasattr(result, 'items'): + enriched = enrichRowsWithFkLabels(_itemsToDicts(result.items), TrusteeDocument, db=getRootInterface().db) return { - "items": _itemsToDicts(result.items), + "items": enriched, "pagination": PaginationMetadata( currentPage=paginationParams.page or 1, pageSize=paginationParams.pageSize or 20, @@ -978,7 +980,8 @@ def get_documents( ).model_dump(), } items = result if isinstance(result, list) else result.items - return {"items": _itemsToDicts(items), "pagination": None} + enriched = enrichRowsWithFkLabels(_itemsToDicts(items), TrusteeDocument, db=getRootInterface().db) + return {"items": enriched, "pagination": None} def _handleDocumentMode(instanceId, mandateId, mode, column, pagination, context): @@ -991,7 +994,7 @@ def _handleDocumentMode(instanceId, mandateId, mode, column, pagination, context raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") result = interface.getAllDocuments(None) items = [r.model_dump() if hasattr(r, 'model_dump') else r for r in (result.items if hasattr(result, 'items') else result)] - enrichRowsWithFkLabels(items, TrusteeDocument, db=interface.db) + enrichRowsWithFkLabels(items, TrusteeDocument, db=getRootInterface().db) return handleFilterValuesInMemory(items, column, pagination) if mode == "ids": result = interface.getAllDocuments(None) @@ -1229,6 +1232,7 @@ def get_positions( context: RequestContext = Depends(getRequestContext) ): """Get all positions with optional pagination.""" + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels mandateId = _validateInstanceAccess(instanceId, context) if mode in ("filterValues", "ids"): @@ -1241,9 +1245,12 @@ def get_positions( def _itemsToDicts(items): return [r.model_dump() if hasattr(r, 'model_dump') else r for r in items] + featureResolvers = _buildFeatureInternalResolvers(TrusteePosition, interface.db) + if paginationParams and hasattr(result, 'items'): items = _itemsToDicts(result.items) _enrichPositionsWithSyncStatus(items, interface, instanceId) + enrichRowsWithFkLabels(items, TrusteePosition, db=getRootInterface().db, extraResolvers=featureResolvers or None) return { "items": items, "pagination": PaginationMetadata( @@ -1258,6 +1265,7 @@ def get_positions( rawItems = result if isinstance(result, list) else result.items items = _itemsToDicts(rawItems) _enrichPositionsWithSyncStatus(items, interface, instanceId) + enrichRowsWithFkLabels(items, TrusteePosition, db=getRootInterface().db, extraResolvers=featureResolvers or None) return {"items": items, "pagination": None} @@ -1273,7 +1281,7 @@ def _handlePositionMode(instanceId, mandateId, mode, column, pagination, context result = interface.getAllPositions(None) items = [r.model_dump() if hasattr(r, 'model_dump') else r for r in (result.items if hasattr(result, 'items') else result)] _enrichPositionsWithSyncStatus(items, interface, instanceId) - enrichRowsWithFkLabels(items, TrusteePositionView, db=interface.db) + enrichRowsWithFkLabels(items, TrusteePositionView, db=getRootInterface().db) return handleFilterValuesInMemory(items, column, pagination) if mode == "ids": result = interface.getAllPositions(None) @@ -2075,7 +2083,7 @@ def _paginatedReadEndpoint( rawItems = result.items if hasattr(result, "items") else result items = [r.model_dump() if hasattr(r, "model_dump") else r for r in rawItems] featureResolvers = _buildFeatureInternalResolvers(modelClass, interface.db) - enrichRowsWithFkLabels(items, modelClass, db=interface.db, extraResolvers=featureResolvers or None) + enrichRowsWithFkLabels(items, modelClass, db=getRootInterface().db, extraResolvers=featureResolvers or None) return handleFilterValuesInMemory(items, column, pagination) if mode == "ids": @@ -2113,7 +2121,7 @@ def _paginatedReadEndpoint( if paginationParams and hasattr(result, "items"): enriched = enrichRowsWithFkLabels( _itemsToDicts(result.items), modelClass, - db=interface.db, extraResolvers=featureResolvers or None, + db=getRootInterface().db, extraResolvers=featureResolvers or None, ) return { "items": enriched, @@ -2129,7 +2137,7 @@ def _paginatedReadEndpoint( items = result.items if hasattr(result, "items") else result enriched = enrichRowsWithFkLabels( _itemsToDicts(items), modelClass, - db=interface.db, extraResolvers=featureResolvers or None, + db=getRootInterface().db, extraResolvers=featureResolvers or None, ) return {"items": enriched, "pagination": None} diff --git a/modules/routes/routeAdminFeatures.py b/modules/routes/routeAdminFeatures.py index 0a9626dc..e8daa385 100644 --- a/modules/routes/routeAdminFeatures.py +++ b/modules/routes/routeAdminFeatures.py @@ -472,12 +472,13 @@ 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": if not column: raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") - from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels - from modules.datamodels.datamodelFeatures import FeatureInstance - enrichRowsWithFkLabels(items, FeatureInstance, db=rootInterface.db) return handleFilterValuesInMemory(items, column, pagination) if mode == "ids": diff --git a/modules/routes/routeAdminRbacRules.py b/modules/routes/routeAdminRbacRules.py index 36577de7..83aaef00 100644 --- a/modules/routes/routeAdminRbacRules.py +++ b/modules/routes/routeAdminRbacRules.py @@ -940,6 +940,8 @@ def list_roles( if paginationParams: from modules.dbHelpers.paginationHelpers import applyFiltersAndSort + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels + enrichRowsWithFkLabels(result, Role, db=interface.db) sortedResult = applyFiltersAndSort(result, paginationParams) totalItems = len(sortedResult) totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0 @@ -959,7 +961,8 @@ def list_roles( ) ) else: - # No pagination - return all roles + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels + enrichRowsWithFkLabels(result, Role, db=interface.db) return PaginatedResponse( items=result, pagination=None diff --git a/modules/routes/routeAudit.py b/modules/routes/routeAudit.py index 9dfd074d..f6aa16c8 100644 --- a/modules/routes/routeAudit.py +++ b/modules/routes/routeAudit.py @@ -363,6 +363,16 @@ async def getNeutralizationMappings( _enrichUserAndInstanceLabels(items, context) + fileIds = list({r.get("fileId") for r in items if r.get("fileId")}) + if fileIds: + from modules.dbHelpers.fkLabelResolver import resolveFileLabels + from modules.interfaces.interfaceDbApp import getRootInterface + fileMap = resolveFileLabels(getRootInterface().db, fileIds) + for r in items: + fid = r.get("fileId") + if fid and fid in fileMap: + r["fileIdLabel"] = fileMap[fid] or fid + if mode == "filterValues" and column: items = _applySortFilterSearch(items, filtersJson=filters) return _distinctColumnValues(items, column) diff --git a/modules/routes/routeDataPrompts.py b/modules/routes/routeDataPrompts.py index bbb566e7..164d4233 100644 --- a/modules/routes/routeDataPrompts.py +++ b/modules/routes/routeDataPrompts.py @@ -120,7 +120,7 @@ def get_prompts( def _promptsToEnrichedDicts(promptItems): dicts = [r.model_dump() if hasattr(r, 'model_dump') else (dict(r) if not isinstance(r, dict) else r) for r in promptItems] - enrichRowsWithFkLabels(dicts, Prompt, db=managementInterface.db) + enrichRowsWithFkLabels(dicts, Prompt, db=getAppInterface(currentUser).db) return dicts managementInterface = interfaceDbManagement.getInterface(currentUser) diff --git a/modules/routes/routeSubscription.py b/modules/routes/routeSubscription.py index 709d70e5..57a00093 100644 --- a/modules/routes/routeSubscription.py +++ b/modules/routes/routeSubscription.py @@ -514,6 +514,10 @@ def getAllSubscriptions( raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}") enriched = _buildEnrichedSubscriptions() + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels + from modules.datamodels.datamodelSubscription import MandateSubscription + from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIf + enrichRowsWithFkLabels(enriched, MandateSubscription, db=_getRootIf().db) filtered = applyFiltersAndSort(enriched, paginationParams) if paginationParams: diff --git a/modules/routes/routeWorkflowAutomation.py b/modules/routes/routeWorkflowAutomation.py index 4fb7cca9..fe3e8853 100644 --- a/modules/routes/routeWorkflowAutomation.py +++ b/modules/routes/routeWorkflowAutomation.py @@ -63,6 +63,8 @@ async def _listWorkflows( pagination: Optional[str] = Query(default=None), 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) @@ -76,6 +78,7 @@ async def _listWorkflows( params = _parsePaginationOr400(pagination) records = db.getRecordset(AutoWorkflow, recordFilter=scopeFilter) + enrichRowsWithFkLabels(records or [], AutoWorkflow, db=_getRootIface().db) if params: filtered = applyFiltersAndSort(records or [], params) pageItems, totalItems = paginateInMemory(filtered, params) @@ -169,6 +172,8 @@ async def _listRuns( mandateId: Optional[str] = Query(default=None), 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) @@ -185,6 +190,15 @@ async def _listRuns( params = _parsePaginationOr400(pagination) records = db.getRecordset(AutoRun, recordFilter=scopeFilter) + + def _resolveWorkflowLabels(ids): + wfRecs = db.getRecordset(AutoWorkflow, recordFilter={"id": list(set(ids))}) or [] + return {r.get("id"): r.get("label") or r.get("name") for r in wfRecs} + + enrichRowsWithFkLabels( + records or [], AutoRun, db=_getRootIface().db, + extraResolvers={"workflowId": _resolveWorkflowLabels}, + ) if params: filtered = applyFiltersAndSort(records or [], params) pageItems, totalItems = paginateInMemory(filtered, params) @@ -991,7 +1005,7 @@ def _getMetrics( try: workflows = db.getRecordset(AutoWorkflow, recordFilter=scopeFilter) or [] if db._ensureTableExists(AutoWorkflow) else [] wfIds = [w.get("id") for w in workflows] - runFilter = {"workflowId": {"$in": wfIds}} if wfIds else {"workflowId": "__none__"} + runFilter = {"workflowId": wfIds} if wfIds else {"workflowId": "__none__"} runs = db.getRecordset(AutoRun, recordFilter=runFilter) or [] if db._ensureTableExists(AutoRun) else [] tasks = db.getRecordset(AutoTask, recordFilter=runFilter) or [] if db._ensureTableExists(AutoTask) else [] finally: @@ -1275,7 +1289,8 @@ def _getRunDetail( if tid: try: from modules.dbHelpers.fkLabelResolver import resolveInstanceLabels - labelMap = resolveInstanceLabels(db, [tid]) + from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIface + labelMap = resolveInstanceLabels(_getRootIface().db, [tid]) targetInstanceLabel = labelMap.get(tid) except Exception: pass @@ -1465,6 +1480,85 @@ async def _executeWorkflow( return result +@router.post("/execute") +@limiter.limit("30/minute") +async def _executeWorkflowFromBody( + request: Request, + body: dict = Body(..., description="{ workflowId?, graph?, targetInstanceId?, payload?, runEnvelope? }"), + context: RequestContext = Depends(getRequestContext), +) -> dict: + """Execute a workflow — workflowId from body or ad-hoc graph execution.""" + from modules.workflowAutomation.mainWorkflowAutomation import _getWorkflowAutomationServices + from modules.workflowAutomation.engine.executionEngine import executeGraph + from modules.interfaces.interfaceWorkflowAutomation import _getWorkflowAutomationInterface + from modules.workflows.processing.shared.methodDiscovery import discoverMethods + + userId = str(context.user.id) if context.user else None + workflowId = body.get("workflowId") or "" + targetInstanceId = body.get("targetInstanceId") or "" + + wf = None + if workflowId: + db = _getWorkflowAutomationDb() + try: + db._ensureTableExists(AutoWorkflow) + wf = db.getRecord(AutoWorkflow, workflowId) + finally: + db.close() + if not wf: + raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) + _validateWorkflowAccess(context, wf, "execute") + + mandateId = (wf.get("mandateId") if wf else None) or str(context.mandateId or "") + instanceId = (wf.get("featureInstanceId") if wf else None) or targetInstanceId or str(context.featureInstanceId or "") + targetFeatureInstanceId = (wf.get("targetFeatureInstanceId") if wf else None) or targetInstanceId or "" + + services = _getWorkflowAutomationServices( + context.user, + mandateId=mandateId, + featureInstanceId=instanceId, + ) + discoverMethods(services) + + graph = body.get("graph") or body.get("payload") or {} + if wf and not (graph.get("nodes") or []): + graph = wf.get("graph") or {} + + logger.info( + "workflowAutomation /execute: workflowId=%s nodes=%d userId=%s", + workflowId, len(graph.get("nodes") or []), userId, + ) + + workflowForEnvelope = wf + runEnv = _buildExecuteRunEnvelope( + body, + workflowForEnvelope, + userId, + getattr(context.user, "language", None) if context.user else None, + ) + wfLabel = (wf.get("label") if wf else None) or "" + + iface = _getWorkflowAutomationInterface(context.user, mandateId, instanceId) + result = await executeGraph( + graph=graph, + services=services, + workflowId=workflowId or None, + instanceId=instanceId, + userId=userId, + mandateId=mandateId, + automation2_interface=iface, + run_envelope=runEnv, + label=wfLabel, + targetFeatureInstanceId=targetFeatureInstanceId, + ) + logger.info( + "workflowAutomation /execute result: success=%s error=%s paused=%s", + result.get("success"), result.get("error"), result.get("paused"), + ) + _startEmailPollerIfNeeded(result) + return result + + # --------------------------------------------------------------------------- # Version management # --------------------------------------------------------------------------- diff --git a/modules/serviceCenter/services/serviceChat/mainServiceChat.py b/modules/serviceCenter/services/serviceChat/mainServiceChat.py index 18ab2a68..44e42583 100644 --- a/modules/serviceCenter/services/serviceChat/mainServiceChat.py +++ b/modules/serviceCenter/services/serviceChat/mainServiceChat.py @@ -1003,6 +1003,9 @@ class ChatService: """Get workflow by ID by delegating to the chat interface""" try: logger.debug(f"getWorkflow called with workflowId: {workflowId}") + if workflowId.startswith("transient-"): + logger.debug(f"getWorkflow: skipping DB lookup for transient workflow {workflowId}") + return None result = self.interfaceDbChat.getWorkflow(workflowId) if result: logger.debug(f"getWorkflow returned workflow with ID: {result.id}") diff --git a/modules/serviceCenter/services/serviceKnowledge/udbNodes.py b/modules/serviceCenter/services/serviceKnowledge/udbNodes.py index 00f07bfb..c6bc0622 100644 --- a/modules/serviceCenter/services/serviceKnowledge/udbNodes.py +++ b/modules/serviceCenter/services/serviceKnowledge/udbNodes.py @@ -21,7 +21,7 @@ model (see wiki/b-reference/platform/unified-data-bar.md): - FdsRecordNode (+children)-- feature-owned FeatureDataSource records - FdsFieldNode -- virtual per-column nodes under fdsTable -The classes use `_inheritFlags.py` as a helper module for the actual +The classes use `modules/serviceCenter/core/flagResolution.py` as a helper module for the actual walk/aggregate/cascade arithmetic, so the inheritance semantics live in one place. The classes themselves only express "what does this node type DO" -- ownership, RBAC, persistence routing, child enumeration. diff --git a/modules/workflowAutomation/engine/executionEngine.py b/modules/workflowAutomation/engine/executionEngine.py index e188adab..807b6743 100644 --- a/modules/workflowAutomation/engine/executionEngine.py +++ b/modules/workflowAutomation/engine/executionEngine.py @@ -770,7 +770,7 @@ async def executeGraph( waFileLogger: Optional[RunFileLogger] = None nodeOutputs: Dict[str, Any] = dict(initialNodeOutputs or {}) - if not runId and automation2_interface and workflowId and not is_resume: + if not runId and automation2_interface and not is_resume: run_context = { "connectionMap": connectionMap, "inputSources": inputSources, diff --git a/modules/workflowAutomation/engine/executors/actionNodeExecutor.py b/modules/workflowAutomation/engine/executors/actionNodeExecutor.py index 12dffc31..aa472f15 100644 --- a/modules/workflowAutomation/engine/executors/actionNodeExecutor.py +++ b/modules/workflowAutomation/engine/executors/actionNodeExecutor.py @@ -599,9 +599,9 @@ class ActionNodeExecutor: logger.exception("ActionNodeExecutor node %s FAILED: %s", nodeId, e) return _normalizeError(e, outputSchema) finally: - if chatService: + if self.services.chat: try: - chatService.progressLogFinish(nodeOperationId, actionSuccess) + self.services.chat.progressLogFinish(nodeOperationId, actionSuccess) except Exception: pass @@ -630,11 +630,11 @@ class ActionNodeExecutor: rawBytes = coerceDocumentDataToBytes(rawData) if isinstance(dumped, dict) and rawBytes: try: - _mgmt = self.services.interfaceDbComponent + _chatSvc = self.services.chat _docName = dumped.get("documentName") or f"workflow-result-{nodeId}.bin" _mimeType = dumped.get("mimeType") or "application/octet-stream" - _fileItem = _mgmt.createFile(_docName, _mimeType, rawBytes, folderId=persist_folder_id) - _mgmt.createFileData(_fileItem.id, rawBytes) + _fileItem = _chatSvc.createFile(_docName, _mimeType, rawBytes, folderId=persist_folder_id) + _chatSvc.createFileData(_fileItem.id, rawBytes) dumped["fileId"] = _fileItem.id dumped["id"] = _fileItem.id dumped["fileName"] = _fileItem.fileName @@ -656,7 +656,7 @@ class ActionNodeExecutor: "documents": docsList, "count": len(docsList), } - _attachConnectionProvenance(list_out, resolvedParams, outputSchema, chatService, self.services) + _attachConnectionProvenance(list_out, resolvedParams, outputSchema, self.services.chat, self.services) return normalizeToSchema(list_out, outputSchema) extractedContext = "" @@ -751,7 +751,7 @@ class ActionNodeExecutor: "mode": data_dict.get("mode", resolvedParams.get("mode", "summarize")), "count": int(data_dict.get("count", 0)), } - _attachConnectionProvenance(cr_out, resolvedParams, outputSchema, chatService, self.services) + _attachConnectionProvenance(cr_out, resolvedParams, outputSchema, self.services.chat, self.services) return normalizeToSchema(cr_out, outputSchema) if nodeDef.get("popDocumentsFromOutput"): @@ -760,7 +760,7 @@ class ActionNodeExecutor: if outputSchema in ("AiResult", "ActionResult") and result.success: _attach_unified_presentation_data(out, node_def=nodeDef) - _attachConnectionProvenance(out, resolvedParams, outputSchema, chatService, self.services) + _attachConnectionProvenance(out, resolvedParams, outputSchema, self.services.chat, self.services) # When the node declares ``surfaceDataAsTopLevel`` (typical for # dynamic-schema context nodes whose output keys are graph-defined), diff --git a/modules/workflows/methods/methodContext/actions/extractContent.py b/modules/workflows/methods/methodContext/actions/extractContent.py index 5172ced2..19677d2b 100644 --- a/modules/workflows/methods/methodContext/actions/extractContent.py +++ b/modules/workflows/methods/methodContext/actions/extractContent.py @@ -1194,8 +1194,8 @@ def _persist_extracted_image_parts( ) return content_extracted_serial, artifacts - if services and hasattr(services, "interfaceDbComponent"): - mgmt = services.interfaceDbComponent + if services and hasattr(services, "chat"): + mgmt = services.chat else: from modules.interfaces.interfaceDbManagement import getInterface as _get_mgmt from modules.security.rootAccess import getRootUser @@ -1206,7 +1206,7 @@ def _persist_extracted_image_parts( return content_extracted_serial, artifacts if not mgmt: - logger.warning("extractContent image persist: no interfaceDbComponent available") + logger.warning("extractContent image persist: no chat service available") return content_extracted_serial, artifacts stem = re.sub(r"[^\w\-]+", "_", name_stem).strip("_") or "extract" @@ -1310,11 +1310,11 @@ _IMAGE_MAX_DIMENSION = 1200 def _get_mgmt_for_presentation_render(services: Any) -> Optional[Any]: - mgmt = getattr(services, "interfaceDbComponent", None) if services else None - if mgmt: - return mgmt if not services: return None + chat = getattr(services, "chat", None) + if chat: + return chat try: import modules.interfaces.interfaceDbManagement as iface @@ -1385,7 +1385,7 @@ def _load_image_bytes_by_file_id(services: Any, file_id: str) -> Optional[bytes] if not mgmt or not hasattr(mgmt, "getFileData"): raise ValueError( "no management interface available to load persisted image bytes — " - "services.interfaceDbComponent / mandate / instance must be set" + "services.chat / mandate / instance must be set" ) return mgmt.getFileData(str(file_id)) diff --git a/tests/unit/services/test_inheritFlags.py b/tests/unit/services/test_inheritFlags.py index be3b41cf..07b29bc0 100644 --- a/tests/unit/services/test_inheritFlags.py +++ b/tests/unit/services/test_inheritFlags.py @@ -17,7 +17,7 @@ import unittest from typing import List from unittest.mock import MagicMock -from modules.serviceCenter.services.serviceKnowledge import _inheritFlags +from modules.serviceCenter.core import flagResolution as _inheritFlags def _ds(idVal: str, path: str, **flags) -> dict: From dce41a01acaeba4e345cc2481184b539639fff8c Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Tue, 9 Jun 2026 23:40:43 +0200 Subject: [PATCH 15/16] fix: unit tests and pdf bullet rendering Co-authored-by: Cursor --- .../renderers/rendererPdf.py | 38 ++++++++++++++----- .../workflow/test_extract_content_handover.py | 4 +- tests/unit/workflow/test_node_combinations.py | 2 +- 3 files changed, 32 insertions(+), 12 deletions(-) diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py index 0543a7f3..d1fe3b20 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py @@ -828,7 +828,15 @@ class RendererPdf(BaseRenderer): return [] def _renderJsonBulletList(self, list_data: Dict[str, Any], styles: Dict[str, Any]) -> List[Any]: - """Render a JSON bullet list to PDF elements.""" + """Render a JSON bullet list to PDF elements. + + Uses ReportLab's built-in ``bulletText`` parameter for proper hanging + indent: the bullet/number is drawn at ``bulletIndent`` while all text + lines (including continuation) start at ``leftIndent``. This avoids + the previous approach of prepending the bullet character to the text + which caused misaligned wrap lines when the character width did not + match the indent value. + """ try: content = list_data.get("content", {}) if not isinstance(content, dict): @@ -836,27 +844,39 @@ class RendererPdf(BaseRenderer): items = content.get("items", []) bulletStyleDef = styles.get("bullet_list", {}) indent = bulletStyleDef.get("indent", 18) + fs = bulletStyleDef.get("font_size", 11) + + us = getattr(self, '_unifiedStyle', None) + primaryFont = us["fonts"]["primary"] if us else "Calibri" + fontName = _resolveFontFamily(primaryFont, False) + + isNumbered = content.get("list_type") == "numbered" + bulletChar = bulletStyleDef.get("bullet_char", "\u2022") + bulletStyle = ParagraphStyle( "BulletItem", - fontSize=bulletStyleDef.get("font_size", 11), + fontName=fontName, + fontSize=fs, textColor=self._hexToColor(bulletStyleDef.get("color", styles.get("colors", {}).get("primary", "#24292e"))), leftIndent=indent, - firstLineIndent=-indent, + bulletIndent=0, + bulletFontName=fontName, + bulletFontSize=fs, spaceAfter=2, - leading=bulletStyleDef.get("font_size", 11) * 1.25, + leading=fs * 1.25, ) - bulletChar = bulletStyleDef.get("bullet_char", "\u2022") elements = [] - for item in items: + for idx, item in enumerate(items): + marker = f"{idx + 1}." if isNumbered else bulletChar runs = self._inlineRunsForListItem(item) if isinstance(item, list): xml = self._renderInlineRunsToPdfXml(runs) - elements.append(Paragraph(f"{bulletChar} {_wrapEmojiSpansInXml(xml)}", bulletStyle)) + elements.append(Paragraph(_wrapEmojiSpansInXml(xml), bulletStyle, bulletText=marker)) elif isinstance(item, str): - elements.append(Paragraph(f"{bulletChar} {self._markdownInlineToReportlabXml(item)}", bulletStyle)) + elements.append(Paragraph(self._markdownInlineToReportlabXml(item), bulletStyle, bulletText=marker)) elif isinstance(item, dict) and "text" in item: - elements.append(Paragraph(f"{bulletChar} {self._markdownInlineToReportlabXml(item['text'])}", bulletStyle)) + elements.append(Paragraph(self._markdownInlineToReportlabXml(item['text']), bulletStyle, bulletText=marker)) if elements: elements.append(Spacer(1, bulletStyleDef.get("space_after", 3))) diff --git a/tests/unit/workflow/test_extract_content_handover.py b/tests/unit/workflow/test_extract_content_handover.py index 401b7a16..f18a3fc6 100644 --- a/tests/unit/workflow/test_extract_content_handover.py +++ b/tests/unit/workflow/test_extract_content_handover.py @@ -554,7 +554,7 @@ def test_presentation_envelopes_preserves_data_slot_order_text_image_text(): ) class _Svc: - interfaceDbComponent = _Mgmt() + chat = _Mgmt() pres = { "kind": PRESENTATION_KIND, @@ -666,7 +666,7 @@ def test_presentation_envelopes_to_document_json_image_slot(): return b"\x89PNG\r\n\x1a\n" + b"\x00" * 16 class _Svc: - interfaceDbComponent = _Mgmt() + chat = _Mgmt() out = presentation_envelopes_to_document_json( pres, diff --git a/tests/unit/workflow/test_node_combinations.py b/tests/unit/workflow/test_node_combinations.py index 3c807f19..c26d4f07 100644 --- a/tests/unit/workflow/test_node_combinations.py +++ b/tests/unit/workflow/test_node_combinations.py @@ -596,7 +596,7 @@ def test_extract_image_slot_carries_file_id_and_mime(): class _Services: def __init__(self): - self.interfaceDbComponent = _MgmtStub() + self.chat = _MgmtStub() envelope = { "schemaVersion": PRESENTATION_SCHEMA_VERSION, From 30db7a310cab0b3649375e2520c4cbe22e0efdad Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Tue, 9 Jun 2026 23:52:50 +0200 Subject: [PATCH 16/16] fix: resolve all deprecation warnings and remove dead test scripts Co-authored-by: Cursor --- .../realEstate/datamodelFeatureRealEstate.py | 438 +++--------------- .../features/redmine/routeFeatureRedmine.py | 4 +- tests/functional/test_kpi_full.py | 97 ---- tests/functional/test_kpi_incomplete.py | 134 ------ tests/functional/test_kpi_path.py | 68 --- .../test_featureCatalogLabels_i18n.py | 6 +- 6 files changed, 76 insertions(+), 671 deletions(-) delete mode 100644 tests/functional/test_kpi_full.py delete mode 100644 tests/functional/test_kpi_incomplete.py delete mode 100644 tests/functional/test_kpi_path.py diff --git a/modules/features/realEstate/datamodelFeatureRealEstate.py b/modules/features/realEstate/datamodelFeatureRealEstate.py index 346e998b..cf2c6c67 100644 --- a/modules/features/realEstate/datamodelFeatureRealEstate.py +++ b/modules/features/realEstate/datamodelFeatureRealEstate.py @@ -57,37 +57,17 @@ class GeoTag(str, Enum): class GeoPunkt(BaseModel): """Represents a 3D point with reference.""" koordinatensystem: str = Field( - description="Coordinate system (e.g. 'LV95', 'EPSG:2056')", - frontend_type="text", - frontend_readonly=False, - frontend_required=True, - ) + description="Coordinate system (e.g. 'LV95', 'EPSG:2056')", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}) x: float = Field( - description="East value (E) [m], typically 2'480'000 - 2'840'000", - frontend_type="number", - frontend_readonly=False, - frontend_required=True, - ) + description="East value (E) [m], typically 2'480'000 - 2'840'000", json_schema_extra={"frontend_type": "number", "frontend_readonly": False, "frontend_required": True}) y: float = Field( - description="North value (N) [m], typically 1'070'000 - 1'300'000", - frontend_type="number", - frontend_readonly=False, - frontend_required=True, - ) + description="North value (N) [m], typically 1'070'000 - 1'300'000", json_schema_extra={"frontend_type": "number", "frontend_readonly": False, "frontend_required": True}) z: Optional[float] = Field( None, - description="Height above sea level [m]", - frontend_type="number", - frontend_readonly=False, - frontend_required=False, - ) + description="Height above sea level [m]", json_schema_extra={"frontend_type": "number", "frontend_readonly": False, "frontend_required": False}) referenz: Optional[GeoTag] = Field( None, - description="Point categorization", - frontend_type="select", - frontend_readonly=False, - frontend_required=False, - ) + description="Point categorization", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False}) class GeoPolylinie(BaseModel): @@ -97,18 +77,10 @@ class GeoPolylinie(BaseModel): description="Primary key", ) closed: bool = Field( - description="Is the GeoPolylinie closed (polygon)?", - frontend_type="boolean", - frontend_readonly=False, - frontend_required=True, - ) + description="Is the GeoPolylinie closed (polygon)?", json_schema_extra={"frontend_type": "boolean", "frontend_readonly": False, "frontend_required": True}) punkte: List[GeoPunkt] = Field( default_factory=list, - description="List of GeoPunkte forming the GeoPolylinie", - frontend_type="json", - frontend_readonly=False, - frontend_required=True, - ) + description="List of GeoPunkte forming the GeoPolylinie", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": True}) @i18nModel("Dokument") @@ -117,73 +89,33 @@ class Dokument(PowerOnModel): id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Primary key", - frontend_type="text", - frontend_readonly=True, - frontend_required=False, - label="ID", - ) + json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) mandateId: str = Field( description="ID of the mandate this document belongs to", - frontend_type="text", - frontend_readonly=True, - frontend_required=False, - label="Mandats-ID", - ) + json_schema_extra={"label": "Mandats-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) featureInstanceId: str = Field( description="ID of the feature instance this document belongs to", - frontend_type="text", - frontend_readonly=True, - frontend_required=False, - label="Feature-Instanz-ID", - ) + json_schema_extra={"label": "Feature-Instanz-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) label: str = Field( description="Document label", - frontend_type="text", - frontend_readonly=False, - frontend_required=True, - label="Bezeichnung", - ) + json_schema_extra={"label": "Bezeichnung", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True}) versionsbezeichnung: Optional[str] = Field( None, - description="Version number or designation (e.g. 'v1.0', 'Rev. A')", - frontend_type="text", - frontend_readonly=False, - frontend_required=False, - ) + description="Version number or designation (e.g. 'v1.0', 'Rev. A')", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}) dokumentTyp: Optional[DokumentTyp] = Field( None, - description="Document type", - frontend_type="select", - frontend_readonly=False, - frontend_required=False, - ) + description="Document type", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False}) dokumentReferenz: str = Field( - description="File path or URL", - frontend_type="text", - frontend_readonly=False, - frontend_required=True, - ) + description="File path or URL", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}) quelle: Optional[str] = Field( None, - description="Source of the document", - frontend_type="text", - frontend_readonly=False, - frontend_required=False, - ) + description="Source of the document", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}) mimeType: Optional[str] = Field( None, - description="MIME type of the document (e.g. 'application/pdf', 'image/png')", - frontend_type="text", - frontend_readonly=False, - frontend_required=False, - ) + description="MIME type of the document (e.g. 'application/pdf', 'image/png')", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}) kategorienTags: List[str] = Field( default_factory=list, - description="Document categorization tags", - frontend_type="json", - frontend_readonly=False, - frontend_required=False, - ) + description="Document categorization tags", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False}) class Kontext(PowerOnModel): @@ -193,78 +125,38 @@ class Kontext(PowerOnModel): description="Primary key", ) thema: str = Field( - description="Theme designation", - frontend_type="text", - frontend_readonly=False, - frontend_required=True, - ) + description="Theme designation", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}) inhalt: str = Field( - description="Detailed information (text)", - frontend_type="textarea", - frontend_readonly=False, - frontend_required=True, - ) + description="Detailed information (text)", json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": True}) class Land(PowerOnModel): """National level administrative entity.""" id: str = Field( default_factory=lambda: str(uuid.uuid4()), - description="Primary key", - frontend_type="text", - frontend_readonly=True, - frontend_required=False, - ) + description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) mandateId: str = Field( - description="ID of the mandate", - frontend_type="text", - frontend_readonly=True, - frontend_required=False, - ) + description="ID of the mandate", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) featureInstanceId: str = Field( - description="ID of the feature instance", - frontend_type="text", - frontend_readonly=True, - frontend_required=False, - ) + description="ID of the feature instance", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) label: str = Field( - description="Country name (e.g. 'Schweiz')", - frontend_type="text", - frontend_readonly=False, - frontend_required=True, - ) + description="Country name (e.g. 'Schweiz')", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}) abk: Optional[str] = Field( None, - description="Abbreviation (e.g. 'CH')", - frontend_type="text", - frontend_readonly=False, - frontend_required=False, - ) + description="Abbreviation (e.g. 'CH')", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}) dokumente: List[Dokument] = Field( default_factory=list, - description="National laws/documents", - frontend_type="json", - frontend_readonly=False, - frontend_required=False, - ) + description="National laws/documents", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False}) kontextInformationen: List[Kontext] = Field( default_factory=list, - description="National context information", - frontend_type="json", - frontend_readonly=False, - frontend_required=False, - ) + description="National context information", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False}) class Kanton(PowerOnModel): """Cantonal level administrative entity.""" id: str = Field( default_factory=lambda: str(uuid.uuid4()), - description="Primary key", - frontend_type="text", - frontend_readonly=True, - frontend_required=False, - ) + description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) mandateId: str = Field( description="ID of the mandate", json_schema_extra={ @@ -282,11 +174,7 @@ class Kanton(PowerOnModel): }, ) label: str = Field( - description="Canton name (e.g. 'Zürich')", - frontend_type="text", - frontend_readonly=False, - frontend_required=True, - ) + description="Canton name (e.g. 'Zürich')", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}) id_land: Optional[str] = Field( None, description="Land ID (Foreign Key) - eindeutiger Link zum Land, in welchem Land der Kanton liegt", @@ -299,54 +187,26 @@ class Kanton(PowerOnModel): ) abk: Optional[str] = Field( None, - description="Abbreviation (e.g. 'ZH')", - frontend_type="text", - frontend_readonly=False, - frontend_required=False, - ) + description="Abbreviation (e.g. 'ZH')", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}) dokumente: List[Dokument] = Field( default_factory=list, - description="Cantonal documents", - frontend_type="json", - frontend_readonly=False, - frontend_required=False, - ) + description="Cantonal documents", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False}) kontextInformationen: List[Kontext] = Field( default_factory=list, - description="Canton-specific context information", - frontend_type="json", - frontend_readonly=False, - frontend_required=False, - ) + description="Canton-specific context information", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False}) class Gemeinde(PowerOnModel): """Municipal level administrative entity.""" id: str = Field( default_factory=lambda: str(uuid.uuid4()), - description="Primary key", - frontend_type="text", - frontend_readonly=True, - frontend_required=False, - ) + description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) mandateId: str = Field( - description="ID of the mandate", - frontend_type="text", - frontend_readonly=True, - frontend_required=False, - ) + description="ID of the mandate", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) featureInstanceId: str = Field( - description="ID of the feature instance", - frontend_type="text", - frontend_readonly=True, - frontend_required=False, - ) + description="ID of the feature instance", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) label: str = Field( - description="Municipality name (e.g. 'Zürich')", - frontend_type="text", - frontend_readonly=False, - frontend_required=True, - ) + description="Municipality name (e.g. 'Zürich')", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}) id_kanton: Optional[str] = Field( None, description="Kanton ID (Foreign Key) - eindeutiger Link zum Kanton, in welchem Kanton die Gemeinde liegt", @@ -359,25 +219,13 @@ class Gemeinde(PowerOnModel): ) plz: Optional[str] = Field( None, - description="Postal code (for municipalities with multiple PLZ, this can be a main PLZ). Bei Gemeinden mit mehreren Postleitzahlen wird die konkrete PLZ der Parzelle im Attribut `plz` der Parzelle erfasst.", - frontend_type="text", - frontend_readonly=False, - frontend_required=False, - ) + description="Postal code (for municipalities with multiple PLZ, this can be a main PLZ). Bei Gemeinden mit mehreren Postleitzahlen wird die konkrete PLZ der Parzelle im Attribut `plz` der Parzelle erfasst.", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}) dokumente: List[Dokument] = Field( default_factory=list, - description="Municipal documents", - frontend_type="json", - frontend_readonly=False, - frontend_required=False, - ) + description="Municipal documents", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False}) kontextInformationen: List[Kontext] = Field( default_factory=list, - description="Municipality-specific context information", - frontend_type="json", - frontend_readonly=False, - frontend_required=False, - ) + description="Municipality-specific context information", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False}) # ===== Main Models (use ForwardRef for circular references) ===== @@ -392,11 +240,7 @@ class Parzelle(PowerOnModel): id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Primary key", - frontend_type="text", - frontend_readonly=True, - frontend_required=False, - label="ID", - ) + json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) mandateId: str = Field( description="ID of the mandate", json_schema_extra={ @@ -421,55 +265,27 @@ class Parzelle(PowerOnModel): # Grunddaten label: str = Field( description="Plot designation", - frontend_type="text", - frontend_readonly=False, - frontend_required=True, - label="Bezeichnung", - ) + json_schema_extra={"label": "Bezeichnung", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True}) parzellenAliasTags: List[str] = Field( default_factory=list, - description="Additional plot names or field names", - frontend_type="json", - frontend_readonly=False, - frontend_required=False, - ) + description="Additional plot names or field names", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False}) eigentuemerschaft: Optional[str] = Field( None, - description="Owner of the plot", - frontend_type="text", - frontend_readonly=False, - frontend_required=False, - ) + description="Owner of the plot", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}) strasseNr: Optional[str] = Field( None, - description="Street and house number", - frontend_type="text", - frontend_readonly=False, - frontend_required=False, - ) + description="Street and house number", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}) plz: Optional[str] = Field( None, - description="Postal code of the plot", - frontend_type="text", - frontend_readonly=False, - frontend_required=False, - ) + description="Postal code of the plot", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}) # Geografischer Kontext perimeter: Optional[GeoPolylinie] = Field( None, - description="Plot boundary as closed GeoPolylinie", - frontend_type="json", - frontend_readonly=False, - frontend_required=False, - ) + description="Plot boundary as closed GeoPolylinie", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False}) baulinie: Optional[GeoPolylinie] = Field( None, - description="Building line of the plot", - frontend_type="json", - frontend_readonly=False, - frontend_required=False, - ) + description="Building line of the plot", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False}) kontextGemeinde: Optional[str] = Field( None, @@ -485,145 +301,69 @@ class Parzelle(PowerOnModel): # Bebauungsparameter bauzone: Optional[str] = Field( None, - description="Building zone designation (e.g. W3, WG2, etc.)", - frontend_type="text", - frontend_readonly=False, - frontend_required=False, - ) + description="Building zone designation (e.g. W3, WG2, etc.)", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}) az: Optional[float] = Field( None, - description="Ausnützungsziffer", - frontend_type="number", - frontend_readonly=False, - frontend_required=False, - ) + description="Ausnützungsziffer", json_schema_extra={"frontend_type": "number", "frontend_readonly": False, "frontend_required": False}) bz: Optional[float] = Field( None, - description="Bebauungsziffer", - frontend_type="number", - frontend_readonly=False, - frontend_required=False, - ) + description="Bebauungsziffer", json_schema_extra={"frontend_type": "number", "frontend_readonly": False, "frontend_required": False}) vollgeschossZahl: Optional[int] = Field( None, - description="Number of allowed full floors", - frontend_type="number", - frontend_readonly=False, - frontend_required=False, - ) + description="Number of allowed full floors", json_schema_extra={"frontend_type": "number", "frontend_readonly": False, "frontend_required": False}) anrechenbarDachgeschoss: Optional[float] = Field( None, - description="Accountable portion of attic (0.0 - 1.0)", - frontend_type="number", - frontend_readonly=False, - frontend_required=False, - ) + description="Accountable portion of attic (0.0 - 1.0)", json_schema_extra={"frontend_type": "number", "frontend_readonly": False, "frontend_required": False}) anrechenbarUntergeschoss: Optional[float] = Field( None, - description="Accountable portion of basement (0.0 - 1.0)", - frontend_type="number", - frontend_readonly=False, - frontend_required=False, - ) + description="Accountable portion of basement (0.0 - 1.0)", json_schema_extra={"frontend_type": "number", "frontend_readonly": False, "frontend_required": False}) gebaeudehoeheMax: Optional[float] = Field( None, - description="Maximum building height in meters", - frontend_type="number", - frontend_readonly=False, - frontend_required=False, - ) + description="Maximum building height in meters", json_schema_extra={"frontend_type": "number", "frontend_readonly": False, "frontend_required": False}) # Abstandsregelungen regelnGrenzabstand: List[str] = Field( default_factory=list, - description="Regulations for boundary distance", - frontend_type="json", - frontend_readonly=False, - frontend_required=False, - ) + description="Regulations for boundary distance", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False}) regelnMehrlaengenzuschlag: List[str] = Field( default_factory=list, - description="Regulations for additional length surcharge", - frontend_type="json", - frontend_readonly=False, - frontend_required=False, - ) + description="Regulations for additional length surcharge", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False}) regelnMehrhoehenzuschlag: List[str] = Field( default_factory=list, - description="Regulations for additional height surcharge", - frontend_type="json", - frontend_readonly=False, - frontend_required=False, - ) + description="Regulations for additional height surcharge", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False}) # Eigenschaften (Ja/Nein) parzelleBebaut: Optional[JaNein] = Field( None, - description="Is the plot built?", - frontend_type="select", - frontend_readonly=False, - frontend_required=False, - ) + description="Is the plot built?", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False}) parzelleErschlossen: Optional[JaNein] = Field( None, - description="Is the plot developed?", - frontend_type="select", - frontend_readonly=False, - frontend_required=False, - ) + description="Is the plot developed?", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False}) parzelleHanglage: Optional[JaNein] = Field( None, - description="Is the plot on a slope?", - frontend_type="select", - frontend_readonly=False, - frontend_required=False, - ) + description="Is the plot on a slope?", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False}) # Schutzzonen laermschutzzone: Optional[str] = Field( None, - description="Noise protection zone (e.g. 'II')", - frontend_type="text", - frontend_readonly=False, - frontend_required=False, - ) + description="Noise protection zone (e.g. 'II')", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}) hochwasserschutzzone: Optional[str] = Field( None, - description="Flood protection zone (e.g. 'tief')", - frontend_type="text", - frontend_readonly=False, - frontend_required=False, - ) + description="Flood protection zone (e.g. 'tief')", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}) grundwasserschutzzone: Optional[str] = Field( None, - description="Groundwater protection zone", - frontend_type="text", - frontend_readonly=False, - frontend_required=False, - ) + description="Groundwater protection zone", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}) # Beziehungen (stored as JSONB in database) parzellenNachbarschaft: List[Dict[str, Any]] = Field( default_factory=list, - description="Neighboring plots (stored as list of Parzelle IDs or full objects)", - frontend_type="json", - frontend_readonly=False, - frontend_required=False, - ) + description="Neighboring plots (stored as list of Parzelle IDs or full objects)", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False}) dokumente: List[Dokument] = Field( default_factory=list, - description="Plot-specific documents", - frontend_type="json", - frontend_readonly=False, - frontend_required=False, - ) + description="Plot-specific documents", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False}) kontextInformationen: List[Kontext] = Field( default_factory=list, - description="Plot-specific context information", - frontend_type="json", - frontend_readonly=False, - frontend_required=False, - ) + description="Plot-specific context information", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False}) @i18nModel("Projekt") @@ -632,11 +372,7 @@ class Projekt(PowerOnModel): id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Primary key", - frontend_type="text", - frontend_readonly=True, - frontend_required=False, - label="ID", - ) + json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) mandateId: str = Field( description="ID of the mandate", json_schema_extra={ @@ -659,54 +395,26 @@ class Projekt(PowerOnModel): ) label: str = Field( description="Project designation", - frontend_type="text", - frontend_readonly=False, - frontend_required=True, - label="Bezeichnung", - ) + json_schema_extra={"label": "Bezeichnung", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True}) statusProzess: Optional[StatusProzess] = Field( None, description="Project status", - frontend_type="select", - frontend_readonly=False, - frontend_required=False, - label="Prozessstatus", - ) + json_schema_extra={"label": "Prozessstatus", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False}) perimeter: Optional[GeoPolylinie] = Field( None, - description="Envelope of all plots in the project", - frontend_type="json", - frontend_readonly=False, - frontend_required=False, - ) + description="Envelope of all plots in the project", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False}) baulinie: Optional[GeoPolylinie] = Field( None, - description="Building line of the project", - frontend_type="json", - frontend_readonly=False, - frontend_required=False, - ) + description="Building line of the project", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False}) parzellen: List[Parzelle] = Field( default_factory=list, - description="All plots of the project", - frontend_type="json", - frontend_readonly=False, - frontend_required=False, - ) + description="All plots of the project", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False}) dokumente: List[Dokument] = Field( default_factory=list, - description="Project-specific documents", - frontend_type="json", - frontend_readonly=False, - frontend_required=False, - ) + description="Project-specific documents", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False}) kontextInformationen: List[Kontext] = Field( default_factory=list, - description="Project-specific context information", - frontend_type="json", - frontend_readonly=False, - frontend_required=False, - ) + description="Project-specific context information", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False}) # Resolve forward references diff --git a/modules/features/redmine/routeFeatureRedmine.py b/modules/features/redmine/routeFeatureRedmine.py index 86ac8d30..d3f5b771 100644 --- a/modules/features/redmine/routeFeatureRedmine.py +++ b/modules/features/redmine/routeFeatureRedmine.py @@ -457,10 +457,10 @@ async def getStats( instanceId: str, dateFrom: Optional[str] = Query(default=None, description="ISO date YYYY-MM-DD"), dateTo: Optional[str] = Query(default=None, description="ISO date YYYY-MM-DD"), - bucket: str = Query(default="week", regex="^(day|week|month)$"), + bucket: str = Query(default="week", pattern="^(day|week|month)$"), trackerIds: Optional[List[int]] = Query(default=None), categoryIds: Optional[List[int]] = Query(default=None, description="Filter by Redmine issue categories"), - statusFilter: str = Query(default="*", regex="^(\\*|open|closed)$", description="Restrict to open/closed/all tickets"), + statusFilter: str = Query(default="*", pattern="^(\\*|open|closed)$", description="Restrict to open/closed/all tickets"), context: RequestContext = Depends(getRequestContext), ) -> RedmineStatsDto: mandateId = _validateInstanceAccess(instanceId, context) diff --git a/tests/functional/test_kpi_full.py b/tests/functional/test_kpi_full.py deleted file mode 100644 index aa8d8540..00000000 --- a/tests/functional/test_kpi_full.py +++ /dev/null @@ -1,97 +0,0 @@ -# Copyright (c) 2026 PowerOn AG -# All rights reserved. -"""Test full KPI extraction and validation flow""" -import json -import sys -import os -import pytest - -# Add gateway directory to path -_gateway_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) -if _gateway_path not in sys.path: - sys.path.insert(0, _gateway_path) - -from modules.serviceCenter.services.serviceAi.subJsonResponseHandling import JsonResponseHandler -from modules.datamodels.datamodelAi import JsonAccumulationState - -# Load actual JSON response -json_file = os.path.join( - os.path.dirname(__file__), - "..", "..", "..", "local", "debug", "prompts", - "20251130-211706-078-document_generation_response.txt" -) - -if not os.path.exists(json_file): - pytest.skip(f"Test data file not found: {json_file}", allow_module_level=True) - -with open(json_file, 'r', encoding='utf-8') as f: - content = f.read() - -# Extract JSON -from modules.shared.jsonUtils import extractJsonString -extracted = extractJsonString(content) -parsedJson = json.loads(extracted) - -# KPI definition from the response -kpiDefinitions = [{ - "id": "prime_numbers_count", - "description": "Number of prime numbers generated and organized in the table", - "jsonPath": "documents[0].sections[0].elements[0].rows", - "targetValue": 4000 -}] - -print("="*60) -print("KPI EXTRACTION AND VALIDATION TEST") -print("="*60) - -# Step 1: Initialize accumulation state with KPIs -accumulationState = JsonAccumulationState( - accumulatedJsonString="", - isAccumulationMode=True, - lastParsedResult=None, - allSections=[], - kpis=[{**kpi, "currentValue": 0} for kpi in kpiDefinitions] -) - -print(f"\nStep 1: Initialized KPIs") -for kpi in accumulationState.kpis: - print(f" KPI {kpi['id']}: currentValue={kpi.get('currentValue', 'N/A')}, targetValue={kpi.get('targetValue', 'N/A')}") - -# Step 2: Extract KPI values from parsed JSON -print(f"\nStep 2: Extracting KPI values from JSON...") -updatedKpis = JsonResponseHandler.extractKpiValuesFromJson( - parsedJson, - accumulationState.kpis -) - -print(f" Extracted {len(updatedKpis)} KPIs") -for kpi in updatedKpis: - print(f" KPI {kpi['id']}: currentValue={kpi.get('currentValue', 'N/A')}, targetValue={kpi.get('targetValue', 'N/A')}") - -# Step 3: Validate progression -print(f"\nStep 3: Validating KPI progression...") -shouldProceed, reason = JsonResponseHandler.validateKpiProgression( - accumulationState, - updatedKpis -) - -print(f" Result: shouldProceed={shouldProceed}, reason={reason}") - -# Step 4: Check what's in accumulationState.kpis vs updatedKpis -print(f"\nStep 4: Comparing state...") -print(f" accumulationState.kpis[0].currentValue = {accumulationState.kpis[0].get('currentValue', 'N/A')}") -print(f" updatedKpis[0].currentValue = {updatedKpis[0].get('currentValue', 'N/A')}") - -# Step 5: Check if we need to update accumulationState.kpis -print(f"\nStep 5: Updating accumulationState.kpis...") -accumulationState.kpis = updatedKpis -print(f" Updated accumulationState.kpis[0].currentValue = {accumulationState.kpis[0].get('currentValue', 'N/A')}") - -# Step 6: Validate again (should show progress) -print(f"\nStep 6: Validating again after update...") -shouldProceed2, reason2 = JsonResponseHandler.validateKpiProgression( - accumulationState, - updatedKpis -) -print(f" Result: shouldProceed={shouldProceed2}, reason={reason2}") - diff --git a/tests/functional/test_kpi_incomplete.py b/tests/functional/test_kpi_incomplete.py deleted file mode 100644 index b9125d9f..00000000 --- a/tests/functional/test_kpi_incomplete.py +++ /dev/null @@ -1,134 +0,0 @@ -# Copyright (c) 2026 PowerOn AG -# All rights reserved. -"""Test KPI extraction with incomplete JSON""" -import json -import sys -import os -import pytest - -# Add gateway directory to path -_gateway_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) -if _gateway_path not in sys.path: - sys.path.insert(0, _gateway_path) - -from modules.serviceCenter.services.serviceAi.subJsonResponseHandling import JsonResponseHandler -from modules.datamodels.datamodelAi import JsonAccumulationState -from modules.shared.jsonUtils import extractJsonString, repairBrokenJson - -# Load actual incomplete JSON response -json_file = os.path.join( - os.path.dirname(__file__), - "..", "..", "..", "local", "debug", "prompts", - "20251130-211706-078-document_generation_response.txt" -) - -if not os.path.exists(json_file): - pytest.skip(f"Test data file not found: {json_file}", allow_module_level=True) - -with open(json_file, 'r', encoding='utf-8') as f: - content = f.read() - -print("="*60) -print("KPI EXTRACTION WITH INCOMPLETE JSON TEST") -print("="*60) - -# Step 1: Try to extract and parse JSON -print(f"\nStep 1: Extracting JSON string...") -extracted = extractJsonString(content) -print(f" Extracted length: {len(extracted)} chars") - -# Step 2: Try to parse -print(f"\nStep 2: Attempting to parse...") -parsedJson = None -try: - parsedJson = json.loads(extracted) - print(f" ✅ JSON parsed successfully") -except json.JSONDecodeError as e: - print(f" ❌ JSON parsing failed: {e}") - print(f" Attempting repair...") - try: - parsedJson = repairBrokenJson(extracted) - if parsedJson: - print(f" ✅ JSON repaired successfully") - else: - print(f" ❌ JSON repair failed") - except Exception as e2: - print(f" ❌ Repair error: {e2}") - -if not parsedJson: - pytest.skip("Cannot proceed - JSON cannot be parsed or repaired", allow_module_level=True) - -# Step 3: Check if path exists -print(f"\nStep 3: Checking if KPI path exists...") -path = "documents[0].sections[0].elements[0].rows" -try: - value = JsonResponseHandler._extractValueByPath(parsedJson, path) - print(f" ✅ Path exists: {type(value)}") - if isinstance(value, list): - print(f" ✅ Value is list with {len(value)} items") - if len(value) > 0: - print(f" ✅ First item: {value[0]}") - else: - print(f" ⚠️ Value is not a list: {value}") -except Exception as e: - print(f" ❌ Path extraction failed: {e}") - import traceback - traceback.print_exc() - pytest.skip(f"Path extraction failed: {e}", allow_module_level=True) - -# Step 4: Test KPI extraction -print(f"\nStep 4: Testing KPI extraction...") -kpiDefinitions = [{ - "id": "prime_numbers_count", - "description": "Number of prime numbers generated and organized in the table", - "jsonPath": "documents[0].sections[0].elements[0].rows", - "targetValue": 4000 -}] - -accumulationState = JsonAccumulationState( - accumulatedJsonString="", - isAccumulationMode=True, - lastParsedResult=parsedJson, - allSections=[], - kpis=[{**kpi, "currentValue": 0} for kpi in kpiDefinitions] -) - -print(f" Initial KPI currentValue: {accumulationState.kpis[0].get('currentValue', 'N/A')}") - -updatedKpis = JsonResponseHandler.extractKpiValuesFromJson( - parsedJson, - accumulationState.kpis -) - -print(f" Updated KPI currentValue: {updatedKpis[0].get('currentValue', 'N/A')}") - -# Step 5: Test validation -print(f"\nStep 5: Testing KPI validation...") -shouldProceed, reason = JsonResponseHandler.validateKpiProgression( - accumulationState, - updatedKpis -) - -print(f" Result: shouldProceed={shouldProceed}, reason={reason}") - -if not shouldProceed: - print(f"\n❌ VALIDATION FAILED - This is the problem!") - print(f" Let's debug why...") - - # Check what's being compared - lastValues = {kpi.get("id"): kpi.get("currentValue", 0) for kpi in accumulationState.kpis} - print(f" Last values from accumulationState: {lastValues}") - - for updatedKpi in updatedKpis: - kpiId = updatedKpi.get("id") - currentValue = updatedKpi.get("currentValue", 0) - print(f" Updated KPI {kpiId}: currentValue={currentValue}") - - if kpiId in lastValues: - lastValue = lastValues[kpiId] - print(f" Comparing: {lastValue} vs {currentValue}") - if currentValue > lastValue: - print(f" ✅ Should detect progress!") - else: - print(f" ❌ No progress detected (currentValue <= lastValue)") - diff --git a/tests/functional/test_kpi_path.py b/tests/functional/test_kpi_path.py deleted file mode 100644 index c0a32862..00000000 --- a/tests/functional/test_kpi_path.py +++ /dev/null @@ -1,68 +0,0 @@ -# Copyright (c) 2026 PowerOn AG -# All rights reserved. -"""Test KPI path extraction""" -import json -import sys -import os - -# Add gateway directory to path -_gateway_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) -if _gateway_path not in sys.path: - sys.path.insert(0, _gateway_path) - -from modules.serviceCenter.services.serviceAi.subJsonResponseHandling import JsonResponseHandler - -# Test JSON matching the actual response -test_json = { - "metadata": { - "split_strategy": "single_document", - "source_documents": [], - "extraction_method": "ai_generation" - }, - "documents": [ - { - "id": "doc_1", - "title": "Prime Numbers Table", - "filename": "prime_numbers.json", - "sections": [ - { - "id": "section_prime_numbers_table", - "content_type": "table", - "elements": [ - { - "headers": ["Column 1", "Column 2"], - "rows": [ - [2, 3, 5, 7, 11], - [13, 17, 19, 23, 29] - ] - } - ] - } - ] - } - ] -} - -# Test path from KPI definition -path = "documents[0].sections[0].elements[0].rows" - -print(f"Testing path: {path}") -print(f"JSON structure: documents[0].sections[0].elements[0].rows") -print() - -try: - value = JsonResponseHandler._extractValueByPath(test_json, path) - print(f"✅ Extracted value: {type(value)}") - print(f" Value: {value}") - - if isinstance(value, list): - count = len(value) - print(f" Count: {count}") - else: - print(f" Not a list!") - -except Exception as e: - print(f"❌ Error: {e}") - import traceback - traceback.print_exc() - diff --git a/tests/validation/test_featureCatalogLabels_i18n.py b/tests/validation/test_featureCatalogLabels_i18n.py index ffdf1c2b..f5b2b490 100644 --- a/tests/validation/test_featureCatalogLabels_i18n.py +++ b/tests/validation/test_featureCatalogLabels_i18n.py @@ -30,11 +30,7 @@ _FEATURES_DIR = Path(__file__).resolve().parents[2] / "modules" / "features" _BARE_LABEL_PATTERN = re.compile(r'^\s*"label"\s*:\s*"[^"]+"', re.MULTILINE) -# mainRealEstate.py contains "label": "AA1704" inside a multi-line f-string -# that is used as a JSON example in an AI prompt -- not a real catalog entry. -_ALLOWED_FILES_WITH_BARE_LABELS: set[str] = { - "mainRealEstate.py", -} +_ALLOWED_FILES_WITH_BARE_LABELS: set[str] = set() def _findFeatureMainFiles() -> list[Path]: