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", "fastapi.security.oauth2",
"msal", "msal",
"azure.core.pipeline.policies.http_logging_policy", "azure.core.pipeline.policies.http_logging_policy",
"stripe",
"apscheduler",
] ]
for loggerName in noisyLoggers: for loggerName in noisyLoggers:
logging.getLogger(loggerName).setLevel(logging.WARNING) logging.getLogger(loggerName).setLevel(logging.WARNING)
@ -294,16 +296,7 @@ except Exception as e:
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
logger.info("Application is starting up") logger.info("Application is starting up")
# --- Pre-warm AI connectors FIRST (before any other startup work) --- # AI connectors already pre-warmed at module-load via _eager_prewarm() in aicoreModelRegistry.
# 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}")
# Bootstrap database if needed (creates initial users, mandates, roles, etc.) # Bootstrap database if needed (creates initial users, mandates, roles, etc.)
# This must happen before getting root interface # 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]]: 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): if not self.db._ensureTableExists(AutoWorkflow):
return [] return []
rf: Dict[str, Any] = { rf: Dict[str, Any] = {
@ -566,7 +568,21 @@ class GraphicalEditorObjects:
} }
if scope: if scope:
rf["templateScope"] = 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 [] return [dict(r) for r in records] if records else []
def createTemplateFromWorkflow(self, workflowId: str, scope: str = "user") -> Optional[Dict[str, Any]]: 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 .sharepoint import SHAREPOINT_NODES
from .clickup import CLICKUP_NODES from .clickup import CLICKUP_NODES
from .file import FILE_NODES from .file import FILE_NODES
from .trustee import TRUSTEE_NODES
STATIC_NODE_TYPES = ( STATIC_NODE_TYPES = (
TRIGGER_NODES TRIGGER_NODES
@ -19,4 +20,5 @@ STATIC_NODE_TYPES = (
+ SHAREPOINT_NODES + SHAREPOINT_NODES
+ CLICKUP_NODES + CLICKUP_NODES
+ FILE_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") userLanguage = body.get("userLanguage", "de")
conversationHistory = body.get("conversationHistory") or [] 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 from modules.serviceCenter.core.serviceStreaming import get_event_manager
sseEventManager = get_event_manager() sseEventManager = get_event_manager()
@ -487,6 +490,9 @@ async def post_editor_chat(
sseEventManager=sseEventManager, sseEventManager=sseEventManager,
userLanguage=userLanguage, userLanguage=userLanguage,
conversationHistory=conversationHistory, conversationHistory=conversationHistory,
fileIds=fileIds,
dataSourceIds=dataSourceIds,
featureDataSourceIds=featureDataSourceIds,
) )
) )
sseEventManager.register_agent_task(queueId, agentTask) sseEventManager.register_agent_task(queueId, agentTask)
@ -531,6 +537,9 @@ async def _runEditorAgent(
sseEventManager=None, sseEventManager=None,
userLanguage: str = "de", userLanguage: str = "de",
conversationHistory: List[Dict[str, Any]] = None, 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.""" """Run the serviceAgent loop with workflow toolbox and forward events to the SSE queue."""
try: try:
@ -555,10 +564,25 @@ async def _runEditorAgent(
"Respond concisely and confirm what you changed." "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 = "" accumulatedText = ""
async for event in agentService.runAgent( async for event in agentService.runAgent(
prompt=prompt, prompt=enrichedPrompt,
fileIds=fileIds or [],
workflowId=workflowId, workflowId=workflowId,
userLanguage=userLanguage, userLanguage=userLanguage,
conversationHistory=conversationHistory or [], conversationHistory=conversationHistory or [],

View file

@ -1221,17 +1221,15 @@ async def listWorkspaceDataSources(
instanceId: str = Path(...), instanceId: str = Path(...),
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
_validateInstanceAccess(instanceId, context) wsMandateId, _ = _validateInstanceAccess(instanceId, context)
try: try:
from modules.serviceCenter import getService from modules.datamodels.datamodelDataSource import DataSource
from modules.serviceCenter.context import ServiceCenterContext from modules.interfaces.interfaceDbApp import getRootInterface
ctx = ServiceCenterContext( rootIf = getRootInterface()
user=context.user, recordFilter: dict = {"featureInstanceId": instanceId}
mandate_id=str(context.mandateId) if context.mandateId else None, if wsMandateId:
feature_instance_id=instanceId, recordFilter["mandateId"] = wsMandateId
) dataSources = rootIf.db.getRecordset(DataSource, recordFilter=recordFilter)
chatService = getService("chat", ctx)
dataSources = chatService.listDataSources(featureInstanceId=instanceId)
return JSONResponse({"dataSources": dataSources or []}) return JSONResponse({"dataSources": dataSources or []})
except Exception: except Exception:
return JSONResponse({"dataSources": []}) return JSONResponse({"dataSources": []})
@ -1642,16 +1640,16 @@ async def listFeatureDataSources(
instanceId: str = Path(...), instanceId: str = Path(...),
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
"""List active FeatureDataSources for this workspace instance.""" """List active FeatureDataSources for this workspace instance, scoped to mandate."""
_validateInstanceAccess(instanceId, context) wsMandateId, _ = _validateInstanceAccess(instanceId, context)
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
rootIf = getRootInterface() rootIf = getRootInterface()
records = rootIf.db.getRecordset( recordFilter: dict = {"workspaceInstanceId": instanceId}
FeatureDataSource, if wsMandateId:
recordFilter={"workspaceInstanceId": instanceId}, recordFilter["mandateId"] = wsMandateId
) records = rootIf.db.getRecordset(FeatureDataSource, recordFilter=recordFilter)
return JSONResponse({"featureDataSources": records or []}) return JSONResponse({"featureDataSources": records or []})

View file

@ -38,13 +38,21 @@ pwdContext = CryptContext(schemes=["argon2"], deprecated="auto")
# Cache für Role-IDs (roleLabel -> roleId) # Cache für Role-IDs (roleLabel -> roleId)
_roleIdCache: Dict[str, str] = {} _roleIdCache: Dict[str, str] = {}
_bootstrapDone: bool = False
def initBootstrap(db: DatabaseConnector) -> None: def initBootstrap(db: DatabaseConnector) -> None:
""" """
Main bootstrap entry point - initializes all system components. Main bootstrap entry point - initializes all system components.
Idempotent: runs only once per process regardless of how many callers invoke it.
Args: Args:
db: Database connector instance db: Database connector instance
""" """
global _bootstrapDone
if _bootstrapDone:
return
_bootstrapDone = True
logger.info("Starting system bootstrap") logger.info("Starting system bootstrap")
# Initialize root mandate # Initialize root mandate
@ -144,6 +152,112 @@ def initBootstrap(db: DatabaseConnector) -> None:
except Exception as e: except Exception as e:
logger.warning(f"Mandate retention purge failed: {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: 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.connectors.connectorDbPostgre import DatabaseConnector, _get_cached_connector
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp
from modules.interfaces.interfaceBootstrap import initBootstrap
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
from modules.security.rbac import RbacClass from modules.security.rbac import RbacClass
from modules.datamodels.datamodelUam import ( from modules.datamodels.datamodelUam import (
@ -55,8 +54,6 @@ _gatewayInterfaces = {}
# Root interface instance # Root interface instance
_rootAppObjects = None _rootAppObjects = None
# Bootstrap completion flag - ensures bootstrap runs only ONCE per application lifecycle
_bootstrapCompleted = False
# Password-Hashing # Password-Hashing
pwdContext = CryptContext(schemes=["argon2"], deprecated="auto") pwdContext = CryptContext(schemes=["argon2"], deprecated="auto")
@ -79,9 +76,6 @@ class AppObjects:
# Initialize database # Initialize database
self._initializeDatabase() self._initializeDatabase()
# Initialize standard records if needed
self._initRecords()
# Set user context if provided # Set user context if provided
if currentUser: if currentUser:
self.setUserContext(currentUser) self.setUserContext(currentUser)
@ -195,29 +189,6 @@ class AppObjects:
return simpleFields, objectFields 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( def checkRbacPermission(