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(