grapheditor editor and template handling

This commit is contained in:
ValueOn AG 2026-04-07 14:22:53 +02:00
parent f65223137e
commit 13242fa5ac
8 changed files with 244 additions and 58 deletions

13
app.py
View file

@ -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 48 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

View file

@ -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]]:

View file

@ -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
)

View file

@ -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"},
},
]

View file

@ -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 [],

View file

@ -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 []})

View file

@ -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:
"""

View file

@ -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(