From 13242fa5ac438090d3e9d3319c35fcca83fe39a4 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 7 Apr 2026 14:22:53 +0200
Subject: [PATCH] grapheditor editor and template handling
---
app.py | 13 +-
.../interfaceFeatureGraphicalEditor.py | 20 ++-
.../nodeDefinitions/__init__.py | 2 +
.../nodeDefinitions/trustee.py | 68 +++++++++++
.../routeFeatureGraphicalEditor.py | 26 +++-
.../workspace/routeFeatureWorkspace.py | 30 +++--
modules/interfaces/interfaceBootstrap.py | 114 ++++++++++++++++++
modules/interfaces/interfaceDbApp.py | 29 -----
8 files changed, 244 insertions(+), 58 deletions(-)
create mode 100644 modules/features/graphicalEditor/nodeDefinitions/trustee.py
diff --git a/app.py b/app.py
index 9627a698..8dce20cb 100644
--- a/app.py
+++ b/app.py
@@ -244,6 +244,8 @@ def initLogging():
"fastapi.security.oauth2",
"msal",
"azure.core.pipeline.policies.http_logging_policy",
+ "stripe",
+ "apscheduler",
]
for loggerName in noisyLoggers:
logging.getLogger(loggerName).setLevel(logging.WARNING)
@@ -294,16 +296,7 @@ except Exception as e:
async def lifespan(app: FastAPI):
logger.info("Application is starting up")
- # --- Pre-warm AI connectors FIRST (before any other startup work) ---
- # Avoids 4–8 s latency on first chatbot request; must run before first use.
- try:
- import modules.aicore.aicoreModelRegistry # noqa: F401 - triggers eager pre-warm
- from modules.aicore.aicoreModelRegistry import modelRegistry
- modelRegistry.ensureConnectorsRegistered()
- modelRegistry.refreshModels(force=True)
- logger.info("AI connectors and model registry pre-warmed")
- except Exception as e:
- logger.warning(f"AI pre-warm failed: {e}")
+ # AI connectors already pre-warmed at module-load via _eager_prewarm() in aicoreModelRegistry.
# Bootstrap database if needed (creates initial users, mandates, roles, etc.)
# This must happen before getting root interface
diff --git a/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py b/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py
index c777ba59..79ff146e 100644
--- a/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py
+++ b/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py
@@ -556,7 +556,9 @@ class GraphicalEditorObjects:
# -------------------------------------------------------------------------
def getTemplates(self, scope: str = None) -> List[Dict[str, Any]]:
- """Get workflow templates, optionally filtered by scope."""
+ """Get workflow templates, optionally filtered by scope.
+ Always includes system-scope templates (mandateId=None) alongside mandate-owned ones.
+ """
if not self.db._ensureTableExists(AutoWorkflow):
return []
rf: Dict[str, Any] = {
@@ -566,7 +568,21 @@ class GraphicalEditorObjects:
}
if scope:
rf["templateScope"] = scope
- records = self.db.getRecordset(AutoWorkflow, recordFilter=rf)
+ records = self.db.getRecordset(AutoWorkflow, recordFilter=rf) or []
+
+ if scope is None or scope == "system":
+ systemFilter: Dict[str, Any] = {
+ "isTemplate": True,
+ "templateScope": "system",
+ "mandateId": None,
+ }
+ systemRecords = self.db.getRecordset(AutoWorkflow, recordFilter=systemFilter) or []
+ seenIds = {(r.get("id") if isinstance(r, dict) else getattr(r, "id", None)) for r in records}
+ for sr in systemRecords:
+ srId = sr.get("id") if isinstance(sr, dict) else getattr(sr, "id", None)
+ if srId not in seenIds:
+ records.append(sr)
+
return [dict(r) for r in records] if records else []
def createTemplateFromWorkflow(self, workflowId: str, scope: str = "user") -> Optional[Dict[str, Any]]:
diff --git a/modules/features/graphicalEditor/nodeDefinitions/__init__.py b/modules/features/graphicalEditor/nodeDefinitions/__init__.py
index 2f4920c8..5fda431e 100644
--- a/modules/features/graphicalEditor/nodeDefinitions/__init__.py
+++ b/modules/features/graphicalEditor/nodeDefinitions/__init__.py
@@ -9,6 +9,7 @@ from .email import EMAIL_NODES
from .sharepoint import SHAREPOINT_NODES
from .clickup import CLICKUP_NODES
from .file import FILE_NODES
+from .trustee import TRUSTEE_NODES
STATIC_NODE_TYPES = (
TRIGGER_NODES
@@ -19,4 +20,5 @@ STATIC_NODE_TYPES = (
+ SHAREPOINT_NODES
+ CLICKUP_NODES
+ FILE_NODES
+ + TRUSTEE_NODES
)
diff --git a/modules/features/graphicalEditor/nodeDefinitions/trustee.py b/modules/features/graphicalEditor/nodeDefinitions/trustee.py
new file mode 100644
index 00000000..abc1fa79
--- /dev/null
+++ b/modules/features/graphicalEditor/nodeDefinitions/trustee.py
@@ -0,0 +1,68 @@
+# Copyright (c) 2025 Patrick Motsch
+# Trustee node definitions - map to methodTrustee actions.
+# Pipeline: extractFromFiles -> processDocuments -> syncToAccounting.
+
+TRUSTEE_NODES = [
+ {
+ "id": "trustee.extractFromFiles",
+ "category": "trustee",
+ "label": {"en": "Extract Documents", "de": "Dokumente extrahieren", "fr": "Extraire documents"},
+ "description": {
+ "en": "Extract document type and data from PDF/JPG via AI (from fileIds or SharePoint folder)",
+ "de": "Dokumenttyp und Daten aus PDF/JPG per AI extrahieren (aus Dateien oder SharePoint-Ordner)",
+ "fr": "Extraire type et données de PDF/JPG par IA",
+ },
+ "parameters": [
+ {"name": "connectionId", "type": "string", "required": False, "description": {"en": "SharePoint connection (if reading from SharePoint)", "de": "SharePoint-Verbindung (falls aus SharePoint)", "fr": "Connexion SharePoint"}, "default": ""},
+ {"name": "sharepointFolder", "type": "string", "required": False, "description": {"en": "SharePoint folder path (e.g. /sites/MySite/Documents/Expenses)", "de": "SharePoint-Ordnerpfad", "fr": "Chemin dossier SharePoint"}, "default": ""},
+ {"name": "featureInstanceId", "type": "string", "required": True, "description": {"en": "Trustee feature instance ID", "de": "Trustee Feature-Instanz-ID", "fr": "ID instance Trustee"}},
+ {"name": "prompt", "type": "string", "required": False, "description": {"en": "AI prompt for extraction (optional)", "de": "AI-Prompt für Extraktion (optional)", "fr": "Prompt IA pour extraction"}, "default": ""},
+ ],
+ "inputs": 1,
+ "outputs": 1,
+ "meta": {"icon": "mdi-file-document-scan", "color": "#4CAF50"},
+ "_method": "trustee",
+ "_action": "extractFromFiles",
+ "_paramMap": {"connectionId": "connectionReference", "sharepointFolder": "sharepointFolder", "featureInstanceId": "featureInstanceId", "prompt": "prompt"},
+ },
+ {
+ "id": "trustee.processDocuments",
+ "category": "trustee",
+ "label": {"en": "Process Documents", "de": "Dokumente verarbeiten", "fr": "Traiter documents"},
+ "description": {
+ "en": "Create TrusteeDocument + TrusteePosition from extraction result",
+ "de": "TrusteeDocument + TrusteePosition aus Extraktionsergebnis erstellen",
+ "fr": "Créer TrusteeDocument + TrusteePosition à partir du résultat",
+ },
+ "parameters": [
+ {"name": "documentList", "type": "string", "required": True, "description": {"en": "Reference to extractFromFiles result", "de": "Referenz auf extractFromFiles-Ergebnis", "fr": "Référence au résultat extractFromFiles"}},
+ {"name": "featureInstanceId", "type": "string", "required": True, "description": {"en": "Trustee feature instance ID", "de": "Trustee Feature-Instanz-ID", "fr": "ID instance Trustee"}},
+ ],
+ "inputs": 1,
+ "outputs": 1,
+ "meta": {"icon": "mdi-file-document-check", "color": "#4CAF50"},
+ "_method": "trustee",
+ "_action": "processDocuments",
+ "_paramMap": {"documentList": "documentList", "featureInstanceId": "featureInstanceId"},
+ },
+ {
+ "id": "trustee.syncToAccounting",
+ "category": "trustee",
+ "label": {"en": "Sync to Accounting", "de": "In Buchhaltung synchronisieren", "fr": "Synchroniser comptabilité"},
+ "description": {
+ "en": "Push trustee positions to accounting system",
+ "de": "Trustee-Positionen in Buchhaltungssystem übertragen",
+ "fr": "Transférer les positions vers la comptabilité",
+ },
+ "parameters": [
+ {"name": "documentList", "type": "string", "required": True, "description": {"en": "Reference to processDocuments result", "de": "Referenz auf processDocuments-Ergebnis", "fr": "Référence au résultat processDocuments"}},
+ {"name": "featureInstanceId", "type": "string", "required": True, "description": {"en": "Trustee feature instance ID", "de": "Trustee Feature-Instanz-ID", "fr": "ID instance Trustee"}},
+ ],
+ "inputs": 1,
+ "outputs": 1,
+ "meta": {"icon": "mdi-calculator", "color": "#4CAF50"},
+ "_method": "trustee",
+ "_action": "syncToAccounting",
+ "_paramMap": {"documentList": "documentList", "featureInstanceId": "featureInstanceId"},
+ },
+]
diff --git a/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py b/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py
index c2673a98..caf89c10 100644
--- a/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py
+++ b/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py
@@ -470,6 +470,9 @@ async def post_editor_chat(
userLanguage = body.get("userLanguage", "de")
conversationHistory = body.get("conversationHistory") or []
+ fileIds = body.get("fileIds") or []
+ dataSourceIds = body.get("dataSourceIds") or []
+ featureDataSourceIds = body.get("featureDataSourceIds") or []
from modules.serviceCenter.core.serviceStreaming import get_event_manager
sseEventManager = get_event_manager()
@@ -487,6 +490,9 @@ async def post_editor_chat(
sseEventManager=sseEventManager,
userLanguage=userLanguage,
conversationHistory=conversationHistory,
+ fileIds=fileIds,
+ dataSourceIds=dataSourceIds,
+ featureDataSourceIds=featureDataSourceIds,
)
)
sseEventManager.register_agent_task(queueId, agentTask)
@@ -531,6 +537,9 @@ async def _runEditorAgent(
sseEventManager=None,
userLanguage: str = "de",
conversationHistory: List[Dict[str, Any]] = None,
+ fileIds: List[str] = None,
+ dataSourceIds: List[str] = None,
+ featureDataSourceIds: List[str] = None,
):
"""Run the serviceAgent loop with workflow toolbox and forward events to the SSE queue."""
try:
@@ -555,10 +564,25 @@ async def _runEditorAgent(
"Respond concisely and confirm what you changed."
)
+ 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=prompt,
+ prompt=enrichedPrompt,
+ fileIds=fileIds or [],
workflowId=workflowId,
userLanguage=userLanguage,
conversationHistory=conversationHistory or [],
diff --git a/modules/features/workspace/routeFeatureWorkspace.py b/modules/features/workspace/routeFeatureWorkspace.py
index ae0154dc..064325a2 100644
--- a/modules/features/workspace/routeFeatureWorkspace.py
+++ b/modules/features/workspace/routeFeatureWorkspace.py
@@ -1221,17 +1221,15 @@ async def listWorkspaceDataSources(
instanceId: str = Path(...),
context: RequestContext = Depends(getRequestContext),
):
- _validateInstanceAccess(instanceId, context)
+ wsMandateId, _ = _validateInstanceAccess(instanceId, context)
try:
- 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 None,
- feature_instance_id=instanceId,
- )
- chatService = getService("chat", ctx)
- dataSources = chatService.listDataSources(featureInstanceId=instanceId)
+ from modules.datamodels.datamodelDataSource import DataSource
+ from modules.interfaces.interfaceDbApp import getRootInterface
+ rootIf = getRootInterface()
+ recordFilter: dict = {"featureInstanceId": instanceId}
+ if wsMandateId:
+ recordFilter["mandateId"] = wsMandateId
+ dataSources = rootIf.db.getRecordset(DataSource, recordFilter=recordFilter)
return JSONResponse({"dataSources": dataSources or []})
except Exception:
return JSONResponse({"dataSources": []})
@@ -1642,16 +1640,16 @@ async def listFeatureDataSources(
instanceId: str = Path(...),
context: RequestContext = Depends(getRequestContext),
):
- """List active FeatureDataSources for this workspace instance."""
- _validateInstanceAccess(instanceId, context)
+ """List active FeatureDataSources for this workspace instance, scoped to mandate."""
+ wsMandateId, _ = _validateInstanceAccess(instanceId, context)
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
rootIf = getRootInterface()
- records = rootIf.db.getRecordset(
- FeatureDataSource,
- recordFilter={"workspaceInstanceId": instanceId},
- )
+ recordFilter: dict = {"workspaceInstanceId": instanceId}
+ if wsMandateId:
+ recordFilter["mandateId"] = wsMandateId
+ records = rootIf.db.getRecordset(FeatureDataSource, recordFilter=recordFilter)
return JSONResponse({"featureDataSources": records or []})
diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py
index e30d11ed..762b20ea 100644
--- a/modules/interfaces/interfaceBootstrap.py
+++ b/modules/interfaces/interfaceBootstrap.py
@@ -38,13 +38,21 @@ pwdContext = CryptContext(schemes=["argon2"], deprecated="auto")
# Cache für Role-IDs (roleLabel -> roleId)
_roleIdCache: Dict[str, str] = {}
+_bootstrapDone: bool = False
+
def initBootstrap(db: DatabaseConnector) -> None:
"""
Main bootstrap entry point - initializes all system components.
+ Idempotent: runs only once per process regardless of how many callers invoke it.
Args:
db: Database connector instance
"""
+ global _bootstrapDone
+ if _bootstrapDone:
+ return
+ _bootstrapDone = True
+
logger.info("Starting system bootstrap")
# Initialize root mandate
@@ -144,6 +152,112 @@ 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)
+
+
+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
+ import uuid
+
+ greenfieldDb = DatabaseConnector(
+ dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
+ dbDatabase="poweron_graphicaleditor",
+ 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 _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": {}},
+ {"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": {}},
+ {"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:
"""
diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py
index d52c23d6..f5560fed 100644
--- a/modules/interfaces/interfaceDbApp.py
+++ b/modules/interfaces/interfaceDbApp.py
@@ -18,7 +18,6 @@ import uuid
from modules.connectors.connectorDbPostgre import DatabaseConnector, _get_cached_connector
from modules.shared.configuration import APP_CONFIG
from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp
-from modules.interfaces.interfaceBootstrap import initBootstrap
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
from modules.security.rbac import RbacClass
from modules.datamodels.datamodelUam import (
@@ -55,8 +54,6 @@ _gatewayInterfaces = {}
# Root interface instance
_rootAppObjects = None
-# Bootstrap completion flag - ensures bootstrap runs only ONCE per application lifecycle
-_bootstrapCompleted = False
# Password-Hashing
pwdContext = CryptContext(schemes=["argon2"], deprecated="auto")
@@ -79,9 +76,6 @@ class AppObjects:
# Initialize database
self._initializeDatabase()
- # Initialize standard records if needed
- self._initRecords()
-
# Set user context if provided
if currentUser:
self.setUserContext(currentUser)
@@ -195,29 +189,6 @@ class AppObjects:
return simpleFields, objectFields
- def _initRecords(self):
- """Initialize standard records if they don't exist.
-
- Uses a global flag to ensure bootstrap only runs ONCE per application lifecycle.
- The flag is set BEFORE calling bootstrap to prevent recursive calls during bootstrap.
- """
- global _bootstrapCompleted
-
- if _bootstrapCompleted:
- return
-
- # Set flag BEFORE bootstrap to prevent recursive calls during bootstrap
- _bootstrapCompleted = True
- logger.info("Starting bootstrap (will only run once)")
-
- try:
- initBootstrap(self.db)
- logger.info("Bootstrap completed successfully")
- except Exception as e:
- # Reset flag on failure so bootstrap can be retried
- _bootstrapCompleted = False
- logger.error(f"Bootstrap failed: {e}")
- raise
def checkRbacPermission(